@o-a/cms-agent 0.2.1 → 0.2.2

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.
@@ -58,9 +58,9 @@ This scaffold already ships real, working examples worth reading before writing
58
58
  {% endschema %}
59
59
  ```
60
60
 
61
- Available variables in a section: `section.id`, `section.settings.<key>`, and `blocksHtml` (an array of already-rendered child block HTML strings - a section never sees raw block data, only finished HTML, output with `{{ html | raw }}`). A block template gets the same shape: `block.id`, `block.settings.<key>`, and (rarely) its own `blocksHtml` if it nests further blocks.
61
+ Available variables in a section: `section.id`, `section.settings.<key>`, `blocksHtml` (an array of already-rendered child block HTML strings - a section never sees raw block data, only finished HTML, output with `{{ html | raw }}`), and `page` - the same built-in envelope a layout gets (`page.title`, `page.author`, `page.publishDate`, `page.tags`; see "Content JSON model" below). A block template gets the same shape: `block.id`, `block.settings.<key>`, `page`, and (rarely) its own `blocksHtml` if it nests further blocks. `page.author`/`publishDate`/`tags` render as empty/absent, not an error, on a page that doesn't set them - useful for printing a byline/date inside the page body without duplicating the value into a settings field.
62
62
 
63
- **Every property listed in a schema's `"required"` array must also declare a `"default"`** that itself satisfies the property's own constraints (e.g. not `"default": ""` against `"minLength": 1`). A schema that violates this is silently excluded from the theme entirely - it simply won't be selectable, with no error printed anywhere obvious. If a new section/block isn't showing up, check this first.
63
+ **Every property listed in a schema's `"required"` array must also declare a `"default"`** that itself satisfies the property's own constraints (e.g. not `"default": ""` against `"minLength": 1`). A schema that violates this - or one with no `{% schema %}` block at all, or invalid JSON inside it - is excluded from the theme entirely (it simply won't be selectable), but never silently: boot prints a warning naming the type and the specific reason. If a new section/block isn't showing up, check the server's console output first.
64
64
 
65
65
  To restrict which block types are allowed under a given section/block, add `"allowedBlocks": ["button", "logo-mark"]` alongside `"properties"` in its schema - omit it entirely for no restriction (the default).
66
66
 
@@ -70,7 +70,10 @@ To restrict which block types are allowed under a given section/block, add `"all
70
70
 
71
71
  ```liquid
72
72
  {{ content_for_layout | raw }} the page's fully-rendered sections, concatenated
73
- {{ page.title }} the page's title (the ONLY page field exposed to layouts)
73
+ {{ page.title }} the page's title
74
+ {{ page.author }} the page's author, if set
75
+ {{ page.publishDate }} the page's publish date, if set
76
+ {% for tag in page.tags %}...{% endfor %} the page's tags, if any
74
77
  {{ menus.<name>.items }} every menu in content/menus/, keyed by filename
75
78
  ```
76
79
 
@@ -170,6 +173,8 @@ unless the design needs a focal point for a cropped image, in which case use `"f
170
173
 
171
174
  A public, unauthenticated, read-only endpoint - safe to call directly from a section's own client-side JavaScript with a plain `fetch()`, no token needed. Only ever returns already-published content. Query params: `q` (full-text), `filter=field:op:value` (repeatable, ANDed; `op` is `eq`/`gt`/`gte`/`lt`/`lte`), `pageType`, `sort` (`-publishDate` for newest-first), `limit`, `offset`. Useful for a blog listing, a filterable directory, or a live search box.
172
175
 
176
+ The index behind this endpoint keeps itself current automatically - it rebuilds in the background after every publish/unpublish/delete/move, and once at boot if no index exists yet (a fresh clone, since the index itself is never git-tracked). No manual step is needed for a blog listing built on this endpoint to work on a freshly deployed site.
177
+
173
178
  ## Hard constraints - do not deviate from these
174
179
 
175
180
  - **No dynamically registered Liquid tags or filters, ever.** Only standard LiquidJS built-ins (`if`, `for`, `assign`, `render`, filters like `upcase`, `times`) plus the CMS-provided context objects described above. Never invent a custom tag.
@@ -20,6 +20,9 @@ export interface PageContent {
20
20
  published: boolean;
21
21
  layout: string;
22
22
  sections: SectionOrBlockInstance[];
23
+ author?: string;
24
+ publishDate?: string;
25
+ tags?: string[];
23
26
  }
24
27
  export declare function renderSections(page: PageContent, themeTemplates: ThemeTemplates, engine: Liquid): Promise<string>;
25
28
  export type RenderMode = 'public' | 'preview';
@@ -10,13 +10,16 @@ export class PageRenderError extends Error {
10
10
  this.reason = reason;
11
11
  }
12
12
  }
13
+ function pageEnvelope(page) {
14
+ return { title: page.title, author: page.author, publishDate: page.publishDate, tags: page.tags };
15
+ }
13
16
  // Renders one section or block, recursively rendering any nested blocks
14
17
  // first (instance.schema.json is self-referential, so a block can carry
15
18
  // its own nested blocks). Never uses {% include %}/{% render %}: block
16
19
  // HTML is pre-rendered here in JS and handed to the parent template as
17
20
  // a plain array, which sidesteps LiquidJS's own filesystem include
18
21
  // resolution entirely.
19
- async function renderInstance(instance, kind, themeTemplates, engine) {
22
+ async function renderInstance(instance, kind, themeTemplates, engine, page) {
20
23
  const templates = kind === 'section' ? themeTemplates.sections : themeTemplates.blocks;
21
24
  const template = templates[instance.type];
22
25
  if (!template) {
@@ -24,13 +27,16 @@ async function renderInstance(instance, kind, themeTemplates, engine) {
24
27
  }
25
28
  const blocksHtml = [];
26
29
  for (const block of instance.blocks ?? []) {
27
- blocksHtml.push(await renderInstance(block, 'block', themeTemplates, engine));
30
+ blocksHtml.push(await renderInstance(block, 'block', themeTemplates, engine, page));
28
31
  }
29
32
  // Shopify-style scope shape: settings nested under the instance, not
30
33
  // flattened, so templates read section.settings.x / block.settings.x.
34
+ // `page` is the same built-in envelope a layout gets (title plus the
35
+ // optional author/publishDate/tags fields) - lets a section print a
36
+ // byline/date without duplicating it into a settings field.
31
37
  const scope = kind === 'section'
32
- ? { section: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml }
33
- : { block: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml };
38
+ ? { section: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml, page }
39
+ : { block: { id: instance.id, type: instance.type, settings: instance.settings }, blocksHtml, page };
34
40
  try {
35
41
  return (await engine.parseAndRender(template, scope));
36
42
  }
@@ -46,9 +52,10 @@ async function renderInstance(instance, kind, themeTemplates, engine) {
46
52
  // No page-level layout/wrapper concept in Phase 1 - this is the whole
47
53
  // output.
48
54
  export async function renderSections(page, themeTemplates, engine) {
55
+ const envelope = pageEnvelope(page);
49
56
  const html = [];
50
57
  for (const section of page.sections) {
51
- html.push(await renderInstance(section, 'section', themeTemplates, engine));
58
+ html.push(await renderInstance(section, 'section', themeTemplates, engine, envelope));
52
59
  }
53
60
  return html.join('');
54
61
  }
@@ -134,7 +141,7 @@ export async function renderLoadedPage(page, config, themeTemplates, layouts, en
134
141
  // outputEscape: 'escape' double-escapes it into literal text.
135
142
  return (await engine.parseAndRender(layoutTemplate, {
136
143
  content_for_layout: bodyHtml,
137
- page: { title: page.title },
144
+ page: pageEnvelope(page),
138
145
  menus,
139
146
  }));
140
147
  }
@@ -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
  }
@@ -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
@@ -241,8 +242,18 @@ async function unpublishPageJob(config, relativePath, message, author) {
241
242
  }
242
243
  }
243
244
  export function publishDrafts(config, themeSchemas, relativePaths, message, author) {
244
- return enqueue(() => publishDraftsJob(config, themeSchemas, relativePaths, message, author));
245
+ const result = enqueue(() => publishDraftsJob(config, themeSchemas, relativePaths, message, author));
246
+ // Only on success - a failed publish changed nothing, so there's
247
+ // nothing to reindex. Chained onto `result` rather than awaited here:
248
+ // by the time this callback runs, the write-queue's own tail has
249
+ // already advanced past this job, so reindexInBackground's own
250
+ // enqueue()d rebuild queues cleanly behind it (see that function's
251
+ // own comment on why calling it any earlier would deadlock).
252
+ result.then(() => reindexInBackground(config), () => undefined);
253
+ return result;
245
254
  }
246
255
  export function unpublishPage(config, relativePath, message, author) {
247
- return enqueue(() => unpublishPageJob(config, relativePath, message, author));
256
+ const result = enqueue(() => unpublishPageJob(config, relativePath, message, author));
257
+ result.then(() => reindexInBackground(config), () => undefined);
258
+ return result;
248
259
  }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o-a/cms-agent",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"