@o-a/cms-agent 0.2.1 → 0.3.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/create-site/cli.js +0 -0
- package/dist/create-site/generate-site.js +21 -0
- package/dist/create-site/mint-token-cli.js +0 -0
- package/dist/create-site/template/AGENTS.md +189 -5
- package/dist/create-site/template/content/menus/footerCompany.json +1 -1
- package/dist/create-site/template/content/menus/footerProduct.json +1 -1
- package/dist/create-site/template/content/menus/footerResources.json +1 -1
- package/dist/create-site/template/content/menus/main.json +1 -1
- package/dist/create-site/template/content/pages/404.json +1 -1
- package/dist/create-site/template/content/pages/about/careers.json +1 -1
- package/dist/create-site/template/content/pages/about/team.json +1 -1
- package/dist/create-site/template/content/pages/about.json +1 -1
- package/dist/create-site/template/content/pages/docs/deployment.json +1 -1
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +1 -1
- package/dist/create-site/template/content/pages/docs/getting-started.json +1 -1
- package/dist/create-site/template/content/pages/docs.json +1 -1
- package/dist/create-site/template/content/pages/index.json +1 -1
- package/dist/media/filename.d.ts +1 -0
- package/dist/media/filename.js +13 -0
- package/dist/media/seed-media-cli.d.ts +2 -0
- package/dist/media/seed-media-cli.js +22 -0
- package/dist/media/seed-media.d.ts +11 -0
- package/dist/media/seed-media.js +76 -0
- package/dist/renderer/render-page.d.ts +3 -0
- package/dist/renderer/render-page.js +13 -6
- package/dist/routes/media.js +5 -9
- package/dist/routes/publish.js +37 -3
- package/dist/routes/sitemap.d.ts +1 -0
- package/dist/routes/sitemap.js +7 -1
- package/dist/search/rebuild-index.d.ts +1 -0
- package/dist/search/rebuild-index.js +17 -1
- package/dist/server.js +17 -0
- package/dist/services/batch.js +10 -1
- package/dist/services/delete-content.js +4 -1
- package/dist/services/move.js +4 -1
- package/dist/services/publish.d.ts +1 -0
- package/dist/services/publish.js +67 -2
- package/dist/services/reindex-on-write.d.ts +3 -0
- package/dist/services/reindex-on-write.js +30 -0
- package/dist/services/theme-schemas.js +13 -5
- package/dist/services/validation.d.ts +1 -0
- package/dist/services/validation.js +33 -1
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +36 -0
- package/dist/site-check/run-check.d.ts +11 -0
- package/dist/site-check/run-check.js +109 -0
- package/package.json +5 -3
- package/dist/search/query-index.d.ts +0 -5
- package/dist/search/query-index.js +0 -21
- package/dist/services/post-urls.d.ts +0 -3
- package/dist/services/post-urls.js +0 -27
- package/dist/services/resolve-blog-url.d.ts +0 -11
- package/dist/services/resolve-blog-url.js +0 -31
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { bootSite } from "../boot.js";
|
|
3
|
+
import { renderPage } from "../renderer/render-page.js";
|
|
4
|
+
import { PathSafetyError, sanitisePath } from "../services/path-safety.js";
|
|
5
|
+
import { buildSitemapUrls } from "../routes/sitemap.js";
|
|
6
|
+
import { urlToPagePath } from "../services/urls.js";
|
|
7
|
+
// A reference this project's own theme conventions actually produce:
|
|
8
|
+
// src="...", srcset="w1 480w, w2 960w" (comma-separated, each entry a
|
|
9
|
+
// url then a space then a width descriptor - strip the descriptor),
|
|
10
|
+
// href="...". Not a full HTML parser - matching SchemaField.tsx's own
|
|
11
|
+
// "the schema surface here is narrow and flat... a library would be
|
|
12
|
+
// heavier than the problem warrants" precedent for the equivalent
|
|
13
|
+
// choice on the admin side.
|
|
14
|
+
const ATTR_PATTERN = /\b(?:src|href)="([^"]*)"|\bsrcset="([^"]*)"/g;
|
|
15
|
+
function extractReferences(html) {
|
|
16
|
+
const refs = [];
|
|
17
|
+
for (const match of html.matchAll(ATTR_PATTERN)) {
|
|
18
|
+
const [, single, srcset] = match;
|
|
19
|
+
if (single !== undefined) {
|
|
20
|
+
refs.push(single);
|
|
21
|
+
}
|
|
22
|
+
else if (srcset !== undefined) {
|
|
23
|
+
for (const entry of srcset.split(',')) {
|
|
24
|
+
const url = entry.trim().split(/\s+/)[0];
|
|
25
|
+
if (url) {
|
|
26
|
+
refs.push(url);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return refs;
|
|
32
|
+
}
|
|
33
|
+
// True for a root-relative static path that looks like a real file
|
|
34
|
+
// (has a "." in its last path segment - /favicon.ico, /robots.txt),
|
|
35
|
+
// as opposed to a page URL like /about or /blog/hello-world, which
|
|
36
|
+
// never do. Doesn't need to be perfect - a page URL with a literal dot
|
|
37
|
+
// in its own slug is vanishingly unlikely and, worst case, just gets
|
|
38
|
+
// checked against the wrong bucket and reported as the wrong kind of
|
|
39
|
+
// finding, never silently skipped.
|
|
40
|
+
function looksLikeStaticFile(path) {
|
|
41
|
+
const lastSegment = path.split('/').pop() ?? '';
|
|
42
|
+
return lastSegment.includes('.');
|
|
43
|
+
}
|
|
44
|
+
// A rendered reference is theme/content-derived, not a live HTTP
|
|
45
|
+
// request's own :path - but the same traversal concern still applies
|
|
46
|
+
// (constraint 7), so it still goes through sanitisePath rather than a
|
|
47
|
+
// bare join+existsSync. A reference that fails to sanitise (a "../"
|
|
48
|
+
// escaping root) is exactly as real a finding as one that's simply
|
|
49
|
+
// missing - reported the same way, not silently skipped.
|
|
50
|
+
function checkStaticReference(findings, root, relativePath, originalPath, rootLabel, pageUrl) {
|
|
51
|
+
try {
|
|
52
|
+
const filePath = sanitisePath(root, relativePath);
|
|
53
|
+
if (!existsSync(filePath)) {
|
|
54
|
+
findings.push({ kind: 'missing-asset', message: `${originalPath} does not exist under ${rootLabel}`, pageUrl });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof PathSafetyError) {
|
|
59
|
+
findings.push({ kind: 'missing-asset', message: `${originalPath} is not a safe reference under ${rootLabel} (${error.message})`, pageUrl });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function runSiteCheck(siteRoot) {
|
|
66
|
+
const booted = bootSite(siteRoot);
|
|
67
|
+
const findings = [];
|
|
68
|
+
for (const warning of booted.themeSchemas.warnings ?? []) {
|
|
69
|
+
findings.push({ kind: 'schema', message: warning });
|
|
70
|
+
}
|
|
71
|
+
const publishedUrls = buildSitemapUrls(booted.config);
|
|
72
|
+
const publishedUrlSet = new Set(publishedUrls);
|
|
73
|
+
for (const pageUrl of publishedUrls) {
|
|
74
|
+
// urlToPagePath's own result is relative to pagesRoot (e.g.
|
|
75
|
+
// "about.json"), but renderPage's own relativePath is relative to
|
|
76
|
+
// contentRoot - the same "pages/" prefix routes/public.ts's own
|
|
77
|
+
// toRenderPath helper adds before every one of its renderPage
|
|
78
|
+
// calls.
|
|
79
|
+
const relativePath = `pages/${urlToPagePath(pageUrl)}`;
|
|
80
|
+
let html;
|
|
81
|
+
try {
|
|
82
|
+
html = await renderPage(booted.config, booted.themeTemplates, booted.layouts, booted.engine, relativePath, 'public');
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
86
|
+
findings.push({ kind: 'render-error', message: detail, pageUrl });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
for (const ref of extractReferences(html)) {
|
|
90
|
+
if (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('data:') || ref.startsWith('//')) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const path = ref.split(/[?#]/)[0] ?? ref;
|
|
94
|
+
if (path.startsWith('/media/')) {
|
|
95
|
+
checkStaticReference(findings, booted.config.mediaRoot, path.slice('/media/'.length), path, 'media/', pageUrl);
|
|
96
|
+
}
|
|
97
|
+
else if (path.startsWith('/assets/')) {
|
|
98
|
+
checkStaticReference(findings, booted.config.assetsRoot, path.slice('/assets/'.length), path, 'theme/assets/', pageUrl);
|
|
99
|
+
}
|
|
100
|
+
else if (looksLikeStaticFile(path)) {
|
|
101
|
+
checkStaticReference(findings, booted.config.rootMirrorRoot, path.slice(1), path, 'theme/root/', pageUrl);
|
|
102
|
+
}
|
|
103
|
+
else if (path !== '' && !publishedUrlSet.has(path)) {
|
|
104
|
+
findings.push({ kind: 'broken-link', message: `${path} does not point at a published page`, pageUrl });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { ok: findings.length === 0, findings };
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@o-a/cms-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
},
|
|
24
24
|
"bin": {
|
|
25
25
|
"create-site": "dist/create-site/cli.js",
|
|
26
|
-
"mint-token": "dist/create-site/mint-token-cli.js"
|
|
26
|
+
"mint-token": "dist/create-site/mint-token-cli.js",
|
|
27
|
+
"check-site": "dist/site-check/cli.js",
|
|
28
|
+
"seed-media": "dist/media/seed-media-cli.js"
|
|
27
29
|
},
|
|
28
30
|
"files": [
|
|
29
31
|
"dist"
|
|
@@ -35,7 +37,7 @@
|
|
|
35
37
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
36
38
|
"lint": "eslint .",
|
|
37
39
|
"test": "node --experimental-strip-types --test",
|
|
38
|
-
"test:packaging": "node --experimental-strip-types --test e2e/create-site-packaging.check.ts",
|
|
40
|
+
"test:packaging": "node --experimental-strip-types --test --test-concurrency=1 e2e/create-site-packaging.check.ts e2e/dev-watch.check.ts",
|
|
39
41
|
"build": "tsc -p tsconfig.build.json && rm -rf dist/schemas dist/create-site/template && cp -r src/schemas/. dist/schemas/ && cp -r src/create-site/template/. dist/create-site/template/",
|
|
40
42
|
"prepack": "npm run build"
|
|
41
43
|
},
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { openNodeSqliteDriver } from "./drivers/node-sqlite-driver.js";
|
|
2
|
-
// Never queued: an in-flight query holding an open handle during a
|
|
3
|
-
// concurrent rebuild's unlink just keeps reading the pre-rebuild inode
|
|
4
|
-
// (stale but consistent, never torn) - queuing a read against the same
|
|
5
|
-
// queue as writes would only add latency for no correctness benefit.
|
|
6
|
-
export function queryIndex(searchIndexPath, term) {
|
|
7
|
-
const driver = openNodeSqliteDriver(searchIndexPath);
|
|
8
|
-
try {
|
|
9
|
-
const rows = driver.prepare('SELECT url, title FROM pages_fts WHERE pages_fts MATCH ?').all(term);
|
|
10
|
-
// node:sqlite returns rows as [Object: null prototype] instances;
|
|
11
|
-
// rebuilt here as plain objects so callers (and assert.deepEqual)
|
|
12
|
-
// never have to know that's a driver implementation detail.
|
|
13
|
-
return rows.map((row) => {
|
|
14
|
-
const { url, title } = row;
|
|
15
|
-
return { url, title };
|
|
16
|
-
});
|
|
17
|
-
}
|
|
18
|
-
finally {
|
|
19
|
-
driver.close();
|
|
20
|
-
}
|
|
21
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// Pure, filesystem-free mapping between a /blog/<slug> URL and a
|
|
2
|
-
// post's path relative to postsRoot. Unlike pages' arbitrary nested
|
|
3
|
-
// paths (about.json beside a sibling about/ directory), posts are
|
|
4
|
-
// flat only - a URL with more than one segment after /blog/ is never
|
|
5
|
-
// a valid post URL, enforced here rather than left to sanitisePath.
|
|
6
|
-
const BLOG_PREFIX = '/blog/';
|
|
7
|
-
// /blog is a permanently reserved namespace: both "/blog" itself (no
|
|
8
|
-
// slug) and every "/blog/..." URL are recognised here, so a caller can
|
|
9
|
-
// route the whole namespace to post resolution before ever checking
|
|
10
|
-
// for a page, matching the confirmed reserved-namespace decision.
|
|
11
|
-
export function isBlogUrl(url) {
|
|
12
|
-
return url === '/blog' || url.startsWith(BLOG_PREFIX);
|
|
13
|
-
}
|
|
14
|
-
export function urlToPostPath(url) {
|
|
15
|
-
if (!url.startsWith(BLOG_PREFIX)) {
|
|
16
|
-
return null;
|
|
17
|
-
}
|
|
18
|
-
const slug = url.slice(BLOG_PREFIX.length);
|
|
19
|
-
if (slug === '' || slug.includes('/')) {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
return `${slug}.json`;
|
|
23
|
-
}
|
|
24
|
-
export function postPathToUrl(relativePostPath) {
|
|
25
|
-
const withoutExtension = relativePostPath.replace(/\.json$/, '');
|
|
26
|
-
return `${BLOG_PREFIX}${withoutExtension}`;
|
|
27
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { SiteConfig } from '../config.ts';
|
|
2
|
-
export type ResolvedBlogUrl = {
|
|
3
|
-
kind: 'post';
|
|
4
|
-
relativePath: string;
|
|
5
|
-
} | {
|
|
6
|
-
kind: 'redirect';
|
|
7
|
-
to: string;
|
|
8
|
-
} | {
|
|
9
|
-
kind: 'not-found';
|
|
10
|
-
};
|
|
11
|
-
export declare function resolveBlogUrl(config: SiteConfig, url: string): ResolvedBlogUrl;
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
2
|
-
import { sanitisePath } from "./path-safety.js";
|
|
3
|
-
import { buildRedirectLookup, loadRedirects } from "./redirects.js";
|
|
4
|
-
import { urlToPostPath } from "./post-urls.js";
|
|
5
|
-
// Mirrors resolve-url.ts's shape exactly (a live post always wins over
|
|
6
|
-
// a redirect at the same URL), kept as its own distinct result type
|
|
7
|
-
// rather than reusing ResolvedUrl - clearer branching at the call site
|
|
8
|
-
// and avoids touching Group C's already-tested resolve-url.ts.
|
|
9
|
-
//
|
|
10
|
-
// Unlike pagesRoot (always expected to exist on a real site),
|
|
11
|
-
// postsRoot is optional - a site that has never used blog posts has
|
|
12
|
-
// no content/posts/ directory at all. sanitisePath calls realpathSync
|
|
13
|
-
// directly on its root argument, which throws a raw, uncaught ENOENT
|
|
14
|
-
// if that root itself is missing - so postsRoot's existence is checked
|
|
15
|
-
// first, before ever calling sanitisePath, rather than letting a
|
|
16
|
-
// perfectly ordinary "no posts yet" site crash on its first /blog/ hit.
|
|
17
|
-
export function resolveBlogUrl(config, url) {
|
|
18
|
-
const relativePath = urlToPostPath(url);
|
|
19
|
-
if (relativePath !== null && existsSync(config.postsRoot)) {
|
|
20
|
-
const postFile = sanitisePath(config.postsRoot, relativePath);
|
|
21
|
-
if (existsSync(postFile)) {
|
|
22
|
-
return { kind: 'post', relativePath };
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
const lookup = buildRedirectLookup(loadRedirects(config).entries);
|
|
26
|
-
const to = lookup.get(url);
|
|
27
|
-
if (to !== undefined) {
|
|
28
|
-
return { kind: 'redirect', to };
|
|
29
|
-
}
|
|
30
|
-
return { kind: 'not-found' };
|
|
31
|
-
}
|