sdocs 0.0.51 → 0.0.53

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,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.53] - 2026-07-05
11
+
12
+ ### Added
13
+
14
+ - **Content layout is configurable — globally and in place.** The config's
15
+ `content` option sets per-kind defaults, and the same names work as entity
16
+ and block attributes (block beats entity beats config):
17
+ - `maxWidth` — the content column for `[DOCS]`/`[PAGE]` (default `1200px`),
18
+ the stage for `[LAYOUT]` (default `100%`) and for `[preview]`/`[example]`
19
+ (default `100%`; narrower stages center).
20
+ - `padding` — page content `32px`, preview/example stages `16px`
21
+ (settable on `[DOCS]` as the entity default), layout stages `0px`.
22
+ - `direction` / `gap` — preview/example stages are flex containers:
23
+ `flex-direction` (default `row`) and `gap` (default `16px`).
24
+ - `toc` — `[PAGE]` table-of-contents visibility (default `true`;
25
+ `toc="false"` hides it).
26
+
27
+ ## [0.0.52] - 2026-07-05
28
+
29
+ ### Added
30
+
31
+ - **Svelte islands in `[PAGE]` bodies.** A line opening a component/HTML tag
32
+ or a Svelte block (`{#snippet`, `{@render`) after a blank line starts an
33
+ island that passes to Svelte verbatim, until its tags and blocks balance —
34
+ blank lines inside are fine. Markdown can no longer split a snippet across
35
+ paragraphs or cut an HTML section in half. Snippets declared anywhere on
36
+ the page are renderable from anywhere (they land at the top level of the
37
+ compiled fragment), so one `{#snippet}` can serve many sections.
38
+ - **Svelte coloring inside page bodies.** `{#…}…{/…}` blocks highlight as
39
+ Svelte throughout — keywords, typed params, and the markup inside them —
40
+ and `{@…}` tags color as keywords with TypeScript interiors, instead of
41
+ HTML-guess colors. Fenced code and inline code stay inert.
42
+
10
43
  ## [0.0.51] - 2026-07-05
11
44
 
12
45
  ### Changed
@@ -288,7 +288,7 @@
288
288
  );
289
289
  </script>
290
290
 
291
- <div class="sdocs-component-view">
291
+ <div class="sdocs-component-view" style:max-width={doc.maxWidth}>
292
292
  {#if focusedSnippet}
293
293
  <!-- Example full-page view -->
294
294
  <div class="sdocs-view-header">
@@ -482,7 +482,7 @@
482
482
  <style>
483
483
  .sdocs-component-view {
484
484
  padding: 24px 32px;
485
- max-width: 1120px;
485
+ /* max-width comes from the doc entry (config/entity cascade) */
486
486
  font-family: var(--sans);
487
487
  }
488
488
  .sdocs-doc-section {
@@ -579,8 +579,8 @@
579
579
  overflow: hidden;
580
580
  }
581
581
  .sdocs-preview-wrapper {
582
+ /* stage padding lives inside the iframe (config/entity/block cascade) */
582
583
  background: var(--color-base-0);
583
- padding: 16px;
584
584
  }
585
585
  .sdocs-divider {
586
586
  border: none;
@@ -23,7 +23,7 @@
23
23
  </script>
24
24
 
25
25
  <div class="sdocs-page-view">
26
- <div class="sdocs-page-main">
26
+ <div class="sdocs-page-main" style:max-width={doc.maxWidth}>
27
27
  <!-- Header -->
28
28
  <div class="sdocs-view-header">
29
29
  <h1 class="sdocs-view-title">{displayTitle(meta.title)}</h1>
@@ -41,7 +41,7 @@
41
41
  </div>
42
42
 
43
43
  <!-- Table of Contents -->
44
- {#if toc.length > 0}
44
+ {#if toc.length > 0 && doc.showToc !== false}
45
45
  <aside class="sdocs-toc">
46
46
  <h3 class="sdocs-toc-title">On this page</h3>
47
47
  <nav>
@@ -5,6 +5,18 @@
5
5
  "fileTypes": [
6
6
  "sdoc"
7
7
  ],
8
+ "injections": {
9
+ "L:meta.embedded.block.markdown - (markup.fenced_code.block.markdown, markup.inline.raw.string.markdown, meta.template.block.sdoc)": {
10
+ "patterns": [
11
+ {
12
+ "include": "#page-svelte-tag"
13
+ },
14
+ {
15
+ "include": "#page-svelte-expression"
16
+ }
17
+ ]
18
+ }
19
+ },
8
20
  "patterns": [
9
21
  {
10
22
  "include": "#comment"
@@ -232,6 +244,9 @@
232
244
  "while": "(^|\\G)(?!\\s*\\[/PAGE\\]\\s*$)(?:\\t|[ ]{1,4})?",
233
245
  "contentName": "meta.embedded.block.markdown",
234
246
  "patterns": [
247
+ {
248
+ "include": "#page-svelte-block"
249
+ },
235
250
  {
236
251
  "include": "text.html.markdown"
237
252
  }
@@ -239,6 +254,79 @@
239
254
  }
240
255
  ]
241
256
  },
257
+ "page-svelte-block": {
258
+ "name": "meta.template.block.sdoc",
259
+ "begin": "(\\{#[a-zA-Z]+)([^}]*)(\\})",
260
+ "beginCaptures": {
261
+ "1": {
262
+ "name": "keyword.control.svelte.sdoc"
263
+ },
264
+ "2": {
265
+ "patterns": [
266
+ {
267
+ "include": "source.ts"
268
+ }
269
+ ]
270
+ },
271
+ "3": {
272
+ "name": "keyword.control.svelte.sdoc"
273
+ }
274
+ },
275
+ "end": "(\\{/[a-zA-Z]+\\})",
276
+ "endCaptures": {
277
+ "1": {
278
+ "name": "keyword.control.svelte.sdoc"
279
+ }
280
+ },
281
+ "patterns": [
282
+ {
283
+ "include": "source.svelte"
284
+ }
285
+ ]
286
+ },
287
+ "page-svelte-tag": {
288
+ "name": "meta.template.block.sdoc",
289
+ "begin": "(\\{)([@:][a-zA-Z]+)",
290
+ "beginCaptures": {
291
+ "1": {
292
+ "name": "punctuation.definition.block.begin.sdoc"
293
+ },
294
+ "2": {
295
+ "name": "keyword.control.svelte.sdoc"
296
+ }
297
+ },
298
+ "end": "(\\})",
299
+ "endCaptures": {
300
+ "1": {
301
+ "name": "punctuation.definition.block.end.sdoc"
302
+ }
303
+ },
304
+ "patterns": [
305
+ {
306
+ "include": "source.ts"
307
+ }
308
+ ]
309
+ },
310
+ "page-svelte-expression": {
311
+ "name": "meta.template.expression.sdoc",
312
+ "begin": "(\\{)(?![#/@:\\s}])(?=[^{}]*\\})",
313
+ "beginCaptures": {
314
+ "1": {
315
+ "name": "punctuation.definition.expression.begin.sdoc"
316
+ }
317
+ },
318
+ "end": "(\\})",
319
+ "endCaptures": {
320
+ "1": {
321
+ "name": "punctuation.definition.expression.end.sdoc"
322
+ }
323
+ },
324
+ "patterns": [
325
+ {
326
+ "include": "source.ts"
327
+ }
328
+ ]
329
+ },
242
330
  "layout": {
243
331
  "name": "meta.block.layout.sdoc",
244
332
  "begin": "^\\s*(\\[)(LAYOUT)\\b(.*)$",
@@ -1,3 +1,4 @@
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
- export { parseSdoc, parseArgsLiteral, slugifyTitle, normalizeBody, type ArgValue, type PreviewBlock, type ExampleBlock, type DocsEntity, type PageEntity, type LayoutEntity, type SdocEntity, type SdocDocument, } from './parser.js';
3
+ export { segmentPageBody, type PageSegment, } from './page-islands.js';
4
+ export { parseSdoc, parseArgsLiteral, slugifyTitle, normalizeBody, type ArgValue, type Sizing, type PreviewBlock, type ExampleBlock, type DocsEntity, type PageEntity, type LayoutEntity, type SdocEntity, type SdocDocument, } from './parser.js';
@@ -1,3 +1,4 @@
1
1
  export { scanSdoc, offsetToPosition, ENTITY_KINDS, SUB_BLOCK_KINDS, } from './scanner.js';
2
2
  export { projectSdoc, } from './projection.js';
3
+ export { segmentPageBody, } from './page-islands.js';
3
4
  export { parseSdoc, parseArgsLiteral, slugifyTitle, normalizeBody, } from './parser.js';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Island segmentation for [PAGE] bodies.
3
+ *
4
+ * A page body is markdown prose interleaved with Svelte "islands" — component
5
+ * tags, HTML sections, and template blocks like {#snippet}…{/snippet}. Markdown
6
+ * tooling (the renderer, prettier) must never look inside an island: markdown's
7
+ * paragraph and HTML-block rules split Svelte structure apart (a snippet opened
8
+ * inside a <p> and closed outside fails to compile; a blank line cuts a <div>
9
+ * section in half; a formatter re-indents block lines it can't understand).
10
+ *
11
+ * The rule an author sees: a line that starts a Svelte block ({#…}, {@render …})
12
+ * or an HTML/component tag, sitting at the start of a markdown block (preceded
13
+ * by a blank line or the body edge), begins an island. The island runs until
14
+ * its Svelte blocks and HTML tags are balanced — blank lines inside are part of
15
+ * the island. Everything else is prose.
16
+ */
17
+ export interface PageSegment {
18
+ kind: 'prose' | 'island';
19
+ /** Verbatim lines of the segment */
20
+ lines: string[];
21
+ }
22
+ /** Split a page body into prose and island segments. */
23
+ export declare function segmentPageBody(body: string): PageSegment[];
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Island segmentation for [PAGE] bodies.
3
+ *
4
+ * A page body is markdown prose interleaved with Svelte "islands" — component
5
+ * tags, HTML sections, and template blocks like {#snippet}…{/snippet}. Markdown
6
+ * tooling (the renderer, prettier) must never look inside an island: markdown's
7
+ * paragraph and HTML-block rules split Svelte structure apart (a snippet opened
8
+ * inside a <p> and closed outside fails to compile; a blank line cuts a <div>
9
+ * section in half; a formatter re-indents block lines it can't understand).
10
+ *
11
+ * The rule an author sees: a line that starts a Svelte block ({#…}, {@render …})
12
+ * or an HTML/component tag, sitting at the start of a markdown block (preceded
13
+ * by a blank line or the body edge), begins an island. The island runs until
14
+ * its Svelte blocks and HTML tags are balanced — blank lines inside are part of
15
+ * the island. Everything else is prose.
16
+ */
17
+ /** Void elements never contribute to tag depth. */
18
+ const VOID_TAGS = new Set([
19
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
20
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
21
+ ]);
22
+ /** Does this line begin an island (at a markdown block start)? */
23
+ function startsIsland(line) {
24
+ const t = line.trimStart();
25
+ return /^\{[#/@]/.test(t) || /^<[A-Za-z]/.test(t) || /^<\//.test(t);
26
+ }
27
+ /**
28
+ * Net Svelte block depth and HTML tag depth contributed by one line.
29
+ * Line-based and heuristic by design: quoted attribute values are skipped,
30
+ * self-closing and void tags are neutral. Unbalanced input never throws —
31
+ * the island simply extends (and Svelte reports the real error).
32
+ */
33
+ function lineDepths(line) {
34
+ let svelte = 0;
35
+ let html = 0;
36
+ for (let i = 0; i < line.length; i++) {
37
+ const ch = line[i];
38
+ if (ch === '"' || ch === "'" || ch === '`') {
39
+ for (i++; i < line.length && line[i] !== ch; i++) {
40
+ if (line[i] === '\\')
41
+ i++;
42
+ }
43
+ continue;
44
+ }
45
+ if (ch === '{') {
46
+ if (line[i + 1] === '#')
47
+ svelte++;
48
+ else if (line[i + 1] === '/')
49
+ svelte--;
50
+ continue;
51
+ }
52
+ if (ch !== '<')
53
+ continue;
54
+ const rest = line.slice(i);
55
+ const close = rest.match(/^<\/([A-Za-z][A-Za-z0-9-]*)/);
56
+ if (close) {
57
+ if (!VOID_TAGS.has(close[1].toLowerCase()))
58
+ html--;
59
+ continue;
60
+ }
61
+ const open = rest.match(/^<([A-Za-z][A-Za-z0-9-]*)/);
62
+ if (!open)
63
+ continue;
64
+ if (VOID_TAGS.has(open[1].toLowerCase()))
65
+ continue;
66
+ // Find the tag's own '>' (skipping quoted attrs) to detect self-closing.
67
+ let j = i + open[0].length;
68
+ for (; j < line.length; j++) {
69
+ const c = line[j];
70
+ if (c === '"' || c === "'") {
71
+ for (j++; j < line.length && line[j] !== c; j++)
72
+ ;
73
+ }
74
+ else if (c === '>')
75
+ break;
76
+ }
77
+ if (j >= line.length || line[j - 1] !== '/')
78
+ html++;
79
+ // Self-closing (`/>`) is neutral; an unterminated tag counts as open.
80
+ }
81
+ return { svelte, html };
82
+ }
83
+ /** Split a page body into prose and island segments. */
84
+ export function segmentPageBody(body) {
85
+ const lines = body.split('\n');
86
+ const segments = [];
87
+ let current = null;
88
+ const push = (kind, line) => {
89
+ if (current?.kind !== kind) {
90
+ current = { kind, lines: [] };
91
+ segments.push(current);
92
+ }
93
+ current.lines.push(line);
94
+ };
95
+ let i = 0;
96
+ while (i < lines.length) {
97
+ const line = lines[i];
98
+ const prevBlank = i === 0 || lines[i - 1].trim() === '';
99
+ if (prevBlank && line.trim() !== '' && startsIsland(line)) {
100
+ // Consume the island: until Svelte blocks and HTML tags balance out.
101
+ let svelte = 0;
102
+ let html = 0;
103
+ let j = i;
104
+ for (; j < lines.length; j++) {
105
+ const d = lineDepths(lines[j]);
106
+ svelte += d.svelte;
107
+ html += d.html;
108
+ if (svelte <= 0 && html <= 0)
109
+ break;
110
+ }
111
+ const end = Math.min(j, lines.length - 1);
112
+ for (let k = i; k <= end; k++)
113
+ push('island', lines[k]);
114
+ current = null; // consecutive islands stay separate segments
115
+ i = end + 1;
116
+ continue;
117
+ }
118
+ push('prose', line);
119
+ i++;
120
+ }
121
+ return segments;
122
+ }
@@ -5,6 +5,17 @@
5
5
  */
6
6
  import { type ScanError, type Span, type TagBlock } from './scanner.js';
7
7
  export type ArgValue = string | number | boolean;
8
+ /** Explicit presentation overrides from stage attributes */
9
+ export interface Sizing {
10
+ maxWidth: string | null;
11
+ padding: string | null;
12
+ /** flex-direction of preview/example stages */
13
+ direction: string | null;
14
+ /** gap of preview/example stages */
15
+ gap: string | null;
16
+ /** table-of-contents visibility (PAGE) */
17
+ toc: boolean | null;
18
+ }
8
19
  export interface PreviewBlock {
9
20
  /** The identifier from component={X}; null when invalid/missing (already reported) */
10
21
  componentName: string | null;
@@ -16,12 +27,14 @@ export interface PreviewBlock {
16
27
  title: string | null;
17
28
  /** Tab label: the title override or the component name */
18
29
  label: string;
30
+ sizing: Sizing;
19
31
  body: string;
20
32
  bodySpan: Span;
21
33
  span: Span;
22
34
  }
23
35
  export interface ExampleBlock {
24
36
  title: string;
37
+ sizing: Sizing;
25
38
  body: string;
26
39
  bodySpan: Span;
27
40
  span: Span;
@@ -31,6 +44,7 @@ export interface DocsEntity {
31
44
  title: string;
32
45
  slug: string;
33
46
  description: string | null;
47
+ sizing: Sizing;
34
48
  previews: PreviewBlock[];
35
49
  examples: ExampleBlock[];
36
50
  openerSpan: Span;
@@ -40,6 +54,7 @@ export interface PageEntity {
40
54
  kind: 'PAGE';
41
55
  title: string;
42
56
  slug: string;
57
+ sizing: Sizing;
43
58
  body: string;
44
59
  bodySpan: Span;
45
60
  openerSpan: Span;
@@ -49,7 +64,7 @@ export interface LayoutEntity {
49
64
  kind: 'LAYOUT';
50
65
  title: string;
51
66
  slug: string;
52
- padding: string | null;
67
+ sizing: Sizing;
53
68
  body: string;
54
69
  bodySpan: Span;
55
70
  openerSpan: Span;
@@ -43,17 +43,39 @@ export function slugifyTitle(title) {
43
43
  .replace(/^-+|-+$/g, '') || 'untitled');
44
44
  }
45
45
  const IDENTIFIER_RE = /^[A-Z][A-Za-z0-9_]*$/;
46
+ const SIZING_ATTR_RULES = {
47
+ maxWidth: { required: false, kind: 'string', hint: 'maxWidth="1200px"' },
48
+ padding: { required: false, kind: 'string', hint: 'padding="32px"' },
49
+ };
50
+ const STAGE_LAYOUT_ATTR_RULES = {
51
+ direction: { required: false, kind: 'string', hint: 'direction="column"' },
52
+ gap: { required: false, kind: 'string', hint: 'gap="16px"' },
53
+ };
54
+ function sizingOf(attrs) {
55
+ const toc = stringAttr(attrs, 'toc');
56
+ return {
57
+ maxWidth: stringAttr(attrs, 'maxWidth'),
58
+ padding: stringAttr(attrs, 'padding'),
59
+ direction: stringAttr(attrs, 'direction'),
60
+ gap: stringAttr(attrs, 'gap'),
61
+ toc: toc === null ? null : toc === 'true',
62
+ };
63
+ }
46
64
  const ENTITY_ATTR_RULES = {
47
65
  DOCS: {
48
66
  title: { required: true, kind: 'string', hint: 'title="Group / Name"' },
49
67
  description: { required: false, kind: 'string', hint: 'description="…"' },
68
+ ...SIZING_ATTR_RULES,
69
+ ...STAGE_LAYOUT_ATTR_RULES,
50
70
  },
51
71
  PAGE: {
52
72
  title: { required: true, kind: 'string', hint: 'title="Group / Name"' },
73
+ ...SIZING_ATTR_RULES,
74
+ toc: { required: false, kind: 'string', hint: 'toc="false"' },
53
75
  },
54
76
  LAYOUT: {
55
77
  title: { required: true, kind: 'string', hint: 'title="Group / Name"' },
56
- padding: { required: false, kind: 'string', hint: 'padding="48px"' },
78
+ ...SIZING_ATTR_RULES,
57
79
  },
58
80
  };
59
81
  const SUB_BLOCK_ATTR_RULES = {
@@ -61,9 +83,13 @@ const SUB_BLOCK_ATTR_RULES = {
61
83
  component: { required: true, kind: 'expression', hint: 'component={Button}' },
62
84
  args: { required: false, kind: 'expression', hint: 'args={{ label: "Hi" }}' },
63
85
  title: { required: false, kind: 'string', hint: 'title="…"' },
86
+ ...SIZING_ATTR_RULES,
87
+ ...STAGE_LAYOUT_ATTR_RULES,
64
88
  },
65
89
  example: {
66
90
  title: { required: true, kind: 'string', hint: 'title="…"' },
91
+ ...SIZING_ATTR_RULES,
92
+ ...STAGE_LAYOUT_ATTR_RULES,
67
93
  },
68
94
  };
69
95
  function checkAttrs(owner, attrs, rules, ownerSpan, diagnostics) {
@@ -192,6 +218,7 @@ function parsePreview(block, diagnostics) {
192
218
  argsRaw,
193
219
  title,
194
220
  label: title ?? componentName ?? 'Preview',
221
+ sizing: sizingOf(block.attrs),
195
222
  body: normalizeBody(block.body),
196
223
  bodySpan: block.bodySpan,
197
224
  span: block.span,
@@ -229,6 +256,7 @@ function parseDocs(entity, diagnostics) {
229
256
  exampleTitles.add(title);
230
257
  examples.push({
231
258
  title,
259
+ sizing: sizingOf(block.attrs),
232
260
  body: normalizeBody(block.body),
233
261
  bodySpan: block.bodySpan,
234
262
  span: block.span,
@@ -241,6 +269,7 @@ function parseDocs(entity, diagnostics) {
241
269
  title,
242
270
  slug: slugifyTitle(title),
243
271
  description: stringAttr(entity.attrs, 'description'),
272
+ sizing: sizingOf(entity.attrs),
244
273
  previews,
245
274
  examples,
246
275
  openerSpan: entity.openerSpan,
@@ -263,15 +292,13 @@ export function parseSdoc(source) {
263
292
  const base = {
264
293
  title,
265
294
  slug: slugifyTitle(title),
295
+ sizing: sizingOf(entity.attrs),
266
296
  body: normalizeBody(entity.body),
267
297
  bodySpan: entity.bodySpan,
268
298
  openerSpan: entity.openerSpan,
269
299
  span: entity.span,
270
300
  };
271
- typed =
272
- entity.kind === 'PAGE'
273
- ? { kind: 'PAGE', ...base }
274
- : { kind: 'LAYOUT', padding: stringAttr(entity.attrs, 'padding'), ...base };
301
+ typed = entity.kind === 'PAGE' ? { kind: 'PAGE', ...base } : { kind: 'LAYOUT', ...base };
275
302
  }
276
303
  if (slugs.has(typed.slug)) {
277
304
  diagnostics.push({
@@ -13,6 +13,11 @@ const DEFAULTS = {
13
13
  order: {},
14
14
  open: [],
15
15
  },
16
+ content: {
17
+ page: { maxWidth: '1200px', padding: '32px', toc: true },
18
+ docs: { maxWidth: '1200px', padding: '16px', direction: 'row', gap: '16px' },
19
+ layout: { maxWidth: '100%', padding: '0px' },
20
+ },
16
21
  };
17
22
  /** Find the config file path in the given directory */
18
23
  export function findConfigFile(root) {
@@ -83,5 +88,10 @@ export function resolveConfig(userConfig) {
83
88
  order: userConfig.sidebar?.order ?? DEFAULTS.sidebar.order,
84
89
  open: userConfig.sidebar?.open ?? DEFAULTS.sidebar.open,
85
90
  },
91
+ content: {
92
+ page: { ...DEFAULTS.content.page, ...userConfig.content?.page },
93
+ docs: { ...DEFAULTS.content.docs, ...userConfig.content?.docs },
94
+ layout: { ...DEFAULTS.content.layout, ...userConfig.content?.layout },
95
+ },
86
96
  };
87
97
  }
@@ -12,4 +12,11 @@ export interface RenderedPage {
12
12
  html: string;
13
13
  toc: TocHeading[];
14
14
  }
15
+ /**
16
+ * Render a [PAGE] body: Svelte islands (see segmentPageBody) pass through
17
+ * verbatim — markdown never splits a snippet or an HTML section — and the
18
+ * prose between them renders as markdown. Islands land at the top level of
19
+ * the compiled fragment, so a snippet declared anywhere is renderable from
20
+ * anywhere on the page.
21
+ */
15
22
  export declare function renderPageMarkdown(source: string): Promise<RenderedPage>;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { Marked } from 'marked';
11
11
  import { highlight } from './highlighter.js';
12
+ import { segmentPageBody } from '../language/page-islands.js';
12
13
  function slugify(text) {
13
14
  return (text
14
15
  .toLowerCase()
@@ -86,9 +87,28 @@ function plainText(tokens) {
86
87
  }
87
88
  return out;
88
89
  }
90
+ /**
91
+ * Render a [PAGE] body: Svelte islands (see segmentPageBody) pass through
92
+ * verbatim — markdown never splits a snippet or an HTML section — and the
93
+ * prose between them renders as markdown. Islands land at the top level of
94
+ * the compiled fragment, so a snippet declared anywhere is renderable from
95
+ * anywhere on the page.
96
+ */
89
97
  export async function renderPageMarkdown(source) {
90
98
  const toc = [];
91
99
  const usedIds = new Set();
100
+ const parts = [];
101
+ for (const segment of segmentPageBody(source)) {
102
+ if (segment.kind === 'island') {
103
+ parts.push(segment.lines.join('\n'));
104
+ }
105
+ else {
106
+ parts.push(await renderProse(segment.lines.join('\n'), toc, usedIds));
107
+ }
108
+ }
109
+ return { html: parts.join('\n'), toc };
110
+ }
111
+ async function renderProse(source, toc, usedIds) {
92
112
  const headingIds = new WeakMap();
93
113
  const fenceHtml = new WeakMap();
94
114
  const marked = new Marked({ gfm: true });
@@ -161,5 +181,5 @@ export async function renderPageMarkdown(source) {
161
181
  }
162
182
  }
163
183
  });
164
- return { html: marked.parser(tokens), toc };
184
+ return marked.parser(tokens);
165
185
  }
@@ -23,7 +23,12 @@ export declare function resolveScriptImports(script: string, docFilePath: string
23
23
  /** Generate a virtual Svelte iframe wrapper component for a snippet.
24
24
  * Includes $state for reactive prop updates via postMessage, invokes
25
25
  * component methods on request, and broadcasts exported state values. */
26
- export declare function generateIframeComponent(scriptPrelude: string, snippetBody: string, stateNames?: string[], componentName?: string): string;
26
+ export declare function generateIframeComponent(scriptPrelude: string, snippetBody: string, stateNames?: string[], componentName?: string, stage?: {
27
+ maxWidth: string;
28
+ padding: string;
29
+ direction?: string;
30
+ gap?: string;
31
+ }): string;
27
32
  /** The JS that boots a preview page: mount the wrapper + parent-frame messaging. */
28
33
  export declare function generateMountScript(iframeComponentId: string): string;
29
34
  /** Generate the HTML page served inside the iframe (dev / CLI-build inputs) */
@@ -81,7 +81,27 @@ function injectRootRef(snippetBody, componentName) {
81
81
  /** Generate a virtual Svelte iframe wrapper component for a snippet.
82
82
  * Includes $state for reactive prop updates via postMessage, invokes
83
83
  * component methods on request, and broadcasts exported state values. */
84
- export function generateIframeComponent(scriptPrelude, snippetBody, stateNames = [], componentName) {
84
+ export function generateIframeComponent(scriptPrelude, snippetBody, stateNames = [], componentName, stage) {
85
+ // The stage layout (config -> entity -> block cascade) applies here, inside
86
+ // the iframe, so every consumer of the preview page gets it. Preview and
87
+ // example stages are flex containers (direction + gap); page and layout
88
+ // stages are flow-root blocks. Both contain child margins, so the height
89
+ // reported for iframe auto-sizing is exact.
90
+ const stageStyle = [
91
+ ...(stage?.direction
92
+ ? [
93
+ 'display: flex',
94
+ `flex-direction: ${stage.direction}`,
95
+ 'flex-wrap: wrap',
96
+ 'align-items: flex-start',
97
+ `gap: ${stage.gap}`,
98
+ ]
99
+ : ['display: flow-root']),
100
+ ...(stage ? [`padding: ${stage.padding}`] : []),
101
+ ...(stage && stage.maxWidth !== '100%'
102
+ ? [`max-width: ${stage.maxWidth}`, 'margin-inline: auto']
103
+ : []),
104
+ ].join('; ');
85
105
  // The file <script> (imports + shared values), lifted verbatim so previews
86
106
  // and examples see everything the entity's siblings do.
87
107
  const importBlock = scriptPrelude.trim() ? scriptPrelude.trim() + '\n' : '';
@@ -151,7 +171,7 @@ ${stateBroadcast}
151
171
  });
152
172
  </script>
153
173
 
154
- <div id="sdocs-preview" style="display: flow-root">
174
+ <div id="sdocs-preview" style="${stageStyle}">
155
175
  {#snippet SdocsPreview(args)}
156
176
  ${injectRootRef(snippetBody, componentName)}
157
177
  {/snippet}
package/dist/types.d.ts CHANGED
@@ -19,6 +19,38 @@ export interface SdocsConfig {
19
19
  /** Folders expanded by default on load. */
20
20
  open?: string[];
21
21
  };
22
+ /** Content presentation per entity kind; entity/block attributes override these. */
23
+ content?: {
24
+ /** [PAGE] content. Defaults: maxWidth '1200px', padding '32px', toc true. */
25
+ page?: ContentSizing & {
26
+ /** Show the table of contents. Default: true */
27
+ toc?: boolean;
28
+ };
29
+ /** [DOCS] pages: maxWidth is the content column (default '1200px');
30
+ * padding/direction/gap are the default preview/example stage layout
31
+ * (defaults '16px', 'row', '16px'). */
32
+ docs?: ContentSizing & {
33
+ /** Stage flex-direction. Default: 'row' */
34
+ direction?: string;
35
+ /** Stage gap. Default: '16px' */
36
+ gap?: string;
37
+ };
38
+ /** [LAYOUT] stages. Defaults: maxWidth '100%', padding '0px'. */
39
+ layout?: ContentSizing;
40
+ };
41
+ }
42
+ /** Content sizing knobs (any CSS length; padding takes CSS shorthand) */
43
+ export interface ContentSizing {
44
+ maxWidth?: string;
45
+ padding?: string;
46
+ }
47
+ /** Resolved stage layout applied inside a preview iframe */
48
+ export interface StageLayout {
49
+ maxWidth: string;
50
+ padding: string;
51
+ /** flex-direction + gap; set for preview/example stages only */
52
+ direction?: string;
53
+ gap?: string;
22
54
  }
23
55
  /** Resolved config with all defaults applied */
24
56
  export interface ResolvedSdocsConfig {
@@ -32,6 +64,16 @@ export interface ResolvedSdocsConfig {
32
64
  order: Record<string, string[]>;
33
65
  open: string[];
34
66
  };
67
+ content: {
68
+ page: Required<ContentSizing> & {
69
+ toc: boolean;
70
+ };
71
+ docs: Required<ContentSizing> & {
72
+ direction: string;
73
+ gap: string;
74
+ };
75
+ layout: Required<ContentSizing>;
76
+ };
35
77
  }
36
78
  /** Entity metadata from a [DOCS]/[PAGE]/[LAYOUT] opener */
37
79
  export interface SdocMeta {
@@ -87,6 +129,8 @@ export interface ExtractedSnippet {
87
129
  highlightedHtml?: string;
88
130
  /** Preview URL for iframe (added by virtual module) */
89
131
  previewUrl?: string;
132
+ /** Resolved stage layout applied inside the iframe (config → entity → block) */
133
+ stage?: StageLayout;
90
134
  }
91
135
  /** One [preview] of a DOCS entity: a live showcase of one component */
92
136
  export interface PreviewEntry {
@@ -128,6 +172,8 @@ export interface DocEntry {
128
172
  content: ExtractedSnippet | null;
129
173
  /** Table of contents headings (pages only) */
130
174
  toc?: TocHeading[];
131
- /** Stage padding (layouts only) */
132
- padding?: string | null;
175
+ /** Resolved content-column max width (component and page kinds) */
176
+ maxWidth?: string;
177
+ /** Resolved table-of-contents visibility (pages only) */
178
+ showToc?: boolean;
133
179
  }
package/dist/vite.js CHANGED
@@ -228,7 +228,7 @@ export function sdocsPlugin(userConfig) {
228
228
  // example iframes fall back to the first preview's component.
229
229
  const preview = entry.previews.find((p) => p.snippet.slug === snippet.slug) ?? entry.previews[0];
230
230
  const stateNames = (preview?.componentData?.state ?? []).map((s) => s.name);
231
- return generateIframeComponent(scriptPrelude, snippet.body, stateNames, preview?.componentName ?? undefined);
231
+ return generateIframeComponent(scriptPrelude, snippet.body, stateNames, preview?.componentName ?? undefined, snippet.stage);
232
232
  }
233
233
  // Virtual mount script for an emitted preview page
234
234
  if (id.startsWith('\0' + MOUNT_PREFIX)) {
@@ -299,6 +299,27 @@ export function sdocsPlugin(userConfig) {
299
299
  examples: [],
300
300
  content: null,
301
301
  };
302
+ // Sizing cascade: block attribute -> entity attribute -> config default.
303
+ const kindKey = entity.kind === 'DOCS' ? 'docs' : entity.kind === 'PAGE' ? 'page' : 'layout';
304
+ const kindDefaults = config.content[kindKey];
305
+ const stageOf = (block) => ({
306
+ // Entity-level maxWidth on DOCS/PAGE is the content column, not the
307
+ // stage; stages inside them span their panel unless the block says so.
308
+ maxWidth: block?.maxWidth ??
309
+ (entity.kind === 'LAYOUT' ? (entity.sizing.maxWidth ?? kindDefaults.maxWidth) : '100%'),
310
+ padding: block?.padding ?? entity.sizing.padding ?? kindDefaults.padding,
311
+ // direction/gap flex the preview/example stages only
312
+ ...(entity.kind === 'DOCS' && block
313
+ ? {
314
+ direction: block.direction ?? entity.sizing.direction ?? config.content.docs.direction,
315
+ gap: block.gap ?? entity.sizing.gap ?? config.content.docs.gap,
316
+ }
317
+ : {}),
318
+ });
319
+ entry.maxWidth = entity.sizing.maxWidth ?? kindDefaults.maxWidth;
320
+ if (entity.kind === 'PAGE') {
321
+ entry.showToc = entity.sizing.toc ?? config.content.page.toc;
322
+ }
302
323
  if (entity.kind === 'DOCS') {
303
324
  if (entity.description)
304
325
  entry.meta.description = entity.description;
@@ -318,6 +339,7 @@ export function sdocsPlugin(userConfig) {
318
339
  console.warn(`[sdocs] ${filePath}: component {${preview.componentName}} is not imported in the file's <script>`);
319
340
  }
320
341
  }
342
+ snippet.stage = stageOf(preview.sizing);
321
343
  snippet.highlightedHtml = await highlight(snippet.body);
322
344
  entry.previews.push({
323
345
  label: preview.label,
@@ -330,6 +352,10 @@ export function sdocsPlugin(userConfig) {
330
352
  });
331
353
  }
332
354
  entry.examples = snippets.filter((s) => s.role === 'example');
355
+ entity.examples.forEach((example, i) => {
356
+ if (entry.examples[i])
357
+ entry.examples[i].stage = stageOf(example.sizing);
358
+ });
333
359
  for (const example of entry.examples) {
334
360
  example.highlightedHtml = await highlight(example.body);
335
361
  }
@@ -337,12 +363,13 @@ export function sdocsPlugin(userConfig) {
337
363
  else if (entity.kind === 'PAGE') {
338
364
  const rendered = await renderPageMarkdown(entity.body);
339
365
  snippets[0].body = rendered.html;
366
+ snippets[0].stage = stageOf();
340
367
  entry.content = snippets[0];
341
368
  entry.toc = rendered.toc;
342
369
  }
343
370
  else {
371
+ snippets[0].stage = stageOf();
344
372
  entry.content = snippets[0];
345
- entry.padding = entity.padding;
346
373
  }
347
374
  docEntries.set(entityKey(filePath, entity.slug), entry);
348
375
  }
@@ -369,7 +396,8 @@ export function sdocsPlugin(userConfig) {
369
396
  examples: e.examples.map((s) => withUrl(e, s)),
370
397
  content: e.content ? withUrl(e, e.content) : null,
371
398
  toc: e.toc,
372
- padding: e.padding,
399
+ maxWidth: e.maxWidth,
400
+ showToc: e.showToc,
373
401
  }));
374
402
  // Extract named CSS stylesheet names (empty array if single string or null)
375
403
  const cssNames = config.css && typeof config.css === 'object'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdocs",
3
- "version": "0.0.51",
3
+ "version": "0.0.53",
4
4
  "description": "A lightweight documentation tool for Svelte 5 components",
5
5
  "type": "module",
6
6
  "license": "MIT",