@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 +124 -112
- package/package.json +5 -3
- package/scripts/build-tenants.js +9 -0
- package/scripts/lib/collections-generator.js +173 -0
- package/scripts/lib/frontmatter.js +80 -0
- package/site/index.html +1 -1
- package/site/pages/tenant-config.html +45 -0
- package/site/robots.txt +1 -1
- package/site/sections/tenant-config.js +1 -1
package/README.md
CHANGED
|
@@ -1,13 +1,39 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# Pagenary Publisher
|
|
2
4
|
|
|
3
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/@pagenary/publisher)
|
|
16
|
+
[](https://www.npmjs.com/package/@pagenary/publisher)
|
|
17
|
+
[](https://docs.pagenary.com)
|
|
18
|
+
[](../../LICENSE)
|
|
19
|
+
[](https://nodejs.org)
|
|
4
20
|
|
|
5
|
-
|
|
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**
|
|
34
|
-
- **HTML**
|
|
35
|
-
- **JavaScript Modules**
|
|
36
|
-
- **Nested Directories**
|
|
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**
|
|
40
|
-
- **Syntax Highlighting**
|
|
41
|
-
- **Markdown Tables**
|
|
42
|
-
- **HTML Components**
|
|
43
|
-
- **Internal Links**
|
|
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**
|
|
47
|
-
- **Smart Link Handling**
|
|
48
|
-
- **Visual Indicators**
|
|
49
|
-
- **CTA Styling**
|
|
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**
|
|
79
|
-
- **Full-Text Search**
|
|
80
|
-
- **Manifest-Driven Nav**
|
|
81
|
-
- **Keyboard Navigation**
|
|
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**
|
|
85
|
-
- **Brand Identity**
|
|
86
|
-
- **Typography**
|
|
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**
|
|
90
|
-
- **Branded Exports**
|
|
91
|
-
- **Document Export**
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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
|
-
"
|
|
223
|
-
|
|
224
|
-
"
|
|
225
|
-
"
|
|
226
|
-
"
|
|
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 | "
|
|
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 |
|
|
233
|
+
| `copyright` | Footer copyright text | - |
|
|
239
234
|
| `accentColor` | Links, buttons, highlights | `#111111` |
|
|
240
235
|
| `surfaceColor` | Background color (hex) | `#ffffff` |
|
|
241
|
-
| `
|
|
242
|
-
| `
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
308
|
-
npm run
|
|
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
|
-
#
|
|
314
|
-
npm run caddy:
|
|
315
|
-
npm run caddy:
|
|
316
|
-
npm run caddy:
|
|
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 #
|
|
329
|
+
│ ├── seo.js # Runtime meta tag management
|
|
332
330
|
│ ├── mermaid-init.js # Diagram rendering
|
|
333
331
|
│ ├── syntax-highlight.js # Code highlighting
|
|
334
|
-
│
|
|
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
|
-
│ └──
|
|
337
|
+
│ └── lib/seo-generator.js # Sitemap, robots, snapshots, JSON-LD
|
|
344
338
|
├── tenants/ # Built-in example tenants
|
|
345
339
|
├── docs/ # Documentation
|
|
346
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
- [
|
|
356
|
-
- [
|
|
357
|
-
- [
|
|
358
|
-
- [
|
|
359
|
-
- [
|
|
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.
|
|
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
|
-
"
|
|
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
|
}
|
package/scripts/build-tenants.js
CHANGED
|
@@ -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, '&')
|
|
29
|
+
.replace(/</g, '<')
|
|
30
|
+
.replace(/>/g, '>')
|
|
31
|
+
.replace(/"/g, '"')
|
|
32
|
+
.replace(/'/g, ''');
|
|
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-
|
|
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 "interactive version"</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
|
+
"collections": [
|
|
195
|
+
{
|
|
196
|
+
"path": "blog",
|
|
197
|
+
"route": "/blog",
|
|
198
|
+
"title": "Blog",
|
|
199
|
+
"manifest": true,
|
|
200
|
+
"feed": true,
|
|
201
|
+
"sortBy": "date",
|
|
202
|
+
"order": "desc"
|
|
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">`/<path>`</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">`"date"`</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">`"desc"`</td><td style="text-align: left">`"desc"` or `"asc"` (entries missing the sort key sort last)</td></tr></tbody></table>
|
|
207
|
+
<p>Each post (`<path>/<slug>.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/<route>/`:</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'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,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 "tenant-id": {\n "source": "/path/to/content",\n "domain": "docs.example.com"\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 "my-docs": {\n "source": "/home/user/my-docs"\n }\n}</code></pre>\n<p><strong>Git Repository:</strong></p>\n<pre><code class=\"language-json\">{\n "my-docs": {\n "source": "git:https://github.com/org/my-docs.git#main"\n }\n}</code></pre>\n<p>Format: `git:<repo-url>#<branch>`</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 "title": "ACME Documentation",\n "description": "Complete guide to the ACME platform",\n "brandMark": "ACME",\n "brandSub": "Docs",\n "tagline": "Build better, faster",\n "copyright": "ACME Corporation",\n "accentColor": "#6366F1",\n "surfaceColor": "#F7FAFC"\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\">"Docs Toolkit"</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\">"DOCS"</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\">"TOOLKIT"</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\">"Modular Documentation Toolkit"</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\">"#111111"</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\">"#ffffff"</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 "title": "ACME Documentation",\n "domain": "docs.acme.com",\n "seo": {\n "enabled": true,\n "siteUrl": "https://docs.acme.com",\n "ogImage": "/assets/og-card.png",\n "generateSitemap": true,\n "generateStaticPages": true,\n "generateRobotsTxt": true,\n "defaultChangeFreq": "weekly",\n "structuredData": {\n "organizationName": "ACME Corporation",\n "logoUrl": "https://docs.acme.com/assets/logo.svg"\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 `<loc>`, canonical, `og:url`, and `robots` `Sitemap:`. <strong>If omitted, the tenant'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\">`"weekly"`</td><td style=\"text-align: left\">`<changefreq>` 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/<id>.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 "interactive version"</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 "id": "welcome",\n "title": "Welcome",\n "summary": "Introduction to the platform",\n "file": "welcome.md"\n },\n {\n "id": "guides",\n "title": "Guides",\n "summary": "Step-by-step tutorials",\n "subsections": [\n {\n "id": "guides/getting-started",\n "title": "Getting Started",\n "summary": "First steps",\n "file": "guides/getting-started.md"\n },\n {\n "id": "guides/advanced",\n "title": "Advanced",\n "file": "guides/advanced.md"\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 "id": "docs",\n "title": "Documentation",\n "file": "docs.md"\n },\n {\n "title": "GitHub",\n "url": "https://github.com/example/repo"\n },\n {\n "title": "Support Portal",\n "url": "https://support.example.com"\n }\n]</code></pre>\n<p>External links automatically:</p>\n<ul>\n<li>Open in new tab (`target="_blank"`)</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 "title": "API Reference",\n "summary": "Complete API documentation",\n "sections": [\n {\n "id": "overview",\n "title": "Overview",\n "file": "overview.md"\n },\n {\n "id": "endpoints",\n "title": "Endpoints",\n "file": "endpoints.md"\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 "bottomNav": "always",\n "bottomNavSections": ["getting-started", "api-reference", "faq"],\n "sections": [\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\">`"mobile"`</td><td style=\"text-align: left\">When to show bottom nav: `"mobile"`, `"always"`, or `"never"`</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>`"mobile"` - Show only on small screens (default)</li>\n<li>`"always"` - Show on all screen sizes</li>\n<li>`"never"` - 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 "bottomNav": "mobile"\n}</code></pre>\n<p>Show specific sections always:</p>\n<pre><code class=\"language-json\">{\n "bottomNav": "always",\n "bottomNavSections": ["home", "docs", "api"]\n}</code></pre>\n<p>Hide bottom navigation:</p>\n<pre><code class=\"language-json\">{\n "bottomNav": "never"\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 --> 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="_blank"`)</li>\n<li>Display subtle ↗ indicator via CSS</li>\n<li>Include security attributes (`rel="noopener noreferrer"`)</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] --> B{Decision}\n B -->|Yes| C[Action 1]\n B -->|No| D[Action 2]\n C --> E[End]\n D --> 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('/api/data.json').then(r => r.json());\n\n return {\n html: `\n <section class="section doc">\n <h1>Dynamic Content</h1>\n <p>Value: ${data.value}</p>\n </section>\n `,\n afterRender(container) {\n // Optional: DOM manipulation after render\n container.querySelector('button')?.addEventListener('click', () => {\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/<tenant-id>/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/<tenant-id>/`) 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\">\n</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'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\n\n## Quick Start\n\nCheck out our dashboard:\n\n\n\n## Community\n\nJoin us on:\n-  [GitHub](https://github.com/acme)\n-  [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 "tenant-id": {\n "source": "/path/to/content",\n "domain": "docs.example.com"\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 "my-docs": {\n "source": "/home/user/my-docs"\n }\n}</code></pre>\n<p><strong>Git Repository:</strong></p>\n<pre><code class=\"language-json\">{\n "my-docs": {\n "source": "git:https://github.com/org/my-docs.git#main"\n }\n}</code></pre>\n<p>Format: `git:<repo-url>#<branch>`</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 "title": "ACME Documentation",\n "description": "Complete guide to the ACME platform",\n "brandMark": "ACME",\n "brandSub": "Docs",\n "tagline": "Build better, faster",\n "copyright": "ACME Corporation",\n "accentColor": "#6366F1",\n "surfaceColor": "#F7FAFC"\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\">"Docs Toolkit"</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\">"DOCS"</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\">"TOOLKIT"</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\">"Modular Documentation Toolkit"</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\">"#111111"</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\">"#ffffff"</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 "title": "ACME Documentation",\n "domain": "docs.acme.com",\n "seo": {\n "enabled": true,\n "siteUrl": "https://docs.acme.com",\n "ogImage": "/assets/og-card.png",\n "generateSitemap": true,\n "generateStaticPages": true,\n "generateRobotsTxt": true,\n "defaultChangeFreq": "weekly",\n "structuredData": {\n "organizationName": "ACME Corporation",\n "logoUrl": "https://docs.acme.com/assets/logo.svg"\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 `<loc>`, canonical, `og:url`, and `robots` `Sitemap:`. <strong>If omitted, the tenant'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\">`"weekly"`</td><td style=\"text-align: left\">`<changefreq>` 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/<id>.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 "interactive version"</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 "collections": [\n {\n "path": "blog",\n "route": "/blog",\n "title": "Blog",\n "manifest": true,\n "feed": true,\n "sortBy": "date",\n "order": "desc"\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\">`/<path>`</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\">`"date"`</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\">`"desc"`</td><td style=\"text-align: left\">`"desc"` or `"asc"` (entries missing the sort key sort last)</td></tr></tbody></table>\n<p>Each post (`<path>/<slug>.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/<route>/`:</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'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 "id": "welcome",\n "title": "Welcome",\n "summary": "Introduction to the platform",\n "file": "welcome.md"\n },\n {\n "id": "guides",\n "title": "Guides",\n "summary": "Step-by-step tutorials",\n "subsections": [\n {\n "id": "guides/getting-started",\n "title": "Getting Started",\n "summary": "First steps",\n "file": "guides/getting-started.md"\n },\n {\n "id": "guides/advanced",\n "title": "Advanced",\n "file": "guides/advanced.md"\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 "id": "docs",\n "title": "Documentation",\n "file": "docs.md"\n },\n {\n "title": "GitHub",\n "url": "https://github.com/example/repo"\n },\n {\n "title": "Support Portal",\n "url": "https://support.example.com"\n }\n]</code></pre>\n<p>External links automatically:</p>\n<ul>\n<li>Open in new tab (`target="_blank"`)</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 "title": "API Reference",\n "summary": "Complete API documentation",\n "sections": [\n {\n "id": "overview",\n "title": "Overview",\n "file": "overview.md"\n },\n {\n "id": "endpoints",\n "title": "Endpoints",\n "file": "endpoints.md"\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 "bottomNav": "always",\n "bottomNavSections": ["getting-started", "api-reference", "faq"],\n "sections": [\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\">`"mobile"`</td><td style=\"text-align: left\">When to show bottom nav: `"mobile"`, `"always"`, or `"never"`</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>`"mobile"` - Show only on small screens (default)</li>\n<li>`"always"` - Show on all screen sizes</li>\n<li>`"never"` - 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 "bottomNav": "mobile"\n}</code></pre>\n<p>Show specific sections always:</p>\n<pre><code class=\"language-json\">{\n "bottomNav": "always",\n "bottomNavSections": ["home", "docs", "api"]\n}</code></pre>\n<p>Hide bottom navigation:</p>\n<pre><code class=\"language-json\">{\n "bottomNav": "never"\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 --> 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="_blank"`)</li>\n<li>Display subtle ↗ indicator via CSS</li>\n<li>Include security attributes (`rel="noopener noreferrer"`)</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] --> B{Decision}\n B -->|Yes| C[Action 1]\n B -->|No| D[Action 2]\n C --> E[End]\n D --> 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('/api/data.json').then(r => r.json());\n\n return {\n html: `\n <section class="section doc">\n <h1>Dynamic Content</h1>\n <p>Value: ${data.value}</p>\n </section>\n `,\n afterRender(container) {\n // Optional: DOM manipulation after render\n container.querySelector('button')?.addEventListener('click', () => {\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/<tenant-id>/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/<tenant-id>/`) 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\">\n</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'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\n\n## Quick Start\n\nCheck out our dashboard:\n\n\n\n## Community\n\nJoin us on:\n-  [GitHub](https://github.com/acme)\n-  [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
|
}
|