@minamocms/framework 0.0.0-stage → 0.10.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/CHANGELOG.md +234 -0
- package/LICENSE +105 -0
- package/README.md +133 -2
- package/dist/boot.d.ts +19 -0
- package/dist/boot.js +22 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +44 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +12 -0
- package/dist/create-site/generate-site.js +168 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/AGENTS.md +473 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +23 -0
- package/dist/create-site/template/content/pages/about/careers.json +22 -0
- package/dist/create-site/template/content/pages/about/team.json +22 -0
- package/dist/create-site/template/content/pages/about.json +22 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
- package/dist/create-site/template/content/pages/docs.json +22 -0
- package/dist/create-site/template/content/pages/index.json +182 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1223 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/config/site_settings.json +11 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +48 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +81 -0
- package/dist/media/filename.d.ts +2 -0
- package/dist/media/filename.js +54 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/media/seed-media-cli.d.ts +2 -0
- package/dist/media/seed-media-cli.js +22 -0
- package/dist/media/seed-media.d.ts +11 -0
- package/dist/media/seed-media.js +76 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +83 -0
- package/dist/pull-site/cli.d.ts +2 -0
- package/dist/pull-site/cli.js +78 -0
- package/dist/pull-site/prompt-token.d.ts +16 -0
- package/dist/pull-site/prompt-token.js +69 -0
- package/dist/pull-site/prompts.d.ts +22 -0
- package/dist/pull-site/prompts.js +127 -0
- package/dist/pull-site/pull-site.d.ts +23 -0
- package/dist/pull-site/pull-site.js +211 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-cache.d.ts +11 -0
- package/dist/renderer/render-cache.js +11 -0
- package/dist/renderer/render-page.d.ts +34 -0
- package/dist/renderer/render-page.js +209 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/admin-redirect.d.ts +5 -0
- package/dist/routes/admin-redirect.js +26 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +6 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +169 -0
- package/dist/routes/deleted-pages.d.ts +8 -0
- package/dist/routes/deleted-pages.js +9 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +12 -0
- package/dist/routes/index.js +86 -0
- package/dist/routes/links.d.ts +8 -0
- package/dist/routes/links.js +18 -0
- package/dist/routes/media-public.d.ts +11 -0
- package/dist/routes/media-public.js +125 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +80 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +148 -0
- package/dist/routes/preview-revision.d.ts +10 -0
- package/dist/routes/preview-revision.js +96 -0
- package/dist/routes/preview.d.ts +10 -0
- package/dist/routes/preview.js +56 -0
- package/dist/routes/public.d.ts +10 -0
- package/dist/routes/public.js +123 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +139 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search-public.d.ts +6 -0
- package/dist/routes/search-public.js +104 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +15 -0
- package/dist/routes/settings.d.ts +10 -0
- package/dist/routes/settings.js +68 -0
- package/dist/routes/sitemap.d.ts +7 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +116 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +24 -0
- package/dist/schemas/page.schema.json +26 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-content.d.ts +32 -0
- package/dist/search/query-content.js +207 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +3 -0
- package/dist/search/rebuild-index.js +320 -0
- package/dist/server-config.d.ts +23 -0
- package/dist/server-config.js +237 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +235 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +145 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +151 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +166 -0
- package/dist/services/deleted-pages.d.ts +11 -0
- package/dist/services/deleted-pages.js +83 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +16 -0
- package/dist/services/drafts.js +164 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/links.d.ts +22 -0
- package/dist/services/links.js +169 -0
- package/dist/services/manage-menus.d.ts +15 -0
- package/dist/services/manage-menus.js +138 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +156 -0
- package/dist/services/menu-references.d.ts +3 -0
- package/dist/services/menu-references.js +38 -0
- package/dist/services/menus.d.ts +9 -0
- package/dist/services/menus.js +43 -0
- package/dist/services/migration-runner.d.ts +16 -0
- package/dist/services/migration-runner.js +169 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +43 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +236 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/pid-file.d.ts +13 -0
- package/dist/services/pid-file.js +54 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +15 -0
- package/dist/services/publish.js +313 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +22 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/reindex-on-write.d.ts +3 -0
- package/dist/services/reindex-on-write.js +30 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/site-settings.d.ts +28 -0
- package/dist/services/site-settings.js +116 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +6 -0
- package/dist/services/theme-component-file.js +39 -0
- package/dist/services/theme-files.d.ts +33 -0
- package/dist/services/theme-files.js +148 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +137 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +26 -0
- package/dist/services/validation.js +195 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +48 -0
- package/dist/site-check/run-check.d.ts +12 -0
- package/dist/site-check/run-check.js +188 -0
- package/dist/site-sync/cli-helpers.d.ts +23 -0
- package/dist/site-sync/cli-helpers.js +75 -0
- package/dist/site-sync/deploy.d.ts +36 -0
- package/dist/site-sync/deploy.js +124 -0
- package/dist/site-sync/live-content.d.ts +30 -0
- package/dist/site-sync/live-content.js +68 -0
- package/dist/site-sync/prompts.d.ts +30 -0
- package/dist/site-sync/prompts.js +237 -0
- package/dist/site-sync/pull-cli.d.ts +2 -0
- package/dist/site-sync/pull-cli.js +75 -0
- package/dist/site-sync/pull-site.d.ts +28 -0
- package/dist/site-sync/pull-site.js +171 -0
- package/dist/site-sync/push-cli.d.ts +2 -0
- package/dist/site-sync/push-cli.js +268 -0
- package/dist/site-sync/push-site.d.ts +96 -0
- package/dist/site-sync/push-site.js +376 -0
- package/dist/site-sync/remote-site.d.ts +38 -0
- package/dist/site-sync/remote-site.js +122 -0
- package/dist/site-sync/sync-record.d.ts +26 -0
- package/dist/site-sync/sync-record.js +76 -0
- package/dist/site-sync/theme-sync.d.ts +34 -0
- package/dist/site-sync/theme-sync.js +128 -0
- package/dist/stop-site/cli.d.ts +2 -0
- package/dist/stop-site/cli.js +28 -0
- package/dist/stop-site/stop-site.d.ts +26 -0
- package/dist/stop-site/stop-site.js +56 -0
- package/dist/theme-state.d.ts +20 -0
- package/dist/theme-state.js +33 -0
- package/dist/upgrade/changelog.d.ts +8 -0
- package/dist/upgrade/changelog.js +46 -0
- package/dist/upgrade/finish.d.ts +9 -0
- package/dist/upgrade/finish.js +30 -0
- package/dist/upgrade/owned-files.d.ts +11 -0
- package/dist/upgrade/owned-files.js +42 -0
- package/dist/upgrade/package-names.d.ts +4 -0
- package/dist/upgrade/package-names.js +26 -0
- package/dist/upgrade/upgrade-cli.d.ts +2 -0
- package/dist/upgrade/upgrade-cli.js +170 -0
- package/package.json +64 -4
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Pure, filesystem-free mapping between a /blog/<slug> URL and a
|
|
2
|
+
// post's path relative to postsRoot. Unlike pages' arbitrary nested
|
|
3
|
+
// paths (about.json beside a sibling about/ directory), posts are
|
|
4
|
+
// flat only - a URL with more than one segment after /blog/ is never
|
|
5
|
+
// a valid post URL, enforced here rather than left to sanitisePath.
|
|
6
|
+
const BLOG_PREFIX = '/blog/';
|
|
7
|
+
// /blog is a permanently reserved namespace: both "/blog" itself (no
|
|
8
|
+
// slug) and every "/blog/..." URL are recognised here, so a caller can
|
|
9
|
+
// route the whole namespace to post resolution before ever checking
|
|
10
|
+
// for a page, matching the confirmed reserved-namespace decision.
|
|
11
|
+
export function isBlogUrl(url) {
|
|
12
|
+
return url === '/blog' || url.startsWith(BLOG_PREFIX);
|
|
13
|
+
}
|
|
14
|
+
export function urlToPostPath(url) {
|
|
15
|
+
if (!url.startsWith(BLOG_PREFIX)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const slug = url.slice(BLOG_PREFIX.length);
|
|
19
|
+
if (slug === '' || slug.includes('/')) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return `${slug}.json`;
|
|
23
|
+
}
|
|
24
|
+
export function postPathToUrl(relativePostPath) {
|
|
25
|
+
const withoutExtension = relativePostPath.replace(/\.json$/, '');
|
|
26
|
+
return `${BLOG_PREFIX}${withoutExtension}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
import type { CommitAuthor } from './git.ts';
|
|
3
|
+
import type { PreparedOperation } from './prepared-operation.ts';
|
|
4
|
+
import type { ThemeSchemas } from './validation.ts';
|
|
5
|
+
export type PublishReason = 'validation-failed' | 'draft-not-found' | 'page-not-found' | 'duplicate-path' | 'write-failed' | 'commit-failed' | 'rollback-failed';
|
|
6
|
+
export declare class PublishError extends Error {
|
|
7
|
+
readonly reason: PublishReason;
|
|
8
|
+
constructor(reason: PublishReason, message: string, options?: {
|
|
9
|
+
cause?: unknown;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export declare function preparePublishDrafts(config: SiteConfig, themeSchemas: ThemeSchemas, relativePaths: string[]): PreparedOperation;
|
|
13
|
+
export declare function publishDrafts(config: SiteConfig, themeSchemas: ThemeSchemas, relativePaths: string[], message: string, author: CommitAuthor): Promise<void>;
|
|
14
|
+
export declare function unpublishPage(config: SiteConfig, relativePath: string, message: string, author: CommitAuthor): Promise<void>;
|
|
15
|
+
export declare function publishPage(config: SiteConfig, relativePath: string, message: string, author: CommitAuthor): Promise<void>;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { GitOperationError, commitPaths } from "./git.js";
|
|
4
|
+
import { sanitisePath } from "./path-safety.js";
|
|
5
|
+
import { loadRedirects, removeRedirectForPath, serialiseRedirects } from "./redirects.js";
|
|
6
|
+
import { pagePathToUrl } from "./urls.js";
|
|
7
|
+
import { validateContent } from "./validation.js";
|
|
8
|
+
import { enqueue } from "./write-queue.js";
|
|
9
|
+
import { reindexInBackground } from "./reindex-on-write.js";
|
|
10
|
+
const PAGES_PREFIX = 'pages/';
|
|
11
|
+
// Mirrors delete-content.ts's urlForDeletedEntry - a tiny duplicated
|
|
12
|
+
// helper, not a shared abstraction. Returns null for anything else
|
|
13
|
+
// (menus have no public URL, so redirect semantics don't apply).
|
|
14
|
+
function urlForPublishedEntry(relativePath) {
|
|
15
|
+
if (relativePath.startsWith(PAGES_PREFIX)) {
|
|
16
|
+
return pagePathToUrl(relativePath.slice(PAGES_PREFIX.length));
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
export class PublishError extends Error {
|
|
21
|
+
reason;
|
|
22
|
+
constructor(reason, message, options) {
|
|
23
|
+
super(message, options);
|
|
24
|
+
this.name = 'PublishError';
|
|
25
|
+
this.reason = reason;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Hand-rolled fs snapshot/restore, not a database transaction
|
|
29
|
+
// (deliberate, given the minimal-dependency policy). Attempts every
|
|
30
|
+
// restore even if one fails, rather than aborting on the first error,
|
|
31
|
+
// and reports every failure to the caller: a filesystem cannot give a
|
|
32
|
+
// true atomicity guarantee (disk full, permissions changing mid-flight
|
|
33
|
+
// are real residual risks), so the honest contract is "restores
|
|
34
|
+
// everything it can, and is loud if it can't" rather than a silent
|
|
35
|
+
// best-effort.
|
|
36
|
+
function rollback(snapshots, redirects) {
|
|
37
|
+
const failures = [];
|
|
38
|
+
for (const snapshot of snapshots) {
|
|
39
|
+
try {
|
|
40
|
+
if (snapshot.liveExisted) {
|
|
41
|
+
writeFileSync(snapshot.livePath, snapshot.liveOriginal);
|
|
42
|
+
}
|
|
43
|
+
else if (existsSync(snapshot.livePath)) {
|
|
44
|
+
unlinkSync(snapshot.livePath);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
failures.push(error);
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(dirname(snapshot.draftPath), { recursive: true });
|
|
52
|
+
writeFileSync(snapshot.draftPath, snapshot.draftOriginal);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
failures.push(error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (redirects) {
|
|
59
|
+
try {
|
|
60
|
+
if (redirects.existed) {
|
|
61
|
+
writeFileSync(redirects.path, redirects.original);
|
|
62
|
+
}
|
|
63
|
+
else if (existsSync(redirects.path)) {
|
|
64
|
+
unlinkSync(redirects.path);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
failures.push(error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return failures;
|
|
72
|
+
}
|
|
73
|
+
function rollbackOrRethrow(snapshots, cause, redirects) {
|
|
74
|
+
const failures = rollback(snapshots, redirects);
|
|
75
|
+
if (failures.length > 0) {
|
|
76
|
+
throw new PublishError('rollback-failed', 'Publish failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause });
|
|
77
|
+
}
|
|
78
|
+
const reason = cause instanceof GitOperationError ? 'commit-failed' : 'write-failed';
|
|
79
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
80
|
+
throw new PublishError(reason, `Publish failed: ${message}`, { cause });
|
|
81
|
+
}
|
|
82
|
+
// Validates and writes everything a publish needs, stopping short of
|
|
83
|
+
// the commit itself - used both by the standalone publishDraftsJob
|
|
84
|
+
// (which commits immediately after) and by batch.ts (which accumulates
|
|
85
|
+
// this alongside other operations and commits once for the whole
|
|
86
|
+
// batch). A write-phase failure is caught and rolled back here,
|
|
87
|
+
// inside prepare itself - never left for the caller to discover -
|
|
88
|
+
// so prepare either fully succeeds or fully undoes itself before
|
|
89
|
+
// throwing.
|
|
90
|
+
export function preparePublishDrafts(config, themeSchemas, relativePaths) {
|
|
91
|
+
// Reject duplicates up front: without this, a repeated path's live-
|
|
92
|
+
// file snapshot would capture already-overwritten state instead of
|
|
93
|
+
// the true original, corrupting any later rollback.
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
for (const relativePath of relativePaths) {
|
|
96
|
+
if (seen.has(relativePath)) {
|
|
97
|
+
throw new PublishError('duplicate-path', `Duplicate path in publish request: "${relativePath}"`);
|
|
98
|
+
}
|
|
99
|
+
seen.add(relativePath);
|
|
100
|
+
}
|
|
101
|
+
// Validate every path before writing anything. Nothing is written
|
|
102
|
+
// during this pass, so "no files change and no commit is created" on
|
|
103
|
+
// any failure (C6) falls out of ordering, not cleanup logic. Safe
|
|
104
|
+
// because the write queue serialises everything: no other job can
|
|
105
|
+
// interleave and change a draft between this pass and the write pass.
|
|
106
|
+
const entries = [];
|
|
107
|
+
for (const relativePath of relativePaths) {
|
|
108
|
+
const draftPath = sanitisePath(config.draftsRoot, relativePath);
|
|
109
|
+
const livePath = sanitisePath(config.contentRoot, relativePath);
|
|
110
|
+
let draftContent;
|
|
111
|
+
try {
|
|
112
|
+
draftContent = readFileSync(draftPath);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw new PublishError('draft-not-found', `No draft found at "${relativePath}"`);
|
|
116
|
+
}
|
|
117
|
+
const parsed = JSON.parse(draftContent.toString('utf-8'));
|
|
118
|
+
const result = validateContent(relativePath, parsed, themeSchemas);
|
|
119
|
+
if (!result.valid) {
|
|
120
|
+
throw new PublishError('validation-failed', `Draft at "${relativePath}" failed validation: ${JSON.stringify(result.errors)}`);
|
|
121
|
+
}
|
|
122
|
+
entries.push({ relativePath, draftPath, livePath, draftContent });
|
|
123
|
+
}
|
|
124
|
+
const snapshots = [];
|
|
125
|
+
let redirectsSnapshot;
|
|
126
|
+
try {
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
let liveExisted = false;
|
|
129
|
+
let liveOriginal;
|
|
130
|
+
try {
|
|
131
|
+
liveOriginal = readFileSync(entry.livePath);
|
|
132
|
+
liveExisted = true;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
liveExisted = false;
|
|
136
|
+
}
|
|
137
|
+
snapshots.push({
|
|
138
|
+
livePath: entry.livePath,
|
|
139
|
+
liveExisted,
|
|
140
|
+
liveOriginal,
|
|
141
|
+
draftPath: entry.draftPath,
|
|
142
|
+
draftOriginal: entry.draftContent,
|
|
143
|
+
});
|
|
144
|
+
mkdirSync(dirname(entry.livePath), { recursive: true });
|
|
145
|
+
writeFileSync(entry.livePath, entry.draftContent);
|
|
146
|
+
unlinkSync(entry.draftPath);
|
|
147
|
+
}
|
|
148
|
+
// E5 (publish half): a brand-new live page may supersede a stale
|
|
149
|
+
// redirect recorded at its own URL (checklist wording: "creating a
|
|
150
|
+
// page at a path that has a redirect entry"). Only applies to page
|
|
151
|
+
// content - redirect semantics aren't defined for menus (no public
|
|
152
|
+
// URL), so anything else is skipped silently via
|
|
153
|
+
// urlForPublishedEntry's null return.
|
|
154
|
+
const qualifyingUrls = entries
|
|
155
|
+
.map((entry) => urlForPublishedEntry(entry.relativePath))
|
|
156
|
+
.filter((url) => url !== null);
|
|
157
|
+
if (qualifyingUrls.length > 0) {
|
|
158
|
+
const existed = existsSync(config.redirectsPath);
|
|
159
|
+
const original = existed ? readFileSync(config.redirectsPath, 'utf-8') : '';
|
|
160
|
+
let entriesAfterRemoval = loadRedirects(config).entries;
|
|
161
|
+
for (const url of qualifyingUrls) {
|
|
162
|
+
entriesAfterRemoval = removeRedirectForPath(entriesAfterRemoval, url);
|
|
163
|
+
}
|
|
164
|
+
const updated = serialiseRedirects(entriesAfterRemoval);
|
|
165
|
+
if (updated !== (existed ? original : '')) {
|
|
166
|
+
writeFileSync(config.redirectsPath, updated);
|
|
167
|
+
redirectsSnapshot = { path: config.redirectsPath, existed, original };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// A write-phase failure: roll back immediately, inside prepare
|
|
173
|
+
// itself. cause here can never be a GitOperationError (commitPaths
|
|
174
|
+
// hasn't been called yet), so rollbackOrRethrow's own
|
|
175
|
+
// instanceof-based classification correctly resolves to
|
|
176
|
+
// 'write-failed', never 'commit-failed'.
|
|
177
|
+
rollbackOrRethrow(snapshots, error, redirectsSnapshot);
|
|
178
|
+
}
|
|
179
|
+
// Only the live paths (and redirects.json, if it changed) are ever
|
|
180
|
+
// staged: drafts are never git-tracked in the first place (saves
|
|
181
|
+
// don't commit, per C2), so a draft's deletion has no git-visible
|
|
182
|
+
// effect at all. Including it in the commit would fail outright,
|
|
183
|
+
// since `git add` on a path that is both absent from disk and was
|
|
184
|
+
// never tracked is a hard error ("pathspec did not match any
|
|
185
|
+
// files"), not a silent no-op.
|
|
186
|
+
const paths = entries.map((entry) => entry.livePath);
|
|
187
|
+
if (redirectsSnapshot) {
|
|
188
|
+
paths.push(redirectsSnapshot.path);
|
|
189
|
+
}
|
|
190
|
+
return { paths, undo: () => rollback(snapshots, redirectsSnapshot) };
|
|
191
|
+
}
|
|
192
|
+
async function publishDraftsJob(config, themeSchemas, relativePaths, message, author) {
|
|
193
|
+
const { paths, undo } = preparePublishDrafts(config, themeSchemas, relativePaths);
|
|
194
|
+
try {
|
|
195
|
+
// commitPaths gives its own all-or-nothing contract for git staging,
|
|
196
|
+
// so a failure here only ever leaves filesystem state for this
|
|
197
|
+
// catch block to worry about, never git index state.
|
|
198
|
+
commitPaths(config.siteRoot, paths, message, author);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const failures = undo();
|
|
202
|
+
if (failures.length > 0) {
|
|
203
|
+
throw new PublishError('rollback-failed', 'Publish failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
204
|
+
}
|
|
205
|
+
// Always commit-failed here, structurally: prepare already
|
|
206
|
+
// succeeded (no write-phase error), so anything caught in this try
|
|
207
|
+
// can only have come from commitPaths itself.
|
|
208
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
209
|
+
throw new PublishError('commit-failed', `Publish failed: ${errorMessage}`, { cause: error });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async function unpublishPageJob(config, relativePath, message, author) {
|
|
213
|
+
const livePath = sanitisePath(config.contentRoot, relativePath);
|
|
214
|
+
let original;
|
|
215
|
+
try {
|
|
216
|
+
original = readFileSync(livePath);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new PublishError('page-not-found', `No live page found at "${relativePath}"`);
|
|
220
|
+
}
|
|
221
|
+
const parsed = JSON.parse(original.toString('utf-8'));
|
|
222
|
+
parsed.published = false;
|
|
223
|
+
const updated = Buffer.from(JSON.stringify(parsed, null, 2));
|
|
224
|
+
// Unpublish only ever touches one file and never a draft, so it gets
|
|
225
|
+
// its own minimal inline restore rather than routing through the
|
|
226
|
+
// two-file publish rollback() above, which would need an unused
|
|
227
|
+
// draft slot to fit the same shape for no benefit.
|
|
228
|
+
try {
|
|
229
|
+
writeFileSync(livePath, updated);
|
|
230
|
+
commitPaths(config.siteRoot, [livePath], message, author);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
try {
|
|
234
|
+
writeFileSync(livePath, original);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
throw new PublishError('rollback-failed', 'Unpublish failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
238
|
+
}
|
|
239
|
+
const reason = error instanceof GitOperationError ? 'commit-failed' : 'write-failed';
|
|
240
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
241
|
+
throw new PublishError(reason, `Unpublish failed: ${errorMessage}`, { cause: error });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// The exact twin of unpublishPageJob above, flipping the same flag the
|
|
245
|
+
// other way: reads the live file, sets published true, commits. Kept as
|
|
246
|
+
// its own job rather than a parameterised shared one - the two read
|
|
247
|
+
// identically but say opposite things, and a boolean argument at every
|
|
248
|
+
// call site ("publishPage(config, path, true)") reads far worse than
|
|
249
|
+
// two named functions.
|
|
250
|
+
//
|
|
251
|
+
// Deliberately does NOT validate against the theme schemas, matching
|
|
252
|
+
// unpublish rather than publishDrafts: this only ever touches content
|
|
253
|
+
// that is already live, and was therefore already validated when it was
|
|
254
|
+
// published in the first place. publishDrafts validates because it
|
|
255
|
+
// promotes a draft, which may never have been checked before.
|
|
256
|
+
//
|
|
257
|
+
// Like unpublish, never touches a draft. A page with unpublished edits
|
|
258
|
+
// pending keeps them, and this only changes whether what is already
|
|
259
|
+
// live is publicly visible.
|
|
260
|
+
async function publishPageJob(config, relativePath, message, author) {
|
|
261
|
+
const livePath = sanitisePath(config.contentRoot, relativePath);
|
|
262
|
+
let original;
|
|
263
|
+
try {
|
|
264
|
+
original = readFileSync(livePath);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw new PublishError('page-not-found', `No live page found at "${relativePath}"`);
|
|
268
|
+
}
|
|
269
|
+
const parsed = JSON.parse(original.toString('utf-8'));
|
|
270
|
+
parsed.published = true;
|
|
271
|
+
const updated = Buffer.from(JSON.stringify(parsed, null, 2));
|
|
272
|
+
// Same minimal inline restore unpublishPageJob uses, and for the same
|
|
273
|
+
// reason - one file, no draft, so the two-file publish rollback()
|
|
274
|
+
// above would need an unused draft slot for no benefit.
|
|
275
|
+
try {
|
|
276
|
+
writeFileSync(livePath, updated);
|
|
277
|
+
commitPaths(config.siteRoot, [livePath], message, author);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
try {
|
|
281
|
+
writeFileSync(livePath, original);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
throw new PublishError('rollback-failed', 'Publishing this page failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
285
|
+
}
|
|
286
|
+
const reason = error instanceof GitOperationError ? 'commit-failed' : 'write-failed';
|
|
287
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
288
|
+
throw new PublishError(reason, `Publishing this page failed: ${errorMessage}`, { cause: error });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
export function publishDrafts(config, themeSchemas, relativePaths, message, author) {
|
|
292
|
+
const result = enqueue(() => publishDraftsJob(config, themeSchemas, relativePaths, message, author));
|
|
293
|
+
// Only on success - a failed publish changed nothing, so there's
|
|
294
|
+
// nothing to reindex. Chained onto `result` rather than awaited here:
|
|
295
|
+
// by the time this callback runs, the write-queue's own tail has
|
|
296
|
+
// already advanced past this job, so reindexInBackground's own
|
|
297
|
+
// enqueue()d rebuild queues cleanly behind it (see that function's
|
|
298
|
+
// own comment on why calling it any earlier would deadlock).
|
|
299
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
export function unpublishPage(config, relativePath, message, author) {
|
|
303
|
+
const result = enqueue(() => unpublishPageJob(config, relativePath, message, author));
|
|
304
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
// Twin of unpublishPage - same queue, same background reindex (a page
|
|
308
|
+
// becoming visible changes the index exactly as much as one leaving it).
|
|
309
|
+
export function publishPage(config, relativePath, message, author) {
|
|
310
|
+
const result = enqueue(() => publishPageJob(config, relativePath, message, author));
|
|
311
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Shared rate-limit config markers for route options objects
|
|
2
|
+
// (Fastify's config: { rateLimit: {...} } opt-in shape, consumed by
|
|
3
|
+
// @fastify/rate-limit registered with global: false in server.ts).
|
|
4
|
+
// One constant per tier, not ad-hoc literals at each of the ~10 call
|
|
5
|
+
// sites, both for DRYness and so the structural test
|
|
6
|
+
// (test/static/static-analysis.test.ts) has something unambiguous to
|
|
7
|
+
// grep for.
|
|
8
|
+
// An empty per-route override: @fastify/rate-limit (registered with
|
|
9
|
+
// global: false in server.ts) still requires each route to opt in via
|
|
10
|
+
// this marker, but an empty {} inherits the plugin's own registration-
|
|
11
|
+
// level max/timeWindow - which server.ts sets from serverConfig.rateLimit
|
|
12
|
+
// (site.config.json-configurable, defaulting to 60/min - see
|
|
13
|
+
// server-config.ts). Verified empirically before relying on it: a
|
|
14
|
+
// route's own config.rateLimit only needs *something* present to opt
|
|
15
|
+
// in, not a duplicated copy of the numbers.
|
|
16
|
+
export const WRITE_ROUTE_RATE_LIMIT = { rateLimit: {} };
|
|
17
|
+
// Generous defense-in-depth against basic scanning/abuse of the
|
|
18
|
+
// endpoints reachable with zero credentials (GET /v1/capabilities,
|
|
19
|
+
// GET /v1/search), not a meaningful throttle on legitimate use - a
|
|
20
|
+
// real front-end calling search on every keystroke of a live search
|
|
21
|
+
// box should never realistically hit this.
|
|
22
|
+
export const NO_AUTH_ROUTE_RATE_LIMIT = { rateLimit: { max: 300, timeWindow: 60000 } };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
export type RedirectReason = 'redirect-cycle' | 'malformed-file' | 'invalid-target';
|
|
3
|
+
export declare class RedirectError extends Error {
|
|
4
|
+
readonly reason: RedirectReason;
|
|
5
|
+
constructor(reason: RedirectReason, message: string);
|
|
6
|
+
}
|
|
7
|
+
export interface RedirectEntry {
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
note?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Redirects {
|
|
13
|
+
schemaVersion: number;
|
|
14
|
+
entries: RedirectEntry[];
|
|
15
|
+
}
|
|
16
|
+
export declare const CURRENT_REDIRECTS_SCHEMA_VERSION = 1;
|
|
17
|
+
export declare function loadRedirects(config: SiteConfig): Redirects;
|
|
18
|
+
export declare function loadRedirectsStrict(config: SiteConfig): Redirects;
|
|
19
|
+
export declare function serialiseRedirects(entries: RedirectEntry[]): string;
|
|
20
|
+
export declare function buildRedirectLookup(entries: RedirectEntry[]): Map<string, string>;
|
|
21
|
+
export interface AddRedirectResult {
|
|
22
|
+
entries: RedirectEntry[];
|
|
23
|
+
retargeted: RedirectEntry[];
|
|
24
|
+
}
|
|
25
|
+
export declare function addRedirect(entries: RedirectEntry[], from: string, to: string, note?: string): AddRedirectResult;
|
|
26
|
+
export declare function removeRedirectForPath(entries: RedirectEntry[], url: string): RedirectEntry[];
|
|
27
|
+
export declare function isValidRedirectTarget(value: string): boolean;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { validateRedirects } from "./validation.js";
|
|
3
|
+
export class RedirectError extends Error {
|
|
4
|
+
reason;
|
|
5
|
+
constructor(reason, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'RedirectError';
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export const CURRENT_REDIRECTS_SCHEMA_VERSION = 1;
|
|
12
|
+
const EMPTY_REDIRECTS = { schemaVersion: CURRENT_REDIRECTS_SCHEMA_VERSION, entries: [] };
|
|
13
|
+
function isPlainObject(value) {
|
|
14
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
// The old shape (pre-Group M) was a flat { from: to } map, with no
|
|
17
|
+
// schemaVersion or entries key at all. Identified purely by the
|
|
18
|
+
// ABSENCE of both new-shape keys - a file that has an `entries` key is
|
|
19
|
+
// always treated as new-shape from here on, even if malformed, so a
|
|
20
|
+
// corrupt new-shape file is never silently reinterpreted as an empty
|
|
21
|
+
// old-shape map (which would look like "no redirects" and risk a
|
|
22
|
+
// write-time caller clobbering real data - see loadRedirectsStrict).
|
|
23
|
+
function isOldFlatShape(value) {
|
|
24
|
+
return !('schemaVersion' in value) && !('entries' in value);
|
|
25
|
+
}
|
|
26
|
+
function upgradeOldFlatShape(value) {
|
|
27
|
+
const entries = Object.entries(value)
|
|
28
|
+
.filter((pair) => typeof pair[1] === 'string')
|
|
29
|
+
.map(([from, to]) => ({ from, to }));
|
|
30
|
+
return { schemaVersion: CURRENT_REDIRECTS_SCHEMA_VERSION, entries };
|
|
31
|
+
}
|
|
32
|
+
// Read-time loader, used by public request handling (resolve-url.ts,
|
|
33
|
+
// resolve-blog-url.ts) as well as by write paths before they call
|
|
34
|
+
// loadRedirectsStrict for the extra guarantee. Graceful-empty on any
|
|
35
|
+
// failure - including a malformed new-shape file - matching
|
|
36
|
+
// theme-schemas.ts's established pattern: a corrupt redirects.json
|
|
37
|
+
// must never crash public page serving, it should just mean redirects
|
|
38
|
+
// don't resolve until the file is fixed.
|
|
39
|
+
export function loadRedirects(config) {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = JSON.parse(readFileSync(config.redirectsPath, 'utf-8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return EMPTY_REDIRECTS;
|
|
46
|
+
}
|
|
47
|
+
if (!isPlainObject(raw)) {
|
|
48
|
+
return EMPTY_REDIRECTS;
|
|
49
|
+
}
|
|
50
|
+
if (isOldFlatShape(raw)) {
|
|
51
|
+
return upgradeOldFlatShape(raw);
|
|
52
|
+
}
|
|
53
|
+
const result = validateRedirects(raw);
|
|
54
|
+
if (!result.valid) {
|
|
55
|
+
return EMPTY_REDIRECTS;
|
|
56
|
+
}
|
|
57
|
+
return raw;
|
|
58
|
+
}
|
|
59
|
+
// Stricter sibling of loadRedirects, for write paths only (manage-
|
|
60
|
+
// redirects.ts, and any other future direct redirect writer). Unlike
|
|
61
|
+
// the graceful read-time loader, this throws on a malformed new-shape
|
|
62
|
+
// file instead of silently treating it as empty - a write that follows
|
|
63
|
+
// an incorrectly-"empty" load would overwrite and destroy whatever
|
|
64
|
+
// real entries the file actually held, which is a much worse outcome
|
|
65
|
+
// for a write than for a page render.
|
|
66
|
+
export function loadRedirectsStrict(config) {
|
|
67
|
+
if (!existsSync(config.redirectsPath)) {
|
|
68
|
+
return EMPTY_REDIRECTS;
|
|
69
|
+
}
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = JSON.parse(readFileSync(config.redirectsPath, 'utf-8'));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new RedirectError('malformed-file', 'redirects.json exists but is not valid JSON; refusing to write until it is fixed');
|
|
76
|
+
}
|
|
77
|
+
if (!isPlainObject(raw)) {
|
|
78
|
+
throw new RedirectError('malformed-file', 'redirects.json exists but is not a JSON object');
|
|
79
|
+
}
|
|
80
|
+
if (isOldFlatShape(raw)) {
|
|
81
|
+
return upgradeOldFlatShape(raw);
|
|
82
|
+
}
|
|
83
|
+
const result = validateRedirects(raw);
|
|
84
|
+
if (!result.valid) {
|
|
85
|
+
throw new RedirectError('malformed-file', `redirects.json exists but does not match the expected shape: ${result.errors.map((e) => e.message).join(', ')}`);
|
|
86
|
+
}
|
|
87
|
+
return raw;
|
|
88
|
+
}
|
|
89
|
+
export function serialiseRedirects(entries) {
|
|
90
|
+
const redirects = { schemaVersion: CURRENT_REDIRECTS_SCHEMA_VERSION, entries };
|
|
91
|
+
return JSON.stringify(redirects, null, 2);
|
|
92
|
+
}
|
|
93
|
+
export function buildRedirectLookup(entries) {
|
|
94
|
+
return new Map(entries.map((entry) => [entry.from, entry.to]));
|
|
95
|
+
}
|
|
96
|
+
// Collapses chains at write time. Walks `to` forward through any
|
|
97
|
+
// existing chain to its final destination (not just one hop), then
|
|
98
|
+
// rewrites every existing entry that pointed at the just-added `from`
|
|
99
|
+
// to point at that final destination too, so old entries stay
|
|
100
|
+
// collapsed alongside the new one. Also handles updating an existing
|
|
101
|
+
// entry in place: if `from` already has an entry, it is replaced.
|
|
102
|
+
export function addRedirect(entries, from, to, note) {
|
|
103
|
+
const byFrom = new Map(entries.map((entry) => [entry.from, entry]));
|
|
104
|
+
let target = to;
|
|
105
|
+
const visited = new Set();
|
|
106
|
+
for (;;) {
|
|
107
|
+
const next = byFrom.get(target)?.to;
|
|
108
|
+
if (next === undefined) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (visited.has(target)) {
|
|
112
|
+
throw new RedirectError('redirect-cycle', `redirects.json already contains a cycle reachable from "${to}"`);
|
|
113
|
+
}
|
|
114
|
+
visited.add(target);
|
|
115
|
+
target = next;
|
|
116
|
+
}
|
|
117
|
+
// The walk above only detects a cycle among *pre-existing* entries.
|
|
118
|
+
// A walk that terminates normally can still resolve back to `from`
|
|
119
|
+
// itself (e.g. adding "/b" -> "/a" when "/a" -> "/b" already exists:
|
|
120
|
+
// the walk hits "/b", which has no further mapping, and stops without
|
|
121
|
+
// revisiting anything) - that's a self-referential redirect, checked
|
|
122
|
+
// separately here.
|
|
123
|
+
if (target === from) {
|
|
124
|
+
throw new RedirectError('redirect-cycle', `Adding redirect "${from}" -> "${to}" would create a cycle (resolves back to "${from}")`);
|
|
125
|
+
}
|
|
126
|
+
const retargeted = [];
|
|
127
|
+
const updated = [];
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (entry.from === from) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (entry.to === from) {
|
|
133
|
+
const rewritten = { ...entry, to: target };
|
|
134
|
+
retargeted.push(rewritten);
|
|
135
|
+
updated.push(rewritten);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
updated.push(entry);
|
|
139
|
+
}
|
|
140
|
+
const newEntry = note !== undefined ? { from, to: target, note } : { from, to: target };
|
|
141
|
+
updated.push(newEntry);
|
|
142
|
+
return { entries: updated, retargeted };
|
|
143
|
+
}
|
|
144
|
+
export function removeRedirectForPath(entries, url) {
|
|
145
|
+
if (!entries.some((entry) => entry.from === url)) {
|
|
146
|
+
return entries;
|
|
147
|
+
}
|
|
148
|
+
return entries.filter((entry) => entry.from !== url);
|
|
149
|
+
}
|
|
150
|
+
// Checked by char code rather than a regex control-character class, to
|
|
151
|
+
// avoid embedding literal control characters in source.
|
|
152
|
+
function hasControlCharacter(value) {
|
|
153
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
154
|
+
const code = value.charCodeAt(i);
|
|
155
|
+
if (code <= 0x1f || code === 0x7f) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
// Shared by manage-redirects.ts (POST/PUT /v1/redirects) and the
|
|
162
|
+
// retroactive fix to delete-content.ts's client-supplied redirectTo -
|
|
163
|
+
// both must enforce the same rule on a redirect target, or the two
|
|
164
|
+
// write paths would silently disagree on what's allowed into the same
|
|
165
|
+
// file. Internal-only: must start with a single "/", never a scheme,
|
|
166
|
+
// host, or protocol-relative "//" - closes an open-redirect vector.
|
|
167
|
+
// Control characters are rejected regardless of the internal/external
|
|
168
|
+
// question, since public.ts writes this value directly into the
|
|
169
|
+
// Location response header.
|
|
170
|
+
export function isValidRedirectTarget(value) {
|
|
171
|
+
if (value.length === 0 || hasControlCharacter(value)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return value.startsWith('/') && !value.startsWith('//');
|
|
175
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { rebuildIndex, rebuildIndexIfMissing } from "../search/rebuild-index.js";
|
|
2
|
+
// Search is a derived, disposable index (constraint 3) - previously
|
|
3
|
+
// nothing ever rebuilt it except a caller manually hitting
|
|
4
|
+
// POST /v1/search/rebuild, so GET /search.json stayed empty forever on
|
|
5
|
+
// a fresh site and stale forever after a real edit. This is the one
|
|
6
|
+
// place that closes that gap: called after a content-affecting write's
|
|
7
|
+
// own enqueue()d promise has already resolved.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately fire-and-forget, never awaited by the caller: rebuildIndex
|
|
10
|
+
// is itself enqueue()d internally (search/rebuild-index.ts) - awaiting it
|
|
11
|
+
// from inside the very job whose own completion is what let this run
|
|
12
|
+
// would deadlock (routes/search.ts's own comment describes the same
|
|
13
|
+
// hazard for a second, nested enqueue() call). A failed reindex must
|
|
14
|
+
// also never surface as a failure of the write that triggered it - the
|
|
15
|
+
// write already succeeded, and the index is rebuildable from content at
|
|
16
|
+
// any time, so a logged-and-swallowed failure here is the correct
|
|
17
|
+
// severity, not a thrown error.
|
|
18
|
+
export function reindexInBackground(config) {
|
|
19
|
+
void rebuildIndex(config).catch((error) => {
|
|
20
|
+
console.error('Background search reindex failed:', error);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// startServer's own boot-time call - see rebuildIndexIfMissing's own
|
|
24
|
+
// comment for why this is conditional (existsSync), unlike the
|
|
25
|
+
// unconditional reindexInBackground above.
|
|
26
|
+
export function reindexOnBootIfMissing(config) {
|
|
27
|
+
void rebuildIndexIfMissing(config).catch((error) => {
|
|
28
|
+
console.error('Background search reindex failed:', error);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
export type ResolvedBlogUrl = {
|
|
3
|
+
kind: 'post';
|
|
4
|
+
relativePath: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'redirect';
|
|
7
|
+
to: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'not-found';
|
|
10
|
+
};
|
|
11
|
+
export declare function resolveBlogUrl(config: SiteConfig, url: string): ResolvedBlogUrl;
|