@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 CHANGED
@@ -1,13 +1,39 @@
1
+ <div align="center">
2
+
1
3
  # Pagenary Publisher
2
4
 
3
- Static publishing component for Pagenary — "Where documentation takes shape."
5
+ **Where documentation takes shape.**
6
+
7
+ `@pagenary/publisher` is the static site generator behind Pagenary — it turns one shared template catalog into many branded, tenant-specific documentation sites. Zero runtime dependencies, hash-based routing, full-text search, and a Git-aware build pipeline. Install it as a dev dependency and drive it with the `pagenary` CLI.
8
+
9
+ ```bash
10
+ npm install --save-dev @pagenary/publisher # add Pagenary to your project
11
+ npx pagenary build:tenants my-docs # build your docs tenant
12
+ npx pagenary serve # serve on http://localhost:5173
13
+ ```
14
+
15
+ [![npm version](https://img.shields.io/npm/v/@pagenary/publisher?label=npm&color=CB3837&logo=npm&style=flat-square)](https://www.npmjs.com/package/@pagenary/publisher)
16
+ [![npm downloads](https://img.shields.io/npm/dm/@pagenary/publisher?color=CB3837&logo=npm&style=flat-square)](https://www.npmjs.com/package/@pagenary/publisher)
17
+ [![Docs](https://img.shields.io/badge/docs-docs.pagenary.com-22d3ee?style=flat-square&logo=readthedocs&logoColor=white)](https://docs.pagenary.com)
18
+ [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg?style=flat-square)](../../LICENSE)
19
+ [![Node Version](https://img.shields.io/badge/node-%E2%89%A516.0.0-brightgreen?style=flat-square&logo=node.js)](https://nodejs.org)
4
20
 
5
- Transform shared documentation templates into tenant-specific bundles with custom branding, themes, and content. Zero runtime dependencies, hash-based routing, and full-text search make it ideal for white-label documentation portals.
21
+ [**Docs Site**](https://docs.pagenary.com) · [**Quick Start**](#quick-start) · [**Features**](#features) · [**Tenant Workflow**](#tenant-content-workflow) · [**Documentation**](#documentation)
22
+
23
+ </div>
24
+
25
+ ---
26
+
27
+ ## What It Is
28
+
29
+ The publisher takes a catalog of shared section templates plus per-tenant content and configuration and produces a self-contained documentation bundle for each tenant. Each bundle is a static single-page app — hash-based routing (`#/page-id`), no server-side rendering, no runtime dependencies — that you build once and host anywhere that serves files. Tenants share the template catalog but keep isolated content, branding, navigation, and domains, so one repository can publish a dozen distinct sites.
30
+
31
+ ---
6
32
 
7
33
  ## Quick Start
8
34
 
9
35
  Install the package and drive it with the `pagenary` CLI — **no clone required**.
10
- New here? Follow the [Getting Started guide](docs/GETTING-STARTED.md).
36
+ New here? Follow the **[Getting Started guide](docs/GETTING-STARTED.md)**.
11
37
 
12
38
  ```bash
13
39
  npm install --save-dev @pagenary/publisher
@@ -17,7 +43,7 @@ npx pagenary serve # preview on http://localhost:5173
17
43
  ```
18
44
 
19
45
  Commands: `build`, `build:tenants [id]`, `tenants:list`, `serve` (run
20
- `npx pagenary --help`). The package also ships a compiled reference site under `site/`.
46
+ `npx pagenary --help`). The package also ships a compiled reference site under `site/` — the Pagenary docs, built by Pagenary itself.
21
47
 
22
48
  **Building from source** (contributors / modifying Pagenary):
23
49
 
@@ -27,71 +53,52 @@ npm run dev # build + serve with watch mode
27
53
  npm run build # build default bundle to dist/
28
54
  ```
29
55
 
56
+ ---
57
+
30
58
  ## Features
31
59
 
32
60
  ### Content Authoring
33
- - **Markdown** - Write in `.md` files with full CommonMark support
34
- - **HTML** - Direct markup control with `.html` files
35
- - **JavaScript Modules** - Dynamic content with `.js` files returning `{ html, afterRender? }`
36
- - **Nested Directories** - Organize content in subdirectories (`content/guides/setup.md`)
61
+ - **Markdown** write in `.md` files with full CommonMark support
62
+ - **HTML** direct markup control with `.html` files
63
+ - **JavaScript Modules** dynamic content with `.js` files returning `{ html, afterRender? }`
64
+ - **Nested Directories** organize content in subdirectories (`content/guides/setup.md`)
37
65
 
38
66
  ### Rich Content
39
- - **Mermaid Diagrams** - Flowcharts, sequence diagrams, state machines, and more
40
- - **Syntax Highlighting** - Prism.js with 10+ language support
41
- - **Markdown Tables** - Full table syntax with alignment support
42
- - **HTML Components** - Spec tables, layer stacks, box diagrams, cards
43
- - **Internal Links** - Auto-resolved `#section-id` links in Markdown
67
+ - **Mermaid Diagrams** flowcharts, sequence diagrams, state machines, and more
68
+ - **Syntax Highlighting** Prism.js with 10+ language support
69
+ - **Markdown Tables** full table syntax with alignment support
70
+ - **HTML Components** spec tables, layer stacks, box diagrams, cards
71
+ - **Internal Links** auto-resolved `#section-id` links in Markdown
44
72
 
45
73
  ### External Links
46
- - **Navigation Links** - Add external URLs directly in manifest with `url` property
47
- - **Smart Link Handling** - All external links open in new tab with security headers
48
- - **Visual Indicators** - Subtle ↗ icon shows external destinations
49
- - **CTA Styling** - Button-like `external-cta` class for prominent external links
50
-
51
- **External navigation example** (manifest.json):
52
- ```json
53
- [
54
- { "id": "welcome", "title": "Welcome", "file": "welcome.md" },
55
- { "title": "External Resource", "url": "https://example.com" }
56
- ]
57
- ```
58
-
59
- **External links in Markdown** (auto-handled):
60
- ```markdown
61
- Visit our [support portal](https://support.example.com) for help.
62
- ```
63
-
64
- **Prominent CTA in HTML**:
65
- ```html
66
- <a href="https://example.com" target="_blank" rel="noopener noreferrer" class="external-cta">
67
- Get Started →
68
- </a>
69
- ```
70
-
71
- **Security & UX:**
72
- - All external links use `target="_blank"` and `rel="noopener noreferrer"` by default
73
- - Navigation external links show ↗ indicator
74
- - Content external links styled with subtle ↗ after link text
75
- - No configuration needed - works automatically for `http://` and `https://` URLs
74
+ - **Navigation Links** add external URLs directly in the manifest with a `url` property
75
+ - **Smart Link Handling** external links open in a new tab with security headers (`rel="noopener noreferrer"`)
76
+ - **Visual Indicators** a subtle ↗ icon marks external destinations
77
+ - **CTA Styling** button-like `external-cta` class for prominent external links
76
78
 
77
79
  ### Navigation & Search
78
- - **Command Palette** - `Ctrl/Cmd+K` or `/` opens global finder
79
- - **Full-Text Search** - Searches all content, not just titles
80
- - **Manifest-Driven Nav** - Declarative navigation structure
81
- - **Keyboard Navigation** - Arrow keys, Enter to select
80
+ - **Command Palette** `Ctrl/Cmd+K` or `/` opens a global finder
81
+ - **Full-Text Search** searches all content, not just titles
82
+ - **Manifest-Driven Nav** declarative navigation structure
83
+ - **Keyboard Navigation** arrow keys, Enter to select
82
84
 
83
85
  ### Theming & Branding
84
- - **Custom Colors** - `accentColor` and `surfaceColor` per tenant
85
- - **Brand Identity** - Logo text, tagline, copyright
86
- - **Typography** - IBM Plex Sans/Mono defaults, customizable
86
+ - **Custom Colors** `accentColor` and `surfaceColor` per tenant
87
+ - **Brand Identity** logo text, tagline, copyright
88
+ - **Typography** IBM Plex Sans/Mono defaults, customizable
89
+
90
+ ### SEO (built in)
91
+ - **Absolute URLs** — declare a `domain` (or `seo.siteUrl`) and the sitemap, canonical, `og:url`, and `robots` URLs become fully-qualified
92
+ - **Static snapshots** — crawler-friendly `/pages/<id>.html` for every section, self-canonical (the SPA hash route isn't crawlable)
93
+ - **`sitemap.xml`, `robots.txt`, `llms.txt`** — generated automatically
94
+ - **JSON-LD + Open Graph** — `TechArticle`/`BreadcrumbList` per page, optional Organization data, and `og:image`/`twitter:image` via `seo.ogImage`
87
95
 
88
96
  ### Export & Sharing
89
- - **Export Options** - Choose between Current Page or Entire Site export
90
- - **Branded Exports** - Tenant logo, brand name, and tagline in export header
91
- - **Document Export** - One-click HTML export with TOC
92
- - **Print Styles** - Optimized for PDF generation
93
- - **Syntax Highlighting** - Preserved in exports
94
- - **Table Rendering** - Markdown tables render correctly in exports
97
+ - **Export Options** Current Page or Entire Site
98
+ - **Branded Exports** tenant logo, brand name, and tagline in the export header
99
+ - **Document Export** one-click HTML export with a table of contents, print-optimized for PDF
100
+
101
+ ---
95
102
 
96
103
  ## Tenant Content Workflow
97
104
 
@@ -99,7 +106,7 @@ Visit our [support portal](https://support.example.com) for help.
99
106
 
100
107
  ```
101
108
  my-tenant/
102
- ├── config.json # Branding and theme settings
109
+ ├── config.json # Branding, theme, and SEO settings
103
110
  ├── manifest.json # Navigation structure (optional)
104
111
  ├── content/ # Content files
105
112
  │ ├── welcome.md # Root-level content
@@ -160,14 +167,10 @@ export async function load() {
160
167
 
161
168
  ### Manifest Configuration
162
169
 
163
- **Root manifest.json** (optional - auto-generated from content/ if omitted):
170
+ **Root manifest.json** (optional auto-generated from `content/` if omitted):
164
171
  ```json
165
172
  [
166
- {
167
- "id": "welcome",
168
- "title": "Welcome",
169
- "file": "welcome.md"
170
- },
173
+ { "id": "welcome", "title": "Welcome", "file": "welcome.md" },
171
174
  {
172
175
  "id": "guides",
173
176
  "title": "Guides",
@@ -190,23 +193,15 @@ export async function load() {
190
193
  }
191
194
  ```
192
195
 
193
- **External links in manifest** (use `url` instead of `id`):
196
+ **External links in the manifest** (use `url` instead of `file`):
194
197
  ```json
195
198
  [
196
199
  { "id": "welcome", "title": "Welcome", "file": "welcome.md" },
197
- { "title": "Support Portal", "url": "https://support.example.com" },
198
- {
199
- "id": "resources",
200
- "title": "Resources",
201
- "subsections": [
202
- { "id": "guides/overview", "title": "Overview", "file": "guides/overview.md" },
203
- { "title": "API Docs", "url": "https://api.example.com/docs" }
204
- ]
205
- }
200
+ { "title": "Support Portal", "url": "https://support.example.com" }
206
201
  ]
207
202
  ```
208
203
 
209
- ### Branding Configuration
204
+ ### Branding & SEO Configuration
210
205
 
211
206
  **config.json**:
212
207
  ```json
@@ -219,29 +214,29 @@ export async function load() {
219
214
  "copyright": "ACME Corp",
220
215
  "accentColor": "#6366F1",
221
216
  "surfaceColor": "#F7FAFC",
222
- "export": {
223
- "logo": "embed",
224
- "logoPath": "favicon.png",
225
- "showTagline": true,
226
- "showDate": true
217
+ "domain": "docs.acme.com",
218
+ "seo": {
219
+ "siteUrl": "https://docs.acme.com",
220
+ "ogImage": "/assets/og-card.png",
221
+ "structuredData": { "organizationName": "ACME Corporation" }
227
222
  }
228
223
  }
229
224
  ```
230
225
 
231
226
  | Property | Description | Default |
232
227
  |----------|-------------|---------|
233
- | `title` | Browser tab title | "Docs Toolkit" |
228
+ | `title` | Browser tab title | "Documentation" |
234
229
  | `description` | Meta description for SEO | - |
235
230
  | `brandMark` | Primary brand text (bold) | "DOCS" |
236
231
  | `brandSub` | Secondary brand text (light) | "TOOLKIT" |
237
232
  | `tagline` | Subtitle under brand | - |
238
- | `copyright` | Footer copyright text | "Modular Documentation Toolkit" |
233
+ | `copyright` | Footer copyright text | - |
239
234
  | `accentColor` | Links, buttons, highlights | `#111111` |
240
235
  | `surfaceColor` | Background color (hex) | `#ffffff` |
241
- | `export.logo` | Logo mode: `"embed"`, `"reference"`, or `null` | `"embed"` |
242
- | `export.logoPath` | Path to logo in `.public/` directory | Auto-detect |
243
- | `export.showTagline` | Show tagline in export header | `true` |
244
- | `export.showDate` | Show generation date in export | `true` |
236
+ | `domain` | Canonical domain; also the SEO base URL when `seo.siteUrl` is unset | - |
237
+ | `seo` | SEO block see [Tenant Configuration](docs/TENANT-CONFIG.md#seo-seo) | - |
238
+
239
+ ---
245
240
 
246
241
  ## Build Commands
247
242
 
@@ -266,6 +261,8 @@ npm run check # run all checks
266
261
  npm test # run test suite
267
262
  ```
268
263
 
264
+ ---
265
+
269
266
  ## Tenant Registry
270
267
 
271
268
  Register tenants in a `tenants.json` at your project root (validated by the
@@ -296,6 +293,8 @@ Per-tenant options include `enabled` (default `true`), `strictLinks` (default
296
293
  `true` — fail the build on broken internal links), and `domains` (for the
297
294
  multi-tenant Caddy router). See [Tenant Configuration](docs/TENANT-CONFIG.md).
298
295
 
296
+ ---
297
+
299
298
  ## Docker Caddy Workflow
300
299
 
301
300
  For multi-tenant domain testing:
@@ -304,20 +303,19 @@ For multi-tenant domain testing:
304
303
  # Add to /etc/hosts:
305
304
  # 127.0.0.1 my-docs.local client-portal.local
306
305
 
307
- # Build tenants and start Caddy
308
- npm run build:tenants
309
- npm run caddy:up
310
-
306
+ npm run build:tenants # build tenants
307
+ npm run caddy:up # start Caddy
311
308
  # Visit http://my-docs.local or http://client-portal.local
312
309
 
313
- # Management commands
314
- npm run caddy:logs # Tail logs
315
- npm run caddy:reload # Reload config without restart
316
- npm run caddy:restart # Full restart
317
- npm run caddy:down # Stop container
310
+ npm run caddy:logs # tail logs
311
+ npm run caddy:reload # reload config without restart
312
+ npm run caddy:restart # full restart
313
+ npm run caddy:down # stop container
318
314
  ```
319
315
 
320
- Use non-privileged port: `DOCS_TOOLKIT_PORT=5173 npm run caddy:up`
316
+ Use a non-privileged port: `DOCS_TOOLKIT_PORT=5173 npm run caddy:up`
317
+
318
+ ---
321
319
 
322
320
  ## Repository Layout
323
321
 
@@ -328,32 +326,46 @@ apps/publisher/
328
326
  │ ├── app.js # Router and core logic
329
327
  │ ├── styles.css # All styling
330
328
  │ ├── manifest.js # Default navigation
331
- │ ├── seo.js # Meta tag management
329
+ │ ├── seo.js # Runtime meta tag management
332
330
  │ ├── mermaid-init.js # Diagram rendering
333
331
  │ ├── syntax-highlight.js # Code highlighting
334
- ├── lib/
335
- │ │ ├── search.js # Full-text search
336
- │ │ ├── router.js # Hash routing
337
- │ │ └── export.js # Document export
338
- │ └── sections/ # Default section modules
332
+ └── lib/ # search, router, export
339
333
  ├── scripts/
340
334
  │ ├── build.js # Core build script
341
335
  │ ├── build-tenants.js # Multi-tenant builder
342
336
  │ ├── serve.js # Dev server
343
- │ └── sync-docs.js # Template sync
337
+ │ └── lib/seo-generator.js # Sitemap, robots, snapshots, JSON-LD
344
338
  ├── tenants/ # Built-in example tenants
345
339
  ├── docs/ # Documentation
346
- ├── dist/ # Build output
347
- ├── Caddyfile # Multi-tenant routing
348
- └── docker-compose.yml # Caddy container
340
+ └── Caddyfile, docker-compose.yml # Multi-tenant routing
349
341
  ```
350
342
 
343
+ ---
344
+
351
345
  ## Documentation
352
346
 
353
- - [Getting Started](docs/GETTING-STARTED.md) - **start here**: zero to a published site with the npm package
354
- - [Quick Start Guide](docs/QUICKSTART.md) - Step-by-step tenant creation
355
- - [Tenant Configuration](docs/TENANT-CONFIG.md) - All config options
356
- - [Architecture](docs/ARCHITECTURE.md) - System design
357
- - [API Reference](docs/API.md) - Module documentation
358
- - [Deployment](docs/DEPLOYMENT.md) - Hosting patterns
359
- - [Extending](docs/EXTENDING.md) - Customization guide
347
+ The full documentation site is published at **[docs.pagenary.com](https://docs.pagenary.com)** built by this publisher from the source below. Read it online, or browse the source:
348
+
349
+ - [Getting Started](docs/GETTING-STARTED.md) **start here**: zero to a published site with the npm package
350
+ - [Quick Start Guide](docs/QUICKSTART.md) — step-by-step tenant creation
351
+ - [Tenant Configuration](docs/TENANT-CONFIG.md) all config options (branding, theme, SEO)
352
+ - [Architecture](docs/ARCHITECTURE.md) system design
353
+ - [API Reference](docs/API.md) module documentation
354
+ - [Deployment](docs/DEPLOYMENT.md) — hosting patterns
355
+ - [Extending](docs/EXTENDING.md) — customization guide
356
+
357
+ ---
358
+
359
+ ## License
360
+
361
+ **GNU Affero General Public License v3.0** — strong copyleft. You may use, modify, and distribute Pagenary, but if you run a modified version to provide a network service, you must make the modified source available to its users. See [LICENSE](../../LICENSE).
362
+
363
+ ---
364
+
365
+ <div align="center">
366
+
367
+ **[Back to Top](#pagenary-publisher)**
368
+
369
+ Made with care by [Joseph Magly](https://github.com/jmagly)
370
+
371
+ </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagenary/publisher",
3
- "version": "2026.5.2",
3
+ "version": "2026.5.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
- "devDependencies": {
63
- "jest": "^29.7.0",
62
+ "optionalDependencies": {
64
63
  "terser": "^5.44.0"
64
+ },
65
+ "devDependencies": {
66
+ "jest": "^29.7.0"
65
67
  }
66
68
  }
@@ -7,6 +7,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
- main().catch((err) => {
3603
- console.error(err);
3604
- process.exit(1);
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, '&amp;')
29
+ .replace(/</g, '&lt;')
30
+ .replace(/>/g, '&gt;')
31
+ .replace(/"/g, '&quot;')
32
+ .replace(/'/g, '&apos;');
33
+ }
34
+
35
+ /** Output subdirectory for a collection: its route (slug-ified) or its path. */
36
+ function outputDir(collection) {
37
+ const route = (collection.route || collection.path || '').replace(/^\/+|\/+$/g, '');
38
+ return route;
39
+ }
40
+
41
+ /**
42
+ * Build the entry list for one collection by reading its source posts.
43
+ * @returns {Promise<Array>} sorted post entries
44
+ */
45
+ async function collectEntries(collection, contentBasePath, baseUrl) {
46
+ const srcDir = path.join(contentBasePath, collection.path);
47
+ let files;
48
+ try {
49
+ files = await fsp.readdir(srcDir, { withFileTypes: true });
50
+ } catch {
51
+ return null; // folder missing — caller warns
52
+ }
53
+
54
+ const entries = [];
55
+ for (const f of files) {
56
+ if (!f.isFile()) continue;
57
+ const ext = path.extname(f.name).toLowerCase();
58
+ if (!POST_EXTENSIONS.has(ext)) continue;
59
+ if (f.name.startsWith('_') || f.name.toLowerCase() === 'index.md') continue;
60
+
61
+ const slug = f.name.slice(0, -ext.length);
62
+ const raw = await fsp.readFile(path.join(srcDir, f.name), 'utf8');
63
+ const { data, body } = parseFrontmatter(raw);
64
+
65
+ // Section id mirrors the build's nested-id scheme: <collection.path>/<slug>
66
+ const sectionId = `${collection.path.replace(/^\/+|\/+$/g, '')}/${slug}`;
67
+ const staticPath = `/pages/${encodePathForFilename(sectionId)}.html`;
68
+ const routePath = collection.route
69
+ ? `${collection.route.replace(/\/+$/, '')}/${slug}`
70
+ : `/#/${sectionId}`;
71
+
72
+ entries.push({
73
+ slug,
74
+ title: data.title || firstHeading(body) || slug,
75
+ date: data.date || null,
76
+ summary: data.summary || data.description || '',
77
+ hero: data.hero || data.image || null,
78
+ tags: Array.isArray(data.tags) ? data.tags : (data.tags ? [data.tags] : []),
79
+ reading_time: estimateReadingTime(body),
80
+ canonical: baseUrl ? `${baseUrl}${staticPath}` : staticPath,
81
+ path: routePath
82
+ });
83
+ }
84
+
85
+ const sortBy = collection.sortBy || 'date';
86
+ const dir = (collection.order || 'desc').toLowerCase() === 'asc' ? 1 : -1;
87
+ entries.sort((a, b) => {
88
+ const av = a[sortBy];
89
+ const bv = b[sortBy];
90
+ // Missing sort key always sorts last, regardless of order.
91
+ if (av == null && bv == null) return 0;
92
+ if (av == null) return 1;
93
+ if (bv == null) return -1;
94
+ if (av < bv) return -1 * dir;
95
+ if (av > bv) return 1 * dir;
96
+ return 0;
97
+ });
98
+ return entries;
99
+ }
100
+
101
+ function buildFeedXml(collection, entries, config, baseUrl) {
102
+ const title = collection.title || config.title || 'Feed';
103
+ const channelLink = baseUrl
104
+ ? `${baseUrl}/${outputDir(collection)}`
105
+ : `/${outputDir(collection)}`;
106
+ const items = entries.map((e) => {
107
+ const pubDate = e.date ? new Date(e.date).toUTCString() : '';
108
+ return ` <item>
109
+ <title>${escapeXml(e.title)}</title>
110
+ <link>${escapeXml(e.canonical)}</link>
111
+ <guid isPermaLink="true">${escapeXml(e.canonical)}</guid>${pubDate ? `\n <pubDate>${pubDate}</pubDate>` : ''}
112
+ <description>${escapeXml(e.summary)}</description>
113
+ </item>`;
114
+ }).join('\n');
115
+
116
+ return `<?xml version="1.0" encoding="UTF-8"?>
117
+ <rss version="2.0">
118
+ <channel>
119
+ <title>${escapeXml(title)}</title>
120
+ <link>${escapeXml(channelLink)}</link>
121
+ <description>${escapeXml(config.description || title)}</description>
122
+ <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
123
+ ${items}
124
+ </channel>
125
+ </rss>
126
+ `;
127
+ }
128
+
129
+ /**
130
+ * Generate collection artifacts for a tenant.
131
+ * @param {string} distDir - Tenant dist directory
132
+ * @param {object} config - Tenant configuration
133
+ * @param {string} contentBasePath - Resolved tenant content root (source)
134
+ */
135
+ export async function generateCollections(distDir, config, contentBasePath) {
136
+ const collections = Array.isArray(config.collections) ? config.collections : [];
137
+ if (collections.length === 0 || !contentBasePath) return;
138
+
139
+ const baseUrl = resolveBaseUrl(config);
140
+
141
+ for (const collection of collections) {
142
+ if (!collection || !collection.path) {
143
+ console.warn(' ⚠ collection entry missing "path" — skipped');
144
+ continue;
145
+ }
146
+ const entries = await collectEntries(collection, contentBasePath, baseUrl);
147
+ if (entries === null) {
148
+ console.warn(` ⚠ collection source not found: ${collection.path}`);
149
+ continue;
150
+ }
151
+
152
+ const outDir = path.join(distDir, outputDir(collection));
153
+ await fsp.mkdir(outDir, { recursive: true });
154
+
155
+ if (collection.manifest !== false) {
156
+ const manifest = {
157
+ title: collection.title || config.title || '',
158
+ route: collection.route || `/${outputDir(collection)}`,
159
+ count: entries.length,
160
+ generated: new Date().toISOString(),
161
+ posts: entries
162
+ };
163
+ await fsp.writeFile(path.join(outDir, 'index.json'), JSON.stringify(manifest, null, 2), 'utf8');
164
+ console.log(` ↳ generated ${outputDir(collection)}/index.json (${entries.length} posts)`);
165
+ }
166
+
167
+ if (collection.feed) {
168
+ const xml = buildFeedXml(collection, entries, config, baseUrl);
169
+ await fsp.writeFile(path.join(outDir, 'feed.xml'), xml, 'utf8');
170
+ console.log(` ↳ generated ${outputDir(collection)}/feed.xml`);
171
+ }
172
+ }
173
+ }