@pagenary/publisher 2026.5.2 → 2026.5.3

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/README.md CHANGED
@@ -1,13 +1,39 @@
1
+ <div align="center">
2
+
1
3
  # Pagenary Publisher
2
4
 
3
- Static publishing component for Pagenary — "Where documentation takes shape."
5
+ **Where documentation takes shape.**
6
+
7
+ `@pagenary/publisher` is the static site generator behind Pagenary — it turns one shared template catalog into many branded, tenant-specific documentation sites. Zero runtime dependencies, hash-based routing, full-text search, and a Git-aware build pipeline. Install it as a dev dependency and drive it with the `pagenary` CLI.
8
+
9
+ ```bash
10
+ npm install --save-dev @pagenary/publisher # add Pagenary to your project
11
+ npx pagenary build:tenants my-docs # build your docs tenant
12
+ npx pagenary serve # serve on http://localhost:5173
13
+ ```
14
+
15
+ [![npm version](https://img.shields.io/npm/v/@pagenary/publisher?label=npm&color=CB3837&logo=npm&style=flat-square)](https://www.npmjs.com/package/@pagenary/publisher)
16
+ [![npm downloads](https://img.shields.io/npm/dm/@pagenary/publisher?color=CB3837&logo=npm&style=flat-square)](https://www.npmjs.com/package/@pagenary/publisher)
17
+ [![Docs](https://img.shields.io/badge/docs-docs.pagenary.com-22d3ee?style=flat-square&logo=readthedocs&logoColor=white)](https://docs.pagenary.com)
18
+ [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg?style=flat-square)](../../LICENSE)
19
+ [![Node Version](https://img.shields.io/badge/node-%E2%89%A516.0.0-brightgreen?style=flat-square&logo=node.js)](https://nodejs.org)
4
20
 
5
- Transform shared documentation templates into tenant-specific bundles with custom branding, themes, and content. Zero runtime dependencies, hash-based routing, and full-text search make it ideal for white-label documentation portals.
21
+ [**Docs Site**](https://docs.pagenary.com) · [**Quick Start**](#quick-start) · [**Features**](#features) · [**Tenant Workflow**](#tenant-content-workflow) · [**Documentation**](#documentation)
22
+
23
+ </div>
24
+
25
+ ---
26
+
27
+ ## What It Is
28
+
29
+ The publisher takes a catalog of shared section templates plus per-tenant content and configuration and produces a self-contained documentation bundle for each tenant. Each bundle is a static single-page app — hash-based routing (`#/page-id`), no server-side rendering, no runtime dependencies — that you build once and host anywhere that serves files. Tenants share the template catalog but keep isolated content, branding, navigation, and domains, so one repository can publish a dozen distinct sites.
30
+
31
+ ---
6
32
 
7
33
  ## Quick Start
8
34
 
9
35
  Install the package and drive it with the `pagenary` CLI — **no clone required**.
10
- New here? Follow the [Getting Started guide](docs/GETTING-STARTED.md).
36
+ New here? Follow the **[Getting Started guide](docs/GETTING-STARTED.md)**.
11
37
 
12
38
  ```bash
13
39
  npm install --save-dev @pagenary/publisher
@@ -17,7 +43,7 @@ npx pagenary serve # preview on http://localhost:5173
17
43
  ```
18
44
 
19
45
  Commands: `build`, `build:tenants [id]`, `tenants:list`, `serve` (run
20
- `npx pagenary --help`). The package also ships a compiled reference site under `site/`.
46
+ `npx pagenary --help`). The package also ships a compiled reference site under `site/` — the Pagenary docs, built by Pagenary itself.
21
47
 
22
48
  **Building from source** (contributors / modifying Pagenary):
23
49
 
@@ -27,71 +53,52 @@ npm run dev # build + serve with watch mode
27
53
  npm run build # build default bundle to dist/
28
54
  ```
29
55
 
56
+ ---
57
+
30
58
  ## Features
31
59
 
32
60
  ### Content Authoring
33
- - **Markdown** - Write in `.md` files with full CommonMark support
34
- - **HTML** - Direct markup control with `.html` files
35
- - **JavaScript Modules** - Dynamic content with `.js` files returning `{ html, afterRender? }`
36
- - **Nested Directories** - Organize content in subdirectories (`content/guides/setup.md`)
61
+ - **Markdown** write in `.md` files with full CommonMark support
62
+ - **HTML** direct markup control with `.html` files
63
+ - **JavaScript Modules** dynamic content with `.js` files returning `{ html, afterRender? }`
64
+ - **Nested Directories** organize content in subdirectories (`content/guides/setup.md`)
37
65
 
38
66
  ### Rich Content
39
- - **Mermaid Diagrams** - Flowcharts, sequence diagrams, state machines, and more
40
- - **Syntax Highlighting** - Prism.js with 10+ language support
41
- - **Markdown Tables** - Full table syntax with alignment support
42
- - **HTML Components** - Spec tables, layer stacks, box diagrams, cards
43
- - **Internal Links** - Auto-resolved `#section-id` links in Markdown
67
+ - **Mermaid Diagrams** flowcharts, sequence diagrams, state machines, and more
68
+ - **Syntax Highlighting** Prism.js with 10+ language support
69
+ - **Markdown Tables** full table syntax with alignment support
70
+ - **HTML Components** spec tables, layer stacks, box diagrams, cards
71
+ - **Internal Links** auto-resolved `#section-id` links in Markdown
44
72
 
45
73
  ### External Links
46
- - **Navigation Links** - Add external URLs directly in manifest with `url` property
47
- - **Smart Link Handling** - All external links open in new tab with security headers
48
- - **Visual Indicators** - Subtle ↗ icon shows external destinations
49
- - **CTA Styling** - Button-like `external-cta` class for prominent external links
50
-
51
- **External navigation example** (manifest.json):
52
- ```json
53
- [
54
- { "id": "welcome", "title": "Welcome", "file": "welcome.md" },
55
- { "title": "External Resource", "url": "https://example.com" }
56
- ]
57
- ```
58
-
59
- **External links in Markdown** (auto-handled):
60
- ```markdown
61
- Visit our [support portal](https://support.example.com) for help.
62
- ```
63
-
64
- **Prominent CTA in HTML**:
65
- ```html
66
- <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="external-cta">
67
- Get Started →
68
- </a>
69
- ```
70
-
71
- **Security & UX:**
72
- - All external links use `target="_blank"` and `rel="noopener noreferrer"` by default
73
- - Navigation external links show ↗ indicator
74
- - Content external links styled with subtle ↗ after link text
75
- - No configuration needed - works automatically for `http://` and `https://` URLs
74
+ - **Navigation Links** add external URLs directly in the manifest with a `url` property
75
+ - **Smart Link Handling** external links open in a new tab with security headers (`rel="noopener noreferrer"`)
76
+ - **Visual Indicators** a subtle ↗ icon marks external destinations
77
+ - **CTA Styling** button-like `external-cta` class for prominent external links
76
78
 
77
79
  ### Navigation & Search
78
- - **Command Palette** - `Ctrl/Cmd+K` or `/` opens global finder
79
- - **Full-Text Search** - Searches all content, not just titles
80
- - **Manifest-Driven Nav** - Declarative navigation structure
81
- - **Keyboard Navigation** - Arrow keys, Enter to select
80
+ - **Command Palette** `Ctrl/Cmd+K` or `/` opens a global finder
81
+ - **Full-Text Search** searches all content, not just titles
82
+ - **Manifest-Driven Nav** declarative navigation structure
83
+ - **Keyboard Navigation** arrow keys, Enter to select
82
84
 
83
85
  ### Theming & Branding
84
- - **Custom Colors** - `accentColor` and `surfaceColor` per tenant
85
- - **Brand Identity** - Logo text, tagline, copyright
86
- - **Typography** - IBM Plex Sans/Mono defaults, customizable
86
+ - **Custom Colors** `accentColor` and `surfaceColor` per tenant
87
+ - **Brand Identity** logo text, tagline, copyright
88
+ - **Typography** IBM Plex Sans/Mono defaults, customizable
89
+
90
+ ### SEO (built in)
91
+ - **Absolute URLs** — declare a `domain` (or `seo.siteUrl`) and the sitemap, canonical, `og:url`, and `robots` URLs become fully-qualified
92
+ - **Static snapshots** — crawler-friendly `/pages/<id>.html` for every section, self-canonical (the SPA hash route isn't crawlable)
93
+ - **`sitemap.xml`, `robots.txt`, `llms.txt`** — generated automatically
94
+ - **JSON-LD + Open Graph** — `TechArticle`/`BreadcrumbList` per page, optional Organization data, and `og:image`/`twitter:image` via `seo.ogImage`
87
95
 
88
96
  ### Export & Sharing
89
- - **Export Options** - Choose between Current Page or Entire Site export
90
- - **Branded Exports** - Tenant logo, brand name, and tagline in export header
91
- - **Document Export** - One-click HTML export with TOC
92
- - **Print Styles** - Optimized for PDF generation
93
- - **Syntax Highlighting** - Preserved in exports
94
- - **Table Rendering** - Markdown tables render correctly in exports
97
+ - **Export Options** Current Page or Entire Site
98
+ - **Branded Exports** tenant logo, brand name, and tagline in the export header
99
+ - **Document Export** one-click HTML export with a table of contents, print-optimized for PDF
100
+
101
+ ---
95
102
 
96
103
  ## Tenant Content Workflow
97
104
 
@@ -99,7 +106,7 @@ Visit our [support portal](https://support.example.com) for help.
99
106
 
100
107
  ```
101
108
  my-tenant/
102
- ├── config.json # Branding and theme settings
109
+ ├── config.json # Branding, theme, and SEO settings
103
110
  ├── manifest.json # Navigation structure (optional)
104
111
  ├── content/ # Content files
105
112
  │ ├── welcome.md # Root-level content
@@ -160,14 +167,10 @@ export async function load() {
160
167
 
161
168
  ### Manifest Configuration
162
169
 
163
- **Root manifest.json** (optional - auto-generated from content/ if omitted):
170
+ **Root manifest.json** (optional auto-generated from `content/` if omitted):
164
171
  ```json
165
172
  [
166
- {
167
- "id": "welcome",
168
- "title": "Welcome",
169
- "file": "welcome.md"
170
- },
173
+ { "id": "welcome", "title": "Welcome", "file": "welcome.md" },
171
174
  {
172
175
  "id": "guides",
173
176
  "title": "Guides",
@@ -190,23 +193,15 @@ export async function load() {
190
193
  }
191
194
  ```
192
195
 
193
- **External links in manifest** (use `url` instead of `id`):
196
+ **External links in the manifest** (use `url` instead of `file`):
194
197
  ```json
195
198
  [
196
199
  { "id": "welcome", "title": "Welcome", "file": "welcome.md" },
197
- { "title": "Support Portal", "url": "https://support.example.com" },
198
- {
199
- "id": "resources",
200
- "title": "Resources",
201
- "subsections": [
202
- { "id": "guides/overview", "title": "Overview", "file": "guides/overview.md" },
203
- { "title": "API Docs", "url": "https://api.example.com/docs" }
204
- ]
205
- }
200
+ { "title": "Support Portal", "url": "https://support.example.com" }
206
201
  ]
207
202
  ```
208
203
 
209
- ### Branding Configuration
204
+ ### Branding & SEO Configuration
210
205
 
211
206
  **config.json**:
212
207
  ```json
@@ -219,29 +214,29 @@ export async function load() {
219
214
  "copyright": "ACME Corp",
220
215
  "accentColor": "#6366F1",
221
216
  "surfaceColor": "#F7FAFC",
222
- "export": {
223
- "logo": "embed",
224
- "logoPath": "favicon.png",
225
- "showTagline": true,
226
- "showDate": true
217
+ "domain": "docs.acme.com",
218
+ "seo": {
219
+ "siteUrl": "https://docs.acme.com",
220
+ "ogImage": "/assets/og-card.png",
221
+ "structuredData": { "organizationName": "ACME Corporation" }
227
222
  }
228
223
  }
229
224
  ```
230
225
 
231
226
  | Property | Description | Default |
232
227
  |----------|-------------|---------|
233
- | `title` | Browser tab title | "Docs Toolkit" |
228
+ | `title` | Browser tab title | "Documentation" |
234
229
  | `description` | Meta description for SEO | - |
235
230
  | `brandMark` | Primary brand text (bold) | "DOCS" |
236
231
  | `brandSub` | Secondary brand text (light) | "TOOLKIT" |
237
232
  | `tagline` | Subtitle under brand | - |
238
- | `copyright` | Footer copyright text | "Modular Documentation Toolkit" |
233
+ | `copyright` | Footer copyright text | - |
239
234
  | `accentColor` | Links, buttons, highlights | `#111111` |
240
235
  | `surfaceColor` | Background color (hex) | `#ffffff` |
241
- | `export.logo` | Logo mode: `"embed"`, `"reference"`, or `null` | `"embed"` |
242
- | `export.logoPath` | Path to logo in `.public/` directory | Auto-detect |
243
- | `export.showTagline` | Show tagline in export header | `true` |
244
- | `export.showDate` | Show generation date in export | `true` |
236
+ | `domain` | Canonical domain; also the SEO base URL when `seo.siteUrl` is unset | - |
237
+ | `seo` | SEO block see [Tenant Configuration](docs/TENANT-CONFIG.md#seo-seo) | - |
238
+
239
+ ---
245
240
 
246
241
  ## Build Commands
247
242
 
@@ -266,6 +261,8 @@ npm run check # run all checks
266
261
  npm test # run test suite
267
262
  ```
268
263
 
264
+ ---
265
+
269
266
  ## Tenant Registry
270
267
 
271
268
  Register tenants in a `tenants.json` at your project root (validated by the
@@ -296,6 +293,8 @@ Per-tenant options include `enabled` (default `true`), `strictLinks` (default
296
293
  `true` — fail the build on broken internal links), and `domains` (for the
297
294
  multi-tenant Caddy router). See [Tenant Configuration](docs/TENANT-CONFIG.md).
298
295
 
296
+ ---
297
+
299
298
  ## Docker Caddy Workflow
300
299
 
301
300
  For multi-tenant domain testing:
@@ -304,20 +303,19 @@ For multi-tenant domain testing:
304
303
  # Add to /etc/hosts:
305
304
  # 127.0.0.1 my-docs.local client-portal.local
306
305
 
307
- # Build tenants and start Caddy
308
- npm run build:tenants
309
- npm run caddy:up
310
-
306
+ npm run build:tenants # build tenants
307
+ npm run caddy:up # start Caddy
311
308
  # Visit http://my-docs.local or http://client-portal.local
312
309
 
313
- # Management commands
314
- npm run caddy:logs # Tail logs
315
- npm run caddy:reload # Reload config without restart
316
- npm run caddy:restart # Full restart
317
- npm run caddy:down # Stop container
310
+ npm run caddy:logs # tail logs
311
+ npm run caddy:reload # reload config without restart
312
+ npm run caddy:restart # full restart
313
+ npm run caddy:down # stop container
318
314
  ```
319
315
 
320
- Use non-privileged port: `DOCS_TOOLKIT_PORT=5173 npm run caddy:up`
316
+ Use a non-privileged port: `DOCS_TOOLKIT_PORT=5173 npm run caddy:up`
317
+
318
+ ---
321
319
 
322
320
  ## Repository Layout
323
321
 
@@ -328,32 +326,46 @@ apps/publisher/
328
326
  │ ├── app.js # Router and core logic
329
327
  │ ├── styles.css # All styling
330
328
  │ ├── manifest.js # Default navigation
331
- │ ├── seo.js # Meta tag management
329
+ │ ├── seo.js # Runtime meta tag management
332
330
  │ ├── mermaid-init.js # Diagram rendering
333
331
  │ ├── syntax-highlight.js # Code highlighting
334
- ├── lib/
335
- │ │ ├── search.js # Full-text search
336
- │ │ ├── router.js # Hash routing
337
- │ │ └── export.js # Document export
338
- │ └── sections/ # Default section modules
332
+ └── lib/ # search, router, export
339
333
  ├── scripts/
340
334
  │ ├── build.js # Core build script
341
335
  │ ├── build-tenants.js # Multi-tenant builder
342
336
  │ ├── serve.js # Dev server
343
- │ └── sync-docs.js # Template sync
337
+ │ └── lib/seo-generator.js # Sitemap, robots, snapshots, JSON-LD
344
338
  ├── tenants/ # Built-in example tenants
345
339
  ├── docs/ # Documentation
346
- ├── dist/ # Build output
347
- ├── Caddyfile # Multi-tenant routing
348
- └── docker-compose.yml # Caddy container
340
+ └── Caddyfile, docker-compose.yml # Multi-tenant routing
349
341
  ```
350
342
 
343
+ ---
344
+
351
345
  ## Documentation
352
346
 
353
- - [Getting Started](docs/GETTING-STARTED.md) - **start here**: zero to a published site with the npm package
354
- - [Quick Start Guide](docs/QUICKSTART.md) - Step-by-step tenant creation
355
- - [Tenant Configuration](docs/TENANT-CONFIG.md) - All config options
356
- - [Architecture](docs/ARCHITECTURE.md) - System design
357
- - [API Reference](docs/API.md) - Module documentation
358
- - [Deployment](docs/DEPLOYMENT.md) - Hosting patterns
359
- - [Extending](docs/EXTENDING.md) - Customization guide
347
+ The full documentation site is published at **[docs.pagenary.com](https://docs.pagenary.com)** built by this publisher from the source below. Read it online, or browse the source:
348
+
349
+ - [Getting Started](docs/GETTING-STARTED.md) **start here**: zero to a published site with the npm package
350
+ - [Quick Start Guide](docs/QUICKSTART.md) — step-by-step tenant creation
351
+ - [Tenant Configuration](docs/TENANT-CONFIG.md) all config options (branding, theme, SEO)
352
+ - [Architecture](docs/ARCHITECTURE.md) system design
353
+ - [API Reference](docs/API.md) module documentation
354
+ - [Deployment](docs/DEPLOYMENT.md) — hosting patterns
355
+ - [Extending](docs/EXTENDING.md) — customization guide
356
+
357
+ ---
358
+
359
+ ## License
360
+
361
+ **GNU Affero General Public License v3.0** — strong copyleft. You may use, modify, and distribute Pagenary, but if you run a modified version to provide a network service, you must make the modified source available to its users. See [LICENSE](../../LICENSE).
362
+
363
+ ---
364
+
365
+ <div align="center">
366
+
367
+ **[Back to Top](#pagenary-publisher)**
368
+
369
+ Made with care by [Joseph Magly](https://github.com/jmagly)
370
+
371
+ </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagenary/publisher",
3
- "version": "2026.5.2",
3
+ "version": "2026.5.3",
4
4
  "type": "module",
5
5
  "description": "Multi-tenant static publishing component for Pagenary platform.",
6
6
  "license": "AGPL-3.0-or-later",
@@ -59,8 +59,10 @@
59
59
  "engines": {
60
60
  "node": ">=16"
61
61
  },
62
- "devDependencies": {
63
- "jest": "^29.7.0",
62
+ "optionalDependencies": {
64
63
  "terser": "^5.44.0"
64
+ },
65
+ "devDependencies": {
66
+ "jest": "^29.7.0"
65
67
  }
66
68
  }
@@ -7,6 +7,7 @@ import { spawn, execSync } from 'child_process';
7
7
  import { createHash } from 'crypto';
8
8
  import os from 'os';
9
9
  import { generateSeoArtifacts, resolveBaseUrl, resolveOgImage } from './lib/seo-generator.js';
10
+ import { generateCollections } from './lib/collections-generator.js';
10
11
  import { fileURLToPath } from 'node:url';
11
12
 
12
13
  const root = process.cwd();
@@ -3214,6 +3215,14 @@ async function buildTenant(tenant, targetOverride, cacheDir, buildOptions) {
3214
3215
  // Generate SEO artifacts (sitemap.xml, robots.txt, static pages)
3215
3216
  await generateSeoArtifacts(distDir, config);
3216
3217
 
3218
+ // Generate collection manifests/feeds (#18) — opt-in via config.collections
3219
+ if (Array.isArray(config.collections) && config.collections.length > 0) {
3220
+ const collectionRoot = await findContentRoot(sourceDir);
3221
+ if (collectionRoot.basePath) {
3222
+ await generateCollections(distDir, config, collectionRoot.basePath);
3223
+ }
3224
+ }
3225
+
3217
3226
  // Copy to final target if different from dist
3218
3227
  if (targetDir !== distDir) {
3219
3228
  // Ensure target parent exists
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Collection generator (#18)
3
+ *
4
+ * For a tenant that marks a content folder as a "collection" (e.g. a blog),
5
+ * emit a machine-readable manifest (`index.json`) and optional RSS `feed.xml`
6
+ * derived from each post's front matter — so downstream sites can consume the
7
+ * collection without scraping rendered HTML.
8
+ *
9
+ * Config (tenant config.json):
10
+ * "collections": [
11
+ * { "path": "blog", "route": "/blog", "title": "Blog",
12
+ * "manifest": true, "feed": true, "sortBy": "date", "order": "desc" }
13
+ * ]
14
+ *
15
+ * `path` is relative to the tenant content root. Output lands at the route
16
+ * (or path) under dist: `<dist>/<route>/index.json` and `/feed.xml`.
17
+ */
18
+
19
+ import * as fsp from 'node:fs/promises';
20
+ import * as path from 'node:path';
21
+ import { resolveBaseUrl, encodePathForFilename } from './seo-generator.js';
22
+ import { parseFrontmatter, estimateReadingTime, firstHeading } from './frontmatter.js';
23
+
24
+ const POST_EXTENSIONS = new Set(['.md', '.markdown']);
25
+
26
+ function escapeXml(str) {
27
+ return String(str == null ? '' : str)
28
+ .replace(/&/g, '&amp;')
29
+ .replace(/</g, '&lt;')
30
+ .replace(/>/g, '&gt;')
31
+ .replace(/"/g, '&quot;')
32
+ .replace(/'/g, '&apos;');
33
+ }
34
+
35
+ /** Output subdirectory for a collection: its route (slug-ified) or its path. */
36
+ function outputDir(collection) {
37
+ const route = (collection.route || collection.path || '').replace(/^\/+|\/+$/g, '');
38
+ return route;
39
+ }
40
+
41
+ /**
42
+ * Build the entry list for one collection by reading its source posts.
43
+ * @returns {Promise<Array>} sorted post entries
44
+ */
45
+ async function collectEntries(collection, contentBasePath, baseUrl) {
46
+ const srcDir = path.join(contentBasePath, collection.path);
47
+ let files;
48
+ try {
49
+ files = await fsp.readdir(srcDir, { withFileTypes: true });
50
+ } catch {
51
+ return null; // folder missing — caller warns
52
+ }
53
+
54
+ const entries = [];
55
+ for (const f of files) {
56
+ if (!f.isFile()) continue;
57
+ const ext = path.extname(f.name).toLowerCase();
58
+ if (!POST_EXTENSIONS.has(ext)) continue;
59
+ if (f.name.startsWith('_') || f.name.toLowerCase() === 'index.md') continue;
60
+
61
+ const slug = f.name.slice(0, -ext.length);
62
+ const raw = await fsp.readFile(path.join(srcDir, f.name), 'utf8');
63
+ const { data, body } = parseFrontmatter(raw);
64
+
65
+ // Section id mirrors the build's nested-id scheme: <collection.path>/<slug>
66
+ const sectionId = `${collection.path.replace(/^\/+|\/+$/g, '')}/${slug}`;
67
+ const staticPath = `/pages/${encodePathForFilename(sectionId)}.html`;
68
+ const routePath = collection.route
69
+ ? `${collection.route.replace(/\/+$/, '')}/${slug}`
70
+ : `/#/${sectionId}`;
71
+
72
+ entries.push({
73
+ slug,
74
+ title: data.title || firstHeading(body) || slug,
75
+ date: data.date || null,
76
+ summary: data.summary || data.description || '',
77
+ hero: data.hero || data.image || null,
78
+ tags: Array.isArray(data.tags) ? data.tags : (data.tags ? [data.tags] : []),
79
+ reading_time: estimateReadingTime(body),
80
+ canonical: baseUrl ? `${baseUrl}${staticPath}` : staticPath,
81
+ path: routePath
82
+ });
83
+ }
84
+
85
+ const sortBy = collection.sortBy || 'date';
86
+ const dir = (collection.order || 'desc').toLowerCase() === 'asc' ? 1 : -1;
87
+ entries.sort((a, b) => {
88
+ const av = a[sortBy];
89
+ const bv = b[sortBy];
90
+ // Missing sort key always sorts last, regardless of order.
91
+ if (av == null && bv == null) return 0;
92
+ if (av == null) return 1;
93
+ if (bv == null) return -1;
94
+ if (av < bv) return -1 * dir;
95
+ if (av > bv) return 1 * dir;
96
+ return 0;
97
+ });
98
+ return entries;
99
+ }
100
+
101
+ function buildFeedXml(collection, entries, config, baseUrl) {
102
+ const title = collection.title || config.title || 'Feed';
103
+ const channelLink = baseUrl
104
+ ? `${baseUrl}/${outputDir(collection)}`
105
+ : `/${outputDir(collection)}`;
106
+ const items = entries.map((e) => {
107
+ const pubDate = e.date ? new Date(e.date).toUTCString() : '';
108
+ return ` <item>
109
+ <title>${escapeXml(e.title)}</title>
110
+ <link>${escapeXml(e.canonical)}</link>
111
+ <guid isPermaLink="true">${escapeXml(e.canonical)}</guid>${pubDate ? `\n <pubDate>${pubDate}</pubDate>` : ''}
112
+ <description>${escapeXml(e.summary)}</description>
113
+ </item>`;
114
+ }).join('\n');
115
+
116
+ return `<?xml version="1.0" encoding="UTF-8"?>
117
+ <rss version="2.0">
118
+ <channel>
119
+ <title>${escapeXml(title)}</title>
120
+ <link>${escapeXml(channelLink)}</link>
121
+ <description>${escapeXml(config.description || title)}</description>
122
+ <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
123
+ ${items}
124
+ </channel>
125
+ </rss>
126
+ `;
127
+ }
128
+
129
+ /**
130
+ * Generate collection artifacts for a tenant.
131
+ * @param {string} distDir - Tenant dist directory
132
+ * @param {object} config - Tenant configuration
133
+ * @param {string} contentBasePath - Resolved tenant content root (source)
134
+ */
135
+ export async function generateCollections(distDir, config, contentBasePath) {
136
+ const collections = Array.isArray(config.collections) ? config.collections : [];
137
+ if (collections.length === 0 || !contentBasePath) return;
138
+
139
+ const baseUrl = resolveBaseUrl(config);
140
+
141
+ for (const collection of collections) {
142
+ if (!collection || !collection.path) {
143
+ console.warn(' ⚠ collection entry missing "path" — skipped');
144
+ continue;
145
+ }
146
+ const entries = await collectEntries(collection, contentBasePath, baseUrl);
147
+ if (entries === null) {
148
+ console.warn(` ⚠ collection source not found: ${collection.path}`);
149
+ continue;
150
+ }
151
+
152
+ const outDir = path.join(distDir, outputDir(collection));
153
+ await fsp.mkdir(outDir, { recursive: true });
154
+
155
+ if (collection.manifest !== false) {
156
+ const manifest = {
157
+ title: collection.title || config.title || '',
158
+ route: collection.route || `/${outputDir(collection)}`,
159
+ count: entries.length,
160
+ generated: new Date().toISOString(),
161
+ posts: entries
162
+ };
163
+ await fsp.writeFile(path.join(outDir, 'index.json'), JSON.stringify(manifest, null, 2), 'utf8');
164
+ console.log(` ↳ generated ${outputDir(collection)}/index.json (${entries.length} posts)`);
165
+ }
166
+
167
+ if (collection.feed) {
168
+ const xml = buildFeedXml(collection, entries, config, baseUrl);
169
+ await fsp.writeFile(path.join(outDir, 'feed.xml'), xml, 'utf8');
170
+ console.log(` ↳ generated ${outputDir(collection)}/feed.xml`);
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Minimal, zero-dependency YAML front-matter parser.
3
+ *
4
+ * Handles the common documentation subset: a leading `---` fenced block of
5
+ * `key: value` pairs. Values are coerced to boolean / number where obvious,
6
+ * inline `[a, b]` lists are parsed, and quotes are stripped. Nested maps are
7
+ * NOT supported (out of scope for post metadata) — a nested value is kept as a
8
+ * raw string. Anything unparseable degrades to a string rather than throwing.
9
+ */
10
+
11
+ /**
12
+ * @param {string} raw - File contents
13
+ * @returns {{ data: Record<string, any>, body: string }}
14
+ */
15
+ export function parseFrontmatter(raw) {
16
+ const text = String(raw == null ? '' : raw);
17
+ // Front matter must be the very first thing in the file.
18
+ const match = text.match(/^?---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
19
+ if (!match) return { data: {}, body: text };
20
+
21
+ const [, block, body] = match;
22
+ const data = {};
23
+ for (const line of block.split(/\r?\n/)) {
24
+ if (!line.trim() || line.trim().startsWith('#')) continue;
25
+ const idx = line.indexOf(':');
26
+ if (idx === -1) continue;
27
+ const key = line.slice(0, idx).trim();
28
+ if (!key) continue;
29
+ const rawValue = line.slice(idx + 1).trim();
30
+ data[key] = coerceValue(rawValue);
31
+ }
32
+ return { data, body };
33
+ }
34
+
35
+ function coerceValue(value) {
36
+ if (value === '' || value === '~' || value.toLowerCase() === 'null') return null;
37
+
38
+ // Inline list: [a, b, "c"]
39
+ if (value.startsWith('[') && value.endsWith(']')) {
40
+ const inner = value.slice(1, -1).trim();
41
+ if (!inner) return [];
42
+ return inner.split(',').map((v) => coerceScalar(v.trim()));
43
+ }
44
+ return coerceScalar(value);
45
+ }
46
+
47
+ function coerceScalar(value) {
48
+ // Strip surrounding quotes
49
+ if (
50
+ (value.startsWith('"') && value.endsWith('"')) ||
51
+ (value.startsWith("'") && value.endsWith("'"))
52
+ ) {
53
+ return value.slice(1, -1);
54
+ }
55
+ if (value === 'true') return true;
56
+ if (value === 'false') return false;
57
+ // Number (but not a date like 2026-05-27, and not a version like 1.2.3)
58
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
59
+ return value;
60
+ }
61
+
62
+ /**
63
+ * Estimate reading time in minutes from body text (~200 words/min, min 1).
64
+ * @param {string} body
65
+ * @returns {number}
66
+ */
67
+ export function estimateReadingTime(body) {
68
+ const words = String(body || '').trim().split(/\s+/).filter(Boolean).length;
69
+ return Math.max(1, Math.round(words / 200));
70
+ }
71
+
72
+ /**
73
+ * First Markdown H1 (`# Title`) in the body, or null.
74
+ * @param {string} body
75
+ * @returns {string|null}
76
+ */
77
+ export function firstHeading(body) {
78
+ const m = String(body || '').match(/^#\s+(.+?)\s*$/m);
79
+ return m ? m[1].trim() : null;
80
+ }
package/site/index.html CHANGED
@@ -7,7 +7,7 @@
7
7
  <meta name="description" content="Pagenary developer documentation — building, configuring, deploying, and extending the multi-tenant documentation publisher, published with Pagenary itself." />
8
8
  <link rel="icon" type="image/png" href="./favicon.png" />
9
9
  <link rel="stylesheet" href="./styles.css" />
10
- <meta name="x-build" content="2026-05-27T05:01:47.803Z" />
10
+ <meta name="x-build" content="2026-05-27T07:30:04.796Z" />
11
11
  </head>
12
12
  <body>
13
13
  <a class="skip-link" href="#app">Skip to content</a>
@@ -186,6 +186,51 @@
186
186
  <p>engines drop URL fragments, so hash canonicals would collapse every page onto the</p>
187
187
  <p>homepage. The `#hash` route is still used for the in-page &quot;interactive version&quot;</p>
188
188
  <p>link and the JS redirect.</p>
189
+ <h2 id="collections">Collections</h2>
190
+ <p>A <strong>collection</strong> marks a content folder (e.g. a blog) so the build emits a</p>
191
+ <p>machine-readable manifest — letting downstream sites consume the posts without</p>
192
+ <p>scraping rendered HTML. Configure collections in the tenant `config.json`:</p>
193
+ <pre><code class="language-json">{
194
+ &quot;collections&quot;: [
195
+ {
196
+ &quot;path&quot;: &quot;blog&quot;,
197
+ &quot;route&quot;: &quot;/blog&quot;,
198
+ &quot;title&quot;: &quot;Blog&quot;,
199
+ &quot;manifest&quot;: true,
200
+ &quot;feed&quot;: true,
201
+ &quot;sortBy&quot;: &quot;date&quot;,
202
+ &quot;order&quot;: &quot;desc&quot;
203
+ }
204
+ ]
205
+ }</code></pre>
206
+ <table><thead><tr><th style="text-align: left">Property</th><th style="text-align: left">Type</th><th style="text-align: left">Default</th><th style="text-align: left">Description</th></tr></thead><tbody><tr><td style="text-align: left">`path`</td><td style="text-align: left">string</td><td style="text-align: left">required</td><td style="text-align: left">Collection folder, relative to the content root (e.g. `blog` → `content/blog/`)</td></tr><tr><td style="text-align: left">`route`</td><td style="text-align: left">string</td><td style="text-align: left">`/&lt;path&gt;`</td><td style="text-align: left">Public route; also the output subdirectory under `dist/`</td></tr><tr><td style="text-align: left">`title`</td><td style="text-align: left">string</td><td style="text-align: left">tenant `title`</td><td style="text-align: left">Collection title (manifest + feed)</td></tr><tr><td style="text-align: left">`manifest`</td><td style="text-align: left">boolean</td><td style="text-align: left">`true`</td><td style="text-align: left">Emit `index.json`</td></tr><tr><td style="text-align: left">`feed`</td><td style="text-align: left">boolean</td><td style="text-align: left">`false`</td><td style="text-align: left">Emit RSS `feed.xml`</td></tr><tr><td style="text-align: left">`sortBy`</td><td style="text-align: left">string</td><td style="text-align: left">`&quot;date&quot;`</td><td style="text-align: left">Front-matter field to sort by</td></tr><tr><td style="text-align: left">`order`</td><td style="text-align: left">string</td><td style="text-align: left">`&quot;desc&quot;`</td><td style="text-align: left">`&quot;desc&quot;` or `&quot;asc&quot;` (entries missing the sort key sort last)</td></tr></tbody></table>
207
+ <p>Each post (`&lt;path&gt;/&lt;slug&gt;.md`) supplies metadata via YAML <strong>front matter</strong>;</p>
208
+ <p>files starting with `_` and `index.md` are skipped:</p>
209
+ <pre><code class="language-markdown">---
210
+ title: Shipping Pagenary Collections
211
+ date: 2026-05-27
212
+ summary: How the new collection manifest works.
213
+ hero: /assets/blog/collections.png
214
+ tags: [release, seo]
215
+ ---
216
+
217
+ # Shipping Pagenary Collections
218
+
219
+ Post body…</code></pre>
220
+ <p>The build writes to `dist/&lt;route&gt;/`:</p>
221
+ <ul>
222
+ <li><strong>`index.json`</strong> — `{ title, route, count, generated, posts: [...] }`, where each</li>
223
+ </ul>
224
+ <p>post is `{ slug, title, date, summary, hero, tags, reading_time, canonical, path }`,</p>
225
+ <p>sorted per `sortBy`/`order`. `canonical` is the absolute static-page URL (uses</p>
226
+ <p>the same base URL as <a href="#seo-seo">SEO</a>); `reading_time` is estimated from the body.</p>
227
+ <ul>
228
+ <li><strong>`feed.xml`</strong> <em>(when `feed: true`)</em> — RSS 2.0 of the same set.</li>
229
+ </ul>
230
+ <blockquote>
231
+ <p>A collection&#39;s posts are still rendered as normal pages (each `.md` becomes a</p>
232
+ <p>section). The manifest/feed are additive, machine-readable indexes.</p>
233
+ </blockquote>
189
234
  <h2 id="navigation-manifest-manifestjson">Navigation Manifest (manifest.json)</h2>
190
235
  <h3 id="root-manifest">Root Manifest</h3>
191
236
  <p>Located at tenant root, defines top-level navigation:</p>
package/site/robots.txt CHANGED
@@ -1,5 +1,5 @@
1
1
  # Pagenary Docs
2
- # Generated: 2026-05-27T05:01:48.119Z
2
+ # Generated: 2026-05-27T07:30:05.130Z
3
3
 
4
4
  User-agent: *
5
5
  Allow: /
@@ -1,3 +1,3 @@
1
1
  export async function load() {
2
- return { html: "<section class=\"section doc markdown\">\n <div class=\"doc-content\">\n<h1 id=\"tenant-configuration-reference\">Tenant Configuration Reference</h1>\n<p>Complete reference for all tenant configuration options.</p>\n<h2 id=\"tenant-registry-tenantsjson\">Tenant Registry (tenants.json)</h2>\n<p>Located at `apps/publisher/tenants.json`, this file registers all tenants:</p>\n<pre><code class=\"language-json\">{\n &quot;tenant-id&quot;: {\n &quot;source&quot;: &quot;/path/to/content&quot;,\n &quot;domain&quot;: &quot;docs.example.com&quot;\n }\n}</code></pre>\n<h3 id=\"registry-properties\">Registry Properties</h3>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Required</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`source`</td><td style=\"text-align: left\">Yes</td><td style=\"text-align: left\">Path to tenant content directory</td></tr><tr><td style=\"text-align: left\">`domain`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Custom domain for Caddy routing</td></tr><tr><td style=\"text-align: left\">`enabled`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Whether to build this tenant (default `true`)</td></tr><tr><td style=\"text-align: left\">`strictLinks`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Broken-link gate (default `true`). When `true`, <strong>broken internal links fail the build</strong> — the tenant is reported `Failed` and the process exits non-zero, so CI can gate on it. Set `false` to log broken links as warnings and continue.</td></tr></tbody></table>\n<h3 id=\"source-types\">Source Types</h3>\n<p><strong>Local Path:</strong></p>\n<pre><code class=\"language-json\">{\n &quot;my-docs&quot;: {\n &quot;source&quot;: &quot;/home/user/my-docs&quot;\n }\n}</code></pre>\n<p><strong>Git Repository:</strong></p>\n<pre><code class=\"language-json\">{\n &quot;my-docs&quot;: {\n &quot;source&quot;: &quot;git:https://github.com/org/my-docs.git#main&quot;\n }\n}</code></pre>\n<p>Format: `git:&lt;repo-url&gt;#&lt;branch&gt;`</p>\n<p>Git sources are cloned to a cache directory and updated on each build.</p>\n<h2 id=\"tenant-directory-structure\">Tenant Directory Structure</h2>\n<pre><code>my-tenant/\n├── config.json # Branding and theme (required)\n├── manifest.json # Navigation structure (optional)\n├── content/ # Content files\n│ ├── *.md # Markdown files\n│ ├── *.html # HTML files\n│ ├── *.js # JavaScript modules\n│ └── section/ # Nested directories\n│ └── _manifest.json\n├── .public/ # Static assets (optional)\n│ ├── favicon.ico # Favicons copied to dist root\n│ ├── logo.svg # Assets copied to dist/assets/\n│ └── icons/ # Subdirectories preserved\n└── overrides/ # Post-build replacements (optional)\n └── styles.css # Replace built files</code></pre>\n<h2 id=\"branding-configuration-configjson\">Branding Configuration (config.json)</h2>\n<h3 id=\"complete-example\">Complete Example</h3>\n<pre><code class=\"language-json\">{\n &quot;title&quot;: &quot;ACME Documentation&quot;,\n &quot;description&quot;: &quot;Complete guide to the ACME platform&quot;,\n &quot;brandMark&quot;: &quot;ACME&quot;,\n &quot;brandSub&quot;: &quot;Docs&quot;,\n &quot;tagline&quot;: &quot;Build better, faster&quot;,\n &quot;copyright&quot;: &quot;ACME Corporation&quot;,\n &quot;accentColor&quot;: &quot;#6366F1&quot;,\n &quot;surfaceColor&quot;: &quot;#F7FAFC&quot;\n}</code></pre>\n<h3 id=\"properties-reference\">Properties Reference</h3>\n<h4 id=\"site-metadata\">Site Metadata</h4>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Type</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`title`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">&quot;Docs Toolkit&quot;</td><td style=\"text-align: left\">Browser tab title and header</td></tr><tr><td style=\"text-align: left\">`description`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">-</td><td style=\"text-align: left\">Meta description for SEO</td></tr></tbody></table>\n<h4 id=\"branding\">Branding</h4>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Type</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`brandMark`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">&quot;DOCS&quot;</td><td style=\"text-align: left\">Primary brand text (bold, uppercase)</td></tr><tr><td style=\"text-align: left\">`brandSub`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">&quot;TOOLKIT&quot;</td><td style=\"text-align: left\">Secondary brand text (light weight)</td></tr><tr><td style=\"text-align: left\">`tagline`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">-</td><td style=\"text-align: left\">Subtitle displayed under brand</td></tr><tr><td style=\"text-align: left\">`copyright`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">&quot;Modular Documentation Toolkit&quot;</td><td style=\"text-align: left\">Footer copyright text</td></tr></tbody></table>\n<h4 id=\"theme-colors\">Theme Colors</h4>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Type</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`accentColor`</td><td style=\"text-align: left\">hex string</td><td style=\"text-align: left\">&quot;#111111&quot;</td><td style=\"text-align: left\">Links, buttons, active states</td></tr><tr><td style=\"text-align: left\">`surfaceColor`</td><td style=\"text-align: left\">hex string</td><td style=\"text-align: left\">&quot;#ffffff&quot;</td><td style=\"text-align: left\">Page background color</td></tr></tbody></table>\n<p>Color values must be 6-digit hex codes (e.g., `#6366F1`).</p>\n<h4 id=\"seo-seo\">SEO (`seo`)</h4>\n<p>The optional `seo` block controls the build-time SEO artifacts (sitemap, robots,</p>\n<p>`llms.txt`, static HTML snapshots, JSON-LD) and the runtime meta tags.</p>\n<pre><code class=\"language-json\">{\n &quot;title&quot;: &quot;ACME Documentation&quot;,\n &quot;domain&quot;: &quot;docs.acme.com&quot;,\n &quot;seo&quot;: {\n &quot;enabled&quot;: true,\n &quot;siteUrl&quot;: &quot;https://docs.acme.com&quot;,\n &quot;ogImage&quot;: &quot;/assets/og-card.png&quot;,\n &quot;generateSitemap&quot;: true,\n &quot;generateStaticPages&quot;: true,\n &quot;generateRobotsTxt&quot;: true,\n &quot;defaultChangeFreq&quot;: &quot;weekly&quot;,\n &quot;structuredData&quot;: {\n &quot;organizationName&quot;: &quot;ACME Corporation&quot;,\n &quot;logoUrl&quot;: &quot;https://docs.acme.com/assets/logo.svg&quot;\n }\n }\n}</code></pre>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Type</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`enabled`</td><td style=\"text-align: left\">boolean</td><td style=\"text-align: left\">`true`</td><td style=\"text-align: left\">Set `false` to skip all SEO artifact generation</td></tr><tr><td style=\"text-align: left\">`siteUrl`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">falls back to `domain`</td><td style=\"text-align: left\">Absolute base URL for sitemap `&lt;loc&gt;`, canonical, `og:url`, and `robots` `Sitemap:`. <strong>If omitted, the tenant&#39;s top-level `domain` is used</strong> (https-prefixed). If neither is set, URLs are emitted relative and the build prints a warning.</td></tr><tr><td style=\"text-align: left\">`ogImage`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">-</td><td style=\"text-align: left\">Social share image for `og:image` / `twitter:image`. Absolute URL or site-relative path (joined to the base URL). When set, `twitter:card` is upgraded to `summary_large_image`. Per-section override: set `ogImage` on a manifest entry.</td></tr><tr><td style=\"text-align: left\">`generateSitemap`</td><td style=\"text-align: left\">boolean</td><td style=\"text-align: left\">`true`</td><td style=\"text-align: left\">Emit `sitemap.xml`</td></tr><tr><td style=\"text-align: left\">`generateStaticPages`</td><td style=\"text-align: left\">boolean</td><td style=\"text-align: left\">`true`</td><td style=\"text-align: left\">Emit per-section static HTML snapshots under `/pages/` (crawler-friendly; the SPA uses hash routing)</td></tr><tr><td style=\"text-align: left\">`generateRobotsTxt`</td><td style=\"text-align: left\">boolean</td><td style=\"text-align: left\">`true`</td><td style=\"text-align: left\">Emit `robots.txt`</td></tr><tr><td style=\"text-align: left\">`defaultChangeFreq`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">`&quot;weekly&quot;`</td><td style=\"text-align: left\">`&lt;changefreq&gt;` for the sitemap root entry</td></tr><tr><td style=\"text-align: left\">`structuredData.organizationName`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">-</td><td style=\"text-align: left\">Organization name in the JSON-LD `publisher`</td></tr><tr><td style=\"text-align: left\">`structuredData.logoUrl`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">-</td><td style=\"text-align: left\">Organization logo URL in the JSON-LD `publisher`</td></tr></tbody></table>\n<p><strong>Absolute URLs:</strong> declaring a `domain` (or `seo.siteUrl`) is what makes the</p>\n<p>sitemap, canonical, and Open Graph URLs absolute. The sitemap protocol requires</p>\n<p>fully-qualified URLs, so a tenant with neither set will emit a non-compliant</p>\n<p>sitemap — the build warns when this happens.</p>\n<p><strong>Canonical strategy:</strong> static snapshots and the runtime SPA canonicalize to the</p>\n<p>crawlable static URL (`/pages/&lt;id&gt;.html`), not the SPA `#hash` route — search</p>\n<p>engines drop URL fragments, so hash canonicals would collapse every page onto the</p>\n<p>homepage. The `#hash` route is still used for the in-page &quot;interactive version&quot;</p>\n<p>link and the JS redirect.</p>\n<h2 id=\"navigation-manifest-manifestjson\">Navigation Manifest (manifest.json)</h2>\n<h3 id=\"root-manifest\">Root Manifest</h3>\n<p>Located at tenant root, defines top-level navigation:</p>\n<pre><code class=\"language-json\">[\n {\n &quot;id&quot;: &quot;welcome&quot;,\n &quot;title&quot;: &quot;Welcome&quot;,\n &quot;summary&quot;: &quot;Introduction to the platform&quot;,\n &quot;file&quot;: &quot;welcome.md&quot;\n },\n {\n &quot;id&quot;: &quot;guides&quot;,\n &quot;title&quot;: &quot;Guides&quot;,\n &quot;summary&quot;: &quot;Step-by-step tutorials&quot;,\n &quot;subsections&quot;: [\n {\n &quot;id&quot;: &quot;guides/getting-started&quot;,\n &quot;title&quot;: &quot;Getting Started&quot;,\n &quot;summary&quot;: &quot;First steps&quot;,\n &quot;file&quot;: &quot;guides/getting-started.md&quot;\n },\n {\n &quot;id&quot;: &quot;guides/advanced&quot;,\n &quot;title&quot;: &quot;Advanced&quot;,\n &quot;file&quot;: &quot;guides/advanced.md&quot;\n }\n ]\n }\n]</code></pre>\n<h3 id=\"section-properties\">Section Properties</h3>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Required</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`id`</td><td style=\"text-align: left\">Yes*</td><td style=\"text-align: left\">Unique section identifier (used in URLs)</td></tr><tr><td style=\"text-align: left\">`title`</td><td style=\"text-align: left\">Yes</td><td style=\"text-align: left\">Display title in navigation</td></tr><tr><td style=\"text-align: left\">`summary`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Description shown in search results</td></tr><tr><td style=\"text-align: left\">`file`</td><td style=\"text-align: left\">No**</td><td style=\"text-align: left\">Path to content file (relative to `content/`)</td></tr><tr><td style=\"text-align: left\">`url`</td><td style=\"text-align: left\">No**</td><td style=\"text-align: left\">External link URL (opens in new tab)</td></tr><tr><td style=\"text-align: left\">`subsections`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Array of child sections</td></tr><tr><td style=\"text-align: left\">`exclude`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Exclude from build (`true` to skip)</td></tr></tbody></table>\n<p>*Not required for external links</p>\n<p>**Use either `file` OR `url`, not both</p>\n<h3 id=\"external-links-in-navigation\">External Links in Navigation</h3>\n<p>Manifest entries can link to external resources using `url` instead of `id`/`file`:</p>\n<pre><code class=\"language-json\">[\n {\n &quot;id&quot;: &quot;docs&quot;,\n &quot;title&quot;: &quot;Documentation&quot;,\n &quot;file&quot;: &quot;docs.md&quot;\n },\n {\n &quot;title&quot;: &quot;GitHub&quot;,\n &quot;url&quot;: &quot;https://github.com/example/repo&quot;\n },\n {\n &quot;title&quot;: &quot;Support Portal&quot;,\n &quot;url&quot;: &quot;https://support.example.com&quot;\n }\n]</code></pre>\n<p>External links automatically:</p>\n<ul>\n<li>Open in new tab (`target=&quot;_blank&quot;`)</li>\n<li>Display subtle arrow icon indicator (↗)</li>\n<li>Skip URL hash routing</li>\n</ul>\n<h3 id=\"section-manifest-manifestjson\">Section Manifest (_manifest.json)</h3>\n<p>Located in content subdirectories for nested navigation:</p>\n<pre><code class=\"language-json\">{\n &quot;title&quot;: &quot;API Reference&quot;,\n &quot;summary&quot;: &quot;Complete API documentation&quot;,\n &quot;sections&quot;: [\n {\n &quot;id&quot;: &quot;overview&quot;,\n &quot;title&quot;: &quot;Overview&quot;,\n &quot;file&quot;: &quot;overview.md&quot;\n },\n {\n &quot;id&quot;: &quot;endpoints&quot;,\n &quot;title&quot;: &quot;Endpoints&quot;,\n &quot;file&quot;: &quot;endpoints.md&quot;\n }\n ]\n}</code></pre>\n<h3 id=\"auto-generated-manifest\">Auto-Generated Manifest</h3>\n<p>If no `manifest.json` exists, the build system auto-generates navigation from the `content/` directory structure:</p>\n<ul>\n<li>Files become sections (filename → title)</li>\n<li>Directories become groups</li>\n<li>`_manifest.json` in directories customizes the group</li>\n</ul>\n<h2 id=\"bottom-navigation\">Bottom Navigation</h2>\n<p>Configure bottom navigation bar behavior in root `_manifest.json` or `manifest.json`:</p>\n<pre><code class=\"language-json\">{\n &quot;bottomNav&quot;: &quot;always&quot;,\n &quot;bottomNavSections&quot;: [&quot;getting-started&quot;, &quot;api-reference&quot;, &quot;faq&quot;],\n &quot;sections&quot;: [\n // ... section definitions\n ]\n}</code></pre>\n<h3 id=\"bottom-navigation-properties\">Bottom Navigation Properties</h3>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Type</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`bottomNav`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">`&quot;mobile&quot;`</td><td style=\"text-align: left\">When to show bottom nav: `&quot;mobile&quot;`, `&quot;always&quot;`, or `&quot;never&quot;`</td></tr><tr><td style=\"text-align: left\">`bottomNavSections`</td><td style=\"text-align: left\">string[]</td><td style=\"text-align: left\">`[]`</td><td style=\"text-align: left\">Section IDs to include (empty array = all sections)</td></tr></tbody></table>\n<p><strong>Behavior:</strong></p>\n<ul>\n<li>`&quot;mobile&quot;` - Show only on small screens (default)</li>\n<li>`&quot;always&quot;` - Show on all screen sizes</li>\n<li>`&quot;never&quot;` - Hide bottom navigation completely</li>\n</ul>\n<p><strong>Examples:</strong></p>\n<p>Show all sections on mobile only (default):</p>\n<pre><code class=\"language-json\">{\n &quot;bottomNav&quot;: &quot;mobile&quot;\n}</code></pre>\n<p>Show specific sections always:</p>\n<pre><code class=\"language-json\">{\n &quot;bottomNav&quot;: &quot;always&quot;,\n &quot;bottomNavSections&quot;: [&quot;home&quot;, &quot;docs&quot;, &quot;api&quot;]\n}</code></pre>\n<p>Hide bottom navigation:</p>\n<pre><code class=\"language-json\">{\n &quot;bottomNav&quot;: &quot;never&quot;\n}</code></pre>\n<h2 id=\"content-files\">Content Files</h2>\n<h3 id=\"markdown-md\">Markdown (.md)</h3>\n<p>Full CommonMark support plus:</p>\n<ul>\n<li>Fenced code blocks with syntax highlighting</li>\n<li>Mermaid diagrams with interactive controls</li>\n<li>Internal links via `#section-id`</li>\n<li>External links auto-open in new tab</li>\n</ul>\n<pre><code class=\"language-markdown\"># Page Title\n\nRegular markdown content.\n\n## Code Example\n\n\\`\\`\\`javascript\nconst x = 1;\n\\`\\`\\`\n\n## Diagram\n\n\\`\\`\\`mermaid\ngraph LR\n A --&gt; B\n\\`\\`\\`\n\nSee also: [Getting Started](#guides/getting-started)\n\nLearn more: [GitHub](https://github.com/example)</code></pre>\n<h4 id=\"external-links-in-content\">External Links in Content</h4>\n<p>All HTTP/HTTPS links in markdown automatically:</p>\n<ul>\n<li>Open in new tab (`target=&quot;_blank&quot;`)</li>\n<li>Display subtle ↗ indicator via CSS</li>\n<li>Include security attributes (`rel=&quot;noopener noreferrer&quot;`)</li>\n</ul>\n<p><strong>Standard external link:</strong></p>\n<pre><code class=\"language-markdown\">Visit our [GitHub repository](https://github.com/example/repo).</code></pre>\n<p><strong>Prominent call-to-action link:</strong></p>\n<div class=\"html-block\"><a href=\"https://example.com/signup\" class=\"external-cta\">\n Sign Up Now →\n</a></div>\n<p>The `.external-cta` class provides enhanced styling for important external links.</p>\n<h4 id=\"mermaid-diagrams\">Mermaid Diagrams</h4>\n<p>Mermaid diagrams render with interactive controls:</p>\n<p><strong>Features:</strong></p>\n<ul>\n<li><strong>Zoom controls</strong>: +/− buttons for zoom in/out</li>\n<li><strong>Reset button</strong>: ⊙ restores original view</li>\n<li><strong>Pan</strong>: Click and drag to move diagram</li>\n<li><strong>Pinch zoom</strong>: Touch devices support pinch gestures</li>\n<li><strong>Auto-scroll</strong>: Diagrams larger than viewport are scrollable</li>\n</ul>\n<p><strong>Supported diagram types:</strong></p>\n<ul>\n<li>Flowcharts (`graph`, `flowchart`)</li>\n<li>Sequence diagrams (`sequenceDiagram`)</li>\n<li>Class diagrams (`classDiagram`)</li>\n<li>State diagrams (`stateDiagram`)</li>\n<li>ER diagrams (`erDiagram`)</li>\n<li>User journey (`journey`)</li>\n<li>Gantt charts (`gantt`)</li>\n<li>And more (see Mermaid documentation)</li>\n</ul>\n<p><strong>Example:</strong></p>\n<pre><code class=\"language-markdown\">\\`\\`\\`mermaid\ngraph TD\n A[Start] --&gt; B{Decision}\n B --&gt;|Yes| C[Action 1]\n B --&gt;|No| D[Action 2]\n C --&gt; E[End]\n D --&gt; E\n\\`\\`\\`</code></pre>\n<h3 id=\"html-html\">HTML (.html)</h3>\n<p>Direct HTML with access to built-in CSS classes:</p>\n<div class=\"html-block\"><section class=\"section doc markdown\">\n <div class=\"doc-content\">\n <h1>Custom Section</h1>\n\n <table class=\"spec-table\">\n <thead>\n <tr><th>Feature</th><th>Status</th></tr>\n </thead>\n <tbody>\n <tr><td>Search</td><td>Ready</td></tr>\n </tbody>\n </table>\n\n <div class=\"layer-stack\">\n <div class=\"layer\">\n <div class=\"layer-title\">Layer 1</div>\n <div class=\"layer-desc\">Description</div>\n </div>\n </div>\n </div>\n</section></div>\n<h3 id=\"javascript-js\">JavaScript (.js)</h3>\n<p>Dynamic content modules:</p>\n<pre><code class=\"language-javascript\">export async function load() {\n // Fetch data, compute values, etc.\n const data = await fetch(&#39;/api/data.json&#39;).then(r =&gt; r.json());\n\n return {\n html: `\n &lt;section class=&quot;section doc&quot;&gt;\n &lt;h1&gt;Dynamic Content&lt;/h1&gt;\n &lt;p&gt;Value: ${data.value}&lt;/p&gt;\n &lt;/section&gt;\n `,\n afterRender(container) {\n // Optional: DOM manipulation after render\n container.querySelector(&#39;button&#39;)?.addEventListener(&#39;click&#39;, () =&gt; {\n // Handle click\n });\n }\n };\n}</code></pre>\n<h2 id=\"css-classes-reference\">CSS Classes Reference</h2>\n<h3 id=\"layout\">Layout</h3>\n<table><thead><tr><th style=\"text-align: left\">Class</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`.section`</td><td style=\"text-align: left\">Section container</td></tr><tr><td style=\"text-align: left\">`.doc`</td><td style=\"text-align: left\">Document-style section</td></tr><tr><td style=\"text-align: left\">`.markdown`</td><td style=\"text-align: left\">Apply markdown typography</td></tr><tr><td style=\"text-align: left\">`.doc-content`</td><td style=\"text-align: left\">Content wrapper</td></tr></tbody></table>\n<h3 id=\"components\">Components</h3>\n<table><thead><tr><th style=\"text-align: left\">Class</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`.spec-table`</td><td style=\"text-align: left\">Styled data table</td></tr><tr><td style=\"text-align: left\">`.layer-stack`</td><td style=\"text-align: left\">Vertical layer diagram</td></tr><tr><td style=\"text-align: left\">`.layer`</td><td style=\"text-align: left\">Individual layer in stack</td></tr><tr><td style=\"text-align: left\">`.layer-title`</td><td style=\"text-align: left\">Layer heading</td></tr><tr><td style=\"text-align: left\">`.layer-desc`</td><td style=\"text-align: left\">Layer description</td></tr><tr><td style=\"text-align: left\">`.card`</td><td style=\"text-align: left\">Card component</td></tr><tr><td style=\"text-align: left\">`.card-grid`</td><td style=\"text-align: left\">Grid of cards</td></tr><tr><td style=\"text-align: left\">`.content-box`</td><td style=\"text-align: left\">Bordered content box</td></tr><tr><td style=\"text-align: left\">`.box-title`</td><td style=\"text-align: left\">Box heading</td></tr><tr><td style=\"text-align: left\">`.html-block`</td><td style=\"text-align: left\">HTML content wrapper</td></tr><tr><td style=\"text-align: left\">`.external-cta`</td><td style=\"text-align: left\">Prominent external link button</td></tr></tbody></table>\n<h3 id=\"typography\">Typography</h3>\n<table><thead><tr><th style=\"text-align: left\">Class</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`.doc-h1` through `.doc-h4`</td><td style=\"text-align: left\">Heading styles</td></tr><tr><td style=\"text-align: left\">`.doc-list`</td><td style=\"text-align: left\">Styled list</td></tr><tr><td style=\"text-align: left\">`.doc-grid`</td><td style=\"text-align: left\">Two-column grid</td></tr></tbody></table>\n<h2 id=\"overrides-directory\">Overrides Directory</h2>\n<p>Files in `overrides/` replace built files after the build completes:</p>\n<pre><code>my-tenant/\n└── overrides/\n ├── styles.css # Replace default styles\n └── favicon.ico # Custom favicon</code></pre>\n<p>Use for:</p>\n<ul>\n<li>Custom stylesheets</li>\n<li>Custom favicons</li>\n<li>Additional assets</li>\n</ul>\n<h2 id=\"static-assets-public\">Static Assets (.public/)</h2>\n<p>The `.public/` directory stores static assets (images, icons, logos) that should be included in the built tenant bundle.</p>\n<h3 id=\"directory-structure\">Directory Structure</h3>\n<pre><code>my-tenant/\n├── .public/ # Static assets directory\n│ ├── favicon.ico # Copied to dist root\n│ ├── favicon.png # Copied to dist root\n│ ├── logo.svg # Copied to dist/assets/\n│ └── icons/ # Subdirectories preserved\n│ ├── discord.svg\n│ └── github.svg\n├── config.json\n├── content/\n└── ...</code></pre>\n<h3 id=\"build-behavior\">Build Behavior</h3>\n<p>During the build process:</p>\n<p>1. <strong>Assets directory creation</strong>: Contents are copied to `dist/&lt;tenant-id&gt;/assets/`</p>\n<p>2. <strong>Favicon handling</strong>: Files matching `favicon.*` (e.g., `favicon.ico`, `favicon.png`, `favicon.svg`) are copied to the dist root (`dist/&lt;tenant-id&gt;/`) for browser auto-detection</p>\n<p>3. <strong>Subdirectory preservation</strong>: Subdirectory structure within `.public/` is maintained in the output</p>\n<h3 id=\"referencing-assets-in-content\">Referencing Assets in Content</h3>\n<p><strong>In Markdown:</strong></p>\n<pre><code class=\"language-markdown\">![Company Logo](./assets/logo.svg)\n![Product Screenshot](./assets/screenshots/dashboard.png)</code></pre>\n<p><strong>In HTML content:</strong></p>\n<div class=\"html-block\"><img src=\"./assets/logo.svg\" alt=\"Company Logo\">\n<img src=\"./assets/icons/github.svg\" alt=\"GitHub\"></div>\n<p><strong>In CSS (via overrides):</strong></p>\n<pre><code class=\"language-css\">.custom-header {\n background-image: url(./assets/logo.svg);\n}\n\n.icon-discord {\n content: url(./assets/icons/discord.svg);\n}</code></pre>\n<h3 id=\"why-public-instead-of-public\">Why .public Instead of public?</h3>\n<p>The dot-prefix (`.public/`) was chosen to:</p>\n<ul>\n<li><strong>Avoid conflicts</strong>: Prevents naming collisions with user&#39;s conventional `public/` directories that might contain user-facing content</li>\n<li><strong>Clear separation</strong>: Distinguishes between tenant assets and potential user content directories</li>\n<li><strong>Build system clarity</strong>: Signals this is a build-time directive, not user-facing content</li>\n</ul>\n<h3 id=\"supported-file-formats\">Supported File Formats</h3>\n<p>The `.public/` directory supports all static file types:</p>\n<p><strong>Images:</strong></p>\n<ul>\n<li>PNG (`.png`)</li>\n<li>JPEG (`.jpg`, `.jpeg`)</li>\n<li>SVG (`.svg`)</li>\n<li>WebP (`.webp`)</li>\n<li>GIF (`.gif`)</li>\n</ul>\n<p><strong>Icons:</strong></p>\n<ul>\n<li>ICO (`.ico`)</li>\n<li>SVG (`.svg`)</li>\n</ul>\n<p><strong>Other static files:</strong></p>\n<ul>\n<li>Any additional static assets needed by your documentation</li>\n</ul>\n<h3 id=\"example-usage\">Example Usage</h3>\n<p><strong>Typical tenant structure with assets:</strong></p>\n<pre><code>acme-docs/\n├── config.json\n├── manifest.json\n├── .public/\n│ ├── favicon.ico # Browser tab icon\n│ ├── favicon.svg # Modern browsers\n│ ├── logo.svg # Company logo\n│ ├── logo-dark.svg # Dark mode variant\n│ ├── screenshots/ # Product screenshots\n│ │ ├── dashboard.png\n│ │ └── settings.png\n│ └── icons/ # Social/external icons\n│ ├── github.svg\n│ ├── discord.svg\n│ └── twitter.svg\n└── content/\n └── welcome.md</code></pre>\n<p><strong>Referenced in welcome.md:</strong></p>\n<pre><code class=\"language-markdown\"># Welcome to ACME Docs\n\n![ACME Logo](./assets/logo.svg)\n\n## Quick Start\n\nCheck out our dashboard:\n\n![Dashboard Screenshot](./assets/screenshots/dashboard.png)\n\n## Community\n\nJoin us on:\n- ![GitHub](./assets/icons/github.svg) [GitHub](https://github.com/acme)\n- ![Discord](./assets/icons/discord.svg) [Discord](https://discord.gg/acme)</code></pre>\n<h2 id=\"environment-variables\">Environment Variables</h2>\n<table><thead><tr><th style=\"text-align: left\">Variable</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`BUILD_OUTPUT`</td><td style=\"text-align: left\">`dist/`</td><td style=\"text-align: left\">Output directory</td></tr><tr><td style=\"text-align: left\">`DOCS_TOOLKIT_PORT`</td><td style=\"text-align: left\">`80`</td><td style=\"text-align: left\">Caddy server port</td></tr><tr><td style=\"text-align: left\">`PORT`</td><td style=\"text-align: left\">`5173`</td><td style=\"text-align: left\">Dev server port</td></tr></tbody></table>\n<h2 id=\"build-modes\">Build Modes</h2>\n<h3 id=\"full-build\">Full Build</h3>\n<pre><code class=\"language-bash\">npm run build:tenants my-tenant</code></pre>\n<p>Rebuilds everything from scratch.</p>\n<h3 id=\"incremental-build\">Incremental Build</h3>\n<pre><code class=\"language-bash\">npm run build:incremental my-tenant</code></pre>\n<p>Only rebuilds files changed since last build (git-aware).</p>\n<h3 id=\"all-tenants\">All Tenants</h3>\n<pre><code class=\"language-bash\">npm run build:tenants</code></pre>\n<p>Builds all registered tenants.</p>\n </div>\n</section>" };
2
+ return { html: "<section class=\"section doc markdown\">\n <div class=\"doc-content\">\n<h1 id=\"tenant-configuration-reference\">Tenant Configuration Reference</h1>\n<p>Complete reference for all tenant configuration options.</p>\n<h2 id=\"tenant-registry-tenantsjson\">Tenant Registry (tenants.json)</h2>\n<p>Located at `apps/publisher/tenants.json`, this file registers all tenants:</p>\n<pre><code class=\"language-json\">{\n &quot;tenant-id&quot;: {\n &quot;source&quot;: &quot;/path/to/content&quot;,\n &quot;domain&quot;: &quot;docs.example.com&quot;\n }\n}</code></pre>\n<h3 id=\"registry-properties\">Registry Properties</h3>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Required</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`source`</td><td style=\"text-align: left\">Yes</td><td style=\"text-align: left\">Path to tenant content directory</td></tr><tr><td style=\"text-align: left\">`domain`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Custom domain for Caddy routing</td></tr><tr><td style=\"text-align: left\">`enabled`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Whether to build this tenant (default `true`)</td></tr><tr><td style=\"text-align: left\">`strictLinks`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Broken-link gate (default `true`). When `true`, <strong>broken internal links fail the build</strong> — the tenant is reported `Failed` and the process exits non-zero, so CI can gate on it. Set `false` to log broken links as warnings and continue.</td></tr></tbody></table>\n<h3 id=\"source-types\">Source Types</h3>\n<p><strong>Local Path:</strong></p>\n<pre><code class=\"language-json\">{\n &quot;my-docs&quot;: {\n &quot;source&quot;: &quot;/home/user/my-docs&quot;\n }\n}</code></pre>\n<p><strong>Git Repository:</strong></p>\n<pre><code class=\"language-json\">{\n &quot;my-docs&quot;: {\n &quot;source&quot;: &quot;git:https://github.com/org/my-docs.git#main&quot;\n }\n}</code></pre>\n<p>Format: `git:&lt;repo-url&gt;#&lt;branch&gt;`</p>\n<p>Git sources are cloned to a cache directory and updated on each build.</p>\n<h2 id=\"tenant-directory-structure\">Tenant Directory Structure</h2>\n<pre><code>my-tenant/\n├── config.json # Branding and theme (required)\n├── manifest.json # Navigation structure (optional)\n├── content/ # Content files\n│ ├── *.md # Markdown files\n│ ├── *.html # HTML files\n│ ├── *.js # JavaScript modules\n│ └── section/ # Nested directories\n│ └── _manifest.json\n├── .public/ # Static assets (optional)\n│ ├── favicon.ico # Favicons copied to dist root\n│ ├── logo.svg # Assets copied to dist/assets/\n│ └── icons/ # Subdirectories preserved\n└── overrides/ # Post-build replacements (optional)\n └── styles.css # Replace built files</code></pre>\n<h2 id=\"branding-configuration-configjson\">Branding Configuration (config.json)</h2>\n<h3 id=\"complete-example\">Complete Example</h3>\n<pre><code class=\"language-json\">{\n &quot;title&quot;: &quot;ACME Documentation&quot;,\n &quot;description&quot;: &quot;Complete guide to the ACME platform&quot;,\n &quot;brandMark&quot;: &quot;ACME&quot;,\n &quot;brandSub&quot;: &quot;Docs&quot;,\n &quot;tagline&quot;: &quot;Build better, faster&quot;,\n &quot;copyright&quot;: &quot;ACME Corporation&quot;,\n &quot;accentColor&quot;: &quot;#6366F1&quot;,\n &quot;surfaceColor&quot;: &quot;#F7FAFC&quot;\n}</code></pre>\n<h3 id=\"properties-reference\">Properties Reference</h3>\n<h4 id=\"site-metadata\">Site Metadata</h4>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Type</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`title`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">&quot;Docs Toolkit&quot;</td><td style=\"text-align: left\">Browser tab title and header</td></tr><tr><td style=\"text-align: left\">`description`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">-</td><td style=\"text-align: left\">Meta description for SEO</td></tr></tbody></table>\n<h4 id=\"branding\">Branding</h4>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Type</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`brandMark`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">&quot;DOCS&quot;</td><td style=\"text-align: left\">Primary brand text (bold, uppercase)</td></tr><tr><td style=\"text-align: left\">`brandSub`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">&quot;TOOLKIT&quot;</td><td style=\"text-align: left\">Secondary brand text (light weight)</td></tr><tr><td style=\"text-align: left\">`tagline`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">-</td><td style=\"text-align: left\">Subtitle displayed under brand</td></tr><tr><td style=\"text-align: left\">`copyright`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">&quot;Modular Documentation Toolkit&quot;</td><td style=\"text-align: left\">Footer copyright text</td></tr></tbody></table>\n<h4 id=\"theme-colors\">Theme Colors</h4>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Type</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`accentColor`</td><td style=\"text-align: left\">hex string</td><td style=\"text-align: left\">&quot;#111111&quot;</td><td style=\"text-align: left\">Links, buttons, active states</td></tr><tr><td style=\"text-align: left\">`surfaceColor`</td><td style=\"text-align: left\">hex string</td><td style=\"text-align: left\">&quot;#ffffff&quot;</td><td style=\"text-align: left\">Page background color</td></tr></tbody></table>\n<p>Color values must be 6-digit hex codes (e.g., `#6366F1`).</p>\n<h4 id=\"seo-seo\">SEO (`seo`)</h4>\n<p>The optional `seo` block controls the build-time SEO artifacts (sitemap, robots,</p>\n<p>`llms.txt`, static HTML snapshots, JSON-LD) and the runtime meta tags.</p>\n<pre><code class=\"language-json\">{\n &quot;title&quot;: &quot;ACME Documentation&quot;,\n &quot;domain&quot;: &quot;docs.acme.com&quot;,\n &quot;seo&quot;: {\n &quot;enabled&quot;: true,\n &quot;siteUrl&quot;: &quot;https://docs.acme.com&quot;,\n &quot;ogImage&quot;: &quot;/assets/og-card.png&quot;,\n &quot;generateSitemap&quot;: true,\n &quot;generateStaticPages&quot;: true,\n &quot;generateRobotsTxt&quot;: true,\n &quot;defaultChangeFreq&quot;: &quot;weekly&quot;,\n &quot;structuredData&quot;: {\n &quot;organizationName&quot;: &quot;ACME Corporation&quot;,\n &quot;logoUrl&quot;: &quot;https://docs.acme.com/assets/logo.svg&quot;\n }\n }\n}</code></pre>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Type</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`enabled`</td><td style=\"text-align: left\">boolean</td><td style=\"text-align: left\">`true`</td><td style=\"text-align: left\">Set `false` to skip all SEO artifact generation</td></tr><tr><td style=\"text-align: left\">`siteUrl`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">falls back to `domain`</td><td style=\"text-align: left\">Absolute base URL for sitemap `&lt;loc&gt;`, canonical, `og:url`, and `robots` `Sitemap:`. <strong>If omitted, the tenant&#39;s top-level `domain` is used</strong> (https-prefixed). If neither is set, URLs are emitted relative and the build prints a warning.</td></tr><tr><td style=\"text-align: left\">`ogImage`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">-</td><td style=\"text-align: left\">Social share image for `og:image` / `twitter:image`. Absolute URL or site-relative path (joined to the base URL). When set, `twitter:card` is upgraded to `summary_large_image`. Per-section override: set `ogImage` on a manifest entry.</td></tr><tr><td style=\"text-align: left\">`generateSitemap`</td><td style=\"text-align: left\">boolean</td><td style=\"text-align: left\">`true`</td><td style=\"text-align: left\">Emit `sitemap.xml`</td></tr><tr><td style=\"text-align: left\">`generateStaticPages`</td><td style=\"text-align: left\">boolean</td><td style=\"text-align: left\">`true`</td><td style=\"text-align: left\">Emit per-section static HTML snapshots under `/pages/` (crawler-friendly; the SPA uses hash routing)</td></tr><tr><td style=\"text-align: left\">`generateRobotsTxt`</td><td style=\"text-align: left\">boolean</td><td style=\"text-align: left\">`true`</td><td style=\"text-align: left\">Emit `robots.txt`</td></tr><tr><td style=\"text-align: left\">`defaultChangeFreq`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">`&quot;weekly&quot;`</td><td style=\"text-align: left\">`&lt;changefreq&gt;` for the sitemap root entry</td></tr><tr><td style=\"text-align: left\">`structuredData.organizationName`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">-</td><td style=\"text-align: left\">Organization name in the JSON-LD `publisher`</td></tr><tr><td style=\"text-align: left\">`structuredData.logoUrl`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">-</td><td style=\"text-align: left\">Organization logo URL in the JSON-LD `publisher`</td></tr></tbody></table>\n<p><strong>Absolute URLs:</strong> declaring a `domain` (or `seo.siteUrl`) is what makes the</p>\n<p>sitemap, canonical, and Open Graph URLs absolute. The sitemap protocol requires</p>\n<p>fully-qualified URLs, so a tenant with neither set will emit a non-compliant</p>\n<p>sitemap — the build warns when this happens.</p>\n<p><strong>Canonical strategy:</strong> static snapshots and the runtime SPA canonicalize to the</p>\n<p>crawlable static URL (`/pages/&lt;id&gt;.html`), not the SPA `#hash` route — search</p>\n<p>engines drop URL fragments, so hash canonicals would collapse every page onto the</p>\n<p>homepage. The `#hash` route is still used for the in-page &quot;interactive version&quot;</p>\n<p>link and the JS redirect.</p>\n<h2 id=\"collections\">Collections</h2>\n<p>A <strong>collection</strong> marks a content folder (e.g. a blog) so the build emits a</p>\n<p>machine-readable manifest — letting downstream sites consume the posts without</p>\n<p>scraping rendered HTML. Configure collections in the tenant `config.json`:</p>\n<pre><code class=\"language-json\">{\n &quot;collections&quot;: [\n {\n &quot;path&quot;: &quot;blog&quot;,\n &quot;route&quot;: &quot;/blog&quot;,\n &quot;title&quot;: &quot;Blog&quot;,\n &quot;manifest&quot;: true,\n &quot;feed&quot;: true,\n &quot;sortBy&quot;: &quot;date&quot;,\n &quot;order&quot;: &quot;desc&quot;\n }\n ]\n}</code></pre>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Type</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`path`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">required</td><td style=\"text-align: left\">Collection folder, relative to the content root (e.g. `blog` → `content/blog/`)</td></tr><tr><td style=\"text-align: left\">`route`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">`/&lt;path&gt;`</td><td style=\"text-align: left\">Public route; also the output subdirectory under `dist/`</td></tr><tr><td style=\"text-align: left\">`title`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">tenant `title`</td><td style=\"text-align: left\">Collection title (manifest + feed)</td></tr><tr><td style=\"text-align: left\">`manifest`</td><td style=\"text-align: left\">boolean</td><td style=\"text-align: left\">`true`</td><td style=\"text-align: left\">Emit `index.json`</td></tr><tr><td style=\"text-align: left\">`feed`</td><td style=\"text-align: left\">boolean</td><td style=\"text-align: left\">`false`</td><td style=\"text-align: left\">Emit RSS `feed.xml`</td></tr><tr><td style=\"text-align: left\">`sortBy`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">`&quot;date&quot;`</td><td style=\"text-align: left\">Front-matter field to sort by</td></tr><tr><td style=\"text-align: left\">`order`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">`&quot;desc&quot;`</td><td style=\"text-align: left\">`&quot;desc&quot;` or `&quot;asc&quot;` (entries missing the sort key sort last)</td></tr></tbody></table>\n<p>Each post (`&lt;path&gt;/&lt;slug&gt;.md`) supplies metadata via YAML <strong>front matter</strong>;</p>\n<p>files starting with `_` and `index.md` are skipped:</p>\n<pre><code class=\"language-markdown\">---\ntitle: Shipping Pagenary Collections\ndate: 2026-05-27\nsummary: How the new collection manifest works.\nhero: /assets/blog/collections.png\ntags: [release, seo]\n---\n\n# Shipping Pagenary Collections\n\nPost body…</code></pre>\n<p>The build writes to `dist/&lt;route&gt;/`:</p>\n<ul>\n<li><strong>`index.json`</strong> — `{ title, route, count, generated, posts: [...] }`, where each</li>\n</ul>\n<p>post is `{ slug, title, date, summary, hero, tags, reading_time, canonical, path }`,</p>\n<p>sorted per `sortBy`/`order`. `canonical` is the absolute static-page URL (uses</p>\n<p>the same base URL as <a href=\"#seo-seo\">SEO</a>); `reading_time` is estimated from the body.</p>\n<ul>\n<li><strong>`feed.xml`</strong> <em>(when `feed: true`)</em> — RSS 2.0 of the same set.</li>\n</ul>\n<blockquote>\n<p>A collection&#39;s posts are still rendered as normal pages (each `.md` becomes a</p>\n<p>section). The manifest/feed are additive, machine-readable indexes.</p>\n</blockquote>\n<h2 id=\"navigation-manifest-manifestjson\">Navigation Manifest (manifest.json)</h2>\n<h3 id=\"root-manifest\">Root Manifest</h3>\n<p>Located at tenant root, defines top-level navigation:</p>\n<pre><code class=\"language-json\">[\n {\n &quot;id&quot;: &quot;welcome&quot;,\n &quot;title&quot;: &quot;Welcome&quot;,\n &quot;summary&quot;: &quot;Introduction to the platform&quot;,\n &quot;file&quot;: &quot;welcome.md&quot;\n },\n {\n &quot;id&quot;: &quot;guides&quot;,\n &quot;title&quot;: &quot;Guides&quot;,\n &quot;summary&quot;: &quot;Step-by-step tutorials&quot;,\n &quot;subsections&quot;: [\n {\n &quot;id&quot;: &quot;guides/getting-started&quot;,\n &quot;title&quot;: &quot;Getting Started&quot;,\n &quot;summary&quot;: &quot;First steps&quot;,\n &quot;file&quot;: &quot;guides/getting-started.md&quot;\n },\n {\n &quot;id&quot;: &quot;guides/advanced&quot;,\n &quot;title&quot;: &quot;Advanced&quot;,\n &quot;file&quot;: &quot;guides/advanced.md&quot;\n }\n ]\n }\n]</code></pre>\n<h3 id=\"section-properties\">Section Properties</h3>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Required</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`id`</td><td style=\"text-align: left\">Yes*</td><td style=\"text-align: left\">Unique section identifier (used in URLs)</td></tr><tr><td style=\"text-align: left\">`title`</td><td style=\"text-align: left\">Yes</td><td style=\"text-align: left\">Display title in navigation</td></tr><tr><td style=\"text-align: left\">`summary`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Description shown in search results</td></tr><tr><td style=\"text-align: left\">`file`</td><td style=\"text-align: left\">No**</td><td style=\"text-align: left\">Path to content file (relative to `content/`)</td></tr><tr><td style=\"text-align: left\">`url`</td><td style=\"text-align: left\">No**</td><td style=\"text-align: left\">External link URL (opens in new tab)</td></tr><tr><td style=\"text-align: left\">`subsections`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Array of child sections</td></tr><tr><td style=\"text-align: left\">`exclude`</td><td style=\"text-align: left\">No</td><td style=\"text-align: left\">Exclude from build (`true` to skip)</td></tr></tbody></table>\n<p>*Not required for external links</p>\n<p>**Use either `file` OR `url`, not both</p>\n<h3 id=\"external-links-in-navigation\">External Links in Navigation</h3>\n<p>Manifest entries can link to external resources using `url` instead of `id`/`file`:</p>\n<pre><code class=\"language-json\">[\n {\n &quot;id&quot;: &quot;docs&quot;,\n &quot;title&quot;: &quot;Documentation&quot;,\n &quot;file&quot;: &quot;docs.md&quot;\n },\n {\n &quot;title&quot;: &quot;GitHub&quot;,\n &quot;url&quot;: &quot;https://github.com/example/repo&quot;\n },\n {\n &quot;title&quot;: &quot;Support Portal&quot;,\n &quot;url&quot;: &quot;https://support.example.com&quot;\n }\n]</code></pre>\n<p>External links automatically:</p>\n<ul>\n<li>Open in new tab (`target=&quot;_blank&quot;`)</li>\n<li>Display subtle arrow icon indicator (↗)</li>\n<li>Skip URL hash routing</li>\n</ul>\n<h3 id=\"section-manifest-manifestjson\">Section Manifest (_manifest.json)</h3>\n<p>Located in content subdirectories for nested navigation:</p>\n<pre><code class=\"language-json\">{\n &quot;title&quot;: &quot;API Reference&quot;,\n &quot;summary&quot;: &quot;Complete API documentation&quot;,\n &quot;sections&quot;: [\n {\n &quot;id&quot;: &quot;overview&quot;,\n &quot;title&quot;: &quot;Overview&quot;,\n &quot;file&quot;: &quot;overview.md&quot;\n },\n {\n &quot;id&quot;: &quot;endpoints&quot;,\n &quot;title&quot;: &quot;Endpoints&quot;,\n &quot;file&quot;: &quot;endpoints.md&quot;\n }\n ]\n}</code></pre>\n<h3 id=\"auto-generated-manifest\">Auto-Generated Manifest</h3>\n<p>If no `manifest.json` exists, the build system auto-generates navigation from the `content/` directory structure:</p>\n<ul>\n<li>Files become sections (filename → title)</li>\n<li>Directories become groups</li>\n<li>`_manifest.json` in directories customizes the group</li>\n</ul>\n<h2 id=\"bottom-navigation\">Bottom Navigation</h2>\n<p>Configure bottom navigation bar behavior in root `_manifest.json` or `manifest.json`:</p>\n<pre><code class=\"language-json\">{\n &quot;bottomNav&quot;: &quot;always&quot;,\n &quot;bottomNavSections&quot;: [&quot;getting-started&quot;, &quot;api-reference&quot;, &quot;faq&quot;],\n &quot;sections&quot;: [\n // ... section definitions\n ]\n}</code></pre>\n<h3 id=\"bottom-navigation-properties\">Bottom Navigation Properties</h3>\n<table><thead><tr><th style=\"text-align: left\">Property</th><th style=\"text-align: left\">Type</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`bottomNav`</td><td style=\"text-align: left\">string</td><td style=\"text-align: left\">`&quot;mobile&quot;`</td><td style=\"text-align: left\">When to show bottom nav: `&quot;mobile&quot;`, `&quot;always&quot;`, or `&quot;never&quot;`</td></tr><tr><td style=\"text-align: left\">`bottomNavSections`</td><td style=\"text-align: left\">string[]</td><td style=\"text-align: left\">`[]`</td><td style=\"text-align: left\">Section IDs to include (empty array = all sections)</td></tr></tbody></table>\n<p><strong>Behavior:</strong></p>\n<ul>\n<li>`&quot;mobile&quot;` - Show only on small screens (default)</li>\n<li>`&quot;always&quot;` - Show on all screen sizes</li>\n<li>`&quot;never&quot;` - Hide bottom navigation completely</li>\n</ul>\n<p><strong>Examples:</strong></p>\n<p>Show all sections on mobile only (default):</p>\n<pre><code class=\"language-json\">{\n &quot;bottomNav&quot;: &quot;mobile&quot;\n}</code></pre>\n<p>Show specific sections always:</p>\n<pre><code class=\"language-json\">{\n &quot;bottomNav&quot;: &quot;always&quot;,\n &quot;bottomNavSections&quot;: [&quot;home&quot;, &quot;docs&quot;, &quot;api&quot;]\n}</code></pre>\n<p>Hide bottom navigation:</p>\n<pre><code class=\"language-json\">{\n &quot;bottomNav&quot;: &quot;never&quot;\n}</code></pre>\n<h2 id=\"content-files\">Content Files</h2>\n<h3 id=\"markdown-md\">Markdown (.md)</h3>\n<p>Full CommonMark support plus:</p>\n<ul>\n<li>Fenced code blocks with syntax highlighting</li>\n<li>Mermaid diagrams with interactive controls</li>\n<li>Internal links via `#section-id`</li>\n<li>External links auto-open in new tab</li>\n</ul>\n<pre><code class=\"language-markdown\"># Page Title\n\nRegular markdown content.\n\n## Code Example\n\n\\`\\`\\`javascript\nconst x = 1;\n\\`\\`\\`\n\n## Diagram\n\n\\`\\`\\`mermaid\ngraph LR\n A --&gt; B\n\\`\\`\\`\n\nSee also: [Getting Started](#guides/getting-started)\n\nLearn more: [GitHub](https://github.com/example)</code></pre>\n<h4 id=\"external-links-in-content\">External Links in Content</h4>\n<p>All HTTP/HTTPS links in markdown automatically:</p>\n<ul>\n<li>Open in new tab (`target=&quot;_blank&quot;`)</li>\n<li>Display subtle ↗ indicator via CSS</li>\n<li>Include security attributes (`rel=&quot;noopener noreferrer&quot;`)</li>\n</ul>\n<p><strong>Standard external link:</strong></p>\n<pre><code class=\"language-markdown\">Visit our [GitHub repository](https://github.com/example/repo).</code></pre>\n<p><strong>Prominent call-to-action link:</strong></p>\n<div class=\"html-block\"><a href=\"https://example.com/signup\" class=\"external-cta\">\n Sign Up Now →\n</a></div>\n<p>The `.external-cta` class provides enhanced styling for important external links.</p>\n<h4 id=\"mermaid-diagrams\">Mermaid Diagrams</h4>\n<p>Mermaid diagrams render with interactive controls:</p>\n<p><strong>Features:</strong></p>\n<ul>\n<li><strong>Zoom controls</strong>: +/− buttons for zoom in/out</li>\n<li><strong>Reset button</strong>: ⊙ restores original view</li>\n<li><strong>Pan</strong>: Click and drag to move diagram</li>\n<li><strong>Pinch zoom</strong>: Touch devices support pinch gestures</li>\n<li><strong>Auto-scroll</strong>: Diagrams larger than viewport are scrollable</li>\n</ul>\n<p><strong>Supported diagram types:</strong></p>\n<ul>\n<li>Flowcharts (`graph`, `flowchart`)</li>\n<li>Sequence diagrams (`sequenceDiagram`)</li>\n<li>Class diagrams (`classDiagram`)</li>\n<li>State diagrams (`stateDiagram`)</li>\n<li>ER diagrams (`erDiagram`)</li>\n<li>User journey (`journey`)</li>\n<li>Gantt charts (`gantt`)</li>\n<li>And more (see Mermaid documentation)</li>\n</ul>\n<p><strong>Example:</strong></p>\n<pre><code class=\"language-markdown\">\\`\\`\\`mermaid\ngraph TD\n A[Start] --&gt; B{Decision}\n B --&gt;|Yes| C[Action 1]\n B --&gt;|No| D[Action 2]\n C --&gt; E[End]\n D --&gt; E\n\\`\\`\\`</code></pre>\n<h3 id=\"html-html\">HTML (.html)</h3>\n<p>Direct HTML with access to built-in CSS classes:</p>\n<div class=\"html-block\"><section class=\"section doc markdown\">\n <div class=\"doc-content\">\n <h1>Custom Section</h1>\n\n <table class=\"spec-table\">\n <thead>\n <tr><th>Feature</th><th>Status</th></tr>\n </thead>\n <tbody>\n <tr><td>Search</td><td>Ready</td></tr>\n </tbody>\n </table>\n\n <div class=\"layer-stack\">\n <div class=\"layer\">\n <div class=\"layer-title\">Layer 1</div>\n <div class=\"layer-desc\">Description</div>\n </div>\n </div>\n </div>\n</section></div>\n<h3 id=\"javascript-js\">JavaScript (.js)</h3>\n<p>Dynamic content modules:</p>\n<pre><code class=\"language-javascript\">export async function load() {\n // Fetch data, compute values, etc.\n const data = await fetch(&#39;/api/data.json&#39;).then(r =&gt; r.json());\n\n return {\n html: `\n &lt;section class=&quot;section doc&quot;&gt;\n &lt;h1&gt;Dynamic Content&lt;/h1&gt;\n &lt;p&gt;Value: ${data.value}&lt;/p&gt;\n &lt;/section&gt;\n `,\n afterRender(container) {\n // Optional: DOM manipulation after render\n container.querySelector(&#39;button&#39;)?.addEventListener(&#39;click&#39;, () =&gt; {\n // Handle click\n });\n }\n };\n}</code></pre>\n<h2 id=\"css-classes-reference\">CSS Classes Reference</h2>\n<h3 id=\"layout\">Layout</h3>\n<table><thead><tr><th style=\"text-align: left\">Class</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`.section`</td><td style=\"text-align: left\">Section container</td></tr><tr><td style=\"text-align: left\">`.doc`</td><td style=\"text-align: left\">Document-style section</td></tr><tr><td style=\"text-align: left\">`.markdown`</td><td style=\"text-align: left\">Apply markdown typography</td></tr><tr><td style=\"text-align: left\">`.doc-content`</td><td style=\"text-align: left\">Content wrapper</td></tr></tbody></table>\n<h3 id=\"components\">Components</h3>\n<table><thead><tr><th style=\"text-align: left\">Class</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`.spec-table`</td><td style=\"text-align: left\">Styled data table</td></tr><tr><td style=\"text-align: left\">`.layer-stack`</td><td style=\"text-align: left\">Vertical layer diagram</td></tr><tr><td style=\"text-align: left\">`.layer`</td><td style=\"text-align: left\">Individual layer in stack</td></tr><tr><td style=\"text-align: left\">`.layer-title`</td><td style=\"text-align: left\">Layer heading</td></tr><tr><td style=\"text-align: left\">`.layer-desc`</td><td style=\"text-align: left\">Layer description</td></tr><tr><td style=\"text-align: left\">`.card`</td><td style=\"text-align: left\">Card component</td></tr><tr><td style=\"text-align: left\">`.card-grid`</td><td style=\"text-align: left\">Grid of cards</td></tr><tr><td style=\"text-align: left\">`.content-box`</td><td style=\"text-align: left\">Bordered content box</td></tr><tr><td style=\"text-align: left\">`.box-title`</td><td style=\"text-align: left\">Box heading</td></tr><tr><td style=\"text-align: left\">`.html-block`</td><td style=\"text-align: left\">HTML content wrapper</td></tr><tr><td style=\"text-align: left\">`.external-cta`</td><td style=\"text-align: left\">Prominent external link button</td></tr></tbody></table>\n<h3 id=\"typography\">Typography</h3>\n<table><thead><tr><th style=\"text-align: left\">Class</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`.doc-h1` through `.doc-h4`</td><td style=\"text-align: left\">Heading styles</td></tr><tr><td style=\"text-align: left\">`.doc-list`</td><td style=\"text-align: left\">Styled list</td></tr><tr><td style=\"text-align: left\">`.doc-grid`</td><td style=\"text-align: left\">Two-column grid</td></tr></tbody></table>\n<h2 id=\"overrides-directory\">Overrides Directory</h2>\n<p>Files in `overrides/` replace built files after the build completes:</p>\n<pre><code>my-tenant/\n└── overrides/\n ├── styles.css # Replace default styles\n └── favicon.ico # Custom favicon</code></pre>\n<p>Use for:</p>\n<ul>\n<li>Custom stylesheets</li>\n<li>Custom favicons</li>\n<li>Additional assets</li>\n</ul>\n<h2 id=\"static-assets-public\">Static Assets (.public/)</h2>\n<p>The `.public/` directory stores static assets (images, icons, logos) that should be included in the built tenant bundle.</p>\n<h3 id=\"directory-structure\">Directory Structure</h3>\n<pre><code>my-tenant/\n├── .public/ # Static assets directory\n│ ├── favicon.ico # Copied to dist root\n│ ├── favicon.png # Copied to dist root\n│ ├── logo.svg # Copied to dist/assets/\n│ └── icons/ # Subdirectories preserved\n│ ├── discord.svg\n│ └── github.svg\n├── config.json\n├── content/\n└── ...</code></pre>\n<h3 id=\"build-behavior\">Build Behavior</h3>\n<p>During the build process:</p>\n<p>1. <strong>Assets directory creation</strong>: Contents are copied to `dist/&lt;tenant-id&gt;/assets/`</p>\n<p>2. <strong>Favicon handling</strong>: Files matching `favicon.*` (e.g., `favicon.ico`, `favicon.png`, `favicon.svg`) are copied to the dist root (`dist/&lt;tenant-id&gt;/`) for browser auto-detection</p>\n<p>3. <strong>Subdirectory preservation</strong>: Subdirectory structure within `.public/` is maintained in the output</p>\n<h3 id=\"referencing-assets-in-content\">Referencing Assets in Content</h3>\n<p><strong>In Markdown:</strong></p>\n<pre><code class=\"language-markdown\">![Company Logo](./assets/logo.svg)\n![Product Screenshot](./assets/screenshots/dashboard.png)</code></pre>\n<p><strong>In HTML content:</strong></p>\n<div class=\"html-block\"><img src=\"./assets/logo.svg\" alt=\"Company Logo\">\n<img src=\"./assets/icons/github.svg\" alt=\"GitHub\"></div>\n<p><strong>In CSS (via overrides):</strong></p>\n<pre><code class=\"language-css\">.custom-header {\n background-image: url(./assets/logo.svg);\n}\n\n.icon-discord {\n content: url(./assets/icons/discord.svg);\n}</code></pre>\n<h3 id=\"why-public-instead-of-public\">Why .public Instead of public?</h3>\n<p>The dot-prefix (`.public/`) was chosen to:</p>\n<ul>\n<li><strong>Avoid conflicts</strong>: Prevents naming collisions with user&#39;s conventional `public/` directories that might contain user-facing content</li>\n<li><strong>Clear separation</strong>: Distinguishes between tenant assets and potential user content directories</li>\n<li><strong>Build system clarity</strong>: Signals this is a build-time directive, not user-facing content</li>\n</ul>\n<h3 id=\"supported-file-formats\">Supported File Formats</h3>\n<p>The `.public/` directory supports all static file types:</p>\n<p><strong>Images:</strong></p>\n<ul>\n<li>PNG (`.png`)</li>\n<li>JPEG (`.jpg`, `.jpeg`)</li>\n<li>SVG (`.svg`)</li>\n<li>WebP (`.webp`)</li>\n<li>GIF (`.gif`)</li>\n</ul>\n<p><strong>Icons:</strong></p>\n<ul>\n<li>ICO (`.ico`)</li>\n<li>SVG (`.svg`)</li>\n</ul>\n<p><strong>Other static files:</strong></p>\n<ul>\n<li>Any additional static assets needed by your documentation</li>\n</ul>\n<h3 id=\"example-usage\">Example Usage</h3>\n<p><strong>Typical tenant structure with assets:</strong></p>\n<pre><code>acme-docs/\n├── config.json\n├── manifest.json\n├── .public/\n│ ├── favicon.ico # Browser tab icon\n│ ├── favicon.svg # Modern browsers\n│ ├── logo.svg # Company logo\n│ ├── logo-dark.svg # Dark mode variant\n│ ├── screenshots/ # Product screenshots\n│ │ ├── dashboard.png\n│ │ └── settings.png\n│ └── icons/ # Social/external icons\n│ ├── github.svg\n│ ├── discord.svg\n│ └── twitter.svg\n└── content/\n └── welcome.md</code></pre>\n<p><strong>Referenced in welcome.md:</strong></p>\n<pre><code class=\"language-markdown\"># Welcome to ACME Docs\n\n![ACME Logo](./assets/logo.svg)\n\n## Quick Start\n\nCheck out our dashboard:\n\n![Dashboard Screenshot](./assets/screenshots/dashboard.png)\n\n## Community\n\nJoin us on:\n- ![GitHub](./assets/icons/github.svg) [GitHub](https://github.com/acme)\n- ![Discord](./assets/icons/discord.svg) [Discord](https://discord.gg/acme)</code></pre>\n<h2 id=\"environment-variables\">Environment Variables</h2>\n<table><thead><tr><th style=\"text-align: left\">Variable</th><th style=\"text-align: left\">Default</th><th style=\"text-align: left\">Description</th></tr></thead><tbody><tr><td style=\"text-align: left\">`BUILD_OUTPUT`</td><td style=\"text-align: left\">`dist/`</td><td style=\"text-align: left\">Output directory</td></tr><tr><td style=\"text-align: left\">`DOCS_TOOLKIT_PORT`</td><td style=\"text-align: left\">`80`</td><td style=\"text-align: left\">Caddy server port</td></tr><tr><td style=\"text-align: left\">`PORT`</td><td style=\"text-align: left\">`5173`</td><td style=\"text-align: left\">Dev server port</td></tr></tbody></table>\n<h2 id=\"build-modes\">Build Modes</h2>\n<h3 id=\"full-build\">Full Build</h3>\n<pre><code class=\"language-bash\">npm run build:tenants my-tenant</code></pre>\n<p>Rebuilds everything from scratch.</p>\n<h3 id=\"incremental-build\">Incremental Build</h3>\n<pre><code class=\"language-bash\">npm run build:incremental my-tenant</code></pre>\n<p>Only rebuilds files changed since last build (git-aware).</p>\n<h3 id=\"all-tenants\">All Tenants</h3>\n<pre><code class=\"language-bash\">npm run build:tenants</code></pre>\n<p>Builds all registered tenants.</p>\n </div>\n</section>" };
3
3
  }