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.
- package/CHANGELOG.md +58 -0
- package/dist/commands/build.js +2 -0
- package/dist/commands/dev.js +2 -0
- package/dist/explorer/Explorer.svelte +4 -1
- package/dist/explorer/views/PageView.svelte +285 -41
- package/dist/grammar/sdoc.tmLanguage.json +45 -0
- package/dist/language/config-schema.js +13 -1
- package/dist/language/page-islands.d.ts +3 -1
- package/dist/language/page-islands.js +15 -1
- package/dist/language/parser.d.ts +4 -0
- package/dist/language/parser.js +69 -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/config.js +5 -1
- package/dist/server/doc-model.d.ts +8 -2
- package/dist/server/doc-model.js +22 -9
- package/dist/server/page-markdown.d.ts +3 -0
- package/dist/server/page-markdown.js +60 -3
- package/dist/server/snippet-compiler.d.ts +14 -0
- package/dist/server/snippet-compiler.js +30 -0
- package/dist/types.d.ts +19 -1
- package/dist/vite.js +62 -7
- package/package.json +1 -1
package/dist/language/parser.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
349
|
+
checkAttrs('[LAYOUT]', entity.attrs, ENTITY_ATTR_RULES.LAYOUT, entity.openerSpan, diagnostics);
|
|
301
350
|
const title = stringAttr(entity.attrs, 'title') ?? '';
|
|
302
|
-
|
|
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>
|
|
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
|
}
|
package/dist/server/config.js
CHANGED
|
@@ -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
|
|
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) {
|
|
@@ -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'),
|
|
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
|
-
|
|
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`;
|