@weatherboard/cms 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -2
- package/dist/adapters/next/reader.d.cts +2 -0
- package/dist/adapters/next/reader.d.mts +2 -0
- package/dist/adapters/next/reader.d.ts +2 -0
- package/dist/adapters/sanity/adapter.d.cts +15 -0
- package/dist/adapters/sanity/adapter.d.mts +15 -0
- package/dist/adapters/sanity/adapter.d.ts +15 -0
- package/dist/contracts/query.d.cts +20 -0
- package/dist/contracts/query.d.mts +20 -0
- package/dist/contracts/query.d.ts +20 -0
- package/dist/contracts/text.d.cts +65 -0
- package/dist/contracts/text.d.mts +65 -0
- package/dist/contracts/text.d.ts +65 -0
- package/dist/guard.cjs +3 -0
- package/dist/guard.cjs.map +7 -0
- package/dist/guard.d.cts +2 -0
- package/dist/guard.d.mts +2 -0
- package/dist/guard.d.ts +2 -0
- package/dist/guard.mjs +3 -0
- package/dist/guard.mjs.map +7 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +7 -0
- package/dist/invalidation/route.d.cts +12 -0
- package/dist/invalidation/route.d.mts +12 -0
- package/dist/invalidation/route.d.ts +12 -0
- package/dist/next.cjs +2 -0
- package/dist/next.cjs.map +7 -0
- package/dist/next.d.cts +4 -0
- package/dist/next.d.mts +4 -0
- package/dist/next.d.ts +4 -0
- package/dist/next.mjs +2 -0
- package/dist/next.mjs.map +7 -0
- package/dist/portable/array.d.cts +2 -0
- package/dist/portable/array.d.mts +2 -0
- package/dist/portable/array.d.ts +2 -0
- package/dist/portable/fallback.d.cts +4 -0
- package/dist/portable/fallback.d.mts +4 -0
- package/dist/portable/fallback.d.ts +4 -0
- package/dist/portable/site-content.d.cts +220 -0
- package/dist/portable/site-content.d.mts +220 -0
- package/dist/portable/site-content.d.ts +220 -0
- package/dist/portable/webhook.d.cts +133 -0
- package/dist/portable/webhook.d.mts +133 -0
- package/dist/portable/webhook.d.ts +133 -0
- package/dist/runtime/reader.d.cts +51 -0
- package/dist/runtime/reader.d.mts +51 -0
- package/dist/runtime/reader.d.ts +51 -0
- package/dist/sanity.cjs +2 -0
- package/dist/sanity.cjs.map +7 -0
- package/dist/sanity.d.cts +3 -0
- package/dist/sanity.d.mts +3 -0
- package/dist/sanity.d.ts +3 -0
- package/dist/sanity.mjs +2 -0
- package/dist/sanity.mjs.map +7 -0
- package/dist/server.cjs +2 -0
- package/dist/server.cjs.map +7 -0
- package/dist/server.d.cts +3 -0
- package/dist/server.d.mts +3 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.mjs +2 -0
- package/dist/server.mjs.map +7 -0
- package/dist/site-content.cjs +7 -0
- package/dist/site-content.cjs.map +7 -0
- package/dist/site-content.d.cts +4 -0
- package/dist/site-content.d.mts +4 -0
- package/dist/site-content.d.ts +4 -0
- package/dist/site-content.mjs +7 -0
- package/dist/site-content.mjs.map +7 -0
- package/dist/testing.cjs +2 -0
- package/dist/testing.cjs.map +7 -0
- package/dist/testing.d.cts +2 -0
- package/dist/testing.d.mts +2 -0
- package/dist/testing.d.ts +2 -0
- package/dist/testing.mjs +2 -0
- package/dist/testing.mjs.map +7 -0
- package/dist/tooling/guard.d.cts +13 -0
- package/dist/tooling/guard.d.mts +13 -0
- package/dist/tooling/guard.d.ts +13 -0
- package/dist/tooling/test-fixtures/break-proof.d.cts +1 -0
- package/dist/tooling/test-fixtures/break-proof.d.mts +1 -0
- package/dist/tooling/test-fixtures/break-proof.d.ts +1 -0
- package/dist/tooling/test-fixtures/consumer.d.cts +9 -0
- package/dist/tooling/test-fixtures/consumer.d.mts +9 -0
- package/dist/tooling/test-fixtures/consumer.d.ts +9 -0
- package/dist/tooling/testing.d.cts +10 -0
- package/dist/tooling/testing.d.mts +10 -0
- package/dist/tooling/testing.d.ts +10 -0
- package/dist/webhook.cjs +2 -0
- package/dist/webhook.cjs.map +7 -0
- package/dist/webhook.d.cts +2 -0
- package/dist/webhook.d.mts +2 -0
- package/dist/webhook.d.ts +2 -0
- package/dist/webhook.mjs +2 -0
- package/dist/webhook.mjs.map +7 -0
- package/package.json +142 -3
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A-1713. The site content model, read: the home page, content pages and site
|
|
3
|
+
* settings, validated on read and returned in the shape a site's page
|
|
4
|
+
* composition consumes.
|
|
5
|
+
*
|
|
6
|
+
* ===========================================================================
|
|
7
|
+
* READ ONLY, BY CONSTRUCTION
|
|
8
|
+
* ===========================================================================
|
|
9
|
+
*
|
|
10
|
+
* A client site reads its content with a read-only token. All editing goes
|
|
11
|
+
* through the studio's dashboard, which writes to Sanity; nothing on a client
|
|
12
|
+
* site writes, and this module has no write path to call. It is handed a
|
|
13
|
+
* `ContentClient` (a `query` function) and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* ===========================================================================
|
|
16
|
+
* VALIDATED ON READ, BECAUSE SANITY VALIDATION ONLY ADVISES
|
|
17
|
+
* ===========================================================================
|
|
18
|
+
*
|
|
19
|
+
* The schema's rules warn an editor; they do not refuse a publish, and
|
|
20
|
+
* anything writing over the API skips them entirely. So every document is
|
|
21
|
+
* checked here again, and a malformed one is refused BY NAME
|
|
22
|
+
* (`MalformedContentError`, code `malformed-content`) listing every problem
|
|
23
|
+
* with its path, rather than rendered half-empty. A page that renders with a
|
|
24
|
+
* missing headline and a page whose content is broken look identical to a
|
|
25
|
+
* visitor; a build that fails naming `hero.headline` does not.
|
|
26
|
+
*
|
|
27
|
+
* Absent is not malformed: `null` from `home()` means nobody has created the
|
|
28
|
+
* home page yet, and a site decides what to do about that.
|
|
29
|
+
*
|
|
30
|
+
* ===========================================================================
|
|
31
|
+
* THE SHAPE IT RETURNS
|
|
32
|
+
* ===========================================================================
|
|
33
|
+
*
|
|
34
|
+
* The INPUT shape of a site's composition schema (the reference build's
|
|
35
|
+
* frontmatter): a copy value is a plain string for the client's own words
|
|
36
|
+
* with no claim about their source, `{ text, copySource: "captured" }` for
|
|
37
|
+
* words claimed verbatim from the capture (the site checks every such claim
|
|
38
|
+
* against its capture evidence, so the claim must survive Sanity), and
|
|
39
|
+
* `{ text, copySource: "proposed" }` for studio-written words awaiting
|
|
40
|
+
* approval. All three round-trip exactly; a photo is its library slug; every key a block declares is
|
|
41
|
+
* present, `null` where there is nothing. So a site validates this with the
|
|
42
|
+
* same schema it validates its Markdown frontmatter with, and its components
|
|
43
|
+
* never know where the content came from.
|
|
44
|
+
*
|
|
45
|
+
* Mapping from the generic model to the composition's names:
|
|
46
|
+
*
|
|
47
|
+
* hero.headline / subline / lead hero.heading / subline / intro
|
|
48
|
+
* hero.photos[0], [1] hero.image, hero.inset
|
|
49
|
+
* hero.primaryAction / secondary... hero.cta / secondaryCta
|
|
50
|
+
* campaign (when shown) the FIRST block, kind `campaign`, and
|
|
51
|
+
* the page body (`# title` + its text)
|
|
52
|
+
* featureStoriesBlock kind `feature_stories`
|
|
53
|
+
* surfaceCardBlock kind `cta`
|
|
54
|
+
* accentBandBlock kind `accent_band`
|
|
55
|
+
* locationsBlock kind `locations` (jumpLink → anchor)
|
|
56
|
+
*
|
|
57
|
+
* Two places the composition cannot carry a proposed marker: a `cta` block's
|
|
58
|
+
* plain strings, and the campaign's title and body (which travel as Markdown).
|
|
59
|
+
* There the text is returned plain and the line is still listed in
|
|
60
|
+
* `proposedCopy`, which is the list a dashboard and a site both read for
|
|
61
|
+
* approval. Nothing is dropped silently.
|
|
62
|
+
*/
|
|
63
|
+
import { type CmsQuery } from "../contracts/query.cjs";
|
|
64
|
+
export type CopyInput = string | {
|
|
65
|
+
readonly text: string;
|
|
66
|
+
readonly copySource: "captured" | "proposed";
|
|
67
|
+
};
|
|
68
|
+
export type ActionInput = {
|
|
69
|
+
readonly label: CopyInput;
|
|
70
|
+
readonly href: string;
|
|
71
|
+
};
|
|
72
|
+
export type HeroInput = {
|
|
73
|
+
readonly heading: CopyInput;
|
|
74
|
+
readonly subline: CopyInput | null;
|
|
75
|
+
readonly intro: CopyInput | null;
|
|
76
|
+
readonly image: string | null;
|
|
77
|
+
readonly inset: string | null;
|
|
78
|
+
readonly cta: ActionInput | null;
|
|
79
|
+
readonly secondaryCta: ActionInput | null;
|
|
80
|
+
};
|
|
81
|
+
export type StoryInput = {
|
|
82
|
+
readonly heading: CopyInput;
|
|
83
|
+
readonly body: CopyInput;
|
|
84
|
+
readonly action: ActionInput;
|
|
85
|
+
readonly image: string | null;
|
|
86
|
+
readonly shape: "wide" | "tall" | "round" | "free";
|
|
87
|
+
readonly card: boolean;
|
|
88
|
+
};
|
|
89
|
+
export type CompositionBlockInput = {
|
|
90
|
+
readonly kind: "campaign";
|
|
91
|
+
readonly action: ActionInput | null;
|
|
92
|
+
readonly images: readonly string[];
|
|
93
|
+
} | {
|
|
94
|
+
readonly kind: "locations";
|
|
95
|
+
readonly anchor: string | null;
|
|
96
|
+
readonly heading: CopyInput;
|
|
97
|
+
readonly tail: CopyInput | null;
|
|
98
|
+
readonly image: string | null;
|
|
99
|
+
} | {
|
|
100
|
+
readonly kind: "feature_stories";
|
|
101
|
+
readonly heading: CopyInput;
|
|
102
|
+
readonly tail: CopyInput | null;
|
|
103
|
+
readonly items: readonly StoryInput[];
|
|
104
|
+
} | {
|
|
105
|
+
readonly kind: "accent_band";
|
|
106
|
+
readonly heading: CopyInput;
|
|
107
|
+
readonly body: CopyInput;
|
|
108
|
+
readonly action: ActionInput | null;
|
|
109
|
+
readonly image: string | null;
|
|
110
|
+
} | {
|
|
111
|
+
readonly kind: "cta";
|
|
112
|
+
readonly heading: string;
|
|
113
|
+
readonly body: string | null;
|
|
114
|
+
readonly link: {
|
|
115
|
+
readonly label: string;
|
|
116
|
+
readonly href: string;
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
export type CompositionInput = {
|
|
120
|
+
readonly intro: string | null;
|
|
121
|
+
readonly photo: string | null;
|
|
122
|
+
readonly hero: HeroInput | null;
|
|
123
|
+
readonly blocks: readonly CompositionBlockInput[];
|
|
124
|
+
};
|
|
125
|
+
/** One line of studio-written copy awaiting approval, and where it is in the document. */
|
|
126
|
+
export type ProposedCopy = {
|
|
127
|
+
readonly where: string;
|
|
128
|
+
readonly text: string;
|
|
129
|
+
};
|
|
130
|
+
export type CampaignState = "shown" | "off" | "not-started" | "ended" | "none";
|
|
131
|
+
export type HomeContent = {
|
|
132
|
+
readonly composition: CompositionInput;
|
|
133
|
+
/** The page body as Markdown: the campaign's title and text when it is shown, otherwise empty. */
|
|
134
|
+
readonly body: string;
|
|
135
|
+
readonly campaign: CampaignState;
|
|
136
|
+
readonly proposedCopy: readonly ProposedCopy[];
|
|
137
|
+
};
|
|
138
|
+
export type PageContent = {
|
|
139
|
+
readonly slug: string;
|
|
140
|
+
readonly title: string;
|
|
141
|
+
readonly composition: CompositionInput;
|
|
142
|
+
readonly body: string;
|
|
143
|
+
readonly migrated: boolean;
|
|
144
|
+
readonly proposedCopy: readonly ProposedCopy[];
|
|
145
|
+
};
|
|
146
|
+
export type SiteSettingsContent = {
|
|
147
|
+
readonly name: string;
|
|
148
|
+
readonly description: string | null;
|
|
149
|
+
readonly contact: {
|
|
150
|
+
readonly email: string | null;
|
|
151
|
+
readonly phone: string | null;
|
|
152
|
+
};
|
|
153
|
+
readonly social: readonly {
|
|
154
|
+
readonly network: string;
|
|
155
|
+
readonly url: string;
|
|
156
|
+
}[];
|
|
157
|
+
};
|
|
158
|
+
export declare const MALFORMED_CONTENT = "malformed-content";
|
|
159
|
+
export declare class MalformedContentError extends Error {
|
|
160
|
+
readonly code = "malformed-content";
|
|
161
|
+
readonly documentId: string;
|
|
162
|
+
readonly problems: readonly string[];
|
|
163
|
+
constructor(documentId: string, problems: readonly string[]);
|
|
164
|
+
}
|
|
165
|
+
export declare const READER_PHOTO_SLUG: RegExp;
|
|
166
|
+
export declare const READER_ACTION_HREF: RegExp;
|
|
167
|
+
export declare const READER_JUMP_LINK: RegExp;
|
|
168
|
+
export declare const READER_STORY_LAYOUTS: readonly ["wide", "tall", "round", "free"];
|
|
169
|
+
export declare const READER_HOME_PAGE_ID = "homePage";
|
|
170
|
+
export declare const READER_SITE_SETTINGS_ID = "siteSettings";
|
|
171
|
+
/**
|
|
172
|
+
* The narrow Portable Text `portableText` declares, back to Markdown: normal,
|
|
173
|
+
* h2, h3 and quote blocks, bullet and numbered lists, bold, italic, code and
|
|
174
|
+
* links. It is the inverse of the seed package's Markdown mapper, and the
|
|
175
|
+
* round-trip test holds the two to each other.
|
|
176
|
+
*
|
|
177
|
+
* An uploaded image inside rich text is REFUSED, not dropped: nothing can
|
|
178
|
+
* upload one yet (the dashboard slice decides how), so one arriving means
|
|
179
|
+
* something wrote the document by hand, and a page silently missing a picture
|
|
180
|
+
* is the failure this reader exists to prevent.
|
|
181
|
+
*/
|
|
182
|
+
export declare function portableTextToMarkdown(value: unknown): string;
|
|
183
|
+
/** Whether a campaign is shown at `now`, and if not, why not. */
|
|
184
|
+
export declare function campaignState(campaign: unknown, now: Date): CampaignState;
|
|
185
|
+
/**
|
|
186
|
+
* A stored home page document, validated and mapped. Throws
|
|
187
|
+
* `MalformedContentError` naming every problem. `now` decides whether a
|
|
188
|
+
* scheduled campaign is shown, and is a parameter so a build, a preview and a
|
|
189
|
+
* test each say which moment they mean.
|
|
190
|
+
*/
|
|
191
|
+
export declare function parseHomePage(document: unknown, options: {
|
|
192
|
+
readonly now: Date;
|
|
193
|
+
}): HomeContent;
|
|
194
|
+
export declare function parseContentPage(document: unknown): PageContent;
|
|
195
|
+
export declare function parseSiteSettings(document: unknown): SiteSettingsContent;
|
|
196
|
+
/** By fixed id: a second home page at a random id is never read. */
|
|
197
|
+
export declare const HOME_PAGE_DOCUMENT_QUERY = "*[_id == $id][0]";
|
|
198
|
+
export declare const CONTENT_PAGES_QUERY = "*[_type == \"page\" && defined(slug.current)]|order(slug.current asc)";
|
|
199
|
+
export declare const CONTENT_PAGE_BY_SLUG_QUERY = "*[_type == \"page\" && slug.current == $slug][0]";
|
|
200
|
+
export interface SiteContentReader {
|
|
201
|
+
/** `null` when no home page document exists yet. */
|
|
202
|
+
home: (options: {
|
|
203
|
+
readonly now: Date;
|
|
204
|
+
}) => Promise<HomeContent | null>;
|
|
205
|
+
pages: () => Promise<PageContent[]>;
|
|
206
|
+
/** `null` when no page has that slug. */
|
|
207
|
+
pageBySlug: (slug: string) => Promise<PageContent | null>;
|
|
208
|
+
/** `null` before anyone has filled in the site's details. */
|
|
209
|
+
siteSettings: () => Promise<SiteSettingsContent | null>;
|
|
210
|
+
}
|
|
211
|
+
export declare function createSiteContentReader(client: {
|
|
212
|
+
query: <T>(groq: string, options?: {
|
|
213
|
+
params?: Record<string, unknown>;
|
|
214
|
+
tags?: readonly string[];
|
|
215
|
+
}) => Promise<T>;
|
|
216
|
+
}): SiteContentReader;
|
|
217
|
+
export declare function homeContentQuery(now: Date): CmsQuery<HomeContent | null>;
|
|
218
|
+
export declare function contentPageQuery(slug: string): CmsQuery<PageContent | null>;
|
|
219
|
+
export declare function contentPagesQuery(): CmsQuery<readonly PageContent[]>;
|
|
220
|
+
export declare function siteSettingsQuery(): CmsQuery<SiteSettingsContent | null>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A-1713. The site content model, read: the home page, content pages and site
|
|
3
|
+
* settings, validated on read and returned in the shape a site's page
|
|
4
|
+
* composition consumes.
|
|
5
|
+
*
|
|
6
|
+
* ===========================================================================
|
|
7
|
+
* READ ONLY, BY CONSTRUCTION
|
|
8
|
+
* ===========================================================================
|
|
9
|
+
*
|
|
10
|
+
* A client site reads its content with a read-only token. All editing goes
|
|
11
|
+
* through the studio's dashboard, which writes to Sanity; nothing on a client
|
|
12
|
+
* site writes, and this module has no write path to call. It is handed a
|
|
13
|
+
* `ContentClient` (a `query` function) and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* ===========================================================================
|
|
16
|
+
* VALIDATED ON READ, BECAUSE SANITY VALIDATION ONLY ADVISES
|
|
17
|
+
* ===========================================================================
|
|
18
|
+
*
|
|
19
|
+
* The schema's rules warn an editor; they do not refuse a publish, and
|
|
20
|
+
* anything writing over the API skips them entirely. So every document is
|
|
21
|
+
* checked here again, and a malformed one is refused BY NAME
|
|
22
|
+
* (`MalformedContentError`, code `malformed-content`) listing every problem
|
|
23
|
+
* with its path, rather than rendered half-empty. A page that renders with a
|
|
24
|
+
* missing headline and a page whose content is broken look identical to a
|
|
25
|
+
* visitor; a build that fails naming `hero.headline` does not.
|
|
26
|
+
*
|
|
27
|
+
* Absent is not malformed: `null` from `home()` means nobody has created the
|
|
28
|
+
* home page yet, and a site decides what to do about that.
|
|
29
|
+
*
|
|
30
|
+
* ===========================================================================
|
|
31
|
+
* THE SHAPE IT RETURNS
|
|
32
|
+
* ===========================================================================
|
|
33
|
+
*
|
|
34
|
+
* The INPUT shape of a site's composition schema (the reference build's
|
|
35
|
+
* frontmatter): a copy value is a plain string for the client's own words
|
|
36
|
+
* with no claim about their source, `{ text, copySource: "captured" }` for
|
|
37
|
+
* words claimed verbatim from the capture (the site checks every such claim
|
|
38
|
+
* against its capture evidence, so the claim must survive Sanity), and
|
|
39
|
+
* `{ text, copySource: "proposed" }` for studio-written words awaiting
|
|
40
|
+
* approval. All three round-trip exactly; a photo is its library slug; every key a block declares is
|
|
41
|
+
* present, `null` where there is nothing. So a site validates this with the
|
|
42
|
+
* same schema it validates its Markdown frontmatter with, and its components
|
|
43
|
+
* never know where the content came from.
|
|
44
|
+
*
|
|
45
|
+
* Mapping from the generic model to the composition's names:
|
|
46
|
+
*
|
|
47
|
+
* hero.headline / subline / lead hero.heading / subline / intro
|
|
48
|
+
* hero.photos[0], [1] hero.image, hero.inset
|
|
49
|
+
* hero.primaryAction / secondary... hero.cta / secondaryCta
|
|
50
|
+
* campaign (when shown) the FIRST block, kind `campaign`, and
|
|
51
|
+
* the page body (`# title` + its text)
|
|
52
|
+
* featureStoriesBlock kind `feature_stories`
|
|
53
|
+
* surfaceCardBlock kind `cta`
|
|
54
|
+
* accentBandBlock kind `accent_band`
|
|
55
|
+
* locationsBlock kind `locations` (jumpLink → anchor)
|
|
56
|
+
*
|
|
57
|
+
* Two places the composition cannot carry a proposed marker: a `cta` block's
|
|
58
|
+
* plain strings, and the campaign's title and body (which travel as Markdown).
|
|
59
|
+
* There the text is returned plain and the line is still listed in
|
|
60
|
+
* `proposedCopy`, which is the list a dashboard and a site both read for
|
|
61
|
+
* approval. Nothing is dropped silently.
|
|
62
|
+
*/
|
|
63
|
+
import { type CmsQuery } from "../contracts/query.mjs";
|
|
64
|
+
export type CopyInput = string | {
|
|
65
|
+
readonly text: string;
|
|
66
|
+
readonly copySource: "captured" | "proposed";
|
|
67
|
+
};
|
|
68
|
+
export type ActionInput = {
|
|
69
|
+
readonly label: CopyInput;
|
|
70
|
+
readonly href: string;
|
|
71
|
+
};
|
|
72
|
+
export type HeroInput = {
|
|
73
|
+
readonly heading: CopyInput;
|
|
74
|
+
readonly subline: CopyInput | null;
|
|
75
|
+
readonly intro: CopyInput | null;
|
|
76
|
+
readonly image: string | null;
|
|
77
|
+
readonly inset: string | null;
|
|
78
|
+
readonly cta: ActionInput | null;
|
|
79
|
+
readonly secondaryCta: ActionInput | null;
|
|
80
|
+
};
|
|
81
|
+
export type StoryInput = {
|
|
82
|
+
readonly heading: CopyInput;
|
|
83
|
+
readonly body: CopyInput;
|
|
84
|
+
readonly action: ActionInput;
|
|
85
|
+
readonly image: string | null;
|
|
86
|
+
readonly shape: "wide" | "tall" | "round" | "free";
|
|
87
|
+
readonly card: boolean;
|
|
88
|
+
};
|
|
89
|
+
export type CompositionBlockInput = {
|
|
90
|
+
readonly kind: "campaign";
|
|
91
|
+
readonly action: ActionInput | null;
|
|
92
|
+
readonly images: readonly string[];
|
|
93
|
+
} | {
|
|
94
|
+
readonly kind: "locations";
|
|
95
|
+
readonly anchor: string | null;
|
|
96
|
+
readonly heading: CopyInput;
|
|
97
|
+
readonly tail: CopyInput | null;
|
|
98
|
+
readonly image: string | null;
|
|
99
|
+
} | {
|
|
100
|
+
readonly kind: "feature_stories";
|
|
101
|
+
readonly heading: CopyInput;
|
|
102
|
+
readonly tail: CopyInput | null;
|
|
103
|
+
readonly items: readonly StoryInput[];
|
|
104
|
+
} | {
|
|
105
|
+
readonly kind: "accent_band";
|
|
106
|
+
readonly heading: CopyInput;
|
|
107
|
+
readonly body: CopyInput;
|
|
108
|
+
readonly action: ActionInput | null;
|
|
109
|
+
readonly image: string | null;
|
|
110
|
+
} | {
|
|
111
|
+
readonly kind: "cta";
|
|
112
|
+
readonly heading: string;
|
|
113
|
+
readonly body: string | null;
|
|
114
|
+
readonly link: {
|
|
115
|
+
readonly label: string;
|
|
116
|
+
readonly href: string;
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
export type CompositionInput = {
|
|
120
|
+
readonly intro: string | null;
|
|
121
|
+
readonly photo: string | null;
|
|
122
|
+
readonly hero: HeroInput | null;
|
|
123
|
+
readonly blocks: readonly CompositionBlockInput[];
|
|
124
|
+
};
|
|
125
|
+
/** One line of studio-written copy awaiting approval, and where it is in the document. */
|
|
126
|
+
export type ProposedCopy = {
|
|
127
|
+
readonly where: string;
|
|
128
|
+
readonly text: string;
|
|
129
|
+
};
|
|
130
|
+
export type CampaignState = "shown" | "off" | "not-started" | "ended" | "none";
|
|
131
|
+
export type HomeContent = {
|
|
132
|
+
readonly composition: CompositionInput;
|
|
133
|
+
/** The page body as Markdown: the campaign's title and text when it is shown, otherwise empty. */
|
|
134
|
+
readonly body: string;
|
|
135
|
+
readonly campaign: CampaignState;
|
|
136
|
+
readonly proposedCopy: readonly ProposedCopy[];
|
|
137
|
+
};
|
|
138
|
+
export type PageContent = {
|
|
139
|
+
readonly slug: string;
|
|
140
|
+
readonly title: string;
|
|
141
|
+
readonly composition: CompositionInput;
|
|
142
|
+
readonly body: string;
|
|
143
|
+
readonly migrated: boolean;
|
|
144
|
+
readonly proposedCopy: readonly ProposedCopy[];
|
|
145
|
+
};
|
|
146
|
+
export type SiteSettingsContent = {
|
|
147
|
+
readonly name: string;
|
|
148
|
+
readonly description: string | null;
|
|
149
|
+
readonly contact: {
|
|
150
|
+
readonly email: string | null;
|
|
151
|
+
readonly phone: string | null;
|
|
152
|
+
};
|
|
153
|
+
readonly social: readonly {
|
|
154
|
+
readonly network: string;
|
|
155
|
+
readonly url: string;
|
|
156
|
+
}[];
|
|
157
|
+
};
|
|
158
|
+
export declare const MALFORMED_CONTENT = "malformed-content";
|
|
159
|
+
export declare class MalformedContentError extends Error {
|
|
160
|
+
readonly code = "malformed-content";
|
|
161
|
+
readonly documentId: string;
|
|
162
|
+
readonly problems: readonly string[];
|
|
163
|
+
constructor(documentId: string, problems: readonly string[]);
|
|
164
|
+
}
|
|
165
|
+
export declare const READER_PHOTO_SLUG: RegExp;
|
|
166
|
+
export declare const READER_ACTION_HREF: RegExp;
|
|
167
|
+
export declare const READER_JUMP_LINK: RegExp;
|
|
168
|
+
export declare const READER_STORY_LAYOUTS: readonly ["wide", "tall", "round", "free"];
|
|
169
|
+
export declare const READER_HOME_PAGE_ID = "homePage";
|
|
170
|
+
export declare const READER_SITE_SETTINGS_ID = "siteSettings";
|
|
171
|
+
/**
|
|
172
|
+
* The narrow Portable Text `portableText` declares, back to Markdown: normal,
|
|
173
|
+
* h2, h3 and quote blocks, bullet and numbered lists, bold, italic, code and
|
|
174
|
+
* links. It is the inverse of the seed package's Markdown mapper, and the
|
|
175
|
+
* round-trip test holds the two to each other.
|
|
176
|
+
*
|
|
177
|
+
* An uploaded image inside rich text is REFUSED, not dropped: nothing can
|
|
178
|
+
* upload one yet (the dashboard slice decides how), so one arriving means
|
|
179
|
+
* something wrote the document by hand, and a page silently missing a picture
|
|
180
|
+
* is the failure this reader exists to prevent.
|
|
181
|
+
*/
|
|
182
|
+
export declare function portableTextToMarkdown(value: unknown): string;
|
|
183
|
+
/** Whether a campaign is shown at `now`, and if not, why not. */
|
|
184
|
+
export declare function campaignState(campaign: unknown, now: Date): CampaignState;
|
|
185
|
+
/**
|
|
186
|
+
* A stored home page document, validated and mapped. Throws
|
|
187
|
+
* `MalformedContentError` naming every problem. `now` decides whether a
|
|
188
|
+
* scheduled campaign is shown, and is a parameter so a build, a preview and a
|
|
189
|
+
* test each say which moment they mean.
|
|
190
|
+
*/
|
|
191
|
+
export declare function parseHomePage(document: unknown, options: {
|
|
192
|
+
readonly now: Date;
|
|
193
|
+
}): HomeContent;
|
|
194
|
+
export declare function parseContentPage(document: unknown): PageContent;
|
|
195
|
+
export declare function parseSiteSettings(document: unknown): SiteSettingsContent;
|
|
196
|
+
/** By fixed id: a second home page at a random id is never read. */
|
|
197
|
+
export declare const HOME_PAGE_DOCUMENT_QUERY = "*[_id == $id][0]";
|
|
198
|
+
export declare const CONTENT_PAGES_QUERY = "*[_type == \"page\" && defined(slug.current)]|order(slug.current asc)";
|
|
199
|
+
export declare const CONTENT_PAGE_BY_SLUG_QUERY = "*[_type == \"page\" && slug.current == $slug][0]";
|
|
200
|
+
export interface SiteContentReader {
|
|
201
|
+
/** `null` when no home page document exists yet. */
|
|
202
|
+
home: (options: {
|
|
203
|
+
readonly now: Date;
|
|
204
|
+
}) => Promise<HomeContent | null>;
|
|
205
|
+
pages: () => Promise<PageContent[]>;
|
|
206
|
+
/** `null` when no page has that slug. */
|
|
207
|
+
pageBySlug: (slug: string) => Promise<PageContent | null>;
|
|
208
|
+
/** `null` before anyone has filled in the site's details. */
|
|
209
|
+
siteSettings: () => Promise<SiteSettingsContent | null>;
|
|
210
|
+
}
|
|
211
|
+
export declare function createSiteContentReader(client: {
|
|
212
|
+
query: <T>(groq: string, options?: {
|
|
213
|
+
params?: Record<string, unknown>;
|
|
214
|
+
tags?: readonly string[];
|
|
215
|
+
}) => Promise<T>;
|
|
216
|
+
}): SiteContentReader;
|
|
217
|
+
export declare function homeContentQuery(now: Date): CmsQuery<HomeContent | null>;
|
|
218
|
+
export declare function contentPageQuery(slug: string): CmsQuery<PageContent | null>;
|
|
219
|
+
export declare function contentPagesQuery(): CmsQuery<readonly PageContent[]>;
|
|
220
|
+
export declare function siteSettingsQuery(): CmsQuery<SiteSettingsContent | null>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A-1713. The site content model, read: the home page, content pages and site
|
|
3
|
+
* settings, validated on read and returned in the shape a site's page
|
|
4
|
+
* composition consumes.
|
|
5
|
+
*
|
|
6
|
+
* ===========================================================================
|
|
7
|
+
* READ ONLY, BY CONSTRUCTION
|
|
8
|
+
* ===========================================================================
|
|
9
|
+
*
|
|
10
|
+
* A client site reads its content with a read-only token. All editing goes
|
|
11
|
+
* through the studio's dashboard, which writes to Sanity; nothing on a client
|
|
12
|
+
* site writes, and this module has no write path to call. It is handed a
|
|
13
|
+
* `ContentClient` (a `query` function) and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* ===========================================================================
|
|
16
|
+
* VALIDATED ON READ, BECAUSE SANITY VALIDATION ONLY ADVISES
|
|
17
|
+
* ===========================================================================
|
|
18
|
+
*
|
|
19
|
+
* The schema's rules warn an editor; they do not refuse a publish, and
|
|
20
|
+
* anything writing over the API skips them entirely. So every document is
|
|
21
|
+
* checked here again, and a malformed one is refused BY NAME
|
|
22
|
+
* (`MalformedContentError`, code `malformed-content`) listing every problem
|
|
23
|
+
* with its path, rather than rendered half-empty. A page that renders with a
|
|
24
|
+
* missing headline and a page whose content is broken look identical to a
|
|
25
|
+
* visitor; a build that fails naming `hero.headline` does not.
|
|
26
|
+
*
|
|
27
|
+
* Absent is not malformed: `null` from `home()` means nobody has created the
|
|
28
|
+
* home page yet, and a site decides what to do about that.
|
|
29
|
+
*
|
|
30
|
+
* ===========================================================================
|
|
31
|
+
* THE SHAPE IT RETURNS
|
|
32
|
+
* ===========================================================================
|
|
33
|
+
*
|
|
34
|
+
* The INPUT shape of a site's composition schema (the reference build's
|
|
35
|
+
* frontmatter): a copy value is a plain string for the client's own words
|
|
36
|
+
* with no claim about their source, `{ text, copySource: "captured" }` for
|
|
37
|
+
* words claimed verbatim from the capture (the site checks every such claim
|
|
38
|
+
* against its capture evidence, so the claim must survive Sanity), and
|
|
39
|
+
* `{ text, copySource: "proposed" }` for studio-written words awaiting
|
|
40
|
+
* approval. All three round-trip exactly; a photo is its library slug; every key a block declares is
|
|
41
|
+
* present, `null` where there is nothing. So a site validates this with the
|
|
42
|
+
* same schema it validates its Markdown frontmatter with, and its components
|
|
43
|
+
* never know where the content came from.
|
|
44
|
+
*
|
|
45
|
+
* Mapping from the generic model to the composition's names:
|
|
46
|
+
*
|
|
47
|
+
* hero.headline / subline / lead hero.heading / subline / intro
|
|
48
|
+
* hero.photos[0], [1] hero.image, hero.inset
|
|
49
|
+
* hero.primaryAction / secondary... hero.cta / secondaryCta
|
|
50
|
+
* campaign (when shown) the FIRST block, kind `campaign`, and
|
|
51
|
+
* the page body (`# title` + its text)
|
|
52
|
+
* featureStoriesBlock kind `feature_stories`
|
|
53
|
+
* surfaceCardBlock kind `cta`
|
|
54
|
+
* accentBandBlock kind `accent_band`
|
|
55
|
+
* locationsBlock kind `locations` (jumpLink → anchor)
|
|
56
|
+
*
|
|
57
|
+
* Two places the composition cannot carry a proposed marker: a `cta` block's
|
|
58
|
+
* plain strings, and the campaign's title and body (which travel as Markdown).
|
|
59
|
+
* There the text is returned plain and the line is still listed in
|
|
60
|
+
* `proposedCopy`, which is the list a dashboard and a site both read for
|
|
61
|
+
* approval. Nothing is dropped silently.
|
|
62
|
+
*/
|
|
63
|
+
import { type CmsQuery } from "../contracts/query.js";
|
|
64
|
+
export type CopyInput = string | {
|
|
65
|
+
readonly text: string;
|
|
66
|
+
readonly copySource: "captured" | "proposed";
|
|
67
|
+
};
|
|
68
|
+
export type ActionInput = {
|
|
69
|
+
readonly label: CopyInput;
|
|
70
|
+
readonly href: string;
|
|
71
|
+
};
|
|
72
|
+
export type HeroInput = {
|
|
73
|
+
readonly heading: CopyInput;
|
|
74
|
+
readonly subline: CopyInput | null;
|
|
75
|
+
readonly intro: CopyInput | null;
|
|
76
|
+
readonly image: string | null;
|
|
77
|
+
readonly inset: string | null;
|
|
78
|
+
readonly cta: ActionInput | null;
|
|
79
|
+
readonly secondaryCta: ActionInput | null;
|
|
80
|
+
};
|
|
81
|
+
export type StoryInput = {
|
|
82
|
+
readonly heading: CopyInput;
|
|
83
|
+
readonly body: CopyInput;
|
|
84
|
+
readonly action: ActionInput;
|
|
85
|
+
readonly image: string | null;
|
|
86
|
+
readonly shape: "wide" | "tall" | "round" | "free";
|
|
87
|
+
readonly card: boolean;
|
|
88
|
+
};
|
|
89
|
+
export type CompositionBlockInput = {
|
|
90
|
+
readonly kind: "campaign";
|
|
91
|
+
readonly action: ActionInput | null;
|
|
92
|
+
readonly images: readonly string[];
|
|
93
|
+
} | {
|
|
94
|
+
readonly kind: "locations";
|
|
95
|
+
readonly anchor: string | null;
|
|
96
|
+
readonly heading: CopyInput;
|
|
97
|
+
readonly tail: CopyInput | null;
|
|
98
|
+
readonly image: string | null;
|
|
99
|
+
} | {
|
|
100
|
+
readonly kind: "feature_stories";
|
|
101
|
+
readonly heading: CopyInput;
|
|
102
|
+
readonly tail: CopyInput | null;
|
|
103
|
+
readonly items: readonly StoryInput[];
|
|
104
|
+
} | {
|
|
105
|
+
readonly kind: "accent_band";
|
|
106
|
+
readonly heading: CopyInput;
|
|
107
|
+
readonly body: CopyInput;
|
|
108
|
+
readonly action: ActionInput | null;
|
|
109
|
+
readonly image: string | null;
|
|
110
|
+
} | {
|
|
111
|
+
readonly kind: "cta";
|
|
112
|
+
readonly heading: string;
|
|
113
|
+
readonly body: string | null;
|
|
114
|
+
readonly link: {
|
|
115
|
+
readonly label: string;
|
|
116
|
+
readonly href: string;
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
export type CompositionInput = {
|
|
120
|
+
readonly intro: string | null;
|
|
121
|
+
readonly photo: string | null;
|
|
122
|
+
readonly hero: HeroInput | null;
|
|
123
|
+
readonly blocks: readonly CompositionBlockInput[];
|
|
124
|
+
};
|
|
125
|
+
/** One line of studio-written copy awaiting approval, and where it is in the document. */
|
|
126
|
+
export type ProposedCopy = {
|
|
127
|
+
readonly where: string;
|
|
128
|
+
readonly text: string;
|
|
129
|
+
};
|
|
130
|
+
export type CampaignState = "shown" | "off" | "not-started" | "ended" | "none";
|
|
131
|
+
export type HomeContent = {
|
|
132
|
+
readonly composition: CompositionInput;
|
|
133
|
+
/** The page body as Markdown: the campaign's title and text when it is shown, otherwise empty. */
|
|
134
|
+
readonly body: string;
|
|
135
|
+
readonly campaign: CampaignState;
|
|
136
|
+
readonly proposedCopy: readonly ProposedCopy[];
|
|
137
|
+
};
|
|
138
|
+
export type PageContent = {
|
|
139
|
+
readonly slug: string;
|
|
140
|
+
readonly title: string;
|
|
141
|
+
readonly composition: CompositionInput;
|
|
142
|
+
readonly body: string;
|
|
143
|
+
readonly migrated: boolean;
|
|
144
|
+
readonly proposedCopy: readonly ProposedCopy[];
|
|
145
|
+
};
|
|
146
|
+
export type SiteSettingsContent = {
|
|
147
|
+
readonly name: string;
|
|
148
|
+
readonly description: string | null;
|
|
149
|
+
readonly contact: {
|
|
150
|
+
readonly email: string | null;
|
|
151
|
+
readonly phone: string | null;
|
|
152
|
+
};
|
|
153
|
+
readonly social: readonly {
|
|
154
|
+
readonly network: string;
|
|
155
|
+
readonly url: string;
|
|
156
|
+
}[];
|
|
157
|
+
};
|
|
158
|
+
export declare const MALFORMED_CONTENT = "malformed-content";
|
|
159
|
+
export declare class MalformedContentError extends Error {
|
|
160
|
+
readonly code = "malformed-content";
|
|
161
|
+
readonly documentId: string;
|
|
162
|
+
readonly problems: readonly string[];
|
|
163
|
+
constructor(documentId: string, problems: readonly string[]);
|
|
164
|
+
}
|
|
165
|
+
export declare const READER_PHOTO_SLUG: RegExp;
|
|
166
|
+
export declare const READER_ACTION_HREF: RegExp;
|
|
167
|
+
export declare const READER_JUMP_LINK: RegExp;
|
|
168
|
+
export declare const READER_STORY_LAYOUTS: readonly ["wide", "tall", "round", "free"];
|
|
169
|
+
export declare const READER_HOME_PAGE_ID = "homePage";
|
|
170
|
+
export declare const READER_SITE_SETTINGS_ID = "siteSettings";
|
|
171
|
+
/**
|
|
172
|
+
* The narrow Portable Text `portableText` declares, back to Markdown: normal,
|
|
173
|
+
* h2, h3 and quote blocks, bullet and numbered lists, bold, italic, code and
|
|
174
|
+
* links. It is the inverse of the seed package's Markdown mapper, and the
|
|
175
|
+
* round-trip test holds the two to each other.
|
|
176
|
+
*
|
|
177
|
+
* An uploaded image inside rich text is REFUSED, not dropped: nothing can
|
|
178
|
+
* upload one yet (the dashboard slice decides how), so one arriving means
|
|
179
|
+
* something wrote the document by hand, and a page silently missing a picture
|
|
180
|
+
* is the failure this reader exists to prevent.
|
|
181
|
+
*/
|
|
182
|
+
export declare function portableTextToMarkdown(value: unknown): string;
|
|
183
|
+
/** Whether a campaign is shown at `now`, and if not, why not. */
|
|
184
|
+
export declare function campaignState(campaign: unknown, now: Date): CampaignState;
|
|
185
|
+
/**
|
|
186
|
+
* A stored home page document, validated and mapped. Throws
|
|
187
|
+
* `MalformedContentError` naming every problem. `now` decides whether a
|
|
188
|
+
* scheduled campaign is shown, and is a parameter so a build, a preview and a
|
|
189
|
+
* test each say which moment they mean.
|
|
190
|
+
*/
|
|
191
|
+
export declare function parseHomePage(document: unknown, options: {
|
|
192
|
+
readonly now: Date;
|
|
193
|
+
}): HomeContent;
|
|
194
|
+
export declare function parseContentPage(document: unknown): PageContent;
|
|
195
|
+
export declare function parseSiteSettings(document: unknown): SiteSettingsContent;
|
|
196
|
+
/** By fixed id: a second home page at a random id is never read. */
|
|
197
|
+
export declare const HOME_PAGE_DOCUMENT_QUERY = "*[_id == $id][0]";
|
|
198
|
+
export declare const CONTENT_PAGES_QUERY = "*[_type == \"page\" && defined(slug.current)]|order(slug.current asc)";
|
|
199
|
+
export declare const CONTENT_PAGE_BY_SLUG_QUERY = "*[_type == \"page\" && slug.current == $slug][0]";
|
|
200
|
+
export interface SiteContentReader {
|
|
201
|
+
/** `null` when no home page document exists yet. */
|
|
202
|
+
home: (options: {
|
|
203
|
+
readonly now: Date;
|
|
204
|
+
}) => Promise<HomeContent | null>;
|
|
205
|
+
pages: () => Promise<PageContent[]>;
|
|
206
|
+
/** `null` when no page has that slug. */
|
|
207
|
+
pageBySlug: (slug: string) => Promise<PageContent | null>;
|
|
208
|
+
/** `null` before anyone has filled in the site's details. */
|
|
209
|
+
siteSettings: () => Promise<SiteSettingsContent | null>;
|
|
210
|
+
}
|
|
211
|
+
export declare function createSiteContentReader(client: {
|
|
212
|
+
query: <T>(groq: string, options?: {
|
|
213
|
+
params?: Record<string, unknown>;
|
|
214
|
+
tags?: readonly string[];
|
|
215
|
+
}) => Promise<T>;
|
|
216
|
+
}): SiteContentReader;
|
|
217
|
+
export declare function homeContentQuery(now: Date): CmsQuery<HomeContent | null>;
|
|
218
|
+
export declare function contentPageQuery(slug: string): CmsQuery<PageContent | null>;
|
|
219
|
+
export declare function contentPagesQuery(): CmsQuery<readonly PageContent[]>;
|
|
220
|
+
export declare function siteSettingsQuery(): CmsQuery<SiteSettingsContent | null>;
|