sdocs 0.0.56 → 0.0.58

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.
@@ -75,6 +75,8 @@ const ENTITY_ATTR_RULES = {
75
75
  PAGE: {
76
76
  title: { required: true, kind: 'string', hint: 'title="Group / Name"' },
77
77
  ...SIZING_ATTR_RULES,
78
+ // On PAGE, contentX aligns the content column (with its toc), not a stage.
79
+ contentX: { required: false, kind: 'string', hint: 'contentX="center"' },
78
80
  toc: { required: false, kind: 'string', hint: 'toc="false"' },
79
81
  },
80
82
  LAYOUT: {
@@ -234,6 +236,25 @@ function parsePreview(block, diagnostics) {
234
236
  span: block.span,
235
237
  };
236
238
  }
239
+ function parseExample(block, seenTitles, owner, diagnostics) {
240
+ checkAttrs('[example]', block.attrs, SUB_BLOCK_ATTR_RULES.example, block.openerSpan, diagnostics);
241
+ const title = stringAttr(block.attrs, 'title') ?? '';
242
+ if (title && seenTitles.has(title)) {
243
+ diagnostics.push({
244
+ code: 'duplicate-example-title',
245
+ message: `Duplicate example title "${title}" — titles are unique within a [${owner}] block.`,
246
+ span: block.openerSpan,
247
+ });
248
+ }
249
+ seenTitles.add(title);
250
+ return {
251
+ title,
252
+ sizing: sizingOf(block.attrs),
253
+ body: normalizeBody(block.body),
254
+ bodySpan: block.bodySpan,
255
+ span: block.span,
256
+ };
257
+ }
237
258
  function parseDocs(entity, diagnostics) {
238
259
  checkAttrs('[DOCS]', entity.attrs, ENTITY_ATTR_RULES.DOCS, entity.openerSpan, diagnostics);
239
260
  const previews = [];
@@ -254,23 +275,7 @@ function parseDocs(entity, diagnostics) {
254
275
  previews.push(preview);
255
276
  }
256
277
  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
- });
278
+ examples.push(parseExample(block, exampleTitles, 'DOCS', diagnostics));
274
279
  }
275
280
  }
276
281
  const title = stringAttr(entity.attrs, 'title') ?? '';
@@ -286,6 +291,47 @@ function parseDocs(entity, diagnostics) {
286
291
  span: entity.span,
287
292
  };
288
293
  }
294
+ /**
295
+ * Replace each [example] block in a PAGE's raw body with a
296
+ * `{@render __sdocsExample?.(i)}` marker at the opener's indentation, so the
297
+ * markdown renderer passes it through verbatim and the Explorer renders the
298
+ * example's stage in place.
299
+ */
300
+ function spliceExampleMarkers(entity) {
301
+ if (entity.blocks.length === 0)
302
+ return entity.body;
303
+ let out = '';
304
+ let from = 0;
305
+ entity.blocks.forEach((block, i) => {
306
+ const before = entity.body.slice(from, block.span.start - entity.bodySpan.start);
307
+ const indent = before.slice(before.lastIndexOf('\n') + 1);
308
+ out += before.slice(0, before.length - indent.length);
309
+ out += `${indent}{@render __sdocsExample?.(${i})}`;
310
+ from = block.span.end - entity.bodySpan.start;
311
+ });
312
+ out += entity.body.slice(from);
313
+ return out;
314
+ }
315
+ function parsePage(entity, diagnostics) {
316
+ checkAttrs('[PAGE]', entity.attrs, ENTITY_ATTR_RULES.PAGE, entity.openerSpan, diagnostics);
317
+ const examples = [];
318
+ const exampleTitles = new Set();
319
+ for (const block of entity.blocks) {
320
+ examples.push(parseExample(block, exampleTitles, 'PAGE', diagnostics));
321
+ }
322
+ const title = stringAttr(entity.attrs, 'title') ?? '';
323
+ return {
324
+ kind: 'PAGE',
325
+ title,
326
+ slug: slugifyTitle(title),
327
+ sizing: sizingOf(entity.attrs),
328
+ body: normalizeBody(spliceExampleMarkers(entity)),
329
+ examples,
330
+ bodySpan: entity.bodySpan,
331
+ openerSpan: entity.openerSpan,
332
+ span: entity.span,
333
+ };
334
+ }
289
335
  export function parseSdoc(source) {
290
336
  const scanned = scanSdoc(source);
291
337
  const diagnostics = [...scanned.errors];
@@ -296,10 +342,14 @@ export function parseSdoc(source) {
296
342
  if (entity.kind === 'DOCS') {
297
343
  typed = parseDocs(entity, diagnostics);
298
344
  }
345
+ else if (entity.kind === 'PAGE') {
346
+ typed = parsePage(entity, diagnostics);
347
+ }
299
348
  else {
300
- checkAttrs(`[${entity.kind}]`, entity.attrs, ENTITY_ATTR_RULES[entity.kind], entity.openerSpan, diagnostics);
349
+ checkAttrs('[LAYOUT]', entity.attrs, ENTITY_ATTR_RULES.LAYOUT, entity.openerSpan, diagnostics);
301
350
  const title = stringAttr(entity.attrs, 'title') ?? '';
302
- const base = {
351
+ typed = {
352
+ kind: 'LAYOUT',
303
353
  title,
304
354
  slug: slugifyTitle(title),
305
355
  sizing: sizingOf(entity.attrs),
@@ -308,7 +358,6 @@ export function parseSdoc(source) {
308
358
  openerSpan: entity.openerSpan,
309
359
  span: entity.span,
310
360
  };
311
- typed = entity.kind === 'PAGE' ? { kind: 'PAGE', ...base } : { kind: 'LAYOUT', ...base };
312
361
  }
313
362
  if (slugs.has(typed.slug)) {
314
363
  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
  }
@@ -7,6 +7,7 @@ const DEFAULTS = {
7
7
  port: 3000,
8
8
  open: false,
9
9
  css: null,
10
+ static: null,
10
11
  logo: 'sdocs',
11
12
  icon: 'sdocs',
12
13
  sidebar: {
@@ -14,7 +15,7 @@ const DEFAULTS = {
14
15
  open: [],
15
16
  },
16
17
  content: {
17
- page: { maxWidth: '1200px', padding: '32px', toc: true },
18
+ page: { maxWidth: '1200px', padding: '32px', toc: true, contentX: 'left' },
18
19
  docs: {
19
20
  maxWidth: '1200px',
20
21
  padding: '16px',
@@ -69,6 +70,8 @@ export function resolveAndFinalize(userConfig, root) {
69
70
  const resolved = resolveConfig(userConfig);
70
71
  resolved.include = resolveIncludePatterns(resolved.include, root);
71
72
  resolved.css = resolveCssPaths(resolved.css, root);
73
+ if (resolved.static)
74
+ resolved.static = resolve(root, resolved.static);
72
75
  return resolved;
73
76
  }
74
77
  /** Import a config file (.js/.mjs; .ts only on Node with native type stripping) */
@@ -89,6 +92,7 @@ export function resolveConfig(userConfig) {
89
92
  port: userConfig.port ?? DEFAULTS.port,
90
93
  open: userConfig.open ?? DEFAULTS.open,
91
94
  css: userConfig.css ?? DEFAULTS.css,
95
+ static: userConfig.static ?? DEFAULTS.static,
92
96
  logo: userConfig.logo ?? DEFAULTS.logo,
93
97
  icon: userConfig.icon ?? DEFAULTS.icon,
94
98
  sidebar: {
@@ -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) {
@@ -11,6 +11,9 @@ import type { TocHeading } from '../types.js';
11
11
  export interface RenderedPage {
12
12
  html: string;
13
13
  toc: TocHeading[];
14
+ /** Text of the body's first `#` heading, when present — it takes over as
15
+ * the page's displayed title. */
16
+ bodyTitle?: string;
14
17
  }
15
18
  /**
16
19
  * Render a [PAGE] body: Svelte islands (see segmentPageBody) pass through
@@ -97,26 +97,56 @@ function plainText(tokens) {
97
97
  export async function renderPageMarkdown(source) {
98
98
  const toc = [];
99
99
  const usedIds = new Set();
100
+ const state = { toc, usedIds, bodyTitle: undefined };
100
101
  const parts = [];
101
102
  for (const segment of segmentPageBody(source)) {
102
103
  if (segment.kind === 'island') {
103
104
  parts.push(segment.lines.join('\n'));
104
105
  }
105
106
  else {
106
- parts.push(await renderProse(segment.lines.join('\n'), toc, usedIds));
107
+ parts.push(await renderProse(segment.lines.join('\n'), state));
107
108
  }
108
109
  }
109
- return { html: parts.join('\n'), toc };
110
+ return { html: parts.join('\n'), toc, bodyTitle: state.bodyTitle };
110
111
  }
111
- async function renderProse(source, toc, usedIds) {
112
+ /** GitHub-style alert kinds recognized on a blockquote's first line. */
113
+ const ALERT_KINDS = ['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION'];
114
+ const ALERT_RE = new RegExp(`^\\[!(${ALERT_KINDS.join('|')})\\][ \\t]*(?:\\n|$)`);
115
+ async function renderProse(source, state) {
116
+ const { toc, usedIds } = state;
112
117
  const headingIds = new WeakMap();
113
118
  const fenceHtml = new WeakMap();
119
+ const alertKinds = new WeakMap();
114
120
  const marked = new Marked({ gfm: true });
115
121
  const tokens = marked.lexer(source);
122
+ /** Detect a GitHub-style alert marker on a blockquote's first line and
123
+ * strip it from the tokens; the renderer wraps the rest as a callout. */
124
+ const detectAlert = (token) => {
125
+ if (!('tokens' in token) || !token.tokens)
126
+ return;
127
+ const para = token.tokens[0];
128
+ if (para?.type !== 'paragraph' || !para.tokens)
129
+ return;
130
+ const first = para.tokens[0];
131
+ if (first?.type !== 'text')
132
+ return;
133
+ const m = ALERT_RE.exec(first.text ?? '');
134
+ if (!m)
135
+ return;
136
+ alertKinds.set(token, m[1]);
137
+ first.text = first.text.slice(m[0].length);
138
+ if (first.text === '')
139
+ para.tokens.shift();
140
+ if (para.tokens.length === 0)
141
+ token.tokens.shift();
142
+ };
116
143
  const walk = (list) => {
117
144
  for (const token of list) {
118
145
  if (token.type === 'heading') {
119
146
  const text = plainText(token.tokens ?? []);
147
+ if (token.depth === 1 && state.bodyTitle === undefined) {
148
+ state.bodyTitle = text;
149
+ }
120
150
  const baseId = slugify(text);
121
151
  let id = baseId;
122
152
  for (let n = 2; usedIds.has(id); n++)
@@ -127,6 +157,8 @@ async function renderProse(source, toc, usedIds) {
127
157
  toc.push({ text, level: token.depth, id });
128
158
  }
129
159
  }
160
+ if (token.type === 'blockquote')
161
+ detectAlert(token);
130
162
  if ('tokens' in token && token.tokens)
131
163
  walk(token.tokens);
132
164
  if ('items' in token && token.items)
@@ -155,6 +187,31 @@ async function renderProse(source, toc, usedIds) {
155
187
  ? `<h${token.depth} id="${id}">${html}</h${token.depth}>\n`
156
188
  : `<h${token.depth}>${html}</h${token.depth}>\n`;
157
189
  },
190
+ // GitHub-style alerts: `> [!NOTE]` blockquotes become callouts.
191
+ blockquote(token) {
192
+ const body = this.parser.parse(token.tokens ?? []);
193
+ const kind = alertKinds.get(token);
194
+ if (!kind)
195
+ return `<blockquote>\n${body}</blockquote>\n`;
196
+ const label = kind.charAt(0) + kind.slice(1).toLowerCase();
197
+ return `<div class="sdocs-alert sdocs-alert-${kind.toLowerCase()}"><p class="sdocs-alert-label">${label}</p>\n${body}</div>\n`;
198
+ },
199
+ // External links open away from the docs app; hrefs stay inert.
200
+ link(token) {
201
+ const inner = this.parser.parseInline(token.tokens ?? []);
202
+ const href = escapeBraces(escapeHtml(token.href ?? ''));
203
+ const title = token.title ? ` title="${escapeBraces(escapeHtml(token.title))}"` : '';
204
+ const external = /^https?:\/\//i.test(token.href ?? '');
205
+ const attrs = external ? ' target="_blank" rel="noopener noreferrer"' : '';
206
+ return `<a href="${href}"${title}${attrs}>${inner}</a>`;
207
+ },
208
+ // Self-closing and brace-escaped so images are always Svelte-safe.
209
+ image(token) {
210
+ const src = escapeBraces(escapeHtml(token.href ?? ''));
211
+ const alt = escapeBraces(escapeHtml(token.text ?? ''));
212
+ const title = token.title ? ` title="${escapeBraces(escapeHtml(token.title))}"` : '';
213
+ return `<img src="${src}" alt="${alt}"${title} loading="lazy" />`;
214
+ },
158
215
  code(token) {
159
216
  return (fenceHtml.get(token) ??
160
217
  `<pre><code>${escapeBraces(escapeHtml(token.text))}</code></pre>\n`);
@@ -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`;