caracms 0.1.0 → 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/bin/init.mjs +1 -0
- package/bin/templates/catch-all.astro.txt +3 -2
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +12 -0
- package/skills/caracms-adopt/look-same.mjs +16 -7
- package/skills/caracms-blocks/SKILL.md +49 -2
- package/skills/caracms-content/SKILL.md +2 -1
- 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/cli/validate.ts +10 -5
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +20 -1
- package/src/components/Form.astro +76 -10
- package/src/components/Head.astro +1 -0
- package/src/components/Media.astro +27 -1
- package/src/components/RichText.astro +12 -4
- package/src/components/Text.astro +32 -9
- package/src/components/form-client.ts +7 -4
- package/src/components/sitemap.xml.ts +2 -1
- package/src/config.ts +32 -1
- package/src/content-schema.ts +7 -4
- package/src/content.ts +26 -0
- package/src/document.ts +15 -2
- package/src/editor/client/block-list/summary.ts +7 -1
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +17 -12
- package/src/editor/client/fields/ReferenceField.tsx +6 -0
- package/src/editor/client/fields/RichTextField.tsx +38 -32
- package/src/editor/client/fields/host.tsx +5 -0
- package/src/editor/client/fields/parts.tsx +2 -1
- package/src/editor/client/fields/rich-text-doc.ts +28 -2
- package/src/editor/client/fields/rich-text-schema.ts +41 -2
- package/src/editor/client/nav/DocRow.tsx +6 -0
- package/src/editor/client/overlays/rich-text-bar.ts +6 -6
- package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
- package/src/editor/client/preview/driver.ts +5 -5
- package/src/editor/client/preview/field-text.ts +40 -2
- package/src/editor/client/preview/page-edit.ts +2 -2
- package/src/editor/client/preview/page-editor-registry.ts +4 -3
- package/src/editor/client/preview/page-editor.ts +3 -3
- package/src/editor/client/state/document-store.ts +20 -2
- package/src/editor/styles/editor.css +2 -0
- package/src/editor/types.ts +2 -0
- package/src/fields.ts +63 -15
- package/src/forms/rules.ts +9 -1
- package/src/head.ts +7 -3
- package/src/links.ts +35 -0
- package/src/live/README.md +1 -0
- package/src/live/backend.ts +41 -23
- package/src/live/branches.ts +19 -0
- package/src/live/mode.ts +46 -0
- package/src/live/runtime.ts +5 -2
- package/src/media.ts +14 -6
- package/src/read.ts +2 -1
- package/src/rich-text.ts +30 -5
- package/src/serialize.ts +10 -1
package/src/live/backend.ts
CHANGED
|
@@ -26,7 +26,10 @@ export interface Heads {
|
|
|
26
26
|
drafts: Head;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
import type { Branch, BranchNames } from './branches.ts';
|
|
30
|
+
import { DEFAULT_BRANCHES } from './branches.ts';
|
|
31
|
+
|
|
32
|
+
export { DEFAULT_BRANCHES, type Branch, type BranchNames };
|
|
30
33
|
|
|
31
34
|
export interface CommitSummary {
|
|
32
35
|
sha: string;
|
|
@@ -111,6 +114,8 @@ export class MissingBranchError extends Error {
|
|
|
111
114
|
export interface GitBackend {
|
|
112
115
|
readonly tracker: Tracker;
|
|
113
116
|
readonly name: 'github' | 'memory';
|
|
117
|
+
/** The ref name behind each role. */
|
|
118
|
+
readonly branches: Readonly<BranchNames>;
|
|
114
119
|
/** Both heads, in one request. Throws MissingBranchError. */
|
|
115
120
|
readHeads(): Promise<Heads>;
|
|
116
121
|
createBranch(branch: Branch, sha: string): Promise<void>;
|
|
@@ -144,11 +149,11 @@ export function isStaleGraphQL(errors: { type?: string; message?: string }[]): b
|
|
|
144
149
|
|
|
145
150
|
// ── GitHub ────────────────────────────────────────────────────────────────────
|
|
146
151
|
|
|
147
|
-
const HEADS_QUERY = `query($owner: String!, $name: String!) {
|
|
152
|
+
const HEADS_QUERY = `query($owner: String!, $name: String!, $main: String!, $drafts: String!) {
|
|
148
153
|
repository(owner: $owner, name: $name) {
|
|
149
154
|
id
|
|
150
|
-
main: ref(qualifiedName:
|
|
151
|
-
drafts: ref(qualifiedName:
|
|
155
|
+
main: ref(qualifiedName: $main) { target { oid ... on Commit { tree { oid } } } }
|
|
156
|
+
drafts: ref(qualifiedName: $drafts) { target { oid ... on Commit { tree { oid } } } }
|
|
152
157
|
}
|
|
153
158
|
}`;
|
|
154
159
|
|
|
@@ -168,10 +173,16 @@ const COMPARE_FILE_CAP = 300;
|
|
|
168
173
|
export class GitHubBackend implements GitBackend {
|
|
169
174
|
readonly name = 'github';
|
|
170
175
|
readonly gh: GitHubClient;
|
|
176
|
+
readonly branches: Readonly<BranchNames>;
|
|
171
177
|
private repoId = '';
|
|
172
178
|
|
|
173
|
-
constructor(gh: GitHubClient) {
|
|
179
|
+
constructor(gh: GitHubClient, branches: Readonly<BranchNames> = DEFAULT_BRANCHES) {
|
|
174
180
|
this.gh = gh;
|
|
181
|
+
this.branches = branches;
|
|
182
|
+
}
|
|
183
|
+
/** `refs/heads/<name>` for a role. */
|
|
184
|
+
private qualified(branch: Branch) {
|
|
185
|
+
return `refs/heads/${this.branches[branch]}`;
|
|
175
186
|
}
|
|
176
187
|
get tracker() {
|
|
177
188
|
return this.gh.tracker;
|
|
@@ -180,7 +191,7 @@ export class GitHubBackend implements GitBackend {
|
|
|
180
191
|
async readHeads(): Promise<Heads> {
|
|
181
192
|
const d = await this.gh.graphql<{ repository: { id: string; main: RefNode; drafts: RefNode } }>(
|
|
182
193
|
HEADS_QUERY,
|
|
183
|
-
{ owner: this.gh.owner, name: this.gh.repo },
|
|
194
|
+
{ owner: this.gh.owner, name: this.gh.repo, main: this.qualified('main'), drafts: this.qualified('drafts') },
|
|
184
195
|
'readHeads',
|
|
185
196
|
);
|
|
186
197
|
this.repoId = d.repository.id;
|
|
@@ -192,7 +203,7 @@ export class GitHubBackend implements GitBackend {
|
|
|
192
203
|
}
|
|
193
204
|
|
|
194
205
|
async createBranch(branch: Branch, sha: string) {
|
|
195
|
-
await this.gh.rest('POST', this.gh.repoPath('/git/refs'), { ref:
|
|
206
|
+
await this.gh.rest('POST', this.gh.repoPath('/git/refs'), { ref: this.qualified(branch), sha });
|
|
196
207
|
}
|
|
197
208
|
|
|
198
209
|
async readArchive(commit: string, keep: (path: string) => boolean) {
|
|
@@ -253,7 +264,7 @@ export class GitHubBackend implements GitBackend {
|
|
|
253
264
|
COMMIT_ON_BRANCH,
|
|
254
265
|
{
|
|
255
266
|
input: {
|
|
256
|
-
branch: { repositoryNameWithOwner: this.gh.nameWithOwner, branchName: branch },
|
|
267
|
+
branch: { repositoryNameWithOwner: this.gh.nameWithOwner, branchName: this.branches[branch] },
|
|
257
268
|
expectedHeadOid: expectedHead,
|
|
258
269
|
message: { headline, body },
|
|
259
270
|
fileChanges: {
|
|
@@ -295,7 +306,7 @@ export class GitHubBackend implements GitBackend {
|
|
|
295
306
|
{
|
|
296
307
|
input: {
|
|
297
308
|
repositoryId: this.repoId,
|
|
298
|
-
refUpdates: updates.map((u) => ({ name:
|
|
309
|
+
refUpdates: updates.map((u) => ({ name: this.qualified(u.branch), beforeOid: u.before, afterOid: u.after, force: u.force })),
|
|
299
310
|
},
|
|
300
311
|
},
|
|
301
312
|
'updateRefs',
|
|
@@ -309,7 +320,7 @@ export class GitHubBackend implements GitBackend {
|
|
|
309
320
|
async listCommits(branch: Branch, count: number) {
|
|
310
321
|
const list = await this.gh.rest<{ sha: string; parents: { sha: string }[]; commit: { message: string; committer: { date: string } } }[]>(
|
|
311
322
|
'GET',
|
|
312
|
-
this.gh.repoPath(`/commits?sha=${branch}&per_page=${Math.min(100, count)}`),
|
|
323
|
+
this.gh.repoPath(`/commits?sha=${encodeURIComponent(this.branches[branch])}&per_page=${Math.min(100, count)}`),
|
|
313
324
|
);
|
|
314
325
|
return list.map((k) => ({ sha: k.sha, parents: k.parents.map((p) => p.sha), message: k.commit.message, date: k.commit.committer.date }));
|
|
315
326
|
}
|
|
@@ -395,6 +406,7 @@ export const blobSha = (contents: string) => {
|
|
|
395
406
|
export class MemoryBackend implements GitBackend {
|
|
396
407
|
readonly name = 'memory';
|
|
397
408
|
readonly tracker: Tracker;
|
|
409
|
+
readonly branches: Readonly<BranchNames>;
|
|
398
410
|
/** Deployments, newest last; tests push to it. */
|
|
399
411
|
deployments: MemoryDeployment[] = [];
|
|
400
412
|
/** Called before each recorded request, with its endpoint: tests inject races here. */
|
|
@@ -404,12 +416,18 @@ export class MemoryBackend implements GitBackend {
|
|
|
404
416
|
private blobs = new Map<string, string>();
|
|
405
417
|
private trees = new Map<string, MemTree>();
|
|
406
418
|
private commits = new Map<string, MemCommit>();
|
|
407
|
-
|
|
419
|
+
/** Every ref, by its name (not its role): a test can seed a branch caracms mustn't touch. */
|
|
420
|
+
refs = new Map<string, string>();
|
|
408
421
|
private n = 0;
|
|
409
422
|
|
|
410
|
-
constructor(tracker = new Tracker(), options: { now?: () => number } = {}) {
|
|
423
|
+
constructor(tracker = new Tracker(), options: { now?: () => number; branches?: Readonly<BranchNames> } = {}) {
|
|
411
424
|
this.tracker = tracker;
|
|
412
425
|
this.now = options.now ?? Date.now;
|
|
426
|
+
this.branches = options.branches ?? DEFAULT_BRANCHES;
|
|
427
|
+
}
|
|
428
|
+
/** The ref name behind a role. */
|
|
429
|
+
private ref(branch: Branch) {
|
|
430
|
+
return this.branches[branch];
|
|
413
431
|
}
|
|
414
432
|
|
|
415
433
|
private async rec(endpoint: string, creating: boolean, status = 200) {
|
|
@@ -464,7 +482,7 @@ export class MemoryBackend implements GitBackend {
|
|
|
464
482
|
return c;
|
|
465
483
|
}
|
|
466
484
|
private head(branch: Branch): Head {
|
|
467
|
-
const c = this.refs.get(branch);
|
|
485
|
+
const c = this.refs.get(this.ref(branch));
|
|
468
486
|
if (!c) throw new MissingBranchError(branch);
|
|
469
487
|
return { commit: c, tree: this.commit(c).tree };
|
|
470
488
|
}
|
|
@@ -479,12 +497,12 @@ export class MemoryBackend implements GitBackend {
|
|
|
479
497
|
|
|
480
498
|
// Test helpers (not part of GitBackend; record no requests).
|
|
481
499
|
|
|
482
|
-
/** Start the repo: one parentless commit with these files on
|
|
500
|
+
/** Start the repo: one parentless commit with these files on the main branch (and the drafts branch, if asked). */
|
|
483
501
|
seed(files: FileWrite[], options: { drafts?: boolean } = {}): string {
|
|
484
502
|
const tree = this.putTree(new Map(files.map((f) => [f.path, { sha: this.blob(f.contents), mode: '100644' }])));
|
|
485
503
|
const seed = this.putCommit(tree, [], 'Seed');
|
|
486
|
-
this.refs.set('main', seed);
|
|
487
|
-
if (options.drafts) this.refs.set('drafts', seed);
|
|
504
|
+
this.refs.set(this.ref('main'), seed);
|
|
505
|
+
if (options.drafts) this.refs.set(this.ref('drafts'), seed);
|
|
488
506
|
return seed;
|
|
489
507
|
}
|
|
490
508
|
|
|
@@ -497,7 +515,7 @@ export class MemoryBackend implements GitBackend {
|
|
|
497
515
|
else t.set(path, { sha: this.blob(contents), mode: '100644' });
|
|
498
516
|
}
|
|
499
517
|
const c = this.putCommit(this.putTree(t), [head.commit], message);
|
|
500
|
-
this.refs.set(branch, c);
|
|
518
|
+
this.refs.set(this.ref(branch), c);
|
|
501
519
|
return c;
|
|
502
520
|
}
|
|
503
521
|
|
|
@@ -530,9 +548,9 @@ export class MemoryBackend implements GitBackend {
|
|
|
530
548
|
|
|
531
549
|
async createBranch(branch: Branch, sha: string) {
|
|
532
550
|
await this.rec('POST /git/refs', true);
|
|
533
|
-
if (this.refs.has(branch)) throw new HttpError(422, 'Reference already exists', null);
|
|
551
|
+
if (this.refs.has(this.ref(branch))) throw new HttpError(422, 'Reference already exists', null);
|
|
534
552
|
this.commit(sha);
|
|
535
|
-
this.refs.set(branch, sha);
|
|
553
|
+
this.refs.set(this.ref(branch), sha);
|
|
536
554
|
}
|
|
537
555
|
|
|
538
556
|
async readArchive(commit: string, keep: (path: string) => boolean) {
|
|
@@ -574,14 +592,14 @@ export class MemoryBackend implements GitBackend {
|
|
|
574
592
|
|
|
575
593
|
async commitOnBranch(branch: Branch, expectedHead: string, additions: FileWrite[], deletions: string[], headline: string, body: string) {
|
|
576
594
|
await this.rec('graphql createCommitOnBranch', true);
|
|
577
|
-
const cur = this.refs.get(branch);
|
|
595
|
+
const cur = this.refs.get(this.ref(branch));
|
|
578
596
|
if (cur !== expectedHead) throw new StaleHeadError(`Expected branch to point to "${expectedHead}" but it did not. Pull and try again.`);
|
|
579
597
|
const t = new Map(this.trees.get(this.commit(cur).tree)!);
|
|
580
598
|
for (const f of additions) t.set(f.path, { sha: this.blob(f.contents), mode: '100644' });
|
|
581
599
|
for (const p of deletions) t.delete(p);
|
|
582
600
|
const tree = this.putTree(t);
|
|
583
601
|
const c = this.putCommit(tree, [cur], body ? `${headline}\n\n${body}` : headline);
|
|
584
|
-
this.refs.set(branch, c);
|
|
602
|
+
this.refs.set(this.ref(branch), c);
|
|
585
603
|
return { commit: c, tree };
|
|
586
604
|
}
|
|
587
605
|
|
|
@@ -606,10 +624,10 @@ export class MemoryBackend implements GitBackend {
|
|
|
606
624
|
await this.rec('graphql updateRefs', true);
|
|
607
625
|
for (const u of updates) {
|
|
608
626
|
// GitHub's answer here is a generic error, not a typed one (M1 → G6 (a)).
|
|
609
|
-
if (this.refs.get(u.branch) !== u.before) throw new GraphQLError([{ message: 'Something went wrong while executing your query.' }]);
|
|
627
|
+
if (this.refs.get(this.ref(u.branch)) !== u.before) throw new GraphQLError([{ message: 'Something went wrong while executing your query.' }]);
|
|
610
628
|
if (!u.force && !this.isAncestor(u.before, u.after)) throw new GraphQLError([{ message: 'Something went wrong while executing your query.' }]);
|
|
611
629
|
}
|
|
612
|
-
for (const u of updates) this.refs.set(u.branch, u.after); // all or nothing
|
|
630
|
+
for (const u of updates) this.refs.set(this.ref(u.branch), u.after); // all or nothing
|
|
613
631
|
}
|
|
614
632
|
|
|
615
633
|
async listCommits(branch: Branch, count: number) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ref names behind each branch role (M9 Decision 6). `main` is what's published
|
|
3
|
+
* (and deployed); `drafts` is where autosaves land. `CARACMS_MAIN_BRANCH` and
|
|
4
|
+
* `CARACMS_DRAFTS_BRANCH` name them (mode.ts), defaulting to `main` and `drafts`.
|
|
5
|
+
* With both named, a branch called `main` is never read or moved.
|
|
6
|
+
*
|
|
7
|
+
* Only the backends (backend.ts) turn a role into a name; everything above them speaks
|
|
8
|
+
* in roles. No imports here, so mode.ts can read it without the backends.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A branch's role, not its name. */
|
|
12
|
+
export type Branch = 'main' | 'drafts';
|
|
13
|
+
|
|
14
|
+
export interface BranchNames {
|
|
15
|
+
main: string;
|
|
16
|
+
drafts: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_BRANCHES: Readonly<BranchNames> = Object.freeze({ main: 'main', drafts: 'drafts' });
|
package/src/live/mode.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* This only reads; it doesn't wire anything (Phase 2 switches the routes on it,
|
|
16
16
|
* Phase 3 guards with it).
|
|
17
17
|
*/
|
|
18
|
+
import { DEFAULT_BRANCHES, type BranchNames } from './branches.ts';
|
|
18
19
|
import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
|
|
19
20
|
|
|
20
21
|
export interface LiveSettings {
|
|
@@ -38,6 +39,12 @@ export interface LiveSettings {
|
|
|
38
39
|
runningCommit: string | null;
|
|
39
40
|
/** Dev only: CARACMS_TIMINGS, a JSON object of LiveTimings to shorten (Decision 2). */
|
|
40
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;
|
|
41
48
|
production: boolean;
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -97,6 +104,8 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
|
|
|
97
104
|
else timings = { ...timings, ...parseTimings(rawTimings, problems) };
|
|
98
105
|
}
|
|
99
106
|
|
|
107
|
+
const branches = readBranches(get, problems);
|
|
108
|
+
|
|
100
109
|
if (missing.length || problems.length) return { kind: 'unconfigured', missing, problems };
|
|
101
110
|
return {
|
|
102
111
|
kind: 'live',
|
|
@@ -113,11 +122,48 @@ export function readMode(env: Env = process.env, production = env.NODE_ENV === '
|
|
|
113
122
|
backup: user && password ? { user, password } : null,
|
|
114
123
|
runningCommit: get('CARACMS_RUNNING_COMMIT') ?? get('RAILWAY_GIT_COMMIT_SHA'),
|
|
115
124
|
timings,
|
|
125
|
+
branches,
|
|
116
126
|
production,
|
|
117
127
|
},
|
|
118
128
|
};
|
|
119
129
|
}
|
|
120
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
|
+
|
|
121
167
|
/** The repo: GITHUB_REPO overrides Railway's own variables (Decision 3). */
|
|
122
168
|
function readRepo(env: Env, problems: string[]): { owner: string; repo: string } | null {
|
|
123
169
|
const override = env.GITHUB_REPO?.trim();
|
package/src/live/runtime.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
19
19
|
import { join, relative, sep } from 'node:path';
|
|
20
20
|
import { repoPrefix, type ResolvedSite } from '../config.ts';
|
|
21
21
|
import { GitHubBackend, MemoryBackend, type FileWrite, type GitBackend } from './backend.ts';
|
|
22
|
+
import { DEFAULT_BRANCHES } from './branches.ts';
|
|
22
23
|
import { GitHubClient, Tracker } from './github.ts';
|
|
23
24
|
import { LiveGit, SITE_SETTINGS_FILE } from './git.ts';
|
|
24
25
|
import type { LiveSettings } from './mode.ts';
|
|
@@ -62,16 +63,18 @@ export async function createLiveRuntime(options: RuntimeOptions): Promise<LiveRu
|
|
|
62
63
|
if (settings.production || process.env.NODE_ENV === 'production') {
|
|
63
64
|
throw new Error('caracms: the in-memory GitHub (CARACMS_GITHUB=memory) is refused in production.');
|
|
64
65
|
}
|
|
65
|
-
const memory = new MemoryBackend(new Tracker(now), { now });
|
|
66
|
+
const memory = new MemoryBackend(new Tracker(now), { now, branches: settings.branches });
|
|
66
67
|
memory.seed(await siteFiles(site));
|
|
67
68
|
log('live mode on the in-memory GitHub, seeded from the site\'s files on disk (CARACMS_GITHUB=memory)');
|
|
68
69
|
backend = memory;
|
|
69
70
|
} else {
|
|
70
71
|
const tracker = new Tracker(now);
|
|
71
72
|
const { appId, privateKey, owner, repo } = settings.github;
|
|
72
|
-
backend = new GitHubBackend(new GitHubClient({ appId, privateKey, owner, repo, now }, tracker));
|
|
73
|
+
backend = new GitHubBackend(new GitHubClient({ appId, privateKey, owner, repo, now }, tracker), settings.branches);
|
|
73
74
|
}
|
|
74
75
|
}
|
|
76
|
+
const { main, drafts } = backend.branches;
|
|
77
|
+
if (main !== DEFAULT_BRANCHES.main || drafts !== DEFAULT_BRANCHES.drafts) log(`publishing to ${main}, autosaving to ${drafts} (CARACMS_MAIN_BRANCH, CARACMS_DRAFTS_BRANCH)`);
|
|
75
78
|
const redis = options.redis ?? (settings.redisUrl ? connectRedis(settings.redisUrl, { log }) : memoryRedis({ now }));
|
|
76
79
|
if (!options.redis && !settings.redisUrl) log('no REDIS_URL: using the in-memory Redis stand-in (dev only)');
|
|
77
80
|
const git = new LiveGit({
|
package/src/media.ts
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* is refused (403).
|
|
13
13
|
* - A gravity goes only on a `fill` (a `fit` crops nothing; a gravity there would only
|
|
14
14
|
* make a second URL for the same file). Quality 80. WebP, always in the URL (never
|
|
15
|
-
* imgproxy's auto-format: Railway's CDN ignores `Vary: Accept`)
|
|
15
|
+
* imgproxy's auto-format: Railway's CDN ignores `Vary: Accept`); `@png` when the
|
|
16
|
+
* media field says `format: 'png'` (M9 Decision 8).
|
|
16
17
|
* - Sides are rounded and capped at 4000 (imgproxy's `IMGPROXY_MAX_RESULT_DIMENSION`).
|
|
17
18
|
* - GIFs and SVGs pass through: `/<sig>/plain/s3://<bucket>/<key>`, no resize, no
|
|
18
19
|
* format, still signed (a GIF resize drops its animation; an SVG is sharp at any size).
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
* fixed test key and salt and a test bucket, and is refused on a production server.
|
|
24
25
|
*/
|
|
25
26
|
import { createHmac } from 'node:crypto';
|
|
27
|
+
import type { ImageFormat } from './fields.ts';
|
|
26
28
|
import type { RenderImage } from './media-value.ts';
|
|
27
29
|
|
|
28
30
|
/** The srcset width ladder every fluid image draws from (Decision 2). 1920 at the top: imgproxy doesn't upscale. */
|
|
@@ -30,8 +32,8 @@ export const IMAGE_WIDTHS: readonly number[] = [160, 320, 480, 640, 960, 1280, 1
|
|
|
30
32
|
/** imgproxy's IMGPROXY_MAX_RESULT_DIMENSION: a side is never asked for above it. */
|
|
31
33
|
export const IMAGE_MAX_DIMENSION = 4000;
|
|
32
34
|
export const IMAGE_QUALITY = 80;
|
|
33
|
-
/** Pinned in every resized URL. */
|
|
34
|
-
export const IMAGE_FORMAT = 'webp';
|
|
35
|
+
/** Pinned in every resized URL, unless the media field asks for PNG (M9 Decision 8). */
|
|
36
|
+
export const IMAGE_FORMAT: ImageFormat = 'webp';
|
|
35
37
|
/** Fixed mode's densities (Decision 4: 1×, 2×, 3×). */
|
|
36
38
|
export const IMAGE_DENSITIES: readonly number[] = [1, 2, 3];
|
|
37
39
|
|
|
@@ -141,6 +143,8 @@ export interface ImageUrlOptions {
|
|
|
141
143
|
resize?: 'fit' | 'fill';
|
|
142
144
|
/** The focal point, fractions; used only on a `fill`. */
|
|
143
145
|
focal?: readonly [number, number] | null;
|
|
146
|
+
/** Default: WebP (M9 Decision 8). */
|
|
147
|
+
format?: ImageFormat;
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
/**
|
|
@@ -154,7 +158,7 @@ export function imageUrl(settings: MediaSettings, key: string, options: ImageUrl
|
|
|
154
158
|
if (!width && !height) throw new Error(`An image URL needs a width or a height (${key}).`);
|
|
155
159
|
const resize = options.resize ?? (width && height ? 'fill' : 'fit');
|
|
156
160
|
const gravity = resize === 'fill' && options.focal ? `/g:fp:${options.focal[0]}:${options.focal[1]}` : '';
|
|
157
|
-
return signed(settings, `/rs:${resize}:${width}:${height}${gravity}/q:${IMAGE_QUALITY}/plain/${sourceOf(settings, key)}@${IMAGE_FORMAT}`);
|
|
161
|
+
return signed(settings, `/rs:${resize}:${width}:${height}${gravity}/q:${IMAGE_QUALITY}/plain/${sourceOf(settings, key)}@${options.format ?? IMAGE_FORMAT}`);
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
/** A slot's part of the ladder: every rung from `min` to `max`, inclusive. */
|
|
@@ -184,6 +188,7 @@ export interface FluidOptions {
|
|
|
184
188
|
ratio?: number;
|
|
185
189
|
minWidth?: number;
|
|
186
190
|
maxWidth?: number;
|
|
191
|
+
format?: ImageFormat;
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
export interface FixedOptions {
|
|
@@ -192,6 +197,7 @@ export interface FixedOptions {
|
|
|
192
197
|
height?: number;
|
|
193
198
|
fit?: 'cover' | 'contain';
|
|
194
199
|
densities?: readonly number[];
|
|
200
|
+
format?: ImageFormat;
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
const naturalHeight = (image: Pick<RenderImage, 'w' | 'h'>, width: number) => (image.w && image.h ? Math.round((width * image.h) / image.w) : undefined);
|
|
@@ -213,7 +219,9 @@ export function fluidImage(settings: MediaSettings, image: Pick<RenderImage, 'ke
|
|
|
213
219
|
};
|
|
214
220
|
if (!isResizable(image.key)) return { src: imageUrl(settings, image.key), ...dims(image.w ?? undefined) };
|
|
215
221
|
const at = (w: number) =>
|
|
216
|
-
options.ratio
|
|
222
|
+
options.ratio
|
|
223
|
+
? imageUrl(settings, image.key, { width: w, height: Math.round(w / options.ratio), focal: image.focal, format: options.format })
|
|
224
|
+
: imageUrl(settings, image.key, { width: w, format: options.format });
|
|
217
225
|
return {
|
|
218
226
|
src: at(top),
|
|
219
227
|
srcset: widths.map((w) => `${at(w)} ${w}w`).join(', '),
|
|
@@ -231,7 +239,7 @@ export function fixedImage(settings: MediaSettings, image: Pick<RenderImage, 'ke
|
|
|
231
239
|
if (!isResizable(image.key)) return { src: imageUrl(settings, image.key), ...dims };
|
|
232
240
|
const both = !!width && !!height;
|
|
233
241
|
const resize = both && options.fit !== 'contain' ? 'fill' : 'fit';
|
|
234
|
-
const at = (d: number) => imageUrl(settings, image.key, { width: width ? width * d : 0, height: height ? height * d : 0, resize, focal: image.focal });
|
|
242
|
+
const at = (d: number) => imageUrl(settings, image.key, { width: width ? width * d : 0, height: height ? height * d : 0, resize, focal: image.focal, format: options.format });
|
|
235
243
|
const densities = options.densities ?? IMAGE_DENSITIES;
|
|
236
244
|
return { src: at(densities[0] ?? 1), srcset: densities.map((d) => `${at(d)} ${d}x`).join(', '), ...dims };
|
|
237
245
|
}
|
package/src/read.ts
CHANGED
|
@@ -140,7 +140,8 @@ export function documentReader(site: Site, source: ContentSource): DocumentReade
|
|
|
140
140
|
let found = lists.get(collection);
|
|
141
141
|
if (!found) {
|
|
142
142
|
found = listDocuments(site, collection, source).then(async (listed) => {
|
|
143
|
-
|
|
143
|
+
// M9 Decision 7: a hidden page isn't on the site, so a listing of its collection leaves it out.
|
|
144
|
+
const read = await Promise.all(listed.filter((e) => !e.warning && !e.hidden).map((e) => reader.document(collection, e.path)));
|
|
144
145
|
return read.filter((r): r is ReadEntry => r !== null);
|
|
145
146
|
});
|
|
146
147
|
lists.set(collection, found);
|
package/src/rich-text.ts
CHANGED
|
@@ -69,12 +69,19 @@ export interface RichTextOptions {
|
|
|
69
69
|
headings: readonly HeadingKind[];
|
|
70
70
|
/** Whether the field allows links. */
|
|
71
71
|
links: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* M9 Decision 11: a heading rich-text field. Paragraphs and bold only: a list's items
|
|
74
|
+
* read as paragraphs (a `disallowed-kind` issue) and italic is dropped (`italic-off`).
|
|
75
|
+
* Such a field also has no headings and no links.
|
|
76
|
+
*/
|
|
77
|
+
heading?: boolean;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/** Something the reader changed or couldn't read, for the field's warning. `block` is the stored entry's index. */
|
|
75
81
|
export type RichTextIssue =
|
|
76
82
|
| { code: 'disallowed-kind'; block: number; kind: string }
|
|
77
83
|
| { code: 'link-off'; block: number }
|
|
84
|
+
| { code: 'italic-off'; block: number }
|
|
78
85
|
| { code: 'unreadable'; block: number };
|
|
79
86
|
|
|
80
87
|
// ── Runs ──────────────────────────────────────────────────────────────────────
|
|
@@ -480,10 +487,12 @@ const LIST_KINDS = new Set<string>(['ul', 'ol']);
|
|
|
480
487
|
const allowedText = (kind: string, options: RichTextOptions): TextKind =>
|
|
481
488
|
kind === 'p' || ((kind === 'h2' || kind === 'h3') && options.headings.includes(kind)) ? (kind as TextKind) : 'p';
|
|
482
489
|
|
|
483
|
-
/** Read one unit, applying `links` (off: hrefs dropped, with a flag). */
|
|
484
|
-
function unit(runs: TextRun[], options: RichTextOptions): { runs: TextRun[]; droppedLink: boolean } {
|
|
490
|
+
/** Read one unit, applying `links` (off: hrefs dropped, with a flag) and a heading field's no italic (M9 Decision 11). */
|
|
491
|
+
function unit(runs: TextRun[], options: RichTextOptions): { runs: TextRun[]; droppedLink: boolean; droppedItalic: boolean } {
|
|
485
492
|
const droppedLink = !options.links && runs.some((r) => r.href !== undefined);
|
|
486
|
-
|
|
493
|
+
const droppedItalic = !!options.heading && runs.some((r) => r.italic);
|
|
494
|
+
const plain = droppedItalic ? runs.map(({ italic: _, ...r }) => r) : runs;
|
|
495
|
+
return { runs: trimRuns(normaliseRuns(plain, { links: options.links })), droppedLink, droppedItalic };
|
|
487
496
|
}
|
|
488
497
|
|
|
489
498
|
/** A plain string as rich text: its characters literal, one paragraph per blank-line chunk, single newlines as breaks. */
|
|
@@ -550,14 +559,17 @@ export function readRichText(raw: unknown, options: RichTextOptions): { value: R
|
|
|
550
559
|
}
|
|
551
560
|
}
|
|
552
561
|
|
|
553
|
-
if (!list && !(TEXT_KINDS.has(kind) && allowedText(kind, options) === kind)) {
|
|
562
|
+
if ((!list || options.heading) && !(TEXT_KINDS.has(kind) && allowedText(kind, options) === kind)) {
|
|
554
563
|
issues.push({ code: 'disallowed-kind', block: index, kind });
|
|
555
564
|
}
|
|
556
565
|
const read = units.map((u) => unit(u, options));
|
|
557
566
|
if (read.some((u) => u.droppedLink)) issues.push({ code: 'link-off', block: index });
|
|
567
|
+
if (read.some((u) => u.droppedItalic)) issues.push({ code: 'italic-off', block: index });
|
|
558
568
|
const kept = read.map((u) => u.runs).filter((runs) => runs.length > 0);
|
|
559
569
|
if (kept.length === 0) return;
|
|
560
|
-
|
|
570
|
+
// M9 Decision 11: a heading field has no lists; each item is a line of its own.
|
|
571
|
+
if (list && options.heading) for (const runs of kept) value.push({ kind: 'p', runs });
|
|
572
|
+
else if (list) value.push({ kind: kind as ListKind, items: kept });
|
|
561
573
|
else value.push({ kind: allowedText(kind, options), runs: kept[0]! });
|
|
562
574
|
});
|
|
563
575
|
return { value, issues };
|
|
@@ -647,6 +659,19 @@ export function inlineHtml(runs: readonly TextRun[], options: RichTextHtmlOption
|
|
|
647
659
|
.join('');
|
|
648
660
|
}
|
|
649
661
|
|
|
662
|
+
/**
|
|
663
|
+
* A heading rich-text field's value as the inside of its heading tag (M9 Decision 11):
|
|
664
|
+
* each paragraph's inline runs, the paragraphs joined by `<br>`; never a `<p>`.
|
|
665
|
+
*/
|
|
666
|
+
export function richHeadingHtml(value: RichTextValue, options: RichTextHtmlOptions = {}): string {
|
|
667
|
+
return value.flatMap((block) => ('runs' in block ? [block.runs] : block.items)).map((runs) => inlineHtml(runs, options)).join('<br>');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** A value's plain text, one line per paragraph or item (breaks inside one as `\n`): what reads a rich heading as text. */
|
|
671
|
+
export function richTextPlain(value: RichTextValue): string {
|
|
672
|
+
return value.flatMap((block) => ('runs' in block ? [block.runs] : block.items)).map(runsText).join('\n');
|
|
673
|
+
}
|
|
674
|
+
|
|
650
675
|
/**
|
|
651
676
|
* A value as semantic HTML with no classes: `<p>`, `<h2>`, `<h3>`, `<ul>`/`<ol>`
|
|
652
677
|
* with `<li>`. A heading with a break renders as Text's multi-line heading does,
|
package/src/serialize.ts
CHANGED
|
@@ -169,6 +169,8 @@ export function applyEdits(base: string | null, edited: SiteDocument, site: Pick
|
|
|
169
169
|
}
|
|
170
170
|
members.push(['blocks', arrayText(blocks, 1)]);
|
|
171
171
|
if (!isEmptySeo(seo)) members.push(['seo', seoText(seo, 1)]);
|
|
172
|
+
// M9 Decision 7: `hidden` right after `title` (where the Tina script writes it).
|
|
173
|
+
if (edited.hidden) members.splice(1, 0, ['hidden', 'true']);
|
|
172
174
|
return `${objectText(members, 0)}\n`;
|
|
173
175
|
}
|
|
174
176
|
|
|
@@ -222,6 +224,9 @@ export function applyEdits(base: string | null, edited: SiteDocument, site: Pick
|
|
|
222
224
|
if (!same(value, parseField(field, raw[key]))) fieldTexts.set(key, fieldText(field, value, 1));
|
|
223
225
|
}
|
|
224
226
|
}
|
|
227
|
+
// M9 Decision 7: `hidden` per edit: absent, untouched; true adds it after `title`; false takes it out.
|
|
228
|
+
const hiddenEdit = edited.hidden === undefined || edited.hidden === (read.hidden === true) ? null : edited.hidden;
|
|
229
|
+
if (hiddenEdit !== null) fieldTexts.set('hidden', 'true');
|
|
225
230
|
if (!titleChanged && !blocksChanged && !seo && fieldTexts.size === 0) return base;
|
|
226
231
|
|
|
227
232
|
const replacements = new Map<string, string>();
|
|
@@ -231,9 +236,13 @@ export function applyEdits(base: string | null, edited: SiteDocument, site: Pick
|
|
|
231
236
|
|
|
232
237
|
let out = replacements.size ? replaceTopLevel(source, root, replacements) : source.text;
|
|
233
238
|
if (seo && !seo.text) out = removeTopLevel(out, 'seo');
|
|
239
|
+
if (hiddenEdit === false) {
|
|
240
|
+
fieldTexts.delete('hidden');
|
|
241
|
+
out = removeTopLevel(out, 'hidden');
|
|
242
|
+
}
|
|
234
243
|
if (fieldTexts.size) {
|
|
235
244
|
const next = parseSource(out);
|
|
236
|
-
if (next.root.kind === 'object') out = spliceKeys(next, next.root, fieldTexts, ['title', ...Object.keys(fields)]);
|
|
245
|
+
if (next.root.kind === 'object') out = spliceKeys(next, next.root, fieldTexts, ['title', 'hidden', ...Object.keys(fields)]);
|
|
237
246
|
}
|
|
238
247
|
return withTrailingNewline(out);
|
|
239
248
|
}
|