@radicool/throughline 0.15.0 → 0.17.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/README.md +2 -1
- package/adapters/codex/prompts/token-crosswalk-builder.md +2 -0
- package/adapters/codex/prompts/token-sync-layer.md +64 -4
- package/adapters/cursor/.cursor/rules/token-crosswalk-builder.mdc +2 -0
- package/adapters/cursor/.cursor/rules/token-sync-layer.mdc +64 -4
- package/adapters/generic/skills/token-crosswalk-builder/SKILL.md +2 -0
- package/adapters/generic/skills/token-sync-layer/SKILL.md +64 -4
- package/package.json +1 -1
- package/references/manifest-schema.md +2 -2
- package/references/native-adapter-config.md +996 -0
- package/references/sync-adapters.md +104 -12
- package/scripts/README.md +14 -3
- package/scripts/build-native-adapter-config.mjs +284 -0
- package/scripts/install.mjs +3 -2
- package/scripts/lib/dtcg.mjs +203 -0
- package/scripts/lib/native-literal.mjs +205 -0
- package/scripts/lib/sd-native.mjs +832 -0
- package/scripts/validate-crosswalk.mjs +3 -29
- package/scripts/validate-token-output.mjs +376 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Shared DTCG reading: flatten a token tree to dot-paths, resolve {alias} chains.
|
|
2
|
+
// Zero dependencies. Consumed by validate-crosswalk.mjs and validate-token-output.mjs,
|
|
3
|
+
// and copied alongside both when a skill installs either gate.
|
|
4
|
+
|
|
5
|
+
const REF = /^\{([^}]+)\}$/;
|
|
6
|
+
|
|
7
|
+
// The typographic member names DTCG §9.8 fixes at MUST level, the unit gate a
|
|
8
|
+
// text-role dimension must pass, and this project's $extensions namespace.
|
|
9
|
+
//
|
|
10
|
+
// They live here rather than in sd-native.mjs because textRoleGraph below and
|
|
11
|
+
// sd-native.mjs's preprocess apply the identical rules, and sd-native.mjs
|
|
12
|
+
// already imports this file — so the reverse import would be a cycle. Their
|
|
13
|
+
// full rationale stays at the point of use in sd-native.mjs, which is what the
|
|
14
|
+
// generated references/native-adapter-config.md renders.
|
|
15
|
+
export const TEXT_UNIT_NAMES = new Set(['fontSize', 'letterSpacing', 'lineHeight']);
|
|
16
|
+
export const TEXT_ROLE_UNIT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/;
|
|
17
|
+
export const EXT_NS = 'com.radicool.throughline';
|
|
18
|
+
|
|
19
|
+
// Flatten nested DTCG groups into { "dot.path": rawValue }. Skips $-prefixed meta keys.
|
|
20
|
+
//
|
|
21
|
+
// A node carrying BOTH a $value and children yields its own value AND is descended
|
|
22
|
+
// into — the dual-node pattern, where `text.sm` has `$value: "14px"` plus a
|
|
23
|
+
// `text.sm.lineHeight` child. Stopping at the first $value drops those children,
|
|
24
|
+
// which makes every alias to one unresolvable: the crosswalk gate reported them as
|
|
25
|
+
// "missing from the DTCG source" though they exist, and the output validator could
|
|
26
|
+
// not check them at all.
|
|
27
|
+
export function flattenDtcg(obj, prefix = [], out = {}) {
|
|
28
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
29
|
+
if (key.startsWith('$')) continue;
|
|
30
|
+
if (!val || typeof val !== 'object') continue;
|
|
31
|
+
const path = [...prefix, key];
|
|
32
|
+
if ('$value' in val) out[path.join('.')] = val.$value;
|
|
33
|
+
flattenDtcg(val, path, out);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Flatten nested DTCG groups into { "dot.path": effectiveType }, applying the
|
|
39
|
+
// $type resolution of DTCG 5.2.2: a token's own $type wins, otherwise the
|
|
40
|
+
// nearest ancestor GROUP's. A node carrying a $value is a token, not a group
|
|
41
|
+
// (DTCG 6.1), so it is not an inheritance source for its children — the same
|
|
42
|
+
// rule hoistDualNodes computes as `inherited`, and the two must agree or two
|
|
43
|
+
// functions in this codebase disagree about the type of the same tree.
|
|
44
|
+
//
|
|
45
|
+
// Separate from flattenDtcg rather than folded into it: that function has four
|
|
46
|
+
// consumers and both validators re-export it, so its return shape is fixed.
|
|
47
|
+
//
|
|
48
|
+
// LIMIT, stated rather than hidden: this reads the RAW source, so it cannot see
|
|
49
|
+
// the $type carry hoistDualNodes applies during preprocessing. An untyped child
|
|
50
|
+
// of a dimension-typed dual node with no enclosing group type is a dimension to
|
|
51
|
+
// the pipeline and undefined here. Reference-derived typing (5.2.2 rule 1) is
|
|
52
|
+
// likewise not resolved — an alias is undefined, but its referent is typed, and
|
|
53
|
+
// the referent is the token an author edits.
|
|
54
|
+
export function flattenDtcgTypes(obj, prefix = [], out = {}, groupType = undefined) {
|
|
55
|
+
const inherited = '$value' in obj ? groupType : (obj.$type ?? groupType);
|
|
56
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
57
|
+
if (key.startsWith('$')) continue;
|
|
58
|
+
if (!val || typeof val !== 'object') continue;
|
|
59
|
+
const path = [...prefix, key];
|
|
60
|
+
if ('$value' in val) out[path.join('.')] = val.$type ?? inherited;
|
|
61
|
+
flattenDtcgTypes(val, path, out, inherited);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Follow {alias} chains to a leaf literal. Throws on missing or circular refs.
|
|
67
|
+
export function resolveValue(name, flat, seen = new Set()) {
|
|
68
|
+
if (!(name in flat)) throw new Error(`token "${name}" not found in DTCG source`);
|
|
69
|
+
const val = flat[name];
|
|
70
|
+
if (typeof val === 'string') {
|
|
71
|
+
const m = val.match(REF);
|
|
72
|
+
if (m) {
|
|
73
|
+
if (seen.has(name)) throw new Error(`circular reference at "${name}"`);
|
|
74
|
+
seen.add(name);
|
|
75
|
+
return resolveValue(m[1], flat, seen);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return val;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A token path defined in more than one source file with differing values means
|
|
82
|
+
// the build's source list spans modes. Style Dictionary dedupes these silently,
|
|
83
|
+
// dropping one whole mode — 864 such collisions produced a light-only build from
|
|
84
|
+
// a dark-default system.
|
|
85
|
+
export function findModeCollisions(sources) {
|
|
86
|
+
const seen = new Map();
|
|
87
|
+
for (const { file, dtcg } of sources) {
|
|
88
|
+
for (const [path, value] of Object.entries(flattenDtcg(dtcg))) {
|
|
89
|
+
if (!seen.has(path)) seen.set(path, []);
|
|
90
|
+
seen.get(path).push({ file, value });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const collisions = [];
|
|
94
|
+
for (const [path, defs] of seen) {
|
|
95
|
+
const distinct = new Set(defs.map((d) => JSON.stringify(d.value)));
|
|
96
|
+
if (defs.length > 1 && distinct.size > 1) collisions.push({ path, defs });
|
|
97
|
+
}
|
|
98
|
+
return collisions;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Deep merge in list order, later source winning — the same later-wins rule
|
|
102
|
+
// validate-token-output.mjs already applies when it flattens a source list, and
|
|
103
|
+
// what Style Dictionary hands preprocess as one dict.
|
|
104
|
+
//
|
|
105
|
+
// Each source is cloned on the way in. Merging the caller's own objects would
|
|
106
|
+
// mutate the token trees it still holds, and the validator reads them again.
|
|
107
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
108
|
+
|
|
109
|
+
function mergeInto(target, src) {
|
|
110
|
+
for (const [key, val] of Object.entries(src)) {
|
|
111
|
+
if (isPlainObject(val) && isPlainObject(target[key])) mergeInto(target[key], val);
|
|
112
|
+
else target[key] = val;
|
|
113
|
+
}
|
|
114
|
+
return target;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function mergeDtcg(dicts) {
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const dict of dicts) mergeInto(out, structuredClone(dict));
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A dimension primitive states no typographic role: text.base: "16px" is a font
|
|
124
|
+
// size only to a human, so it emitted as dp, and an em letterSpacing primitive
|
|
125
|
+
// was dropped from native output entirely. #51 sources the role from the member
|
|
126
|
+
// names DTCG §9.8 fixes, which reaches the semantic tokens and not the
|
|
127
|
+
// primitives they reference. This reaches the primitives, structurally.
|
|
128
|
+
//
|
|
129
|
+
// Reads the UNRESOLVED tree: preprocess resolves aliases in place, so by
|
|
130
|
+
// transform time the graph is gone. Nothing needs carrying, because the
|
|
131
|
+
// inference is applied during preprocessing and only the $extensions stamp
|
|
132
|
+
// survives — see sd-native.mjs's applyTextRoleGraph.
|
|
133
|
+
//
|
|
134
|
+
// A referrer whose leaf name is NOT typographic is counter-evidence, not
|
|
135
|
+
// neutral. A dimension referenced by something that is not a typographic member
|
|
136
|
+
// is a length, which is exactly what the dp default already asserts about it.
|
|
137
|
+
// Treating it as neutral would let one stray fontSize reference convert a whole
|
|
138
|
+
// spacing ramp.
|
|
139
|
+
//
|
|
140
|
+
// Single-pass and deliberately not transitive: a chain through an intermediate
|
|
141
|
+
// whose own leaf name states no role is declined at the second hop, because
|
|
142
|
+
// that intermediate is itself counter-evidence. Only whole-value references
|
|
143
|
+
// count; a reference embedded in an expression is resolved by resolveInPlace
|
|
144
|
+
// but is not evidence of a role.
|
|
145
|
+
export function textRoleGraph(dict) {
|
|
146
|
+
const edges = [];
|
|
147
|
+
(function walk(node, prefix) {
|
|
148
|
+
for (const [key, val] of Object.entries(node)) {
|
|
149
|
+
if (key.startsWith('$') || !isPlainObject(val)) continue;
|
|
150
|
+
const path = [...prefix, key];
|
|
151
|
+
if (typeof val.$value === 'string') {
|
|
152
|
+
const m = REF.exec(val.$value.trim());
|
|
153
|
+
if (m) edges.push({ to: m[1], leaf: key });
|
|
154
|
+
}
|
|
155
|
+
walk(val, path);
|
|
156
|
+
}
|
|
157
|
+
})(dict, []);
|
|
158
|
+
|
|
159
|
+
const referrers = new Map();
|
|
160
|
+
for (const edge of edges) {
|
|
161
|
+
if (!referrers.has(edge.to)) referrers.set(edge.to, []);
|
|
162
|
+
referrers.get(edge.to).push(edge);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const typographic = new Set();
|
|
166
|
+
const ambiguous = [];
|
|
167
|
+
for (const [path, rs] of referrers) {
|
|
168
|
+
const textLeaves = [...new Set(rs.filter((r) => TEXT_UNIT_NAMES.has(r.leaf)).map((r) => r.leaf))];
|
|
169
|
+
if (textLeaves.length === 0) continue;
|
|
170
|
+
const otherLeaves = [...new Set(rs.filter((r) => !TEXT_UNIT_NAMES.has(r.leaf)).map((r) => r.leaf))];
|
|
171
|
+
if (otherLeaves.length) ambiguous.push({ path, textLeaves, otherLeaves });
|
|
172
|
+
else typographic.add(path);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// A primitive nothing references has no structural signal at all, so it is
|
|
176
|
+
// never inferred. Reported instead, where its group holds one that was: that
|
|
177
|
+
// is the strongest hint available without guessing, and a silent gap is the
|
|
178
|
+
// failure this module exists to prevent. A token whose source already stamps
|
|
179
|
+
// nativeUnit is closed and is not reported.
|
|
180
|
+
const inferredGroups = new Set([...typographic].map((p) => p.split('.').slice(0, -1).join('.')));
|
|
181
|
+
const unreferencedSiblings = [];
|
|
182
|
+
(function walk(node, prefix) {
|
|
183
|
+
for (const [key, val] of Object.entries(node)) {
|
|
184
|
+
if (key.startsWith('$') || !isPlainObject(val)) continue;
|
|
185
|
+
const path = [...prefix, key];
|
|
186
|
+
const dotted = path.join('.');
|
|
187
|
+
const group = prefix.join('.');
|
|
188
|
+
if (
|
|
189
|
+
'$value' in val &&
|
|
190
|
+
val.$type === 'dimension' &&
|
|
191
|
+
TEXT_ROLE_UNIT.test(String(val.$value).trim()) &&
|
|
192
|
+
!referrers.has(dotted) &&
|
|
193
|
+
!('nativeUnit' in (val.$extensions?.[EXT_NS] ?? {})) &&
|
|
194
|
+
inferredGroups.has(group)
|
|
195
|
+
) {
|
|
196
|
+
unreferencedSiblings.push({ path: dotted, group });
|
|
197
|
+
}
|
|
198
|
+
walk(val, path);
|
|
199
|
+
}
|
|
200
|
+
})(dict, []);
|
|
201
|
+
|
|
202
|
+
return { typographic, ambiguous, unreferencedSiblings };
|
|
203
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Is an emitted value a well-formed Swift or Kotlin literal?
|
|
2
|
+
//
|
|
3
|
+
// A CSS function and a native call expression look alike until you check the
|
|
4
|
+
// callee. `linear-gradient` is not a valid identifier — the hyphen disqualifies
|
|
5
|
+
// it — while `UIColor` is. `calc` IS a valid identifier, but `1rem + 2px` is
|
|
6
|
+
// not a literal. Parsing rather than pattern-matching rejects all of them
|
|
7
|
+
// without naming any of them, which is the point: the next unanticipated case
|
|
8
|
+
// is caught by the same rule.
|
|
9
|
+
//
|
|
10
|
+
// Two consumers — sd-native.mjs's output filter, and
|
|
11
|
+
// validate-token-output.mjs's invalid-literal rule. Its own module for the same
|
|
12
|
+
// reason lib/dtcg.mjs is one: shared by both token gates.
|
|
13
|
+
//
|
|
14
|
+
// This asserts LITERAL well-formedness, not that the file compiles. A call to
|
|
15
|
+
// an undefined function still parses.
|
|
16
|
+
|
|
17
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*/;
|
|
18
|
+
|
|
19
|
+
// Swift and Kotlin disagree about numeric literals in OPPOSITE directions, so
|
|
20
|
+
// one shared rule necessarily over-accepts on one platform or false-fails on
|
|
21
|
+
// the other. Both sides measured rather than assumed:
|
|
22
|
+
//
|
|
23
|
+
// Swift, via `swiftc -parse`: `.5` and `-.5` are rejected — the compiler says
|
|
24
|
+
// "it must be written '0.5'" — while `0100` and `00` compile.
|
|
25
|
+
//
|
|
26
|
+
// Kotlin, via `kotlinc` 2.4.10: `0100` and `00` are rejected outright —
|
|
27
|
+
// "leading zeros are not allowed in integer literals" — while `.5` and `-.5`
|
|
28
|
+
// compile, which is why they stay accepted here. This matches the spec's
|
|
29
|
+
// lexical grammar exactly: IntegerLiteral is
|
|
30
|
+
// `DecDigitNoZero {DecDigitOrSeparator} DecDigit | DecDigit`, and
|
|
31
|
+
// DoubleLiteral is `[DecDigits] '.' DecDigits [DoubleExponent]`.
|
|
32
|
+
//
|
|
33
|
+
// Hex compiles on both and stays. A leading-zero integer is caught by the
|
|
34
|
+
// trailing-input rule at the end of parseLiteral rather than by the regex:
|
|
35
|
+
// `0100` matches only `0`, leaving `100` unconsumed.
|
|
36
|
+
const NUMBER_SWIFT = /^-?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?)/;
|
|
37
|
+
const NUMBER_KOTLIN = /^-?(?:0[xX][0-9a-fA-F]+|(?:0|[1-9]\d*)(?:\.\d+)?|\.\d+)/;
|
|
38
|
+
|
|
39
|
+
// The union of both, for a caller that names no platform. Every real consumer
|
|
40
|
+
// passes a GRAMMAR entry, which overrides this.
|
|
41
|
+
const NUMBER = /^-?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?|\.\d+)/;
|
|
42
|
+
|
|
43
|
+
// `escapes` are the characters legal after a backslash inside a string.
|
|
44
|
+
// \$ escapes Kotlin's template interpolation; it is not a valid Swift escape,
|
|
45
|
+
// so a shared set would over-accept on iOS.
|
|
46
|
+
export const GRAMMAR = {
|
|
47
|
+
'ios-swift': {
|
|
48
|
+
number: NUMBER_SWIFT,
|
|
49
|
+
suffixes: [],
|
|
50
|
+
units: [],
|
|
51
|
+
escapes: ['0', '\\', 't', 'n', 'r', '"', "'", 'u'],
|
|
52
|
+
},
|
|
53
|
+
'android-kotlin': {
|
|
54
|
+
number: NUMBER_KOTLIN,
|
|
55
|
+
suffixes: ['f', 'F', 'L'],
|
|
56
|
+
units: ['dp', 'sp', 'em'],
|
|
57
|
+
escapes: ['\\', 't', 'n', 'r', '"', "'", '$', 'u'],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// CSS constructs that validate-token-output.mjs diagnoses BY NAME.
|
|
62
|
+
//
|
|
63
|
+
// These are unimplemented rescues, not values with no native form: `calc` and
|
|
64
|
+
// `var` are valid identifiers, and `color-mix` has a rescue in sd-native.mjs
|
|
65
|
+
// that merely did not match this variant. So they must reach the output and
|
|
66
|
+
// fail loudly under no-foreign-syntax, never be silently dropped by a filter.
|
|
67
|
+
// Kept here, beside the grammar, so the build and the gate cannot drift apart.
|
|
68
|
+
export const CSS_CONSTRUCT = /^(?:color-mix|calc|var)\s*\(/;
|
|
69
|
+
|
|
70
|
+
export function parseLiteral(value, grammar = {}) {
|
|
71
|
+
const s = String(value);
|
|
72
|
+
const suffixes = grammar.suffixes ?? [];
|
|
73
|
+
const units = grammar.units ?? [];
|
|
74
|
+
const escapes = new Set(grammar.escapes ?? []);
|
|
75
|
+
const numberRe = grammar.number ?? NUMBER;
|
|
76
|
+
let i = 0;
|
|
77
|
+
|
|
78
|
+
const ws = () => {
|
|
79
|
+
while (i < s.length && /\s/.test(s[i])) i += 1;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Longest match wins, so a short unit cannot shadow a longer one.
|
|
83
|
+
const take = (words) => {
|
|
84
|
+
let best = null;
|
|
85
|
+
for (const w of words) {
|
|
86
|
+
if (s.startsWith(w, i) && (best === null || w.length > best.length)) best = w;
|
|
87
|
+
}
|
|
88
|
+
if (best !== null) i += best.length;
|
|
89
|
+
return best !== null;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const string = () => {
|
|
93
|
+
i += 1; // opening quote
|
|
94
|
+
while (i < s.length) {
|
|
95
|
+
if (s[i] === '\\') {
|
|
96
|
+
if (!escapes.has(s[i + 1])) return false;
|
|
97
|
+
i += 2;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (s[i] === '"') {
|
|
101
|
+
i += 1;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
if (s[i] === '\n') return false;
|
|
105
|
+
i += 1;
|
|
106
|
+
}
|
|
107
|
+
return false; // unterminated
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const number = () => {
|
|
111
|
+
const m = s.slice(i).match(numberRe);
|
|
112
|
+
if (!m) return false;
|
|
113
|
+
i += m[0].length;
|
|
114
|
+
if (take(units.map((u) => `.${u}`))) return true;
|
|
115
|
+
take(suffixes);
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const literal = () => {
|
|
120
|
+
ws();
|
|
121
|
+
if (i >= s.length) return false;
|
|
122
|
+
if (s[i] === '"') return string();
|
|
123
|
+
|
|
124
|
+
// A parenthesised NUMBER, optionally with a unit. Compose letter spacing
|
|
125
|
+
// needs `(-0.03).em`, because `-0.03.em` parses as `-(0.03.em)` and
|
|
126
|
+
// kotlinc rejects it with "unresolved reference 'unaryMinus'" unless
|
|
127
|
+
// TextUnit defines that operator. The parenthesised form compiles either
|
|
128
|
+
// way, so it is what the transform emits.
|
|
129
|
+
//
|
|
130
|
+
// Deliberately not expression support: only a single number may sit inside
|
|
131
|
+
// the parens. Accepting `(1 + 2)` would make this gate vouch for
|
|
132
|
+
// arithmetic it cannot evaluate, which is the failure class the module
|
|
133
|
+
// exists to prevent.
|
|
134
|
+
if (s[i] === '(') {
|
|
135
|
+
const save = i;
|
|
136
|
+
i += 1;
|
|
137
|
+
ws();
|
|
138
|
+
const inner = s.slice(i).match(numberRe);
|
|
139
|
+
if (!inner) {
|
|
140
|
+
i = save;
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
i += inner[0].length;
|
|
144
|
+
ws();
|
|
145
|
+
if (s[i] !== ')') {
|
|
146
|
+
i = save;
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
i += 1;
|
|
150
|
+
if (take(units.map((u) => `.${u}`))) return true;
|
|
151
|
+
take(suffixes);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const rest = s.slice(i);
|
|
156
|
+
const bool = rest.match(/^(?:true|false)(?![A-Za-z0-9_])/);
|
|
157
|
+
if (bool) {
|
|
158
|
+
i += bool[0].length;
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (numberRe.test(rest)) return number();
|
|
162
|
+
|
|
163
|
+
const id = rest.match(IDENT);
|
|
164
|
+
if (!id) return false;
|
|
165
|
+
i += id[0].length;
|
|
166
|
+
ws();
|
|
167
|
+
if (s[i] !== '(') return false; // a bare identifier is not a literal
|
|
168
|
+
i += 1;
|
|
169
|
+
ws();
|
|
170
|
+
if (s[i] === ')') {
|
|
171
|
+
i += 1;
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
for (;;) {
|
|
175
|
+
ws();
|
|
176
|
+
const save = i;
|
|
177
|
+
const label = s.slice(i).match(IDENT);
|
|
178
|
+
if (label) {
|
|
179
|
+
i += label[0].length;
|
|
180
|
+
ws();
|
|
181
|
+
if (s[i] === ':') i += 1;
|
|
182
|
+
else i = save; // not a label after all; re-read as a literal
|
|
183
|
+
}
|
|
184
|
+
if (!literal()) return false;
|
|
185
|
+
ws();
|
|
186
|
+
if (s[i] === ',') {
|
|
187
|
+
i += 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (s[i] === ')') {
|
|
191
|
+
i += 1;
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (!literal()) return { ok: false, offset: i, rest: s.slice(i) };
|
|
199
|
+
ws();
|
|
200
|
+
// Trailing input after a complete literal is a failure: `400 garbage`.
|
|
201
|
+
if (i !== s.length) return { ok: false, offset: i, rest: s.slice(i) };
|
|
202
|
+
return { ok: true };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const isValidLiteral = (value, grammar) => parseLiteral(value, grammar).ok;
|