sdocs 0.0.39 → 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.
- package/CHANGELOG.md +29 -0
- package/dist/explorer/views/ComponentView.svelte +5 -2
- package/dist/grammar/sdoc.tmLanguage.json +245 -0
- 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/snippet-compiler.d.ts +1 -1
- package/dist/server/snippet-compiler.js +15 -6
- package/dist/server/snippet-compiler.test.d.ts +1 -0
- package/dist/server/snippet-compiler.test.js +23 -0
- package/dist/vite.js +2 -1
- package/package.json +6 -1
|
@@ -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
|
+
};
|