@o-a/cms-agent 0.2.1 → 0.3.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/dist/create-site/cli.js +0 -0
- package/dist/create-site/generate-site.js +21 -0
- package/dist/create-site/mint-token-cli.js +0 -0
- package/dist/create-site/template/AGENTS.md +189 -5
- package/dist/create-site/template/content/menus/footerCompany.json +1 -1
- package/dist/create-site/template/content/menus/footerProduct.json +1 -1
- package/dist/create-site/template/content/menus/footerResources.json +1 -1
- package/dist/create-site/template/content/menus/main.json +1 -1
- package/dist/create-site/template/content/pages/404.json +1 -1
- package/dist/create-site/template/content/pages/about/careers.json +1 -1
- package/dist/create-site/template/content/pages/about/team.json +1 -1
- package/dist/create-site/template/content/pages/about.json +1 -1
- package/dist/create-site/template/content/pages/docs/deployment.json +1 -1
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +1 -1
- package/dist/create-site/template/content/pages/docs/getting-started.json +1 -1
- package/dist/create-site/template/content/pages/docs.json +1 -1
- package/dist/create-site/template/content/pages/index.json +1 -1
- package/dist/media/filename.d.ts +1 -0
- package/dist/media/filename.js +13 -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/renderer/render-page.d.ts +3 -0
- package/dist/renderer/render-page.js +13 -6
- package/dist/routes/media.js +5 -9
- package/dist/routes/publish.js +37 -3
- package/dist/routes/sitemap.d.ts +1 -0
- package/dist/routes/sitemap.js +7 -1
- package/dist/search/rebuild-index.d.ts +1 -0
- package/dist/search/rebuild-index.js +17 -1
- package/dist/server.js +17 -0
- package/dist/services/batch.js +10 -1
- package/dist/services/delete-content.js +4 -1
- package/dist/services/move.js +4 -1
- package/dist/services/publish.d.ts +1 -0
- package/dist/services/publish.js +67 -2
- package/dist/services/reindex-on-write.d.ts +3 -0
- package/dist/services/reindex-on-write.js +30 -0
- package/dist/services/theme-schemas.js +13 -5
- package/dist/services/validation.d.ts +1 -0
- package/dist/services/validation.js +33 -1
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +36 -0
- package/dist/site-check/run-check.d.ts +11 -0
- package/dist/site-check/run-check.js +109 -0
- package/package.json +5 -3
- package/dist/search/query-index.d.ts +0 -5
- package/dist/search/query-index.js +0 -21
- package/dist/services/post-urls.d.ts +0 -3
- package/dist/services/post-urls.js +0 -27
- package/dist/services/resolve-blog-url.d.ts +0 -11
- package/dist/services/resolve-blog-url.js +0 -31
package/dist/routes/media.js
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
import { extname } from 'node:path';
|
|
2
2
|
import multipart from '@fastify/multipart';
|
|
3
|
+
import { ALLOWED_UPLOAD_EXTENSIONS } from "../media/filename.js";
|
|
3
4
|
import { ManageMediaError, deleteMedia, listMedia, putMedia } from "../media/manage-media.js";
|
|
4
5
|
import { PathSafetyError } from "../services/path-safety.js";
|
|
5
6
|
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
6
7
|
import { requireScope } from "../services/token-auth.js";
|
|
7
|
-
// Images only - confirmed with the user, not a general document
|
|
8
|
-
// library. Checked against the *original* uploaded filename, not the
|
|
9
|
-
// client-supplied mimetype header (trivially spoofable) and not the
|
|
10
|
-
// stored content-addressed filename (built only after this check
|
|
11
|
-
// passes, from the same already-validated extension). .svg is
|
|
12
|
-
// rejected regardless of this list even though it's technically an
|
|
13
|
-
// image format - docs/cms-build-plan.md's own "SVG rejected outright,
|
|
14
|
-
// not sanitised" decision.
|
|
15
|
-
const ALLOWED_UPLOAD_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
16
8
|
function sendManageMediaError(reply, error) {
|
|
17
9
|
if (error.reason === 'not-found') {
|
|
18
10
|
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: error.message });
|
|
@@ -29,6 +21,10 @@ async function handleUploadMedia(request, reply, config) {
|
|
|
29
21
|
reply.code(400).send({ statusCode: 400, error: 'Bad Request', message: 'Expected a multipart file upload' });
|
|
30
22
|
return;
|
|
31
23
|
}
|
|
24
|
+
// Checked against the *original* uploaded filename, not the
|
|
25
|
+
// client-supplied mimetype header (trivially spoofable) and not the
|
|
26
|
+
// stored content-addressed filename (built only after this check
|
|
27
|
+
// passes, from the same already-validated extension).
|
|
32
28
|
const extension = extname(data.filename).toLowerCase();
|
|
33
29
|
if (!ALLOWED_UPLOAD_EXTENSIONS.has(extension)) {
|
|
34
30
|
reply.code(415).send({
|
package/dist/routes/publish.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isValidCommitAuthor } from "../services/git.js";
|
|
2
2
|
import { PathSafetyError } from "../services/path-safety.js";
|
|
3
3
|
import { WRITE_ROUTE_RATE_LIMIT } from "../services/rate-limit-config.js";
|
|
4
|
-
import { PublishError, publishDrafts, unpublishPage } from "../services/publish.js";
|
|
4
|
+
import { PublishError, publishDrafts, publishPage, unpublishPage } from "../services/publish.js";
|
|
5
5
|
import { requireScope } from "../services/token-auth.js";
|
|
6
6
|
function isNonEmptyString(value) {
|
|
7
7
|
return typeof value === 'string' && value.length > 0;
|
|
@@ -19,7 +19,7 @@ function parsePublishBody(body) {
|
|
|
19
19
|
}
|
|
20
20
|
return { paths, message, author };
|
|
21
21
|
}
|
|
22
|
-
function
|
|
22
|
+
function parsePublishedFlagBody(body) {
|
|
23
23
|
if (typeof body !== 'object' || body === null) {
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
@@ -73,7 +73,7 @@ export const publishRoutes = async (fastify, opts) => {
|
|
|
73
73
|
}
|
|
74
74
|
});
|
|
75
75
|
fastify.post('/unpublish/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
76
|
-
const parsed =
|
|
76
|
+
const parsed = parsePublishedFlagBody(request.body);
|
|
77
77
|
if (!parsed) {
|
|
78
78
|
reply.code(400).send({
|
|
79
79
|
statusCode: 400,
|
|
@@ -102,4 +102,38 @@ export const publishRoutes = async (fastify, opts) => {
|
|
|
102
102
|
throw error;
|
|
103
103
|
}
|
|
104
104
|
});
|
|
105
|
+
// The twin of /unpublish/* above: sets published:true on a live page
|
|
106
|
+
// in place and commits. Deliberately separate from /publish, which
|
|
107
|
+
// promotes drafts - a page that is live but unpublished has no draft
|
|
108
|
+
// to promote, so /publish cannot reach it at all (draft-not-found),
|
|
109
|
+
// and promoting a draft would publish every pending edit along with
|
|
110
|
+
// the flag. This only ever changes the one boolean.
|
|
111
|
+
fastify.post('/publish-page/*', { preHandler: requireScope(opts.tokens, 'content'), config: WRITE_ROUTE_RATE_LIMIT }, async (request, reply) => {
|
|
112
|
+
const parsed = parsePublishedFlagBody(request.body);
|
|
113
|
+
if (!parsed) {
|
|
114
|
+
reply.code(400).send({
|
|
115
|
+
statusCode: 400,
|
|
116
|
+
error: 'Bad Request',
|
|
117
|
+
message: 'Expected { message: string, author: { name, email } }',
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const relativePath = request.params['*'];
|
|
122
|
+
try {
|
|
123
|
+
await publishPage(opts.config, relativePath, parsed.message, parsed.author);
|
|
124
|
+
reply.send({ ok: true });
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
// Same PathSafetyError guard as every other :path route here.
|
|
128
|
+
if (error instanceof PathSafetyError) {
|
|
129
|
+
reply.code(404).send({ statusCode: 404, error: 'Not Found', message: 'No content at that path' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (error instanceof PublishError) {
|
|
133
|
+
replyForPublishError(reply, error);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
105
139
|
};
|
package/dist/routes/sitemap.d.ts
CHANGED
package/dist/routes/sitemap.js
CHANGED
|
@@ -24,7 +24,13 @@ function isPublished(contentRoot, relativePath) {
|
|
|
24
24
|
// never authoritative", see cms-build-plan.md). A saved sitemap would
|
|
25
25
|
// go stale the moment anything is published or unpublished; this
|
|
26
26
|
// can't.
|
|
27
|
-
|
|
27
|
+
// Exported for site-check/run-check.ts's own reuse - it needs the
|
|
28
|
+
// identical "every published page's own URL" walk this route already
|
|
29
|
+
// does, and duplicating it would be the exact kind of drift this
|
|
30
|
+
// codebase avoids elsewhere (see slugify.ts's own "second use
|
|
31
|
+
// justifies the abstraction" precedent, cited directly in this
|
|
32
|
+
// project's own admin sibling repo).
|
|
33
|
+
export function buildSitemapUrls(config) {
|
|
28
34
|
const urls = [];
|
|
29
35
|
for (const relativePath of listFilesRecursively(config.pagesRoot, config.pagesRoot, '.json')) {
|
|
30
36
|
// The 404 page must never be listed as a real crawlable URL,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
|
|
5
5
|
import { listFilesRecursively } from "../services/fs-walk.js";
|
|
@@ -302,3 +302,19 @@ async function rebuildIndexJob(config) {
|
|
|
302
302
|
export function rebuildIndex(config) {
|
|
303
303
|
return enqueue(() => rebuildIndexJob(config));
|
|
304
304
|
}
|
|
305
|
+
// For startServer's own boot-time call: a search index is never
|
|
306
|
+
// git-tracked (constraint 3 - a derived, disposable index), so a fresh
|
|
307
|
+
// clone or a first-ever boot has no index file at all and GET
|
|
308
|
+
// /search.json would stay permanently empty until some content write
|
|
309
|
+
// happened to trigger reindex-on-write.ts, or a caller manually hit
|
|
310
|
+
// POST /v1/search/rebuild. Guarded on existsSync rather than
|
|
311
|
+
// unconditionally rebuilding on every restart - a currently-running
|
|
312
|
+
// site's index is already kept fresh by every publish/unpublish/
|
|
313
|
+
// delete/move/batch (reindex-on-write.ts), so an unconditional rebuild
|
|
314
|
+
// here would just be redundant work on every ordinary restart.
|
|
315
|
+
export function rebuildIndexIfMissing(config) {
|
|
316
|
+
if (existsSync(config.searchIndexPath)) {
|
|
317
|
+
return Promise.resolve();
|
|
318
|
+
}
|
|
319
|
+
return rebuildIndex(config);
|
|
320
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { sitemapRoutes } from "./routes/sitemap.js";
|
|
|
12
12
|
import { CHECKPOINT_AUTHOR, runCheckpoint } from "./services/checkpoint.js";
|
|
13
13
|
import { startDevTunnel } from "./services/dev-tunnel.js";
|
|
14
14
|
import { startIntervalJob } from "./services/interval-job.js";
|
|
15
|
+
import { reindexOnBootIfMissing } from "./services/reindex-on-write.js";
|
|
15
16
|
// Never wrap v1Routes (or any route-group plugin it registers) with
|
|
16
17
|
// fastify-plugin (fp()): plain app.register() gives each file its own
|
|
17
18
|
// encapsulation scope by default, which Group B's auth preHandler
|
|
@@ -132,6 +133,15 @@ export async function startServer(siteRoot, options = {}) {
|
|
|
132
133
|
const booted = bootSite(siteRoot);
|
|
133
134
|
const serverConfig = loadServerConfig(siteRoot);
|
|
134
135
|
const app = buildServer(booted, serverConfig, options);
|
|
136
|
+
// Previously these three failure cases (missing/invalid {% schema %}
|
|
137
|
+
// block, or a required property with no valid default) were
|
|
138
|
+
// completely silent - a broken component just stopped being
|
|
139
|
+
// selectable, with nothing distinguishing "deliberately excluded"
|
|
140
|
+
// from "you have a typo". Never a boot failure (loadThemeSchemas's
|
|
141
|
+
// own contract), just no longer silent about it either.
|
|
142
|
+
for (const warning of booted.themeSchemas.warnings ?? []) {
|
|
143
|
+
console.warn(warning);
|
|
144
|
+
}
|
|
135
145
|
const doCheckpoint = () => runCheckpoint(booted.config, CHECKPOINT_AUTHOR);
|
|
136
146
|
const scheduler = startIntervalJob(doCheckpoint, serverConfig.checkpointIntervalMs, (error) => {
|
|
137
147
|
app.log.error(error, 'background draft checkpoint failed');
|
|
@@ -191,6 +201,13 @@ export async function startServer(siteRoot, options = {}) {
|
|
|
191
201
|
if (address !== null && typeof address !== 'string') {
|
|
192
202
|
console.log(`Site running at http://127.0.0.1:${address.port}`);
|
|
193
203
|
}
|
|
204
|
+
// A search index is never git-tracked (constraint 3), so a fresh
|
|
205
|
+
// clone or first-ever boot has no index file - without this,
|
|
206
|
+
// GET /search.json would stay empty until the first content write.
|
|
207
|
+
// Fire-and-forget, after the port is already bound: never blocks
|
|
208
|
+
// startup, and a slow/failed rebuild is never a reason the server
|
|
209
|
+
// itself fails to come up.
|
|
210
|
+
reindexOnBootIfMissing(booted.config);
|
|
194
211
|
if (options.tunnel) {
|
|
195
212
|
try {
|
|
196
213
|
tunnel = await (options.startTunnel ?? startDevTunnel)(serverConfig.port);
|
package/dist/services/batch.js
CHANGED
|
@@ -4,6 +4,7 @@ import { commitPaths } from "./git.js";
|
|
|
4
4
|
import { MoveError, prepareMovePage } from "./move.js";
|
|
5
5
|
import { PublishError, preparePublishDrafts } from "./publish.js";
|
|
6
6
|
import { enqueue } from "./write-queue.js";
|
|
7
|
+
import { reindexInBackground } from "./reindex-on-write.js";
|
|
7
8
|
export class BatchError extends Error {
|
|
8
9
|
reason;
|
|
9
10
|
// Which part of the batch failed. operationIndex indexes into the
|
|
@@ -132,5 +133,13 @@ async function rollbackAndThrow(undoStack, cause, context) {
|
|
|
132
133
|
throw new BatchError('commit-failed', `Batch failed: ${detail}`, { cause, ...context });
|
|
133
134
|
}
|
|
134
135
|
export function runBatch(config, themeSchemas, operations, publish, message, author) {
|
|
135
|
-
|
|
136
|
+
const result = enqueue(() => batchJob(config, themeSchemas, operations, publish, message, author));
|
|
137
|
+
// One trigger for the whole batch, regardless of which operation
|
|
138
|
+
// types it contained (content-delete/move/publish affect the index,
|
|
139
|
+
// draft-write/draft-discard don't) - simpler and no real cost than
|
|
140
|
+
// inspecting `operations` to decide, since this never blocks the
|
|
141
|
+
// caller either way. See reindex-on-write.ts for why this is
|
|
142
|
+
// fire-and-forget, chained onto `result` rather than awaited here.
|
|
143
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
144
|
+
return result;
|
|
136
145
|
}
|
|
@@ -5,6 +5,7 @@ import { sanitisePath } from "./path-safety.js";
|
|
|
5
5
|
import { RedirectError, addRedirect, isValidRedirectTarget, loadRedirects, serialiseRedirects, } from "./redirects.js";
|
|
6
6
|
import { pagePathToUrl } from "./urls.js";
|
|
7
7
|
import { enqueue } from "./write-queue.js";
|
|
8
|
+
import { reindexInBackground } from "./reindex-on-write.js";
|
|
8
9
|
const PAGES_PREFIX = 'pages/';
|
|
9
10
|
// Returns null for anything else (menus have no public URL at all, so
|
|
10
11
|
// redirects are meaningless there).
|
|
@@ -159,5 +160,7 @@ async function deleteContentJob(config, relativePath, redirectTo, message, autho
|
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
export function deleteContent(config, relativePath, redirectTo, message, author) {
|
|
162
|
-
|
|
163
|
+
const result = enqueue(() => deleteContentJob(config, relativePath, redirectTo, message, author));
|
|
164
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
165
|
+
return result;
|
|
163
166
|
}
|
package/dist/services/move.js
CHANGED
|
@@ -6,6 +6,7 @@ import { sanitisePath } from "./path-safety.js";
|
|
|
6
6
|
import { addRedirect, loadRedirects, removeRedirectForPath, serialiseRedirects } from "./redirects.js";
|
|
7
7
|
import { pagePathToUrl, urlToPagePath } from "./urls.js";
|
|
8
8
|
import { enqueue } from "./write-queue.js";
|
|
9
|
+
import { reindexInBackground } from "./reindex-on-write.js";
|
|
9
10
|
export class MoveError extends Error {
|
|
10
11
|
reason;
|
|
11
12
|
constructor(reason, message, options) {
|
|
@@ -171,5 +172,7 @@ async function movePageJob(config, fromUrl, toUrl, message, author, options = {}
|
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
export function movePage(config, fromUrl, toUrl, message, author, options = {}) {
|
|
174
|
-
|
|
175
|
+
const result = enqueue(() => movePageJob(config, fromUrl, toUrl, message, author, options));
|
|
176
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
177
|
+
return result;
|
|
175
178
|
}
|
|
@@ -12,3 +12,4 @@ export declare class PublishError extends Error {
|
|
|
12
12
|
export declare function preparePublishDrafts(config: SiteConfig, themeSchemas: ThemeSchemas, relativePaths: string[]): PreparedOperation;
|
|
13
13
|
export declare function publishDrafts(config: SiteConfig, themeSchemas: ThemeSchemas, relativePaths: string[], message: string, author: CommitAuthor): Promise<void>;
|
|
14
14
|
export declare function unpublishPage(config: SiteConfig, relativePath: string, message: string, author: CommitAuthor): Promise<void>;
|
|
15
|
+
export declare function publishPage(config: SiteConfig, relativePath: string, message: string, author: CommitAuthor): Promise<void>;
|
package/dist/services/publish.js
CHANGED
|
@@ -6,6 +6,7 @@ import { loadRedirects, removeRedirectForPath, serialiseRedirects } from "./redi
|
|
|
6
6
|
import { pagePathToUrl } from "./urls.js";
|
|
7
7
|
import { validateContent } from "./validation.js";
|
|
8
8
|
import { enqueue } from "./write-queue.js";
|
|
9
|
+
import { reindexInBackground } from "./reindex-on-write.js";
|
|
9
10
|
const PAGES_PREFIX = 'pages/';
|
|
10
11
|
// Mirrors delete-content.ts's urlForDeletedEntry - a tiny duplicated
|
|
11
12
|
// helper, not a shared abstraction. Returns null for anything else
|
|
@@ -240,9 +241,73 @@ async function unpublishPageJob(config, relativePath, message, author) {
|
|
|
240
241
|
throw new PublishError(reason, `Unpublish failed: ${errorMessage}`, { cause: error });
|
|
241
242
|
}
|
|
242
243
|
}
|
|
244
|
+
// The exact twin of unpublishPageJob above, flipping the same flag the
|
|
245
|
+
// other way: reads the live file, sets published true, commits. Kept as
|
|
246
|
+
// its own job rather than a parameterised shared one - the two read
|
|
247
|
+
// identically but say opposite things, and a boolean argument at every
|
|
248
|
+
// call site ("publishPage(config, path, true)") reads far worse than
|
|
249
|
+
// two named functions.
|
|
250
|
+
//
|
|
251
|
+
// Deliberately does NOT validate against the theme schemas, matching
|
|
252
|
+
// unpublish rather than publishDrafts: this only ever touches content
|
|
253
|
+
// that is already live, and was therefore already validated when it was
|
|
254
|
+
// published in the first place. publishDrafts validates because it
|
|
255
|
+
// promotes a draft, which may never have been checked before.
|
|
256
|
+
//
|
|
257
|
+
// Like unpublish, never touches a draft. A page with unpublished edits
|
|
258
|
+
// pending keeps them, and this only changes whether what is already
|
|
259
|
+
// live is publicly visible.
|
|
260
|
+
async function publishPageJob(config, relativePath, message, author) {
|
|
261
|
+
const livePath = sanitisePath(config.contentRoot, relativePath);
|
|
262
|
+
let original;
|
|
263
|
+
try {
|
|
264
|
+
original = readFileSync(livePath);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw new PublishError('page-not-found', `No live page found at "${relativePath}"`);
|
|
268
|
+
}
|
|
269
|
+
const parsed = JSON.parse(original.toString('utf-8'));
|
|
270
|
+
parsed.published = true;
|
|
271
|
+
const updated = Buffer.from(JSON.stringify(parsed, null, 2));
|
|
272
|
+
// Same minimal inline restore unpublishPageJob uses, and for the same
|
|
273
|
+
// reason - one file, no draft, so the two-file publish rollback()
|
|
274
|
+
// above would need an unused draft slot for no benefit.
|
|
275
|
+
try {
|
|
276
|
+
writeFileSync(livePath, updated);
|
|
277
|
+
commitPaths(config.siteRoot, [livePath], message, author);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
try {
|
|
281
|
+
writeFileSync(livePath, original);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
throw new PublishError('rollback-failed', 'Publishing this page failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
285
|
+
}
|
|
286
|
+
const reason = error instanceof GitOperationError ? 'commit-failed' : 'write-failed';
|
|
287
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
288
|
+
throw new PublishError(reason, `Publishing this page failed: ${errorMessage}`, { cause: error });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
243
291
|
export function publishDrafts(config, themeSchemas, relativePaths, message, author) {
|
|
244
|
-
|
|
292
|
+
const result = enqueue(() => publishDraftsJob(config, themeSchemas, relativePaths, message, author));
|
|
293
|
+
// Only on success - a failed publish changed nothing, so there's
|
|
294
|
+
// nothing to reindex. Chained onto `result` rather than awaited here:
|
|
295
|
+
// by the time this callback runs, the write-queue's own tail has
|
|
296
|
+
// already advanced past this job, so reindexInBackground's own
|
|
297
|
+
// enqueue()d rebuild queues cleanly behind it (see that function's
|
|
298
|
+
// own comment on why calling it any earlier would deadlock).
|
|
299
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
300
|
+
return result;
|
|
245
301
|
}
|
|
246
302
|
export function unpublishPage(config, relativePath, message, author) {
|
|
247
|
-
|
|
303
|
+
const result = enqueue(() => unpublishPageJob(config, relativePath, message, author));
|
|
304
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
// Twin of unpublishPage - same queue, same background reindex (a page
|
|
308
|
+
// becoming visible changes the index exactly as much as one leaving it).
|
|
309
|
+
export function publishPage(config, relativePath, message, author) {
|
|
310
|
+
const result = enqueue(() => publishPageJob(config, relativePath, message, author));
|
|
311
|
+
result.then(() => reindexInBackground(config), () => undefined);
|
|
312
|
+
return result;
|
|
248
313
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { rebuildIndex, rebuildIndexIfMissing } from "../search/rebuild-index.js";
|
|
2
|
+
// Search is a derived, disposable index (constraint 3) - previously
|
|
3
|
+
// nothing ever rebuilt it except a caller manually hitting
|
|
4
|
+
// POST /v1/search/rebuild, so GET /search.json stayed empty forever on
|
|
5
|
+
// a fresh site and stale forever after a real edit. This is the one
|
|
6
|
+
// place that closes that gap: called after a content-affecting write's
|
|
7
|
+
// own enqueue()d promise has already resolved.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately fire-and-forget, never awaited by the caller: rebuildIndex
|
|
10
|
+
// is itself enqueue()d internally (search/rebuild-index.ts) - awaiting it
|
|
11
|
+
// from inside the very job whose own completion is what let this run
|
|
12
|
+
// would deadlock (routes/search.ts's own comment describes the same
|
|
13
|
+
// hazard for a second, nested enqueue() call). A failed reindex must
|
|
14
|
+
// also never surface as a failure of the write that triggered it - the
|
|
15
|
+
// write already succeeded, and the index is rebuildable from content at
|
|
16
|
+
// any time, so a logged-and-swallowed failure here is the correct
|
|
17
|
+
// severity, not a thrown error.
|
|
18
|
+
export function reindexInBackground(config) {
|
|
19
|
+
void rebuildIndex(config).catch((error) => {
|
|
20
|
+
console.error('Background search reindex failed:', error);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// startServer's own boot-time call - see rebuildIndexIfMissing's own
|
|
24
|
+
// comment for why this is conditional (existsSync), unlike the
|
|
25
|
+
// unconditional reindexInBackground above.
|
|
26
|
+
export function reindexOnBootIfMissing(config) {
|
|
27
|
+
void rebuildIndexIfMissing(config).catch((error) => {
|
|
28
|
+
console.error('Background search reindex failed:', error);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -12,9 +12,13 @@ import { requiredFieldsHaveValidDefaults } from "./validation.js";
|
|
|
12
12
|
// loadFlatTemplates walk exactly (already established for snippets/
|
|
13
13
|
// layouts), extended to extract the embedded {% schema %} block instead
|
|
14
14
|
// of returning the raw file contents.
|
|
15
|
-
|
|
15
|
+
//
|
|
16
|
+
// kind is only used to word each warning ("Section" vs "Block") -
|
|
17
|
+
// callers already know which directory they asked for.
|
|
18
|
+
function loadTypeSchemas(typesDir, kind) {
|
|
16
19
|
const schemas = {};
|
|
17
20
|
const acceptsBlocks = {};
|
|
21
|
+
const warnings = [];
|
|
18
22
|
let entries;
|
|
19
23
|
try {
|
|
20
24
|
entries = readdirSync(typesDir, { withFileTypes: true })
|
|
@@ -22,10 +26,11 @@ function loadTypeSchemas(typesDir) {
|
|
|
22
26
|
.map((entry) => entry.name);
|
|
23
27
|
}
|
|
24
28
|
catch {
|
|
25
|
-
return { schemas, acceptsBlocks };
|
|
29
|
+
return { schemas, acceptsBlocks, warnings };
|
|
26
30
|
}
|
|
27
31
|
for (const fileName of entries) {
|
|
28
32
|
const type = fileName.slice(0, -'.liquid'.length);
|
|
33
|
+
const label = `${kind} type "${type}" (${fileName}) was excluded from the theme`;
|
|
29
34
|
let source;
|
|
30
35
|
try {
|
|
31
36
|
source = readFileSync(join(typesDir, fileName), 'utf-8');
|
|
@@ -35,6 +40,7 @@ function loadTypeSchemas(typesDir) {
|
|
|
35
40
|
}
|
|
36
41
|
const parsed = parseThemeComponentFile(source);
|
|
37
42
|
if (!parsed) {
|
|
43
|
+
warnings.push(`${label}: no valid {% schema %} block found (missing, or not parseable JSON).`);
|
|
38
44
|
continue;
|
|
39
45
|
}
|
|
40
46
|
// A type whose required settings fields lack usable defaults is
|
|
@@ -42,6 +48,7 @@ function loadTypeSchemas(typesDir) {
|
|
|
42
48
|
// never a boot failure, just excluded from what gets registered
|
|
43
49
|
// (guide-theme-authoring.md, Group L).
|
|
44
50
|
if (!requiredFieldsHaveValidDefaults(parsed.schema)) {
|
|
51
|
+
warnings.push(`${label}: a property listed in "required" has no valid "default" (see guide-theme-authoring.md).`);
|
|
45
52
|
continue;
|
|
46
53
|
}
|
|
47
54
|
schemas[type] = parsed.schema;
|
|
@@ -50,14 +57,15 @@ function loadTypeSchemas(typesDir) {
|
|
|
50
57
|
// blocksHtml), not a schema field (guide-theme-authoring.md).
|
|
51
58
|
acceptsBlocks[type] = parsed.markup.includes('blocksHtml');
|
|
52
59
|
}
|
|
53
|
-
return { schemas, acceptsBlocks };
|
|
60
|
+
return { schemas, acceptsBlocks, warnings };
|
|
54
61
|
}
|
|
55
62
|
export function loadThemeSchemas(themeRoot) {
|
|
56
|
-
const sections = loadTypeSchemas(join(themeRoot, 'sections'));
|
|
57
|
-
const blocks = loadTypeSchemas(join(themeRoot, 'blocks'));
|
|
63
|
+
const sections = loadTypeSchemas(join(themeRoot, 'sections'), 'Section');
|
|
64
|
+
const blocks = loadTypeSchemas(join(themeRoot, 'blocks'), 'Block');
|
|
58
65
|
return {
|
|
59
66
|
sections: sections.schemas,
|
|
60
67
|
blocks: blocks.schemas,
|
|
61
68
|
acceptsBlocks: { sections: sections.acceptsBlocks, blocks: blocks.acceptsBlocks },
|
|
69
|
+
warnings: [...sections.warnings, ...blocks.warnings],
|
|
62
70
|
};
|
|
63
71
|
}
|
|
@@ -14,6 +14,7 @@ export interface ThemeSchemas {
|
|
|
14
14
|
sections: Record<string, boolean>;
|
|
15
15
|
blocks: Record<string, boolean>;
|
|
16
16
|
};
|
|
17
|
+
warnings?: string[];
|
|
17
18
|
}
|
|
18
19
|
export declare function requiredFieldsHaveValidDefaults(schema: object): boolean;
|
|
19
20
|
export declare function validateInstance(instance: unknown, kind: 'section' | 'block', themeSchemas: ThemeSchemas): ValidationResult;
|
|
@@ -5,6 +5,20 @@ import { Ajv } from 'ajv';
|
|
|
5
5
|
// authors, not agent code, and are validated the same lenient way
|
|
6
6
|
// everywhere (see theme-schemas.ts).
|
|
7
7
|
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
8
|
+
// The six format values guide-theme-authoring.md/AGENTS.md actually
|
|
9
|
+
// document (richtext/image/textarea/uri/date/color/range/toggle - uri
|
|
10
|
+
// and date are real standard JSON Schema formats already understood
|
|
11
|
+
// without this) are still, correctly, UI hints only: registering them
|
|
12
|
+
// as a literal no-op format (the `true` here, not a real validator
|
|
13
|
+
// function) doesn't make Ajv enforce anything about them, it only
|
|
14
|
+
// stops it printing "unknown format \"x\" ignored" for values that are
|
|
15
|
+
// completely expected. A theme author's genuine typo (e.g. "iamge")
|
|
16
|
+
// still isn't in this list, so it still warns - this only silences the
|
|
17
|
+
// six we ourselves tell theme authors to use, not unknown-format
|
|
18
|
+
// warnings in general.
|
|
19
|
+
for (const format of ['richtext', 'image', 'textarea', 'color', 'range', 'toggle']) {
|
|
20
|
+
ajv.addFormat(format, true);
|
|
21
|
+
}
|
|
8
22
|
const schemasDir = join(import.meta.dirname, '..', 'schemas');
|
|
9
23
|
function readSchema(filename) {
|
|
10
24
|
return JSON.parse(readFileSync(join(schemasDir, filename), 'utf-8'));
|
|
@@ -70,7 +84,25 @@ export function requiredFieldsHaveValidDefaults(schema) {
|
|
|
70
84
|
if (typeof propertySchema !== 'object' || propertySchema === null || !('default' in propertySchema)) {
|
|
71
85
|
return false;
|
|
72
86
|
}
|
|
73
|
-
|
|
87
|
+
// ajv.validate compiles+runs propertySchema in isolation, detached
|
|
88
|
+
// from whatever schema it was pulled out of - a "$ref" pointing at
|
|
89
|
+
// a "$defs" entry declared on the parent (not inside this property
|
|
90
|
+
// sub-schema itself) can't resolve here, and Ajv throws
|
|
91
|
+
// (MissingRefError) rather than returning false. That's a real
|
|
92
|
+
// theme-authoring mistake (this function's whole contract assumes
|
|
93
|
+
// a schema self-contained enough to validate standalone), not a
|
|
94
|
+
// reason to crash the caller - loadTypeSchemas already treats a
|
|
95
|
+
// false return here as "type excluded, boot warning printed", so
|
|
96
|
+
// folding a thrown validation error into that exact same false
|
|
97
|
+
// keeps every possible way a required field's default can be
|
|
98
|
+
// unusable on the one graceful path, instead of one of them being
|
|
99
|
+
// the sole exception that takes the whole server down.
|
|
100
|
+
try {
|
|
101
|
+
return ajv.validate(propertySchema, propertySchema.default) === true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
74
106
|
});
|
|
75
107
|
}
|
|
76
108
|
export function validateInstance(instance, kind, themeSchemas) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { runSiteCheck } from "./run-check.js";
|
|
4
|
+
// Run from vhost/ (see the scaffold's own "check" script), so the
|
|
5
|
+
// site root is one level up - the exact same relative relationship
|
|
6
|
+
// SERVER_JS in create-site/generate-site.ts already relies on.
|
|
7
|
+
const siteRoot = resolve(process.cwd(), '..');
|
|
8
|
+
const KIND_LABELS = {
|
|
9
|
+
schema: 'Theme schema',
|
|
10
|
+
'render-error': 'Render error',
|
|
11
|
+
'missing-asset': 'Missing asset',
|
|
12
|
+
'broken-link': 'Broken link',
|
|
13
|
+
};
|
|
14
|
+
function printGrouped(findings) {
|
|
15
|
+
const byKind = new Map();
|
|
16
|
+
for (const finding of findings) {
|
|
17
|
+
const list = byKind.get(finding.kind) ?? [];
|
|
18
|
+
list.push(finding);
|
|
19
|
+
byKind.set(finding.kind, list);
|
|
20
|
+
}
|
|
21
|
+
for (const [kind, list] of byKind) {
|
|
22
|
+
console.log(`\n${KIND_LABELS[kind]} (${list.length}):`);
|
|
23
|
+
for (const finding of list) {
|
|
24
|
+
const location = finding.pageUrl ? ` ${finding.pageUrl}: ` : ' ';
|
|
25
|
+
console.log(`${location}${finding.message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const result = await runSiteCheck(siteRoot);
|
|
30
|
+
if (result.ok) {
|
|
31
|
+
console.log('No problems found.');
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
console.log(`${result.findings.length} problem${result.findings.length === 1 ? '' : 's'} found:`);
|
|
35
|
+
printGrouped(result.findings);
|
|
36
|
+
process.exit(1);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type CheckFindingKind = 'schema' | 'render-error' | 'missing-asset' | 'broken-link';
|
|
2
|
+
export interface CheckFinding {
|
|
3
|
+
kind: CheckFindingKind;
|
|
4
|
+
message: string;
|
|
5
|
+
pageUrl?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CheckResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
findings: CheckFinding[];
|
|
10
|
+
}
|
|
11
|
+
export declare function runSiteCheck(siteRoot: string): Promise<CheckResult>;
|