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,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three-way merge (M4 Decisions 6 and 7), pure: text in, text out.
|
|
3
|
+
*
|
|
4
|
+
* One rule for every merge in live mode: committing an autosave (the autosave's
|
|
5
|
+
* base, the autosave, the file in `drafts` now), merging `main` into `drafts` (the
|
|
6
|
+
* merge base, `drafts`, `main`), and held edits re-sent after a sign-in. One side is
|
|
7
|
+
* always **the draft**, and the draft wins a conflict.
|
|
8
|
+
*
|
|
9
|
+
* - **A JSON object file** merges per top-level key: a key changed on one side takes
|
|
10
|
+
* that side; a key changed on both takes the draft; a key added or removed on one
|
|
11
|
+
* side follows that side.
|
|
12
|
+
* - **Anything else** (not JSON on any side, not an object, a file deleted on one
|
|
13
|
+
* side and changed on the other, or a caller saying it isn't a content file) falls
|
|
14
|
+
* back to the whole file, the draft winning.
|
|
15
|
+
* - **Byte-preserving** (M3 Decision 7's rule): the result is built on the draft's
|
|
16
|
+
* text. Untouched keys keep their text and position exactly. A member taken from
|
|
17
|
+
* the other side has its value's text spliced in at the draft member's span; a key
|
|
18
|
+
* new on the other side is appended at the end, in that side's order, with its
|
|
19
|
+
* text as written there; a key the other side removed is cut out with its comma.
|
|
20
|
+
* Never `JSON.stringify`, never `applyEdits` (which only knows a title and blocks).
|
|
21
|
+
*
|
|
22
|
+
* "Changed" compares values, not text: a key re-indented but equal isn't a change.
|
|
23
|
+
*/
|
|
24
|
+
import { member, parseSource, slice, type MemberNode, type ObjectNode, type ParsedSource } from '../json-source.ts';
|
|
25
|
+
|
|
26
|
+
export interface MergeInput {
|
|
27
|
+
/** The common ancestor's text; null when the file didn't exist there. */
|
|
28
|
+
base: string | null;
|
|
29
|
+
/** The draft's text (it wins conflicts); null when the draft deleted it. */
|
|
30
|
+
draft: string | null;
|
|
31
|
+
/** The other side's text; null when that side deleted it. */
|
|
32
|
+
other: string | null;
|
|
33
|
+
/** False forces the whole-file rule (a non-content file). Default true. */
|
|
34
|
+
perKey?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MergeResult {
|
|
38
|
+
/** The merged text, or null for "the file is deleted". */
|
|
39
|
+
text: string | null;
|
|
40
|
+
/** Some key, or the whole file, was changed on both sides and decided for the draft: show the notice. */
|
|
41
|
+
keptDraft: boolean;
|
|
42
|
+
/** The keys decided for the draft (empty for a whole-file decision). */
|
|
43
|
+
conflicts: string[];
|
|
44
|
+
/** How it was decided. */
|
|
45
|
+
how: 'same' | 'draft' | 'other' | 'keys' | 'whole-file';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function mergeFile({ base, draft, other, perKey = true }: MergeInput): MergeResult {
|
|
49
|
+
if (draft === other) return { text: draft, keptDraft: false, conflicts: [], how: 'same' };
|
|
50
|
+
if (draft === base) return { text: other, keptDraft: false, conflicts: [], how: 'other' };
|
|
51
|
+
if (other === base) return { text: draft, keptDraft: false, conflicts: [], how: 'draft' };
|
|
52
|
+
|
|
53
|
+
const whole: MergeResult = { text: draft, keptDraft: true, conflicts: [], how: 'whole-file' };
|
|
54
|
+
if (!perKey || draft === null || other === null) return whole;
|
|
55
|
+
const d = objectSource(draft);
|
|
56
|
+
const o = objectSource(other);
|
|
57
|
+
// An added-on-both-sides file merges as if the ancestor were `{}`.
|
|
58
|
+
const b = base === null ? EMPTY : objectSource(base);
|
|
59
|
+
if (!d || !o || !b) return whole;
|
|
60
|
+
|
|
61
|
+
const value = (s: Parsed, key: string) => (Object.hasOwn(s.value, key) ? { v: s.value[key] } : null);
|
|
62
|
+
const same = (x: { v: unknown } | null, y: { v: unknown } | null) => (x === null || y === null ? x === y : equal(x.v, y.v));
|
|
63
|
+
|
|
64
|
+
const conflicts: string[] = [];
|
|
65
|
+
const replace: { member: MemberNode; text: string }[] = [];
|
|
66
|
+
const remove: string[] = [];
|
|
67
|
+
const append: string[] = [];
|
|
68
|
+
const keys = [...new Set([...unique(d.root), ...unique(o.root), ...unique(b.root)])];
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
const dv = value(d, key);
|
|
71
|
+
const ov = value(o, key);
|
|
72
|
+
const bv = value(b, key);
|
|
73
|
+
if (same(dv, ov)) continue; // the same on both sides
|
|
74
|
+
if (same(ov, bv)) continue; // only the draft changed it: the draft has it
|
|
75
|
+
if (same(dv, bv)) {
|
|
76
|
+
// Only the other side changed it: take that side's.
|
|
77
|
+
if (ov === null) remove.push(key);
|
|
78
|
+
else if (dv === null) append.push(key);
|
|
79
|
+
else replace.push({ member: member(d.root, key)!, text: slice(o.source, member(o.root, key)!.value) });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
conflicts.push(key); // changed on both: the draft's stays
|
|
83
|
+
}
|
|
84
|
+
if (replace.length === 0 && remove.length === 0 && append.length === 0) {
|
|
85
|
+
return { text: draft, keptDraft: conflicts.length > 0, conflicts, how: 'keys' };
|
|
86
|
+
}
|
|
87
|
+
return { text: spliceMembers(d, o, replace, remove, append), keptDraft: conflicts.length > 0, conflicts, how: 'keys' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Parsed {
|
|
91
|
+
source: ParsedSource;
|
|
92
|
+
root: ObjectNode;
|
|
93
|
+
value: Record<string, unknown>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const EMPTY: Parsed = (() => {
|
|
97
|
+
const source = parseSource('{}');
|
|
98
|
+
return { source, root: source.root as ObjectNode, value: {} };
|
|
99
|
+
})();
|
|
100
|
+
|
|
101
|
+
function objectSource(text: string): Parsed | null {
|
|
102
|
+
let source: ParsedSource;
|
|
103
|
+
try {
|
|
104
|
+
source = parseSource(text);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (source.root.kind !== 'object') return null;
|
|
109
|
+
return { source, root: source.root, value: source.value as Record<string, unknown> };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Keys in the order they first appear. */
|
|
113
|
+
const unique = (root: ObjectNode) => [...new Set(root.members.map((m) => m.key))];
|
|
114
|
+
|
|
115
|
+
/** Deep equality of JSON values; object key order doesn't matter. */
|
|
116
|
+
function equal(x: unknown, y: unknown): boolean {
|
|
117
|
+
if (x === y) return true;
|
|
118
|
+
if (typeof x !== 'object' || typeof y !== 'object' || x === null || y === null) return false;
|
|
119
|
+
if (Array.isArray(x) !== Array.isArray(y)) return false;
|
|
120
|
+
if (Array.isArray(x)) {
|
|
121
|
+
const ys = y as unknown[];
|
|
122
|
+
return x.length === ys.length && x.every((v, i) => equal(v, ys[i]));
|
|
123
|
+
}
|
|
124
|
+
const xk = Object.keys(x);
|
|
125
|
+
const yk = Object.keys(y);
|
|
126
|
+
return xk.length === yk.length && xk.every((k) => Object.hasOwn(y, k) && equal((x as Record<string, unknown>)[k], (y as Record<string, unknown>)[k]));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Where each member's key starts (its opening quote): parseSource records only values. */
|
|
130
|
+
function keyStarts(source: ParsedSource, root: ObjectNode): number[] {
|
|
131
|
+
const text = source.text;
|
|
132
|
+
const skip = (i: number) => {
|
|
133
|
+
while (i < text.length && /\s/.test(text[i]!)) i++;
|
|
134
|
+
return i;
|
|
135
|
+
};
|
|
136
|
+
return root.members.map((_, k) => {
|
|
137
|
+
if (k === 0) return skip(root.start + 1);
|
|
138
|
+
return skip(skip(root.members[k - 1]!.value.end) + 1); // past the comma
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The draft's text with members replaced (value spans), removed (with their comma)
|
|
144
|
+
* and appended (the other side's member text, in its order). Edits run from the end
|
|
145
|
+
* of the text back, so earlier offsets stay true.
|
|
146
|
+
*/
|
|
147
|
+
function spliceMembers(d: Parsed, o: Parsed, replace: { member: MemberNode; text: string }[], remove: string[], append: string[]): string {
|
|
148
|
+
const text = d.source.text;
|
|
149
|
+
const members = d.root.members;
|
|
150
|
+
const starts = keyStarts(d.source, d.root);
|
|
151
|
+
const edits: { from: number; to: number; text: string }[] = [];
|
|
152
|
+
|
|
153
|
+
for (const r of replace) edits.push({ from: r.member.value.start, to: r.member.value.end, text: r.text });
|
|
154
|
+
|
|
155
|
+
// Removing a key removes every member with it (JSON.parse keeps the last; all go).
|
|
156
|
+
const removing = new Set(remove);
|
|
157
|
+
const kept = members.map((m) => !removing.has(m.key));
|
|
158
|
+
for (let k = 0; k < members.length; k++) {
|
|
159
|
+
if (kept[k]) continue;
|
|
160
|
+
const next = kept.indexOf(true, k + 1) === -1 ? -1 : k + 1;
|
|
161
|
+
if (next !== -1) {
|
|
162
|
+
// Cut from this key to the next member's key: the member, its comma and the gap.
|
|
163
|
+
edits.push({ from: starts[k]!, to: starts[k + 1]!, text: '' });
|
|
164
|
+
} else {
|
|
165
|
+
// The last kept members are before it: cut from the end of the previous member.
|
|
166
|
+
const prev = k - 1;
|
|
167
|
+
const from = prev >= 0 ? members[prev]!.value.end : starts[k]!;
|
|
168
|
+
edits.push({ from, to: members[k]!.value.end, text: '' });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (append.length) {
|
|
173
|
+
const oStarts = keyStarts(o.source, o.root);
|
|
174
|
+
const memberText = (key: string) => {
|
|
175
|
+
const k = o.root.members.findLastIndex((m) => m.key === key);
|
|
176
|
+
return o.source.text.slice(oStarts[k]!, o.root.members[k]!.value.end);
|
|
177
|
+
};
|
|
178
|
+
const order = unique(o.root).filter((k) => append.includes(k));
|
|
179
|
+
const lastKept = kept.lastIndexOf(true);
|
|
180
|
+
if (lastKept === -1) {
|
|
181
|
+
// No members left in the draft: lay them out with the other side's gaps.
|
|
182
|
+
const gap = o.source.text.slice(o.root.start + 1, oStarts[0]);
|
|
183
|
+
const close = o.source.text.slice(o.root.members.at(-1)!.value.end, o.root.end - 1);
|
|
184
|
+
edits.push({ from: d.root.start + 1, to: d.root.end - 1, text: gap + order.map(memberText).join(`,${gap}`) + close });
|
|
185
|
+
} else {
|
|
186
|
+
// The gap before the draft's members (`\n `), so new keys line up with them.
|
|
187
|
+
const gap = lastKept > 0 ? text.slice(text.indexOf(',', members[lastKept - 1]!.value.end) + 1, starts[lastKept]) : text.slice(d.root.start + 1, starts[0]);
|
|
188
|
+
const at = members[lastKept]!.value.end;
|
|
189
|
+
edits.push({ from: at, to: at, text: order.map((k) => `,${gap}${memberText(k)}`).join('') });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
edits.sort((a, b) => b.from - a.from || b.to - a.to);
|
|
194
|
+
let out = text;
|
|
195
|
+
for (const e of edits) out = out.slice(0, e.from) + e.text + out.slice(e.to);
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commit messages live mode writes, and reading them back (M4 Decisions 5, 8, 10).
|
|
3
|
+
* **The formats are a contract**: the waiting list, who changed what, the merge
|
|
4
|
+
* notices and the publish history are all rebuilt from them if Redis is flushed
|
|
5
|
+
* ("everything in it either expires or can be rebuilt from git"). Commits have no
|
|
6
|
+
* author, so GitHub signs them (M1 → G1); people go in the message.
|
|
7
|
+
*
|
|
8
|
+
* A draft commit (the sweep), one per batch:
|
|
9
|
+
*
|
|
10
|
+
* Draft: Home, About
|
|
11
|
+
*
|
|
12
|
+
* Edited-by: Aoife <aoife@fernhill.ie>: content/pages/index.json, content/pages/about.json
|
|
13
|
+
* Edited-by: Ciarán <ciaran@fernhill.ie>: content/pages/about.json
|
|
14
|
+
* Draft-kept: content/pages/about.json
|
|
15
|
+
*
|
|
16
|
+
* One `Edited-by` per editor, paths relative to the site's folder (Decision 3). A
|
|
17
|
+
* name is the part of the address before `@` when the person has no other name.
|
|
18
|
+
* `Draft-kept` is there only when the merge (merge.ts) decided a key or a file for
|
|
19
|
+
* the draft: the notice's data.
|
|
20
|
+
*
|
|
21
|
+
* Merging `main` into `drafts`:
|
|
22
|
+
*
|
|
23
|
+
* Merge main into drafts
|
|
24
|
+
*
|
|
25
|
+
* From-main: content/pages/about.json
|
|
26
|
+
* Draft-kept: content/pages/index.json
|
|
27
|
+
*
|
|
28
|
+
* A publish, the one squashed commit on `main`:
|
|
29
|
+
*
|
|
30
|
+
* Published: Home, About — by Aoife
|
|
31
|
+
*
|
|
32
|
+
* Published-by: Aoife <aoife@fernhill.ie>
|
|
33
|
+
* Published-files: content/pages/index.json, content/pages/about.json
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface Person {
|
|
37
|
+
email: string;
|
|
38
|
+
/** Shown instead of the address's local part when set. */
|
|
39
|
+
name?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The name a person goes by in messages: their name, or the address before `@`. */
|
|
43
|
+
export function displayName(person: Person): string {
|
|
44
|
+
const name = person.name?.trim();
|
|
45
|
+
return name || person.email.split('@')[0]!;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `Aoife <aoife@fernhill.ie>`, with anything that would break the trailer taken out. */
|
|
49
|
+
const ident = (p: Person) => `${displayName(p).replace(/[<>\n\r]/g, '').trim()} <${p.email.replace(/[<>\s]/g, '')}>`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* "Home, About", or "Home, About, Work, Contact, Journal and 3 more". A title's line
|
|
53
|
+
* breaks and control characters become spaces, so no title can start a trailer line.
|
|
54
|
+
*/
|
|
55
|
+
export function titleList(titles: string[]): string {
|
|
56
|
+
const unique = [...new Set(titles.map((t) => t.replace(/\p{Cc}+/gu, ' ').trim()))];
|
|
57
|
+
if (unique.length <= 5) return unique.join(', ');
|
|
58
|
+
return `${unique.slice(0, 5).join(', ')} and ${unique.length - 5} more`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DraftEdit {
|
|
62
|
+
/** Site-relative path. */
|
|
63
|
+
file: string;
|
|
64
|
+
title: string;
|
|
65
|
+
editor: Person;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A draft commit's headline and body. Editors in the order they first appear; their files in batch order. */
|
|
69
|
+
export function draftMessage(edits: DraftEdit[], keptDraft: string[] = []): { headline: string; body: string } {
|
|
70
|
+
const byEditor = new Map<string, { person: Person; files: string[] }>();
|
|
71
|
+
for (const e of edits) {
|
|
72
|
+
const key = e.editor.email.toLowerCase();
|
|
73
|
+
const entry = byEditor.get(key) ?? { person: e.editor, files: [] };
|
|
74
|
+
if (!entry.files.includes(e.file)) entry.files.push(e.file);
|
|
75
|
+
byEditor.set(key, entry);
|
|
76
|
+
}
|
|
77
|
+
const lines = [...byEditor.values()].map(({ person, files }) => `Edited-by: ${ident(person)}: ${files.join(', ')}`);
|
|
78
|
+
if (keptDraft.length) lines.push(`Draft-kept: ${[...new Set(keptDraft)].join(', ')}`);
|
|
79
|
+
return { headline: `Draft: ${titleList(edits.map((e) => e.title))}`, body: lines.join('\n') };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function mergeMessage(fromMain: string[], keptDraft: string[]): string {
|
|
83
|
+
const lines = [];
|
|
84
|
+
if (fromMain.length) lines.push(`From-main: ${fromMain.join(', ')}`);
|
|
85
|
+
if (keptDraft.length) lines.push(`Draft-kept: ${keptDraft.join(', ')}`);
|
|
86
|
+
return `Merge main into drafts${lines.length ? `\n\n${lines.join('\n')}` : ''}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function publishMessage(titles: string[], files: string[], by: Person): string {
|
|
90
|
+
return `Published: ${titleList(titles)} — by ${displayName(by)}\n\nPublished-by: ${ident(by)}\nPublished-files: ${files.join(', ')}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Reading them back ─────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export interface ParsedMessage {
|
|
96
|
+
kind: 'draft' | 'merge' | 'publish' | 'other';
|
|
97
|
+
/** Edited-by trailers: who changed which files. */
|
|
98
|
+
edits: { person: Person; files: string[] }[];
|
|
99
|
+
/** Draft-kept: files where the draft won a conflict. */
|
|
100
|
+
keptDraft: string[];
|
|
101
|
+
/** Published-by, for a publish. */
|
|
102
|
+
publishedBy: Person | null;
|
|
103
|
+
/** Published-files, for a publish. */
|
|
104
|
+
publishedFiles: string[];
|
|
105
|
+
/** The titles in a Draft or Published headline (without "and N more"). */
|
|
106
|
+
titles: string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const list = (value: string) =>
|
|
110
|
+
value
|
|
111
|
+
.split(',')
|
|
112
|
+
.map((s) => s.trim())
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
|
|
115
|
+
const parsePerson = (value: string): Person | null => {
|
|
116
|
+
const m = /^(.*?)\s*<([^>]+)>$/.exec(value.trim());
|
|
117
|
+
if (!m) return null;
|
|
118
|
+
const name = m[1]!.trim();
|
|
119
|
+
const email = m[2]!.trim();
|
|
120
|
+
return { email, name: name && name !== email.split('@')[0] ? name : null };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Read a message written by the functions above. Anything else parses as `other` with no data. */
|
|
124
|
+
export function parseMessage(message: string): ParsedMessage {
|
|
125
|
+
const [headline = '', ...rest] = message.split('\n');
|
|
126
|
+
const out: ParsedMessage = { kind: 'other', edits: [], keptDraft: [], publishedBy: null, publishedFiles: [], titles: [] };
|
|
127
|
+
const titles = (s: string) => list(s.replace(/ and \d+ more$/, ''));
|
|
128
|
+
if (headline.startsWith('Draft: ')) {
|
|
129
|
+
out.kind = 'draft';
|
|
130
|
+
out.titles = titles(headline.slice('Draft: '.length));
|
|
131
|
+
} else if (headline === 'Merge main into drafts') out.kind = 'merge';
|
|
132
|
+
else if (headline.startsWith('Published: ')) {
|
|
133
|
+
out.kind = 'publish';
|
|
134
|
+
out.titles = titles(headline.slice('Published: '.length).replace(/ — by .*$/, ''));
|
|
135
|
+
}
|
|
136
|
+
for (const line of rest) {
|
|
137
|
+
const m = /^([A-Za-z-]+): (.*)$/.exec(line.trim());
|
|
138
|
+
if (!m) continue;
|
|
139
|
+
const [, key, value] = m;
|
|
140
|
+
if (key === 'Edited-by') {
|
|
141
|
+
// `Name <email>: path, path`; the person ends at the first `>: `.
|
|
142
|
+
const at = value!.indexOf('>: ');
|
|
143
|
+
if (at === -1) continue;
|
|
144
|
+
const person = parsePerson(value!.slice(0, at + 1));
|
|
145
|
+
if (person) out.edits.push({ person, files: list(value!.slice(at + 3)) });
|
|
146
|
+
} else if (key === 'Draft-kept') out.keptDraft.push(...list(value!));
|
|
147
|
+
else if (key === 'Published-by') out.publishedBy = parsePerson(value!);
|
|
148
|
+
else if (key === 'Published-files') out.publishedFiles = list(value!);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
package/src/live/mode.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which mode the editor runs in, read from the environment (M4 Decision 1).
|
|
3
|
+
*
|
|
4
|
+
* - **Local mode**: `astro dev` with no `GITHUB_APP_ID`. M3's editor, files on disk.
|
|
5
|
+
* - **Live mode**: whenever the App is configured, and **always on a production
|
|
6
|
+
* server**. It needs `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, the repo
|
|
7
|
+
* (`RAILWAY_GIT_REPO_OWNER` and `RAILWAY_GIT_REPO_NAME`, or `GITHUB_REPO=owner/name`;
|
|
8
|
+
* Decision 3), `CARACMS_SECRET`, and `REDIS_URL` in production (dev may use the
|
|
9
|
+
* stand-in). A production server missing any of them still serves the public site;
|
|
10
|
+
* `/admin` answers 503 naming them (Phase 2 builds that from `missing`).
|
|
11
|
+
* - **The in-memory GitHub** (Decision 2): `CARACMS_GITHUB=memory`, for tests and local
|
|
12
|
+
* browser runs, never against the site's own repo. It's live mode with no App and
|
|
13
|
+
* no repo, and it's **refused in production**.
|
|
14
|
+
*
|
|
15
|
+
* This only reads; it doesn't wire anything (Phase 2 switches the routes on it,
|
|
16
|
+
* Phase 3 guards with it).
|
|
17
|
+
*/
|
|
18
|
+
import { DEFAULT_BRANCHES, type BranchNames } from './branches.ts';
|
|
19
|
+
import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
|
|
20
|
+
|
|
21
|
+
export interface LiveSettings {
|
|
22
|
+
/** `memory`: the in-memory GitHub backend (Decision 2). */
|
|
23
|
+
github:
|
|
24
|
+
| { kind: 'app'; appId: string; privateKey: string; owner: string; repo: string }
|
|
25
|
+
| { kind: 'memory' };
|
|
26
|
+
/** Null in dev without REDIS_URL: the in-memory stand-in. */
|
|
27
|
+
redisUrl: string | null;
|
|
28
|
+
/** CARACMS_SECRET: signs sessions (Phase 3). */
|
|
29
|
+
secret: string;
|
|
30
|
+
/** CARACMS_ALLOW, split: exact addresses and `@domain`s, lower-cased and trimmed (Decision 15). */
|
|
31
|
+
allow: string[];
|
|
32
|
+
/** GITHUB_WEBHOOK_SECRET; null means the webhook route answers 404 (Decision 14). */
|
|
33
|
+
webhookSecret: string | null;
|
|
34
|
+
/** Try again (Decision 12): all three, or null. */
|
|
35
|
+
railway: { token: string; serviceId: string; environmentId: string } | null;
|
|
36
|
+
/** CARACMS_BACKUP_USER and CARACMS_BACKUP_PASSWORD, both or neither (Phase 3 checks the length). */
|
|
37
|
+
backup: { user: string; password: string } | null;
|
|
38
|
+
/** CARACMS_RUNNING_COMMIT, else RAILWAY_GIT_COMMIT_SHA; null with no deploy pipeline (Decision 12). */
|
|
39
|
+
runningCommit: string | null;
|
|
40
|
+
/** Dev only: CARACMS_TIMINGS, a JSON object of LiveTimings to shorten (Decision 2). */
|
|
41
|
+
timings: LiveTimings;
|
|
42
|
+
/**
|
|
43
|
+
* CARACMS_MAIN_BRANCH and CARACMS_DRAFTS_BRANCH (M9 Decision 6): the refs Publish and
|
|
44
|
+
* autosave move, `main` and `drafts` unset. readMode always fills it; missing (a test's
|
|
45
|
+
* settings) means the defaults.
|
|
46
|
+
*/
|
|
47
|
+
branches?: BranchNames;
|
|
48
|
+
production: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type Mode =
|
|
52
|
+
| { kind: 'local' }
|
|
53
|
+
| { kind: 'live'; settings: LiveSettings }
|
|
54
|
+
/** Live mode, but not usable: `missing` names required variables; `problems` are sentences for anything else wrong. */
|
|
55
|
+
| { kind: 'unconfigured'; missing: string[]; problems: string[] };
|
|
56
|
+
|
|
57
|
+
type Env = Record<string, string | undefined>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read the mode. `production` is whether this is a production server (the
|
|
61
|
+
* integration knows: not `astro dev`); it defaults to `NODE_ENV === 'production'`.
|
|
62
|
+
*/
|
|
63
|
+
export function readMode(env: Env = process.env, production = env.NODE_ENV === 'production'): Mode {
|
|
64
|
+
const get = (name: string) => {
|
|
65
|
+
const v = env[name]?.trim();
|
|
66
|
+
return v ? v : null;
|
|
67
|
+
};
|
|
68
|
+
const memory = get('CARACMS_GITHUB') === 'memory';
|
|
69
|
+
if (!production && !memory && !get('GITHUB_APP_ID')) return { kind: 'local' };
|
|
70
|
+
|
|
71
|
+
const missing: string[] = [];
|
|
72
|
+
const problems: string[] = [];
|
|
73
|
+
const need = (name: string) => {
|
|
74
|
+
const v = get(name);
|
|
75
|
+
if (!v) missing.push(name);
|
|
76
|
+
return v ?? '';
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (memory && production) problems.push('CARACMS_GITHUB=memory is for tests and local runs; a production server never uses the in-memory GitHub.');
|
|
80
|
+
const other = get('CARACMS_GITHUB');
|
|
81
|
+
if (other && other !== 'memory') problems.push(`CARACMS_GITHUB must be "memory" or unset (got "${other}").`);
|
|
82
|
+
|
|
83
|
+
let github: LiveSettings['github'] = { kind: 'memory' };
|
|
84
|
+
if (!memory || production) {
|
|
85
|
+
const appId = need('GITHUB_APP_ID');
|
|
86
|
+
const privateKey = need('GITHUB_APP_PRIVATE_KEY');
|
|
87
|
+
const repo = readRepo(env, problems);
|
|
88
|
+
if (!repo) missing.push('RAILWAY_GIT_REPO_OWNER and RAILWAY_GIT_REPO_NAME (or GITHUB_REPO)');
|
|
89
|
+
github = { kind: 'app', appId, privateKey, owner: repo?.owner ?? '', repo: repo?.repo ?? '' };
|
|
90
|
+
}
|
|
91
|
+
const secret = need('CARACMS_SECRET');
|
|
92
|
+
const redisUrl = production ? need('REDIS_URL') : get('REDIS_URL');
|
|
93
|
+
|
|
94
|
+
const token = get('CARACMS_RAILWAY_TOKEN');
|
|
95
|
+
const serviceId = get('RAILWAY_SERVICE_ID');
|
|
96
|
+
const environmentId = get('RAILWAY_ENVIRONMENT_ID');
|
|
97
|
+
const user = get('CARACMS_BACKUP_USER');
|
|
98
|
+
const password = env.CARACMS_BACKUP_PASSWORD || null; // a password's spaces are its own
|
|
99
|
+
|
|
100
|
+
let timings = { ...DEFAULT_TIMINGS };
|
|
101
|
+
const rawTimings = get('CARACMS_TIMINGS');
|
|
102
|
+
if (rawTimings) {
|
|
103
|
+
if (production) problems.push('CARACMS_TIMINGS is for tests; a production server uses the real timings.');
|
|
104
|
+
else timings = { ...timings, ...parseTimings(rawTimings, problems) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const branches = readBranches(get, problems);
|
|
108
|
+
|
|
109
|
+
if (missing.length || problems.length) return { kind: 'unconfigured', missing, problems };
|
|
110
|
+
return {
|
|
111
|
+
kind: 'live',
|
|
112
|
+
settings: {
|
|
113
|
+
github,
|
|
114
|
+
redisUrl: redisUrl || null,
|
|
115
|
+
secret,
|
|
116
|
+
allow: (get('CARACMS_ALLOW') ?? '')
|
|
117
|
+
.split(',')
|
|
118
|
+
.map((s) => s.trim().toLowerCase())
|
|
119
|
+
.filter(Boolean),
|
|
120
|
+
webhookSecret: get('GITHUB_WEBHOOK_SECRET'),
|
|
121
|
+
railway: token && serviceId && environmentId ? { token, serviceId, environmentId } : null,
|
|
122
|
+
backup: user && password ? { user, password } : null,
|
|
123
|
+
runningCommit: get('CARACMS_RUNNING_COMMIT') ?? get('RAILWAY_GIT_COMMIT_SHA'),
|
|
124
|
+
timings,
|
|
125
|
+
branches,
|
|
126
|
+
production,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The branch names (M9 Decision 6). Both set or neither (one alone would leave the other
|
|
133
|
+
* at its default, so a site meant to keep off `main` would still publish to it). Each must
|
|
134
|
+
* be a plain branch name, the two must differ, and autosaves never go to a branch called
|
|
135
|
+
* `main` under another name (that's the branch the settings exist to keep still).
|
|
136
|
+
*/
|
|
137
|
+
function readBranches(get: (name: string) => string | null, problems: string[]): BranchNames {
|
|
138
|
+
const main = get('CARACMS_MAIN_BRANCH');
|
|
139
|
+
const drafts = get('CARACMS_DRAFTS_BRANCH');
|
|
140
|
+
if ((main === null) !== (drafts === null)) {
|
|
141
|
+
problems.push(`CARACMS_MAIN_BRANCH and CARACMS_DRAFTS_BRANCH must be set together (only ${main === null ? 'CARACMS_DRAFTS_BRANCH' : 'CARACMS_MAIN_BRANCH'} is set), or neither for main and drafts.`);
|
|
142
|
+
}
|
|
143
|
+
const branches: BranchNames = {
|
|
144
|
+
main: get('CARACMS_MAIN_BRANCH') ?? DEFAULT_BRANCHES.main,
|
|
145
|
+
drafts: get('CARACMS_DRAFTS_BRANCH') ?? DEFAULT_BRANCHES.drafts,
|
|
146
|
+
};
|
|
147
|
+
for (const [name, value] of [
|
|
148
|
+
['CARACMS_MAIN_BRANCH', branches.main],
|
|
149
|
+
['CARACMS_DRAFTS_BRANCH', branches.drafts],
|
|
150
|
+
] as const) {
|
|
151
|
+
if (!isBranchName(value)) problems.push(`${name} must be a branch name, like port-caracms (got "${value}").`);
|
|
152
|
+
}
|
|
153
|
+
if (branches.main === branches.drafts) problems.push(`CARACMS_MAIN_BRANCH and CARACMS_DRAFTS_BRANCH must differ (both are "${branches.main}").`);
|
|
154
|
+
else if (branches.drafts === 'main') problems.push('CARACMS_DRAFTS_BRANCH can\'t be main: autosaves would land on main.');
|
|
155
|
+
return branches;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A plain branch name: no `refs/` prefix, nothing git refuses in a ref. */
|
|
159
|
+
function isBranchName(name: string): boolean {
|
|
160
|
+
return (
|
|
161
|
+
/^[A-Za-z0-9._/-]+$/.test(name) &&
|
|
162
|
+
!name.startsWith('refs/') &&
|
|
163
|
+
!/^[-/.]|[/.]$|\.\.|\/\/|\/\.|\.lock$|\.lock\//.test(name)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The repo: GITHUB_REPO overrides Railway's own variables (Decision 3). */
|
|
168
|
+
function readRepo(env: Env, problems: string[]): { owner: string; repo: string } | null {
|
|
169
|
+
const override = env.GITHUB_REPO?.trim();
|
|
170
|
+
if (override) {
|
|
171
|
+
const m = /^([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)$/.exec(override);
|
|
172
|
+
if (!m) {
|
|
173
|
+
problems.push(`GITHUB_REPO must be owner/name (got "${override}").`);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return { owner: m[1]!, repo: m[2]! };
|
|
177
|
+
}
|
|
178
|
+
const owner = env.RAILWAY_GIT_REPO_OWNER?.trim();
|
|
179
|
+
const repo = env.RAILWAY_GIT_REPO_NAME?.trim();
|
|
180
|
+
return owner && repo ? { owner, repo } : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseTimings(raw: string, problems: string[]): Partial<LiveTimings> {
|
|
184
|
+
let value: unknown;
|
|
185
|
+
try {
|
|
186
|
+
value = JSON.parse(raw);
|
|
187
|
+
} catch {
|
|
188
|
+
problems.push('CARACMS_TIMINGS must be a JSON object, like {"sweepIdleMs":2000}.');
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
const out: Partial<LiveTimings> = {};
|
|
192
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
193
|
+
problems.push('CARACMS_TIMINGS must be a JSON object, like {"sweepIdleMs":2000}.');
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
for (const [k, v] of Object.entries(value)) {
|
|
197
|
+
if (!Object.hasOwn(DEFAULT_TIMINGS, k)) problems.push(`CARACMS_TIMINGS: "${k}" isn't a timing (${Object.keys(DEFAULT_TIMINGS).join(', ')}).`);
|
|
198
|
+
else if (typeof v !== 'number' || !(v > 0)) problems.push(`CARACMS_TIMINGS: "${k}" must be a positive number of milliseconds.`);
|
|
199
|
+
else out[k as keyof LiveTimings] = v;
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The sentence `/admin` shows when live mode isn't usable (Decision 1). */
|
|
205
|
+
export function unconfiguredMessage(mode: Extract<Mode, { kind: 'unconfigured' }>): string {
|
|
206
|
+
const parts = [];
|
|
207
|
+
if (mode.missing.length) parts.push(`The editor isn't set up on this server yet: it needs ${mode.missing.join(', ')}.`);
|
|
208
|
+
parts.push(...mode.problems);
|
|
209
|
+
return parts.join(' ');
|
|
210
|
+
}
|