@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,31 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { sanitisePath } from "./path-safety.js";
|
|
3
|
+
import { buildRedirectLookup, loadRedirects } from "./redirects.js";
|
|
4
|
+
import { urlToPostPath } from "./post-urls.js";
|
|
5
|
+
// Mirrors resolve-url.ts's shape exactly (a live post always wins over
|
|
6
|
+
// a redirect at the same URL), kept as its own distinct result type
|
|
7
|
+
// rather than reusing ResolvedUrl - clearer branching at the call site
|
|
8
|
+
// and avoids touching Group C's already-tested resolve-url.ts.
|
|
9
|
+
//
|
|
10
|
+
// Unlike pagesRoot (always expected to exist on a real site),
|
|
11
|
+
// postsRoot is optional - a site that has never used blog posts has
|
|
12
|
+
// no content/posts/ directory at all. sanitisePath calls realpathSync
|
|
13
|
+
// directly on its root argument, which throws a raw, uncaught ENOENT
|
|
14
|
+
// if that root itself is missing - so postsRoot's existence is checked
|
|
15
|
+
// first, before ever calling sanitisePath, rather than letting a
|
|
16
|
+
// perfectly ordinary "no posts yet" site crash on its first /blog/ hit.
|
|
17
|
+
export function resolveBlogUrl(config, url) {
|
|
18
|
+
const relativePath = urlToPostPath(url);
|
|
19
|
+
if (relativePath !== null && existsSync(config.postsRoot)) {
|
|
20
|
+
const postFile = sanitisePath(config.postsRoot, relativePath);
|
|
21
|
+
if (existsSync(postFile)) {
|
|
22
|
+
return { kind: 'post', relativePath };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const lookup = buildRedirectLookup(loadRedirects(config).entries);
|
|
26
|
+
const to = lookup.get(url);
|
|
27
|
+
if (to !== undefined) {
|
|
28
|
+
return { kind: 'redirect', to };
|
|
29
|
+
}
|
|
30
|
+
return { kind: 'not-found' };
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
export type ResolvedUrl = {
|
|
3
|
+
kind: 'page';
|
|
4
|
+
relativePath: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'redirect';
|
|
7
|
+
to: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'not-found';
|
|
10
|
+
};
|
|
11
|
+
export declare function resolveUrl(config: SiteConfig, url: string): ResolvedUrl;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { sanitisePath } from "./path-safety.js";
|
|
3
|
+
import { buildRedirectLookup, loadRedirects } from "./redirects.js";
|
|
4
|
+
import { urlToPagePath } from "./urls.js";
|
|
5
|
+
// A redirect whose "from" matches a live page is never served: the
|
|
6
|
+
// page always wins (E6). Only consults redirects.json when no live
|
|
7
|
+
// page exists at the requested URL.
|
|
8
|
+
export function resolveUrl(config, url) {
|
|
9
|
+
const relativePath = urlToPagePath(url);
|
|
10
|
+
const pageFile = sanitisePath(config.pagesRoot, relativePath);
|
|
11
|
+
if (existsSync(pageFile)) {
|
|
12
|
+
return { kind: 'page', relativePath };
|
|
13
|
+
}
|
|
14
|
+
const lookup = buildRedirectLookup(loadRedirects(config).entries);
|
|
15
|
+
const to = lookup.get(url);
|
|
16
|
+
if (to !== undefined) {
|
|
17
|
+
return { kind: 'redirect', to };
|
|
18
|
+
}
|
|
19
|
+
return { kind: 'not-found' };
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
import type { CommitAuthor } from './git.ts';
|
|
3
|
+
import { type ThemeSchemas } from './validation.ts';
|
|
4
|
+
export type SiteSettings = Record<string, unknown>;
|
|
5
|
+
export declare function siteSettingsPath(config: SiteConfig): string;
|
|
6
|
+
export interface StoredSiteSettings {
|
|
7
|
+
settings: SiteSettings;
|
|
8
|
+
etag: string | null;
|
|
9
|
+
}
|
|
10
|
+
export declare function readSiteSettings(config: SiteConfig): StoredSiteSettings;
|
|
11
|
+
export declare function resolveSiteSettings(config: SiteConfig, themeSchemas: ThemeSchemas): SiteSettings;
|
|
12
|
+
export declare function resolveSiteSettingsFrom(saved: SiteSettings, themeSchemas: ThemeSchemas): SiteSettings;
|
|
13
|
+
export declare function siteSettingsMtimeMs(config: SiteConfig): number;
|
|
14
|
+
export type SaveSiteSettingsReason = 'validation-failed' | 'conflict' | 'commit-failed' | 'rollback-failed';
|
|
15
|
+
export declare class SaveSiteSettingsError extends Error {
|
|
16
|
+
readonly reason: SaveSiteSettingsReason;
|
|
17
|
+
readonly errors: Array<{
|
|
18
|
+
path: string;
|
|
19
|
+
message: string;
|
|
20
|
+
}>;
|
|
21
|
+
constructor(reason: SaveSiteSettingsReason, message: string, errors?: Array<{
|
|
22
|
+
path: string;
|
|
23
|
+
message: string;
|
|
24
|
+
}>, options?: {
|
|
25
|
+
cause?: unknown;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export declare function saveSiteSettings(config: SiteConfig, themeSchemas: ThemeSchemas, settings: unknown, expectedEtag: string, message: string, author: CommitAuthor): Promise<string>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { CURRENT_SCHEMA_VERSION } from "../migrations/index.js";
|
|
3
|
+
import { computeEtag, etagsMatch } from "./etag.js";
|
|
4
|
+
import { commitPaths } from "./git.js";
|
|
5
|
+
import { sanitisePath } from "./path-safety.js";
|
|
6
|
+
import { validateSiteSettings } from "./validation.js";
|
|
7
|
+
import { enqueue } from "./write-queue.js";
|
|
8
|
+
export function siteSettingsPath(config) {
|
|
9
|
+
return sanitisePath(config.contentRoot, 'settings.json');
|
|
10
|
+
}
|
|
11
|
+
// A settings.json that isn't a readable object counts as empty rather
|
|
12
|
+
// than breaking every page: settings only ever fill in a theme's
|
|
13
|
+
// defaults, and the admin shows the file's problem when it's saved.
|
|
14
|
+
export function readSiteSettings(config) {
|
|
15
|
+
const path = siteSettingsPath(config);
|
|
16
|
+
if (!existsSync(path)) {
|
|
17
|
+
return { settings: {}, etag: null };
|
|
18
|
+
}
|
|
19
|
+
const bytes = readFileSync(path);
|
|
20
|
+
let settings = {};
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(bytes.toString('utf-8'));
|
|
23
|
+
if (typeof parsed.settings === 'object' && parsed.settings !== null && !Array.isArray(parsed.settings)) {
|
|
24
|
+
settings = parsed.settings;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
settings = {};
|
|
29
|
+
}
|
|
30
|
+
return { settings, etag: computeEtag(bytes) };
|
|
31
|
+
}
|
|
32
|
+
// What templates see as `settings`: every setting the theme defines,
|
|
33
|
+
// with its default where nothing has been saved. Only the theme's own
|
|
34
|
+
// settings - a saved value for a setting the theme no longer defines is
|
|
35
|
+
// left out.
|
|
36
|
+
export function resolveSiteSettings(config, themeSchemas) {
|
|
37
|
+
return resolveSiteSettingsFrom(readSiteSettings(config).settings, themeSchemas);
|
|
38
|
+
}
|
|
39
|
+
// The same, from values given rather than read from disk: the preview
|
|
40
|
+
// of settings an editor has changed but not saved yet.
|
|
41
|
+
export function resolveSiteSettingsFrom(saved, themeSchemas) {
|
|
42
|
+
const properties = themeSchemas.settings?.properties;
|
|
43
|
+
if (!properties) {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
const resolved = {};
|
|
47
|
+
for (const [name, property] of Object.entries(properties)) {
|
|
48
|
+
if (name in saved) {
|
|
49
|
+
resolved[name] = saved[name];
|
|
50
|
+
}
|
|
51
|
+
else if (property && typeof property === 'object' && 'default' in property) {
|
|
52
|
+
resolved[name] = property.default;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return resolved;
|
|
56
|
+
}
|
|
57
|
+
// For the render cache: pages depend on settings like they depend on
|
|
58
|
+
// menus, so a save must make every cached page stale. 0 when there is
|
|
59
|
+
// no settings.json.
|
|
60
|
+
export function siteSettingsMtimeMs(config) {
|
|
61
|
+
try {
|
|
62
|
+
return statSync(siteSettingsPath(config)).mtimeMs;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class SaveSiteSettingsError extends Error {
|
|
69
|
+
reason;
|
|
70
|
+
errors;
|
|
71
|
+
constructor(reason, message, errors = [], options) {
|
|
72
|
+
super(message, options);
|
|
73
|
+
this.name = 'SaveSiteSettingsError';
|
|
74
|
+
this.reason = reason;
|
|
75
|
+
this.errors = errors;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function saveSiteSettingsJob(config, themeSchemas, settings, expectedEtag, message, author) {
|
|
79
|
+
// Checked inside the queued job, never before it (the same TOCTOU
|
|
80
|
+
// reasoning as drafts.ts and manage-menus.ts). No file yet means
|
|
81
|
+
// nothing to conflict with.
|
|
82
|
+
const current = readSiteSettings(config);
|
|
83
|
+
if (current.etag !== null && !etagsMatch(current.etag, expectedEtag)) {
|
|
84
|
+
throw new SaveSiteSettingsError('conflict', 'The site settings changed since you opened them');
|
|
85
|
+
}
|
|
86
|
+
const result = validateSiteSettings(settings, themeSchemas);
|
|
87
|
+
if (!result.valid) {
|
|
88
|
+
throw new SaveSiteSettingsError('validation-failed', `Site settings failed validation: ${result.errors.map((error) => `${error.path || '/'} ${error.message}`).join('; ')}`, result.errors);
|
|
89
|
+
}
|
|
90
|
+
const path = siteSettingsPath(config);
|
|
91
|
+
const before = existsSync(path) ? readFileSync(path) : null;
|
|
92
|
+
const bytes = Buffer.from(`${JSON.stringify({ schemaVersion: CURRENT_SCHEMA_VERSION, settings }, null, 2)}\n`);
|
|
93
|
+
writeFileSync(path, bytes);
|
|
94
|
+
try {
|
|
95
|
+
commitPaths(config.siteRoot, [path], message, author);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
try {
|
|
99
|
+
if (before === null) {
|
|
100
|
+
unlinkSync(path);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
writeFileSync(path, before);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (rollbackError) {
|
|
107
|
+
throw new SaveSiteSettingsError('rollback-failed', 'Saving the site settings failed and putting them back also failed; content/settings.json needs manual inspection', [], { cause: rollbackError });
|
|
108
|
+
}
|
|
109
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
110
|
+
throw new SaveSiteSettingsError('commit-failed', `Saving the site settings failed: ${detail}`, [], { cause: error });
|
|
111
|
+
}
|
|
112
|
+
return computeEtag(bytes);
|
|
113
|
+
}
|
|
114
|
+
export function saveSiteSettings(config, themeSchemas, settings, expectedEtag, message, author) {
|
|
115
|
+
return enqueue(() => saveSiteSettingsJob(config, themeSchemas, settings, expectedEtag, message, author));
|
|
116
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type StartupCheckReason = 'node-version-too-low' | 'site-root-missing' | 'git-binary-absent' | 'not-a-git-repo' | 'invalid-site-config' | 'invalid-token-config';
|
|
2
|
+
export declare class StartupCheckError extends Error {
|
|
3
|
+
readonly reason: StartupCheckReason;
|
|
4
|
+
constructor(reason: StartupCheckReason, message: string);
|
|
5
|
+
}
|
|
6
|
+
export interface StartupCheckOptions {
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
nodeVersion?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function runStartupChecks(siteRoot: string, options?: StartupCheckOptions): void;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export class StartupCheckError extends Error {
|
|
5
|
+
reason;
|
|
6
|
+
constructor(reason, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'StartupCheckError';
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function parseVersion(version) {
|
|
13
|
+
const [major = 0, minor = 0, patch = 0] = version
|
|
14
|
+
.replace(/^v/, '')
|
|
15
|
+
.split('.')
|
|
16
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
17
|
+
return { major, minor, patch };
|
|
18
|
+
}
|
|
19
|
+
function isBelow(version, floor) {
|
|
20
|
+
if (version.major !== floor.major) {
|
|
21
|
+
return version.major < floor.major;
|
|
22
|
+
}
|
|
23
|
+
if (version.minor !== floor.minor) {
|
|
24
|
+
return version.minor < floor.minor;
|
|
25
|
+
}
|
|
26
|
+
return version.patch < floor.patch;
|
|
27
|
+
}
|
|
28
|
+
// Reads the Node version floor from the agent's own package.json rather
|
|
29
|
+
// than hardcoding a second literal here, so the dev-toolchain minimum
|
|
30
|
+
// (package.json engines.node, driven by --experimental-strip-types) and
|
|
31
|
+
// the shipped-agent's runtime minimum can't drift apart by accident.
|
|
32
|
+
// This is the agent's own bundled file, not site data, so resolving it
|
|
33
|
+
// relative to this module's own location is correct here, the same way
|
|
34
|
+
// validation.ts resolves its own schema files.
|
|
35
|
+
function readNodeVersionFloor() {
|
|
36
|
+
const packageJsonPath = join(import.meta.dirname, '..', '..', 'package.json');
|
|
37
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
38
|
+
const floor = packageJson.engines?.node;
|
|
39
|
+
if (!floor) {
|
|
40
|
+
throw new Error('package.json is missing engines.node');
|
|
41
|
+
}
|
|
42
|
+
return floor.replace(/^[^\d]*/, '');
|
|
43
|
+
}
|
|
44
|
+
// Fail-fast, in this specific order. Checking "is a git repo" before
|
|
45
|
+
// confirming the site root exists would make a missing root fail with
|
|
46
|
+
// the same ENOENT as "git binary absent" (both execFileSync failures),
|
|
47
|
+
// which would misreport one case as the other.
|
|
48
|
+
export function runStartupChecks(siteRoot, options = {}) {
|
|
49
|
+
const nodeVersion = options.nodeVersion ?? process.version;
|
|
50
|
+
const floor = parseVersion(readNodeVersionFloor());
|
|
51
|
+
if (isBelow(parseVersion(nodeVersion), floor)) {
|
|
52
|
+
throw new StartupCheckError('node-version-too-low', `Node ${readNodeVersionFloor()} or later is required, found ${nodeVersion}`);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
if (!statSync(siteRoot).isDirectory()) {
|
|
56
|
+
throw new Error('not a directory');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new StartupCheckError('site-root-missing', `Site root does not exist or is not a directory: ${siteRoot}`);
|
|
61
|
+
}
|
|
62
|
+
const env = options.env ?? process.env;
|
|
63
|
+
try {
|
|
64
|
+
// Deliberately not run with cwd: siteRoot, so this check never
|
|
65
|
+
// depends on the site root existing.
|
|
66
|
+
execFileSync('git', ['--version'], { env, stdio: 'ignore' });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new StartupCheckError('git-binary-absent', 'git binary not found on PATH');
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
73
|
+
cwd: siteRoot,
|
|
74
|
+
env,
|
|
75
|
+
stdio: 'ignore',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new StartupCheckError('not-a-git-repo', `Site root is not a git repository: ${siteRoot}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FastifyReply } from 'fastify';
|
|
2
|
+
export interface StaticFileMatch {
|
|
3
|
+
fullPath: string;
|
|
4
|
+
contentType: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function findStaticFile(root: string, relativePath: string): StaticFileMatch | null;
|
|
7
|
+
export declare function sendStaticFile(reply: FastifyReply, match: StaticFileMatch): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { mimeTypeFor } from "./mime-types.js";
|
|
3
|
+
import { PathSafetyError, sanitisePath } from "./path-safety.js";
|
|
4
|
+
export function findStaticFile(root, relativePath) {
|
|
5
|
+
// sanitisePath's own symlink-escape check calls realpathSync on the
|
|
6
|
+
// root itself, which throws (a raw ENOENT, not a PathSafetyError) if
|
|
7
|
+
// the root doesn't exist at all. theme/assets/ is always created by
|
|
8
|
+
// scaffoldSite, but theme/root/ is genuinely optional (found live:
|
|
9
|
+
// this crashed with a 500 on a fixture site with no theme/root/ at
|
|
10
|
+
// all) - checked here rather than in sanitisePath itself, since
|
|
11
|
+
// every other caller of that shared, security-audited function
|
|
12
|
+
// legitimately expects its root to exist.
|
|
13
|
+
if (!existsSync(root)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const fullPath = sanitisePath(root, relativePath);
|
|
18
|
+
if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return { fullPath, contentType: mimeTypeFor(fullPath) };
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error instanceof PathSafetyError) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Access-Control-Allow-Origin: * - both callers are already
|
|
31
|
+
// deliberately unauthenticated, public routes (see assets.ts's own
|
|
32
|
+
// comment for the full reasoning: a font loaded via @font-face
|
|
33
|
+
// enforces CORS unconditionally, and the admin's preview route makes
|
|
34
|
+
// genuinely cross-origin requests for paths that look same-origin).
|
|
35
|
+
export function sendStaticFile(reply, match) {
|
|
36
|
+
reply.header('Access-Control-Allow-Origin', '*').type(match.contentType).send(readFileSync(match.fullPath));
|
|
37
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// {% schema %} ... {% endschema %} embedded in a single .liquid file,
|
|
2
|
+
// matching Shopify's own theme-section convention - the natural fit
|
|
3
|
+
// since this project already deliberately mirrors Shopify-style
|
|
4
|
+
// sections and blocks. Never a real, live Liquid tag (CLAUDE.md: no
|
|
5
|
+
// dynamically registered Liquid tags or filters, ever) - extracted by
|
|
6
|
+
// plain string parsing before the file is ever handed to the Liquid
|
|
7
|
+
// engine, and stripped out of what actually gets rendered.
|
|
8
|
+
const SCHEMA_BLOCK_PATTERN = /\{%-?\s*schema\s*-?%\}([\s\S]*?)\{%-?\s*endschema\s*-?%\}/;
|
|
9
|
+
// Returns null if no schema block is found, or its content isn't
|
|
10
|
+
// valid JSON - callers treat this the same as today's "file missing/
|
|
11
|
+
// unreadable" case (skip, don't throw). A deliberate behaviour change
|
|
12
|
+
// from the old two-file shape: previously markup and schema loaded and
|
|
13
|
+
// failed independently; now they're one file, so a broken schema block
|
|
14
|
+
// fails the whole component, not just the schema half.
|
|
15
|
+
export function parseThemeComponentFile(source) {
|
|
16
|
+
const match = SCHEMA_BLOCK_PATTERN.exec(source);
|
|
17
|
+
if (!match) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
let schema;
|
|
21
|
+
try {
|
|
22
|
+
schema = JSON.parse(match[1]);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (typeof schema !== 'object' || schema === null) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const markup = source.slice(0, match.index) + source.slice(match.index + match[0].length);
|
|
31
|
+
return { markup, schema };
|
|
32
|
+
}
|
|
33
|
+
// The markup Liquid actually sees: {% schema %} is never a real, live
|
|
34
|
+
// Liquid tag, so it is removed before anything is parsed - including
|
|
35
|
+
// when a pushed theme's syntax is checked (theme-files.ts).
|
|
36
|
+
export function stripSchemaBlock(source) {
|
|
37
|
+
const match = SCHEMA_BLOCK_PATTERN.exec(source);
|
|
38
|
+
return match ? source.slice(0, match.index) + source.slice(match.index + match[0].length) : source;
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
import type { ThemeState } from '../theme-state.ts';
|
|
3
|
+
import type { CommitAuthor } from './git.ts';
|
|
4
|
+
export declare function isValidThemePath(path: string): boolean;
|
|
5
|
+
export declare function hashThemeFile(bytes: Buffer): string;
|
|
6
|
+
export interface ThemeFileEntry {
|
|
7
|
+
path: string;
|
|
8
|
+
size: number;
|
|
9
|
+
hash: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function listThemeFiles(config: SiteConfig): ThemeFileEntry[];
|
|
12
|
+
export declare function readThemeFile(config: SiteConfig, path: string): Buffer | null;
|
|
13
|
+
export type ThemePushReason = 'invalid-path' | 'conflict' | 'invalid-liquid' | 'write-failed' | 'commit-failed' | 'rollback-failed';
|
|
14
|
+
export declare class ThemePushError extends Error {
|
|
15
|
+
readonly reason: ThemePushReason;
|
|
16
|
+
constructor(reason: ThemePushReason, message: string, options?: {
|
|
17
|
+
cause?: unknown;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export interface ThemeWrite {
|
|
21
|
+
path: string;
|
|
22
|
+
bytes: Buffer;
|
|
23
|
+
expected: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface ThemeDelete {
|
|
26
|
+
path: string;
|
|
27
|
+
expected: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ThemePushResult {
|
|
30
|
+
generation: number;
|
|
31
|
+
warnings: string[];
|
|
32
|
+
}
|
|
33
|
+
export declare function pushTheme(config: SiteConfig, theme: ThemeState, writes: ThemeWrite[], deletes: ThemeDelete[], message: string, author: CommitAuthor): Promise<ThemePushResult>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, relative } from 'node:path';
|
|
4
|
+
import { createEngine } from "../renderer/engine.js";
|
|
5
|
+
import { listFilesRecursively } from "./fs-walk.js";
|
|
6
|
+
import { commitPaths } from "./git.js";
|
|
7
|
+
import { sanitisePath } from "./path-safety.js";
|
|
8
|
+
import { stripSchemaBlock } from "./theme-component-file.js";
|
|
9
|
+
import { enqueue } from "./write-queue.js";
|
|
10
|
+
// A theme file path, relative to theme/: folders and a file name, no dot
|
|
11
|
+
// segments and no hidden files (.DS_Store and the like are never part of
|
|
12
|
+
// a theme).
|
|
13
|
+
const THEME_PATH = /^(?:[A-Za-z0-9_-][A-Za-z0-9._-]*\/)*[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
|
|
14
|
+
export function isValidThemePath(path) {
|
|
15
|
+
return THEME_PATH.test(path);
|
|
16
|
+
}
|
|
17
|
+
export function hashThemeFile(bytes) {
|
|
18
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
export function listThemeFiles(config) {
|
|
21
|
+
return listFilesRecursively(config.themeRoot, config.themeRoot, '')
|
|
22
|
+
.filter(isValidThemePath)
|
|
23
|
+
.sort()
|
|
24
|
+
.map((path) => {
|
|
25
|
+
const full = sanitisePath(config.themeRoot, path);
|
|
26
|
+
const bytes = readFileSync(full);
|
|
27
|
+
return { path, size: statSync(full).size, hash: hashThemeFile(bytes) };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
export function readThemeFile(config, path) {
|
|
31
|
+
if (!isValidThemePath(path)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const full = sanitisePath(config.themeRoot, path);
|
|
35
|
+
return existsSync(full) ? readFileSync(full) : null;
|
|
36
|
+
}
|
|
37
|
+
export class ThemePushError extends Error {
|
|
38
|
+
reason;
|
|
39
|
+
constructor(reason, message, options) {
|
|
40
|
+
super(message, options);
|
|
41
|
+
this.name = 'ThemePushError';
|
|
42
|
+
this.reason = reason;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function currentHash(path) {
|
|
46
|
+
return existsSync(path) ? hashThemeFile(readFileSync(path)) : null;
|
|
47
|
+
}
|
|
48
|
+
// Only syntax: a template that can't be parsed breaks every page using
|
|
49
|
+
// it, so it is refused. Anything that parses is left to render as the
|
|
50
|
+
// theme author wrote it. Parsed exactly as the site will: the agent's
|
|
51
|
+
// own engine settings, with any {% schema %} block removed first.
|
|
52
|
+
function checkLiquid(writes) {
|
|
53
|
+
const parser = createEngine({});
|
|
54
|
+
for (const write of writes) {
|
|
55
|
+
if (!write.path.endsWith('.liquid')) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
parser.parse(stripSchemaBlock(write.bytes.toString('utf-8')));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
63
|
+
throw new ThemePushError('invalid-liquid', `theme/${write.path} is not valid Liquid: ${detail}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Writes and deletes theme files as one change: every file is checked
|
|
68
|
+
// against what the client expected first (so a file changed on the site
|
|
69
|
+
// since the client last pulled it is never overwritten), then everything
|
|
70
|
+
// is written, committed once, and the running theme reloaded. If the
|
|
71
|
+
// commit fails, every file is put back as it was.
|
|
72
|
+
async function pushThemeJob(config, theme, writes, deletes, message, author) {
|
|
73
|
+
const all = [...writes.map((write) => write.path), ...deletes.map((entry) => entry.path)];
|
|
74
|
+
for (const path of all) {
|
|
75
|
+
if (!isValidThemePath(path)) {
|
|
76
|
+
throw new ThemePushError('invalid-path', `Not a theme file path: ${JSON.stringify(path)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (new Set(all).size !== all.length) {
|
|
80
|
+
throw new ThemePushError('invalid-path', 'The same theme file appears more than once in one push');
|
|
81
|
+
}
|
|
82
|
+
mkdirSync(config.themeRoot, { recursive: true });
|
|
83
|
+
const resolved = new Map(all.map((path) => [path, sanitisePath(config.themeRoot, path)]));
|
|
84
|
+
const conflicts = [];
|
|
85
|
+
for (const write of writes) {
|
|
86
|
+
if (currentHash(resolved.get(write.path)) !== write.expected) {
|
|
87
|
+
conflicts.push(write.path);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const entry of deletes) {
|
|
91
|
+
if (currentHash(resolved.get(entry.path)) !== entry.expected) {
|
|
92
|
+
conflicts.push(entry.path);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (conflicts.length > 0) {
|
|
96
|
+
throw new ThemePushError('conflict', `Changed on the site since you last saw them: ${conflicts.map((path) => `theme/${path}`).join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
checkLiquid(writes);
|
|
99
|
+
const originals = new Map();
|
|
100
|
+
for (const path of all) {
|
|
101
|
+
const full = resolved.get(path);
|
|
102
|
+
originals.set(path, existsSync(full) ? readFileSync(full) : null);
|
|
103
|
+
}
|
|
104
|
+
function restore() {
|
|
105
|
+
for (const [path, bytes] of originals) {
|
|
106
|
+
const full = resolved.get(path);
|
|
107
|
+
if (bytes === null) {
|
|
108
|
+
rmSync(full, { force: true });
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
112
|
+
writeFileSync(full, bytes);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
for (const write of writes) {
|
|
118
|
+
const full = resolved.get(write.path);
|
|
119
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
120
|
+
writeFileSync(full, write.bytes);
|
|
121
|
+
}
|
|
122
|
+
for (const entry of deletes) {
|
|
123
|
+
rmSync(resolved.get(entry.path), { force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
restore();
|
|
128
|
+
throw new ThemePushError('write-failed', 'Writing the theme failed; nothing was changed', { cause: error });
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
commitPaths(config.siteRoot, [...resolved.values()].map((full) => relative(config.siteRoot, full)), message, author);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
try {
|
|
135
|
+
restore();
|
|
136
|
+
}
|
|
137
|
+
catch (rollbackError) {
|
|
138
|
+
throw new ThemePushError('rollback-failed', 'Committing the theme failed and putting it back also failed; theme/ needs manual inspection', { cause: rollbackError });
|
|
139
|
+
}
|
|
140
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
141
|
+
throw new ThemePushError('commit-failed', `Committing the theme failed: ${detail}`, { cause: error });
|
|
142
|
+
}
|
|
143
|
+
const reloaded = theme.reload();
|
|
144
|
+
return { generation: reloaded.generation, warnings: reloaded.themeSchemas.warnings ?? [] };
|
|
145
|
+
}
|
|
146
|
+
export function pushTheme(config, theme, writes, deletes, message, author) {
|
|
147
|
+
return enqueue(() => pushThemeJob(config, theme, writes, deletes, message, author));
|
|
148
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { validatePage } from "./validation.js";
|
|
4
|
+
// templatesRoot is agent configuration (config.templatesRoot), not a
|
|
5
|
+
// request-supplied :path parameter - same non-sanitised, boot-time-only
|
|
6
|
+
// directory walk as theme-schemas.ts's own loadTypeSchemas, never
|
|
7
|
+
// reused for untrusted request paths.
|
|
8
|
+
//
|
|
9
|
+
// Flat *.json files, one per template, named directly (e.g.
|
|
10
|
+
// blog-article.json) - mirrors loadTypeSchemas's walk exactly. Each
|
|
11
|
+
// file must already be a fully valid page (the same page.schema.json
|
|
12
|
+
// every real content/pages/*.json file is validated against, via the
|
|
13
|
+
// theme's own current section/block schemas) - a template IS a real
|
|
14
|
+
// page file, nothing more, so a theme author can create one by
|
|
15
|
+
// literally copying and adapting a real page rather than learning a
|
|
16
|
+
// new authoring convention. A template that fails to parse or fails
|
|
17
|
+
// validation is skipped, never a boot failure - same defensive
|
|
18
|
+
// handling loadTypeSchemas already gives a malformed schema.json.
|
|
19
|
+
export function loadPageTemplates(templatesRoot, themeSchemas) {
|
|
20
|
+
const templates = [];
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = readdirSync(templatesRoot, { withFileTypes: true })
|
|
24
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
25
|
+
.map((entry) => entry.name);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return templates;
|
|
29
|
+
}
|
|
30
|
+
for (const fileName of entries) {
|
|
31
|
+
const id = fileName.slice(0, -'.json'.length);
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = readFileSync(join(templatesRoot, fileName), 'utf-8');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
let content;
|
|
40
|
+
try {
|
|
41
|
+
content = JSON.parse(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!validatePage(content, themeSchemas).valid) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const title = content.title;
|
|
50
|
+
templates.push({ id, title, content });
|
|
51
|
+
}
|
|
52
|
+
return templates;
|
|
53
|
+
}
|