@limaformat/lima 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.
- package/CHANGELOG.md +29 -0
- package/LICENSE +15 -0
- package/README.md +70 -0
- package/dist/block-cursor.d.ts +24 -0
- package/dist/block-cursor.js +71 -0
- package/dist/block.d.ts +18 -0
- package/dist/block.js +305 -0
- package/dist/builder.d.ts +41 -0
- package/dist/builder.js +1 -0
- package/dist/chars.d.ts +2 -0
- package/dist/chars.js +4 -0
- package/dist/core.d.ts +73 -0
- package/dist/core.js +439 -0
- package/dist/errors.d.ts +31 -0
- package/dist/errors.js +24 -0
- package/dist/flow.d.ts +7 -0
- package/dist/flow.js +162 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/normalize.d.ts +41 -0
- package/dist/normalize.js +67 -0
- package/dist/references.d.ts +36 -0
- package/dist/references.js +522 -0
- package/dist/scalars.d.ts +89 -0
- package/dist/scalars.js +407 -0
- package/dist/scanner.d.ts +82 -0
- package/dist/scanner.js +204 -0
- package/dist/value.d.ts +95 -0
- package/dist/value.js +185 -0
- package/package.json +41 -0
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level key tokenization — a hand-written scanner, not a regex, for
|
|
3
|
+
* two reasons: it's measurably faster (the regex-split approach it
|
|
4
|
+
* replaced re-scanned the whole document with a single large alternation
|
|
5
|
+
* regex on every parse), and it removes the parser's only remaining
|
|
6
|
+
* dependency on genuine backtracking-dependent regex semantics, keeping
|
|
7
|
+
* the implementation representable by an RE2-family (linear-time,
|
|
8
|
+
* non-backtracking) engine — relevant for a future Rust port, where the
|
|
9
|
+
* standard `regex` crate is itself RE2-derived and cannot express
|
|
10
|
+
* backtracking-dependent patterns at all.
|
|
11
|
+
*
|
|
12
|
+
* Reproduces (verified by extensive differential testing against the
|
|
13
|
+
* regex it replaced — over 100,000 fuzzed and structured cases, the full
|
|
14
|
+
* conformance corpus, and hand-built adversarial edge cases, zero
|
|
15
|
+
* divergences) the exact matching behavior of:
|
|
16
|
+
*
|
|
17
|
+
* /^(?:([a-zA-Z\d_][a-zA-Z\d_:-]*)|'([^']*)'|"((?:[^"\\]|\\.)*)"):( *\n| )/gm
|
|
18
|
+
*
|
|
19
|
+
* including subtle backtracking-dependent cases that are easy to get
|
|
20
|
+
* wrong without empirical verification against the real parser:
|
|
21
|
+
* - `a:b: value` → key "a:b" (colon is a legal mid-key character; the
|
|
22
|
+
* mandatory separator-introducing `:` is whichever colon within the
|
|
23
|
+
* greedily-matched run is the RIGHTMOST one for which a valid
|
|
24
|
+
* separator follows it — matching real regex backtracking, which
|
|
25
|
+
* always tries the longest capture first and shrinks it only when the
|
|
26
|
+
* rest of the pattern fails to match).
|
|
27
|
+
* - `key: value` (two spaces) → the separator consumes exactly one
|
|
28
|
+
* space; the second space becomes part of the raw value.
|
|
29
|
+
* - `key:` at end of input with no following space or `\n` → no match
|
|
30
|
+
* at all (the separator alternative `( *\n| )` requires an actual
|
|
31
|
+
* trailing newline or a literal space; end-of-string alone satisfies
|
|
32
|
+
* neither).
|
|
33
|
+
* - Quoted keys (`'...'`, `"..."`) may contain a literal `\n` — the
|
|
34
|
+
* character classes inside quotes don't exclude it.
|
|
35
|
+
* - A backslash directly followed by a line terminator inside a
|
|
36
|
+
* double-quoted key is NOT a valid `\\.` escape — the source regex's
|
|
37
|
+
* `.` never matches a line terminator without the `s` flag (not set),
|
|
38
|
+
* so this fails the match entirely rather than consuming the pair.
|
|
39
|
+
*/
|
|
40
|
+
const isKeyStartChar = (c) => (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || (c >= 48 && c <= 57) || c === 95; // a-z A-Z 0-9 _
|
|
41
|
+
const isKeyContinueChar = (c) => isKeyStartChar(c) || c === 58 || c === 45; // + : -
|
|
42
|
+
/** Positive = block end, negative = inline end, zero = no separator. */
|
|
43
|
+
const matchSeparator = (s, pos) => {
|
|
44
|
+
let i = pos;
|
|
45
|
+
while (i < s.length && s.charCodeAt(i) === 32)
|
|
46
|
+
i++; // consume spaces
|
|
47
|
+
if (i < s.length && s.charCodeAt(i) === 10)
|
|
48
|
+
return i + 1;
|
|
49
|
+
// Backtracking a pure-space run can never reveal a `\n` the maximal
|
|
50
|
+
// scan didn't already see (every intermediate position is followed by
|
|
51
|
+
// either another space or the same non-`\n` stop character) — so
|
|
52
|
+
// falling straight through to the single-space alternative, without a
|
|
53
|
+
// loop, is exactly equivalent to real regex backtracking here.
|
|
54
|
+
if (s.charCodeAt(pos) === 32)
|
|
55
|
+
return -(pos + 1);
|
|
56
|
+
return 0;
|
|
57
|
+
};
|
|
58
|
+
const matchAt = (s, pos, line, out) => {
|
|
59
|
+
const c = s.charCodeAt(pos);
|
|
60
|
+
let keyStart = pos, keyEnd = pos, keyKind = 0, sepStart = 0, separator = 0;
|
|
61
|
+
if (c === 39) { // '
|
|
62
|
+
const end = s.indexOf("'", pos + 1);
|
|
63
|
+
if (end === -1 || s.charCodeAt(end + 1) !== 58)
|
|
64
|
+
return false;
|
|
65
|
+
sepStart = end + 2;
|
|
66
|
+
separator = matchSeparator(s, sepStart);
|
|
67
|
+
if (separator === 0)
|
|
68
|
+
return false;
|
|
69
|
+
keyStart = pos + 1;
|
|
70
|
+
keyEnd = end;
|
|
71
|
+
keyKind = 1;
|
|
72
|
+
}
|
|
73
|
+
else if (c === 34) { // "
|
|
74
|
+
let i = pos + 1;
|
|
75
|
+
let closed = false;
|
|
76
|
+
while (i < s.length) {
|
|
77
|
+
const cc = s.charCodeAt(i);
|
|
78
|
+
if (cc === 92) {
|
|
79
|
+
// `\\.` in the source regex — see the module doc comment.
|
|
80
|
+
const next = s.charCodeAt(i + 1);
|
|
81
|
+
if (i + 1 >= s.length || next === 10 || next === 13 || next === 0x2028 || next === 0x2029)
|
|
82
|
+
return false;
|
|
83
|
+
i += 2;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (cc === 34) {
|
|
87
|
+
closed = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
if (!closed)
|
|
93
|
+
return false;
|
|
94
|
+
const end = i;
|
|
95
|
+
if (s.charCodeAt(end + 1) !== 58)
|
|
96
|
+
return false;
|
|
97
|
+
sepStart = end + 2;
|
|
98
|
+
separator = matchSeparator(s, sepStart);
|
|
99
|
+
if (separator === 0)
|
|
100
|
+
return false;
|
|
101
|
+
keyStart = pos + 1;
|
|
102
|
+
keyEnd = end;
|
|
103
|
+
keyKind = 2;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
if (!isKeyStartChar(c))
|
|
107
|
+
return false;
|
|
108
|
+
let runEnd = pos + 1;
|
|
109
|
+
while (runEnd < s.length && isKeyContinueChar(s.charCodeAt(runEnd)))
|
|
110
|
+
runEnd++;
|
|
111
|
+
for (let k = runEnd - 1; k >= pos + 1; k--) {
|
|
112
|
+
if (s.charCodeAt(k) !== 58)
|
|
113
|
+
continue;
|
|
114
|
+
sepStart = k + 1;
|
|
115
|
+
separator = matchSeparator(s, sepStart);
|
|
116
|
+
if (separator !== 0) {
|
|
117
|
+
keyEnd = k;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (separator === 0)
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
out.tokenLine = line;
|
|
125
|
+
out.matchStart = pos;
|
|
126
|
+
out.sepStart = sepStart;
|
|
127
|
+
out.rawStart = separator > 0 ? separator : -separator;
|
|
128
|
+
out.isBlock = separator > 0;
|
|
129
|
+
out.keyStart = keyStart;
|
|
130
|
+
out.keyEnd = keyEnd;
|
|
131
|
+
out.keyKind = keyKind;
|
|
132
|
+
return true;
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* All top-level key matches, in document order. Only line-start positions
|
|
136
|
+
* (index 0, and every position right after a `\n`) are attempted,
|
|
137
|
+
* mirroring `^` with the multiline flag — content between one match's raw
|
|
138
|
+
* start and the next match's start (or end of document) is that key's raw
|
|
139
|
+
* value text, exactly as the regex-split's discarded inter-match segments
|
|
140
|
+
* were.
|
|
141
|
+
*/
|
|
142
|
+
export const scanKeys = (frontMatter) => {
|
|
143
|
+
const matches = [];
|
|
144
|
+
const cursor = new KeyCursor(frontMatter);
|
|
145
|
+
while (cursor.next()) {
|
|
146
|
+
const rawKey = frontMatter.slice(cursor.keyStart, cursor.keyEnd);
|
|
147
|
+
matches.push({
|
|
148
|
+
line: cursor.tokenLine, matchStart: cursor.matchStart,
|
|
149
|
+
sepStart: cursor.sepStart, rawStart: cursor.rawStart,
|
|
150
|
+
isBlock: cursor.isBlock, inlineEnd: cursor.inlineEnd,
|
|
151
|
+
...(cursor.keyKind === 0 ? { unquoted: rawKey } :
|
|
152
|
+
cursor.keyKind === 1 ? { singleQuoted: rawKey } : { doubleQuotedRaw: rawKey }),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return matches;
|
|
156
|
+
};
|
|
157
|
+
/** Stateful top-level token cursor; `next()` performs no text slicing. */
|
|
158
|
+
export class KeyCursor {
|
|
159
|
+
source;
|
|
160
|
+
tokenLine = 0;
|
|
161
|
+
matchStart = 0;
|
|
162
|
+
sepStart = 0;
|
|
163
|
+
rawStart = 0;
|
|
164
|
+
inlineEnd = -1;
|
|
165
|
+
keyStart = 0;
|
|
166
|
+
keyEnd = 0;
|
|
167
|
+
keyKind = 0;
|
|
168
|
+
isBlock = false;
|
|
169
|
+
pos = 0;
|
|
170
|
+
line = 1;
|
|
171
|
+
constructor(source) {
|
|
172
|
+
this.source = source;
|
|
173
|
+
}
|
|
174
|
+
next() {
|
|
175
|
+
while (this.pos <= this.source.length) {
|
|
176
|
+
const start = this.pos;
|
|
177
|
+
if (matchAt(this.source, start, this.line, this)) {
|
|
178
|
+
for (let i = start; i < this.rawStart; i++) {
|
|
179
|
+
if (this.source.charCodeAt(i) === 10)
|
|
180
|
+
this.line++;
|
|
181
|
+
}
|
|
182
|
+
this.pos = this.rawStart;
|
|
183
|
+
this.inlineEnd = -1;
|
|
184
|
+
if (!this.isBlock) {
|
|
185
|
+
const nextNl = this.source.indexOf('\n', this.pos);
|
|
186
|
+
this.inlineEnd = nextNl === -1 ? this.source.length : nextNl;
|
|
187
|
+
if (nextNl === -1)
|
|
188
|
+
this.pos = this.source.length + 1;
|
|
189
|
+
else {
|
|
190
|
+
this.pos = nextNl + 1;
|
|
191
|
+
this.line++;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
const nextNl = this.source.indexOf('\n', this.pos);
|
|
197
|
+
if (nextNl === -1)
|
|
198
|
+
break;
|
|
199
|
+
this.pos = nextNl + 1;
|
|
200
|
+
this.line++;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
package/dist/value.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lima Value Model — a tagged union that stands in for raw JavaScript values
|
|
3
|
+
* everywhere in the parser. Precise integer/float tagging is what a Rust
|
|
4
|
+
* port needs (`i64` vs `f64` are genuinely different representations there,
|
|
5
|
+
* unlike JS's single `number` type) — this module carries that distinction
|
|
6
|
+
* from the moment a scalar is recognised, rather than reconstructing it
|
|
7
|
+
* later from formatting details.
|
|
8
|
+
*/
|
|
9
|
+
export type LimaValue = {
|
|
10
|
+
kind: 'null';
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'bool';
|
|
13
|
+
value: boolean;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'int';
|
|
16
|
+
value: number;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'float';
|
|
19
|
+
value: number;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'string';
|
|
22
|
+
value: string;
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'instant';
|
|
25
|
+
value: Date;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'array';
|
|
28
|
+
items: LimaValue[];
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'mapping';
|
|
31
|
+
entries: Map<string, LimaValue>;
|
|
32
|
+
};
|
|
33
|
+
export declare const LNull: LimaValue;
|
|
34
|
+
export declare const LBool: (value: boolean) => LimaValue;
|
|
35
|
+
export declare const LInt: (value: number) => LimaValue;
|
|
36
|
+
export declare const LFloat: (value: number) => LimaValue;
|
|
37
|
+
export declare const LString: (value: string) => LimaValue;
|
|
38
|
+
export declare const LInstant: (value: Date) => LimaValue;
|
|
39
|
+
export declare const LArray: (items: LimaValue[]) => LimaValue;
|
|
40
|
+
export declare const LMapping: (entries?: Map<string, LimaValue>) => LimaValue;
|
|
41
|
+
/**
|
|
42
|
+
* References §6.2 node-count definition: `nodeCount(scalar) = 1`,
|
|
43
|
+
* `nodeCount(collection) = 1 + sum(nodeCount(child))` — mapping keys do not
|
|
44
|
+
* count as separate nodes.
|
|
45
|
+
*/
|
|
46
|
+
export declare const countNodes: (v: LimaValue) => number;
|
|
47
|
+
/**
|
|
48
|
+
* Canonical string representation for interpolation (References §3.5.1).
|
|
49
|
+
* `Number.prototype.toString` already picks the correct fixed-vs-exponential
|
|
50
|
+
* form and digit sequence (ECMAScript is the normative algorithm for both
|
|
51
|
+
* the int and the float rule, within Lima's supported ranges) — this only
|
|
52
|
+
* applies the lexical cleanup the spec requires on top: lowercase `e`, no
|
|
53
|
+
* `+` after `e`, no leading zeros in the exponent.
|
|
54
|
+
*/
|
|
55
|
+
export declare const canonicalString: (v: LimaValue) => string;
|
|
56
|
+
/** References §6.2 partial resource limits. */
|
|
57
|
+
export declare const PARTIAL_KEY_LENGTH_LIMIT = 128;
|
|
58
|
+
export declare const PARTIAL_VALUE_DEPTH_LIMIT = 16;
|
|
59
|
+
export declare const PARTIAL_COUNT_LIMIT = 128;
|
|
60
|
+
export declare const PARTIAL_NAME_LENGTH_LIMIT = 128;
|
|
61
|
+
export declare const PARTIAL_NODE_LIMIT = 4096;
|
|
62
|
+
export declare const RESULT_NODE_LIMIT = 65536;
|
|
63
|
+
export declare const SCALAR_LENGTH_LIMIT = 16384;
|
|
64
|
+
/**
|
|
65
|
+
* Counts Unicode code points (what every length-limit check in this
|
|
66
|
+
* codebase means by "code points", never UTF-16 code units) without
|
|
67
|
+
* `[...s].length`'s unconditional cost: spreading a string allocates an
|
|
68
|
+
* array of every character just to read its length, ~15x slower than
|
|
69
|
+
* `.length` for a typical short ASCII/BMP string (measured). A surrogate
|
|
70
|
+
* pair (the only case where `.length` and code-point count differ) can
|
|
71
|
+
* only occur for code points above U+FFFF — astral-plane characters like
|
|
72
|
+
* emoji, vanishingly rare in real frontmatter — so scanning for one first
|
|
73
|
+
* and falling back to the exact spread-based count only then keeps the
|
|
74
|
+
* common case (ASCII titles, but also accented Latin/Cyrillic/CJK BMP
|
|
75
|
+
* text, none of which need surrogate pairs) on the fast, allocation-free
|
|
76
|
+
* path.
|
|
77
|
+
*/
|
|
78
|
+
export declare const codepointLength: (s: string) => number;
|
|
79
|
+
/**
|
|
80
|
+
* Converts and validates a single host value against the Lima Value Model
|
|
81
|
+
* (References §6.2), recursively. `seen` tracks the current recursion path
|
|
82
|
+
* (not every visited object) to detect genuine cycles without rejecting
|
|
83
|
+
* shared, non-cyclic substructure.
|
|
84
|
+
*
|
|
85
|
+
* Host types with no Lima equivalent (functions, symbols, class instances,
|
|
86
|
+
* accessor properties) are rejected outright rather than silently coerced.
|
|
87
|
+
* Negative zero is normalised to positive zero; Date milliseconds are
|
|
88
|
+
* truncated (not rounded) to zero — both per References §6.2.
|
|
89
|
+
*
|
|
90
|
+
* This is the ONLY boundary in the whole implementation that touches JS
|
|
91
|
+
* host-value reflection (`Object.getPrototypeOf`, `getOwnPropertyDescriptor`)
|
|
92
|
+
* — a Rust port has no equivalent step at all, since its partials API takes
|
|
93
|
+
* a native Rust type up front.
|
|
94
|
+
*/
|
|
95
|
+
export declare const ingestPartialValue: (value: any, partialName: string, path: string, depth?: number, seen?: Set<unknown>) => LimaValue;
|
package/dist/value.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lima Value Model — a tagged union that stands in for raw JavaScript values
|
|
3
|
+
* everywhere in the parser. Precise integer/float tagging is what a Rust
|
|
4
|
+
* port needs (`i64` vs `f64` are genuinely different representations there,
|
|
5
|
+
* unlike JS's single `number` type) — this module carries that distinction
|
|
6
|
+
* from the moment a scalar is recognised, rather than reconstructing it
|
|
7
|
+
* later from formatting details.
|
|
8
|
+
*/
|
|
9
|
+
import { LimaError } from './errors.js';
|
|
10
|
+
export const LNull = { kind: 'null' };
|
|
11
|
+
export const LBool = (value) => ({ kind: 'bool', value });
|
|
12
|
+
export const LInt = (value) => ({ kind: 'int', value: value === 0 ? 0 : value });
|
|
13
|
+
export const LFloat = (value) => ({ kind: 'float', value: value === 0 ? 0 : value });
|
|
14
|
+
export const LString = (value) => ({ kind: 'string', value });
|
|
15
|
+
export const LInstant = (value) => ({ kind: 'instant', value });
|
|
16
|
+
export const LArray = (items) => ({ kind: 'array', items });
|
|
17
|
+
export const LMapping = (entries = new Map()) => ({ kind: 'mapping', entries });
|
|
18
|
+
/**
|
|
19
|
+
* References §6.2 node-count definition: `nodeCount(scalar) = 1`,
|
|
20
|
+
* `nodeCount(collection) = 1 + sum(nodeCount(child))` — mapping keys do not
|
|
21
|
+
* count as separate nodes.
|
|
22
|
+
*/
|
|
23
|
+
export const countNodes = (v) => {
|
|
24
|
+
if (v.kind === 'array')
|
|
25
|
+
return 1 + v.items.reduce((sum, item) => sum + countNodes(item), 0);
|
|
26
|
+
if (v.kind === 'mapping') {
|
|
27
|
+
let sum = 1;
|
|
28
|
+
for (const child of v.entries.values())
|
|
29
|
+
sum += countNodes(child);
|
|
30
|
+
return sum;
|
|
31
|
+
}
|
|
32
|
+
return 1;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Canonical string representation for interpolation (References §3.5.1).
|
|
36
|
+
* `Number.prototype.toString` already picks the correct fixed-vs-exponential
|
|
37
|
+
* form and digit sequence (ECMAScript is the normative algorithm for both
|
|
38
|
+
* the int and the float rule, within Lima's supported ranges) — this only
|
|
39
|
+
* applies the lexical cleanup the spec requires on top: lowercase `e`, no
|
|
40
|
+
* `+` after `e`, no leading zeros in the exponent.
|
|
41
|
+
*/
|
|
42
|
+
export const canonicalString = (v) => {
|
|
43
|
+
switch (v.kind) {
|
|
44
|
+
case 'null': return '';
|
|
45
|
+
case 'bool': return v.value ? 'true' : 'false';
|
|
46
|
+
case 'instant': return v.value.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
47
|
+
case 'string': return v.value;
|
|
48
|
+
case 'int':
|
|
49
|
+
case 'float': {
|
|
50
|
+
const s = String(v.value);
|
|
51
|
+
return s.includes('e') || s.includes('E')
|
|
52
|
+
? s.replace(/[eE]\+?(-?)0*(\d+)/, 'e$1$2')
|
|
53
|
+
: s;
|
|
54
|
+
}
|
|
55
|
+
default: return '';
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
// ─── Partial ingestion: host JS values → LimaValue ────────────────────────
|
|
59
|
+
/** References §6.2 partial resource limits. */
|
|
60
|
+
export const PARTIAL_KEY_LENGTH_LIMIT = 128;
|
|
61
|
+
export const PARTIAL_VALUE_DEPTH_LIMIT = 16;
|
|
62
|
+
export const PARTIAL_COUNT_LIMIT = 128;
|
|
63
|
+
export const PARTIAL_NAME_LENGTH_LIMIT = 128;
|
|
64
|
+
export const PARTIAL_NODE_LIMIT = 4096;
|
|
65
|
+
export const RESULT_NODE_LIMIT = 65536;
|
|
66
|
+
export const SCALAR_LENGTH_LIMIT = 16384;
|
|
67
|
+
/**
|
|
68
|
+
* Counts Unicode code points (what every length-limit check in this
|
|
69
|
+
* codebase means by "code points", never UTF-16 code units) without
|
|
70
|
+
* `[...s].length`'s unconditional cost: spreading a string allocates an
|
|
71
|
+
* array of every character just to read its length, ~15x slower than
|
|
72
|
+
* `.length` for a typical short ASCII/BMP string (measured). A surrogate
|
|
73
|
+
* pair (the only case where `.length` and code-point count differ) can
|
|
74
|
+
* only occur for code points above U+FFFF — astral-plane characters like
|
|
75
|
+
* emoji, vanishingly rare in real frontmatter — so scanning for one first
|
|
76
|
+
* and falling back to the exact spread-based count only then keeps the
|
|
77
|
+
* common case (ASCII titles, but also accented Latin/Cyrillic/CJK BMP
|
|
78
|
+
* text, none of which need surrogate pairs) on the fast, allocation-free
|
|
79
|
+
* path.
|
|
80
|
+
*/
|
|
81
|
+
export const codepointLength = (s) => {
|
|
82
|
+
for (let i = 0; i < s.length; i++) {
|
|
83
|
+
const c = s.charCodeAt(i);
|
|
84
|
+
if (c >= 0xd800 && c <= 0xdfff)
|
|
85
|
+
return [...s].length;
|
|
86
|
+
}
|
|
87
|
+
return s.length;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Converts and validates a single host value against the Lima Value Model
|
|
91
|
+
* (References §6.2), recursively. `seen` tracks the current recursion path
|
|
92
|
+
* (not every visited object) to detect genuine cycles without rejecting
|
|
93
|
+
* shared, non-cyclic substructure.
|
|
94
|
+
*
|
|
95
|
+
* Host types with no Lima equivalent (functions, symbols, class instances,
|
|
96
|
+
* accessor properties) are rejected outright rather than silently coerced.
|
|
97
|
+
* Negative zero is normalised to positive zero; Date milliseconds are
|
|
98
|
+
* truncated (not rounded) to zero — both per References §6.2.
|
|
99
|
+
*
|
|
100
|
+
* This is the ONLY boundary in the whole implementation that touches JS
|
|
101
|
+
* host-value reflection (`Object.getPrototypeOf`, `getOwnPropertyDescriptor`)
|
|
102
|
+
* — a Rust port has no equivalent step at all, since its partials API takes
|
|
103
|
+
* a native Rust type up front.
|
|
104
|
+
*/
|
|
105
|
+
export const ingestPartialValue = (value, partialName, path, depth = 0, seen = new Set()) => {
|
|
106
|
+
const invalid = (reason) => {
|
|
107
|
+
throw new LimaError({
|
|
108
|
+
code: 'INVALID_PARTIAL', partial: partialName, path,
|
|
109
|
+
message: `LIMA: invalid partial "${partialName}" at path "${path}": ${reason}`,
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
if (value === null)
|
|
113
|
+
return LNull;
|
|
114
|
+
if (typeof value === 'boolean')
|
|
115
|
+
return LBool(value);
|
|
116
|
+
if (typeof value === 'number') {
|
|
117
|
+
if (!Number.isFinite(value))
|
|
118
|
+
invalid('non-finite number');
|
|
119
|
+
return LFloat(value);
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === 'string') {
|
|
122
|
+
if (codepointLength(value) > SCALAR_LENGTH_LIMIT) {
|
|
123
|
+
invalid(`string exceeds maximum length of ${SCALAR_LENGTH_LIMIT} code points`);
|
|
124
|
+
}
|
|
125
|
+
return LString(value);
|
|
126
|
+
}
|
|
127
|
+
if (value instanceof Date) {
|
|
128
|
+
if (isNaN(value.getTime()))
|
|
129
|
+
invalid('invalid date');
|
|
130
|
+
const year = value.getUTCFullYear();
|
|
131
|
+
if (year < 1 || year > 9999)
|
|
132
|
+
invalid(`date year ${year} outside the range 0001-9999`);
|
|
133
|
+
return LInstant(new Date(Math.floor(value.getTime() / 1000) * 1000));
|
|
134
|
+
}
|
|
135
|
+
if (value === undefined || typeof value !== 'object') {
|
|
136
|
+
invalid('unsupported value type'); // undefined, function, symbol, bigint, ...
|
|
137
|
+
}
|
|
138
|
+
if (seen.has(value))
|
|
139
|
+
invalid('cyclic reference');
|
|
140
|
+
if (depth >= PARTIAL_VALUE_DEPTH_LIMIT) {
|
|
141
|
+
invalid(`nesting depth exceeds maximum of ${PARTIAL_VALUE_DEPTH_LIMIT}`);
|
|
142
|
+
}
|
|
143
|
+
seen.add(value);
|
|
144
|
+
let result;
|
|
145
|
+
if (Array.isArray(value)) {
|
|
146
|
+
result = LArray(value.map((item, i) => {
|
|
147
|
+
if (Array.isArray(item)) {
|
|
148
|
+
throw new LimaError({
|
|
149
|
+
code: 'INVALID_PARTIAL', partial: partialName, path: `${path}[${i}]`,
|
|
150
|
+
message: `LIMA: invalid partial "${partialName}" at path "${path}[${i}]": nested arrays are not supported`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return ingestPartialValue(item, partialName, `${path}[${i}]`, depth + 1, seen);
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Plain-object check: `Object.keys()` alone can't distinguish a class
|
|
158
|
+
// instance's own data properties from a genuine plain mapping — a
|
|
159
|
+
// prototype check can. `Object.create(null)` (proto === null) is also
|
|
160
|
+
// accepted.
|
|
161
|
+
const proto = Object.getPrototypeOf(value);
|
|
162
|
+
if (proto !== null && proto !== Object.prototype)
|
|
163
|
+
invalid('unsupported value type');
|
|
164
|
+
const entries = new Map();
|
|
165
|
+
for (const key of Object.keys(value)) {
|
|
166
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
167
|
+
if (!descriptor || !('value' in descriptor)) {
|
|
168
|
+
throw new LimaError({
|
|
169
|
+
code: 'INVALID_PARTIAL', partial: partialName, path: `${path}.${key}`,
|
|
170
|
+
message: `LIMA: invalid partial "${partialName}" at path "${path}.${key}": accessor properties are not supported`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (codepointLength(key) > PARTIAL_KEY_LENGTH_LIMIT) {
|
|
174
|
+
throw new LimaError({
|
|
175
|
+
code: 'INVALID_PARTIAL', partial: partialName, path: `${path}.${key}`,
|
|
176
|
+
message: `LIMA: invalid partial "${partialName}" at path "${path}.${key}": key exceeds maximum length of ${PARTIAL_KEY_LENGTH_LIMIT} code points`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
entries.set(key, ingestPartialValue(value[key], partialName, `${path}.${key}`, depth + 1, seen));
|
|
180
|
+
}
|
|
181
|
+
result = LMapping(entries);
|
|
182
|
+
}
|
|
183
|
+
seen.delete(value);
|
|
184
|
+
return result;
|
|
185
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@limaformat/lima",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lima frontmatter parser — LIMA Is Metadata Annotation",
|
|
5
|
+
"keywords": ["frontmatter", "parser", "yaml", "markdown", "metadata"],
|
|
6
|
+
"homepage": "https://limaformat.dev",
|
|
7
|
+
"author": "Michael Kortstiege",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/limaformat/lima",
|
|
11
|
+
"directory": "js"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/limaformat/lima/issues",
|
|
14
|
+
"license": "ISC",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": ["dist", "README.md", "LICENSE", "CHANGELOG.md"],
|
|
23
|
+
"engines": { "node": ">=18" },
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"pretest": "bun run build",
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"prepublishOnly": "bun run typecheck && bun test",
|
|
30
|
+
"bench": "bun run bench/index.ts",
|
|
31
|
+
"bench:vs-yaml": "bun run bench/vs-yaml.ts",
|
|
32
|
+
"bench:vs-yaml:node": "node bench/vs-yaml-node.mjs",
|
|
33
|
+
"bench:differential": "bun run bench/differential.ts"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/bun": "^1.3.14",
|
|
37
|
+
"@types/js-yaml": "^4.0.9",
|
|
38
|
+
"js-yaml": "^5.2.3",
|
|
39
|
+
"typescript": "^7.0.2"
|
|
40
|
+
}
|
|
41
|
+
}
|