@caelo-cms/shared 0.11.2 → 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.
- package/dist/ai-tools.d.ts +8 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/build-page.d.ts +16 -0
- package/dist/build-page.d.ts.map +1 -1
- package/dist/content.d.ts +24 -0
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +40 -0
- package/dist/content.js.map +1 -1
- package/dist/lists/fields.d.ts +2 -0
- package/dist/lists/fields.d.ts.map +1 -1
- package/dist/lists/fields.js +3 -2
- package/dist/lists/fields.js.map +1 -1
- package/dist/lists/index.d.ts +2 -0
- package/dist/lists/index.d.ts.map +1 -1
- package/dist/lists/index.js +2 -0
- package/dist/lists/index.js.map +1 -1
- package/dist/lists/list-type.d.ts +4 -1
- package/dist/lists/list-type.d.ts.map +1 -1
- package/dist/lists/list-type.js +2 -0
- package/dist/lists/list-type.js.map +1 -1
- package/dist/lists/paginated-render.d.ts +38 -0
- package/dist/lists/paginated-render.d.ts.map +1 -0
- package/dist/lists/paginated-render.js +45 -0
- package/dist/lists/paginated-render.js.map +1 -0
- package/dist/lists/pagination.d.ts +95 -0
- package/dist/lists/pagination.d.ts.map +1 -0
- package/dist/lists/pagination.js +136 -0
- package/dist/lists/pagination.js.map +1 -0
- package/dist/lists/resolve.d.ts +65 -0
- package/dist/lists/resolve.d.ts.map +1 -1
- package/dist/lists/resolve.js +166 -20
- package/dist/lists/resolve.js.map +1 -1
- package/dist/live-events.d.ts.map +1 -1
- package/dist/live-events.js +12 -1
- package/dist/live-events.js.map +1 -1
- package/dist/markup-structure.d.ts +25 -0
- package/dist/markup-structure.d.ts.map +1 -1
- package/dist/markup-structure.js +88 -1
- package/dist/markup-structure.js.map +1 -1
- package/dist/media.d.ts +13 -0
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +40 -0
- package/dist/media.js.map +1 -1
- package/dist/nested-module-render.d.ts +13 -0
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +22 -1
- package/dist/nested-module-render.js.map +1 -1
- package/dist/template-engine.d.ts +4 -1
- package/dist/template-engine.d.ts.map +1 -1
- package/dist/template-engine.js +6 -4
- package/dist/template-engine.js.map +1 -1
- package/dist/template-sections.d.ts.map +1 -1
- package/dist/template-sections.js +1 -0
- package/dist/template-sections.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/content.ts +43 -0
- package/src/lists/fields.ts +3 -2
- package/src/lists/index.ts +2 -0
- package/src/lists/list-type.ts +2 -0
- package/src/lists/paginated-render.ts +77 -0
- package/src/lists/pagination.test.ts +422 -0
- package/src/lists/pagination.ts +160 -0
- package/src/lists/resolve.ts +254 -22
- package/src/live-events.ts +12 -1
- package/src/markup-structure.test.ts +27 -0
- package/src/markup-structure.ts +96 -1
- package/src/media.ts +43 -0
- package/src/nested-module-render.ts +25 -1
- package/src/template-engine.ts +9 -4
- package/src/template-sections.ts +1 -0
- package/src/version.ts +1 -1
package/src/lists/resolve.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
|
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
|
|
package/src/live-events.ts
CHANGED
|
@@ -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
|
});
|
package/src/markup-structure.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|