Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ build
src/core/config/modules/*
src/core/config/OperationConfig.json
src/core/operations/index.mjs
src/core/lib/HTMLEntities.mjs
src/node/config/OperationConfig.json
src/node/index.mjs
tests/operations/index.mjs
Expand Down
1 change: 1 addition & 0 deletions Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ module.exports = function (grunt) {
command: chainCommands([
"echo '\n--- Regenerating config files. ---'",
"echo [] > src/core/config/OperationConfig.json",
`node ${nodeFlags} src/core/config/scripts/generateHTMLEntities.mjs`,
`node ${nodeFlags} src/core/config/scripts/generateOpsIndex.mjs`,
`node ${nodeFlags} src/core/config/scripts/generateConfig.mjs`,
"echo '--- Config scripts finished. ---\n'"
Expand Down
8 changes: 5 additions & 3 deletions src/core/config/Categories.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@
"Rison Decode",
"To Modhex",
"From Modhex",
"MIME Decoding"
"MIME Decoding",
"To COBS",
"From COBS"
]
},
{
Expand Down Expand Up @@ -173,6 +175,7 @@
"AES Key Wrap",
"AES Key Unwrap",
"Pseudo-Random Number Generator",
"Pseudo-Random Prime Generator",
"Enigma",
"Bombe",
"Multiple Bombe",
Expand Down Expand Up @@ -241,9 +244,8 @@
"Subtract",
"Multiply",
"Divide",
"Modular Exponentiation",
"Modular Inverse",
"Extended GCD",
"Modular Inverse",
"Mean",
"Median",
"Standard Deviation",
Expand Down
139 changes: 139 additions & 0 deletions src/core/config/scripts/generateHTMLEntities.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* This script automatically generates src/core/lib/HTMLEntities.mjs, the shared
* HTML entity lookup tables used by the "To HTML Entity" and "From HTML Entity"
* operations.
*
* The data is derived from the vendored WHATWG named character reference set
* (src/core/vendor/htmlEntities/entity.json, from
* https://html.spec.whatwg.org/entities.json) so the
* two operations cannot drift apart and every entity is spec-conformant:
*
* - HTML_ENTITY_REVERSE_LOOKUP (decode) is every single-code-point spec name.
* - HTML_ENTITY_LOOKUP (encode) picks one canonical name per code point via a
* deterministic tiebreak, overridden by htmlEntityOverrides.mjs where a
* specific historical name is preferred.
*
* @author roberson-io [michaelroberson@gmail.com]
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/

/* eslint no-console: ["off"] */

import path from "path";
import fs from "fs";
import process from "process";
import { fileURLToPath } from "url";
import { HTML_ENTITY_CANONICAL_OVERRIDES } from "./htmlEntityOverrides.mjs";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));

if (!fs.existsSync(path.join(process.cwd(), "src/core/lib"))) {
console.log("\nCWD: " + process.cwd());
console.log("Error: generateHTMLEntities.mjs should be run from the project root");
console.log("Example> node src/core/config/scripts/generateHTMLEntities.mjs");
process.exit(1);
}

const SPEC = JSON.parse(fs.readFileSync(
path.join(scriptDir, "..", "..", "vendor", "htmlEntities", "entity.json"), "utf8"));

// Build code point -> [spec names] for single-code-point, semicolon-terminated
// references (the representable subset; multi-code-point entities are skipped).
const codePointNames = {};
for (const [key, val] of Object.entries(SPEC)) {
if (!key.endsWith(";") || val.codepoints.length !== 1) continue;
const name = key.slice(1, -1); // strip leading "&" and trailing ";"
(codePointNames[val.codepoints[0]] ??= []).push(name);
}

// Deterministic canonical-name tiebreak: prefer a lower-case name, then the
// shortest, then alphabetical. Overridden per code point where a specific
// historical name is preferred.
const isAllUpper = s => s === s.toUpperCase() && s !== s.toLowerCase();

/**
* Choose the single canonical entity name for a code point.
*
* @param {number} codePoint - the Unicode code point being encoded
* @param {string[]} names - all valid WHATWG names for that code point
* @returns {string} the canonical name to emit when encoding
*/
function canonicalName(codePoint, names) {
const override = HTML_ENTITY_CANONICAL_OVERRIDES[codePoint];
if (override !== undefined) {
if (!names.includes(override))
throw new Error(`Override &${override}; is not a spec name for code point ${codePoint} (spec: ${names})`);
return override;
}
return [...names].sort((a, b) =>
(isAllUpper(a) - isAllUpper(b)) ||
(a.length - b.length) ||
(a < b ? -1 : a > b ? 1 : 0)
)[0];
}

const forward = {}; // code point -> canonical name (encode)
const reverse = {}; // name -> code point (decode, every spec name)
for (const [cpStr, names] of Object.entries(codePointNames)) {
const cp = Number(cpStr);
forward[cp] = canonicalName(cp, names);
for (const name of names) reverse[name] = cp;
}

// Decode-only aliases = spec names that are not the canonical encode name.
const aliases = {};
for (const [name, cp] of Object.entries(reverse)) {
if (forward[cp] !== name) aliases[name] = cp;
}

// --- emit -----------------------------------------------------------------
const fwdEntries = Object.keys(forward).map(Number).sort((a, b) => a - b)
.map(cp => ` ${cp}: "${forward[cp]}",`).join("\n").replace(/,$/, "");
const aliasEntries = Object.keys(aliases).sort()
.map(n => ` ${JSON.stringify(n)}: ${aliases[n]},`).join("\n").replace(/,$/, "");

const code = `/**
* THIS FILE IS AUTOMATICALLY GENERATED BY src/core/config/scripts/generateHTMLEntities.mjs
*
* HTML entity lookup tables shared by the "To HTML Entity" and "From HTML Entity"
* operations, derived from the WHATWG named character reference set
* (https://html.spec.whatwg.org/entities.json). Do not edit by hand — change the
* vendored src/core/vendor/htmlEntities/entity.json or htmlEntityOverrides.mjs
* and regenerate.
*
* @author roberson-io [michaelroberson@gmail.com]
* @copyright Crown Copyright ${new Date().getUTCFullYear()}
* @license Apache-2.0
*/

/**
* Canonical lookup: Unicode code point -> entity name (without "&" and ";"),
* used for ENCODING. One canonical name per code point.
*/
export const HTML_ENTITY_LOOKUP = {
${fwdEntries}
};

/**
* Legacy / alias names that only DECODE (spelling variants, deprecated names,
* box-drawing aliases, etc.); not used for encoding.
*/
export const HTML_ENTITY_DECODE_ALIASES = {
${aliasEntries}
};

/**
* Derived reverse lookup: entity name -> code point, used for DECODING. Built
* from the canonical lookup plus the legacy aliases.
*/
export const HTML_ENTITY_REVERSE_LOOKUP = {...HTML_ENTITY_DECODE_ALIASES};
for (const codePoint in HTML_ENTITY_LOOKUP) {
HTML_ENTITY_REVERSE_LOOKUP[HTML_ENTITY_LOOKUP[codePoint]] = Number(codePoint);
}
`;

fs.writeFileSync(path.join(process.cwd(), "src/core/lib/HTMLEntities.mjs"), code);
console.log(`generateHTMLEntities: ${Object.keys(forward).length} encode names, ` +
`${Object.keys(reverse).length} decode names, ${Object.keys(aliases).length} aliases, ` +
`${Object.keys(HTML_ENTITY_CANONICAL_OVERRIDES).length} overrides applied.`);
86 changes: 86 additions & 0 deletions src/core/config/scripts/htmlEntityOverrides.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Canonical entity-name overrides for generateHTMLEntities.mjs.
*
* The WHATWG named character reference set assigns MANY names to some code
* points (e.g. U+2211 is both &sum; and &Sum;), but does not designate a
* canonical one. The generator therefore needs a rule to pick a single name per
* code point for ENCODING. It uses a deterministic tiebreak (prefer a
* lower-case name, then the shortest, then alphabetical), which reproduces the
* historically-emitted name for ~1355 of the ~1414 encodable code points.
*
* This file pins the canonical name for the code points where the tiebreak
* would otherwise change the emitted entity. Every value here is still a valid
* WHATWG name for that code point (the generator asserts this) — these are
* editorial choices, not correctness fixes, kept so "To HTML Entity" output
* stays stable. Trim an entry to let the tiebreak decide instead.
*
* @author roberson-io [michaelroberson@gmail.com]
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/

/**
* @constant
* @type {Object.<number, string>}
*/
export const HTML_ENTITY_CANONICAL_OVERRIDES = {
124: "verbar",
168: "uml",
177: "plusmn",
189: "frac12",
247: "divide",
711: "caron",
728: "breve",
937: "Omega",
949: "epsilon",
965: "upsilon",
977: "thetasym",
978: "upsih",
981: "straightphi",
8208: "hyphen",
8214: "Verbar",
8230: "hellip",
8289: "ApplyFunction",
8290: "InvisibleTimes",
8291: "InvisibleComma",
8459: "hamilt",
8461: "quaternions",
8463: "planck",
8465: "image",
8472: "weierp",
8474: "rationals",
8476: "real",
8477: "reals",
8484: "integers",
8492: "bernou",
8499: "phmmat",
8500: "order",
8501: "alefsym",
8518: "DifferentialD",
8519: "ExponentialE",
8520: "ImaginaryI",
8612: "LeftTeeArrow",
8613: "UpTeeArrow",
8615: "DownTeeArrow",
8624: "lsh",
8625: "rsh",
8660: "hArr",
8704: "forall",
8711: "nabla",
8712: "isin",
8721: "sum",
8723: "mnplus",
8730: "radic",
8750: "conint",
8768: "wreath",
8776: "asymp",
8781: "asympeq",
8784: "esdot",
8788: "colone",
8869: "perp",
8896: "xwedge",
8897: "xvee",
8902: "sstarf",
10536: "nesear",
10537: "seswar"
};
1 change: 0 additions & 1 deletion src/core/lib/BigIntUtils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,3 @@ export function modPow(base, exponent, modulus) {

return result;
}

86 changes: 86 additions & 0 deletions src/core/lib/COBS.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* @author Imantas Lukenskas [imantas@lukenskas.dev]
* @copyright Imantas Lukenskas 2026
* @license Apache-2.0
*/

import OperationError from "../errors/OperationError.mjs";

/**
* COBS-encode a byte array
* @param {Uint8Array} data
* @return {Uint8Array}
*/
export function toCobs(data) {
if (!data || data.length === 0) {
return new Uint8Array();
}

const output = [];
data = [0, ...data];

while (data.length > 0) {
const endIndex = data.findIndex((value, index) => value === 0 && index > 0);

if ((endIndex < 0 || endIndex > 254) && data.length > 254) {
output.push(255);
output.push(...data.slice(1, 255));
data = data.slice(255);
if (data.length !== 0) {
data = [0, ...data];
}
} else if (endIndex < 0) {
output.push(data.length);
output.push(...data.slice(1));
data = [];
} else {
output.push(endIndex);
output.push(...data.slice(1, endIndex));
data = data.slice(endIndex);
}
}

return output;
}

/**
* COBS-decode a byte array
* @param {Uint8Array} data
* @return {Uint8Array}
*/
export function fromCobs(data) {
if (!data || data.length === 0) {
return new Uint8Array();
}

if (data.findIndex((value) => value === 0) >= 0) {
throw new OperationError("Could not decode from COBS: payload must not contain a 0x00 byte");
}

const output = [];

while (data.length > 0) {
if (data[0] === 0xFF) {
output.push(...data.slice(1, 255));
data = data.slice(255);
} else {
const nextZeroIndex = data[0];
output.push(...data.slice(1, nextZeroIndex));
data = data.slice(nextZeroIndex);

let blockSize = data[0];
while (data.length > 0) {
output.push(0, ...data.slice(1, blockSize));
data = data.slice(blockSize);

if (blockSize === 0xFF) {
break;
}

blockSize = data[0];
}
}
}

return output;
}
Loading