@getquick/content 0.1.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;
@@ -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) => ({
@@ -3736,7 +3737,8 @@ function publicationStore(db) {
3736
3737
  RETURNING event_id`).bind(event.nodeId, event.id, event.occurredAt).first()))?.event_id ?? null;
3737
3738
  },
3738
3739
  async entryIndex() {
3739
- 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
3740
3742
  FROM publications p LEFT JOIN refresh_attempts a ON a.key = p.key
3741
3743
  WHERE substr(p.key, 1, 6) = 'entry:'
3742
3744
  ORDER BY p.key`), db.prepare("SELECT node_id, withdrawn_at FROM withdrawals WHERE republished_at IS NULL")]));
@@ -3746,6 +3748,7 @@ function publicationStore(db) {
3746
3748
  state: row.state,
3747
3749
  nodeId: row.node_id,
3748
3750
  modifiedAt: row.modified_at,
3751
+ readStartedAt: row.read_started_at,
3749
3752
  attemptedAt: row.attempted_at
3750
3753
  })),
3751
3754
  withdrawals: new Map(withdrawals.results.map((row) => [row.node_id, row.withdrawn_at]))
@@ -3790,7 +3793,7 @@ function publicationStore(db) {
3790
3793
  },
3791
3794
  async status() {
3792
3795
  const [shared, entries, withdrawals, failed] = await guard("be read", () => db.batch([
3793
- 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')"),
3794
3797
  db.prepare(`SELECT CASE WHEN format = ?1 THEN state ELSE 'unusable' END AS state, count(*) AS count
3795
3798
  FROM publications WHERE substr(key, 1, 6) = 'entry:' GROUP BY 1`).bind(1),
3796
3799
  db.prepare("SELECT count(*) AS count FROM withdrawals WHERE republished_at IS NULL"),
@@ -3807,11 +3810,47 @@ function publicationStore(db) {
3807
3810
  home: state("home"),
3808
3811
  chrome: state("chrome"),
3809
3812
  design: state("design"),
3813
+ ...state("archives") === null ? {} : { archives: state("archives") },
3810
3814
  entries: Object.fromEntries(entries.results.map((row) => [row.state, Number(row.count)])),
3811
3815
  withdrawals: count(withdrawals),
3812
3816
  failedEvents: count(failed)
3813
3817
  };
3814
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
+ },
3815
3854
  async startEvent(id) {
3816
3855
  await guard("record an event", () => db.prepare("UPDATE publication_events SET attempts = attempts + 1 WHERE id = ?").bind(id).run());
3817
3856
  },
@@ -3826,6 +3865,33 @@ function publicationStore(db) {
3826
3865
  }
3827
3866
  };
3828
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
+ }
3829
3895
  function movedTo(body) {
3830
3896
  try {
3831
3897
  const uri = JSON.parse(body ?? "null")?.uri;
@@ -3836,7 +3902,7 @@ function movedTo(body) {
3836
3902
  }
3837
3903
  //#endregion
3838
3904
  //#region frontend/src/reconciliation.ts
3839
- function configuredReconciliation(languages, delivery, wordpress) {
3905
+ function configuredReconciliation(languages, delivery, wordpress, collections = false) {
3840
3906
  const { eachLimited, reconcileShared, refreshEntries, sharedOutcomes } = delivery;
3841
3907
  const { getPublishedEntries } = wordpress;
3842
3908
  const isLanguageHome$4 = (route) => isLanguageHome(route, languages);
@@ -3847,10 +3913,24 @@ function configuredReconciliation(languages, delivery, wordpress) {
3847
3913
  * is asked again without them. Chrome and design have no block recovery.
3848
3914
  */
3849
3915
  const REQUESTS_PER_READ = 2;
3850
- /** Reserve design once, and each language's home (including recovery) and chrome. */
3851
- const SHARED_REQUESTS = 1 + 3 * languages.length;
3852
- /** 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
+ */
3853
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;
3854
3934
  /** How long a run holds the lease; an interrupted run's lease is taken over after it. */
3855
3935
  const LEASE_MS = 12e4;
3856
3936
  const ENTRY = "entry:";
@@ -3903,7 +3983,7 @@ function configuredReconciliation(languages, delivery, wordpress) {
3903
3983
  }
3904
3984
  }
3905
3985
  async function run(store, startedAt, siteOrigin, publish) {
3906
- const [shared, listing] = await Promise.all([reconcileShared(store, siteOrigin), getPublishedEntries()]);
3986
+ const [{ archiveRoutes = [], ...shared }, listing] = await Promise.all([reconcileShared(store, siteOrigin), getPublishedEntries()]);
3907
3987
  const failures = [];
3908
3988
  let changed = 0;
3909
3989
  for (const outcome of sharedOutcomes(shared)) {
@@ -3930,9 +4010,10 @@ function configuredReconciliation(languages, delivery, wordpress) {
3930
4010
  }
3931
4011
  const { entries: listed, requests } = listing.content;
3932
4012
  const index = await store.entryIndex();
3933
- const found = candidates(listed, index);
4013
+ const { found, settled } = candidates(listed, index);
3934
4014
  let budget = CMS_REQUEST_BUDGET - SHARED_REQUESTS - requests;
3935
4015
  const chosen = [];
4016
+ found.unshift(...archived(archiveRoutes, found));
3936
4017
  let pending = 0;
3937
4018
  for (const candidate of found) {
3938
4019
  const cost = (candidate.previousRoute ? 2 : 1) * REQUESTS_PER_READ;
@@ -3943,7 +4024,7 @@ function configuredReconciliation(languages, delivery, wordpress) {
3943
4024
  budget -= cost;
3944
4025
  chosen.push(candidate);
3945
4026
  }
3946
- 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)));
3947
4028
  const entries = await refresh(store, chosen, publish);
3948
4029
  for (const outcome of Object.values(entries)) {
3949
4030
  if (outcome.outcome === "kept") failures.push(outcome.failure);
@@ -3966,7 +4047,8 @@ function configuredReconciliation(languages, delivery, wordpress) {
3966
4047
  };
3967
4048
  }
3968
4049
  /**
3969
- * 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).
3970
4052
  * WordPress's front page (`/`) is the homepage's, reconciled with the shared
3971
4053
  * rows, unless it is withdrawn and modified since.
3972
4054
  */
@@ -3978,6 +4060,7 @@ function configuredReconciliation(languages, delivery, wordpress) {
3978
4060
  storedAt.set(row.nodeId, [...storedAt.get(row.nodeId) ?? [], row.key.slice(6)]);
3979
4061
  }
3980
4062
  const found = [];
4063
+ const settled = /* @__PURE__ */ new Set();
3981
4064
  const listedRoutes = /* @__PURE__ */ new Set();
3982
4065
  const left = /* @__PURE__ */ new Set();
3983
4066
  for (const entry of listed) {
@@ -3998,6 +4081,10 @@ function configuredReconciliation(languages, delivery, wordpress) {
3998
4081
  if (isLanguageHome$4(route)) continue;
3999
4082
  if (row?.state === "published" && entry.id !== null && row.nodeId === entry.id && entry.modifiedAt !== null && row.modifiedAt === entry.modifiedAt) continue;
4000
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
+ }
4001
4088
  if (previousRoute) left.add(previousRoute);
4002
4089
  found.push({
4003
4090
  change: previousRoute ? "moved" : row ? "changed" : "new",
@@ -4016,12 +4103,33 @@ function configuredReconciliation(languages, delivery, wordpress) {
4016
4103
  attemptedAt: row.attemptedAt
4017
4104
  });
4018
4105
  }
4019
- 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
+ };
4020
4111
  }
4021
- /** Up to `limit` (and VERIFIED_PER_RUN) stored entries no change was found in, oldest-attempted first. */
4022
- function verified(index, found, limit) {
4112
+ /** The addresses whose archive moved, unless already a change, as changes. */
4113
+ function archived(routes, found) {
4023
4114
  const changing = new Set(found.flatMap(({ route, previousRoute }) => [route, previousRoute]));
4024
- 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) => ({
4115
+ return routes.filter((route) => !changing.has(route) && !isLanguageHome$4(route)).map((route) => ({
4116
+ change: "archive",
4117
+ route,
4118
+ attemptedAt: 0
4119
+ }));
4120
+ }
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) {
4127
+ const changing = new Set(found.flatMap(({ route, previousRoute }) => [route, previousRoute]));
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) => ({
4025
4133
  change: "verified",
4026
4134
  route: row.key.slice(6),
4027
4135
  attemptedAt: row.attemptedAt
@@ -4052,11 +4160,11 @@ function configuredReconciliation(languages, delivery, wordpress) {
4052
4160
  };
4053
4161
  try {
4054
4162
  const { body } = await publish(store, publication);
4055
- if (body.status === "refreshed") outcomes[candidate.route] = {
4163
+ if (body.status === "refreshed" && !body.duplicate) outcomes[candidate.route] = {
4056
4164
  change: candidate.change,
4057
4165
  outcome: "refreshed"
4058
4166
  };
4059
- else if (body.status === "superseded") superseded.push(candidate);
4167
+ else if (body.status === "refreshed" || body.status === "superseded") superseded.push(candidate);
4060
4168
  else outcomes[candidate.route] = {
4061
4169
  change: candidate.change,
4062
4170
  outcome: "kept",
@@ -4133,7 +4241,7 @@ function configuredReconciliation(languages, delivery, wordpress) {
4133
4241
  //#endregion
4134
4242
  //#region frontend/src/events.ts
4135
4243
  function configuredEvents(config, languages, delivery, wordpress) {
4136
- const { reconcile } = configuredReconciliation(languages, delivery, wordpress);
4244
+ const { reconcile } = configuredReconciliation(languages, delivery, wordpress, Object.keys(config.collections ?? {}).length > 0);
4137
4245
  const SITE = config.site;
4138
4246
  const isLanguageHome$3 = (route) => isLanguageHome(route, languages);
4139
4247
  const routeLanguage$3 = (route) => routeLanguage(route, languages);
@@ -4210,15 +4318,19 @@ function configuredEvents(config, languages, delivery, wordpress) {
4210
4318
  * The shared settings an event can name, and what each change refreshes: the
4211
4319
  * menus and the logo are in the chrome every page is served with; the site's
4212
4320
  * identity is its title and tagline (the front page's) and its icon (the
4213
- * 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.
4214
4324
  */
4215
4325
  const SETTINGS = {
4216
4326
  menus: ["chrome"],
4217
4327
  logo: ["chrome"],
4218
4328
  identity: ["home", "chrome"],
4219
4329
  design: ["design"],
4330
+ patterns: ["chrome"],
4220
4331
  legal: ["chrome"],
4221
- analytics: ["chrome"]
4332
+ analytics: ["chrome"],
4333
+ archives: ["archives"]
4222
4334
  };
4223
4335
  /**
4224
4336
  * What a change in one language refreshes, for the settings a language has
@@ -4564,7 +4676,7 @@ function configuredEvents(config, languages, delivery, wordpress) {
4564
4676
  await store.startEvent(event.id);
4565
4677
  const language = languages.find((each) => each.slug === event.language);
4566
4678
  const { refreshed, ...parts } = await refreshShared(store, language ? [...LANGUAGE_SETTINGS[event.setting]] : [...SETTINGS[event.setting]], siteOrigin, language);
4567
- const failure = sharedOutcomes(parts).find((outcome) => outcome.outcome === "kept");
4679
+ const failure = [...sharedOutcomes(parts), ...Object.values(parts.entries ?? {})].find((outcome) => outcome.outcome === "kept");
4568
4680
  await store.finishEvent(recorded, failure?.outcome === "kept" ? {
4569
4681
  status: "failed",
4570
4682
  reason: failure.failure.reason,
@@ -4660,10 +4772,32 @@ function configuredRefresh(languages, delivery) {
4660
4772
  function publishedHtml(languages, transform) {
4661
4773
  const defaultLanguage = languages[0];
4662
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
+ }
4663
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
+ };
4664
4799
  if (!transform || delivery.kind !== "found") return delivery;
4665
4800
  const { home } = delivery.content;
4666
- const served = languageOf(language.slug, defaultLanguage);
4667
4801
  return {
4668
4802
  ...delivery,
4669
4803
  content: {
@@ -4674,11 +4808,16 @@ function publishedHtml(languages, transform) {
4674
4808
  ...home.page,
4675
4809
  content: transform(home.page.content, served)
4676
4810
  }
4677
- }
4811
+ },
4812
+ chrome: chrome(delivery.content.chrome, served)
4678
4813
  }
4679
4814
  };
4680
4815
  }
4681
4816
  function entry(delivery, routeLanguage) {
4817
+ if (transform && delivery.kind === "missing") return {
4818
+ ...delivery,
4819
+ chrome: chrome(delivery.chrome, routeLanguage)
4820
+ };
4682
4821
  if (!transform || delivery.kind !== "found") return delivery;
4683
4822
  const { entry } = delivery.content;
4684
4823
  const served = languageOf(entry.language, routeLanguage);
@@ -4689,24 +4828,28 @@ function publishedHtml(languages, transform) {
4689
4828
  entry: {
4690
4829
  ...entry,
4691
4830
  content: transform(entry.content, served)
4692
- }
4831
+ },
4832
+ chrome: chrome(delivery.content.chrome, served)
4693
4833
  }
4694
4834
  };
4695
4835
  }
4696
4836
  return {
4697
4837
  home,
4698
- entry
4838
+ entry,
4839
+ chrome
4699
4840
  };
4700
4841
  }
4701
4842
  //#endregion
4702
4843
  //#region frontend/src/delivery.ts
4703
4844
  function configuredDelivery(config, languages, wordpress, transformHtml) {
4704
4845
  const defaultLanguage = languages[0];
4846
+ const collections = config.collections ?? {};
4847
+ const hasCollections = Object.keys(collections).length > 0;
4705
4848
  const html = publishedHtml(languages, transformHtml);
4706
4849
  const multilingual = languages.length > 1;
4707
4850
  const routeLanguage$1 = (route) => routeLanguage(route, languages);
4708
4851
  const isLanguageHome$1 = (route) => isLanguageHome(route, languages);
4709
- const { getHomeContent, getEntryByUri, getSiteChrome, getDesignPresets, getPublishedRoutes } = wordpress;
4852
+ const { getHomeContent, getEntryByUri, getSiteChrome, getDesignPresets, getPublishedRoutes, getArchives } = wordpress;
4710
4853
  const HOME = "home";
4711
4854
  const CHROME = "chrome";
4712
4855
  /**
@@ -4718,6 +4861,8 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4718
4861
  const chromeKey = (language) => language.isDefault ? CHROME : `${CHROME}:${language.slug}`;
4719
4862
  /** The design presets every page is served with, once a refresh has stored them. */
4720
4863
  const DESIGN = "design";
4864
+ /** Where each collection's archive is, on a Site with collections. */
4865
+ const ARCHIVES = "archives";
4721
4866
  /** Entries are stored at "entry:<route>". */
4722
4867
  const ENTRY = "entry:";
4723
4868
  const entryKey = (route) => `${ENTRY}${route}`;
@@ -4743,6 +4888,29 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4743
4888
  slug: stringType(),
4744
4889
  color: stringType()
4745
4890
  }));
4891
+ /**
4892
+ * The Site's extension of a page or post, as its `parse` reads it back. A
4893
+ * row stored without one (before the Site declared it) still serves; an
4894
+ * extension stored without the Site declaring one now is left out.
4895
+ */
4896
+ const extension = config.extension;
4897
+ const extended = extension ? { extension: unknownType().optional().transform((value, context) => {
4898
+ if (value === void 0) return void 0;
4899
+ let parsed;
4900
+ try {
4901
+ parsed = extension.parse(value);
4902
+ } catch {
4903
+ parsed = void 0;
4904
+ }
4905
+ if (parsed === void 0) {
4906
+ context.addIssue({
4907
+ code: "custom",
4908
+ message: "the Site can't serve this extension"
4909
+ });
4910
+ return NEVER;
4911
+ }
4912
+ return parsed;
4913
+ }) } : {};
4746
4914
  const homePublication = objectType({
4747
4915
  settings: objectType({
4748
4916
  title: stringType(),
@@ -4751,7 +4919,8 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4751
4919
  page: objectType({
4752
4920
  title: stringType(),
4753
4921
  content: stringType(),
4754
- hasVideoHero: booleanType()
4922
+ hasVideoHero: booleanType(),
4923
+ ...extended
4755
4924
  }),
4756
4925
  spacingSizes,
4757
4926
  colors
@@ -4760,7 +4929,24 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4760
4929
  uri: stringType(),
4761
4930
  language: stringType()
4762
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
+ }));
4763
4948
  const entryPublication = objectType({
4949
+ collection: membership.optional(),
4764
4950
  language: stringType().optional(),
4765
4951
  translations: arrayType(translation).optional(),
4766
4952
  id: stringType(),
@@ -4772,7 +4958,8 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4772
4958
  featuredImage: image,
4773
4959
  hasVideoHero: booleanType(),
4774
4960
  spacingSizes,
4775
- colors
4961
+ colors,
4962
+ ...extended
4776
4963
  });
4777
4964
  const legalRole = objectType({
4778
4965
  role: stringType(),
@@ -4790,10 +4977,22 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4790
4977
  roles: arrayType(legalRole),
4791
4978
  missingRequired: arrayType(stringType())
4792
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();
4793
4990
  const siteChrome = objectType({
4794
4991
  menuItems: arrayType(menuItem),
4795
4992
  siteIcon: image,
4796
4993
  siteLogo: image,
4994
+ header: designPattern.optional().transform((pattern) => pattern ?? null),
4995
+ footer: designPattern.optional().transform((pattern) => pattern ?? null),
4797
4996
  legal: legal.optional(),
4798
4997
  analytics: objectType({
4799
4998
  enabled: booleanType(),
@@ -4804,6 +5003,7 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4804
5003
  spacingSizes,
4805
5004
  colors
4806
5005
  });
5006
+ const archives = recordType(stringType());
4807
5007
  const parseWith = (schema) => (body) => {
4808
5008
  const parsed = schema.safeParse(body);
4809
5009
  return parsed.success ? parsed.data : void 0;
@@ -4812,10 +5012,13 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
4812
5012
  const parseChrome = parseWith(siteChrome);
4813
5013
  const parseEntry = parseWith(entryPublication);
4814
5014
  const parseDesign = parseWith(designPresets);
5015
+ const parseArchives = parseWith(archives);
4815
5016
  const emptyChrome = {
4816
5017
  menuItems: [],
4817
5018
  siteIcon: null,
4818
- siteLogo: null
5019
+ siteLogo: null,
5020
+ header: null,
5021
+ footer: null
4819
5022
  };
4820
5023
  async function boundStore() {
4821
5024
  const { PUBLICATION_DB } = await config.bindings();
@@ -5126,6 +5329,27 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5126
5329
  }
5127
5330
  }
5128
5331
  /**
5332
+ * The design presets for a page that isn't a publication: the stored ones
5333
+ * when there is a store, else a live read in explicit development. Null
5334
+ * when neither has them; the page is then served without them.
5335
+ */
5336
+ async function pageDesign() {
5337
+ if (!config.isServer()) return null;
5338
+ const store = await boundStore();
5339
+ if (!store) {
5340
+ if (!config.development) return null;
5341
+ const design = await getDesignPresets();
5342
+ return design.kind === "found" ? design.content : null;
5343
+ }
5344
+ try {
5345
+ return sharedDesign(await store.read(DESIGN, parseDesign));
5346
+ } catch (error) {
5347
+ if (!(error instanceof StoreFailure)) throw error;
5348
+ console.error(`Delivery: the design presets can't be served (store): ${error.message}`);
5349
+ return null;
5350
+ }
5351
+ }
5352
+ /**
5129
5353
  * Refreshes the whole Site, the explicit preparation of its store: each
5130
5354
  * language's front page and chrome, the shared design presets, and every
5131
5355
  * entry WordPress lists as published or the store already holds (so one
@@ -5138,16 +5362,18 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5138
5362
  async function refreshSite(store, siteOrigin) {
5139
5363
  const readStartedAt = Date.now();
5140
5364
  const others = languages.filter((language) => !language.isDefault);
5141
- const [home, chrome, design, listing, ...otherReads] = await Promise.all([
5365
+ const [home, chrome, design, archiveRead, listing, ...otherReads] = await Promise.all([
5142
5366
  getHomeContent(),
5143
5367
  getSiteChrome(siteOrigin),
5144
5368
  getDesignPresets(),
5369
+ hasCollections ? getArchives() : void 0,
5145
5370
  getPublishedRoutes(),
5146
5371
  ...others.map((language) => Promise.all([getHomeContent(language), getSiteChrome(siteOrigin, { language })]))
5147
5372
  ]);
5148
5373
  const homeOutcome = await promoteHome(store, home, readStartedAt);
5149
5374
  const chromeOutcome = await promoteChrome(store, chrome, readStartedAt);
5150
5375
  const designOutcome = await promoteDesign(store, design, readStartedAt);
5376
+ const archivesOutcome = archiveRead ? await promoteArchives(store, archiveRead, readStartedAt) : void 0;
5151
5377
  const languageOutcomes = {};
5152
5378
  for (const [index, language] of others.entries()) {
5153
5379
  const [languageHome, languageChrome] = otherReads[index];
@@ -5187,10 +5413,11 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5187
5413
  }
5188
5414
  return {
5189
5415
  ready,
5190
- 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,
5191
5417
  home: homeOutcome,
5192
5418
  chrome: chromeOutcome,
5193
5419
  design: designOutcome,
5420
+ ...archivesOutcome ? { archives: archivesOutcome } : {},
5194
5421
  routes,
5195
5422
  entries: entries.entries,
5196
5423
  moved: entries.moved,
@@ -5203,6 +5430,7 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5203
5430
  rows.home,
5204
5431
  rows.chrome,
5205
5432
  rows.design,
5433
+ rows.archives,
5206
5434
  ...Object.values(rows.languages ?? {}).flatMap((language) => [language.home, language.chrome])
5207
5435
  ].filter((outcome) => outcome !== void 0);
5208
5436
  }
@@ -5222,7 +5450,11 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5222
5450
  const readStartedAt = Date.now();
5223
5451
  const wanted = new Set(parts);
5224
5452
  const scope = language ? [language] : languages;
5225
- 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
+ ]);
5226
5458
  const report = { refreshed: true };
5227
5459
  for (const [index, each] of scope.entries()) {
5228
5460
  const [home, chrome] = reads[index];
@@ -5231,7 +5463,15 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5231
5463
  if (chrome) rows.chrome = await promoteChrome(store, chrome, readStartedAt, each);
5232
5464
  }
5233
5465
  if (design) report.design = await promoteDesign(store, design, readStartedAt);
5234
- 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");
5235
5475
  return report;
5236
5476
  }
5237
5477
  /**
@@ -5258,7 +5498,11 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5258
5498
  */
5259
5499
  async function reconcileShared(store, siteOrigin) {
5260
5500
  const readStartedAt = Date.now();
5261
- 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
+ ]);
5262
5506
  const unchanged = async (key, parse, read) => {
5263
5507
  if (!read) return false;
5264
5508
  try {
@@ -5291,10 +5535,22 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5291
5535
  chrome: await unchanged(chromeKey(language), parseChrome, sharedRead(chrome)) ? { outcome: "unchanged" } : await promoteChrome(store, chrome, readStartedAt, language)
5292
5536
  });
5293
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
+ }
5294
5548
  const [own, ...others] = outcomes;
5295
5549
  return {
5296
5550
  ...own,
5297
5551
  design: await unchanged(DESIGN, parseDesign, sharedRead(design)) ? { outcome: "unchanged" } : await promoteDesign(store, design, readStartedAt),
5552
+ ...archives ? { archives } : {},
5553
+ ...changedArchives.length > 0 ? { archiveRoutes: changedArchives } : {},
5298
5554
  ...multilingual ? { languages: Object.fromEntries(others.map((outcome, index) => [languages[index + 1].slug, outcome])) } : {}
5299
5555
  };
5300
5556
  }
@@ -5318,6 +5574,36 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5318
5574
  function promoteDesign(store, read, readStartedAt) {
5319
5575
  return promoteShared(store, DESIGN, "the shared design presets", parseDesign, read, readStartedAt);
5320
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
+ }
5321
5607
  /**
5322
5608
  * Refreshes a language's front page only (not its chrome or the entries): the
5323
5609
  * front page itself was published or changed.
@@ -5418,7 +5704,7 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5418
5704
  }
5419
5705
  const outcome = await promote(canonical, () => ({
5420
5706
  state: "published",
5421
- content: entry,
5707
+ content: stored(entry),
5422
5708
  nodeId: entry.id,
5423
5709
  modifiedAt
5424
5710
  }));
@@ -5443,6 +5729,18 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5443
5729
  found
5444
5730
  };
5445
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
+ }
5446
5744
  /**
5447
5745
  * Every other route still serving an entry that was just found at its own
5448
5746
  * route is marked moved there, unless it was read since `readBefore`: a
@@ -5566,6 +5864,151 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5566
5864
  withdrawn
5567
5865
  };
5568
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
+ }
5569
6012
  /** The refresh's credential when the Worker has one bound, and it is strong enough. */
5570
6013
  async function refreshAuthority() {
5571
6014
  const { PUBLICATION_DB, FRONTEND_REFRESH_TOKEN: token } = await config.bindings();
@@ -5599,14 +6042,45 @@ function configuredDelivery(config, languages, wordpress, transformHtml) {
5599
6042
  isAuthorized,
5600
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")),
5601
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")),
5602
- pageChrome: (siteOrigin) => safely(() => pageChrome(siteOrigin), () => 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
+ }
5603
6053
  };
5604
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
+ }
5605
6059
  //#endregion
5606
6060
  //#region frontend/src/wordpress.ts
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";
6064
+ function internalOrigins(config, siteOrigin) {
6065
+ return [new URL(config.graphqlEndpoint).origin, ...siteOrigin ? [siteOrigin] : []];
6066
+ }
5607
6067
  function configuredWordPress(config, languages) {
5608
6068
  const defaultLanguage = languages[0];
5609
6069
  const multilingual = languages.length > 1;
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();
6076
+ /**
6077
+ * The Site's extension fields on a kind of node, read with the blocks (left
6078
+ * out with them when WordPress fails on them).
6079
+ */
6080
+ const extensionFields = (kind, collection) => {
6081
+ const fields = extensionSelection(kind, collection);
6082
+ return fields ? `\n... @include(if: $withBlocks) {\n${fields}\n}` : "";
6083
+ };
5610
6084
  const image = objectType({
5611
6085
  sourceUrl: stringType(),
5612
6086
  altText: stringType().nullable()
@@ -5634,7 +6108,7 @@ function configuredWordPress(config, languages) {
5634
6108
  title: stringType().nullable(),
5635
6109
  content: stringType().nullable(),
5636
6110
  blocks: unknownType().optional()
5637
- }), objectType({ __typename: stringType().refine((typename) => typename !== "Page") })]).nullable(),
6111
+ }).passthrough(), objectType({ __typename: stringType().refine((typename) => typename !== "Page") })]).nullable(),
5638
6112
  designTokens
5639
6113
  });
5640
6114
  const pageEntry = objectType({
@@ -5647,7 +6121,7 @@ function configuredWordPress(config, languages) {
5647
6121
  modifiedGmt: stringType().nullable().optional(),
5648
6122
  featuredImage: imageEdge,
5649
6123
  blocks: unknownType().optional()
5650
- });
6124
+ }).passthrough();
5651
6125
  const postEntry = pageEntry.extend({
5652
6126
  excerpt: stringType().nullable(),
5653
6127
  date: stringType().nullable()
@@ -5665,6 +6139,32 @@ function configuredWordPress(config, languages) {
5665
6139
  pageBy: pageEntry.nullable(),
5666
6140
  designTokens
5667
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
+ });
5668
6168
  const translatedEntryData = objectType({
5669
6169
  postBy: postEntry.extend(translated).nullable(),
5670
6170
  pageBy: pageEntry.extend(translated).nullable(),
@@ -5693,13 +6193,21 @@ function configuredWordPress(config, languages) {
5693
6193
  modifiedGmt: stringType().nullable().optional()
5694
6194
  }))
5695
6195
  }) });
5696
- const siteChromeData = objectType({
6196
+ const designPattern = objectType({
6197
+ title: stringType(),
6198
+ blocksJson: stringType()
6199
+ }).nullable();
6200
+ const chromeWithoutPatternsData = objectType({
5697
6201
  generalSettings: objectType({
5698
6202
  siteIcon: imageEdge,
5699
6203
  siteLogo: imageEdge
5700
6204
  }),
5701
6205
  menuItems: objectType({ nodes: arrayType(menuItem) })
5702
6206
  });
6207
+ const siteChromeData = chromeWithoutPatternsData.extend({
6208
+ header: designPattern,
6209
+ footer: designPattern
6210
+ });
5703
6211
  const consentSettingsData = objectType({
5704
6212
  getquickLegal: unknownType(),
5705
6213
  googleAnalytics: objectType({
@@ -5745,7 +6253,7 @@ function configuredWordPress(config, languages) {
5745
6253
  content
5746
6254
  blocks(attributes: true, htmlContent: true, dynamicContent: true, postTemplate: false)
5747
6255
  @include(if: $withBlocks)
5748
- isFrontPage
6256
+ isFrontPage${extensionFields("page")}
5749
6257
  }
5750
6258
  }
5751
6259
  designTokens {
@@ -5774,7 +6282,7 @@ function configuredWordPress(config, languages) {
5774
6282
  content
5775
6283
  blocks(attributes: true, htmlContent: true, dynamicContent: true, postTemplate: false)
5776
6284
  @include(if: $withBlocks)
5777
- isFrontPage
6285
+ isFrontPage${extensionFields("page")}
5778
6286
  }
5779
6287
  }
5780
6288
  designTokens {
@@ -5789,9 +6297,13 @@ function configuredWordPress(config, languages) {
5789
6297
  }
5790
6298
  }
5791
6299
  `;
5792
- function siteChromeQueryText(inLanguage) {
6300
+ function siteChromeQueryText(inLanguage, withPatterns = true) {
5793
6301
  return `
5794
- query SiteChrome($location: MenuLocationEnum!${inLanguage ? ", $language: LanguageCodeEnum!" : ""}) {
6302
+ query SiteChrome(${[
6303
+ "$location: MenuLocationEnum!",
6304
+ ...withPatterns ? ["$template: String!"] : [],
6305
+ ...inLanguage ? ["$language: LanguageCodeEnum!"] : []
6306
+ ].join(", ")}) {
5795
6307
  generalSettings {
5796
6308
  siteIcon {
5797
6309
  node {
@@ -5815,6 +6327,14 @@ function configuredWordPress(config, languages) {
5815
6327
  target
5816
6328
  }
5817
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
+ }` : ""}
5818
6338
  }
5819
6339
  `;
5820
6340
  }
@@ -5860,7 +6380,7 @@ function configuredWordPress(config, languages) {
5860
6380
  sourceUrl
5861
6381
  altText
5862
6382
  }
5863
- }
6383
+ }${extensionFields("post")}
5864
6384
  }
5865
6385
  pageBy(uri: $uri) {
5866
6386
  id
@@ -5877,13 +6397,80 @@ function configuredWordPress(config, languages) {
5877
6397
  sourceUrl
5878
6398
  altText
5879
6399
  }
5880
- }
6400
+ }${extensionFields("page")}
5881
6401
  }
5882
6402
  }
5883
6403
  `;
5884
6404
  }
5885
6405
  const entryQuery = entryQueryText(false);
5886
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);
5887
6474
  const designQuery = `
5888
6475
  query DesignPresets {
5889
6476
  designTokens {
@@ -5898,10 +6485,19 @@ function configuredWordPress(config, languages) {
5898
6485
  }
5899
6486
  }
5900
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
+ ];
5901
6497
  function publishedRoutesQueryText(everyLanguage) {
5902
6498
  return `
5903
6499
  query PublishedRoutes($after: String) {
5904
- 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" : ""} }) {
5905
6501
  pageInfo {
5906
6502
  hasNextPage
5907
6503
  endCursor
@@ -5946,6 +6542,31 @@ function configuredWordPress(config, languages) {
5946
6542
  function describeIssues(error) {
5947
6543
  return error.issues.map((issue) => `${issue.path.join(".") || "data"}: ${issue.message}`).join("; ");
5948
6544
  }
6545
+ /**
6546
+ * `{ extension }` with the Site's value for a node WordPress returned, or
6547
+ * nothing: without an extension, fields for its kind, or a value, and when
6548
+ * the blocks (and with them the fields) were left out.
6549
+ */
6550
+ function extended(kind, node, blocksOmitted, collection) {
6551
+ if (!extension || !extensionSelection(kind, collection) || blocksOmitted) return {};
6552
+ let value;
6553
+ try {
6554
+ value = extension.read(collection === void 0 ? {
6555
+ kind,
6556
+ fields: node
6557
+ } : {
6558
+ kind,
6559
+ collection,
6560
+ fields: node
6561
+ });
6562
+ } catch (error) {
6563
+ throw new CmsFailureError({
6564
+ reason: "schema",
6565
+ message: `WordPress's ${collection === void 0 ? kind : `${collection} item`} doesn't match the Site's extension: ${error instanceof Error ? error.message : String(error)}`
6566
+ }, { cause: error });
6567
+ }
6568
+ return value === void 0 ? {} : { extension: value };
6569
+ }
5949
6570
  async function query(schema, queryText, variables) {
5950
6571
  let response;
5951
6572
  let credentials = [];
@@ -6105,7 +6726,8 @@ function configuredWordPress(config, languages) {
6105
6726
  page: {
6106
6727
  title: node.title ?? "",
6107
6728
  content: containsVideoHero ? renderWordPressBlocks(blocks) : node.content ?? "",
6108
- hasVideoHero: containsVideoHero
6729
+ hasVideoHero: containsVideoHero,
6730
+ ...extended("page", node, blocksOmitted)
6109
6731
  },
6110
6732
  blocksOmitted,
6111
6733
  spacingSizes: data.designTokens.spacingSizes,
@@ -6120,6 +6742,7 @@ function configuredWordPress(config, languages) {
6120
6742
  * what any visitor may read.
6121
6743
  */
6122
6744
  async function getEntryByUri(uri) {
6745
+ if (collections.length > 0) return getNodeByUri(uri);
6123
6746
  return deliver(`the entry ${uri}`, async () => {
6124
6747
  const { data, blocksOmitted } = multilingual ? await queryWithBlockRecovery(translatedEntryData, translatedEntryQuery, { uri }) : await queryWithBlockRecovery(entryData, entryQuery, { uri });
6125
6748
  const post = data.postBy ?? data.pageBy;
@@ -6137,11 +6760,65 @@ function configuredWordPress(config, languages) {
6137
6760
  modifiedAt: cmsTime(post.modifiedGmt),
6138
6761
  spacingSizes: data.designTokens.spacingSizes,
6139
6762
  colors: data.designTokens.colors,
6140
- ...multilingual ? languageOf(post) : {}
6763
+ ...multilingual ? languageOf(post) : {},
6764
+ ...extended(data.postBy ? "post" : "page", post, blocksOmitted)
6765
+ }
6766
+ };
6767
+ });
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)
6141
6811
  }
6142
6812
  };
6143
6813
  });
6144
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
+ }
6145
6822
  /** An entry's language and published translations, as a multilingual Site stores them. */
6146
6823
  function languageOf(post) {
6147
6824
  return {
@@ -6203,6 +6880,93 @@ function configuredWordPress(config, languages) {
6203
6880
  });
6204
6881
  });
6205
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
+ }
6206
6970
  /** The URI of every published page and post. */
6207
6971
  async function getPublishedRoutes() {
6208
6972
  const listed = await getPublishedEntries();
@@ -6254,24 +7018,77 @@ function configuredWordPress(config, languages) {
6254
7018
  }
6255
7019
  }
6256
7020
  /**
6257
- * The menu, logo and icon every page in a language shares: the logo and icon
6258
- * are the site's, the menu the one at the location in that language. A site
6259
- * without them is found with an empty menu and no images; a failed read is
6260
- * 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.
6261
7079
  */
6262
7080
  async function getSiteChrome(siteOrigin, { location = "PRIMARY", language = defaultLanguage } = {}) {
6263
7081
  return deliver(languageSubject(language, "site chrome"), async () => {
6264
- const [data, consent] = await Promise.all([language.isDefault ? query(siteChromeData, siteChromeQuery, { location }) : query(siteChromeData, languageChromeQuery, {
6265
- location,
6266
- language: language.code
6267
- }), config.consent?.cmsSettings ? consentSettings(language) : null]);
6268
- const origins = [new URL(config.graphqlEndpoint).origin, ...siteOrigin ? [siteOrigin] : []];
7082
+ const [data, consent] = await Promise.all([chromeData(language, location), config.consent?.cmsSettings ? consentSettings(language) : null]);
7083
+ const origins = internalOrigins(config, siteOrigin);
6269
7084
  return {
6270
7085
  kind: "found",
6271
7086
  content: {
6272
7087
  menuItems: buildMenuTree(data.menuItems.nodes, origins),
6273
7088
  siteIcon: normalizeImage(data.generalSettings.siteIcon),
6274
7089
  siteLogo: normalizeImage(data.generalSettings.siteLogo),
7090
+ header: designPatternOf("header", data.header),
7091
+ footer: designPatternOf("footer", data.footer),
6275
7092
  ...consent ? {
6276
7093
  legal: parseLegalLanguage(consent.getquickLegal),
6277
7094
  analytics: consent.googleAnalytics ?? {
@@ -6289,6 +7106,7 @@ function configuredWordPress(config, languages) {
6289
7106
  getDesignPresets,
6290
7107
  getPublishedEntries,
6291
7108
  getPublishedRoutes,
7109
+ getArchives,
6292
7110
  getSiteChrome
6293
7111
  };
6294
7112
  }
@@ -6297,6 +7115,10 @@ function configuredWordPress(config, languages) {
6297
7115
  function createContentRuntime(input) {
6298
7116
  const site = input.site.trim();
6299
7117
  if (!site) throw new Error("A nonempty Site identity is required");
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 ?? {});
6300
7122
  const config = {
6301
7123
  ...input,
6302
7124
  site,
@@ -6351,8 +7173,49 @@ function createContentRuntime(input) {
6351
7173
  handleRefresh: guarded("Refresh", "Refresh with POST.", refresh.handleRefresh),
6352
7174
  publishedHome: delivery.publishedHome,
6353
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,
6354
7193
  pageChrome: delivery.pageChrome,
6355
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
+ /**
7207
+ * The design presets every page shares (GQ Design's spacing sizes and
7208
+ * colors), for a page that isn't a publication: the stored ones when there
7209
+ * is a store, else (in explicit development) a live read. Null when
7210
+ * neither has them.
7211
+ */
7212
+ designPresets: delivery.designPresets,
7213
+ /**
7214
+ * The origins a link into this Site may use: WordPress's, and the
7215
+ * Frontend's when given. `menuItemHref` turns links at them into paths.
7216
+ */
7217
+ internalOrigins: (siteOrigin) => internalOrigins(config, siteOrigin),
7218
+ /**
6356
7219
  * The Site's consent configuration for a page served with this chrome:
6357
7220
  * declared services, GA4 when gq-config's toggle is on, and the
6358
7221
  * controller from the Legal Contract. Null when consent isn't configured.
@@ -6364,5 +7227,27 @@ function createContentRuntime(input) {
6364
7227
  }) : null
6365
7228
  };
6366
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
+ }
6367
7252
  //#endregion
6368
7253
  export { createContentRuntime };