caracms 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `caracms validate [--fix] [--json]` (M8 Decisions 15–16): the site's content checked
|
|
3
|
+
* against its config itself (never the generated schema files, so a stale schema can't
|
|
4
|
+
* pass bad content), from the site's root, with no server, network or environment.
|
|
5
|
+
*
|
|
6
|
+
* - **Errors** would break a build, lose data, or are files the editor couldn't open: JSON
|
|
7
|
+
* that doesn't parse, a file in no collection, a file the editor can't open (no title, a
|
|
8
|
+
* nested `index.json`), an unknown block type or field, a value of the wrong shape (rich
|
|
9
|
+
* text outside its grammar and a media value with no key among them), a missing required
|
|
10
|
+
* key (a document's `title`, a page's `blocks`, a shared block's `block`, a block's
|
|
11
|
+
* `type`), a reference to a document or shared block that isn't there, two documents at
|
|
12
|
+
* one URL, a document on a code route (Decision 10), a list inside a list inside a list.
|
|
13
|
+
* - **Warnings** are what the editor itself only warns about (fields.ts → fieldWarnings:
|
|
14
|
+
* half links, links to no page, missing alt text, too many buttons, numbers out of
|
|
15
|
+
* range…), what renders with a fallback (an unknown select value or button style), a
|
|
16
|
+
* lockup line too long for its box, schema files out of date, a content file with no
|
|
17
|
+
* `$schema`, an image with no `w` or `h` (it renders, but can't keep its space while it
|
|
18
|
+
* loads), and a file the editor's next save would reformat (serialize.ts → savedForm).
|
|
19
|
+
* - **`--fix`** runs `schemas`, then rewrites each content file a save would reformat in
|
|
20
|
+
* the form a save writes, changing only whitespace, and prints each one.
|
|
21
|
+
*
|
|
22
|
+
* Exit 1 on errors only. Each finding is `file › path.in.the.json: what's wrong`.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import type { ResolvedCollection, ResolvedSite } from '../config.ts';
|
|
27
|
+
import { codeRouteAt, codeRouteMessage, shadowingMessage, shadowingRoutes, type CodeRoute } from '../code-routes.ts';
|
|
28
|
+
import { pagesRoutes, readCodeRoutes, srcDirOf } from '../code-routes-disk.ts';
|
|
29
|
+
import { listDocuments, type DocumentEntry } from '../content.ts';
|
|
30
|
+
import { own } from '../document.ts';
|
|
31
|
+
import { fieldWarnings, parseDay, richTextOptions, type Field, type Fields, type WarningContext } from '../fields.ts';
|
|
32
|
+
import { measureLockup } from '../measure.ts';
|
|
33
|
+
import { isFileKey } from '../files.ts';
|
|
34
|
+
import { readRichText } from '../rich-text.ts';
|
|
35
|
+
import { savedForm } from '../serialize.ts';
|
|
36
|
+
import { SCHEMA_KEY, schemaRef } from '../schema-key.ts';
|
|
37
|
+
import { sameAddress } from '../slug.ts';
|
|
38
|
+
import { hiddenDocuments, hiddenPages } from '../links.ts';
|
|
39
|
+
import { runSchemas, schemaFileChanges, schemasReport } from './schemas.ts';
|
|
40
|
+
import { jsonFilesUnder, loadSite } from './site.ts';
|
|
41
|
+
|
|
42
|
+
export interface Finding {
|
|
43
|
+
/** Relative to the site's root. */
|
|
44
|
+
file: string;
|
|
45
|
+
/** The JSON path, in dot and bracket form (`blocks[2].heading`); `''` for the file itself. */
|
|
46
|
+
path: string;
|
|
47
|
+
message: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Report {
|
|
51
|
+
errors: Finding[];
|
|
52
|
+
warnings: Finding[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks' | 'textStyles' | 'phoneLinesWidth'>;
|
|
56
|
+
|
|
57
|
+
const key = (path: string, k: string) => (path === '' ? k : `${path}.${k}`);
|
|
58
|
+
const at = (path: string, i: number) => `${path}[${i}]`;
|
|
59
|
+
/** fields.ts's dotted relative paths (`1.href`, `credits.0.name`) in dot and bracket form, under `base`. */
|
|
60
|
+
function fromDotted(base: string, dotted: string): string {
|
|
61
|
+
if (dotted === '') return base;
|
|
62
|
+
return dotted.split('.').reduce((p, part) => (/^\d+$/.test(part) ? at(p, Number(part)) : key(p, part)), base);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
66
|
+
|
|
67
|
+
/** A value as a person would name it in a sentence. */
|
|
68
|
+
function what(v: unknown): string {
|
|
69
|
+
if (v === null) return 'null';
|
|
70
|
+
if (Array.isArray(v)) return 'a list';
|
|
71
|
+
switch (typeof v) {
|
|
72
|
+
case 'string':
|
|
73
|
+
return `text (${JSON.stringify(v.length > 40 ? `${v.slice(0, 40)}…` : v)})`;
|
|
74
|
+
case 'number':
|
|
75
|
+
return `the number ${v}`;
|
|
76
|
+
case 'boolean':
|
|
77
|
+
return v ? 'true' : 'false';
|
|
78
|
+
default:
|
|
79
|
+
return 'an object';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const quoteList = (values: readonly string[]) => values.map((v) => JSON.stringify(v)).join(', ');
|
|
84
|
+
|
|
85
|
+
interface Context {
|
|
86
|
+
file: string;
|
|
87
|
+
site: Site;
|
|
88
|
+
report: Report;
|
|
89
|
+
warningContext: WarningContext;
|
|
90
|
+
/** Whether a document exists, for references (and shared entries). */
|
|
91
|
+
exists: (collection: string, path: string) => boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const error = (c: Context, path: string, message: string) => c.report.errors.push({ file: c.file, path, message });
|
|
95
|
+
const warning = (c: Context, path: string, message: string) => c.report.warnings.push({ file: c.file, path, message });
|
|
96
|
+
|
|
97
|
+
/** Keys of `raw` that `known` doesn't have, each an error. */
|
|
98
|
+
function unknownKeys(c: Context, raw: Record<string, unknown>, known: readonly string[], path: string, where: string) {
|
|
99
|
+
for (const k of Object.keys(raw)) {
|
|
100
|
+
if (!known.includes(k)) error(c, key(path, k), `"${k}" isn't a field of ${where}. Its fields are ${quoteList(known.filter((n) => n !== SCHEMA_KEY))}.`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const strings = (v: unknown) => Array.isArray(v) && v.every((s) => typeof s === 'string');
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A field's stored value against its field: errors for a wrong shape (fields.ts's reader
|
|
108
|
+
* would show it as empty, and the next save of that field would lose it), warnings for
|
|
109
|
+
* what renders with a fallback. `inList`: how many lists this value sits inside.
|
|
110
|
+
*/
|
|
111
|
+
function checkValue(c: Context, field: Field, raw: unknown, path: string, inList: number) {
|
|
112
|
+
if (raw === undefined) return;
|
|
113
|
+
const wrong = (expected: string) => {
|
|
114
|
+
if (Array.isArray(raw) && inList >= 2) error(c, path, `This is a list inside a list inside a list; lists nest one level at most. Expected ${expected}.`);
|
|
115
|
+
else error(c, path, `Expected ${expected}, found ${what(raw)}.`);
|
|
116
|
+
};
|
|
117
|
+
switch (field.kind) {
|
|
118
|
+
case 'text':
|
|
119
|
+
if (typeof raw !== 'string') wrong('text (a string)');
|
|
120
|
+
return;
|
|
121
|
+
case 'lockup': {
|
|
122
|
+
const plain = strings(raw);
|
|
123
|
+
const withPhone = isObject(raw) && strings(raw.lines) && (raw.phone === undefined || strings(raw.phone)) && Object.keys(raw).every((k) => k === 'lines' || k === 'phone');
|
|
124
|
+
if (!plain && !withPhone) return wrong('a lockup: a list of lines (["First line", "Second line"]), or { "lines": […], "phone": […] }');
|
|
125
|
+
lockupWarnings(c, field.style, raw, path);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
case 'select':
|
|
129
|
+
if (typeof raw !== 'string') return wrong(`one of ${quoteList(field.options.map((o) => o.value))}`);
|
|
130
|
+
if (!field.options.some((o) => o.value === raw)) {
|
|
131
|
+
warning(c, path, `"${raw}" isn't one of this field's options (${quoteList(field.options.map((o) => o.value))}), so the page shows "${field.default}".`);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
case 'richText': {
|
|
135
|
+
if (typeof raw !== 'string' && !Array.isArray(raw)) return wrong('rich text: a list of { "p": "…" } entries, or a string');
|
|
136
|
+
// The grammar (rich-text.ts): what the reader couldn't read is lost on the next save.
|
|
137
|
+
const { issues } = readRichText(raw, richTextOptions(field));
|
|
138
|
+
for (const issue of issues) {
|
|
139
|
+
if (issue.code !== 'unreadable') continue;
|
|
140
|
+
const allowed = ['p', ...field.headings, 'ul', 'ol'];
|
|
141
|
+
error(c, at(path, issue.block), `This entry isn't rich text this field can read. Each entry is one of ${allowed.map((k) => `{ "${k}": ${k === 'ul' || k === 'ol' ? '[…]' : '"…"'} }`).join(', ')}.`);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
case 'link': {
|
|
146
|
+
const keys = field.addressOnly ? ['href'] : ['label', 'href'];
|
|
147
|
+
if (!isObject(raw)) return wrong(field.addressOnly ? 'a link: { "href": "…" }' : 'a link: { "label": "…", "href": "…" }');
|
|
148
|
+
unknownKeys(c, raw, keys, path, 'a link');
|
|
149
|
+
for (const k of keys) if (raw[k] !== undefined && typeof raw[k] !== 'string') error(c, key(path, k), `Expected text (a string), found ${what(raw[k])}.`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
case 'buttons': {
|
|
153
|
+
if (!Array.isArray(raw)) return wrong('a list of buttons: [{ "label", "href", "style" }]');
|
|
154
|
+
const styles = field.styles.map((s) => s.value);
|
|
155
|
+
raw.forEach((b, i) => {
|
|
156
|
+
const p = at(path, i);
|
|
157
|
+
if (!isObject(b)) return error(c, p, `Expected a button ({ "label", "href", "style" }), found ${what(b)}.`);
|
|
158
|
+
unknownKeys(c, b, ['label', 'href', 'style'], p, 'a button');
|
|
159
|
+
for (const k of ['label', 'href', 'style']) if (b[k] !== undefined && typeof b[k] !== 'string') error(c, key(p, k), `Expected text (a string), found ${what(b[k])}.`);
|
|
160
|
+
if (typeof b.style === 'string' && !styles.includes(b.style)) warning(c, key(p, 'style'), `"${b.style}" isn't one of the button styles (${quoteList(styles)}), so it shows as "${styles[0]}".`);
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
case 'toggle':
|
|
165
|
+
if (typeof raw !== 'boolean') wrong('true or false');
|
|
166
|
+
return;
|
|
167
|
+
case 'number':
|
|
168
|
+
if (raw !== null && (typeof raw !== 'number' || !Number.isFinite(raw))) wrong('a number');
|
|
169
|
+
return;
|
|
170
|
+
case 'date':
|
|
171
|
+
if (raw !== null && parseDay(raw) === null) wrong('a date in the form 2026-10-15');
|
|
172
|
+
return;
|
|
173
|
+
case 'reference':
|
|
174
|
+
if (raw === null) return;
|
|
175
|
+
if (typeof raw !== 'string') return wrong(`the path of a document in ${field.to}`);
|
|
176
|
+
if (raw.trim() !== '' && !c.exists(field.to, raw.trim())) error(c, path, `This points at "${raw}", which isn't a document in ${c.site.collections[field.to]?.label ?? field.to}.`);
|
|
177
|
+
return;
|
|
178
|
+
case 'media':
|
|
179
|
+
checkMedia(c, raw, path, wrong);
|
|
180
|
+
return;
|
|
181
|
+
case 'list': {
|
|
182
|
+
if (!Array.isArray(raw)) return wrong('a list');
|
|
183
|
+
raw.forEach((item, i) => {
|
|
184
|
+
const p = at(path, i);
|
|
185
|
+
if (field.item) return checkValue(c, field.item, item, p, inList + 1);
|
|
186
|
+
if (!isObject(item)) {
|
|
187
|
+
if (Array.isArray(item) && inList >= 1) return error(c, p, 'This is a list inside a list inside a list; lists nest one level at most. Expected an item of fields.');
|
|
188
|
+
return error(c, p, `Expected an item of fields (${quoteList(Object.keys(field.fields ?? {}))}), found ${what(item)}.`);
|
|
189
|
+
}
|
|
190
|
+
checkFields(c, field.fields ?? {}, item, p, 'this list\'s items', inList + 1);
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
case 'group':
|
|
195
|
+
if (!isObject(raw)) return wrong(`a group of fields (${quoteList(Object.keys(field.fields))})`);
|
|
196
|
+
checkFields(c, field.fields, raw, path, 'this group', inList);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const MEDIA_IMAGE_KEYS = ['key', 'alt', 'decorative', 'focal', 'w', 'h'];
|
|
202
|
+
const MEDIA_CLIP_KEYS = ['video', 'play', 'poster', 'name', 'duration', 'w', 'h'];
|
|
203
|
+
|
|
204
|
+
function checkMedia(c: Context, raw: unknown, path: string, wrong: (expected: string) => void) {
|
|
205
|
+
if (raw === null) return;
|
|
206
|
+
if (!isObject(raw)) return wrong('an image ({ "key", "alt", … }), a video ({ "video", … }) or null');
|
|
207
|
+
const isClip = raw.video !== undefined && raw.key === undefined;
|
|
208
|
+
if (isClip) {
|
|
209
|
+
if (typeof raw.video !== 'string' || raw.video.trim() === '') error(c, key(path, 'video'), 'A video needs its Mux playback ID in "video".');
|
|
210
|
+
unknownKeys(c, raw, MEDIA_CLIP_KEYS, path, 'a video');
|
|
211
|
+
if (raw.play !== undefined && raw.play !== 'loop' && raw.play !== 'player') error(c, key(path, 'play'), `Expected "loop" or "player", found ${what(raw.play)}.`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (typeof raw.key !== 'string' || raw.key.trim() === '') return error(c, key(path, 'key'), 'This image has no "key": the bucket key of an image in the media library (`caracms media import` prints it).');
|
|
215
|
+
unknownKeys(c, raw, MEDIA_IMAGE_KEYS, path, 'an image');
|
|
216
|
+
if (raw.alt !== undefined && typeof raw.alt !== 'string') error(c, key(path, 'alt'), `Expected text (a string), found ${what(raw.alt)}.`);
|
|
217
|
+
if (raw.decorative !== undefined && typeof raw.decorative !== 'boolean') error(c, key(path, 'decorative'), `Expected true or false, found ${what(raw.decorative)}.`);
|
|
218
|
+
for (const k of ['w', 'h']) if (raw[k] !== undefined && (typeof raw[k] !== 'number' || !((raw[k] as number) > 0))) error(c, key(path, k), `Expected the size in pixels, found ${what(raw[k])}.`);
|
|
219
|
+
sizeWarning(c, raw, path);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* MEC trial item 5: an image with no `w` or `h` still renders, but without its size the page
|
|
224
|
+
* can't reserve its box (no width and height on the `<img>`, media.ts → fluidImage), so the
|
|
225
|
+
* layout shifts as it loads. A file's key (a PDF in a media slot) has no size to give.
|
|
226
|
+
*/
|
|
227
|
+
function sizeWarning(c: Context, raw: Record<string, unknown>, path: string) {
|
|
228
|
+
if (typeof raw.key !== 'string' || isFileKey(raw.key)) return;
|
|
229
|
+
const missing = ['w', 'h'].filter((k) => raw[k] === undefined);
|
|
230
|
+
if (!missing.length) return;
|
|
231
|
+
const which = missing.length === 2 ? '"w" or "h"' : `"${missing[0]}"`;
|
|
232
|
+
warning(c, path, `This image (${raw.key}) has no ${which}, its size in pixels, so the page can't keep its space while it loads. \`caracms media import <the image's file>\` prints its key with "w" and "h": add them here.`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A lockup line that can't stay at its style's smallest size even across a whole phone (or, for main lines over phone lines, across the phone-lines width). */
|
|
236
|
+
function lockupWarnings(c: Context, styleName: string, raw: unknown, path: string) {
|
|
237
|
+
const style = own(c.site.textStyles, styleName);
|
|
238
|
+
if (!style) return;
|
|
239
|
+
const lines = Array.isArray(raw) ? (raw as string[]) : ((raw as { lines: string[] }).lines ?? []);
|
|
240
|
+
const phone = Array.isArray(raw) ? null : ((raw as { phone?: string[] }).phone ?? null);
|
|
241
|
+
const sets: [string, string[], number][] = phone?.length ? [['lines', lines, c.site.phoneLinesWidth], ['phone', phone, 375]] : [['', lines, 375]];
|
|
242
|
+
for (const [part, set, widthPx] of sets) {
|
|
243
|
+
const measured = measureLockup(set, style);
|
|
244
|
+
const p = part ? key(path, part) : path;
|
|
245
|
+
if (measured.longest > 0 && widthPx / measured.longest < style.minSizePx) {
|
|
246
|
+
warning(c, p, `The longest line is too long for its box: even ${widthPx}px wide it would be under ${style.minSizePx}px, ${style.name}'s smallest size. Break it into shorter lines${part === '' ? ' or add phone lines' : ''}.`);
|
|
247
|
+
}
|
|
248
|
+
const uncovered = measured.uncovered;
|
|
249
|
+
if (uncovered.length) warning(c, p, `${quoteList(uncovered)} ${uncovered.length === 1 ? "isn't" : "aren't"} in ${style.name}'s font, so the line may not fit.`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** An object of fields: unknown keys are errors; each field's value is checked, then the editor's own warnings. */
|
|
254
|
+
function checkFields(c: Context, fields: Fields, raw: Record<string, unknown>, path: string, where: string, inList: number, extra: readonly string[] = []) {
|
|
255
|
+
unknownKeys(c, raw, [...extra, ...Object.keys(fields)], path, where);
|
|
256
|
+
for (const [k, f] of Object.entries(fields)) {
|
|
257
|
+
if (!Object.hasOwn(raw, k)) continue;
|
|
258
|
+
checkValue(c, f, raw[k], key(path, k), inList);
|
|
259
|
+
editorWarnings(c, f, raw[k], key(path, k));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The editor's field warnings (fields.ts), minus what the shape checks already reported. */
|
|
264
|
+
function editorWarnings(c: Context, field: Field, raw: unknown, path: string) {
|
|
265
|
+
// Nested fields are reached through their own checkFields call: only this field's own here.
|
|
266
|
+
if (field.kind === 'group' || (field.kind === 'list' && !field.item)) {
|
|
267
|
+
for (const w of fieldWarnings(field, raw, c.warningContext)) if (w.path === '' && (w.code === 'too-few' || w.code === 'too-many')) warning(c, path, w.message);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
for (const w of fieldWarnings(field, raw, c.warningContext)) {
|
|
271
|
+
const p = fromDotted(path, w.path);
|
|
272
|
+
if (w.code === 'unreadable' || w.code === 'missing-reference') continue;
|
|
273
|
+
// Rich text outside the field's grammar (an h1, a heading or a link the field doesn't take) is an error: Decision 15.
|
|
274
|
+
if (w.code === 'disallowed-kind' || w.code === 'link-off' || w.code === 'italic-off') error(c, p, w.message);
|
|
275
|
+
else warning(c, p, w.message);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function checkBlock(c: Context, raw: unknown, path: string, collection: ResolvedCollection | null) {
|
|
280
|
+
if (!isObject(raw)) return error(c, path, `Expected a block ({ "type": "…", … }), found ${what(raw)}.`);
|
|
281
|
+
if (Object.hasOwn(raw, 'shared') && collection) {
|
|
282
|
+
if (typeof raw.shared !== 'string' || raw.shared.trim() === '') return error(c, key(path, 'shared'), 'Expected the path of a shared block.');
|
|
283
|
+
unknownKeys(c, raw, ['shared', 'type'], path, 'a shared block\'s entry');
|
|
284
|
+
if (!c.exists('shared', raw.shared)) error(c, key(path, 'shared'), `There's no shared block "${raw.shared}" (content/shared/${raw.shared}.json).`);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (typeof raw.type !== 'string' || raw.type === '') return error(c, key(path, 'type'), `This block has no "type". The site's blocks are ${quoteList(Object.keys(c.site.blocks))}.`);
|
|
288
|
+
const block = own(c.site.blocks, raw.type);
|
|
289
|
+
if (!block) return error(c, key(path, 'type'), `There's no block type "${raw.type}". The site's blocks are ${quoteList(Object.keys(c.site.blocks))}.`);
|
|
290
|
+
checkFields(c, block.fields, raw, path, `the ${block.label} block`, 0, ['type']);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const SEO_KEYS = ['title', 'description', 'image'];
|
|
294
|
+
function checkSeo(c: Context, raw: unknown, path: string) {
|
|
295
|
+
if (raw === undefined) return;
|
|
296
|
+
if (!isObject(raw)) return error(c, path, `Expected { "title", "description", "image" }, found ${what(raw)}.`);
|
|
297
|
+
unknownKeys(c, raw, SEO_KEYS, path, 'seo');
|
|
298
|
+
for (const k of ['title', 'description']) if (raw[k] !== undefined && typeof raw[k] !== 'string') error(c, key(path, k), `Expected text (a string), found ${what(raw[k])}.`);
|
|
299
|
+
if (raw.image !== undefined && raw.image !== null) {
|
|
300
|
+
if (!isObject(raw.image) || typeof raw.image.key !== 'string' || raw.image.key.trim() === '') error(c, key(path, 'image'), 'The share image needs the "key" of an image in the media library.');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function checkDocument(c: Context, collection: ResolvedCollection, raw: unknown) {
|
|
305
|
+
if (!isObject(raw)) return error(c, '', `Expected a JSON object, found ${what(raw)}.`);
|
|
306
|
+
const own = collection.fields ?? {};
|
|
307
|
+
if (raw[SCHEMA_KEY] !== undefined && typeof raw[SCHEMA_KEY] !== 'string') error(c, SCHEMA_KEY, 'Expected the relative path of its schema file.');
|
|
308
|
+
if (raw.title !== undefined && typeof raw.title !== 'string') error(c, 'title', `Expected text (a string), found ${what(raw.title)}.`);
|
|
309
|
+
switch (collection.kind) {
|
|
310
|
+
case 'sections': {
|
|
311
|
+
checkFields(c, own, raw, '', `a ${collection.label} page`, 0, [SCHEMA_KEY, 'title', 'hidden', 'blocks', 'seo']);
|
|
312
|
+
// M9 Decision 7.
|
|
313
|
+
if (raw.hidden !== undefined && raw.hidden !== true) error(c, 'hidden', `Expected true (hidden from the site) or no "hidden" at all, found ${what(raw.hidden)}.`);
|
|
314
|
+
if (!Object.hasOwn(raw, 'blocks')) error(c, 'blocks', 'This page has no "blocks". Every page has a list of blocks, even an empty one ([]).');
|
|
315
|
+
else if (!Array.isArray(raw.blocks)) error(c, 'blocks', `Expected a list of blocks, found ${what(raw.blocks)}.`);
|
|
316
|
+
else raw.blocks.forEach((b, i) => checkBlock(c, b, at('blocks', i), collection));
|
|
317
|
+
checkSeo(c, raw.seo, 'seo');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
case 'fields':
|
|
321
|
+
checkFields(c, own, raw, '', `a ${collection.label} document`, 0, [SCHEMA_KEY, 'title', 'seo']);
|
|
322
|
+
checkSeo(c, raw.seo, 'seo');
|
|
323
|
+
return;
|
|
324
|
+
case 'shared':
|
|
325
|
+
unknownKeys(c, raw, [SCHEMA_KEY, 'title', 'block'], '', 'a shared block');
|
|
326
|
+
if (!Object.hasOwn(raw, 'block')) error(c, 'block', 'This shared block has no "block".');
|
|
327
|
+
else checkBlock(c, raw.block, 'block', null);
|
|
328
|
+
return;
|
|
329
|
+
case 'settings': {
|
|
330
|
+
checkFields(c, own, raw, '', 'Site Settings', 0, [SCHEMA_KEY, 'title', 'editors', 'brand', 'seo', 'googleTag', 'formRecipients', 'redirects']);
|
|
331
|
+
if (raw.editors !== undefined && !strings(raw.editors)) error(c, 'editors', `Expected a list of email addresses, found ${what(raw.editors)}.`);
|
|
332
|
+
if (raw.brand !== undefined) {
|
|
333
|
+
if (!isObject(raw.brand)) error(c, 'brand', `Expected { "name", "accent", "logo", "icon" }, found ${what(raw.brand)}.`);
|
|
334
|
+
else {
|
|
335
|
+
unknownKeys(c, raw.brand, ['name', 'accent', 'logo', 'icon'], 'brand', 'the brand');
|
|
336
|
+
for (const k of ['name', 'accent']) if (raw.brand[k] !== undefined && typeof raw.brand[k] !== 'string') error(c, key('brand', k), `Expected text (a string), found ${what(raw.brand[k])}.`);
|
|
337
|
+
for (const k of ['logo', 'icon']) {
|
|
338
|
+
const v = raw.brand[k];
|
|
339
|
+
if (v !== undefined && v !== null && (!isObject(v) || typeof v.key !== 'string' || v.key.trim() === '')) error(c, key('brand', k), 'Expected an image from the media library ({ "key", "w", "h" }) or null.');
|
|
340
|
+
else if (isObject(v)) sizeWarning(c, v, key('brand', k));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
checkSeo(c, raw.seo, 'seo');
|
|
345
|
+
if (raw.googleTag !== undefined && typeof raw.googleTag !== 'string') error(c, 'googleTag', `Expected a tag ID (text), found ${what(raw.googleTag)}.`);
|
|
346
|
+
if (raw.formRecipients !== undefined && !(isObject(raw.formRecipients) && Object.values(raw.formRecipients).every(strings))) {
|
|
347
|
+
error(c, 'formRecipients', 'Expected { "<form id>": ["address", …] }.');
|
|
348
|
+
}
|
|
349
|
+
if (raw.redirects !== undefined && !(Array.isArray(raw.redirects) && raw.redirects.every((r) => isObject(r) && typeof r.from === 'string' && typeof r.to === 'string'))) {
|
|
350
|
+
error(c, 'redirects', 'Expected a list of { "from": "/old", "to": "/new" }.');
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Every finding for the site's content. `codeRoutes`: the site's own (default: read from its src/pages). */
|
|
358
|
+
export async function validateSite(site: Site, codeRoutes: readonly CodeRoute[] = readCodeRoutes(site.root, srcDirOf(site.root), Object.values(site.collections))): Promise<Report> {
|
|
359
|
+
const report: Report = { errors: [], warnings: [] };
|
|
360
|
+
const entries = await listDocuments(site);
|
|
361
|
+
const ids = new Set(entries.map((e) => `${e.collection}\0${e.path}`));
|
|
362
|
+
const exists = (collection: string, path: string) => ids.has(`${collection}\0${path}`);
|
|
363
|
+
const warningContext: WarningContext = { addresses: entries.map((e) => e.url), documentExists: exists, codeRoutes, hiddenPages: hiddenPages(entries), hiddenDocuments: hiddenDocuments(entries) };
|
|
364
|
+
|
|
365
|
+
// Files in no collection: under content/ and every collection's folder.
|
|
366
|
+
const listed = new Set(entries.map((e) => e.file));
|
|
367
|
+
const scanned = new Set<string>();
|
|
368
|
+
for (const dir of [join(site.root, 'content'), ...Object.values(site.collections).map((c) => c.dir)]) {
|
|
369
|
+
for (const file of jsonFilesUnder(site.root, dir)) scanned.add(file);
|
|
370
|
+
}
|
|
371
|
+
for (const file of [...scanned].sort()) {
|
|
372
|
+
if (!listed.has(file)) report.errors.push({ file, path: '', message: "This file isn't in any collection, so nothing reads it. Move it into a collection's folder, or delete it." });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// A second route with parameters at a collection's base hides its documents (code-routes.ts → one per base).
|
|
376
|
+
for (const s of shadowingRoutes(pagesRoutes(site.root, srcDirOf(site.root)), Object.values(site.collections))) {
|
|
377
|
+
report.errors.push({ file: s.route.file, path: '', message: shadowingMessage(s) });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const urls = new Map<string, DocumentEntry>();
|
|
381
|
+
for (const entry of entries) {
|
|
382
|
+
const c: Context = { file: entry.file, site, report, warningContext, exists };
|
|
383
|
+
const collection = site.collections[entry.collection]!;
|
|
384
|
+
if (entry.warning) {
|
|
385
|
+
if (entry.warning.code === 'no-title') error(c, 'title', 'This document has no "title", so the editor can\'t open it. Add one.');
|
|
386
|
+
else error(c, '', entry.warning.message);
|
|
387
|
+
if (entry.warning.code !== 'no-title') continue;
|
|
388
|
+
}
|
|
389
|
+
let text: string;
|
|
390
|
+
try {
|
|
391
|
+
text = readFileSync(join(site.root, entry.file), 'utf8');
|
|
392
|
+
} catch {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const raw = JSON.parse(text) as unknown;
|
|
396
|
+
checkDocument(c, collection, raw);
|
|
397
|
+
// M9 Decision 7: as in Tina, the home page can't be hidden (content.ts reads it as shown).
|
|
398
|
+
if (collection.kind === 'sections' && entry.path === 'index' && isObject(raw) && raw.hidden === true) warning(c, 'hidden', 'The home page can’t be hidden, so it’s shown. Remove "hidden".');
|
|
399
|
+
if (isObject(raw) && raw[SCHEMA_KEY] === undefined) warning(c, '', `This file has no "$schema" (${JSON.stringify(schemaRef(collection, entry.file))}). \`caracms validate --fix\` adds it.`);
|
|
400
|
+
const saved = savedForm(text, collection, site);
|
|
401
|
+
if (saved !== null && saved !== text) warning(c, '', REFORMAT_MESSAGE);
|
|
402
|
+
if (entry.url !== null && collection.urlBase !== null) {
|
|
403
|
+
const route = codeRouteAt(entry.url, codeRoutes);
|
|
404
|
+
if (route) error(c, '', `${codeRouteMessage(entry.url, route)} Delete ${route.file} if this document replaces it, or move the document.`);
|
|
405
|
+
const same = sameAddress(entry.url);
|
|
406
|
+
const first = urls.get(same);
|
|
407
|
+
if (first) error(c, '', `${first.file} is at ${entry.url} too. Two documents can't share an address: move one.`);
|
|
408
|
+
else urls.set(same, entry);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const change of schemaFileChanges(site)) {
|
|
413
|
+
report.warnings.push({
|
|
414
|
+
file: change.file,
|
|
415
|
+
path: '',
|
|
416
|
+
message: change.text === null ? 'The config no longer makes this schema. `caracms schemas` removes it.' : change.before === null ? 'This schema file is missing. `caracms schemas` writes it.' : "This schema file is out of date with caracms.config.ts. `caracms schemas` rewrites it.",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return report;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const REFORMAT_MESSAGE = "This file isn't laid out the way the editor saves it (two-space JSON, a trailing newline), so the editor's next save would reformat it and mix the two. `caracms validate --fix` rewrites it the editor's way, changing no values.";
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* MEC trial item 7: every content file the editor can open rewritten in the form a save
|
|
426
|
+
* writes (serialize.ts → savedForm), only whitespace changing. Returns the files changed.
|
|
427
|
+
*/
|
|
428
|
+
export async function reformatContent(site: Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>): Promise<string[]> {
|
|
429
|
+
const changed: string[] = [];
|
|
430
|
+
for (const entry of await listDocuments(site)) {
|
|
431
|
+
if (entry.warning) continue;
|
|
432
|
+
const file = join(site.root, entry.file);
|
|
433
|
+
let text: string;
|
|
434
|
+
try {
|
|
435
|
+
text = readFileSync(file, 'utf8');
|
|
436
|
+
} catch {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const saved = savedForm(text, site.collections[entry.collection]!, site);
|
|
440
|
+
if (saved === null || saved === text) continue;
|
|
441
|
+
writeFileSync(file, saved);
|
|
442
|
+
changed.push(entry.file);
|
|
443
|
+
}
|
|
444
|
+
return changed;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const reformatReport = (files: readonly string[]) =>
|
|
448
|
+
files.length ? `caracms validate --fix:\n${files.map((f) => ` reformatted ${f} (as the editor saves it)`).join('\n')}\n` : '';
|
|
449
|
+
|
|
450
|
+
const line = (f: Finding) => `${f.file}${f.path ? ` › ${f.path}` : ''}: ${f.message}`;
|
|
451
|
+
|
|
452
|
+
/** The plain report: errors, then warnings, then a count. */
|
|
453
|
+
export function formatReport(report: Report): string {
|
|
454
|
+
const parts: string[] = [];
|
|
455
|
+
if (report.errors.length) parts.push(`Errors:\n${report.errors.map((f) => ` ${line(f)}`).join('\n')}`);
|
|
456
|
+
if (report.warnings.length) parts.push(`Warnings:\n${report.warnings.map((f) => ` ${line(f)}`).join('\n')}`);
|
|
457
|
+
const count = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
458
|
+
parts.push(report.errors.length || report.warnings.length ? `caracms validate: ${count(report.errors.length, 'error')}, ${count(report.warnings.length, 'warning')}.` : 'caracms validate: no problems.');
|
|
459
|
+
return `${parts.join('\n\n')}\n`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The command's entry (bin/caracms.mjs loads this module through the site's Vite). */
|
|
463
|
+
export async function command(root: string, options: { fix?: boolean; json?: boolean } = {}): Promise<{ code: number; out: string; err?: string }> {
|
|
464
|
+
const site = await loadSite(root);
|
|
465
|
+
let fixed = '';
|
|
466
|
+
if (options.fix) fixed = schemasReport(await runSchemas(site)) + reformatReport(await reformatContent(site));
|
|
467
|
+
const report = await validateSite(site);
|
|
468
|
+
const code = report.errors.length ? 1 : 0;
|
|
469
|
+
if (options.json) return { code, out: `${JSON.stringify(report, null, 2)}\n`, err: options.fix ? fixed : undefined };
|
|
470
|
+
return { code, out: `${options.fix ? `${fixed}\n` : ''}${formatReport(report)}` };
|
|
471
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the site's pages folder for its code routes (M8 Decision 10; code-routes.ts
|
|
3
|
+
* has the rules). Server-only. The integration passes Astro's own `srcDir`; `validate`,
|
|
4
|
+
* with no Astro running, reads it from the site's astro.config (`srcDirOf`).
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
8
|
+
import { codeRoutesFrom, routeOf, type CodeRoute } from './code-routes.ts';
|
|
9
|
+
|
|
10
|
+
const posix = (path: string) => path.split(sep).join('/');
|
|
11
|
+
|
|
12
|
+
/** Every route under `<srcDir>/pages` (each one, collections' own included), in file order. */
|
|
13
|
+
export function pagesRoutes(root: string, srcDir: string): CodeRoute[] {
|
|
14
|
+
const pages = join(isAbsolute(srcDir) ? srcDir : join(root, srcDir), 'pages');
|
|
15
|
+
const out: CodeRoute[] = [];
|
|
16
|
+
const walk = (dir: string) => {
|
|
17
|
+
let names: string[];
|
|
18
|
+
try {
|
|
19
|
+
names = readdirSync(dir).sort();
|
|
20
|
+
} catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
if (name.startsWith('_') || name.startsWith('.')) continue;
|
|
25
|
+
const path = join(dir, name);
|
|
26
|
+
let isDir = false;
|
|
27
|
+
try {
|
|
28
|
+
isDir = statSync(path).isDirectory();
|
|
29
|
+
} catch {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (isDir) walk(path);
|
|
33
|
+
else {
|
|
34
|
+
const route = routeOf(posix(relative(pages, path)), posix(relative(root, path)));
|
|
35
|
+
if (route) out.push(route);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(pages);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The routes that reserve URLs (the collections' own left out). */
|
|
44
|
+
export function readCodeRoutes(root: string, srcDir: string, collections: readonly { urlBase: string | null }[]): CodeRoute[] {
|
|
45
|
+
return codeRoutesFrom(pagesRoutes(root, srcDir), collections);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const ASTRO_CONFIG_FILES = ['astro.config.mjs', 'astro.config.ts', 'astro.config.mts', 'astro.config.js', 'astro.config.cjs', 'astro.config.cts'] as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The site's `srcDir` as its astro.config writes it (`srcDir: './app'`), else `./src`,
|
|
52
|
+
* Astro's default. Read from the text: a computed value isn't seen (and `validate` says
|
|
53
|
+
* nothing about it), which is the price of needing no running Astro.
|
|
54
|
+
*/
|
|
55
|
+
export function srcDirOf(root: string): string {
|
|
56
|
+
for (const name of ASTRO_CONFIG_FILES) {
|
|
57
|
+
let text: string;
|
|
58
|
+
try {
|
|
59
|
+
text = readFileSync(join(root, name), 'utf8');
|
|
60
|
+
} catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const m = /\bsrcDir\s*:\s*(['"`])([^'"`]+)\1/.exec(text);
|
|
64
|
+
return m ? m[2]! : './src';
|
|
65
|
+
}
|
|
66
|
+
return './src';
|
|
67
|
+
}
|