@o-a/cms-agent 0.1.6 → 0.2.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 (45) hide show
  1. package/dist/boot.d.ts +2 -0
  2. package/dist/boot.js +3 -1
  3. package/dist/config.d.ts +0 -1
  4. package/dist/config.js +0 -1
  5. package/dist/create-site/cli.js +0 -0
  6. package/dist/create-site/mint-token-cli.js +0 -0
  7. package/dist/media/filename.js +4 -1
  8. package/dist/migrations/index.d.ts +1 -1
  9. package/dist/migrations/index.js +24 -1
  10. package/dist/renderer/render-cache.d.ts +10 -0
  11. package/dist/renderer/render-cache.js +11 -0
  12. package/dist/renderer/render-page.d.ts +2 -0
  13. package/dist/renderer/render-page.js +40 -1
  14. package/dist/routes/admin-redirect.d.ts +5 -0
  15. package/dist/routes/admin-redirect.js +26 -0
  16. package/dist/routes/capabilities.js +2 -2
  17. package/dist/routes/media-public.js +6 -0
  18. package/dist/routes/preview-revision.js +3 -19
  19. package/dist/routes/preview.js +0 -18
  20. package/dist/routes/public.d.ts +2 -0
  21. package/dist/routes/public.js +25 -30
  22. package/dist/routes/search-public.d.ts +6 -0
  23. package/dist/routes/search-public.js +104 -0
  24. package/dist/routes/search.js +4 -0
  25. package/dist/routes/sitemap.js +3 -9
  26. package/dist/schemas/page.schema.json +6 -0
  27. package/dist/search/drivers/node-sqlite-driver.d.ts +5 -1
  28. package/dist/search/drivers/node-sqlite-driver.js +2 -2
  29. package/dist/search/query-content.d.ts +32 -0
  30. package/dist/search/query-content.js +207 -0
  31. package/dist/search/rebuild-index.js +248 -55
  32. package/dist/server-config.d.ts +1 -0
  33. package/dist/server-config.js +28 -1
  34. package/dist/server.js +32 -0
  35. package/dist/services/content-read.js +5 -10
  36. package/dist/services/delete-content.js +2 -13
  37. package/dist/services/manage-redirects.js +5 -15
  38. package/dist/services/migration-runner.js +55 -13
  39. package/dist/services/publish.js +8 -14
  40. package/dist/services/rate-limit-config.d.ts +1 -1
  41. package/dist/services/rate-limit-config.js +6 -4
  42. package/dist/services/validation.d.ts +0 -1
  43. package/dist/services/validation.js +0 -11
  44. package/package.json +2 -2
  45. package/dist/schemas/post.schema.json +0 -25
package/dist/server.js CHANGED
@@ -2,10 +2,12 @@ import rateLimitPlugin from '@fastify/rate-limit';
2
2
  import Fastify, {} from 'fastify';
3
3
  import { bootSite } from "./boot.js";
4
4
  import { loadServerConfig } from "./server-config.js";
5
+ import { adminRedirectRoutes } from "./routes/admin-redirect.js";
5
6
  import { assetsRoutes } from "./routes/assets.js";
6
7
  import { v1Routes } from "./routes/index.js";
7
8
  import { mediaPublicRoutes } from "./routes/media-public.js";
8
9
  import { publicRoutes } from "./routes/public.js";
10
+ import { searchPublicRoutes } from "./routes/search-public.js";
9
11
  import { sitemapRoutes } from "./routes/sitemap.js";
10
12
  import { CHECKPOINT_AUTHOR, runCheckpoint } from "./services/checkpoint.js";
11
13
  import { startDevTunnel } from "./services/dev-tunnel.js";
@@ -76,6 +78,7 @@ export function buildServer(booted, serverConfig, options = {}) {
76
78
  themeTemplates: booted.themeTemplates,
77
79
  layouts: booted.layouts,
78
80
  engine: booted.engine,
81
+ renderCache: booted.renderCache,
79
82
  });
80
83
  // A more specific static-prefixed route than the public catch-all's
81
84
  // own /* wildcard - Fastify prefers it regardless of registration
@@ -98,6 +101,25 @@ export function buildServer(booted, serverConfig, options = {}) {
98
101
  // also always wins over a same-named static file under
99
102
  // theme/root/sitemap.xml (see routes/public.ts's root-mirror check).
100
103
  app.register(sitemapRoutes, { config: booted.config });
104
+ // Same "more specific than the public catch-all" reasoning again - a
105
+ // stable, public, front-end-facing contract (a theme's own JS calls
106
+ // it directly), not part of the versioned /v1 admin/integration
107
+ // surface, so it lives alongside these other unprefixed routes
108
+ // rather than inside v1Routes (search.ts's /v1/search/rebuild is the
109
+ // authenticated write side that does). GET /search.json, not
110
+ // GET /search: a single reserved path, not a whole prefix, so a
111
+ // site's own content page can still live at the bare /search URL
112
+ // (granite-starter's search-demo section does exactly that).
113
+ app.register(searchPublicRoutes, { config: booted.config });
114
+ // Conditional, unlike every other registration above - this is what
115
+ // makes GET /admin genuinely opt-in rather than a reserved
116
+ // namespace: with no adminBaseUrl configured, the route is never
117
+ // registered at all, so an unconfigured site can still use "admin"
118
+ // as an ordinary page path. See routes/admin-redirect.ts's own
119
+ // comment for the full reasoning.
120
+ if (serverConfig.adminBaseUrl) {
121
+ app.register(adminRedirectRoutes, { adminBaseUrl: serverConfig.adminBaseUrl });
122
+ }
101
123
  return app;
102
124
  }
103
125
  // Neither a recurring timer nor process signal handling exists
@@ -159,6 +181,16 @@ export async function startServer(siteRoot, options = {}) {
159
181
  }
160
182
  throw error;
161
183
  }
184
+ // The real bound port, not just serverConfig.port verbatim - the
185
+ // same reasoning tests already rely on (app.server.address()):
186
+ // port 0 means "OS picks a free one", so echoing the configured
187
+ // value back would print a meaningless 0 in that case. 127.0.0.1,
188
+ // not the 0.0.0.0 bind host above - that's what's actually
189
+ // reachable and clickable from the machine running this.
190
+ const address = app.server.address();
191
+ if (address !== null && typeof address !== 'string') {
192
+ console.log(`Site running at http://127.0.0.1:${address.port}`);
193
+ }
162
194
  if (options.tunnel) {
163
195
  try {
164
196
  tunnel = await (options.startTunnel ?? startDevTunnel)(serverConfig.port);
@@ -4,7 +4,6 @@ import { computeEtag } from "./etag.js";
4
4
  import { listFilesRecursively } from "./fs-walk.js";
5
5
  import { sanitisePath } from "./path-safety.js";
6
6
  import { pagePathToUrl } from "./urls.js";
7
- import { postPathToUrl } from "./post-urls.js";
8
7
  export class ContentReadError extends Error {
9
8
  reason;
10
9
  constructor(reason, message) {
@@ -29,7 +28,7 @@ export function readContentFile(root, relativePath) {
29
28
  return { bytes, etag: computeEtag(bytes) };
30
29
  }
31
30
  // Both contentRoot-relative live paths and draftsRoot-relative draft
32
- // paths share the identical pages/posts/menus subroot prefix shape
31
+ // paths share the identical pages/menus subroot prefix shape
33
32
  // (content/drafts/ mirrors content/'s own layout), so one function
34
33
  // handles both with no branching on which root an entry came from.
35
34
  // Menus have no public URL - there is no preview route for them.
@@ -37,9 +36,6 @@ function computeContentUrl(relativePath) {
37
36
  if (relativePath.startsWith('pages/')) {
38
37
  return pagePathToUrl(relativePath.slice('pages/'.length));
39
38
  }
40
- if (relativePath.startsWith('posts/')) {
41
- return postPathToUrl(relativePath.slice('posts/'.length));
42
- }
43
39
  return null;
44
40
  }
45
41
  // The later of the live file's and the draft's own mtime, whichever
@@ -87,20 +83,19 @@ function readSummary(fullPath) {
87
83
  // anywhere in the build plan - this is the only place a draft-only
88
84
  // page (never yet published) is discoverable at all.
89
85
  //
90
- // Deliberately three named walks (pages/posts/menus), not one broad
91
- // walk of contentRoot: since Group N nested draftsRoot and redirectsPath
86
+ // Deliberately two named walks (pages/menus), not one broad walk of
87
+ // contentRoot: since Group N nested draftsRoot and redirectsPath
92
88
  // inside contentRoot (content/drafts/, content/redirects.json), a
93
89
  // single broad contentRoot walk would descend into content/drafts/
94
90
  // too, double-listing draft files under two different relative-path
95
91
  // keys, and would pick up redirects.json as if it were a page. An
96
92
  // inclusion-based walk sidesteps both problems structurally, with no
97
- // exclusion list to maintain. base stays config.contentRoot for all
98
- // three (not each subroot) so the resulting relative paths ("pages/x.json")
93
+ // exclusion list to maintain. base stays config.contentRoot for both
94
+ // (not each subroot) so the resulting relative paths ("pages/x.json")
99
95
  // line up with draftPaths below for the hasDraft/hasLive union logic.
100
96
  export function listContent(config, filters) {
101
97
  const contentPaths = new Set([
102
98
  ...listFilesRecursively(config.pagesRoot, config.contentRoot, '.json'),
103
- ...listFilesRecursively(config.postsRoot, config.contentRoot, '.json'),
104
99
  ...listFilesRecursively(config.menusRoot, config.contentRoot, '.json'),
105
100
  ]);
106
101
  const draftPaths = new Set(listFilesRecursively(config.draftsRoot, config.draftsRoot, '.json'));
@@ -2,27 +2,16 @@ import { existsSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync }
2
2
  import { listFilesRecursively } from "./fs-walk.js";
3
3
  import { commitPaths } from "./git.js";
4
4
  import { sanitisePath } from "./path-safety.js";
5
- import { postPathToUrl } from "./post-urls.js";
6
5
  import { RedirectError, addRedirect, isValidRedirectTarget, loadRedirects, serialiseRedirects, } from "./redirects.js";
7
6
  import { pagePathToUrl } from "./urls.js";
8
7
  import { enqueue } from "./write-queue.js";
9
8
  const PAGES_PREFIX = 'pages/';
10
- const POSTS_PREFIX = 'posts/';
11
- // Widens the redirect-on-delete gate to posts alongside pages, without
12
- // touching the has-children subtree check below (PAGES_PREFIX-only,
13
- // unchanged - posts never have nested children by construction). A
14
- // tiny duplicated helper, not a shared "collection" abstraction,
15
- // matching this codebase's existing precedent (e.g. prepareSaveDraft/
16
- // prepareDiscardDraft mirror rather than share a base with publish.ts/
17
- // move.ts). Returns null for anything else (menus have no public URL
18
- // at all, so redirects are meaningless there).
9
+ // Returns null for anything else (menus have no public URL at all, so
10
+ // redirects are meaningless there).
19
11
  function urlForDeletedEntry(relativePath) {
20
12
  if (relativePath.startsWith(PAGES_PREFIX)) {
21
13
  return pagePathToUrl(relativePath.slice(PAGES_PREFIX.length));
22
14
  }
23
- if (relativePath.startsWith(POSTS_PREFIX)) {
24
- return postPathToUrl(relativePath.slice(POSTS_PREFIX.length));
25
- }
26
15
  return null;
27
16
  }
28
17
  export class DeleteContentError extends Error {
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { commitPaths } from "./git.js";
3
3
  import { sanitisePath } from "./path-safety.js";
4
- import { isBlogUrl, urlToPostPath } from "./post-urls.js";
5
4
  import { RedirectError, addRedirect, isValidRedirectTarget, loadRedirectsStrict, removeRedirectForPath, serialiseRedirects, } from "./redirects.js";
6
5
  import { urlToPagePath } from "./urls.js";
7
6
  import { enqueue } from "./write-queue.js";
@@ -13,22 +12,13 @@ export class ManageRedirectError extends Error {
13
12
  this.reason = reason;
14
13
  }
15
14
  }
16
- // Not required for correctness (resolve-url.ts/resolve-blog-url.ts
17
- // already guarantee live content always wins over a redirect at the
18
- // same URL), but rejecting here avoids a marketing manager creating a
19
- // redirect that would silently never fire.
15
+ // Not required for correctness (resolve-url.ts already guarantees live
16
+ // content always wins over a redirect at the same URL), but rejecting
17
+ // here avoids a marketing manager creating a redirect that would
18
+ // silently never fire.
20
19
  function liveContentExistsAt(config, url) {
21
20
  const pageFile = sanitisePath(config.pagesRoot, urlToPagePath(url));
22
- if (existsSync(pageFile)) {
23
- return true;
24
- }
25
- if (isBlogUrl(url) && existsSync(config.postsRoot)) {
26
- const postRelative = urlToPostPath(url);
27
- if (postRelative !== null && existsSync(sanitisePath(config.postsRoot, postRelative))) {
28
- return true;
29
- }
30
- }
31
- return false;
21
+ return existsSync(pageFile);
32
22
  }
33
23
  function validateFromAndTo(from, to) {
34
24
  if (!isValidRedirectTarget(from)) {
@@ -1,5 +1,5 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
3
  import { listFilesRecursively } from "./fs-walk.js";
4
4
  import { GitOperationError, commitPaths } from "./git.js";
5
5
  import { validateContent } from "./validation.js";
@@ -41,11 +41,23 @@ function applyMigrationChain(content, migrations, currentVersion, path) {
41
41
  // Hand-rolled fs snapshot/restore, matching publish.ts's established
42
42
  // shape - but simpler: every file the write phase touches already
43
43
  // existed with real prior bytes (F3's skip logic guarantees this), so
44
- // there is no "did this file exist before" branch to carry.
44
+ // there is no "did this file exist before" branch to carry. A
45
+ // relocated file (targetPath !== path) is undone by removing whatever
46
+ // landed at targetPath and restoring the original at its original path.
45
47
  function rollback(files) {
46
48
  const failures = [];
47
49
  for (const file of files) {
48
50
  try {
51
+ if (file.targetPath !== file.path) {
52
+ try {
53
+ unlinkSync(file.targetPath);
54
+ }
55
+ catch (error) {
56
+ if (error.code !== 'ENOENT') {
57
+ throw error;
58
+ }
59
+ }
60
+ }
49
61
  writeFileSync(file.path, file.originalBytes);
50
62
  }
51
63
  catch (error) {
@@ -55,27 +67,42 @@ function rollback(files) {
55
67
  return failures;
56
68
  }
57
69
  async function runMigrationsJob(config, themeSchemas, migrations, currentVersion, author) {
58
- // Three named walks (pages/posts/menus), not one broad walk of
70
+ // content/posts/ is a legacy root: posts were folded into pages, and
71
+ // no code walks a postsRoot for anything else any more (see
72
+ // config.ts) - it's computed locally, here only, purely to find any
73
+ // leftover legacy files this migration needs to relocate. A site
74
+ // that never had posts has no such directory; listFilesRecursively
75
+ // already returns [] for a missing root, same as every other caller.
76
+ const legacyPostsRoot = join(config.contentRoot, 'posts');
77
+ // Named walks (pages/menus/legacy posts), not one broad walk of
59
78
  // contentRoot - see content-read.ts's listContent for the identical
60
79
  // reasoning (Group N nested draftsRoot/redirectsPath inside
61
80
  // contentRoot; a broad walk would double-migrate drafts and try to
62
81
  // validate redirects.json as a page). base stays config.contentRoot
63
- // for all three so relativePath matches what validateContent expects.
82
+ // for pages/menus so relativePath matches what validateContent
83
+ // expects. Legacy posts get their own targetPath: content/posts/
84
+ // was always flat (no nested slugs), so relocating each file to
85
+ // content/pages/blog/<slug>.json preserves its old /blog/<slug> URL
86
+ // as an ordinary nested page - no reserved namespace needed any more.
64
87
  const files = [
65
88
  ...listFilesRecursively(config.pagesRoot, config.contentRoot, '.json').map((rel) => ({
66
89
  path: join(config.contentRoot, rel),
67
- relativePath: rel,
68
- })),
69
- ...listFilesRecursively(config.postsRoot, config.contentRoot, '.json').map((rel) => ({
70
- path: join(config.contentRoot, rel),
90
+ targetPath: join(config.contentRoot, rel),
71
91
  relativePath: rel,
72
92
  })),
73
93
  ...listFilesRecursively(config.menusRoot, config.contentRoot, '.json').map((rel) => ({
74
94
  path: join(config.contentRoot, rel),
95
+ targetPath: join(config.contentRoot, rel),
75
96
  relativePath: rel,
76
97
  })),
98
+ ...listFilesRecursively(legacyPostsRoot, legacyPostsRoot, '.json').map((rel) => ({
99
+ path: join(legacyPostsRoot, rel),
100
+ targetPath: join(config.contentRoot, 'pages', 'blog', rel),
101
+ relativePath: join('pages', 'blog', rel),
102
+ })),
77
103
  ...listFilesRecursively(config.draftsRoot, config.draftsRoot, '.json').map((rel) => ({
78
104
  path: join(config.draftsRoot, rel),
105
+ targetPath: join(config.draftsRoot, rel),
79
106
  relativePath: rel,
80
107
  })),
81
108
  ];
@@ -84,10 +111,14 @@ async function runMigrationsJob(config, themeSchemas, migrations, currentVersion
84
111
  // files so far is simply discarded - a mid-list failure aborts with
85
112
  // literally zero writes having happened (F4's primary scenario).
86
113
  const toMigrate = [];
87
- for (const { path, relativePath } of files) {
114
+ for (const { path, targetPath, relativePath } of files) {
88
115
  const originalBytes = readFileSync(path);
89
116
  const parsed = JSON.parse(originalBytes.toString('utf-8'));
90
117
  const schemaVersion = parsed.schemaVersion;
118
+ // A legacy posts/ file can never already be at currentVersion: the
119
+ // posts/ directory only ever held content written before this very
120
+ // migration existed, so this skip and the relocation above never
121
+ // conflict in practice.
91
122
  if (typeof schemaVersion === 'number' && schemaVersion >= currentVersion) {
92
123
  continue;
93
124
  }
@@ -97,16 +128,27 @@ async function runMigrationsJob(config, themeSchemas, migrations, currentVersion
97
128
  throw new MigrationError('validation-failed', `Migrated content at "${path}" failed validation: ${JSON.stringify(result.errors)}`);
98
129
  }
99
130
  const migratedBytes = Buffer.from(JSON.stringify(migrated, null, 2));
100
- toMigrate.push({ path, originalBytes, migratedBytes });
131
+ toMigrate.push({ path, targetPath, originalBytes, migratedBytes });
101
132
  }
102
133
  if (toMigrate.length === 0) {
103
134
  return;
104
135
  }
105
136
  try {
106
137
  for (const file of toMigrate) {
107
- writeFileSync(file.path, file.migratedBytes);
138
+ if (file.targetPath !== file.path) {
139
+ mkdirSync(dirname(file.targetPath), { recursive: true });
140
+ }
141
+ writeFileSync(file.targetPath, file.migratedBytes);
142
+ if (file.targetPath !== file.path) {
143
+ unlinkSync(file.path);
144
+ }
145
+ }
146
+ const committedPaths = new Set();
147
+ for (const file of toMigrate) {
148
+ committedPaths.add(file.path);
149
+ committedPaths.add(file.targetPath);
108
150
  }
109
- commitPaths(config.siteRoot, toMigrate.map((file) => file.path), `chore: migrate content to schema version ${currentVersion}`, author);
151
+ commitPaths(config.siteRoot, [...committedPaths], `chore: migrate content to schema version ${currentVersion}`, author);
110
152
  }
111
153
  catch (error) {
112
154
  const failures = rollback(toMigrate);
@@ -2,24 +2,18 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
2
2
  import { dirname } from 'node:path';
3
3
  import { GitOperationError, commitPaths } from "./git.js";
4
4
  import { sanitisePath } from "./path-safety.js";
5
- import { postPathToUrl } from "./post-urls.js";
6
5
  import { loadRedirects, removeRedirectForPath, serialiseRedirects } from "./redirects.js";
7
6
  import { pagePathToUrl } from "./urls.js";
8
7
  import { validateContent } from "./validation.js";
9
8
  import { enqueue } from "./write-queue.js";
10
9
  const PAGES_PREFIX = 'pages/';
11
- const POSTS_PREFIX = 'posts/';
12
10
  // Mirrors delete-content.ts's urlForDeletedEntry - a tiny duplicated
13
- // helper, not a shared abstraction. Widens the stale-redirect-clearing
14
- // block below to posts alongside pages; returns null for anything
15
- // else (menus have no public URL, so redirect semantics don't apply).
11
+ // helper, not a shared abstraction. Returns null for anything else
12
+ // (menus have no public URL, so redirect semantics don't apply).
16
13
  function urlForPublishedEntry(relativePath) {
17
14
  if (relativePath.startsWith(PAGES_PREFIX)) {
18
15
  return pagePathToUrl(relativePath.slice(PAGES_PREFIX.length));
19
16
  }
20
- if (relativePath.startsWith(POSTS_PREFIX)) {
21
- return postPathToUrl(relativePath.slice(POSTS_PREFIX.length));
22
- }
23
17
  return null;
24
18
  }
25
19
  export class PublishError extends Error {
@@ -150,12 +144,12 @@ export function preparePublishDrafts(config, themeSchemas, relativePaths) {
150
144
  writeFileSync(entry.livePath, entry.draftContent);
151
145
  unlinkSync(entry.draftPath);
152
146
  }
153
- // E5 (publish half): a brand-new live page or post may supersede a
154
- // stale redirect recorded at its own URL (checklist wording:
155
- // "creating a page at a path that has a redirect entry"). Only
156
- // applies to pages/posts content - redirect semantics aren't
157
- // defined for menus (no public URL), so anything else is skipped
158
- // silently via urlForPublishedEntry's null return.
147
+ // E5 (publish half): a brand-new live page may supersede a stale
148
+ // redirect recorded at its own URL (checklist wording: "creating a
149
+ // page at a path that has a redirect entry"). Only applies to page
150
+ // content - redirect semantics aren't defined for menus (no public
151
+ // URL), so anything else is skipped silently via
152
+ // urlForPublishedEntry's null return.
159
153
  const qualifyingUrls = entries
160
154
  .map((entry) => urlForPublishedEntry(entry.relativePath))
161
155
  .filter((url) => url !== null);
@@ -1,7 +1,7 @@
1
1
  export declare const WRITE_ROUTE_RATE_LIMIT: {
2
2
  rateLimit: {};
3
3
  };
4
- export declare const CAPABILITIES_RATE_LIMIT: {
4
+ export declare const NO_AUTH_ROUTE_RATE_LIMIT: {
5
5
  rateLimit: {
6
6
  max: number;
7
7
  timeWindow: number;
@@ -14,7 +14,9 @@
14
14
  // route's own config.rateLimit only needs *something* present to opt
15
15
  // in, not a duplicated copy of the numbers.
16
16
  export const WRITE_ROUTE_RATE_LIMIT = { rateLimit: {} };
17
- // Generous defense-in-depth against basic scanning of the one endpoint
18
- // reachable with zero credentials, not a meaningful throttle on
19
- // legitimate use.
20
- export const CAPABILITIES_RATE_LIMIT = { rateLimit: { max: 300, timeWindow: 60000 } };
17
+ // Generous defense-in-depth against basic scanning/abuse of the
18
+ // endpoints reachable with zero credentials (GET /v1/capabilities,
19
+ // GET /v1/search), not a meaningful throttle on legitimate use - a
20
+ // real front-end calling search on every keystroke of a live search
21
+ // box should never realistically hit this.
22
+ export const NO_AUTH_ROUTE_RATE_LIMIT = { rateLimit: { max: 300, timeWindow: 60000 } };
@@ -18,7 +18,6 @@ export interface ThemeSchemas {
18
18
  export declare function requiredFieldsHaveValidDefaults(schema: object): boolean;
19
19
  export declare function validateInstance(instance: unknown, kind: 'section' | 'block', themeSchemas: ThemeSchemas): ValidationResult;
20
20
  export declare function validatePage(page: unknown, themeSchemas: ThemeSchemas): ValidationResult;
21
- export declare function validatePost(post: unknown, themeSchemas: ThemeSchemas): ValidationResult;
22
21
  export declare function validateMenu(menu: unknown): ValidationResult;
23
22
  export declare function validateRedirects(redirects: unknown): ValidationResult;
24
23
  export declare function validateContent(relativePath: string, content: unknown, themeSchemas: ThemeSchemas): ValidationResult;
@@ -12,7 +12,6 @@ function readSchema(filename) {
12
12
  const instanceSchema = readSchema('instance.schema.json');
13
13
  ajv.addSchema(instanceSchema);
14
14
  const validatePageEnvelope = ajv.compile(readSchema('page.schema.json'));
15
- const validatePostEnvelope = ajv.compile(readSchema('post.schema.json'));
16
15
  const validateMenuEnvelope = ajv.compile(readSchema('menu.schema.json'));
17
16
  const validateRedirectsEnvelope = ajv.compile(readSchema('redirects.schema.json'));
18
17
  const validateInstanceEnvelope = ajv.compile(instanceSchema);
@@ -112,10 +111,6 @@ export function validateInstance(instance, kind, themeSchemas) {
112
111
  }
113
112
  return { valid: errors.length === 0, errors };
114
113
  }
115
- // Shared by validatePage and validatePost: both envelopes require an
116
- // identical sections/blocks recursion once their own envelope-level
117
- // shape is confirmed valid - genuinely load-bearing for two real
118
- // content types now, not a speculative abstraction.
119
114
  function validateSectionedContent(envelopeValidate, content, themeSchemas) {
120
115
  const envelopeResult = runValidator(envelopeValidate, content);
121
116
  if (!envelopeResult.valid) {
@@ -132,9 +127,6 @@ function validateSectionedContent(envelopeValidate, content, themeSchemas) {
132
127
  export function validatePage(page, themeSchemas) {
133
128
  return validateSectionedContent(validatePageEnvelope, page, themeSchemas);
134
129
  }
135
- export function validatePost(post, themeSchemas) {
136
- return validateSectionedContent(validatePostEnvelope, post, themeSchemas);
137
- }
138
130
  export function validateMenu(menu) {
139
131
  return runValidator(validateMenuEnvelope, menu);
140
132
  }
@@ -151,8 +143,5 @@ export function validateContent(relativePath, content, themeSchemas) {
151
143
  if (relativePath.startsWith('menus/')) {
152
144
  return validateMenu(content);
153
145
  }
154
- if (relativePath.startsWith('posts/')) {
155
- return validatePost(content, themeSchemas);
156
- }
157
146
  return validatePage(content, themeSchemas);
158
147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o-a/cms-agent",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -29,7 +29,7 @@
29
29
  "dist"
30
30
  ],
31
31
  "engines": {
32
- "node": ">=22.6.0"
32
+ "node": ">=22.16.0"
33
33
  },
34
34
  "scripts": {
35
35
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -1,25 +0,0 @@
1
- {
2
- "$id": "post.schema.json",
3
- "$schema": "http://json-schema.org/draft-07/schema#",
4
- "title": "Post",
5
- "type": "object",
6
- "additionalProperties": false,
7
- "required": ["schemaVersion", "title", "type", "layout", "published", "author", "publishDate", "tags", "sections"],
8
- "properties": {
9
- "schemaVersion": { "type": "integer", "minimum": 1 },
10
- "title": { "type": "string", "minLength": 1 },
11
- "type": { "const": "post" },
12
- "layout": { "type": "string", "minLength": 1 },
13
- "published": { "type": "boolean" },
14
- "author": { "type": "string", "minLength": 1 },
15
- "publishDate": { "type": "string", "minLength": 1 },
16
- "tags": {
17
- "type": "array",
18
- "items": { "type": "string", "minLength": 1 }
19
- },
20
- "sections": {
21
- "type": "array",
22
- "items": { "$ref": "instance.schema.json" }
23
- }
24
- }
25
- }