@sarimarcus/content-sites-core 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/interludes/index.d.ts +116 -0
- package/dist/interludes/index.js +91 -0
- package/package.json +5 -1
- package/src/interludes/index.ts +116 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All packages in the platform release in lockstep; entries are per release version.
|
|
4
4
|
|
|
5
|
+
## 0.20.0 (2026-10-02)
|
|
6
|
+
|
|
7
|
+
- New `./interludes`: `createInterludeSchema(z, config)` builds the 14-member article interlude union from a site's entity taxonomy (`relatedCardTypes`, `featureTypes`) with the site's own zod; `INTERLUDE_TYPES` and `INTERLUDE_MAX_PER_SECTION` for factory gates. Replaces the per-site generated `interludes.schema.ts` (SLA-2740).
|
|
8
|
+
|
|
9
|
+
## 0.19.0 (2026-10-02)
|
|
10
|
+
|
|
11
|
+
- No changes; lockstep release with ui (SLA-2740).
|
|
12
|
+
|
|
5
13
|
## 0.18.0 (2026-10-02)
|
|
6
14
|
|
|
7
15
|
- `./url`: `articleFeedItem(article, site, author?)` builds one @astrojs/rss item (relative `/articles/<slug>` link, date-only values read as noon UTC, the OG image as `media:content`) (SLA-2744).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { z as Zod } from 'astro/zod';
|
|
2
|
+
/** The discriminated-union tags, in schema order. Factory gates import this instead of keeping a copy. */
|
|
3
|
+
export declare const INTERLUDE_TYPES: readonly ["quote", "stat-fact", "evidence-context", "related-card", "tldr", "answer-box", "field-note", "caution", "checklist", "did-you-know", "timeline", "comparison-table", "monument-feature", "article-feature"];
|
|
4
|
+
export type InterludeType = (typeof INTERLUDE_TYPES)[number];
|
|
5
|
+
/**
|
|
6
|
+
* Per-section shape guard, applied as `z.array(schema).max(INTERLUDE_MAX_PER_SECTION)`. Not the budget: the
|
|
7
|
+
* article-wide cap is `sections.length - 1`, enforced by scripts/validate-articles.mjs, so at least one section of
|
|
8
|
+
* every article stays clean. Zod cannot see across sections.
|
|
9
|
+
*/
|
|
10
|
+
export declare const INTERLUDE_MAX_PER_SECTION = 2;
|
|
11
|
+
/** A site's entity taxonomy: the only part of the union that varies per site. */
|
|
12
|
+
export interface InterludeSchemaConfig {
|
|
13
|
+
/** Collections a `related-card` may point at (singular: 'monument', 'article'). */
|
|
14
|
+
relatedCardTypes: readonly string[];
|
|
15
|
+
featureTypes: {
|
|
16
|
+
/** Collections the mandatory `monument-feature` may feature. */
|
|
17
|
+
types: readonly string[];
|
|
18
|
+
/** Whether content may pin `relatedSlug`; false where the template injects it at build time. */
|
|
19
|
+
allowPinnedSlug: boolean;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** The builders the union uses; `astro:content`'s `z` and `astro/zod` both satisfy it. */
|
|
23
|
+
type ZodBuilders = Pick<typeof Zod, 'discriminatedUnion' | 'object' | 'literal' | 'enum' | 'string' | 'number' | 'array'>;
|
|
24
|
+
/**
|
|
25
|
+
* The 14-member interlude union. `z` is the site's own (`astro:content`), so the schema belongs to the zod instance
|
|
26
|
+
* the content layer validates with.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createInterludeSchema(z: ZodBuilders, config: InterludeSchemaConfig): Zod.ZodDiscriminatedUnion<[Zod.ZodObject<{
|
|
29
|
+
type: Zod.ZodLiteral<"quote">;
|
|
30
|
+
variant: Zod.ZodEnum<{
|
|
31
|
+
pull: "pull";
|
|
32
|
+
external: "external";
|
|
33
|
+
}>;
|
|
34
|
+
content: Zod.ZodString;
|
|
35
|
+
attribution: Zod.ZodOptional<Zod.ZodObject<{
|
|
36
|
+
name: Zod.ZodString;
|
|
37
|
+
role: Zod.ZodOptional<Zod.ZodString>;
|
|
38
|
+
}, Zod.z.core.$strip>>;
|
|
39
|
+
afterParagraph: Zod.ZodNumber;
|
|
40
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
41
|
+
type: Zod.ZodLiteral<"stat-fact">;
|
|
42
|
+
figure: Zod.ZodString;
|
|
43
|
+
caption: Zod.ZodString;
|
|
44
|
+
afterParagraph: Zod.ZodNumber;
|
|
45
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
46
|
+
type: Zod.ZodLiteral<"evidence-context">;
|
|
47
|
+
finding: Zod.ZodString;
|
|
48
|
+
basis: Zod.ZodString;
|
|
49
|
+
boundary: Zod.ZodString;
|
|
50
|
+
afterParagraph: Zod.ZodNumber;
|
|
51
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
52
|
+
type: Zod.ZodLiteral<"related-card">;
|
|
53
|
+
relatedType: Zod.ZodEnum<{
|
|
54
|
+
[x: string]: string;
|
|
55
|
+
}>;
|
|
56
|
+
relatedSlug: Zod.ZodString;
|
|
57
|
+
afterParagraph: Zod.ZodNumber;
|
|
58
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
59
|
+
type: Zod.ZodLiteral<"tldr">;
|
|
60
|
+
content: Zod.ZodOptional<Zod.ZodString>;
|
|
61
|
+
bullets: Zod.ZodOptional<Zod.ZodArray<Zod.ZodString>>;
|
|
62
|
+
title: Zod.ZodOptional<Zod.ZodString>;
|
|
63
|
+
position: Zod.ZodLiteral<"end">;
|
|
64
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
65
|
+
type: Zod.ZodLiteral<"answer-box">;
|
|
66
|
+
question: Zod.ZodString;
|
|
67
|
+
answer: Zod.ZodString;
|
|
68
|
+
afterParagraph: Zod.ZodNumber;
|
|
69
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
70
|
+
type: Zod.ZodLiteral<"field-note">;
|
|
71
|
+
content: Zod.ZodString;
|
|
72
|
+
afterParagraph: Zod.ZodNumber;
|
|
73
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
74
|
+
type: Zod.ZodLiteral<"caution">;
|
|
75
|
+
variant: Zod.ZodEnum<{
|
|
76
|
+
tip: "tip";
|
|
77
|
+
warning: "warning";
|
|
78
|
+
info: "info";
|
|
79
|
+
}>;
|
|
80
|
+
title: Zod.ZodOptional<Zod.ZodString>;
|
|
81
|
+
content: Zod.ZodString;
|
|
82
|
+
afterParagraph: Zod.ZodNumber;
|
|
83
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
84
|
+
type: Zod.ZodLiteral<"checklist">;
|
|
85
|
+
title: Zod.ZodString;
|
|
86
|
+
items: Zod.ZodArray<Zod.ZodString>;
|
|
87
|
+
afterParagraph: Zod.ZodNumber;
|
|
88
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
89
|
+
type: Zod.ZodLiteral<"did-you-know">;
|
|
90
|
+
content: Zod.ZodString;
|
|
91
|
+
afterParagraph: Zod.ZodNumber;
|
|
92
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
93
|
+
type: Zod.ZodLiteral<"timeline">;
|
|
94
|
+
events: Zod.ZodArray<Zod.ZodObject<{
|
|
95
|
+
date: Zod.ZodString;
|
|
96
|
+
label: Zod.ZodString;
|
|
97
|
+
description: Zod.ZodOptional<Zod.ZodString>;
|
|
98
|
+
}, Zod.z.core.$strip>>;
|
|
99
|
+
afterParagraph: Zod.ZodNumber;
|
|
100
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
101
|
+
type: Zod.ZodLiteral<"comparison-table">;
|
|
102
|
+
headers: Zod.ZodArray<Zod.ZodString>;
|
|
103
|
+
rows: Zod.ZodArray<Zod.ZodArray<Zod.ZodString>>;
|
|
104
|
+
afterParagraph: Zod.ZodNumber;
|
|
105
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
106
|
+
relatedType: Zod.ZodEnum<{
|
|
107
|
+
[x: string]: string;
|
|
108
|
+
}>;
|
|
109
|
+
afterParagraph: Zod.ZodNumber;
|
|
110
|
+
relatedSlug?: Zod.ZodOptional<Zod.ZodString> | undefined;
|
|
111
|
+
type: Zod.ZodLiteral<"monument-feature">;
|
|
112
|
+
}, Zod.z.core.$strip>, Zod.ZodObject<{
|
|
113
|
+
type: Zod.ZodLiteral<"article-feature">;
|
|
114
|
+
afterParagraph: Zod.ZodNumber;
|
|
115
|
+
}, Zod.z.core.$strip>], "type">;
|
|
116
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** The discriminated-union tags, in schema order. Factory gates import this instead of keeping a copy. */
|
|
2
|
+
export const INTERLUDE_TYPES = [
|
|
3
|
+
'quote', 'stat-fact', 'evidence-context', 'related-card', 'tldr', 'answer-box', 'field-note', 'caution',
|
|
4
|
+
'checklist', 'did-you-know', 'timeline', 'comparison-table', 'monument-feature', 'article-feature',
|
|
5
|
+
];
|
|
6
|
+
/**
|
|
7
|
+
* Per-section shape guard, applied as `z.array(schema).max(INTERLUDE_MAX_PER_SECTION)`. Not the budget: the
|
|
8
|
+
* article-wide cap is `sections.length - 1`, enforced by scripts/validate-articles.mjs, so at least one section of
|
|
9
|
+
* every article stays clean. Zod cannot see across sections.
|
|
10
|
+
*/
|
|
11
|
+
export const INTERLUDE_MAX_PER_SECTION = 2;
|
|
12
|
+
const nonEmpty = (values, field) => {
|
|
13
|
+
if (!values.length)
|
|
14
|
+
throw new Error(`createInterludeSchema: ${field} is empty`);
|
|
15
|
+
return values;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The 14-member interlude union. `z` is the site's own (`astro:content`), so the schema belongs to the zod instance
|
|
19
|
+
* the content layer validates with.
|
|
20
|
+
*/
|
|
21
|
+
export function createInterludeSchema(z, config) {
|
|
22
|
+
const relatedCardTypes = nonEmpty(config.relatedCardTypes, 'relatedCardTypes');
|
|
23
|
+
const featureTypes = nonEmpty(config.featureTypes.types, 'featureTypes.types');
|
|
24
|
+
const after = z.number().int().min(0);
|
|
25
|
+
const cited = (field) => z.string().min(1).max(260).regex(/\[\^\d+\]\s*$/, `${field} must end with a [^N] citation marker`);
|
|
26
|
+
return z.discriminatedUnion('type', [
|
|
27
|
+
z.object({
|
|
28
|
+
type: z.literal('quote'),
|
|
29
|
+
variant: z.enum(['pull', 'external']),
|
|
30
|
+
content: z.string(),
|
|
31
|
+
attribution: z.object({ name: z.string().min(1), role: z.string().optional() }).optional(),
|
|
32
|
+
afterParagraph: after,
|
|
33
|
+
}),
|
|
34
|
+
z.object({ type: z.literal('stat-fact'), figure: z.string().min(1), caption: z.string().min(1), afterParagraph: after }),
|
|
35
|
+
z.object({
|
|
36
|
+
type: z.literal('evidence-context'),
|
|
37
|
+
finding: cited('finding'),
|
|
38
|
+
basis: cited('basis'),
|
|
39
|
+
boundary: cited('boundary'),
|
|
40
|
+
afterParagraph: after,
|
|
41
|
+
}),
|
|
42
|
+
z.object({ type: z.literal('related-card'), relatedType: z.enum(relatedCardTypes), relatedSlug: z.string(), afterParagraph: after }),
|
|
43
|
+
z.object({
|
|
44
|
+
type: z.literal('tldr'),
|
|
45
|
+
// Exactly one of content (legacy prose) / bullets is enforced by scripts/validate-articles.mjs, which keeps
|
|
46
|
+
// this a plain object inside the union.
|
|
47
|
+
content: z.string().optional(),
|
|
48
|
+
bullets: z.array(z.string().min(1)).min(2).max(5).optional(),
|
|
49
|
+
title: z.string().max(40).optional(),
|
|
50
|
+
position: z.literal('end'),
|
|
51
|
+
}),
|
|
52
|
+
z.object({ type: z.literal('answer-box'), question: z.string().min(1).max(120), answer: z.string().min(1).max(400), afterParagraph: after }),
|
|
53
|
+
z.object({ type: z.literal('field-note'), content: z.string().min(1).max(400), afterParagraph: after }),
|
|
54
|
+
z.object({
|
|
55
|
+
type: z.literal('caution'),
|
|
56
|
+
variant: z.enum(['tip', 'warning', 'info']),
|
|
57
|
+
title: z.string().max(60).optional(),
|
|
58
|
+
content: z.string().min(1).max(400),
|
|
59
|
+
afterParagraph: after,
|
|
60
|
+
}),
|
|
61
|
+
z.object({
|
|
62
|
+
type: z.literal('checklist'),
|
|
63
|
+
title: z.string().min(1).max(60),
|
|
64
|
+
items: z.array(z.string().min(1).max(90)).min(3).max(8),
|
|
65
|
+
afterParagraph: after,
|
|
66
|
+
}),
|
|
67
|
+
// Unbounded on purpose: a Zod cap applies to the whole corpus and broke the build on a shipped article. The
|
|
68
|
+
// length bound lives in scripts/validate-articles.mjs, which only bites on touched files.
|
|
69
|
+
z.object({ type: z.literal('did-you-know'), content: z.string(), afterParagraph: after }),
|
|
70
|
+
z.object({
|
|
71
|
+
type: z.literal('timeline'),
|
|
72
|
+
events: z.array(z.object({ date: z.string(), label: z.string(), description: z.string().optional() })).min(2),
|
|
73
|
+
afterParagraph: after,
|
|
74
|
+
}),
|
|
75
|
+
z.object({
|
|
76
|
+
type: z.literal('comparison-table'),
|
|
77
|
+
headers: z.array(z.string()).min(2),
|
|
78
|
+
rows: z.array(z.array(z.string())).min(2),
|
|
79
|
+
afterParagraph: after,
|
|
80
|
+
}),
|
|
81
|
+
// The mandatory mid-article feature: despite the name, any entity with its own page. Without a pinned slug the
|
|
82
|
+
// template auto-selects it and injects relatedType/relatedSlug.
|
|
83
|
+
z.object({
|
|
84
|
+
type: z.literal('monument-feature'),
|
|
85
|
+
...(config.featureTypes.allowPinnedSlug ? { relatedSlug: z.string().optional() } : {}),
|
|
86
|
+
relatedType: z.enum(featureTypes),
|
|
87
|
+
afterParagraph: after,
|
|
88
|
+
}),
|
|
89
|
+
z.object({ type: z.literal('article-feature'), afterParagraph: after }),
|
|
90
|
+
]);
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sarimarcus/content-sites-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Non-visual utilities, Astro config builders and SEO schema assembly shared by the content sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
"./schema": {
|
|
62
62
|
"types": "./dist/schema/index.d.ts",
|
|
63
63
|
"default": "./dist/schema/index.js"
|
|
64
|
+
},
|
|
65
|
+
"./interludes": {
|
|
66
|
+
"types": "./dist/interludes/index.d.ts",
|
|
67
|
+
"default": "./dist/interludes/index.js"
|
|
64
68
|
}
|
|
65
69
|
},
|
|
66
70
|
"dependencies": {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Public surface of @sarimarcus/content-sites-core/interludes: the article interlude union a site's content.config
|
|
2
|
+
// applies to `sections[].interludes`, and the constants factory scripts read from dist.
|
|
3
|
+
import type { z as Zod } from 'astro/zod';
|
|
4
|
+
|
|
5
|
+
/** The discriminated-union tags, in schema order. Factory gates import this instead of keeping a copy. */
|
|
6
|
+
export const INTERLUDE_TYPES = [
|
|
7
|
+
'quote', 'stat-fact', 'evidence-context', 'related-card', 'tldr', 'answer-box', 'field-note', 'caution',
|
|
8
|
+
'checklist', 'did-you-know', 'timeline', 'comparison-table', 'monument-feature', 'article-feature',
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
export type InterludeType = (typeof INTERLUDE_TYPES)[number];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Per-section shape guard, applied as `z.array(schema).max(INTERLUDE_MAX_PER_SECTION)`. Not the budget: the
|
|
15
|
+
* article-wide cap is `sections.length - 1`, enforced by scripts/validate-articles.mjs, so at least one section of
|
|
16
|
+
* every article stays clean. Zod cannot see across sections.
|
|
17
|
+
*/
|
|
18
|
+
export const INTERLUDE_MAX_PER_SECTION = 2;
|
|
19
|
+
|
|
20
|
+
/** A site's entity taxonomy: the only part of the union that varies per site. */
|
|
21
|
+
export interface InterludeSchemaConfig {
|
|
22
|
+
/** Collections a `related-card` may point at (singular: 'monument', 'article'). */
|
|
23
|
+
relatedCardTypes: readonly string[];
|
|
24
|
+
featureTypes: {
|
|
25
|
+
/** Collections the mandatory `monument-feature` may feature. */
|
|
26
|
+
types: readonly string[];
|
|
27
|
+
/** Whether content may pin `relatedSlug`; false where the template injects it at build time. */
|
|
28
|
+
allowPinnedSlug: boolean;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The builders the union uses; `astro:content`'s `z` and `astro/zod` both satisfy it. */
|
|
33
|
+
type ZodBuilders = Pick<typeof Zod, 'discriminatedUnion' | 'object' | 'literal' | 'enum' | 'string' | 'number' | 'array'>;
|
|
34
|
+
|
|
35
|
+
const nonEmpty = (values: readonly string[], field: string): [string, ...string[]] => {
|
|
36
|
+
if (!values.length) throw new Error(`createInterludeSchema: ${field} is empty`);
|
|
37
|
+
return values as [string, ...string[]];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The 14-member interlude union. `z` is the site's own (`astro:content`), so the schema belongs to the zod instance
|
|
42
|
+
* the content layer validates with.
|
|
43
|
+
*/
|
|
44
|
+
export function createInterludeSchema(z: ZodBuilders, config: InterludeSchemaConfig) {
|
|
45
|
+
const relatedCardTypes = nonEmpty(config.relatedCardTypes, 'relatedCardTypes');
|
|
46
|
+
const featureTypes = nonEmpty(config.featureTypes.types, 'featureTypes.types');
|
|
47
|
+
const after = z.number().int().min(0);
|
|
48
|
+
const cited = (field: string) =>
|
|
49
|
+
z.string().min(1).max(260).regex(/\[\^\d+\]\s*$/, `${field} must end with a [^N] citation marker`);
|
|
50
|
+
|
|
51
|
+
return z.discriminatedUnion('type', [
|
|
52
|
+
z.object({
|
|
53
|
+
type: z.literal('quote'),
|
|
54
|
+
variant: z.enum(['pull', 'external']),
|
|
55
|
+
content: z.string(),
|
|
56
|
+
attribution: z.object({ name: z.string().min(1), role: z.string().optional() }).optional(),
|
|
57
|
+
afterParagraph: after,
|
|
58
|
+
}),
|
|
59
|
+
z.object({ type: z.literal('stat-fact'), figure: z.string().min(1), caption: z.string().min(1), afterParagraph: after }),
|
|
60
|
+
z.object({
|
|
61
|
+
type: z.literal('evidence-context'),
|
|
62
|
+
finding: cited('finding'),
|
|
63
|
+
basis: cited('basis'),
|
|
64
|
+
boundary: cited('boundary'),
|
|
65
|
+
afterParagraph: after,
|
|
66
|
+
}),
|
|
67
|
+
z.object({ type: z.literal('related-card'), relatedType: z.enum(relatedCardTypes), relatedSlug: z.string(), afterParagraph: after }),
|
|
68
|
+
z.object({
|
|
69
|
+
type: z.literal('tldr'),
|
|
70
|
+
// Exactly one of content (legacy prose) / bullets is enforced by scripts/validate-articles.mjs, which keeps
|
|
71
|
+
// this a plain object inside the union.
|
|
72
|
+
content: z.string().optional(),
|
|
73
|
+
bullets: z.array(z.string().min(1)).min(2).max(5).optional(),
|
|
74
|
+
title: z.string().max(40).optional(),
|
|
75
|
+
position: z.literal('end'),
|
|
76
|
+
}),
|
|
77
|
+
z.object({ type: z.literal('answer-box'), question: z.string().min(1).max(120), answer: z.string().min(1).max(400), afterParagraph: after }),
|
|
78
|
+
z.object({ type: z.literal('field-note'), content: z.string().min(1).max(400), afterParagraph: after }),
|
|
79
|
+
z.object({
|
|
80
|
+
type: z.literal('caution'),
|
|
81
|
+
variant: z.enum(['tip', 'warning', 'info']),
|
|
82
|
+
title: z.string().max(60).optional(),
|
|
83
|
+
content: z.string().min(1).max(400),
|
|
84
|
+
afterParagraph: after,
|
|
85
|
+
}),
|
|
86
|
+
z.object({
|
|
87
|
+
type: z.literal('checklist'),
|
|
88
|
+
title: z.string().min(1).max(60),
|
|
89
|
+
items: z.array(z.string().min(1).max(90)).min(3).max(8),
|
|
90
|
+
afterParagraph: after,
|
|
91
|
+
}),
|
|
92
|
+
// Unbounded on purpose: a Zod cap applies to the whole corpus and broke the build on a shipped article. The
|
|
93
|
+
// length bound lives in scripts/validate-articles.mjs, which only bites on touched files.
|
|
94
|
+
z.object({ type: z.literal('did-you-know'), content: z.string(), afterParagraph: after }),
|
|
95
|
+
z.object({
|
|
96
|
+
type: z.literal('timeline'),
|
|
97
|
+
events: z.array(z.object({ date: z.string(), label: z.string(), description: z.string().optional() })).min(2),
|
|
98
|
+
afterParagraph: after,
|
|
99
|
+
}),
|
|
100
|
+
z.object({
|
|
101
|
+
type: z.literal('comparison-table'),
|
|
102
|
+
headers: z.array(z.string()).min(2),
|
|
103
|
+
rows: z.array(z.array(z.string())).min(2),
|
|
104
|
+
afterParagraph: after,
|
|
105
|
+
}),
|
|
106
|
+
// The mandatory mid-article feature: despite the name, any entity with its own page. Without a pinned slug the
|
|
107
|
+
// template auto-selects it and injects relatedType/relatedSlug.
|
|
108
|
+
z.object({
|
|
109
|
+
type: z.literal('monument-feature'),
|
|
110
|
+
...(config.featureTypes.allowPinnedSlug ? { relatedSlug: z.string().optional() } : {}),
|
|
111
|
+
relatedType: z.enum(featureTypes),
|
|
112
|
+
afterParagraph: after,
|
|
113
|
+
}),
|
|
114
|
+
z.object({ type: z.literal('article-feature'), afterParagraph: after }),
|
|
115
|
+
]);
|
|
116
|
+
}
|