@o-a/cms-agent 0.1.0

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.
Files changed (201) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +132 -0
  3. package/dist/boot.d.ts +15 -0
  4. package/dist/boot.js +33 -0
  5. package/dist/config.d.ts +19 -0
  6. package/dist/config.js +45 -0
  7. package/dist/create-site/cli.d.ts +2 -0
  8. package/dist/create-site/cli.js +31 -0
  9. package/dist/create-site/generate-site.d.ts +9 -0
  10. package/dist/create-site/generate-site.js +109 -0
  11. package/dist/create-site/mint-token-cli.d.ts +2 -0
  12. package/dist/create-site/mint-token-cli.js +26 -0
  13. package/dist/create-site/mint-token.d.ts +7 -0
  14. package/dist/create-site/mint-token.js +49 -0
  15. package/dist/create-site/template/content/menus/footerCompany.json +9 -0
  16. package/dist/create-site/template/content/menus/footerProduct.json +9 -0
  17. package/dist/create-site/template/content/menus/footerResources.json +9 -0
  18. package/dist/create-site/template/content/menus/main.json +9 -0
  19. package/dist/create-site/template/content/pages/404.json +22 -0
  20. package/dist/create-site/template/content/pages/about/careers.json +21 -0
  21. package/dist/create-site/template/content/pages/about/team.json +21 -0
  22. package/dist/create-site/template/content/pages/about.json +21 -0
  23. package/dist/create-site/template/content/pages/docs/deployment.json +21 -0
  24. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +21 -0
  25. package/dist/create-site/template/content/pages/docs/getting-started.json +23 -0
  26. package/dist/create-site/template/content/pages/docs.json +21 -0
  27. package/dist/create-site/template/content/pages/index.json +181 -0
  28. package/dist/create-site/template/content/redirects.json +4 -0
  29. package/dist/create-site/template/dockerignore +6 -0
  30. package/dist/create-site/template/gitignore +11 -0
  31. package/dist/create-site/template/theme/assets/site.js +49 -0
  32. package/dist/create-site/template/theme/assets/style.css +1153 -0
  33. package/dist/create-site/template/theme/blocks/button.liquid +21 -0
  34. package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
  35. package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
  36. package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
  37. package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
  38. package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
  39. package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
  40. package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
  41. package/dist/create-site/template/theme/layouts/theme.liquid +99 -0
  42. package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
  43. package/dist/create-site/template/theme/sections/cta-banner.liquid +27 -0
  44. package/dist/create-site/template/theme/sections/faq.liquid +27 -0
  45. package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
  46. package/dist/create-site/template/theme/sections/hero.liquid +36 -0
  47. package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
  48. package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
  49. package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
  50. package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
  51. package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
  52. package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
  53. package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
  54. package/dist/create-site/template/vhost/Dockerfile +33 -0
  55. package/dist/create-site/template/vhost/docker-entrypoint.sh +30 -0
  56. package/dist/index.d.ts +2 -0
  57. package/dist/index.js +4 -0
  58. package/dist/media/drivers/driver.d.ts +11 -0
  59. package/dist/media/drivers/driver.js +1 -0
  60. package/dist/media/drivers/local-fs-driver.d.ts +3 -0
  61. package/dist/media/drivers/local-fs-driver.js +81 -0
  62. package/dist/media/filename.d.ts +1 -0
  63. package/dist/media/filename.js +31 -0
  64. package/dist/media/manage-media.d.ts +19 -0
  65. package/dist/media/manage-media.js +45 -0
  66. package/dist/migrations/index.d.ts +3 -0
  67. package/dist/migrations/index.js +49 -0
  68. package/dist/renderer/engine.d.ts +2 -0
  69. package/dist/renderer/engine.js +48 -0
  70. package/dist/renderer/render-page.d.ts +28 -0
  71. package/dist/renderer/render-page.js +143 -0
  72. package/dist/renderer/theme-templates.d.ts +7 -0
  73. package/dist/renderer/theme-templates.js +82 -0
  74. package/dist/routes/assets.d.ts +6 -0
  75. package/dist/routes/assets.js +30 -0
  76. package/dist/routes/batch.d.ts +10 -0
  77. package/dist/routes/batch.js +148 -0
  78. package/dist/routes/capabilities.d.ts +5 -0
  79. package/dist/routes/capabilities.js +27 -0
  80. package/dist/routes/content.d.ts +8 -0
  81. package/dist/routes/content.js +166 -0
  82. package/dist/routes/drafts.d.ts +10 -0
  83. package/dist/routes/drafts.js +73 -0
  84. package/dist/routes/git.d.ts +8 -0
  85. package/dist/routes/git.js +158 -0
  86. package/dist/routes/index.d.ts +19 -0
  87. package/dist/routes/index.js +84 -0
  88. package/dist/routes/media-public.d.ts +5 -0
  89. package/dist/routes/media-public.js +48 -0
  90. package/dist/routes/media.d.ts +9 -0
  91. package/dist/routes/media.js +84 -0
  92. package/dist/routes/menus.d.ts +8 -0
  93. package/dist/routes/menus.js +77 -0
  94. package/dist/routes/preview-revision.d.ts +13 -0
  95. package/dist/routes/preview-revision.js +109 -0
  96. package/dist/routes/preview.d.ts +13 -0
  97. package/dist/routes/preview.js +52 -0
  98. package/dist/routes/public.d.ts +11 -0
  99. package/dist/routes/public.js +117 -0
  100. package/dist/routes/publish.d.ts +10 -0
  101. package/dist/routes/publish.js +105 -0
  102. package/dist/routes/redirects.d.ts +8 -0
  103. package/dist/routes/redirects.js +132 -0
  104. package/dist/routes/search.d.ts +8 -0
  105. package/dist/routes/search.js +11 -0
  106. package/dist/routes/sitemap.d.ts +6 -0
  107. package/dist/routes/sitemap.js +79 -0
  108. package/dist/routes/theme.d.ts +10 -0
  109. package/dist/routes/theme.js +17 -0
  110. package/dist/schemas/instance.schema.json +17 -0
  111. package/dist/schemas/menu.schema.json +23 -0
  112. package/dist/schemas/page.schema.json +20 -0
  113. package/dist/schemas/post.schema.json +25 -0
  114. package/dist/schemas/redirects.schema.json +24 -0
  115. package/dist/search/drivers/driver.d.ts +9 -0
  116. package/dist/search/drivers/driver.js +1 -0
  117. package/dist/search/drivers/node-sqlite-driver.d.ts +3 -0
  118. package/dist/search/drivers/node-sqlite-driver.js +19 -0
  119. package/dist/search/query-index.d.ts +5 -0
  120. package/dist/search/query-index.js +21 -0
  121. package/dist/search/rebuild-index.d.ts +2 -0
  122. package/dist/search/rebuild-index.js +111 -0
  123. package/dist/server-config.d.ts +22 -0
  124. package/dist/server-config.js +196 -0
  125. package/dist/server.d.ts +13 -0
  126. package/dist/server.js +163 -0
  127. package/dist/services/batch.d.ts +35 -0
  128. package/dist/services/batch.js +136 -0
  129. package/dist/services/checkpoint.d.ts +4 -0
  130. package/dist/services/checkpoint.js +45 -0
  131. package/dist/services/content-read.d.ts +27 -0
  132. package/dist/services/content-read.js +156 -0
  133. package/dist/services/delete-content.d.ts +12 -0
  134. package/dist/services/delete-content.js +174 -0
  135. package/dist/services/dev-tunnel.d.ts +5 -0
  136. package/dist/services/dev-tunnel.js +5 -0
  137. package/dist/services/drafts.d.ts +15 -0
  138. package/dist/services/drafts.js +155 -0
  139. package/dist/services/etag.d.ts +2 -0
  140. package/dist/services/etag.js +30 -0
  141. package/dist/services/fs-walk.d.ts +1 -0
  142. package/dist/services/fs-walk.js +26 -0
  143. package/dist/services/git-commit.d.ts +3 -0
  144. package/dist/services/git-commit.js +12 -0
  145. package/dist/services/git-history.d.ts +28 -0
  146. package/dist/services/git-history.js +131 -0
  147. package/dist/services/git-revert.d.ts +12 -0
  148. package/dist/services/git-revert.js +140 -0
  149. package/dist/services/git.d.ts +16 -0
  150. package/dist/services/git.js +127 -0
  151. package/dist/services/interval-job.d.ts +4 -0
  152. package/dist/services/interval-job.js +9 -0
  153. package/dist/services/ip-allowlist.d.ts +7 -0
  154. package/dist/services/ip-allowlist.js +31 -0
  155. package/dist/services/manage-menus.d.ts +10 -0
  156. package/dist/services/manage-menus.js +84 -0
  157. package/dist/services/manage-redirects.d.ts +25 -0
  158. package/dist/services/manage-redirects.js +166 -0
  159. package/dist/services/menus.d.ts +8 -0
  160. package/dist/services/menus.js +42 -0
  161. package/dist/services/migration-runner.d.ts +13 -0
  162. package/dist/services/migration-runner.js +123 -0
  163. package/dist/services/mime-types.d.ts +3 -0
  164. package/dist/services/mime-types.js +36 -0
  165. package/dist/services/move.d.ts +16 -0
  166. package/dist/services/move.js +175 -0
  167. package/dist/services/path-safety.d.ts +6 -0
  168. package/dist/services/path-safety.js +112 -0
  169. package/dist/services/post-urls.d.ts +3 -0
  170. package/dist/services/post-urls.js +27 -0
  171. package/dist/services/prepared-operation.d.ts +4 -0
  172. package/dist/services/prepared-operation.js +1 -0
  173. package/dist/services/publish.d.ts +14 -0
  174. package/dist/services/publish.js +254 -0
  175. package/dist/services/rate-limit-config.d.ts +9 -0
  176. package/dist/services/rate-limit-config.js +20 -0
  177. package/dist/services/redirects.d.ts +27 -0
  178. package/dist/services/redirects.js +175 -0
  179. package/dist/services/resolve-blog-url.d.ts +11 -0
  180. package/dist/services/resolve-blog-url.js +31 -0
  181. package/dist/services/resolve-url.d.ts +11 -0
  182. package/dist/services/resolve-url.js +20 -0
  183. package/dist/services/startup-checks.d.ts +10 -0
  184. package/dist/services/startup-checks.js +81 -0
  185. package/dist/services/static-file.d.ts +7 -0
  186. package/dist/services/static-file.js +37 -0
  187. package/dist/services/theme-component-file.d.ts +5 -0
  188. package/dist/services/theme-component-file.js +32 -0
  189. package/dist/services/theme-page-templates.d.ts +7 -0
  190. package/dist/services/theme-page-templates.js +53 -0
  191. package/dist/services/theme-schemas.d.ts +2 -0
  192. package/dist/services/theme-schemas.js +63 -0
  193. package/dist/services/token-auth.d.ts +10 -0
  194. package/dist/services/token-auth.js +64 -0
  195. package/dist/services/urls.d.ts +2 -0
  196. package/dist/services/urls.js +20 -0
  197. package/dist/services/validation.d.ts +24 -0
  198. package/dist/services/validation.js +158 -0
  199. package/dist/services/write-queue.d.ts +1 -0
  200. package/dist/services/write-queue.js +14 -0
  201. package/package.json +53 -0
@@ -0,0 +1,33 @@
1
+ # Generic and site-content-agnostic - this file never needs to change
2
+ # per site. Runs identically via plain `docker build`/`docker run` on a
3
+ # bare VPS, Fly, ECS, or any PaaS - nothing platform-specific is baked
4
+ # in here (see docs/hosting.md for platform-specific deploy config,
5
+ # which always layers on top of this, never inside it).
6
+ #
7
+ # Lives in vhost/, not the site root, alongside the rest of the site's
8
+ # own serving config (package.json, server.js, site.config.json) - the
9
+ # build context is still the site root, so build with:
10
+ # docker build -f vhost/Dockerfile -t my-site .
11
+ FROM node:22-alpine
12
+
13
+ # git is a hard runtime requirement (services/startup-checks.ts fails
14
+ # fast without it) - content, drafts and publishes are all real git
15
+ # operations, not a database.
16
+ RUN apk add --no-cache git
17
+
18
+ # Build-time install happens against the image-baked copy of the site
19
+ # ("/seed"), never directly against "/site" - "/site" is where a
20
+ # persistent volume gets mounted at runtime, and anything written there
21
+ # during the build would simply be shadowed by that mount.
22
+ WORKDIR /seed
23
+ COPY . .
24
+ RUN cd vhost && npm install --omit=dev
25
+
26
+ WORKDIR /site
27
+ COPY vhost/docker-entrypoint.sh /docker-entrypoint.sh
28
+ RUN chmod +x /docker-entrypoint.sh
29
+
30
+ # Must match vhost/site.config.json's own "port" field.
31
+ EXPOSE 3000
32
+
33
+ ENTRYPOINT ["/docker-entrypoint.sh"]
@@ -0,0 +1,30 @@
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ # First boot against an empty mounted volume: seed it from the
5
+ # image-baked copy at /seed (content, theme, the already-`npm
6
+ # install`ed vhost/node_modules). A later boot finds /site/vhost
7
+ # already populated and skips this, so the same image works
8
+ # identically with a real persistent volume mounted at /site
9
+ # (production) or with nothing mounted at all (a quick local trial,
10
+ # where content just lives in the ephemeral container layer).
11
+ if [ ! -d /site/vhost ]; then
12
+ cp -r /seed/. /site/
13
+ fi
14
+
15
+ # The site root must be a real git repository (services/startup-checks.ts) -
16
+ # a plain `docker build` from a full local checkout preserves .git, but
17
+ # some platforms (Railway's `railway up` included) build from a
18
+ # git-archive-style upload of tracked file contents only, which never
19
+ # includes .git at all. Recovered here rather than assumed away: if
20
+ # it's missing, start a fresh repo over the already-seeded content.
21
+ if [ ! -d /site/.git ]; then
22
+ git -C /site init --quiet
23
+ git -C /site add -A
24
+ GIT_AUTHOR_NAME="cms-agent" GIT_AUTHOR_EMAIL="cms-agent@localhost" \
25
+ GIT_COMMITTER_NAME="cms-agent" GIT_COMMITTER_EMAIL="cms-agent@localhost" \
26
+ git -C /site commit --quiet -m "chore: initial scaffold (recovered - .git was not part of the deploy upload)"
27
+ fi
28
+
29
+ cd /site/vhost
30
+ exec node server.js
@@ -0,0 +1,2 @@
1
+ export { startServer, buildServer } from './server.ts';
2
+ export type { BuildServerOptions } from './server.ts';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // The package's public entry point (package.json's "main"): a site's
2
+ // real server.js ("import the agent, point it at this directory,
3
+ // start", per the build plan) imports startServer from here.
4
+ export { startServer, buildServer } from "./server.js";
@@ -0,0 +1,11 @@
1
+ export interface MediaListEntry {
2
+ name: string;
3
+ size: number;
4
+ mtimeMs: number;
5
+ }
6
+ export interface MediaStorageDriver {
7
+ list(): Promise<MediaListEntry[]>;
8
+ put(filename: string, bytes: Buffer): Promise<void>;
9
+ get(filename: string): Promise<Buffer | null>;
10
+ delete(filename: string): Promise<boolean>;
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { MediaStorageDriver } from './driver.ts';
2
+ export declare const DRIVER_NAME = "local-fs";
3
+ export declare function openLocalFsMediaDriver(root: string): MediaStorageDriver;
@@ -0,0 +1,81 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { PathSafetyError, sanitisePath } from "../../services/path-safety.js";
4
+ // A plain string constant, matching node-sqlite-driver.ts's own
5
+ // DRIVER_NAME convention - safe for the capabilities endpoint to
6
+ // report without needing to import anything fs-touching.
7
+ export const DRIVER_NAME = 'local-fs';
8
+ // The default (and, for this build increment, only) MediaStorageDriver
9
+ // implementation. No driver-containment static-analysis test exists
10
+ // for this yet, unlike search/drivers' own G5 check for node:sqlite -
11
+ // node:fs is a single already-pervasive Node builtin, not a swappable
12
+ // third-party library the way node:sqlite/better-sqlite3 are, so
13
+ // there's no "which alternative" question to fence off yet. The
14
+ // natural trigger for that kind of test is a future object-storage
15
+ // driver's own third-party SDK import.
16
+ //
17
+ // Every method resolves filename through sanitisePath itself before
18
+ // touching disk - this file importing both node:fs and sanitisePath
19
+ // is what satisfies the B7 static-analysis rule with no allowlist
20
+ // entry needed, matching assets.ts/content.ts's own pattern of the
21
+ // fs-touching file being the one that calls it.
22
+ export function openLocalFsMediaDriver(root) {
23
+ return {
24
+ async list() {
25
+ mkdirSync(root, { recursive: true });
26
+ return readdirSync(root, { withFileTypes: true })
27
+ .filter((entry) => entry.isFile())
28
+ .map((entry) => {
29
+ const stats = statSync(sanitisePath(root, entry.name));
30
+ return { name: entry.name, size: stats.size, mtimeMs: stats.mtimeMs };
31
+ });
32
+ },
33
+ async put(filename, bytes) {
34
+ const resolved = sanitisePath(root, filename);
35
+ // Harmless no-op for a flat, content-addressed namespace (media
36
+ // filenames never contain subdirectories) - matches
37
+ // manage-menus.ts's own defensive precedent of creating the
38
+ // parent directory before a write rather than assuming it
39
+ // already exists.
40
+ mkdirSync(dirname(resolved), { recursive: true });
41
+ // No existence check first: content-addressed naming means an
42
+ // identical re-upload writes identical bytes over identical
43
+ // bytes - a harmless idempotent overwrite, not something that
44
+ // needs special-casing.
45
+ writeFileSync(resolved, bytes);
46
+ },
47
+ async get(filename) {
48
+ let resolved;
49
+ try {
50
+ resolved = sanitisePath(root, filename);
51
+ }
52
+ catch (error) {
53
+ // An invalid/traversal-shaped filename reads as "not found"
54
+ // from this method's own contract - the route layer is what
55
+ // decides whether that's a 404 or something else, the driver
56
+ // itself stays mechanical.
57
+ if (error instanceof PathSafetyError) {
58
+ return null;
59
+ }
60
+ throw error;
61
+ }
62
+ if (!existsSync(resolved) || !statSync(resolved).isFile()) {
63
+ return null;
64
+ }
65
+ return readFileSync(resolved);
66
+ },
67
+ async delete(filename) {
68
+ // Unlike get(), a malformed delete path is let through
69
+ // uncaught - worth surfacing distinctly rather than silently
70
+ // reading as "already didn't exist", matching
71
+ // manage-redirects.ts's own not-found-is-a-distinct-reason
72
+ // precedent.
73
+ const resolved = sanitisePath(root, filename);
74
+ if (!existsSync(resolved)) {
75
+ return false;
76
+ }
77
+ unlinkSync(resolved);
78
+ return true;
79
+ },
80
+ };
81
+ }
@@ -0,0 +1 @@
1
+ export declare function buildMediaFilename(originalFilename: string, bytes: Buffer): string;
@@ -0,0 +1,31 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { basename, extname } from 'node:path';
3
+ // Content-addressed: the filename is derived from a hash of the
4
+ // file's own bytes, so two uploads can never collide, and an
5
+ // identical re-upload naturally produces the identical filename (a
6
+ // harmless overwrite in local-fs-driver.ts's put(), not a duplicate).
7
+ // No separate manifest/index recording original filenames - this
8
+ // codebase avoids a second source of truth on principle (the same
9
+ // reasoning that keeps the SQLite search index disposable rather than
10
+ // authoritative), so the original name is folded into the filename
11
+ // itself instead of tracked anywhere else.
12
+ //
13
+ // Truncated to 12 hex chars (48 bits), not the full 64-char digest -
14
+ // git itself only shows short hashes for the same reason. At this
15
+ // length a collision between two different files stays astronomically
16
+ // unlikely (well past a 1-in-a-trillion chance even at tens of
17
+ // thousands of uploads to one site), while keeping filenames/URLs/git
18
+ // history actually readable.
19
+ //
20
+ // Slug regex matches generate-site.ts's own sanitisePackageName -
21
+ // lowercase, non [a-z0-9-] runs collapsed to a single "-", trimmed -
22
+ // not a second, independently-invented convention.
23
+ const HASH_LENGTH = 12;
24
+ export function buildMediaFilename(originalFilename, bytes) {
25
+ const hash = createHash('sha256').update(bytes).digest('hex').slice(0, HASH_LENGTH);
26
+ const base = basename(originalFilename, extname(originalFilename));
27
+ const cleaned = base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
28
+ const slug = cleaned.length > 0 ? cleaned : 'file';
29
+ const extension = extname(originalFilename).toLowerCase();
30
+ return `${hash}-${slug}${extension}`;
31
+ }
@@ -0,0 +1,19 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export type ManageMediaReason = 'not-found' | 'invalid-file-type';
3
+ export declare class ManageMediaError extends Error {
4
+ readonly reason: ManageMediaReason;
5
+ constructor(reason: ManageMediaReason, message: string);
6
+ }
7
+ export interface MediaEntry {
8
+ name: string;
9
+ size: number;
10
+ mtimeMs: number;
11
+ url: string;
12
+ }
13
+ export declare function listMedia(config: SiteConfig): Promise<MediaEntry[]>;
14
+ export declare function putMedia(config: SiteConfig, originalFilename: string, bytes: Buffer): Promise<{
15
+ name: string;
16
+ size: number;
17
+ url: string;
18
+ }>;
19
+ export declare function deleteMedia(config: SiteConfig, filename: string): Promise<void>;
@@ -0,0 +1,45 @@
1
+ import { enqueue } from "../services/write-queue.js";
2
+ import { openLocalFsMediaDriver } from "./drivers/local-fs-driver.js";
3
+ import { buildMediaFilename } from "./filename.js";
4
+ export class ManageMediaError extends Error {
5
+ reason;
6
+ constructor(reason, message) {
7
+ super(message);
8
+ this.name = 'ManageMediaError';
9
+ this.reason = reason;
10
+ }
11
+ }
12
+ function mediaUrl(name) {
13
+ return `/media/${name}`;
14
+ }
15
+ // Opens a fresh driver per call rather than threading one long-lived
16
+ // instance through BootedSite - unlike DatabaseSync, plain fs calls
17
+ // have no connection/handle to reuse, so there's no driver-lifecycle
18
+ // wiring needed. The only call site that decides which driver to open
19
+ // is here, hardcoded, matching how rebuild-index.ts hardcodes
20
+ // openNodeSqliteDriver today with no config-driven selection either -
21
+ // this is where a future config-driven driver choice would go.
22
+ function driverFor(config) {
23
+ return openLocalFsMediaDriver(config.mediaRoot);
24
+ }
25
+ // Not enqueued - reads bypass the write queue, matching
26
+ // handleListRedirects' own precedent of only serialising mutations.
27
+ export async function listMedia(config) {
28
+ const entries = await driverFor(config).list();
29
+ return entries.map((entry) => ({ ...entry, url: mediaUrl(entry.name) }));
30
+ }
31
+ export function putMedia(config, originalFilename, bytes) {
32
+ return enqueue(async () => {
33
+ const name = buildMediaFilename(originalFilename, bytes);
34
+ await driverFor(config).put(name, bytes);
35
+ return { name, size: bytes.length, url: mediaUrl(name) };
36
+ });
37
+ }
38
+ export function deleteMedia(config, filename) {
39
+ return enqueue(async () => {
40
+ const existed = await driverFor(config).delete(filename);
41
+ if (!existed) {
42
+ throw new ManageMediaError('not-found', `No media file at "${filename}"`);
43
+ }
44
+ });
45
+ }
@@ -0,0 +1,3 @@
1
+ import type { MigrationMap } from '../services/migration-runner.ts';
2
+ export declare const CURRENT_SCHEMA_VERSION = 5;
3
+ export declare const migrations: MigrationMap;
@@ -0,0 +1,49 @@
1
+ // The current content schema version. Bumping this and adding a new
2
+ // migrations[N] entry is the only way a content shape may change
3
+ // (constraint 4) - never a manual edit convention.
4
+ export const CURRENT_SCHEMA_VERSION = 5;
5
+ // A trivial identity migration, proving the mechanism (per the build
6
+ // plan's Phase 1 scope): no shape change, only the version bump. Safe
7
+ // against page.schema.json's schemaVersion: { minimum: 1 } (not an
8
+ // exact/enum check), so nothing else needs updating for this bump to
9
+ // keep validating.
10
+ function migrateV1ToV2(content) {
11
+ return { ...content, schemaVersion: 2 };
12
+ }
13
+ // page.schema.json now requires "type" (Phase 2 Group D, backing the
14
+ // GET /v1/content list endpoint's type filter). Every page authored
15
+ // before this migration is a generic page, not any newer distinct
16
+ // kind, so "page" is a safe, honest default for existing content -
17
+ // never invented per-file, always this one fixed value.
18
+ function migrateV2ToV3(content) {
19
+ return { ...content, schemaVersion: 3, type: 'page' };
20
+ }
21
+ // page.schema.json now requires "layout" (theme layouts group), naming
22
+ // which theme/layouts/*.liquid file wraps this page's rendered
23
+ // content. Every page authored before this migration used the only
24
+ // layout that existed at the time, so "theme" (theme/layouts/theme.liquid)
25
+ // is the correct default, not an invented placeholder - render-page.ts's
26
+ // own read path applies this exact same default defensively too, since
27
+ // migrations aren't wired to run automatically anywhere yet.
28
+ function migrateV3ToV4(content) {
29
+ return { ...content, schemaVersion: 4, layout: 'theme' };
30
+ }
31
+ // page.schema.json now requires "name" (the admin's own page tree
32
+ // label, distinct from "title" - the same content can now be called
33
+ // "Home Page" in the tree while still rendering a different <title>).
34
+ // Every page authored before this migration has never had a name of
35
+ // its own, so its existing title is the only honest default - not an
36
+ // invented placeholder, and not the file's own path/slug, which is a
37
+ // separate concept again. content-read.ts's listContent applies this
38
+ // exact same title fallback defensively too, since migrations aren't
39
+ // wired to run automatically anywhere yet.
40
+ function migrateV4ToV5(content) {
41
+ const title = typeof content.title === 'string' ? content.title : '';
42
+ return { ...content, schemaVersion: 5, name: title };
43
+ }
44
+ export const migrations = {
45
+ 1: migrateV1ToV2,
46
+ 2: migrateV2ToV3,
47
+ 3: migrateV3ToV4,
48
+ 4: migrateV4ToV5,
49
+ };
@@ -0,0 +1,2 @@
1
+ import { Liquid } from 'liquidjs';
2
+ export declare function createEngine(snippets: Record<string, string>): Liquid;
@@ -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,28 @@
1
+ import type { Liquid } from 'liquidjs';
2
+ import type { SiteConfig } from '../config.ts';
3
+ import type { ThemeTemplates } from './theme-templates.ts';
4
+ export type PageRenderReason = 'page-not-found' | 'missing-section-type' | 'missing-block-type' | 'missing-layout' | 'template-error';
5
+ export declare class PageRenderError extends Error {
6
+ readonly reason: PageRenderReason;
7
+ constructor(reason: PageRenderReason, message: string, options?: {
8
+ cause?: unknown;
9
+ });
10
+ }
11
+ export interface SectionOrBlockInstance {
12
+ id: string;
13
+ type: string;
14
+ settings: Record<string, unknown>;
15
+ blocks?: SectionOrBlockInstance[];
16
+ }
17
+ export interface PageContent {
18
+ schemaVersion: number;
19
+ title: string;
20
+ published: boolean;
21
+ layout: string;
22
+ sections: SectionOrBlockInstance[];
23
+ }
24
+ export declare function renderSections(page: PageContent, themeTemplates: ThemeTemplates, engine: Liquid): Promise<string>;
25
+ export type RenderMode = 'public' | 'preview';
26
+ export declare function parsePageContent(raw: string): PageContent;
27
+ export declare function renderLoadedPage(page: PageContent, config: SiteConfig, themeTemplates: ThemeTemplates, layouts: Record<string, string>, engine: Liquid): Promise<string>;
28
+ export declare function renderPage(config: SiteConfig, themeTemplates: ThemeTemplates, layouts: Record<string, string>, engine: Liquid, relativePath: string, mode: RenderMode): Promise<string>;
@@ -0,0 +1,143 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { loadMenus } from "../services/menus.js";
3
+ import { sanitisePath } from "../services/path-safety.js";
4
+ export class PageRenderError extends Error {
5
+ reason;
6
+ constructor(reason, message, options) {
7
+ super(message, options);
8
+ this.name = 'PageRenderError';
9
+ this.reason = reason;
10
+ }
11
+ }
12
+ // Renders one section or block, recursively rendering any nested blocks
13
+ // first (instance.schema.json is self-referential, so a block can carry
14
+ // its own nested blocks). Never uses {% include %}/{% render %}: block
15
+ // HTML is pre-rendered here in JS and handed to the parent template as
16
+ // a plain array, which sidesteps LiquidJS's own filesystem include
17
+ // resolution entirely.
18
+ async function renderInstance(instance, kind, themeTemplates, engine) {
19
+ const templates = kind === 'section' ? themeTemplates.sections : themeTemplates.blocks;
20
+ const template = templates[instance.type];
21
+ if (!template) {
22
+ 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}")`);
23
+ }
24
+ const blocksHtml = [];
25
+ for (const block of instance.blocks ?? []) {
26
+ blocksHtml.push(await renderInstance(block, 'block', themeTemplates, engine));
27
+ }
28
+ // Shopify-style scope shape: settings nested under the instance, not
29
+ // flattened, so templates read section.settings.x / block.settings.x.
30
+ const scope = kind === 'section'
31
+ ? { section: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml }
32
+ : { block: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml };
33
+ try {
34
+ return (await engine.parseAndRender(template, scope));
35
+ }
36
+ catch (error) {
37
+ if (error instanceof PageRenderError) {
38
+ throw error;
39
+ }
40
+ const detail = error instanceof Error ? error.message : String(error);
41
+ throw new PageRenderError('template-error', `Failed to render ${kind} "${instance.id}" (type "${instance.type}"): ${detail}`, { cause: error });
42
+ }
43
+ }
44
+ // Renders a page's top-level sections, in declared order, concatenated.
45
+ // No page-level layout/wrapper concept in Phase 1 - this is the whole
46
+ // output.
47
+ export async function renderSections(page, themeTemplates, engine) {
48
+ const html = [];
49
+ for (const section of page.sections) {
50
+ html.push(await renderInstance(section, 'section', themeTemplates, engine));
51
+ }
52
+ return html.join('');
53
+ }
54
+ // The layout a page with no layout field (or an invalid one) gets
55
+ // defaulted to. boot.ts never auto-migrates content, so a real site
56
+ // can have pre-existing pages that predate this field entirely - this
57
+ // default must match migrateV3ToV4's own default exactly, so render
58
+ // behaviour is identical whether or not a site has ever run its
59
+ // migrations (content-read.ts established this same defensive-read
60
+ // pattern for the "type" field).
61
+ const DEFAULT_LAYOUT = 'theme';
62
+ // Throws on invalid JSON rather than swallowing to null - callers reading
63
+ // a specific historical revision (preview-revision.ts) want to know "the
64
+ // JSON at this ref is broken" as a distinct, reportable case, whereas
65
+ // tryReadPage's null return means "nothing here, try the next root".
66
+ export function parsePageContent(raw) {
67
+ const parsed = JSON.parse(raw);
68
+ const layout = typeof parsed.layout === 'string' && parsed.layout.length > 0 ? parsed.layout : DEFAULT_LAYOUT;
69
+ return { ...parsed, layout };
70
+ }
71
+ function tryReadPage(root, relativePath) {
72
+ const fullPath = sanitisePath(root, relativePath);
73
+ try {
74
+ return parsePageContent(readFileSync(fullPath, 'utf-8'));
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ // Public mode reads /content/ only, and treats published: false the same
81
+ // as "doesn't exist" - this is what makes unpublish actually take a
82
+ // page offline (checklist C8: the renderer skips unpublished pages).
83
+ // Preview mode overlays /drafts/ over /content/, falling back to live
84
+ // when no draft exists, and deliberately does NOT filter on published:
85
+ // an editor needs to see a currently-unpublished page to review it
86
+ // before republishing.
87
+ function loadPageForRender(config, relativePath, mode) {
88
+ if (mode === 'preview') {
89
+ const draft = tryReadPage(config.draftsRoot, relativePath);
90
+ if (draft) {
91
+ return draft;
92
+ }
93
+ const live = tryReadPage(config.contentRoot, relativePath);
94
+ if (live) {
95
+ return live;
96
+ }
97
+ throw new PageRenderError('page-not-found', `No page found at "${relativePath}"`);
98
+ }
99
+ const live = tryReadPage(config.contentRoot, relativePath);
100
+ if (!live || live.published === false) {
101
+ throw new PageRenderError('page-not-found', `No publicly reachable page found at "${relativePath}"`);
102
+ }
103
+ return live;
104
+ }
105
+ // The shared back half of rendering: takes a page already loaded from
106
+ // wherever (current draft/live disk state via loadPageForRender, or a
107
+ // historical git blob via readFileAtRevision + parsePageContent) and runs
108
+ // it through the identical layout/section/menu pipeline - so "render
109
+ // what's on disk" and "render this specific historical revision" never
110
+ // duplicate this logic.
111
+ export async function renderLoadedPage(page, config, themeTemplates, layouts, engine) {
112
+ // Checked before rendering any sections, matching the fail-fast
113
+ // ordering missing-section-type/missing-block-type already
114
+ // establish - no point rendering a page's whole body just to
115
+ // discover its layout doesn't exist.
116
+ const layoutTemplate = layouts[page.layout];
117
+ if (!layoutTemplate) {
118
+ throw new PageRenderError('missing-layout', `Layout "${page.layout}" is missing from the theme`);
119
+ }
120
+ const bodyHtml = await renderSections(page, themeTemplates, engine);
121
+ // Menus are content, not theme data - loaded fresh here rather than
122
+ // once at boot, same freshness guarantee as the page itself. Scoped
123
+ // to the layout only (not threaded into renderSections/renderInstance
124
+ // above), matching the confirmed requirement: a layout renders nav,
125
+ // sections/blocks don't need it in this pass. No mode distinction:
126
+ // menus have no draft state (Group N), always the live file. This
127
+ // also means a historical page preview always shows today's menus,
128
+ // never a menu as it existed at that revision - an accepted limitation.
129
+ const menus = loadMenus(config);
130
+ // content_for_layout is already-rendered, already-safe HTML (like
131
+ // blocksHtml handed to a section template) - the layout template
132
+ // itself must use `{{ content_for_layout | raw }}`, or engine.ts's
133
+ // outputEscape: 'escape' double-escapes it into literal text.
134
+ return (await engine.parseAndRender(layoutTemplate, {
135
+ content_for_layout: bodyHtml,
136
+ page: { title: page.title },
137
+ menus,
138
+ }));
139
+ }
140
+ export async function renderPage(config, themeTemplates, layouts, engine, relativePath, mode) {
141
+ const page = loadPageForRender(config, relativePath, mode);
142
+ return renderLoadedPage(page, config, themeTemplates, layouts, engine);
143
+ }
@@ -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
+ }