sdocs 0.0.46 → 0.0.47
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 +16 -0
- package/dist/server/snippet-compiler.d.ts +9 -1
- package/dist/server/snippet-compiler.js +15 -4
- package/dist/vite.js +6 -6
- package/package.json +67 -67
- package/dist/language/parser.test.d.ts +0 -1
- package/dist/language/parser.test.js +0 -158
- package/dist/language/projection.test.d.ts +0 -1
- package/dist/language/projection.test.js +0 -117
- package/dist/language/scanner.test.d.ts +0 -1
- package/dist/language/scanner.test.js +0 -146
- package/dist/server/prop-parser.test.d.ts +0 -1
- package/dist/server/prop-parser.test.js +0 -148
- package/dist/server/snippet-compiler.test.d.ts +0 -1
- package/dist/server/snippet-compiler.test.js +0 -23
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.47] - 2026-07-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Shared values in the file `<script>` reach previews and examples.** The
|
|
15
|
+
pipeline lifted only `import` lines into a preview, so a shared value like
|
|
16
|
+
`const options = […]` was `undefined` at render time even though the
|
|
17
|
+
language reference promises the file script's shared values are available
|
|
18
|
+
to every entity. The whole file script (imports and declarations) is now
|
|
19
|
+
lifted, with its relative imports resolved.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Test files moved out of `src/lib` into `tests/` — they no longer ship
|
|
24
|
+
compiled inside the published package.
|
|
25
|
+
|
|
10
26
|
## [0.0.46] - 2026-07-04
|
|
11
27
|
|
|
12
28
|
### Fixed
|
|
@@ -12,10 +12,18 @@ export declare function encodeEntityId(filePath: string, entitySlug: string): st
|
|
|
12
12
|
export declare function entityKey(filePath: string, entitySlug: string): string;
|
|
13
13
|
/** Resolve relative imports to absolute paths for use in virtual components */
|
|
14
14
|
export declare function resolveImportsToAbsolute(imports: string[], docFilePath: string): string[];
|
|
15
|
+
/**
|
|
16
|
+
* Rewrite the relative import specifiers in a whole `<script>` block to
|
|
17
|
+
* absolute paths, so the file script — imports AND shared values (consts,
|
|
18
|
+
* functions) — can be lifted verbatim into a generated preview. Everything
|
|
19
|
+
* else in the script is preserved, which is what makes shared values
|
|
20
|
+
* available to previews and examples.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveScriptImports(script: string, docFilePath: string): string;
|
|
15
23
|
/** Generate a virtual Svelte iframe wrapper component for a snippet.
|
|
16
24
|
* Includes $state for reactive prop updates via postMessage, invokes
|
|
17
25
|
* component methods on request, and broadcasts exported state values. */
|
|
18
|
-
export declare function generateIframeComponent(
|
|
26
|
+
export declare function generateIframeComponent(scriptPrelude: string, snippetBody: string, stateNames?: string[], componentName?: string): string;
|
|
19
27
|
/** The JS that boots a preview page: mount the wrapper + parent-frame messaging. */
|
|
20
28
|
export declare function generateMountScript(iframeComponentId: string): string;
|
|
21
29
|
/** Generate the HTML page served inside the iframe (dev / CLI-build inputs) */
|
|
@@ -47,6 +47,17 @@ export function resolveImportsToAbsolute(imports, docFilePath) {
|
|
|
47
47
|
return imp;
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Rewrite the relative import specifiers in a whole `<script>` block to
|
|
52
|
+
* absolute paths, so the file script — imports AND shared values (consts,
|
|
53
|
+
* functions) — can be lifted verbatim into a generated preview. Everything
|
|
54
|
+
* else in the script is preserved, which is what makes shared values
|
|
55
|
+
* available to previews and examples.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveScriptImports(script, docFilePath) {
|
|
58
|
+
const docDir = dirname(docFilePath);
|
|
59
|
+
return script.replace(/(\bfrom\s+|\bimport\s+)(['"])(\.\.?\/[^'"]*)\2/g, (_m, keyword, quote, spec) => `${keyword}${quote}${resolve(docDir, spec)}${quote}`);
|
|
60
|
+
}
|
|
50
61
|
/** Add bind:this to the documented component in the snippet so the wrapper
|
|
51
62
|
* can reach its exported methods and state. Binds the first occurrence of
|
|
52
63
|
* the doc's component when named (so wrapped children like
|
|
@@ -70,10 +81,10 @@ function injectRootRef(snippetBody, componentName) {
|
|
|
70
81
|
/** Generate a virtual Svelte iframe wrapper component for a snippet.
|
|
71
82
|
* Includes $state for reactive prop updates via postMessage, invokes
|
|
72
83
|
* component methods on request, and broadcasts exported state values. */
|
|
73
|
-
export function generateIframeComponent(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
export function generateIframeComponent(scriptPrelude, snippetBody, stateNames = [], componentName) {
|
|
85
|
+
// The file <script> (imports + shared values), lifted verbatim so previews
|
|
86
|
+
// and examples see everything the entity's siblings do.
|
|
87
|
+
const importBlock = scriptPrelude.trim() ? scriptPrelude.trim() + '\n' : '';
|
|
77
88
|
const stateBroadcast = stateNames.length > 0
|
|
78
89
|
? `
|
|
79
90
|
$effect(() => {
|
package/dist/vite.js
CHANGED
|
@@ -6,7 +6,7 @@ import { parseSdoc, offsetToPosition } from './language/index.js';
|
|
|
6
6
|
import { planEntitySnippets, extractImports, resolveComponentImport, } from './server/doc-model.js';
|
|
7
7
|
import { renderPageMarkdown } from './server/page-markdown.js';
|
|
8
8
|
import { highlight, disposeHighlighter } from './server/highlighter.js';
|
|
9
|
-
import { parseIframeId, parseMountId, parsePreviewUrl,
|
|
9
|
+
import { parseIframeId, parseMountId, parsePreviewUrl, resolveScriptImports, generateIframeComponent, generateMountScript, generatePreviewHtml, generateStaticPreviewHtml, iframeVirtualId, mountVirtualId, previewUrl, buildPreviewUrl, encodeEntityId, entityKey, setDocPathRoot, } from './server/snippet-compiler.js';
|
|
10
10
|
const VIRTUAL_MODULE_ID = 'virtual:sdocs';
|
|
11
11
|
const RESOLVED_VIRTUAL_ID = '\0virtual:sdocs';
|
|
12
12
|
const IFRAME_PREFIX = '/@sdocs/iframe/';
|
|
@@ -25,7 +25,7 @@ export function sdocsPlugin(userConfig) {
|
|
|
25
25
|
let root;
|
|
26
26
|
let server;
|
|
27
27
|
let docEntries = new Map();
|
|
28
|
-
let
|
|
28
|
+
let docScriptCache = new Map();
|
|
29
29
|
const buildMode = userConfig?._buildMode ?? false;
|
|
30
30
|
let isBuild = false;
|
|
31
31
|
let isSsrBuild = false;
|
|
@@ -92,7 +92,7 @@ export function sdocsPlugin(userConfig) {
|
|
|
92
92
|
if (isDocFile(filePath)) {
|
|
93
93
|
console.log(`[sdocs] Removed doc file: ${filePath}`);
|
|
94
94
|
deleteEntriesOf(filePath);
|
|
95
|
-
|
|
95
|
+
docScriptCache.delete(filePath);
|
|
96
96
|
invalidateVirtualModule();
|
|
97
97
|
}
|
|
98
98
|
});
|
|
@@ -223,12 +223,12 @@ export function sdocsPlugin(userConfig) {
|
|
|
223
223
|
const snippet = allSnippets(entry).find((s) => s.slug === parsed.snippetSlug);
|
|
224
224
|
if (!snippet)
|
|
225
225
|
return null;
|
|
226
|
-
const
|
|
226
|
+
const scriptPrelude = docScriptCache.get(parsed.docFilePath) ?? '';
|
|
227
227
|
// Method calls and live state bind to the snippet's own preview;
|
|
228
228
|
// example iframes fall back to the first preview's component.
|
|
229
229
|
const preview = entry.previews.find((p) => p.snippet.slug === snippet.slug) ?? entry.previews[0];
|
|
230
230
|
const stateNames = (preview?.componentData?.state ?? []).map((s) => s.name);
|
|
231
|
-
return generateIframeComponent(
|
|
231
|
+
return generateIframeComponent(scriptPrelude, snippet.body, stateNames, preview?.componentName ?? undefined);
|
|
232
232
|
}
|
|
233
233
|
// Virtual mount script for an emitted preview page
|
|
234
234
|
if (id.startsWith('\0' + MOUNT_PREFIX)) {
|
|
@@ -262,7 +262,7 @@ export function sdocsPlugin(userConfig) {
|
|
|
262
262
|
deleteEntriesOf(filePath);
|
|
263
263
|
const scriptContent = doc.script?.content ?? '';
|
|
264
264
|
const imports = extractImports(scriptContent);
|
|
265
|
-
|
|
265
|
+
docScriptCache.set(filePath, resolveScriptImports(scriptContent, filePath));
|
|
266
266
|
// One component parse per component file per rebuild, however many
|
|
267
267
|
// previews reference it.
|
|
268
268
|
const componentCache = new Map();
|
package/package.json
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
2
|
+
"name": "sdocs",
|
|
3
|
+
"version": "0.0.47",
|
|
4
|
+
"description": "A lightweight documentation tool for Svelte 5 components",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/gabilungu/sdocs.git",
|
|
10
|
+
"directory": "packages/sdocs"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"sdocs": "./bin/sdocs.js"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"svelte": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./vite": {
|
|
21
|
+
"types": "./dist/vite.d.ts",
|
|
22
|
+
"default": "./dist/vite.js"
|
|
23
|
+
},
|
|
24
|
+
"./language": {
|
|
25
|
+
"types": "./dist/language/index.d.ts",
|
|
26
|
+
"default": "./dist/language/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./grammar/sdoc.tmLanguage.json": "./dist/grammar/sdoc.tmLanguage.json",
|
|
29
|
+
"./explorer": {
|
|
30
|
+
"svelte": "./dist/explorer/Explorer.svelte"
|
|
31
|
+
},
|
|
32
|
+
"./ui": {
|
|
33
|
+
"types": "./dist/ui/index.d.ts",
|
|
34
|
+
"svelte": "./dist/ui/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"bin",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@sveltejs/vite-plugin-svelte": "*",
|
|
45
|
+
"svelte": "*",
|
|
46
|
+
"vite": "*"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@sveltejs/package": "^2.5.7",
|
|
50
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
51
|
+
"svelte": "^5.50.0",
|
|
52
|
+
"svelte-check": "^4.0.0",
|
|
53
|
+
"typescript": "^5.7.0",
|
|
54
|
+
"vite": "^7.3.1",
|
|
55
|
+
"vitest": "^3.0.0"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"dev": "vite",
|
|
59
|
+
"build": "svelte-package -i src/lib -o dist",
|
|
60
|
+
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
61
|
+
"test": "vitest run"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"marked": "^16.4.2",
|
|
65
|
+
"shiki": "^3.22.0",
|
|
66
|
+
"tinyglobby": "^0.2.15",
|
|
67
|
+
"typescript": "^5.7.0"
|
|
68
|
+
}
|
|
69
69
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,158 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
-
import { resolve } from 'node:path';
|
|
4
|
-
import { compile } from 'svelte/compiler';
|
|
5
|
-
import { scanSdoc } from './scanner.js';
|
|
6
|
-
import { projectSdoc } from './projection.js';
|
|
7
|
-
const SOURCE = `<script lang="ts">
|
|
8
|
-
import Tabs from './Tabs.svelte';
|
|
9
|
-
import Tab from './Tab.svelte';
|
|
10
|
-
const shared = 1;
|
|
11
|
-
</script>
|
|
12
|
-
|
|
13
|
-
[DOCS title="Nav / Tabs" description="A tab bar."]
|
|
14
|
-
|
|
15
|
-
[preview component={Tabs} args={{ active: 0 }}]
|
|
16
|
-
<Tabs {...args} active={shared}>x</Tabs>
|
|
17
|
-
[/preview]
|
|
18
|
-
|
|
19
|
-
[example title="Wrapped child"]
|
|
20
|
-
<Tabs><Tab {...args} /></Tabs>
|
|
21
|
-
[/example]
|
|
22
|
-
|
|
23
|
-
[/DOCS]
|
|
24
|
-
|
|
25
|
-
[PAGE title="Guide"]
|
|
26
|
-
|
|
27
|
-
## Heading
|
|
28
|
-
|
|
29
|
-
Uses {shared} and <Tabs />.
|
|
30
|
-
|
|
31
|
-
\`\`\`svelte
|
|
32
|
-
<NotReal {broken} />
|
|
33
|
-
\`\`\`
|
|
34
|
-
|
|
35
|
-
Inline \`{code}\` is masked.
|
|
36
|
-
|
|
37
|
-
[/PAGE]
|
|
38
|
-
|
|
39
|
-
<style>
|
|
40
|
-
.x { color: red; }
|
|
41
|
-
</style>
|
|
42
|
-
`;
|
|
43
|
-
describe('projectSdoc', () => {
|
|
44
|
-
const projection = projectSdoc(scanSdoc(SOURCE));
|
|
45
|
-
const lines = projection.text.split('\n');
|
|
46
|
-
const sourceLines = SOURCE.split('\n');
|
|
47
|
-
it('preserves every authored line position', () => {
|
|
48
|
-
expect(projection.sourceLineCount).toBe(sourceLines.length);
|
|
49
|
-
for (let i = 0; i < sourceLines.length; i++) {
|
|
50
|
-
const kind = projection.lineKinds[i];
|
|
51
|
-
if (kind === 'verbatim')
|
|
52
|
-
expect(lines[i]).toBe(sourceLines[i]);
|
|
53
|
-
if (kind === 'blank')
|
|
54
|
-
expect(lines[i]).toBe('');
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
const at = (needle) => sourceLines.findIndex((l) => l.includes(needle));
|
|
58
|
-
it('keeps script, style, and bodies verbatim at identical lines', () => {
|
|
59
|
-
expect(lines[1]).toBe("\timport Tabs from './Tabs.svelte';");
|
|
60
|
-
expect(lines[at('{...args} active')]).toBe(sourceLines[at('{...args} active')]);
|
|
61
|
-
expect(lines[at('color: red')]).toContain('color: red');
|
|
62
|
-
});
|
|
63
|
-
it('rewrites sub-block openers to snippet wrappers in place', () => {
|
|
64
|
-
expect(lines[at('[preview')]).toBe('{#snippet __sdocs$0_0(args: any)}');
|
|
65
|
-
expect(lines[at('[/preview]')]).toBe('{/snippet}');
|
|
66
|
-
expect(lines[at('[example')]).toBe('{#snippet __sdocs$0_1(args: any)}');
|
|
67
|
-
});
|
|
68
|
-
it('blanks entity tags and masks PAGE code while keeping prose live', () => {
|
|
69
|
-
expect(lines[at('[DOCS')]).toBe('');
|
|
70
|
-
const proseLine = sourceLines.findIndex((l) => l.includes('Uses {shared}'));
|
|
71
|
-
expect(lines[proseLine]).toBe(sourceLines[proseLine]);
|
|
72
|
-
const fenceBody = sourceLines.findIndex((l) => l.includes('NotReal'));
|
|
73
|
-
expect(lines[fenceBody]).toBe('');
|
|
74
|
-
const inline = sourceLines.findIndex((l) => l.includes('Inline'));
|
|
75
|
-
expect(lines[inline]).not.toContain('{code}');
|
|
76
|
-
expect(lines[inline].length).toBe(sourceLines[inline].length);
|
|
77
|
-
});
|
|
78
|
-
it('appends a trailer that renders snippets and references components', () => {
|
|
79
|
-
const trailer = lines.slice(projection.sourceLineCount).join('\n');
|
|
80
|
-
expect(trailer).toContain('{@render __sdocs$0_0({})}');
|
|
81
|
-
expect(trailer).toContain('{@render __sdocs$1()}');
|
|
82
|
-
expect(trailer).toContain('<Tabs />');
|
|
83
|
-
});
|
|
84
|
-
it('produces text the Svelte compiler accepts', () => {
|
|
85
|
-
expect(() => compile(projection.text, { generate: false })).not.toThrow();
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
describe('projectSdoc over the real corpus', () => {
|
|
89
|
-
const dirs = [
|
|
90
|
-
resolve(__dirname, '../../../../../apps/site/src/lib/ui'),
|
|
91
|
-
resolve(__dirname, '../../../../../apps/testapp-embedded/src/lib/UI'),
|
|
92
|
-
resolve(__dirname, '../../../../../apps/testapp-standalone/src'),
|
|
93
|
-
];
|
|
94
|
-
const files = [];
|
|
95
|
-
for (const dir of dirs) {
|
|
96
|
-
try {
|
|
97
|
-
for (const f of readdirSync(dir, { recursive: true })) {
|
|
98
|
-
if (f.endsWith('.sdoc'))
|
|
99
|
-
files.push(resolve(dir, f));
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
// corpus dir not present in this checkout
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
it('found the corpus', () => {
|
|
107
|
-
expect(files.length).toBeGreaterThanOrEqual(10);
|
|
108
|
-
});
|
|
109
|
-
for (const file of files) {
|
|
110
|
-
it(`compiles ${file.split('/').slice(-1)[0]}`, () => {
|
|
111
|
-
const source = readFileSync(file, 'utf-8');
|
|
112
|
-
const projection = projectSdoc(scanSdoc(source));
|
|
113
|
-
expect(projection.sourceLineCount).toBe(source.split('\n').length);
|
|
114
|
-
expect(() => compile(projection.text, { generate: false })).not.toThrow();
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,146 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { parseComponentSource } from './prop-parser.js';
|
|
3
|
-
function byName(list, name) {
|
|
4
|
-
const found = list.find((item) => item.name === name);
|
|
5
|
-
expect(found, `expected to find "${name}"`).toBeDefined();
|
|
6
|
-
return found;
|
|
7
|
-
}
|
|
8
|
-
describe('TypeScript components (interface Props)', () => {
|
|
9
|
-
const source = `<script lang="ts">
|
|
10
|
-
import type { Snippet } from 'svelte';
|
|
11
|
-
|
|
12
|
-
interface Props {
|
|
13
|
-
/** The button label text */
|
|
14
|
-
label?: string;
|
|
15
|
-
/** Visual style variant */
|
|
16
|
-
variant?: 'primary' | 'secondary';
|
|
17
|
-
count: number;
|
|
18
|
-
/** Click handler */
|
|
19
|
-
onclick?: (e: MouseEvent) => void;
|
|
20
|
-
oneTime?: boolean;
|
|
21
|
-
children?: Snippet;
|
|
22
|
-
item?: Snippet<[value: string]>;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
let { label = 'Button', variant = 'primary', count, onclick, oneTime, children, item }: Props = $props();
|
|
26
|
-
</script>
|
|
27
|
-
|
|
28
|
-
<button>{label}</button>`;
|
|
29
|
-
const data = parseComponentSource(source);
|
|
30
|
-
it('extracts types, defaults, descriptions, and requiredness', () => {
|
|
31
|
-
const label = byName(data.props, 'label');
|
|
32
|
-
expect(label.type).toBe('string');
|
|
33
|
-
expect(label.default).toBe('Button');
|
|
34
|
-
expect(label.description).toBe('The button label text');
|
|
35
|
-
expect(label.required).toBe(false);
|
|
36
|
-
const count = byName(data.props, 'count');
|
|
37
|
-
expect(count.type).toBe('number');
|
|
38
|
-
expect(count.default).toBe(null);
|
|
39
|
-
expect(count.required).toBe(true);
|
|
40
|
-
});
|
|
41
|
-
it('classifies events, snippets, and on-prefixed non-functions', () => {
|
|
42
|
-
expect(byName(data.props, 'onclick').category).toBe('event');
|
|
43
|
-
expect(byName(data.props, 'oneTime').category).toBe('prop');
|
|
44
|
-
expect(byName(data.props, 'children').category).toBe('snippet');
|
|
45
|
-
expect(byName(data.props, 'item').category).toBe('snippet');
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
describe('JS components with @type {{ ... }} annotation', () => {
|
|
49
|
-
const source = `<script>
|
|
50
|
-
/** @type {{ label?: string, size?: 'sm' | 'lg', onclick?: (e: MouseEvent) => void }} */
|
|
51
|
-
let { label = 'Badge', size = 'sm', onclick } = $props();
|
|
52
|
-
</script>
|
|
53
|
-
|
|
54
|
-
<span>{label}</span>`;
|
|
55
|
-
const data = parseComponentSource(source);
|
|
56
|
-
it('extracts types and optionality from the annotation', () => {
|
|
57
|
-
const label = byName(data.props, 'label');
|
|
58
|
-
expect(label.type).toBe('string');
|
|
59
|
-
expect(label.default).toBe('Badge');
|
|
60
|
-
expect(label.required).toBe(false);
|
|
61
|
-
expect(byName(data.props, 'size').type).toBe("'sm' | 'lg'");
|
|
62
|
-
});
|
|
63
|
-
it('classifies events from JSDoc function types', () => {
|
|
64
|
-
expect(byName(data.props, 'onclick').category).toBe('event');
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
describe('JS components with @typedef/@property', () => {
|
|
68
|
-
const source = `<script>
|
|
69
|
-
/**
|
|
70
|
-
* @typedef {Object} Props
|
|
71
|
-
* @property {string} [label] - The badge label
|
|
72
|
-
* @property {number} count - How many
|
|
73
|
-
* @property {() => void} [ondismiss] - Called when dismissed
|
|
74
|
-
* @property {import('svelte').Snippet} [children] - Body content
|
|
75
|
-
*/
|
|
76
|
-
|
|
77
|
-
/** @type {Props} */
|
|
78
|
-
let { label = 'Badge', count, ondismiss, children } = $props();
|
|
79
|
-
</script>
|
|
80
|
-
|
|
81
|
-
<span>{label}{count}</span>`;
|
|
82
|
-
const data = parseComponentSource(source);
|
|
83
|
-
it('extracts types, descriptions, and optionality from @property tags', () => {
|
|
84
|
-
const label = byName(data.props, 'label');
|
|
85
|
-
expect(label.type).toBe('string');
|
|
86
|
-
expect(label.description).toBe('The badge label');
|
|
87
|
-
expect(label.required).toBe(false);
|
|
88
|
-
expect(label.default).toBe('Badge');
|
|
89
|
-
const count = byName(data.props, 'count');
|
|
90
|
-
expect(count.type).toBe('number');
|
|
91
|
-
expect(count.description).toBe('How many');
|
|
92
|
-
expect(count.required).toBe(true);
|
|
93
|
-
});
|
|
94
|
-
it('classifies events and imported Snippet types', () => {
|
|
95
|
-
expect(byName(data.props, 'ondismiss').category).toBe('event');
|
|
96
|
-
expect(byName(data.props, 'children').category).toBe('snippet');
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
describe('untyped JS components', () => {
|
|
100
|
-
it('still extracts names and defaults from destructuring', () => {
|
|
101
|
-
const data = parseComponentSource(`<script>
|
|
102
|
-
let { label = 'x', flag } = $props();
|
|
103
|
-
</script>`);
|
|
104
|
-
expect(byName(data.props, 'label').default).toBe('x');
|
|
105
|
-
expect(byName(data.props, 'label').type).toBe(null);
|
|
106
|
-
expect(byName(data.props, 'flag').required).toBe(true);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
describe('methods, state, and CSS custom properties', () => {
|
|
110
|
-
const source = `<script lang="ts">
|
|
111
|
-
/** Clears the value */
|
|
112
|
-
export function clear(): void {}
|
|
113
|
-
|
|
114
|
-
/** Current count */
|
|
115
|
-
export const count = $state(0);
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* @cssvar {color} --bg - Background color
|
|
119
|
-
* @cssvar {dimension} --radius - Corner radius
|
|
120
|
-
*/
|
|
121
|
-
</script>
|
|
122
|
-
|
|
123
|
-
<style>
|
|
124
|
-
.x {
|
|
125
|
-
background: var(--bg, #333);
|
|
126
|
-
border-radius: var(--radius, 4px);
|
|
127
|
-
padding: var(--pad, 8px);
|
|
128
|
-
}
|
|
129
|
-
</style>`;
|
|
130
|
-
const data = parseComponentSource(source);
|
|
131
|
-
it('extracts exported functions with descriptions', () => {
|
|
132
|
-
const clear = byName(data.methods, 'clear');
|
|
133
|
-
expect(clear.returnType).toBe('void');
|
|
134
|
-
expect(clear.description).toBe('Clears the value');
|
|
135
|
-
});
|
|
136
|
-
it('extracts exported state', () => {
|
|
137
|
-
expect(byName(data.state, 'count').description).toBe('Current count');
|
|
138
|
-
});
|
|
139
|
-
it('extracts CSS vars, merging @cssvar type/description with var() defaults', () => {
|
|
140
|
-
const bg = byName(data.cssProps, '--bg');
|
|
141
|
-
expect(bg.type).toBe('color');
|
|
142
|
-
expect(bg.default).toBe('#333');
|
|
143
|
-
expect(bg.description).toBe('Background color');
|
|
144
|
-
const pad = byName(data.cssProps, '--pad');
|
|
145
|
-
expect(pad.type).toBe(null);
|
|
146
|
-
expect(pad.default).toBe('8px');
|
|
147
|
-
});
|
|
148
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { generateIframeComponent } from './snippet-compiler.js';
|
|
3
|
-
describe('root ref injection', () => {
|
|
4
|
-
it('binds the documented component, not the wrapper', () => {
|
|
5
|
-
const out = generateIframeComponent([], '<Tabs>\n\t<Tab {...args} />\n</Tabs>', [], 'Tab');
|
|
6
|
-
expect(out).toContain('<Tab bind:this={__sdocsRef} {...args} />');
|
|
7
|
-
expect(out).not.toContain('<Tabs bind:this');
|
|
8
|
-
});
|
|
9
|
-
it('binds the first capitalized tag when no component is named', () => {
|
|
10
|
-
const out = generateIframeComponent([], '<Card><Button /></Card>', []);
|
|
11
|
-
expect(out).toContain('<Card bind:this={__sdocsRef}>');
|
|
12
|
-
});
|
|
13
|
-
it('falls back to the first capitalized tag when the named component is absent', () => {
|
|
14
|
-
const out = generateIframeComponent([], '<Tabs active={0}>text</Tabs>', [], 'Tab');
|
|
15
|
-
expect(out).toContain('<Tabs bind:this={__sdocsRef} active={0}>');
|
|
16
|
-
});
|
|
17
|
-
it('leaves snippets with an author bind:this untouched', () => {
|
|
18
|
-
const body = '<Tab bind:this={mine} />';
|
|
19
|
-
const out = generateIframeComponent([], body, [], 'Tab');
|
|
20
|
-
expect(out).toContain(body);
|
|
21
|
-
expect(out).not.toContain('bind:this={__sdocsRef}');
|
|
22
|
-
});
|
|
23
|
-
});
|