@o-a/cms-agent 0.1.7 → 0.2.1

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 (50) hide show
  1. package/README.md +7 -16
  2. package/dist/boot.d.ts +2 -0
  3. package/dist/boot.js +3 -1
  4. package/dist/config.d.ts +0 -1
  5. package/dist/config.js +0 -1
  6. package/dist/create-site/cli.js +0 -0
  7. package/dist/create-site/generate-site.js +1 -1
  8. package/dist/create-site/mint-token-cli.js +0 -0
  9. package/dist/create-site/template/AGENTS.md +191 -0
  10. package/dist/create-site/template/vhost/Dockerfile +1 -1
  11. package/dist/media/filename.js +4 -1
  12. package/dist/migrations/index.d.ts +1 -1
  13. package/dist/migrations/index.js +24 -1
  14. package/dist/renderer/render-cache.d.ts +10 -0
  15. package/dist/renderer/render-cache.js +11 -0
  16. package/dist/renderer/render-page.d.ts +2 -0
  17. package/dist/renderer/render-page.js +40 -1
  18. package/dist/routes/admin-redirect.d.ts +5 -0
  19. package/dist/routes/admin-redirect.js +26 -0
  20. package/dist/routes/capabilities.js +2 -2
  21. package/dist/routes/media-public.js +6 -0
  22. package/dist/routes/preview-revision.js +3 -19
  23. package/dist/routes/preview.js +0 -18
  24. package/dist/routes/public.d.ts +2 -0
  25. package/dist/routes/public.js +25 -30
  26. package/dist/routes/search-public.d.ts +6 -0
  27. package/dist/routes/search-public.js +104 -0
  28. package/dist/routes/search.js +4 -0
  29. package/dist/routes/sitemap.js +4 -10
  30. package/dist/schemas/page.schema.json +6 -0
  31. package/dist/search/drivers/node-sqlite-driver.d.ts +5 -1
  32. package/dist/search/drivers/node-sqlite-driver.js +2 -2
  33. package/dist/search/query-content.d.ts +32 -0
  34. package/dist/search/query-content.js +207 -0
  35. package/dist/search/rebuild-index.js +248 -55
  36. package/dist/server-config.d.ts +1 -0
  37. package/dist/server-config.js +28 -1
  38. package/dist/server.js +22 -0
  39. package/dist/services/content-read.js +5 -10
  40. package/dist/services/delete-content.js +2 -13
  41. package/dist/services/manage-redirects.js +5 -15
  42. package/dist/services/migration-runner.js +55 -13
  43. package/dist/services/publish.js +8 -14
  44. package/dist/services/rate-limit-config.d.ts +1 -1
  45. package/dist/services/rate-limit-config.js +6 -4
  46. package/dist/services/theme-schemas.js +2 -2
  47. package/dist/services/validation.d.ts +0 -1
  48. package/dist/services/validation.js +0 -11
  49. package/package.json +2 -2
  50. package/dist/schemas/post.schema.json +0 -25
@@ -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 } };
@@ -40,14 +40,14 @@ function loadTypeSchemas(typesDir) {
40
40
  // A type whose required settings fields lack usable defaults is
41
41
  // skipped the same way a malformed schema block already is -
42
42
  // never a boot failure, just excluded from what gets registered
43
- // (theme-authoring-guide.md, Group L).
43
+ // (guide-theme-authoring.md, Group L).
44
44
  if (!requiredFieldsHaveValidDefaults(parsed.schema)) {
45
45
  continue;
46
46
  }
47
47
  schemas[type] = parsed.schema;
48
48
  // The only place "does this type support nested blocks" is ever
49
49
  // expressed - a markup convention (does the template loop
50
- // blocksHtml), not a schema field (theme-authoring-guide.md).
50
+ // blocksHtml), not a schema field (guide-theme-authoring.md).
51
51
  acceptsBlocks[type] = parsed.markup.includes('blocksHtml');
52
52
  }
53
53
  return { schemas, acceptsBlocks };
@@ -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.7",
3
+ "version": "0.2.1",
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
- }