@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,48 @@
|
|
|
1
|
+
import { Liquid } from 'liquidjs';
|
|
2
|
+
// Called exactly once, inside bootSite - never a setter invoked again
|
|
3
|
+
// mid-process. This is what keeps "every theme file read once at
|
|
4
|
+
// boot" a structural guarantee rather than a convention: a mutable
|
|
5
|
+
// singleton with a snippets setter would let a future caller silently
|
|
6
|
+
// reload snippets without a restart, exactly what this design exists
|
|
7
|
+
// to avoid.
|
|
8
|
+
//
|
|
9
|
+
// Nothing beyond these constructor options is ever configured: no
|
|
10
|
+
// registerTag, no registerFilter, no plugin() - ever (constraint: no
|
|
11
|
+
// dynamically registered Liquid tags or filters). fs and
|
|
12
|
+
// relativeReference are stock constructor options, not registered
|
|
13
|
+
// tags/filters - confirmed empirically that the built-in `render` tag
|
|
14
|
+
// resolves purely against the in-memory `snippets` map below, with no
|
|
15
|
+
// disk access at all, and that a snippet rendered this way is still
|
|
16
|
+
// bounded by renderLimit (see test/renderer/engine.test.ts, D8, and
|
|
17
|
+
// test/renderer/snippets.test.ts).
|
|
18
|
+
//
|
|
19
|
+
// outputEscape: 'escape' makes {{ value }} escape HTML by default (a
|
|
20
|
+
// stock constructor option, not a registered filter/tag), with an
|
|
21
|
+
// explicit `| raw` as the only opt-out - used for pre-rendered block
|
|
22
|
+
// HTML that was already safely rendered by an earlier parseAndRender
|
|
23
|
+
// call (see render-page.ts), and for content_for_layout in a layout
|
|
24
|
+
// template for the same reason.
|
|
25
|
+
//
|
|
26
|
+
// renderLimit: 50 (ms) is the only reliable defence against a runaway
|
|
27
|
+
// template: an external Promise.race/setTimeout wrapper cannot abort a
|
|
28
|
+
// synchronous-only render (LiquidJS's drive loop only yields to the
|
|
29
|
+
// event loop when it awaits an actual Promise), confirmed empirically.
|
|
30
|
+
// This bounds a single parseAndRender call, not a whole page: a page
|
|
31
|
+
// with many sections/blocks each individually finishing just under
|
|
32
|
+
// budget has no aggregate ceiling. A page-level wall-clock guard is a
|
|
33
|
+
// Phase 2 route-handler concern, once there is an actual request to
|
|
34
|
+
// bound - not solved here.
|
|
35
|
+
export function createEngine(snippets) {
|
|
36
|
+
return new Liquid({
|
|
37
|
+
outputEscape: 'escape',
|
|
38
|
+
renderLimit: 50,
|
|
39
|
+
relativeReference: false,
|
|
40
|
+
fs: {
|
|
41
|
+
exists: async (file) => file in snippets,
|
|
42
|
+
existsSync: (file) => file in snippets,
|
|
43
|
+
readFile: async (file) => snippets[file],
|
|
44
|
+
readFileSync: (file) => snippets[file],
|
|
45
|
+
resolve: (_root, file) => file,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface RenderCacheEntry {
|
|
2
|
+
html: string;
|
|
3
|
+
pageMtimeMs: number;
|
|
4
|
+
menusMtimeMs: number;
|
|
5
|
+
themeGeneration: number;
|
|
6
|
+
}
|
|
7
|
+
export interface RenderCache {
|
|
8
|
+
get(renderPath: string): RenderCacheEntry | undefined;
|
|
9
|
+
set(renderPath: string, entry: RenderCacheEntry): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function createRenderCache(): RenderCache;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Liquid } from 'liquidjs';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { ThemeTemplates } from './theme-templates.ts';
|
|
4
|
+
import { type SiteSettings } from '../services/site-settings.ts';
|
|
5
|
+
export type PageRenderReason = 'page-not-found' | 'missing-section-type' | 'missing-block-type' | 'missing-layout' | 'template-error';
|
|
6
|
+
export declare class PageRenderError extends Error {
|
|
7
|
+
readonly reason: PageRenderReason;
|
|
8
|
+
constructor(reason: PageRenderReason, message: string, options?: {
|
|
9
|
+
cause?: unknown;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export interface SectionOrBlockInstance {
|
|
13
|
+
id: string;
|
|
14
|
+
type: string;
|
|
15
|
+
settings: Record<string, unknown>;
|
|
16
|
+
blocks?: SectionOrBlockInstance[];
|
|
17
|
+
}
|
|
18
|
+
export interface PageContent {
|
|
19
|
+
schemaVersion: number;
|
|
20
|
+
title: string;
|
|
21
|
+
published: boolean;
|
|
22
|
+
layout: string;
|
|
23
|
+
sections: SectionOrBlockInstance[];
|
|
24
|
+
author?: string;
|
|
25
|
+
publishDate?: string;
|
|
26
|
+
tags?: string[];
|
|
27
|
+
}
|
|
28
|
+
export declare function renderSections(page: PageContent, themeTemplates: ThemeTemplates, engine: Liquid, siteSettings?: SiteSettings): Promise<string>;
|
|
29
|
+
export type RenderMode = 'public' | 'preview';
|
|
30
|
+
export declare function parsePageContent(raw: string): PageContent;
|
|
31
|
+
export declare function renderLoadedPage(page: PageContent, config: SiteConfig, themeTemplates: ThemeTemplates, layouts: Record<string, string>, engine: Liquid, siteSettings?: SiteSettings): Promise<string>;
|
|
32
|
+
export declare function renderPage(config: SiteConfig, themeTemplates: ThemeTemplates, layouts: Record<string, string>, engine: Liquid, relativePath: string, mode: RenderMode, siteSettings?: SiteSettings): Promise<string>;
|
|
33
|
+
export declare function getPageMtimeMs(config: SiteConfig, relativePath: string): number | null;
|
|
34
|
+
export declare function getMenusMtimeMs(config: SiteConfig): number;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { listFilesRecursively } from "../services/fs-walk.js";
|
|
3
|
+
import { loadMenus } from "../services/menus.js";
|
|
4
|
+
import { sanitisePath } from "../services/path-safety.js";
|
|
5
|
+
import { siteSettingsMtimeMs } from "../services/site-settings.js";
|
|
6
|
+
export class PageRenderError extends Error {
|
|
7
|
+
reason;
|
|
8
|
+
constructor(reason, message, options) {
|
|
9
|
+
super(message, options);
|
|
10
|
+
this.name = 'PageRenderError';
|
|
11
|
+
this.reason = reason;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function pageEnvelope(page) {
|
|
15
|
+
return { title: page.title, author: page.author, publishDate: page.publishDate, tags: page.tags };
|
|
16
|
+
}
|
|
17
|
+
// Renders one section or block, recursively rendering any nested blocks
|
|
18
|
+
// first (instance.schema.json is self-referential, so a block can carry
|
|
19
|
+
// its own nested blocks). Never uses {% include %}/{% render %}: block
|
|
20
|
+
// HTML is pre-rendered here in JS and handed to the parent template as
|
|
21
|
+
// a plain array, which sidesteps LiquidJS's own filesystem include
|
|
22
|
+
// resolution entirely.
|
|
23
|
+
// Site settings reach every template in a render - layout, sections,
|
|
24
|
+
// blocks, and snippets pulled in with {% render %} (which otherwise sees
|
|
25
|
+
// only what it is passed) - as `settings`, via Liquid's per-render
|
|
26
|
+
// globals. Shopify's name for the same thing; no clash with
|
|
27
|
+
// section.settings/block.settings, which are nested.
|
|
28
|
+
function settingsGlobals(siteSettings) {
|
|
29
|
+
return { globals: { settings: siteSettings } };
|
|
30
|
+
}
|
|
31
|
+
async function renderInstance(instance, kind, themeTemplates, engine, page, siteSettings) {
|
|
32
|
+
const templates = kind === 'section' ? themeTemplates.sections : themeTemplates.blocks;
|
|
33
|
+
const template = templates[instance.type];
|
|
34
|
+
if (!template) {
|
|
35
|
+
throw new PageRenderError(kind === 'section' ? 'missing-section-type' : 'missing-block-type', `${kind === 'section' ? 'Section' : 'Block'} type "${instance.type}" is missing from the theme (${kind} id: "${instance.id}")`);
|
|
36
|
+
}
|
|
37
|
+
const blocksHtml = [];
|
|
38
|
+
for (const block of instance.blocks ?? []) {
|
|
39
|
+
blocksHtml.push(await renderInstance(block, 'block', themeTemplates, engine, page, siteSettings));
|
|
40
|
+
}
|
|
41
|
+
// Shopify-style scope shape: settings nested under the instance, not
|
|
42
|
+
// flattened, so templates read section.settings.x / block.settings.x.
|
|
43
|
+
// `page` is the same built-in envelope a layout gets (title plus the
|
|
44
|
+
// optional author/publishDate/tags fields) - lets a section print a
|
|
45
|
+
// byline/date without duplicating it into a settings field.
|
|
46
|
+
const scope = kind === 'section'
|
|
47
|
+
? { section: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml, page }
|
|
48
|
+
: { block: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml, page };
|
|
49
|
+
try {
|
|
50
|
+
return (await engine.parseAndRender(template, scope, settingsGlobals(siteSettings)));
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof PageRenderError) {
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
57
|
+
throw new PageRenderError('template-error', `Failed to render ${kind} "${instance.id}" (type "${instance.type}"): ${detail}`, { cause: error });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Renders a page's top-level sections, in declared order, concatenated.
|
|
61
|
+
// No page-level layout/wrapper concept in Phase 1 - this is the whole
|
|
62
|
+
// output.
|
|
63
|
+
export async function renderSections(page, themeTemplates, engine, siteSettings = {}) {
|
|
64
|
+
const envelope = pageEnvelope(page);
|
|
65
|
+
const html = [];
|
|
66
|
+
for (const section of page.sections) {
|
|
67
|
+
html.push(await renderInstance(section, 'section', themeTemplates, engine, envelope, siteSettings));
|
|
68
|
+
}
|
|
69
|
+
return html.join('');
|
|
70
|
+
}
|
|
71
|
+
// The layout a page with no layout field (or an invalid one) gets
|
|
72
|
+
// defaulted to. boot.ts never auto-migrates content, so a real site
|
|
73
|
+
// can have pre-existing pages that predate this field entirely - this
|
|
74
|
+
// default must match migrateV3ToV4's own default exactly, so render
|
|
75
|
+
// behaviour is identical whether or not a site has ever run its
|
|
76
|
+
// migrations (content-read.ts established this same defensive-read
|
|
77
|
+
// pattern for the "type" field).
|
|
78
|
+
const DEFAULT_LAYOUT = 'theme';
|
|
79
|
+
// Throws on invalid JSON rather than swallowing to null - callers reading
|
|
80
|
+
// a specific historical revision (preview-revision.ts) want to know "the
|
|
81
|
+
// JSON at this ref is broken" as a distinct, reportable case, whereas
|
|
82
|
+
// tryReadPage's null return means "nothing here, try the next root".
|
|
83
|
+
export function parsePageContent(raw) {
|
|
84
|
+
const parsed = JSON.parse(raw);
|
|
85
|
+
const layout = typeof parsed.layout === 'string' && parsed.layout.length > 0 ? parsed.layout : DEFAULT_LAYOUT;
|
|
86
|
+
return { ...parsed, layout };
|
|
87
|
+
}
|
|
88
|
+
function tryReadPage(root, relativePath) {
|
|
89
|
+
const fullPath = sanitisePath(root, relativePath);
|
|
90
|
+
try {
|
|
91
|
+
return parsePageContent(readFileSync(fullPath, 'utf-8'));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Public mode reads /content/ only, and treats published: false the same
|
|
98
|
+
// as "doesn't exist" - this is what makes unpublish actually take a
|
|
99
|
+
// page offline (checklist C8: the renderer skips unpublished pages).
|
|
100
|
+
// Preview mode overlays /drafts/ over /content/, falling back to live
|
|
101
|
+
// when no draft exists, and deliberately does NOT filter on published:
|
|
102
|
+
// an editor needs to see a currently-unpublished page to review it
|
|
103
|
+
// before republishing.
|
|
104
|
+
function loadPageForRender(config, relativePath, mode) {
|
|
105
|
+
if (mode === 'preview') {
|
|
106
|
+
const draft = tryReadPage(config.draftsRoot, relativePath);
|
|
107
|
+
if (draft) {
|
|
108
|
+
return draft;
|
|
109
|
+
}
|
|
110
|
+
const live = tryReadPage(config.contentRoot, relativePath);
|
|
111
|
+
if (live) {
|
|
112
|
+
return live;
|
|
113
|
+
}
|
|
114
|
+
throw new PageRenderError('page-not-found', `No page found at "${relativePath}"`);
|
|
115
|
+
}
|
|
116
|
+
const live = tryReadPage(config.contentRoot, relativePath);
|
|
117
|
+
if (!live || live.published === false) {
|
|
118
|
+
throw new PageRenderError('page-not-found', `No publicly reachable page found at "${relativePath}"`);
|
|
119
|
+
}
|
|
120
|
+
return live;
|
|
121
|
+
}
|
|
122
|
+
// The shared back half of rendering: takes a page already loaded from
|
|
123
|
+
// wherever (current draft/live disk state via loadPageForRender, or a
|
|
124
|
+
// historical git blob via readFileAtRevision + parsePageContent) and runs
|
|
125
|
+
// it through the identical layout/section/menu pipeline - so "render
|
|
126
|
+
// what's on disk" and "render this specific historical revision" never
|
|
127
|
+
// duplicate this logic.
|
|
128
|
+
export async function renderLoadedPage(page, config, themeTemplates, layouts, engine, siteSettings = {}) {
|
|
129
|
+
// Checked before rendering any sections, matching the fail-fast
|
|
130
|
+
// ordering missing-section-type/missing-block-type already
|
|
131
|
+
// establish - no point rendering a page's whole body just to
|
|
132
|
+
// discover its layout doesn't exist.
|
|
133
|
+
const layoutTemplate = layouts[page.layout];
|
|
134
|
+
if (!layoutTemplate) {
|
|
135
|
+
throw new PageRenderError('missing-layout', `Layout "${page.layout}" is missing from the theme`);
|
|
136
|
+
}
|
|
137
|
+
const bodyHtml = await renderSections(page, themeTemplates, engine, siteSettings);
|
|
138
|
+
// Menus are content, not theme data - loaded fresh here rather than
|
|
139
|
+
// once at boot, same freshness guarantee as the page itself. Scoped
|
|
140
|
+
// to the layout only (not threaded into renderSections/renderInstance
|
|
141
|
+
// above), matching the confirmed requirement: a layout renders nav,
|
|
142
|
+
// sections/blocks don't need it in this pass. No mode distinction:
|
|
143
|
+
// menus have no draft state (Group N), always the live file. This
|
|
144
|
+
// also means a historical page preview always shows today's menus,
|
|
145
|
+
// never a menu as it existed at that revision - an accepted limitation.
|
|
146
|
+
const menus = loadMenus(config);
|
|
147
|
+
// content_for_layout is already-rendered, already-safe HTML (like
|
|
148
|
+
// blocksHtml handed to a section template) - the layout template
|
|
149
|
+
// itself must use `{{ content_for_layout | raw }}`, or engine.ts's
|
|
150
|
+
// outputEscape: 'escape' double-escapes it into literal text.
|
|
151
|
+
return (await engine.parseAndRender(layoutTemplate, {
|
|
152
|
+
content_for_layout: bodyHtml,
|
|
153
|
+
page: pageEnvelope(page),
|
|
154
|
+
menus,
|
|
155
|
+
}, settingsGlobals(siteSettings)));
|
|
156
|
+
}
|
|
157
|
+
export async function renderPage(config, themeTemplates, layouts, engine, relativePath, mode, siteSettings = {}) {
|
|
158
|
+
const page = loadPageForRender(config, relativePath, mode);
|
|
159
|
+
return renderLoadedPage(page, config, themeTemplates, layouts, engine, siteSettings);
|
|
160
|
+
}
|
|
161
|
+
// For public.ts's render cache: the page's own current mtime, so a
|
|
162
|
+
// cached render can be validated against real filesystem state rather
|
|
163
|
+
// than needing every write path (publish/unpublish/delete/move/batch)
|
|
164
|
+
// to remember to invalidate something. null means "not cacheable this
|
|
165
|
+
// time" (the file vanished between resolveUrl confirming it exists and
|
|
166
|
+
// this call - vanishingly unlikely, not a hard failure) rather than
|
|
167
|
+
// throwing, since a cache-freshness check is never the place to
|
|
168
|
+
// surface a real error - the ordinary render path below still will.
|
|
169
|
+
export function getPageMtimeMs(config, relativePath) {
|
|
170
|
+
try {
|
|
171
|
+
return statSync(sanitisePath(config.contentRoot, relativePath)).mtimeMs;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// A menu edit affects every page's rendered nav, not just one page, so
|
|
178
|
+
// the cache's freshness check needs one value covering all menus
|
|
179
|
+
// together rather than per-page tracking. The max mtime across every
|
|
180
|
+
// menu file covers an edit. It does not cover a menu being renamed (a
|
|
181
|
+
// handle change keeps the file's mtime) or deleted (removes one), so
|
|
182
|
+
// the menus directory's own mtime is included too: that changes
|
|
183
|
+
// whenever an entry is added, renamed or removed. Menus are flat, so
|
|
184
|
+
// the one directory is enough. 0 (never stale relative to anything)
|
|
185
|
+
// when there are no menus at all, matching listFilesRecursively's own
|
|
186
|
+
// "missing directory returns []" behaviour.
|
|
187
|
+
export function getMenusMtimeMs(config) {
|
|
188
|
+
// Site settings too: like menus, they feed every page.
|
|
189
|
+
let max = siteSettingsMtimeMs(config);
|
|
190
|
+
try {
|
|
191
|
+
max = Math.max(max, statSync(config.menusRoot).mtimeMs);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// No content/menus/ at all - nothing to track.
|
|
195
|
+
}
|
|
196
|
+
for (const relativePath of listFilesRecursively(config.menusRoot, config.menusRoot, '.json')) {
|
|
197
|
+
try {
|
|
198
|
+
const { mtimeMs } = statSync(sanitisePath(config.menusRoot, relativePath));
|
|
199
|
+
if (mtimeMs > max) {
|
|
200
|
+
max = mtimeMs;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Vanished between the directory walk and this stat (e.g. a
|
|
205
|
+
// concurrent delete) - doesn't contribute, not fatal.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return max;
|
|
209
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ThemeTemplates {
|
|
2
|
+
sections: Record<string, string>;
|
|
3
|
+
blocks: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
export declare function loadThemeTemplates(themeRoot: string): ThemeTemplates;
|
|
6
|
+
export declare function loadSnippets(themeRoot: string): Record<string, string>;
|
|
7
|
+
export declare function loadLayouts(themeRoot: string): Record<string, string>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseThemeComponentFile } from "../services/theme-component-file.js";
|
|
4
|
+
// themeRoot is agent configuration (the configured site's theme
|
|
5
|
+
// directory), not a request-supplied :path parameter. This walk is
|
|
6
|
+
// deliberately NOT the Group B path-sanitisation helper and must
|
|
7
|
+
// never be reused for untrusted request paths.
|
|
8
|
+
//
|
|
9
|
+
// Flat *.liquid files, one per type, named directly (e.g. hero.liquid,
|
|
10
|
+
// media-text.liquid) - no subfolder per type, no separate schema.json.
|
|
11
|
+
// Mirrors loadFlatTemplates below exactly, extended to strip the
|
|
12
|
+
// embedded {% schema %} block (via theme-component-file.ts's shared
|
|
13
|
+
// parser - the same parse theme-schemas.ts uses for the other half of
|
|
14
|
+
// the same file) before handing the remaining markup to the Liquid
|
|
15
|
+
// engine, since {% schema %} is never a real, live Liquid tag.
|
|
16
|
+
function loadTypeTemplates(typesDir) {
|
|
17
|
+
const templates = {};
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = readdirSync(typesDir, { withFileTypes: true })
|
|
21
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.liquid'))
|
|
22
|
+
.map((entry) => entry.name);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return templates;
|
|
26
|
+
}
|
|
27
|
+
for (const fileName of entries) {
|
|
28
|
+
const type = fileName.slice(0, -'.liquid'.length);
|
|
29
|
+
let source;
|
|
30
|
+
try {
|
|
31
|
+
source = readFileSync(join(typesDir, fileName), 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const parsed = parseThemeComponentFile(source);
|
|
37
|
+
if (!parsed) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
templates[type] = parsed.markup;
|
|
41
|
+
}
|
|
42
|
+
return templates;
|
|
43
|
+
}
|
|
44
|
+
export function loadThemeTemplates(themeRoot) {
|
|
45
|
+
return {
|
|
46
|
+
sections: loadTypeTemplates(join(themeRoot, 'sections')),
|
|
47
|
+
blocks: loadTypeTemplates(join(themeRoot, 'blocks')),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
// Flat *.liquid files, one per name, no subdirectory-per-name and no
|
|
51
|
+
// co-located schema.json - unlike sections/blocks, snippets and
|
|
52
|
+
// layouts are theme-author code with no user-facing settings to
|
|
53
|
+
// declare, matching Shopify's own flat snippets/layouts convention.
|
|
54
|
+
// Same agent-configuration-not-request-path caveat as loadTypeTemplates.
|
|
55
|
+
function loadFlatTemplates(dir) {
|
|
56
|
+
const templates = {};
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
60
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.liquid'))
|
|
61
|
+
.map((entry) => entry.name);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return templates;
|
|
65
|
+
}
|
|
66
|
+
for (const fileName of entries) {
|
|
67
|
+
const name = fileName.slice(0, -'.liquid'.length);
|
|
68
|
+
try {
|
|
69
|
+
templates[name] = readFileSync(join(dir, fileName), 'utf-8');
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return templates;
|
|
76
|
+
}
|
|
77
|
+
export function loadSnippets(themeRoot) {
|
|
78
|
+
return loadFlatTemplates(join(themeRoot, 'snippets'));
|
|
79
|
+
}
|
|
80
|
+
export function loadLayouts(themeRoot) {
|
|
81
|
+
return loadFlatTemplates(join(themeRoot, 'layouts'));
|
|
82
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NO_AUTH_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
2
|
+
// Only ever registered by server.ts when serverConfig.adminBaseUrl is
|
|
3
|
+
// actually set - that's what makes this genuinely opt-in, not a
|
|
4
|
+
// reserved namespace like /media or /assets. A site that hasn't
|
|
5
|
+
// configured this can still use "admin" as an ordinary page path,
|
|
6
|
+
// same reasoning as GET /search.json over reserving all of /search.
|
|
7
|
+
//
|
|
8
|
+
// Redirects to adminBaseUrl with the requesting host appended as
|
|
9
|
+
// ?site=, never a deep link with a site ID baked in - keeps this
|
|
10
|
+
// agent completely decoupled from any particular admin app's own URL
|
|
11
|
+
// scheme, so a self-hoster running a different admin frontend just
|
|
12
|
+
// points adminBaseUrl at it. request.host (not request.hostname,
|
|
13
|
+
// which strips the port - same reasoning sitemap.ts already
|
|
14
|
+
// documents) so the admin side can match it directly against
|
|
15
|
+
// new URL(registeredSiteUrl).host.
|
|
16
|
+
//
|
|
17
|
+
// Deliberately unauthenticated: this is a discovery aid an operator
|
|
18
|
+
// (or anyone) can hit before they have a token, same class of
|
|
19
|
+
// zero-credential route as GET /v1/capabilities and GET /search.json.
|
|
20
|
+
export const adminRedirectRoutes = async (fastify, opts) => {
|
|
21
|
+
fastify.get('/admin', { config: NO_AUTH_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
22
|
+
const target = new URL(opts.adminBaseUrl);
|
|
23
|
+
target.searchParams.set('site', request.host);
|
|
24
|
+
reply.code(302).header('location', target.toString()).send();
|
|
25
|
+
});
|
|
26
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { findStaticFile, sendStaticFile } from "../services/static-file.js";
|
|
2
|
+
async function handleAssetRequest(request, reply, config) {
|
|
3
|
+
const relativePath = request.params['*'];
|
|
4
|
+
const match = findStaticFile(config.assetsRoot, relativePath);
|
|
5
|
+
if (!match) {
|
|
6
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: `No asset at "${relativePath}"` });
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
// A synchronous buffered read, not a stream: reply.send(stream) was
|
|
10
|
+
// verified empirically to produce an empty body in this Fastify
|
|
11
|
+
// version (confirmed both via .inject() and a real socket, isolated
|
|
12
|
+
// down to a minimal repro) - a real, environment-specific problem,
|
|
13
|
+
// not a mistake in this route's own logic. No Range/If-Range support
|
|
14
|
+
// in this pass either way (nothing has asked for large-file
|
|
15
|
+
// streaming yet), so buffering the whole file is not a meaningful
|
|
16
|
+
// regression. sendStaticFile (services/static-file.ts) does this
|
|
17
|
+
// read, shared with routes/public.ts's root-mirror check.
|
|
18
|
+
sendStaticFile(reply, match);
|
|
19
|
+
}
|
|
20
|
+
// Deliberately and permanently unauthenticated: static assets (CSS/JS/
|
|
21
|
+
// images) must be fetchable by any visitor's browser without a token,
|
|
22
|
+
// same as the public website itself. Registered without a /v1 prefix,
|
|
23
|
+
// directly on app in server.ts, alongside publicRoutes. No guard
|
|
24
|
+
// against the public catch-all's own /* wildcard is needed here - this
|
|
25
|
+
// is the more specific static-prefixed route winning cleanly (verified
|
|
26
|
+
// empirically), not the reverse case that needed public.ts's /v1/*
|
|
27
|
+
// guard.
|
|
28
|
+
export const assetsRoutes = async (fastify, opts) => {
|
|
29
|
+
fastify.get('/assets/*', async (request, reply) => handleAssetRequest(request, reply, opts.config));
|
|
30
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FastifyPluginAsync } from 'fastify';
|
|
2
|
+
import type { SiteConfig } from '../config.ts';
|
|
3
|
+
import type { ThemeState } from '../theme-state.ts';
|
|
4
|
+
import type { TokenEntry } from '../server-config.ts';
|
|
5
|
+
export interface BatchRouteOptions {
|
|
6
|
+
config: SiteConfig;
|
|
7
|
+
theme: ThemeState;
|
|
8
|
+
tokens: TokenEntry[];
|
|
9
|
+
}
|
|
10
|
+
export declare const batchRoutes: FastifyPluginAsync<BatchRouteOptions>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { BatchError, runBatch } from "../services/batch.js";
|
|
2
|
+
import { isValidCommitAuthor } from "../services/git.js";
|
|
3
|
+
import { PathSafetyError } from "../services/path-safety.js";
|
|
4
|
+
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
5
|
+
import { requireScope } from "../services/token-auth.js";
|
|
6
|
+
function isNonEmptyString(value) {
|
|
7
|
+
return typeof value === 'string' && value.length > 0;
|
|
8
|
+
}
|
|
9
|
+
function isPlainObject(value) {
|
|
10
|
+
return typeof value === 'object' && value !== null;
|
|
11
|
+
}
|
|
12
|
+
function parseOperation(value) {
|
|
13
|
+
if (!isPlainObject(value)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const { type } = value;
|
|
17
|
+
switch (type) {
|
|
18
|
+
case 'draft-write': {
|
|
19
|
+
const { path, content, expectedEtag } = value;
|
|
20
|
+
if (!isNonEmptyString(path) || !isPlainObject(content) || !isNonEmptyString(expectedEtag)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return { type, path, content, expectedEtag };
|
|
24
|
+
}
|
|
25
|
+
case 'draft-discard': {
|
|
26
|
+
const { path } = value;
|
|
27
|
+
if (!isNonEmptyString(path)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return { type, path };
|
|
31
|
+
}
|
|
32
|
+
case 'content-delete': {
|
|
33
|
+
const { path, redirectTo } = value;
|
|
34
|
+
if (!isNonEmptyString(path) || (redirectTo !== undefined && !isNonEmptyString(redirectTo))) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return { type, path, redirectTo };
|
|
38
|
+
}
|
|
39
|
+
case 'move': {
|
|
40
|
+
const { from, to } = value;
|
|
41
|
+
if (!isNonEmptyString(from) || !isNonEmptyString(to)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return { type, from, to };
|
|
45
|
+
}
|
|
46
|
+
default:
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function parsePublish(value) {
|
|
51
|
+
if (!isPlainObject(value)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const { paths } = value;
|
|
55
|
+
if (!Array.isArray(paths) || paths.length === 0 || !paths.every(isNonEmptyString)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return { relativePaths: paths };
|
|
59
|
+
}
|
|
60
|
+
const BATCH_BODY_SHAPE = 'Expected { operations: BatchOperation[], publish?: { paths: string[] }, message: string, author: { name, email } }';
|
|
61
|
+
function parseBatchBody(body) {
|
|
62
|
+
if (!isPlainObject(body)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const { operations, publish, message, author } = body;
|
|
66
|
+
if (!Array.isArray(operations)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const parsedOperations = [];
|
|
70
|
+
for (const raw of operations) {
|
|
71
|
+
const operation = parseOperation(raw);
|
|
72
|
+
if (!operation) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
parsedOperations.push(operation);
|
|
76
|
+
}
|
|
77
|
+
let parsedPublish;
|
|
78
|
+
if (publish !== undefined) {
|
|
79
|
+
const result = parsePublish(publish);
|
|
80
|
+
if (!result) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
parsedPublish = result;
|
|
84
|
+
}
|
|
85
|
+
if (!isNonEmptyString(message) || !isValidCommitAuthor(author)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return { operations: parsedOperations, publish: parsedPublish, message, author };
|
|
89
|
+
}
|
|
90
|
+
// Maps every BatchReason to a status code, mirroring the same mapping
|
|
91
|
+
// each originating service's own route already applies (publish.ts's
|
|
92
|
+
// replyForPublishError, content.ts's move/delete handlers, drafts.ts's
|
|
93
|
+
// conflict/validation-failed handling) - kept as one switch here since
|
|
94
|
+
// batch.ts's BatchReason is a superset drawn from all four services.
|
|
95
|
+
function replyForBatchError(reply, error) {
|
|
96
|
+
switch (error.reason) {
|
|
97
|
+
case 'conflict':
|
|
98
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
99
|
+
return;
|
|
100
|
+
case 'destination-exists':
|
|
101
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
102
|
+
return;
|
|
103
|
+
case 'has-children':
|
|
104
|
+
reply.code(409).send({ statusCode: 409, error: 'Conflict', message: error.message });
|
|
105
|
+
return;
|
|
106
|
+
case 'validation-failed':
|
|
107
|
+
case 'duplicate-path':
|
|
108
|
+
case 'redirect-cycle':
|
|
109
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: error.message });
|
|
110
|
+
return;
|
|
111
|
+
case 'page-not-found':
|
|
112
|
+
case 'source-not-found':
|
|
113
|
+
case 'draft-not-found':
|
|
114
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
115
|
+
return;
|
|
116
|
+
default:
|
|
117
|
+
// write-failed / commit-failed / rollback-failed: no client-side
|
|
118
|
+
// fix applies, fall through to the global handler's sanitised 500.
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export const batchRoutes = async (fastify, opts) => {
|
|
123
|
+
fastify.post('/batch', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
124
|
+
const parsed = parseBatchBody(request.body);
|
|
125
|
+
if (!parsed) {
|
|
126
|
+
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: BATCH_BODY_SHAPE });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await runBatch(opts.config, opts.theme.current.themeSchemas, parsed.operations, parsed.publish, parsed.message, parsed.author);
|
|
131
|
+
reply.send({ ok: true });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
// PathSafetyError has no .statusCode - left uncaught it falls
|
|
135
|
+
// through to the global handler's sanitised 500, the same gap
|
|
136
|
+
// every other route touching a :path already guards against.
|
|
137
|
+
if (error instanceof PathSafetyError) {
|
|
138
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (error instanceof BatchError) {
|
|
142
|
+
replyForBatchError(reply, error);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
};
|