@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.
Files changed (53) hide show
  1. package/dist/create-site/cli.js +0 -0
  2. package/dist/create-site/generate-site.js +21 -0
  3. package/dist/create-site/mint-token-cli.js +0 -0
  4. package/dist/create-site/template/AGENTS.md +189 -5
  5. package/dist/create-site/template/content/menus/footerCompany.json +1 -1
  6. package/dist/create-site/template/content/menus/footerProduct.json +1 -1
  7. package/dist/create-site/template/content/menus/footerResources.json +1 -1
  8. package/dist/create-site/template/content/menus/main.json +1 -1
  9. package/dist/create-site/template/content/pages/404.json +1 -1
  10. package/dist/create-site/template/content/pages/about/careers.json +1 -1
  11. package/dist/create-site/template/content/pages/about/team.json +1 -1
  12. package/dist/create-site/template/content/pages/about.json +1 -1
  13. package/dist/create-site/template/content/pages/docs/deployment.json +1 -1
  14. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +1 -1
  15. package/dist/create-site/template/content/pages/docs/getting-started.json +1 -1
  16. package/dist/create-site/template/content/pages/docs.json +1 -1
  17. package/dist/create-site/template/content/pages/index.json +1 -1
  18. package/dist/media/filename.d.ts +1 -0
  19. package/dist/media/filename.js +13 -0
  20. package/dist/media/seed-media-cli.d.ts +2 -0
  21. package/dist/media/seed-media-cli.js +22 -0
  22. package/dist/media/seed-media.d.ts +11 -0
  23. package/dist/media/seed-media.js +76 -0
  24. package/dist/renderer/render-page.d.ts +3 -0
  25. package/dist/renderer/render-page.js +13 -6
  26. package/dist/routes/media.js +5 -9
  27. package/dist/routes/publish.js +37 -3
  28. package/dist/routes/sitemap.d.ts +1 -0
  29. package/dist/routes/sitemap.js +7 -1
  30. package/dist/search/rebuild-index.d.ts +1 -0
  31. package/dist/search/rebuild-index.js +17 -1
  32. package/dist/server.js +17 -0
  33. package/dist/services/batch.js +10 -1
  34. package/dist/services/delete-content.js +4 -1
  35. package/dist/services/move.js +4 -1
  36. package/dist/services/publish.d.ts +1 -0
  37. package/dist/services/publish.js +67 -2
  38. package/dist/services/reindex-on-write.d.ts +3 -0
  39. package/dist/services/reindex-on-write.js +30 -0
  40. package/dist/services/theme-schemas.js +13 -5
  41. package/dist/services/validation.d.ts +1 -0
  42. package/dist/services/validation.js +33 -1
  43. package/dist/site-check/cli.d.ts +2 -0
  44. package/dist/site-check/cli.js +36 -0
  45. package/dist/site-check/run-check.d.ts +11 -0
  46. package/dist/site-check/run-check.js +109 -0
  47. package/package.json +5 -3
  48. package/dist/search/query-index.d.ts +0 -5
  49. package/dist/search/query-index.js +0 -21
  50. package/dist/services/post-urls.d.ts +0 -3
  51. package/dist/services/post-urls.js +0 -27
  52. package/dist/services/resolve-blog-url.d.ts +0 -11
  53. 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.2.1",
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,5 +0,0 @@
1
- export interface SearchResult {
2
- url: string;
3
- title: string;
4
- }
5
- export declare function queryIndex(searchIndexPath: string, term: string): SearchResult[];
@@ -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,3 +0,0 @@
1
- export declare function isBlogUrl(url: string): boolean;
2
- export declare function urlToPostPath(url: string): string | null;
3
- export declare function postPathToUrl(relativePostPath: string): string;
@@ -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
- }