sdocs 0.0.40 → 0.0.42
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 +40 -0
- package/dist/explorer/tree-builder.js +2 -4
- package/dist/explorer/views/ComponentView.svelte +85 -29
- package/dist/explorer/views/LayoutView.svelte +1 -1
- package/dist/explorer/views/PageView.svelte +1 -1
- package/dist/grammar/sdoc.tmLanguage.json +245 -0
- package/dist/index.d.ts +1 -1
- package/dist/language/index.d.ts +2 -0
- package/dist/language/index.js +2 -0
- package/dist/language/parser.d.ts +82 -0
- package/dist/language/parser.js +293 -0
- package/dist/language/parser.test.d.ts +1 -0
- package/dist/language/parser.test.js +158 -0
- package/dist/language/scanner.d.ts +82 -0
- package/dist/language/scanner.js +529 -0
- package/dist/language/scanner.test.d.ts +1 -0
- package/dist/language/scanner.test.js +146 -0
- package/dist/server/app-gen.js +18 -21
- package/dist/server/discovery.d.ts +0 -2
- package/dist/server/discovery.js +0 -9
- package/dist/server/doc-model.d.ts +26 -0
- package/dist/server/doc-model.js +58 -0
- package/dist/server/page-markdown.d.ts +15 -0
- package/dist/server/page-markdown.js +104 -0
- package/dist/server/snippet-compiler.d.ts +18 -18
- package/dist/server/snippet-compiler.js +32 -29
- package/dist/types.d.ts +35 -19
- package/dist/vite.js +168 -99
- package/package.json +7 -1
- package/dist/server/meta-parser.d.ts +0 -11
- package/dist/server/meta-parser.js +0 -109
- package/dist/server/snippet-extractor.d.ts +0 -11
- package/dist/server/snippet-extractor.js +0 -83
- package/dist/server/toc-extractor.d.ts +0 -3
- package/dist/server/toc-extractor.js +0 -23
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic layer over the syntactic scanner: validates attributes against
|
|
3
|
+
* the sdoc language rules and produces typed entities ready for the Vite
|
|
4
|
+
* plugin, the CLI app-gen, and the editor tooling.
|
|
5
|
+
*/
|
|
6
|
+
import { scanSdoc, } from './scanner.js';
|
|
7
|
+
/**
|
|
8
|
+
* Normalize a raw block body for consumption: strip the common leading
|
|
9
|
+
* indentation (bodies sit one level deep inside their block), drop a
|
|
10
|
+
* trailing carriage return per line, and unescape lines that start with
|
|
11
|
+
* `\[` (the escape for body lines that would otherwise read as tags).
|
|
12
|
+
* The scanner keeps the raw text and spans; this is the display/runtime form.
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeBody(raw) {
|
|
15
|
+
const lines = raw.split('\n').map((line) => line.replace(/\r$/, ''));
|
|
16
|
+
let common = null;
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
if (line.trim() === '')
|
|
19
|
+
continue;
|
|
20
|
+
const indent = line.match(/^[ \t]*/)[0];
|
|
21
|
+
if (common === null) {
|
|
22
|
+
common = indent;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
let k = 0;
|
|
26
|
+
while (k < common.length && k < indent.length && common[k] === indent[k])
|
|
27
|
+
k++;
|
|
28
|
+
common = common.slice(0, k);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const cut = common?.length ?? 0;
|
|
32
|
+
return lines
|
|
33
|
+
.map((line) => (line.trim() === '' ? '' : line.slice(cut)))
|
|
34
|
+
.map((line) => (line.startsWith('\\[') ? line.slice(1) : line))
|
|
35
|
+
.join('\n')
|
|
36
|
+
.replace(/^\n+|\n+$/g, '');
|
|
37
|
+
}
|
|
38
|
+
/** Slug used for entity addressing: relPath + '#' + slug. */
|
|
39
|
+
export function slugifyTitle(title) {
|
|
40
|
+
return (title
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
43
|
+
.replace(/^-+|-+$/g, '') || 'untitled');
|
|
44
|
+
}
|
|
45
|
+
const IDENTIFIER_RE = /^[A-Z][A-Za-z0-9_]*$/;
|
|
46
|
+
const ENTITY_ATTR_RULES = {
|
|
47
|
+
DOCS: {
|
|
48
|
+
title: { required: true, kind: 'string', hint: 'title="Group / Name"' },
|
|
49
|
+
description: { required: false, kind: 'string', hint: 'description="…"' },
|
|
50
|
+
},
|
|
51
|
+
PAGE: {
|
|
52
|
+
title: { required: true, kind: 'string', hint: 'title="Group / Name"' },
|
|
53
|
+
},
|
|
54
|
+
LAYOUT: {
|
|
55
|
+
title: { required: true, kind: 'string', hint: 'title="Group / Name"' },
|
|
56
|
+
padding: { required: false, kind: 'string', hint: 'padding="48px"' },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const SUB_BLOCK_ATTR_RULES = {
|
|
60
|
+
preview: {
|
|
61
|
+
component: { required: true, kind: 'expression', hint: 'component={Button}' },
|
|
62
|
+
args: { required: false, kind: 'expression', hint: 'args={{ label: "Hi" }}' },
|
|
63
|
+
title: { required: false, kind: 'string', hint: 'title="…"' },
|
|
64
|
+
},
|
|
65
|
+
example: {
|
|
66
|
+
title: { required: true, kind: 'string', hint: 'title="…"' },
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
function checkAttrs(owner, attrs, rules, ownerSpan, diagnostics) {
|
|
70
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
71
|
+
const rule = rules[name];
|
|
72
|
+
if (!rule) {
|
|
73
|
+
diagnostics.push({
|
|
74
|
+
code: 'unknown-attr',
|
|
75
|
+
message: `Unknown attribute "${name}" on ${owner}. Allowed: ${Object.keys(rules).join(', ')}.`,
|
|
76
|
+
span: value.span,
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (value.kind !== rule.kind) {
|
|
81
|
+
diagnostics.push({
|
|
82
|
+
code: 'attr-value-kind',
|
|
83
|
+
message: `Attribute "${name}" on ${owner} must be written ${rule.hint}.`,
|
|
84
|
+
span: value.span,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const [name, rule] of Object.entries(rules)) {
|
|
89
|
+
if (rule.required && !attrs[name]) {
|
|
90
|
+
diagnostics.push({
|
|
91
|
+
code: 'missing-attr',
|
|
92
|
+
message: `${owner} requires ${rule.hint}.`,
|
|
93
|
+
span: ownerSpan,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function stringAttr(attrs, name) {
|
|
99
|
+
const v = attrs[name];
|
|
100
|
+
return v && v.kind === 'string' ? v.raw : null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Parse a preview args expression: a flat object literal whose values are
|
|
104
|
+
* plain literals (strings, numbers, booleans). Anything richer belongs in
|
|
105
|
+
* the block body, so it is rejected here.
|
|
106
|
+
*/
|
|
107
|
+
export function parseArgsLiteral(raw, span, diagnostics) {
|
|
108
|
+
const fail = (message) => {
|
|
109
|
+
diagnostics.push({ code: 'args-literal', message, span });
|
|
110
|
+
return null;
|
|
111
|
+
};
|
|
112
|
+
let i = 0;
|
|
113
|
+
const ws = () => {
|
|
114
|
+
while (i < raw.length && /\s/.test(raw[i]))
|
|
115
|
+
i++;
|
|
116
|
+
};
|
|
117
|
+
ws();
|
|
118
|
+
if (raw[i] !== '{')
|
|
119
|
+
return fail('args must be an object literal: args={{ name: value }}.');
|
|
120
|
+
i++;
|
|
121
|
+
const values = {};
|
|
122
|
+
ws();
|
|
123
|
+
while (i < raw.length && raw[i] !== '}') {
|
|
124
|
+
const keyMatch = raw.slice(i).match(/^([A-Za-z_$][A-Za-z0-9_$]*|'[^']*'|"[^"]*")\s*:/);
|
|
125
|
+
if (!keyMatch)
|
|
126
|
+
return fail('args keys must be plain names, like args={{ label: "Hi" }}.');
|
|
127
|
+
const key = keyMatch[1].replace(/^['"]|['"]$/g, '');
|
|
128
|
+
i += keyMatch[0].length;
|
|
129
|
+
ws();
|
|
130
|
+
const rest = raw.slice(i);
|
|
131
|
+
let m;
|
|
132
|
+
if ((m = rest.match(/^'((?:[^'\\]|\\.)*)'/)) || (m = rest.match(/^"((?:[^"\\]|\\.)*)"/))) {
|
|
133
|
+
values[key] = m[1].replace(/\\(.)/g, '$1');
|
|
134
|
+
i += m[0].length;
|
|
135
|
+
}
|
|
136
|
+
else if ((m = rest.match(/^-?\d+(\.\d+)?/))) {
|
|
137
|
+
values[key] = Number(m[0]);
|
|
138
|
+
i += m[0].length;
|
|
139
|
+
}
|
|
140
|
+
else if ((m = rest.match(/^(true|false)\b/))) {
|
|
141
|
+
values[key] = m[0] === 'true';
|
|
142
|
+
i += m[0].length;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
return fail(`args value for "${key}" must be a plain literal (string, number, or boolean); richer values go in the block body.`);
|
|
146
|
+
}
|
|
147
|
+
ws();
|
|
148
|
+
if (raw[i] === ',') {
|
|
149
|
+
i++;
|
|
150
|
+
ws();
|
|
151
|
+
}
|
|
152
|
+
else if (raw[i] !== '}') {
|
|
153
|
+
return fail('args entries must be separated by commas.');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (raw[i] !== '}')
|
|
157
|
+
return fail('args object literal is missing its closing "}".');
|
|
158
|
+
i++;
|
|
159
|
+
ws();
|
|
160
|
+
if (i !== raw.length)
|
|
161
|
+
return fail('Unexpected text after the args object literal.');
|
|
162
|
+
return values;
|
|
163
|
+
}
|
|
164
|
+
function parsePreview(block, diagnostics) {
|
|
165
|
+
checkAttrs('[preview]', block.attrs, SUB_BLOCK_ATTR_RULES.preview, block.openerSpan, diagnostics);
|
|
166
|
+
let componentName = null;
|
|
167
|
+
const component = block.attrs.component;
|
|
168
|
+
if (component && component.kind === 'expression') {
|
|
169
|
+
const name = component.raw.trim();
|
|
170
|
+
if (IDENTIFIER_RE.test(name)) {
|
|
171
|
+
componentName = name;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
diagnostics.push({
|
|
175
|
+
code: 'component-identifier',
|
|
176
|
+
message: 'component must be a component identifier imported in the file\'s <script>, like component={Button}.',
|
|
177
|
+
span: component.valueSpan,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
let args = null;
|
|
182
|
+
let argsRaw = null;
|
|
183
|
+
const argsAttr = block.attrs.args;
|
|
184
|
+
if (argsAttr && argsAttr.kind === 'expression') {
|
|
185
|
+
argsRaw = argsAttr.raw.trim();
|
|
186
|
+
args = parseArgsLiteral(argsAttr.raw, argsAttr.valueSpan, diagnostics);
|
|
187
|
+
}
|
|
188
|
+
const title = stringAttr(block.attrs, 'title');
|
|
189
|
+
return {
|
|
190
|
+
componentName,
|
|
191
|
+
args,
|
|
192
|
+
argsRaw,
|
|
193
|
+
title,
|
|
194
|
+
label: title ?? componentName ?? 'Preview',
|
|
195
|
+
body: normalizeBody(block.body),
|
|
196
|
+
bodySpan: block.bodySpan,
|
|
197
|
+
span: block.span,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function parseDocs(entity, diagnostics) {
|
|
201
|
+
checkAttrs('[DOCS]', entity.attrs, ENTITY_ATTR_RULES.DOCS, entity.openerSpan, diagnostics);
|
|
202
|
+
const previews = [];
|
|
203
|
+
const examples = [];
|
|
204
|
+
const exampleTitles = new Set();
|
|
205
|
+
const previewLabels = new Set();
|
|
206
|
+
for (const block of entity.blocks) {
|
|
207
|
+
if (block.kind === 'preview') {
|
|
208
|
+
const preview = parsePreview(block, diagnostics);
|
|
209
|
+
if (previewLabels.has(preview.label)) {
|
|
210
|
+
diagnostics.push({
|
|
211
|
+
code: 'duplicate-preview-label',
|
|
212
|
+
message: `Two previews are both labeled "${preview.label}" — give one a title="…".`,
|
|
213
|
+
span: block.openerSpan,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
previewLabels.add(preview.label);
|
|
217
|
+
previews.push(preview);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
checkAttrs('[example]', block.attrs, SUB_BLOCK_ATTR_RULES.example, block.openerSpan, diagnostics);
|
|
221
|
+
const title = stringAttr(block.attrs, 'title') ?? '';
|
|
222
|
+
if (title && exampleTitles.has(title)) {
|
|
223
|
+
diagnostics.push({
|
|
224
|
+
code: 'duplicate-example-title',
|
|
225
|
+
message: `Duplicate example title "${title}" — titles are unique within a [DOCS] block.`,
|
|
226
|
+
span: block.openerSpan,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
exampleTitles.add(title);
|
|
230
|
+
examples.push({
|
|
231
|
+
title,
|
|
232
|
+
body: normalizeBody(block.body),
|
|
233
|
+
bodySpan: block.bodySpan,
|
|
234
|
+
span: block.span,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const title = stringAttr(entity.attrs, 'title') ?? '';
|
|
239
|
+
return {
|
|
240
|
+
kind: 'DOCS',
|
|
241
|
+
title,
|
|
242
|
+
slug: slugifyTitle(title),
|
|
243
|
+
description: stringAttr(entity.attrs, 'description'),
|
|
244
|
+
previews,
|
|
245
|
+
examples,
|
|
246
|
+
openerSpan: entity.openerSpan,
|
|
247
|
+
span: entity.span,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
export function parseSdoc(source) {
|
|
251
|
+
const scanned = scanSdoc(source);
|
|
252
|
+
const diagnostics = [...scanned.errors];
|
|
253
|
+
const entities = [];
|
|
254
|
+
const slugs = new Set();
|
|
255
|
+
for (const entity of scanned.entities) {
|
|
256
|
+
let typed;
|
|
257
|
+
if (entity.kind === 'DOCS') {
|
|
258
|
+
typed = parseDocs(entity, diagnostics);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
checkAttrs(`[${entity.kind}]`, entity.attrs, ENTITY_ATTR_RULES[entity.kind], entity.openerSpan, diagnostics);
|
|
262
|
+
const title = stringAttr(entity.attrs, 'title') ?? '';
|
|
263
|
+
const base = {
|
|
264
|
+
title,
|
|
265
|
+
slug: slugifyTitle(title),
|
|
266
|
+
body: normalizeBody(entity.body),
|
|
267
|
+
bodySpan: entity.bodySpan,
|
|
268
|
+
openerSpan: entity.openerSpan,
|
|
269
|
+
span: entity.span,
|
|
270
|
+
};
|
|
271
|
+
typed =
|
|
272
|
+
entity.kind === 'PAGE'
|
|
273
|
+
? { kind: 'PAGE', ...base }
|
|
274
|
+
: { kind: 'LAYOUT', padding: stringAttr(entity.attrs, 'padding'), ...base };
|
|
275
|
+
}
|
|
276
|
+
if (slugs.has(typed.slug)) {
|
|
277
|
+
diagnostics.push({
|
|
278
|
+
code: 'duplicate-entity-title',
|
|
279
|
+
message: `Two entities in this file resolve to the same address ("${typed.slug}") — make their titles distinct.`,
|
|
280
|
+
span: typed.openerSpan,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
slugs.add(typed.slug);
|
|
284
|
+
entities.push(typed);
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
script: scanned.script,
|
|
288
|
+
style: scanned.style,
|
|
289
|
+
entities,
|
|
290
|
+
diagnostics,
|
|
291
|
+
source,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseSdoc, parseArgsLiteral, slugifyTitle, normalizeBody, } from './parser.js';
|
|
3
|
+
function diagnosticCodes(source) {
|
|
4
|
+
return parseSdoc(source).diagnostics.map((d) => d.code);
|
|
5
|
+
}
|
|
6
|
+
describe('parseSdoc typed entities', () => {
|
|
7
|
+
const source = `<script>
|
|
8
|
+
import Tabs from './Tabs.svelte';
|
|
9
|
+
import Tab from './Tab.svelte';
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
[DOCS title="Navigation / Tabs" description="A tab bar."]
|
|
13
|
+
|
|
14
|
+
[preview component={Tabs} args={{ active: 0 }}]
|
|
15
|
+
<Tabs {...args}><Tab label="One">…</Tab></Tabs>
|
|
16
|
+
[/preview]
|
|
17
|
+
|
|
18
|
+
[preview component={Tab} args={{ label: 'One' }}]
|
|
19
|
+
<Tabs><Tab {...args}>…</Tab></Tabs>
|
|
20
|
+
[/preview]
|
|
21
|
+
|
|
22
|
+
[example title="Vertical"]
|
|
23
|
+
<Tabs vertical />
|
|
24
|
+
[/example]
|
|
25
|
+
|
|
26
|
+
[/DOCS]
|
|
27
|
+
`;
|
|
28
|
+
const doc = parseSdoc(source);
|
|
29
|
+
const docs = doc.entities[0];
|
|
30
|
+
it('parses without diagnostics', () => {
|
|
31
|
+
expect(doc.diagnostics).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
it('types the DOCS entity', () => {
|
|
34
|
+
expect(docs.kind).toBe('DOCS');
|
|
35
|
+
expect(docs.title).toBe('Navigation / Tabs');
|
|
36
|
+
expect(docs.slug).toBe('navigation-tabs');
|
|
37
|
+
expect(docs.description).toBe('A tab bar.');
|
|
38
|
+
});
|
|
39
|
+
it('collects previews with component names, own args, and labels', () => {
|
|
40
|
+
expect(docs.previews).toHaveLength(2);
|
|
41
|
+
expect(docs.previews[0]).toMatchObject({
|
|
42
|
+
componentName: 'Tabs',
|
|
43
|
+
args: { active: 0 },
|
|
44
|
+
label: 'Tabs',
|
|
45
|
+
});
|
|
46
|
+
expect(docs.previews[1]).toMatchObject({
|
|
47
|
+
componentName: 'Tab',
|
|
48
|
+
args: { label: 'One' },
|
|
49
|
+
label: 'Tab',
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it('collects examples', () => {
|
|
53
|
+
expect(docs.examples).toHaveLength(1);
|
|
54
|
+
expect(docs.examples[0].title).toBe('Vertical');
|
|
55
|
+
expect(docs.examples[0].body).toContain('<Tabs vertical />');
|
|
56
|
+
});
|
|
57
|
+
it('uses title= as the tab label override', () => {
|
|
58
|
+
const overridden = parseSdoc(`[DOCS title="X"]
|
|
59
|
+
[preview component={Button} title="As a link" args={{ a: 1 }}]
|
|
60
|
+
x
|
|
61
|
+
[/preview]
|
|
62
|
+
[preview component={Button}]
|
|
63
|
+
x
|
|
64
|
+
[/preview]
|
|
65
|
+
[/DOCS]
|
|
66
|
+
`);
|
|
67
|
+
const entity = overridden.entities[0];
|
|
68
|
+
expect(entity.previews.map((p) => p.label)).toEqual(['As a link', 'Button']);
|
|
69
|
+
expect(overridden.diagnostics).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('parseSdoc validation', () => {
|
|
73
|
+
it('requires entity titles', () => {
|
|
74
|
+
expect(diagnosticCodes('[DOCS]\n[/DOCS]\n')).toContain('missing-attr');
|
|
75
|
+
expect(diagnosticCodes('[PAGE]\nx\n[/PAGE]\n')).toContain('missing-attr');
|
|
76
|
+
});
|
|
77
|
+
it('requires component on previews and title on examples', () => {
|
|
78
|
+
expect(diagnosticCodes('[DOCS title="X"]\n[preview]\nx\n[/preview]\n[/DOCS]\n')).toContain('missing-attr');
|
|
79
|
+
expect(diagnosticCodes('[DOCS title="X"]\n[example]\nx\n[/example]\n[/DOCS]\n')).toContain('missing-attr');
|
|
80
|
+
});
|
|
81
|
+
it('rejects unknown attributes', () => {
|
|
82
|
+
expect(diagnosticCodes('[DOCS title="X" component={B}]\n[/DOCS]\n')).toContain('unknown-attr');
|
|
83
|
+
expect(diagnosticCodes('[PAGE title="X" padding="4px"]\nx\n[/PAGE]\n')).toContain('unknown-attr');
|
|
84
|
+
});
|
|
85
|
+
it('rejects wrong attribute value kinds', () => {
|
|
86
|
+
expect(diagnosticCodes('[DOCS title={x}]\n[/DOCS]\n')).toContain('attr-value-kind');
|
|
87
|
+
expect(diagnosticCodes('[DOCS title="X"]\n[preview component="Button"]\nx\n[/preview]\n[/DOCS]\n')).toContain('attr-value-kind');
|
|
88
|
+
});
|
|
89
|
+
it('rejects non-identifier component expressions', () => {
|
|
90
|
+
expect(diagnosticCodes('[DOCS title="X"]\n[preview component={Tabs.Tab}]\nx\n[/preview]\n[/DOCS]\n')).toContain('component-identifier');
|
|
91
|
+
});
|
|
92
|
+
it('rejects duplicate example titles and preview labels', () => {
|
|
93
|
+
expect(diagnosticCodes('[DOCS title="X"]\n[example title="A"]\nx\n[/example]\n[example title="A"]\ny\n[/example]\n[/DOCS]\n')).toContain('duplicate-example-title');
|
|
94
|
+
expect(diagnosticCodes('[DOCS title="X"]\n[preview component={B}]\nx\n[/preview]\n[preview component={B}]\ny\n[/preview]\n[/DOCS]\n')).toContain('duplicate-preview-label');
|
|
95
|
+
});
|
|
96
|
+
it('rejects colliding entity addresses in one file', () => {
|
|
97
|
+
expect(diagnosticCodes('[PAGE title="A B"]\nx\n[/PAGE]\n[PAGE title="A / B"]\ny\n[/PAGE]\n')).toContain('duplicate-entity-title');
|
|
98
|
+
});
|
|
99
|
+
it('accepts LAYOUT presentation attributes', () => {
|
|
100
|
+
const doc = parseSdoc('[LAYOUT title="Login" padding="48px"]\n<div />\n[/LAYOUT]\n');
|
|
101
|
+
expect(doc.diagnostics).toEqual([]);
|
|
102
|
+
expect(doc.entities[0]).toMatchObject({ kind: 'LAYOUT', padding: '48px' });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('parseArgsLiteral', () => {
|
|
106
|
+
function parse(raw) {
|
|
107
|
+
const diagnostics = [];
|
|
108
|
+
const values = parseArgsLiteral(raw, { start: 0, end: raw.length }, diagnostics);
|
|
109
|
+
return { values, messages: diagnostics.map((d) => d.message) };
|
|
110
|
+
}
|
|
111
|
+
it('parses flat literals of all three types', () => {
|
|
112
|
+
expect(parse(`{ label: 'Hi', size: 14, wide: true, off: false, neg: -0.5 }`).values).toEqual({
|
|
113
|
+
label: 'Hi',
|
|
114
|
+
size: 14,
|
|
115
|
+
wide: true,
|
|
116
|
+
off: false,
|
|
117
|
+
neg: -0.5,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
it('parses empty objects, quoted keys, and escaped strings', () => {
|
|
121
|
+
expect(parse('{}').values).toEqual({});
|
|
122
|
+
expect(parse(`{ "data-x": 'a', b: "it\\'s" }`).values).toEqual({ 'data-x': 'a', b: "it's" });
|
|
123
|
+
});
|
|
124
|
+
it('rejects nested values and identifiers with a pointer to the body', () => {
|
|
125
|
+
expect(parse(`{ items: [1, 2] }`).values).toBeNull();
|
|
126
|
+
expect(parse(`{ obj: { a: 1 } }`).values).toBeNull();
|
|
127
|
+
const { values, messages } = parse(`{ icon: MyIcon }`);
|
|
128
|
+
expect(values).toBeNull();
|
|
129
|
+
expect(messages[0]).toContain('plain literal');
|
|
130
|
+
});
|
|
131
|
+
it('rejects malformed objects', () => {
|
|
132
|
+
expect(parse(`plain`).values).toBeNull();
|
|
133
|
+
expect(parse(`{ a: 1 b: 2 }`).values).toBeNull();
|
|
134
|
+
expect(parse(`{ a: 1 } extra`).values).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
describe('normalizeBody', () => {
|
|
138
|
+
it('strips the common indentation but keeps relative indent', () => {
|
|
139
|
+
expect(normalizeBody('\n\t\t<div>\n\t\t\t<b>x</b>\n\t\t</div>\n')).toBe('<div>\n\t<b>x</b>\n</div>');
|
|
140
|
+
});
|
|
141
|
+
it('ignores blank lines when computing the indent and blanks them out', () => {
|
|
142
|
+
expect(normalizeBody('\t\ta\n\n\t\tb\n')).toBe('a\n\nb');
|
|
143
|
+
});
|
|
144
|
+
it('unescapes body lines that start with \\[', () => {
|
|
145
|
+
expect(normalizeBody('\t\\[example title="x"]\n\ttext\n')).toBe('[example title="x"]\ntext');
|
|
146
|
+
});
|
|
147
|
+
it('dedents PAGE bodies so markdown does not become code blocks', () => {
|
|
148
|
+
const doc = parseSdoc('[PAGE title="X"]\n\t## Heading\n\n\ttext\n[/PAGE]\n');
|
|
149
|
+
expect(doc.entities[0].body).toBe('## Heading\n\ntext');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
describe('slugifyTitle', () => {
|
|
153
|
+
it('slugifies title paths', () => {
|
|
154
|
+
expect(slugifyTitle('Forms / Button')).toBe('forms-button');
|
|
155
|
+
expect(slugifyTitle(' Weird -- Title!! ')).toBe('weird-title');
|
|
156
|
+
expect(slugifyTitle('')).toBe('untitled');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Syntactic scanner for v2 .sdoc files.
|
|
3
|
+
*
|
|
4
|
+
* A .sdoc file is: optional <script> at the top, entity blocks in the
|
|
5
|
+
* middle ([DOCS] / [PAGE] / [LAYOUT], with lowercase sub-blocks [preview] /
|
|
6
|
+
* [example] inside [DOCS]), optional <style> at the bottom.
|
|
7
|
+
*
|
|
8
|
+
* The scanner is line-anchored and non-balancing: tags are recognized only
|
|
9
|
+
* at the start of a line and only where the current state allows them, so
|
|
10
|
+
* block bodies may contain any text — including square brackets — without
|
|
11
|
+
* escaping. Inside a body, only the matching closer line terminates it.
|
|
12
|
+
*
|
|
13
|
+
* This layer is purely syntactic: it reports structure, attribute values
|
|
14
|
+
* and precise spans, and recovers from errors so an editor can keep
|
|
15
|
+
* getting diagnostics for the rest of the file. Semantic validation
|
|
16
|
+
* (required attributes, uniqueness, literal-only args) lives in parser.ts.
|
|
17
|
+
*/
|
|
18
|
+
export interface Span {
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
}
|
|
22
|
+
export type EntityKind = 'DOCS' | 'PAGE' | 'LAYOUT';
|
|
23
|
+
export type SubBlockKind = 'preview' | 'example';
|
|
24
|
+
export declare const ENTITY_KINDS: readonly EntityKind[];
|
|
25
|
+
export declare const SUB_BLOCK_KINDS: readonly SubBlockKind[];
|
|
26
|
+
export interface AttrValue {
|
|
27
|
+
/** 'string' for name="text", 'expression' for name={expr}, 'bare' for a lone name */
|
|
28
|
+
kind: 'string' | 'expression' | 'bare';
|
|
29
|
+
/** Exact source between the quotes/braces ('' for bare attributes) */
|
|
30
|
+
raw: string;
|
|
31
|
+
/** Span of the whole attribute (name through closing quote/brace) */
|
|
32
|
+
span: Span;
|
|
33
|
+
/** Span of `raw` inside the source */
|
|
34
|
+
valueSpan: Span;
|
|
35
|
+
}
|
|
36
|
+
export type Attrs = Record<string, AttrValue>;
|
|
37
|
+
export interface SubBlock {
|
|
38
|
+
kind: SubBlockKind;
|
|
39
|
+
attrs: Attrs;
|
|
40
|
+
/** Raw body between the opener line and the closer line */
|
|
41
|
+
body: string;
|
|
42
|
+
bodySpan: Span;
|
|
43
|
+
openerSpan: Span;
|
|
44
|
+
span: Span;
|
|
45
|
+
}
|
|
46
|
+
export interface Entity {
|
|
47
|
+
kind: EntityKind;
|
|
48
|
+
attrs: Attrs;
|
|
49
|
+
/** DOCS: sub-blocks. PAGE/LAYOUT: always empty. */
|
|
50
|
+
blocks: SubBlock[];
|
|
51
|
+
/** PAGE/LAYOUT: the raw body. DOCS: '' (body text between blocks is an error). */
|
|
52
|
+
body: string;
|
|
53
|
+
bodySpan: Span;
|
|
54
|
+
openerSpan: Span;
|
|
55
|
+
span: Span;
|
|
56
|
+
}
|
|
57
|
+
/** A top-level <script> or <style> tag block. */
|
|
58
|
+
export interface TagBlock {
|
|
59
|
+
/** Raw text between the tag name and '>', e.g. ' lang="ts"' */
|
|
60
|
+
attrsText: string;
|
|
61
|
+
content: string;
|
|
62
|
+
contentSpan: Span;
|
|
63
|
+
span: Span;
|
|
64
|
+
}
|
|
65
|
+
export interface ScanError {
|
|
66
|
+
code: string;
|
|
67
|
+
message: string;
|
|
68
|
+
span: Span;
|
|
69
|
+
}
|
|
70
|
+
export interface SdocFile {
|
|
71
|
+
script: TagBlock | null;
|
|
72
|
+
style: TagBlock | null;
|
|
73
|
+
entities: Entity[];
|
|
74
|
+
errors: ScanError[];
|
|
75
|
+
source: string;
|
|
76
|
+
}
|
|
77
|
+
export declare function scanSdoc(source: string): SdocFile;
|
|
78
|
+
/** Convert an offset to a 0-based line/column pair (for editors/CLI output). */
|
|
79
|
+
export declare function offsetToPosition(source: string, offset: number): {
|
|
80
|
+
line: number;
|
|
81
|
+
column: number;
|
|
82
|
+
};
|