sdocs 0.0.40 → 0.0.41

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,529 @@
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 const ENTITY_KINDS = ['DOCS', 'PAGE', 'LAYOUT'];
19
+ export const SUB_BLOCK_KINDS = ['preview', 'example'];
20
+ function splitLines(source) {
21
+ const lines = [];
22
+ let start = 0;
23
+ for (let i = 0; i <= source.length; i++) {
24
+ if (i === source.length || source[i] === '\n') {
25
+ lines.push({ start, end: i, text: source.slice(start, i) });
26
+ start = i + 1;
27
+ }
28
+ }
29
+ return lines;
30
+ }
31
+ const NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]*/;
32
+ /** Scan an opener's attributes starting right after `[KIND`. Handles
33
+ * multi-line openers and quote/brace nesting. Returns the offset just past
34
+ * the closing ']' or reports an error and returns -1. */
35
+ function scanOpenerAttrs(source, from, attrs, errors) {
36
+ let i = from;
37
+ while (i < source.length) {
38
+ const ch = source[i];
39
+ if (ch === ']')
40
+ return i + 1;
41
+ if (/\s/.test(ch)) {
42
+ i++;
43
+ continue;
44
+ }
45
+ const nameMatch = source.slice(i).match(NAME_RE);
46
+ if (!nameMatch) {
47
+ errors.push({
48
+ code: 'attr-syntax',
49
+ message: `Unexpected character '${ch}' in block opener.`,
50
+ span: { start: i, end: i + 1 },
51
+ });
52
+ return -1;
53
+ }
54
+ const name = nameMatch[0];
55
+ const nameStart = i;
56
+ i += name.length;
57
+ if (source[i] !== '=') {
58
+ addAttr(attrs, errors, name, {
59
+ kind: 'bare',
60
+ raw: '',
61
+ span: { start: nameStart, end: i },
62
+ valueSpan: { start: i, end: i },
63
+ });
64
+ continue;
65
+ }
66
+ i++; // '='
67
+ if (source[i] === '"') {
68
+ const valueStart = i + 1;
69
+ const close = source.indexOf('"', valueStart);
70
+ if (close === -1) {
71
+ errors.push({
72
+ code: 'attr-syntax',
73
+ message: `Unterminated string value for attribute "${name}".`,
74
+ span: { start: nameStart, end: source.length },
75
+ });
76
+ return -1;
77
+ }
78
+ addAttr(attrs, errors, name, {
79
+ kind: 'string',
80
+ raw: source.slice(valueStart, close),
81
+ span: { start: nameStart, end: close + 1 },
82
+ valueSpan: { start: valueStart, end: close },
83
+ });
84
+ i = close + 1;
85
+ }
86
+ else if (source[i] === '{') {
87
+ const valueStart = i + 1;
88
+ const close = scanBalancedBraces(source, i);
89
+ if (close === -1) {
90
+ errors.push({
91
+ code: 'attr-syntax',
92
+ message: `Unterminated expression value for attribute "${name}".`,
93
+ span: { start: nameStart, end: source.length },
94
+ });
95
+ return -1;
96
+ }
97
+ addAttr(attrs, errors, name, {
98
+ kind: 'expression',
99
+ raw: source.slice(valueStart, close),
100
+ span: { start: nameStart, end: close + 1 },
101
+ valueSpan: { start: valueStart, end: close },
102
+ });
103
+ i = close + 1;
104
+ }
105
+ else {
106
+ errors.push({
107
+ code: 'attr-syntax',
108
+ message: `Attribute "${name}" must have a "string" or {expression} value.`,
109
+ span: { start: nameStart, end: i },
110
+ });
111
+ return -1;
112
+ }
113
+ }
114
+ errors.push({
115
+ code: 'attr-syntax',
116
+ message: 'Block opener is missing its closing "]".',
117
+ span: { start: from, end: source.length },
118
+ });
119
+ return -1;
120
+ }
121
+ function addAttr(attrs, errors, name, value) {
122
+ if (attrs[name]) {
123
+ errors.push({
124
+ code: 'duplicate-attr',
125
+ message: `Duplicate attribute "${name}".`,
126
+ span: value.span,
127
+ });
128
+ return;
129
+ }
130
+ attrs[name] = value;
131
+ }
132
+ /** Given source[open] === '{', return the offset of the matching '}',
133
+ * skipping strings and nested braces. -1 when unterminated. */
134
+ function scanBalancedBraces(source, open) {
135
+ let depth = 0;
136
+ for (let i = open; i < source.length; i++) {
137
+ const ch = source[i];
138
+ if (ch === "'" || ch === '"' || ch === '`') {
139
+ i = skipString(source, i);
140
+ if (i === -1)
141
+ return -1;
142
+ continue;
143
+ }
144
+ if (ch === '{')
145
+ depth++;
146
+ else if (ch === '}') {
147
+ depth--;
148
+ if (depth === 0)
149
+ return i;
150
+ }
151
+ }
152
+ return -1;
153
+ }
154
+ /** Given source[at] is a quote, return the offset of the closing quote. */
155
+ function skipString(source, at) {
156
+ const quote = source[at];
157
+ for (let i = at + 1; i < source.length; i++) {
158
+ if (source[i] === '\\') {
159
+ i++;
160
+ continue;
161
+ }
162
+ if (source[i] === quote)
163
+ return i;
164
+ }
165
+ return -1;
166
+ }
167
+ /** Parse a `[name` / `[/name` token at the start of a trimmed line. */
168
+ function tagToken(trimmed) {
169
+ const m = trimmed.match(/^\[(\/?)([A-Za-z][A-Za-z0-9_-]*)/);
170
+ if (!m)
171
+ return null;
172
+ return { name: m[2], closer: m[1] === '/' };
173
+ }
174
+ function isEntityKind(name) {
175
+ return ENTITY_KINDS.includes(name);
176
+ }
177
+ function isSubBlockKind(name) {
178
+ return SUB_BLOCK_KINDS.includes(name);
179
+ }
180
+ export function scanSdoc(source) {
181
+ const lines = splitLines(source);
182
+ const errors = [];
183
+ const entities = [];
184
+ let script = null;
185
+ let style = null;
186
+ let li = 0;
187
+ /** Skip an HTML comment starting on line `li` at trimmed position; returns the next line index. */
188
+ function skipComment(startLi) {
189
+ const startOffset = lines[startLi].start + lines[startLi].text.indexOf('<!--');
190
+ const close = source.indexOf('-->', startOffset + 4);
191
+ if (close === -1) {
192
+ errors.push({
193
+ code: 'unclosed-comment',
194
+ message: 'Unclosed HTML comment.',
195
+ span: { start: startOffset, end: source.length },
196
+ });
197
+ return lines.length;
198
+ }
199
+ let i = startLi;
200
+ while (i < lines.length && lines[i].end < close + 3)
201
+ i++;
202
+ const rest = source.slice(close + 3, lines[i].end);
203
+ if (rest.trim() !== '') {
204
+ errors.push({
205
+ code: 'text-outside-blocks',
206
+ message: 'Unexpected text after comment.',
207
+ span: { start: close + 3, end: lines[i].end },
208
+ });
209
+ }
210
+ return i + 1;
211
+ }
212
+ /** Capture a <script>/<style> tag block whose opener starts line `li`. */
213
+ function captureTag(tag, startLi) {
214
+ const openLine = lines[startLi];
215
+ const openStart = openLine.start + openLine.text.indexOf(`<${tag}`);
216
+ const openEnd = source.indexOf('>', openStart);
217
+ if (openEnd === -1) {
218
+ errors.push({
219
+ code: 'tag-syntax',
220
+ message: `Malformed <${tag}> tag.`,
221
+ span: { start: openStart, end: openLine.end },
222
+ });
223
+ return null;
224
+ }
225
+ const closeIdx = source.indexOf(`</${tag}>`, openEnd + 1);
226
+ if (closeIdx === -1) {
227
+ errors.push({
228
+ code: 'unclosed-tag',
229
+ message: `Missing </${tag}>.`,
230
+ span: { start: openStart, end: source.length },
231
+ });
232
+ return null;
233
+ }
234
+ const closeEnd = closeIdx + `</${tag}>`.length;
235
+ let i = startLi;
236
+ while (i < lines.length && lines[i].end < closeEnd)
237
+ i++;
238
+ const rest = source.slice(closeEnd, lines[i].end);
239
+ if (rest.trim() !== '') {
240
+ errors.push({
241
+ code: 'text-outside-blocks',
242
+ message: `Unexpected text after </${tag}>.`,
243
+ span: { start: closeEnd, end: lines[i].end },
244
+ });
245
+ }
246
+ return {
247
+ block: {
248
+ attrsText: source.slice(openStart + tag.length + 1, openEnd),
249
+ content: source.slice(openEnd + 1, closeIdx),
250
+ contentSpan: { start: openEnd + 1, end: closeIdx },
251
+ span: { start: openStart, end: closeEnd },
252
+ },
253
+ nextLi: i + 1,
254
+ };
255
+ }
256
+ /** Scan an entity or sub-block opener starting at line `li`. Returns the
257
+ * attrs, the opener span, and the line index after the opener. */
258
+ function scanOpener(startLi, kindLen) {
259
+ const line = lines[startLi];
260
+ const bracketStart = line.start + line.text.indexOf('[');
261
+ const attrs = {};
262
+ const end = scanOpenerAttrs(source, bracketStart + 1 + kindLen, attrs, errors);
263
+ if (end === -1)
264
+ return null;
265
+ let i = startLi;
266
+ while (i < lines.length && lines[i].end < end)
267
+ i++;
268
+ const rest = source.slice(end, lines[i].end);
269
+ if (rest.trim() !== '') {
270
+ errors.push({
271
+ code: 'tag-not-alone',
272
+ message: 'A block opener must be alone on its line.',
273
+ span: { start: end, end: lines[i].end },
274
+ });
275
+ }
276
+ return { attrs, openerSpan: { start: bracketStart, end }, nextLi: i + 1 };
277
+ }
278
+ /** Collect raw body lines until the exact closer line `[/kind]`. */
279
+ function captureBody(startLi, closer) {
280
+ const bodyStart = startLi < lines.length ? lines[startLi].start : source.length;
281
+ for (let i = startLi; i < lines.length; i++) {
282
+ if (lines[i].text.trim() === closer) {
283
+ const bodyEnd = i > 0 ? lines[i - 1].end : bodyStart;
284
+ const tagStart = lines[i].start + lines[i].text.indexOf(closer);
285
+ return {
286
+ body: source.slice(bodyStart, Math.max(bodyStart, bodyEnd)),
287
+ bodySpan: { start: bodyStart, end: Math.max(bodyStart, bodyEnd) },
288
+ closerSpan: { start: tagStart, end: tagStart + closer.length },
289
+ nextLi: i + 1,
290
+ };
291
+ }
292
+ }
293
+ return null;
294
+ }
295
+ /** Scan the inside of a [DOCS] entity until its closer. */
296
+ function scanDocsBody(startLi, entity) {
297
+ let i = startLi;
298
+ while (i < lines.length) {
299
+ const line = lines[i];
300
+ const trimmed = line.text.trim();
301
+ if (trimmed === '') {
302
+ i++;
303
+ continue;
304
+ }
305
+ if (trimmed.startsWith('<!--')) {
306
+ i = skipComment(i);
307
+ continue;
308
+ }
309
+ const token = tagToken(trimmed);
310
+ if (token && token.closer && token.name === 'DOCS' && trimmed === '[/DOCS]') {
311
+ const tagStart = line.start + line.text.indexOf('[/DOCS]');
312
+ entity.span.end = tagStart + '[/DOCS]'.length;
313
+ return i + 1;
314
+ }
315
+ if (token && !token.closer && isSubBlockKind(token.name)) {
316
+ const opener = scanOpener(i, token.name.length);
317
+ if (!opener)
318
+ return lines.length;
319
+ const captured = captureBody(opener.nextLi, `[/${token.name}]`);
320
+ if (!captured) {
321
+ errors.push({
322
+ code: 'unclosed-block',
323
+ message: `Missing [/${token.name}].`,
324
+ span: opener.openerSpan,
325
+ });
326
+ return lines.length;
327
+ }
328
+ entity.blocks.push({
329
+ kind: token.name,
330
+ attrs: opener.attrs,
331
+ body: captured.body,
332
+ bodySpan: captured.bodySpan,
333
+ openerSpan: opener.openerSpan,
334
+ span: { start: opener.openerSpan.start, end: captured.closerSpan.end },
335
+ });
336
+ i = captured.nextLi;
337
+ continue;
338
+ }
339
+ const span = { start: line.start + line.text.indexOf(trimmed[0]), end: line.end };
340
+ if (token && isSubBlockKind(token.name.toLowerCase()) && !token.closer) {
341
+ errors.push({
342
+ code: 'casing',
343
+ message: `Sub-block tags are lowercase: [${token.name.toLowerCase()}].`,
344
+ span,
345
+ });
346
+ i++;
347
+ continue;
348
+ }
349
+ if (token && !token.closer && isEntityKind(token.name)) {
350
+ errors.push({
351
+ code: 'unclosed-block',
352
+ message: `Missing [/DOCS] before the next entity.`,
353
+ span,
354
+ });
355
+ entity.span.end = line.start;
356
+ return i; // recover: let the outer loop re-read this line
357
+ }
358
+ if (token) {
359
+ errors.push({
360
+ code: token.closer ? 'stray-closer' : 'unknown-tag',
361
+ message: token.closer
362
+ ? `Unexpected closer [/${token.name}] inside [DOCS].`
363
+ : `Unknown block [${token.name}] inside [DOCS] — expected [preview] or [example].`,
364
+ span,
365
+ });
366
+ i++;
367
+ continue;
368
+ }
369
+ errors.push({
370
+ code: 'text-outside-blocks',
371
+ message: 'Text inside [DOCS] must be inside a [preview] or [example] block.',
372
+ span,
373
+ });
374
+ i++;
375
+ }
376
+ errors.push({
377
+ code: 'unclosed-block',
378
+ message: 'Missing [/DOCS].',
379
+ span: entity.openerSpan,
380
+ });
381
+ entity.span.end = source.length;
382
+ return lines.length;
383
+ }
384
+ // ---- main loop over top-level lines ----
385
+ while (li < lines.length) {
386
+ const line = lines[li];
387
+ const trimmed = line.text.trim();
388
+ if (trimmed === '') {
389
+ li++;
390
+ continue;
391
+ }
392
+ if (trimmed.startsWith('<!--')) {
393
+ li = skipComment(li);
394
+ continue;
395
+ }
396
+ if (trimmed.startsWith('<script')) {
397
+ const captured = captureTag('script', li);
398
+ if (!captured)
399
+ return { script, style, entities, errors, source };
400
+ if (script) {
401
+ errors.push({
402
+ code: 'duplicate-script',
403
+ message: 'Only one <script> block is allowed.',
404
+ span: captured.block.span,
405
+ });
406
+ }
407
+ else if (entities.length > 0 || style) {
408
+ errors.push({
409
+ code: 'script-position',
410
+ message: '<script> must be at the top of the file, before any entity.',
411
+ span: captured.block.span,
412
+ });
413
+ }
414
+ else {
415
+ script = captured.block;
416
+ }
417
+ li = captured.nextLi;
418
+ continue;
419
+ }
420
+ if (trimmed.startsWith('<style')) {
421
+ const captured = captureTag('style', li);
422
+ if (!captured)
423
+ return { script, style, entities, errors, source };
424
+ if (style) {
425
+ errors.push({
426
+ code: 'duplicate-style',
427
+ message: 'Only one <style> block is allowed.',
428
+ span: captured.block.span,
429
+ });
430
+ }
431
+ else {
432
+ style = captured.block;
433
+ }
434
+ li = captured.nextLi;
435
+ continue;
436
+ }
437
+ const token = tagToken(trimmed);
438
+ if (token && !token.closer && isEntityKind(token.name)) {
439
+ if (style) {
440
+ errors.push({
441
+ code: 'style-position',
442
+ message: '<style> must be at the bottom of the file, after all entities.',
443
+ span: { start: line.start, end: line.end },
444
+ });
445
+ }
446
+ const opener = scanOpener(li, token.name.length);
447
+ if (!opener)
448
+ return { script, style, entities, errors, source };
449
+ const entity = {
450
+ kind: token.name,
451
+ attrs: opener.attrs,
452
+ blocks: [],
453
+ body: '',
454
+ bodySpan: { start: opener.openerSpan.end, end: opener.openerSpan.end },
455
+ openerSpan: opener.openerSpan,
456
+ span: { start: opener.openerSpan.start, end: opener.openerSpan.end },
457
+ };
458
+ entities.push(entity);
459
+ if (token.name === 'DOCS') {
460
+ li = scanDocsBody(opener.nextLi, entity);
461
+ }
462
+ else {
463
+ const captured = captureBody(opener.nextLi, `[/${token.name}]`);
464
+ if (!captured) {
465
+ errors.push({
466
+ code: 'unclosed-block',
467
+ message: `Missing [/${token.name}].`,
468
+ span: opener.openerSpan,
469
+ });
470
+ entity.body = source.slice(opener.nextLi < lines.length ? lines[opener.nextLi].start : source.length);
471
+ entity.span.end = source.length;
472
+ return { script, style, entities, errors, source };
473
+ }
474
+ entity.body = captured.body;
475
+ entity.bodySpan = captured.bodySpan;
476
+ entity.span.end = captured.closerSpan.end;
477
+ li = captured.nextLi;
478
+ }
479
+ continue;
480
+ }
481
+ const span = { start: line.start + line.text.indexOf(trimmed[0]), end: line.end };
482
+ if (token && !token.closer && isEntityKind(token.name.toUpperCase())) {
483
+ errors.push({
484
+ code: 'casing',
485
+ message: `Entity tags are uppercase: [${token.name.toUpperCase()}].`,
486
+ span,
487
+ });
488
+ li++;
489
+ continue;
490
+ }
491
+ if (token && !token.closer && isSubBlockKind(token.name)) {
492
+ errors.push({
493
+ code: 'block-outside-entity',
494
+ message: `[${token.name}] is only valid inside a [DOCS] entity.`,
495
+ span,
496
+ });
497
+ li++;
498
+ continue;
499
+ }
500
+ if (token && token.closer) {
501
+ errors.push({
502
+ code: 'stray-closer',
503
+ message: `Unexpected closer [/${token.name}].`,
504
+ span,
505
+ });
506
+ li++;
507
+ continue;
508
+ }
509
+ errors.push({
510
+ code: 'text-outside-blocks',
511
+ message: 'Text must be inside an entity block.',
512
+ span,
513
+ });
514
+ li++;
515
+ }
516
+ return { script, style, entities, errors, source };
517
+ }
518
+ /** Convert an offset to a 0-based line/column pair (for editors/CLI output). */
519
+ export function offsetToPosition(source, offset) {
520
+ let line = 0;
521
+ let lineStart = 0;
522
+ for (let i = 0; i < offset && i < source.length; i++) {
523
+ if (source[i] === '\n') {
524
+ line++;
525
+ lineStart = i + 1;
526
+ }
527
+ }
528
+ return { line, column: offset - lineStart };
529
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { scanSdoc } from './scanner.js';
3
+ const FULL = `<script lang="ts">
4
+ import Button from './Button.svelte';
5
+ </script>
6
+
7
+ [DOCS title="Forms / Button" description="A flexible button."]
8
+
9
+ [preview component={Button} args={{ label: 'Hi', count: 2 }}]
10
+ <Button {...args} />
11
+ [/preview]
12
+
13
+ [example title="Disabled"]
14
+ <Button label="Nope" disabled />
15
+ [/example]
16
+
17
+ [/DOCS]
18
+
19
+ [PAGE title="Guides / Usage"]
20
+
21
+ ## When to use
22
+
23
+ A [reference link][DOCS] and a fence:
24
+
25
+ \`\`\`svelte
26
+ [preview looks like a tag but is content]
27
+ \`\`\`
28
+
29
+ [/PAGE]
30
+
31
+ [LAYOUT title="Patterns / Login" padding="48px"]
32
+ <Button label="Sign in" />
33
+ [/LAYOUT]
34
+
35
+ <style>
36
+ .row { display: flex; }
37
+ </style>
38
+ `;
39
+ describe('scanSdoc structure', () => {
40
+ const file = scanSdoc(FULL);
41
+ it('scans a full file without errors', () => {
42
+ expect(file.errors).toEqual([]);
43
+ });
44
+ it('captures script and style', () => {
45
+ expect(file.script?.attrsText).toContain('lang="ts"');
46
+ expect(file.script?.content).toContain("import Button from './Button.svelte';");
47
+ expect(file.style?.content).toContain('.row { display: flex; }');
48
+ });
49
+ it('finds all three entities in order', () => {
50
+ expect(file.entities.map((e) => e.kind)).toEqual(['DOCS', 'PAGE', 'LAYOUT']);
51
+ });
52
+ it('parses entity attributes with kinds', () => {
53
+ const docs = file.entities[0];
54
+ expect(docs.attrs.title).toMatchObject({ kind: 'string', raw: 'Forms / Button' });
55
+ expect(docs.attrs.description).toMatchObject({ kind: 'string', raw: 'A flexible button.' });
56
+ const layout = file.entities[2];
57
+ expect(layout.attrs.padding).toMatchObject({ kind: 'string', raw: '48px' });
58
+ });
59
+ it('parses sub-blocks with attributes and bodies', () => {
60
+ const [preview, example] = file.entities[0].blocks;
61
+ expect(preview.kind).toBe('preview');
62
+ expect(preview.attrs.component).toMatchObject({ kind: 'expression', raw: 'Button' });
63
+ expect(preview.attrs.args.raw).toBe("{ label: 'Hi', count: 2 }");
64
+ expect(preview.body).toContain('<Button {...args} />');
65
+ expect(example.kind).toBe('example');
66
+ expect(example.body).toContain('<Button label="Nope" disabled />');
67
+ });
68
+ it('keeps bracket-looking lines inside bodies as content', () => {
69
+ const page = file.entities[1];
70
+ expect(page.body).toContain('[reference link][DOCS]');
71
+ expect(page.body).toContain('[preview looks like a tag but is content]');
72
+ expect(file.entities).toHaveLength(3);
73
+ });
74
+ it('tracks spans that slice back to the source', () => {
75
+ const docs = file.entities[0];
76
+ expect(FULL.slice(docs.openerSpan.start, docs.openerSpan.end)).toBe('[DOCS title="Forms / Button" description="A flexible button."]');
77
+ expect(FULL.slice(docs.span.start, docs.span.end).endsWith('[/DOCS]')).toBe(true);
78
+ const preview = docs.blocks[0];
79
+ expect(FULL.slice(preview.bodySpan.start, preview.bodySpan.end)).toBe(preview.body);
80
+ });
81
+ });
82
+ describe('scanSdoc details', () => {
83
+ it('supports multi-line openers', () => {
84
+ const file = scanSdoc('[DOCS\n\ttitle="Forms / Button"\n\tdescription="Long."\n]\n[/DOCS]\n');
85
+ expect(file.errors).toEqual([]);
86
+ expect(file.entities[0].attrs.title.raw).toBe('Forms / Button');
87
+ });
88
+ it('supports bare attributes', () => {
89
+ const file = scanSdoc('[DOCS title="X" draft]\n[/DOCS]\n');
90
+ expect(file.entities[0].attrs.draft).toMatchObject({ kind: 'bare', raw: '' });
91
+ });
92
+ it('allows comments between blocks', () => {
93
+ const file = scanSdoc('<!-- a note\nspanning lines -->\n[PAGE title="X"]\nhi\n[/PAGE]\n');
94
+ expect(file.errors).toEqual([]);
95
+ expect(file.entities).toHaveLength(1);
96
+ });
97
+ it('tolerates CRLF line endings and a BOM', () => {
98
+ const file = scanSdoc('[DOCS title="X"]\r\n[preview component={B}]\r\n<B />\r\n[/preview]\r\n[/DOCS]\r\n');
99
+ expect(file.errors).toEqual([]);
100
+ expect(file.entities[0].blocks[0].body).toContain('<B />');
101
+ });
102
+ it('handles nested braces and strings in expression attributes', () => {
103
+ const file = scanSdoc(`[DOCS title="X"]\n[preview component={Button} args={{ a: '}', b: { }, c: "]" }}]\nx\n[/preview]\n[/DOCS]\n`);
104
+ expect(file.errors).toEqual([]);
105
+ expect(file.entities[0].blocks[0].attrs.args.raw).toBe(`{ a: '}', b: { }, c: "]" }`);
106
+ });
107
+ });
108
+ function codes(source) {
109
+ return scanSdoc(source).errors.map((e) => e.code);
110
+ }
111
+ describe('scanSdoc errors', () => {
112
+ it('rejects text outside blocks', () => {
113
+ expect(codes('hello\n')).toContain('text-outside-blocks');
114
+ expect(codes('[DOCS title="X"]\nstray text\n[/DOCS]\n')).toContain('text-outside-blocks');
115
+ });
116
+ it('rejects wrong casing with a targeted message', () => {
117
+ expect(codes('[docs title="X"]\n[/docs]\n')).toContain('casing');
118
+ expect(codes('[DOCS title="X"]\n[PREVIEW component={B}]\nx\n[/PREVIEW]\n[/DOCS]\n')).toContain('casing');
119
+ });
120
+ it('rejects sub-blocks outside DOCS and unknown blocks inside', () => {
121
+ expect(codes('[preview component={B}]\nx\n[/preview]\n')).toContain('block-outside-entity');
122
+ expect(codes('[DOCS title="X"]\n[stuff]\nx\n[/stuff]\n[/DOCS]\n')).toContain('unknown-tag');
123
+ });
124
+ it('requires openers to be alone on their line', () => {
125
+ expect(codes('[DOCS title="X"] trailing\n[/DOCS]\n')).toContain('tag-not-alone');
126
+ });
127
+ it('reports unclosed blocks', () => {
128
+ expect(codes('[DOCS title="X"]\n')).toContain('unclosed-block');
129
+ expect(codes('[DOCS title="X"]\n[preview component={B}]\nx\n[/DOCS]\n')).toContain('unclosed-block');
130
+ expect(codes('[PAGE title="X"]\ntext\n')).toContain('unclosed-block');
131
+ });
132
+ it('reports stray closers and duplicate attributes', () => {
133
+ expect(codes('[/DOCS]\n')).toContain('stray-closer');
134
+ expect(codes('[DOCS title="A" title="B"]\n[/DOCS]\n')).toContain('duplicate-attr');
135
+ });
136
+ it('enforces file anatomy: script top, style bottom', () => {
137
+ expect(codes('[PAGE title="X"]\nx\n[/PAGE]\n<script>\nlet a;\n</script>\n')).toContain('script-position');
138
+ expect(codes('<style>\n.a {}\n</style>\n[PAGE title="X"]\nx\n[/PAGE]\n')).toContain('style-position');
139
+ });
140
+ it('recovers when an entity opener appears inside an unclosed [DOCS]', () => {
141
+ const file = scanSdoc('[DOCS title="A"]\n[PAGE title="B"]\nx\n[/PAGE]\n');
142
+ expect(file.errors.map((e) => e.code)).toContain('unclosed-block');
143
+ expect(file.entities.map((e) => e.kind)).toEqual(['DOCS', 'PAGE']);
144
+ expect(file.entities[1].body.trim()).toBe('x');
145
+ });
146
+ });