rapier-math 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +23 -93
  2. package/dist/classic.d.ts +4 -0
  3. package/dist/delimiters.d.ts +8 -0
  4. package/dist/delimiters.mjs +39 -0
  5. package/dist/index.d.ts +43 -0
  6. package/{index.mjs → dist/index.mjs} +43 -15
  7. package/dist/integration.d.ts +9 -0
  8. package/dist/integration.mjs +28 -0
  9. package/dist/markdown-it.d.ts +6 -0
  10. package/dist/markdown-it.mjs +45 -0
  11. package/dist/markdown-rules.mjs +65 -0
  12. package/{rapier-math-1.0.2.js → dist/rapier-math.js} +43 -15
  13. package/dist/rehype.d.ts +6 -0
  14. package/dist/rehype.mjs +37 -0
  15. package/dist/stream.d.ts +14 -0
  16. package/dist/stream.mjs +46 -0
  17. package/docs/api.md +210 -0
  18. package/{math-components.json → docs/math-components.json} +7 -7
  19. package/docs/math-resources.json +290 -0
  20. package/examples/README.md +25 -0
  21. package/examples/browser.html +10 -0
  22. package/examples/browser.mjs +24 -0
  23. package/examples/formula.mjs +12 -0
  24. package/examples/markdown.mjs +9 -0
  25. package/examples/react.tsx +19 -0
  26. package/examples/stream.mjs +19 -0
  27. package/examples/unified.mjs +26 -0
  28. package/examples/worker.mjs +7 -0
  29. package/package.json +68 -19
  30. package/src/math/api.js +35 -13
  31. package/src/math/browser.js +7 -1
  32. package/src/math/layout.js +1 -1
  33. package/src/math/package-build.mjs +0 -13
  34. package/src/math/package-layout.mjs +17 -0
  35. package/src/math/package-readme.mjs +45 -0
  36. package/src/tools/build-math.mjs +48 -13
  37. package/tests/adoption.mjs +149 -0
  38. package/{check.mjs → tests/check.mjs} +29 -8
  39. package/tests/verify.mjs +46 -0
  40. package/LICENSE-MIT +0 -21
  41. package/math-LICENSE.txt +0 -21
  42. package/math-font-LICENSE.txt +0 -457
  43. package/src/math/package-verify.mjs +0 -40
  44. package/src/package.json +0 -9
  45. package/src/shell/math-resources.json +0 -105
  46. package/verify.mjs +0 -40
  47. /package/{src/math/FONT-SOURCE.md → docs/font-source.md} +0 -0
@@ -88,12 +88,18 @@ function mathFitElement(svg, printing = false) {
88
88
  if(screenKey!==state.screen || (target===null)!==(state.width===null) || target!==null && Math.abs(target-state.width)>.005) {
89
89
  const rendered=renderMath(source,{displayMode:display,maxWidth:(screenKey!==state.screen?width:target) || undefined,screen});
90
90
  if(!rendered.errors.length) {
91
+ const active=doc.activeElement,links=active && svg.contains(active)?[...svg.querySelectorAll('a')]:[];
92
+ const focusIndex=links.indexOf(active),focusHref=focusIndex<0?null:active.getAttribute('href');
91
93
  const replacement=new view.DOMParser().parseFromString(rendered.svg,'image/svg+xml').documentElement;
92
- for(const name of ['width','height','viewBox','data-math-natural-width','data-math-max-width','data-math-lines','data-math-screen']) {
94
+ for(const name of ['role','width','height','viewBox','data-math-natural-width','data-math-max-width','data-math-lines','data-math-screen']) {
93
95
  if(replacement.hasAttribute(name))svg.setAttribute(name,replacement.getAttribute(name));else svg.removeAttribute(name);
94
96
  }
95
97
  svg.style.verticalAlign=replacement.style.verticalAlign;
96
98
  svg.replaceChildren(...Array.from(replacement.childNodes,node=>doc.importNode(node,true)));
99
+ if(focusIndex>=0 && (!doc.activeElement || doc.activeElement===doc.body)) {
100
+ const link=svg.querySelectorAll('a')[focusIndex];
101
+ if(link?.getAttribute('href')===focusHref)link.focus({preventScroll:true});
102
+ }
97
103
  state.natural=Number(svg.getAttribute('data-math-natural-width'));state.screen=screenKey;
98
104
  state.width=state.natural>width?width:null;
99
105
  if(state.width===null)svg.removeAttribute('data-math-max-width');
@@ -574,7 +574,7 @@ function mathLayout(n,c) {
574
574
  case 'smash': {const b=mathLayout(n.body,c);return {...b,h:n.mode==='b'?b.h:0,d:n.mode==='t'?b.d:0};}
575
575
  case 'raise': {const b=mathLayout(n.body,c),s=mathScale(c);return mathCompose([[b,0,-n.amount*s]],b.w,{class:b.class,...(n.height===undefined?{}:{h:n.height*s}),...(n.depth===undefined?{}:{d:n.depth*s})});}
576
576
  case 'rule': {const s=mathScale(c);return mathRule(n.width*s,(n.height+n.depth)*s,-n.height*s);}
577
- case 'link': {const b=mathLayout(n.body,c);const url=new URL(n.href);if(!['http:','https:'].includes(url.protocol))throw Object.assign(new Error('Unsupported equation link'),{code:'unsafe-url'});b.svg='<a href="'+mathEscape(n.href)+'" target="_blank" rel="noopener noreferrer">'+b.svg+'</a>';return b;}
577
+ case 'link': {const b=mathLayout(n.body,c);const url=new URL(n.href);if(!['http:','https:'].includes(url.protocol))throw Object.assign(new Error('Unsupported equation link'),{code:'unsafe-url'});b.svg='<a href="'+mathEscape(n.href)+'" aria-label="'+mathEscape(n.href)+'" tabindex="0" target="_blank" rel="noopener noreferrer">'+b.svg+'</a>';return b;}
578
578
  default: throw Object.assign(new Error('Unsupported maths construct: '+n.type),{code:'unsupported-construct'});
579
579
  }
580
580
  }
@@ -1,8 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // The module has the exact classic renderer body, with module-local exports.
3
- import {readFile, writeFile} from 'node:fs/promises';
4
- import {dirname, resolve} from 'node:path';
5
- import {fileURLToPath} from 'node:url';
6
3
 
7
4
  export function mathModule(bundle) {
8
5
  const text = String(bundle), opening = "(function(){\n'use strict';\n", ending = '\n})();\n';
@@ -15,13 +12,3 @@ export function mathModule(bundle) {
15
12
  'const {version, parse, render, fit, observe, renderToString} = RapierMath;\n' +
16
13
  'export {version, parse, render, fit, observe, renderToString};\nexport default RapierMath;\n';
17
14
  }
18
-
19
- if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
20
- if (!process.argv[2]) throw new Error('usage: node src/math/package-build.mjs <module-file>');
21
- const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
22
- const {buildMath} = await import('../tools/build-math.mjs');
23
- const result = await buildMath({root, updatePins: false});
24
- const text = mathModule(await readFile(resolve(root, 'shell/vendor', result.bundle), 'utf8'));
25
- await writeFile(resolve(process.argv[2]), text);
26
- console.log(JSON.stringify({file: process.argv[2], bytes: Buffer.byteLength(text)}));
27
- }
@@ -0,0 +1,17 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export const MATH_CLASSIC = 'dist/rapier-math.js';
3
+
4
+ // One public location per authored input. Runtime formats share dist/.
5
+ export function mathPackagePath(file) {
6
+ const owners = {
7
+ 'math/README.md': 'docs/api.md',
8
+ 'math/FONT-SOURCE.md': 'docs/font-source.md',
9
+ 'math/package-check.mjs': 'tests/check.mjs',
10
+ 'math/package-verify.mjs': 'tests/verify.mjs',
11
+ 'math/adoption-verify.mjs': 'tests/adoption.mjs',
12
+ };
13
+ if (owners[file]) return owners[file];
14
+ if (file.startsWith('math/examples/')) return file.slice(5);
15
+ if (/^math\/(?!package-).+\.(?:mjs|d\.ts)$/.test(file)) return 'dist/' + file.slice(5);
16
+ return 'src/' + file;
17
+ }
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export function mathReadme(resources) {
3
+ return `# Rapier Math
4
+
5
+ A self-contained TeX-to-SVG renderer with Latin Modern outlines. Synchronous, no runtime downloads.
6
+
7
+ ## Use
8
+
9
+ \`\`\`sh
10
+ npm install rapier-math
11
+ \`\`\`
12
+
13
+ \`\`\`js
14
+ import {renderToString} from 'rapier-math';
15
+ const svg = renderToString(String.raw\`\\frac{a}{b}\`);
16
+ \`\`\`
17
+
18
+ For a browser script, load [${resources.packageBundle}](${resources.packageBundle}) and use
19
+ \`globalThis.RapierMath\`. The native ESM entry is [dist/index.mjs](dist/index.mjs).
20
+ Both include the font and return synchronously. Type declarations accompany the runtime files.
21
+
22
+ - [API and integration guide](docs/api.md)
23
+ - [Runnable examples](examples/README.md)
24
+ - [Font source and regeneration](docs/font-source.md)
25
+
26
+ ## Develop
27
+
28
+ The current runtime formats live in \`dist/\`; editable renderer source and its build tools live in
29
+ \`src/\`. Optional adapters have one source copy in \`dist/\`. Examples, documentation and tests each
30
+ have their own directory. Only the current release is kept in this checkout.
31
+
32
+ From a repository checkout, run \`node tests/check.mjs\` and \`node tests/verify.mjs\` to check the package and rebuild its exact bytes.
33
+ Run \`node src/tools/build-math.mjs --package .\` to regenerate the classic and ESM files from source.
34
+ Optional host checks run with \`node tests/adoption.mjs\` after installing the example dependencies.
35
+
36
+ ## License
37
+
38
+ The renderer and adapters are MIT-licensed. Embedded Latin Modern Math outlines and metrics are
39
+ licensed under the GUST Font License. [LICENSE](LICENSE) contains both complete grants.
40
+ [Component provenance](docs/math-components.json) and [source hashes](docs/math-resources.json)
41
+ travel with the package.
42
+
43
+ Version ${resources.version}. Classic payload: ${resources.bytes} bytes; gzip (level 9): ${resources.gzipBytes} bytes.
44
+ `;
45
+ }
@@ -7,12 +7,20 @@ import {fileURLToPath} from 'node:url';
7
7
  import {createHash} from 'node:crypto';
8
8
  import {gzipSync} from 'node:zlib';
9
9
  import {Script} from 'node:vm';
10
+ import {mathPackagePath, MATH_CLASSIC} from '../math/package-layout.mjs';
11
+ import {mathModule} from '../math/package-build.mjs';
12
+ import {mathReadme} from '../math/package-readme.mjs';
10
13
 
11
14
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
12
- export const MATH_VERSION = '1.0.2';
15
+ export const MATH_VERSION = '1.1.1';
13
16
  export const MATH_BUNDLE = 'rapier-math-' + MATH_VERSION + '.js';
14
17
  export const MATH_SOURCES = ['font.js', 'symbols.js', 'parser.js', 'layout.js', 'fallbacks.js', 'responsive.js', 'browser.js', 'api.js'].map(name => 'math/' + name);
15
- export const MATH_RECIPE = ['math/build-font.py', 'math/FONT-SOURCE.md', 'math/FONT-LICENSE.txt', 'math/package-verify.mjs', 'tools/build-math.mjs', 'math/LICENSE-MIT'];
18
+ export const MATH_RECIPE = ['math/build-font.py', 'math/FONT-SOURCE.md', 'math/FONT-LICENSE.txt', 'math/package-verify.mjs', 'tools/build-math.mjs', 'math/LICENSE-MIT', 'math/adoption-verify.mjs', 'math/package-build.mjs', 'math/package-layout.mjs', 'math/package-check.mjs', 'math/package-readme.mjs', 'math/README.md'];
19
+ export const MATH_ADOPTION = ['delimiters.mjs', 'markdown-rules.mjs', 'integration.mjs', 'markdown-it.mjs', 'rehype.mjs', 'stream.mjs',
20
+ 'index.d.ts', 'classic.d.ts', 'delimiters.d.ts', 'integration.d.ts', 'markdown-it.d.ts', 'rehype.d.ts', 'stream.d.ts',
21
+ 'examples/formula.mjs', 'examples/markdown.mjs', 'examples/stream.mjs', 'examples/unified.mjs',
22
+ 'examples/browser.html', 'examples/browser.mjs', 'examples/react.tsx', 'examples/worker.mjs', 'examples/README.md']
23
+ .map(file => ({file: 'math/' + file, packageFile: mathPackagePath('math/' + file)}));
16
24
  const FONT = {
17
25
  name: 'Latin Modern Math', version: '1.959', license: 'LicenseRef-GUST-Font-License',
18
26
  licenseName: 'GUST Font License',
@@ -25,8 +33,8 @@ const FONT = {
25
33
  };
26
34
  const digest = (bytes, algorithm = 'sha256', encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding);
27
35
 
28
- async function fontLicense(root) {
29
- const bytes = await readFile(resolve(root, FONT.licenseFile));
36
+ async function fontLicense(root, packageLayout = false) {
37
+ const bytes = await readFile(resolve(root, packageLayout ? mathPackagePath(FONT.licenseFile) : FONT.licenseFile));
30
38
  if (digest(bytes) !== FONT.licenseSha256 || !bytes.includes(FONT.copyright))
31
39
  throw new Error('The math font license differs from its source pin');
32
40
  return bytes;
@@ -39,15 +47,16 @@ export async function fillMathLicense(markup, {root = ROOT} = {}) {
39
47
  return markup.replace(marker, () => '<pre class="license-text">' + escaped + '</pre>');
40
48
  }
41
49
 
42
- export async function buildMath({root = ROOT, updatePins = true} = {}) {
50
+ export async function assembleMath({root = ROOT, packageLayout = false} = {}) {
51
+ const read = file => readFile(resolve(root, packageLayout ? mathPackagePath(file) : file));
43
52
  const inputs = [];
44
53
  for (const file of MATH_SOURCES) {
45
- const bytes = await readFile(resolve(root, file));
54
+ const bytes = await read(file);
46
55
  inputs.push({file, bytes, text: bytes.toString('utf8')});
47
56
  }
48
- const license = await fontLicense(root);
57
+ const license = await fontLicense(root, packageLayout);
49
58
  if (!inputs[0].text.includes(license.toString('utf8').trim())) throw new Error('The embedded math font is missing its complete license');
50
- const ownLicense = await readFile(resolve(root, 'math/LICENSE-MIT'), 'utf8');
59
+ const ownLicense = (await read('math/LICENSE-MIT')).toString('utf8');
51
60
  const bytes = Buffer.from('/*!\n' + ownLicense.trim() + '\n*/\n(function(){\n\'use strict\';\n' + inputs.map(input => input.text.trimEnd()).join('\n\n') + '\n})();\n');
52
61
  new Script(bytes.toString('utf8'), {filename: MATH_BUNDLE});
53
62
  const gzipBytes = gzipSync(bytes, {level: 9}).length;
@@ -55,17 +64,41 @@ export async function buildMath({root = ROOT, updatePins = true} = {}) {
55
64
  const sha256 = digest(bytes), sha384 = digest(bytes, 'sha384', 'base64');
56
65
  const recipe = [];
57
66
  for (const file of MATH_RECIPE) {
58
- const content = await readFile(resolve(root, file));
59
- recipe.push({file, bytes: content.length, sha256: digest(content)});
67
+ const content = await read(file);
68
+ recipe.push({file, packageFile: mathPackagePath(file), bytes: content.length, sha256: digest(content)});
60
69
  }
61
70
  const resources = {
62
- bundle: MATH_BUNDLE, version: MATH_VERSION, license: 'MIT AND ' + FONT.license,
71
+ bundle: MATH_BUNDLE, packageBundle: MATH_CLASSIC, version: MATH_VERSION, license: 'MIT AND ' + FONT.license,
63
72
  bytes: bytes.length, gzipBytes, sha256, sha384,
64
- sources: inputs.map(input => ({file: input.file, bytes: input.bytes.length, sha256: digest(input.bytes)})),
73
+ sources: inputs.map(input => ({file: input.file, packageFile: mathPackagePath(input.file), bytes: input.bytes.length, sha256: digest(input.bytes)})),
65
74
  recipe,
66
75
  components: [{name: FONT.name, version: FONT.version, license: FONT.license, source: FONT.source, sha256: FONT.sourceSha256}],
67
76
  font: {...FONT, licenseBytes: license.length},
68
77
  };
78
+ resources.adoption = [];
79
+ for (const {file, packageFile} of MATH_ADOPTION) {
80
+ const content = await read(file);
81
+ resources.adoption.push({file, packageFile, bytes: content.length, sha256: digest(content)});
82
+ }
83
+ return {resources, bytes};
84
+ }
85
+
86
+ export async function buildMath({root = ROOT, updatePins = true, packageLayout = false} = {}) {
87
+ const {resources, bytes} = await assembleMath({root, packageLayout});
88
+ if (packageLayout) {
89
+ await mkdir(resolve(root, 'dist'), {recursive: true});
90
+ await writeFile(resolve(root, MATH_CLASSIC), bytes);
91
+ await writeFile(resolve(root, 'dist/index.mjs'), mathModule(bytes));
92
+ await writeFile(resolve(root, 'docs/math-resources.json'), JSON.stringify(resources, null, 2) + '\n');
93
+ await writeFile(resolve(root, 'README.md'), mathReadme(resources));
94
+ const sbomPath = resolve(root, 'docs/math-components.json');
95
+ const sbom = JSON.parse(await readFile(sbomPath, 'utf8'));
96
+ sbom.packages.find(item => item.name === 'rapier-math').checksums = [{algorithm: 'SHA256', checksumValue: resources.sha256}];
97
+ sbom.documentNamespace = 'https://rapier.website/spdx/rapier-math-' + resources.version + '-' + resources.sha256;
98
+ await writeFile(sbomPath, JSON.stringify(sbom, null, 2) + '\n');
99
+ return {bundle: MATH_CLASSIC, bytes: bytes.length, sha256: resources.sha256};
100
+ }
101
+ const {sha256, sha384, gzipBytes} = resources;
69
102
  const vendor = resolve(root, 'shell/vendor');
70
103
  await mkdir(vendor, {recursive: true});
71
104
  await writeFile(resolve(vendor, MATH_BUNDLE), bytes);
@@ -91,5 +124,7 @@ export async function buildMath({root = ROOT, updatePins = true} = {}) {
91
124
  }
92
125
 
93
126
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
94
- console.log(JSON.stringify(await buildMath({root: process.argv[2] ? resolve(process.argv[2]) : ROOT})));
127
+ const packageLayout = process.argv[2] === '--package';
128
+ const directory = process.argv[packageLayout ? 3 : 2];
129
+ console.log(JSON.stringify(await buildMath({root: directory ? resolve(directory) : ROOT, packageLayout})));
95
130
  }
@@ -0,0 +1,149 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Optional host checks: run from the installed package with the example peers.
3
+ // One source-custody table; no appearance or timing assertions.
4
+ import assert from 'node:assert/strict';
5
+ import MarkdownIt from 'markdown-it';
6
+ import {render} from 'rapier-math';
7
+ import rapierMath from 'rapier-math/markdown-it';
8
+ import {createMathMatcher} from 'rapier-math/delimiters';
9
+ import {createMarkdownStream} from 'rapier-math/stream';
10
+ import {processor} from '../examples/unified.mjs';
11
+
12
+ const md = new MarkdownIt({html: false}).use(rapierMath);
13
+ const dollar = new MarkdownIt({html: false}).use(rapierMath, {singleDollar: true});
14
+ const mathNodes = (host, source) => host.parse(source, {}).flatMap(token => token.children || [token])
15
+ .filter(token => token.type === 'math_inline' || token.type === 'math_block');
16
+ const rows = [
17
+ ['escaped pair incomplete', String.raw`$$x\$$`, 0],
18
+ ['escaped pair then closer', String.raw`$$x\$$y$$`, 1],
19
+ ['even escape run', String.raw`$$x\\$$`, 1],
20
+ ['brackets', String.raw`A \(x+1\) B \[y\]`, 2],
21
+ ['escaped bracket opener', String.raw`A \\(x\\)`, 0],
22
+ ['currency', 'It costs $5 and $10.', 0],
23
+ ['code and fence', '`$$x$$`\n\n```tex\n$$y$$\n```\n\n $$z$$\n', 0],
24
+ ['links', String.raw`[\(x\)](https://example.com/$$x$$)`, 0],
25
+ ['literal backslashes', String.raw`Path C:\\temp. \(\text{a\ b}\)`, 1],
26
+ ['CRLF BOM Unicode', '\ufeffπ 🐈\r\n\r\n$$\r\nx+1\r\n$$\r\n', 1],
27
+ ['incomplete', 'Before \\(x+1 and after', 0],
28
+ ['malformed UTF-16', '\ud800 and \\(x\\) and \udfff', 1],
29
+ ];
30
+ for (const [name, source, count] of rows) {
31
+ assert.equal(mathNodes(md, source).length, count, name);
32
+ const batch = md.render(source, {});
33
+ // Every two-piece boundary, including CRLF, escape runs and surrogate pairs.
34
+ for (let split = 0; split <= source.length; split++) {
35
+ const stream = createMarkdownStream(text => md.render(text, {}), {previewEvery: 8});
36
+ stream.write(source.slice(0, split)); stream.write(source.slice(split));
37
+ const final = stream.finish();
38
+ assert.equal(final.source, source, name + ': exact transport source');
39
+ assert.equal(final.html, batch, name + ': final batch agreement');
40
+ }
41
+ }
42
+ for (const [source, count] of [[String.raw`$x\$y$`, 1], ['$x$5', 0], ['$ x$', 0], ['$x $', 0], ['$$$x$$$', 0]])
43
+ assert.equal(mathNodes(dollar, source).length, count);
44
+ // Image descriptions are text-only host output: unknown math token types must
45
+ // not disappear from alt text or spend a formula-render budget.
46
+ for (const [source, alt] of [
47
+ [String.raw`![before $$x$$ after](image.png)`, 'before $$x$$ after'],
48
+ [String.raw`![before \(x\) after](image.png)`, String.raw`before \(x\) after`],
49
+ [String.raw`![a ![b $$x$$](inner.png) c](outer.png)`, 'a b $$x$$ c'],
50
+ [String.raw`![a $$\text{<>&"}$$ b](image.png)`, 'a $$\\text{&lt;&gt;&amp;&quot;}$$ b'],
51
+ ]) {
52
+ const host = new MarkdownIt({html: false}).use(rapierMath, {maxFormulas: 1});
53
+ const tokens = host.parse(source + String.raw` \(y\)`, {});
54
+ for (let pass = 0; pass < 2; pass++) {
55
+ const env = {}, html = host.renderer.render(tokens, host.options, env);
56
+ assert(html.includes('alt="' + alt + '"'), 'image math remains literal source');
57
+ assert.equal((html.match(/<svg /g) || []).length, 1, 'alt text does not spend the formula budget');
58
+ assert.equal(env.rapierMathErrors, undefined);
59
+ }
60
+ }
61
+ const exact = '\ufeff$$\r\n α+\\text{a\\ b} 🐈\r\n$$';
62
+ const span = createMathMatcher(exact)(1);
63
+ assert.equal(exact.slice(span.bodyStart, span.bodyEnd), '\r\n α+\\text{a\\ b} 🐈\r\n');
64
+ assert.equal(createMathMatcher(String.raw`$$x\$$`)(0), null);
65
+
66
+ const first = createMarkdownStream(text => text, {maxSourceLength: 8, previewEvery: 2});
67
+ const second = createMarkdownStream(text => text);
68
+ first.write('ab'); second.write('other'); first.abort(); first.abort();
69
+ assert.throws(() => first.write('c'));
70
+ first.reset(); first.write('new');
71
+ assert.throws(() => first.write('123456'), {code: 'resource-limit'});
72
+ assert.equal(first.finish().source, 'new');
73
+ assert.equal(second.finish().source, 'other');
74
+ assert.throws(() => second.finish());
75
+ let failingHost = true, supplied = 0;
76
+ const failing = createMarkdownStream(text => {
77
+ supplied += text.length;
78
+ if (failingHost) throw new Error('host failed');
79
+ return text;
80
+ }, {previewEvery: 2});
81
+ const failedSource = 'x'.repeat(32);
82
+ for (const chunk of failedSource) {
83
+ try { failing.write(chunk); } catch (error) { assert.equal(error.message, 'host failed'); }
84
+ }
85
+ failingHost = false;
86
+ assert.equal(failing.finish().source, failedSource, 'failed previews retain every accepted chunk');
87
+ assert(supplied < 3 * failedSource.length, 'failed previews keep the repeated-work bound');
88
+ failing.abort(); failing.reset();
89
+
90
+ const hostile = String.raw`Before \(\text{</title><script>alert(1)</script>}\). <img src=x onerror=alert(1)>`;
91
+ assert(!/<script|<img/i.test(md.render(hostile, {})));
92
+ // Cached syntax can serve multiple outputs. Budgets belong to each render, not
93
+ // to parsing or to an env object; every paragraph still shares one document cap.
94
+ const formulaSource = String.raw`\(x\)`, svgCount = html => (html.match(/<svg /g) || []).length;
95
+ for (const budgets of [{maxFormulas: 1}, {maxOutputLength: render('x').svg.length}]) {
96
+ const host = new MarkdownIt({html: false}).use(rapierMath, budgets);
97
+ for (const inline of [false, true]) {
98
+ const source = formulaSource + (inline ? ' ' : '\n\n') + formulaSource;
99
+ const tree = host.parse(source, {});
100
+ const tokens = inline ? tree[1].children : tree;
101
+ const renderer = inline ? host.renderer.renderInline : host.renderer.render;
102
+ for (let pass = 0; pass < 2; pass++) {
103
+ const env = {}, out = renderer.call(host.renderer, tokens, host.options, env);
104
+ assert.equal(svgCount(out), 1);
105
+ assert.equal(env.rapierMathErrors.length, 1);
106
+ assert.equal(env.rapierMathErrors[0].errors[0].code, 'resource-limit');
107
+ assert(out.includes(formulaSource));
108
+ }
109
+ }
110
+ }
111
+ const nestedHost = new MarkdownIt({html: false}).use(rapierMath, {maxFormulas: 1});
112
+ const nestedTree = nestedHost.parse(formulaSource + ' fail', {});
113
+ const textRule = nestedHost.renderer.rules.text;
114
+ nestedHost.renderer.rules.text = (tokens, index, opts, env, self) => {
115
+ if (tokens[index].content === ' fail') throw new Error('host render failed');
116
+ if (tokens[index].content.includes('nested')) {
117
+ for (const inline of [false, true]) {
118
+ const nestedEnv = {};
119
+ assert.throws(() => inline
120
+ ? self.renderInline(nestedTree[1].children, opts, nestedEnv)
121
+ : self.render(nestedTree, opts, nestedEnv), /host render failed/);
122
+ assert.equal(nestedEnv.rapierMathErrors, undefined, 'nested render has its own budget');
123
+ }
124
+ }
125
+ return textRule(tokens, index, opts, env, self);
126
+ };
127
+ const outerTree = nestedHost.parse(formulaSource + ' nested ' + formulaSource, {});
128
+ // A custom host can run the same renderer twice; a failed nested render must
129
+ // restore the outer cap and leave subsequent root renders fresh.
130
+ for (let pass = 0; pass < 2; pass++) {
131
+ const env = {}, out = nestedHost.renderer.render(outerTree, nestedHost.options, env);
132
+ assert.equal(svgCount(out), 1);
133
+ assert.equal(env.rapierMathErrors.length, 1);
134
+ assert.equal(env.rapierMathErrors[0].errors[0].code, 'resource-limit');
135
+ }
136
+ const tiny = new MarkdownIt({html: false}).use(rapierMath, {maxOutputLength: 1});
137
+ assert(!tiny.render(String.raw`\(x\)`).includes('<svg'));
138
+ const bad = {}, fallback = md.render(String.raw`Good \(x\). Bad \(\unknown{x}\).`, bad);
139
+ assert.equal(bad.rapierMathErrors[0].errors[0].code, 'unsupported-command');
140
+ assert(fallback.includes('\\unknown{x}'));
141
+
142
+ const unified = await processor.process('Code `$$x$$`. Math $$x+1$$.\n\n<script>alert(1)</script>\n\n$$\\unknown{x}$$');
143
+ assert.equal((String(unified).match(/<svg /g) || []).length, 1);
144
+ assert(!String(unified).includes('<script'));
145
+ assert.equal(unified.messages[0].ruleId, 'unsupported-command');
146
+ assert(String(unified).includes('\\unknown{x}'));
147
+ console.log(JSON.stringify({sourceCustodyRows: rows.length, splitAgreement: 'all two-piece boundaries',
148
+ lifetimes: 'abort, reset, replacement, rejection, interleaved messages', adapters: 'markdown-it and sanitized unified',
149
+ security: 'hostile source stays inert; document math budgets fall back'}));
@@ -3,28 +3,31 @@
3
3
  import assert from 'node:assert/strict';
4
4
  import {readFile} from 'node:fs/promises';
5
5
  import {Script, createContext} from 'node:vm';
6
- import {mathModule} from './src/math/package-build.mjs';
6
+ import {mathModule} from '../src/math/package-build.mjs';
7
7
 
8
- const read = path => readFile(new URL(path, import.meta.url), 'utf8');
8
+ const read = path => readFile(new URL('../' + path, import.meta.url), 'utf8');
9
9
  const pkg = JSON.parse(await read('./package.json'));
10
- const resources = JSON.parse(await read('./src/shell/math-resources.json'));
11
- const classicSource = await read('./' + resources.bundle);
10
+ const resources = JSON.parse(await read('./docs/math-resources.json'));
11
+ const classicSource = await read('./' + resources.packageBundle);
12
12
  assert.equal(pkg.name, 'rapier-math');
13
13
  assert.equal(pkg.version, resources.version);
14
14
  assert.equal(pkg.type, 'module');
15
15
  assert.equal(pkg.license, 'SEE LICENSE IN LICENSE');
16
16
  assert.equal(Object.keys(pkg.dependencies || {}).length, 0);
17
17
  assert.equal(Object.keys(pkg.scripts || {}).length, 0);
18
- assert.equal(await read('./index.mjs'), mathModule(classicSource), 'exact generated module');
19
- const rendererLicense = await read('./LICENSE-MIT'), fontLicense = await read('./math-font-LICENSE.txt');
20
- for (const name of ['./LICENSE', './index.mjs', './' + resources.bundle]) {
18
+ assert.equal(await read('./dist/index.mjs'), mathModule(classicSource), 'exact generated module');
19
+ const rendererLicense = await read('./src/math/LICENSE-MIT'), fontLicense = await read('./src/math/FONT-LICENSE.txt');
20
+ for (const name of ['./LICENSE', './dist/index.mjs', './' + resources.packageBundle]) {
21
21
  const text = await read(name);
22
22
  assert(text.includes(rendererLicense.trim()), name + ': renderer license');
23
23
  assert(text.includes(fontLicense.trim()), name + ': complete font license');
24
24
  }
25
25
 
26
+ // Initialize Node's lazy Web API globals before observing module purity. This
27
+ // allocates a local Response only; no network request or renderer evaluation.
28
+ new Response();
26
29
  const globals = Object.getOwnPropertyDescriptors(globalThis);
27
- const module = await import('./index.mjs');
30
+ const module = await import('../dist/index.mjs');
28
31
  const afterImport = Object.getOwnPropertyDescriptors(globalThis);
29
32
  assert.deepEqual(Reflect.ownKeys(afterImport), Reflect.ownKeys(globals), 'module adds no globals');
30
33
  for (const key of Reflect.ownKeys(globals)) for (const field of Object.keys(globals[key]))
@@ -93,5 +96,23 @@ for (const source of hostile) {
93
96
  assert.deepEqual(plain(result), plain(classic.render(source)), 'module/classic hostile result');
94
97
  }
95
98
  for (const source of hostile.slice(1)) assert(module.render(source).errors.length, 'unsafe or exhausted input produces a diagnostic');
99
+ for (const source of ['x'.repeat(1_000_001), '\ud800'.repeat(1_000_001), '&'.repeat(8_000_000)]) {
100
+ const result = module.render(source);
101
+ assert.equal(result.errors[0].code, 'resource-limit');
102
+ assert.equal(result.errors[0].sourceOmitted, true);
103
+ assert(result.svg.includes('data-math-source-omitted="true"'));
104
+ assert(!result.svg.includes('data-tex-utf16'), 'refused source never creates a carrier');
105
+ assert(result.svg.length < 100_000, 'refusal diagnostic is fixed-size');
106
+ assert.equal(module.renderToString(source), result.svg);
107
+ assert.deepEqual(plain(result), plain(classic.render(source)), 'module/classic refusal result');
108
+ inert(result.svg);
109
+ }
110
+ const longCommand = '\\' + 'a'.repeat(10_000), diagnostic = module.render(longCommand);
111
+ assert(diagnostic.errors[0].messageTruncated && diagnostic.errors[0].commandTruncated);
112
+ sourceAlternative(diagnostic, longCommand);
113
+ assert(diagnostic.errors[0].message.length <= 1025 && diagnostic.errors[0].command.length <= 128);
114
+ const unicodeFailure = module.render('\\begin{' + '😀'.repeat(600) + '}x');
115
+ assert(unicodeFailure.errors[0].message.isWellFormed(), 'diagnostic admission cannot split a source surrogate pair');
116
+ assert.equal(module.render('x+1').errors.length, 0, 'refused input cannot poison later rendering');
96
117
  console.log(JSON.stringify({name: pkg.name, version: module.version, module: 'native ESM; no global changes',
97
118
  rebuild: 'exact module', classicAgreement: examples.length + hostile.length, sourceAlternatives: 'exact', hostileContent: 'inert', licenses: 'complete'}));
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Verify each carried owner once and rebuild both current runtime formats.
3
+ import assert from 'node:assert/strict';
4
+ import {readFile} from 'node:fs/promises';
5
+ import {createHash} from 'node:crypto';
6
+ import {dirname, resolve} from 'node:path';
7
+ import {fileURLToPath} from 'node:url';
8
+ import {assembleMath} from '../src/tools/build-math.mjs';
9
+ import {mathModule} from '../src/math/package-build.mjs';
10
+ import {mathReadme} from '../src/math/package-readme.mjs';
11
+
12
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
+ const read = path => readFile(resolve(root, path));
14
+ const hash = (bytes, algorithm = 'sha256', encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding);
15
+ const resources = JSON.parse(await read('docs/math-resources.json'));
16
+ const payload = await read(resources.packageBundle);
17
+ assert.equal(payload.length, resources.bytes, 'payload length');
18
+ assert.equal(hash(payload), resources.sha256, 'payload SHA-256');
19
+ assert.equal(hash(payload, 'sha384', 'base64'), resources.sha384, 'payload SHA-384');
20
+ const paths = new Set();
21
+ for (const input of [...resources.sources, ...resources.recipe, ...resources.adoption]) {
22
+ assert(!paths.has(input.packageFile), 'one public location per owner');
23
+ paths.add(input.packageFile);
24
+ const bytes = await read(input.packageFile);
25
+ assert.equal(bytes.length, input.bytes, input.file + ': length');
26
+ assert.equal(hash(bytes), input.sha256, input.file + ': SHA-256');
27
+ }
28
+ const rendererLicense = await read('src/math/LICENSE-MIT');
29
+ const fontLicense = await read('src/math/FONT-LICENSE.txt');
30
+ assert.equal(hash(fontLicense), resources.font.licenseSha256, 'font license SHA-256');
31
+ for (const text of [payload, await read('LICENSE'), await read('dist/index.mjs')]) {
32
+ assert(text.includes(rendererLicense.toString('utf8').trim()), 'complete renderer license');
33
+ assert(text.includes(fontLicense.toString('utf8').trim()), 'complete font license');
34
+ }
35
+ const sbom = JSON.parse(await read('docs/math-components.json'));
36
+ const font = sbom.packages.find(item => item.name === resources.font.name);
37
+ assert.equal(font?.copyrightText, resources.font.copyright, 'font copyright');
38
+ assert.equal(font.licenseDeclared, resources.font.license, 'font license identity');
39
+ assert.equal(sbom.hasExtractedLicensingInfos.find(item => item.licenseId === resources.font.license)?.extractedText,
40
+ fontLicense.toString('utf8'), 'complete extracted font license');
41
+ assert.equal((await read('README.md')).toString('utf8'), mathReadme(resources), 'generated README');
42
+ const rebuilt = await assembleMath({root, packageLayout: true});
43
+ assert.deepEqual(rebuilt.bytes, payload, 'rebuilt classic payload');
44
+ assert.equal((await read('dist/index.mjs')).toString('utf8'), mathModule(rebuilt.bytes), 'rebuilt ESM payload');
45
+ console.log(JSON.stringify({file: resources.packageBundle, bytes: payload.length, sha256: resources.sha256,
46
+ sourceFiles: paths.size, rebuild: 'exact', licenses: 'complete'}));
package/LICENSE-MIT DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Jack Skipworth
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
package/math-LICENSE.txt DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Jack Skipworth
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.