kitfly 0.1.2 → 0.2.0
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 +34 -0
- package/README.md +63 -16
- package/VERSION +1 -1
- package/dist/_raw/content/deployment/preflight.md +134 -0
- package/dist/_raw/content/deployment/recipes/aws-s3.md +128 -0
- package/dist/_raw/content/deployment/recipes/cloudflare-pages.md +73 -0
- package/dist/_raw/content/deployment/recipes/cloudflare-r2.md +156 -0
- package/dist/_raw/content/deployment/recipes/fly-io.md +57 -0
- package/dist/_raw/content/deployment/recipes/github-pages.md +112 -0
- package/dist/_raw/content/deployment/recipes/netlify.md +99 -0
- package/dist/_raw/content/deployment/recipes/vercel.md +88 -0
- package/dist/_raw/content/deployment/secrets-and-env-vars.md +75 -0
- package/dist/_raw/content/deployment.md +128 -0
- package/dist/_raw/content/guide/approaches.md +182 -0
- package/dist/_raw/content/guide/features.md +121 -0
- package/dist/_raw/content/guide/getting-started.md +112 -0
- package/dist/_raw/content/guide/kitfly-overview.md +209 -0
- package/dist/_raw/content/reference/configuration.md +259 -0
- package/dist/_raw/content/reference/design-catalog.md +167 -0
- package/dist/_raw/content/reference/environment-variables.md +66 -0
- package/dist/_raw/content/reference/glossary.md +92 -0
- package/dist/_raw/content/reference/key-concepts.md +118 -0
- package/dist/_raw/content/reference/plugins.md +220 -0
- package/dist/_raw/content/reference/structure.md +166 -0
- package/dist/_raw/content/reference.md +19 -0
- package/dist/_raw/content/templates/crucible.md +192 -0
- package/dist/_raw/content/templates/handbook.md +83 -0
- package/dist/_raw/content/templates/minimal.md +138 -0
- package/dist/_raw/content/templates/overview.md +187 -0
- package/dist/_raw/content/templates/pipeline.md +151 -0
- package/dist/_raw/content/templates/productbook.md +187 -0
- package/dist/_raw/content/templates/runbook.md +193 -0
- package/dist/_raw/content/templates/servicebook.md +163 -0
- package/dist/_raw/docs/decisions/ADR-0001-minimalist-site-code.md +118 -0
- package/dist/_raw/docs/decisions/ADR-0002-ai-accessibility.md +153 -0
- package/dist/_raw/docs/decisions/ADR-0003-single-file-bundle.md +93 -0
- package/dist/_raw/docs/decisions/ADR-0004-bun-runtime.md +98 -0
- package/dist/_raw/docs/decisions/ADR-0005-plugin-contract-and-distribution.md +110 -0
- package/dist/_raw/docs/decisions/DDR-0001-viewport-locked-layout.md +111 -0
- package/dist/_raw/docs/decisions/DDR-0002-theme-system.md +131 -0
- package/dist/_raw/docs/decisions/DDR-0003-bounded-logo-slot.md +106 -0
- package/dist/_raw/docs/decisions/DDR-0004-slides-rendering-model.md +113 -0
- package/dist/_raw/docs/decisions/DDR-0005-deterministic-layout-boundary.md +107 -0
- package/dist/_raw/docs/userguide/cli/build.md +85 -0
- package/dist/_raw/docs/userguide/cli/bundle.md +81 -0
- package/dist/_raw/docs/userguide/cli/dev.md +92 -0
- package/dist/_raw/docs/userguide/cli/init.md +116 -0
- package/dist/_raw/docs/userguide/cli/servers.md +69 -0
- package/dist/_raw/docs/userguide/cli/stop.md +76 -0
- package/dist/_raw/docs/userguide/cli/update.md +78 -0
- package/dist/_raw/docs/userguide/cli/version.md +65 -0
- package/dist/_raw/docs/userguide/cli.md +34 -0
- package/dist/_raw/docs/userguide/sharing.md +94 -0
- package/dist/_raw/schemas/plugin-schemas-notes.md +71 -0
- package/dist/_raw/schemas.md +42 -0
- package/dist/assets/brand/kitfly-favicon-32.png +0 -0
- package/dist/assets/brand/kitfly-icon-64.png +0 -0
- package/dist/assets/brand/kitfly-logo-128.png +0 -0
- package/dist/assets/brand/kitfly-logo-512.png +0 -0
- package/dist/assets/brand/kitfly-logo.svg +12132 -0
- package/dist/assets/brand/kitfly-neon-128.png +0 -0
- package/dist/assets/brand/kitfly-neon-192.png +0 -0
- package/dist/assets/brand/kitfly-neon-256.png +0 -0
- package/dist/assets/brand/kitfly-neon.png +0 -0
- package/dist/assets/brand/palette.md +75 -0
- package/dist/content/deployment/index.html +11 -0
- package/dist/content/deployment/preflight.html +418 -0
- package/dist/content/deployment/recipes/aws-s3.html +421 -0
- package/dist/content/deployment/recipes/cloudflare-pages.html +372 -0
- package/dist/content/deployment/recipes/cloudflare-r2.html +443 -0
- package/dist/content/deployment/recipes/fly-io.html +356 -0
- package/dist/content/deployment/recipes/github-pages.html +414 -0
- package/dist/content/deployment/recipes/index.html +11 -0
- package/dist/content/deployment/recipes/netlify.html +394 -0
- package/dist/content/deployment/recipes/vercel.html +382 -0
- package/dist/content/deployment/secrets-and-env-vars.html +380 -0
- package/dist/content/deployment.html +426 -0
- package/dist/content/guide/approaches.html +501 -0
- package/dist/content/guide/features.html +436 -0
- package/dist/content/guide/getting-started.html +403 -0
- package/dist/content/guide/index.html +11 -0
- package/dist/content/guide/kitfly-overview.html +544 -0
- package/dist/content/index.html +11 -0
- package/dist/content/reference/configuration.html +580 -0
- package/dist/content/reference/design-catalog.html +449 -0
- package/dist/content/reference/environment-variables.html +367 -0
- package/dist/content/reference/glossary.html +368 -0
- package/dist/content/reference/index.html +11 -0
- package/dist/content/reference/key-concepts.html +399 -0
- package/dist/content/reference/plugins.html +491 -0
- package/dist/content/reference/structure.html +463 -0
- package/dist/content/reference.html +334 -0
- package/dist/content/templates/crucible.html +546 -0
- package/dist/content/templates/handbook.html +405 -0
- package/dist/content/templates/index.html +11 -0
- package/dist/content/templates/minimal.html +447 -0
- package/dist/content/templates/overview.html +558 -0
- package/dist/content/templates/pipeline.html +494 -0
- package/dist/content/templates/productbook.html +540 -0
- package/dist/content/templates/runbook.html +543 -0
- package/dist/content/templates/servicebook.html +523 -0
- package/dist/content-index.json +540 -0
- package/dist/docs/decisions/ADR-0001-minimalist-site-code.html +491 -0
- package/dist/docs/decisions/ADR-0002-ai-accessibility.html +434 -0
- package/dist/docs/decisions/ADR-0003-single-file-bundle.html +412 -0
- package/dist/docs/decisions/ADR-0004-bun-runtime.html +409 -0
- package/dist/docs/decisions/ADR-0005-plugin-contract-and-distribution.html +402 -0
- package/dist/docs/decisions/DDR-0001-viewport-locked-layout.html +459 -0
- package/dist/docs/decisions/DDR-0002-theme-system.html +452 -0
- package/dist/docs/decisions/DDR-0003-bounded-logo-slot.html +423 -0
- package/dist/docs/decisions/DDR-0004-slides-rendering-model.html +399 -0
- package/dist/docs/decisions/DDR-0005-deterministic-layout-boundary.html +422 -0
- package/dist/docs/decisions/index.html +11 -0
- package/dist/docs/userguide/cli/build.html +408 -0
- package/dist/docs/userguide/cli/bundle.html +419 -0
- package/dist/docs/userguide/cli/dev.html +428 -0
- package/dist/docs/userguide/cli/index.html +11 -0
- package/dist/docs/userguide/cli/init.html +436 -0
- package/dist/docs/userguide/cli/servers.html +393 -0
- package/dist/docs/userguide/cli/stop.html +408 -0
- package/dist/docs/userguide/cli/update.html +406 -0
- package/dist/docs/userguide/cli/version.html +406 -0
- package/dist/docs/userguide/cli.html +386 -0
- package/dist/docs/userguide/index.html +11 -0
- package/dist/docs/userguide/sharing.html +465 -0
- package/dist/index.html +387 -0
- package/dist/llms.txt +18 -0
- package/dist/provenance.json +7 -0
- package/dist/schemas/index.html +11 -0
- package/dist/schemas/plugin-registry.schema.html +327 -0
- package/dist/schemas/plugin-schemas-notes.html +364 -0
- package/dist/schemas/plugin.schema.html +327 -0
- package/dist/schemas/plugins.schema.html +327 -0
- package/dist/schemas/v0/common.schema.html +386 -0
- package/dist/schemas/v0/index.html +11 -0
- package/dist/schemas/v0/plugin-registry.schema.html +547 -0
- package/dist/schemas/v0/plugin.schema.html +497 -0
- package/dist/schemas/v0/plugins.schema.html +406 -0
- package/dist/schemas/v0/site.schema.html +541 -0
- package/dist/schemas/v0/theme.schema.html +615 -0
- package/dist/schemas.html +351 -0
- package/dist/styles.css +1262 -0
- package/package.json +4 -2
- package/plugins-dist/callouts.css +32 -0
- package/plugins-dist/callouts.js +46 -0
- package/plugins-dist/slides-visuals.css +224 -0
- package/plugins-dist/slides-visuals.js +598 -0
- package/registry/plugins.yaml +35 -0
- package/schemas/README.md +10 -0
- package/schemas/plugin-registry.schema.json +5 -0
- package/schemas/plugin-schemas-notes.md +71 -0
- package/schemas/plugin.schema.json +5 -0
- package/schemas/plugins.schema.json +5 -0
- package/schemas/v0/common.schema.json +64 -0
- package/schemas/v0/plugin-registry.schema.json +225 -0
- package/schemas/v0/plugin.schema.json +175 -0
- package/schemas/v0/plugins.schema.json +84 -0
- package/schemas/v0/site.schema.json +56 -9
- package/schemas/v0/theme.schema.json +105 -22
- package/scripts/build.ts +155 -3
- package/scripts/bundle.ts +258 -95
- package/scripts/dev.ts +203 -1
- package/src/__tests__/build.test.ts +158 -1
- package/src/__tests__/bundle.test.ts +31 -0
- package/src/__tests__/cli.test.ts +14 -3
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/bad-list-indent.md +5 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/blank-line.md +5 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/compare-object-items.md +9 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/indented-fence.md +4 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/stat-grid-missing-fields.md +5 -0
- package/src/__tests__/fixtures/fences/slides-visuals/invalid/unknown-type.md +3 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/compare.md +10 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/comparison-table.md +14 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/funnel.md +7 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/kpi.md +5 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/layer-cake.md +6 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/pyramid.md +6 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/quadrant-grid.md +8 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/scorecard.md +13 -0
- package/src/__tests__/fixtures/fences/slides-visuals/valid/stat-grid.md +8 -0
- package/src/__tests__/init.test.ts +35 -0
- package/src/__tests__/plugin-loader.test.ts +221 -0
- package/src/__tests__/shared.test.ts +428 -0
- package/src/__tests__/slides-visuals-fence-contract.test.ts +28 -0
- package/src/__tests__/slides-visuals-runtime-regressions.bun.test.ts +114 -0
- package/src/__tests__/styles.test.ts +35 -0
- package/src/cli.ts +9 -4
- package/src/plugin-loader.ts +245 -0
- package/src/shared.ts +614 -7
- package/src/site/styles.css +331 -0
- package/src/site/template.html +66 -5
- package/src/templates/deck.ts +186 -0
- package/src/templates/driver.ts +11 -1
- package/src/templates/minimal.ts +1 -0
package/src/shared.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
9
|
-
import { join, resolve, sep } from "node:path";
|
|
9
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
10
|
import { ENGINE_SITE_DIR, siteOverridePath } from "./engine.ts";
|
|
11
11
|
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
@@ -62,10 +62,15 @@ export interface SiteServer {
|
|
|
62
62
|
host?: string; // Default dev server host
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
export type SiteMode = "docs" | "slides";
|
|
66
|
+
export type SlideAspect = "16/9" | "4/3" | "3/2" | "16/10";
|
|
67
|
+
|
|
65
68
|
export interface SiteConfig {
|
|
66
69
|
docroot: string;
|
|
67
70
|
title: string;
|
|
68
71
|
version?: string;
|
|
72
|
+
mode?: SiteMode;
|
|
73
|
+
aspect?: SlideAspect;
|
|
69
74
|
home?: string;
|
|
70
75
|
brand: SiteBrand;
|
|
71
76
|
sections: SiteSection[];
|
|
@@ -88,6 +93,22 @@ export interface ContentFile {
|
|
|
88
93
|
sectionBase?: string;
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
export interface SlideSegment {
|
|
97
|
+
index: number;
|
|
98
|
+
frontmatter: Record<string, unknown>;
|
|
99
|
+
body: string;
|
|
100
|
+
title: string;
|
|
101
|
+
className?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface SlideContent extends SlideSegment {
|
|
105
|
+
id: string;
|
|
106
|
+
section: string;
|
|
107
|
+
sourcePath: string;
|
|
108
|
+
sourceUrlPath: string;
|
|
109
|
+
kind: "markdown" | "yaml" | "json";
|
|
110
|
+
}
|
|
111
|
+
|
|
91
112
|
// ---------------------------------------------------------------------------
|
|
92
113
|
// Environment and CLI helpers
|
|
93
114
|
// ---------------------------------------------------------------------------
|
|
@@ -386,14 +407,28 @@ export function parseFrontmatter(content: string): {
|
|
|
386
407
|
frontmatter: Record<string, unknown>;
|
|
387
408
|
body: string;
|
|
388
409
|
} {
|
|
389
|
-
const
|
|
390
|
-
|
|
410
|
+
const normalized = content.replace(/^\uFEFF/, "").replaceAll("\r\n", "\n");
|
|
411
|
+
const lines = normalized.split("\n");
|
|
412
|
+
|
|
413
|
+
let i = 0;
|
|
414
|
+
while (i < lines.length && lines[i].trim() === "") i += 1;
|
|
415
|
+
if (i >= lines.length || lines[i].trim() !== "---") {
|
|
391
416
|
return { frontmatter: {}, body: content };
|
|
392
417
|
}
|
|
393
418
|
|
|
419
|
+
i += 1;
|
|
420
|
+
const fmLines: string[] = [];
|
|
421
|
+
while (i < lines.length && lines[i].trim() !== "---") {
|
|
422
|
+
fmLines.push(lines[i]);
|
|
423
|
+
i += 1;
|
|
424
|
+
}
|
|
425
|
+
if (i >= lines.length) return { frontmatter: {}, body: content };
|
|
426
|
+
i += 1; // consume closing ---
|
|
427
|
+
|
|
428
|
+
const body = lines.slice(i).join("\n");
|
|
429
|
+
|
|
394
430
|
const frontmatter: Record<string, unknown> = {};
|
|
395
|
-
const
|
|
396
|
-
for (const line of lines) {
|
|
431
|
+
for (const line of fmLines) {
|
|
397
432
|
const colonIndex = line.indexOf(":");
|
|
398
433
|
if (colonIndex > 0) {
|
|
399
434
|
const key = line.slice(0, colonIndex).trim();
|
|
@@ -409,7 +444,7 @@ export function parseFrontmatter(content: string): {
|
|
|
409
444
|
}
|
|
410
445
|
}
|
|
411
446
|
|
|
412
|
-
return { frontmatter, body
|
|
447
|
+
return { frontmatter, body };
|
|
413
448
|
}
|
|
414
449
|
|
|
415
450
|
export function slugify(text: string): string {
|
|
@@ -421,6 +456,524 @@ export function slugify(text: string): string {
|
|
|
421
456
|
.trim();
|
|
422
457
|
}
|
|
423
458
|
|
|
459
|
+
export type SlidesVisualsFenceDiagnostic = {
|
|
460
|
+
line: number;
|
|
461
|
+
message: string;
|
|
462
|
+
type?: string;
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
interface FenceState {
|
|
466
|
+
char: "`" | "~";
|
|
467
|
+
length: number;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
interface FenceMarker extends FenceState {
|
|
471
|
+
trailer: string;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function parseFenceMarker(trimmed: string): FenceMarker | null {
|
|
475
|
+
const match = trimmed.match(/^([`~]{3,})(.*)$/);
|
|
476
|
+
if (!match) return null;
|
|
477
|
+
const marker = match[1];
|
|
478
|
+
if (!marker.split("").every((ch) => ch === marker[0])) return null;
|
|
479
|
+
const char = marker[0] as "`" | "~";
|
|
480
|
+
return { char, length: marker.length, trailer: match[2] };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function updateFenceState(trimmed: string, fence: FenceState | null): FenceState | null {
|
|
484
|
+
const marker = parseFenceMarker(trimmed);
|
|
485
|
+
if (!marker) return fence;
|
|
486
|
+
|
|
487
|
+
if (!fence) {
|
|
488
|
+
return { char: marker.char, length: marker.length };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Markdown closing fences must use the same fence character and at least the same length.
|
|
492
|
+
if (marker.char === fence.char && marker.length >= fence.length && marker.trailer.trim() === "") {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return fence;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const SLIDES_VISUALS_TYPES = new Set([
|
|
500
|
+
"kpi",
|
|
501
|
+
"stat-grid",
|
|
502
|
+
"compare",
|
|
503
|
+
"quadrant-grid",
|
|
504
|
+
"scorecard",
|
|
505
|
+
"comparison-table",
|
|
506
|
+
"layer-cake",
|
|
507
|
+
"pyramid",
|
|
508
|
+
"funnel",
|
|
509
|
+
]);
|
|
510
|
+
|
|
511
|
+
const SLIDES_VISUALS_RULES: Record<
|
|
512
|
+
string,
|
|
513
|
+
{
|
|
514
|
+
required: string[];
|
|
515
|
+
scalars: string[];
|
|
516
|
+
lists: Record<
|
|
517
|
+
string,
|
|
518
|
+
{ kind: "strings" } | { kind: "objects"; fields: string[]; optional?: string[] }
|
|
519
|
+
>;
|
|
520
|
+
}
|
|
521
|
+
> = {
|
|
522
|
+
kpi: {
|
|
523
|
+
required: ["label", "value"],
|
|
524
|
+
scalars: ["label", "value", "trend"],
|
|
525
|
+
lists: {},
|
|
526
|
+
},
|
|
527
|
+
"stat-grid": {
|
|
528
|
+
required: ["metrics"],
|
|
529
|
+
scalars: [],
|
|
530
|
+
lists: { metrics: { kind: "objects", fields: ["label", "value"], optional: ["trend"] } },
|
|
531
|
+
},
|
|
532
|
+
compare: {
|
|
533
|
+
required: ["left", "right"],
|
|
534
|
+
scalars: ["left-title", "right-title"],
|
|
535
|
+
lists: { left: { kind: "strings" }, right: { kind: "strings" } },
|
|
536
|
+
},
|
|
537
|
+
"quadrant-grid": {
|
|
538
|
+
required: ["tl", "tr", "bl", "br"],
|
|
539
|
+
scalars: ["axis-x", "axis-y", "tl", "tr", "bl", "br"],
|
|
540
|
+
lists: {},
|
|
541
|
+
},
|
|
542
|
+
scorecard: {
|
|
543
|
+
required: ["metrics"],
|
|
544
|
+
scalars: [],
|
|
545
|
+
lists: { metrics: { kind: "objects", fields: ["label", "value"], optional: ["trend"] } },
|
|
546
|
+
},
|
|
547
|
+
"comparison-table": {
|
|
548
|
+
required: ["headers", "rows"],
|
|
549
|
+
scalars: [],
|
|
550
|
+
lists: { headers: { kind: "strings" }, rows: { kind: "strings" } },
|
|
551
|
+
},
|
|
552
|
+
"layer-cake": {
|
|
553
|
+
required: ["layers"],
|
|
554
|
+
scalars: [],
|
|
555
|
+
lists: { layers: { kind: "strings" } },
|
|
556
|
+
},
|
|
557
|
+
pyramid: {
|
|
558
|
+
required: ["levels"],
|
|
559
|
+
scalars: [],
|
|
560
|
+
lists: { levels: { kind: "strings" } },
|
|
561
|
+
},
|
|
562
|
+
funnel: {
|
|
563
|
+
required: ["stages"],
|
|
564
|
+
scalars: [],
|
|
565
|
+
lists: { stages: { kind: "strings" } },
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Validate slides-visuals `:::` blocks in a single markdown slide body.
|
|
571
|
+
* This contract is intentionally strict so writers/devs don’t guess at edge cases.
|
|
572
|
+
*/
|
|
573
|
+
export function validateSlidesVisualsFences(markdown: string): SlidesVisualsFenceDiagnostic[] {
|
|
574
|
+
const diagnostics: SlidesVisualsFenceDiagnostic[] = [];
|
|
575
|
+
const lines = markdown.replaceAll("\r\n", "\n").split("\n");
|
|
576
|
+
|
|
577
|
+
let mdFence: FenceState | null = null;
|
|
578
|
+
let inVisual = false;
|
|
579
|
+
let visualType = "";
|
|
580
|
+
let visualStart = 0;
|
|
581
|
+
let seenKeys = new Set<string>();
|
|
582
|
+
let currentListKey: string | null = null;
|
|
583
|
+
let listItems = 0;
|
|
584
|
+
let listItemFields: Record<string, Set<string>> | null = null;
|
|
585
|
+
|
|
586
|
+
function err(line: number, message: string) {
|
|
587
|
+
diagnostics.push({ line, message, type: inVisual ? visualType : undefined });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function finishFence(closeLine: number) {
|
|
591
|
+
const rules = SLIDES_VISUALS_RULES[visualType];
|
|
592
|
+
if (!rules) return;
|
|
593
|
+
|
|
594
|
+
for (const key of rules.required) {
|
|
595
|
+
if (!seenKeys.has(key)) {
|
|
596
|
+
err(visualStart, `Missing required key: ${key}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (currentListKey && listItems === 0) {
|
|
601
|
+
err(closeLine, `List '${currentListKey}' must have at least one item`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (listItemFields) {
|
|
605
|
+
for (const [requiredKey, fields] of Object.entries(listItemFields)) {
|
|
606
|
+
const listRule = rules.lists[requiredKey];
|
|
607
|
+
if (!listRule || listRule.kind !== "objects") continue;
|
|
608
|
+
for (const req of listRule.fields) {
|
|
609
|
+
if (!fields.has(req))
|
|
610
|
+
err(visualStart, `List '${requiredKey}' items must include '${req}'`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
for (let i = 0; i < lines.length; i++) {
|
|
617
|
+
const raw = lines[i];
|
|
618
|
+
const trimmed = raw.trim();
|
|
619
|
+
|
|
620
|
+
mdFence = updateFenceState(trimmed, mdFence);
|
|
621
|
+
if (mdFence) continue;
|
|
622
|
+
|
|
623
|
+
if (!inVisual) {
|
|
624
|
+
if (trimmed.startsWith(":::") && !raw.startsWith(":::")) {
|
|
625
|
+
const mBad = trimmed.match(/^:::\s*([a-z0-9-]+)\s*$/i);
|
|
626
|
+
const type = mBad?.[1]?.toLowerCase();
|
|
627
|
+
diagnostics.push({
|
|
628
|
+
line: i + 1,
|
|
629
|
+
message: "Opening ::: fence must start at column 0",
|
|
630
|
+
type,
|
|
631
|
+
});
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const m = raw.match(/^:::\s*([a-z0-9-]+)\s*$/i);
|
|
635
|
+
if (!m) continue;
|
|
636
|
+
const type = m[1].toLowerCase();
|
|
637
|
+
if (!SLIDES_VISUALS_TYPES.has(type)) {
|
|
638
|
+
diagnostics.push({
|
|
639
|
+
line: i + 1,
|
|
640
|
+
message: `Unknown slides-visuals block type: ${type}`,
|
|
641
|
+
type,
|
|
642
|
+
});
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
inVisual = true;
|
|
646
|
+
visualType = type;
|
|
647
|
+
visualStart = i + 1;
|
|
648
|
+
seenKeys = new Set();
|
|
649
|
+
currentListKey = null;
|
|
650
|
+
listItems = 0;
|
|
651
|
+
listItemFields = null;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// inside visual fence
|
|
656
|
+
if (trimmed === ":::" && !raw.startsWith(":::")) {
|
|
657
|
+
err(i + 1, "Closing ::: fence must start at column 0");
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (raw.match(/^:::\s*$/)) {
|
|
662
|
+
finishFence(i + 1);
|
|
663
|
+
inVisual = false;
|
|
664
|
+
visualType = "";
|
|
665
|
+
currentListKey = null;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (trimmed === "") {
|
|
670
|
+
err(i + 1, "Blank lines are not allowed inside ::: blocks");
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (/^\s/.test(raw)) {
|
|
675
|
+
// list item or list continuation
|
|
676
|
+
if (!currentListKey) {
|
|
677
|
+
err(i + 1, "Indented content is only allowed inside a list");
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const listRule = SLIDES_VISUALS_RULES[visualType]?.lists[currentListKey];
|
|
682
|
+
const item = raw.match(/^ {2}-\s+(.+)$/);
|
|
683
|
+
if (item) {
|
|
684
|
+
listItems += 1;
|
|
685
|
+
if (listRule?.kind === "objects") {
|
|
686
|
+
const kv = item[1].match(/^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i);
|
|
687
|
+
if (!listItemFields) listItemFields = {};
|
|
688
|
+
const fields = listItemFields;
|
|
689
|
+
fields[currentListKey] ??= new Set<string>();
|
|
690
|
+
if (kv) fields[currentListKey].add(kv[1].toLowerCase());
|
|
691
|
+
}
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const cont = raw.match(/^ {4}([a-z][a-z0-9-]*)\s*:\s*(.+)$/i);
|
|
696
|
+
if (cont) {
|
|
697
|
+
if (listRule?.kind !== "objects") {
|
|
698
|
+
err(i + 1, `List '${currentListKey}' items must be strings (no object fields)`);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (!listItemFields) listItemFields = {};
|
|
702
|
+
const fields = listItemFields;
|
|
703
|
+
fields[currentListKey] ??= new Set<string>();
|
|
704
|
+
fields[currentListKey].add(cont[1].toLowerCase());
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
err(i + 1, "Invalid list syntax (expected ' - ...' or ' field: value')");
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const rules = SLIDES_VISUALS_RULES[visualType];
|
|
713
|
+
if (!rules) continue;
|
|
714
|
+
|
|
715
|
+
const kv = raw.match(/^([a-z][a-z0-9-]*)\s*:\s*(.*)$/i);
|
|
716
|
+
if (!kv) {
|
|
717
|
+
err(i + 1, "Invalid line inside ::: block (expected 'key: value' or 'key:')");
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const key = kv[1].toLowerCase();
|
|
722
|
+
const value = kv[2];
|
|
723
|
+
|
|
724
|
+
if (value === "") {
|
|
725
|
+
// list key
|
|
726
|
+
const listRule = rules.lists[key];
|
|
727
|
+
if (!listRule) {
|
|
728
|
+
err(i + 1, `Key '${key}' is not a supported list for ${visualType}`);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
seenKeys.add(key);
|
|
732
|
+
currentListKey = key;
|
|
733
|
+
listItems = 0;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// scalar key
|
|
738
|
+
if (!rules.scalars.includes(key)) {
|
|
739
|
+
err(i + 1, `Key '${key}' is not a supported scalar for ${visualType}`);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
seenKeys.add(key);
|
|
743
|
+
currentListKey = null;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (inVisual) {
|
|
747
|
+
diagnostics.push({
|
|
748
|
+
line: visualStart,
|
|
749
|
+
message: `Unclosed ::: block (missing closing ':::')`,
|
|
750
|
+
type: visualType,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return diagnostics;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Split markdown content into slide chunks using explicit delimiter.
|
|
759
|
+
* Delimiter lines inside fenced code blocks are ignored.
|
|
760
|
+
*/
|
|
761
|
+
export function splitSlides(content: string): string[] {
|
|
762
|
+
const lines = content.split(/\r?\n/);
|
|
763
|
+
const slides: string[] = [];
|
|
764
|
+
let current: string[] = [];
|
|
765
|
+
let fence: FenceState | null = null;
|
|
766
|
+
|
|
767
|
+
for (const line of lines) {
|
|
768
|
+
const trimmed = line.trim();
|
|
769
|
+
fence = updateFenceState(trimmed, fence);
|
|
770
|
+
|
|
771
|
+
if (!fence && trimmed === "--- slide ---") {
|
|
772
|
+
slides.push(current.join("\n"));
|
|
773
|
+
current = [];
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
current.push(line);
|
|
778
|
+
}
|
|
779
|
+
slides.push(current.join("\n"));
|
|
780
|
+
|
|
781
|
+
return slides.filter((s) => s.trim() !== "");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function extractHeadingTitle(markdown: string): string | undefined {
|
|
785
|
+
const lines = markdown.split(/\r?\n/);
|
|
786
|
+
let fence: FenceState | null = null;
|
|
787
|
+
|
|
788
|
+
for (const line of lines) {
|
|
789
|
+
const trimmed = line.trim();
|
|
790
|
+
fence = updateFenceState(trimmed, fence);
|
|
791
|
+
|
|
792
|
+
if (fence) continue;
|
|
793
|
+
const match = trimmed.match(/^#{1,6}\s+(.+)$/);
|
|
794
|
+
if (match) {
|
|
795
|
+
return match[1].replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return undefined;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function asNonEmptyString(value: unknown): string | undefined {
|
|
803
|
+
if (typeof value !== "string") return undefined;
|
|
804
|
+
const trimmed = value.trim();
|
|
805
|
+
return trimmed === "" ? undefined : trimmed;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function sanitizeClassNameList(value: unknown): string | undefined {
|
|
809
|
+
const classList = asNonEmptyString(value);
|
|
810
|
+
if (!classList) return undefined;
|
|
811
|
+
|
|
812
|
+
const safeTokens = classList
|
|
813
|
+
.split(/\s+/)
|
|
814
|
+
.map((token) => token.trim())
|
|
815
|
+
.filter(Boolean)
|
|
816
|
+
.filter((token) => /^[A-Za-z0-9_-]+$/.test(token));
|
|
817
|
+
|
|
818
|
+
return safeTokens.length > 0 ? safeTokens.join(" ") : undefined;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Parse markdown into slide segments with resolved titles and optional classes.
|
|
823
|
+
* Title precedence: frontmatter.title -> first heading -> fallback title.
|
|
824
|
+
*/
|
|
825
|
+
export function segmentSlides(content: string, fallbackTitle: string): SlideSegment[] {
|
|
826
|
+
const parts = splitSlides(content);
|
|
827
|
+
const total = parts.length;
|
|
828
|
+
|
|
829
|
+
return parts.map((part, index) => {
|
|
830
|
+
const { frontmatter, body } = parseFrontmatter(part);
|
|
831
|
+
const fmTitle = asNonEmptyString(frontmatter.title);
|
|
832
|
+
const headingTitle = extractHeadingTitle(body);
|
|
833
|
+
const autoFallback = total > 1 ? `${fallbackTitle} (${index + 1})` : fallbackTitle;
|
|
834
|
+
const title = fmTitle || headingTitle || autoFallback;
|
|
835
|
+
const className = sanitizeClassNameList(frontmatter.class);
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
index,
|
|
839
|
+
frontmatter,
|
|
840
|
+
body,
|
|
841
|
+
title,
|
|
842
|
+
className,
|
|
843
|
+
};
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Collect slide content objects from discovered content files.
|
|
849
|
+
* Markdown files can produce multiple slides via explicit delimiters.
|
|
850
|
+
*/
|
|
851
|
+
export async function collectSlides(files: ContentFile[]): Promise<SlideContent[]> {
|
|
852
|
+
const slides: SlideContent[] = [];
|
|
853
|
+
let index = 0;
|
|
854
|
+
|
|
855
|
+
for (const file of files) {
|
|
856
|
+
const raw = await readFile(file.path, "utf-8");
|
|
857
|
+
const stem = basename(file.path, extname(file.path));
|
|
858
|
+
|
|
859
|
+
if (file.path.endsWith(".md")) {
|
|
860
|
+
const segments = segmentSlides(raw, stem);
|
|
861
|
+
for (const segment of segments) {
|
|
862
|
+
index += 1;
|
|
863
|
+
slides.push({
|
|
864
|
+
...segment,
|
|
865
|
+
id: `slide-${index}`,
|
|
866
|
+
section: file.section,
|
|
867
|
+
sourcePath: file.path,
|
|
868
|
+
sourceUrlPath: file.urlPath,
|
|
869
|
+
kind: "markdown",
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
index += 1;
|
|
876
|
+
slides.push({
|
|
877
|
+
index: 0,
|
|
878
|
+
frontmatter: {},
|
|
879
|
+
body: raw,
|
|
880
|
+
title: stem,
|
|
881
|
+
className: undefined,
|
|
882
|
+
id: `slide-${index}`,
|
|
883
|
+
section: file.section,
|
|
884
|
+
sourcePath: file.path,
|
|
885
|
+
sourceUrlPath: file.urlPath,
|
|
886
|
+
kind: file.path.endsWith(".yaml") ? "yaml" : "json",
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return slides;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export function buildSlideNav(
|
|
894
|
+
slides: SlideContent[],
|
|
895
|
+
config: SiteConfig,
|
|
896
|
+
currentSlideId?: string,
|
|
897
|
+
): string {
|
|
898
|
+
const grouped = new Map<string, SlideContent[]>();
|
|
899
|
+
for (const slide of slides) {
|
|
900
|
+
if (!grouped.has(slide.section)) grouped.set(slide.section, []);
|
|
901
|
+
grouped.get(slide.section)?.push(slide);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
let html = "<ul>";
|
|
905
|
+
for (const section of config.sections) {
|
|
906
|
+
const items = grouped.get(section.name);
|
|
907
|
+
if (!items || items.length === 0) continue;
|
|
908
|
+
html += `<li><span class="nav-section">${escapeHtml(section.name)}</span><ul>`;
|
|
909
|
+
for (const slide of items) {
|
|
910
|
+
const active = currentSlideId === slide.id ? ' class="active"' : "";
|
|
911
|
+
html += `<li><a href="#${slide.id}"${active}>${escapeHtml(slide.title)}</a></li>`;
|
|
912
|
+
}
|
|
913
|
+
html += "</ul></li>";
|
|
914
|
+
}
|
|
915
|
+
html += "</ul>";
|
|
916
|
+
return html;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function resolveRelativeContentPath(pathOrRef: string, currentUrlPath?: string): string {
|
|
920
|
+
let cleaned = pathOrRef;
|
|
921
|
+
if (currentUrlPath && !cleaned.startsWith("/")) {
|
|
922
|
+
const base = currentUrlPath.includes("/")
|
|
923
|
+
? currentUrlPath.slice(0, currentUrlPath.lastIndexOf("/"))
|
|
924
|
+
: "";
|
|
925
|
+
cleaned = base ? `${base}/${cleaned}` : cleaned;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const segments = cleaned.split("/");
|
|
929
|
+
const resolved: string[] = [];
|
|
930
|
+
for (const segment of segments) {
|
|
931
|
+
if (!segment || segment === ".") continue;
|
|
932
|
+
if (segment === "..") resolved.pop();
|
|
933
|
+
else resolved.push(segment);
|
|
934
|
+
}
|
|
935
|
+
return resolved.join("/");
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function splitUrlSuffix(url: string): { path: string; suffix: string } {
|
|
939
|
+
const idx = url.search(/[?#]/);
|
|
940
|
+
if (idx < 0) return { path: url, suffix: "" };
|
|
941
|
+
return { path: url.slice(0, idx), suffix: url.slice(idx) };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function isExternalOrAnchorRef(ref: string): boolean {
|
|
945
|
+
return /^(https?:|mailto:|tel:|data:|javascript:|#|\/\/)/i.test(ref);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Rewrite relative href/src URLs so slide assets resolve from their source folder.
|
|
949
|
+
export function rewriteRelativeAssetUrls(
|
|
950
|
+
html: string,
|
|
951
|
+
currentUrlPath?: string,
|
|
952
|
+
pathPrefix = "/",
|
|
953
|
+
): string {
|
|
954
|
+
const assetHrefPattern =
|
|
955
|
+
/\.(pdf|png|jpe?g|gif|webp|svg|ico|bmp|avif|json|ya?ml|csv|txt|zip|mp4|webm|mov|mp3|wav|ogg)$/i;
|
|
956
|
+
|
|
957
|
+
return html.replace(/\b(href|src)="([^"]+)"/g, (_m, attr, value: string) => {
|
|
958
|
+
if (isExternalOrAnchorRef(value) || value.startsWith("/")) {
|
|
959
|
+
return `${attr}="${value}"`;
|
|
960
|
+
}
|
|
961
|
+
if (attr === "href") {
|
|
962
|
+
const isExplicitRelative = value.startsWith("./") || value.startsWith("../");
|
|
963
|
+
const { path } = splitUrlSuffix(value);
|
|
964
|
+
if (!isExplicitRelative || !assetHrefPattern.test(path)) {
|
|
965
|
+
return `${attr}="${value}"`;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const { path, suffix } = splitUrlSuffix(value);
|
|
970
|
+
const resolved = resolveRelativeContentPath(path, currentUrlPath);
|
|
971
|
+
const prefix = pathPrefix.endsWith("/") ? pathPrefix : `${pathPrefix}/`;
|
|
972
|
+
const rewritten = `${prefix}${resolved}${suffix}`;
|
|
973
|
+
return `${attr}="${rewritten}"`;
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
|
|
424
977
|
// ---------------------------------------------------------------------------
|
|
425
978
|
// Navigation/template building
|
|
426
979
|
// ---------------------------------------------------------------------------
|
|
@@ -1081,7 +1634,32 @@ export async function resolveSiteVersion(
|
|
|
1081
1634
|
configuredVersion?: string,
|
|
1082
1635
|
): Promise<string | undefined> {
|
|
1083
1636
|
if (typeof configuredVersion === "string" && configuredVersion.trim() !== "") {
|
|
1084
|
-
|
|
1637
|
+
const value = configuredVersion.trim();
|
|
1638
|
+
const lower = value.toLowerCase();
|
|
1639
|
+
|
|
1640
|
+
if (lower === "auto") {
|
|
1641
|
+
const autoVersion = await readVersionLine(join(root, "VERSION"));
|
|
1642
|
+
if (autoVersion) return autoVersion;
|
|
1643
|
+
} else if (lower.startsWith("file:")) {
|
|
1644
|
+
const rawPath = value.slice(5).trim();
|
|
1645
|
+
if (!rawPath) {
|
|
1646
|
+
console.warn("version file: path is empty");
|
|
1647
|
+
} else if (isAbsoluteVersionPath(rawPath)) {
|
|
1648
|
+
console.warn(`version file: absolute paths are not allowed: ${rawPath}`);
|
|
1649
|
+
} else {
|
|
1650
|
+
const normalizedRoot = resolve(root);
|
|
1651
|
+
const resolvedPath = resolve(root, rawPath);
|
|
1652
|
+
const rel = relative(normalizedRoot, resolvedPath);
|
|
1653
|
+
if (rel.startsWith("..") || rel === ".." || rel.includes(`${sep}..${sep}`)) {
|
|
1654
|
+
console.warn(`version file: path escapes site root: ${rawPath}`);
|
|
1655
|
+
} else {
|
|
1656
|
+
const fileVersion = await readVersionLine(resolvedPath);
|
|
1657
|
+
if (fileVersion) return fileVersion;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
} else {
|
|
1661
|
+
return value;
|
|
1662
|
+
}
|
|
1085
1663
|
}
|
|
1086
1664
|
|
|
1087
1665
|
try {
|
|
@@ -1102,6 +1680,23 @@ export async function resolveSiteVersion(
|
|
|
1102
1680
|
return undefined;
|
|
1103
1681
|
}
|
|
1104
1682
|
|
|
1683
|
+
function isAbsoluteVersionPath(pathValue: string): boolean {
|
|
1684
|
+
return isAbsolute(pathValue) || /^[A-Za-z]:/.test(pathValue) || pathValue.startsWith("\\\\");
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
async function readVersionLine(path: string): Promise<string | undefined> {
|
|
1688
|
+
try {
|
|
1689
|
+
const content = await readFile(path, "utf-8");
|
|
1690
|
+
for (const line of content.split(/\r?\n/)) {
|
|
1691
|
+
const trimmed = line.trim();
|
|
1692
|
+
if (trimmed) return trimmed;
|
|
1693
|
+
}
|
|
1694
|
+
} catch {
|
|
1695
|
+
// Fall through to git tag resolution
|
|
1696
|
+
}
|
|
1697
|
+
return undefined;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1105
1700
|
/**
|
|
1106
1701
|
* Generate provenance information
|
|
1107
1702
|
* @param root - The root directory
|
|
@@ -1182,6 +1777,14 @@ export async function loadSiteConfig(
|
|
|
1182
1777
|
docroot: parsed.docroot || ".",
|
|
1183
1778
|
title: parsed.title,
|
|
1184
1779
|
version: typeof parsedRecord.version === "string" ? parsedRecord.version : undefined,
|
|
1780
|
+
mode: parsedRecord.mode === "slides" ? "slides" : "docs",
|
|
1781
|
+
aspect:
|
|
1782
|
+
parsedRecord.aspect === "4/3" ||
|
|
1783
|
+
parsedRecord.aspect === "3/2" ||
|
|
1784
|
+
parsedRecord.aspect === "16/10" ||
|
|
1785
|
+
parsedRecord.aspect === "16/9"
|
|
1786
|
+
? parsedRecord.aspect
|
|
1787
|
+
: "16/9",
|
|
1185
1788
|
home: parsed.home as string | undefined,
|
|
1186
1789
|
brand: {
|
|
1187
1790
|
...parsed.brand,
|
|
@@ -1221,6 +1824,8 @@ export async function loadSiteConfig(
|
|
|
1221
1824
|
return {
|
|
1222
1825
|
docroot: "content",
|
|
1223
1826
|
title: "Documentation",
|
|
1827
|
+
mode: "docs",
|
|
1828
|
+
aspect: "16/9",
|
|
1224
1829
|
brand: { name: "Docs", url: "/" },
|
|
1225
1830
|
sections,
|
|
1226
1831
|
};
|
|
@@ -1233,6 +1838,8 @@ export async function loadSiteConfig(
|
|
|
1233
1838
|
return {
|
|
1234
1839
|
docroot: ".",
|
|
1235
1840
|
title: defaultTitle,
|
|
1841
|
+
mode: "docs",
|
|
1842
|
+
aspect: "16/9",
|
|
1236
1843
|
brand: { name: "Handbook", url: "/" },
|
|
1237
1844
|
sections: [],
|
|
1238
1845
|
};
|