sdocs 0.0.56 → 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 +27 -0
- package/dist/explorer/Explorer.svelte +4 -1
- package/dist/explorer/views/PageView.svelte +192 -14
- package/dist/grammar/sdoc.tmLanguage.json +45 -0
- package/dist/language/parser.d.ts +4 -0
- package/dist/language/parser.js +67 -20
- package/dist/language/projection.js +53 -0
- package/dist/language/scanner.d.ts +5 -3
- package/dist/language/scanner.js +90 -2
- package/dist/server/app-gen.js +5 -3
- package/dist/server/doc-model.d.ts +8 -2
- package/dist/server/doc-model.js +22 -9
- package/dist/server/snippet-compiler.d.ts +14 -0
- package/dist/server/snippet-compiler.js +30 -0
- package/dist/types.d.ts +5 -1
- package/dist/vite.js +58 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ 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
|
+
|
|
10
37
|
## [0.0.56] - 2026-07-05
|
|
11
38
|
|
|
12
39
|
### Added
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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;
|
package/dist/language/parser.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
347
|
+
checkAttrs('[LAYOUT]', entity.attrs, ENTITY_ATTR_RULES.LAYOUT, entity.openerSpan, diagnostics);
|
|
301
348
|
const title = stringAttr(entity.attrs, 'title') ?? '';
|
|
302
|
-
|
|
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>
|
|
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
|
|
50
|
+
/** DOCS: preview/example sub-blocks. PAGE: example sub-blocks. LAYOUT: always empty. */
|
|
50
51
|
blocks: SubBlock[];
|
|
51
|
-
/** PAGE/LAYOUT: the raw body
|
|
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;
|
package/dist/language/scanner.js
CHANGED
|
@@ -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>
|
|
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:
|
|
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++;
|
package/dist/server/app-gen.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
20
|
-
*
|
|
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
|
package/dist/server/doc-model.js
CHANGED
|
@@ -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
|
|
18
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' });
|