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
package/bin/init.mjs
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
// `caracms init [--write]` (M8 Decisions 7–13): add caracms's plumbing to an existing
|
|
2
|
+
// Astro site, and nothing about its design.
|
|
3
|
+
//
|
|
4
|
+
// - Run plainly, it prints every file it would add and every edit as a diff, and writes
|
|
5
|
+
// nothing. `--write` applies exactly that, then writes the schemas (`caracms schemas`).
|
|
6
|
+
// It never prompts.
|
|
7
|
+
// - It stops on anything in the way (Decision 9), naming each problem with what to do, and
|
|
8
|
+
// writes nothing. One run reports every problem, the git checks included.
|
|
9
|
+
// - An existing file it can't edit safely (an astro.config it can't follow, an existing
|
|
10
|
+
// `.railway/railway.ts`) gets the lines to add printed instead, and is left alone.
|
|
11
|
+
// - Run again, it re-links the skills, refreshes the CLAUDE.md block and the schemas, and
|
|
12
|
+
// reports the rest as already done.
|
|
13
|
+
//
|
|
14
|
+
// Plain JavaScript importing only Node built-ins (and, for the schemas, the site's own
|
|
15
|
+
// Vite through site-runner.mjs), so it runs on Node 22.12 with no flags.
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const packageRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
24
|
+
const templates = fileURLToPath(new URL('./templates/', import.meta.url));
|
|
25
|
+
const template = (name) => readFileSync(join(templates, name), 'utf8');
|
|
26
|
+
const ownPackage = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
|
|
27
|
+
|
|
28
|
+
/** What init adds to a site's package.json (Decisions 7 and 11). */
|
|
29
|
+
export const VERSIONS = { node: '^11.1.7', railway: '^3.16.0' };
|
|
30
|
+
|
|
31
|
+
export const MARK_START = '<!-- caracms:start -->';
|
|
32
|
+
export const MARK_END = '<!-- caracms:end -->';
|
|
33
|
+
/** Where a site's skills link to: the installed package (Decision 12). */
|
|
34
|
+
const SKILLS_LINK_BASE = '../../node_modules/caracms/skills/';
|
|
35
|
+
/** The URLs the package reserves (src/slug.ts → addressProblem). */
|
|
36
|
+
const RESERVED = ['/admin', '/_caracms', '/files'];
|
|
37
|
+
const ROUTE_EXTENSIONS = ['.astro', '.md', '.mdx', '.markdown', '.html', '.ts', '.js', '.mjs', '.mts'];
|
|
38
|
+
const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'bun.lock', 'bun.lockb', 'npm-shrinkwrap.json'];
|
|
39
|
+
const ASTRO_CONFIGS = ['astro.config.mjs', 'astro.config.ts', 'astro.config.mts', 'astro.config.js', 'astro.config.cjs', 'astro.config.cts'];
|
|
40
|
+
/** The schema files the starting config makes (bin/templates/caracms.config.ts.txt). */
|
|
41
|
+
const STARTING_SCHEMAS = ['schemas/pages.schema.json', 'schemas/shared.schema.json', 'schemas/site-settings.schema.json'];
|
|
42
|
+
const SKILL_BLURBS = {
|
|
43
|
+
'caracms-content': 'changing content: the JSON files in content/, rich text, images, references, Site Settings, and working on a branch',
|
|
44
|
+
'caracms-blocks': 'building the site: blocks and their fields, collections, text styles, the page component, forms, and scripts beside the editor',
|
|
45
|
+
'caracms-adopt': 'bringing the site\'s existing pages onto caracms, one page at a time, and checking they look the same',
|
|
46
|
+
'caracms-tina': 'porting a site built on Tina (a Tina template site) onto caracms with the reference conversion script, re-runnable on newer content',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const posix = (p) => p.split(sep).join('/');
|
|
50
|
+
const read = (path) => {
|
|
51
|
+
try {
|
|
52
|
+
return readFileSync(path, 'utf8');
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const isDir = (path) => {
|
|
58
|
+
try {
|
|
59
|
+
return statSync(path).isDirectory();
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const lstat = (path) => {
|
|
65
|
+
try {
|
|
66
|
+
return lstatSync(path);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const titleCase = (name) => name.replace(/^@[^/]+\//, '').split(/[-_\s]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
|
|
72
|
+
|
|
73
|
+
function git(root, args) {
|
|
74
|
+
try {
|
|
75
|
+
return { ok: true, out: execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) };
|
|
76
|
+
} catch {
|
|
77
|
+
return { ok: false, out: '' };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Routes (the same reading as src/code-routes.ts, in plain JavaScript) ─────────
|
|
82
|
+
|
|
83
|
+
/** Every route file under src/pages (Astro's rules: `_` and `.` names skipped), with its URL and whether it has parameters. */
|
|
84
|
+
function pageRoutes(root, srcDir) {
|
|
85
|
+
const pages = join(root, srcDir, 'pages');
|
|
86
|
+
const out = [];
|
|
87
|
+
const walk = (dir) => {
|
|
88
|
+
let names = [];
|
|
89
|
+
try {
|
|
90
|
+
names = readdirSync(dir).sort();
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const name of names) {
|
|
95
|
+
if (name.startsWith('_') || name.startsWith('.')) continue;
|
|
96
|
+
const path = join(dir, name);
|
|
97
|
+
if (isDir(path)) {
|
|
98
|
+
walk(path);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (/\.d\.[mc]?ts$/.test(name)) continue;
|
|
102
|
+
const ext = ROUTE_EXTENSIONS.find((e) => name.endsWith(e));
|
|
103
|
+
if (!ext) continue;
|
|
104
|
+
const parts = posix(relative(pages, path)).split('/');
|
|
105
|
+
const base = parts.pop().slice(0, -ext.length);
|
|
106
|
+
const segments = [...parts, ...(base === 'index' ? [] : [base])];
|
|
107
|
+
const first = segments.findIndex((s) => s.includes('['));
|
|
108
|
+
const file = posix(relative(root, path));
|
|
109
|
+
if (first === -1) out.push({ file, url: `/${segments.join('/')}`, dynamic: false, rest: false });
|
|
110
|
+
else out.push({ file, url: first ? `/${segments.slice(0, first).join('/')}/` : '/', dynamic: true, rest: /^\[\.\.\.[^\]]+\]$/.test(segments[first]) });
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
walk(pages);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Does `route` serve `url` or anything under it? */
|
|
118
|
+
function routeReaches(route, url) {
|
|
119
|
+
const at = url.toLowerCase();
|
|
120
|
+
const r = route.url.toLowerCase().replace(/\/$/, '') || '/';
|
|
121
|
+
if (!route.dynamic) return r === at || r.startsWith(`${at}/`);
|
|
122
|
+
// A catch-all at the top never takes the package's own routes: Astro ranks a rest parameter last.
|
|
123
|
+
if (r === '/') return false;
|
|
124
|
+
return r === at || r.startsWith(`${at}/`) || at.startsWith(`${r}/`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── The site as found ───────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function inspect(root) {
|
|
130
|
+
const pkgText = read(join(root, 'package.json'));
|
|
131
|
+
let pkg = null;
|
|
132
|
+
try {
|
|
133
|
+
pkg = pkgText === null ? null : JSON.parse(pkgText);
|
|
134
|
+
} catch {
|
|
135
|
+
pkg = undefined;
|
|
136
|
+
}
|
|
137
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
138
|
+
const configName = ASTRO_CONFIGS.find((n) => existsSync(join(root, n))) ?? null;
|
|
139
|
+
const configText = configName ? read(join(root, configName)) : null;
|
|
140
|
+
const srcDir = (configText && /\bsrcDir\s*:\s*(['"`])([^'"`]+)\1/.exec(configText)?.[2]) || 'src';
|
|
141
|
+
const lock = existsSync(join(root, 'pnpm-lock.yaml'))
|
|
142
|
+
? 'pnpm'
|
|
143
|
+
: existsSync(join(root, 'package-lock.json'))
|
|
144
|
+
? 'npm'
|
|
145
|
+
: existsSync(join(root, 'yarn.lock'))
|
|
146
|
+
? 'yarn'
|
|
147
|
+
: existsSync(join(root, 'bun.lock')) || existsSync(join(root, 'bun.lockb'))
|
|
148
|
+
? 'bun'
|
|
149
|
+
: (pkg?.packageManager?.split('@')[0] ?? 'npm');
|
|
150
|
+
return { root, pkgText, pkg, deps, configName, configText, srcDir, pm: lock, setUp: existsSync(join(root, 'caracms.config.ts')) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function installedAstro(root) {
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(readFileSync(createRequire(join(root, 'package.json')).resolve('astro/package.json'), 'utf8')).version;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const parseVersion = (v) => {
|
|
162
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(v ?? '');
|
|
163
|
+
return m ? m.slice(1, 4).map(Number) : null;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Does `version` satisfy the package's peer range for Astro (a caret range: `^7.3.8`)? */
|
|
167
|
+
function inPeerRange(version, range) {
|
|
168
|
+
const v = parseVersion(version);
|
|
169
|
+
const r = parseVersion(range);
|
|
170
|
+
if (!v || !r) return true;
|
|
171
|
+
if (v[0] !== r[0]) return false;
|
|
172
|
+
return v[1] > r[1] || (v[1] === r[1] && v[2] >= r[2]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The adapter astro.config uses: its import's module, or null for none. `unknown` when it can't be followed. */
|
|
176
|
+
function adapterOf(configText) {
|
|
177
|
+
if (!configText) return null;
|
|
178
|
+
const m = /\badapter\s*:\s*([A-Za-z_$][\w$]*)\s*\(/.exec(configText);
|
|
179
|
+
if (!m) return /\badapter\s*:/.test(configText) ? 'unknown' : null;
|
|
180
|
+
const from = new RegExp(`import\\s+${m[1].replace(/\$/g, '\\$')}\\s+from\\s+['"]([^'"]+)['"]`).exec(configText);
|
|
181
|
+
return from ? from[1] : 'unknown';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Problems (Decisions 8 and 9) ────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
function problemsOf(site, write) {
|
|
187
|
+
const { root } = site;
|
|
188
|
+
const problems = [];
|
|
189
|
+
const inGit = git(root, ['rev-parse', '--is-inside-work-tree']).out.trim() === 'true';
|
|
190
|
+
if (!inGit) problems.push("This isn't a git repository: init only runs in one, so what it does is one reviewable diff. Run `git init` and commit the site first.");
|
|
191
|
+
else if (write) {
|
|
192
|
+
// Paths relative to the site's root (porcelain prints them from the repository's), so the `git add` line runs from here.
|
|
193
|
+
const prefix = git(root, ['rev-parse', '--show-prefix']).out.trim();
|
|
194
|
+
const dirty = git(root, ['status', '--porcelain', '--untracked-files=no', '--', '.'])
|
|
195
|
+
.out.split('\n')
|
|
196
|
+
.filter(Boolean)
|
|
197
|
+
.map((l) => l.slice(3))
|
|
198
|
+
.map((f) => (prefix && f.startsWith(prefix) ? f.slice(prefix.length) : f));
|
|
199
|
+
if (dirty.length && dirty.every((f) => f === 'package.json' || LOCKFILES.includes(f))) {
|
|
200
|
+
// Installing caracms (the step before init) changes exactly these: the refusal stays (Decision 8), with the step.
|
|
201
|
+
problems.push(`${dirty.join(' and ')} ${dirty.length === 1 ? 'has' : 'have'} uncommitted changes, which installing caracms makes. Commit the install first, so init's change is one reviewable diff of its own: \`git add ${dirty.join(' ')} && git commit -m "Install caracms"\`, then run init again.`);
|
|
202
|
+
} else if (dirty.length) problems.push(`There are uncommitted changes to tracked files (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ', …' : ''}): commit or stash them first, so init's change is one reviewable diff.`);
|
|
203
|
+
}
|
|
204
|
+
if (site.pkg === null) {
|
|
205
|
+
problems.push("There's no package.json here. Run init from the site's root.");
|
|
206
|
+
return problems;
|
|
207
|
+
}
|
|
208
|
+
if (site.pkg === undefined) problems.push("package.json isn't valid JSON. Fix it, then run init again.");
|
|
209
|
+
|
|
210
|
+
// Tina (Decision 9): named as one, by what's real (tinaOf).
|
|
211
|
+
const tina = tinaOf(site, inGit).reasons;
|
|
212
|
+
if (tina.length) problems.push(`This is a Tina site (${tina.join(' and ')}): Tina sites come in through a port, not \`init\`.`);
|
|
213
|
+
|
|
214
|
+
const astroVersion = installedAstro(root);
|
|
215
|
+
const peer = ownPackage.peerDependencies?.astro ?? '';
|
|
216
|
+
if (!site.deps.astro && !astroVersion) problems.push("There's no Astro here (no astro in package.json): caracms adds to an existing Astro site. Run init from the site's root.");
|
|
217
|
+
else {
|
|
218
|
+
const version = astroVersion ?? site.deps.astro;
|
|
219
|
+
if (!inPeerRange(version, peer)) problems.push(`Astro ${version} is outside the range caracms ${ownPackage.version} works with (${peer}). Move the site to an Astro in that range first.`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const adapter = adapterOf(site.configText);
|
|
223
|
+
if (adapter === 'unknown') problems.push(`${site.configName} sets an adapter init can't follow. caracms needs @astrojs/node in standalone mode: set \`adapter: node({ mode: 'standalone' })\` (import node from '@astrojs/node') by hand, then run init again.`);
|
|
224
|
+
else if (adapter && adapter !== '@astrojs/node') problems.push(`${site.configName} uses the ${adapter} adapter. caracms runs on the Node adapter (@astrojs/node, standalone): move the site to it first.`);
|
|
225
|
+
else if (adapter === '@astrojs/node' && /\bmode\s*:\s*['"]middleware['"]/.test(site.configText)) problems.push(`${site.configName} has the Node adapter in middleware mode. caracms's server (\`caracms start\`) needs standalone mode: change it to \`node({ mode: 'standalone' })\`.`);
|
|
226
|
+
|
|
227
|
+
// content/ (Decision 9): caracms's own. A set-up site's is its documents.
|
|
228
|
+
if (!site.setUp && isDir(join(root, 'content'))) {
|
|
229
|
+
const files = listFiles(join(root, 'content')).map((f) => posix(relative(root, f)));
|
|
230
|
+
if (files.length) problems.push(`content/ already holds files that aren't caracms's (${files.slice(0, 3).join(', ')}${files.length > 3 ? ', …' : ''}). caracms keeps its content there: move them (Astro's own content collections can live in src/content/), then run init again.`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const routes = pageRoutes(root, site.srcDir);
|
|
234
|
+
for (const url of RESERVED) {
|
|
235
|
+
for (const r of routes.filter((r) => routeReaches(r, url))) problems.push(`${r.file} serves ${r.dynamic ? `addresses under ${r.url.replace(/\/$/, '')}` : r.url}, which caracms reserves (${url} and everything under it). Move that page elsewhere.`);
|
|
236
|
+
const inPublic = join(root, 'public', url.slice(1));
|
|
237
|
+
if (existsSync(inPublic)) problems.push(`public${url} is at ${url}, which caracms reserves (${url} and everything under it). Move it elsewhere in public/.`);
|
|
238
|
+
}
|
|
239
|
+
for (const [url, why] of [
|
|
240
|
+
['/sitemap.xml', 'caracms makes the sitemap from its documents (when SITE_URL is set)'],
|
|
241
|
+
['/robots.txt', "caracms's server (`caracms start`) answers /robots.txt itself, before Astro and public/ (bin/gate.mjs), so this would never be reached"],
|
|
242
|
+
]) {
|
|
243
|
+
for (const r of routes.filter((r) => !r.dynamic && r.url === url)) problems.push(`${r.file} is the site's own ${url}, and ${why}. Delete it; to keep pages out of search, use SITE_URL and Site Settings.`);
|
|
244
|
+
// A static file is the same address: the adapter serves public/ before Astro's routes (and robots.txt never gets that far).
|
|
245
|
+
if (existsSync(join(root, 'public', url.slice(1)))) problems.push(`public${url} is the site's own ${url}, and ${why}. Delete it; to keep pages out of search, use SITE_URL and Site Settings.`);
|
|
246
|
+
}
|
|
247
|
+
if (!site.setUp) {
|
|
248
|
+
// caracms's own catch-all counts as done only when it's the template, byte for byte (as health.ts is compared).
|
|
249
|
+
const own = `${posix(site.srcDir)}/pages/[...path].astro`;
|
|
250
|
+
const isOwn = (r) => r.file === own && read(join(root, own)) === template('catch-all.astro.txt');
|
|
251
|
+
for (const r of routes.filter((r) => r.dynamic && r.url === '/' && !isOwn(r))) {
|
|
252
|
+
if (r.file === own) problems.push(`${r.file} is the site's own: it isn't the page caracms adds at that path, and it catches addresses at the top of the site, where caracms's pages go. Move it under a folder of its own (src/pages/<folder>/), or delete it if caracms's documents replace it, then run init again.`);
|
|
253
|
+
else problems.push(`${r.file} already catches addresses at the top of the site, where caracms's pages go. Rename it under a folder of its own (src/pages/<folder>/), then run init again.`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return problems;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Is this a Tina site (Decision 9)? `tinacms` (or an `@tinacms/` package) in its dependencies,
|
|
261
|
+
* or a tina/ folder holding tracked files. MEC trial item 1: a site stripped of Tina can keep an
|
|
262
|
+
* untracked, git-ignored tina/__generated__/, which alone isn't a Tina site: `leftover` names
|
|
263
|
+
* what's there, for a note. Outside git there's no telling, so a tina/ folder counts.
|
|
264
|
+
*/
|
|
265
|
+
function tinaOf(site, inGit = git(site.root, ['rev-parse', '--is-inside-work-tree']).out.trim() === 'true') {
|
|
266
|
+
const reasons = [];
|
|
267
|
+
let leftover = null;
|
|
268
|
+
if (isDir(join(site.root, 'tina'))) {
|
|
269
|
+
const tracked = inGit ? git(site.root, ['ls-files', '--', 'tina']).out.trim() !== '' : true;
|
|
270
|
+
if (tracked) reasons.push('a tina/ folder with tracked files');
|
|
271
|
+
else leftover = readdirSync(join(site.root, 'tina')).filter((n) => n !== '.DS_Store').sort().map((n) => `tina/${n}${isDir(join(site.root, 'tina', n)) ? '/' : ''}`);
|
|
272
|
+
}
|
|
273
|
+
if (Object.keys(site.deps).some((d) => d === 'tinacms' || d.startsWith('@tinacms/'))) reasons.push('tinacms in its dependencies');
|
|
274
|
+
return { reasons, leftover };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function listFiles(dir) {
|
|
278
|
+
const out = [];
|
|
279
|
+
const walk = (d) => {
|
|
280
|
+
for (const name of readdirSync(d).sort()) {
|
|
281
|
+
if (name === '.DS_Store') continue;
|
|
282
|
+
const p = join(d, name);
|
|
283
|
+
if (isDir(p)) walk(p);
|
|
284
|
+
else out.push(p);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
walk(dir);
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── The changes ─────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Everything init would do, as a list of changes ({ kind: 'add' | 'edit' | 'link' |
|
|
295
|
+
* 'unlink' | 'schemas', file, before?, after?, target? }), notes (printed, nothing written),
|
|
296
|
+
* and what's already done.
|
|
297
|
+
*/
|
|
298
|
+
function changesOf(site) {
|
|
299
|
+
const { root } = site;
|
|
300
|
+
const changes = [];
|
|
301
|
+
const notes = [];
|
|
302
|
+
const done = [];
|
|
303
|
+
const add = (file, text, what) => {
|
|
304
|
+
if (existsSync(join(root, file))) done.push(what ?? file);
|
|
305
|
+
else changes.push({ kind: 'add', file, after: text });
|
|
306
|
+
};
|
|
307
|
+
const edit = (file, before, after, what) => {
|
|
308
|
+
if (after === before) done.push(what ?? file);
|
|
309
|
+
else changes.push({ kind: before === null ? 'add' : 'edit', file, before, after });
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// package.json: @astrojs/node, `start`, and railway when its file is made.
|
|
313
|
+
const railwayFile = '.railway/railway.ts';
|
|
314
|
+
const makeRailway = !existsSync(join(root, railwayFile));
|
|
315
|
+
{
|
|
316
|
+
const pkg = structuredClone(site.pkg);
|
|
317
|
+
pkg.dependencies ??= {};
|
|
318
|
+
if (!site.deps['@astrojs/node']) pkg.dependencies = withKey(pkg.dependencies, '@astrojs/node', VERSIONS.node);
|
|
319
|
+
// Decision 3: a site pins the exact version. An existing entry (on the trial, the tarball) is left alone.
|
|
320
|
+
if (!site.deps.caracms) pkg.dependencies = withKey(pkg.dependencies, 'caracms', ownPackage.version);
|
|
321
|
+
if (makeRailway && !site.deps.railway) pkg.devDependencies = withKey(pkg.devDependencies ?? {}, 'railway', VERSIONS.railway);
|
|
322
|
+
pkg.scripts = { ...(pkg.scripts ?? {}) };
|
|
323
|
+
pkg.scripts.start = 'caracms start';
|
|
324
|
+
if (Object.keys(pkg.dependencies).length === 0 && !site.pkg.dependencies) delete pkg.dependencies;
|
|
325
|
+
edit('package.json', site.pkgText, jsonText(pkg, site.pkgText), 'package.json (@astrojs/node, `start`)');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// astro.config: the adapter, the integration, no dev toolbar.
|
|
329
|
+
{
|
|
330
|
+
const result = astroConfigEdit(site);
|
|
331
|
+
if (result.edit) edit(result.file, site.configText, result.edit, `${result.file} (adapter, integration)`);
|
|
332
|
+
else if (result.done) done.push(`${result.file} (adapter, integration)`);
|
|
333
|
+
else notes.push(result.note);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
add('caracms.config.ts', template('caracms.config.ts.txt'));
|
|
337
|
+
add('src/components/caracms/Page.astro', template('Page.astro.txt'));
|
|
338
|
+
add('src/components/caracms/TextBlock.astro', template('TextBlock.astro.txt'));
|
|
339
|
+
add(`${posix(site.srcDir)}/pages/[...path].astro`, template('catch-all.astro.txt'));
|
|
340
|
+
const health = pageRoutes(root, site.srcDir).find((r) => !r.dynamic && r.url === '/health');
|
|
341
|
+
const healthFile = `${posix(site.srcDir)}/pages/health.ts`;
|
|
342
|
+
if (!health) changes.push({ kind: 'add', file: healthFile, after: template('health.ts.txt') });
|
|
343
|
+
else if (health.file === healthFile && read(join(root, healthFile)) === template('health.ts.txt')) done.push(healthFile);
|
|
344
|
+
else notes.push(`The site already has a /health route (${health.file}), so init leaves it alone. Railway's healthcheck asks /health for a 200.`);
|
|
345
|
+
|
|
346
|
+
{
|
|
347
|
+
const name = titleCase(site.pkg.name ?? basename(root)) || 'Site';
|
|
348
|
+
const settings = { $schema: '../schemas/site-settings.schema.json', title: 'Site Settings', editors: [], brand: { name } };
|
|
349
|
+
add('content/site-settings.json', `${JSON.stringify(settings, null, 2)}\n`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
{
|
|
353
|
+
const before = read(join(root, '.gitignore'));
|
|
354
|
+
const has = before !== null && before.split('\n').some((l) => /^\/?\.caracms\/?$/.test(l.trim()));
|
|
355
|
+
const lines = "# caracms's working files\n.caracms/\n";
|
|
356
|
+
const after = has ? before : before === null || before === '' ? lines : `${before}${before.endsWith('\n') ? '' : '\n'}\n${lines}`;
|
|
357
|
+
edit('.gitignore', before, after, '.gitignore (.caracms/)');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
{
|
|
361
|
+
const before = read(join(root, 'tsconfig.json'));
|
|
362
|
+
const after = tsconfigEdit(before);
|
|
363
|
+
if (after === undefined) notes.push('tsconfig.json: init couldn\'t follow it. Add `"allowImportingTsExtensions": true` to its compilerOptions by hand (caracms.config.ts imports .ts files by name).');
|
|
364
|
+
else edit('tsconfig.json', before, after, 'tsconfig.json (allowImportingTsExtensions)');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Railway (Decision 11).
|
|
368
|
+
if (makeRailway) {
|
|
369
|
+
const remote = git(root, ['remote', 'get-url', 'origin']).out.trim();
|
|
370
|
+
const repo = /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(remote)?.[1] ?? null;
|
|
371
|
+
const repoName = basename(git(root, ['rev-parse', '--show-toplevel']).out.trim() || root);
|
|
372
|
+
const run = site.pm === 'npm' ? 'npm run' : site.pm;
|
|
373
|
+
const text = template('railway.ts.txt')
|
|
374
|
+
.replaceAll('{{SITE}}', repoName)
|
|
375
|
+
.replaceAll('{{GITHUB_REPO}}', repo ?? 'OWNER/REPO')
|
|
376
|
+
.replaceAll('{{BUILD}}', `${run} build`)
|
|
377
|
+
.replaceAll('{{START}}', `${site.pm} start`);
|
|
378
|
+
changes.push({ kind: 'add', file: railwayFile, after: text });
|
|
379
|
+
if (!repo) notes.push(`${railwayFile}: the git remote isn't on GitHub, so GITHUB_REPO is "OWNER/REPO". Set it to the site's repository.`);
|
|
380
|
+
} else if (!read(join(root, railwayFile))?.includes('(written by `caracms init` from the')) {
|
|
381
|
+
// One init wrote is set up; a site's own (every Tina site has one) gets caracms's needs printed.
|
|
382
|
+
notes.push(railwayNeeds(railwayFile));
|
|
383
|
+
} else done.push(railwayFile);
|
|
384
|
+
|
|
385
|
+
// Skills (Decision 12), linked from the installed package.
|
|
386
|
+
const skills = skillNames();
|
|
387
|
+
const skillsDir = join(root, '.claude/skills');
|
|
388
|
+
for (const name of skills) {
|
|
389
|
+
const file = `.claude/skills/${name}`;
|
|
390
|
+
const target = `${SKILLS_LINK_BASE}${name}`;
|
|
391
|
+
const st = lstat(join(root, file));
|
|
392
|
+
if (!st) changes.push({ kind: 'link', file, target });
|
|
393
|
+
else if (st.isSymbolicLink() && readlinkSync(join(root, file)) === target) done.push(file);
|
|
394
|
+
else if (st.isSymbolicLink() && readlinkSync(join(root, file)).startsWith(SKILLS_LINK_BASE)) changes.push({ kind: 'link', file, target, replace: true });
|
|
395
|
+
else notes.push(`${file} is the site's own, so init doesn't link caracms's ${name} skill over it.`);
|
|
396
|
+
}
|
|
397
|
+
if (isDir(skillsDir)) {
|
|
398
|
+
for (const name of readdirSync(skillsDir).sort()) {
|
|
399
|
+
const path = join(skillsDir, name);
|
|
400
|
+
if (skills.includes(name) || !lstat(path)?.isSymbolicLink()) continue;
|
|
401
|
+
if (readlinkSync(path).startsWith(SKILLS_LINK_BASE)) changes.push({ kind: 'unlink', file: `.claude/skills/${name}` });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// The CLAUDE.md block (Decision 12): into the link's target when CLAUDE.md is a link.
|
|
406
|
+
{
|
|
407
|
+
const link = lstat(join(root, 'CLAUDE.md'))?.isSymbolicLink() ? readlinkSync(join(root, 'CLAUDE.md')) : null;
|
|
408
|
+
const file = link ? posix(relative(root, resolve(root, link))) : 'CLAUDE.md';
|
|
409
|
+
const before = read(join(root, file));
|
|
410
|
+
const block = claudeBlock(skills);
|
|
411
|
+
const after = withBlock(before, block);
|
|
412
|
+
if (after === null) notes.push(`${file} has a ${MARK_START} line with no ${MARK_END} after it, so init can't tell where its block ends and leaves the file alone. Replace the old block with this one by hand, then run init again:\n${block.split('\n').map((l) => ` ${l}`).join('\n')}`);
|
|
413
|
+
else edit(file, before, after, `${file} (the caracms block)`);
|
|
414
|
+
}
|
|
415
|
+
// MEC trial item 1: what Tina left behind, untracked (tinaOf).
|
|
416
|
+
const { leftover } = tinaOf(site);
|
|
417
|
+
if (leftover) notes.push(`${leftover.length ? leftover.join(', ') : 'tina/'} ${leftover.length > 1 ? 'are' : 'is'} left over from Tina (nothing under tina/ is tracked): delete ${leftover.length > 1 ? 'them' : 'it'}.`);
|
|
418
|
+
return { changes, notes, done };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** `object` with `key` added, in alphabetical order when the object already is. */
|
|
422
|
+
function withKey(object, key, value) {
|
|
423
|
+
if (Object.hasOwn(object, key)) return object;
|
|
424
|
+
const keys = Object.keys(object);
|
|
425
|
+
const sorted = keys.every((k, i) => i === 0 || keys[i - 1].localeCompare(k) <= 0);
|
|
426
|
+
const entries = [...Object.entries(object), [key, value]];
|
|
427
|
+
if (sorted) entries.sort(([a], [b]) => a.localeCompare(b));
|
|
428
|
+
return Object.fromEntries(entries);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** JSON in the file's own indentation, with its trailing newline. */
|
|
432
|
+
function jsonText(value, like) {
|
|
433
|
+
const indent = /\n([ \t]+)"/.exec(like ?? '')?.[1] ?? ' ';
|
|
434
|
+
return `${JSON.stringify(value, null, indent)}${like?.endsWith('\n') === false ? '' : '\n'}`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** tsconfig.json's text with `allowImportingTsExtensions`, or undefined when it can't be followed. */
|
|
438
|
+
function tsconfigEdit(text) {
|
|
439
|
+
if (text === null) return `{\n "extends": "astro/tsconfigs/base",\n "compilerOptions": {\n "allowImportingTsExtensions": true\n }\n}\n`;
|
|
440
|
+
if (/"allowImportingTsExtensions"\s*:\s*true/.test(text)) return text;
|
|
441
|
+
if (/"allowImportingTsExtensions"/.test(text)) return undefined;
|
|
442
|
+
const options = /"compilerOptions"\s*:\s*\{/.exec(text);
|
|
443
|
+
const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? ' ';
|
|
444
|
+
if (options) {
|
|
445
|
+
const at = options.index + options[0].length;
|
|
446
|
+
const empty = /^\s*\}/.test(text.slice(at));
|
|
447
|
+
return `${text.slice(0, at)}\n${indent}${indent}"allowImportingTsExtensions": true${empty ? `\n${indent}` : ','}${empty ? text.slice(at).replace(/^\s*/, '') : text.slice(at)}`;
|
|
448
|
+
}
|
|
449
|
+
const end = text.lastIndexOf('}');
|
|
450
|
+
if (end === -1) return undefined;
|
|
451
|
+
const body = text.slice(0, end).replace(/\s*$/, '');
|
|
452
|
+
const comma = body.endsWith('{') ? '' : ',';
|
|
453
|
+
return `${body}${comma}\n${indent}"compilerOptions": {\n${indent}${indent}"allowImportingTsExtensions": true\n${indent}}\n${text.slice(end)}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const INTEGRATION_LINES = (indent, { adapter, integrations, toolbar }) =>
|
|
457
|
+
[
|
|
458
|
+
adapter ? `${indent}// caracms: pages stay prerendered; the Node server adds the editor (caracms start).\n${indent}adapter: node({ mode: 'standalone' }),` : null,
|
|
459
|
+
integrations ? `${indent}// caracms reads caracms.config.ts: collections, blocks, text styles and the page component.\n${indent}integrations: [caracms(site)],` : null,
|
|
460
|
+
toolbar ? `${indent}// Astro's dev toolbar would fight the editor's preview.\n${indent}devToolbar: { enabled: false },` : null,
|
|
461
|
+
].filter(Boolean);
|
|
462
|
+
|
|
463
|
+
/** astro.config's new text: `{ file, edit }`, `{ file, done: true }`, or `{ file, note }` (lines to add by hand). */
|
|
464
|
+
function astroConfigEdit(site) {
|
|
465
|
+
const file = site.configName ?? 'astro.config.mjs';
|
|
466
|
+
const imports = ["import node from '@astrojs/node';", "import caracms from 'caracms';", "import site from './caracms.config.ts';"];
|
|
467
|
+
const text = site.configText;
|
|
468
|
+
if (text === null) {
|
|
469
|
+
const body = INTEGRATION_LINES('\t', { adapter: true, integrations: true, toolbar: true }).join('\n');
|
|
470
|
+
return { file, edit: `// @ts-check\nimport { defineConfig } from 'astro/config';\n${imports.join('\n')}\n\nexport default defineConfig({\n${body}\n});\n` };
|
|
471
|
+
}
|
|
472
|
+
if (/\bcaracms\s*\(/.test(text)) return { file, done: true };
|
|
473
|
+
const hasAdapter = adapterOf(text) !== null;
|
|
474
|
+
const hasToolbar = /\bdevToolbar\s*:/.test(text);
|
|
475
|
+
const hasIntegrations = /\bintegrations\s*:/.test(text);
|
|
476
|
+
const needed = imports.filter((line, i) => !(i === 0 && hasAdapter));
|
|
477
|
+
const byHand = (why) => {
|
|
478
|
+
const lines = [...needed, ...INTEGRATION_LINES(' ', { adapter: !hasAdapter, integrations: !hasIntegrations, toolbar: !hasToolbar }).map((l) => l.trimStart())];
|
|
479
|
+
if (hasIntegrations) lines.push('caracms(site) in its integrations: [ … ]');
|
|
480
|
+
return { file, note: `${file}: ${why}, so init leaves it alone. Add these by hand, then run init again:\n${lines.map((l) => ` ${l}`).join('\n')}` };
|
|
481
|
+
};
|
|
482
|
+
const open = /defineConfig\(\s*\{/.exec(text);
|
|
483
|
+
if (!open) return byHand("it doesn't export `defineConfig({ … })`");
|
|
484
|
+
const integrationsArray = hasIntegrations ? /\bintegrations\s*:\s*\[/.exec(text) : null;
|
|
485
|
+
if (hasIntegrations && !integrationsArray) return byHand('its integrations aren\'t a plain list');
|
|
486
|
+
|
|
487
|
+
let out = text;
|
|
488
|
+
// The integration first (later offsets move), then the keys after `defineConfig({`, then the imports.
|
|
489
|
+
if (integrationsArray) {
|
|
490
|
+
const at = integrationsArray.index + integrationsArray[0].length;
|
|
491
|
+
const empty = /^\s*\]/.test(out.slice(at));
|
|
492
|
+
out = `${out.slice(0, at)}caracms(site)${empty ? '' : ', '}${out.slice(at)}`;
|
|
493
|
+
}
|
|
494
|
+
const openAt = open.index + open[0].length;
|
|
495
|
+
const nextLine = /\n([ \t]*)\S/.exec(out.slice(openAt));
|
|
496
|
+
const indent = nextLine?.[1] || ' ';
|
|
497
|
+
const lines = INTEGRATION_LINES(indent, { adapter: !hasAdapter, integrations: !hasIntegrations, toolbar: !hasToolbar });
|
|
498
|
+
if (lines.length) out = `${out.slice(0, openAt)}\n${lines.join('\n')}${out.slice(openAt)}`;
|
|
499
|
+
|
|
500
|
+
const importRe = /^import\s(?:[^;'"]*?\sfrom\s*)?['"][^'"]+['"];?[ \t]*$/gm;
|
|
501
|
+
let lastImportEnd = -1;
|
|
502
|
+
for (let m; (m = importRe.exec(out)); ) lastImportEnd = m.index + m[0].length;
|
|
503
|
+
const importText = needed.join('\n');
|
|
504
|
+
if (lastImportEnd >= 0) out = `${out.slice(0, lastImportEnd)}\n${importText}${out.slice(lastImportEnd)}`;
|
|
505
|
+
else {
|
|
506
|
+
const check = /^\/\/ @ts-check[ \t]*\n/.exec(out);
|
|
507
|
+
const at = check ? check[0].length : 0;
|
|
508
|
+
out = `${out.slice(0, at)}${importText}\n${out.slice(at)}`;
|
|
509
|
+
}
|
|
510
|
+
return { file, edit: out };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function railwayNeeds(file) {
|
|
514
|
+
return [
|
|
515
|
+
`${file} exists, so init doesn't edit it (every Tina site has one). Add what caracms needs to it by hand:`,
|
|
516
|
+
' Redis: the editor\'s drafts and sessions. The site gets REDIS_URL.',
|
|
517
|
+
' A media bucket: uploaded images. The site gets S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY.',
|
|
518
|
+
' A submissions bucket: form submissions, apart from the media imgproxy can read. The site gets SUBMISSIONS_S3_BUCKET, SUBMISSIONS_S3_ACCESS_KEY_ID and SUBMISSIONS_S3_SECRET_ACCESS_KEY.',
|
|
519
|
+
' imgproxy (ghcr.io/imgproxy/imgproxy), reading only the media bucket: it serves the images, signed. The site gets CARACMS_MEDIA_URL (imgproxy\'s public address), IMGPROXY_KEY and IMGPROXY_SALT (the same as imgproxy\'s).',
|
|
520
|
+
' The site: NODE_ENV=production, SITE_URL (its public address), GITHUB_REPO (owner/name), start command `caracms start`, healthcheck /health; set by hand: GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET, CARACMS_SECRET, CARACMS_ALLOW, CARACMS_BACKUP_USER, CARACMS_BACKUP_PASSWORD, CARACMS_RAILWAY_TOKEN, MUX_TOKEN_ID, MUX_TOKEN_SECRET, RESEND_API_KEY, FORMS_FROM.',
|
|
521
|
+
' The whole of it, as caracms\'s starter writes it: node_modules/caracms/bin/templates/railway.ts.txt.',
|
|
522
|
+
].join('\n');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The package's skills: its `skills/<name>/` folders. */
|
|
526
|
+
export function skillNames() {
|
|
527
|
+
const dir = join(packageRoot, 'skills');
|
|
528
|
+
if (!isDir(dir)) return [];
|
|
529
|
+
return readdirSync(dir).filter((n) => !n.startsWith('.') && isDir(join(dir, n))).sort();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export function claudeBlock(skills) {
|
|
533
|
+
const lines = [
|
|
534
|
+
MARK_START,
|
|
535
|
+
'## caracms',
|
|
536
|
+
'',
|
|
537
|
+
"This site's content is edited with caracms (the `caracms` package). Its skills are linked from the installed package into `.claude/skills/`, so they always match the installed version:",
|
|
538
|
+
'',
|
|
539
|
+
...skills.map((name) => `- \`${name}\`${SKILL_BLURBS[name] ? `: ${SKILL_BLURBS[name]}.` : ''}`),
|
|
540
|
+
...(skills.length ? [''] : []),
|
|
541
|
+
'Run `caracms validate` before a pull request.',
|
|
542
|
+
MARK_END,
|
|
543
|
+
];
|
|
544
|
+
return lines.join('\n');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The file's text with the block between the markers replaced, or appended. Nothing outside the markers changes.
|
|
549
|
+
* Null when there's a start marker with no end marker after it: where the old block ends can't be told, so it isn't safe to edit.
|
|
550
|
+
*/
|
|
551
|
+
export function withBlock(text, block) {
|
|
552
|
+
if (text === null || text === '') return `${block}\n`;
|
|
553
|
+
const start = text.indexOf(MARK_START);
|
|
554
|
+
const end = start === -1 ? -1 : text.indexOf(MARK_END, start);
|
|
555
|
+
if (start !== -1 && end === -1) return null;
|
|
556
|
+
if (start !== -1) return text.slice(0, start) + block + text.slice(end + MARK_END.length);
|
|
557
|
+
return `${text.replace(/\n*$/, '')}\n\n${block}\n`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── Output ──────────────────────────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
/** A unified diff of two texts, three lines of context. */
|
|
563
|
+
export function diff(file, before, after) {
|
|
564
|
+
const a = before === null ? [] : before.replace(/\n$/, '').split('\n');
|
|
565
|
+
const b = after.replace(/\n$/, '').split('\n');
|
|
566
|
+
// The longest common subsequence, by a table (config files are small).
|
|
567
|
+
const lcs = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
|
|
568
|
+
for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
569
|
+
const ops = [];
|
|
570
|
+
let i = 0;
|
|
571
|
+
let j = 0;
|
|
572
|
+
while (i < a.length || j < b.length) {
|
|
573
|
+
if (i < a.length && j < b.length && a[i] === b[j]) ops.push([' ', a[i++], j++]);
|
|
574
|
+
else if (i < a.length && (j === b.length || lcs[i + 1][j] >= lcs[i][j + 1])) ops.push(['-', a[i++]]);
|
|
575
|
+
else ops.push(['+', b[j++]]);
|
|
576
|
+
}
|
|
577
|
+
const out = [`--- ${before === null ? '/dev/null' : `a/${file}`}`, `+++ b/${file}`];
|
|
578
|
+
const changed = ops.map((o, k) => (o[0] !== ' ' ? k : -1)).filter((k) => k >= 0);
|
|
579
|
+
let k = 0;
|
|
580
|
+
while (k < changed.length) {
|
|
581
|
+
let from = Math.max(0, changed[k] - 3);
|
|
582
|
+
let to = Math.min(ops.length, changed[k] + 4);
|
|
583
|
+
while (k + 1 < changed.length && changed[k + 1] - 3 <= to) to = Math.min(ops.length, changed[++k] + 4);
|
|
584
|
+
k++;
|
|
585
|
+
const hunk = ops.slice(from, to);
|
|
586
|
+
const aStart = ops.slice(0, from).filter((o) => o[0] !== '+').length + 1;
|
|
587
|
+
const bStart = ops.slice(0, from).filter((o) => o[0] !== '-').length + 1;
|
|
588
|
+
const aLen = hunk.filter((o) => o[0] !== '+').length;
|
|
589
|
+
const bLen = hunk.filter((o) => o[0] !== '-').length;
|
|
590
|
+
out.push(`@@ -${aLen ? aStart : aStart - 1},${aLen} +${bLen ? bStart : bStart - 1},${bLen} @@`);
|
|
591
|
+
for (const o of hunk) out.push(`${o[0]}${o[1]}`);
|
|
592
|
+
}
|
|
593
|
+
return out.join('\n');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function describe(change) {
|
|
597
|
+
switch (change.kind) {
|
|
598
|
+
case 'add':
|
|
599
|
+
return ` add ${change.file}`;
|
|
600
|
+
case 'edit':
|
|
601
|
+
return ` edit ${change.file}`;
|
|
602
|
+
case 'link':
|
|
603
|
+
return ` link ${change.file} → ${change.target}`;
|
|
604
|
+
case 'unlink':
|
|
605
|
+
return ` unlink ${change.file} (that skill is no longer in the package)`;
|
|
606
|
+
case 'schemas':
|
|
607
|
+
return ` ${change.before === null ? 'add ' : change.after === null ? 'remove' : 'edit '} ${change.file}${change.generated ? ' (generated: caracms schemas)' : ''}`;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function report({ changes, notes, done }, { write, pm }) {
|
|
612
|
+
const parts = [];
|
|
613
|
+
if (changes.length === 0) {
|
|
614
|
+
parts.push('caracms init: nothing to do. caracms is set up here.');
|
|
615
|
+
} else {
|
|
616
|
+
parts.push(write ? 'caracms init: done. It changed:' : 'caracms init: the change it would make. Nothing is written: `caracms init --write` applies exactly this.');
|
|
617
|
+
parts.push(changes.map(describe).join('\n'));
|
|
618
|
+
if (!write) {
|
|
619
|
+
const diffs = changes.filter((c) => (c.kind === 'add' || c.kind === 'edit' || (c.kind === 'schemas' && !c.generated && c.after !== null)));
|
|
620
|
+
if (diffs.length) parts.push(diffs.map((c) => diff(c.file, c.before ?? null, c.after)).join('\n\n'));
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (done.length && changes.length) parts.push(`Already done: ${done.join('; ')}.`);
|
|
624
|
+
if (notes.length) parts.push(`Notes:\n${notes.map((n) => ` - ${n}`).join('\n')}`);
|
|
625
|
+
if (changes.length) {
|
|
626
|
+
const install = `${pm} install`;
|
|
627
|
+
parts.push(
|
|
628
|
+
write
|
|
629
|
+
? `Next: \`${install}\` (it adds @astrojs/node), commit this as one change, then follow the caracms-adopt skill to bring the pages in.`
|
|
630
|
+
: `Then, after --write: \`${install}\`, and the caracms-adopt skill.`,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
return `${parts.join('\n\n')}\n`;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── Schemas ─────────────────────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
/** The schema and `$schema` changes, from the site's config through its Vite. A fresh site's are the starting config's. */
|
|
639
|
+
async function schemaChanges(site) {
|
|
640
|
+
if (!site.setUp) return STARTING_SCHEMAS.map((file) => ({ kind: 'schemas', file, before: null, after: '', generated: true }));
|
|
641
|
+
const { withPackageModule } = await import('./site-runner.mjs');
|
|
642
|
+
return withPackageModule(site.root, 'schemas', async (m) => {
|
|
643
|
+
const loaded = await m.loadSite(site.root);
|
|
644
|
+
const files = m.schemaFileChanges(loaded).map((c) => ({ kind: 'schemas', file: c.file, before: c.before, after: c.text, generated: true }));
|
|
645
|
+
const stamps = (await m.stampChanges(loaded)).map((c) => ({ kind: 'schemas', file: c.file, before: c.before, after: c.text }));
|
|
646
|
+
return [...files, ...stamps];
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ── The command ─────────────────────────────────────────────────────────────────
|
|
651
|
+
|
|
652
|
+
/** The plan: problems (nothing happens when there are any), or the changes. */
|
|
653
|
+
export async function plan(root, { write = false } = {}) {
|
|
654
|
+
const site = inspect(root);
|
|
655
|
+
const problems = problemsOf(site, write);
|
|
656
|
+
if (problems.length) return { site, problems, changes: [], notes: [], done: [] };
|
|
657
|
+
const { changes, notes, done } = changesOf(site);
|
|
658
|
+
try {
|
|
659
|
+
changes.push(...(await schemaChanges(site)));
|
|
660
|
+
} catch (error) {
|
|
661
|
+
return { site, problems: [`The schemas couldn't be made from caracms.config.ts: ${error.message}`], changes: [], notes: [], done: [] };
|
|
662
|
+
}
|
|
663
|
+
return { site, problems, changes, notes, done };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function apply(root, changes) {
|
|
667
|
+
for (const c of changes) {
|
|
668
|
+
const path = join(root, c.file);
|
|
669
|
+
switch (c.kind) {
|
|
670
|
+
case 'add':
|
|
671
|
+
case 'edit':
|
|
672
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
673
|
+
writeFileSync(path, c.after);
|
|
674
|
+
break;
|
|
675
|
+
case 'link':
|
|
676
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
677
|
+
if (c.replace) rmSync(path, { force: true });
|
|
678
|
+
symlinkSync(c.target, path);
|
|
679
|
+
break;
|
|
680
|
+
case 'unlink':
|
|
681
|
+
rmSync(path, { force: true });
|
|
682
|
+
break;
|
|
683
|
+
// 'schemas' is written by the schemas command, after everything else.
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* `caracms init [--write]`, from `root`. Returns the exit code; prints to `out` and `err` (stdout and stderr).
|
|
690
|
+
* @param {{ root: string, write?: boolean, out?: { write(text: string): unknown }, err?: { write(text: string): unknown } }} options
|
|
691
|
+
*/
|
|
692
|
+
export async function run({ root, write = false, out = process.stdout, err = process.stderr }) {
|
|
693
|
+
const p = await plan(root, { write });
|
|
694
|
+
if (p.problems.length) {
|
|
695
|
+
err.write(`caracms init: ${p.problems.length === 1 ? 'one thing is' : `${p.problems.length} things are`} in the way, so nothing was ${write ? 'written' : 'planned'}:\n${p.problems.map((x) => ` - ${x}`).join('\n')}\n`);
|
|
696
|
+
return 1;
|
|
697
|
+
}
|
|
698
|
+
if (write && p.changes.length) {
|
|
699
|
+
apply(root, p.changes);
|
|
700
|
+
if (p.changes.some((c) => c.kind === 'schemas')) {
|
|
701
|
+
try {
|
|
702
|
+
const { withPackageModule } = await import('./site-runner.mjs');
|
|
703
|
+
await withPackageModule(root, 'schemas', (m) => m.command(root));
|
|
704
|
+
} catch (error) {
|
|
705
|
+
err.write(`caracms init: the files are written, but the schemas couldn't be: ${error.message}\nFix that, then run \`caracms schemas\`.\n`);
|
|
706
|
+
return 1;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
out.write(report(p, { write, pm: p.site.pm }));
|
|
711
|
+
return 0;
|
|
712
|
+
}
|