@caelo-cms/shared 0.11.3 → 0.11.4

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 (68) hide show
  1. package/dist/ai-tools.d.ts +8 -0
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/build-page.d.ts +16 -0
  4. package/dist/build-page.d.ts.map +1 -1
  5. package/dist/content.d.ts +24 -0
  6. package/dist/content.d.ts.map +1 -1
  7. package/dist/content.js +40 -0
  8. package/dist/content.js.map +1 -1
  9. package/dist/lists/index.d.ts +2 -0
  10. package/dist/lists/index.d.ts.map +1 -1
  11. package/dist/lists/index.js +2 -0
  12. package/dist/lists/index.js.map +1 -1
  13. package/dist/lists/list-type.d.ts +4 -1
  14. package/dist/lists/list-type.d.ts.map +1 -1
  15. package/dist/lists/list-type.js +2 -0
  16. package/dist/lists/list-type.js.map +1 -1
  17. package/dist/lists/paginated-render.d.ts +38 -0
  18. package/dist/lists/paginated-render.d.ts.map +1 -0
  19. package/dist/lists/paginated-render.js +45 -0
  20. package/dist/lists/paginated-render.js.map +1 -0
  21. package/dist/lists/pagination.d.ts +95 -0
  22. package/dist/lists/pagination.d.ts.map +1 -0
  23. package/dist/lists/pagination.js +136 -0
  24. package/dist/lists/pagination.js.map +1 -0
  25. package/dist/lists/resolve.d.ts +65 -0
  26. package/dist/lists/resolve.d.ts.map +1 -1
  27. package/dist/lists/resolve.js +166 -20
  28. package/dist/lists/resolve.js.map +1 -1
  29. package/dist/live-events.d.ts.map +1 -1
  30. package/dist/live-events.js +12 -1
  31. package/dist/live-events.js.map +1 -1
  32. package/dist/markup-structure.d.ts +25 -0
  33. package/dist/markup-structure.d.ts.map +1 -1
  34. package/dist/markup-structure.js +88 -1
  35. package/dist/markup-structure.js.map +1 -1
  36. package/dist/media.d.ts +13 -0
  37. package/dist/media.d.ts.map +1 -1
  38. package/dist/media.js +40 -0
  39. package/dist/media.js.map +1 -1
  40. package/dist/nested-module-render.d.ts +13 -0
  41. package/dist/nested-module-render.d.ts.map +1 -1
  42. package/dist/nested-module-render.js +22 -1
  43. package/dist/nested-module-render.js.map +1 -1
  44. package/dist/template-engine.d.ts +4 -1
  45. package/dist/template-engine.d.ts.map +1 -1
  46. package/dist/template-engine.js +6 -4
  47. package/dist/template-engine.js.map +1 -1
  48. package/dist/template-sections.d.ts.map +1 -1
  49. package/dist/template-sections.js +1 -0
  50. package/dist/template-sections.js.map +1 -1
  51. package/dist/version.d.ts +2 -2
  52. package/dist/version.js +1 -1
  53. package/package.json +1 -1
  54. package/src/content.ts +43 -0
  55. package/src/lists/index.ts +2 -0
  56. package/src/lists/list-type.ts +2 -0
  57. package/src/lists/paginated-render.ts +77 -0
  58. package/src/lists/pagination.test.ts +422 -0
  59. package/src/lists/pagination.ts +160 -0
  60. package/src/lists/resolve.ts +254 -22
  61. package/src/live-events.ts +12 -1
  62. package/src/markup-structure.test.ts +27 -0
  63. package/src/markup-structure.ts +96 -1
  64. package/src/media.ts +43 -0
  65. package/src/nested-module-render.ts +25 -1
  66. package/src/template-engine.ts +9 -4
  67. package/src/template-sections.ts +1 -0
  68. package/src/version.ts +1 -1
@@ -24,6 +24,7 @@
24
24
 
25
25
  import type { LinkResolver } from "../link-refs.js";
26
26
  import { parseLinkRef } from "../link-refs.js";
27
+ import type { PageUrlStyle } from "../seo.js";
27
28
  import { SITE_TEXTS_LIST_TYPE } from "./default-types.js";
28
29
  import { type FieldDef, TERM_REF_RE, validateFieldValue } from "./fields.js";
29
30
  import type { ListItem } from "./items.js";
@@ -38,6 +39,16 @@ import {
38
39
  type SourceDef,
39
40
  type SystemSourceName,
40
41
  } from "./list-type.js";
42
+ import {
43
+ type EffectivePagination,
44
+ effectivePagination,
45
+ type ListPaginationSettings,
46
+ listPaginationSchema,
47
+ type PaginationFollowUps,
48
+ pageCountOf,
49
+ pagedUrl,
50
+ splitPaginationValue,
51
+ } from "./pagination.js";
41
52
  import { parseListRef } from "./refs.js";
42
53
  import type {
43
54
  ListPageRecord,
@@ -71,6 +82,9 @@ const LIST_MARKER_PREFIXES = [
71
82
  "list-source-unavailable:",
72
83
  "list-malformed:",
73
84
  "list-context-unavailable:",
85
+ // #1257 — a second paginated list on one page; a paginated page without a pager.
86
+ "list-pagination-conflict:",
87
+ "list-pager-missing:",
74
88
  ] as const;
75
89
 
76
90
  /** True for a list failure marker — the editor's missing-content surface shows these. */
@@ -86,6 +100,8 @@ export interface ListFieldSpec {
86
100
  readonly list?: string;
87
101
  readonly source?: SourceDef;
88
102
  readonly default?: unknown;
103
+ /** #1257 — the field lists every match across follow-up pages. */
104
+ readonly paginate?: ListPaginationSettings;
89
105
  }
90
106
 
91
107
  /** Result of resolving one list field for one page. */
@@ -116,6 +132,66 @@ export interface ListPageOptions {
116
132
  readonly linkResolver?: LinkResolver;
117
133
  /** Entries of every ACTIVE plugin list source for this page, keyed by source name. */
118
134
  readonly pluginSources?: Readonly<Record<string, readonly PluginSourceEntry[]>>;
135
+ /**
136
+ * #1257 — the target's URL style, for the pager's links to follow-up
137
+ * pages. Without it a rendered `pagination` source is
138
+ * `list-context-unavailable:pagination`.
139
+ */
140
+ readonly pageUrlStyle?: PageUrlStyle;
141
+ /**
142
+ * #1257 — which page of the paginated list this render is (1 = the
143
+ * page's own URL; default 1), and — once a first render found it —
144
+ * how many pages there are, so a pager rendered BEFORE the list knows.
145
+ */
146
+ readonly pagination?: { readonly page: number; readonly pageCount?: number };
147
+ }
148
+
149
+ /**
150
+ * #1257 — the page's paginated list as one render saw it
151
+ * ({@link ListPageContext.pagination}).
152
+ */
153
+ export interface ListPaginationState {
154
+ /** The paginated module field. */
155
+ readonly field: string;
156
+ readonly perPage: number;
157
+ /** Every match of the query. */
158
+ readonly total: number;
159
+ readonly pageCount: number;
160
+ /** The page this render showed. */
161
+ readonly page: number;
162
+ readonly followUps: PaginationFollowUps;
163
+ /** A `pagination` source rendered on this page. */
164
+ readonly pagerShown: boolean;
165
+ /**
166
+ * The pager rendered before the list was resolved, so it did not know
167
+ * the page count yet: render the page again with
168
+ * `pagination: { page, pageCount }`.
169
+ */
170
+ readonly pagerEarly: boolean;
171
+ }
172
+
173
+ /**
174
+ * #1257 — a list cut at its limit: more pages match than it shows. Never
175
+ * silent: the Stage result and `preview_list_source` report it.
176
+ */
177
+ export interface TruncatedList {
178
+ /** The module field (or source node's field) that rendered the list. */
179
+ readonly field: string;
180
+ readonly shown: number;
181
+ readonly total: number;
182
+ /** The limit that cut it (the query's `limit`; 50 for term_pages). */
183
+ readonly limit: number;
184
+ }
185
+
186
+ /**
187
+ * The markers a finished render's pagination asks for: a paginated page
188
+ * that shows no pager strands every entry after the first page
189
+ * (`list-pager-missing:<field>`). Exported so the preview and the
190
+ * generator apply one rule.
191
+ */
192
+ export function paginationMarkers(state: ListPaginationState | null): string[] {
193
+ if (state === null || state.pageCount <= 1 || state.pagerShown) return [];
194
+ return [`list-pager-missing:${state.field}`];
119
195
  }
120
196
 
121
197
  /** The list view of ONE rendering page, handed to the template engine. */
@@ -149,6 +225,10 @@ export interface ListPageContext {
149
225
  deps(): ListRenderDeps;
150
226
  /** Overlay values this page's render refused (also the render-wide ones). */
151
227
  overlayIssues(): readonly ListOverlayIssue[];
228
+ /** #1257 — the paginated list this render resolved, or null. */
229
+ pagination(): ListPaginationState | null;
230
+ /** #1257 — lists this render cut at their limit (not paginated). */
231
+ truncatedLists(): readonly TruncatedList[];
152
232
  }
153
233
 
154
234
  /** Render-wide options of {@link createListRenderContext}. */
@@ -435,6 +515,14 @@ function createPageContext(
435
515
  let depTree = false;
436
516
  const depPages = new Set<string>();
437
517
  let trail: readonly string[] | null = null;
518
+ // #1257 — pagination and cut lists of this render.
519
+ const requestedPage = opts.pagination?.page ?? 1;
520
+ let paged:
521
+ | (Omit<ListPaginationState, "pagerShown" | "pagerEarly" | "page"> & { readonly key: string })
522
+ | null = null;
523
+ let pagerShown = false;
524
+ let pagerEarly = false;
525
+ const truncated = new Map<string, TruncatedList>();
438
526
  /** Entries of a page-shaped system source: their pages become deps. */
439
527
  const readPages = (pages: ListPageRecord[]): ListPageRecord[] => {
440
528
  for (const pg of pages) depPages.add(pg.id);
@@ -790,7 +878,8 @@ function createPageContext(
790
878
  const sameVariant = (p: ListPageRecord): boolean =>
791
879
  variant === null || data.pageVariants?.get(p.id)?.variant === variant;
792
880
 
793
- const runQuery = (q: PageQuery): ScopeValue => {
881
+ /** Every page a query matches, in its order (deps recorded). */
882
+ const queryHits = (q: PageQuery): ListPageRecord[] => {
794
883
  const parent = q.parent
795
884
  ? ((parseLinkRef(q.parent) as { pageId: string } | null)?.pageId ?? null)
796
885
  : null;
@@ -814,7 +903,108 @@ function createPageContext(
814
903
  : terms.some((t) => p.terms.includes(t)))),
815
904
  );
816
905
  hits.sort(pageOrder(q.order, s.collator));
817
- return listValue(pageEntries(hits.slice(0, q.limit)));
906
+ return hits;
907
+ };
908
+
909
+ /** The first `limit` hits; a cut is recorded, never silent (#1257). */
910
+ const cutAt = (hits: readonly ListPageRecord[], limit: number, field: string): ScopeValue => {
911
+ if (hits.length > limit) {
912
+ const key = `${field}:${limit}:${hits.length}`;
913
+ if (!truncated.has(key))
914
+ truncated.set(key, { field, shown: limit, total: hits.length, limit });
915
+ }
916
+ return listValue(pageEntries(hits.slice(0, limit)));
917
+ };
918
+
919
+ const runQuery = (q: PageQuery, field: string): ScopeValue => cutAt(queryHits(q), q.limit, field);
920
+
921
+ /**
922
+ * The slice of a paginated list this render shows. One paginated list
923
+ * per page: a second one (another query or page size) is refused.
924
+ */
925
+ const paginate = (
926
+ field: string,
927
+ key: string,
928
+ hits: readonly ListPageRecord[],
929
+ eff: EffectivePagination,
930
+ ): ScopeValue => {
931
+ const fullKey = JSON.stringify([key, eff.perPage, eff.followUps]);
932
+ if (paged !== null && paged.key !== fullKey) {
933
+ return { kind: "failed", marker: `list-pagination-conflict:${field}` };
934
+ }
935
+ paged = {
936
+ key: fullKey,
937
+ field,
938
+ perPage: eff.perPage,
939
+ total: hits.length,
940
+ pageCount: pageCountOf(hits.length, eff.perPage),
941
+ followUps: eff.followUps,
942
+ };
943
+ const from = (requestedPage - 1) * eff.perPage;
944
+ return listValue(pageEntries(hits.slice(from, from + eff.perPage)));
945
+ };
946
+
947
+ /** Hits of the term_pages source for `terms` (deps recorded). */
948
+ const termPageHits = (terms: readonly string[]): ListPageRecord[] => {
949
+ depTree = true;
950
+ if (terms.length === 0) return [];
951
+ const sorted = [...terms].sort();
952
+ const key = JSON.stringify([null, sorted, null]);
953
+ if (!depQueries.has(key)) depQueries.set(key, { pageType: null, terms: sorted, parent: null });
954
+ return s.pageList
955
+ .filter(
956
+ (p) =>
957
+ p.inBuild && sameVariant(p) && p.id !== pageId && terms.some((t) => p.terms.includes(t)),
958
+ )
959
+ .sort(byTitle);
960
+ };
961
+
962
+ /**
963
+ * The `pagination` source: one entry per page of the page's paginated
964
+ * list (`number`, `link`, `is_current`, `is_prev`, `is_next`); empty
965
+ * while everything fits on one page.
966
+ */
967
+ const pagerEntries = (): ScopeValue => {
968
+ pagerShown = true;
969
+ const known = opts.pagination?.pageCount ?? paged?.pageCount;
970
+ if (known === undefined) {
971
+ // The list renders later on this page: the caller renders again
972
+ // with the page count (ListPaginationState.pagerEarly).
973
+ pagerEarly = true;
974
+ return EMPTY_LIST;
975
+ }
976
+ if (known <= 1) return EMPTY_LIST;
977
+ const self = pageId === null ? null : parseLinkRef(`caelo:page/${pageId}`);
978
+ const r = self !== null && resolver ? resolver.resolve(self) : null;
979
+ const style = opts.pageUrlStyle;
980
+ if (
981
+ r === null ||
982
+ style === undefined ||
983
+ (r.status !== "resolved" && r.status !== "unpublished")
984
+ ) {
985
+ return { kind: "failed", marker: "list-context-unavailable:pagination" };
986
+ }
987
+ const entries: ScopeEntry[] = [];
988
+ for (let n = 1; n <= known; n += 1) {
989
+ const href = n === 1 ? r.url : pagedUrl(r.url, n, style);
990
+ const pos = { index: n - 1, count: known, depth: 1 };
991
+ const values: Record<string, ScopeValue> = {
992
+ number: text(String(n)),
993
+ link: linkValue({ href }, null),
994
+ is_current: bool(n === requestedPage),
995
+ is_prev: bool(n === requestedPage - 1),
996
+ is_next: bool(n === requestedPage + 1),
997
+ is_ancestor: bool(false),
998
+ };
999
+ entries.push({
1000
+ id: `page-${n}`,
1001
+ itemAttr: null,
1002
+ childrenKey: null,
1003
+ depth: 1,
1004
+ get: (key) => values[key] ?? positionValue(key, pos),
1005
+ });
1006
+ }
1007
+ return listValue(entries);
818
1008
  };
819
1009
 
820
1010
  const byTitle = (a: ListPageRecord, b: ListPageRecord): number =>
@@ -877,24 +1067,10 @@ function createPageContext(
877
1067
  field,
878
1068
  );
879
1069
  }
880
- case "term_pages": {
881
- depTree = true;
882
- if (terms.length === 0) return EMPTY_LIST;
883
- const sorted = [...terms].sort();
884
- const key = JSON.stringify([null, sorted, null]);
885
- if (!depQueries.has(key))
886
- depQueries.set(key, { pageType: null, terms: sorted, parent: null });
887
- const hits = s.pageList
888
- .filter(
889
- (p) =>
890
- p.inBuild &&
891
- sameVariant(p) &&
892
- p.id !== pageId &&
893
- terms.some((t) => p.terms.includes(t)),
894
- )
895
- .sort(byTitle);
896
- return listValue(pageEntries(hits.slice(0, LIST_QUERY_MAX_LIMIT)));
897
- }
1070
+ case "term_pages":
1071
+ return cutAt(termPageHits(terms), LIST_QUERY_MAX_LIMIT, field);
1072
+ case "pagination":
1073
+ return pagerEntries();
898
1074
  }
899
1075
  };
900
1076
 
@@ -955,7 +1131,7 @@ function createPageContext(
955
1131
  switch (source.kind) {
956
1132
  case "query": {
957
1133
  const q = mergedQuery(source, item.query, field, `${item.id}.query`);
958
- return "marker" in q ? { kind: "failed", marker: q.marker } : runQuery(q);
1134
+ return "marker" in q ? { kind: "failed", marker: q.marker } : runQuery(q, field);
959
1135
  }
960
1136
  case "system": {
961
1137
  const termFields = node.fields.filter((f) => f.type === "term");
@@ -980,19 +1156,61 @@ function createPageContext(
980
1156
  ? { ok: false, marker: v.marker }
981
1157
  : { ok: true, entries: v.kind === "list" ? v.entries : [] };
982
1158
 
1159
+ /**
1160
+ * A paginated source field (#1257): a query, or the term_pages system
1161
+ * source. The page size replaces the query's `limit`; the placement
1162
+ * value may carry `paginate` overrides next to its knobs.
1163
+ */
1164
+ const resolvePaginated = (
1165
+ field: ListFieldSpec,
1166
+ settings: ListPaginationSettings,
1167
+ value: unknown,
1168
+ ): ListFieldResolution => {
1169
+ const { paginate: overrides, rest } = splitPaginationValue(value);
1170
+ const valid = listPaginationSchema.safeParse(settings);
1171
+ const eff = valid.success ? effectivePagination(valid.data, overrides) : null;
1172
+ if (eff === null) return { ok: false, marker: `list-malformed:${field.name}[paginate]` };
1173
+ const source = field.source;
1174
+ if (source?.kind === "query") {
1175
+ const q = mergedQuery(
1176
+ { ...source, defaults: { ...source.defaults, limit: eff.perPage } },
1177
+ rest,
1178
+ field.name,
1179
+ "query",
1180
+ );
1181
+ if ("marker" in q) return { ok: false, marker: q.marker };
1182
+ const key = JSON.stringify(["query", { ...q, limit: null }]);
1183
+ return fromValue(paginate(field.name, key, queryHits(q), eff));
1184
+ }
1185
+ if (source?.kind === "system" && source.name === "term_pages") {
1186
+ const terms = (rest as { terms?: unknown } | undefined)?.terms;
1187
+ const list = Array.isArray(terms)
1188
+ ? terms.filter((t): t is string => typeof t === "string")
1189
+ : [];
1190
+ const key = JSON.stringify(["term_pages", [...list].sort()]);
1191
+ return fromValue(paginate(field.name, key, termPageHits(list), eff));
1192
+ }
1193
+ return { ok: false, marker: `list-malformed:${field.name}[paginate]` };
1194
+ };
1195
+
983
1196
  const resolveField = (
984
1197
  field: ListFieldSpec,
985
1198
  value: unknown,
986
1199
  holder: ListHolder,
987
1200
  ): ListFieldResolution => {
988
1201
  const bind = field.bind ?? "local";
1202
+ if (bind === "source" && field.paginate !== undefined) {
1203
+ return resolvePaginated(field, field.paginate, value);
1204
+ }
989
1205
  if (bind === "source") {
990
1206
  const source = field.source;
991
1207
  if (!source) return { ok: false, marker: `list-malformed:${field.name}[source]` };
992
1208
  switch (source.kind) {
993
1209
  case "query": {
994
1210
  const q = mergedQuery(source, value, field.name, "query");
995
- return "marker" in q ? { ok: false, marker: q.marker } : fromValue(runQuery(q));
1211
+ return "marker" in q
1212
+ ? { ok: false, marker: q.marker }
1213
+ : fromValue(runQuery(q, field.name));
996
1214
  }
997
1215
  case "system": {
998
1216
  const overrides = (value ?? {}) as { terms?: unknown };
@@ -1163,6 +1381,20 @@ function createPageContext(
1163
1381
  pages: [...depPages].sort(),
1164
1382
  }),
1165
1383
  overlayIssues: () => [...renderIssues, ...pageIssues],
1384
+ pagination: () =>
1385
+ paged === null
1386
+ ? null
1387
+ : {
1388
+ field: paged.field,
1389
+ perPage: paged.perPage,
1390
+ total: paged.total,
1391
+ pageCount: paged.pageCount,
1392
+ page: requestedPage,
1393
+ followUps: paged.followUps,
1394
+ pagerShown,
1395
+ pagerEarly,
1396
+ },
1397
+ truncatedLists: () => [...truncated.values()],
1166
1398
  };
1167
1399
  }
1168
1400
 
@@ -92,6 +92,9 @@ const EXACT: Readonly<Record<string, readonly LiveTopic[]>> = {
92
92
  "quality_audits.ack_chat_notification": ["quality"],
93
93
  "quality_audits.release_chat_notification": ["quality"],
94
94
  "quality_audits.publish_anyway": ["quality"],
95
+ // #1225 — an approved quality decision's claim and record steps.
96
+ "quality_audits.begin_execute_proposal": ["pending", "quality"],
97
+ "quality_audits.end_execute_proposal": ["pending", "quality"],
95
98
  "imports.create_run": ["crawl"],
96
99
  "imports.update_run_status": ["crawl"],
97
100
  "imports.write_extracted_pages": ["crawl"],
@@ -100,11 +103,19 @@ const EXACT: Readonly<Record<string, readonly LiveTopic[]>> = {
100
103
  "deploy.start_trigger": ["deploys"],
101
104
  "deploy.fail_run": ["deploys"],
102
105
  "deploy.finish_trigger": ["deploys"],
103
- "deploy.begin_run": ["deploys"],
104
106
  "deploy.update_progress": ["deploys"],
105
107
  "deploy.promote": ["deploys"],
108
+ // #1225 — Publish live's steps, and an approved card's claim and record.
109
+ "deploy.start_promote": ["deploys"],
110
+ "deploy.finish_promote": ["deploys"],
111
+ "deploy.begin_execute_proposal": ["pending", "deploys"],
112
+ "deploy.end_execute_proposal": ["pending", "deploys"],
106
113
  "deploy.rollback": ["deploys"],
114
+ "deploy.release_rollback": ["deploys"],
107
115
  "deploy.build_plugin_data": ["deploys"],
116
+ // #1220 — the plugin-data build's steps that start and record a run.
117
+ "deploy.begin_plugin_data_run": ["deploys"],
118
+ "deploy.finish_plugin_data_run": ["deploys"],
108
119
  };
109
120
 
110
121
  /**
@@ -113,6 +113,33 @@ describe("templates and free regions", () => {
113
113
  expect(performance.now() - started).toBeLessThan(500);
114
114
  });
115
115
 
116
+ it("stripMustache markSections keeps sections between elements as markers (#1256)", () => {
117
+ const t =
118
+ '<ul class="x {{#on}}on{{/on}}">{{#links}}<li><a href="{{link}}">{{label}}</a></li>{{/links}}{{^links}}<p>none</p>{{/links}}</ul>';
119
+ expect(stripMustache(t, { markSections: true })).toBe(
120
+ '<ul class="x "><caelo-section class="s:links"><li><a href="\u001f"><caelo-partial class="v:label"></caelo-partial></a></li></caelo-section><caelo-section class="i:links"><p>none</p></caelo-section></ul>',
121
+ );
122
+ // Default output is unchanged.
123
+ expect(stripMustache(t)).toBe('<ul class="x "><li><a href=""></a></li><p>none</p></ul>');
124
+ expect(
125
+ stripMustache("<ul>{{ #links }}<li></li>{{ /links }}</ul>", { markSections: true }),
126
+ ).toBe('<ul><caelo-section class="s:links"><li></li></caelo-section></ul>');
127
+ expect(
128
+ stripMustache('<a class="card card--{{type}}" href="{{link}}">{{title}}</a>', {
129
+ markSections: true,
130
+ }),
131
+ ).toBe(
132
+ '<a class="card card--\u001f" href="\u001f"><caelo-partial class="v:title"></caelo-partial></a>',
133
+ );
134
+ expect(
135
+ stripMustache("<div>{{#g}}{{> props.teaser }}{{/g}}</div>", { markSections: true }),
136
+ ).toBe(
137
+ '<div><caelo-section class="s:g"><caelo-partial class="p:props.teaser"></caelo-partial></caelo-section></div>',
138
+ );
139
+ stripMustache(`{{#${" ".repeat(50_000)}`, { markSections: true });
140
+ stripMustache(`<a ${"{{".repeat(50_000)}`, { markSections: true });
141
+ });
142
+
116
143
  it("stripMustache keeps the markup a template renders", () => {
117
144
  expect(stripMustache("<p>{{a}}{{{b}}}</p>{{#s}}<i></i>{{/s}}{{>n}}")).toBe("<p></p><i></i>");
118
145
  });
@@ -128,8 +128,36 @@ export interface StripMustacheOptions {
128
128
  * such a section is dropped, leaving the classes it ALWAYS renders.
129
129
  */
130
130
  readonly keepOptionalAttributeText?: boolean;
131
+ /**
132
+ * Issue #1256 — keep every section between elements as a marker element
133
+ * instead of dropping its tags: `{{#f}}…{{/f}}` becomes
134
+ * `<caelo-section class="s:f">…</caelo-section>`, an inverted
135
+ * `{{^f}}` the class `i:f`. The structure check reads from them which
136
+ * links a template renders per list entry. Ignored with
137
+ * `keepOptionalAttributeText` (in-tag sections must be dropped first).
138
+ */
139
+ readonly markSections?: boolean;
131
140
  }
132
141
 
142
+ /** The marker element {@link stripMustache} `markSections` emits. */
143
+ export const SECTION_MARKER_TAG = "caelo-section";
144
+
145
+ /**
146
+ * The empty element `markSections` leaves where a placeholder between
147
+ * elements renders: a partial's nested module (`{{>teaser}}` →
148
+ * `<caelo-partial class="p:teaser">`) or a value that may hold markup
149
+ * (`{{body}}` → `class="v:body"`), so a reader knows which section the
150
+ * rendered content sits in.
151
+ */
152
+ export const PARTIAL_MARKER_TAG = "caelo-partial";
153
+
154
+ /**
155
+ * What a placeholder inside a tag becomes with `markSections`
156
+ * (`class="card card--{{type}}"` → `card--\u001f`): a class token holding
157
+ * it matches any rendered token with the same text around it.
158
+ */
159
+ export const PLACEHOLDER_MARK = "\u001f";
160
+
133
161
  /**
134
162
  * Remove Mustache tags (`{{x}}`, `{{{x}}}`, `{{#s}}`, `{{/s}}`, `{{>p}}`)
135
163
  * from a module template so it parses as the markup it renders: a section
@@ -146,7 +174,74 @@ export interface StripMustacheOptions {
146
174
  */
147
175
  export function stripMustache(template: string, opts: StripMustacheOptions = {}): string {
148
176
  const base = opts.keepOptionalAttributeText ? template : dropInTagSections(template);
149
- return stripDelimited(stripDelimited(base, 3), 2);
177
+ const marked =
178
+ opts.markSections && !opts.keepOptionalAttributeText
179
+ ? markSections(markTagPlaceholders(base))
180
+ : base;
181
+ return stripDelimited(stripDelimited(marked, 3), 2);
182
+ }
183
+
184
+ /**
185
+ * Replace every placeholder inside a tag (`{{x}}`, `{{{x}}}`) with
186
+ * {@link PLACEHOLDER_MARK}. One linear scan, tags tracked as in
187
+ * {@link dropInTagSections}; placeholders between elements are left for
188
+ * the strip.
189
+ */
190
+ function markTagPlaceholders(text: string): string {
191
+ let out = "";
192
+ let i = 0;
193
+ let inTag = false;
194
+ let quote: string | null = null;
195
+ while (i < text.length) {
196
+ if (text.startsWith("{{", i)) {
197
+ const end = text.indexOf("}}", i + 2);
198
+ if (end < 0) break;
199
+ const close = text.startsWith("}}}", end) ? end + 3 : end + 2;
200
+ out += inTag ? PLACEHOLDER_MARK : text.slice(i, close);
201
+ i = close;
202
+ continue;
203
+ }
204
+ const ch = text[i] as string;
205
+ if (inTag) {
206
+ if (quote !== null) {
207
+ if (ch === quote) quote = null;
208
+ } else if (ch === '"' || ch === "'") {
209
+ quote = ch;
210
+ } else if (ch === ">") {
211
+ inTag = false;
212
+ }
213
+ } else if (ch === "<" && /[a-zA-Z]/.test(text[i + 1] ?? "")) {
214
+ inTag = true;
215
+ quote = null;
216
+ }
217
+ out += ch;
218
+ i++;
219
+ }
220
+ return out + text.slice(i);
221
+ }
222
+
223
+ /**
224
+ * Turn the section tags left after {@link dropInTagSections} (all between
225
+ * elements) into marker elements, and every partial and value placeholder
226
+ * into an empty {@link PARTIAL_MARKER_TAG}. Whitespace around the sigil and the name
227
+ * is allowed, as the template engine allows it (`{{ #guides }}`). `[^{}]*`
228
+ * before the closer keeps the match linear.
229
+ */
230
+ function markSections(text: string): string {
231
+ return text.replace(/\{\{([^{}]*)\}\}/g, (tag: string, raw: string) => {
232
+ const body = raw.trim();
233
+ const sigil = body[0];
234
+ const name = body.slice(1).replace(/[\s"<>]/g, "");
235
+ if (sigil === ">") return `<${PARTIAL_MARKER_TAG} class="p:${name}"></${PARTIAL_MARKER_TAG}>`;
236
+ if (sigil !== "#" && sigil !== "^" && sigil !== "/") {
237
+ const value = body.replace(/[\s"<>{}]/g, "");
238
+ return value === ""
239
+ ? tag
240
+ : `<${PARTIAL_MARKER_TAG} class="v:${value}"></${PARTIAL_MARKER_TAG}>`;
241
+ }
242
+ if (sigil === "/") return `</${SECTION_MARKER_TAG}>`;
243
+ return `<${SECTION_MARKER_TAG} class="${sigil === "#" ? "s" : "i"}:${name}">`;
244
+ });
150
245
  }
151
246
 
152
247
  /**
package/src/media.ts CHANGED
@@ -243,6 +243,49 @@ export function extractMediaRefs(html: string): MediaRef[] {
243
243
  return out;
244
244
  }
245
245
 
246
+ /**
247
+ * Issue #1217 — every editor media URL in an HTML string as the browser
248
+ * will request it (`/_caelo/media/<slug>` or `/_caelo/media/<ref>/<variant>`),
249
+ * deduped, with the asset reference and variant the admin's media routes
250
+ * resolve it to. The capture job serves exactly these paths.
251
+ */
252
+ export function extractMediaUrlPaths(
253
+ html: string,
254
+ ): { readonly path: string; readonly ref: string; readonly variant: string }[] {
255
+ const out = new Map<string, { path: string; ref: string; variant: string }>();
256
+ for (const m of html.matchAll(mediaUrlPattern)) {
257
+ const path = m[0];
258
+ if (!out.has(path)) out.set(path, { path, ref: m[1] as string, variant: m[2] ?? "orig" });
259
+ }
260
+ return [...out.values()];
261
+ }
262
+
263
+ /** The Content-Type the admin serves a stored media variant with, by its `format`. */
264
+ export function mediaFormatContentType(format: string): string {
265
+ switch (format) {
266
+ case "jpeg":
267
+ return "image/jpeg";
268
+ case "png":
269
+ return "image/png";
270
+ case "webp":
271
+ return "image/webp";
272
+ case "avif":
273
+ return "image/avif";
274
+ case "gif":
275
+ return "image/gif";
276
+ case "svg":
277
+ return "image/svg+xml";
278
+ case "ico":
279
+ return "image/x-icon";
280
+ case "pdf":
281
+ return "application/pdf";
282
+ case "mp4":
283
+ return "video/mp4";
284
+ default:
285
+ return "application/octet-stream";
286
+ }
287
+ }
288
+
246
289
  const mediaRefPattern =
247
290
  /caelo:media\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\/([a-z][a-z0-9-]{0,63}))?/g;
248
291
 
@@ -397,13 +397,37 @@ function nestedRefForPartial(
397
397
  const name = key.slice(0, sep);
398
398
  const list = fieldByName.get(name);
399
399
  if (list?.kind !== "module-list") return undefined;
400
- const raw = Object.hasOwn(values, name) ? values[name] : list.default;
400
+ const raw = rendersFieldDefault(list, values) ? list.default : values[name];
401
401
  if (!Array.isArray(raw)) return undefined;
402
402
  const i = Number(index);
403
403
  const el = raw[i];
404
404
  return isNestedRef(el) ? { ref: el, field: name, step: `${name}[${i}]` } : undefined;
405
405
  }
406
406
 
407
+ /**
408
+ * Whether a module rendered with `values` shows `field`'s DEFAULT instead
409
+ * of a stored value — the renderer's per-kind fallback, shared with the
410
+ * reach walk so a content instance's blast radius follows it (#627):
411
+ *
412
+ * - `module-list`: the default when the values do not hold the field
413
+ * (an explicit `[]` or `null` overrides it);
414
+ * - a local `list`: the default when the value is absent or `null`
415
+ * (`resolveListField`);
416
+ * - `module` and the rest: the stored value only, never the default.
417
+ * (A shared list's default names a list, which embeds nothing here.)
418
+ */
419
+ export function rendersFieldDefault(
420
+ field: Pick<TemplateField, "name" | "kind" | "bind">,
421
+ values: Readonly<Record<string, unknown>>,
422
+ ): boolean {
423
+ if (field.kind === "module-list") return !Object.hasOwn(values, field.name);
424
+ if (field.kind === "list" && (field.bind ?? "local") === "local") {
425
+ const value = values[field.name];
426
+ return value === undefined || value === null;
427
+ }
428
+ return false;
429
+ }
430
+
407
431
  /** Partial-key prefix of a list entry's embedded module (`template-sections.ts`). */
408
432
  const LIST_PARTIAL_PREFIX = "list:";
409
433