caracms 0.3.4 → 0.5.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/README.md +73 -3
- package/bin/templates/catch-all.astro.txt +10 -9
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +21 -4
- package/skills/caracms-blocks/SKILL.md +36 -13
- package/skills/caracms-content/SKILL.md +27 -6
- package/skills/caracms-tina/SKILL.md +53 -11
- package/skills/caracms-tina/field-kinds.md +2 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +71 -3
- package/src/cli/validate.ts +103 -12
- package/src/code-routes.ts +26 -0
- package/src/components/Head.astro +5 -5
- package/src/components/RenderDocument.astro +3 -3
- package/src/components/sitemap.xml.ts +5 -4
- package/src/config.ts +57 -11
- package/src/content-schema.ts +6 -2
- package/src/content.ts +188 -27
- package/src/document.ts +13 -2
- package/src/editor/README.md +3 -0
- package/src/editor/addresses.ts +6 -1
- package/src/editor/client/arrange/ArrangeStage.tsx +629 -0
- package/src/editor/client/arrange/model.ts +659 -0
- package/src/editor/client/arrange/store.ts +335 -0
- package/src/editor/client/collections/CollectionTable.tsx +9 -1
- package/src/editor/client/documents/actions.ts +75 -19
- package/src/editor/client/documents/dialogs.tsx +137 -18
- package/src/editor/client/editor.ts +78 -6
- package/src/editor/client/fields/HiddenToggle.tsx +18 -2
- package/src/editor/client/nav/DocRow.tsx +8 -1
- package/src/editor/client/nav/tree.ts +26 -1
- package/src/editor/client/pane/Pane.tsx +3 -0
- package/src/editor/client/pane/levels/ArrangeLevel.tsx +342 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +68 -13
- package/src/editor/client/pane/parts.tsx +14 -1
- package/src/editor/client/shell/Stage.tsx +8 -4
- package/src/editor/routes/admin.astro +1 -1
- package/src/editor/routes/arrange.ts +17 -0
- package/src/editor/routes/move.ts +1 -1
- package/src/editor/server/admin-props.ts +24 -4
- package/src/editor/server/arrange.ts +245 -0
- package/src/editor/server/create.ts +13 -2
- package/src/editor/server/drafts-store.ts +55 -10
- package/src/editor/server/move.ts +300 -81
- package/src/editor/styles/editor.css +5 -0
- package/src/editor/styles/levels/arrange.css +152 -0
- package/src/editor/styles/levels/collections.css +2 -0
- package/src/editor/styles/levels/m5a-documents.css +15 -0
- package/src/editor/types.ts +83 -7
- package/src/held.ts +116 -0
- package/src/index.ts +6 -2
- package/src/live/README.md +3 -2
- package/src/live/auth/middleware.ts +11 -0
- package/src/live/autosaves.ts +88 -1
- package/src/live/cloud.ts +131 -0
- package/src/live/drafts-store.ts +170 -77
- package/src/live/editor.ts +26 -4
- package/src/live/git.ts +63 -12
- package/src/live/github.ts +69 -7
- package/src/live/mode.ts +42 -6
- package/src/live/runtime.ts +18 -2
- package/src/page-order.ts +253 -0
- package/src/redirects.ts +64 -1
- package/src/relink.ts +40 -9
- package/src/reserved.ts +3 -2
- package/src/serialize.ts +18 -1
- package/src/slug.ts +15 -4
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Arrange pages (notes/2026-10-25-arrange-pages.md; the mockup's notes/mockups/arrange-pages.html, as drawn).
|
|
3
|
+
The screen takes the preview's place as a light sheet over the stage, the way a collection's table does
|
|
4
|
+
(collections.css → .stage-table); the pane holds its level ("Arrange pages", then "Review").
|
|
5
|
+
Rows are the Pages tree's rows grown wide: a grip, the name at its depth (16px steps), the address, the row's end.
|
|
6
|
+
Marks are washes, full rings, chips and labels. No side rails anywhere (M2 Decision 10).
|
|
7
|
+
*/
|
|
8
|
+
:root{
|
|
9
|
+
--arr-max-w:940px; /* the tree's width on the stage: names and addresses side by side, left-aligned under its head */
|
|
10
|
+
--arr-row-h:32px; /* a row: a touch taller than the sidebar's 30px, a bigger target to drag and drop on */
|
|
11
|
+
--arr-grip-w:24px; /* the grip's column; a row's icon starts after it, then one --tree-indent per depth */
|
|
12
|
+
--arr-addr-w:300px; /* the address column, at its widest */
|
|
13
|
+
--arr-addr-min:140px; /* the address column at its narrowest (the editor's 1,024px floor), where it gives way to the name first */
|
|
14
|
+
--arr-name-min:200px; /* the name column's narrowest: an icon, a name cut short, and a mark or two */
|
|
15
|
+
--arr-end-w:184px; /* a row's end: its actions (Rename…, Remove), the inside tag, the twisty */
|
|
16
|
+
--arr-mark:2px; /* the beside line's weight; the inside ring is the same 2px, all the way round */
|
|
17
|
+
--arr-ghost-w:300px; /* the dragged card's widest */
|
|
18
|
+
}
|
|
19
|
+
.stage.is-arrange .frame{visibility:hidden}
|
|
20
|
+
.arr{position:absolute;inset:0;z-index:3;display:flex;flex-direction:column;min-height:0;background:var(--surface);color:var(--text);animation:fade-in var(--dur-base) var(--ease-out)}
|
|
21
|
+
.arr-head{flex:none;padding:var(--space-4) var(--space-6) var(--space-3);border-bottom:1px solid var(--border)}
|
|
22
|
+
.arr-head-in{max-width:var(--arr-max-w);display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:var(--space-2) var(--space-3)}
|
|
23
|
+
.arr-title{display:flex;align-items:baseline;gap:var(--space-2);min-width:0}
|
|
24
|
+
.arr-title h2{margin:0;font-size:var(--text-xl);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-tight);white-space:nowrap}
|
|
25
|
+
.arr-title .n-chip{font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-subtle);white-space:nowrap}
|
|
26
|
+
.arr-hint{grid-column:1 / -1;display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-1-5) var(--space-5);font-size:var(--text-sm);color:var(--text-muted)}
|
|
27
|
+
.arr-ro{display:inline-flex;align-items:center;gap:var(--space-1-5);font-size:var(--text-sm);color:var(--text-muted)}
|
|
28
|
+
/* The two markers' key, drawn as the markers themselves */
|
|
29
|
+
.key{display:inline-flex;align-items:center;gap:var(--space-2);white-space:nowrap}
|
|
30
|
+
.key-line{position:relative;width:26px;height:var(--arr-mark);border-radius:2px;background:var(--select)}
|
|
31
|
+
.key-line::before{content:"";position:absolute;left:-3px;top:50%;width:6px;height:6px;translate:0 -50%;border:2px solid var(--select);border-radius:50%;background:var(--surface)}
|
|
32
|
+
.key-ring{width:26px;height:14px;border-radius:var(--radius-sm);background:var(--select-wash);box-shadow:0 0 0 var(--arr-mark) var(--select)}
|
|
33
|
+
.arr-scroll{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;padding:0 var(--space-6) var(--space-10);scrollbar-width:thin;scrollbar-color:var(--border-hover) transparent}
|
|
34
|
+
.arr-cols{position:sticky;top:0;z-index:2;max-width:var(--arr-max-w);display:grid;grid-template-columns:var(--arr-grip-w) minmax(var(--arr-name-min),1fr) minmax(var(--arr-addr-min),var(--arr-addr-w)) var(--arr-end-w);align-items:center;height:34px;margin-bottom:var(--space-1);background:var(--surface);border-bottom:1px solid var(--border);font-size:var(--text-xs);font-weight:var(--weight-semibold);letter-spacing:var(--tracking-caps);text-transform:uppercase;color:var(--text-subtle)}
|
|
35
|
+
.arr-tree{position:relative;max-width:var(--arr-max-w)}
|
|
36
|
+
/* A row */
|
|
37
|
+
.arow{position:relative;display:grid;grid-template-columns:var(--arr-grip-w) minmax(var(--arr-name-min),1fr) minmax(var(--arr-addr-min),var(--arr-addr-w)) var(--arr-end-w);align-items:center;min-height:var(--arr-row-h);border:1px solid transparent;border-radius:var(--radius-md);transition:background var(--dur-fast),border-color var(--dur-fast),box-shadow var(--dur-fast)}
|
|
38
|
+
.arow:hover{background:var(--surface-hover)}
|
|
39
|
+
.arow[data-drag]{cursor:grab}
|
|
40
|
+
.ag{display:grid;place-items:center;align-self:stretch;color:var(--icon)}
|
|
41
|
+
.arow[data-drag]:hover .ag{color:var(--text)}
|
|
42
|
+
.an{display:flex;align-items:center;gap:var(--space-2);min-width:0;overflow:hidden;padding-left:calc(var(--depth,0) * var(--tree-indent));padding-right:var(--space-2)}
|
|
43
|
+
.an>.i{flex:none;color:var(--icon)}
|
|
44
|
+
.an .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
45
|
+
.an .meta{display:inline-flex;flex:0 1 auto;align-items:center;gap:var(--space-1-5);min-width:0;overflow:hidden;font-size:var(--text-xs);color:var(--text-subtle);white-space:nowrap}
|
|
46
|
+
.an .meta .chip{flex:none;height:18px;font-size:var(--text-2xs)}
|
|
47
|
+
.an .meta>:not(.chip){min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
48
|
+
.an .cnt{font-variant-numeric:tabular-nums}
|
|
49
|
+
.aa{min-width:0;padding-right:var(--space-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:var(--text-xs)/1.3 var(--font-mono);color:var(--text-muted)}
|
|
50
|
+
.aa .new{color:var(--select-strong);font-weight:var(--weight-medium)}
|
|
51
|
+
.aa .was{margin-left:var(--space-2);color:var(--text-subtle);font-family:var(--font-ui)}
|
|
52
|
+
.aa .was .mono{font-family:var(--font-mono)}
|
|
53
|
+
.ae{display:flex;align-items:center;justify-content:flex-end;gap:var(--space-1);min-width:0;padding-right:var(--space-1)}
|
|
54
|
+
.ae .act{opacity:0;transition:opacity var(--dur-fast)}
|
|
55
|
+
.arow:hover .ae .act,.arow:focus-within .ae .act{opacity:1}
|
|
56
|
+
.ae .carried{font-size:var(--text-xs);color:var(--text-subtle);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
57
|
+
.atw{display:grid;place-items:center;width:calc(var(--tree-twisty) + 6px);height:calc(var(--tree-twisty) + 6px);flex:none;padding:0;border:0;border-radius:var(--radius-sm);background:none;color:var(--icon);transition:background var(--dur-fast),color var(--dur-fast)}
|
|
58
|
+
.atw:hover{background:var(--surface-pressed);color:var(--text)}
|
|
59
|
+
.atw .i{transition:rotate var(--dur-fast) var(--ease-out)}
|
|
60
|
+
.atw[aria-expanded="true"] .i{rotate:90deg}
|
|
61
|
+
.atw-gap{width:calc(var(--tree-twisty) + 6px);flex:none}
|
|
62
|
+
/* Home: fixed, first, takes nothing */
|
|
63
|
+
.arow.is-home .an .nm{font-weight:var(--weight-medium)}
|
|
64
|
+
/* A held collection's row: moves with its page; nothing drags it, nothing drops on it */
|
|
65
|
+
.arow.is-held{color:var(--text-muted)}
|
|
66
|
+
.arow.is-held:hover{background:none}
|
|
67
|
+
/* Marks for what's changed here and not applied yet: the tool's blue in a chip, and the new address in blue */
|
|
68
|
+
.arow.is-gone .an .nm,.arow.is-gone .an>.i{color:var(--text-subtle)}
|
|
69
|
+
.arow.is-gone .aa{color:var(--text-subtle);font-family:var(--font-ui)}
|
|
70
|
+
/* A new group with nothing in it: a full dashed ring (an empty slot, like .empty and .add-row), and its line under it */
|
|
71
|
+
.arow.is-empty{border:1px dashed var(--border-strong)}
|
|
72
|
+
.arow-sub{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-1-5) var(--space-2);min-height:24px;margin:0 0 var(--space-1);padding-left:calc(var(--arr-grip-w) + var(--depth,0) * var(--tree-indent) + 24px);font-size:var(--text-sm);color:var(--text-muted)}
|
|
73
|
+
.arow-sub.err{color:var(--status-danger);font-weight:var(--weight-medium)}
|
|
74
|
+
.arow-sub .i{flex:none}
|
|
75
|
+
/* Naming a new group, or renaming one, in its row */
|
|
76
|
+
.arow.is-naming{background:var(--surface-sunken);border-color:var(--border)}
|
|
77
|
+
.arow.is-naming:hover{background:var(--surface-sunken)}
|
|
78
|
+
.arow.is-naming .an .input{width:min(260px,100%);min-height:26px;height:26px;padding:2px 8px;font-size:var(--text-base)}
|
|
79
|
+
.arow.is-naming .ae .btn{flex:none}
|
|
80
|
+
/* Linked from the review: hovering an entry there washes its row here */
|
|
81
|
+
.arow.is-linked{background:var(--select-wash)}
|
|
82
|
+
/* Dragging. The row being dragged keeps its place as a dashed outline (the block list's ghost, .brow.is-ghost); what's under it goes with it. */
|
|
83
|
+
.arow.is-src{background:none;border:1px dashed var(--border-strong)}
|
|
84
|
+
.arow.is-src>*{visibility:hidden}
|
|
85
|
+
/* Inside: the row it goes into gets the selection's wash and a full 2px ring, and says so at its end */
|
|
86
|
+
.arow.is-into,.arow.is-into:hover{background:var(--select-wash);border-color:var(--select);box-shadow:0 0 0 1px var(--select)}
|
|
87
|
+
.arow.is-into .an .nm{color:var(--select-strong);font-weight:var(--weight-semibold)}
|
|
88
|
+
.arow.is-into .an>.i{color:var(--select-strong)}
|
|
89
|
+
.tag{display:inline-flex;align-items:center;gap:var(--space-1);height:20px;padding:0 var(--space-2);border-radius:var(--radius-pill);background:var(--select);color:var(--on-fill);font-size:var(--text-xs);font-weight:var(--weight-semibold);white-space:nowrap}
|
|
90
|
+
/* Refused: a danger wash and a full thin ring, and the reason in one line across the address and the end */
|
|
91
|
+
.arow.is-refused,.arow.is-refused:hover{background:var(--status-danger-wash);border-color:var(--status-danger-border)}
|
|
92
|
+
.arow-why{grid-column:3 / 5;display:flex;align-items:center;gap:var(--space-1-5);min-width:0;padding-right:var(--space-2);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--status-danger);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
93
|
+
/* Beside: a 2px line between two rows, starting where that level's icons start (so its indent says which level), a ring at its
|
|
94
|
+
start, and the level it lands in at its end. The line is a marker across the row, never a bar down a card's side. */
|
|
95
|
+
.amark{position:absolute;right:0;z-index:4;height:var(--arr-mark);border-radius:2px;background:var(--select);box-shadow:0 0 0 2px var(--surface);pointer-events:none}
|
|
96
|
+
.amark::before{content:"";position:absolute;left:-4px;top:50%;width:8px;height:8px;translate:0 -50%;border:2px solid var(--select);border-radius:50%;background:var(--surface)}
|
|
97
|
+
.amark .tag{position:absolute;right:var(--space-1);top:50%;translate:0 -50%;box-shadow:0 0 0 2px var(--surface)}
|
|
98
|
+
/* The card under the pointer while dragging */
|
|
99
|
+
.adrag{position:fixed;left:0;top:0;z-index:70;display:grid;grid-template-columns:16px minmax(0,1fr);align-items:center;gap:2px var(--space-2);min-width:220px;max-width:var(--arr-ghost-w);padding:var(--space-2) var(--space-3) var(--space-2) var(--space-2-5);background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius-md);box-shadow:var(--shadow-drag);pointer-events:none;font-size:var(--text-base)}
|
|
100
|
+
.adrag>.i{color:var(--icon)}
|
|
101
|
+
.adrag .nm{display:flex;align-items:baseline;gap:var(--space-1-5);min-width:0;font-weight:var(--weight-semibold);white-space:nowrap;overflow:hidden}
|
|
102
|
+
.adrag .nm small{font-size:var(--text-xs);font-weight:var(--weight-regular);color:var(--text-subtle);overflow:hidden;text-overflow:ellipsis}
|
|
103
|
+
.adrag .to{grid-column:2;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:var(--text-xs)/1.35 var(--font-mono);color:var(--select-strong)}
|
|
104
|
+
.adrag .to.plain{font-family:var(--font-ui);color:var(--text-muted)}
|
|
105
|
+
.adrag.no{border-color:var(--status-danger-border)}
|
|
106
|
+
.adrag.no .to{display:flex;align-items:center;gap:var(--space-1);font-family:var(--font-ui);font-weight:var(--weight-medium);color:var(--status-danger)}
|
|
107
|
+
body.is-dragging,body.is-dragging *{cursor:grabbing!important;user-select:none}
|
|
108
|
+
body.is-dragging.is-no,body.is-dragging.is-no *{cursor:not-allowed!important}
|
|
109
|
+
|
|
110
|
+
/* The pane's levels: Arrange pages, then Review */
|
|
111
|
+
.arr-none{font-size:var(--text-sm);color:var(--text-muted)}
|
|
112
|
+
.chg{list-style:none;margin:0 calc(-1 * var(--space-1));padding:0;display:flex;flex-direction:column;gap:1px}
|
|
113
|
+
.chg li{display:grid;grid-template-columns:16px minmax(0,1fr);gap:var(--space-2);padding:var(--space-1-5) var(--space-1);border-radius:var(--radius-md);font-size:var(--text-sm);color:var(--text-muted);line-height:1.4}
|
|
114
|
+
.chg li .i{margin-top:1px;color:var(--icon)}
|
|
115
|
+
.chg li b{color:var(--text);font-weight:var(--weight-semibold)}
|
|
116
|
+
.chg li.is-muted b{color:var(--text-muted)}
|
|
117
|
+
.arr-foot{flex:none;padding:var(--space-3) var(--space-4) var(--space-4);border-top:1px solid var(--border);background:var(--surface)}
|
|
118
|
+
.arr-foot .why{display:grid;grid-template-columns:16px minmax(0,1fr);gap:var(--space-1-5);margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--text-muted)}
|
|
119
|
+
.arr-foot .why .i{margin-top:1px;color:var(--icon)}
|
|
120
|
+
.arr-foot .why b{color:var(--text);font-weight:var(--weight-semibold)}
|
|
121
|
+
.arr-foot .busy{margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--status-waiting)}
|
|
122
|
+
.arr-foot .acts{display:flex;justify-content:flex-end;gap:var(--space-2)}
|
|
123
|
+
/* The review: what moves, where, and whether old links keep working */
|
|
124
|
+
.rv-lead{margin-bottom:var(--space-4);font-size:var(--text-md);font-weight:var(--weight-semibold);line-height:1.35}
|
|
125
|
+
.rv-lead small{display:block;margin-top:2px;font-size:var(--text-sm);font-weight:var(--weight-regular);color:var(--text-muted)}
|
|
126
|
+
.rv{list-style:none;margin:0 calc(-1 * var(--space-1));padding:0;display:flex;flex-direction:column;gap:1px}
|
|
127
|
+
.rv-e{display:grid;grid-template-columns:16px minmax(0,1fr);column-gap:var(--space-2);row-gap:1px;margin-left:calc(var(--in,0) * var(--tree-indent));padding:var(--space-1-5) var(--space-1);border-radius:var(--radius-md);transition:background var(--dur-fast)}
|
|
128
|
+
.rv-e:hover{background:var(--surface-hover)}
|
|
129
|
+
.rv-e>.i{margin-top:1px;color:var(--icon)}
|
|
130
|
+
.rv-e .t{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-1) var(--space-1-5);min-width:0;font-weight:var(--weight-semibold);line-height:1.35}
|
|
131
|
+
.rv-e .t .chip{height:18px;font-size:var(--text-2xs)}
|
|
132
|
+
.rv-e.is-carried .t,.rv-e.is-held .t{font-weight:var(--weight-medium)}
|
|
133
|
+
.rv-e.is-gone .t{font-weight:var(--weight-medium);color:var(--text-muted)}
|
|
134
|
+
.rv-e.is-gone>.i{color:var(--text-subtle)}
|
|
135
|
+
.mv{grid-column:2;display:flex;flex-wrap:wrap;column-gap:var(--space-1);min-width:0;font:var(--text-xs)/1.5 var(--font-mono);color:var(--text-subtle);overflow-wrap:anywhere}
|
|
136
|
+
.mv .to{color:var(--text)}
|
|
137
|
+
.rv-more{margin-left:auto;font-size:var(--text-xs)}
|
|
138
|
+
.rv-more .i{transition:rotate var(--dur-fast) var(--ease-out)}
|
|
139
|
+
.rv-more[aria-expanded="true"] .i{rotate:90deg}
|
|
140
|
+
.rv-docs{grid-column:2;list-style:none;margin:var(--space-1-5) 0 0;padding:var(--space-1-5) var(--space-2);border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface-sunken);font:var(--text-xs)/1.6 var(--font-mono);color:var(--text-muted);overflow-wrap:anywhere}
|
|
141
|
+
.rv-docs li.is-new{color:var(--status-warning-strong);font-weight:var(--weight-medium)}
|
|
142
|
+
.rv-tick{margin-top:var(--space-4);padding-top:var(--space-4);border-top:1px solid var(--border)}
|
|
143
|
+
.rv-tick .field-help{margin-bottom:0}
|
|
144
|
+
/* Shared pieces the mockup draws outside a dialog or a level's own CSS: the tick, and the order-only panel's fixed line */
|
|
145
|
+
.rv-tick .check{display:flex;align-items:center;gap:var(--space-2);font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text);cursor:pointer}
|
|
146
|
+
.rv-tick .check input{flex:none;width:16px;height:16px;margin:0;accent-color:var(--select);cursor:inherit}
|
|
147
|
+
.rv-tick .check + .field-help{margin-top:var(--space-1);padding-left:calc(16px + var(--space-2))}
|
|
148
|
+
.rv-tick .check:has(input:disabled){cursor:not-allowed;color:var(--text-muted)}
|
|
149
|
+
.side-level[data-pane-level="arrange"] .sec-h .n{font-weight:var(--weight-medium);letter-spacing:0;color:var(--text-subtle)}
|
|
150
|
+
.arow.is-naming .input[aria-invalid="true"]{border-color:var(--status-danger);box-shadow:0 0 0 3px var(--status-danger-halo)}
|
|
151
|
+
.arr-foot .progress{margin:0 0 var(--space-2-5)}
|
|
152
|
+
.mv .fr{margin-top:0}
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
.ct-open:focus-visible{outline:2px solid var(--select);outline-offset:2px;border-radius:var(--radius-xs)}
|
|
38
38
|
.ct-title-cell .meta{display:inline-flex;align-items:center;gap:var(--space-1);margin-left:var(--space-1-5)}
|
|
39
39
|
.ct-title-cell .meta:empty{display:none}
|
|
40
|
+
/* Hidden (M9 Decision 7; collections under pages, Decision 11): the sidebar row's mark, small and grey, not part of the title. */
|
|
41
|
+
.ct-title-cell .hmark{font-size:var(--text-xs);font-weight:var(--weight-regular);color:var(--text-subtle);white-space:nowrap}
|
|
40
42
|
.ct-extra{margin-left:var(--space-2);font-size:var(--text-xs);color:var(--text-subtle)}
|
|
41
43
|
.ct tr.is-warned .ct-open{color:var(--text-muted)}
|
|
42
44
|
.ct tr.is-current .ct-open{color:var(--select-strong);font-weight:var(--weight-semibold)}
|
|
@@ -11,6 +11,21 @@
|
|
|
11
11
|
.ptype.sm .i{color:var(--icon)}
|
|
12
12
|
.ptype[aria-checked="true"]{border-color:var(--select);background:var(--select-wash)}
|
|
13
13
|
.ptype[aria-checked="true"] b,.ptype[aria-checked="true"] .i{color:var(--select-strong)}
|
|
14
|
+
/* Collections under pages (Decision 9): Change address…'s list of every address that changes */
|
|
15
|
+
.mv-list{list-style:none;margin:var(--space-2) 0 0;padding:var(--space-1) var(--space-2);max-height:11rem;overflow:auto;border:1px solid var(--border);border-radius:var(--radius-md);font-size:var(--text-sm);line-height:1.6}
|
|
16
|
+
.mv-list li{overflow-wrap:anywhere}
|
|
17
|
+
/* In a short window the dialog stays inside it, the list the part that gives (down to about
|
|
18
|
+
two addresses), so Cancel and Change address always show. A column, not a scrolling dialog:
|
|
19
|
+
the Inside picker's popover must not be clipped. The margins keep the block layout's gaps
|
|
20
|
+
(no margins collapse in a column). */
|
|
21
|
+
.dialog[data-dialog="move"]{display:flex;flex-direction:column;max-height:calc(100dvh - 2 * var(--space-4))}
|
|
22
|
+
.dialog[data-dialog="move"]>form{display:flex;flex-direction:column;min-height:0}
|
|
23
|
+
.dialog[data-dialog="move"]>form>*{flex:none}
|
|
24
|
+
.dialog[data-dialog="move"]>form>h2+.field{margin-top:var(--space-2)}
|
|
25
|
+
.dialog[data-dialog="move"]>form>.field>:last-child{margin-bottom:0}
|
|
26
|
+
.dialog[data-dialog="move"]>form>[data-moving]{flex:0 1 auto;display:flex;flex-direction:column;min-height:0}
|
|
27
|
+
.dialog[data-dialog="move"]>form>[data-moving]>*{flex:none}
|
|
28
|
+
.dialog[data-dialog="move"]>form>[data-moving]>.mv-list{flex:0 1 auto;min-height:3rem;margin-top:0}
|
|
14
29
|
.dlg-list{display:flex;flex-direction:column;gap:2px;margin-top:var(--space-3);padding:var(--space-1);border:1px solid var(--border);border-radius:var(--radius-md)}
|
|
15
30
|
/* Site Settings (M5a): a fixed form with no page of its own */
|
|
16
31
|
.set-sec{margin:0 calc(-1 * var(--space-4));padding:var(--space-4) var(--space-4) var(--space-1);border-top:1px solid var(--border)}
|
package/src/editor/types.ts
CHANGED
|
@@ -54,6 +54,16 @@ export interface EditorAnyCollection {
|
|
|
54
54
|
sort?: import('../columns.ts').SortConfig | null;
|
|
55
55
|
/** A section builder's offered blocks (config.ts); null or absent for every block. */
|
|
56
56
|
blocks?: string[] | null;
|
|
57
|
+
/**
|
|
58
|
+
* Collections under pages (Decision 7): the nested collection whose page holds this one, set
|
|
59
|
+
* only on a held collection. Its `urlBase` is then the server's resolved base (the holder's
|
|
60
|
+
* address + `/`), or null while `problem` says why it has none.
|
|
61
|
+
*/
|
|
62
|
+
heldBy?: string | null;
|
|
63
|
+
/** A held collection's holder (held.ts → HeldBase); null or absent when none holds it, and on any other collection. */
|
|
64
|
+
holder?: { collection: string; path: string } | null;
|
|
65
|
+
/** Decision 13's sentence for a held collection with no addresses (shown on its sidebar entry); null or absent otherwise. */
|
|
66
|
+
problem?: string | null;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
/**
|
|
@@ -176,6 +186,11 @@ export interface AdminProps {
|
|
|
176
186
|
* document's address. `opened` is then Home, the page under the inbox.
|
|
177
187
|
*/
|
|
178
188
|
inbox?: { id: string | null } | null;
|
|
189
|
+
/**
|
|
190
|
+
* Arrange pages (Decision 15): the address opened the arrange screen (`/admin/arrange`),
|
|
191
|
+
* with nothing arranged. `opened` is then Home, the page under it.
|
|
192
|
+
*/
|
|
193
|
+
arrange?: boolean;
|
|
179
194
|
/**
|
|
180
195
|
* M6 Decision 9: the unread count for the Site-wide row, read as /admin renders; or,
|
|
181
196
|
* when the production server has no submissions bucket, the sentence naming what's
|
|
@@ -271,6 +286,8 @@ export const ENDPOINTS = {
|
|
|
271
286
|
delete: '/_caracms/delete',
|
|
272
287
|
// Pre-M7 item 8: changing a document's address, both modes.
|
|
273
288
|
move: '/_caracms/move',
|
|
289
|
+
// Arrange pages (Contracts): the arrange screen's Apply, both modes.
|
|
290
|
+
arrange: '/_caracms/arrange',
|
|
274
291
|
// M5b Phase 2: the media routes, both modes (MEDIA_ENDPOINTS below has each one).
|
|
275
292
|
media: '/_caracms/media',
|
|
276
293
|
// M4 Phase 2 (live mode only; local mode answers 404).
|
|
@@ -486,14 +503,22 @@ export interface StatusResponse {
|
|
|
486
503
|
* Runs-anywhere Decision 5: what's wrong with the site's GitHub access, for every
|
|
487
504
|
* person, while it holds (not dismissable, unlike `notices`): a refused credential, a
|
|
488
505
|
* missing permission, or a token expiring within 14 days. `message` names the fix,
|
|
489
|
-
* for the developer it's forwarded to. Null when nothing is wrong.
|
|
506
|
+
* for the developer it's forwarded to. Null when nothing is wrong. `cloud` (caracms-cloud
|
|
507
|
+
* M2 Decision 14): CaraCMS Cloud refused a token; `message` is the cloud's sentence and
|
|
508
|
+
* `link` the install link it names, if any, which the note shows as a link.
|
|
490
509
|
*/
|
|
491
|
-
githubAccess: { kind: 'refused' | 'permission' | 'expiring'; title: string; message: string } | null;
|
|
510
|
+
githubAccess: { kind: 'refused' | 'permission' | 'expiring' | 'cloud'; title: string; message: string; link?: string | null } | null;
|
|
492
511
|
/**
|
|
493
512
|
* The brand as committed in Site Settings (M5a Decision 10: read from `drafts`, never
|
|
494
513
|
* an autosave), so the chrome picks up a change on the next poll with no reload.
|
|
495
514
|
*/
|
|
496
515
|
brand: EditorBrand;
|
|
516
|
+
/**
|
|
517
|
+
* Arrange pages (Decision 18): a short hash of Site Settings' `pageOrder` as it stands in
|
|
518
|
+
* the store (the autosave on top of `drafts`); `""` when there's none. When it changes,
|
|
519
|
+
* the tab re-reads the listing, as it does when the waiting list's keys change.
|
|
520
|
+
*/
|
|
521
|
+
pageOrderStamp: string;
|
|
497
522
|
}
|
|
498
523
|
|
|
499
524
|
/** POST /_caracms/lock `{ file, action }`. */
|
|
@@ -582,12 +607,15 @@ export interface DeleteResponse {
|
|
|
582
607
|
* POST /_caracms/move (pre-M7 item 8): change a document's address. `to` is its new
|
|
583
608
|
* path in the same collection (`studio/visit`), its new parts made with slug.ts as a
|
|
584
609
|
* new document's are. Every link and reference to it is rewritten, and a redirect from
|
|
585
|
-
* the old address to the new is added to Site Settings
|
|
610
|
+
* the old address to the new is added to Site Settings unless `redirect` is false
|
|
611
|
+
* (Change address…'s "Leave a redirect from …", on unless it's unticked).
|
|
586
612
|
*/
|
|
587
613
|
export interface MoveRequest {
|
|
588
614
|
collection: string;
|
|
589
615
|
path: string;
|
|
590
616
|
to: string;
|
|
617
|
+
/** Whether to leave a redirect from the old address (default true). A document that was never live gets none either way. */
|
|
618
|
+
redirect?: boolean;
|
|
591
619
|
}
|
|
592
620
|
|
|
593
621
|
/** 200: the document is at its new path (live mode: as autosaves, all going out at the next Publish). */
|
|
@@ -602,8 +630,49 @@ export interface MoveResponse {
|
|
|
602
630
|
from: { path: string; file: string; url: string };
|
|
603
631
|
/** The other documents whose links (or references) to it were rewritten, with their titles. */
|
|
604
632
|
relinked: { collection: string; path: string; title: string }[];
|
|
605
|
-
/** The redirect added: null for a document that was never live (made since the last Publish). */
|
|
633
|
+
/** The redirect added: null when none was asked for, or for a document that was never live (made since the last Publish). */
|
|
606
634
|
redirect: { from: string; to: string } | null;
|
|
635
|
+
/**
|
|
636
|
+
* Collections under pages (Decision 9): every address that changed, this document's first,
|
|
637
|
+
* then the pages under it and the documents of a collection held under it (`from` = `to`:
|
|
638
|
+
* a held document's file stays where it is).
|
|
639
|
+
*/
|
|
640
|
+
moved: { collection: string; from: string; to: string; title: string; fromUrl: string; toUrl: string }[];
|
|
641
|
+
/** Every redirect added, one per address that was live (none when none was asked for). */
|
|
642
|
+
redirects: { from: string; to: string }[];
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* POST /_caracms/arrange (arrange pages, Contracts): the arrangement as its result.
|
|
647
|
+
* - `moves`: the roots only, each page whose path changes and isn't carried by a root above
|
|
648
|
+
* it (its old path under that root's `fromPath`, its new path that root's `toPath` + the
|
|
649
|
+
* same rest). The server expands each to everything under it (pages at any depth, a held
|
|
650
|
+
* collection's documents) except what's under another root, which wins for its own subtree.
|
|
651
|
+
* - `order`: the full child list for every parent whose children or order changed, keyed by
|
|
652
|
+
* the parent's path after the moves (`""` the top level); an empty list drops the key.
|
|
653
|
+
* Every other saved list is re-keyed after the moves by the server.
|
|
654
|
+
* - `redirect: false`: no redirects from the old addresses (the review's tick unticked).
|
|
655
|
+
*/
|
|
656
|
+
export interface ArrangeRequest {
|
|
657
|
+
moves: ArrangeMove[];
|
|
658
|
+
order: Record<string, string[]>;
|
|
659
|
+
redirect?: boolean;
|
|
660
|
+
/**
|
|
661
|
+
* Collection name → page-order.ts's `listingStamp` of every document path in that
|
|
662
|
+
* collection (hidden ones included) as the review's listing had them: Pages, and each
|
|
663
|
+
* collection held by a page that moves. Any difference at Apply refuses with 409
|
|
664
|
+
* `changed` and nothing is written (Decision 10). Absent: no check.
|
|
665
|
+
*/
|
|
666
|
+
expect?: Record<string, string>;
|
|
667
|
+
}
|
|
668
|
+
export interface ArrangeMove {
|
|
669
|
+
collection: string;
|
|
670
|
+
fromPath: string;
|
|
671
|
+
toPath: string;
|
|
672
|
+
}
|
|
673
|
+
/** 200: the moves and the order landed together (live mode: as autosaves, out at the next Publish). Refused: an ApiError like the move route's (`{ error, code }`, a lock refusal's state with `locked`). */
|
|
674
|
+
export interface ArrangeResponse {
|
|
675
|
+
ok: true;
|
|
607
676
|
}
|
|
608
677
|
|
|
609
678
|
/**
|
|
@@ -613,9 +682,10 @@ export interface MoveResponse {
|
|
|
613
682
|
* isn't an address `slugify` leaves as it is, or nothing left), `index`,
|
|
614
683
|
* `reserved-address` (under `/admin` or `/_caracms`), `below-collection` (strictly
|
|
615
684
|
* under another collection's address), `not-allowed` (Site Settings, Home, a shared
|
|
616
|
-
* block with no or an unknown block type)
|
|
685
|
+
* block with no or an unknown block type), `not-held` (collections under pages, Decision 7: a
|
|
686
|
+
* held collection no page holds, or whose holding is a problem; the message is held.ts's sentence);
|
|
617
687
|
* - 404 `not-found` (delete: no such document);
|
|
618
|
-
* - 409 `exists` (create: the document is already there), `address-taken` (another
|
|
688
|
+
* - 409 `changed` (arrange: the listing isn't the one the review was planned on), `exists` (create: the document is already there), `address-taken` (another
|
|
619
689
|
* document has that address: "There's already a page at /services/packaging"),
|
|
620
690
|
* `in-use` (a shared block something still uses; `usedOn` says where), `locked`
|
|
621
691
|
* (live: someone else holds the document's lock: "Aoife is editing Contact"; the
|
|
@@ -636,12 +706,18 @@ export type CreateDeleteCode =
|
|
|
636
706
|
| 'reserved-address'
|
|
637
707
|
| 'below-collection'
|
|
638
708
|
| 'not-allowed'
|
|
709
|
+
| 'not-held'
|
|
639
710
|
| 'not-found'
|
|
640
711
|
| 'exists'
|
|
641
712
|
| 'address-taken'
|
|
642
713
|
| 'in-use'
|
|
714
|
+
| 'holds'
|
|
643
715
|
| 'locked'
|
|
644
|
-
| 'same'
|
|
716
|
+
| 'same'
|
|
717
|
+
// Arrange pages (Decision 10): the listing changed since the review (ArrangeRequest.expect).
|
|
718
|
+
| 'changed'
|
|
719
|
+
// Arrange pages: an order-only Apply when Site Settings, which holds the order, isn't valid JSON.
|
|
720
|
+
| 'invalid-json';
|
|
645
721
|
|
|
646
722
|
// ── Media (M5b Phase 2: Decisions 6, 7 and 12) ────────────────────────────────
|
|
647
723
|
|
package/src/held.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collections under pages (Decisions 2, 7, 11, 13): a held collection's address is the
|
|
3
|
+
* address of the page that holds it, worked out from a listing, never from the config.
|
|
4
|
+
*
|
|
5
|
+
* - **The mount is in the content:** a page of the collection `heldBy` names says
|
|
6
|
+
* `"holds": "<collection>"` (content.ts reads it into the entry's `holds`). The
|
|
7
|
+
* collection's base is that page's address + `/` (`/journal/`), so its documents follow
|
|
8
|
+
* the page wherever it moves.
|
|
9
|
+
* - **One holder or none.** No page holding it, two pages holding it, Home holding it, or
|
|
10
|
+
* the holder's own file unopenable (invalid JSON, no title): the collection has no
|
|
11
|
+
* addresses (base null) and `problem` says why, naming the files. `validate` reports the
|
|
12
|
+
* same sentence as an error; the editor shows it on the collection's sidebar entry.
|
|
13
|
+
* - **A hidden holder hides its documents** (Decision 11), as a hidden page hides the pages
|
|
14
|
+
* under it: `hidden` here, and content.ts marks each document.
|
|
15
|
+
* - **Pure and browser-safe** (no imports): the server resolves bases for the editor's
|
|
16
|
+
* payload, and the browser can do the same from a fresh listing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What resolving needs of a collection (config.ts → ResolvedCollection, the editor's payload). */
|
|
20
|
+
export interface HoldableCollection {
|
|
21
|
+
name: string;
|
|
22
|
+
label: string;
|
|
23
|
+
urlBase: string | null;
|
|
24
|
+
heldBy?: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What resolving needs of a listed document (content.ts → DocumentEntry). */
|
|
28
|
+
export interface HoldingEntry {
|
|
29
|
+
collection: string;
|
|
30
|
+
path: string;
|
|
31
|
+
file: string;
|
|
32
|
+
url: string | null;
|
|
33
|
+
label: string;
|
|
34
|
+
warning: { message: string } | null;
|
|
35
|
+
hidden?: true;
|
|
36
|
+
/** The collection key the page's file says it holds (`"holds"`), even when the file can't be opened. */
|
|
37
|
+
holds?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HeldBase {
|
|
41
|
+
collection: string;
|
|
42
|
+
/** The holder's address + `/` (`/journal/`); null while no page holds it, or the holding is a problem. */
|
|
43
|
+
base: string | null;
|
|
44
|
+
/** The page that holds it; null when none does (or two do). */
|
|
45
|
+
holder: { collection: string; path: string; file: string; label: string } | null;
|
|
46
|
+
/** The holder is hidden from the site (itself, or a page above it), so the documents are too (Decision 11). */
|
|
47
|
+
hidden: boolean;
|
|
48
|
+
/** Decision 13's sentence when the collection has no addresses, naming the files; null when it's held. */
|
|
49
|
+
problem: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const and = (items: readonly string[]): string => (items.length <= 1 ? (items[0] ?? '') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`);
|
|
53
|
+
|
|
54
|
+
/** Every held collection's base, from a listing that has (at least) every document of the collections their `heldBy` names. */
|
|
55
|
+
export function heldBases(collections: Readonly<Record<string, HoldableCollection>>, entries: readonly HoldingEntry[]): Record<string, HeldBase> {
|
|
56
|
+
const out: Record<string, HeldBase> = {};
|
|
57
|
+
for (const collection of Object.values(collections)) {
|
|
58
|
+
if (!collection.heldBy) continue;
|
|
59
|
+
const { name, label } = collection;
|
|
60
|
+
const holders = entries.filter((e) => e.collection === collection.heldBy && e.holds === name);
|
|
61
|
+
const none: HeldBase = { collection: name, base: null, holder: null, hidden: false, problem: null };
|
|
62
|
+
if (holders.length === 0) {
|
|
63
|
+
out[name] = { ...none, problem: `No page holds ${label}, so its documents have no addresses and aren't on the site. Add "holds": "${name}" to the page they go under.` };
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (holders.length > 1) {
|
|
67
|
+
const files = holders.map((h) => h.file);
|
|
68
|
+
out[name] = { ...none, problem: `${and(files)} ${files.length === 2 ? 'both' : 'all'} hold ${label}, but only one page can hold it, so its documents have no addresses. Take "holds" out of ${files.length === 2 ? 'one of them' : 'all but one'}.` };
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const page = holders[0]!;
|
|
72
|
+
const holder = { collection: page.collection, path: page.path, file: page.file, label: page.label };
|
|
73
|
+
if (page.path === 'index') {
|
|
74
|
+
out[name] = { ...none, holder, problem: `${page.file} is Home, which can't hold ${label} (its address is the whole site's), so ${label}'s documents have no addresses. Put "holds": "${name}" on another page.` };
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (page.warning || page.url === null) {
|
|
78
|
+
const why = page.warning ? ` ${page.warning.message}` : '';
|
|
79
|
+
out[name] = { ...none, holder, problem: `${page.file} holds ${label}, but it can't be opened, so ${label}'s documents have no addresses until it's fixed.${why}` };
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
out[name] = { collection: name, base: `${page.url.replace(/\/+$/, '')}/`, holder, hidden: page.hidden === true, problem: null };
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The site with each held collection's `urlBase` as resolved (null while it has a problem): what every address rule and the editor read. */
|
|
88
|
+
export function withHeldBases<C extends HoldableCollection, S extends { collections: Record<string, C> }>(site: S, bases: Readonly<Record<string, HeldBase>>): S {
|
|
89
|
+
const held = Object.values(bases);
|
|
90
|
+
if (held.length === 0) return site;
|
|
91
|
+
const collections = { ...site.collections };
|
|
92
|
+
for (const b of held) if (Object.hasOwn(collections, b.collection)) collections[b.collection] = { ...collections[b.collection]!, urlBase: b.base };
|
|
93
|
+
return { ...site, collections };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Does the site have a collection a page holds? (Most don't, and skip the extra listing.) */
|
|
97
|
+
export const hasHeld = (collections: Readonly<Record<string, HoldableCollection>>): boolean => Object.values(collections).some((c) => !!c.heldBy);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A held collection's word for its documents, as the editor's `singular` (documents/actions.ts)
|
|
101
|
+
* names one: a plural label as it is ("case studies", "posts"), any other with "entries"
|
|
102
|
+
* ("journal entries"). A held collection is flat and never shared blocks, so the label is all it takes.
|
|
103
|
+
*/
|
|
104
|
+
export function documentsNoun(label: string): string {
|
|
105
|
+
const l = label.toLowerCase();
|
|
106
|
+
return /ies$/.test(l) || /[^s]s$/.test(l) ? l : `${l} entries`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Decision 12's sentence: "Journal holds the journal entries. Delete the “holds” line in its
|
|
111
|
+
* file, or move the journal entries to another page, first." Delete… on the page says it instead of asking.
|
|
112
|
+
*/
|
|
113
|
+
export function holdsMessage(page: string, collection: string): string {
|
|
114
|
+
const noun = documentsNoun(collection);
|
|
115
|
+
return `${page} holds the ${noun}. Delete the “holds” line in its file, or move the ${noun} to another page, first.`;
|
|
116
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -58,7 +58,8 @@ import { lockupCss } from './lockup-css.ts';
|
|
|
58
58
|
import { shareBuildReads } from './read.ts';
|
|
59
59
|
import { writeSchemaFiles } from './cli/schemas.ts';
|
|
60
60
|
import { configFile } from './cli/site.ts';
|
|
61
|
-
import {
|
|
61
|
+
import { pagesRoutes } from './code-routes-disk.ts';
|
|
62
|
+
import { codeRoutesFrom } from './code-routes.ts';
|
|
62
63
|
import { GUARD_MIDDLEWARE, LOGIN_ROUTES } from './live/auth/routes.ts';
|
|
63
64
|
import { HOST_COMMIT_VARIABLES } from './live/mode.ts';
|
|
64
65
|
|
|
@@ -124,6 +125,8 @@ export const EDITOR_ROUTES = [
|
|
|
124
125
|
{ pattern: '/_caracms/delete', entrypoint: 'editor/routes/delete.ts' },
|
|
125
126
|
// Pre-M7 item 8: changing a document's address, both modes.
|
|
126
127
|
{ pattern: '/_caracms/move', entrypoint: 'editor/routes/move.ts' },
|
|
128
|
+
// Arrange pages: the arrange screen's Apply, both modes.
|
|
129
|
+
{ pattern: '/_caracms/arrange', entrypoint: 'editor/routes/arrange.ts' },
|
|
127
130
|
// M5b Phase 2: the media library, uploads and Mux, both modes (types.ts → MEDIA_ENDPOINTS).
|
|
128
131
|
{ pattern: '/_caracms/media/[...path]', entrypoint: 'editor/routes/media.ts' },
|
|
129
132
|
// M4 Decision 14: GitHub's push webhook (signature-checked; 404 with no secret).
|
|
@@ -352,7 +355,8 @@ export default function caracms(config: SiteConfig): AstroIntegration {
|
|
|
352
355
|
const configName = configFile(site.root);
|
|
353
356
|
if (configName) addWatchFile(join(site.root, configName));
|
|
354
357
|
// M8 Decision 10: the site's own routes, read once here, for the editor's server and browser alike.
|
|
355
|
-
site.
|
|
358
|
+
site.pageRoutes = pagesRoutes(site.root, astroConfig.srcDir ? fileURLToPath(astroConfig.srcDir) : 'src');
|
|
359
|
+
site.codeRoutes = codeRoutesFrom(site.pageRoutes, Object.values(site.collections));
|
|
356
360
|
// M5b Decision 5: local dev (and a local build) read the media variables from the site's
|
|
357
361
|
// `.env`, which Astro doesn't put in process.env. Only those names; a set value wins.
|
|
358
362
|
const siteEnv = await loadSiteEnv(command === 'dev' ? 'development' : 'production', site.root);
|
package/src/live/README.md
CHANGED
|
@@ -7,7 +7,7 @@ live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on
|
|
|
7
7
|
| File | What |
|
|
8
8
|
|---|---|
|
|
9
9
|
| `mode.ts` | `readMode(env, production)` → `local`, `live` (with `LiveSettings`) or `unconfigured` (`missing`, `problems`); `unconfiguredMessage()` for the 503 |
|
|
10
|
-
| `runtime.ts` | `liveRuntime(site, settings)`: one per process, lazy. `createLiveRuntime({...})` for tests (inject backend, Redis, clock). `CARACMS_GITHUB=memory`: a MemoryBackend seeded from disk (`siteFiles`), refused in production |
|
|
10
|
+
| `runtime.ts` | `liveRuntime(site, settings)`: one per process, lazy. With a cloud key it reads the repo from the cloud and takes its first token before anything else, so a refusal stops the start (the guard answers 503 with the cloud's sentence; the next request tries again). `createLiveRuntime({...})` for tests (inject backend, Redis, clock). `CARACMS_GITHUB=memory`: a MemoryBackend seeded from disk (`siteFiles`), refused in production |
|
|
11
11
|
| `git.ts` | `LiveGit`: the `drafts` copy (`files()`, `read()`, `contentSource()`), `commitDrafts()`, `check()`, `publish()` and its `commitPending` hook, `waiting()`, `publishStatus()`, `history()`, `tryAgain()`. M5a: a draft entry with `text` null removes the file; "Contact (deleted)" in the headlines; `mainHas`, `changeOf` (new, deleted or changed), `titleFor` |
|
|
12
12
|
| `timings.ts` | `LiveTimings` and `DEFAULT_TIMINGS`: every timing, Phase 2's sweep and lock ones included |
|
|
13
13
|
| `merge.ts` | `mergeFile({ base, draft, other })`: the per-key three-way merge, pure and byte-preserving |
|
|
@@ -18,7 +18,8 @@ live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on
|
|
|
18
18
|
| `notices.ts` | Redis diet. `Notices`: "published as it stood" notices and dismissed "draft kept" ones, written through the status cache |
|
|
19
19
|
| `backend.ts` | `GitBackend`; `GitHubBackend`, `MemoryBackend` (test helpers: `seed`, `push`, `file`, `deployments`, `beforeRequest`); `StaleHeadError` |
|
|
20
20
|
| `branches.ts` | `Branch` (a role: `main` or `drafts`), `BranchNames` (the refs behind them), `DEFAULT_BRANCHES`. `CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH` (read by `mode.ts`, M9 Decision 6) name the branch Publish commits to and the one autosaves land on; unset, `main` and `drafts`. They're set together or not at all: one alone is a problem, so the mode is unconfigured (`/admin` answers 503 saying both must be set; the public site still serves). Only the backends turn a role into a ref name, so with both set a branch called `main` is never read or moved. `MemoryBackend.refs` is keyed by name |
|
|
21
|
-
| `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts) |
|
|
21
|
+
| `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts). A third credential, `cloud`: the token from CaraCMS Cloud, refreshed the same way; its refusals are an access problem of kind `cloud` |
|
|
22
|
+
| `cloud.ts` | CaraCMS Cloud (`CARACMS_CLOUD_KEY`; caracms-cloud M2): `DEFAULT_CLOUD_URL`, `cloudSettings()` (the repo, at start), `cloudToken()`, `CloudRefusal` (the cloud's `error`, `message`, `installUrl`), and `cloudUnavailable()`, the sign-in guard's 503 for a refusal at start |
|
|
22
23
|
| `webhook.ts` | `handleGithubWebhook()`, behind `POST /_caracms/github` (`../editor/routes/github.ts`) |
|
|
23
24
|
| `autosaves.ts` | Phase 2. Autosaves in Redis (`caracms:autosave:<file>`, a week; stored compare-and-set, ordered per tab by `X-Caracms-Seq`), the session's last commit (the merge's ancestor) and who last changed a document (Decision 11; no expiry, and `known: false` when neither Redis nor git can say). M5a: a new document's autosave carries `created: { over }` (its ancestor until committed), a deletion is a "gone" autosave (`text` and `document` null, `gone: { title }`) |
|
|
24
25
|
| `locks.ts` | Phase 2. `Locks`: taken by the first write, heartbeat on the poll (Redis diet: only when `needsBeat`), lapse only once committed, releasing, Edit here; presence, each record holding for twice its tab's poll interval plus 3 s |
|
|
@@ -14,11 +14,18 @@
|
|
|
14
14
|
* answering 503 here until the GitHub App's variables were set). A GET to it is as
|
|
15
15
|
* unavailable as any other guarded path, as live mode's let-through is POST-only too.
|
|
16
16
|
*
|
|
17
|
+
* - **A start that fails** answers 503 and the next request tries again (runtime.ts). When
|
|
18
|
+
* CaraCMS Cloud refused the start or couldn't be reached (caracms-cloud M2 Decision 14), the
|
|
19
|
+
* answer is the cloud's own sentence, as an HTML page with its install link as a link
|
|
20
|
+
* (JSON under `/_caracms/`, as ../../editor/middleware.ts answers); any other failure keeps
|
|
21
|
+
* the plain sentence. Neither the log line nor the page carries the site key or a token.
|
|
22
|
+
*
|
|
17
23
|
* The mode is Phase 2's `editorMode()`, so the two agree on live and local.
|
|
18
24
|
*/
|
|
19
25
|
import type { MiddlewareHandler } from 'astro';
|
|
20
26
|
import { site } from 'virtual:caracms/config';
|
|
21
27
|
import { editorMode } from '../../editor/server/mode.ts';
|
|
28
|
+
import { CloudRefusal, cloudUnavailable } from '../cloud.ts';
|
|
22
29
|
import { setPerson } from '../locals.ts';
|
|
23
30
|
import { guard, isFormEndpoint, isGuardedPath } from './guard.ts';
|
|
24
31
|
import { authFor } from './index.ts';
|
|
@@ -35,6 +42,10 @@ export const onRequest: MiddlewareHandler = async (context, next) => {
|
|
|
35
42
|
try {
|
|
36
43
|
auth = await authFor(site, mode.settings, context.site);
|
|
37
44
|
} catch (error) {
|
|
45
|
+
if (error instanceof CloudRefusal) {
|
|
46
|
+
console.error(`[caracms] sign-in: the live runtime could not start: CaraCMS Cloud (${error.error}): ${error.message}`);
|
|
47
|
+
return cloudUnavailable(error, context.url.pathname.startsWith('/_caracms/'));
|
|
48
|
+
}
|
|
38
49
|
console.error('[caracms] sign-in: the live runtime could not start:', error);
|
|
39
50
|
return unavailable("The editor couldn't start. Try again in a moment.");
|
|
40
51
|
}
|