@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.
- package/LICENSE +105 -0
- package/README.md +132 -0
- package/dist/boot.d.ts +15 -0
- package/dist/boot.js +33 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +45 -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 +9 -0
- package/dist/create-site/generate-site.js +109 -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/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 +22 -0
- package/dist/create-site/template/content/pages/about/careers.json +21 -0
- package/dist/create-site/template/content/pages/about/team.json +21 -0
- package/dist/create-site/template/content/pages/about.json +21 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +23 -0
- package/dist/create-site/template/content/pages/docs.json +21 -0
- package/dist/create-site/template/content/pages/index.json +181 -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 +1153 -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/layouts/theme.liquid +99 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +27 -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 +36 -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/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +30 -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 +1 -0
- package/dist/media/filename.js +31 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +49 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-page.d.ts +28 -0
- package/dist/renderer/render-page.js +143 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -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 +5 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +166 -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 +19 -0
- package/dist/routes/index.js +84 -0
- package/dist/routes/media-public.d.ts +5 -0
- package/dist/routes/media-public.js +48 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +84 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +77 -0
- package/dist/routes/preview-revision.d.ts +13 -0
- package/dist/routes/preview-revision.js +109 -0
- package/dist/routes/preview.d.ts +13 -0
- package/dist/routes/preview.js +52 -0
- package/dist/routes/public.d.ts +11 -0
- package/dist/routes/public.js +117 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +105 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +11 -0
- package/dist/routes/sitemap.d.ts +6 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +17 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +23 -0
- package/dist/schemas/page.schema.json +20 -0
- package/dist/schemas/post.schema.json +25 -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 +3 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -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 +2 -0
- package/dist/search/rebuild-index.js +111 -0
- package/dist/server-config.d.ts +22 -0
- package/dist/server-config.js +196 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +163 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +136 -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 +156 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +174 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +15 -0
- package/dist/services/drafts.js +155 -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/manage-menus.d.ts +10 -0
- package/dist/services/manage-menus.js +84 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +166 -0
- package/dist/services/menus.d.ts +8 -0
- package/dist/services/menus.js +42 -0
- package/dist/services/migration-runner.d.ts +13 -0
- package/dist/services/migration-runner.js +123 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +36 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +175 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -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 +14 -0
- package/dist/services/publish.js +254 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +20 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -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/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 +5 -0
- package/dist/services/theme-component-file.js +32 -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 +63 -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 +24 -0
- package/dist/services/validation.js +158 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- 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
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -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,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,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,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
|
+
}
|