@pagenary/publisher 2026.5.0 → 2026.5.2

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
@@ -6,13 +6,25 @@ Transform shared documentation templates into tenant-specific bundles with custo
6
6
 
7
7
  ## Quick Start
8
8
 
9
+ 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).
11
+
9
12
  ```bash
10
- npm install
11
- npm run dev # Build + serve with watch mode
13
+ npm install --save-dev @pagenary/publisher
12
14
 
13
- # Or separately:
14
- npm run build # Build default bundle to dist/
15
- npm run serve # Preview on http://localhost:5173
15
+ npx pagenary build:tenants my-docs # build your tenant (see Tenant Registry below)
16
+ npx pagenary serve # preview on http://localhost:5173
17
+ ```
18
+
19
+ Commands: `build`, `build:tenants [id]`, `tenants:list`, `serve` (run
20
+ `npx pagenary --help`). The package also ships a compiled reference site under `site/`.
21
+
22
+ **Building from source** (contributors / modifying Pagenary):
23
+
24
+ ```bash
25
+ npm install
26
+ npm run dev # build + serve with watch mode
27
+ npm run build # build default bundle to dist/
16
28
  ```
17
29
 
18
30
  ## Features
@@ -233,47 +245,56 @@ export async function load() {
233
245
 
234
246
  ## Build Commands
235
247
 
248
+ With the package installed (the default):
249
+
236
250
  ```bash
237
- # Full builds
238
- npm run build # Build default bundle
239
- npm run build:tenants # Build all registered tenants
240
- npm run build:tenants my-tenant # Build specific tenant
241
-
242
- # Incremental builds (git-aware)
243
- npm run build:incremental my-tenant # Only rebuild changed files
244
-
245
- # Development
246
- npm run dev # Build + serve with watch
247
- npm run serve # Serve dist/ on localhost:5173
248
-
249
- # Utilities
250
- npm run lint:content # Check for trailing whitespace/tabs
251
- npm run check:seo # Verify SEO metadata
252
- npm run check # Run all checks
253
- npm run sync:docs # Regenerate section templates
254
- npm test # Run test suite
251
+ npx pagenary build # build the default bundle to dist/
252
+ npx pagenary build:tenants # build all enabled tenants
253
+ npx pagenary build:tenants my-tenant # build a specific tenant
254
+ npx pagenary tenants:list # list configured tenants
255
+ npx pagenary serve # serve dist/ on localhost:5173
256
+ ```
257
+
258
+ From source (clone — adds dev/utility scripts):
259
+
260
+ ```bash
261
+ npm run build:incremental my-tenant # git-aware incremental rebuild
262
+ npm run dev # build + serve with watch
263
+ npm run lint:content # check trailing whitespace/tabs
264
+ npm run check:seo # verify SEO metadata
265
+ npm run check # run all checks
266
+ npm test # run test suite
255
267
  ```
256
268
 
257
269
  ## Tenant Registry
258
270
 
259
- Register tenants in `tenants.json`:
271
+ Register tenants in a `tenants.json` at your project root (validated by the
272
+ bundled `tenants.schema.json`):
260
273
 
261
274
  ```json
262
275
  {
263
- "my-docs": {
264
- "source": "/absolute/path/to/my-docs",
265
- "domain": "my-docs.local"
266
- },
267
- "client-portal": {
268
- "source": "git:https://github.com/org/client-docs.git#main",
269
- "domain": "docs.client.com"
270
- }
276
+ "tenants": [
277
+ {
278
+ "id": "my-docs",
279
+ "source": { "type": "local", "path": "./docs" },
280
+ "strictLinks": true
281
+ },
282
+ {
283
+ "id": "client-portal",
284
+ "source": { "type": "git", "url": "https://github.com/org/client-docs.git", "ref": "main" },
285
+ "domains": ["docs.client.com"]
286
+ }
287
+ ]
271
288
  }
272
289
  ```
273
290
 
274
291
  **Source types:**
275
- - **Local path**: `/absolute/path/to/content`
276
- - **Git repository**: `git:https://github.com/org/repo.git#branch`
292
+ - **Local**: `{ "type": "local", "path": "./relative/or/abs/path" }`
293
+ - **Git**: `{ "type": "git", "url": "https://…", "ref": "main", "path": "subdir" }`
294
+
295
+ Per-tenant options include `enabled` (default `true`), `strictLinks` (default
296
+ `true` — fail the build on broken internal links), and `domains` (for the
297
+ multi-tenant Caddy router). See [Tenant Configuration](docs/TENANT-CONFIG.md).
277
298
 
278
299
  ## Docker Caddy Workflow
279
300
 
@@ -329,6 +350,7 @@ apps/publisher/
329
350
 
330
351
  ## Documentation
331
352
 
353
+ - [Getting Started](docs/GETTING-STARTED.md) - **start here**: zero to a published site with the npm package
332
354
  - [Quick Start Guide](docs/QUICKSTART.md) - Step-by-step tenant creation
333
355
  - [Tenant Configuration](docs/TENANT-CONFIG.md) - All config options
334
356
  - [Architecture](docs/ARCHITECTURE.md) - System design
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagenary/publisher",
3
- "version": "2026.5.0",
3
+ "version": "2026.5.2",
4
4
  "type": "module",
5
5
  "description": "Multi-tenant static publishing component for Pagenary platform.",
6
6
  "license": "AGPL-3.0-or-later",
@@ -6,9 +6,15 @@ import path from 'path';
6
6
  import { spawn, execSync } from 'child_process';
7
7
  import { createHash } from 'crypto';
8
8
  import os from 'os';
9
- import { generateSeoArtifacts } from './lib/seo-generator.js';
9
+ import { generateSeoArtifacts, resolveBaseUrl, resolveOgImage } from './lib/seo-generator.js';
10
+ import { fileURLToPath } from 'node:url';
10
11
 
11
12
  const root = process.cwd();
13
+ // The package's own directory (this file lives at <pkg>/scripts/build-tenants.js).
14
+ // Bundled scripts/assets (build.js, src/, build.config.json) resolve against this
15
+ // so the `pagenary` bin works from any consumer CWD (#11). Tenant source/target/
16
+ // registry paths stay relative to `root` (the caller's CWD).
17
+ const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
12
18
  const DEFAULT_TENANTS_DIR = path.join(root, 'tenants');
13
19
  const DEFAULT_DIST_DIR = path.join(root, 'dist');
14
20
  const DEFAULT_REGISTRY_PATH = path.join(root, 'tenants.json');
@@ -738,10 +744,16 @@ function escapeAttribute(value) {
738
744
 
739
745
  async function runBuild(buildOutput) {
740
746
  return new Promise((resolve, reject) => {
741
- const proc = spawn(process.execPath, [path.join('scripts', 'build.js')], {
742
- cwd: root,
747
+ // Resolve build.js and run it from the package dir so it reads the package's
748
+ // own src/ + build.config.json (not the caller's CWD). Pass an ABSOLUTE
749
+ // output path (resolved against the caller's CWD) so the bundle still lands
750
+ // in the right dist/ regardless of build.js's CWD (#11).
751
+ const buildScript = path.join(packageRoot, 'scripts', 'build.js');
752
+ const absOutput = path.resolve(root, buildOutput);
753
+ const proc = spawn(process.execPath, [buildScript], {
754
+ cwd: packageRoot,
743
755
  stdio: 'inherit',
744
- env: { ...process.env, BUILD_OUTPUT: buildOutput }
756
+ env: { ...process.env, BUILD_OUTPUT: absOutput }
745
757
  });
746
758
  proc.on('exit', (code) => {
747
759
  if (code === 0) return resolve();
@@ -2634,9 +2646,11 @@ async function processNestedContent(sourceDir, distDir, tenantId, contentRoot, o
2634
2646
  const siteConfig = {
2635
2647
  bottomNav: rootManifest?.bottomNav || 'mobile',
2636
2648
  bottomNavSections: rootManifest?.bottomNavSections || [],
2637
- // Pass SEO-relevant config to SPA for dynamic meta tag updates
2649
+ // Pass SEO-relevant config to SPA for dynamic meta tag updates.
2650
+ // siteUrl falls back to `domain` (#15); ogImage drives social cards (#16).
2638
2651
  siteTitle: config.title || '',
2639
- siteUrl: config.seo?.siteUrl || ''
2652
+ siteUrl: resolveBaseUrl(config),
2653
+ ogImage: resolveOgImage(config, resolveBaseUrl(config))
2640
2654
  };
2641
2655
 
2642
2656
  // Build export branding configuration
@@ -2874,7 +2888,7 @@ async function processTenantContent(sourceDir, distDir, tenantId, options = {},
2874
2888
 
2875
2889
  if (contentRoot.type === 'none' && !hasManifest) {
2876
2890
  console.warn(` ↳ ${tenantId}: no content found`);
2877
- return;
2891
+ return { success: true };
2878
2892
  }
2879
2893
 
2880
2894
  // If nested structure detected, use new processing
@@ -2892,8 +2906,7 @@ async function processTenantContent(sourceDir, distDir, tenantId, options = {},
2892
2906
 
2893
2907
  if (hasDirectoryType || !hasExplicitFiles) {
2894
2908
  // Use nested content processing
2895
- await processNestedContent(sourceDir, distDir, tenantId, contentRoot, options, config);
2896
- return;
2909
+ return (await processNestedContent(sourceDir, distDir, tenantId, contentRoot, options, config)) ?? { success: true };
2897
2910
  }
2898
2911
  } catch {
2899
2912
  // Fall through to nested processing if manifest is invalid
@@ -2901,20 +2914,20 @@ async function processTenantContent(sourceDir, distDir, tenantId, options = {},
2901
2914
  }
2902
2915
 
2903
2916
  // No manifest or manifest doesn't fully define structure - use nested scanning
2904
- await processNestedContent(sourceDir, distDir, tenantId, contentRoot, options, config);
2905
- return;
2917
+ return (await processNestedContent(sourceDir, distDir, tenantId, contentRoot, options, config)) ?? { success: true };
2906
2918
  }
2907
2919
 
2908
2920
  // Flat content/ structure with manifest - use legacy processing
2909
2921
  if (hasManifest && contentRoot.type === 'flat') {
2910
- await processTenantManifestLegacy(sourceDir, distDir, tenantId, options);
2911
- return;
2922
+ return (await processTenantManifestLegacy(sourceDir, distDir, tenantId, options)) ?? { success: true };
2912
2923
  }
2913
2924
 
2914
2925
  // Fallback: try legacy processing
2915
2926
  if (hasManifest) {
2916
- await processTenantManifestLegacy(sourceDir, distDir, tenantId, options);
2927
+ return (await processTenantManifestLegacy(sourceDir, distDir, tenantId, options)) ?? { success: true };
2917
2928
  }
2929
+
2930
+ return { success: true };
2918
2931
  }
2919
2932
 
2920
2933
  /**
@@ -2996,15 +3009,21 @@ async function processTenantManifestLegacy(sourceDir, distDir, tenantId, options
2996
3009
  return;
2997
3010
  }
2998
3011
 
2999
- // Print link warnings
3012
+ // Print link warnings, and fail the tenant on broken links under strict mode
3000
3013
  if (linkWarnings.length > 0) {
3001
3014
  printLinkWarnings(linkWarnings, tenantId, strictLinks);
3015
+ const brokenLinks = linkWarnings.filter(w => w.type === 'broken');
3016
+ if (strictLinks && brokenLinks.length > 0) {
3017
+ console.error(` ↳ [ERROR] ${tenantId}: Build failed due to ${brokenLinks.length} broken link(s). Use strictLinks: false to warn instead.`);
3018
+ return { success: false, brokenLinks: brokenLinks.length };
3019
+ }
3002
3020
  }
3003
3021
 
3004
3022
  const defaultSection = manifestData.default || manifestData.defaultSection || context.leafOrder[0];
3005
3023
  const manifestModule = buildManifestModuleSource(processedManifest, defaultSection, context.siteConfig);
3006
3024
  await fsp.writeFile(path.join(distDir, 'manifest.js'), manifestModule, 'utf8');
3007
3025
  console.log(` ↳ applied manifest-driven content for ${tenantId}`);
3026
+ return { success: true };
3008
3027
  }
3009
3028
 
3010
3029
  /**
@@ -3127,6 +3146,7 @@ async function buildTenant(tenant, targetOverride, cacheDir, buildOptions) {
3127
3146
  followLinks: followLinksSetting || false
3128
3147
  };
3129
3148
 
3149
+ let contentResult = { success: true };
3130
3150
  if (isExplicitFileBuild) {
3131
3151
  // Explicit file targeting - create synthetic change set
3132
3152
  const explicitFiles = {
@@ -3140,7 +3160,7 @@ async function buildTenant(tenant, targetOverride, cacheDir, buildOptions) {
3140
3160
  if (!(await pathExists(distDir))) {
3141
3161
  console.log(` ↳ no existing build found, performing full build first`);
3142
3162
  await runBuild(buildOutput);
3143
- await processTenantContent(sourceDir, distDir, tenantId, contentOptions, config);
3163
+ contentResult = await processTenantContent(sourceDir, distDir, tenantId, contentOptions, config);
3144
3164
  }
3145
3165
 
3146
3166
  // Process only the specified files
@@ -3161,7 +3181,16 @@ async function buildTenant(tenant, targetOverride, cacheDir, buildOptions) {
3161
3181
  await runBuild(buildOutput);
3162
3182
 
3163
3183
  // Process full manifest from source directory
3164
- await processTenantContent(sourceDir, distDir, tenantId, contentOptions, config);
3184
+ contentResult = await processTenantContent(sourceDir, distDir, tenantId, contentOptions, config);
3185
+ }
3186
+
3187
+ // Fail the tenant before branding/SEO/deploy if content processing failed
3188
+ // (e.g. broken links under strictLinks). Continuing would deploy a half-built
3189
+ // dist with no tenant manifest.js, yielding a misleading "ready" and the
3190
+ // downstream SEO "sectionEntry is not defined" error (#12, #13).
3191
+ if (contentResult && contentResult.success === false) {
3192
+ console.error(` ↳ ${tenantId}: aborting build — content processing failed`);
3193
+ return { success: false, changes };
3165
3194
  }
3166
3195
 
3167
3196
  // Apply file overrides from source FIRST (before branding/theme modifications)
@@ -3559,6 +3588,13 @@ async function main() {
3559
3588
  }
3560
3589
  }
3561
3590
 
3591
+ // Exit non-zero when any tenant failed so CI can gate on it — e.g. the
3592
+ // strictLinks broken-link gate (#12). Use exitCode (not exit()) so the
3593
+ // return below still works when this script is imported programmatically.
3594
+ if (failCount > 0) {
3595
+ process.exitCode = 1;
3596
+ }
3597
+
3562
3598
  // Return results for programmatic use (if this script is imported)
3563
3599
  return results;
3564
3600
  }
@@ -6,6 +6,43 @@
6
6
  import * as fsp from 'node:fs/promises';
7
7
  import * as path from 'node:path';
8
8
 
9
+ /**
10
+ * Resolve the absolute base URL for a tenant's SEO output.
11
+ *
12
+ * Precedence: `seo.siteUrl` > `domain` (https:// prefixed) > '' (relative fallback).
13
+ * Returns a URL with no trailing slash, or '' when neither is configured.
14
+ * Tenants that declare only `domain` (the common case) get absolute SEO URLs
15
+ * for free — see issue #15.
16
+ *
17
+ * @param {object} config - Tenant configuration
18
+ * @returns {string} Absolute base URL (no trailing slash) or ''
19
+ */
20
+ export function resolveBaseUrl(config = {}) {
21
+ const seoConfig = config.seo || {};
22
+ const raw = String(seoConfig.siteUrl || config.domain || '').trim();
23
+ if (!raw) return '';
24
+ const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
25
+ return withScheme.replace(/\/+$/, '');
26
+ }
27
+
28
+ /**
29
+ * Resolve a social-share image URL (og:image / twitter:image).
30
+ * Absolute URLs pass through; site-relative paths are joined to baseUrl.
31
+ * Returns '' when no image is configured. See issue #16.
32
+ *
33
+ * @param {object} config - Tenant configuration
34
+ * @param {string} baseUrl - Resolved base URL (may be '')
35
+ * @returns {string} Image URL, or '' when none configured
36
+ */
37
+ export function resolveOgImage(config = {}, baseUrl = '') {
38
+ const seoConfig = config.seo || {};
39
+ const img = String(seoConfig.ogImage || '').trim();
40
+ if (!img) return '';
41
+ if (/^https?:\/\//i.test(img)) return img;
42
+ const pathPart = img.startsWith('/') ? img : `/${img}`;
43
+ return baseUrl ? `${baseUrl}${pathPart}` : pathPart;
44
+ }
45
+
9
46
  /**
10
47
  * Encode section ID for use as filename (replace / with --)
11
48
  * @param {string} sectionId - Section ID like "guides/getting-started"
@@ -30,7 +67,8 @@ export function collectAllSections(manifest, parentTitle = null) {
30
67
  title: entry.title,
31
68
  summary: entry.summary || '',
32
69
  module: entry.module,
33
- parent: parentTitle
70
+ parent: parentTitle,
71
+ ogImage: entry.ogImage || ''
34
72
  });
35
73
  }
36
74
  if (entry.subsections) {
@@ -85,7 +123,7 @@ export async function generateSitemap(distDir, manifest, config) {
85
123
  const seoConfig = config.seo || {};
86
124
  if (seoConfig.generateSitemap === false) return;
87
125
 
88
- const baseUrl = seoConfig.siteUrl || '';
126
+ const baseUrl = resolveBaseUrl(config);
89
127
  const defaultChangeFreq = seoConfig.defaultChangeFreq || 'weekly';
90
128
  const urls = [];
91
129
 
@@ -125,7 +163,7 @@ export async function generateRobotsTxt(distDir, config) {
125
163
  const seoConfig = config.seo || {};
126
164
  if (seoConfig.generateRobotsTxt === false) return;
127
165
 
128
- const baseUrl = seoConfig.siteUrl || '';
166
+ const baseUrl = resolveBaseUrl(config);
129
167
  const sitemapUrl = baseUrl ? `${baseUrl}/sitemap.xml` : '/sitemap.xml';
130
168
  const buildDate = new Date().toISOString();
131
169
 
@@ -153,8 +191,10 @@ Sitemap: ${sitemapUrl}
153
191
  */
154
192
  export function buildPageJsonLd(section, config) {
155
193
  const seoConfig = config.seo || {};
156
- const baseUrl = seoConfig.siteUrl || '';
157
- const canonicalUrl = `${baseUrl}/#${section.id}`;
194
+ const baseUrl = resolveBaseUrl(config);
195
+ // Canonical points at the crawlable static snapshot, not the SPA hash route
196
+ // (search engines ignore #fragments, which collapsed every page to home). #17
197
+ const canonicalUrl = `${baseUrl}/pages/${encodePathForFilename(section.id)}.html`;
158
198
  const buildDate = new Date().toISOString().split('T')[0];
159
199
 
160
200
  const articleSchema = {
@@ -229,7 +269,7 @@ export function buildPageJsonLd(section, config) {
229
269
  */
230
270
  export function buildHomePageJsonLd(config) {
231
271
  const seoConfig = config.seo || {};
232
- const baseUrl = seoConfig.siteUrl || '';
272
+ const baseUrl = resolveBaseUrl(config);
233
273
 
234
274
  const schema = {
235
275
  '@context': 'https://schema.org',
@@ -265,10 +305,14 @@ export function buildStaticPage(options) {
265
305
  contentHtml,
266
306
  siteTitle,
267
307
  baseUrl,
308
+ ogImage = '',
268
309
  config
269
310
  } = options;
270
311
 
271
- const canonicalUrl = `${baseUrl}/#${sectionId}`;
312
+ // Canonical = the crawlable static snapshot (#17). The SPA hash route is for
313
+ // humans (JS redirect / noscript / "interactive version" link).
314
+ const canonicalUrl = `${baseUrl}/pages/${encodePathForFilename(sectionId)}.html`;
315
+ const spaUrl = `${baseUrl}/#${sectionId}`;
272
316
  const jsonLd = buildPageJsonLd({
273
317
  id: sectionId,
274
318
  title: sectionTitle,
@@ -281,6 +325,13 @@ export function buildStaticPage(options) {
281
325
  const safeSiteTitle = escapeHtml(siteTitle);
282
326
  const safeSummary = escapeHtml(sectionSummary || '');
283
327
 
328
+ // Social share image (#16): summary_large_image when present, else summary.
329
+ const safeImage = ogImage ? escapeHtml(ogImage) : '';
330
+ const twitterCard = safeImage ? 'summary_large_image' : 'summary';
331
+ const imageTags = safeImage
332
+ ? `\n <meta property="og:image" content="${safeImage}" />\n <meta name="twitter:image" content="${safeImage}" />`
333
+ : '';
334
+
284
335
  return `<!doctype html>
285
336
  <html lang="en">
286
337
  <head>
@@ -294,10 +345,10 @@ export function buildStaticPage(options) {
294
345
  <meta property="og:title" content="${safeTitle}" />
295
346
  <meta property="og:description" content="${safeSummary}" />
296
347
  <meta property="og:type" content="article" />
297
- <meta property="og:url" content="${canonicalUrl}" />
348
+ <meta property="og:url" content="${canonicalUrl}" />${imageTags}
298
349
 
299
350
  <!-- Twitter Card -->
300
- <meta name="twitter:card" content="summary" />
351
+ <meta name="twitter:card" content="${twitterCard}" />
301
352
  <meta name="twitter:title" content="${safeTitle}" />
302
353
  <meta name="twitter:description" content="${safeSummary}" />
303
354
 
@@ -309,11 +360,11 @@ ${jsonLd}
309
360
  <!-- Redirect to SPA for JavaScript-enabled browsers -->
310
361
  <script>
311
362
  if (typeof window !== 'undefined') {
312
- window.location.replace('${baseUrl}/#${sectionId}');
363
+ window.location.replace('${spaUrl}');
313
364
  }
314
365
  </script>
315
366
  <noscript>
316
- <meta http-equiv="refresh" content="0; url=${baseUrl}/#${sectionId}" />
367
+ <meta http-equiv="refresh" content="0; url=${spaUrl}" />
317
368
  </noscript>
318
369
 
319
370
  <link rel="stylesheet" href="../styles.css" />
@@ -332,7 +383,7 @@ ${contentHtml}
332
383
  </div>
333
384
  </article>
334
385
  <footer class="static-footer">
335
- <p>View interactive version: <a href="${canonicalUrl}">${safeTitle}</a></p>
386
+ <p>View interactive version: <a href="${spaUrl}">${safeTitle}</a></p>
336
387
  </footer>
337
388
  </main>
338
389
  </body>
@@ -398,7 +449,8 @@ export async function generateStaticSnapshots(distDir, manifest, config) {
398
449
  const pagesDir = path.join(distDir, 'pages');
399
450
  await fsp.mkdir(pagesDir, { recursive: true });
400
451
 
401
- const baseUrl = seoConfig.siteUrl || '';
452
+ const baseUrl = resolveBaseUrl(config);
453
+ const siteOgImage = resolveOgImage(config, baseUrl);
402
454
  const sections = collectAllSections(manifest);
403
455
  let generated = 0;
404
456
 
@@ -415,6 +467,11 @@ export async function generateStaticSnapshots(distDir, manifest, config) {
415
467
  continue;
416
468
  }
417
469
 
470
+ // Per-section og:image override (frontmatter) falls back to the site image
471
+ const pageOgImage = section.ogImage
472
+ ? resolveOgImage({ seo: { ogImage: section.ogImage } }, baseUrl)
473
+ : siteOgImage;
474
+
418
475
  // Generate static page
419
476
  const pageHtml = buildStaticPage({
420
477
  sectionId: section.id,
@@ -424,6 +481,7 @@ export async function generateStaticSnapshots(distDir, manifest, config) {
424
481
  contentHtml,
425
482
  siteTitle: config.title || 'Documentation',
426
483
  baseUrl,
484
+ ogImage: pageOgImage,
427
485
  config
428
486
  });
429
487
 
@@ -471,7 +529,7 @@ export async function readManifestFromDist(distDir) {
471
529
  */
472
530
  export async function generateLlmsTxt(distDir, manifest, config) {
473
531
  const seoConfig = config.seo || {};
474
- const baseUrl = seoConfig.siteUrl || '';
532
+ const baseUrl = resolveBaseUrl(config);
475
533
  const title = config.title || 'Documentation';
476
534
  const description = config.description || '';
477
535
 
@@ -543,6 +601,12 @@ export async function generateSeoArtifacts(distDir, config) {
543
601
  return;
544
602
  }
545
603
 
604
+ // Warn when neither seo.siteUrl nor domain is set — SEO output will use
605
+ // relative URLs (invalid sitemap <loc>, weak canonicals). See #15.
606
+ if (!resolveBaseUrl(config)) {
607
+ console.warn(` ⚠ SEO: no seo.siteUrl or domain set — sitemap/canonical URLs will be relative. Set "domain" or "seo.siteUrl" for absolute URLs.`);
608
+ }
609
+
546
610
  // Read manifest from generated file
547
611
  const manifest = await readManifestFromDist(distDir);
548
612
  if (!manifest) {
package/site/app.js CHANGED
@@ -1 +1 @@
1
- import{MANIFEST as e,DEFAULT_SECTION as t,findSection as n,getAdjacentSections as a,SITE_CONFIG as s,EXPORT_CONFIG as o}from"./manifest.js";import{updateMetaTags as i}from"./seo.js";import{escapeRegExp as l,searchContent as c,flattenManifest as r,findPreferredIndex as d}from"./lib/search.js";import{resolveTarget as m,resolveEntry as p}from"./lib/router.js";import{composeExportDocument as u,collectExportableSections as v}from"./lib/export.js";import{renderMermaidBlocks as h}from"./mermaid-init.js";import{highlightCodeBlocks as f}from"./syntax-highlight.js";const y=document.getElementById("app"),b=document.getElementById("nav"),g=document.getElementById("year"),E=document.getElementById("exportBtn"),L=document.getElementById("commandToggle"),T=document.getElementById("commandPalette"),x=document.getElementById("commandInput"),N=document.getElementById("commandList"),w=document.getElementById("mobileMenuToggle"),C=document.querySelector(".sidebar"),k="docs-toolkit-command-query",$=new Map,H=new Map,M=new Map,I=new Set;let A=[],S=0,P=!1,q=(localStorage.getItem(k)||"").trim(),B=!1;function R(e,t){const n=document.createElement("a");return n.href=e.url,n.target="_blank",n.rel="noopener noreferrer",n.className=`${t} nav-external`,n.title=e.summary||e.title,n.innerHTML=`\n <span class="nav-title">${e.title}<span class="nav-external-icon" aria-label="(opens in new tab)">↗</span></span>\n ${e.summary?`<span class="nav-summary">${e.summary}</span>`:""}\n `,n}function F(e,t={}){const{scrollToHighlight:a=!1}=t,{targetId:s,parentId:o}=function(e){return m(e,n)}(e);P&&_(),o&&j(o,!0),B=a||Boolean(q),location.hash.replace("#","")===s?O():location.hash=`#${s}`}function D(){return location.hash.replace("#","")||t}async function O(){const e=D(),t=p(e,n);if(!t)return;const{entry:o,targetId:l,parentId:c}=t;l===e?(c&&j(c,!0),function(e,t=null){H.forEach(e=>{e.setAttribute("aria-current","false")});const n=H.get(e);if(n&&n.setAttribute("aria-current","page"),t){const e=H.get(t);e&&e.setAttribute("aria-current","page")}}(o.id,c),await async function(e){if(!e)return;const t=await import(e.module),n=t.load||t.default;if("function"!=typeof n)return void(y.innerHTML='<article class="section"><p>Section failed to load.</p></article>');const o=await n();y.innerHTML=o.html||"",await h(y),await f(y),function(e){const t=y.querySelector(".bottom-nav");if(t&&t.remove(),"never"===s.bottomNav)return;const n=(s.bottomNavSections||[]).some(t=>e.startsWith(t)),o="mobile"===s.bottomNav&&!n,{prev:i,next:l}=a(e);if(!i&&!l)return;const c=document.createElement("nav");if(c.className="bottom-nav",o&&c.classList.add("mobile-only"),i){const e=document.createElement("div");e.className="bottom-nav-item bottom-nav-prev",e.innerHTML='<span class="bottom-nav-chevron">‹</span>';const t=document.createElement("a");t.href=`#${i.id}`,t.className="bottom-nav-link",t.title=`Previous: ${i.title}`,t.textContent=i.title,t.addEventListener("click",e=>{e.preventDefault(),F(i.id)}),e.appendChild(t),c.appendChild(e)}else{const e=document.createElement("div");e.className="bottom-nav-spacer",c.appendChild(e)}if(l){const e=document.createElement("div");e.className="bottom-nav-item bottom-nav-next";const t=document.createElement("a");t.href=`#${l.id}`,t.className="bottom-nav-link",t.title=`Next: ${l.title}`,t.textContent=l.title,t.addEventListener("click",e=>{e.preventDefault(),F(l.id)}),e.appendChild(t),e.innerHTML+='<span class="bottom-nav-chevron">›</span>',c.appendChild(e)}(y.querySelector("section")||y).appendChild(c)}(e.id),y.scrollTop=0,window.scrollTo(0,0),"function"==typeof o.afterRender&&o.afterRender(y),i({title:e.title,description:e.summary,siteTitle:s.siteTitle,siteUrl:s.siteUrl,sectionId:e.id}),$.set(e.id,Date.now());const l=B;B=!1,K(l),requestAnimationFrame(()=>y.focus())}(o)):location.replace(`#${l}`)}function j(e,t){if(!e)return;const n=M.get(e);t?(I.add(e),n&&n.group.classList.add("expanded")):(I.delete(e),n&&n.group.classList.remove("expanded"))}function W(){if(!T||!x)return;P=!0,T.hidden=!1;const e=q;x.value=e,z(e),requestAnimationFrame(()=>{x.focus(),e&&x.select()})}function _(){T&&x&&(P=!1,T.hidden=!0,x.blur())}!function(){b.innerHTML="",H.clear(),M.clear();let t=I.size>0;e.forEach((e,n)=>{if(e.url){const t=R(e,"nav-leaf");return void b.appendChild(t)}if(e.subsections&&e.subsections.length){const n=document.createElement("div");n.className="nav-group";const a=Boolean(e.module),s=document.createElement("button");s.type="button",s.className="nav-parent"+(a?" nav-parent-with-content":""),s.dataset.section=e.id,s.title=e.summary,a?(s.innerHTML=`\n <span class="nav-title-link">${e.title}</span>\n <span class="nav-expand-toggle" aria-label="Expand"></span>\n ${e.summary?`<span class="nav-summary">${e.summary}</span>`:""}\n `,s.querySelector(".nav-title-link").addEventListener("click",t=>{t.stopPropagation(),F(e.id,{scrollToHighlight:Boolean(q)})}),s.querySelector(".nav-expand-toggle").addEventListener("click",t=>{t.stopPropagation();const n=!I.has(e.id);j(e.id,n)}),s.addEventListener("click",t=>{if(t.target===s){const t=!I.has(e.id);j(e.id,t)}})):(s.innerHTML=`\n <span class="nav-title">${e.title}</span>\n ${e.summary?`<span class="nav-summary">${e.summary}</span>`:""}\n `,s.addEventListener("click",()=>{const t=!I.has(e.id);j(e.id,t)}));const o=document.createElement("div");o.className="nav-sublist",e.subsections.forEach(e=>{if(e.url){const t=R(e,"nav-item");return void o.appendChild(t)}if(e.subsections&&e.subsections.length){const t=document.createElement("div");t.className="nav-group nav-group-nested";const n=document.createElement("button");n.type="button",n.className="nav-parent nav-parent-nested",n.dataset.section=e.id,n.title=e.summary||e.title,n.innerHTML=`<span class="nav-title">${e.title}</span>`,n.addEventListener("click",()=>{const t=!I.has(e.id);j(e.id,t)});const a=document.createElement("div");a.className="nav-sublist nav-sublist-nested",e.subsections.forEach(e=>{if(e.url){const t=R(e,"nav-item");return void a.appendChild(t)}if(e.subsections&&e.subsections.length){const t=document.createElement("div");t.className="nav-group nav-group-deep";const n=document.createElement("button");n.type="button",n.className="nav-parent nav-parent-deep",n.dataset.section=e.id,n.title=e.summary||e.title,n.innerHTML=`<span class="nav-title">${e.title}</span>`,n.addEventListener("click",()=>{const t=!I.has(e.id);j(e.id,t)});const s=document.createElement("div");s.className="nav-sublist nav-sublist-deep",e.subsections.forEach(e=>{if(e.url){const t=R(e,"nav-item");return void s.appendChild(t)}if(e.subsections&&e.subsections.length){const t=document.createElement("div");t.className="nav-group nav-group-ultra";const n=document.createElement("button");n.type="button",n.className="nav-parent nav-parent-ultra",n.dataset.section=e.id,n.title=e.summary||e.title,n.innerHTML=`<span class="nav-title">${e.title}</span>`,n.addEventListener("click",()=>{const t=!I.has(e.id);j(e.id,t)});const a=document.createElement("div");a.className="nav-sublist nav-sublist-ultra",e.subsections.forEach(e=>{if(e.url){const t=R(e,"nav-item");return void a.appendChild(t)}const t=document.createElement("button");t.type="button",t.className="nav-item nav-item-ultra"+(e.type?` nav-type-${e.type}`:""),t.dataset.section=e.id,t.title=e.summary||e.title,t.innerHTML=`\n <span class="nav-title">${e.title}</span>\n <span class="nav-summary">${e.summary||""}</span>\n `,t.addEventListener("click",()=>F(e.id,{scrollToHighlight:Boolean(q)})),a.appendChild(t),H.set(e.id,t)}),t.append(n,a),s.appendChild(t),H.set(e.id,n),M.set(e.id,{group:t,button:n,list:a});const o=I.has(e.id)&&!e.collapsed;return void j(e.id,o)}const t=document.createElement("button");t.type="button",t.className="nav-item nav-item-deep"+(e.type?` nav-type-${e.type}`:""),t.dataset.section=e.id,t.title=e.summary||e.title,t.innerHTML=`\n <span class="nav-title">${e.title}${"press-release"===e.type?'<span class="nav-type-icon" aria-label="Press Release"></span>':""}</span>\n <span class="nav-summary">${e.summary||""}</span>\n `,t.addEventListener("click",()=>F(e.id,{scrollToHighlight:Boolean(q)})),s.appendChild(t),H.set(e.id,t)}),t.append(n,s),a.appendChild(t),H.set(e.id,n),M.set(e.id,{group:t,button:n,list:s});const o=I.has(e.id)&&!e.collapsed;return void j(e.id,o)}const t=document.createElement("button");t.type="button",t.className="nav-item nav-item-nested"+(e.type?` nav-type-${e.type}`:""),t.dataset.section=e.id,t.title=e.summary||e.title,t.innerHTML=`\n <span class="nav-title">${e.title}${"press-release"===e.type?'<span class="nav-type-icon" aria-label="Press Release"></span>':""}</span>\n <span class="nav-summary">${e.summary||""}</span>\n `,t.addEventListener("click",()=>F(e.id,{scrollToHighlight:Boolean(q)})),a.appendChild(t),H.set(e.id,t)}),t.append(n,a),o.appendChild(t),H.set(e.id,n),M.set(e.id,{group:t,button:n,list:a});const s=I.has(e.id)&&!e.collapsed;return void j(e.id,s)}const t=document.createElement("button");t.type="button",t.className="nav-item"+(e.type?` nav-type-${e.type}`:""),t.dataset.section=e.id,t.title=e.summary,t.innerHTML=`\n <span class="nav-title">${e.title}${"press-release"===e.type?'<span class="nav-type-icon" aria-label="Press Release"></span>':""}</span>\n <span class="nav-summary">${e.summary}</span>\n `,t.addEventListener("click",()=>F(e.id,{scrollToHighlight:Boolean(q)})),o.appendChild(t),H.set(e.id,t)}),n.append(s,o),b.appendChild(n),H.set(e.id,s),M.set(e.id,{group:n,button:s,list:o});const i=!e.collapsed&&(I.has(e.id)||!t&&!I.size);j(e.id,i),i&&(t=!0)}else{const t=document.createElement("button");t.type="button",t.className="nav-leaf"+(e.type?` nav-type-${e.type}`:""),t.dataset.section=e.id,t.title=e.summary,t.innerHTML=`\n <span class="nav-title">${e.title}${"press-release"===e.type?'<span class="nav-type-icon" aria-label="Press Release"></span>':""}</span>\n <span class="nav-summary">${e.summary}</span>\n `,t.addEventListener("click",()=>F(e.id,{scrollToHighlight:Boolean(q)})),b.appendChild(t),H.set(e.id,t)}})}(),q&&(B=!0),window.addEventListener("hashchange",()=>{q&&(B=!0),O()}),g.textContent=(new Date).getFullYear(),O(),L&&T&&x&&N&&(L.addEventListener("click",()=>{P?_():W()}),x.addEventListener("input",()=>{const e=x.value;J(e,!0),z(e)}),x.addEventListener("keydown",e=>{const t=A.length-1;if("ArrowDown"===e.key)e.preventDefault(),S=Math.min(t,S+1),X();else if("ArrowUp"===e.key)e.preventDefault(),S=Math.max(0,S-1),X();else if("Enter"===e.key){e.preventDefault();const t=A[S];t&&(J(x.value,!0),F(t.id,{scrollToHighlight:!0}),_())}else"Escape"===e.key&&(e.preventDefault(),_())}),N.addEventListener("click",e=>{const t=e.target.closest("[data-section]");if(!t)return;const n=t.dataset.section;n&&(J(x.value,!0),F(n,{scrollToHighlight:!0}),_())}),T.addEventListener("click",e=>{e.target===T&&_()}),window.addEventListener("keydown",e=>{const t=e.target,n=t&&("INPUT"===t.tagName||"TEXTAREA"===t.tagName||t.isContentEditable),a=e.metaKey||e.ctrlKey;"k"===e.key.toLowerCase()&&a||"/"===e.key&&!n?(e.preventDefault(),P?_():W()):"Escape"===e.key&&P&&(e.preventDefault(),_())}));let V=null,U=!1;async function z(t){N&&(!U&&t.trim()&&(U=!0,N.innerHTML='<li class="cmd-item cmd-loading">Indexing content...</li>'),clearTimeout(V),V=setTimeout(async()=>{A=await c(e,t);const n=D();S=d(A,n),function(){if(N){if(N.innerHTML="",!A.length){const e=document.createElement("li");return e.className="cmd-item",e.setAttribute("aria-selected","false"),e.textContent="No matches.",void N.appendChild(e)}A.forEach(e=>{const t=document.createElement("li");t.className="cmd-item",t.dataset.section=e.id,t.setAttribute("role","option");const n=document.createElement("span");if(n.className="cmd-item-title",n.textContent=e.title,e.group){const t=document.createElement("span");t.className="cmd-item-group",t.textContent=e.group,n.prepend(t)}const a=document.createElement("span");a.className="cmd-item-summary",a.textContent=e.summary||"",t.append(n,a),N.appendChild(t)})}}(),X(),U=!1},t.trim()?150:0))}function X(){N&&Array.from(N.children).forEach((e,t)=>{const n=t===S&&A.length;e.setAttribute("aria-selected",n?"true":"false"),n&&e.scrollIntoView({block:"nearest"})})}function G(e){const t=document.createElement("div");t.innerHTML=e,t.querySelectorAll("script").forEach(e=>e.remove()),t.querySelectorAll("button").forEach(e=>e.removeAttribute("onclick")),t.querySelectorAll("mark.hl").forEach(e=>{const t=document.createTextNode(e.textContent||"");e.replaceWith(t)});const n=t.querySelector("section");return n?n.innerHTML:t.innerHTML}function J(e,t=!1){q=e.trim(),t&&(q?localStorage.setItem(k,q):localStorage.removeItem(k)),K()}function K(e=!1){y&&function(e,t,{scrollToFirst:n=!1}={}){if(!e)return;if(function(e){e&&e.querySelectorAll("mark.hl").forEach(e=>{const t=document.createTextNode(e.textContent||"");e.replaceWith(t)})}(e),!t)return;const a=t.split(/\s+/).map(e=>e.trim()).filter(Boolean);if(!a.length)return;const s=a.map(e=>e.toLowerCase()),o=new Set(["SCRIPT","STYLE","CODE","PRE"]),i=document.createTreeWalker(e,NodeFilter.SHOW_TEXT,{acceptNode(e){if(!e.nodeValue||!e.nodeValue.trim())return NodeFilter.FILTER_REJECT;const t=e.parentNode;return t&&o.has(t.tagName)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT}}),c=[];let r;for(;r=i.nextNode();){const e=r.nodeValue.toLowerCase();s.some(t=>e.includes(t))&&c.push(r)}const d=new RegExp(`(${a.map(l).join("|")})`,"gi");c.forEach(e=>{const t=e.nodeValue,n=[];let a=0;t.replace(d,(e,s,o)=>{o>a&&n.push(document.createTextNode(t.slice(a,o)));const i=document.createElement("mark");return i.className="hl",i.textContent=e,n.push(i),a=o+e.length,e}),a<t.length&&n.push(document.createTextNode(t.slice(a)));const s=document.createDocumentFragment();n.forEach(e=>s.appendChild(e)),e.parentNode.replaceChild(s,e)}),n&&requestAnimationFrame(()=>{const t=e.querySelector("mark.hl");t&&t.scrollIntoView({behavior:"smooth",block:"center"})})}(y,q,{scrollToFirst:e})}E&&E.addEventListener("click",function(){const t=document.createElement("div");t.className="export-options-overlay",t.innerHTML='\n <div class="export-options-modal">\n <div class="export-options-header">EXPORT OPTIONS</div>\n <div class="export-options-buttons">\n <button type="button" class="export-option-btn" data-scope="page">\n <span class="export-option-title">Current Page</span>\n <span class="export-option-desc">Export only this section</span>\n </button>\n <button type="button" class="export-option-btn" data-scope="site">\n <span class="export-option-title">Entire Site</span>\n <span class="export-option-desc">Export all documentation</span>\n </button>\n </div>\n <button type="button" class="export-cancel-btn">Cancel</button>\n </div>\n ',document.body.appendChild(t),setTimeout(()=>t.classList.add("active"),10);const n=()=>{t.classList.remove("active"),setTimeout(()=>t.remove(),200)};t.querySelector(".export-cancel-btn").addEventListener("click",n),t.addEventListener("click",e=>{e.target===t&&n()}),t.querySelectorAll(".export-option-btn").forEach(t=>{t.addEventListener("click",()=>{const a=t.dataset.scope;n(),async function(t="site"){if(!E)return;const n=E.innerHTML,a=window.open("","_blank","width=1,height=1,left=0,top=0");if(!a||a.closed||void 0===a.closed)return void confirm("Pop-ups are blocked. Please allow pop-ups for this site to export the document.\n\nWould you like to try again after enabling pop-ups?");a.close(),E.disabled=!0;const s=document.createElement("div");s.className="export-loading-overlay",s.innerHTML='\n <div class="export-loading-modal">\n <div class="export-loading-header">\n <div class="export-loading-title">COMPILING DOCUMENTATION</div>\n <div class="export-loading-subtitle">Assembling all sections into unified document</div>\n </div>\n <div class="export-loading-progress">\n <div class="export-loading-bar">\n <div class="export-loading-fill"></div>\n </div>\n <div class="export-loading-status-container">\n <div class="export-loading-status">Initializing...</div>\n </div>\n </div>\n <div class="export-loading-scanner">\n <div class="scanner-line"></div>\n </div>\n </div>\n ',document.body.appendChild(s),setTimeout(()=>s.classList.add("active"),10);const i=s.querySelector(".export-loading-fill"),l=s.querySelector(".export-loading-status");try{let n;if("page"===t){const t=D(),a=v(e).find(e=>e.id===t);n=a?[a]:[]}else n=v(e);if(0===n.length)return alert("No content available to export."),s.remove(),void(E.disabled=!1);const a=[],c=n.length;let r=0;for(const e of n){r++;const n=r/c*100;i.style.width=`${n}%`,l.textContent="page"===t?`Exporting: ${e.title}`:`Processing section ${r} of ${c}: ${e.title}`,await new Promise(e=>setTimeout(e,50));try{const t=await import(e.module),n=t.load||t.default;if("function"!=typeof n)continue;const s=G((await n()).html||"");a.push({section:e,html:s})}catch(t){console.error("Failed to include section in export",e.id,t)}}l.textContent="Generating document...",await new Promise(e=>setTimeout(e,200));const d=u(a,o);l.textContent="Opening document viewer...",await new Promise(e=>setTimeout(e,100));const m=window.open("","_blank","width=900,height=860,scrollbars=yes,resizable=yes");if(!m)return alert("Please allow pop-ups to export the document."),void s.remove();m.document.open(),m.document.write(d),m.document.close(),m.focus(),s.classList.remove("active"),setTimeout(()=>s.remove(),300)}catch(e){console.error("Export failed",e),alert("Export failed. Check console for details."),s.remove()}finally{E.disabled=!1,E.innerHTML=n}}(a)})})}),w&&C&&(w.addEventListener("click",()=>{C.classList.contains("mobile-open")?(C.classList.remove("mobile-open"),document.body.classList.remove("menu-open"),w.setAttribute("aria-expanded","false")):(C.classList.add("mobile-open"),document.body.classList.add("menu-open"),w.setAttribute("aria-expanded","true"))}),b.addEventListener("click",e=>{if(window.innerWidth<=960){const t=e.target.closest(".nav-item, .nav-leaf, .nav-parent");t&&(t.classList.contains("nav-item")||t.classList.contains("nav-leaf"))&&(C.classList.remove("mobile-open"),document.body.classList.remove("menu-open"),w.setAttribute("aria-expanded","false"))}}),document.addEventListener("click",e=>{window.innerWidth<=960&&C.classList.contains("mobile-open")&&!C.contains(e.target)&&!w.contains(e.target)&&(C.classList.remove("mobile-open"),document.body.classList.remove("menu-open"),w.setAttribute("aria-expanded","false"))}));
1
+ import{MANIFEST as e,DEFAULT_SECTION as t,findSection as n,getAdjacentSections as a,SITE_CONFIG as s,EXPORT_CONFIG as o}from"./manifest.js";import{updateMetaTags as i}from"./seo.js";import{escapeRegExp as l,searchContent as c,flattenManifest as r,findPreferredIndex as d}from"./lib/search.js";import{resolveTarget as m,resolveEntry as p}from"./lib/router.js";import{composeExportDocument as u,collectExportableSections as v}from"./lib/export.js";import{renderMermaidBlocks as h}from"./mermaid-init.js";import{highlightCodeBlocks as f}from"./syntax-highlight.js";const y=document.getElementById("app"),g=document.getElementById("nav"),b=document.getElementById("year"),E=document.getElementById("exportBtn"),L=document.getElementById("commandToggle"),T=document.getElementById("commandPalette"),x=document.getElementById("commandInput"),N=document.getElementById("commandList"),w=document.getElementById("mobileMenuToggle"),C=document.querySelector(".sidebar"),k="docs-toolkit-command-query",$=new Map,H=new Map,I=new Map,M=new Set;let A=[],S=0,P=!1,q=(localStorage.getItem(k)||"").trim(),B=!1;function R(e,t){const n=document.createElement("a");return n.href=e.url,n.target="_blank",n.rel="noopener noreferrer",n.className=`${t} nav-external`,n.title=e.summary||e.title,n.innerHTML=`\n <span class="nav-title">${e.title}<span class="nav-external-icon" aria-label="(opens in new tab)">↗</span></span>\n ${e.summary?`<span class="nav-summary">${e.summary}</span>`:""}\n `,n}function F(e,t={}){const{scrollToHighlight:a=!1}=t,{targetId:s,parentId:o}=function(e){return m(e,n)}(e);P&&_(),o&&j(o,!0),B=a||Boolean(q),location.hash.replace("#","")===s?O():location.hash=`#${s}`}function D(){return location.hash.replace("#","")||t}async function O(){const e=D(),t=p(e,n);if(!t)return;const{entry:o,targetId:l,parentId:c}=t;l===e?(c&&j(c,!0),function(e,t=null){H.forEach(e=>{e.setAttribute("aria-current","false")});const n=H.get(e);if(n&&n.setAttribute("aria-current","page"),t){const e=H.get(t);e&&e.setAttribute("aria-current","page")}}(o.id,c),await async function(e){if(!e)return;const t=await import(e.module),n=t.load||t.default;if("function"!=typeof n)return void(y.innerHTML='<article class="section"><p>Section failed to load.</p></article>');const o=await n();y.innerHTML=o.html||"",await h(y),await f(y),function(e){const t=y.querySelector(".bottom-nav");if(t&&t.remove(),"never"===s.bottomNav)return;const n=(s.bottomNavSections||[]).some(t=>e.startsWith(t)),o="mobile"===s.bottomNav&&!n,{prev:i,next:l}=a(e);if(!i&&!l)return;const c=document.createElement("nav");if(c.className="bottom-nav",o&&c.classList.add("mobile-only"),i){const e=document.createElement("div");e.className="bottom-nav-item bottom-nav-prev",e.innerHTML='<span class="bottom-nav-chevron">‹</span>';const t=document.createElement("a");t.href=`#${i.id}`,t.className="bottom-nav-link",t.title=`Previous: ${i.title}`,t.textContent=i.title,t.addEventListener("click",e=>{e.preventDefault(),F(i.id)}),e.appendChild(t),c.appendChild(e)}else{const e=document.createElement("div");e.className="bottom-nav-spacer",c.appendChild(e)}if(l){const e=document.createElement("div");e.className="bottom-nav-item bottom-nav-next";const t=document.createElement("a");t.href=`#${l.id}`,t.className="bottom-nav-link",t.title=`Next: ${l.title}`,t.textContent=l.title,t.addEventListener("click",e=>{e.preventDefault(),F(l.id)}),e.appendChild(t),e.innerHTML+='<span class="bottom-nav-chevron">›</span>',c.appendChild(e)}(y.querySelector("section")||y).appendChild(c)}(e.id),y.scrollTop=0,window.scrollTo(0,0),"function"==typeof o.afterRender&&o.afterRender(y),i({title:e.title,description:e.summary,siteTitle:s.siteTitle,siteUrl:s.siteUrl,sectionId:e.id,ogImage:e.ogImage||s.ogImage}),$.set(e.id,Date.now());const l=B;B=!1,K(l),requestAnimationFrame(()=>y.focus())}(o)):location.replace(`#${l}`)}function j(e,t){if(!e)return;const n=I.get(e);t?(M.add(e),n&&n.group.classList.add("expanded")):(M.delete(e),n&&n.group.classList.remove("expanded"))}function W(){if(!T||!x)return;P=!0,T.hidden=!1;const e=q;x.value=e,z(e),requestAnimationFrame(()=>{x.focus(),e&&x.select()})}function _(){T&&x&&(P=!1,T.hidden=!0,x.blur())}!function(){g.innerHTML="",H.clear(),I.clear();let t=M.size>0;e.forEach((e,n)=>{if(e.url){const t=R(e,"nav-leaf");return void g.appendChild(t)}if(e.subsections&&e.subsections.length){const n=document.createElement("div");n.className="nav-group";const a=Boolean(e.module),s=document.createElement("button");s.type="button",s.className="nav-parent"+(a?" nav-parent-with-content":""),s.dataset.section=e.id,s.title=e.summary,a?(s.innerHTML=`\n <span class="nav-title-link">${e.title}</span>\n <span class="nav-expand-toggle" aria-label="Expand"></span>\n ${e.summary?`<span class="nav-summary">${e.summary}</span>`:""}\n `,s.querySelector(".nav-title-link").addEventListener("click",t=>{t.stopPropagation(),F(e.id,{scrollToHighlight:Boolean(q)})}),s.querySelector(".nav-expand-toggle").addEventListener("click",t=>{t.stopPropagation();const n=!M.has(e.id);j(e.id,n)}),s.addEventListener("click",t=>{if(t.target===s){const t=!M.has(e.id);j(e.id,t)}})):(s.innerHTML=`\n <span class="nav-title">${e.title}</span>\n ${e.summary?`<span class="nav-summary">${e.summary}</span>`:""}\n `,s.addEventListener("click",()=>{const t=!M.has(e.id);j(e.id,t)}));const o=document.createElement("div");o.className="nav-sublist",e.subsections.forEach(e=>{if(e.url){const t=R(e,"nav-item");return void o.appendChild(t)}if(e.subsections&&e.subsections.length){const t=document.createElement("div");t.className="nav-group nav-group-nested";const n=document.createElement("button");n.type="button",n.className="nav-parent nav-parent-nested",n.dataset.section=e.id,n.title=e.summary||e.title,n.innerHTML=`<span class="nav-title">${e.title}</span>`,n.addEventListener("click",()=>{const t=!M.has(e.id);j(e.id,t)});const a=document.createElement("div");a.className="nav-sublist nav-sublist-nested",e.subsections.forEach(e=>{if(e.url){const t=R(e,"nav-item");return void a.appendChild(t)}if(e.subsections&&e.subsections.length){const t=document.createElement("div");t.className="nav-group nav-group-deep";const n=document.createElement("button");n.type="button",n.className="nav-parent nav-parent-deep",n.dataset.section=e.id,n.title=e.summary||e.title,n.innerHTML=`<span class="nav-title">${e.title}</span>`,n.addEventListener("click",()=>{const t=!M.has(e.id);j(e.id,t)});const s=document.createElement("div");s.className="nav-sublist nav-sublist-deep",e.subsections.forEach(e=>{if(e.url){const t=R(e,"nav-item");return void s.appendChild(t)}if(e.subsections&&e.subsections.length){const t=document.createElement("div");t.className="nav-group nav-group-ultra";const n=document.createElement("button");n.type="button",n.className="nav-parent nav-parent-ultra",n.dataset.section=e.id,n.title=e.summary||e.title,n.innerHTML=`<span class="nav-title">${e.title}</span>`,n.addEventListener("click",()=>{const t=!M.has(e.id);j(e.id,t)});const a=document.createElement("div");a.className="nav-sublist nav-sublist-ultra",e.subsections.forEach(e=>{if(e.url){const t=R(e,"nav-item");return void a.appendChild(t)}const t=document.createElement("button");t.type="button",t.className="nav-item nav-item-ultra"+(e.type?` nav-type-${e.type}`:""),t.dataset.section=e.id,t.title=e.summary||e.title,t.innerHTML=`\n <span class="nav-title">${e.title}</span>\n <span class="nav-summary">${e.summary||""}</span>\n `,t.addEventListener("click",()=>F(e.id,{scrollToHighlight:Boolean(q)})),a.appendChild(t),H.set(e.id,t)}),t.append(n,a),s.appendChild(t),H.set(e.id,n),I.set(e.id,{group:t,button:n,list:a});const o=M.has(e.id)&&!e.collapsed;return void j(e.id,o)}const t=document.createElement("button");t.type="button",t.className="nav-item nav-item-deep"+(e.type?` nav-type-${e.type}`:""),t.dataset.section=e.id,t.title=e.summary||e.title,t.innerHTML=`\n <span class="nav-title">${e.title}${"press-release"===e.type?'<span class="nav-type-icon" aria-label="Press Release"></span>':""}</span>\n <span class="nav-summary">${e.summary||""}</span>\n `,t.addEventListener("click",()=>F(e.id,{scrollToHighlight:Boolean(q)})),s.appendChild(t),H.set(e.id,t)}),t.append(n,s),a.appendChild(t),H.set(e.id,n),I.set(e.id,{group:t,button:n,list:s});const o=M.has(e.id)&&!e.collapsed;return void j(e.id,o)}const t=document.createElement("button");t.type="button",t.className="nav-item nav-item-nested"+(e.type?` nav-type-${e.type}`:""),t.dataset.section=e.id,t.title=e.summary||e.title,t.innerHTML=`\n <span class="nav-title">${e.title}${"press-release"===e.type?'<span class="nav-type-icon" aria-label="Press Release"></span>':""}</span>\n <span class="nav-summary">${e.summary||""}</span>\n `,t.addEventListener("click",()=>F(e.id,{scrollToHighlight:Boolean(q)})),a.appendChild(t),H.set(e.id,t)}),t.append(n,a),o.appendChild(t),H.set(e.id,n),I.set(e.id,{group:t,button:n,list:a});const s=M.has(e.id)&&!e.collapsed;return void j(e.id,s)}const t=document.createElement("button");t.type="button",t.className="nav-item"+(e.type?` nav-type-${e.type}`:""),t.dataset.section=e.id,t.title=e.summary,t.innerHTML=`\n <span class="nav-title">${e.title}${"press-release"===e.type?'<span class="nav-type-icon" aria-label="Press Release"></span>':""}</span>\n <span class="nav-summary">${e.summary}</span>\n `,t.addEventListener("click",()=>F(e.id,{scrollToHighlight:Boolean(q)})),o.appendChild(t),H.set(e.id,t)}),n.append(s,o),g.appendChild(n),H.set(e.id,s),I.set(e.id,{group:n,button:s,list:o});const i=!e.collapsed&&(M.has(e.id)||!t&&!M.size);j(e.id,i),i&&(t=!0)}else{const t=document.createElement("button");t.type="button",t.className="nav-leaf"+(e.type?` nav-type-${e.type}`:""),t.dataset.section=e.id,t.title=e.summary,t.innerHTML=`\n <span class="nav-title">${e.title}${"press-release"===e.type?'<span class="nav-type-icon" aria-label="Press Release"></span>':""}</span>\n <span class="nav-summary">${e.summary}</span>\n `,t.addEventListener("click",()=>F(e.id,{scrollToHighlight:Boolean(q)})),g.appendChild(t),H.set(e.id,t)}})}(),q&&(B=!0),window.addEventListener("hashchange",()=>{q&&(B=!0),O()}),b.textContent=(new Date).getFullYear(),O(),L&&T&&x&&N&&(L.addEventListener("click",()=>{P?_():W()}),x.addEventListener("input",()=>{const e=x.value;J(e,!0),z(e)}),x.addEventListener("keydown",e=>{const t=A.length-1;if("ArrowDown"===e.key)e.preventDefault(),S=Math.min(t,S+1),X();else if("ArrowUp"===e.key)e.preventDefault(),S=Math.max(0,S-1),X();else if("Enter"===e.key){e.preventDefault();const t=A[S];t&&(J(x.value,!0),F(t.id,{scrollToHighlight:!0}),_())}else"Escape"===e.key&&(e.preventDefault(),_())}),N.addEventListener("click",e=>{const t=e.target.closest("[data-section]");if(!t)return;const n=t.dataset.section;n&&(J(x.value,!0),F(n,{scrollToHighlight:!0}),_())}),T.addEventListener("click",e=>{e.target===T&&_()}),window.addEventListener("keydown",e=>{const t=e.target,n=t&&("INPUT"===t.tagName||"TEXTAREA"===t.tagName||t.isContentEditable),a=e.metaKey||e.ctrlKey;"k"===e.key.toLowerCase()&&a||"/"===e.key&&!n?(e.preventDefault(),P?_():W()):"Escape"===e.key&&P&&(e.preventDefault(),_())}));let V=null,U=!1;async function z(t){N&&(!U&&t.trim()&&(U=!0,N.innerHTML='<li class="cmd-item cmd-loading">Indexing content...</li>'),clearTimeout(V),V=setTimeout(async()=>{A=await c(e,t);const n=D();S=d(A,n),function(){if(N){if(N.innerHTML="",!A.length){const e=document.createElement("li");return e.className="cmd-item",e.setAttribute("aria-selected","false"),e.textContent="No matches.",void N.appendChild(e)}A.forEach(e=>{const t=document.createElement("li");t.className="cmd-item",t.dataset.section=e.id,t.setAttribute("role","option");const n=document.createElement("span");if(n.className="cmd-item-title",n.textContent=e.title,e.group){const t=document.createElement("span");t.className="cmd-item-group",t.textContent=e.group,n.prepend(t)}const a=document.createElement("span");a.className="cmd-item-summary",a.textContent=e.summary||"",t.append(n,a),N.appendChild(t)})}}(),X(),U=!1},t.trim()?150:0))}function X(){N&&Array.from(N.children).forEach((e,t)=>{const n=t===S&&A.length;e.setAttribute("aria-selected",n?"true":"false"),n&&e.scrollIntoView({block:"nearest"})})}function G(e){const t=document.createElement("div");t.innerHTML=e,t.querySelectorAll("script").forEach(e=>e.remove()),t.querySelectorAll("button").forEach(e=>e.removeAttribute("onclick")),t.querySelectorAll("mark.hl").forEach(e=>{const t=document.createTextNode(e.textContent||"");e.replaceWith(t)});const n=t.querySelector("section");return n?n.innerHTML:t.innerHTML}function J(e,t=!1){q=e.trim(),t&&(q?localStorage.setItem(k,q):localStorage.removeItem(k)),K()}function K(e=!1){y&&function(e,t,{scrollToFirst:n=!1}={}){if(!e)return;if(function(e){e&&e.querySelectorAll("mark.hl").forEach(e=>{const t=document.createTextNode(e.textContent||"");e.replaceWith(t)})}(e),!t)return;const a=t.split(/\s+/).map(e=>e.trim()).filter(Boolean);if(!a.length)return;const s=a.map(e=>e.toLowerCase()),o=new Set(["SCRIPT","STYLE","CODE","PRE"]),i=document.createTreeWalker(e,NodeFilter.SHOW_TEXT,{acceptNode(e){if(!e.nodeValue||!e.nodeValue.trim())return NodeFilter.FILTER_REJECT;const t=e.parentNode;return t&&o.has(t.tagName)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT}}),c=[];let r;for(;r=i.nextNode();){const e=r.nodeValue.toLowerCase();s.some(t=>e.includes(t))&&c.push(r)}const d=new RegExp(`(${a.map(l).join("|")})`,"gi");c.forEach(e=>{const t=e.nodeValue,n=[];let a=0;t.replace(d,(e,s,o)=>{o>a&&n.push(document.createTextNode(t.slice(a,o)));const i=document.createElement("mark");return i.className="hl",i.textContent=e,n.push(i),a=o+e.length,e}),a<t.length&&n.push(document.createTextNode(t.slice(a)));const s=document.createDocumentFragment();n.forEach(e=>s.appendChild(e)),e.parentNode.replaceChild(s,e)}),n&&requestAnimationFrame(()=>{const t=e.querySelector("mark.hl");t&&t.scrollIntoView({behavior:"smooth",block:"center"})})}(y,q,{scrollToFirst:e})}E&&E.addEventListener("click",function(){const t=document.createElement("div");t.className="export-options-overlay",t.innerHTML='\n <div class="export-options-modal">\n <div class="export-options-header">EXPORT OPTIONS</div>\n <div class="export-options-buttons">\n <button type="button" class="export-option-btn" data-scope="page">\n <span class="export-option-title">Current Page</span>\n <span class="export-option-desc">Export only this section</span>\n </button>\n <button type="button" class="export-option-btn" data-scope="site">\n <span class="export-option-title">Entire Site</span>\n <span class="export-option-desc">Export all documentation</span>\n </button>\n </div>\n <button type="button" class="export-cancel-btn">Cancel</button>\n </div>\n ',document.body.appendChild(t),setTimeout(()=>t.classList.add("active"),10);const n=()=>{t.classList.remove("active"),setTimeout(()=>t.remove(),200)};t.querySelector(".export-cancel-btn").addEventListener("click",n),t.addEventListener("click",e=>{e.target===t&&n()}),t.querySelectorAll(".export-option-btn").forEach(t=>{t.addEventListener("click",()=>{const a=t.dataset.scope;n(),async function(t="site"){if(!E)return;const n=E.innerHTML,a=window.open("","_blank","width=1,height=1,left=0,top=0");if(!a||a.closed||void 0===a.closed)return void confirm("Pop-ups are blocked. Please allow pop-ups for this site to export the document.\n\nWould you like to try again after enabling pop-ups?");a.close(),E.disabled=!0;const s=document.createElement("div");s.className="export-loading-overlay",s.innerHTML='\n <div class="export-loading-modal">\n <div class="export-loading-header">\n <div class="export-loading-title">COMPILING DOCUMENTATION</div>\n <div class="export-loading-subtitle">Assembling all sections into unified document</div>\n </div>\n <div class="export-loading-progress">\n <div class="export-loading-bar">\n <div class="export-loading-fill"></div>\n </div>\n <div class="export-loading-status-container">\n <div class="export-loading-status">Initializing...</div>\n </div>\n </div>\n <div class="export-loading-scanner">\n <div class="scanner-line"></div>\n </div>\n </div>\n ',document.body.appendChild(s),setTimeout(()=>s.classList.add("active"),10);const i=s.querySelector(".export-loading-fill"),l=s.querySelector(".export-loading-status");try{let n;if("page"===t){const t=D(),a=v(e).find(e=>e.id===t);n=a?[a]:[]}else n=v(e);if(0===n.length)return alert("No content available to export."),s.remove(),void(E.disabled=!1);const a=[],c=n.length;let r=0;for(const e of n){r++;const n=r/c*100;i.style.width=`${n}%`,l.textContent="page"===t?`Exporting: ${e.title}`:`Processing section ${r} of ${c}: ${e.title}`,await new Promise(e=>setTimeout(e,50));try{const t=await import(e.module),n=t.load||t.default;if("function"!=typeof n)continue;const s=G((await n()).html||"");a.push({section:e,html:s})}catch(t){console.error("Failed to include section in export",e.id,t)}}l.textContent="Generating document...",await new Promise(e=>setTimeout(e,200));const d=u(a,o);l.textContent="Opening document viewer...",await new Promise(e=>setTimeout(e,100));const m=window.open("","_blank","width=900,height=860,scrollbars=yes,resizable=yes");if(!m)return alert("Please allow pop-ups to export the document."),void s.remove();m.document.open(),m.document.write(d),m.document.close(),m.focus(),s.classList.remove("active"),setTimeout(()=>s.remove(),300)}catch(e){console.error("Export failed",e),alert("Export failed. Check console for details."),s.remove()}finally{E.disabled=!1,E.innerHTML=n}}(a)})})}),w&&C&&(w.addEventListener("click",()=>{C.classList.contains("mobile-open")?(C.classList.remove("mobile-open"),document.body.classList.remove("menu-open"),w.setAttribute("aria-expanded","false")):(C.classList.add("mobile-open"),document.body.classList.add("menu-open"),w.setAttribute("aria-expanded","true"))}),g.addEventListener("click",e=>{if(window.innerWidth<=960){const t=e.target.closest(".nav-item, .nav-leaf, .nav-parent");t&&(t.classList.contains("nav-item")||t.classList.contains("nav-leaf"))&&(C.classList.remove("mobile-open"),document.body.classList.remove("menu-open"),w.setAttribute("aria-expanded","false"))}}),document.addEventListener("click",e=>{window.innerWidth<=960&&C.classList.contains("mobile-open")&&!C.contains(e.target)&&!w.contains(e.target)&&(C.classList.remove("mobile-open"),document.body.classList.remove("menu-open"),w.setAttribute("aria-expanded","false"))}));
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-26T00:23:59.591Z" />
10
+ <meta name="x-build" content="2026-05-27T05:01:47.803Z" />
11
11
  </head>
12
12
  <body>
13
13
  <a class="skip-link" href="#app">Skip to content</a>