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.
Files changed (58) hide show
  1. package/bin/init.mjs +1 -0
  2. package/bin/templates/catch-all.astro.txt +3 -2
  3. package/package.json +1 -1
  4. package/skills/caracms-adopt/SKILL.md +12 -0
  5. package/skills/caracms-adopt/look-same.mjs +16 -7
  6. package/skills/caracms-blocks/SKILL.md +49 -2
  7. package/skills/caracms-content/SKILL.md +2 -1
  8. package/skills/caracms-tina/SKILL.md +427 -0
  9. package/skills/caracms-tina/field-kinds.md +88 -0
  10. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  11. package/src/block-runs.ts +84 -0
  12. package/src/cli/validate.ts +10 -5
  13. package/src/components/BlockRunTree.astro +45 -0
  14. package/src/components/Blocks.astro +20 -1
  15. package/src/components/Form.astro +76 -10
  16. package/src/components/Head.astro +1 -0
  17. package/src/components/Media.astro +27 -1
  18. package/src/components/RichText.astro +12 -4
  19. package/src/components/Text.astro +32 -9
  20. package/src/components/form-client.ts +7 -4
  21. package/src/components/sitemap.xml.ts +2 -1
  22. package/src/config.ts +32 -1
  23. package/src/content-schema.ts +7 -4
  24. package/src/content.ts +26 -0
  25. package/src/document.ts +15 -2
  26. package/src/editor/client/block-list/summary.ts +7 -1
  27. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  28. package/src/editor/client/fields/LinkField.tsx +17 -12
  29. package/src/editor/client/fields/ReferenceField.tsx +6 -0
  30. package/src/editor/client/fields/RichTextField.tsx +38 -32
  31. package/src/editor/client/fields/host.tsx +5 -0
  32. package/src/editor/client/fields/parts.tsx +2 -1
  33. package/src/editor/client/fields/rich-text-doc.ts +28 -2
  34. package/src/editor/client/fields/rich-text-schema.ts +41 -2
  35. package/src/editor/client/nav/DocRow.tsx +6 -0
  36. package/src/editor/client/overlays/rich-text-bar.ts +6 -6
  37. package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
  38. package/src/editor/client/preview/driver.ts +5 -5
  39. package/src/editor/client/preview/field-text.ts +40 -2
  40. package/src/editor/client/preview/page-edit.ts +2 -2
  41. package/src/editor/client/preview/page-editor-registry.ts +4 -3
  42. package/src/editor/client/preview/page-editor.ts +3 -3
  43. package/src/editor/client/state/document-store.ts +20 -2
  44. package/src/editor/styles/editor.css +2 -0
  45. package/src/editor/types.ts +2 -0
  46. package/src/fields.ts +63 -15
  47. package/src/forms/rules.ts +9 -1
  48. package/src/head.ts +7 -3
  49. package/src/links.ts +35 -0
  50. package/src/live/README.md +1 -0
  51. package/src/live/backend.ts +41 -23
  52. package/src/live/branches.ts +19 -0
  53. package/src/live/mode.ts +46 -0
  54. package/src/live/runtime.ts +5 -2
  55. package/src/media.ts +14 -6
  56. package/src/read.ts +2 -1
  57. package/src/rich-text.ts +30 -5
  58. package/src/serialize.ts +10 -1
@@ -26,7 +26,10 @@ export interface Heads {
26
26
  drafts: Head;
27
27
  }
28
28
 
29
- export type Branch = 'main' | 'drafts';
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: "refs/heads/main") { target { oid ... on Commit { tree { oid } } } }
151
- drafts: ref(qualifiedName: "refs/heads/drafts") { target { oid ... on Commit { tree { oid } } } }
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: `refs/heads/${branch}`, sha });
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: `refs/heads/${u.branch}`, beforeOid: u.before, afterOid: u.after, force: u.force })),
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
- refs = new Map<Branch, string>();
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 `main` (and `drafts`, if asked). */
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();
@@ -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 ? imageUrl(settings, image.key, { width: w, height: Math.round(w / options.ratio), focal: image.focal }) : imageUrl(settings, image.key, { width: w });
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
- const read = await Promise.all(listed.filter((e) => !e.warning).map((e) => reader.document(collection, e.path)));
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
- return { runs: trimRuns(normaliseRuns(runs, { links: options.links })), droppedLink };
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
- if (list) value.push({ kind: kind as ListKind, items: kept });
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
  }