@minamocms/framework 0.0.0-stage → 0.10.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/CHANGELOG.md +240 -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 +94 -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
package/dist/server.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import rateLimitPlugin from '@fastify/rate-limit';
|
|
2
|
+
import Fastify, {} from 'fastify';
|
|
3
|
+
import { bootSite } from "./boot.js";
|
|
4
|
+
import { loadServerConfig } from "./server-config.js";
|
|
5
|
+
import { adminRedirectRoutes } from "./routes/admin-redirect.js";
|
|
6
|
+
import { assetsRoutes } from "./routes/assets.js";
|
|
7
|
+
import { v1Routes } from "./routes/index.js";
|
|
8
|
+
import { mediaPublicRoutes } from "./routes/media-public.js";
|
|
9
|
+
import { publicRoutes } from "./routes/public.js";
|
|
10
|
+
import { searchPublicRoutes } from "./routes/search-public.js";
|
|
11
|
+
import { sitemapRoutes } from "./routes/sitemap.js";
|
|
12
|
+
import { CHECKPOINT_AUTHOR, runCheckpoint } from "./services/checkpoint.js";
|
|
13
|
+
import { startDevTunnel } from "./services/dev-tunnel.js";
|
|
14
|
+
import { startIntervalJob } from "./services/interval-job.js";
|
|
15
|
+
import { reindexOnBootIfMissing } from "./services/reindex-on-write.js";
|
|
16
|
+
import { isProcessAlive, readPidFile, removeOwnPidFile, writePidFile } from "./services/pid-file.js";
|
|
17
|
+
// Never wrap v1Routes (or any route-group plugin it registers) with
|
|
18
|
+
// fastify-plugin (fp()): plain app.register() gives each file its own
|
|
19
|
+
// encapsulation scope by default, which Group B's auth preHandler
|
|
20
|
+
// hooks depend on. Wrapping would silently hoist decorators/hooks to
|
|
21
|
+
// the parent scope and break that later.
|
|
22
|
+
export function buildServer(booted, serverConfig, options = {}) {
|
|
23
|
+
const app = Fastify({ logger: options.logger ?? false, trustProxy: serverConfig.trustProxy });
|
|
24
|
+
// global: false - only routes that explicitly opt in via a
|
|
25
|
+
// config: { rateLimit: {...} } marker on their own route options
|
|
26
|
+
// (see src/services/rate-limit-config.ts) are ever limited. The
|
|
27
|
+
// max/timeWindow set here are the configured (or defaulted)
|
|
28
|
+
// site.config.json values - an opting-in route's own empty
|
|
29
|
+
// { rateLimit: {} } marker inherits these, verified empirically.
|
|
30
|
+
// Registered once here, on the root app (an ancestor of v1Routes and
|
|
31
|
+
// everything it nests), so no individual route file needs to import
|
|
32
|
+
// this plugin itself. Its own 429 response body shape
|
|
33
|
+
// ({statusCode, error, message}) already matches this project's own
|
|
34
|
+
// error-JSON convention exactly - verified empirically before relying
|
|
35
|
+
// on it, no errorResponseBuilder override needed.
|
|
36
|
+
app.register(rateLimitPlugin, {
|
|
37
|
+
global: false,
|
|
38
|
+
max: serverConfig.rateLimit.max,
|
|
39
|
+
timeWindow: serverConfig.rateLimit.windowMs,
|
|
40
|
+
});
|
|
41
|
+
// Fastify's own default 500 body leaks the raw thrown Error.message
|
|
42
|
+
// to the client verbatim - confirmed empirically before this was
|
|
43
|
+
// written. Anything below 500 (e.g. a future route's schema-
|
|
44
|
+
// validation failure) already carries a safe, specific message and
|
|
45
|
+
// is passed through unchanged; only 500-and-above gets sanitised.
|
|
46
|
+
app.setErrorHandler((error, request, reply) => {
|
|
47
|
+
const statusCode = error.statusCode ?? 500;
|
|
48
|
+
if (statusCode >= 500) {
|
|
49
|
+
request.log.error(error);
|
|
50
|
+
reply.code(statusCode).send({
|
|
51
|
+
statusCode,
|
|
52
|
+
error: 'Internal Server Error',
|
|
53
|
+
message: 'Internal Server Error',
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
reply.code(statusCode).send({ statusCode, error: error.name || 'Bad Request', message: error.message });
|
|
58
|
+
});
|
|
59
|
+
app.register(v1Routes, {
|
|
60
|
+
prefix: '/v1',
|
|
61
|
+
config: booted.config,
|
|
62
|
+
theme: booted.theme,
|
|
63
|
+
tokens: serverConfig.tokens,
|
|
64
|
+
ipAllowlist: serverConfig.ipAllowlist,
|
|
65
|
+
maxUploadBytes: serverConfig.media.maxUploadBytes,
|
|
66
|
+
});
|
|
67
|
+
// Registered without a /v1 prefix, alongside v1Routes: this is the
|
|
68
|
+
// site's own public website, not part of the site agent API. Order
|
|
69
|
+
// relative to v1Routes is not load-bearing - Fastify's router
|
|
70
|
+
// prefers exact/prefixed matches over this route's wildcard
|
|
71
|
+
// regardless of registration order (verified empirically). What
|
|
72
|
+
// actually protects unmatched /v1/* paths from being swallowed as
|
|
73
|
+
// page lookups is the explicit guard inside publicRoutes itself.
|
|
74
|
+
app.register(publicRoutes, {
|
|
75
|
+
config: booted.config,
|
|
76
|
+
theme: booted.theme,
|
|
77
|
+
renderCache: booted.renderCache,
|
|
78
|
+
});
|
|
79
|
+
// A more specific static-prefixed route than the public catch-all's
|
|
80
|
+
// own /* wildcard - Fastify prefers it regardless of registration
|
|
81
|
+
// order (verified empirically), so no guard is needed here the way
|
|
82
|
+
// publicRoutes needs one against /v1/*.
|
|
83
|
+
app.register(assetsRoutes, { config: booted.config });
|
|
84
|
+
// Same "more specific than the public catch-all" reasoning as
|
|
85
|
+
// assetsRoutes above. Deliberately not registered inside v1Routes
|
|
86
|
+
// (media.ts's own authenticated /v1/media CRUD routes are what live
|
|
87
|
+
// there) - this is the public, unauthenticated read side, matching
|
|
88
|
+
// assetsRoutes/publicRoutes' own placement.
|
|
89
|
+
//
|
|
90
|
+
// @fastify/multipart itself is NOT registered here, only inside
|
|
91
|
+
// media.ts's own plugin scope - see that file's comment for why
|
|
92
|
+
// (confining it to media routes only, leaving every other route
|
|
93
|
+
// file's default JSON body parsing untouched).
|
|
94
|
+
app.register(mediaPublicRoutes, { config: booted.config });
|
|
95
|
+
// Same "more specific than the public catch-all" reasoning again -
|
|
96
|
+
// this exact path always wins over publicRoutes' /* wildcard, and
|
|
97
|
+
// also always wins over a same-named static file under
|
|
98
|
+
// theme/root/sitemap.xml (see routes/public.ts's root-mirror check).
|
|
99
|
+
app.register(sitemapRoutes, { config: booted.config });
|
|
100
|
+
// Same "more specific than the public catch-all" reasoning again - a
|
|
101
|
+
// stable, public, front-end-facing contract (a theme's own JS calls
|
|
102
|
+
// it directly), not part of the versioned /v1 admin/integration
|
|
103
|
+
// surface, so it lives alongside these other unprefixed routes
|
|
104
|
+
// rather than inside v1Routes (search.ts's /v1/search/rebuild is the
|
|
105
|
+
// authenticated write side that does). GET /search.json, not
|
|
106
|
+
// GET /search: a single reserved path, not a whole prefix, so a
|
|
107
|
+
// site's own content page can still live at the bare /search URL
|
|
108
|
+
// (granite-starter's search-demo section does exactly that).
|
|
109
|
+
app.register(searchPublicRoutes, { config: booted.config });
|
|
110
|
+
// Conditional, unlike every other registration above - this is what
|
|
111
|
+
// makes GET /admin genuinely opt-in rather than a reserved
|
|
112
|
+
// namespace: with no adminBaseUrl configured, the route is never
|
|
113
|
+
// registered at all, so an unconfigured site can still use "admin"
|
|
114
|
+
// as an ordinary page path. See routes/admin-redirect.ts's own
|
|
115
|
+
// comment for the full reasoning.
|
|
116
|
+
if (serverConfig.adminBaseUrl) {
|
|
117
|
+
app.register(adminRedirectRoutes, { adminBaseUrl: serverConfig.adminBaseUrl });
|
|
118
|
+
}
|
|
119
|
+
return app;
|
|
120
|
+
}
|
|
121
|
+
// Neither a recurring timer nor process signal handling exists
|
|
122
|
+
// anywhere else in this codebase - this is genuinely new machinery for
|
|
123
|
+
// the low-frequency draft-checkpoint background job (checklist H4).
|
|
124
|
+
// Wired only here, never in buildServer: every existing route test
|
|
125
|
+
// uses buildServer + .inject() directly and must stay completely
|
|
126
|
+
// unaffected by any of this.
|
|
127
|
+
export async function startServer(siteRoot, options = {}) {
|
|
128
|
+
const booted = bootSite(siteRoot);
|
|
129
|
+
const serverConfig = loadServerConfig(siteRoot);
|
|
130
|
+
const app = buildServer(booted, serverConfig, options);
|
|
131
|
+
// Previously these three failure cases (missing/invalid {% schema %}
|
|
132
|
+
// block, or a required property with no valid default) were
|
|
133
|
+
// completely silent - a broken component just stopped being
|
|
134
|
+
// selectable, with nothing distinguishing "deliberately excluded"
|
|
135
|
+
// from "you have a typo". Never a boot failure (loadThemeSchemas's
|
|
136
|
+
// own contract), just no longer silent about it either.
|
|
137
|
+
for (const warning of booted.themeSchemas.warnings ?? []) {
|
|
138
|
+
console.warn(warning);
|
|
139
|
+
}
|
|
140
|
+
const doCheckpoint = () => runCheckpoint(booted.config, CHECKPOINT_AUTHOR);
|
|
141
|
+
const scheduler = startIntervalJob(doCheckpoint, serverConfig.checkpointIntervalMs, (error) => {
|
|
142
|
+
app.log.error(error, 'background draft checkpoint failed');
|
|
143
|
+
});
|
|
144
|
+
let tunnel;
|
|
145
|
+
// Node's default action for an unhandled SIGTERM/SIGINT is immediate
|
|
146
|
+
// process termination, bypassing all JS-level cleanup - without
|
|
147
|
+
// this, a real kill/Ctrl-C would never run the final checkpoint
|
|
148
|
+
// (build plan: "and on graceful shutdown"). process.once (not .on)
|
|
149
|
+
// self-removes after firing, and is explicitly removed again inside
|
|
150
|
+
// onClose below, so a test calling app.close() leaves zero global
|
|
151
|
+
// process-level listeners behind across the many startServer/
|
|
152
|
+
// buildServer calls the suite makes.
|
|
153
|
+
const shutdown = () => {
|
|
154
|
+
app.close().catch((error) => app.log.error(error, 'error during shutdown'));
|
|
155
|
+
};
|
|
156
|
+
process.once('SIGTERM', shutdown);
|
|
157
|
+
process.once('SIGINT', shutdown);
|
|
158
|
+
app.addHook('onClose', async () => {
|
|
159
|
+
tunnel?.close();
|
|
160
|
+
scheduler.stop();
|
|
161
|
+
process.removeListener('SIGTERM', shutdown);
|
|
162
|
+
process.removeListener('SIGINT', shutdown);
|
|
163
|
+
removeOwnPidFile(booted.config);
|
|
164
|
+
try {
|
|
165
|
+
await doCheckpoint();
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
app.log.error(error, 'final draft checkpoint on shutdown failed');
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
// host: '0.0.0.0' - Fastify's own default binds 127.0.0.1 only,
|
|
172
|
+
// which is unreachable from outside any container or remote host.
|
|
173
|
+
try {
|
|
174
|
+
await app.listen({ port: serverConfig.port, host: '0.0.0.0' });
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
// A raw EADDRINUSE crash is a wall of Node internals with no
|
|
178
|
+
// indication of what to actually do about it. This is the single
|
|
179
|
+
// most likely startup failure a developer hits on their own
|
|
180
|
+
// machine (something else already using the default port) - tell
|
|
181
|
+
// them the two real ways to pick a different one instead of
|
|
182
|
+
// rethrowing Node's own stack trace.
|
|
183
|
+
if (error instanceof Error && 'code' in error && error.code === 'EADDRINUSE') {
|
|
184
|
+
// Most often it's this same site, started earlier in another
|
|
185
|
+
// terminal - say so, and how to stop it from this one.
|
|
186
|
+
const running = readPidFile(booted.config);
|
|
187
|
+
if (running && running.port === serverConfig.port && running.pid !== process.pid && isProcessAlive(running.pid)) {
|
|
188
|
+
console.error(`This site is already running on port ${serverConfig.port} (process ${running.pid}).`);
|
|
189
|
+
console.error('Stop it with "npm run stop" from vhost/, then start it again.');
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
console.error(`Port ${serverConfig.port} is already in use.`);
|
|
193
|
+
console.error(`Set a different port with the PORT environment variable (e.g. PORT=3001 node server.js), or "port" in vhost/site.config.json.`);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
// The real bound port, not just serverConfig.port verbatim - the
|
|
199
|
+
// same reasoning tests already rely on (app.server.address()):
|
|
200
|
+
// port 0 means "OS picks a free one", so echoing the configured
|
|
201
|
+
// value back would print a meaningless 0 in that case. 127.0.0.1,
|
|
202
|
+
// not the 0.0.0.0 bind host above - that's what's actually
|
|
203
|
+
// reachable and clickable from the machine running this.
|
|
204
|
+
const address = app.server.address();
|
|
205
|
+
if (address !== null && typeof address !== 'string') {
|
|
206
|
+
console.log(`Site running at http://127.0.0.1:${address.port}`);
|
|
207
|
+
// Recorded only once the port is really bound, so a start that
|
|
208
|
+
// fails never leaves a record of a site that isn't running.
|
|
209
|
+
writePidFile(booted.config, address.port);
|
|
210
|
+
}
|
|
211
|
+
// A search index is never git-tracked (constraint 3), so a fresh
|
|
212
|
+
// clone or first-ever boot has no index file - without this,
|
|
213
|
+
// GET /search.json would stay empty until the first content write.
|
|
214
|
+
// Fire-and-forget, after the port is already bound: never blocks
|
|
215
|
+
// startup, and a slow/failed rebuild is never a reason the server
|
|
216
|
+
// itself fails to come up.
|
|
217
|
+
reindexOnBootIfMissing(booted.config);
|
|
218
|
+
if (options.tunnel) {
|
|
219
|
+
try {
|
|
220
|
+
tunnel = await (options.startTunnel ?? startDevTunnel)(serverConfig.port);
|
|
221
|
+
console.log(`Tunnel open: ${tunnel.url}`);
|
|
222
|
+
console.log('This URL is publicly reachable for as long as this process runs - anyone with it (and a valid access token) can reach this site. Do not leave it running unattended.');
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
// The site itself is already up and correctly serving locally -
|
|
226
|
+
// a failed tunnel is not a reason to fail the whole boot.
|
|
227
|
+
app.log.error(error, 'failed to start dev tunnel');
|
|
228
|
+
console.error('Failed to start the dev tunnel - the site is still running locally.');
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
console.log('Tip: run with --tunnel to expose this site publicly for testing against a hosted admin.');
|
|
233
|
+
}
|
|
234
|
+
return app;
|
|
235
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
import type { CommitAuthor } from './git.ts';
|
|
3
|
+
import type { ThemeSchemas } from './validation.ts';
|
|
4
|
+
export type BatchOperation = {
|
|
5
|
+
type: 'draft-write';
|
|
6
|
+
path: string;
|
|
7
|
+
content: unknown;
|
|
8
|
+
expectedEtag: string;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'draft-discard';
|
|
11
|
+
path: string;
|
|
12
|
+
} | {
|
|
13
|
+
type: 'content-delete';
|
|
14
|
+
path: string;
|
|
15
|
+
redirectTo?: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: 'move';
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
};
|
|
21
|
+
export interface BatchPublish {
|
|
22
|
+
relativePaths: string[];
|
|
23
|
+
}
|
|
24
|
+
export type BatchReason = 'validation-failed' | 'conflict' | 'page-not-found' | 'has-children' | 'redirect-cycle' | 'invalid-redirect-target' | 'source-not-found' | 'destination-exists' | 'draft-not-found' | 'duplicate-path' | 'write-failed' | 'commit-failed' | 'rollback-failed';
|
|
25
|
+
export declare class BatchError extends Error {
|
|
26
|
+
readonly reason: BatchReason;
|
|
27
|
+
readonly stage?: 'operation' | 'publish';
|
|
28
|
+
readonly operationIndex?: number;
|
|
29
|
+
constructor(reason: BatchReason, message: string, options?: {
|
|
30
|
+
cause?: unknown;
|
|
31
|
+
stage?: 'operation' | 'publish';
|
|
32
|
+
operationIndex?: number;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export declare function runBatch(config: SiteConfig, themeSchemas: ThemeSchemas, operations: BatchOperation[], publish: BatchPublish | undefined, message: string, author: CommitAuthor): Promise<void>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { DraftError, prepareDiscardDraft, prepareSaveDraft } from "./drafts.js";
|
|
2
|
+
import { DeleteContentError, prepareDeleteContent } from "./delete-content.js";
|
|
3
|
+
import { commitPaths } from "./git.js";
|
|
4
|
+
import { MoveError, prepareMovePage } from "./move.js";
|
|
5
|
+
import { PublishError, preparePublishDrafts } from "./publish.js";
|
|
6
|
+
import { enqueue } from "./write-queue.js";
|
|
7
|
+
import { reindexInBackground } from "./reindex-on-write.js";
|
|
8
|
+
export class BatchError extends Error {
|
|
9
|
+
reason;
|
|
10
|
+
// Which part of the batch failed. operationIndex indexes into the
|
|
11
|
+
// operations array; the trailing publish step is a distinct stage,
|
|
12
|
+
// not folded into that same index space (index 3 could otherwise
|
|
13
|
+
// ambiguously mean "the 4th operation" or "there were only 3
|
|
14
|
+
// operations, so this must be publish").
|
|
15
|
+
stage;
|
|
16
|
+
operationIndex;
|
|
17
|
+
constructor(reason, message, options) {
|
|
18
|
+
super(message, { cause: options?.cause });
|
|
19
|
+
this.name = 'BatchError';
|
|
20
|
+
this.reason = reason;
|
|
21
|
+
this.stage = options?.stage;
|
|
22
|
+
this.operationIndex = options?.operationIndex;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// No whole-batch duplicate-path pre-check: rejected in design review.
|
|
26
|
+
// publishDrafts's own internal duplicate check exists because its
|
|
27
|
+
// rollback restores multiple snapshots in *forward* order within a
|
|
28
|
+
// single call - a real hazard specific to that shape. Undo across
|
|
29
|
+
// batch *operations* runs in *reverse* chronological order instead
|
|
30
|
+
// (see batchJob below), which composes correctly even when multiple
|
|
31
|
+
// operations touch the same path (e.g. move A->B then content-delete
|
|
32
|
+
// B in the same batch unwinds correctly: undo delete restores B to
|
|
33
|
+
// what move wrote, then undo move renames B back to A). Genuine
|
|
34
|
+
// conflicts (two moves to the same destination, etc.) are already
|
|
35
|
+
// caught fresh by each sub-service's own real-time validation when it
|
|
36
|
+
// runs, precisely because processing is sequential.
|
|
37
|
+
async function runOperation(config, themeSchemas, operation) {
|
|
38
|
+
switch (operation.type) {
|
|
39
|
+
case 'draft-write':
|
|
40
|
+
return prepareSaveDraft(config, themeSchemas, operation.path, operation.content, operation.expectedEtag);
|
|
41
|
+
case 'draft-discard':
|
|
42
|
+
return prepareDiscardDraft(config, operation.path);
|
|
43
|
+
case 'content-delete':
|
|
44
|
+
return prepareDeleteContent(config, operation.path, operation.redirectTo);
|
|
45
|
+
case 'move':
|
|
46
|
+
return prepareMovePage(config, operation.from, operation.to);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function reasonFromError(error) {
|
|
50
|
+
if (error instanceof DraftError ||
|
|
51
|
+
error instanceof DeleteContentError ||
|
|
52
|
+
error instanceof MoveError ||
|
|
53
|
+
error instanceof PublishError) {
|
|
54
|
+
return error.reason;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
async function batchJob(config, themeSchemas, operations, publish, message, author) {
|
|
59
|
+
const allPaths = [];
|
|
60
|
+
const undoStack = [];
|
|
61
|
+
// Sequential, not two-phase validate-then-write: operations can be
|
|
62
|
+
// order-dependent within a single batch (e.g. write a draft, then
|
|
63
|
+
// publish that same draft, in one call) - a later step's validation
|
|
64
|
+
// may depend on an earlier step's write having already happened.
|
|
65
|
+
for (let index = 0; index < operations.length; index++) {
|
|
66
|
+
const operation = operations[index];
|
|
67
|
+
try {
|
|
68
|
+
const prepared = await runOperation(config, themeSchemas, operation);
|
|
69
|
+
allPaths.push(...prepared.paths);
|
|
70
|
+
undoStack.push(prepared.undo);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
await rollbackAndThrow(undoStack, error, { stage: 'operation', operationIndex: index });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (publish) {
|
|
77
|
+
try {
|
|
78
|
+
const prepared = preparePublishDrafts(config, themeSchemas, publish.relativePaths);
|
|
79
|
+
allPaths.push(...prepared.paths);
|
|
80
|
+
undoStack.push(prepared.undo);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
await rollbackAndThrow(undoStack, error, { stage: 'publish' });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// A batch of pure draft-write/draft-discard operations legitimately
|
|
87
|
+
// produces zero git-tracked paths (drafts are never git-tracked).
|
|
88
|
+
// git commit with nothing staged exits non-zero ("nothing to
|
|
89
|
+
// commit") - verified empirically - which would otherwise produce a
|
|
90
|
+
// spurious commit-failed (or a spurious full rollback) for a batch
|
|
91
|
+
// that did nothing wrong. Skip the commit entirely when there is
|
|
92
|
+
// nothing to stage.
|
|
93
|
+
if (allPaths.length === 0) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
commitPaths(config.siteRoot, allPaths, message, author);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
await rollbackAndThrow(undoStack, error, {});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Runs every already-succeeded operation's undo, in reverse order,
|
|
104
|
+
// then throws. Never returns normally - always throws either
|
|
105
|
+
// BatchError('rollback-failed') (if any undo itself failed) or a
|
|
106
|
+
// BatchError carrying the original failure's own reason.
|
|
107
|
+
async function rollbackAndThrow(undoStack, cause, context) {
|
|
108
|
+
const failures = [];
|
|
109
|
+
for (const undo of [...undoStack].reverse()) {
|
|
110
|
+
failures.push(...undo());
|
|
111
|
+
}
|
|
112
|
+
if (failures.length > 0) {
|
|
113
|
+
throw new BatchError('rollback-failed', 'Batch failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause, ...context });
|
|
114
|
+
}
|
|
115
|
+
const reason = reasonFromError(cause);
|
|
116
|
+
if (reason) {
|
|
117
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
118
|
+
throw new BatchError(reason, `Batch failed: ${detail}`, { cause, ...context });
|
|
119
|
+
}
|
|
120
|
+
// context.stage is only ever set when this was called from the
|
|
121
|
+
// operations/publish loop, never from the post-loop commitPaths call
|
|
122
|
+
// below - so a non-domain error reaching here (e.g. PathSafetyError
|
|
123
|
+
// thrown by sanitisePath deep inside a prepareX function) came from
|
|
124
|
+
// there too. Rethrow it as-is rather than mislabelling it
|
|
125
|
+
// 'commit-failed': callers up the stack (the route's own
|
|
126
|
+
// PathSafetyError check) need to see the original error type.
|
|
127
|
+
if (context.stage) {
|
|
128
|
+
throw cause;
|
|
129
|
+
}
|
|
130
|
+
// No stage means this can only have come from the commitPaths call
|
|
131
|
+
// just above, which only ever throws GitOperationError.
|
|
132
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
133
|
+
throw new BatchError('commit-failed', `Batch failed: ${detail}`, { cause, ...context });
|
|
134
|
+
}
|
|
135
|
+
export function runBatch(config, themeSchemas, operations, publish, message, author) {
|
|
136
|
+
const result = enqueue(() => batchJob(config, themeSchemas, operations, publish, message, author));
|
|
137
|
+
// One trigger for the whole batch, regardless of which operation
|
|
138
|
+
// types it contained (content-delete/move/publish affect the index,
|
|
139
|
+
// draft-write/draft-discard don't) - simpler and no real cost than
|
|
140
|
+
// inspecting `operations` to decide, since this never blocks the
|
|
141
|
+
// caller either way. See reindex-on-write.ts for why this is
|
|
142
|
+
// fire-and-forget, chained onto `result` rather than awaited here.
|
|
143
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { relative } from 'node:path';
|
|
3
|
+
import { CHECKPOINT_MESSAGE } from "./git-history.js";
|
|
4
|
+
import { commitPaths } from "./git.js";
|
|
5
|
+
import { enqueue } from "./write-queue.js";
|
|
6
|
+
// Resolves the checklist's own carried-forward open question 3: the
|
|
7
|
+
// same shape of question boot.ts already declined to answer for
|
|
8
|
+
// boot-time migrations. A fixed, hardcoded in-code identity - not
|
|
9
|
+
// configurable per-site - so this never depends on host config
|
|
10
|
+
// (matching the Phase 1 sign-off that commits never fall back to
|
|
11
|
+
// ~/.gitconfig), and there's zero possibility of a site operator
|
|
12
|
+
// misconfiguring or omitting it.
|
|
13
|
+
export const CHECKPOINT_AUTHOR = { name: 'Minamo', email: 'minamo@localhost' };
|
|
14
|
+
async function checkpointJob(config, author) {
|
|
15
|
+
// Derived from config.draftsRoot, never a hardcoded literal - Group N
|
|
16
|
+
// nested draftsRoot inside contentRoot (content/drafts), so a fixed
|
|
17
|
+
// 'drafts' pathspec would silently match nothing there, making this
|
|
18
|
+
// checkpoint a permanent no-op with zero error output. Always
|
|
19
|
+
// relative to siteRoot, matching commitPaths'/git status's own
|
|
20
|
+
// pathspec convention below.
|
|
21
|
+
const draftsPathspec = relative(config.siteRoot, config.draftsRoot);
|
|
22
|
+
// "When drafts have changed" (checklist H4) - checked before ever
|
|
23
|
+
// staging/committing, matching the empty-commit lesson batch.ts and
|
|
24
|
+
// git-revert.ts already learned: git commit with nothing staged
|
|
25
|
+
// exits non-zero, which must not surface as a spurious failure for
|
|
26
|
+
// a checkpoint tick that legitimately had nothing to do.
|
|
27
|
+
const status = execFileSync('git', ['status', '--porcelain', '--', draftsPathspec], {
|
|
28
|
+
cwd: config.siteRoot,
|
|
29
|
+
}).toString('utf-8');
|
|
30
|
+
if (status.trim().length === 0) {
|
|
31
|
+
return 'clean';
|
|
32
|
+
}
|
|
33
|
+
// Reuses commitPaths (not commitWorkingTree, which stages the
|
|
34
|
+
// *entire* working tree) - scoped to a single drafts-directory
|
|
35
|
+
// pathspec. Verified empirically that a bare pathspec (no -A flag)
|
|
36
|
+
// correctly stages both modifications and deletions under that
|
|
37
|
+
// directory, essential since a discarded draft (a pure fs removal
|
|
38
|
+
// today, no git call) must be captured by the next checkpoint as a
|
|
39
|
+
// real change, not silently missed.
|
|
40
|
+
commitPaths(config.siteRoot, [draftsPathspec], CHECKPOINT_MESSAGE, author);
|
|
41
|
+
return 'committed';
|
|
42
|
+
}
|
|
43
|
+
export function runCheckpoint(config, author) {
|
|
44
|
+
return enqueue(() => checkpointJob(config, author));
|
|
45
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
export type ContentReadReason = 'not-found';
|
|
3
|
+
export declare class ContentReadError extends Error {
|
|
4
|
+
readonly reason: ContentReadReason;
|
|
5
|
+
constructor(reason: ContentReadReason, message: string);
|
|
6
|
+
}
|
|
7
|
+
export interface ReadResult {
|
|
8
|
+
bytes: Buffer;
|
|
9
|
+
etag: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function readContentFile(root: string, relativePath: string): ReadResult;
|
|
12
|
+
export interface ContentListFilters {
|
|
13
|
+
type?: string;
|
|
14
|
+
prefix?: string;
|
|
15
|
+
draftStatus?: 'has-draft' | 'no-draft';
|
|
16
|
+
}
|
|
17
|
+
export interface ContentListEntry {
|
|
18
|
+
path: string;
|
|
19
|
+
name: string;
|
|
20
|
+
title: string;
|
|
21
|
+
type: string;
|
|
22
|
+
published: boolean;
|
|
23
|
+
hasDraft: boolean;
|
|
24
|
+
url: string | null;
|
|
25
|
+
changedAt: string | null;
|
|
26
|
+
}
|
|
27
|
+
export declare function listContent(config: SiteConfig, filters: ContentListFilters): ContentListEntry[];
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { computeEtag } from "./etag.js";
|
|
4
|
+
import { listFilesRecursively } from "./fs-walk.js";
|
|
5
|
+
import { sanitisePath } from "./path-safety.js";
|
|
6
|
+
import { pagePathToUrl } from "./urls.js";
|
|
7
|
+
export class ContentReadError extends Error {
|
|
8
|
+
reason;
|
|
9
|
+
constructor(reason, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ContentReadError';
|
|
12
|
+
this.reason = reason;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Shared by both content.ts (root = config.contentRoot) and drafts.ts
|
|
16
|
+
// (root = config.draftsRoot) - identical read+hash logic, just a
|
|
17
|
+
// different root. sanitisePath's PathSafetyError is left to propagate
|
|
18
|
+
// uncaught: the route handlers catch it explicitly and map it to 404,
|
|
19
|
+
// the same precedent preview.ts already established (PathSafetyError
|
|
20
|
+
// has no .statusCode, so left uncaught it would fall through to the
|
|
21
|
+
// global handler's generic sanitised 500).
|
|
22
|
+
export function readContentFile(root, relativePath) {
|
|
23
|
+
const fullPath = sanitisePath(root, relativePath);
|
|
24
|
+
if (!existsSync(fullPath)) {
|
|
25
|
+
throw new ContentReadError('not-found', `No file at "${relativePath}"`);
|
|
26
|
+
}
|
|
27
|
+
const bytes = readFileSync(fullPath);
|
|
28
|
+
return { bytes, etag: computeEtag(bytes) };
|
|
29
|
+
}
|
|
30
|
+
// Both contentRoot-relative live paths and draftsRoot-relative draft
|
|
31
|
+
// paths share the identical pages/menus subroot prefix shape
|
|
32
|
+
// (content/drafts/ mirrors content/'s own layout), so one function
|
|
33
|
+
// handles both with no branching on which root an entry came from.
|
|
34
|
+
// Menus have no public URL - there is no preview route for them.
|
|
35
|
+
function computeContentUrl(relativePath) {
|
|
36
|
+
if (relativePath.startsWith('pages/')) {
|
|
37
|
+
return pagePathToUrl(relativePath.slice('pages/'.length));
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// The later of the live file's and the draft's own mtime, whichever
|
|
42
|
+
// exist - a page with a pending draft edit should show that edit's
|
|
43
|
+
// recency, not just whichever file readSummary happened to prefer.
|
|
44
|
+
// File mtime, not a git commit date: a per-path git-log call for
|
|
45
|
+
// every entry in a listing that can run to hundreds of pages isn't
|
|
46
|
+
// viable, and mtime is a reasonable proxy for "last touched" - a
|
|
47
|
+
// known, accepted imprecision (e.g. a fresh git clone/checkout can
|
|
48
|
+
// reset it), not a claim of exact publish history (that's what the
|
|
49
|
+
// page history view's own real git log is for).
|
|
50
|
+
function latestMtime(candidatePaths) {
|
|
51
|
+
let latest = null;
|
|
52
|
+
for (const fullPath of candidatePaths) {
|
|
53
|
+
try {
|
|
54
|
+
const { mtime } = statSync(fullPath);
|
|
55
|
+
if (latest === null || mtime > latest) {
|
|
56
|
+
latest = mtime;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// A path that vanished between the directory walk and this stat
|
|
61
|
+
// (e.g. a concurrent delete) just doesn't contribute - not fatal
|
|
62
|
+
// to the rest of the listing.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return latest ? latest.toISOString() : null;
|
|
66
|
+
}
|
|
67
|
+
// Defensive, not the strict PageContent shape from render-page.ts:
|
|
68
|
+
// boot.ts deliberately never auto-migrates content at startup, so a
|
|
69
|
+
// file sitting at an old schemaVersion (and therefore missing a field
|
|
70
|
+
// as new as "type") is a real, persistent possibility, not a
|
|
71
|
+
// hypothetical - matching rebuild-index.ts's PageForIndex precedent.
|
|
72
|
+
function readSummary(fullPath) {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(readFileSync(fullPath, 'utf-8'));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// GET /v1/content lists the union of contentRoot and draftsRoot paths,
|
|
81
|
+
// not live content alone: Group D's own checklist heading is "content
|
|
82
|
+
// AND draft reads", and there is no separate "list drafts" endpoint
|
|
83
|
+
// anywhere in the build plan - this is the only place a draft-only
|
|
84
|
+
// page (never yet published) is discoverable at all.
|
|
85
|
+
//
|
|
86
|
+
// Deliberately two named walks (pages/menus), not one broad walk of
|
|
87
|
+
// contentRoot: since Group N nested draftsRoot and redirectsPath
|
|
88
|
+
// inside contentRoot (content/drafts/, content/redirects.json), a
|
|
89
|
+
// single broad contentRoot walk would descend into content/drafts/
|
|
90
|
+
// too, double-listing draft files under two different relative-path
|
|
91
|
+
// keys, and would pick up redirects.json as if it were a page. An
|
|
92
|
+
// inclusion-based walk sidesteps both problems structurally, with no
|
|
93
|
+
// exclusion list to maintain. base stays config.contentRoot for both
|
|
94
|
+
// (not each subroot) so the resulting relative paths ("pages/x.json")
|
|
95
|
+
// line up with draftPaths below for the hasDraft/hasLive union logic.
|
|
96
|
+
export function listContent(config, filters) {
|
|
97
|
+
const contentPaths = new Set([
|
|
98
|
+
...listFilesRecursively(config.pagesRoot, config.contentRoot, '.json'),
|
|
99
|
+
...listFilesRecursively(config.menusRoot, config.contentRoot, '.json'),
|
|
100
|
+
]);
|
|
101
|
+
const draftPaths = new Set(listFilesRecursively(config.draftsRoot, config.draftsRoot, '.json'));
|
|
102
|
+
const allPaths = new Set([...contentPaths, ...draftPaths]);
|
|
103
|
+
const entries = [];
|
|
104
|
+
for (const relativePath of allPaths) {
|
|
105
|
+
const hasDraft = draftPaths.has(relativePath);
|
|
106
|
+
const hasLive = contentPaths.has(relativePath);
|
|
107
|
+
// Prefer the live file's fields on conflict - the draft is a
|
|
108
|
+
// pending edit, not yet the source of truth for a content
|
|
109
|
+
// inventory view - but hasDraft still reports the draft's
|
|
110
|
+
// presence either way.
|
|
111
|
+
const summary = hasLive
|
|
112
|
+
? readSummary(join(config.contentRoot, relativePath))
|
|
113
|
+
: readSummary(join(config.draftsRoot, relativePath));
|
|
114
|
+
if (!summary) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const title = typeof summary.title === 'string' ? summary.title : '';
|
|
118
|
+
const entry = {
|
|
119
|
+
path: relativePath,
|
|
120
|
+
// Falls back to title, not '', for content written before "name"
|
|
121
|
+
// existed and not yet migrated (migrations aren't wired to run
|
|
122
|
+
// automatically) - the same default migrateV4ToV5 itself writes,
|
|
123
|
+
// just applied defensively at read time too.
|
|
124
|
+
name: typeof summary.name === 'string' ? summary.name : title,
|
|
125
|
+
title,
|
|
126
|
+
type: typeof summary.type === 'string' ? summary.type : '',
|
|
127
|
+
published: summary.published === true,
|
|
128
|
+
hasDraft,
|
|
129
|
+
url: computeContentUrl(relativePath),
|
|
130
|
+
changedAt: latestMtime([
|
|
131
|
+
...(hasLive ? [join(config.contentRoot, relativePath)] : []),
|
|
132
|
+
...(hasDraft ? [join(config.draftsRoot, relativePath)] : []),
|
|
133
|
+
]),
|
|
134
|
+
};
|
|
135
|
+
if (filters.type !== undefined && entry.type !== filters.type) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (filters.prefix !== undefined && !entry.path.startsWith(filters.prefix)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (filters.draftStatus === 'has-draft' && !entry.hasDraft) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (filters.draftStatus === 'no-draft' && entry.hasDraft) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
entries.push(entry);
|
|
148
|
+
}
|
|
149
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
150
|
+
return entries;
|
|
151
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
import type { CommitAuthor } from './git.ts';
|
|
3
|
+
import type { PreparedOperation } from './prepared-operation.ts';
|
|
4
|
+
export type DeleteContentReason = 'page-not-found' | 'has-children' | 'redirect-cycle' | 'invalid-redirect-target' | 'write-failed' | 'commit-failed' | 'rollback-failed';
|
|
5
|
+
export declare class DeleteContentError extends Error {
|
|
6
|
+
readonly reason: DeleteContentReason;
|
|
7
|
+
constructor(reason: DeleteContentReason, message: string, options?: {
|
|
8
|
+
cause?: unknown;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export declare function prepareDeleteContent(config: SiteConfig, relativePath: string, redirectTo: string | undefined): PreparedOperation;
|
|
12
|
+
export declare function deleteContent(config: SiteConfig, relativePath: string, redirectTo: string | undefined, message: string, author: CommitAuthor): Promise<void>;
|