@getquick/content 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  The configured content runtime for WordPress HTML and WPGraphQL. This slice
4
4
  provides pure helpers, the configured WordPress client, durable delivery and
5
- installed SQL migrations, and event/refresh HTTP handlers. This is not a
6
- completed or published 0.1.0 release pair.
5
+ installed SQL migrations, and event/refresh HTTP handlers. It is released as a
6
+ pair with the Composer package `getquick/gq-content`, at the same version and tag.
7
7
 
8
8
  ```ts
9
9
  import { renderWordPressBlocks, htmlLang } from "@getquick/content";
@@ -37,8 +37,8 @@ manual redirects and an eight-second deadline; failed reads return unavailable,
37
37
  confirmed absence returns missing, and successful reads return found.
38
38
 
39
39
  The root entry contains pure helpers and public structural types. The server
40
- entry exports `createContentRuntime`, `ContentConfig`, `ContentBindings`,
41
- `ContentRuntime` and `WordPressClient`. Runtime properties contain languages,
40
+ entry exports `createContentRuntime`, `ContentConfig`, `ContentExtension`,
41
+ `ExtensionNode`, `ContentBindings`, `ContentRuntime` and `WordPressClient`. Runtime properties contain languages,
42
42
  routing functions and the WordPress methods; credentials/configuration are kept
43
43
  in closures. Language configuration retains the source's normalization policy,
44
44
  and routing returns the same language objects held in that instance's array.
@@ -62,7 +62,8 @@ untracked. The npm tarball allowlist contains only compiled output, the five ori
62
62
  separate consent database migrations and package metadata (npm also includes this README).
63
63
 
64
64
  The public delivery methods are `publishedHome(siteOrigin?, language?)`,
65
- `publishedEntry(path, siteOrigin?)` and `pageChrome(siteOrigin?)`. Supply a
65
+ `publishedEntry(path, siteOrigin?)`, `pageChrome(siteOrigin?)` and
66
+ `designPresets()`. Supply a
66
67
  `PUBLICATION_DB` structural binding to serve durable publications. Stored reads
67
68
  need no event or refresh secret and retain last-known-good content without an
68
69
  age limit. Cold entry lookups read the CMS once, preserving missing versus
@@ -72,6 +73,42 @@ Production without a store returns unavailable (chrome returns null). Only
72
73
  activates that fallback. All public reads guard server context before bindings
73
74
  or transport.
74
75
 
76
+ `designPresets()` returns the stored design presets for a page that isn't a
77
+ publication (live only in explicit development, null otherwise), and
78
+ `internalOrigins(siteOrigin?)` the origins `menuItemHref` treats as this Site's.
79
+ `blockPresetStyles(spacing, colors, scope?)` scopes the preset classes to a
80
+ container (`.prose-wp` by default); `presetRootStyle(spacing, colors)` declares
81
+ the presets on `:root`.
82
+
83
+ ## A Site's own content
84
+
85
+ A Site whose WordPress plugin adds fields to its pages and posts declares them
86
+ as an `extension`. The runtime reads them with each front page, page and post
87
+ (with its blocks), stores the Site's value with the publication and serves it,
88
+ typed, through outages:
89
+
90
+ ```ts
91
+ const content = createContentRuntime<SiteBlocks>({
92
+ // …the configuration above…
93
+ extension: {
94
+ fields: { page: "wpBlocksJson pageBackgroundColor", post: "wpBlocksJson" },
95
+ // The Site's value for a node WordPress returned, or undefined for none.
96
+ read: ({ kind, fields }) => siteBlocks(fields.wpBlocksJson, fields.pageBackgroundColor),
97
+ // The stored value (JSON) read back, or undefined when it can't be served.
98
+ parse: (stored) => storedSiteBlocks.safeParse(stored).data,
99
+ },
100
+ });
101
+ const page = await content.publishedEntry("/about/");
102
+ if (page.kind === "found" && page.content.entry.extension) {
103
+ // Render page.content.entry.extension (SiteBlocks); the front page's is
104
+ // home.page.extension.
105
+ }
106
+ ```
107
+
108
+ Rows stored before the Site declared it serve without it; a refresh stores it.
109
+ A read throwing fails as WordPress's answer not matching the schema, and a read
110
+ without its blocks stores nothing, so the stored page stays served. See ADR 0004.
111
+
75
112
  Infrastructure imports the Node-only migration entry separately:
76
113
 
77
114
  ```ts
@@ -1,6 +1,6 @@
1
1
  import { S as SERVICE_CATALOG, T as copyLanguage, c as grantedCategories, d as promptReason, f as readConsentCookie, x as OPTIONAL_CATEGORIES } from "./state-BYWc5TUB.js";
2
2
  import { a as CONSENT_COPY, c as legalRevisions, r as escapeHtml } from "./embeds-p9SyGNZI.js";
3
- import { t as CONSENT_UI_VERSION } from "./version-Dn6VLZJO.js";
3
+ import { t as CONSENT_UI_VERSION } from "./version-DyyNhsuU.js";
4
4
  import { t as CONSENT_CONFIG_ID } from "./client-config-B3LNSOxl.js";
5
5
  import { t as CONSENT_PATH } from "./paths-Cmzzof3v.js";
6
6
  //#region frontend/src/consent/astro.ts
package/dist/consent.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { C as SERVICE_IDS, D as hostMatches, E as embedService, O as hostService, S as SERVICE_CATALOG, T as copyLanguage, _ as consentConfiguration, a as consentCookie, b as COPY_LANGUAGES, c as grantedCategories, d as promptReason, f as readConsentCookie, g as MEASUREMENT_ID, h as CONSENT_COPY_REVISION, i as DECISION_VALIDITY_MS, k as isServiceId, l as isConsentId, m as serviceGranted, n as CONSENT_COOKIE, o as decide, p as serializeConsentState, r as CONSENT_COOKIE_MAX_AGE_SECONDS, s as denyAll, t as CONSENT_ACTIONS, u as parseConsentState, v as needsBanner, w as catalogService, x as OPTIONAL_CATEGORIES, y as CONSENT_CATEGORIES } from "./state-BYWc5TUB.js";
2
2
  import { a as parseConsentRecordRequest, i as isRecordToken, n as consentRecordRequest, r as isLegalRoleName, t as MAX_RECORD_BYTES } from "./records-Dumrz759.js";
3
3
  import { a as CONSENT_COPY, c as legalRevisions, i as rewriteEmbeds, l as parseLegalLanguage, n as embedPlaceholder, o as LEGAL_ROLES, r as escapeHtml, s as REQUIRED_LEGAL_ROLES, t as blockedPlaceholder } from "./embeds-p9SyGNZI.js";
4
- import { t as CONSENT_UI_VERSION } from "./version-Dn6VLZJO.js";
4
+ import { t as CONSENT_UI_VERSION } from "./version-DyyNhsuU.js";
5
5
  export { CONSENT_ACTIONS, CONSENT_CATEGORIES, CONSENT_COOKIE, CONSENT_COOKIE_MAX_AGE_SECONDS, CONSENT_COPY, CONSENT_COPY_REVISION, CONSENT_UI_VERSION, COPY_LANGUAGES, DECISION_VALIDITY_MS, LEGAL_ROLES, MAX_RECORD_BYTES, MEASUREMENT_ID, OPTIONAL_CATEGORIES, REQUIRED_LEGAL_ROLES, SERVICE_CATALOG, SERVICE_IDS, blockedPlaceholder, catalogService, consentConfiguration, consentCookie, consentRecordRequest, copyLanguage, decide, denyAll, embedPlaceholder, embedService, escapeHtml, grantedCategories, hostMatches, hostService, isConsentId, isLegalRoleName, isRecordToken, isServiceId, legalRevisions, needsBanner, parseConsentRecordRequest, parseConsentState, parseLegalLanguage, promptReason, readConsentCookie, rewriteEmbeds, serializeConsentState, serviceGranted };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { A as Unavailable, B as routeLanguage, C as EntryContent, D as PublishedEntry, E as Missing, F as SiteLanguage, I as WordPressLanguages, L as htmlLang, M as WordPressMenuItem, N as WordPressPost, O as SiteChrome, P as WordPressSettings, R as isLanguageHome, S as DesignPresets, T as HomeContent, V as siteLanguages, _ as SqlDatabase, b as CmsFailureReason, c as EntryDelivery, d as HomeDelivery, f as HomePage, g as ContentBindings, h as NotServed, i as ContentRuntime, j as WordPressImage, k as Translation, l as EntryPage, m as Moved, n as ContentConfig, o as Alternate, p as HomePublication, r as ContentConsentConfig, s as DeliveryFailureReason, t as WordPressClient, u as EntryPublication, v as SqlStatement, w as Found, x as Delivery, y as CmsFailure, z as languageSubject } from "./wordpress-CPL40-vq.js";
1
+ import { A as PublishedEntry, B as htmlLang, C as Delivery, D as Found, E as Extended, F as WordPressMenuItem, H as languageSubject, I as WordPressPost, L as WordPressSettings, M as Translation, N as Unavailable, O as HomeContent, P as WordPressImage, R as SiteLanguage, S as CmsFailureReason, T as EntryContent, U as routeLanguage, V as isLanguageHome, W as siteLanguages, _ as NotServed, a as ContentRuntime, b as SqlStatement, c as Alternate, d as EntryPage, f as EntryPublication, g as Moved, h as HomePublication, i as ContentExtension, j as SiteChrome, k as Missing, l as DeliveryFailureReason, m as HomePage, n as ContentConfig, o as ExtensionNode, p as HomeDelivery, r as ContentConsentConfig, t as WordPressClient, u as EntryDelivery, v as ContentBindings, w as DesignPresets, x as CmsFailure, y as SqlDatabase, z as WordPressLanguages } from "./wordpress-CUFb6YZZ.js";
2
2
  //#region frontend/src/wp-block-styles.d.ts
3
3
  export interface Preset {
4
4
  slug: string;
@@ -13,8 +13,19 @@ export interface BlockPresetStyles {
13
13
  vars: Record<string, string>;
14
14
  colorRules: string;
15
15
  }
16
- /** WordPress preset classes need the CMS palette as well as CSS variables. */
17
- export declare function blockPresetStyles(spacingSizes: SpacingPreset[], colors: ColorPreset[]): BlockPresetStyles;
16
+ /**
17
+ * WordPress preset classes need the CMS palette as well as CSS variables.
18
+ * `scope` is the content's container (`.prose-wp` by default; another for
19
+ * content a Site renders itself).
20
+ */
21
+ export declare function blockPresetStyles(spacingSizes: SpacingPreset[], colors: ColorPreset[], scope?: string): BlockPresetStyles;
22
+ /**
23
+ * The design presets as WordPress defines them, on `:root`, for styles that
24
+ * read the palette and spacing from these variables outside the content's
25
+ * container. Presets with an invalid slug, or a value that could end the
26
+ * rule, are left out; "" without any.
27
+ */
28
+ export declare function presetRootStyle(spacingSizes: SpacingPreset[], colors: ColorPreset[]): string;
18
29
  //#endregion
19
30
  //#region frontend/src/wp-block-renderer.d.ts
20
31
  export interface WordPressBlock {
@@ -61,4 +72,4 @@ export declare function stripHtml(value: string): string;
61
72
  export declare function formatDate(value: string, lang?: string): string;
62
73
  export declare function routeOf(path: string): string;
63
74
  //#endregion
64
- export { Alternate, CmsFailure, CmsFailureReason, ContentBindings, type ContentConfig, type ContentConsentConfig, type ContentRuntime, Delivery, DeliveryFailureReason, DesignPresets, EntryContent, EntryDelivery, EntryPage, EntryPublication, Found, HomeContent, HomeDelivery, HomePage, HomePublication, Missing, Moved, NotServed, PublishedEntry, SiteChrome, SiteLanguage, SqlDatabase, SqlStatement, Translation, Unavailable, type WordPressClient, WordPressImage, WordPressLanguages, WordPressMenuItem, WordPressPost, WordPressSettings, htmlLang, isLanguageHome, languageSubject, routeLanguage, siteLanguages };
75
+ export { Alternate, CmsFailure, CmsFailureReason, ContentBindings, type ContentConfig, type ContentConsentConfig, type ContentExtension, type ContentRuntime, Delivery, DeliveryFailureReason, DesignPresets, EntryContent, EntryDelivery, EntryPage, EntryPublication, Extended, type ExtensionNode, Found, HomeContent, HomeDelivery, HomePage, HomePublication, Missing, Moved, NotServed, PublishedEntry, SiteChrome, SiteLanguage, SqlDatabase, SqlStatement, Translation, Unavailable, type WordPressClient, WordPressImage, WordPressLanguages, WordPressMenuItem, WordPressPost, WordPressSettings, htmlLang, isLanguageHome, languageSubject, routeLanguage, siteLanguages };
package/dist/index.js CHANGED
@@ -1,20 +1,39 @@
1
1
  import { _ as youtubeVideoId, a as routeOf, c as htmlLang, d as routeLanguage, f as siteLanguages, g as renderWordPressBlocks, h as parseWordPressBlocks, i as responseStatus, l as isLanguageHome, m as hasVideoHero, n as formatDate, o as stripHtml, p as VIDEO_HERO_BLOCK, r as menuItemHref, s as uriPath, t as cmsTime, u as languageSubject, v as containerChildLayoutHtml, y as containerLayoutHtml } from "./formatting-wOk2jz6J.js";
2
2
  //#region frontend/src/wp-block-styles.ts
3
3
  const validSlug = (slug) => /^[a-z0-9][a-z0-9-]*$/.test(slug);
4
- /** WordPress preset classes need the CMS palette as well as CSS variables. */
5
- function blockPresetStyles(spacingSizes, colors) {
4
+ /** A CSS value safe inside a stylesheet: a color or length, nothing that ends a rule. */
5
+ const safeValue = (value) => /^[#(),.%\w\s-]+$/.test(value);
6
+ /** A selector for the content's container: classes, ids, elements and descendants. */
7
+ const validScope = (scope) => /^[.#]?[A-Za-z][\w-]*(?:[ .#>][A-Za-z][\w-]*)*$/.test(scope);
8
+ /**
9
+ * WordPress preset classes need the CMS palette as well as CSS variables.
10
+ * `scope` is the content's container (`.prose-wp` by default; another for
11
+ * content a Site renders itself).
12
+ */
13
+ function blockPresetStyles(spacingSizes, colors, scope = ".prose-wp") {
14
+ if (!validScope(scope)) throw new Error(`${JSON.stringify(scope)} isn't a container selector`);
6
15
  const vars = {};
7
16
  const colorRules = [];
8
17
  for (const { slug, size } of spacingSizes) if (validSlug(slug)) vars[`--wp--preset--spacing--${slug}`] = size;
9
18
  for (const { slug, color } of colors) {
10
19
  if (!validSlug(slug)) continue;
11
20
  vars[`--wp--preset--color--${slug}`] = color;
12
- colorRules.push(`.prose-wp .has-${slug}-color{color:var(--wp--preset--color--${slug})}`, `.prose-wp .has-${slug}-background-color{background-color:var(--wp--preset--color--${slug})}`, `.prose-wp .has-${slug}-color.has-link-color a{color:var(--wp--preset--color--${slug})}`);
21
+ colorRules.push(`${scope} .has-${slug}-color{color:var(--wp--preset--color--${slug})}`, `${scope} .has-${slug}-background-color{background-color:var(--wp--preset--color--${slug})}`, `${scope} .has-${slug}-color.has-link-color a{color:var(--wp--preset--color--${slug})}`);
13
22
  }
14
23
  return {
15
24
  vars,
16
25
  colorRules: colorRules.join("\n")
17
26
  };
18
27
  }
28
+ /**
29
+ * The design presets as WordPress defines them, on `:root`, for styles that
30
+ * read the palette and spacing from these variables outside the content's
31
+ * container. Presets with an invalid slug, or a value that could end the
32
+ * rule, are left out; "" without any.
33
+ */
34
+ function presetRootStyle(spacingSizes, colors) {
35
+ const declarations = [...spacingSizes.filter(({ slug, size }) => validSlug(slug) && safeValue(size)).map(({ slug, size }) => `--wp--preset--spacing--${slug}:${size}`), ...colors.filter(({ slug, color }) => validSlug(slug) && safeValue(color)).map(({ slug, color }) => `--wp--preset--color--${slug}:${color}`)];
36
+ return declarations.length ? `:root{${declarations.join(";")}}` : "";
37
+ }
19
38
  //#endregion
20
- export { VIDEO_HERO_BLOCK, blockPresetStyles, cmsTime, containerChildLayoutHtml, containerLayoutHtml, formatDate, hasVideoHero, htmlLang, isLanguageHome, languageSubject, menuItemHref, parseWordPressBlocks, renderWordPressBlocks, responseStatus, routeLanguage, routeOf, siteLanguages, stripHtml, uriPath, youtubeVideoId };
39
+ export { VIDEO_HERO_BLOCK, blockPresetStyles, cmsTime, containerChildLayoutHtml, containerLayoutHtml, formatDate, hasVideoHero, htmlLang, isLanguageHome, languageSubject, menuItemHref, parseWordPressBlocks, presetRootStyle, renderWordPressBlocks, responseStatus, routeLanguage, routeOf, siteLanguages, stripHtml, uriPath, youtubeVideoId };
package/dist/server.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as createContentRuntime, g as ContentBindings, i as ContentRuntime, n as ContentConfig, r as ContentConsentConfig, t as WordPressClient } from "./wordpress-CPL40-vq.js";
2
- export { type ContentBindings, ContentConfig, ContentConsentConfig, ContentRuntime, type WordPressClient, createContentRuntime };
1
+ import { a as ContentRuntime, i as ContentExtension, n as ContentConfig, o as ExtensionNode, r as ContentConsentConfig, s as createContentRuntime, t as WordPressClient, v as ContentBindings } from "./wordpress-CUFb6YZZ.js";
2
+ export { type ContentBindings, ContentConfig, ContentConsentConfig, ContentExtension, ContentRuntime, ExtensionNode, type WordPressClient, createContentRuntime };
package/dist/server.js CHANGED
@@ -3550,6 +3550,7 @@ ZodOptional.create;
3550
3550
  ZodNullable.create;
3551
3551
  ZodEffects.createWithPreprocess;
3552
3552
  ZodPipeline.create;
3553
+ const NEVER = INVALID;
3553
3554
  /** The store couldn't be read or written. Says nothing about the content. */
3554
3555
  var StoreFailure = class extends Error {};
3555
3556
  const eventOf = (row) => ({
@@ -4743,6 +4744,29 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4743
4744
  slug: stringType(),
4744
4745
  color: stringType()
4745
4746
  }));
4747
+ /**
4748
+ * The Site's extension of a page or post, as its `parse` reads it back. A
4749
+ * row stored without one (before the Site declared it) still serves; an
4750
+ * extension stored without the Site declaring one now is left out.
4751
+ */
4752
+ const extension = config.extension;
4753
+ const extended = extension ? { extension: unknownType().optional().transform((value, context) => {
4754
+ if (value === void 0) return void 0;
4755
+ let parsed;
4756
+ try {
4757
+ parsed = extension.parse(value);
4758
+ } catch {
4759
+ parsed = void 0;
4760
+ }
4761
+ if (parsed === void 0) {
4762
+ context.addIssue({
4763
+ code: "custom",
4764
+ message: "the Site can't serve this extension"
4765
+ });
4766
+ return NEVER;
4767
+ }
4768
+ return parsed;
4769
+ }) } : {};
4746
4770
  const homePublication = objectType({
4747
4771
  settings: objectType({
4748
4772
  title: stringType(),
@@ -4751,7 +4775,8 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4751
4775
  page: objectType({
4752
4776
  title: stringType(),
4753
4777
  content: stringType(),
4754
- hasVideoHero: booleanType()
4778
+ hasVideoHero: booleanType(),
4779
+ ...extended
4755
4780
  }),
4756
4781
  spacingSizes,
4757
4782
  colors
@@ -4772,7 +4797,8 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4772
4797
  featuredImage: image,
4773
4798
  hasVideoHero: booleanType(),
4774
4799
  spacingSizes,
4775
- colors
4800
+ colors,
4801
+ ...extended
4776
4802
  });
4777
4803
  const legalRole = objectType({
4778
4804
  role: stringType(),
@@ -5126,6 +5152,27 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5126
5152
  }
5127
5153
  }
5128
5154
  /**
5155
+ * The design presets for a page that isn't a publication: the stored ones
5156
+ * when there is a store, else a live read in explicit development. Null
5157
+ * when neither has them; the page is then served without them.
5158
+ */
5159
+ async function pageDesign() {
5160
+ if (!config.isServer()) return null;
5161
+ const store = await boundStore();
5162
+ if (!store) {
5163
+ if (!config.development) return null;
5164
+ const design = await getDesignPresets();
5165
+ return design.kind === "found" ? design.content : null;
5166
+ }
5167
+ try {
5168
+ return sharedDesign(await store.read(DESIGN, parseDesign));
5169
+ } catch (error) {
5170
+ if (!(error instanceof StoreFailure)) throw error;
5171
+ console.error(`Delivery: the design presets can't be served (store): ${error.message}`);
5172
+ return null;
5173
+ }
5174
+ }
5175
+ /**
5129
5176
  * Refreshes the whole Site, the explicit preparation of its store: each
5130
5177
  * language's front page and chrome, the shared design presets, and every
5131
5178
  * entry WordPress lists as published or the store already holds (so one
@@ -5599,14 +5646,28 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5599
5646
  isAuthorized,
5600
5647
  publishedHome: (siteOrigin, language) => safely(async () => html.home(await publishedHome(siteOrigin, language), language), () => notServed("the front page", "store", "The publication store couldn't be loaded")),
5601
5648
  publishedEntry: (path, siteOrigin) => safely(async () => html.entry(await publishedEntry(path, siteOrigin), routeLanguage$1(routeOf(path))), () => notServed("the entry", "store", "The publication store couldn't be loaded")),
5602
- pageChrome: (siteOrigin) => safely(() => pageChrome(siteOrigin), () => null)
5649
+ pageChrome: (siteOrigin) => safely(() => pageChrome(siteOrigin), () => null),
5650
+ designPresets: () => safely(() => pageDesign(), () => null)
5603
5651
  };
5604
5652
  }
5605
5653
  //#endregion
5606
5654
  //#region frontend/src/wordpress.ts
5655
+ /** The origins a link into this Site may use: WordPress's, and the Frontend's when given. */
5656
+ function internalOrigins(config, siteOrigin) {
5657
+ return [new URL(config.graphqlEndpoint).origin, ...siteOrigin ? [siteOrigin] : []];
5658
+ }
5607
5659
  function configuredWordPress(config, languages) {
5608
5660
  const defaultLanguage = languages[0];
5609
5661
  const multilingual = languages.length > 1;
5662
+ const extension = config.extension;
5663
+ /**
5664
+ * The Site's extension fields on a kind of node, read with the blocks (left
5665
+ * out with them when WordPress fails on them).
5666
+ */
5667
+ const extensionFields = (kind) => {
5668
+ const fields = extension?.fields[kind]?.trim();
5669
+ return fields ? `\n... @include(if: $withBlocks) {\n${fields}\n}` : "";
5670
+ };
5610
5671
  const image = objectType({
5611
5672
  sourceUrl: stringType(),
5612
5673
  altText: stringType().nullable()
@@ -5634,7 +5695,7 @@ function configuredWordPress(config, languages) {
5634
5695
  title: stringType().nullable(),
5635
5696
  content: stringType().nullable(),
5636
5697
  blocks: unknownType().optional()
5637
- }), objectType({ __typename: stringType().refine((typename) => typename !== "Page") })]).nullable(),
5698
+ }).passthrough(), objectType({ __typename: stringType().refine((typename) => typename !== "Page") })]).nullable(),
5638
5699
  designTokens
5639
5700
  });
5640
5701
  const pageEntry = objectType({
@@ -5647,7 +5708,7 @@ function configuredWordPress(config, languages) {
5647
5708
  modifiedGmt: stringType().nullable().optional(),
5648
5709
  featuredImage: imageEdge,
5649
5710
  blocks: unknownType().optional()
5650
- });
5711
+ }).passthrough();
5651
5712
  const postEntry = pageEntry.extend({
5652
5713
  excerpt: stringType().nullable(),
5653
5714
  date: stringType().nullable()
@@ -5745,7 +5806,7 @@ function configuredWordPress(config, languages) {
5745
5806
  content
5746
5807
  blocks(attributes: true, htmlContent: true, dynamicContent: true, postTemplate: false)
5747
5808
  @include(if: $withBlocks)
5748
- isFrontPage
5809
+ isFrontPage${extensionFields("page")}
5749
5810
  }
5750
5811
  }
5751
5812
  designTokens {
@@ -5774,7 +5835,7 @@ function configuredWordPress(config, languages) {
5774
5835
  content
5775
5836
  blocks(attributes: true, htmlContent: true, dynamicContent: true, postTemplate: false)
5776
5837
  @include(if: $withBlocks)
5777
- isFrontPage
5838
+ isFrontPage${extensionFields("page")}
5778
5839
  }
5779
5840
  }
5780
5841
  designTokens {
@@ -5860,7 +5921,7 @@ function configuredWordPress(config, languages) {
5860
5921
  sourceUrl
5861
5922
  altText
5862
5923
  }
5863
- }
5924
+ }${extensionFields("post")}
5864
5925
  }
5865
5926
  pageBy(uri: $uri) {
5866
5927
  id
@@ -5877,7 +5938,7 @@ function configuredWordPress(config, languages) {
5877
5938
  sourceUrl
5878
5939
  altText
5879
5940
  }
5880
- }
5941
+ }${extensionFields("page")}
5881
5942
  }
5882
5943
  }
5883
5944
  `;
@@ -5946,6 +6007,27 @@ function configuredWordPress(config, languages) {
5946
6007
  function describeIssues(error) {
5947
6008
  return error.issues.map((issue) => `${issue.path.join(".") || "data"}: ${issue.message}`).join("; ");
5948
6009
  }
6010
+ /**
6011
+ * `{ extension }` with the Site's value for a node WordPress returned, or
6012
+ * nothing: without an extension, fields for its kind, or a value, and when
6013
+ * the blocks (and with them the fields) were left out.
6014
+ */
6015
+ function extended(kind, node, blocksOmitted) {
6016
+ if (!extension || !extensionFields(kind) || blocksOmitted) return {};
6017
+ let value;
6018
+ try {
6019
+ value = extension.read({
6020
+ kind,
6021
+ fields: node
6022
+ });
6023
+ } catch (error) {
6024
+ throw new CmsFailureError({
6025
+ reason: "schema",
6026
+ message: `WordPress's ${kind} doesn't match the Site's extension: ${error instanceof Error ? error.message : String(error)}`
6027
+ }, { cause: error });
6028
+ }
6029
+ return value === void 0 ? {} : { extension: value };
6030
+ }
5949
6031
  async function query(schema, queryText, variables) {
5950
6032
  let response;
5951
6033
  let credentials = [];
@@ -6105,7 +6187,8 @@ function configuredWordPress(config, languages) {
6105
6187
  page: {
6106
6188
  title: node.title ?? "",
6107
6189
  content: containsVideoHero ? renderWordPressBlocks(blocks) : node.content ?? "",
6108
- hasVideoHero: containsVideoHero
6190
+ hasVideoHero: containsVideoHero,
6191
+ ...extended("page", node, blocksOmitted)
6109
6192
  },
6110
6193
  blocksOmitted,
6111
6194
  spacingSizes: data.designTokens.spacingSizes,
@@ -6137,7 +6220,8 @@ function configuredWordPress(config, languages) {
6137
6220
  modifiedAt: cmsTime(post.modifiedGmt),
6138
6221
  spacingSizes: data.designTokens.spacingSizes,
6139
6222
  colors: data.designTokens.colors,
6140
- ...multilingual ? languageOf(post) : {}
6223
+ ...multilingual ? languageOf(post) : {},
6224
+ ...extended(data.postBy ? "post" : "page", post, blocksOmitted)
6141
6225
  }
6142
6226
  };
6143
6227
  });
@@ -6265,7 +6349,7 @@ function configuredWordPress(config, languages) {
6265
6349
  location,
6266
6350
  language: language.code
6267
6351
  }), config.consent?.cmsSettings ? consentSettings(language) : null]);
6268
- const origins = [new URL(config.graphqlEndpoint).origin, ...siteOrigin ? [siteOrigin] : []];
6352
+ const origins = internalOrigins(config, siteOrigin);
6269
6353
  return {
6270
6354
  kind: "found",
6271
6355
  content: {
@@ -6297,6 +6381,7 @@ function configuredWordPress(config, languages) {
6297
6381
  function createContentRuntime(input) {
6298
6382
  const site = input.site.trim();
6299
6383
  if (!site) throw new Error("A nonempty Site identity is required");
6384
+ for (const [kind, fields] of Object.entries(input.extension?.fields ?? {})) if (fields !== void 0 && !fields.trim()) throw new Error(`The extension's ${kind} fields are empty; leave them out instead`);
6300
6385
  const config = {
6301
6386
  ...input,
6302
6387
  site,
@@ -6353,6 +6438,18 @@ function createContentRuntime(input) {
6353
6438
  publishedEntry: delivery.publishedEntry,
6354
6439
  pageChrome: delivery.pageChrome,
6355
6440
  /**
6441
+ * The design presets every page shares (GQ Design's spacing sizes and
6442
+ * colors), for a page that isn't a publication: the stored ones when there
6443
+ * is a store, else (in explicit development) a live read. Null when
6444
+ * neither has them.
6445
+ */
6446
+ designPresets: delivery.designPresets,
6447
+ /**
6448
+ * The origins a link into this Site may use: WordPress's, and the
6449
+ * Frontend's when given. `menuItemHref` turns links at them into paths.
6450
+ */
6451
+ internalOrigins: (siteOrigin) => internalOrigins(config, siteOrigin),
6452
+ /**
6356
6453
  * The Site's consent configuration for a page served with this chrome:
6357
6454
  * declared services, GA4 when gq-config's toggle is on, and the
6358
6455
  * controller from the Legal Contract. Null when consent isn't configured.
@@ -1,6 +1,6 @@
1
1
  //#endregion
2
2
  //#region frontend/src/consent/version.ts
3
3
  /** The UI/adapter version recorded with each decision (CN-R7). */
4
- const CONSENT_UI_VERSION = `gq-content@0.1.0`;
4
+ const CONSENT_UI_VERSION = `gq-content@0.2.0`;
5
5
  //#endregion
6
6
  export { CONSENT_UI_VERSION as t };
@@ -47,7 +47,7 @@ declare function languageSubject(language: SiteLanguage, what: string): string;
47
47
  /** Whether a route is a language's home (/, /en/): served as a home, never as an entry. */
48
48
  declare function isLanguageHome(route: string, among: readonly SiteLanguage[]): boolean;
49
49
  declare namespace content_types_d_exports {
50
- export { CmsFailure, CmsFailureReason, Delivery, DesignPresets, EntryContent, Found, HomeContent, Missing, PublishedEntry, SiteChrome, Translation, Unavailable, WordPressImage, WordPressMenuItem, WordPressPost, WordPressSettings };
50
+ export { CmsFailure, CmsFailureReason, Delivery, DesignPresets, EntryContent, Extended, Found, HomeContent, Missing, PublishedEntry, SiteChrome, Translation, Unavailable, WordPressImage, WordPressMenuItem, WordPressPost, WordPressSettings };
51
51
  }
52
52
  interface WordPressImage {
53
53
  sourceUrl: string;
@@ -84,7 +84,15 @@ interface DesignPresets {
84
84
  color: string;
85
85
  }>;
86
86
  }
87
- interface HomeContent extends DesignPresets {
87
+ /**
88
+ * A page or post's own content as the Site's extension read it
89
+ * (ContentConfig's `extension`): present only when the Site declares one and
90
+ * its read returned a value for that page or post.
91
+ */
92
+ interface Extended<X = unknown> {
93
+ extension?: X;
94
+ }
95
+ interface HomeContent<X = unknown> extends DesignPresets {
88
96
  /** The front page's WordPress entry (WPGraphQL's global id), if WordPress gave it. */
89
97
  nodeId: string | null;
90
98
  settings: WordPressSettings;
@@ -92,7 +100,7 @@ interface HomeContent extends DesignPresets {
92
100
  title: string;
93
101
  content: string;
94
102
  hasVideoHero: boolean;
95
- };
103
+ } & Extended<X>;
96
104
  /** WordPress failed on the blocks, so this is the page without them. */
97
105
  blocksOmitted: boolean;
98
106
  }
@@ -101,7 +109,7 @@ interface Translation {
101
109
  uri: string;
102
110
  language: string;
103
111
  }
104
- interface EntryContent extends WordPressPost, DesignPresets {
112
+ interface EntryContent<X = unknown> extends WordPressPost, DesignPresets, Extended<X> {
105
113
  /** Its language's slug, on a multilingual Site. */
106
114
  language?: string;
107
115
  /** Its published translations into the site's other languages, on a multilingual Site. */
@@ -192,9 +200,9 @@ interface ContentBindings {
192
200
  //#endregion
193
201
  //#region frontend/src/page-types.d.ts
194
202
  /** The front page as stored: what WordPress delivered, without read details. */
195
- type HomePublication = Omit<HomeContent, "blocksOmitted" | "nodeId">;
203
+ type HomePublication<X = unknown> = Omit<HomeContent<X>, "blocksOmitted" | "nodeId">;
196
204
  /** A published page or post as stored, with the design presets it was read with. */
197
- type EntryPublication = Omit<EntryContent, "blocksOmitted" | "modifiedAt">;
205
+ type EntryPublication<X = unknown> = Omit<EntryContent<X>, "blocksOmitted" | "modifiedAt">;
198
206
  /**
199
207
  * A page in another language that a visitor of this one may switch to: its
200
208
  * published translation (`translation`), or else that language's home.
@@ -204,14 +212,14 @@ interface Alternate {
204
212
  route: string;
205
213
  translation: boolean;
206
214
  }
207
- interface HomePage {
208
- home: HomePublication;
215
+ interface HomePage<X = unknown> {
216
+ home: HomePublication<X>;
209
217
  chrome: SiteChrome;
210
218
  /** The other languages' homes that are served; none on a monolingual Site. */
211
219
  alternates: Alternate[];
212
220
  }
213
- interface EntryPage {
214
- entry: EntryPublication;
221
+ interface EntryPage<X = unknown> {
222
+ entry: EntryPublication<X>;
215
223
  chrome: SiteChrome;
216
224
  /** Where its readers may switch language; none on a monolingual Site. */
217
225
  alternates: Alternate[];
@@ -234,15 +242,15 @@ interface NotServed {
234
242
  message: string;
235
243
  };
236
244
  }
237
- type HomeDelivery = Found<HomePage> | (Missing & {
245
+ type HomeDelivery<X = unknown> = Found<HomePage<X>> | (Missing & {
238
246
  chrome: SiteChrome | null;
239
247
  }) | NotServed;
240
- type EntryDelivery = Found<EntryPage> | (Missing & {
248
+ type EntryDelivery<X = unknown> = Found<EntryPage<X>> | (Missing & {
241
249
  chrome: SiteChrome | null;
242
250
  }) | Moved | NotServed;
243
251
  //#endregion
244
252
  //#region frontend/src/server.d.ts
245
- interface ContentConfig {
253
+ interface ContentConfig<X = never> {
246
254
  site: string;
247
255
  graphqlEndpoint: string;
248
256
  cmsOrigin: string;
@@ -258,6 +266,52 @@ interface ContentConfig {
258
266
  * delivery; absent, bodies are delivered as published.
259
267
  */
260
268
  consent?: ContentConsentConfig;
269
+ /**
270
+ * The Site's own content on its pages and posts: GraphQL fields the runtime
271
+ * reads with each of them, and the value the Site makes of them, stored with
272
+ * the publication and served as its `extension`.
273
+ */
274
+ extension?: ContentExtension<X>;
275
+ }
276
+ /**
277
+ * A Site's own content on its pages and posts, beyond what the runtime reads:
278
+ * fields a plugin adds to WPGraphQL (a block tree, a page setting), read with
279
+ * every front page, page and post, stored with it, and served with it through
280
+ * outages, as the rest of it is.
281
+ */
282
+ interface ContentExtension<X> {
283
+ /**
284
+ * GraphQL selections added to each kind of node the runtime reads: `page`
285
+ * to front pages and pages, `post` to posts (e.g. `wpBlocksJson`). They are
286
+ * read with the blocks: a read WordPress can only complete without its
287
+ * blocks leaves them out too, and isn't stored. A kind without fields has
288
+ * no extension.
289
+ */
290
+ fields: {
291
+ page?: string;
292
+ post?: string;
293
+ };
294
+ /**
295
+ * The Site's value for a node WordPress returned (its fields, the runtime's
296
+ * among them), or undefined when it has none (`extension` is then left
297
+ * out). It is stored as JSON, so it must survive JSON. Throwing fails the
298
+ * read as WordPress answering with the wrong schema: what is stored stays
299
+ * served.
300
+ */
301
+ read(node: ExtensionNode): X | undefined;
302
+ /**
303
+ * The value read back from the store, or from a read about to be stored
304
+ * (JSON), or undefined when the Site can't serve it. A stored row whose
305
+ * extension doesn't parse is unusable, as any body that doesn't; one stored
306
+ * without an extension (before the Site declared it) still serves, without.
307
+ * Return a valid value as it is: reconciliation compares it with new reads.
308
+ */
309
+ parse(stored: unknown): X | undefined;
310
+ }
311
+ /** A front page, page or post as WordPress returned it, for a Site's extension. */
312
+ interface ExtensionNode {
313
+ kind: "page" | "post";
314
+ fields: Readonly<Record<string, unknown>>;
261
315
  }
262
316
  interface ContentConsentConfig {
263
317
  /** The catalog services the Site declares in code (e.g. "youtube", "google_maps"). */
@@ -274,22 +328,34 @@ interface ContentConsentConfig {
274
328
  */
275
329
  cmsSettings?: boolean;
276
330
  }
277
- declare function createContentRuntime(input: ContentConfig): {
331
+ declare function createContentRuntime<X = never>(input: ContentConfig<X>): {
278
332
  languages: SiteLanguage[];
279
333
  defaultLanguage: SiteLanguage;
280
334
  multilingual: boolean;
281
335
  routeLanguage: (route: string) => SiteLanguage;
282
336
  isLanguageHome: (route: string) => boolean;
283
- wordpress: WordPressClient;
337
+ wordpress: WordPressClient<X>;
284
338
  handleEvent: (request: Request, context?: {
285
339
  siteOrigin?: string;
286
340
  }) => Promise<Response>;
287
341
  handleRefresh: (request: Request, context?: {
288
342
  siteOrigin?: string;
289
343
  }) => Promise<Response>;
290
- publishedHome: (siteOrigin?: string, language?: SiteLanguage) => Promise<HomeDelivery>;
291
- publishedEntry: (path: string, siteOrigin?: string) => Promise<EntryDelivery>;
344
+ publishedHome: (siteOrigin?: string, language?: SiteLanguage) => Promise<HomeDelivery<X>>;
345
+ publishedEntry: (path: string, siteOrigin?: string) => Promise<EntryDelivery<X>>;
292
346
  pageChrome: (siteOrigin?: string) => Promise<SiteChrome | null>;
347
+ /**
348
+ * The design presets every page shares (GQ Design's spacing sizes and
349
+ * colors), for a page that isn't a publication: the stored ones when there
350
+ * is a store, else (in explicit development) a live read. Null when
351
+ * neither has them.
352
+ */
353
+ designPresets: () => Promise<DesignPresets | null>;
354
+ /**
355
+ * The origins a link into this Site may use: WordPress's, and the
356
+ * Frontend's when given. `menuItemHref` turns links at them into paths.
357
+ */
358
+ internalOrigins: (siteOrigin?: string) => string[];
293
359
  /**
294
360
  * The Site's consent configuration for a page served with this chrome:
295
361
  * declared services, GA4 when gq-config's toggle is on, and the
@@ -297,12 +363,12 @@ declare function createContentRuntime(input: ContentConfig): {
297
363
  */
298
364
  consentConfiguration: (chrome?: SiteChrome | null) => ConsentConfiguration | null;
299
365
  };
300
- type ContentRuntime = ReturnType<typeof createContentRuntime>;
366
+ type ContentRuntime<X = never> = ReturnType<typeof createContentRuntime<X>>;
301
367
  //#endregion
302
368
  //#region frontend/src/wordpress.d.ts
303
- interface WordPressClient {
304
- getHomeContent(this: void, language?: SiteLanguage): Promise<Delivery<HomeContent>>;
305
- getEntryByUri(this: void, uri: string): Promise<Delivery<EntryContent>>;
369
+ interface WordPressClient<X = unknown> {
370
+ getHomeContent(this: void, language?: SiteLanguage): Promise<Delivery<HomeContent<X>>>;
371
+ getEntryByUri(this: void, uri: string): Promise<Delivery<EntryContent<X>>>;
306
372
  getDesignPresets(this: void): Promise<Found<DesignPresets> | Unavailable>;
307
373
  getPublishedEntries(this: void): Promise<Found<{
308
374
  entries: PublishedEntry[];
@@ -315,4 +381,4 @@ interface WordPressClient {
315
381
  }): Promise<Found<SiteChrome> | Unavailable>;
316
382
  }
317
383
  //#endregion
318
- export { Unavailable as A, routeLanguage as B, EntryContent as C, PublishedEntry as D, Missing as E, SiteLanguage as F, WordPressLanguages as I, htmlLang as L, WordPressMenuItem as M, WordPressPost as N, SiteChrome as O, WordPressSettings as P, isLanguageHome as R, DesignPresets as S, HomeContent as T, siteLanguages as V, SqlDatabase as _, createContentRuntime as a, CmsFailureReason as b, EntryDelivery as c, HomeDelivery as d, HomePage as f, ContentBindings as g, NotServed as h, ContentRuntime as i, WordPressImage as j, Translation as k, EntryPage as l, Moved as m, ContentConfig as n, Alternate as o, HomePublication as p, ContentConsentConfig as r, DeliveryFailureReason as s, WordPressClient as t, EntryPublication as u, SqlStatement as v, Found as w, Delivery as x, CmsFailure as y, languageSubject as z };
384
+ export { PublishedEntry as A, htmlLang as B, Delivery as C, Found as D, Extended as E, WordPressMenuItem as F, languageSubject as H, WordPressPost as I, WordPressSettings as L, Translation as M, Unavailable as N, HomeContent as O, WordPressImage as P, SiteLanguage as R, CmsFailureReason as S, EntryContent as T, routeLanguage as U, isLanguageHome as V, siteLanguages as W, NotServed as _, ContentRuntime as a, SqlStatement as b, Alternate as c, EntryPage as d, EntryPublication as f, Moved as g, HomePublication as h, ContentExtension as i, SiteChrome as j, Missing as k, DeliveryFailureReason as l, HomePage as m, ContentConfig as n, ExtensionNode as o, HomeDelivery as p, ContentConsentConfig as r, createContentRuntime as s, WordPressClient as t, EntryDelivery as u, ContentBindings as v, DesignPresets as w, CmsFailure as x, SqlDatabase as y, WordPressLanguages as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getquick/content",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "UNLICENSED",
5
5
  "files": [
6
6
  "dist",