@pagenary/publisher 2026.5.2 → 2026.5.4
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 +28 -4
- package/scripts/lib/collections-generator.js +173 -0
- package/scripts/lib/frontmatter.js +80 -0
- package/site/index.html +1 -1
- package/site/pages/api.html +85 -2
- package/site/pages/architecture.html +50 -7
- package/site/pages/deployment.html +1 -1
- package/site/pages/developer-guide.html +35 -1
- package/site/pages/extending.html +1 -1
- package/site/pages/quickstart.html +1 -1
- package/site/pages/seo-strategy.html +1 -1
- package/site/pages/tenant-config.html +46 -1
- package/site/pages/welcome.html +1 -1
- package/site/robots.txt +1 -1
- package/site/sections/api.js +1 -1
- package/site/sections/architecture.js +1 -1
- package/site/sections/developer-guide.js +1 -1
- package/site/sections/tenant-config.js +1 -1
- package/site/sitemap.xml +10 -10
- package/site/styles.css +17 -0
- package/src/styles.css +17 -0
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.4",
|
|
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,8 @@ 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';
|
|
11
|
+
import { parseFrontmatter } from './lib/frontmatter.js';
|
|
10
12
|
import { fileURLToPath } from 'node:url';
|
|
11
13
|
|
|
12
14
|
const root = process.cwd();
|
|
@@ -1373,6 +1375,12 @@ function parseInlineMarkdown(input, linkContext = null) {
|
|
|
1373
1375
|
* @returns {string} HTML string
|
|
1374
1376
|
*/
|
|
1375
1377
|
function markdownToHtml(markdown, linkContext = null) {
|
|
1378
|
+
// Strip YAML frontmatter before rendering so the fence block doesn't leak
|
|
1379
|
+
// into the page as <hr>/<p>… text (#19). #18 made frontmatter mandatory on
|
|
1380
|
+
// collection posts; this wires the same parser the collections generator
|
|
1381
|
+
// already uses into the page render path so every caller benefits.
|
|
1382
|
+
const parsed = parseFrontmatter(markdown);
|
|
1383
|
+
markdown = parsed.body;
|
|
1376
1384
|
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
|
1377
1385
|
const chunks = [];
|
|
1378
1386
|
let inList = false;
|
|
@@ -3214,6 +3222,14 @@ async function buildTenant(tenant, targetOverride, cacheDir, buildOptions) {
|
|
|
3214
3222
|
// Generate SEO artifacts (sitemap.xml, robots.txt, static pages)
|
|
3215
3223
|
await generateSeoArtifacts(distDir, config);
|
|
3216
3224
|
|
|
3225
|
+
// Generate collection manifests/feeds (#18) — opt-in via config.collections
|
|
3226
|
+
if (Array.isArray(config.collections) && config.collections.length > 0) {
|
|
3227
|
+
const collectionRoot = await findContentRoot(sourceDir);
|
|
3228
|
+
if (collectionRoot.basePath) {
|
|
3229
|
+
await generateCollections(distDir, config, collectionRoot.basePath);
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3217
3233
|
// Copy to final target if different from dist
|
|
3218
3234
|
if (targetDir !== distDir) {
|
|
3219
3235
|
// Ensure target parent exists
|
|
@@ -3599,7 +3615,15 @@ async function main() {
|
|
|
3599
3615
|
return results;
|
|
3600
3616
|
}
|
|
3601
3617
|
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3618
|
+
// Only auto-run when this file is the process entrypoint, so unit tests can
|
|
3619
|
+
// import named exports (e.g. markdownToHtml — #19) without triggering main().
|
|
3620
|
+
const __isMainModule = process.argv[1]
|
|
3621
|
+
&& fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
3622
|
+
if (__isMainModule) {
|
|
3623
|
+
main().catch((err) => {
|
|
3624
|
+
console.error(err);
|
|
3625
|
+
process.exit(1);
|
|
3626
|
+
});
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
export { markdownToHtml };
|
|
@@ -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
|
+
}
|