sdocs 0.0.50 → 0.0.52

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,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.0.52] - 2026-07-05
11
+
12
+ ### Added
13
+
14
+ - **Svelte islands in `[PAGE]` bodies.** A line opening a component/HTML tag
15
+ or a Svelte block (`{#snippet`, `{@render`) after a blank line starts an
16
+ island that passes to Svelte verbatim, until its tags and blocks balance —
17
+ blank lines inside are fine. Markdown can no longer split a snippet across
18
+ paragraphs or cut an HTML section in half. Snippets declared anywhere on
19
+ the page are renderable from anywhere (they land at the top level of the
20
+ compiled fragment), so one `{#snippet}` can serve many sections.
21
+ - **Svelte coloring inside page bodies.** `{#…}…{/…}` blocks highlight as
22
+ Svelte throughout — keywords, typed params, and the markup inside them —
23
+ and `{@…}` tags color as keywords with TypeScript interiors, instead of
24
+ HTML-guess colors. Fenced code and inline code stay inert.
25
+
26
+ ## [0.0.51] - 2026-07-05
27
+
28
+ ### Changed
29
+
30
+ - **The sdoc grammar colors `[PAGE]` bodies as markdown.** Headings, bold,
31
+ lists, and fences highlight in page prose (the body's block indent is
32
+ accounted for, so indented bodies don't read as code blocks). Applies
33
+ everywhere the grammar is used — the VS Code extension and ` ```sdoc `
34
+ fences on the site.
35
+
36
+ ### Fixed
37
+
38
+ - **Page expressions with string literals no longer break the page.** The
39
+ markdown pass HTML-escaped quotes (and `&&`, `<`) inside `{…}` expressions,
40
+ so `{@render colorBox("#ff0000")}` or `{format("x")}` in a `[PAGE]` body
41
+ failed to compile. Balanced `{…}` spans now pass through verbatim; prose
42
+ around them escapes exactly as before, and code fences and inline code stay
43
+ inert.
44
+
45
+ ### Added
46
+
47
+ - **`\{` escapes a literal brace in page prose.** Previously a lone brace
48
+ written in prose could break the page; a backslash-escaped brace now renders
49
+ as an inert literal `{`.
50
+
10
51
  ## [0.0.50] - 2026-07-05
11
52
 
12
53
  ### Fixed
@@ -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"
@@ -229,15 +241,92 @@
229
241
  "patterns": [
230
242
  {
231
243
  "begin": "(^|\\G)",
232
- "while": "(^|\\G)(?!\\s*\\[/PAGE\\]\\s*$)",
244
+ "while": "(^|\\G)(?!\\s*\\[/PAGE\\]\\s*$)(?:\\t|[ ]{1,4})?",
245
+ "contentName": "meta.embedded.block.markdown",
233
246
  "patterns": [
234
247
  {
235
- "include": "source.svelte"
248
+ "include": "#page-svelte-block"
249
+ },
250
+ {
251
+ "include": "text.html.markdown"
236
252
  }
237
253
  ]
238
254
  }
239
255
  ]
240
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
+ },
241
330
  "layout": {
242
331
  "name": "meta.block.layout.sdoc",
243
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 { segmentPageBody, type PageSegment, } from './page-islands.js';
3
4
  export { parseSdoc, parseArgsLiteral, slugifyTitle, normalizeBody, type ArgValue, type PreviewBlock, type ExampleBlock, type DocsEntity, type PageEntity, type LayoutEntity, type SdocEntity, type SdocDocument, } from './parser.js';
@@ -1,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
+ }
@@ -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()
@@ -27,6 +28,55 @@ function escapeHtml(text) {
27
28
  .replace(/>/g, '&gt;')
28
29
  .replace(/"/g, '&quot;');
29
30
  }
31
+ /** marked-equivalent prose escaping (entities already present survive). */
32
+ function escapeProse(text) {
33
+ return text
34
+ .replace(/&(?!(?:#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g, '&amp;')
35
+ .replace(/</g, '&lt;')
36
+ .replace(/>/g, '&gt;')
37
+ .replace(/"/g, '&quot;')
38
+ .replace(/'/g, '&#39;');
39
+ }
40
+ /** Index of the `}` closing the `{` at `open`, or -1. Skips quoted strings. */
41
+ function findExpressionEnd(text, open) {
42
+ let depth = 0;
43
+ for (let i = open; i < text.length; i++) {
44
+ const ch = text[i];
45
+ if (ch === '"' || ch === "'" || ch === '`') {
46
+ for (i++; i < text.length && text[i] !== ch; i++) {
47
+ if (text[i] === '\\')
48
+ i++;
49
+ }
50
+ if (i >= text.length)
51
+ return -1;
52
+ }
53
+ else if (ch === '{')
54
+ depth++;
55
+ else if (ch === '}' && --depth === 0)
56
+ return i;
57
+ }
58
+ return -1;
59
+ }
60
+ /**
61
+ * Escape prose while passing balanced `{…}` spans through verbatim, so Svelte
62
+ * expressions survive the markdown pass — string literals, `&&`, comparisons.
63
+ * An unmatched `{` falls back to prose escaping, exactly as before.
64
+ */
65
+ function escapeTextKeepingExpressions(text) {
66
+ let out = '';
67
+ let from = 0;
68
+ for (let i = 0; i < text.length; i++) {
69
+ if (text[i] !== '{')
70
+ continue;
71
+ const end = findExpressionEnd(text, i);
72
+ if (end === -1)
73
+ break;
74
+ out += escapeProse(text.slice(from, i)) + text.slice(i, end + 1);
75
+ from = end + 1;
76
+ i = end;
77
+ }
78
+ return out + escapeProse(text.slice(from));
79
+ }
30
80
  function plainText(tokens) {
31
81
  let out = '';
32
82
  for (const token of tokens) {
@@ -37,9 +87,28 @@ function plainText(tokens) {
37
87
  }
38
88
  return out;
39
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
+ */
40
97
  export async function renderPageMarkdown(source) {
41
98
  const toc = [];
42
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) {
43
112
  const headingIds = new WeakMap();
44
113
  const fenceHtml = new WeakMap();
45
114
  const marked = new Marked({ gfm: true });
@@ -97,8 +166,20 @@ export async function renderPageMarkdown(source) {
97
166
  // tags, {expressions} stay expressions.
98
167
  html(token) {
99
168
  return token.text;
169
+ },
170
+ // Prose escapes for HTML, but balanced {…} spans stay verbatim so
171
+ // expressions keep their quotes and operators. A backslash-escaped
172
+ // brace (`\{`) becomes an inert literal brace.
173
+ text(token) {
174
+ if (token.type === 'escape')
175
+ return escapeBraces(escapeProse(token.text));
176
+ if ('tokens' in token && token.tokens)
177
+ return this.parser.parseInline(token.tokens);
178
+ if ('escaped' in token && token.escaped)
179
+ return token.text;
180
+ return escapeTextKeepingExpressions(token.text);
100
181
  }
101
182
  }
102
183
  });
103
- return { html: marked.parser(tokens), toc };
184
+ return marked.parser(tokens);
104
185
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdocs",
3
- "version": "0.0.50",
3
+ "version": "0.0.52",
4
4
  "description": "A lightweight documentation tool for Svelte 5 components",
5
5
  "type": "module",
6
6
  "license": "MIT",