@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 +56 -34
- package/package.json +1 -1
- package/scripts/build-tenants.js +53 -17
- package/scripts/lib/seo-generator.js +78 -14
- package/site/app.js +1 -1
- package/site/index.html +1 -1
- package/site/llms.txt +9 -9
- package/site/pages/api.html +16 -12
- package/site/pages/architecture.html +16 -12
- package/site/pages/deployment.html +16 -12
- package/site/pages/developer-guide.html +16 -12
- package/site/pages/extending.html +16 -12
- package/site/pages/quickstart.html +50 -38
- package/site/pages/seo-strategy.html +47 -26
- package/site/pages/tenant-config.html +47 -13
- package/site/pages/welcome.html +15 -11
- package/site/robots.txt +2 -2
- package/site/sections/quickstart.js +1 -1
- package/site/sections/seo-strategy.js +1 -1
- package/site/sections/tenant-config.js +1 -1
- package/site/seo.js +1 -1
- package/site/sitemap.xml +20 -20
- package/src/app.js +2 -1
- package/src/seo.js +28 -7
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
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
#
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
npm run
|
|
248
|
-
|
|
249
|
-
#
|
|
250
|
-
npm run
|
|
251
|
-
npm run check
|
|
252
|
-
npm
|
|
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
|
-
"
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
276
|
-
- **Git
|
|
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
package/scripts/build-tenants.js
CHANGED
|
@@ -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
|
-
|
|
742
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
157
|
-
|
|
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 =
|
|
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
|
-
|
|
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="
|
|
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('${
|
|
363
|
+
window.location.replace('${spaUrl}');
|
|
313
364
|
}
|
|
314
365
|
</script>
|
|
315
366
|
<noscript>
|
|
316
|
-
<meta http-equiv="refresh" content="0; url=${
|
|
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="${
|
|
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 =
|
|
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 =
|
|
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-
|
|
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>
|