@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.
- package/README.md +7 -16
- package/dist/boot.d.ts +2 -0
- package/dist/boot.js +3 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -1
- package/dist/create-site/cli.js +0 -0
- package/dist/create-site/generate-site.js +1 -1
- package/dist/create-site/mint-token-cli.js +0 -0
- package/dist/create-site/template/AGENTS.md +191 -0
- package/dist/create-site/template/vhost/Dockerfile +1 -1
- package/dist/media/filename.js +4 -1
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +24 -1
- package/dist/renderer/render-cache.d.ts +10 -0
- package/dist/renderer/render-cache.js +11 -0
- package/dist/renderer/render-page.d.ts +2 -0
- package/dist/renderer/render-page.js +40 -1
- package/dist/routes/admin-redirect.d.ts +5 -0
- package/dist/routes/admin-redirect.js +26 -0
- package/dist/routes/capabilities.js +2 -2
- package/dist/routes/media-public.js +6 -0
- package/dist/routes/preview-revision.js +3 -19
- package/dist/routes/preview.js +0 -18
- package/dist/routes/public.d.ts +2 -0
- package/dist/routes/public.js +25 -30
- package/dist/routes/search-public.d.ts +6 -0
- package/dist/routes/search-public.js +104 -0
- package/dist/routes/search.js +4 -0
- package/dist/routes/sitemap.js +4 -10
- package/dist/schemas/page.schema.json +6 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +5 -1
- package/dist/search/drivers/node-sqlite-driver.js +2 -2
- package/dist/search/query-content.d.ts +32 -0
- package/dist/search/query-content.js +207 -0
- package/dist/search/rebuild-index.js +248 -55
- package/dist/server-config.d.ts +1 -0
- package/dist/server-config.js +28 -1
- package/dist/server.js +22 -0
- package/dist/services/content-read.js +5 -10
- package/dist/services/delete-content.js +2 -13
- package/dist/services/manage-redirects.js +5 -15
- package/dist/services/migration-runner.js +55 -13
- package/dist/services/publish.js +8 -14
- package/dist/services/rate-limit-config.d.ts +1 -1
- package/dist/services/rate-limit-config.js +6 -4
- package/dist/services/theme-schemas.js +2 -2
- package/dist/services/validation.d.ts +0 -1
- package/dist/services/validation.js +0 -11
- package/package.json +2 -2
- 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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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);
|
package/dist/services/publish.js
CHANGED
|
@@ -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.
|
|
14
|
-
//
|
|
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
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
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);
|
|
@@ -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
|
|
18
|
-
// reachable with zero credentials
|
|
19
|
-
// legitimate use
|
|
20
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
}
|