@getquick/content 0.2.0 → 0.3.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/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as routeOf, d as routeLanguage, f as siteLanguages, g as renderWordPressBlocks, h as parseWordPressBlocks, l as isLanguageHome, m as hasVideoHero, r as menuItemHref, s as uriPath, t as cmsTime, u as languageSubject } from "./formatting-wOk2jz6J.js";
1
+ import { _ as uriPath, a as siteLanguages, b as parseWordPressBlocks, c as parseDesignBlocks, d as decodeEntities, g as stripHtml, h as routeOf, i as routeLanguage, l as renderDesignPattern, n as isLanguageHome, p as menuItemHref, r as languageSubject, u as cmsTime, x as renderWordPressBlocks, y as hasVideoHero } from "./site-language-cBP8SFrS.js";
2
2
  import { t as bearerMatches } from "./bearer-DMOCoSci.js";
3
3
  import { _ as consentConfiguration } from "./state-BYWc5TUB.js";
4
4
  import { i as rewriteEmbeds, l as parseLegalLanguage } from "./embeds-p9SyGNZI.js";
@@ -3536,7 +3536,7 @@ const unionType = ZodUnion.create;
3536
3536
  ZodDiscriminatedUnion.create;
3537
3537
  ZodIntersection.create;
3538
3538
  ZodTuple.create;
3539
- ZodRecord.create;
3539
+ const recordType = ZodRecord.create;
3540
3540
  ZodMap.create;
3541
3541
  ZodSet.create;
3542
3542
  ZodFunction.create;
@@ -3737,7 +3737,8 @@ function publicationStore(db) {
3737
3737
  RETURNING event_id`).bind(event.nodeId, event.id, event.occurredAt).first()))?.event_id ?? null;
3738
3738
  },
3739
3739
  async entryIndex() {
3740
- const [entries, withdrawals] = await guard("be read", () => db.batch([db.prepare(`SELECT p.key, p.state, p.node_id, p.modified_at, coalesce(a.attempted_at, 0) AS attempted_at
3740
+ const [entries, withdrawals] = await guard("be read", () => db.batch([db.prepare(`SELECT p.key, p.state, p.node_id, p.modified_at, p.read_started_at,
3741
+ coalesce(a.attempted_at, 0) AS attempted_at
3741
3742
  FROM publications p LEFT JOIN refresh_attempts a ON a.key = p.key
3742
3743
  WHERE substr(p.key, 1, 6) = 'entry:'
3743
3744
  ORDER BY p.key`), db.prepare("SELECT node_id, withdrawn_at FROM withdrawals WHERE republished_at IS NULL")]));
@@ -3747,6 +3748,7 @@ function publicationStore(db) {
3747
3748
  state: row.state,
3748
3749
  nodeId: row.node_id,
3749
3750
  modifiedAt: row.modified_at,
3751
+ readStartedAt: row.read_started_at,
3750
3752
  attemptedAt: row.attempted_at
3751
3753
  })),
3752
3754
  withdrawals: new Map(withdrawals.results.map((row) => [row.node_id, row.withdrawn_at]))
@@ -3791,7 +3793,7 @@ function publicationStore(db) {
3791
3793
  },
3792
3794
  async status() {
3793
3795
  const [shared, entries, withdrawals, failed] = await guard("be read", () => db.batch([
3794
- db.prepare("SELECT key, state, format FROM publications WHERE key IN ('home', 'chrome', 'design')"),
3796
+ db.prepare("SELECT key, state, format FROM publications WHERE key IN ('home', 'chrome', 'design', 'archives')"),
3795
3797
  db.prepare(`SELECT CASE WHEN format = ?1 THEN state ELSE 'unusable' END AS state, count(*) AS count
3796
3798
  FROM publications WHERE substr(key, 1, 6) = 'entry:' GROUP BY 1`).bind(1),
3797
3799
  db.prepare("SELECT count(*) AS count FROM withdrawals WHERE republished_at IS NULL"),
@@ -3808,11 +3810,47 @@ function publicationStore(db) {
3808
3810
  home: state("home"),
3809
3811
  chrome: state("chrome"),
3810
3812
  design: state("design"),
3813
+ ...state("archives") === null ? {} : { archives: state("archives") },
3811
3814
  entries: Object.fromEntries(entries.results.map((row) => [row.state, Number(row.count)])),
3812
3815
  withdrawals: count(withdrawals),
3813
3816
  failedEvents: count(failed)
3814
3817
  };
3815
3818
  },
3819
+ async listItems(filter, limit, offset) {
3820
+ const { where, values } = itemsWhere(filter);
3821
+ const { results } = await guard("be read", () => db.prepare(`SELECT key, body, modified_at, count(*) OVER () AS total FROM publications p
3822
+ WHERE ${where}
3823
+ ORDER BY json_extract(p.body, '$.date') DESC, p.key
3824
+ LIMIT ? OFFSET ?`).bind(...values, limit, offset).all());
3825
+ let total = Number(results[0]?.total ?? 0);
3826
+ if (results.length === 0 && offset > 0) {
3827
+ const row = await guard("be read", () => db.prepare(`SELECT count(*) AS total FROM publications p WHERE ${where}`).bind(...values).first());
3828
+ total = Number(row?.total ?? 0);
3829
+ }
3830
+ return {
3831
+ items: results.map((row) => ({
3832
+ key: row.key,
3833
+ body: row.body,
3834
+ modifiedAt: row.modified_at
3835
+ })),
3836
+ total
3837
+ };
3838
+ },
3839
+ async itemTerms(filter, path) {
3840
+ const { where, values } = itemsWhere(filter);
3841
+ const { results } = await guard("be read", () => db.prepare(`SELECT json_extract(t.value, '$.slug') AS slug,
3842
+ max(json_extract(t.value, '$.name')) AS name,
3843
+ count(DISTINCT p.key) AS count
3844
+ FROM publications p, json_each(p.body, ?) AS t
3845
+ WHERE ${where} AND json_type(t.value, '$.slug') = 'text'
3846
+ GROUP BY 1
3847
+ ORDER BY 2 COLLATE NOCASE, 1`).bind(path, ...values).all());
3848
+ return results.map((row) => ({
3849
+ slug: row.slug,
3850
+ name: row.name ?? row.slug,
3851
+ count: Number(row.count)
3852
+ }));
3853
+ },
3816
3854
  async startEvent(id) {
3817
3855
  await guard("record an event", () => db.prepare("UPDATE publication_events SET attempts = attempts + 1 WHERE id = ?").bind(id).run());
3818
3856
  },
@@ -3827,6 +3865,33 @@ function publicationStore(db) {
3827
3865
  }
3828
3866
  };
3829
3867
  }
3868
+ /** The WHERE clause (on `publications p`) for a collection's items, and its values in order. */
3869
+ function itemsWhere(filter) {
3870
+ const clauses = [
3871
+ "substr(p.key, 1, 6) = 'entry:'",
3872
+ "p.state = 'published'",
3873
+ "p.format = ?",
3874
+ "json_extract(p.body, '$.collection.name') = ?"
3875
+ ];
3876
+ const values = [1, filter.collection];
3877
+ if (filter.language !== null) {
3878
+ clauses.push("coalesce(json_extract(p.body, '$.language'), ?) = ?");
3879
+ values.push(filter.defaultLanguage, filter.language);
3880
+ }
3881
+ if (filter.search !== null) {
3882
+ clauses.push("instr(json_extract(p.body, '$.collection.search'), ?) > 0");
3883
+ values.push(filter.search);
3884
+ }
3885
+ for (const { path, slugs } of filter.terms) {
3886
+ clauses.push(`EXISTS (SELECT 1 FROM json_each(p.body, ?) AS term
3887
+ WHERE json_extract(term.value, '$.slug') IN (SELECT slug.value FROM json_each(?) AS slug))`);
3888
+ values.push(path, JSON.stringify(slugs));
3889
+ }
3890
+ return {
3891
+ where: clauses.join(" AND "),
3892
+ values
3893
+ };
3894
+ }
3830
3895
  function movedTo(body) {
3831
3896
  try {
3832
3897
  const uri = JSON.parse(body ?? "null")?.uri;
@@ -3837,7 +3902,7 @@ function movedTo(body) {
3837
3902
  }
3838
3903
  //#endregion
3839
3904
  //#region frontend/src/reconciliation.ts
3840
- function configuredReconciliation(languages, delivery, wordpress) {
3905
+ function configuredReconciliation(languages, delivery, wordpress, collections = false) {
3841
3906
  const { eachLimited, reconcileShared, refreshEntries, sharedOutcomes } = delivery;
3842
3907
  const { getPublishedEntries } = wordpress;
3843
3908
  const isLanguageHome$4 = (route) => isLanguageHome(route, languages);
@@ -3848,10 +3913,24 @@ function configuredReconciliation(languages, delivery, wordpress) {
3848
3913
  * is asked again without them. Chrome and design have no block recovery.
3849
3914
  */
3850
3915
  const REQUESTS_PER_READ = 2;
3851
- /** Reserve design once, and each language's home (including recovery) and chrome. */
3852
- const SHARED_REQUESTS = 1 + 3 * languages.length;
3853
- /** Stored entries re-read each run, when the budget allows, though WordPress lists them unchanged. */
3916
+ /**
3917
+ * Reserve design once (and the archives, on a Site with collections), and
3918
+ * each language's home (including recovery) and chrome.
3919
+ */
3920
+ const SHARED_REQUESTS = 1 + (collections ? 2 : 0) + 3 * languages.length;
3921
+ /**
3922
+ * Stored entries re-read each run, when the budget allows, though WordPress
3923
+ * lists them unchanged: published ones, and listed routes settled missing.
3924
+ */
3854
3925
  const VERIFIED_PER_RUN = 2;
3926
+ /**
3927
+ * How much later than an entry's modification time a read must have started
3928
+ * for the missing row it stored to count as settled. The read's start is
3929
+ * the Frontend's clock (ms) and the modification time the CMS's (floored to
3930
+ * the second): the margin covers that rounding and any skew between the two
3931
+ * hosts, so a change made just after a read isn't taken as already read.
3932
+ */
3933
+ const SETTLED_CLOCK_MARGIN_MS = 3e5;
3855
3934
  /** How long a run holds the lease; an interrupted run's lease is taken over after it. */
3856
3935
  const LEASE_MS = 12e4;
3857
3936
  const ENTRY = "entry:";
@@ -3904,7 +3983,7 @@ function configuredReconciliation(languages, delivery, wordpress) {
3904
3983
  }
3905
3984
  }
3906
3985
  async function run(store, startedAt, siteOrigin, publish) {
3907
- const [shared, listing] = await Promise.all([reconcileShared(store, siteOrigin), getPublishedEntries()]);
3986
+ const [{ archiveRoutes = [], ...shared }, listing] = await Promise.all([reconcileShared(store, siteOrigin), getPublishedEntries()]);
3908
3987
  const failures = [];
3909
3988
  let changed = 0;
3910
3989
  for (const outcome of sharedOutcomes(shared)) {
@@ -3931,9 +4010,10 @@ function configuredReconciliation(languages, delivery, wordpress) {
3931
4010
  }
3932
4011
  const { entries: listed, requests } = listing.content;
3933
4012
  const index = await store.entryIndex();
3934
- const found = candidates(listed, index);
4013
+ const { found, settled } = candidates(listed, index);
3935
4014
  let budget = CMS_REQUEST_BUDGET - SHARED_REQUESTS - requests;
3936
4015
  const chosen = [];
4016
+ found.unshift(...archived(archiveRoutes, found));
3937
4017
  let pending = 0;
3938
4018
  for (const candidate of found) {
3939
4019
  const cost = (candidate.previousRoute ? 2 : 1) * REQUESTS_PER_READ;
@@ -3944,7 +4024,7 @@ function configuredReconciliation(languages, delivery, wordpress) {
3944
4024
  budget -= cost;
3945
4025
  chosen.push(candidate);
3946
4026
  }
3947
- if (pending === 0) chosen.push(...verified(index, found, Math.floor(budget / REQUESTS_PER_READ)));
4027
+ if (pending === 0) chosen.push(...verified(index, found, settled, Math.floor(budget / REQUESTS_PER_READ)));
3948
4028
  const entries = await refresh(store, chosen, publish);
3949
4029
  for (const outcome of Object.values(entries)) {
3950
4030
  if (outcome.outcome === "kept") failures.push(outcome.failure);
@@ -3967,7 +4047,8 @@ function configuredReconciliation(languages, delivery, wordpress) {
3967
4047
  };
3968
4048
  }
3969
4049
  /**
3970
- * What differs between WordPress's list and the store, oldest-attempted first.
4050
+ * What differs between WordPress's list and the store (found),
4051
+ * oldest-attempted first, and the listed routes settled missing (settled).
3971
4052
  * WordPress's front page (`/`) is the homepage's, reconciled with the shared
3972
4053
  * rows, unless it is withdrawn and modified since.
3973
4054
  */
@@ -3979,6 +4060,7 @@ function configuredReconciliation(languages, delivery, wordpress) {
3979
4060
  storedAt.set(row.nodeId, [...storedAt.get(row.nodeId) ?? [], row.key.slice(6)]);
3980
4061
  }
3981
4062
  const found = [];
4063
+ const settled = /* @__PURE__ */ new Set();
3982
4064
  const listedRoutes = /* @__PURE__ */ new Set();
3983
4065
  const left = /* @__PURE__ */ new Set();
3984
4066
  for (const entry of listed) {
@@ -3999,6 +4081,10 @@ function configuredReconciliation(languages, delivery, wordpress) {
3999
4081
  if (isLanguageHome$4(route)) continue;
4000
4082
  if (row?.state === "published" && entry.id !== null && row.nodeId === entry.id && entry.modifiedAt !== null && row.modifiedAt === entry.modifiedAt) continue;
4001
4083
  const previousRoute = entry.id === null ? void 0 : storedAt.get(entry.id)?.find((stored) => stored !== route);
4084
+ if (row?.state === "missing" && !previousRoute && entry.modifiedAt !== null && row.readStartedAt - SETTLED_CLOCK_MARGIN_MS > entry.modifiedAt) {
4085
+ settled.add(route);
4086
+ continue;
4087
+ }
4002
4088
  if (previousRoute) left.add(previousRoute);
4003
4089
  found.push({
4004
4090
  change: previousRoute ? "moved" : row ? "changed" : "new",
@@ -4017,12 +4103,33 @@ function configuredReconciliation(languages, delivery, wordpress) {
4017
4103
  attemptedAt: row.attemptedAt
4018
4104
  });
4019
4105
  }
4020
- return found.sort((a, b) => a.attemptedAt - b.attemptedAt || (a.route < b.route ? -1 : a.route > b.route ? 1 : 0));
4106
+ found.sort((a, b) => a.attemptedAt - b.attemptedAt || (a.route < b.route ? -1 : a.route > b.route ? 1 : 0));
4107
+ return {
4108
+ found,
4109
+ settled
4110
+ };
4111
+ }
4112
+ /** The addresses whose archive moved, unless already a change, as changes. */
4113
+ function archived(routes, found) {
4114
+ const changing = new Set(found.flatMap(({ route, previousRoute }) => [route, previousRoute]));
4115
+ return routes.filter((route) => !changing.has(route) && !isLanguageHome$4(route)).map((route) => ({
4116
+ change: "archive",
4117
+ route,
4118
+ attemptedAt: 0
4119
+ }));
4021
4120
  }
4022
- /** Up to `limit` (and VERIFIED_PER_RUN) stored entries no change was found in, oldest-attempted first. */
4023
- function verified(index, found, limit) {
4121
+ /**
4122
+ * Up to `limit` (and VERIFIED_PER_RUN) stored entries no change was found
4123
+ * in, published or settled missing, oldest-attempted first. Other missing
4124
+ * rows (a 404 looked up at an address WordPress doesn't list) aren't re-read.
4125
+ */
4126
+ function verified(index, found, settled, limit) {
4024
4127
  const changing = new Set(found.flatMap(({ route, previousRoute }) => [route, previousRoute]));
4025
- return index.entries.filter((row) => row.state === "published" && !changing.has(row.key.slice(6))).sort((a, b) => a.attemptedAt - b.attemptedAt).slice(0, Math.max(0, Math.min(limit, VERIFIED_PER_RUN))).map((row) => ({
4128
+ return index.entries.filter((row) => {
4129
+ const route = row.key.slice(6);
4130
+ if (changing.has(route)) return false;
4131
+ return row.state === "published" || row.state === "missing" && settled.has(route);
4132
+ }).sort((a, b) => a.attemptedAt - b.attemptedAt).slice(0, Math.max(0, Math.min(limit, VERIFIED_PER_RUN))).map((row) => ({
4026
4133
  change: "verified",
4027
4134
  route: row.key.slice(6),
4028
4135
  attemptedAt: row.attemptedAt
@@ -4053,11 +4160,11 @@ function configuredReconciliation(languages, delivery, wordpress) {
4053
4160
  };
4054
4161
  try {
4055
4162
  const { body } = await publish(store, publication);
4056
- if (body.status === "refreshed") outcomes[candidate.route] = {
4163
+ if (body.status === "refreshed" && !body.duplicate) outcomes[candidate.route] = {
4057
4164
  change: candidate.change,
4058
4165
  outcome: "refreshed"
4059
4166
  };
4060
- else if (body.status === "superseded") superseded.push(candidate);
4167
+ else if (body.status === "refreshed" || body.status === "superseded") superseded.push(candidate);
4061
4168
  else outcomes[candidate.route] = {
4062
4169
  change: candidate.change,
4063
4170
  outcome: "kept",
@@ -4134,7 +4241,7 @@ function configuredReconciliation(languages, delivery, wordpress) {
4134
4241
  //#endregion
4135
4242
  //#region frontend/src/events.ts
4136
4243
  function configuredEvents(config, languages, delivery, wordpress) {
4137
- const { reconcile } = configuredReconciliation(languages, delivery, wordpress);
4244
+ const { reconcile } = configuredReconciliation(languages, delivery, wordpress, Object.keys(config.collections ?? {}).length > 0);
4138
4245
  const SITE = config.site;
4139
4246
  const isLanguageHome$3 = (route) => isLanguageHome(route, languages);
4140
4247
  const routeLanguage$3 = (route) => routeLanguage(route, languages);
@@ -4211,15 +4318,19 @@ function configuredEvents(config, languages, delivery, wordpress) {
4211
4318
  * The shared settings an event can name, and what each change refreshes: the
4212
4319
  * menus and the logo are in the chrome every page is served with; the site's
4213
4320
  * identity is its title and tagline (the front page's) and its icon (the
4214
- * chrome's); the design presets are the shared design every page uses.
4321
+ * chrome's); the design presets are the shared design every page uses; the
4322
+ * patterns are GQ Design's synced patterns and Header & Footer selection,
4323
+ * the chrome's header and footer.
4215
4324
  */
4216
4325
  const SETTINGS = {
4217
4326
  menus: ["chrome"],
4218
4327
  logo: ["chrome"],
4219
4328
  identity: ["home", "chrome"],
4220
4329
  design: ["design"],
4330
+ patterns: ["chrome"],
4221
4331
  legal: ["chrome"],
4222
- analytics: ["chrome"]
4332
+ analytics: ["chrome"],
4333
+ archives: ["archives"]
4223
4334
  };
4224
4335
  /**
4225
4336
  * What a change in one language refreshes, for the settings a language has
@@ -4565,7 +4676,7 @@ function configuredEvents(config, languages, delivery, wordpress) {
4565
4676
  await store.startEvent(event.id);
4566
4677
  const language = languages.find((each) => each.slug === event.language);
4567
4678
  const { refreshed, ...parts } = await refreshShared(store, language ? [...LANGUAGE_SETTINGS[event.setting]] : [...SETTINGS[event.setting]], siteOrigin, language);
4568
- const failure = sharedOutcomes(parts).find((outcome) => outcome.outcome === "kept");
4679
+ const failure = [...sharedOutcomes(parts), ...Object.values(parts.entries ?? {})].find((outcome) => outcome.outcome === "kept");
4569
4680
  await store.finishEvent(recorded, failure?.outcome === "kept" ? {
4570
4681
  status: "failed",
4571
4682
  reason: failure.failure.reason,
@@ -4661,10 +4772,32 @@ function configuredRefresh(languages, delivery) {
4661
4772
  function publishedHtml(languages, transform) {
4662
4773
  const defaultLanguage = languages[0];
4663
4774
  const languageOf = (slug, fallback) => languages.find((language) => language.slug === slug) ?? fallback;
4775
+ const blocks = (list, language) => list.map((block) => ({
4776
+ ...block,
4777
+ content: block.content === null ? null : transform(block.content, language),
4778
+ children: blocks(block.children, language)
4779
+ }));
4780
+ const pattern = (value, language) => value ? {
4781
+ ...value,
4782
+ blocks: blocks(value.blocks, language)
4783
+ } : value;
4784
+ /** A language's chrome, its header and footer patterns' HTML transformed. */
4785
+ function chrome(value, language = defaultLanguage) {
4786
+ if (!transform || !value || !value.header && !value.footer) return value;
4787
+ return {
4788
+ ...value,
4789
+ header: pattern(value.header, language) ?? null,
4790
+ footer: pattern(value.footer, language) ?? null
4791
+ };
4792
+ }
4664
4793
  function home(delivery, language = defaultLanguage) {
4794
+ const served = languageOf(language.slug, defaultLanguage);
4795
+ if (transform && delivery.kind === "missing") return {
4796
+ ...delivery,
4797
+ chrome: chrome(delivery.chrome, served)
4798
+ };
4665
4799
  if (!transform || delivery.kind !== "found") return delivery;
4666
4800
  const { home } = delivery.content;
4667
- const served = languageOf(language.slug, defaultLanguage);
4668
4801
  return {
4669
4802
  ...delivery,
4670
4803
  content: {
@@ -4675,11 +4808,16 @@ function publishedHtml(languages, transform) {
4675
4808
  ...home.page,
4676
4809
  content: transform(home.page.content, served)
4677
4810
  }
4678
- }
4811
+ },
4812
+ chrome: chrome(delivery.content.chrome, served)
4679
4813
  }
4680
4814
  };
4681
4815
  }
4682
4816
  function entry(delivery, routeLanguage) {
4817
+ if (transform && delivery.kind === "missing") return {
4818
+ ...delivery,
4819
+ chrome: chrome(delivery.chrome, routeLanguage)
4820
+ };
4683
4821
  if (!transform || delivery.kind !== "found") return delivery;
4684
4822
  const { entry } = delivery.content;
4685
4823
  const served = languageOf(entry.language, routeLanguage);
@@ -4690,24 +4828,28 @@ function publishedHtml(languages, transform) {
4690
4828
  entry: {
4691
4829
  ...entry,
4692
4830
  content: transform(entry.content, served)
4693
- }
4831
+ },
4832
+ chrome: chrome(delivery.content.chrome, served)
4694
4833
  }
4695
4834
  };
4696
4835
  }
4697
4836
  return {
4698
4837
  home,
4699
- entry
4838
+ entry,
4839
+ chrome
4700
4840
  };
4701
4841
  }
4702
4842
  //#endregion
4703
4843
  //#region frontend/src/delivery.ts
4704
4844
  function configuredDelivery(config, languages, wordpress, transformHtml) {
4705
4845
  const defaultLanguage = languages[0];
4846
+ const collections = config.collections ?? {};
4847
+ const hasCollections = Object.keys(collections).length > 0;
4706
4848
  const html = publishedHtml(languages, transformHtml);
4707
4849
  const multilingual = languages.length > 1;
4708
4850
  const routeLanguage$1 = (route) => routeLanguage(route, languages);
4709
4851
  const isLanguageHome$1 = (route) => isLanguageHome(route, languages);
4710
- const { getHomeContent, getEntryByUri, getSiteChrome, getDesignPresets, getPublishedRoutes } = wordpress;
4852
+ const { getHomeContent, getEntryByUri, getSiteChrome, getDesignPresets, getPublishedRoutes, getArchives } = wordpress;
4711
4853
  const HOME = "home";
4712
4854
  const CHROME = "chrome";
4713
4855
  /**
@@ -4719,6 +4861,8 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4719
4861
  const chromeKey = (language) => language.isDefault ? CHROME : `${CHROME}:${language.slug}`;
4720
4862
  /** The design presets every page is served with, once a refresh has stored them. */
4721
4863
  const DESIGN = "design";
4864
+ /** Where each collection's archive is, on a Site with collections. */
4865
+ const ARCHIVES = "archives";
4722
4866
  /** Entries are stored at "entry:<route>". */
4723
4867
  const ENTRY = "entry:";
4724
4868
  const entryKey = (route) => `${ENTRY}${route}`;
@@ -4785,7 +4929,24 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4785
4929
  uri: stringType(),
4786
4930
  language: stringType()
4787
4931
  });
4932
+ const term = objectType({
4933
+ slug: stringType(),
4934
+ name: stringType()
4935
+ });
4936
+ /**
4937
+ * The collection an item is in, with its terms; stored with the text a
4938
+ * search matches (`search`), which isn't served.
4939
+ */
4940
+ const membership = objectType({
4941
+ name: stringType(),
4942
+ terms: recordType(arrayType(term)),
4943
+ search: stringType().optional()
4944
+ }).transform(({ name, terms }) => ({
4945
+ name,
4946
+ terms
4947
+ }));
4788
4948
  const entryPublication = objectType({
4949
+ collection: membership.optional(),
4789
4950
  language: stringType().optional(),
4790
4951
  translations: arrayType(translation).optional(),
4791
4952
  id: stringType(),
@@ -4816,10 +4977,22 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4816
4977
  roles: arrayType(legalRole),
4817
4978
  missingRequired: arrayType(stringType())
4818
4979
  }).nullable();
4980
+ const designBlock = lazyType(() => objectType({
4981
+ name: stringType().min(1),
4982
+ attributes: recordType(stringType(), unknownType()),
4983
+ content: stringType().nullable(),
4984
+ children: arrayType(designBlock)
4985
+ }));
4986
+ const designPattern = objectType({
4987
+ title: stringType(),
4988
+ blocks: arrayType(designBlock).min(1)
4989
+ }).nullable();
4819
4990
  const siteChrome = objectType({
4820
4991
  menuItems: arrayType(menuItem),
4821
4992
  siteIcon: image,
4822
4993
  siteLogo: image,
4994
+ header: designPattern.optional().transform((pattern) => pattern ?? null),
4995
+ footer: designPattern.optional().transform((pattern) => pattern ?? null),
4823
4996
  legal: legal.optional(),
4824
4997
  analytics: objectType({
4825
4998
  enabled: booleanType(),
@@ -4830,6 +5003,7 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4830
5003
  spacingSizes,
4831
5004
  colors
4832
5005
  });
5006
+ const archives = recordType(stringType());
4833
5007
  const parseWith = (schema) => (body) => {
4834
5008
  const parsed = schema.safeParse(body);
4835
5009
  return parsed.success ? parsed.data : void 0;
@@ -4838,10 +5012,13 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4838
5012
  const parseChrome = parseWith(siteChrome);
4839
5013
  const parseEntry = parseWith(entryPublication);
4840
5014
  const parseDesign = parseWith(designPresets);
5015
+ const parseArchives = parseWith(archives);
4841
5016
  const emptyChrome = {
4842
5017
  menuItems: [],
4843
5018
  siteIcon: null,
4844
- siteLogo: null
5019
+ siteLogo: null,
5020
+ header: null,
5021
+ footer: null
4845
5022
  };
4846
5023
  async function boundStore() {
4847
5024
  const { PUBLICATION_DB } = await config.bindings();
@@ -5185,16 +5362,18 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5185
5362
  async function refreshSite(store, siteOrigin) {
5186
5363
  const readStartedAt = Date.now();
5187
5364
  const others = languages.filter((language) => !language.isDefault);
5188
- const [home, chrome, design, listing, ...otherReads] = await Promise.all([
5365
+ const [home, chrome, design, archiveRead, listing, ...otherReads] = await Promise.all([
5189
5366
  getHomeContent(),
5190
5367
  getSiteChrome(siteOrigin),
5191
5368
  getDesignPresets(),
5369
+ hasCollections ? getArchives() : void 0,
5192
5370
  getPublishedRoutes(),
5193
5371
  ...others.map((language) => Promise.all([getHomeContent(language), getSiteChrome(siteOrigin, { language })]))
5194
5372
  ]);
5195
5373
  const homeOutcome = await promoteHome(store, home, readStartedAt);
5196
5374
  const chromeOutcome = await promoteChrome(store, chrome, readStartedAt);
5197
5375
  const designOutcome = await promoteDesign(store, design, readStartedAt);
5376
+ const archivesOutcome = archiveRead ? await promoteArchives(store, archiveRead, readStartedAt) : void 0;
5198
5377
  const languageOutcomes = {};
5199
5378
  for (const [index, language] of others.entries()) {
5200
5379
  const [languageHome, languageChrome] = otherReads[index];
@@ -5234,10 +5413,11 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5234
5413
  }
5235
5414
  return {
5236
5415
  ready,
5237
- refreshed: homeOutcome.outcome !== "kept" && chromeOutcome.outcome !== "kept" && designOutcome.outcome !== "kept" && Object.values(languageOutcomes).every((outcomes) => outcomes.home.outcome !== "kept" && outcomes.chrome.outcome !== "kept") && routes.outcome === "listed" && entries.refreshed,
5416
+ refreshed: homeOutcome.outcome !== "kept" && chromeOutcome.outcome !== "kept" && designOutcome.outcome !== "kept" && archivesOutcome?.outcome !== "kept" && Object.values(languageOutcomes).every((outcomes) => outcomes.home.outcome !== "kept" && outcomes.chrome.outcome !== "kept") && routes.outcome === "listed" && entries.refreshed,
5238
5417
  home: homeOutcome,
5239
5418
  chrome: chromeOutcome,
5240
5419
  design: designOutcome,
5420
+ ...archivesOutcome ? { archives: archivesOutcome } : {},
5241
5421
  routes,
5242
5422
  entries: entries.entries,
5243
5423
  moved: entries.moved,
@@ -5250,6 +5430,7 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5250
5430
  rows.home,
5251
5431
  rows.chrome,
5252
5432
  rows.design,
5433
+ rows.archives,
5253
5434
  ...Object.values(rows.languages ?? {}).flatMap((language) => [language.home, language.chrome])
5254
5435
  ].filter((outcome) => outcome !== void 0);
5255
5436
  }
@@ -5269,7 +5450,11 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5269
5450
  const readStartedAt = Date.now();
5270
5451
  const wanted = new Set(parts);
5271
5452
  const scope = language ? [language] : languages;
5272
- const [reads, design] = await Promise.all([Promise.all(scope.map((each) => Promise.all([wanted.has("home") ? getHomeContent(each) : void 0, wanted.has("chrome") ? getSiteChrome(siteOrigin, { language: each }) : void 0]))), wanted.has("design") && !language ? getDesignPresets() : void 0]);
5453
+ const [reads, design, archiveRead] = await Promise.all([
5454
+ Promise.all(scope.map((each) => Promise.all([wanted.has("home") ? getHomeContent(each) : void 0, wanted.has("chrome") ? getSiteChrome(siteOrigin, { language: each }) : void 0]))),
5455
+ wanted.has("design") && !language ? getDesignPresets() : void 0,
5456
+ wanted.has("archives") && !language && hasCollections ? getArchives() : void 0
5457
+ ]);
5273
5458
  const report = { refreshed: true };
5274
5459
  for (const [index, each] of scope.entries()) {
5275
5460
  const [home, chrome] = reads[index];
@@ -5278,7 +5463,15 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5278
5463
  if (chrome) rows.chrome = await promoteChrome(store, chrome, readStartedAt, each);
5279
5464
  }
5280
5465
  if (design) report.design = await promoteDesign(store, design, readStartedAt);
5281
- report.refreshed = sharedOutcomes(report).every((outcome) => outcome.outcome !== "kept");
5466
+ if (archiveRead) {
5467
+ const before = archiveRead.kind === "found" ? await storedArchives(store) : void 0;
5468
+ report.archives = await promoteArchives(store, archiveRead, readStartedAt);
5469
+ if (archiveRead.kind === "found" && report.archives.outcome !== "kept") {
5470
+ const routes = archiveRoutes(before, archiveRead.content, "all");
5471
+ if (routes.length > 0) report.entries = (await refreshEntries(store, routes)).entries;
5472
+ }
5473
+ }
5474
+ report.refreshed = [...sharedOutcomes(report), ...Object.values(report.entries ?? {})].every((outcome) => outcome.outcome !== "kept");
5282
5475
  return report;
5283
5476
  }
5284
5477
  /**
@@ -5305,7 +5498,11 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5305
5498
  */
5306
5499
  async function reconcileShared(store, siteOrigin) {
5307
5500
  const readStartedAt = Date.now();
5308
- const [reads, design] = await Promise.all([Promise.all(languages.map((language) => Promise.all([getHomeContent(language), getSiteChrome(siteOrigin, { language })]))), getDesignPresets()]);
5501
+ const [reads, design, archiveRead] = await Promise.all([
5502
+ Promise.all(languages.map((language) => Promise.all([getHomeContent(language), getSiteChrome(siteOrigin, { language })]))),
5503
+ getDesignPresets(),
5504
+ hasCollections ? getArchives() : void 0
5505
+ ]);
5309
5506
  const unchanged = async (key, parse, read) => {
5310
5507
  if (!read) return false;
5311
5508
  try {
@@ -5338,10 +5535,22 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5338
5535
  chrome: await unchanged(chromeKey(language), parseChrome, sharedRead(chrome)) ? { outcome: "unchanged" } : await promoteChrome(store, chrome, readStartedAt, language)
5339
5536
  });
5340
5537
  }
5538
+ let archives;
5539
+ let changedArchives = [];
5540
+ if (archiveRead) {
5541
+ const before = await storedArchives(store);
5542
+ if (await unchanged(ARCHIVES, parseArchives, sharedRead(archiveRead))) archives = { outcome: "unchanged" };
5543
+ else {
5544
+ archives = await promoteArchives(store, archiveRead, readStartedAt);
5545
+ if (archiveRead.kind === "found" && archives.outcome === "promoted") changedArchives = archiveRoutes(before, archiveRead.content, "changed");
5546
+ }
5547
+ }
5341
5548
  const [own, ...others] = outcomes;
5342
5549
  return {
5343
5550
  ...own,
5344
5551
  design: await unchanged(DESIGN, parseDesign, sharedRead(design)) ? { outcome: "unchanged" } : await promoteDesign(store, design, readStartedAt),
5552
+ ...archives ? { archives } : {},
5553
+ ...changedArchives.length > 0 ? { archiveRoutes: changedArchives } : {},
5345
5554
  ...multilingual ? { languages: Object.fromEntries(others.map((outcome, index) => [languages[index + 1].slug, outcome])) } : {}
5346
5555
  };
5347
5556
  }
@@ -5365,6 +5574,36 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5365
5574
  function promoteDesign(store, read, readStartedAt) {
5366
5575
  return promoteShared(store, DESIGN, "the shared design presets", parseDesign, read, readStartedAt);
5367
5576
  }
5577
+ /** Where each collection's archive is, as stored; undefined when it can't be read. */
5578
+ async function storedArchives(store) {
5579
+ try {
5580
+ const stored = await store.read(ARCHIVES, parseArchives);
5581
+ return stored?.state === "published" ? stored.content : void 0;
5582
+ } catch (error) {
5583
+ if (!(error instanceof StoreFailure)) throw error;
5584
+ return;
5585
+ }
5586
+ }
5587
+ /**
5588
+ * The archive addresses to read again after a read of where the archives
5589
+ * are: every address before or after it ("all"), or only the old and new
5590
+ * address of each post type whose archive moved ("changed"). A language's
5591
+ * front page is its home's, never an entry.
5592
+ */
5593
+ function archiveRoutes(before, after, which) {
5594
+ const routes = /* @__PURE__ */ new Set();
5595
+ for (const type of /* @__PURE__ */ new Set([...Object.keys(before ?? {}), ...Object.keys(after)])) {
5596
+ const old = before?.[type];
5597
+ const now = after[type];
5598
+ if (which === "changed" && old === now) continue;
5599
+ for (const uri of [old, now]) if (uri) routes.add(routeOf(uri));
5600
+ }
5601
+ return [...routes].filter((route) => !isLanguageHome$1(route));
5602
+ }
5603
+ /** Promotes a read of where each collection's archive is. */
5604
+ function promoteArchives(store, read, readStartedAt) {
5605
+ return promoteShared(store, ARCHIVES, "the archives", parseArchives, read, readStartedAt);
5606
+ }
5368
5607
  /**
5369
5608
  * Refreshes a language's front page only (not its chrome or the entries): the
5370
5609
  * front page itself was published or changed.
@@ -5465,7 +5704,7 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5465
5704
  }
5466
5705
  const outcome = await promote(canonical, () => ({
5467
5706
  state: "published",
5468
- content: entry,
5707
+ content: stored(entry),
5469
5708
  nodeId: entry.id,
5470
5709
  modifiedAt
5471
5710
  }));
@@ -5490,6 +5729,18 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5490
5729
  found
5491
5730
  };
5492
5731
  }
5732
+ /** An entry as stored: a collection's item with the text its search matches. */
5733
+ function stored(entry) {
5734
+ if (!entry.collection) return entry;
5735
+ const search = searchText(decodeEntities(`${entry.title} ${stripHtml(entry.excerpt)}`));
5736
+ return {
5737
+ ...entry,
5738
+ collection: {
5739
+ ...entry.collection,
5740
+ search
5741
+ }
5742
+ };
5743
+ }
5493
5744
  /**
5494
5745
  * Every other route still serving an entry that was just found at its own
5495
5746
  * route is marked moved there, unless it was read since `readBefore`: a
@@ -5613,6 +5864,151 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5613
5864
  withdrawn
5614
5865
  };
5615
5866
  }
5867
+ /** The collection's config, or an error: a Site only lists what it declares. */
5868
+ function collectionNamed(name) {
5869
+ if (!Object.hasOwn(collections, name)) throw new Error(`${name} isn't a collection`);
5870
+ return collections[name];
5871
+ }
5872
+ /** A taxonomy's terms in an item's stored body, as a JSON path. */
5873
+ const termsPath = (taxonomy) => `$.collection.terms."${taxonomy}"`;
5874
+ /** The language a listing is in: a multilingual Site's (by slug), else none. */
5875
+ function listingLanguage(slug) {
5876
+ if (!multilingual) return null;
5877
+ if (slug === void 0) return defaultLanguage.slug;
5878
+ if (!languages.some((language) => language.slug === slug)) throw new Error(`${slug} isn't one of the Site's languages`);
5879
+ return slug;
5880
+ }
5881
+ /**
5882
+ * A page of a collection's items, from the store alone: never WordPress,
5883
+ * so a listing is served through outages as its items are. Items whose
5884
+ * stored body this Frontend can't serve are left out.
5885
+ */
5886
+ async function list(query) {
5887
+ const collection = collectionNamed(query.collection);
5888
+ const subject = `the ${query.collection} collection`;
5889
+ const page = Math.max(1, whole("page", query.page, 1));
5890
+ const perPage = Math.min(100, Math.max(1, whole("perPage", query.perPage, 12)));
5891
+ const taxonomies = collection.taxonomies ?? [];
5892
+ const terms = Object.entries(query.terms ?? {}).flatMap(([taxonomy, slugs]) => {
5893
+ if (!taxonomies.includes(taxonomy)) throw new Error(`${taxonomy} isn't one of the ${query.collection} collection's taxonomies`);
5894
+ return slugs.length > 0 ? [{
5895
+ path: termsPath(taxonomy),
5896
+ slugs: [...slugs]
5897
+ }] : [];
5898
+ });
5899
+ const search = searchText(query.search ?? "");
5900
+ const filter = {
5901
+ collection: query.collection,
5902
+ language: listingLanguage(query.language),
5903
+ defaultLanguage: defaultLanguage.slug,
5904
+ terms,
5905
+ search: search || null
5906
+ };
5907
+ const store = await listingStore(subject);
5908
+ if ("kind" in store) return store;
5909
+ let found;
5910
+ try {
5911
+ found = await store.listItems(filter, perPage, (page - 1) * perPage);
5912
+ } catch (error) {
5913
+ if (!(error instanceof StoreFailure)) throw error;
5914
+ return notServed(subject, "store", error.message);
5915
+ }
5916
+ return {
5917
+ kind: "found",
5918
+ items: found.items.flatMap((row) => {
5919
+ let entry;
5920
+ try {
5921
+ entry = parseEntry(JSON.parse(row.body));
5922
+ } catch {
5923
+ entry = void 0;
5924
+ }
5925
+ if (!entry) {
5926
+ console.error(`Delivery: ${row.key} is stored with a body this Frontend can't read`);
5927
+ return [];
5928
+ }
5929
+ return [{
5930
+ uri: entry.uri,
5931
+ title: entry.title,
5932
+ date: entry.date,
5933
+ modifiedAt: row.modifiedAt,
5934
+ excerpt: entry.excerpt,
5935
+ featuredImage: entry.featuredImage,
5936
+ terms: Object.fromEntries(taxonomies.map((taxonomy) => [taxonomy, entry.collection?.terms[taxonomy] ?? []])),
5937
+ ...entry.extension !== void 0 ? { extension: entry.extension } : {},
5938
+ ...entry.language !== void 0 ? { language: entry.language } : {}
5939
+ }];
5940
+ }),
5941
+ total: found.total,
5942
+ page,
5943
+ pages: Math.ceil(found.total / perPage)
5944
+ };
5945
+ }
5946
+ /** A listing's whole-number option (as a number, or a string of one, from a URL), else its default. */
5947
+ function whole(option, value, fallback) {
5948
+ if (value === void 0 || value === null || value === "") return fallback;
5949
+ const number = Number(value);
5950
+ if (!Number.isFinite(number)) throw new Error(`${option} must be a number, not ${JSON.stringify(value)}`);
5951
+ return Math.floor(number);
5952
+ }
5953
+ /** The terms of a collection's taxonomy its stored items have, with how many have each. */
5954
+ async function terms(name, taxonomy, options = {}) {
5955
+ if (!(collectionNamed(name).taxonomies ?? []).includes(taxonomy)) throw new Error(`${taxonomy} isn't one of the ${name} collection's taxonomies`);
5956
+ const subject = `the ${name} collection's ${taxonomy}`;
5957
+ const filter = {
5958
+ collection: name,
5959
+ language: listingLanguage(options.language),
5960
+ defaultLanguage: defaultLanguage.slug,
5961
+ terms: [],
5962
+ search: null
5963
+ };
5964
+ const store = await listingStore(subject);
5965
+ if ("kind" in store) return store;
5966
+ try {
5967
+ return {
5968
+ kind: "found",
5969
+ terms: await store.itemTerms(filter, termsPath(taxonomy))
5970
+ };
5971
+ } catch (error) {
5972
+ if (!(error instanceof StoreFailure)) throw error;
5973
+ return notServed(subject, "store", error.message);
5974
+ }
5975
+ }
5976
+ /** The store a listing reads, or why there is none: a listing is never read live. */
5977
+ async function listingStore(subject) {
5978
+ if (!config.isServer()) return notServed(subject, "not-ready", "Content delivery requires a server context");
5979
+ let store;
5980
+ try {
5981
+ store = await boundStore();
5982
+ } catch {
5983
+ return notServed(subject, "store", "The publication store couldn't be loaded");
5984
+ }
5985
+ if (!store) return notServed(subject, "not-ready", "this Frontend has no publication store bound (PUBLICATION_DB); collections are only listed from one");
5986
+ return store;
5987
+ }
5988
+ /**
5989
+ * Where a collection's archive is: the stored archives when there is a
5990
+ * store, else (in explicit development) a live read. Null when it has none,
5991
+ * or neither can be read.
5992
+ */
5993
+ async function archiveUri(name) {
5994
+ const { postType } = collectionNamed(name);
5995
+ if (!config.isServer()) return null;
5996
+ const store = await boundStore();
5997
+ if (!store) {
5998
+ if (!config.development) return null;
5999
+ const read = await getArchives();
6000
+ return read.kind === "found" ? read.content[postType] ?? null : null;
6001
+ }
6002
+ try {
6003
+ const stored = await store.read(ARCHIVES, parseArchives);
6004
+ if (stored?.state === "unusable") console.error(`Delivery: the archives can't be served: ${stored.message}`);
6005
+ return stored?.state === "published" ? stored.content[postType] ?? null : null;
6006
+ } catch (error) {
6007
+ if (!(error instanceof StoreFailure)) throw error;
6008
+ console.error(`Delivery: the archives can't be served (store): ${error.message}`);
6009
+ return null;
6010
+ }
6011
+ }
5616
6012
  /** The refresh's credential when the Worker has one bound, and it is strong enough. */
5617
6013
  async function refreshAuthority() {
5618
6014
  const { PUBLICATION_DB, FRONTEND_REFRESH_TOKEN: token } = await config.bindings();
@@ -5646,13 +6042,25 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5646
6042
  isAuthorized,
5647
6043
  publishedHome: (siteOrigin, language) => safely(async () => html.home(await publishedHome(siteOrigin, language), language), () => notServed("the front page", "store", "The publication store couldn't be loaded")),
5648
6044
  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")),
5649
- pageChrome: (siteOrigin) => safely(() => pageChrome(siteOrigin), () => null),
5650
- designPresets: () => safely(() => pageDesign(), () => null)
6045
+ pageChrome: (siteOrigin) => safely(async () => html.chrome(await pageChrome(siteOrigin)), () => null),
6046
+ designPresets: () => safely(() => pageDesign(), () => null),
6047
+ list,
6048
+ terms,
6049
+ archiveUri: (collection) => {
6050
+ collectionNamed(collection);
6051
+ return safely(() => archiveUri(collection), () => null);
6052
+ }
5651
6053
  };
5652
6054
  }
6055
+ /** Text as a search compares it: lowercase, without accents, its spaces collapsed. */
6056
+ function searchText(text) {
6057
+ return text.normalize("NFD").replace(/\p{M}/gu, "").replace(/[\u2018\u2019\u201A\u201B\u2032]/g, "'").replace(/[\u201C\u201D\u201E\u2033]/g, "\"").toLowerCase().replace(/\s+/g, " ").trim();
6058
+ }
5653
6059
  //#endregion
5654
6060
  //#region frontend/src/wordpress.ts
5655
6061
  /** The origins a link into this Site may use: WordPress's, and the Frontend's when given. */
6062
+ /** The first GQ Design whose `designPattern` serves the default `page` route. */
6063
+ const DESIGN_PATTERN_VERSION = "0.4.1";
5656
6064
  function internalOrigins(config, siteOrigin) {
5657
6065
  return [new URL(config.graphqlEndpoint).origin, ...siteOrigin ? [siteOrigin] : []];
5658
6066
  }
@@ -5660,12 +6068,17 @@ function configuredWordPress(config, languages) {
5660
6068
  const defaultLanguage = languages[0];
5661
6069
  const multilingual = languages.length > 1;
5662
6070
  const extension = config.extension;
6071
+ const collections = Object.entries(config.collections ?? {});
6072
+ /** The collection whose items are of a WPGraphQL type, if one is. */
6073
+ const collectionOf = (typename) => collections.find(([, collection]) => collection.graphqlType === typename);
6074
+ /** The Site's extension fields on a kind of node (an item's, its collection's). */
6075
+ const extensionSelection = (kind, collection) => (kind === "item" ? extension?.fields.collections?.[collection ?? ""] : extension?.fields[kind])?.trim();
5663
6076
  /**
5664
6077
  * The Site's extension fields on a kind of node, read with the blocks (left
5665
6078
  * out with them when WordPress fails on them).
5666
6079
  */
5667
- const extensionFields = (kind) => {
5668
- const fields = extension?.fields[kind]?.trim();
6080
+ const extensionFields = (kind, collection) => {
6081
+ const fields = extensionSelection(kind, collection);
5669
6082
  return fields ? `\n... @include(if: $withBlocks) {\n${fields}\n}` : "";
5670
6083
  };
5671
6084
  const image = objectType({
@@ -5726,6 +6139,32 @@ function configuredWordPress(config, languages) {
5726
6139
  pageBy: pageEntry.nullable(),
5727
6140
  designTokens
5728
6141
  });
6142
+ const termNode = objectType({
6143
+ slug: stringType(),
6144
+ name: stringType().nullable(),
6145
+ taxonomyName: stringType().nullable()
6146
+ });
6147
+ const nodeEntry = objectType({
6148
+ __typename: stringType(),
6149
+ id: stringType(),
6150
+ uri: stringType().nullable(),
6151
+ status: stringType().nullable(),
6152
+ isRestricted: booleanType().nullable(),
6153
+ modifiedGmt: stringType().nullable().optional(),
6154
+ date: stringType().nullable().optional(),
6155
+ title: stringType().nullable().optional(),
6156
+ content: stringType().nullable().optional(),
6157
+ excerpt: stringType().nullable().optional(),
6158
+ featuredImage: imageEdge.optional(),
6159
+ blocks: unknownType().optional(),
6160
+ terms: objectType({ nodes: arrayType(termNode) }).nullable().optional(),
6161
+ language: languageRef.nullable().optional(),
6162
+ translations: translated.translations.optional()
6163
+ }).passthrough();
6164
+ const nodeEntryData = objectType({
6165
+ node: unionType([nodeEntry, objectType({ __typename: stringType() })]).nullable(),
6166
+ designTokens
6167
+ });
5729
6168
  const translatedEntryData = objectType({
5730
6169
  postBy: postEntry.extend(translated).nullable(),
5731
6170
  pageBy: pageEntry.extend(translated).nullable(),
@@ -5754,13 +6193,21 @@ function configuredWordPress(config, languages) {
5754
6193
  modifiedGmt: stringType().nullable().optional()
5755
6194
  }))
5756
6195
  }) });
5757
- const siteChromeData = objectType({
6196
+ const designPattern = objectType({
6197
+ title: stringType(),
6198
+ blocksJson: stringType()
6199
+ }).nullable();
6200
+ const chromeWithoutPatternsData = objectType({
5758
6201
  generalSettings: objectType({
5759
6202
  siteIcon: imageEdge,
5760
6203
  siteLogo: imageEdge
5761
6204
  }),
5762
6205
  menuItems: objectType({ nodes: arrayType(menuItem) })
5763
6206
  });
6207
+ const siteChromeData = chromeWithoutPatternsData.extend({
6208
+ header: designPattern,
6209
+ footer: designPattern
6210
+ });
5764
6211
  const consentSettingsData = objectType({
5765
6212
  getquickLegal: unknownType(),
5766
6213
  googleAnalytics: objectType({
@@ -5850,9 +6297,13 @@ function configuredWordPress(config, languages) {
5850
6297
  }
5851
6298
  }
5852
6299
  `;
5853
- function siteChromeQueryText(inLanguage) {
6300
+ function siteChromeQueryText(inLanguage, withPatterns = true) {
5854
6301
  return `
5855
- query SiteChrome($location: MenuLocationEnum!${inLanguage ? ", $language: LanguageCodeEnum!" : ""}) {
6302
+ query SiteChrome(${[
6303
+ "$location: MenuLocationEnum!",
6304
+ ...withPatterns ? ["$template: String!"] : [],
6305
+ ...inLanguage ? ["$language: LanguageCodeEnum!"] : []
6306
+ ].join(", ")}) {
5856
6307
  generalSettings {
5857
6308
  siteIcon {
5858
6309
  node {
@@ -5876,6 +6327,14 @@ function configuredWordPress(config, languages) {
5876
6327
  target
5877
6328
  }
5878
6329
  }
6330
+ ${withPatterns ? `header: designPattern(template: $template, slot: "header") {
6331
+ title
6332
+ blocksJson
6333
+ }
6334
+ footer: designPattern(template: $template, slot: "footer") {
6335
+ title
6336
+ blocksJson
6337
+ }` : ""}
5879
6338
  }
5880
6339
  `;
5881
6340
  }
@@ -5945,6 +6404,73 @@ function configuredWordPress(config, languages) {
5945
6404
  }
5946
6405
  const entryQuery = entryQueryText(false);
5947
6406
  const translatedEntryQuery = entryQueryText(true);
6407
+ function nodeQueryText(translated) {
6408
+ const languageFields = translated ? `
6409
+ language {
6410
+ slug
6411
+ }
6412
+ translations {
6413
+ uri
6414
+ language {
6415
+ slug
6416
+ }
6417
+ }` : "";
6418
+ const typeFields = (typename, fields) => fields.trim() ? `\n ... on ${typename} {${fields}\n }` : "";
6419
+ const itemFields = collections.map(([name, collection]) => typeFields(collection.graphqlType, `${collection.taxonomies?.length ? `
6420
+ terms(first: 100) {
6421
+ nodes {
6422
+ slug
6423
+ name
6424
+ taxonomyName
6425
+ }
6426
+ }` : ""}${languageFields}${extensionFields("item", name)}`)).join("");
6427
+ return `
6428
+ query EntryByUri($uri: String!, $withBlocks: Boolean = true) {
6429
+ designTokens {
6430
+ colors {
6431
+ slug
6432
+ color
6433
+ }
6434
+ spacingSizes {
6435
+ slug
6436
+ size
6437
+ }
6438
+ }
6439
+ node: nodeByUri(uri: $uri) {
6440
+ __typename
6441
+ ... on ContentNode {
6442
+ id
6443
+ uri
6444
+ date
6445
+ status
6446
+ isRestricted
6447
+ modifiedGmt
6448
+ blocks(attributes: true, htmlContent: true, dynamicContent: true, postTemplate: false)
6449
+ @include(if: $withBlocks)
6450
+ }
6451
+ ... on NodeWithTitle {
6452
+ title
6453
+ }
6454
+ ... on NodeWithContentEditor {
6455
+ content
6456
+ }
6457
+ ... on NodeWithExcerpt {
6458
+ excerpt
6459
+ }
6460
+ ... on NodeWithFeaturedImage {
6461
+ featuredImage {
6462
+ node {
6463
+ sourceUrl
6464
+ altText
6465
+ }
6466
+ }
6467
+ }${typeFields("Post", `${languageFields}${extensionFields("post")}`)}${typeFields("Page", `${languageFields}${extensionFields("page")}`)}${itemFields}
6468
+ }
6469
+ }
6470
+ `;
6471
+ }
6472
+ const nodeQuery = nodeQueryText(false);
6473
+ const translatedNodeQuery = nodeQueryText(true);
5948
6474
  const designQuery = `
5949
6475
  query DesignPresets {
5950
6476
  designTokens {
@@ -5959,10 +6485,19 @@ function configuredWordPress(config, languages) {
5959
6485
  }
5960
6486
  }
5961
6487
  `;
6488
+ /** WPGraphQL's ContentTypeEnum: pages, posts and each collection's post type. */
6489
+ const contentTypes = [
6490
+ "PAGE",
6491
+ "POST",
6492
+ ...collections.map(([, { postType }]) => {
6493
+ const name = postType.replace(/[^A-Za-z0-9_]/g, "_").toUpperCase();
6494
+ return /^[A-Z_]/.test(name) ? name : `_${name}`;
6495
+ })
6496
+ ];
5962
6497
  function publishedRoutesQueryText(everyLanguage) {
5963
6498
  return `
5964
6499
  query PublishedRoutes($after: String) {
5965
- contentNodes(first: 100, after: $after, where: { contentTypes: [PAGE, POST]${everyLanguage ? ", language: ALL" : ""} }) {
6500
+ contentNodes(first: 100, after: $after, where: { contentTypes: [${contentTypes.join(", ")}]${everyLanguage ? ", language: ALL" : ""} }) {
5966
6501
  pageInfo {
5967
6502
  hasNextPage
5968
6503
  endCursor
@@ -6012,18 +6547,22 @@ function configuredWordPress(config, languages) {
6012
6547
  * nothing: without an extension, fields for its kind, or a value, and when
6013
6548
  * the blocks (and with them the fields) were left out.
6014
6549
  */
6015
- function extended(kind, node, blocksOmitted) {
6016
- if (!extension || !extensionFields(kind) || blocksOmitted) return {};
6550
+ function extended(kind, node, blocksOmitted, collection) {
6551
+ if (!extension || !extensionSelection(kind, collection) || blocksOmitted) return {};
6017
6552
  let value;
6018
6553
  try {
6019
- value = extension.read({
6554
+ value = extension.read(collection === void 0 ? {
6020
6555
  kind,
6021
6556
  fields: node
6557
+ } : {
6558
+ kind,
6559
+ collection,
6560
+ fields: node
6022
6561
  });
6023
6562
  } catch (error) {
6024
6563
  throw new CmsFailureError({
6025
6564
  reason: "schema",
6026
- message: `WordPress's ${kind} doesn't match the Site's extension: ${error instanceof Error ? error.message : String(error)}`
6565
+ message: `WordPress's ${collection === void 0 ? kind : `${collection} item`} doesn't match the Site's extension: ${error instanceof Error ? error.message : String(error)}`
6027
6566
  }, { cause: error });
6028
6567
  }
6029
6568
  return value === void 0 ? {} : { extension: value };
@@ -6203,6 +6742,7 @@ function configuredWordPress(config, languages) {
6203
6742
  * what any visitor may read.
6204
6743
  */
6205
6744
  async function getEntryByUri(uri) {
6745
+ if (collections.length > 0) return getNodeByUri(uri);
6206
6746
  return deliver(`the entry ${uri}`, async () => {
6207
6747
  const { data, blocksOmitted } = multilingual ? await queryWithBlockRecovery(translatedEntryData, translatedEntryQuery, { uri }) : await queryWithBlockRecovery(entryData, entryQuery, { uri });
6208
6748
  const post = data.postBy ?? data.pageBy;
@@ -6226,6 +6766,59 @@ function configuredWordPress(config, languages) {
6226
6766
  };
6227
6767
  });
6228
6768
  }
6769
+ /**
6770
+ * On a Site with collections, the published page, post or collection item
6771
+ * at a URI. Missing when the node there is none of them (an archive, a
6772
+ * term, an item of a type the Site doesn't store), or isn't public.
6773
+ */
6774
+ async function getNodeByUri(uri) {
6775
+ return deliver(`the entry ${uri}`, async () => {
6776
+ const { data, blocksOmitted } = await queryWithBlockRecovery(nodeEntryData, multilingual ? translatedNodeQuery : nodeQuery, { uri });
6777
+ const node = data.node;
6778
+ if (!node || !("id" in node)) return { kind: "missing" };
6779
+ const item = collectionOf(node.__typename);
6780
+ const kind = node.__typename === "Page" ? "page" : node.__typename === "Post" ? "post" : null;
6781
+ if (!item && !kind) return { kind: "missing" };
6782
+ if (!node.uri || node.isRestricted || node.status !== "publish") return { kind: "missing" };
6783
+ const entry = {
6784
+ ...node,
6785
+ uri: node.uri,
6786
+ title: node.title ?? null,
6787
+ content: node.content ?? null,
6788
+ excerpt: node.excerpt ?? null,
6789
+ date: node.date ?? null,
6790
+ featuredImage: node.featuredImage ?? null
6791
+ };
6792
+ const normalized = normalizePost(entry);
6793
+ const blocks = parseWordPressBlocks(node.blocks);
6794
+ const containsVideoHero = hasVideoHero(blocks);
6795
+ return {
6796
+ kind: "found",
6797
+ content: {
6798
+ ...normalized,
6799
+ content: containsVideoHero ? renderWordPressBlocks(blocks) : normalized.content,
6800
+ hasVideoHero: containsVideoHero,
6801
+ blocksOmitted,
6802
+ modifiedAt: cmsTime(node.modifiedGmt),
6803
+ spacingSizes: data.designTokens.spacingSizes,
6804
+ colors: data.designTokens.colors,
6805
+ ...multilingual ? languageOf(entry) : {},
6806
+ ...item ? { collection: {
6807
+ name: item[0],
6808
+ terms: termsOf(item[1].taxonomies ?? [], node.terms?.nodes ?? [])
6809
+ } } : {},
6810
+ ...item ? extended("item", node, blocksOmitted, item[0]) : extended(kind, node, blocksOmitted)
6811
+ }
6812
+ };
6813
+ });
6814
+ }
6815
+ /** An item's terms in each of its collection's taxonomies (every one, if it has none). */
6816
+ function termsOf(taxonomies, nodes) {
6817
+ return Object.fromEntries(taxonomies.map((taxonomy) => [taxonomy, nodes.filter((term) => term.taxonomyName === taxonomy).map((term) => ({
6818
+ slug: term.slug,
6819
+ name: term.name ?? term.slug
6820
+ }))]));
6821
+ }
6229
6822
  /** An entry's language and published translations, as a multilingual Site stores them. */
6230
6823
  function languageOf(post) {
6231
6824
  return {
@@ -6287,6 +6880,93 @@ function configuredWordPress(config, languages) {
6287
6880
  });
6288
6881
  });
6289
6882
  }
6883
+ const names = objectType({ nodes: arrayType(objectType({ name: stringType() })) }).nullable();
6884
+ const archivesData = objectType({
6885
+ getquickArchives: arrayType(objectType({
6886
+ postType: stringType(),
6887
+ uri: stringType().nullable()
6888
+ })).nullable().optional(),
6889
+ contentTypes: objectType({ nodes: arrayType(objectType({
6890
+ name: stringType(),
6891
+ graphqlSingleName: stringType().nullable().optional(),
6892
+ connectedTaxonomies: names.optional()
6893
+ })) }).nullable(),
6894
+ taxonomies: names
6895
+ });
6896
+ const archivesQueryText = (archives) => `
6897
+ query ContentArchives {${archives ? `
6898
+ getquickArchives {
6899
+ postType
6900
+ uri
6901
+ }` : ""}
6902
+ contentTypes(first: 100) {
6903
+ nodes {
6904
+ name
6905
+ graphqlSingleName
6906
+ connectedTaxonomies(first: 100) {
6907
+ nodes {
6908
+ name
6909
+ }
6910
+ }
6911
+ }
6912
+ }
6913
+ taxonomies(first: 100) {
6914
+ nodes {
6915
+ name
6916
+ }
6917
+ }
6918
+ }
6919
+ `;
6920
+ /**
6921
+ * Where each collection's archive is, by post type: the Page assigned to it
6922
+ * in GETQUICK → Archives, else the type's own archive. A CMS whose GETQUICK
6923
+ * Content doesn't have Archives yet gives none. A collection whose post
6924
+ * type or taxonomy WPGraphQL doesn't show fails the read (`schema`).
6925
+ */
6926
+ async function getArchives() {
6927
+ return deliver("the archives", async () => {
6928
+ let data;
6929
+ try {
6930
+ data = await query(archivesData, archivesQueryText(true));
6931
+ } catch (error) {
6932
+ if (!(error instanceof CmsFailureError) || error.failure.reason !== "graphql" || !/Cannot query field "getquickArchives"/.test(error.failure.message)) throw error;
6933
+ console.warn(`WordPress: GETQUICK Content provides no archives (${error.failure.message}); collections have none`);
6934
+ data = await query(archivesData, archivesQueryText(false));
6935
+ }
6936
+ const shown = (list) => new Set((list?.nodes ?? []).map(({ name }) => name));
6937
+ const postTypes = new Map((data.contentTypes?.nodes ?? []).map((type) => [type.name, type]));
6938
+ const taxonomies = shown(data.taxonomies);
6939
+ const hidden = collections.flatMap(([name, collection]) => [...postTypes.has(collection.postType) ? [] : [`${name}'s post type ${collection.postType}`], ...(collection.taxonomies ?? []).filter((taxonomy) => !taxonomies.has(taxonomy)).map((taxonomy) => `${name}'s taxonomy ${taxonomy}`)]);
6940
+ if (hidden.length > 0) throw new CmsFailureError({
6941
+ reason: "schema",
6942
+ message: `WPGraphQL doesn't show ${hidden.join(", ")} (show_in_graphql)`
6943
+ });
6944
+ const mismatched = collections.flatMap(([name, collection]) => {
6945
+ const type = postTypes.get(collection.postType);
6946
+ const typeName = type.graphqlSingleName ? graphqlTypeName(type.graphqlSingleName) : null;
6947
+ const connected = shown(type.connectedTaxonomies);
6948
+ return [...typeName === null || typeName === collection.graphqlType ? [] : [`${name}'s graphqlType ${collection.graphqlType} isn't post type ${collection.postType}'s GraphQL type, ${typeName}`], ...type.connectedTaxonomies === void 0 ? [] : (collection.taxonomies ?? []).filter((taxonomy) => !connected.has(taxonomy)).map((taxonomy) => `${name}'s taxonomy ${taxonomy} isn't connected to post type ${collection.postType}`)];
6949
+ });
6950
+ if (mismatched.length > 0) throw new CmsFailureError({
6951
+ reason: "schema",
6952
+ message: mismatched.join("; ")
6953
+ });
6954
+ const stored = new Set(collections.map(([, { postType }]) => postType));
6955
+ return {
6956
+ kind: "found",
6957
+ content: Object.fromEntries((data.getquickArchives ?? []).flatMap(({ postType, uri }) => uri && stored.has(postType) ? [[postType, uriPath(uri)]] : []))
6958
+ };
6959
+ });
6960
+ }
6961
+ /**
6962
+ * The GraphQL type WPGraphQL names after a post type's graphql_single_name
6963
+ * (WPGraphQL's Utils::format_type_name): each word, split at `_`, `-` or a
6964
+ * space, capitalised and joined.
6965
+ */
6966
+ function graphqlTypeName(singleName) {
6967
+ const capital = (word) => word.charAt(0).toUpperCase() + word.slice(1);
6968
+ return singleName.replace(/[^a-zA-Z0-9 -]/g, "_").split(/[_ -]/).map(capital).join("");
6969
+ }
6290
6970
  /** The URI of every published page and post. */
6291
6971
  async function getPublishedRoutes() {
6292
6972
  const listed = await getPublishedEntries();
@@ -6338,17 +7018,68 @@ function configuredWordPress(config, languages) {
6338
7018
  }
6339
7019
  }
6340
7020
  /**
6341
- * The menu, logo and icon every page in a language shares: the logo and icon
6342
- * are the site's, the menu the one at the location in that language. A site
6343
- * without them is found with an empty menu and no images; a failed read is
6344
- * Unavailable, not that.
7021
+ * The chrome's data in a language. A CMS whose GQ Design predates
7022
+ * `designPattern` (before 0.2.17) has no header or footer patterns to
7023
+ * select: its schema rejects the field, and the chrome is read again without
7024
+ * them, with both null (the Site's own header and footer), as when nothing
7025
+ * is selected. The warning names the cause and the version the default
7026
+ * `page` route needs; menus, logos and icons keep refreshing, and new Sites
7027
+ * and languages are served. Any other failure fails the read, so what is
7028
+ * stored is kept.
7029
+ */
7030
+ async function chromeData(language, location) {
7031
+ const template = config.designTemplate ?? "page";
7032
+ const variables = language.isDefault ? { location } : {
7033
+ location,
7034
+ language: language.code
7035
+ };
7036
+ try {
7037
+ return await query(siteChromeData, language.isDefault ? siteChromeQuery : languageChromeQuery, {
7038
+ ...variables,
7039
+ template
7040
+ });
7041
+ } catch (error) {
7042
+ if (!(error instanceof CmsFailureError) || error.failure.reason !== "graphql" || !/Cannot query field "designPattern"/.test(error.failure.message)) throw error;
7043
+ console.warn(`WordPress: GQ Design has no designPattern field (${error.failure.message}); header and footer patterns need GQ Design ${DESIGN_PATTERN_VERSION} or newer, so the chrome is read without them and the Site's own header and footer are served`);
7044
+ return {
7045
+ ...await query(chromeWithoutPatternsData, siteChromeQueryText(!language.isDefault, false), variables),
7046
+ header: null,
7047
+ footer: null
7048
+ };
7049
+ }
7050
+ }
7051
+ /**
7052
+ * A selected pattern's block tree. GQ Design answers a selected pattern it
7053
+ * couldn't resolve (invalid attributes, a cycle, too many blocks) with an
7054
+ * empty tree, not an error; that is a failed read, so the stored chrome is
7055
+ * kept rather than replaced by an empty region.
7056
+ */
7057
+ function designPatternOf(region, pattern) {
7058
+ if (!pattern) return null;
7059
+ const blocks = parseDesignBlocks(pattern.blocksJson);
7060
+ if (!blocks) throw new CmsFailureError({
7061
+ reason: "schema",
7062
+ message: `The ${region} pattern's blocksJson isn't a JSON list of blocks`
7063
+ });
7064
+ if (blocks.length === 0) throw new CmsFailureError({
7065
+ reason: "schema",
7066
+ message: `The ${region} pattern "${pattern.title}" has no blocks GQ Design could resolve`
7067
+ });
7068
+ return {
7069
+ title: pattern.title,
7070
+ blocks
7071
+ };
7072
+ }
7073
+ /**
7074
+ * The menu, logo, icon, header and footer every page in a language shares:
7075
+ * the logo and icon are the site's, the menu the one at the location in that
7076
+ * language, the header and footer the patterns selected for the configured
7077
+ * design route. A site without them is found with an empty menu, no images
7078
+ * and no patterns; a failed read is Unavailable, not that.
6345
7079
  */
6346
7080
  async function getSiteChrome(siteOrigin, { location = "PRIMARY", language = defaultLanguage } = {}) {
6347
7081
  return deliver(languageSubject(language, "site chrome"), async () => {
6348
- const [data, consent] = await Promise.all([language.isDefault ? query(siteChromeData, siteChromeQuery, { location }) : query(siteChromeData, languageChromeQuery, {
6349
- location,
6350
- language: language.code
6351
- }), config.consent?.cmsSettings ? consentSettings(language) : null]);
7082
+ const [data, consent] = await Promise.all([chromeData(language, location), config.consent?.cmsSettings ? consentSettings(language) : null]);
6352
7083
  const origins = internalOrigins(config, siteOrigin);
6353
7084
  return {
6354
7085
  kind: "found",
@@ -6356,6 +7087,8 @@ function configuredWordPress(config, languages) {
6356
7087
  menuItems: buildMenuTree(data.menuItems.nodes, origins),
6357
7088
  siteIcon: normalizeImage(data.generalSettings.siteIcon),
6358
7089
  siteLogo: normalizeImage(data.generalSettings.siteLogo),
7090
+ header: designPatternOf("header", data.header),
7091
+ footer: designPatternOf("footer", data.footer),
6359
7092
  ...consent ? {
6360
7093
  legal: parseLegalLanguage(consent.getquickLegal),
6361
7094
  analytics: consent.googleAnalytics ?? {
@@ -6373,6 +7106,7 @@ function configuredWordPress(config, languages) {
6373
7106
  getDesignPresets,
6374
7107
  getPublishedEntries,
6375
7108
  getPublishedRoutes,
7109
+ getArchives,
6376
7110
  getSiteChrome
6377
7111
  };
6378
7112
  }
@@ -6381,7 +7115,10 @@ function configuredWordPress(config, languages) {
6381
7115
  function createContentRuntime(input) {
6382
7116
  const site = input.site.trim();
6383
7117
  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`);
7118
+ if (input.designTemplate !== void 0 && (!/^[a-z0-9_-]+$/.test(input.designTemplate) || /^[0-9]+$/.test(input.designTemplate))) throw new Error("designTemplate must be a GQ Design route key, such as page");
7119
+ const { collections: collectionFields, ...kindFields } = input.extension?.fields ?? {};
7120
+ for (const [kind, fields] of Object.entries(kindFields)) if (fields !== void 0 && !fields.trim()) throw new Error(`The extension's ${kind} fields are empty; leave them out instead`);
7121
+ validateCollections(input.collections ?? {}, collectionFields ?? {});
6385
7122
  const config = {
6386
7123
  ...input,
6387
7124
  site,
@@ -6436,8 +7173,37 @@ function createContentRuntime(input) {
6436
7173
  handleRefresh: guarded("Refresh", "Refresh with POST.", refresh.handleRefresh),
6437
7174
  publishedHome: delivery.publishedHome,
6438
7175
  publishedEntry: delivery.publishedEntry,
7176
+ /**
7177
+ * A page of a collection's items, from the publication store alone (never
7178
+ * WordPress), newest first: those with the given terms and text, in the
7179
+ * language. Unavailable when the store can't be read, or there is none.
7180
+ */
7181
+ list: delivery.list,
7182
+ /**
7183
+ * The terms of a collection's taxonomy that its stored items have, with how
7184
+ * many of them have each, by name: for filters.
7185
+ */
7186
+ terms: delivery.terms,
7187
+ /**
7188
+ * Where a collection's archive is: the Page assigned to it in GETQUICK →
7189
+ * Archives, else its post type's own archive. Null when it has neither, or
7190
+ * the store can't be read.
7191
+ */
7192
+ archiveUri: delivery.archiveUri,
6439
7193
  pageChrome: delivery.pageChrome,
6440
7194
  /**
7195
+ * A chrome pattern as HTML, by the Site's block renderers (see
7196
+ * `renderDesignPattern`), with links into the CMS or `siteOrigin` served
7197
+ * as this Site's paths.
7198
+ */
7199
+ renderDesignPattern: (pattern, options = {}) => {
7200
+ const { siteOrigin, internalOrigins: extraOrigins = [], ...rest } = options;
7201
+ return renderDesignPattern(pattern, {
7202
+ ...rest,
7203
+ internalOrigins: [...internalOrigins(config, siteOrigin), ...extraOrigins]
7204
+ });
7205
+ },
7206
+ /**
6441
7207
  * The design presets every page shares (GQ Design's spacing sizes and
6442
7208
  * colors), for a page that isn't a publication: the stored ones when there
6443
7209
  * is a store, else (in explicit development) a live read. Null when
@@ -6461,5 +7227,27 @@ function createContentRuntime(input) {
6461
7227
  }) : null
6462
7228
  };
6463
7229
  }
7230
+ /** A collection's names are paths in the store's JSON, and the post type and taxonomies GraphQL names. */
7231
+ function validateCollections(collections, fields) {
7232
+ const name = /^[a-z0-9][a-z0-9_-]*$/;
7233
+ const postTypes = /* @__PURE__ */ new Set();
7234
+ for (const [collection, { postType, graphqlType, taxonomies = [] }] of Object.entries(collections)) {
7235
+ if (!name.test(collection)) throw new Error(`The collection ${JSON.stringify(collection)} needs a name such as biblioteca`);
7236
+ if (!/^[a-z0-9_-]{1,20}$/.test(postType)) throw new Error(`The collection ${collection}'s postType isn't a WordPress post type name`);
7237
+ if ([
7238
+ "page",
7239
+ "post",
7240
+ "attachment"
7241
+ ].includes(postType)) throw new Error(`The collection ${collection} can't be WordPress's ${postType}s`);
7242
+ if (postTypes.has(postType)) throw new Error(`The collection ${collection}'s post type is another collection's`);
7243
+ postTypes.add(postType);
7244
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(graphqlType)) throw new Error(`The collection ${collection}'s graphqlType isn't a GraphQL type name`);
7245
+ for (const taxonomy of taxonomies) if (!/^[a-z0-9_-]{1,32}$/.test(taxonomy)) throw new Error(`The collection ${collection}'s taxonomy ${taxonomy} isn't a taxonomy name`);
7246
+ }
7247
+ for (const [collection, value] of Object.entries(fields)) {
7248
+ if (!Object.hasOwn(collections, collection)) throw new Error(`The extension has fields for ${collection}, which isn't a collection`);
7249
+ if (value !== void 0 && !value.trim()) throw new Error(`The extension's ${collection} fields are empty; leave them out instead`);
7250
+ }
7251
+ }
6464
7252
  //#endregion
6465
7253
  export { createContentRuntime };