@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.
- 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/mint-token-cli.js +0 -0
- 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 +3 -9
- 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 +32 -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/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
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/
|
|
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
|
|
91
|
-
//
|
|
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
|
|
98
|
-
//
|
|
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
|
-
|
|
11
|
-
//
|
|
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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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 } };
|
|
@@ -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.
|
|
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.
|
|
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
|
-
}
|