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
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.41] - 2026-07-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`sdocs/language`** — a scanner and parser for the block-based sdoc
|
|
15
|
+
format: line-anchored `[DOCS]`/`[PAGE]`/`[LAYOUT]` entities with
|
|
16
|
+
`[preview]`/`[example]` sub-blocks, Svelte-style attributes, precise
|
|
17
|
+
source spans, and recoverable diagnostics (it never throws, and keeps
|
|
18
|
+
scanning past mistakes). Groundwork for the format switch — the runtime
|
|
19
|
+
still reads the current `.sdoc` format.
|
|
20
|
+
- **A TextMate grammar for sdoc** at `sdocs/grammar/sdoc.tmLanguage.json`,
|
|
21
|
+
usable by any TextMate-compatible highlighter; the documentation site
|
|
22
|
+
uses it to highlight ```` ```sdoc ```` fences.
|
|
23
|
+
|
|
24
|
+
## [0.0.40] - 2026-07-04
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Wrapped previews bind to the documented component.** When a preview
|
|
29
|
+
wraps the documented component in another one (a `<Tab>` shown inside
|
|
30
|
+
`<Tabs>`), method calls and live state used to attach to the outer
|
|
31
|
+
wrapper — the first capitalized tag in the snippet. The preview ref now
|
|
32
|
+
binds to the doc's own component wherever it appears in the snippet,
|
|
33
|
+
falling back to the first capitalized tag when it's absent.
|
|
34
|
+
- **Usage-code patching matches the component name exactly.** Attribute
|
|
35
|
+
updates from the controls located the root tag with a prefix search, so
|
|
36
|
+
a component like `Tab` could patch a sibling `<Tabs>` tag instead of its
|
|
37
|
+
own. The tag search now requires a whole-name match.
|
|
38
|
+
|
|
10
39
|
## [0.0.39] - 2026-07-04
|
|
11
40
|
|
|
12
41
|
### Changed
|
|
@@ -106,8 +106,11 @@
|
|
|
106
106
|
if (changes.length === 0) return snippetBody;
|
|
107
107
|
|
|
108
108
|
// Find the opening tag: <ComponentName ...> or <ComponentName ... />
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
// (whole-name match, so <Tab doesn't hit <Tabs)
|
|
110
|
+
const escapedName = name.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
111
|
+
const tagMatch = snippetBody.match(new RegExp(`<${escapedName}(?=[\\s/>])`));
|
|
112
|
+
if (!tagMatch || tagMatch.index === undefined) return snippetBody;
|
|
113
|
+
const tagStart = tagMatch.index;
|
|
111
114
|
|
|
112
115
|
// Find the end of the opening tag, respecting {} expressions
|
|
113
116
|
let braceDepth = 0;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
|
|
3
|
+
"name": "sdoc",
|
|
4
|
+
"scopeName": "source.sdoc",
|
|
5
|
+
"fileTypes": ["sdoc"],
|
|
6
|
+
"patterns": [
|
|
7
|
+
{ "include": "#comment" },
|
|
8
|
+
{ "include": "#script-ts" },
|
|
9
|
+
{ "include": "#script-js" },
|
|
10
|
+
{ "include": "#style" },
|
|
11
|
+
{ "include": "#docs" },
|
|
12
|
+
{ "include": "#page" },
|
|
13
|
+
{ "include": "#layout" }
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"comment": {
|
|
17
|
+
"name": "comment.block.html.sdoc",
|
|
18
|
+
"begin": "<!--",
|
|
19
|
+
"end": "-->"
|
|
20
|
+
},
|
|
21
|
+
"script-ts": {
|
|
22
|
+
"begin": "^\\s*(<)(script)(?=[^>]*lang=([\"'])ts\\3)",
|
|
23
|
+
"beginCaptures": {
|
|
24
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
25
|
+
"2": { "name": "entity.name.tag.script.sdoc" }
|
|
26
|
+
},
|
|
27
|
+
"end": "(</)(script)\\s*(>)",
|
|
28
|
+
"endCaptures": {
|
|
29
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
30
|
+
"2": { "name": "entity.name.tag.script.sdoc" },
|
|
31
|
+
"3": { "name": "punctuation.definition.tag.end.sdoc" }
|
|
32
|
+
},
|
|
33
|
+
"patterns": [
|
|
34
|
+
{ "include": "#tag-rest" },
|
|
35
|
+
{
|
|
36
|
+
"begin": "(?<=>)",
|
|
37
|
+
"end": "(?=</script)",
|
|
38
|
+
"contentName": "source.ts",
|
|
39
|
+
"patterns": [{ "include": "source.ts" }]
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"script-js": {
|
|
44
|
+
"begin": "^\\s*(<)(script)(?![^>]*lang=([\"'])ts\\3)(?=[\\s>])",
|
|
45
|
+
"beginCaptures": {
|
|
46
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
47
|
+
"2": { "name": "entity.name.tag.script.sdoc" }
|
|
48
|
+
},
|
|
49
|
+
"end": "(</)(script)\\s*(>)",
|
|
50
|
+
"endCaptures": {
|
|
51
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
52
|
+
"2": { "name": "entity.name.tag.script.sdoc" },
|
|
53
|
+
"3": { "name": "punctuation.definition.tag.end.sdoc" }
|
|
54
|
+
},
|
|
55
|
+
"patterns": [
|
|
56
|
+
{ "include": "#tag-rest" },
|
|
57
|
+
{
|
|
58
|
+
"begin": "(?<=>)",
|
|
59
|
+
"end": "(?=</script)",
|
|
60
|
+
"contentName": "source.js",
|
|
61
|
+
"patterns": [{ "include": "source.js" }]
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
"style": {
|
|
66
|
+
"begin": "^\\s*(<)(style)(?=[\\s>])",
|
|
67
|
+
"beginCaptures": {
|
|
68
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
69
|
+
"2": { "name": "entity.name.tag.style.sdoc" }
|
|
70
|
+
},
|
|
71
|
+
"end": "(</)(style)\\s*(>)",
|
|
72
|
+
"endCaptures": {
|
|
73
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
74
|
+
"2": { "name": "entity.name.tag.style.sdoc" },
|
|
75
|
+
"3": { "name": "punctuation.definition.tag.end.sdoc" }
|
|
76
|
+
},
|
|
77
|
+
"patterns": [
|
|
78
|
+
{ "include": "#tag-rest" },
|
|
79
|
+
{
|
|
80
|
+
"begin": "(?<=>)",
|
|
81
|
+
"end": "(?=</style)",
|
|
82
|
+
"contentName": "source.css",
|
|
83
|
+
"patterns": [{ "include": "source.css" }]
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
"tag-rest": {
|
|
88
|
+
"begin": "\\G",
|
|
89
|
+
"end": "(>)",
|
|
90
|
+
"endCaptures": {
|
|
91
|
+
"1": { "name": "punctuation.definition.tag.end.sdoc" }
|
|
92
|
+
},
|
|
93
|
+
"patterns": [{ "include": "#attributes" }]
|
|
94
|
+
},
|
|
95
|
+
"docs": {
|
|
96
|
+
"name": "meta.block.docs.sdoc",
|
|
97
|
+
"begin": "^\\s*(\\[)(DOCS)\\b",
|
|
98
|
+
"beginCaptures": {
|
|
99
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
100
|
+
"2": { "name": "keyword.control.entity.sdoc" }
|
|
101
|
+
},
|
|
102
|
+
"end": "^\\s*(\\[/)(DOCS)(\\])\\s*$",
|
|
103
|
+
"endCaptures": {
|
|
104
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
105
|
+
"2": { "name": "keyword.control.entity.sdoc" },
|
|
106
|
+
"3": { "name": "punctuation.definition.tag.end.sdoc" }
|
|
107
|
+
},
|
|
108
|
+
"patterns": [
|
|
109
|
+
{ "include": "#opener-attrs" },
|
|
110
|
+
{ "include": "#comment" },
|
|
111
|
+
{ "include": "#preview" },
|
|
112
|
+
{ "include": "#example" }
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
"page": {
|
|
116
|
+
"name": "meta.block.page.sdoc",
|
|
117
|
+
"begin": "^\\s*(\\[)(PAGE)\\b",
|
|
118
|
+
"beginCaptures": {
|
|
119
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
120
|
+
"2": { "name": "keyword.control.entity.sdoc" }
|
|
121
|
+
},
|
|
122
|
+
"end": "^\\s*(\\[/)(PAGE)(\\])\\s*$",
|
|
123
|
+
"endCaptures": {
|
|
124
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
125
|
+
"2": { "name": "keyword.control.entity.sdoc" },
|
|
126
|
+
"3": { "name": "punctuation.definition.tag.end.sdoc" }
|
|
127
|
+
},
|
|
128
|
+
"patterns": [
|
|
129
|
+
{ "include": "#opener-attrs" },
|
|
130
|
+
{ "include": "source.svelte" }
|
|
131
|
+
]
|
|
132
|
+
},
|
|
133
|
+
"layout": {
|
|
134
|
+
"name": "meta.block.layout.sdoc",
|
|
135
|
+
"begin": "^\\s*(\\[)(LAYOUT)\\b",
|
|
136
|
+
"beginCaptures": {
|
|
137
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
138
|
+
"2": { "name": "keyword.control.entity.sdoc" }
|
|
139
|
+
},
|
|
140
|
+
"end": "^\\s*(\\[/)(LAYOUT)(\\])\\s*$",
|
|
141
|
+
"endCaptures": {
|
|
142
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
143
|
+
"2": { "name": "keyword.control.entity.sdoc" },
|
|
144
|
+
"3": { "name": "punctuation.definition.tag.end.sdoc" }
|
|
145
|
+
},
|
|
146
|
+
"patterns": [
|
|
147
|
+
{ "include": "#opener-attrs" },
|
|
148
|
+
{ "include": "source.svelte" }
|
|
149
|
+
]
|
|
150
|
+
},
|
|
151
|
+
"preview": {
|
|
152
|
+
"name": "meta.block.preview.sdoc",
|
|
153
|
+
"begin": "^\\s*(\\[)(preview)\\b",
|
|
154
|
+
"beginCaptures": {
|
|
155
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
156
|
+
"2": { "name": "entity.name.tag.sdoc" }
|
|
157
|
+
},
|
|
158
|
+
"end": "^\\s*(\\[/)(preview)(\\])\\s*$",
|
|
159
|
+
"endCaptures": {
|
|
160
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
161
|
+
"2": { "name": "entity.name.tag.sdoc" },
|
|
162
|
+
"3": { "name": "punctuation.definition.tag.end.sdoc" }
|
|
163
|
+
},
|
|
164
|
+
"patterns": [
|
|
165
|
+
{ "include": "#opener-attrs" },
|
|
166
|
+
{ "include": "source.svelte" }
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
"example": {
|
|
170
|
+
"name": "meta.block.example.sdoc",
|
|
171
|
+
"begin": "^\\s*(\\[)(example)\\b",
|
|
172
|
+
"beginCaptures": {
|
|
173
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
174
|
+
"2": { "name": "entity.name.tag.sdoc" }
|
|
175
|
+
},
|
|
176
|
+
"end": "^\\s*(\\[/)(example)(\\])\\s*$",
|
|
177
|
+
"endCaptures": {
|
|
178
|
+
"1": { "name": "punctuation.definition.tag.begin.sdoc" },
|
|
179
|
+
"2": { "name": "entity.name.tag.sdoc" },
|
|
180
|
+
"3": { "name": "punctuation.definition.tag.end.sdoc" }
|
|
181
|
+
},
|
|
182
|
+
"patterns": [
|
|
183
|
+
{ "include": "#opener-attrs" },
|
|
184
|
+
{ "include": "source.svelte" }
|
|
185
|
+
]
|
|
186
|
+
},
|
|
187
|
+
"opener-attrs": {
|
|
188
|
+
"begin": "\\G",
|
|
189
|
+
"end": "(\\])",
|
|
190
|
+
"endCaptures": {
|
|
191
|
+
"1": { "name": "punctuation.definition.tag.end.sdoc" }
|
|
192
|
+
},
|
|
193
|
+
"patterns": [{ "include": "#attributes" }]
|
|
194
|
+
},
|
|
195
|
+
"attributes": {
|
|
196
|
+
"patterns": [
|
|
197
|
+
{
|
|
198
|
+
"match": "([A-Za-z_][\\w-]*)\\s*(=)",
|
|
199
|
+
"captures": {
|
|
200
|
+
"1": { "name": "entity.other.attribute-name.sdoc" },
|
|
201
|
+
"2": { "name": "punctuation.separator.key-value.sdoc" }
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"match": "([A-Za-z_][\\w-]*)",
|
|
206
|
+
"name": "entity.other.attribute-name.sdoc"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"name": "string.quoted.double.sdoc",
|
|
210
|
+
"begin": "\"",
|
|
211
|
+
"end": "\"",
|
|
212
|
+
"beginCaptures": {
|
|
213
|
+
"0": { "name": "punctuation.definition.string.begin.sdoc" }
|
|
214
|
+
},
|
|
215
|
+
"endCaptures": {
|
|
216
|
+
"0": { "name": "punctuation.definition.string.end.sdoc" }
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
{ "include": "#expression" }
|
|
220
|
+
]
|
|
221
|
+
},
|
|
222
|
+
"expression": {
|
|
223
|
+
"name": "meta.embedded.expression.sdoc",
|
|
224
|
+
"begin": "\\{",
|
|
225
|
+
"end": "\\}",
|
|
226
|
+
"beginCaptures": {
|
|
227
|
+
"0": { "name": "punctuation.section.embedded.begin.sdoc" }
|
|
228
|
+
},
|
|
229
|
+
"endCaptures": {
|
|
230
|
+
"0": { "name": "punctuation.section.embedded.end.sdoc" }
|
|
231
|
+
},
|
|
232
|
+
"patterns": [{ "include": "#expression-inner" }]
|
|
233
|
+
},
|
|
234
|
+
"expression-inner": {
|
|
235
|
+
"patterns": [
|
|
236
|
+
{
|
|
237
|
+
"begin": "\\{",
|
|
238
|
+
"end": "\\}",
|
|
239
|
+
"patterns": [{ "include": "#expression-inner" }]
|
|
240
|
+
},
|
|
241
|
+
{ "include": "source.ts" }
|
|
242
|
+
]
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { scanSdoc, offsetToPosition, ENTITY_KINDS, SUB_BLOCK_KINDS, type Span, type EntityKind, type SubBlockKind, type AttrValue, type Attrs, type SubBlock, type Entity, type TagBlock, type ScanError, type SdocFile, } from './scanner.js';
|
|
2
|
+
export { parseSdoc, parseArgsLiteral, slugifyTitle, normalizeBody, type ArgValue, type PreviewBlock, type ExampleBlock, type DocsEntity, type PageEntity, type LayoutEntity, type SdocEntity, type SdocDocument, } from './parser.js';
|
|
@@ -0,0 +1,82 @@
|
|
|
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 { type ScanError, type Span, type TagBlock } from './scanner.js';
|
|
7
|
+
export type ArgValue = string | number | boolean;
|
|
8
|
+
export interface PreviewBlock {
|
|
9
|
+
/** The identifier from component={X}; null when invalid/missing (already reported) */
|
|
10
|
+
componentName: string | null;
|
|
11
|
+
/** Parsed args literal; null when absent or invalid */
|
|
12
|
+
args: Record<string, ArgValue> | null;
|
|
13
|
+
/** Exact source of the args expression (inner object), for code display */
|
|
14
|
+
argsRaw: string | null;
|
|
15
|
+
/** Explicit title="…" override, when present */
|
|
16
|
+
title: string | null;
|
|
17
|
+
/** Tab label: the title override or the component name */
|
|
18
|
+
label: string;
|
|
19
|
+
body: string;
|
|
20
|
+
bodySpan: Span;
|
|
21
|
+
span: Span;
|
|
22
|
+
}
|
|
23
|
+
export interface ExampleBlock {
|
|
24
|
+
title: string;
|
|
25
|
+
body: string;
|
|
26
|
+
bodySpan: Span;
|
|
27
|
+
span: Span;
|
|
28
|
+
}
|
|
29
|
+
export interface DocsEntity {
|
|
30
|
+
kind: 'DOCS';
|
|
31
|
+
title: string;
|
|
32
|
+
slug: string;
|
|
33
|
+
description: string | null;
|
|
34
|
+
previews: PreviewBlock[];
|
|
35
|
+
examples: ExampleBlock[];
|
|
36
|
+
openerSpan: Span;
|
|
37
|
+
span: Span;
|
|
38
|
+
}
|
|
39
|
+
export interface PageEntity {
|
|
40
|
+
kind: 'PAGE';
|
|
41
|
+
title: string;
|
|
42
|
+
slug: string;
|
|
43
|
+
body: string;
|
|
44
|
+
bodySpan: Span;
|
|
45
|
+
openerSpan: Span;
|
|
46
|
+
span: Span;
|
|
47
|
+
}
|
|
48
|
+
export interface LayoutEntity {
|
|
49
|
+
kind: 'LAYOUT';
|
|
50
|
+
title: string;
|
|
51
|
+
slug: string;
|
|
52
|
+
padding: string | null;
|
|
53
|
+
body: string;
|
|
54
|
+
bodySpan: Span;
|
|
55
|
+
openerSpan: Span;
|
|
56
|
+
span: Span;
|
|
57
|
+
}
|
|
58
|
+
export type SdocEntity = DocsEntity | PageEntity | LayoutEntity;
|
|
59
|
+
export interface SdocDocument {
|
|
60
|
+
script: TagBlock | null;
|
|
61
|
+
style: TagBlock | null;
|
|
62
|
+
entities: SdocEntity[];
|
|
63
|
+
diagnostics: ScanError[];
|
|
64
|
+
source: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Normalize a raw block body for consumption: strip the common leading
|
|
68
|
+
* indentation (bodies sit one level deep inside their block), drop a
|
|
69
|
+
* trailing carriage return per line, and unescape lines that start with
|
|
70
|
+
* `\[` (the escape for body lines that would otherwise read as tags).
|
|
71
|
+
* The scanner keeps the raw text and spans; this is the display/runtime form.
|
|
72
|
+
*/
|
|
73
|
+
export declare function normalizeBody(raw: string): string;
|
|
74
|
+
/** Slug used for entity addressing: relPath + '#' + slug. */
|
|
75
|
+
export declare function slugifyTitle(title: string): string;
|
|
76
|
+
/**
|
|
77
|
+
* Parse a preview args expression: a flat object literal whose values are
|
|
78
|
+
* plain literals (strings, numbers, booleans). Anything richer belongs in
|
|
79
|
+
* the block body, so it is rejected here.
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseArgsLiteral(raw: string, span: Span, diagnostics: ScanError[]): Record<string, ArgValue> | null;
|
|
82
|
+
export declare function parseSdoc(source: string): SdocDocument;
|
|
@@ -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 {};
|