@schemd/core 0.1.0

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.
@@ -0,0 +1,75 @@
1
+ import { MAX_SCHEMATIC_COMPONENTS, MAX_SCHEMATIC_CONNECTIONS, MAX_SCHEMATIC_SOURCE_CHARACTERS, MAX_SCHEMATIC_SVG_OUTPUT_BYTES, utf8ByteLength } from './limits.js';
2
+ import { parseSchematic, parseSchematicFence } from './parser.js';
3
+ import { renderSchematic } from './renderer.js';
4
+ import { SchematicSyntaxError } from './types.js';
5
+ class SchematicDocumentBudgetError extends SchematicSyntaxError {
6
+ }
7
+ function emptyDocumentBudget() {
8
+ return { sourceCharacters: 0, components: 0, connections: 0, svgOutputBytes: 0 };
9
+ }
10
+ function consumeBudget(used, amount, limit, unit) {
11
+ if (amount > limit - used) {
12
+ throw new SchematicDocumentBudgetError(`Schematic document exceeds the cumulative ${limit.toLocaleString('en-US')} ${unit} limit.`);
13
+ }
14
+ return used + amount;
15
+ }
16
+ function escapeHtml(value) {
17
+ return value
18
+ .replaceAll('&', '&')
19
+ .replaceAll('<', '&lt;')
20
+ .replaceAll('>', '&gt;')
21
+ .replaceAll('"', '&quot;')
22
+ .replaceAll("'", '&#39;');
23
+ }
24
+ function defaultErrorRenderer(error, source) {
25
+ return `<figure class="schematic-error" role="group" aria-label="Schematic compilation error"><figcaption>${escapeHtml(error.message)}</figcaption><pre><code class="language-schemd">${escapeHtml(source)}</code></pre></figure>`;
26
+ }
27
+ export function schematicMarkedExtension(options = {}) {
28
+ let diagramIndex = 0;
29
+ let budget = emptyDocumentBudget();
30
+ let budgetError;
31
+ return {
32
+ hooks: {
33
+ preprocess(source) {
34
+ diagramIndex = 0;
35
+ budget = emptyDocumentBudget();
36
+ budgetError = undefined;
37
+ return source;
38
+ }
39
+ },
40
+ renderer: {
41
+ code(token) {
42
+ if (!token.lang || !/^schemd(?:\s|$)/i.test(token.lang.trim())) {
43
+ return false;
44
+ }
45
+ try {
46
+ if (budgetError)
47
+ throw budgetError;
48
+ budget.sourceCharacters = consumeBudget(budget.sourceCharacters, token.text.length, MAX_SCHEMATIC_SOURCE_CHARACTERS, 'schematic source character');
49
+ const fence = parseSchematicFence(token.lang, options.defaultTitle);
50
+ diagramIndex += 1;
51
+ const document = parseSchematic(token.text, fence);
52
+ const nextComponents = consumeBudget(budget.components, document.components.length, MAX_SCHEMATIC_COMPONENTS, 'component');
53
+ const nextConnections = consumeBudget(budget.connections, document.connections.length, MAX_SCHEMATIC_CONNECTIONS, 'connection');
54
+ const html = renderSchematic(document, {
55
+ ...fence,
56
+ idPrefix: `schematic-${diagramIndex}`,
57
+ mode: options.resolveMode?.() ?? options.mode ?? 'default'
58
+ });
59
+ const nextSvgOutputBytes = consumeBudget(budget.svgOutputBytes, utf8ByteLength(html), MAX_SCHEMATIC_SVG_OUTPUT_BYTES, 'compiled SVG byte');
60
+ budget.components = nextComponents;
61
+ budget.connections = nextConnections;
62
+ budget.svgOutputBytes = nextSvgOutputBytes;
63
+ return html;
64
+ }
65
+ catch (error) {
66
+ if (!(error instanceof SchematicSyntaxError))
67
+ throw error;
68
+ if (error instanceof SchematicDocumentBudgetError)
69
+ budgetError = error;
70
+ return (options.onError ?? defaultErrorRenderer)(error, token.text);
71
+ }
72
+ }
73
+ }
74
+ };
75
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Dependency-free micro-math tokenizer and inline SVG text renderer.
3
+ *
4
+ * The grammar is intentionally non-recursive and bounded by the caller's label
5
+ * length. It recognizes subscript/superscript shifts and a fixed engineering
6
+ * symbol map without evaluating TeX or allocating a browser-side math runtime.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+ /** Visual baseline category assigned to one parsed label segment. */
11
+ export type MathLabelSegmentKind = 'text' | 'subscript' | 'superscript';
12
+ /** One normalized, Unicode-translated section of a component label. */
13
+ export interface MathLabelSegment {
14
+ /** Baseline treatment used by the SVG renderer. */
15
+ readonly kind: MathLabelSegmentKind;
16
+ /** Plain Unicode content with grouping delimiters removed. */
17
+ readonly value: string;
18
+ }
19
+ /**
20
+ * Parse a deliberately small, linear-time math-label subset.
21
+ *
22
+ * @param value - Raw component label. Braces only group shifted content.
23
+ * @returns Coalesced immutable baseline segments with symbol commands expanded.
24
+ */
25
+ export declare function parseMathLabel(value: string): readonly MathLabelSegment[];
26
+ /**
27
+ * Return the accessible plain-text representation of a math label.
28
+ *
29
+ * @param value - Raw micro-math label.
30
+ * @returns Unicode text without baseline grouping markup.
31
+ */
32
+ export declare function mathLabelText(value: string): string;
33
+ /**
34
+ * Count rendered Unicode glyphs for deterministic SVG text fitting.
35
+ *
36
+ * @param value - Raw micro-math label.
37
+ * @returns Unicode code-point count after command translation.
38
+ */
39
+ export declare function mathLabelGlyphLength(value: string): number;
40
+ /**
41
+ * Emit escaped inline SVG text with explicit baseline restoration.
42
+ *
43
+ * Plain labels avoid `<tspan>` allocation entirely. Shifted segments use a
44
+ * 70% font size and an empty inverse-`dy` tspan so consecutive shifts cannot
45
+ * accumulate vertical drift.
46
+ *
47
+ * @param value - Raw micro-math label.
48
+ * @returns Trusted compiler-owned XML text suitable inside an SVG `<text>` node.
49
+ */
50
+ export declare function renderMathLabelTspans(value: string): string;
@@ -0,0 +1,151 @@
1
+ const MATH_SYMBOLS = Object.freeze({
2
+ alpha: 'α',
3
+ beta: 'β',
4
+ Delta: 'Δ',
5
+ cdot: '·',
6
+ infty: '∞',
7
+ lambda: 'λ',
8
+ le: '≤',
9
+ ge: '≥',
10
+ mu: 'μ',
11
+ neq: '≠',
12
+ Omega: 'Ω',
13
+ omega: 'ω',
14
+ phi: 'φ',
15
+ pi: 'π',
16
+ pm: '±',
17
+ rightarrow: '→',
18
+ sigma: 'σ',
19
+ sqrt: '√',
20
+ theta: 'θ',
21
+ times: '×'
22
+ });
23
+ function validXmlCodePoint(codePoint) {
24
+ if (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d)
25
+ return true;
26
+ if (codePoint >= 0x20 && codePoint <= 0xd7ff)
27
+ return true;
28
+ if (codePoint >= 0xe000 && codePoint <= 0xfffd)
29
+ return true;
30
+ return codePoint >= 0x10000 && codePoint <= 0x10ffff;
31
+ }
32
+ function escapeXml(value) {
33
+ let normalized = '';
34
+ for (const character of value) {
35
+ const codePoint = character.codePointAt(0);
36
+ normalized += validXmlCodePoint(codePoint) ? character : '\ufffd';
37
+ }
38
+ return normalized
39
+ .replaceAll('&', '&amp;')
40
+ .replaceAll('<', '&lt;')
41
+ .replaceAll('>', '&gt;')
42
+ .replaceAll('"', '&quot;')
43
+ .replaceAll("'", '&#39;');
44
+ }
45
+ function commandAt(value, index) {
46
+ const match = value.slice(index + 1).match(/^[A-Za-z]+/);
47
+ if (!match)
48
+ return { text: value[index], end: index + 1 };
49
+ const command = match[0];
50
+ const replacement = MATH_SYMBOLS[command];
51
+ return {
52
+ text: replacement ?? `\\${command}`,
53
+ end: index + command.length + 1
54
+ };
55
+ }
56
+ function translateMathSymbols(value) {
57
+ let output = '';
58
+ let index = 0;
59
+ while (index < value.length) {
60
+ if (value[index] !== '\\') {
61
+ output += value[index];
62
+ index += 1;
63
+ continue;
64
+ }
65
+ const command = commandAt(value, index);
66
+ output += command.text;
67
+ index = command.end;
68
+ }
69
+ return output;
70
+ }
71
+ function shiftedValue(value, index) {
72
+ const first = value[index + 1];
73
+ if (first !== '{') {
74
+ const character = Array.from(value.slice(index + 1))[0];
75
+ return { value: translateMathSymbols(character), end: index + 1 + character.length };
76
+ }
77
+ const closing = value.indexOf('}', index + 2);
78
+ if (closing < 0)
79
+ return undefined;
80
+ return {
81
+ value: translateMathSymbols(value.slice(index + 2, closing)),
82
+ end: closing + 1
83
+ };
84
+ }
85
+ function appendSegment(segments, kind, value) {
86
+ if (value === '')
87
+ return;
88
+ const previous = segments.at(-1);
89
+ if (previous?.kind === kind) {
90
+ segments[segments.length - 1] = { kind, value: previous.value + value };
91
+ return;
92
+ }
93
+ segments.push({ kind, value });
94
+ }
95
+ export function parseMathLabel(value) {
96
+ const segments = [];
97
+ let text = '';
98
+ let index = 0;
99
+ const flush = () => {
100
+ appendSegment(segments, 'text', text);
101
+ text = '';
102
+ };
103
+ while (index < value.length) {
104
+ const character = value[index];
105
+ if ((character === '_' || character === '^') && index + 1 < value.length) {
106
+ const shifted = shiftedValue(value, index);
107
+ if (shifted !== undefined && shifted.value !== '') {
108
+ flush();
109
+ appendSegment(segments, character === '_' ? 'subscript' : 'superscript', shifted.value);
110
+ index = shifted.end;
111
+ continue;
112
+ }
113
+ }
114
+ if (character === '\\') {
115
+ const command = commandAt(value, index);
116
+ text += command.text;
117
+ index = command.end;
118
+ continue;
119
+ }
120
+ if (character !== '{' && character !== '}')
121
+ text += character;
122
+ index += 1;
123
+ }
124
+ flush();
125
+ return segments;
126
+ }
127
+ export function mathLabelText(value) {
128
+ return parseMathLabel(value)
129
+ .map((segment) => segment.value)
130
+ .join('');
131
+ }
132
+ export function mathLabelGlyphLength(value) {
133
+ return Array.from(mathLabelText(value)).length;
134
+ }
135
+ export function renderMathLabelTspans(value) {
136
+ const segments = parseMathLabel(value);
137
+ if (segments.length === 1 && segments[0]?.kind === 'text' && segments[0].value === value) {
138
+ return escapeXml(value);
139
+ }
140
+ return segments
141
+ .map((segment) => {
142
+ const content = escapeXml(segment.value);
143
+ if (segment.kind === 'text')
144
+ return `<tspan dy="0">${content}</tspan>`;
145
+ if (segment.kind === 'subscript') {
146
+ return `<tspan dy="0.35em" font-size="70%">${content}</tspan><tspan dy="-0.35em" font-size="100%"></tspan>`;
147
+ }
148
+ return `<tspan dy="-0.55em" font-size="70%">${content}</tspan><tspan dy="0.55em" font-size="100%"></tspan>`;
149
+ })
150
+ .join('');
151
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Bounded lexer, parser, and semantic validator for the `schemd` DSL.
3
+ *
4
+ * Parsing is synchronous and deterministic. This module accepts no DOM or
5
+ * browser globals, validates all author-controlled strings before they reach
6
+ * the SVG renderer, and records successful documents in a private provenance
7
+ * registry so arbitrary object graphs cannot bypass the parser boundary.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ import { type SchematicColor, type SchematicDocument, type SchematicFence } from './types.js';
12
+ /**
13
+ * Assert that a document originated from {@link parseSchematic} in this module.
14
+ *
15
+ * @param document - Candidate AST supplied to the renderer.
16
+ * @throws {TypeError} When the object was forged, cloned, or parsed by another
17
+ * loaded copy of the package rather than this module instance.
18
+ * @internal
19
+ */
20
+ export declare function assertParsedSchematicDocument(document: SchematicDocument): void;
21
+ /**
22
+ * Parse a theme token, safe CSS literal, or custom theme alias.
23
+ *
24
+ * Raw aliases never become literal color declarations; the renderer maps them
25
+ * through bounded `--schematic-color-*` custom properties. Functional colors
26
+ * are canonicalized and unsafe CSS expressions are rejected.
27
+ *
28
+ * @param input - Author-supplied color token or expression.
29
+ * @param line - One-based source line used in diagnostics.
30
+ * @returns Sanitized discriminated color representation.
31
+ * @throws {SchematicSyntaxError} For malformed, out-of-range, or unsafe colors.
32
+ */
33
+ export declare function parseSchematicColor(input: string, line?: number): SchematicColor;
34
+ /**
35
+ * Parse and validate a schemd fenced-code information string.
36
+ *
37
+ * @param info - The complete Markdown fence information string beginning with
38
+ * the canonical `schemd` language identifier.
39
+ * @param defaultTitle - Accessible SVG title used when `title` is omitted.
40
+ * @returns Validated intrinsic bounds and title, or `undefined` when the fence
41
+ * belongs to another language.
42
+ * @throws {SchematicSyntaxError} When a recognized fence has malformed
43
+ * metadata, out-of-range bounds, or a title longer than 512 characters.
44
+ */
45
+ export declare function parseSchematicFence(info: string | undefined, defaultTitle?: string): SchematicFence | undefined;
46
+ /**
47
+ * Compile validated schemd DSL source into an immutable schematic AST.
48
+ *
49
+ * @param source - Diagram declarations excluding the surrounding Markdown fence.
50
+ * @param fence - Intrinsic dimensions and accessible title returned by
51
+ * {@link parseSchematicFence}.
52
+ * @returns A deeply frozen, renderer-authorized schematic document.
53
+ * @throws {SchematicSyntaxError} When syntax, resource budgets, port references,
54
+ * color values, component geometry, or connection geometry are invalid.
55
+ */
56
+ export declare function parseSchematic(source: string, fence: SchematicFence): SchematicDocument;