sdocs 0.0.41 → 0.0.43

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/explorer/tree-builder.js +2 -4
  3. package/dist/explorer/views/ComponentView.svelte +85 -29
  4. package/dist/explorer/views/LayoutView.svelte +1 -1
  5. package/dist/explorer/views/PageView.svelte +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/language/index.d.ts +1 -0
  8. package/dist/language/index.js +1 -0
  9. package/dist/language/projection.d.ts +23 -0
  10. package/dist/language/projection.js +136 -0
  11. package/dist/language/projection.test.d.ts +1 -0
  12. package/dist/language/projection.test.js +117 -0
  13. package/dist/server/app-gen.js +18 -21
  14. package/dist/server/discovery.d.ts +0 -2
  15. package/dist/server/discovery.js +0 -9
  16. package/dist/server/doc-model.d.ts +26 -0
  17. package/dist/server/doc-model.js +58 -0
  18. package/dist/server/page-markdown.d.ts +15 -0
  19. package/dist/server/page-markdown.js +104 -0
  20. package/dist/server/snippet-compiler.d.ts +18 -18
  21. package/dist/server/snippet-compiler.js +32 -29
  22. package/dist/types.d.ts +35 -19
  23. package/dist/ui/About.sdoc +28 -0
  24. package/dist/ui/Button/Button.sdoc +41 -44
  25. package/dist/ui/Control/Checkbox.sdoc +7 -13
  26. package/dist/ui/Control/Color.sdoc +7 -13
  27. package/dist/ui/Control/Dimension.sdoc +7 -13
  28. package/dist/ui/Control/Number.sdoc +7 -13
  29. package/dist/ui/Control/Select.sdoc +7 -14
  30. package/dist/ui/Control/Text.sdoc +7 -13
  31. package/dist/ui/Dashboard.sdoc +57 -0
  32. package/dist/ui/Frame/Frame.sdoc +46 -48
  33. package/dist/ui/Icon/Icon.sdoc +33 -38
  34. package/dist/ui/NavTree/Group.sdoc +36 -38
  35. package/dist/ui/NavTree/Item.sdoc +52 -57
  36. package/dist/ui/NavTree/NavTree.sdoc +89 -91
  37. package/dist/ui/Stack/Stack.sdoc +41 -43
  38. package/dist/vite.js +168 -99
  39. package/package.json +2 -1
  40. package/dist/server/meta-parser.d.ts +0 -11
  41. package/dist/server/meta-parser.js +0 -109
  42. package/dist/server/snippet-extractor.d.ts +0 -11
  43. package/dist/server/snippet-extractor.js +0 -83
  44. package/dist/server/toc-extractor.d.ts +0 -3
  45. package/dist/server/toc-extractor.js +0 -23
  46. package/dist/ui/About.page.sdoc +0 -37
  47. package/dist/ui/Dashboard.layout.sdoc +0 -60
package/CHANGELOG.md CHANGED
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.43] - 2026-07-04
11
+
12
+ ### Added
13
+
14
+ - **`projectSdoc` in `sdocs/language`** — a line-preserving projection of a
15
+ `.sdoc` file onto a virtual Svelte document: every authored line keeps its
16
+ exact position, sub-block openers become the same `{#snippet}` wrappers
17
+ the build generates, PAGE prose stays live with code regions masked, and
18
+ a trailer past the authored end marks everything as used. This is the
19
+ foundation of the VS Code extension's sdoc language server.
20
+
21
+ ### Changed
22
+
23
+ - The Explorer's own UI library docs (`src/lib/ui/*.sdoc`) are converted to
24
+ the block format.
25
+
26
+ ## [0.0.42] - 2026-07-04
27
+
28
+ ### Changed
29
+
30
+ - **Breaking: `.sdoc` files are the block format.** A doc file is now
31
+ `<script>` on top, `[DOCS]`/`[PAGE]`/`[LAYOUT]` entity blocks in the
32
+ middle, `<style>` at the bottom — the language documented in the site's
33
+ Language section. Each entity is its own sidebar entry, so one file can
34
+ hold several. The `export const meta` convention, `{#snippet}` extraction,
35
+ auto-generated `Default` previews, and the `.page.sdoc`/`.layout.sdoc`
36
+ filename kinds are gone.
37
+ - **`[DOCS]` holds any number of previews.** Each
38
+ `[preview component={X} args={{…}}]` is self-contained; several render as
39
+ tabs, each a fully live panel with its own controls, extracted API tables,
40
+ and component source. `[example title="…"]` blocks are frozen and render
41
+ page-level below the tabs.
42
+ - **`[PAGE]` bodies are markdown** — GFM with `{expression}` interpolation
43
+ and Svelte component islands; code fences and inline code are inert. The
44
+ table of contents comes from the markdown headings.
45
+ - **Parsing never crashes the dev server.** Doc files parse through the
46
+ `sdocs/language` scanner; mistakes surface as file:line warnings and the
47
+ rest of the file keeps working.
48
+ - **Breaking for embedders:** `DocEntry` is reshaped (`previews`/`examples`/
49
+ `content` replace `snippets`/`componentData`), and preview URLs address
50
+ entities as `path#slug`.
51
+
10
52
  ## [0.0.41] - 2026-07-04
11
53
 
12
54
  ### Added
@@ -44,9 +44,7 @@ export function buildTree(docs, sidebar) {
44
44
  // Create the item node
45
45
  const itemPath = [...currentPath, itemName];
46
46
  if (kind === 'component') {
47
- const examples = doc.snippets
48
- ?.filter((s) => s.name !== 'Default')
49
- .map((s) => s.name) ?? [];
47
+ const examples = doc.examples?.map((s) => s.name) ?? [];
50
48
  // Check if a folder node with this name already exists (created by a child doc)
51
49
  const existing = parent.find((n) => n.name === itemName);
52
50
  const componentNode = existing ?? {
@@ -195,7 +193,7 @@ export function findDocByPath(docs, path) {
195
193
  // One extra segment → example
196
194
  if (segments.length === path.length - 1 && segments.every((s, i) => s === path[i])) {
197
195
  const snippetName = path[path.length - 1];
198
- const hasSnippet = doc.snippets?.some((s) => s.name === snippetName);
196
+ const hasSnippet = doc.examples?.some((s) => s.name === snippetName);
199
197
  if (hasSnippet) {
200
198
  return { doc, snippetName };
201
199
  }
@@ -18,28 +18,40 @@
18
18
  let { doc, snippetName, activeStylesheet }: Props = $props();
19
19
 
20
20
  const meta = $derived(doc.meta);
21
- const cd = $derived(doc.componentData);
21
+ const previews = $derived(doc.previews ?? []);
22
+
23
+ // Active preview tab (one full live panel per preview)
24
+ let activeIndex = $state(0);
25
+ $effect(() => {
26
+ doc;
27
+ activeIndex = 0;
28
+ });
29
+ const activePreview = $derived(
30
+ previews.length > 0 ? previews[Math.min(activeIndex, previews.length - 1)] : undefined,
31
+ );
32
+
33
+ const cd = $derived(activePreview?.componentData ?? null);
22
34
  const componentName = $derived(
23
- typeof meta.component === 'string'
24
- ? meta.component
25
- : (meta.title ?? '').split('/').pop()?.trim() ?? 'Component',
35
+ activePreview?.componentName ??
36
+ ((meta.title ?? '').split('/').pop()?.trim() || 'Component'),
37
+ );
38
+ const exampleSnippets = $derived(doc.examples ?? []);
39
+ const focusedSnippet = $derived(
40
+ snippetName ? exampleSnippets.find((s) => s.name === snippetName) : null,
26
41
  );
27
- const snippets = $derived(doc.snippets ?? []);
28
- const defaultSnippet = $derived(snippets.find((s) => s.name === 'Default'));
29
- const exampleSnippets = $derived(snippets.filter((s) => s.name !== 'Default'));
30
- const focusedSnippet = $derived(snippetName ? snippets.find((s) => s.name === snippetName) : null);
31
42
 
32
43
  // Props/CSS controls state
33
44
  let propValues = $state<Record<string, unknown>>({});
34
45
  let cssValues = $state<Record<string, string>>({});
35
46
 
36
- // Live wiring to the default preview: method invocation + exported state values
47
+ // Live wiring to the active preview: method invocation + exported state values
37
48
  let defaultPreview = $state<{ callMethod: (name: string) => void }>();
38
49
  let liveStateValues = $state<Record<string, unknown>>({});
39
50
 
40
- // Initialize from meta.args (build new objects to avoid read+write loop)
51
+ // Initialize from the active preview's args (build new objects to avoid
52
+ // read+write loop); re-runs on doc and tab change.
41
53
  $effect(() => {
42
- propValues = { ...(meta.args ?? {}) };
54
+ propValues = { ...(activePreview?.args ?? {}) };
43
55
  const newCss: Record<string, string> = {};
44
56
  if (cd?.cssProps) {
45
57
  for (const cp of cd.cssProps) {
@@ -47,6 +59,7 @@
47
59
  }
48
60
  }
49
61
  cssValues = newCss;
62
+ liveStateValues = {};
50
63
  });
51
64
 
52
65
  function handlePropChange(name: string, value: unknown) {
@@ -159,8 +172,8 @@
159
172
  return snippetBody.slice(0, attrsStart) + attrs + closing + snippetBody.slice(tagEnd + 1);
160
173
  }
161
174
 
162
- // Initial values for diffing (computed once per doc change)
163
- const initialProps = $derived(meta.args ?? {});
175
+ // Initial values for diffing (computed once per doc/tab change)
176
+ const initialProps = $derived(activePreview?.args ?? {});
164
177
  const initialCss = $derived.by(() => {
165
178
  const css: Record<string, string> = {};
166
179
  if (cd?.cssProps) {
@@ -172,8 +185,8 @@
172
185
  });
173
186
 
174
187
  const usageCode = $derived.by(() => {
175
- if (defaultSnippet?.body) {
176
- return patchSnippetCode(defaultSnippet.body, componentName, propValues, cssValues, initialProps, initialCss);
188
+ if (activePreview?.snippet.body) {
189
+ return patchSnippetCode(activePreview.snippet.body, componentName, propValues, cssValues, initialProps, initialCss);
177
190
  }
178
191
  return generateFallbackCode(componentName, propValues, cssValues);
179
192
  });
@@ -198,7 +211,7 @@
198
211
  });
199
212
 
200
213
  function handleReset() {
201
- propValues = { ...(meta.args ?? {}) };
214
+ propValues = { ...(activePreview?.args ?? {}) };
202
215
  const newCss: Record<string, string> = {};
203
216
  if (cd?.cssProps) {
204
217
  for (const cp of cd.cssProps) {
@@ -290,19 +303,37 @@
290
303
  {/if}
291
304
  </div>
292
305
 
306
+ {#if previews.length > 1}
307
+ <div class="sdocs-preview-tabs" role="tablist">
308
+ {#each previews as preview, i (preview.snippet.slug)}
309
+ <button
310
+ class="sdocs-preview-tab"
311
+ class:active={i === activeIndex}
312
+ role="tab"
313
+ aria-selected={i === activeIndex}
314
+ onclick={() => (activeIndex = i)}
315
+ >
316
+ {preview.label}
317
+ </button>
318
+ {/each}
319
+ </div>
320
+ {/if}
321
+
293
322
  <div class="sdocs-panels">
294
323
  <!-- Showcase -->
295
- {#if defaultSnippet}
296
- <div class="sdocs-preview-wrapper">
297
- <PreviewFrame
298
- bind:this={defaultPreview}
299
- src={defaultSnippet.previewUrl ?? ''}
300
- props={propValues}
301
- cssVars={cssValues}
302
- {activeStylesheet}
303
- onStateValues={(values) => (liveStateValues = values)}
304
- />
305
- </div>
324
+ {#if activePreview}
325
+ {#key activePreview.snippet.slug}
326
+ <div class="sdocs-preview-wrapper">
327
+ <PreviewFrame
328
+ bind:this={defaultPreview}
329
+ src={activePreview.snippet.previewUrl ?? ''}
330
+ props={propValues}
331
+ cssVars={cssValues}
332
+ {activeStylesheet}
333
+ onStateValues={(values) => (liveStateValues = values)}
334
+ />
335
+ </div>
336
+ {/key}
306
337
 
307
338
  <CollapsiblePanel title="Preview Code" defaultExpanded={false}>
308
339
  <div class="sdocs-code-block">
@@ -414,11 +445,11 @@
414
445
  {/each}
415
446
  {/if}
416
447
 
417
- {#if doc.highlightedSource}
448
+ {#if activePreview?.highlightedSource}
418
449
  <hr class="sdocs-divider" />
419
450
  <div class="sdocs-panels">
420
451
  <CollapsiblePanel title="Component Source" defaultExpanded={false}>
421
- <div class="sdocs-code-block">{@html doc.highlightedSource}</div>
452
+ <div class="sdocs-code-block">{@html activePreview.highlightedSource}</div>
422
453
  </CollapsiblePanel>
423
454
  </div>
424
455
  {/if}
@@ -476,6 +507,31 @@
476
507
  opacity: 0.4;
477
508
  cursor: default;
478
509
  }
510
+ .sdocs-preview-tabs {
511
+ display: flex;
512
+ gap: 4px;
513
+ margin-bottom: 12px;
514
+ border-bottom: 1px solid var(--color-base-200);
515
+ }
516
+ .sdocs-preview-tab {
517
+ padding: 7px 14px;
518
+ border: none;
519
+ border-bottom: 2px solid transparent;
520
+ background: none;
521
+ font: inherit;
522
+ font-size: 13px;
523
+ font-weight: 500;
524
+ color: var(--color-base-500);
525
+ cursor: pointer;
526
+ }
527
+ .sdocs-preview-tab:hover {
528
+ color: var(--color-base-800);
529
+ }
530
+ .sdocs-preview-tab.active {
531
+ color: var(--color-base-900);
532
+ border-bottom-color: var(--color-accent-500, var(--color-base-900));
533
+ font-weight: 600;
534
+ }
479
535
  .sdocs-view-header {
480
536
  margin-bottom: 24px;
481
537
  }
@@ -9,7 +9,7 @@
9
9
 
10
10
  let { doc, activeStylesheet }: Props = $props();
11
11
 
12
- const contentSnippet = $derived(doc.snippets?.[0]);
12
+ const contentSnippet = $derived(doc.content);
13
13
  </script>
14
14
 
15
15
  <div class="sdocs-layout-view">
@@ -10,7 +10,7 @@
10
10
  let { doc, activeStylesheet }: Props = $props();
11
11
 
12
12
  const meta = $derived(doc.meta);
13
- const contentSnippet = $derived(doc.snippets?.[0]);
13
+ const contentSnippet = $derived(doc.content);
14
14
  const toc = $derived(doc.toc ?? []);
15
15
 
16
16
  function scrollToHeading(id: string) {
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { sdocsPlugin } from './vite.js';
2
- export type { SdocsConfig, ResolvedSdocsConfig, SdocMeta, DocEntry, ParsedProp, ParsedMethod, ParsedState, ParsedCssProp, ComponentData, ExtractedSnippet, } from './types.js';
2
+ export type { SdocsConfig, ResolvedSdocsConfig, SdocMeta, DocEntry, ParsedProp, ParsedMethod, ParsedState, ParsedCssProp, ComponentData, ExtractedSnippet, PreviewEntry, TocHeading, } from './types.js';
@@ -1,2 +1,3 @@
1
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 { projectSdoc, type SdocProjection, type ProjectedLineKind, } from './projection.js';
2
3
  export { parseSdoc, parseArgsLiteral, slugifyTitle, normalizeBody, type ArgValue, type PreviewBlock, type ExampleBlock, type DocsEntity, type PageEntity, type LayoutEntity, type SdocEntity, type SdocDocument, } from './parser.js';
@@ -1,2 +1,3 @@
1
1
  export { scanSdoc, offsetToPosition, ENTITY_KINDS, SUB_BLOCK_KINDS, } from './scanner.js';
2
+ export { projectSdoc, } from './projection.js';
2
3
  export { parseSdoc, parseArgsLiteral, slugifyTitle, normalizeBody, } from './parser.js';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Line-preserving projection of a .sdoc file into a virtual Svelte document.
3
+ *
4
+ * Every authored line keeps its exact (line, column) in the virtual text, so
5
+ * editor position mapping is the identity function. Block syntax the Svelte
6
+ * compiler must not see becomes blank lines; sub-block openers are rewritten
7
+ * IN PLACE to `{#snippet …(args)}` wrappers — byte-compatible with the shape
8
+ * the build pipeline generates at runtime — and a trailer appended past the
9
+ * authored end renders every snippet and references previewed components, so
10
+ * nothing draws unused-import noise. Diagnostics on trailer lines
11
+ * (line >= sourceLineCount) are generated, never authored.
12
+ */
13
+ import type { SdocFile } from './scanner.js';
14
+ export type ProjectedLineKind = 'verbatim' | 'blank' | 'wrapper' | 'masked';
15
+ export interface SdocProjection {
16
+ /** The virtual Svelte document text */
17
+ text: string;
18
+ /** Authored line count; virtual lines at or past this are generated */
19
+ sourceLineCount: number;
20
+ /** Per authored line: how it was projected */
21
+ lineKinds: ProjectedLineKind[];
22
+ }
23
+ export declare function projectSdoc(file: SdocFile): SdocProjection;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Line-preserving projection of a .sdoc file into a virtual Svelte document.
3
+ *
4
+ * Every authored line keeps its exact (line, column) in the virtual text, so
5
+ * editor position mapping is the identity function. Block syntax the Svelte
6
+ * compiler must not see becomes blank lines; sub-block openers are rewritten
7
+ * IN PLACE to `{#snippet …(args)}` wrappers — byte-compatible with the shape
8
+ * the build pipeline generates at runtime — and a trailer appended past the
9
+ * authored end renders every snippet and references previewed components, so
10
+ * nothing draws unused-import noise. Diagnostics on trailer lines
11
+ * (line >= sourceLineCount) are generated, never authored.
12
+ */
13
+ function lineStartsOf(source) {
14
+ const starts = [0];
15
+ for (let i = 0; i < source.length; i++) {
16
+ if (source[i] === '\n')
17
+ starts.push(i + 1);
18
+ }
19
+ return starts;
20
+ }
21
+ function lineOfOffset(starts, offset) {
22
+ let lo = 0;
23
+ let hi = starts.length - 1;
24
+ while (lo < hi) {
25
+ const mid = (lo + hi + 1) >> 1;
26
+ if (starts[mid] <= offset)
27
+ lo = mid;
28
+ else
29
+ hi = mid - 1;
30
+ }
31
+ return lo;
32
+ }
33
+ /** Mask markdown code so a PAGE body reads as Svelte-safe prose: fence lines
34
+ * and fence interiors go blank; inline `code` spans are space-overwritten
35
+ * (column-preserving). Everything else — prose, {expressions}, component
36
+ * islands — stays verbatim. */
37
+ function maskMarkdownLine(line, state) {
38
+ const fenceMarker = /^\s*(`{3,}|~{3,})/.test(line);
39
+ if (fenceMarker) {
40
+ state.inFence = !state.inFence;
41
+ return { text: '', masked: true };
42
+ }
43
+ if (state.inFence)
44
+ return { text: '', masked: true };
45
+ if (!line.includes('`'))
46
+ return { text: line, masked: false };
47
+ const text = line.replace(/`[^`]*`/g, (m) => ' '.repeat(m.length));
48
+ return { text, masked: text !== line };
49
+ }
50
+ export function projectSdoc(file) {
51
+ const source = file.source;
52
+ const starts = lineStartsOf(source);
53
+ const total = starts.length;
54
+ const out = new Array(total).fill('');
55
+ const kinds = new Array(total).fill('blank');
56
+ const copyVerbatim = (span) => {
57
+ const first = lineOfOffset(starts, span.start);
58
+ const last = lineOfOffset(starts, Math.max(span.start, span.end - 1));
59
+ for (let l = first; l <= last; l++) {
60
+ const end = l + 1 < total ? starts[l + 1] - 1 : source.length;
61
+ out[l] = source.slice(starts[l], end).replace(/\r$/, '');
62
+ kinds[l] = 'verbatim';
63
+ }
64
+ };
65
+ if (file.script)
66
+ copyVerbatim(file.script.span);
67
+ if (file.style)
68
+ copyVerbatim(file.style.span);
69
+ const isTs = /lang\s*=\s*["']ts["']/.test(file.script?.attrsText ?? '');
70
+ const argsParam = isTs ? '(args: any)' : '(args)';
71
+ const snippets = [];
72
+ const componentRefs = new Set();
73
+ const wrapBlock = (name, openerSpan, bodySpan, closerLineOf, // block span; its end sits on the closer line
74
+ withArgs, maskBody) => {
75
+ const openFirst = lineOfOffset(starts, openerSpan.start);
76
+ const openLast = lineOfOffset(starts, Math.max(openerSpan.start, openerSpan.end - 1));
77
+ out[openFirst] = `{#snippet ${name}${withArgs ? argsParam : '()'}}`;
78
+ kinds[openFirst] = 'wrapper';
79
+ for (let l = openFirst + 1; l <= openLast; l++) {
80
+ out[l] = '';
81
+ kinds[l] = 'blank';
82
+ }
83
+ if (bodySpan.end > bodySpan.start) {
84
+ if (maskBody) {
85
+ const first = lineOfOffset(starts, bodySpan.start);
86
+ const last = lineOfOffset(starts, Math.max(bodySpan.start, bodySpan.end - 1));
87
+ const state = { inFence: false };
88
+ for (let l = first; l <= last; l++) {
89
+ const end = l + 1 < total ? starts[l + 1] - 1 : source.length;
90
+ const line = source.slice(starts[l], end).replace(/\r$/, '');
91
+ const masked = maskMarkdownLine(line, state);
92
+ out[l] = masked.text;
93
+ kinds[l] = masked.masked ? 'masked' : 'verbatim';
94
+ }
95
+ }
96
+ else {
97
+ copyVerbatim(bodySpan);
98
+ }
99
+ }
100
+ const closeLine = lineOfOffset(starts, Math.max(closerLineOf.start, closerLineOf.end - 1));
101
+ out[closeLine] = '{/snippet}';
102
+ kinds[closeLine] = 'wrapper';
103
+ snippets.push({ name, withArgs });
104
+ };
105
+ file.entities.forEach((entity, e) => {
106
+ if (entity.kind === 'DOCS') {
107
+ entity.blocks.forEach((block, b) => {
108
+ wrapBlock(`__sdocs$${e}_${b}`, block.openerSpan, block.bodySpan, block.span, true, false);
109
+ const component = block.attrs.component;
110
+ if (component?.kind === 'expression') {
111
+ const name = component.raw.trim();
112
+ if (/^[A-Z][A-Za-z0-9_]*$/.test(name))
113
+ componentRefs.add(name);
114
+ }
115
+ });
116
+ }
117
+ else {
118
+ wrapBlock(`__sdocs$${e}`, entity.openerSpan, entity.bodySpan, entity.span, false, entity.kind === 'PAGE');
119
+ }
120
+ });
121
+ // Trailer: render every snippet and reference previewed components so the
122
+ // Svelte/TS layer sees everything as used. Lives past the authored end.
123
+ const trailer = ['{#if false}'];
124
+ for (const snippet of snippets) {
125
+ trailer.push(`{@render ${snippet.name}(${snippet.withArgs ? '{}' : ''})}`);
126
+ }
127
+ for (const name of componentRefs) {
128
+ trailer.push(`<${name} />`);
129
+ }
130
+ trailer.push('{/if}');
131
+ return {
132
+ text: out.join('\n') + '\n' + trailer.join('\n') + '\n',
133
+ sourceLineCount: total,
134
+ lineKinds: kinds,
135
+ };
136
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,117 @@
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
+ });
@@ -3,9 +3,10 @@ import { existsSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, resolve, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { discoverDocFiles, getSdocKind } from './discovery.js';
7
- import { extractSnippets, hasDefaultSnippet } from './snippet-extractor.js';
8
- import { encodeDocPath, setDocPathRoot } from './snippet-compiler.js';
6
+ import { discoverDocFiles } from './discovery.js';
7
+ import { parseSdoc } from '../language/index.js';
8
+ import { planEntitySnippets } from './doc-model.js';
9
+ import { encodeEntityId, setDocPathRoot } from './snippet-compiler.js';
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
  /** Source Explorer directory in the installed package (dist/explorer, next to dist/server) */
11
12
  function getExplorerSourceDir() {
@@ -141,23 +142,19 @@ function generatePreviewHtml(iframeVirtualId, css) {
141
142
  </body>
142
143
  </html>`;
143
144
  }
144
- /** Discover doc files and extract snippet names (lightweight, no highlighting) */
145
+ /** Discover doc files and plan snippet slugs per entity (lightweight, no highlighting) */
145
146
  async function discoverSnippets(config, cwd) {
146
147
  const files = await discoverDocFiles(config.include, cwd);
147
148
  const results = [];
148
149
  for (const filePath of files) {
149
150
  const source = await readFile(filePath, 'utf-8');
150
- const kind = getSdocKind(filePath);
151
- if (kind === 'page' || kind === 'layout') {
152
- results.push({ filePath, snippetNames: ['Content'] });
153
- }
154
- else {
155
- const snippets = extractSnippets(source);
156
- const names = snippets.map((s) => s.name);
157
- if (!hasDefaultSnippet(snippets)) {
158
- names.unshift('Default');
159
- }
160
- results.push({ filePath, snippetNames: names });
151
+ const doc = parseSdoc(source);
152
+ for (const entity of doc.entities) {
153
+ results.push({
154
+ filePath,
155
+ entitySlug: entity.slug,
156
+ snippetSlugs: planEntitySnippets(entity).map((s) => s.slug),
157
+ });
161
158
  }
162
159
  }
163
160
  return results;
@@ -186,15 +183,15 @@ export async function generateBuildFiles(config, cwd) {
186
183
  };
187
184
  // Discover snippets and generate preview HTML pages
188
185
  const docSnippets = await discoverSnippets(config, cwd);
189
- for (const { filePath, snippetNames } of docSnippets) {
190
- const encoded = encodeDocPath(filePath);
191
- for (const snippetName of snippetNames) {
192
- const iframeId = `/@sdocs/iframe/${encoded}/${snippetName}.svelte`;
186
+ for (const { filePath, entitySlug, snippetSlugs } of docSnippets) {
187
+ const encoded = encodeEntityId(filePath, entitySlug);
188
+ for (const snippetSlug of snippetSlugs) {
189
+ const iframeId = `/@sdocs/iframe/${encoded}/${snippetSlug}.svelte`;
193
190
  const previewDir = resolve(sdocsDir, 'previews', encoded);
194
191
  await mkdir(previewDir, { recursive: true });
195
- const previewPath = resolve(previewDir, `${snippetName}.html`);
192
+ const previewPath = resolve(previewDir, `${snippetSlug}.html`);
196
193
  await writeFile(previewPath, generatePreviewHtml(iframeId, config.css));
197
- const inputKey = `preview-${encoded}-${snippetName}`;
194
+ const inputKey = `preview-${encoded}-${snippetSlug}`;
198
195
  inputs[inputKey] = previewPath;
199
196
  }
200
197
  }
@@ -1,6 +1,4 @@
1
1
  /** Discover all .sdoc files matching the include patterns */
2
2
  export declare function discoverDocFiles(include: string[], root: string): Promise<string[]>;
3
- /** Determine the sdoc kind from the file path */
4
- export declare function getSdocKind(filePath: string): 'component' | 'page' | 'layout';
5
3
  /** Get the absolute path for a relative import */
6
4
  export declare function resolveImportPath(importPath: string, fromFile: string): string;
@@ -8,15 +8,6 @@ export async function discoverDocFiles(include, root) {
8
8
  });
9
9
  return files.sort();
10
10
  }
11
- /** Determine the sdoc kind from the file path */
12
- export function getSdocKind(filePath) {
13
- const name = filePath.split('/').pop() ?? '';
14
- if (name.includes('.page.'))
15
- return 'page';
16
- if (name.includes('.layout.'))
17
- return 'layout';
18
- return 'component';
19
- }
20
11
  /** Get the absolute path for a relative import */
21
12
  export function resolveImportPath(importPath, fromFile) {
22
13
  const dir = fromFile.substring(0, fromFile.lastIndexOf('/'));