sdocs 0.0.23 → 0.0.26
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 +91 -0
- package/dist/client/views/ComponentView.svelte +13 -2
- package/dist/server/app-gen.js +9 -4
- package/dist/server/config.js +1 -1
- package/dist/server/prop-parser.js +75 -2
- package/dist/server/prop-parser.test.d.ts +1 -0
- package/dist/server/prop-parser.test.js +148 -0
- package/dist/server/snippet-compiler.d.ts +17 -1
- package/dist/server/snippet-compiler.js +58 -24
- package/dist/ui/Button/Button.sdoc +3 -2
- package/dist/vite.js +101 -2
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `sdocs` package are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.0.26] - 2026-07-03
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Embedded production builds now include working previews.** The Vite
|
|
15
|
+
plugin emits each preview as a static page (plus your `css` stylesheets)
|
|
16
|
+
into the host app's client build under `previews/`, and `virtual:sdocs`
|
|
17
|
+
references those pages — preview iframes no longer 404 in deployed apps.
|
|
18
|
+
- The standalone CLI (`sdocs dev`/`sdocs build`) failed from an installed
|
|
19
|
+
package: the client app was staged from a wrong path, and its `ui/` styles
|
|
20
|
+
and fonts were not staged at all.
|
|
21
|
+
- The docs UI stops calling the dev-only highlight endpoint after the first
|
|
22
|
+
failure in production and falls back to plain code.
|
|
23
|
+
|
|
24
|
+
## [0.0.25] - 2026-07-02
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **JSDoc prop extraction for plain-JS components.** Both `@type {{ ... }}`
|
|
29
|
+
object annotations and `@typedef {Object} Props` with `@property` tags on
|
|
30
|
+
the `$props()` declaration are parsed — types, optionality (bracketed
|
|
31
|
+
names), and descriptions all flow into the docs and interactive controls.
|
|
32
|
+
- A prop-parser test suite; `npm test` now runs it.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Props typed as `import('svelte').Snippet` are classified as snippets.
|
|
37
|
+
|
|
38
|
+
## [0.0.24] - 2026-07-02
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Snippets receive `args` as a real snippet parameter: write
|
|
43
|
+
`{#snippet Default(args)}`. Previews render snippets through `{@render}`,
|
|
44
|
+
so `args` is an explicit, editor-visible binding instead of a value injected
|
|
45
|
+
into scope. Parameterless snippets keep working unchanged.
|
|
46
|
+
|
|
47
|
+
## [0.0.23] - 2026-04-16
|
|
48
|
+
|
|
49
|
+
First documented release. `sdocs` is a lightweight documentation tool for Svelte 5
|
|
50
|
+
components; this entry describes its capabilities as of 0.0.23.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **CLI** — `sdocs dev`, `sdocs build`, `sdocs preview`, and `sdocs init`.
|
|
55
|
+
- **Vite plugin** (`sdocs/vite`) for embedding the explorer in an existing Vite or
|
|
56
|
+
SvelteKit app, exposing discovered docs through the `virtual:sdocs` module.
|
|
57
|
+
- **Three `.sdoc` kinds** — component docs (`.sdoc`), standalone pages
|
|
58
|
+
(`.page.sdoc`), and full-page layouts (`.layout.sdoc`).
|
|
59
|
+
- **Prop extraction** from component types, rendered as a props table.
|
|
60
|
+
- **Interactive controls** for live-editing component props and CSS custom
|
|
61
|
+
properties.
|
|
62
|
+
- **Theming** with light and dark modes and named-stylesheet switching.
|
|
63
|
+
- **Configurable sidebar** ordering and hash-based routing.
|
|
64
|
+
- **Syntax highlighting** via Shiki.
|
|
65
|
+
- Package entry points: `sdocs`, `sdocs/vite`, `sdocs/client`, and `sdocs/ui`.
|
|
66
|
+
|
|
67
|
+
## [0.0.20] - 2026-04-15
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- Redesigned the component view and added snippet-based preview code.
|
|
72
|
+
|
|
73
|
+
## [0.0.18] - 2026-02-27
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- Scoped the explorer's CSS under `.sdocs-app` to keep its styles from leaking
|
|
78
|
+
into component previews.
|
|
79
|
+
|
|
80
|
+
## [0.0.17] - 2026-02-27
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
|
|
84
|
+
- Corrected the `Icon` component imports.
|
|
85
|
+
|
|
86
|
+
## [0.0.16] - 2026-02-27
|
|
87
|
+
|
|
88
|
+
### Added
|
|
89
|
+
|
|
90
|
+
- Rewritten codebase: the current architecture for `.sdoc` discovery, preview
|
|
91
|
+
rendering, and the component explorer UI.
|
|
@@ -169,13 +169,20 @@
|
|
|
169
169
|
return generateFallbackCode(componentName, propValues, cssValues);
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
-
// Highlighted usage code via server-side Shiki
|
|
172
|
+
// Highlighted usage code via server-side Shiki. The endpoint is dev-server
|
|
173
|
+
// middleware; in a production build it doesn't exist, so after the first
|
|
174
|
+
// failure stop asking and fall back to plain code.
|
|
173
175
|
let highlightedUsageHtml = $state('');
|
|
174
176
|
let highlightTimer: ReturnType<typeof setTimeout> | undefined;
|
|
177
|
+
let highlightAvailable = true;
|
|
175
178
|
|
|
176
179
|
$effect(() => {
|
|
177
180
|
const code = usageCode;
|
|
178
181
|
clearTimeout(highlightTimer);
|
|
182
|
+
if (!highlightAvailable) {
|
|
183
|
+
highlightedUsageHtml = '';
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
179
186
|
highlightTimer = setTimeout(async () => {
|
|
180
187
|
try {
|
|
181
188
|
const res = await fetch('/__sdocs/highlight', {
|
|
@@ -186,9 +193,13 @@
|
|
|
186
193
|
if (res.ok) {
|
|
187
194
|
const { html } = await res.json();
|
|
188
195
|
highlightedUsageHtml = html;
|
|
196
|
+
} else {
|
|
197
|
+
highlightAvailable = false;
|
|
198
|
+
highlightedUsageHtml = '';
|
|
189
199
|
}
|
|
190
200
|
} catch {
|
|
191
|
-
|
|
201
|
+
highlightAvailable = false;
|
|
202
|
+
highlightedUsageHtml = '';
|
|
192
203
|
}
|
|
193
204
|
}, 150);
|
|
194
205
|
});
|
package/dist/server/app-gen.js
CHANGED
|
@@ -5,9 +5,14 @@ import { discoverDocFiles, getSdocKind } from './discovery.js';
|
|
|
5
5
|
import { extractSnippets, hasDefaultSnippet } from './snippet-extractor.js';
|
|
6
6
|
import { base64urlEncode } from './snippet-compiler.js';
|
|
7
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
/** Source client directory in the installed package */
|
|
8
|
+
/** Source client directory in the installed package (dist/client, next to dist/server) */
|
|
9
9
|
function getClientSourceDir() {
|
|
10
|
-
return resolve(__dirname, 'client');
|
|
10
|
+
return resolve(__dirname, '../client');
|
|
11
|
+
}
|
|
12
|
+
/** Copy the client app plus the ui/ tree it imports (styles, fonts, components) */
|
|
13
|
+
async function copyClientApp(sdocsDir) {
|
|
14
|
+
await copyDir(getClientSourceDir(), resolve(sdocsDir, 'client'));
|
|
15
|
+
await copyDir(resolve(__dirname, '../ui'), resolve(sdocsDir, 'ui'));
|
|
11
16
|
}
|
|
12
17
|
/** Copy a directory recursively */
|
|
13
18
|
async function copyDir(src, dest) {
|
|
@@ -129,7 +134,7 @@ export async function generateDevFiles(config, cwd) {
|
|
|
129
134
|
const sdocsDir = resolve(cwd, '.sdocs');
|
|
130
135
|
await mkdir(sdocsDir, { recursive: true });
|
|
131
136
|
// Copy client components into .sdocs/client/ so they're compiled outside node_modules
|
|
132
|
-
await
|
|
137
|
+
await copyClientApp(sdocsDir);
|
|
133
138
|
await writeFile(resolve(sdocsDir, 'index.html'), generateIndexHtml(config.logo));
|
|
134
139
|
await writeFile(resolve(sdocsDir, 'entry.js'), generateEntryJs(config));
|
|
135
140
|
return sdocsDir;
|
|
@@ -139,7 +144,7 @@ export async function generateBuildFiles(config, cwd) {
|
|
|
139
144
|
const sdocsDir = resolve(cwd, '.sdocs');
|
|
140
145
|
await mkdir(sdocsDir, { recursive: true });
|
|
141
146
|
// Copy client components into .sdocs/client/
|
|
142
|
-
await
|
|
147
|
+
await copyClientApp(sdocsDir);
|
|
143
148
|
await writeFile(resolve(sdocsDir, 'index.html'), generateIndexHtml(config.logo));
|
|
144
149
|
await writeFile(resolve(sdocsDir, 'entry.js'), generateEntryJs(config));
|
|
145
150
|
const inputs = {
|
package/dist/server/config.js
CHANGED
|
@@ -58,7 +58,7 @@ export function resolveAndFinalize(userConfig, root) {
|
|
|
58
58
|
resolved.css = resolveCssPaths(resolved.css, root);
|
|
59
59
|
return resolved;
|
|
60
60
|
}
|
|
61
|
-
/** Import a config file (
|
|
61
|
+
/** Import a config file (.js/.mjs; .ts only on Node with native type stripping) */
|
|
62
62
|
async function importConfig(configPath) {
|
|
63
63
|
// Use dynamic import with file:// URL for ESM compatibility
|
|
64
64
|
const mod = await import(pathToFileURL(configPath).href);
|
|
@@ -15,9 +15,16 @@ export function parseComponentSource(source) {
|
|
|
15
15
|
if (scriptContent) {
|
|
16
16
|
const tsAst = ts.createSourceFile('component.ts', scriptContent, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
17
17
|
const interfaceProps = parseInterfaceProps(tsAst);
|
|
18
|
+
const jsdocTypeProps = parseJsdocTypeProps(tsAst);
|
|
18
19
|
const destructuredProps = parsePropsDestructuring(tsAst);
|
|
19
20
|
const jsdocData = parseJsdocComments(tsAst);
|
|
20
|
-
|
|
21
|
+
// TS interface wins over JSDoc types when both exist (they shouldn't).
|
|
22
|
+
const interfaceNames = new Set(interfaceProps.map((p) => p.name));
|
|
23
|
+
const typedProps = [
|
|
24
|
+
...interfaceProps,
|
|
25
|
+
...jsdocTypeProps.filter((p) => !interfaceNames.has(p.name)),
|
|
26
|
+
];
|
|
27
|
+
props = mergeProps(typedProps, destructuredProps, jsdocData);
|
|
21
28
|
methods = parseExportedFunctions(tsAst);
|
|
22
29
|
state = parseExportedState(tsAst);
|
|
23
30
|
}
|
|
@@ -52,6 +59,71 @@ function parseInterfaceProps(sourceFile) {
|
|
|
52
59
|
});
|
|
53
60
|
return props;
|
|
54
61
|
}
|
|
62
|
+
// ─── JSDoc-typed props (plain JS components) ───
|
|
63
|
+
/** Props typed via JSDoc: `@type {{ ... }}` on the $props() declaration, or
|
|
64
|
+
* `@type {Props}` referencing a `@typedef {Object} Props` with `@property` tags. */
|
|
65
|
+
function parseJsdocTypeProps(sourceFile) {
|
|
66
|
+
const props = [];
|
|
67
|
+
function fromTypeLiteral(literal) {
|
|
68
|
+
for (const member of literal.members) {
|
|
69
|
+
if (ts.isPropertySignature(member) && member.name) {
|
|
70
|
+
props.push({
|
|
71
|
+
name: member.name.getText(sourceFile),
|
|
72
|
+
type: member.type ? member.type.getText(sourceFile) : 'unknown',
|
|
73
|
+
optional: !!member.questionToken,
|
|
74
|
+
description: null,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function fromTypedef(name) {
|
|
80
|
+
function visit(node) {
|
|
81
|
+
// ts.getJSDocTags only surfaces the last JSDoc comment on a node;
|
|
82
|
+
// a standalone @typedef block above the @type one needs the raw list.
|
|
83
|
+
const jsDocs = node.jsDoc ?? [];
|
|
84
|
+
for (const tag of jsDocs.flatMap((jd) => [...(jd.tags ?? [])])) {
|
|
85
|
+
if (ts.isJSDocTypedefTag(tag) &&
|
|
86
|
+
tag.name?.getText(sourceFile) === name &&
|
|
87
|
+
tag.typeExpression &&
|
|
88
|
+
ts.isJSDocTypeLiteral(tag.typeExpression)) {
|
|
89
|
+
for (const propTag of tag.typeExpression.jsDocPropertyTags ?? []) {
|
|
90
|
+
const comment = ts.getTextOfJSDocComment(propTag.comment);
|
|
91
|
+
props.push({
|
|
92
|
+
name: propTag.name.getText(sourceFile),
|
|
93
|
+
type: propTag.typeExpression
|
|
94
|
+
? propTag.typeExpression.type.getText(sourceFile)
|
|
95
|
+
: 'unknown',
|
|
96
|
+
optional: propTag.isBracketed,
|
|
97
|
+
description: comment ? comment.replace(/^-\s*/, '').trim() : null,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
ts.forEachChild(node, visit);
|
|
103
|
+
}
|
|
104
|
+
visit(sourceFile);
|
|
105
|
+
}
|
|
106
|
+
function visit(node) {
|
|
107
|
+
if (ts.isVariableDeclaration(node) &&
|
|
108
|
+
node.initializer &&
|
|
109
|
+
ts.isCallExpression(node.initializer) &&
|
|
110
|
+
node.initializer.expression.getText(sourceFile) === '$props' &&
|
|
111
|
+
ts.isObjectBindingPattern(node.name)) {
|
|
112
|
+
// The @type tag sits on the enclosing statement; getJSDocType finds it.
|
|
113
|
+
const typeNode = ts.getJSDocType(node) ?? ts.getJSDocType(node.parent?.parent ?? node);
|
|
114
|
+
if (typeNode) {
|
|
115
|
+
if (ts.isTypeLiteralNode(typeNode))
|
|
116
|
+
fromTypeLiteral(typeNode);
|
|
117
|
+
else if (ts.isTypeReferenceNode(typeNode)) {
|
|
118
|
+
fromTypedef(typeNode.typeName.getText(sourceFile));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
ts.forEachChild(node, visit);
|
|
123
|
+
}
|
|
124
|
+
visit(sourceFile);
|
|
125
|
+
return props;
|
|
126
|
+
}
|
|
55
127
|
function parsePropsDestructuring(sourceFile) {
|
|
56
128
|
const props = [];
|
|
57
129
|
function visit(node) {
|
|
@@ -159,7 +231,8 @@ function mergeProps(interfaceProps, destructuredProps, jsdocData) {
|
|
|
159
231
|
function classifyProp(name, type) {
|
|
160
232
|
if (name.startsWith('on') && type?.includes('=>'))
|
|
161
233
|
return 'event';
|
|
162
|
-
|
|
234
|
+
// Matches `Snippet`, `Snippet<[...]>`, and `import('svelte').Snippet`
|
|
235
|
+
if (type && /(^|\.)Snippet\b/.test(type))
|
|
163
236
|
return 'snippet';
|
|
164
237
|
return 'prop';
|
|
165
238
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
});
|
|
@@ -7,14 +7,30 @@ export declare function resolveImportsToAbsolute(imports: string[], docFilePath:
|
|
|
7
7
|
/** Generate a virtual Svelte iframe wrapper component for a snippet.
|
|
8
8
|
* Includes $state for reactive prop updates via postMessage. */
|
|
9
9
|
export declare function generateIframeComponent(absoluteImports: string[], snippetBody: string): string;
|
|
10
|
-
/**
|
|
10
|
+
/** The JS that boots a preview page: mount the wrapper + parent-frame messaging. */
|
|
11
|
+
export declare function generateMountScript(iframeComponentId: string): string;
|
|
12
|
+
/** Generate the HTML page served inside the iframe (dev / CLI-build inputs) */
|
|
11
13
|
export declare function generatePreviewHtml(iframeComponentId: string, css: string | Record<string, string> | null): string;
|
|
14
|
+
export interface StaticCssLink {
|
|
15
|
+
href: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** Generate the HTML page for a preview emitted into a host app's build. */
|
|
20
|
+
export declare function generateStaticPreviewHtml(scriptSrc: string, cssLinks: StaticCssLink[]): string;
|
|
12
21
|
/** Build the virtual module ID for an iframe wrapper component */
|
|
13
22
|
export declare function iframeVirtualId(docFilePath: string, snippetName: string): string;
|
|
14
23
|
/** Build the preview URL for an iframe HTML page (dev mode) */
|
|
15
24
|
export declare function previewUrl(docFilePath: string, snippetName: string): string;
|
|
16
25
|
/** Build the preview URL for static build output */
|
|
17
26
|
export declare function buildPreviewUrl(docFilePath: string, snippetName: string): string;
|
|
27
|
+
/** Virtual module ID for a preview's mount script (embedded production builds) */
|
|
28
|
+
export declare function mountVirtualId(docFilePath: string, snippetName: string): string;
|
|
29
|
+
/** Parse a mount virtual ID back into its parts */
|
|
30
|
+
export declare function parseMountId(id: string): {
|
|
31
|
+
docFilePath: string;
|
|
32
|
+
snippetName: string;
|
|
33
|
+
} | null;
|
|
18
34
|
/** Parse an iframe virtual ID back into its parts */
|
|
19
35
|
export declare function parseIframeId(id: string): {
|
|
20
36
|
docFilePath: string;
|
|
@@ -25,7 +25,8 @@ export function generateIframeComponent(absoluteImports, snippetBody) {
|
|
|
25
25
|
const importBlock = absoluteImports.length > 0
|
|
26
26
|
? absoluteImports.join('\n') + '\n'
|
|
27
27
|
: '';
|
|
28
|
-
|
|
28
|
+
// lang="ts" so lifted imports may carry type-only syntax (Svelte 5 erases it natively)
|
|
29
|
+
return `<script lang="ts">
|
|
29
30
|
${importBlock}import { onMount } from 'svelte';
|
|
30
31
|
|
|
31
32
|
let args = $state({});
|
|
@@ -68,7 +69,10 @@ export function generateIframeComponent(absoluteImports, snippetBody) {
|
|
|
68
69
|
</script>
|
|
69
70
|
|
|
70
71
|
<div id="sdocs-preview">
|
|
71
|
-
|
|
72
|
+
{#snippet SdocsPreview(args)}
|
|
73
|
+
${snippetBody}
|
|
74
|
+
{/snippet}
|
|
75
|
+
{@render SdocsPreview(args)}
|
|
72
76
|
</div>`;
|
|
73
77
|
}
|
|
74
78
|
/** Convert a CSS path to a Vite-servable URL */
|
|
@@ -93,9 +97,27 @@ function generateCssLinks(css) {
|
|
|
93
97
|
.map((name, i) => `<link rel="stylesheet" href="${normalizeCssHref(css[name])}" data-sdocs-stylesheet="${name}"${i > 0 ? ' disabled' : ''}>`)
|
|
94
98
|
.join('\n\t');
|
|
95
99
|
}
|
|
96
|
-
/**
|
|
97
|
-
export function
|
|
98
|
-
|
|
100
|
+
/** The JS that boots a preview page: mount the wrapper + parent-frame messaging. */
|
|
101
|
+
export function generateMountScript(iframeComponentId) {
|
|
102
|
+
return `import { mount } from 'svelte';
|
|
103
|
+
import App from '${iframeComponentId}';
|
|
104
|
+
mount(App, { target: document.getElementById('app') });
|
|
105
|
+
|
|
106
|
+
// Listen for sdocs messages from the parent frame
|
|
107
|
+
window.addEventListener('message', (e) => {
|
|
108
|
+
if (e.data?.type === 'sdocs:update-stylesheet') {
|
|
109
|
+
const name = e.data.name;
|
|
110
|
+
document.querySelectorAll('link[data-sdocs-stylesheet]').forEach((link) => {
|
|
111
|
+
link.disabled = link.dataset.sdocsStylesheet !== name;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (e.data?.type === 'sdocs:scroll-to') {
|
|
115
|
+
const el = document.getElementById(e.data.id);
|
|
116
|
+
if (el) el.scrollIntoView({ behavior: 'smooth' });
|
|
117
|
+
}
|
|
118
|
+
});`;
|
|
119
|
+
}
|
|
120
|
+
function previewHtmlShell(cssLinks, script) {
|
|
99
121
|
return `<!DOCTYPE html>
|
|
100
122
|
<html>
|
|
101
123
|
<head>
|
|
@@ -106,28 +128,26 @@ export function generatePreviewHtml(iframeComponentId, css) {
|
|
|
106
128
|
</head>
|
|
107
129
|
<body>
|
|
108
130
|
<div id="app"></div>
|
|
109
|
-
|
|
110
|
-
import { mount } from 'svelte';
|
|
111
|
-
import App from '${iframeComponentId}';
|
|
112
|
-
mount(App, { target: document.getElementById('app') });
|
|
113
|
-
|
|
114
|
-
// Listen for sdocs messages from the parent frame
|
|
115
|
-
window.addEventListener('message', (e) => {
|
|
116
|
-
if (e.data?.type === 'sdocs:update-stylesheet') {
|
|
117
|
-
const name = e.data.name;
|
|
118
|
-
document.querySelectorAll('link[data-sdocs-stylesheet]').forEach((link) => {
|
|
119
|
-
link.disabled = link.dataset.sdocsStylesheet !== name;
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
if (e.data?.type === 'sdocs:scroll-to') {
|
|
123
|
-
const el = document.getElementById(e.data.id);
|
|
124
|
-
if (el) el.scrollIntoView({ behavior: 'smooth' });
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
</script>
|
|
131
|
+
${script}
|
|
128
132
|
</body>
|
|
129
133
|
</html>`;
|
|
130
134
|
}
|
|
135
|
+
/** Generate the HTML page served inside the iframe (dev / CLI-build inputs) */
|
|
136
|
+
export function generatePreviewHtml(iframeComponentId, css) {
|
|
137
|
+
return previewHtmlShell(generateCssLinks(css), `<script type="module">
|
|
138
|
+
${generateMountScript(iframeComponentId)}
|
|
139
|
+
</script>`);
|
|
140
|
+
}
|
|
141
|
+
/** Generate the HTML page for a preview emitted into a host app's build. */
|
|
142
|
+
export function generateStaticPreviewHtml(scriptSrc, cssLinks) {
|
|
143
|
+
const links = cssLinks
|
|
144
|
+
.map(({ href, name, disabled }) => {
|
|
145
|
+
const named = name ? ` data-sdocs-stylesheet="${name}"` : '';
|
|
146
|
+
return `<link rel="stylesheet" href="${href}"${named}${disabled ? ' disabled' : ''}>`;
|
|
147
|
+
})
|
|
148
|
+
.join('\n\t');
|
|
149
|
+
return previewHtmlShell(links, `<script type="module" src="${scriptSrc}"></script>`);
|
|
150
|
+
}
|
|
131
151
|
/** Build the virtual module ID for an iframe wrapper component */
|
|
132
152
|
export function iframeVirtualId(docFilePath, snippetName) {
|
|
133
153
|
return `/@sdocs/iframe/${base64urlEncode(docFilePath)}/${snippetName}.svelte`;
|
|
@@ -140,6 +160,20 @@ export function previewUrl(docFilePath, snippetName) {
|
|
|
140
160
|
export function buildPreviewUrl(docFilePath, snippetName) {
|
|
141
161
|
return `/previews/${base64urlEncode(docFilePath)}/${snippetName}.html`;
|
|
142
162
|
}
|
|
163
|
+
/** Virtual module ID for a preview's mount script (embedded production builds) */
|
|
164
|
+
export function mountVirtualId(docFilePath, snippetName) {
|
|
165
|
+
return `/@sdocs/mount/${base64urlEncode(docFilePath)}/${snippetName}.js`;
|
|
166
|
+
}
|
|
167
|
+
/** Parse a mount virtual ID back into its parts */
|
|
168
|
+
export function parseMountId(id) {
|
|
169
|
+
const match = id.match(/^\/@sdocs\/mount\/([^/]+)\/(\w+)\.js$/);
|
|
170
|
+
if (!match)
|
|
171
|
+
return null;
|
|
172
|
+
return {
|
|
173
|
+
docFilePath: base64urlDecode(match[1]),
|
|
174
|
+
snippetName: match[2],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
143
177
|
/** Parse an iframe virtual ID back into its parts */
|
|
144
178
|
export function parseIframeId(id) {
|
|
145
179
|
const match = id.match(/^\/@sdocs\/iframe\/([^/]+)\/(\w+)\.svelte$/);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import type { ComponentProps } from 'svelte';
|
|
2
3
|
import Button from './Button.svelte';
|
|
3
4
|
import { Icon } from '../Icon/index.js';
|
|
4
5
|
|
|
@@ -9,8 +10,8 @@
|
|
|
9
10
|
};
|
|
10
11
|
</script>
|
|
11
12
|
|
|
12
|
-
{#snippet Default()}
|
|
13
|
-
<Button>Click me</Button>
|
|
13
|
+
{#snippet Default(args: ComponentProps<typeof Button>)}
|
|
14
|
+
<Button {...args}>Click me</Button>
|
|
14
15
|
{/snippet}
|
|
15
16
|
|
|
16
17
|
{#snippet WithLeftIcon()}
|
package/dist/vite.js
CHANGED
|
@@ -6,11 +6,12 @@ import { parseComponent } from './server/prop-parser.js';
|
|
|
6
6
|
import { extractSnippets, extractMarkupBody, hasDefaultSnippet, generateAutoDefault, } from './server/snippet-extractor.js';
|
|
7
7
|
import { highlight, disposeHighlighter } from './server/highlighter.js';
|
|
8
8
|
import { extractTocFromHtml } from './server/toc-extractor.js';
|
|
9
|
-
import { parseIframeId, parsePreviewUrl, resolveImportsToAbsolute, generateIframeComponent, generatePreviewHtml, iframeVirtualId, previewUrl, buildPreviewUrl, } from './server/snippet-compiler.js';
|
|
9
|
+
import { parseIframeId, parseMountId, parsePreviewUrl, resolveImportsToAbsolute, generateIframeComponent, generateMountScript, generatePreviewHtml, generateStaticPreviewHtml, iframeVirtualId, mountVirtualId, previewUrl, buildPreviewUrl, base64urlEncode, } 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/';
|
|
13
13
|
const PREVIEW_PREFIX = '/@sdocs/preview/';
|
|
14
|
+
const MOUNT_PREFIX = '/@sdocs/mount/';
|
|
14
15
|
export function sdocsPlugin(userConfig) {
|
|
15
16
|
let config;
|
|
16
17
|
let root;
|
|
@@ -18,10 +19,17 @@ export function sdocsPlugin(userConfig) {
|
|
|
18
19
|
let docEntries = new Map();
|
|
19
20
|
let docImportsCache = new Map();
|
|
20
21
|
const buildMode = userConfig?._buildMode ?? false;
|
|
22
|
+
let isBuild = false;
|
|
23
|
+
let isSsrBuild = false;
|
|
24
|
+
// Previews planned for emission into a host app's build (embedded mode)
|
|
25
|
+
let plannedPreviews = [];
|
|
26
|
+
let emittedCssLinks = [];
|
|
21
27
|
return {
|
|
22
28
|
name: 'sdocs',
|
|
23
29
|
async configResolved(resolvedConfig) {
|
|
24
30
|
root = resolvedConfig.root;
|
|
31
|
+
isBuild = resolvedConfig.command === 'build';
|
|
32
|
+
isSsrBuild = !!resolvedConfig.build?.ssr;
|
|
25
33
|
const fileConfig = await loadRawConfig(root);
|
|
26
34
|
const merged = { ...fileConfig, ...userConfig };
|
|
27
35
|
config = resolveAndFinalize(merged, root);
|
|
@@ -125,12 +133,85 @@ export function sdocsPlugin(userConfig) {
|
|
|
125
133
|
server.watcher.add(dir);
|
|
126
134
|
}
|
|
127
135
|
}
|
|
136
|
+
// Embedded production build: emit each preview as its own chunk so the
|
|
137
|
+
// host app's build output contains working static preview pages. (The
|
|
138
|
+
// standalone CLI build passes _buildMode and provides HTML inputs itself;
|
|
139
|
+
// the SSR half of an app build has no use for browser preview pages.)
|
|
140
|
+
if (isBuild && !buildMode && !isSsrBuild) {
|
|
141
|
+
plannedPreviews = [];
|
|
142
|
+
emittedCssLinks = [];
|
|
143
|
+
const css = config.css;
|
|
144
|
+
if (typeof css === 'string') {
|
|
145
|
+
emittedCssLinks.push({ href: await emitCssAsset(this, css, 'preview') });
|
|
146
|
+
}
|
|
147
|
+
else if (css) {
|
|
148
|
+
for (const [i, name] of Object.keys(css).entries()) {
|
|
149
|
+
emittedCssLinks.push({
|
|
150
|
+
href: await emitCssAsset(this, css[name], name),
|
|
151
|
+
name,
|
|
152
|
+
disabled: i > 0,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const entry of docEntries.values()) {
|
|
157
|
+
const encoded = base64urlEncode(entry.filePath);
|
|
158
|
+
for (const snippet of entry.snippets) {
|
|
159
|
+
const jsFileName = `previews/${encoded}/${snippet.name}.js`;
|
|
160
|
+
this.emitFile({
|
|
161
|
+
type: 'chunk',
|
|
162
|
+
id: mountVirtualId(entry.filePath, snippet.name),
|
|
163
|
+
fileName: jsFileName,
|
|
164
|
+
});
|
|
165
|
+
plannedPreviews.push({
|
|
166
|
+
jsFileName,
|
|
167
|
+
htmlFileName: `previews/${encoded}/${snippet.name}.html`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
console.log(`[sdocs] Emitting ${plannedPreviews.length} static preview page(s)`);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
generateBundle(_options, bundle) {
|
|
175
|
+
for (const preview of plannedPreviews) {
|
|
176
|
+
const chunk = bundle[preview.jsFileName];
|
|
177
|
+
if (!chunk || chunk.type !== 'chunk')
|
|
178
|
+
continue;
|
|
179
|
+
// Collect CSS emitted for this chunk and everything it imports.
|
|
180
|
+
const cssFiles = new Set();
|
|
181
|
+
const queue = [preview.jsFileName];
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
while (queue.length) {
|
|
184
|
+
const fileName = queue.pop();
|
|
185
|
+
if (seen.has(fileName))
|
|
186
|
+
continue;
|
|
187
|
+
seen.add(fileName);
|
|
188
|
+
const mod = bundle[fileName];
|
|
189
|
+
if (!mod || mod.type !== 'chunk')
|
|
190
|
+
continue;
|
|
191
|
+
for (const css of mod.viteMetadata?.importedCss ?? [])
|
|
192
|
+
cssFiles.add(css);
|
|
193
|
+
queue.push(...mod.imports);
|
|
194
|
+
}
|
|
195
|
+
// The HTML sits at previews/<doc>/<name>.html — two levels deep.
|
|
196
|
+
const cssLinks = [
|
|
197
|
+
...emittedCssLinks,
|
|
198
|
+
...[...cssFiles].map((file) => ({ href: `../../${file}` })),
|
|
199
|
+
];
|
|
200
|
+
this.emitFile({
|
|
201
|
+
type: 'asset',
|
|
202
|
+
fileName: preview.htmlFileName,
|
|
203
|
+
source: generateStaticPreviewHtml(`./${preview.jsFileName.split('/').pop()}`, cssLinks),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
plannedPreviews = [];
|
|
128
207
|
},
|
|
129
208
|
resolveId(id) {
|
|
130
209
|
if (id === VIRTUAL_MODULE_ID)
|
|
131
210
|
return RESOLVED_VIRTUAL_ID;
|
|
132
211
|
if (id.startsWith(IFRAME_PREFIX))
|
|
133
212
|
return '\0' + id;
|
|
213
|
+
if (id.startsWith(MOUNT_PREFIX))
|
|
214
|
+
return '\0' + id;
|
|
134
215
|
},
|
|
135
216
|
load(id) {
|
|
136
217
|
if (id === RESOLVED_VIRTUAL_ID) {
|
|
@@ -151,6 +232,13 @@ export function sdocsPlugin(userConfig) {
|
|
|
151
232
|
const absoluteImports = docImportsCache.get(parsed.docFilePath) ?? [];
|
|
152
233
|
return generateIframeComponent(absoluteImports, snippet.body);
|
|
153
234
|
}
|
|
235
|
+
// Virtual mount script for an emitted preview page
|
|
236
|
+
if (id.startsWith('\0' + MOUNT_PREFIX)) {
|
|
237
|
+
const parsed = parseMountId(id.slice(1));
|
|
238
|
+
if (!parsed)
|
|
239
|
+
return null;
|
|
240
|
+
return generateMountScript(iframeVirtualId(parsed.docFilePath, parsed.snippetName));
|
|
241
|
+
}
|
|
154
242
|
},
|
|
155
243
|
async buildEnd() {
|
|
156
244
|
await disposeHighlighter();
|
|
@@ -232,7 +320,9 @@ export function sdocsPlugin(userConfig) {
|
|
|
232
320
|
name: s.name,
|
|
233
321
|
body: s.body,
|
|
234
322
|
highlightedHtml: s.highlightedHtml,
|
|
235
|
-
previewUrl: buildMode
|
|
323
|
+
previewUrl: buildMode || isBuild
|
|
324
|
+
? buildPreviewUrl(e.filePath, s.name)
|
|
325
|
+
: previewUrl(e.filePath, s.name),
|
|
236
326
|
})),
|
|
237
327
|
highlightedSource: e.highlightedSource,
|
|
238
328
|
toc: e.toc,
|
|
@@ -269,6 +359,15 @@ export function sdocsPlugin(userConfig) {
|
|
|
269
359
|
function isDocFile(filePath) {
|
|
270
360
|
return filePath.endsWith('.sdoc');
|
|
271
361
|
}
|
|
362
|
+
/** Emit a user stylesheet as a build asset; returns its href relative to a preview page. */
|
|
363
|
+
async function emitCssAsset(ctx, href, name) {
|
|
364
|
+
if (href.startsWith('http'))
|
|
365
|
+
return href;
|
|
366
|
+
const source = await readFile(href, 'utf-8');
|
|
367
|
+
const fileName = `previews/_css/${name}.css`;
|
|
368
|
+
ctx.emitFile({ type: 'asset', fileName, source });
|
|
369
|
+
return `../../${fileName}`;
|
|
370
|
+
}
|
|
272
371
|
function isComponentReferencedByDoc(filePath) {
|
|
273
372
|
for (const entry of docEntries.values()) {
|
|
274
373
|
if (entry.componentPath === filePath)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sdocs",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.26",
|
|
4
4
|
"description": "A lightweight documentation tool for Svelte 5 components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"dist",
|
|
29
|
-
"bin"
|
|
29
|
+
"bin",
|
|
30
|
+
"CHANGELOG.md"
|
|
30
31
|
],
|
|
31
32
|
"peerDependencies": {
|
|
32
33
|
"@sveltejs/vite-plugin-svelte": "*",
|