@twango/twiki 0.0.0-stage → 0.1.1
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/.github/actions/product-cache/action.yml +32 -0
- package/.github/actions/product-cache/index.mjs +450 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/bun.lock +1701 -0
- package/cli/runtime.test.ts +420 -0
- package/cli/runtime.ts +385 -0
- package/cli/twiki.ts +305 -0
- package/package.json +138 -4
- package/site/_headers +7 -0
- package/site/assets/fonts/OFL.txt +93 -0
- package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
- package/site/assets/social-background.svg +38 -0
- package/site/bun.lock +1701 -0
- package/site/database/index.ts +7 -0
- package/site/database/schema.ts +95 -0
- package/site/drizzle.config.ts +7 -0
- package/site/migrations/0001_auth.sql +14 -0
- package/site/migrations/0002_site_access.sql +5 -0
- package/site/migrations/0003_github_username.sql +1 -0
- package/site/migrations/meta/0002_snapshot.json +432 -0
- package/site/migrations/meta/0003_snapshot.json +439 -0
- package/site/migrations/meta/_journal.json +27 -0
- package/site/package.json +120 -0
- package/site/src/app.d.ts +14 -0
- package/site/src/app.html +11 -0
- package/site/src/hooks.server.ts +12 -0
- package/site/src/lib/analytics-config.json +1 -0
- package/site/src/lib/analytics.ts +40 -0
- package/site/src/lib/auth-config.json +3 -0
- package/site/src/lib/components/SEO.svelte +113 -0
- package/site/src/lib/components/doc/AccessLink.svelte +27 -0
- package/site/src/lib/components/doc/Backlinks.svelte +59 -0
- package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
- package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
- package/site/src/lib/components/doc/DocPager.svelte +91 -0
- package/site/src/lib/components/doc/DocShell.svelte +115 -0
- package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
- package/site/src/lib/components/doc/LockedContent.svelte +29 -0
- package/site/src/lib/components/doc/NavTree.svelte +158 -0
- package/site/src/lib/components/doc/NoteActions.svelte +139 -0
- package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
- package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
- package/site/src/lib/components/doc/Sidebar.svelte +351 -0
- package/site/src/lib/components/doc/Toc.svelte +339 -0
- package/site/src/lib/components/doc/enhancements.ts +146 -0
- package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
- package/site/src/lib/components/doc/image-preview.ts +114 -0
- package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
- package/site/src/lib/components/embeds/mermaid.ts +353 -0
- package/site/src/lib/components/files/FileCode.svelte +81 -0
- package/site/src/lib/components/files/FileExplorer.svelte +74 -0
- package/site/src/lib/components/files/FileHistory.svelte +170 -0
- package/site/src/lib/components/files/FileIcon.svelte +28 -0
- package/site/src/lib/components/files/FileRmd.svelte +209 -0
- package/site/src/lib/components/files/FileTabs.svelte +180 -0
- package/site/src/lib/components/files/FileTree.svelte +67 -0
- package/site/src/lib/components/files/blame-gutter.ts +45 -0
- package/site/src/lib/components/files/code-view.ts +98 -0
- package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
- package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
- package/site/src/lib/components/graph/GraphView.svelte +100 -0
- package/site/src/lib/components/graph/graph-data.ts +137 -0
- package/site/src/lib/components/graph/graph-model.ts +103 -0
- package/site/src/lib/components/graph/index.ts +3 -0
- package/site/src/lib/components/graph/render-graph.ts +626 -0
- package/site/src/lib/components/popover/index.ts +1 -0
- package/site/src/lib/components/popover/link-popovers.ts +233 -0
- package/site/src/lib/components/popover/popover.css +82 -0
- package/site/src/lib/components/search/SearchButton.svelte +57 -0
- package/site/src/lib/components/search/SearchPalette.svelte +189 -0
- package/site/src/lib/components/search/index.ts +3 -0
- package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
- package/site/src/lib/components/search/pagefind-query.ts +22 -0
- package/site/src/lib/components/search/pagefind-url.ts +12 -0
- package/site/src/lib/components/search/search-state.svelte.ts +22 -0
- package/site/src/lib/components/ui/IconButton.svelte +30 -0
- package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
- package/site/src/lib/components/ui/index.ts +1 -0
- package/site/src/lib/config.ts +9 -0
- package/site/src/lib/content-license.ts +7 -0
- package/site/src/lib/file-history.ts +37 -0
- package/site/src/lib/file-icons.ts +27 -0
- package/site/src/lib/file-tabs.ts +72 -0
- package/site/src/lib/files.ts +73 -0
- package/site/src/lib/github-user.ts +70 -0
- package/site/src/lib/metadata.ts +166 -0
- package/site/src/lib/notebook-nav.ts +63 -0
- package/site/src/lib/publication.ts +1 -0
- package/site/src/lib/rmd.ts +156 -0
- package/site/src/lib/server/access-admin.ts +33 -0
- package/site/src/lib/server/content-model.ts +253 -0
- package/site/src/lib/server/content-provider.ts +266 -0
- package/site/src/lib/server/content.ts +277 -0
- package/site/src/lib/server/runtime-content.ts +34 -0
- package/site/src/lib/site-config.json +9 -0
- package/site/src/lib/social-image.ts +13 -0
- package/site/src/lib/styles/article.css +607 -0
- package/site/src/lib/styles/foundation.css +138 -0
- package/site/src/lib/styles/print.css +115 -0
- package/site/src/lib/styles/shell.css +182 -0
- package/site/src/lib/text-exports.ts +5 -0
- package/site/src/lib/types.ts +140 -0
- package/site/src/lib/utils.ts +6 -0
- package/site/src/routes/+error.svelte +16 -0
- package/site/src/routes/+layout.server.ts +10 -0
- package/site/src/routes/+layout.svelte +72 -0
- package/site/src/routes/+layout.ts +3 -0
- package/site/src/routes/404/+page.svelte +13 -0
- package/site/src/routes/[...slug]/+page.server.ts +24 -0
- package/site/src/routes/[...slug]/+page.svelte +180 -0
- package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
- package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
- package/site/src/routes/admin/access/+page.server.ts +53 -0
- package/site/src/routes/admin/access/+page.svelte +118 -0
- package/site/src/routes/graph.json/+server.ts +7 -0
- package/site/src/routes/index.xml/+server.ts +7 -0
- package/site/src/routes/layout.css +10 -0
- package/site/src/routes/robots.txt/+server.ts +9 -0
- package/site/src/routes/sitemap.xml/+server.ts +7 -0
- package/site/src/routes/tags/+page.server.ts +5 -0
- package/site/src/routes/tags/+page.svelte +38 -0
- package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
- package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
- package/site/static/favicon.png +0 -0
- package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
- package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
- package/site/static/fonts/Virgil.woff2 +0 -0
- package/site/svelte.config.js +71 -0
- package/site/tooling/LICENSE-quartz +25 -0
- package/site/tooling/adapter.js +55 -0
- package/site/tooling/assets.ts +323 -0
- package/site/tooling/benchmark.ts +191 -0
- package/site/tooling/build.ts +407 -0
- package/site/tooling/cache.ts +133 -0
- package/site/tooling/check-diagrams.ts +164 -0
- package/site/tooling/check-links.ts +86 -0
- package/site/tooling/check-targets.ts +139 -0
- package/site/tooling/compiler.ts +1386 -0
- package/site/tooling/deploy.ts +112 -0
- package/site/tooling/fingerprint.ts +81 -0
- package/site/tooling/lib/app-version.js +92 -0
- package/site/tooling/lib/application-asset-urls.ts +27 -0
- package/site/tooling/lib/application-cache.ts +269 -0
- package/site/tooling/lib/asset-hash-cache.ts +116 -0
- package/site/tooling/lib/autotag.ts +99 -0
- package/site/tooling/lib/build-assembly.ts +148 -0
- package/site/tooling/lib/build-workspace.ts +386 -0
- package/site/tooling/lib/cache-selection.ts +70 -0
- package/site/tooling/lib/cloudflare-prewarm.js +40 -0
- package/site/tooling/lib/comments.ts +32 -0
- package/site/tooling/lib/content-cache.ts +244 -0
- package/site/tooling/lib/content-urls.ts +50 -0
- package/site/tooling/lib/course-cache.ts +298 -0
- package/site/tooling/lib/course-files.ts +281 -0
- package/site/tooling/lib/currency.ts +33 -0
- package/site/tooling/lib/deployment-config.ts +222 -0
- package/site/tooling/lib/deployment-manifest.ts +274 -0
- package/site/tooling/lib/deployment-prepare.ts +325 -0
- package/site/tooling/lib/deployment-upload.ts +293 -0
- package/site/tooling/lib/dev-git-state.ts +130 -0
- package/site/tooling/lib/dev-history.ts +86 -0
- package/site/tooling/lib/dev-social-images.ts +38 -0
- package/site/tooling/lib/dev-state.ts +107 -0
- package/site/tooling/lib/edition-builds.ts +184 -0
- package/site/tooling/lib/edition-paths.js +29 -0
- package/site/tooling/lib/empty-tag-route.js +23 -0
- package/site/tooling/lib/file-history.ts +201 -0
- package/site/tooling/lib/file-icons.ts +93 -0
- package/site/tooling/lib/file-policy.ts +38 -0
- package/site/tooling/lib/file-search.js +13 -0
- package/site/tooling/lib/git-revisions.ts +41 -0
- package/site/tooling/lib/history-pool.ts +74 -0
- package/site/tooling/lib/history-worker.ts +22 -0
- package/site/tooling/lib/knit-rmd.R +38 -0
- package/site/tooling/lib/lastmod.ts +201 -0
- package/site/tooling/lib/markdown-files.ts +51 -0
- package/site/tooling/lib/ofm.ts +634 -0
- package/site/tooling/lib/output.ts +60 -0
- package/site/tooling/lib/product-cache.ts +1006 -0
- package/site/tooling/lib/project-config.ts +288 -0
- package/site/tooling/lib/public-assets.ts +38 -0
- package/site/tooling/lib/public-links.ts +56 -0
- package/site/tooling/lib/publishing.ts +156 -0
- package/site/tooling/lib/r-runtime.ts +257 -0
- package/site/tooling/lib/rebuild-queue.ts +25 -0
- package/site/tooling/lib/rmd-preflight.ts +166 -0
- package/site/tooling/lib/rmd-preview-cache.ts +93 -0
- package/site/tooling/lib/rmd-previews.ts +129 -0
- package/site/tooling/lib/runtime-data-modules.ts +30 -0
- package/site/tooling/lib/runtime-packaging.ts +321 -0
- package/site/tooling/lib/search-cache.js +165 -0
- package/site/tooling/lib/search-inputs.js +129 -0
- package/site/tooling/lib/slug.ts +377 -0
- package/site/tooling/lib/social-images.ts +210 -0
- package/site/tooling/lib/text-exports.ts +51 -0
- package/site/tooling/lib/visit-elements.ts +17 -0
- package/site/tooling/live-test.ts +138 -0
- package/site/tooling/pipeline.ts +103 -0
- package/site/tooling/prepare.ts +3 -0
- package/site/tooling/publish.ts +28 -0
- package/site/tooling/r-cache.ts +45 -0
- package/site/tooling/r-render-profile.json +7 -0
- package/site/tooling/search.js +94 -0
- package/site/tooling/verify-deployment.ts +109 -0
- package/site/tooling/vite-content.ts +240 -0
- package/site/tooling/wrangler-paths.js +25 -0
- package/site/tooling/wrangler.ts +19 -0
- package/site/tsconfig.json +16 -0
- package/site/vite.config.ts +40 -0
- package/site/worker/access.ts +57 -0
- package/site/worker/analytics.ts +100 -0
- package/site/worker/auth-routes.ts +78 -0
- package/site/worker/auth.ts +78 -0
- package/site/worker/content-runtime.ts +226 -0
- package/site/worker/framing.ts +4 -0
- package/site/worker/gate.ts +147 -0
- package/site/worker/index.ts +9 -0
- package/site/worker/login.ts +62 -0
- package/site/worker/publication.ts +44 -0
- package/site/worker/runtime.ts +104 -0
- package/site/worker/tsconfig.json +13 -0
- package/site/wrangler.jsonc +37 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
// Content changes update these revalidated assets without renaming application
|
|
4
|
+
// chunks referenced by every prerendered page. Keep them outside _app/immutable.
|
|
5
|
+
export const runtimeDataAssets = {
|
|
6
|
+
'nav.json': 'navigation.js',
|
|
7
|
+
'file-icons.json': 'file-icons.js'
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export function runtimeDataModuleSource(data: unknown): string {
|
|
11
|
+
// JSON.parse preserves own "__proto__" keys in filename/folder catalogs;
|
|
12
|
+
// a JavaScript object literal would interpret those keys as prototype setters.
|
|
13
|
+
return `export default JSON.parse(${JSON.stringify(JSON.stringify(data))});\n`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveRuntimeDataImport(
|
|
17
|
+
source: string,
|
|
18
|
+
config: { root: string; command: 'build' | 'serve'; consumer?: 'client' | 'server' }
|
|
19
|
+
) {
|
|
20
|
+
if (config.command !== 'build' || config.consumer !== 'client') return;
|
|
21
|
+
// SSR and development retain the JSON import. Production browsers load exactly
|
|
22
|
+
// the same edition-specific data through the authenticated asset routing layer.
|
|
23
|
+
for (const [input, asset] of Object.entries(runtimeDataAssets)) {
|
|
24
|
+
if (
|
|
25
|
+
source === `$lib/generated/${input}` ||
|
|
26
|
+
source === path.join(config.root, 'src/lib/generated', input)
|
|
27
|
+
)
|
|
28
|
+
return { id: `/${asset}`, external: 'absolute' as const };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import {
|
|
2
|
+
constants,
|
|
3
|
+
cpSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import type { ContentManifest, PageDoc } from '../../src/lib/types';
|
|
17
|
+
import { fileRoute } from '../../src/lib/files';
|
|
18
|
+
import { createContentModel } from '../../src/lib/server/content-model';
|
|
19
|
+
import { captureAssetHashes } from './asset-hash-cache';
|
|
20
|
+
import { sha256 } from './deployment-config';
|
|
21
|
+
import {
|
|
22
|
+
assetContentType,
|
|
23
|
+
createAssetInventory,
|
|
24
|
+
mergeVerifiedInventories,
|
|
25
|
+
type AssetInventory,
|
|
26
|
+
type DeploymentAsset
|
|
27
|
+
} from './deployment-manifest';
|
|
28
|
+
|
|
29
|
+
export type RuntimeEdition = 'public' | 'full';
|
|
30
|
+
export type CapturedEdition = {
|
|
31
|
+
edition: RuntimeEdition;
|
|
32
|
+
metadata: string;
|
|
33
|
+
staticRoot: string;
|
|
34
|
+
routes: string[];
|
|
35
|
+
staticAssets: Record<string, DeploymentAsset>;
|
|
36
|
+
sources: Record<string, string>;
|
|
37
|
+
};
|
|
38
|
+
const copyOptions = { recursive: true, mode: constants.COPYFILE_FICLONE, preserveTimestamps: true };
|
|
39
|
+
const urlPath = (file: string) => '/' + file.split('/').map(encodeURIComponent).join('/');
|
|
40
|
+
function ownedFile(root: string, file: string) {
|
|
41
|
+
const absolute = path.resolve(root, file);
|
|
42
|
+
if (
|
|
43
|
+
!absolute.startsWith(path.resolve(root) + path.sep) ||
|
|
44
|
+
!lstatSync(absolute).isFile() ||
|
|
45
|
+
realpathSync(absolute) !== absolute
|
|
46
|
+
)
|
|
47
|
+
throw new Error('Runtime artifact must be an owned regular file');
|
|
48
|
+
return absolute;
|
|
49
|
+
}
|
|
50
|
+
function files(directory: string): string[] {
|
|
51
|
+
const result: string[] = [];
|
|
52
|
+
function walk(relative: string) {
|
|
53
|
+
for (const entry of readdirSync(path.join(directory, relative), { withFileTypes: true })) {
|
|
54
|
+
const file = relative ? `${relative}/${entry.name}` : entry.name;
|
|
55
|
+
if (entry.isDirectory()) walk(file);
|
|
56
|
+
else {
|
|
57
|
+
ownedFile(directory, file);
|
|
58
|
+
result.push(file);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
walk('');
|
|
63
|
+
return result.sort();
|
|
64
|
+
}
|
|
65
|
+
function copy(source: string, destination: string) {
|
|
66
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
67
|
+
cpSync(source, destination, copyOptions);
|
|
68
|
+
}
|
|
69
|
+
function asset(site: string, source: string, filename: string): DeploymentAsset {
|
|
70
|
+
return { ...captureAssetHashes(site, source), contentType: assetContentType(filename) };
|
|
71
|
+
}
|
|
72
|
+
function staticFiles(root: string) {
|
|
73
|
+
return files(root).filter(
|
|
74
|
+
(file) => !file.startsWith('.') && !['_headers', '_redirects', '_worker.js'].includes(file)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Capture an edition immediately after its pipeline, before the next edition replaces shared JSON. */
|
|
79
|
+
export function captureRuntimeEdition(
|
|
80
|
+
site: string,
|
|
81
|
+
edition: RuntimeEdition,
|
|
82
|
+
metadata: string,
|
|
83
|
+
searchDirectory?: string
|
|
84
|
+
): CapturedEdition {
|
|
85
|
+
if (existsSync(metadata)) throw new Error('Runtime metadata staging already exists');
|
|
86
|
+
mkdirSync(metadata, { recursive: true });
|
|
87
|
+
const generated = path.join(site, 'build/generated');
|
|
88
|
+
const manifest: ContentManifest = JSON.parse(
|
|
89
|
+
readFileSync(ownedFile(generated, 'content-manifest.json'), 'utf8')
|
|
90
|
+
);
|
|
91
|
+
copy(ownedFile(generated, 'content-manifest.json'), path.join(metadata, 'manifest.json'));
|
|
92
|
+
for (const slug of Object.keys(manifest.pages)) {
|
|
93
|
+
const name = `pages/${slug}.json`;
|
|
94
|
+
const source = ownedFile(generated, name);
|
|
95
|
+
const page: PageDoc = JSON.parse(readFileSync(source, 'utf8'));
|
|
96
|
+
if (
|
|
97
|
+
page.slug !== slug ||
|
|
98
|
+
page.publication.public !== manifest.pages[slug].publication.public ||
|
|
99
|
+
(edition === 'public' &&
|
|
100
|
+
!page.publication.public &&
|
|
101
|
+
(!page.locked || page.html || page.markdown || page.description))
|
|
102
|
+
)
|
|
103
|
+
throw new Error('Runtime PageDoc publication does not match its edition');
|
|
104
|
+
copy(source, path.join(metadata, name));
|
|
105
|
+
}
|
|
106
|
+
for (const name of ['nav.json', 'file-icons.json'])
|
|
107
|
+
copy(ownedFile(path.join(site, 'src/lib/generated'), name), path.join(metadata, name));
|
|
108
|
+
const staticRoot = path.join(
|
|
109
|
+
site,
|
|
110
|
+
edition === 'public' ? 'build/generated/public-static' : 'static'
|
|
111
|
+
);
|
|
112
|
+
const staticAssets: Record<string, DeploymentAsset> = Object.create(null);
|
|
113
|
+
const sources: Record<string, string> = Object.create(null);
|
|
114
|
+
for (const name of staticFiles(staticRoot)) {
|
|
115
|
+
if (name.startsWith('pagefind/')) continue;
|
|
116
|
+
if (/^(?:_app|_content|_published)(?:\/|$)/.test(name))
|
|
117
|
+
throw new Error('Runtime assets cannot replace application/internal namespaces');
|
|
118
|
+
sources[name] = ownedFile(staticRoot, name);
|
|
119
|
+
staticAssets[name] = asset(site, sources[name], urlPath(name));
|
|
120
|
+
}
|
|
121
|
+
if (searchDirectory)
|
|
122
|
+
for (const name of files(path.join(searchDirectory, 'pagefind'))) {
|
|
123
|
+
const target = `pagefind/${name}`;
|
|
124
|
+
sources[target] = ownedFile(path.join(searchDirectory, 'pagefind'), name);
|
|
125
|
+
staticAssets[target] = asset(site, sources[target], urlPath(target));
|
|
126
|
+
}
|
|
127
|
+
const model = createContentModel(manifest);
|
|
128
|
+
const routes = new Set([
|
|
129
|
+
'/',
|
|
130
|
+
'/tags',
|
|
131
|
+
'/index.xml',
|
|
132
|
+
'/sitemap.xml',
|
|
133
|
+
'/graph.json',
|
|
134
|
+
'/robots.txt',
|
|
135
|
+
'/404',
|
|
136
|
+
...model.contentEntries().map((route) => (route ? '/' + route : '/')),
|
|
137
|
+
...Object.keys(manifest.tags).map((tag) => `/tags/${tag}`)
|
|
138
|
+
]);
|
|
139
|
+
const catalogs = path.join(staticRoot, '_files/index');
|
|
140
|
+
if (existsSync(catalogs))
|
|
141
|
+
for (const name of readdirSync(catalogs)) {
|
|
142
|
+
if (!name.endsWith('.json')) continue;
|
|
143
|
+
const course = name.slice(0, -5);
|
|
144
|
+
const entries = JSON.parse(readFileSync(ownedFile(catalogs, name), 'utf8')) as {
|
|
145
|
+
path: string;
|
|
146
|
+
}[];
|
|
147
|
+
routes.add(fileRoute(course, ''));
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
const segments = entry.path.split('/');
|
|
150
|
+
for (let i = 1; i <= segments.length; i++)
|
|
151
|
+
routes.add(fileRoute(course, segments.slice(0, i).join('/')));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const aliases: Record<string, string> = Object.create(null);
|
|
155
|
+
for (const name of Object.keys(staticAssets))
|
|
156
|
+
aliases[urlPath(name)] = edition === 'public' ? `/_published${urlPath(name)}` : urlPath(name);
|
|
157
|
+
for (const name of manifest.htmlAssets) {
|
|
158
|
+
const target =
|
|
159
|
+
edition === 'public' ? `/_published${urlPath(`${name}.html`)}` : urlPath(`${name}.html`);
|
|
160
|
+
if (!staticAssets[`${name}.html`]) throw new Error('Runtime HTML asset is missing');
|
|
161
|
+
aliases[urlPath(name)] = aliases[urlPath(`${name}.html`)] = target;
|
|
162
|
+
}
|
|
163
|
+
const navHash = captureAssetHashes(site, path.join(metadata, 'nav.json')).sha256;
|
|
164
|
+
const iconHash = captureAssetHashes(site, path.join(metadata, 'file-icons.json')).sha256;
|
|
165
|
+
const manifestHash = captureAssetHashes(site, path.join(metadata, 'manifest.json')).sha256;
|
|
166
|
+
const htmlKeys: Record<string, string> = Object.create(null);
|
|
167
|
+
for (const route of routes) {
|
|
168
|
+
const slug = model.pageSlugForRoute(route === '/' ? '' : route.slice(1));
|
|
169
|
+
htmlKeys[route] = slug
|
|
170
|
+
? sha256(
|
|
171
|
+
JSON.stringify([
|
|
172
|
+
captureAssetHashes(site, path.join(metadata, `pages/${slug}.json`)).sha256,
|
|
173
|
+
navHash,
|
|
174
|
+
iconHash
|
|
175
|
+
])
|
|
176
|
+
)
|
|
177
|
+
: sha256(JSON.stringify([manifestHash, navHash, iconHash, route]));
|
|
178
|
+
}
|
|
179
|
+
writeFileSync(
|
|
180
|
+
path.join(metadata, 'routing.json'),
|
|
181
|
+
JSON.stringify({ schemaVersion: 1, routes: [...routes].sort(), assets: aliases, htmlKeys })
|
|
182
|
+
);
|
|
183
|
+
return { edition, metadata, staticRoot, routes: [...routes].sort(), staticAssets, sources };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function packageRuntime(
|
|
187
|
+
site: string,
|
|
188
|
+
output: string,
|
|
189
|
+
applicationVersion: string,
|
|
190
|
+
editions: CapturedEdition[],
|
|
191
|
+
appInventory: AssetInventory
|
|
192
|
+
) {
|
|
193
|
+
if (
|
|
194
|
+
!/^[a-f0-9]{64}$/.test(applicationVersion) ||
|
|
195
|
+
editions.length !== 2 ||
|
|
196
|
+
new Set(editions.map((value) => value.edition)).size !== 2
|
|
197
|
+
)
|
|
198
|
+
throw new Error('Runtime package requires one verified public and full edition');
|
|
199
|
+
const pieces = [{ owner: 'application', inventory: appInventory }];
|
|
200
|
+
const ownership: Record<string, { owner: string; sha256: string; size: number }> =
|
|
201
|
+
Object.create(null);
|
|
202
|
+
for (const [name, value] of Object.entries(appInventory.assets))
|
|
203
|
+
ownership[name] = { owner: 'application', sha256: value.sha256, size: value.size };
|
|
204
|
+
const snapshots = { public: '', full: '' };
|
|
205
|
+
for (const edition of editions) {
|
|
206
|
+
if (edition.edition === 'public') {
|
|
207
|
+
const routeFile = path.join(edition.metadata, 'routing.json');
|
|
208
|
+
const routing = JSON.parse(readFileSync(routeFile, 'utf8'));
|
|
209
|
+
for (const [name, value] of Object.entries(appInventory.assets)) {
|
|
210
|
+
if (!name.startsWith('/_app/') && !name.startsWith('/fonts/') && name !== '/favicon.png')
|
|
211
|
+
continue;
|
|
212
|
+
const key = urlPath(name.slice(1));
|
|
213
|
+
const inherited = edition.staticAssets[name.slice(1)];
|
|
214
|
+
if (inherited && (inherited.sha256 !== value.sha256 || inherited.size !== value.size))
|
|
215
|
+
throw new Error('Shared application asset conflicts with public static output');
|
|
216
|
+
routing.assets[key] = key;
|
|
217
|
+
}
|
|
218
|
+
writeFileSync(routeFile, JSON.stringify(routing));
|
|
219
|
+
}
|
|
220
|
+
const metadataAssets: Record<string, DeploymentAsset> = Object.create(null);
|
|
221
|
+
for (const name of files(edition.metadata))
|
|
222
|
+
metadataAssets[name] = asset(site, ownedFile(edition.metadata, name), urlPath(name));
|
|
223
|
+
const snapshot = sha256(
|
|
224
|
+
JSON.stringify([
|
|
225
|
+
edition.edition,
|
|
226
|
+
Object.entries(metadataAssets).map(([name, value]) => [name, value.sha256, value.size]),
|
|
227
|
+
Object.entries(edition.staticAssets).map(([name, value]) => [
|
|
228
|
+
name,
|
|
229
|
+
value.sha256,
|
|
230
|
+
value.size
|
|
231
|
+
])
|
|
232
|
+
])
|
|
233
|
+
);
|
|
234
|
+
snapshots[edition.edition] = snapshot;
|
|
235
|
+
const assets: Record<string, DeploymentAsset> = Object.create(null);
|
|
236
|
+
for (const [name, value] of Object.entries(metadataAssets)) {
|
|
237
|
+
const physical = `/_content/${edition.edition}/${snapshot}/${name}`;
|
|
238
|
+
copy(ownedFile(edition.metadata, name), path.join(output, physical));
|
|
239
|
+
assets[physical] = value;
|
|
240
|
+
}
|
|
241
|
+
for (const [name, value] of Object.entries(edition.staticAssets)) {
|
|
242
|
+
const physical = edition.edition === 'public' ? `/_published/${name}` : `/${name}`;
|
|
243
|
+
const source = edition.sources[name];
|
|
244
|
+
if (!source || !lstatSync(source).isFile() || realpathSync(source) !== source)
|
|
245
|
+
throw new Error('Runtime source changed before packaging');
|
|
246
|
+
copy(source, path.join(output, physical));
|
|
247
|
+
assets[physical] = value;
|
|
248
|
+
}
|
|
249
|
+
const product = createAssetInventory(assets, snapshot);
|
|
250
|
+
pieces.push({ owner: edition.edition, inventory: product });
|
|
251
|
+
for (const [name, value] of Object.entries(assets))
|
|
252
|
+
ownership[name] = { owner: edition.edition, sha256: value.sha256, size: value.size };
|
|
253
|
+
}
|
|
254
|
+
const descriptor = Buffer.from(
|
|
255
|
+
JSON.stringify({ schemaVersion: 1, applicationVersion, snapshots })
|
|
256
|
+
);
|
|
257
|
+
const provenance = sha256(descriptor);
|
|
258
|
+
const currentPath = path.join(output, '_content/current.json');
|
|
259
|
+
mkdirSync(path.dirname(currentPath), { recursive: true });
|
|
260
|
+
writeFileSync(currentPath, descriptor); // Immutable snapshots are complete before the current pointer appears.
|
|
261
|
+
const control = createAssetInventory(
|
|
262
|
+
{ '/_content/current.json': asset(site, currentPath, '/_content/current.json') },
|
|
263
|
+
provenance
|
|
264
|
+
);
|
|
265
|
+
pieces.push({ owner: 'current', inventory: control });
|
|
266
|
+
ownership['/_content/current.json'] = {
|
|
267
|
+
owner: 'current',
|
|
268
|
+
sha256: control.assets['/_content/current.json'].sha256,
|
|
269
|
+
size: descriptor.length
|
|
270
|
+
};
|
|
271
|
+
const inventory = mergeVerifiedInventories({
|
|
272
|
+
pieces,
|
|
273
|
+
expectedProducts: Object.fromEntries(
|
|
274
|
+
pieces.map((piece) => [
|
|
275
|
+
piece.owner,
|
|
276
|
+
{ provenance: piece.inventory.provenance, digest: piece.inventory.digest }
|
|
277
|
+
])
|
|
278
|
+
),
|
|
279
|
+
ownership,
|
|
280
|
+
provenance,
|
|
281
|
+
controlFiles: appInventory.controlFiles
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
snapshots,
|
|
285
|
+
provenance,
|
|
286
|
+
inventory,
|
|
287
|
+
publicRoutes: editions.find((value) => value.edition === 'public')!.routes.length
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Install a complete product after its application, both editions and descriptor have succeeded. */
|
|
292
|
+
export function promoteRuntimeProduct(site: string, staged: string) {
|
|
293
|
+
const target = path.join(site, 'build/.svelte-kit');
|
|
294
|
+
const transaction = mkdtempSync(path.join(site, 'build/.runtime-promotion-'));
|
|
295
|
+
const previous = path.join(transaction, 'previous');
|
|
296
|
+
let moved = false,
|
|
297
|
+
preserve = false;
|
|
298
|
+
try {
|
|
299
|
+
if (existsSync(target)) {
|
|
300
|
+
renameSync(target, previous);
|
|
301
|
+
moved = true;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
renameSync(staged, target);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (moved)
|
|
307
|
+
try {
|
|
308
|
+
renameSync(previous, target);
|
|
309
|
+
} catch (rollback) {
|
|
310
|
+
preserve = true;
|
|
311
|
+
throw new AggregateError(
|
|
312
|
+
[error, rollback],
|
|
313
|
+
'Previous runtime product preserved for recovery'
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
} finally {
|
|
319
|
+
if (!preserve) rmSync(transaction, { recursive: true, force: true });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
writeFileSync
|
|
12
|
+
} from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
/** @param {string | Uint8Array} bytes */
|
|
16
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
17
|
+
|
|
18
|
+
/** The exact HTML/custom records, their submission order and Pagefind version
|
|
19
|
+
* identify the complete index. Neither checkout paths nor filesystem timestamps
|
|
20
|
+
* participate. Edition is explicit even when two tiny fixtures happen to match.
|
|
21
|
+
* @param {{html: import('pagefind').HTMLFile[]; records: import('pagefind').CustomRecord[]; pagefindVersion: string; edition: string}} input
|
|
22
|
+
*/
|
|
23
|
+
export function searchInputKey(input) {
|
|
24
|
+
return digest(JSON.stringify(['pagefind-inputs-v1', input]));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @param {string} file */
|
|
28
|
+
function safePath(file) {
|
|
29
|
+
return (
|
|
30
|
+
file.length < 1024 &&
|
|
31
|
+
/^[\w.-]+(?:\/[\w.-]+)*$/.test(file) &&
|
|
32
|
+
file.split('/').every((segment) => segment !== '.' && segment !== '..')
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @param {string} root */
|
|
37
|
+
function filesIn(root) {
|
|
38
|
+
/** @type {string[]} */
|
|
39
|
+
const files = [];
|
|
40
|
+
/** @param {string} directory */
|
|
41
|
+
function visit(directory) {
|
|
42
|
+
for (const entry of readdirSync(path.join(root, directory), { withFileTypes: true })) {
|
|
43
|
+
const relative = directory ? `${directory}/${entry.name}` : entry.name;
|
|
44
|
+
if (!safePath(relative)) throw new Error('Invalid Pagefind output path');
|
|
45
|
+
if (entry.isDirectory()) visit(relative);
|
|
46
|
+
else if (entry.isFile()) files.push(relative);
|
|
47
|
+
else throw new Error('Pagefind output must contain regular files');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
visit('');
|
|
51
|
+
return files.sort();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Copy a fully validated generation into a clean directory. Validate before
|
|
55
|
+
* replacing any output so a corrupt/missing cache cannot leave a partial index.
|
|
56
|
+
* @param {string} cache @param {string} output @param {string} key @param {string} edition
|
|
57
|
+
*/
|
|
58
|
+
export function restoreSearchCache(cache, output, key, edition) {
|
|
59
|
+
/** @type {string | undefined} */
|
|
60
|
+
let temporary;
|
|
61
|
+
try {
|
|
62
|
+
const root = realpathSync(cache);
|
|
63
|
+
const manifestPath = path.join(root, 'manifest.json');
|
|
64
|
+
if (!lstatSync(manifestPath).isFile() || realpathSync(manifestPath) !== manifestPath)
|
|
65
|
+
return false;
|
|
66
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
67
|
+
if (
|
|
68
|
+
manifest.version !== 1 ||
|
|
69
|
+
manifest.key !== key ||
|
|
70
|
+
manifest.edition !== edition ||
|
|
71
|
+
!Array.isArray(manifest.files) ||
|
|
72
|
+
manifest.filesSha256 !== digest(JSON.stringify(manifest.files)) ||
|
|
73
|
+
!manifest.files.length ||
|
|
74
|
+
manifest.files.length > 100000
|
|
75
|
+
)
|
|
76
|
+
return false;
|
|
77
|
+
/** @type {Map<string, Buffer>} */
|
|
78
|
+
const verified = new Map();
|
|
79
|
+
let total = 0;
|
|
80
|
+
for (const file of manifest.files) {
|
|
81
|
+
if (
|
|
82
|
+
typeof file.path !== 'string' ||
|
|
83
|
+
!safePath(file.path) ||
|
|
84
|
+
verified.has(file.path) ||
|
|
85
|
+
!Number.isSafeInteger(file.bytes) ||
|
|
86
|
+
file.bytes < 0 ||
|
|
87
|
+
!/^[a-f0-9]{64}$/.test(file.sha256)
|
|
88
|
+
)
|
|
89
|
+
return false;
|
|
90
|
+
total += file.bytes;
|
|
91
|
+
if (total > 512 * 1024 * 1024) return false;
|
|
92
|
+
const source = path.join(root, 'files', file.path);
|
|
93
|
+
const stat = lstatSync(source);
|
|
94
|
+
if (!stat.isFile() || stat.size !== file.bytes || realpathSync(source) !== source)
|
|
95
|
+
return false;
|
|
96
|
+
const bytes = readFileSync(source);
|
|
97
|
+
if (digest(bytes) !== file.sha256) return false;
|
|
98
|
+
verified.set(file.path, bytes);
|
|
99
|
+
}
|
|
100
|
+
if (!verified.has('pagefind.js') || !verified.has('pagefind-entry.json')) return false;
|
|
101
|
+
mkdirSync(path.dirname(output), { recursive: true });
|
|
102
|
+
temporary = mkdtempSync(`${output}.tmp-`);
|
|
103
|
+
for (const [file, bytes] of verified) {
|
|
104
|
+
mkdirSync(path.dirname(path.join(temporary, file)), { recursive: true });
|
|
105
|
+
writeFileSync(path.join(temporary, file), bytes);
|
|
106
|
+
}
|
|
107
|
+
rmSync(output, { recursive: true, force: true });
|
|
108
|
+
renameSync(temporary, output);
|
|
109
|
+
temporary = undefined;
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
} finally {
|
|
114
|
+
if (temporary) rmSync(temporary, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Publish a successful complete index, then its manifest. Returns portable
|
|
119
|
+
* filenames for encrypted cache transport, including the validating manifest.
|
|
120
|
+
* @param {string} cacheRoot @param {string} output @param {string} key @param {string} edition
|
|
121
|
+
*/
|
|
122
|
+
export function saveSearchCache(cacheRoot, output, key, edition) {
|
|
123
|
+
const relative = `search/${edition}/${key}`;
|
|
124
|
+
const cache = path.join(cacheRoot, relative);
|
|
125
|
+
const files = filesIn(output);
|
|
126
|
+
if (!files.includes('pagefind.js') || !files.includes('pagefind-entry.json'))
|
|
127
|
+
throw new Error('Incomplete Pagefind output');
|
|
128
|
+
mkdirSync(path.dirname(cache), { recursive: true });
|
|
129
|
+
const temporary = mkdtempSync(`${cache}.tmp-`);
|
|
130
|
+
try {
|
|
131
|
+
const manifest = {
|
|
132
|
+
version: 1,
|
|
133
|
+
key,
|
|
134
|
+
edition,
|
|
135
|
+
files: files.map((file) => {
|
|
136
|
+
const bytes = readFileSync(path.join(output, file));
|
|
137
|
+
mkdirSync(path.dirname(path.join(temporary, 'files', file)), { recursive: true });
|
|
138
|
+
writeFileSync(path.join(temporary, 'files', file), bytes);
|
|
139
|
+
return { path: file, bytes: bytes.length, sha256: digest(bytes) };
|
|
140
|
+
})
|
|
141
|
+
};
|
|
142
|
+
writeFileSync(
|
|
143
|
+
path.join(temporary, 'manifest.json'),
|
|
144
|
+
JSON.stringify({ ...manifest, filesSha256: digest(JSON.stringify(manifest.files)) })
|
|
145
|
+
);
|
|
146
|
+
rmSync(cache, { recursive: true, force: true });
|
|
147
|
+
renameSync(temporary, cache);
|
|
148
|
+
return [`${relative}/manifest.json`, ...files.map((file) => `${relative}/files/${file}`)];
|
|
149
|
+
} finally {
|
|
150
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @param {string} cacheRoot @param {string} edition @param {string[]} files */
|
|
155
|
+
export function selectSearchCache(cacheRoot, edition, files) {
|
|
156
|
+
mkdirSync(cacheRoot, { recursive: true });
|
|
157
|
+
const target = path.join(cacheRoot, `search-current-${edition}.json`);
|
|
158
|
+
const temporary = `${target}.tmp-${process.pid}`;
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(temporary, JSON.stringify(files));
|
|
161
|
+
renameSync(temporary, target);
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(temporary, { force: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Lightweight equivalents of the searchable content in the document routes.
|
|
2
|
+
* The application shell, controls, backlinks and ignored metadata are not search
|
|
3
|
+
* content. Keep HTML rather than custom records so Pagefind retains heading
|
|
4
|
+
* anchors, HTML tokenization and excerpt semantics.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** @param {string} value */
|
|
8
|
+
export function escapeSearchHtml(value) {
|
|
9
|
+
return value
|
|
10
|
+
.replaceAll('&', '&')
|
|
11
|
+
.replaceAll('<', '<')
|
|
12
|
+
.replaceAll('>', '>')
|
|
13
|
+
.replaceAll('"', '"')
|
|
14
|
+
.replaceAll("'", ''');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @param {string} slug */
|
|
18
|
+
function displayRoute(slug) {
|
|
19
|
+
return slug === 'index' ? '' : slug.endsWith('/index') ? slug.slice(0, -6) : slug;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {string} title @param {string} body */
|
|
23
|
+
function document(title, body) {
|
|
24
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${escapeSearchHtml(title)}</title></head><body><article data-pagefind-body>${body}</article></body></html>`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @param {string} course */
|
|
28
|
+
function courseFilter(course) {
|
|
29
|
+
return `<span data-pagefind-ignore data-pagefind-filter="course">${escapeSearchHtml(course)}</span>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Match the article in [...slug]/+page.svelte, including its leading-H1 fallback.
|
|
33
|
+
* @param {import('../../src/lib/types').PageDoc} page
|
|
34
|
+
*/
|
|
35
|
+
export function noteSearchInput(page) {
|
|
36
|
+
if (page.locked) return;
|
|
37
|
+
const route = displayRoute(page.slug);
|
|
38
|
+
const heading = page.html.match(/^\s*<h1\b[^>]*>[\s\S]*?<\/h1>/i)?.[0];
|
|
39
|
+
const metadata = route
|
|
40
|
+
? `<div data-pagefind-ignore>${courseFilter(route.split('/')[0])}${page.tags.map((tag) => `<span data-pagefind-filter="tag">${escapeSearchHtml(tag)}</span>`).join('')}</div><span data-pagefind-meta="title">${escapeSearchHtml(page.title)}</span>`
|
|
41
|
+
: '';
|
|
42
|
+
return {
|
|
43
|
+
url: `/${route}`,
|
|
44
|
+
content: document(
|
|
45
|
+
page.title,
|
|
46
|
+
`${metadata}${heading ? page.html : `<h1>${escapeSearchHtml(page.title)}</h1>${page.html}`}`
|
|
47
|
+
)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {import('../../src/lib/types').ContentManifest} manifest
|
|
52
|
+
* @param {(slug: string) => import('../../src/lib/types').PageDoc} loadPage
|
|
53
|
+
* @returns {import('pagefind').HTMLFile[]}
|
|
54
|
+
*/
|
|
55
|
+
export function searchHtmlInputs(manifest, loadPage) {
|
|
56
|
+
/** @type {import('pagefind').HTMLFile[]} */
|
|
57
|
+
const inputs = [];
|
|
58
|
+
const routes = new Set();
|
|
59
|
+
for (const slug of Object.keys(manifest.pages)) {
|
|
60
|
+
const route = displayRoute(slug);
|
|
61
|
+
// The route loader prefers an exact page over a colliding folder index.
|
|
62
|
+
if (routes.has(route) || (slug.endsWith('/index') && manifest.pages[route])) continue;
|
|
63
|
+
routes.add(route);
|
|
64
|
+
const input = noteSearchInput(loadPage(slug));
|
|
65
|
+
if (input) inputs.push(input);
|
|
66
|
+
}
|
|
67
|
+
/** @type {Map<string, {name: string; count: number}>} */
|
|
68
|
+
const folders = new Map();
|
|
69
|
+
/** @param {import('../../src/lib/types').TreeNode} node @returns {number} */
|
|
70
|
+
function visit(node) {
|
|
71
|
+
const count = node.pages.length + node.children.reduce((sum, child) => sum + visit(child), 0);
|
|
72
|
+
folders.set(node.slug, { name: node.name, count });
|
|
73
|
+
return count;
|
|
74
|
+
}
|
|
75
|
+
visit(manifest.tree);
|
|
76
|
+
for (const folder of ['', ...manifest.folders]) {
|
|
77
|
+
if (routes.has(folder)) continue;
|
|
78
|
+
const listing = folders.get(folder);
|
|
79
|
+
if (!listing) continue;
|
|
80
|
+
routes.add(folder);
|
|
81
|
+
const name = folder ? folder.split('/').at(-1) || folder : listing.name;
|
|
82
|
+
inputs.push({
|
|
83
|
+
url: `/${folder}`,
|
|
84
|
+
content: document(
|
|
85
|
+
name,
|
|
86
|
+
`${folder ? courseFilter(folder.split('/')[0]) : ''}<h1>${escapeSearchHtml(name)}</h1><p>${listing.count} ${listing.count === 1 ? 'item' : 'items'} under this folder.</p>`
|
|
87
|
+
)
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
|
91
|
+
const tags = Object.keys(manifest.tags).sort((a, b) => collator.compare(a, b));
|
|
92
|
+
inputs.push({
|
|
93
|
+
url: '/tags',
|
|
94
|
+
content: document(
|
|
95
|
+
'Tag Index',
|
|
96
|
+
`<h1>Tag Index</h1><p>Found ${tags.length} total tags.</p><ul>${tags.map((tag) => `<li><a href="/tags/${escapeSearchHtml(tag)}">${escapeSearchHtml(tag)} ${manifest.tags[tag].length}</a></li>`).join('')}</ul>`
|
|
97
|
+
)
|
|
98
|
+
});
|
|
99
|
+
for (const tag of tags) {
|
|
100
|
+
const pages = manifest.tags[tag]
|
|
101
|
+
.map((slug) => manifest.pages[slug])
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.sort(
|
|
104
|
+
(a, b) =>
|
|
105
|
+
Date.parse(b.dates.modified) - Date.parse(a.dates.modified) ||
|
|
106
|
+
collator.compare(a.title, b.title)
|
|
107
|
+
);
|
|
108
|
+
const title = `Tag: ${tag}`;
|
|
109
|
+
inputs.push({
|
|
110
|
+
url: `/tags/${tag}`,
|
|
111
|
+
content: document(
|
|
112
|
+
title,
|
|
113
|
+
`<h1>${escapeSearchHtml(title)}</h1><p>${pages.length} ${pages.length === 1 ? 'item' : 'items'} with this tag.</p><ul>${pages
|
|
114
|
+
.map((page) => {
|
|
115
|
+
const date = page.dates.modified
|
|
116
|
+
? new Date(page.dates.modified).toLocaleDateString('en-US', {
|
|
117
|
+
year: 'numeric',
|
|
118
|
+
month: 'short',
|
|
119
|
+
day: 'numeric'
|
|
120
|
+
})
|
|
121
|
+
: '';
|
|
122
|
+
return `<li><a href="/${escapeSearchHtml(displayRoute(page.slug))}">${escapeSearchHtml(page.title)} ${escapeSearchHtml(date)}${page.description ? ` ${escapeSearchHtml(page.description)}` : ''}</a></li>`;
|
|
123
|
+
})
|
|
124
|
+
.join('')}</ul>`
|
|
125
|
+
)
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return inputs.sort((a, b) => (a.url || '').localeCompare(b.url || '', 'en'));
|
|
129
|
+
}
|