sdocs 0.0.55 → 0.0.57

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 CHANGED
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.57] - 2026-07-05
11
+
12
+ ### Added
13
+
14
+ - **`[example]` blocks inside `[PAGE]`.** Pages can now stage real code in
15
+ the project's context, mid-prose: an example renders in place on an
16
+ isolated iframe stage that loads the configured `css`, with a collapsed
17
+ code panel — exactly like an example in `[DOCS]`. Stage attributes
18
+ (`maxWidth`, `padding`, `direction`, `gap`, `contentX`, `contentY`)
19
+ cascade from `content.docs`. Titles are required and unique per page;
20
+ markdown fences shield block syntax, so a fence may show `[example]`
21
+ without escaping.
22
+
23
+ ### Changed
24
+
25
+ - **Page prose renders natively in the Explorer**, with the docs app's own
26
+ typography — no longer inside an iframe that loads the project `css`. The
27
+ boundary is now crisp: documentation is docs-styled, stages are
28
+ project-styled. Pages gain real markdown styling (headings, lists, tables,
29
+ code), direct table-of-contents scrolling, and native text flow. Svelte
30
+ islands still work and run in the docs context — showcase code belongs in
31
+ `[example]` blocks.
32
+ - **Embedded hosts pass `pageModules`.** `virtual:sdocs` now exports
33
+ `pageModules`; forward it to `<Explorer {docs} {cssNames} {pageModules}>`
34
+ so pages render their content. Hosts that don't pass it show page examples
35
+ and headers but no body.
36
+
37
+ ## [0.0.56] - 2026-07-05
38
+
39
+ ### Added
40
+
41
+ - **`configSchema` in `sdocs/language`** — a structured description of the
42
+ `sdocs.config.*` shape (keys, documentation, and value enums) mirroring
43
+ `SdocsConfig`. Editor tooling reads it to complete the config file in
44
+ projects that don't install `sdocs`, so config completion can't drift from
45
+ the config type.
46
+
10
47
  ## [0.0.55] - 2026-07-05
11
48
 
12
49
  ### Changed
@@ -19,6 +19,8 @@
19
19
  cssNames?: string[];
20
20
  /** URL prefix for preview pages when the host app is served under a sub-path (e.g. SvelteKit's base). */
21
21
  previewBase?: string;
22
+ /** Native page components from `virtual:sdocs`, keyed by contentKey. */
23
+ pageModules?: Record<string, () => Promise<{ default: unknown }>>;
22
24
  sidebarConfig?: {
23
25
  order?: Record<string, string[]>;
24
26
  open?: string[];
@@ -31,6 +33,7 @@
31
33
  icon = 'sdocs',
32
34
  cssNames = [],
33
35
  previewBase = '',
36
+ pageModules = {},
34
37
  sidebarConfig
35
38
  }: Props = $props();
36
39
 
@@ -90,7 +93,7 @@
90
93
  <main class="sdocs-main" class:sdocs-main-fullscreen={sidebarHidden}>
91
94
  {#if resolved}
92
95
  {#if resolved.doc.kind === 'page'}
93
- <PageView doc={resolved.doc} {activeStylesheet} />
96
+ <PageView doc={resolved.doc} {activeStylesheet} {pageModules} />
94
97
  {:else if resolved.doc.kind === 'layout'}
95
98
  <LayoutView doc={resolved.doc} {activeStylesheet} />
96
99
  {:else}
@@ -1,28 +1,70 @@
1
1
  <script lang="ts">
2
+ import type { Component } from 'svelte';
2
3
  import type { DocEntry } from '../../types.js';
4
+ import { Icon } from '../../ui/Icon/index.js';
5
+ import CollapsiblePanel from './CollapsiblePanel.svelte';
3
6
  import PreviewFrame from './PreviewFrame.svelte';
4
7
  import { displayTitle } from '../tree-builder.js';
5
8
 
6
9
  interface Props {
7
10
  doc: DocEntry;
8
11
  activeStylesheet?: string;
12
+ /** Native page components from `virtual:sdocs`, keyed by contentKey. */
13
+ pageModules?: Record<string, () => Promise<{ default: unknown }>>;
9
14
  }
10
15
 
11
- let { doc, activeStylesheet }: Props = $props();
16
+ let { doc, activeStylesheet, pageModules = {} }: Props = $props();
12
17
 
13
18
  const meta = $derived(doc.meta);
14
- const contentSnippet = $derived(doc.content);
15
19
  const toc = $derived(doc.toc ?? []);
16
20
 
21
+ // The page body compiles to its own component (prose + islands + example
22
+ // markers); it renders here, natively — sdocs styling, no iframe. Only the
23
+ // [example] stages load the project's css, each in its own PreviewFrame.
24
+ let PageComponent = $state<Component | null>(null);
25
+ let container: HTMLElement | undefined = $state();
26
+
27
+ $effect(() => {
28
+ const load = doc.contentKey ? pageModules[doc.contentKey] : undefined;
29
+ PageComponent = null;
30
+ if (!load) return;
31
+ let stale = false;
32
+ load().then((mod) => {
33
+ if (!stale) PageComponent = mod.default as Component;
34
+ });
35
+ return () => {
36
+ stale = true;
37
+ };
38
+ });
39
+
17
40
  function scrollToHeading(id: string) {
18
- const iframe = document.querySelector('.sdocs-page-content .sdocs-iframe') as HTMLIFrameElement;
19
- if (iframe?.contentWindow) {
20
- iframe.contentWindow.postMessage({ type: 'sdocs:scroll-to', id }, '*');
21
- }
41
+ container?.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({ behavior: 'smooth' });
22
42
  }
23
43
  </script>
24
44
 
25
- <div class="sdocs-page-view">
45
+ {#snippet exampleFrame(index: number)}
46
+ {@const example = doc.examples?.[index]}
47
+ {#if example}
48
+ <div class="sdocs-page-example">
49
+ {#if example.name}
50
+ <h3 class="sdocs-example-title">
51
+ <Icon name="bookmark" --w="14px" --h="14px" --fill="var(--color-example-500)" />
52
+ {example.name}
53
+ </h3>
54
+ {/if}
55
+ <div class="sdocs-panels">
56
+ <div class="sdocs-preview-wrapper">
57
+ <PreviewFrame src={example.previewUrl ?? ''} {activeStylesheet} />
58
+ </div>
59
+ <CollapsiblePanel title="Code" defaultExpanded={false}>
60
+ <div class="sdocs-code-block">{@html example.highlightedHtml ?? ''}</div>
61
+ </CollapsiblePanel>
62
+ </div>
63
+ </div>
64
+ {/if}
65
+ {/snippet}
66
+
67
+ <div class="sdocs-page-view" style:padding={doc.padding}>
26
68
  <div class="sdocs-page-main" style:max-width={doc.maxWidth}>
27
69
  <!-- Header -->
28
70
  <div class="sdocs-view-header">
@@ -33,11 +75,11 @@
33
75
  </div>
34
76
 
35
77
  <!-- Content -->
36
- {#if contentSnippet}
37
- <div class="sdocs-page-content">
38
- <PreviewFrame src={contentSnippet.previewUrl} {activeStylesheet} />
39
- </div>
40
- {/if}
78
+ <div class="sdocs-page-content" bind:this={container}>
79
+ {#if PageComponent}
80
+ <PageComponent __sdocsExample={exampleFrame} />
81
+ {/if}
82
+ </div>
41
83
  </div>
42
84
 
43
85
  <!-- Table of Contents -->
@@ -66,7 +108,7 @@
66
108
  .sdocs-page-view {
67
109
  display: flex;
68
110
  gap: 24px;
69
- padding: 24px 32px;
111
+ /* padding comes from the doc entry (config/entity cascade) */
70
112
  font-family: var(--sans);
71
113
  }
72
114
  .sdocs-page-main {
@@ -87,9 +129,145 @@
87
129
  color: var(--color-base-500);
88
130
  margin: 6px 0 0;
89
131
  }
132
+
133
+ /* ── Page prose: the docs app's own typography ── */
90
134
  .sdocs-page-content {
91
- flex: 1;
135
+ font-size: 14px;
136
+ line-height: 1.7;
137
+ color: var(--color-base-800);
138
+ }
139
+ .sdocs-page-content :global(h1),
140
+ .sdocs-page-content :global(h2),
141
+ .sdocs-page-content :global(h3),
142
+ .sdocs-page-content :global(h4),
143
+ .sdocs-page-content :global(h5),
144
+ .sdocs-page-content :global(h6) {
145
+ color: var(--color-base-900);
146
+ font-weight: 650;
147
+ line-height: 1.3;
148
+ margin: 1.6em 0 0.5em;
149
+ scroll-margin-top: 16px;
150
+ }
151
+ .sdocs-page-content :global(h1) { font-size: 22px; }
152
+ .sdocs-page-content :global(h2) { font-size: 18px; }
153
+ .sdocs-page-content :global(h3) { font-size: 15px; }
154
+ .sdocs-page-content :global(h4),
155
+ .sdocs-page-content :global(h5),
156
+ .sdocs-page-content :global(h6) { font-size: 14px; }
157
+ .sdocs-page-content :global(h1:first-child),
158
+ .sdocs-page-content :global(h2:first-child),
159
+ .sdocs-page-content :global(h3:first-child) {
160
+ margin-top: 0;
161
+ }
162
+ .sdocs-page-content :global(p) {
163
+ margin: 0.7em 0;
164
+ }
165
+ .sdocs-page-content :global(a) {
166
+ color: var(--color-action-500, var(--color-base-900));
167
+ text-decoration: underline;
168
+ text-underline-offset: 2px;
169
+ }
170
+ .sdocs-page-content :global(ul),
171
+ .sdocs-page-content :global(ol) {
172
+ margin: 0.7em 0;
173
+ padding-left: 1.6em;
174
+ }
175
+ .sdocs-page-content :global(li) {
176
+ margin: 0.25em 0;
177
+ }
178
+ .sdocs-page-content :global(blockquote) {
179
+ margin: 0.9em 0;
180
+ padding: 2px 16px;
181
+ border-left: 3px solid var(--color-base-200);
182
+ color: var(--color-base-600);
183
+ }
184
+ .sdocs-page-content :global(hr) {
185
+ border: none;
186
+ border-top: 1px solid var(--color-base-200);
187
+ margin: 24px 0;
188
+ }
189
+ .sdocs-page-content :global(code) {
190
+ font-family: var(--mono);
191
+ font-size: 0.92em;
192
+ }
193
+ .sdocs-page-content :global(:not(pre) > code) {
194
+ background: var(--color-base-100);
195
+ border-radius: 4px;
196
+ padding: 1px 5px;
92
197
  }
198
+ .sdocs-page-content :global(pre) {
199
+ margin: 0.9em 0;
200
+ padding: 12px;
201
+ border-radius: 6px;
202
+ overflow-x: auto;
203
+ font-size: 13px;
204
+ line-height: 1.5;
205
+ tab-size: 4;
206
+ }
207
+ .sdocs-page-content :global(table) {
208
+ border-collapse: collapse;
209
+ margin: 0.9em 0;
210
+ }
211
+ .sdocs-page-content :global(th),
212
+ .sdocs-page-content :global(td) {
213
+ border: 1px solid var(--color-base-200);
214
+ padding: 6px 12px;
215
+ text-align: left;
216
+ }
217
+ .sdocs-page-content :global(th) {
218
+ background: var(--color-base-50);
219
+ font-weight: 600;
220
+ }
221
+ .sdocs-page-content :global(img) {
222
+ max-width: 100%;
223
+ }
224
+
225
+ /* ── Example stages in the page flow ── */
226
+ .sdocs-page-example {
227
+ display: flex;
228
+ flex-direction: column;
229
+ gap: 8px;
230
+ margin: 16px 0;
231
+ }
232
+ .sdocs-example-title {
233
+ display: flex;
234
+ align-items: center;
235
+ gap: 6px;
236
+ font-size: 15px;
237
+ font-weight: 600;
238
+ color: var(--color-base-800);
239
+ margin: 0;
240
+ }
241
+ .sdocs-panels {
242
+ display: flex;
243
+ flex-direction: column;
244
+ gap: 1px;
245
+ border: 1px solid var(--color-base-200);
246
+ background: var(--color-base-200);
247
+ border-radius: 8px;
248
+ overflow: hidden;
249
+ }
250
+ .sdocs-preview-wrapper {
251
+ /* stage padding lives inside the iframe (config/block cascade) */
252
+ background: var(--color-base-0);
253
+ }
254
+ .sdocs-code-block {
255
+ overflow-x: auto;
256
+ font-size: 13px;
257
+ line-height: 1.5;
258
+ tab-size: 4;
259
+ }
260
+ .sdocs-code-block :global(pre) {
261
+ margin: 0;
262
+ padding: 12px;
263
+ border-radius: 6px;
264
+ overflow-x: auto;
265
+ }
266
+ .sdocs-code-block :global(code) {
267
+ font-family: var(--mono);
268
+ }
269
+
270
+ /* ── Table of contents ── */
93
271
  .sdocs-toc {
94
272
  width: 200px;
95
273
  flex-shrink: 0;
@@ -244,6 +244,9 @@
244
244
  "while": "(^|\\G)(?!\\s*\\[/PAGE\\]\\s*$)(?:\\t|[ ]{1,4})?",
245
245
  "contentName": "meta.embedded.block.markdown",
246
246
  "patterns": [
247
+ {
248
+ "include": "#page-example"
249
+ },
247
250
  {
248
251
  "include": "#page-svelte-block"
249
252
  },
@@ -570,6 +573,48 @@
570
573
  "include": "source.ts"
571
574
  }
572
575
  ]
576
+ },
577
+ "page-example": {
578
+ "name": "meta.block.example.sdoc",
579
+ "begin": "(^|\\G)\\s*(\\[)(example)\\b(.*)$",
580
+ "beginCaptures": {
581
+ "2": {
582
+ "name": "punctuation.definition.tag.begin.sdoc"
583
+ },
584
+ "3": {
585
+ "name": "entity.name.tag.sdoc"
586
+ },
587
+ "4": {
588
+ "patterns": [
589
+ {
590
+ "include": "#attributes"
591
+ }
592
+ ]
593
+ }
594
+ },
595
+ "end": "(^|\\G)\\s*(\\[/)(example)(\\])\\s*$",
596
+ "endCaptures": {
597
+ "2": {
598
+ "name": "punctuation.definition.tag.begin.sdoc"
599
+ },
600
+ "3": {
601
+ "name": "entity.name.tag.sdoc"
602
+ },
603
+ "4": {
604
+ "name": "punctuation.definition.tag.end.sdoc"
605
+ }
606
+ },
607
+ "patterns": [
608
+ {
609
+ "begin": "(^|\\G)",
610
+ "while": "(^|\\G)(?!\\s*\\[/example\\]\\s*$)",
611
+ "patterns": [
612
+ {
613
+ "include": "source.svelte"
614
+ }
615
+ ]
616
+ }
617
+ ]
573
618
  }
574
619
  }
575
620
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A structured description of the `sdocs.config.*` shape, mirroring the
3
+ * `SdocsConfig` type. Editor tooling consumes this to offer key and value
4
+ * completions in projects that don't install `sdocs` (where the TypeScript
5
+ * type isn't resolvable). Keep it in step with `SdocsConfig` in `types.ts`.
6
+ */
7
+ /** One config key: how to present, insert, and (for enums) value-complete it. */
8
+ export interface ConfigFieldSchema {
9
+ /** Short type hint shown as the completion detail, e.g. `'string | string[]'`. */
10
+ detail: string;
11
+ /** Markdown documentation for the key. */
12
+ doc: string;
13
+ /** Snippet inserted after the key name — includes the `:` and a tabstop. */
14
+ insert: string;
15
+ /** Allowed values, for value-position completion. */
16
+ values?: string[];
17
+ /** Whether `values` are strings (quoted on insert) or literals (inserted bare). */
18
+ quoted?: boolean;
19
+ /** Nested object schema, when this key holds an object. */
20
+ object?: ConfigSchema;
21
+ }
22
+ /** A config object shape: its keys mapped to their field descriptions. */
23
+ export type ConfigSchema = Record<string, ConfigFieldSchema>;
24
+ /** The `sdocs.config.*` schema, rooted at the exported config object. */
25
+ export declare const configSchema: ConfigSchema;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * A structured description of the `sdocs.config.*` shape, mirroring the
3
+ * `SdocsConfig` type. Editor tooling consumes this to offer key and value
4
+ * completions in projects that don't install `sdocs` (where the TypeScript
5
+ * type isn't resolvable). Keep it in step with `SdocsConfig` in `types.ts`.
6
+ */
7
+ // Sizing knobs shared by the page/docs/layout content objects.
8
+ const maxWidth = {
9
+ detail: 'string',
10
+ doc: 'Content column max width — any CSS length (`1200px`, `80ch`, `100%`).',
11
+ insert: ": '$0'",
12
+ };
13
+ const padding = {
14
+ detail: 'string',
15
+ doc: 'Space around the content — any CSS padding shorthand (`16px`, `1rem 2rem`).',
16
+ insert: ": '$0'",
17
+ };
18
+ // Stage-layout knobs shared by the docs preview/example stages.
19
+ const direction = {
20
+ detail: "'row' | 'column'",
21
+ doc: 'Preview/example stage `flex-direction`. Default: `row`.',
22
+ insert: ": '${0:row}'",
23
+ values: ['row', 'column', 'row-reverse', 'column-reverse'],
24
+ quoted: true,
25
+ };
26
+ const gap = {
27
+ detail: 'string',
28
+ doc: 'Gap between stage items — any CSS length. Default: `16px`.',
29
+ insert: ": '$0'",
30
+ };
31
+ const contentX = {
32
+ detail: "'left' | 'center' | 'right' | 'justify'",
33
+ doc: 'Horizontal alignment of stage contents. Default: `left`.',
34
+ insert: ": '${0:left}'",
35
+ values: ['left', 'center', 'right', 'justify'],
36
+ quoted: true,
37
+ };
38
+ const contentY = {
39
+ detail: "'top' | 'middle' | 'bottom' | 'justify'",
40
+ doc: 'Vertical alignment of stage contents. Default: `top`.',
41
+ insert: ": '${0:top}'",
42
+ values: ['top', 'middle', 'bottom', 'justify'],
43
+ quoted: true,
44
+ };
45
+ /** The `sdocs.config.*` schema, rooted at the exported config object. */
46
+ export const configSchema = {
47
+ include: {
48
+ detail: 'string | string[]',
49
+ doc: 'Glob pattern(s) locating your `.sdoc` files. Default: `./src/**/*.sdoc`.',
50
+ insert: ": ['$0']",
51
+ },
52
+ port: {
53
+ detail: 'number',
54
+ doc: 'Dev server port. Default: `3000`.',
55
+ insert: ': ${0:3000}',
56
+ },
57
+ open: {
58
+ detail: 'boolean',
59
+ doc: 'Open the browser when the dev server starts. Default: `false`.',
60
+ insert: ': ${0:false}',
61
+ values: ['true', 'false'],
62
+ },
63
+ css: {
64
+ detail: 'string | Record<string, string>',
65
+ doc: 'CSS loaded inside preview iframes — a single stylesheet path, or a map of named stylesheets to switch between.',
66
+ insert: ": '$0'",
67
+ },
68
+ logo: {
69
+ detail: 'string',
70
+ doc: 'Sidebar logo text. Default: `sdocs`.',
71
+ insert: ": '${0:sdocs}'",
72
+ },
73
+ icon: {
74
+ detail: 'string | false',
75
+ doc: "Sidebar logo icon: `'sdocs'` for the built-in mascot, an image URL, or `false` to hide it. Default: `'sdocs'`.",
76
+ insert: ": '${0:sdocs}'",
77
+ values: ['sdocs'],
78
+ quoted: true,
79
+ },
80
+ sidebar: {
81
+ detail: 'object',
82
+ doc: 'Sidebar ordering and default-expanded folders.',
83
+ insert: ': {\n\t$0\n}',
84
+ object: {
85
+ order: {
86
+ detail: 'Record<string, string[]>',
87
+ doc: "Per-folder sort overrides. Keys are folder paths (`'root'` for the top level); `'*'` stands for unlisted items.",
88
+ insert: ': {\n\t$0\n}',
89
+ },
90
+ open: {
91
+ detail: 'string[]',
92
+ doc: 'Folders expanded by default on load.',
93
+ insert: ': [$0]',
94
+ },
95
+ },
96
+ },
97
+ content: {
98
+ detail: 'object',
99
+ doc: 'Content presentation per entity kind. Entity and block attributes override these.',
100
+ insert: ': {\n\t$0\n}',
101
+ object: {
102
+ page: {
103
+ detail: 'object',
104
+ doc: '`[PAGE]` content. Defaults: `maxWidth` `1200px`, `padding` `32px`, `toc` `true`.',
105
+ insert: ': {\n\t$0\n}',
106
+ object: {
107
+ maxWidth,
108
+ padding,
109
+ toc: {
110
+ detail: 'boolean',
111
+ doc: 'Show the page table of contents. Default: `true`.',
112
+ insert: ': ${0:true}',
113
+ values: ['true', 'false'],
114
+ },
115
+ },
116
+ },
117
+ docs: {
118
+ detail: 'object',
119
+ doc: '`[DOCS]` pages. `maxWidth` is the content column; `padding`/`direction`/`gap`/`contentX`/`contentY` are the default preview & example stage layout.',
120
+ insert: ': {\n\t$0\n}',
121
+ object: { maxWidth, padding, direction, gap, contentX, contentY },
122
+ },
123
+ layout: {
124
+ detail: 'object',
125
+ doc: '`[LAYOUT]` stages. Defaults: `maxWidth` `100%`, `padding` `0px`.',
126
+ insert: ': {\n\t$0\n}',
127
+ object: { maxWidth, padding },
128
+ },
129
+ },
130
+ },
131
+ };
@@ -1,4 +1,5 @@
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
2
  export { projectSdoc, type SdocProjection, type ProjectedLineKind, } from './projection.js';
3
3
  export { segmentPageBody, type PageSegment, } from './page-islands.js';
4
+ export { configSchema, type ConfigSchema, type ConfigFieldSchema, } from './config-schema.js';
4
5
  export { parseSdoc, parseArgsLiteral, slugifyTitle, normalizeBody, attributeRules, type AttrRule, type ArgValue, type Sizing, type PreviewBlock, type ExampleBlock, type DocsEntity, type PageEntity, type LayoutEntity, type SdocEntity, type SdocDocument, } from './parser.js';
@@ -1,4 +1,5 @@
1
1
  export { scanSdoc, offsetToPosition, ENTITY_KINDS, SUB_BLOCK_KINDS, } from './scanner.js';
2
2
  export { projectSdoc, } from './projection.js';
3
3
  export { segmentPageBody, } from './page-islands.js';
4
+ export { configSchema, } from './config-schema.js';
4
5
  export { parseSdoc, parseArgsLiteral, slugifyTitle, normalizeBody, attributeRules, } from './parser.js';
@@ -59,7 +59,11 @@ export interface PageEntity {
59
59
  title: string;
60
60
  slug: string;
61
61
  sizing: Sizing;
62
+ /** Prose body with each [example] block replaced by a
63
+ * `{@render __sdocsExample?.(i)}` marker the page renderer resolves. */
62
64
  body: string;
65
+ /** The page's [example] blocks, in marker order */
66
+ examples: ExampleBlock[];
63
67
  bodySpan: Span;
64
68
  openerSpan: Span;
65
69
  span: Span;
@@ -234,6 +234,25 @@ function parsePreview(block, diagnostics) {
234
234
  span: block.span,
235
235
  };
236
236
  }
237
+ function parseExample(block, seenTitles, owner, diagnostics) {
238
+ checkAttrs('[example]', block.attrs, SUB_BLOCK_ATTR_RULES.example, block.openerSpan, diagnostics);
239
+ const title = stringAttr(block.attrs, 'title') ?? '';
240
+ if (title && seenTitles.has(title)) {
241
+ diagnostics.push({
242
+ code: 'duplicate-example-title',
243
+ message: `Duplicate example title "${title}" — titles are unique within a [${owner}] block.`,
244
+ span: block.openerSpan,
245
+ });
246
+ }
247
+ seenTitles.add(title);
248
+ return {
249
+ title,
250
+ sizing: sizingOf(block.attrs),
251
+ body: normalizeBody(block.body),
252
+ bodySpan: block.bodySpan,
253
+ span: block.span,
254
+ };
255
+ }
237
256
  function parseDocs(entity, diagnostics) {
238
257
  checkAttrs('[DOCS]', entity.attrs, ENTITY_ATTR_RULES.DOCS, entity.openerSpan, diagnostics);
239
258
  const previews = [];
@@ -254,23 +273,7 @@ function parseDocs(entity, diagnostics) {
254
273
  previews.push(preview);
255
274
  }
256
275
  else {
257
- checkAttrs('[example]', block.attrs, SUB_BLOCK_ATTR_RULES.example, block.openerSpan, diagnostics);
258
- const title = stringAttr(block.attrs, 'title') ?? '';
259
- if (title && exampleTitles.has(title)) {
260
- diagnostics.push({
261
- code: 'duplicate-example-title',
262
- message: `Duplicate example title "${title}" — titles are unique within a [DOCS] block.`,
263
- span: block.openerSpan,
264
- });
265
- }
266
- exampleTitles.add(title);
267
- examples.push({
268
- title,
269
- sizing: sizingOf(block.attrs),
270
- body: normalizeBody(block.body),
271
- bodySpan: block.bodySpan,
272
- span: block.span,
273
- });
276
+ examples.push(parseExample(block, exampleTitles, 'DOCS', diagnostics));
274
277
  }
275
278
  }
276
279
  const title = stringAttr(entity.attrs, 'title') ?? '';
@@ -286,6 +289,47 @@ function parseDocs(entity, diagnostics) {
286
289
  span: entity.span,
287
290
  };
288
291
  }
292
+ /**
293
+ * Replace each [example] block in a PAGE's raw body with a
294
+ * `{@render __sdocsExample?.(i)}` marker at the opener's indentation, so the
295
+ * markdown renderer passes it through verbatim and the Explorer renders the
296
+ * example's stage in place.
297
+ */
298
+ function spliceExampleMarkers(entity) {
299
+ if (entity.blocks.length === 0)
300
+ return entity.body;
301
+ let out = '';
302
+ let from = 0;
303
+ entity.blocks.forEach((block, i) => {
304
+ const before = entity.body.slice(from, block.span.start - entity.bodySpan.start);
305
+ const indent = before.slice(before.lastIndexOf('\n') + 1);
306
+ out += before.slice(0, before.length - indent.length);
307
+ out += `${indent}{@render __sdocsExample?.(${i})}`;
308
+ from = block.span.end - entity.bodySpan.start;
309
+ });
310
+ out += entity.body.slice(from);
311
+ return out;
312
+ }
313
+ function parsePage(entity, diagnostics) {
314
+ checkAttrs('[PAGE]', entity.attrs, ENTITY_ATTR_RULES.PAGE, entity.openerSpan, diagnostics);
315
+ const examples = [];
316
+ const exampleTitles = new Set();
317
+ for (const block of entity.blocks) {
318
+ examples.push(parseExample(block, exampleTitles, 'PAGE', diagnostics));
319
+ }
320
+ const title = stringAttr(entity.attrs, 'title') ?? '';
321
+ return {
322
+ kind: 'PAGE',
323
+ title,
324
+ slug: slugifyTitle(title),
325
+ sizing: sizingOf(entity.attrs),
326
+ body: normalizeBody(spliceExampleMarkers(entity)),
327
+ examples,
328
+ bodySpan: entity.bodySpan,
329
+ openerSpan: entity.openerSpan,
330
+ span: entity.span,
331
+ };
332
+ }
289
333
  export function parseSdoc(source) {
290
334
  const scanned = scanSdoc(source);
291
335
  const diagnostics = [...scanned.errors];
@@ -296,10 +340,14 @@ export function parseSdoc(source) {
296
340
  if (entity.kind === 'DOCS') {
297
341
  typed = parseDocs(entity, diagnostics);
298
342
  }
343
+ else if (entity.kind === 'PAGE') {
344
+ typed = parsePage(entity, diagnostics);
345
+ }
299
346
  else {
300
- checkAttrs(`[${entity.kind}]`, entity.attrs, ENTITY_ATTR_RULES[entity.kind], entity.openerSpan, diagnostics);
347
+ checkAttrs('[LAYOUT]', entity.attrs, ENTITY_ATTR_RULES.LAYOUT, entity.openerSpan, diagnostics);
301
348
  const title = stringAttr(entity.attrs, 'title') ?? '';
302
- const base = {
349
+ typed = {
350
+ kind: 'LAYOUT',
303
351
  title,
304
352
  slug: slugifyTitle(title),
305
353
  sizing: sizingOf(entity.attrs),
@@ -308,7 +356,6 @@ export function parseSdoc(source) {
308
356
  openerSpan: entity.openerSpan,
309
357
  span: entity.span,
310
358
  };
311
- typed = entity.kind === 'PAGE' ? { kind: 'PAGE', ...base } : { kind: 'LAYOUT', ...base };
312
359
  }
313
360
  if (slugs.has(typed.slug)) {
314
361
  diagnostics.push({
@@ -102,6 +102,56 @@ export function projectSdoc(file) {
102
102
  kinds[closeLine] = 'wrapper';
103
103
  snippets.push({ name, withArgs });
104
104
  };
105
+ /** A PAGE with [example] blocks: the prose regions and each example body
106
+ * become SIBLING snippets, split in place — the example opener line closes
107
+ * the running prose snippet and opens the example's, the example closer
108
+ * does the reverse. Siblings (not nested) so the trailer can render every
109
+ * one of them, keeping imports used only by examples free of unused noise. */
110
+ const wrapPageWithExamples = (entity, e) => {
111
+ const openFirst = lineOfOffset(starts, entity.openerSpan.start);
112
+ const openLast = lineOfOffset(starts, Math.max(entity.openerSpan.start, entity.openerSpan.end - 1));
113
+ out[openFirst] = `{#snippet __sdocs$${e}_p0()}`;
114
+ kinds[openFirst] = 'wrapper';
115
+ for (let l = openFirst + 1; l <= openLast; l++) {
116
+ out[l] = '';
117
+ kinds[l] = 'blank';
118
+ }
119
+ snippets.push({ name: `__sdocs$${e}_p0`, withArgs: false });
120
+ const maskLines = (fromLine, toLine) => {
121
+ const state = { inFence: false };
122
+ for (let l = fromLine; l <= toLine; l++) {
123
+ const end = l + 1 < total ? starts[l + 1] - 1 : source.length;
124
+ const line = source.slice(starts[l], end).replace(/\r$/, '');
125
+ const masked = maskMarkdownLine(line, state);
126
+ out[l] = masked.text;
127
+ kinds[l] = masked.masked ? 'masked' : 'verbatim';
128
+ }
129
+ };
130
+ let proseFrom = openLast + 1;
131
+ entity.blocks.forEach((block, b) => {
132
+ const bOpenFirst = lineOfOffset(starts, block.openerSpan.start);
133
+ const bOpenLast = lineOfOffset(starts, Math.max(block.openerSpan.start, block.openerSpan.end - 1));
134
+ maskLines(proseFrom, bOpenFirst - 1);
135
+ out[bOpenFirst] = `{/snippet}{#snippet __sdocs$${e}_${b}${argsParam}}`;
136
+ kinds[bOpenFirst] = 'wrapper';
137
+ for (let l = bOpenFirst + 1; l <= bOpenLast; l++) {
138
+ out[l] = '';
139
+ kinds[l] = 'blank';
140
+ }
141
+ if (block.bodySpan.end > block.bodySpan.start)
142
+ copyVerbatim(block.bodySpan);
143
+ const bClose = lineOfOffset(starts, Math.max(block.span.start, block.span.end - 1));
144
+ out[bClose] = `{/snippet}{#snippet __sdocs$${e}_p${b + 1}()}`;
145
+ kinds[bClose] = 'wrapper';
146
+ snippets.push({ name: `__sdocs$${e}_${b}`, withArgs: true });
147
+ snippets.push({ name: `__sdocs$${e}_p${b + 1}`, withArgs: false });
148
+ proseFrom = bClose + 1;
149
+ });
150
+ const closeLine = lineOfOffset(starts, Math.max(entity.span.start, entity.span.end - 1));
151
+ maskLines(proseFrom, closeLine - 1);
152
+ out[closeLine] = '{/snippet}';
153
+ kinds[closeLine] = 'wrapper';
154
+ };
105
155
  file.entities.forEach((entity, e) => {
106
156
  if (entity.kind === 'DOCS') {
107
157
  entity.blocks.forEach((block, b) => {
@@ -114,6 +164,9 @@ export function projectSdoc(file) {
114
164
  }
115
165
  });
116
166
  }
167
+ else if (entity.kind === 'PAGE' && entity.blocks.length > 0) {
168
+ wrapPageWithExamples(entity, e);
169
+ }
117
170
  else {
118
171
  wrapBlock(`__sdocs$${e}`, entity.openerSpan, entity.bodySpan, entity.span, false, entity.kind === 'PAGE');
119
172
  }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * A .sdoc file is: optional <script> at the top, entity blocks in the
5
5
  * middle ([DOCS] / [PAGE] / [LAYOUT], with lowercase sub-blocks [preview] /
6
- * [example] inside [DOCS]), optional <style> at the bottom.
6
+ * [example] inside [DOCS] and [example] inside [PAGE]), optional <style>
7
+ * at the bottom.
7
8
  *
8
9
  * The scanner is line-anchored and non-balancing: tags are recognized only
9
10
  * at the start of a line and only where the current state allows them, so
@@ -46,9 +47,10 @@ export interface SubBlock {
46
47
  export interface Entity {
47
48
  kind: EntityKind;
48
49
  attrs: Attrs;
49
- /** DOCS: sub-blocks. PAGE/LAYOUT: always empty. */
50
+ /** DOCS: preview/example sub-blocks. PAGE: example sub-blocks. LAYOUT: always empty. */
50
51
  blocks: SubBlock[];
51
- /** PAGE/LAYOUT: the raw body. DOCS: '' (body text between blocks is an error). */
52
+ /** PAGE/LAYOUT: the raw body (for PAGE it includes any [example] blocks,
53
+ * addressable via their spans). DOCS: '' (body text between blocks is an error). */
52
54
  body: string;
53
55
  bodySpan: Span;
54
56
  openerSpan: Span;
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * A .sdoc file is: optional <script> at the top, entity blocks in the
5
5
  * middle ([DOCS] / [PAGE] / [LAYOUT], with lowercase sub-blocks [preview] /
6
- * [example] inside [DOCS]), optional <style> at the bottom.
6
+ * [example] inside [DOCS] and [example] inside [PAGE]), optional <style>
7
+ * at the bottom.
7
8
  *
8
9
  * The scanner is line-anchored and non-balancing: tags are recognized only
9
10
  * at the start of a line and only where the current state allows them, so
@@ -381,6 +382,88 @@ export function scanSdoc(source) {
381
382
  entity.span.end = source.length;
382
383
  return lines.length;
383
384
  }
385
+ /** Scan the inside of a [PAGE] entity until its closer: prose lines are
386
+ * body text, [example] openers capture sub-blocks. Lines inside markdown
387
+ * code fences are always prose, so a fence may show block syntax without
388
+ * escaping. The body keeps the raw text of the whole range — example
389
+ * blocks included — so consumers can splice by span. */
390
+ function scanPageBody(startLi, entity) {
391
+ const bodyStart = startLi < lines.length ? lines[startLi].start : source.length;
392
+ let bodyEnd = bodyStart;
393
+ let inFence = false;
394
+ let i = startLi;
395
+ while (i < lines.length) {
396
+ const line = lines[i];
397
+ const trimmed = line.text.trim();
398
+ if (/^(`{3,}|~{3,})/.test(trimmed)) {
399
+ inFence = !inFence;
400
+ bodyEnd = line.end;
401
+ i++;
402
+ continue;
403
+ }
404
+ if (inFence) {
405
+ if (trimmed !== '')
406
+ bodyEnd = line.end;
407
+ i++;
408
+ continue;
409
+ }
410
+ if (trimmed === '[/PAGE]') {
411
+ const tagStart = line.start + line.text.indexOf('[/PAGE]');
412
+ entity.body = source.slice(bodyStart, Math.max(bodyStart, bodyEnd));
413
+ entity.bodySpan = { start: bodyStart, end: Math.max(bodyStart, bodyEnd) };
414
+ entity.span.end = tagStart + '[/PAGE]'.length;
415
+ return i + 1;
416
+ }
417
+ const token = tagToken(trimmed);
418
+ if (token && !token.closer && token.name === 'example') {
419
+ const opener = scanOpener(i, token.name.length);
420
+ if (!opener)
421
+ return lines.length;
422
+ const captured = captureBody(opener.nextLi, '[/example]');
423
+ if (!captured) {
424
+ errors.push({
425
+ code: 'unclosed-block',
426
+ message: 'Missing [/example].',
427
+ span: opener.openerSpan,
428
+ });
429
+ return lines.length;
430
+ }
431
+ entity.blocks.push({
432
+ kind: 'example',
433
+ attrs: opener.attrs,
434
+ body: captured.body,
435
+ bodySpan: captured.bodySpan,
436
+ openerSpan: opener.openerSpan,
437
+ span: { start: opener.openerSpan.start, end: captured.closerSpan.end },
438
+ });
439
+ bodyEnd = captured.closerSpan.end;
440
+ i = captured.nextLi;
441
+ continue;
442
+ }
443
+ if (token && !token.closer && token.name === 'preview') {
444
+ errors.push({
445
+ code: 'unknown-tag',
446
+ message: '[preview] is only valid inside [DOCS] — pages showcase with [example].',
447
+ span: { start: line.start + line.text.indexOf('['), end: line.end },
448
+ });
449
+ i++;
450
+ continue;
451
+ }
452
+ // Everything else — prose, markdown, islands, stray brackets — is body.
453
+ if (trimmed !== '')
454
+ bodyEnd = line.end;
455
+ i++;
456
+ }
457
+ errors.push({
458
+ code: 'unclosed-block',
459
+ message: 'Missing [/PAGE].',
460
+ span: entity.openerSpan,
461
+ });
462
+ entity.body = source.slice(bodyStart);
463
+ entity.bodySpan = { start: bodyStart, end: source.length };
464
+ entity.span.end = source.length;
465
+ return lines.length;
466
+ }
384
467
  // ---- main loop over top-level lines ----
385
468
  while (li < lines.length) {
386
469
  const line = lines[li];
@@ -459,6 +542,9 @@ export function scanSdoc(source) {
459
542
  if (token.name === 'DOCS') {
460
543
  li = scanDocsBody(opener.nextLi, entity);
461
544
  }
545
+ else if (token.name === 'PAGE') {
546
+ li = scanPageBody(opener.nextLi, entity);
547
+ }
462
548
  else {
463
549
  const captured = captureBody(opener.nextLi, `[/${token.name}]`);
464
550
  if (!captured) {
@@ -491,7 +577,9 @@ export function scanSdoc(source) {
491
577
  if (token && !token.closer && isSubBlockKind(token.name)) {
492
578
  errors.push({
493
579
  code: 'block-outside-entity',
494
- message: `[${token.name}] is only valid inside a [DOCS] entity.`,
580
+ message: token.name === 'example'
581
+ ? '[example] is only valid inside a [DOCS] or [PAGE] entity.'
582
+ : '[preview] is only valid inside a [DOCS] entity.',
495
583
  span,
496
584
  });
497
585
  li++;
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url';
6
6
  import { createRequire } from 'node:module';
7
7
  import { discoverDocFiles } from './discovery.js';
8
8
  import { parseSdoc } from '../language/index.js';
9
- import { planEntitySnippets } from './doc-model.js';
9
+ import { planIframeSnippets } from './doc-model.js';
10
10
  import { encodeEntityId, setDocPathRoot } from './snippet-compiler.js';
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
12
  const require = createRequire(import.meta.url);
@@ -107,7 +107,7 @@ function generateIndexHtml(title) {
107
107
  /** Generate the entry.js that mounts the Explorer */
108
108
  function generateEntryJs(config) {
109
109
  return `import { mount } from 'svelte';
110
- import { docs, cssNames } from 'virtual:sdocs';
110
+ import { docs, cssNames, pageModules } from 'virtual:sdocs';
111
111
  import Explorer from './explorer/Explorer.svelte';
112
112
 
113
113
  mount(Explorer, {
@@ -115,6 +115,7 @@ mount(Explorer, {
115
115
  props: {
116
116
  docs,
117
117
  cssNames,
118
+ pageModules,
118
119
  logo: ${JSON.stringify(config.logo)},
119
120
  icon: ${JSON.stringify(config.icon)},
120
121
  sidebarConfig: ${JSON.stringify(config.sidebar)},
@@ -178,7 +179,8 @@ async function discoverSnippets(config, cwd) {
178
179
  results.push({
179
180
  filePath,
180
181
  entitySlug: entity.slug,
181
- snippetSlugs: planEntitySnippets(entity).map((s) => s.slug),
182
+ // Iframe pages only: a PAGE's content compiles natively in the Explorer.
183
+ snippetSlugs: planIframeSnippets(entity).map((s) => s.slug),
182
184
  });
183
185
  }
184
186
  }
@@ -16,9 +16,15 @@ export interface PlannedSnippet {
16
16
  export declare function exampleSlug(title: string): string;
17
17
  /** URL-safe slug for a preview snippet (from its tab label). */
18
18
  export declare function previewSlug(label: string): string;
19
- /** The snippets one entity produces, in order: previews, then examples,
20
- * or the single 'content' body for PAGE/LAYOUT. */
19
+ /** The snippets one entity produces, in order: previews then examples for
20
+ * DOCS, the 'content' body then examples for PAGE, the single 'content'
21
+ * body for LAYOUT. A PAGE's content renders natively in the Explorer — it
22
+ * is never served as an iframe page (see planIframeSnippets). */
21
23
  export declare function planEntitySnippets(entity: SdocEntity): PlannedSnippet[];
24
+ /** The snippets of an entity that are served as iframe preview pages:
25
+ * everything except a PAGE's content (which references the Explorer-provided
26
+ * `__sdocsExample` snippet and only compiles there). */
27
+ export declare function planIframeSnippets(entity: SdocEntity): PlannedSnippet[];
22
28
  /** Extract import statements from the file-level script content. */
23
29
  export declare function extractImports(scriptContent: string): string[];
24
30
  /** Resolve a preview's component identifier to an absolute .svelte path via
@@ -14,9 +14,17 @@ export function exampleSlug(title) {
14
14
  export function previewSlug(label) {
15
15
  return slugifyTitle(label);
16
16
  }
17
- /** The snippets one entity produces, in order: previews, then examples,
18
- * or the single 'content' body for PAGE/LAYOUT. */
17
+ /** The snippets one entity produces, in order: previews then examples for
18
+ * DOCS, the 'content' body then examples for PAGE, the single 'content'
19
+ * body for LAYOUT. A PAGE's content renders natively in the Explorer — it
20
+ * is never served as an iframe page (see planIframeSnippets). */
19
21
  export function planEntitySnippets(entity) {
22
+ const example = (e) => ({
23
+ name: e.title,
24
+ slug: exampleSlug(e.title),
25
+ role: 'example',
26
+ body: e.body,
27
+ });
20
28
  if (entity.kind === 'DOCS') {
21
29
  return [
22
30
  ...entity.previews.map((p) => ({
@@ -25,15 +33,20 @@ export function planEntitySnippets(entity) {
25
33
  role: 'preview',
26
34
  body: p.body,
27
35
  })),
28
- ...entity.examples.map((e) => ({
29
- name: e.title,
30
- slug: exampleSlug(e.title),
31
- role: 'example',
32
- body: e.body,
33
- })),
36
+ ...entity.examples.map(example),
34
37
  ];
35
38
  }
36
- return [{ name: 'Content', slug: 'content', role: 'content', body: entity.body }];
39
+ const content = { name: 'Content', slug: 'content', role: 'content', body: entity.body };
40
+ if (entity.kind === 'PAGE') {
41
+ return [content, ...entity.examples.map(example)];
42
+ }
43
+ return [content];
44
+ }
45
+ /** The snippets of an entity that are served as iframe preview pages:
46
+ * everything except a PAGE's content (which references the Explorer-provided
47
+ * `__sdocsExample` snippet and only compiles there). */
48
+ export function planIframeSnippets(entity) {
49
+ return planEntitySnippets(entity).filter((s) => !(entity.kind === 'PAGE' && s.role === 'content'));
37
50
  }
38
51
  /** Extract import statements from the file-level script content. */
39
52
  export function extractImports(scriptContent) {
@@ -31,6 +31,13 @@ export declare function generateIframeComponent(scriptPrelude: string, snippetBo
31
31
  contentX?: string;
32
32
  contentY?: string;
33
33
  }): string;
34
+ /**
35
+ * Generate the Svelte component for a [PAGE] body, rendered natively inside
36
+ * the Explorer (sdocs styling — the project's css never loads here). Prose
37
+ * arrives as rendered markdown, islands verbatim; `{@render __sdocsExample?.(i)}`
38
+ * markers render the example stages the Explorer passes in as a snippet prop.
39
+ */
40
+ export declare function generatePageComponent(scriptPrelude: string, renderedBody: string): string;
34
41
  /** The JS that boots a preview page: mount the wrapper + parent-frame messaging. */
35
42
  export declare function generateMountScript(iframeComponentId: string): string;
36
43
  /** Generate the HTML page served inside the iframe (dev / CLI-build inputs) */
@@ -53,6 +60,13 @@ export declare function iframeVirtualId(docFilePath: string, entitySlug: string,
53
60
  export declare function previewUrl(docFilePath: string, entitySlug: string, snippetSlug: string): string;
54
61
  /** Build the preview URL for static build output */
55
62
  export declare function buildPreviewUrl(docFilePath: string, entitySlug: string, snippetSlug: string): string;
63
+ /** Virtual module ID for a PAGE entity's native content component */
64
+ export declare function pageVirtualId(docFilePath: string, entitySlug: string): string;
65
+ /** Parse a page virtual ID back into its parts */
66
+ export declare function parsePageId(id: string): {
67
+ docFilePath: string;
68
+ entitySlug: string;
69
+ } | null;
56
70
  /** Virtual module ID for a preview's mount script (embedded production builds) */
57
71
  export declare function mountVirtualId(docFilePath: string, entitySlug: string, snippetSlug: string): string;
58
72
  /** Parse a mount virtual ID back into its parts */
@@ -205,6 +205,25 @@ ${stateBroadcast}
205
205
  {@render SdocsPreview(args)}
206
206
  </div>`;
207
207
  }
208
+ /**
209
+ * Generate the Svelte component for a [PAGE] body, rendered natively inside
210
+ * the Explorer (sdocs styling — the project's css never loads here). Prose
211
+ * arrives as rendered markdown, islands verbatim; `{@render __sdocsExample?.(i)}`
212
+ * markers render the example stages the Explorer passes in as a snippet prop.
213
+ */
214
+ export function generatePageComponent(scriptPrelude, renderedBody) {
215
+ const importBlock = scriptPrelude.trim() ? scriptPrelude.trim() + '\n' : '';
216
+ // lang="ts" so lifted imports may carry type-only syntax
217
+ return `<script lang="ts">
218
+ ${importBlock}
219
+ let { __sdocsExample } = $props();
220
+ </script>
221
+
222
+ <div class="sdocs-page-body">
223
+ ${renderedBody}
224
+ </div>
225
+ `;
226
+ }
208
227
  /** Convert a CSS path to a Vite-servable URL */
209
228
  function normalizeCssHref(href) {
210
229
  if (href.startsWith('http'))
@@ -290,6 +309,17 @@ export function previewUrl(docFilePath, entitySlug, snippetSlug) {
290
309
  export function buildPreviewUrl(docFilePath, entitySlug, snippetSlug) {
291
310
  return `/previews/${encodeEntityId(docFilePath, entitySlug)}/${snippetSlug}.html`;
292
311
  }
312
+ /** Virtual module ID for a PAGE entity's native content component */
313
+ export function pageVirtualId(docFilePath, entitySlug) {
314
+ return `/@sdocs/page/${encodeEntityId(docFilePath, entitySlug)}.svelte`;
315
+ }
316
+ /** Parse a page virtual ID back into its parts */
317
+ export function parsePageId(id) {
318
+ const match = id.match(/^\/@sdocs\/page\/([^/]+)\.svelte$/);
319
+ if (!match)
320
+ return null;
321
+ return decodeEntityId(match[1]);
322
+ }
293
323
  /** Virtual module ID for a preview's mount script (embedded production builds) */
294
324
  export function mountVirtualId(docFilePath, entitySlug, snippetSlug) {
295
325
  return `/@sdocs/mount/${encodeEntityId(docFilePath, entitySlug)}/${snippetSlug}.js`;
package/dist/types.d.ts CHANGED
@@ -176,7 +176,7 @@ export interface DocEntry {
176
176
  meta: SdocMeta;
177
177
  /** Live previews (component kind; empty otherwise) */
178
178
  previews: PreviewEntry[];
179
- /** Frozen examples (component kind; empty otherwise) */
179
+ /** Frozen examples (component and page kinds; empty otherwise) */
180
180
  examples: ExtractedSnippet[];
181
181
  /** The rendered body (page/layout kind; null otherwise) */
182
182
  content: ExtractedSnippet | null;
@@ -184,6 +184,10 @@ export interface DocEntry {
184
184
  toc?: TocHeading[];
185
185
  /** Resolved content-column max width (component and page kinds) */
186
186
  maxWidth?: string;
187
+ /** Resolved page padding (pages only) */
188
+ padding?: string;
187
189
  /** Resolved table-of-contents visibility (pages only) */
188
190
  showToc?: boolean;
191
+ /** Key into the virtual module's pageModules map (pages only) */
192
+ contentKey?: string;
189
193
  }
package/dist/vite.js CHANGED
@@ -6,18 +6,20 @@ 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, resolveScriptImports, generateIframeComponent, generateMountScript, generatePreviewHtml, generateStaticPreviewHtml, iframeVirtualId, mountVirtualId, previewUrl, buildPreviewUrl, encodeEntityId, entityKey, setDocPathRoot, } from './server/snippet-compiler.js';
9
+ import { parseIframeId, parseMountId, parsePageId, parsePreviewUrl, resolveScriptImports, generateIframeComponent, generateMountScript, generatePageComponent, generatePreviewHtml, generateStaticPreviewHtml, iframeVirtualId, mountVirtualId, pageVirtualId, 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/';
13
13
  const PREVIEW_PREFIX = '/@sdocs/preview/';
14
14
  const MOUNT_PREFIX = '/@sdocs/mount/';
15
- /** Every renderable snippet of an entry, in order. */
15
+ const PAGE_PREFIX = '/@sdocs/page/';
16
+ /** Every iframe-served snippet of an entry, in order. A PAGE's content
17
+ * renders natively in the Explorer (via pageModules), never as an iframe. */
16
18
  function allSnippets(entry) {
17
19
  return [
18
20
  ...entry.previews.map((p) => p.snippet),
19
21
  ...entry.examples,
20
- ...(entry.content ? [entry.content] : []),
22
+ ...(entry.content && entry.kind !== 'page' ? [entry.content] : []),
21
23
  ];
22
24
  }
23
25
  export function sdocsPlugin(userConfig) {
@@ -206,6 +208,8 @@ export function sdocsPlugin(userConfig) {
206
208
  return '\0' + id;
207
209
  if (id.startsWith(MOUNT_PREFIX))
208
210
  return '\0' + id;
211
+ if (id.startsWith(PAGE_PREFIX))
212
+ return '\0' + id;
209
213
  },
210
214
  load(id) {
211
215
  if (id === RESOLVED_VIRTUAL_ID) {
@@ -230,6 +234,17 @@ export function sdocsPlugin(userConfig) {
230
234
  const stateNames = (preview?.componentData?.state ?? []).map((s) => s.name);
231
235
  return generateIframeComponent(scriptPrelude, snippet.body, stateNames, preview?.componentName ?? undefined, snippet.stage);
232
236
  }
237
+ // Virtual native content component for a PAGE entity
238
+ if (id.startsWith('\0' + PAGE_PREFIX)) {
239
+ const parsed = parsePageId(id.slice(1));
240
+ if (!parsed)
241
+ return null;
242
+ const entry = docEntries.get(entityKey(parsed.docFilePath, parsed.entitySlug));
243
+ if (!entry?.content)
244
+ return null;
245
+ const scriptPrelude = docScriptCache.get(parsed.docFilePath) ?? '';
246
+ return generatePageComponent(scriptPrelude, entry.content.body);
247
+ }
233
248
  // Virtual mount script for an emitted preview page
234
249
  if (id.startsWith('\0' + MOUNT_PREFIX)) {
235
250
  const parsed = parseMountId(id.slice(1));
@@ -363,11 +378,31 @@ export function sdocsPlugin(userConfig) {
363
378
  }
364
379
  }
365
380
  else if (entity.kind === 'PAGE') {
381
+ // The page body renders natively in the Explorer; only its
382
+ // [example] blocks are staged in iframes (with the project css),
383
+ // cascading block attributes over the content.docs stage defaults.
366
384
  const rendered = await renderPageMarkdown(entity.body);
367
385
  snippets[0].body = rendered.html;
368
- snippets[0].stage = stageOf();
369
386
  entry.content = snippets[0];
370
387
  entry.toc = rendered.toc;
388
+ entry.padding = entity.sizing.padding ?? config.content.page.padding;
389
+ entry.contentKey = encodeEntityId(filePath, entity.slug);
390
+ entry.examples = snippets.filter((s) => s.role === 'example');
391
+ entity.examples.forEach((example, i) => {
392
+ if (entry.examples[i]) {
393
+ entry.examples[i].stage = {
394
+ maxWidth: example.sizing.maxWidth ?? '100%',
395
+ padding: example.sizing.padding ?? config.content.docs.padding,
396
+ direction: example.sizing.direction ?? config.content.docs.direction,
397
+ gap: example.sizing.gap ?? config.content.docs.gap,
398
+ contentX: example.sizing.contentX ?? config.content.docs.contentX,
399
+ contentY: example.sizing.contentY ?? config.content.docs.contentY,
400
+ };
401
+ }
402
+ });
403
+ for (const example of entry.examples) {
404
+ example.highlightedHtml = await highlight(example.body);
405
+ }
371
406
  }
372
407
  else {
373
408
  snippets[0].stage = stageOf();
@@ -396,16 +431,26 @@ export function sdocsPlugin(userConfig) {
396
431
  meta: e.meta,
397
432
  previews: e.previews.map((p) => ({ ...p, snippet: withUrl(e, p.snippet) })),
398
433
  examples: e.examples.map((s) => withUrl(e, s)),
399
- content: e.content ? withUrl(e, e.content) : null,
434
+ // Page content renders natively (no iframe URL); see pageModules below.
435
+ content: e.content ? (e.kind === 'page' ? e.content : withUrl(e, e.content)) : null,
400
436
  toc: e.toc,
401
437
  maxWidth: e.maxWidth,
438
+ padding: e.padding,
402
439
  showToc: e.showToc,
440
+ contentKey: e.contentKey,
403
441
  }));
404
442
  // Extract named CSS stylesheet names (empty array if single string or null)
405
443
  const cssNames = config.css && typeof config.css === 'object'
406
444
  ? Object.keys(config.css)
407
445
  : [];
408
- return `export const docs = ${JSON.stringify(data)};\nexport const cssNames = ${JSON.stringify(cssNames)};\nexport default docs;`;
446
+ // Native page components, as static dynamic imports so every mode (dev,
447
+ // embedded build, CLI build) code-splits them through the module graph —
448
+ // shared Svelte runtime, component CSS handled by Vite's import helper.
449
+ const pageImports = Array.from(docEntries.values())
450
+ .filter((e) => e.kind === 'page')
451
+ .map((e) => `\t${JSON.stringify(encodeEntityId(e.filePath, e.entitySlug))}: () => import(${JSON.stringify(pageVirtualId(e.filePath, e.entitySlug))}),`)
452
+ .join('\n');
453
+ return `export const docs = ${JSON.stringify(data)};\nexport const cssNames = ${JSON.stringify(cssNames)};\nexport const pageModules = {\n${pageImports}\n};\nexport default docs;`;
409
454
  }
410
455
  // ─── HMR helpers ───
411
456
  function invalidateVirtualModule(docFilePath) {
@@ -415,7 +460,7 @@ export function sdocsPlugin(userConfig) {
415
460
  if (mod) {
416
461
  server.moduleGraph.invalidateModule(mod);
417
462
  }
418
- // Also invalidate iframe virtual modules for the changed doc file
463
+ // Also invalidate iframe and page virtual modules for the changed doc file
419
464
  if (docFilePath) {
420
465
  for (const entry of entriesOf(docFilePath)) {
421
466
  for (const snippet of allSnippets(entry)) {
@@ -425,6 +470,12 @@ export function sdocsPlugin(userConfig) {
425
470
  server.moduleGraph.invalidateModule(iframeMod);
426
471
  }
427
472
  }
473
+ if (entry.kind === 'page') {
474
+ const pageMod = server.moduleGraph.getModuleById('\0' + pageVirtualId(docFilePath, entry.entitySlug));
475
+ if (pageMod) {
476
+ server.moduleGraph.invalidateModule(pageMod);
477
+ }
478
+ }
428
479
  }
429
480
  }
430
481
  server.ws.send({ type: 'full-reload' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdocs",
3
- "version": "0.0.55",
3
+ "version": "0.0.57",
4
4
  "description": "A lightweight documentation tool for Svelte 5 components",
5
5
  "type": "module",
6
6
  "license": "MIT",