@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.
Files changed (53) hide show
  1. package/dist/create-site/cli.js +0 -0
  2. package/dist/create-site/generate-site.js +21 -0
  3. package/dist/create-site/mint-token-cli.js +0 -0
  4. package/dist/create-site/template/AGENTS.md +189 -5
  5. package/dist/create-site/template/content/menus/footerCompany.json +1 -1
  6. package/dist/create-site/template/content/menus/footerProduct.json +1 -1
  7. package/dist/create-site/template/content/menus/footerResources.json +1 -1
  8. package/dist/create-site/template/content/menus/main.json +1 -1
  9. package/dist/create-site/template/content/pages/404.json +1 -1
  10. package/dist/create-site/template/content/pages/about/careers.json +1 -1
  11. package/dist/create-site/template/content/pages/about/team.json +1 -1
  12. package/dist/create-site/template/content/pages/about.json +1 -1
  13. package/dist/create-site/template/content/pages/docs/deployment.json +1 -1
  14. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +1 -1
  15. package/dist/create-site/template/content/pages/docs/getting-started.json +1 -1
  16. package/dist/create-site/template/content/pages/docs.json +1 -1
  17. package/dist/create-site/template/content/pages/index.json +1 -1
  18. package/dist/media/filename.d.ts +1 -0
  19. package/dist/media/filename.js +13 -0
  20. package/dist/media/seed-media-cli.d.ts +2 -0
  21. package/dist/media/seed-media-cli.js +22 -0
  22. package/dist/media/seed-media.d.ts +11 -0
  23. package/dist/media/seed-media.js +76 -0
  24. package/dist/renderer/render-page.d.ts +3 -0
  25. package/dist/renderer/render-page.js +13 -6
  26. package/dist/routes/media.js +5 -9
  27. package/dist/routes/publish.js +37 -3
  28. package/dist/routes/sitemap.d.ts +1 -0
  29. package/dist/routes/sitemap.js +7 -1
  30. package/dist/search/rebuild-index.d.ts +1 -0
  31. package/dist/search/rebuild-index.js +17 -1
  32. package/dist/server.js +17 -0
  33. package/dist/services/batch.js +10 -1
  34. package/dist/services/delete-content.js +4 -1
  35. package/dist/services/move.js +4 -1
  36. package/dist/services/publish.d.ts +1 -0
  37. package/dist/services/publish.js +67 -2
  38. package/dist/services/reindex-on-write.d.ts +3 -0
  39. package/dist/services/reindex-on-write.js +30 -0
  40. package/dist/services/theme-schemas.js +13 -5
  41. package/dist/services/validation.d.ts +1 -0
  42. package/dist/services/validation.js +33 -1
  43. package/dist/site-check/cli.d.ts +2 -0
  44. package/dist/site-check/cli.js +36 -0
  45. package/dist/site-check/run-check.d.ts +11 -0
  46. package/dist/site-check/run-check.js +109 -0
  47. package/package.json +5 -3
  48. package/dist/search/query-index.d.ts +0 -5
  49. package/dist/search/query-index.js +0 -21
  50. package/dist/services/post-urls.d.ts +0 -3
  51. package/dist/services/post-urls.js +0 -27
  52. package/dist/services/resolve-blog-url.d.ts +0 -11
  53. package/dist/services/resolve-blog-url.js +0 -31
@@ -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({
@@ -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 parseUnpublishBody(body) {
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 = parseUnpublishBody(request.body);
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
  };
@@ -3,4 +3,5 @@ import type { SiteConfig } from '../config.ts';
3
3
  export interface SitemapRouteOptions {
4
4
  config: SiteConfig;
5
5
  }
6
+ export declare function buildSitemapUrls(config: SiteConfig): string[];
6
7
  export declare const sitemapRoutes: FastifyPluginAsync<SitemapRouteOptions>;
@@ -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
- function buildSitemapUrls(config) {
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,2 +1,3 @@
1
1
  import type { SiteConfig } from '../config.ts';
2
2
  export declare function rebuildIndex(config: SiteConfig): Promise<void>;
3
+ export declare function rebuildIndexIfMissing(config: SiteConfig): Promise<void>;
@@ -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);
@@ -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
- return enqueue(() => batchJob(config, themeSchemas, operations, publish, message, author));
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
- return enqueue(() => deleteContentJob(config, relativePath, redirectTo, message, author));
163
+ const result = enqueue(() => deleteContentJob(config, relativePath, redirectTo, message, author));
164
+ result.then(() => reindexInBackground(config), () => undefined);
165
+ return result;
163
166
  }
@@ -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
- return enqueue(() => movePageJob(config, fromUrl, toUrl, message, author, options));
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>;
@@ -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
- return enqueue(() => publishDraftsJob(config, themeSchemas, relativePaths, message, author));
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
- return enqueue(() => unpublishPageJob(config, relativePath, message, author));
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,3 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export declare function reindexInBackground(config: SiteConfig): void;
3
+ export declare function reindexOnBootIfMissing(config: SiteConfig): void;
@@ -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
- function loadTypeSchemas(typesDir) {
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
- return ajv.validate(propertySchema, propertySchema.default) === true;
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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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>;