From 46bdf13daf537c1d77ddb2c72c25fe2fb46a17b3 Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 28 May 2026 17:16:33 +0200 Subject: [PATCH 001/457] =?UTF-8?q?WIP=20variant/document-level=20?= =?UTF-8?q?=E2=80=94=20Step=201+2:=20plugin=20+=20all=209=20schemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install @sanity/document-internationalization (^6.2.1). - studio/sanity.config.ts: replace internationalizedArray({...}) with documentInternationalization({ supportedLanguages, schemaTypes, languageField }). - studio/config/sync/internationalizedArrayLanguages.ts renamed to supportedLanguages.ts (loader signature identical, plus a try/catch fallback that addresses the earlier robustness gap). - All 9 schemas migrated: page, home, errorSettings, siteSettings, siteNav, siteCookieBanner gain a hidden `language` field; module objects (text/carousel/contentRefs) get plain string/richText/ richTextMedia field types (no per-field i18n wrapper). Previews use plain string access; firstLocalizedLabel helper deleted. NOTE: this is an intermediate commit on the long-lived variant branch. typecheck/build are intentionally NOT green here — the queries, web types, fetch wrappers, components, structure items, and presentation resolver still assume field-level i18n and will be migrated in the following commits. --- pnpm-lock.yaml | 56 +++++++++++++++++++ ...rrayLanguages.ts => supportedLanguages.ts} | 24 +++++--- studio/package.json | 1 + studio/sanity.config.ts | 31 +++++++--- studio/schemas/documents/page.ts | 17 ++++-- .../schemas/objects/modules/moduleCarousel.ts | 7 +-- .../objects/modules/moduleContentRefs.ts | 8 ++- studio/schemas/objects/modules/moduleText.ts | 12 ++-- studio/schemas/settings/error.ts | 18 ++++-- studio/schemas/settings/siteCookieBanner.ts | 11 +++- studio/schemas/settings/siteNav.ts | 11 +++- studio/schemas/settings/siteSettings.ts | 11 +++- studio/schemas/singletons/home.ts | 12 +++- studio/utils/firstLocalizedLabel.ts | 22 -------- 14 files changed, 170 insertions(+), 71 deletions(-) rename studio/config/sync/{internationalizedArrayLanguages.ts => supportedLanguages.ts} (65%) delete mode 100644 studio/utils/firstLocalizedLabel.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de39bded..2e52a070 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@sanity/dashboard': specifier: ^5.0.1 version: 5.0.1(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(sanity@5.27.0(@emotion/is-prop-valid@1.4.0)(@noble/hashes@2.2.0)(@oclif/core@4.11.4)(@sanity/cli-core@1.3.2(@noble/hashes@2.2.0)(@types/node@22.19.19)(lightningcss@1.32.0)(yaml@2.9.0))(@types/node@22.19.19)(@types/react@19.2.15)(immer@11.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(prismjs@1.27.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.3))(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) + '@sanity/document-internationalization': + specifier: ^6.2.1 + version: 6.2.1(@emotion/is-prop-valid@1.4.0)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(sanity-plugin-internationalized-array@5.1.3(ad74c337faf32969affba83ed387f611))(sanity@5.27.0(@emotion/is-prop-valid@1.4.0)(@noble/hashes@2.2.0)(@oclif/core@4.11.4)(@sanity/cli-core@1.3.2(@noble/hashes@2.2.0)(@types/node@22.19.19)(lightningcss@1.32.0)(yaml@2.9.0))(@types/node@22.19.19)(@types/react@19.2.15)(immer@11.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(prismjs@1.27.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.3))(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) '@sanity/icons': specifier: ^3.7.4 version: 3.7.4(react@19.2.6) @@ -2388,6 +2391,15 @@ packages: resolution: {integrity: sha512-/zFEmzg7da1hRS2OqOi+M1Ro/39WVvpLSf6ryv1tJ/dR4hoQMYoO4+QqChrjcKHcINI6Nvqs8cy9b/eTbqHEAw==} engines: {node: '>=20.19 <22 || >=22.12'} + '@sanity/document-internationalization@6.2.1': + resolution: {integrity: sha512-HcIVr/i1Sdw6ygidpO9HukRq0pqCVpYgDutZw6hnPg097Wvw7TnuypZcxu0DyhtfoIfOQ5OCan2dTZ8NzmYj+w==} + engines: {node: '>=20.19 <22 || >=22.12'} + peerDependencies: + react: ^19.2 + react-dom: ^19.2 + sanity: ^5 || ^6.0.0-0 + sanity-plugin-internationalized-array: ^5.0.0 + '@sanity/eventsource@5.0.2': resolution: {integrity: sha512-/B9PMkUvAlUrpRq0y+NzXgRv5lYCLxZNsBJD2WXVnqZYOfByL9oQBV7KiTaARuObp5hcQYuPfOAVjgXe3hrixA==} @@ -5094,6 +5106,15 @@ packages: react: ^18 || ^19 sanity: ^3 || ^5 + sanity-plugin-utils@1.8.0: + resolution: {integrity: sha512-fzRThaEO/UIK3PRf7W8UXAaxTOsaB53xl6CJGoAVtBRBHANEMOv9bAcgZyBoMyIVuj17N2SGpKCZSIcAgsQmYQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ^19 + rxjs: ^7.8.1 + sanity: ^3.67.1 || ^4.0.0 || ^5.0.0 + styled-components: ^6.1 + sanity@5.27.0: resolution: {integrity: sha512-PEsEZP3gVi2YG5Xkgr/KjGteR5YRPFFrlfX9Sy9QV4kJRPa8pVHmrswba+VMHg5E9/Dd951TkTAQnUGrlINw0g==} engines: {node: '>=20.19 <22 || >=22.12'} @@ -8267,6 +8288,26 @@ snapshots: dependencies: '@sanity/diff-match-patch': 3.2.0 + '@sanity/document-internationalization@6.2.1(@emotion/is-prop-valid@1.4.0)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(sanity-plugin-internationalized-array@5.1.3(ad74c337faf32969affba83ed387f611))(sanity@5.27.0(@emotion/is-prop-valid@1.4.0)(@noble/hashes@2.2.0)(@oclif/core@4.11.4)(@sanity/cli-core@1.3.2(@noble/hashes@2.2.0)(@types/node@22.19.19)(lightningcss@1.32.0)(yaml@2.9.0))(@types/node@22.19.19)(@types/react@19.2.15)(immer@11.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(prismjs@1.27.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.3))(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))': + dependencies: + '@sanity/icons': 3.7.4(react@19.2.6) + '@sanity/mutator': 5.27.0(@types/react@19.2.15) + '@sanity/ui': 3.2.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) + '@sanity/util': 5.27.0(@types/react@19.2.15) + '@sanity/uuid': 3.0.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + rxjs: 7.8.2 + sanity: 5.27.0(@emotion/is-prop-valid@1.4.0)(@noble/hashes@2.2.0)(@oclif/core@4.11.4)(@sanity/cli-core@1.3.2(@noble/hashes@2.2.0)(@types/node@22.19.19)(lightningcss@1.32.0)(yaml@2.9.0))(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(immer@11.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(prismjs@1.27.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.3) + sanity-plugin-internationalized-array: 5.1.3(ad74c337faf32969affba83ed387f611) + sanity-plugin-utils: 1.8.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(rxjs@7.8.2)(sanity@5.27.0(@emotion/is-prop-valid@1.4.0)(@noble/hashes@2.2.0)(@oclif/core@4.11.4)(@sanity/cli-core@1.3.2(@noble/hashes@2.2.0)(@types/node@22.19.19)(lightningcss@1.32.0)(yaml@2.9.0))(@types/node@22.19.19)(@types/react@19.2.15)(immer@11.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(prismjs@1.27.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.3))(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@types/react' + - react-is + - styled-components + - supports-color + '@sanity/eventsource@5.0.2': dependencies: '@types/event-source-polyfill': 1.0.5 @@ -11211,6 +11252,21 @@ snapshots: - styled-components - supports-color + sanity-plugin-utils@1.8.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(rxjs@7.8.2)(sanity@5.27.0(@emotion/is-prop-valid@1.4.0)(@noble/hashes@2.2.0)(@oclif/core@4.11.4)(@sanity/cli-core@1.3.2(@noble/hashes@2.2.0)(@types/node@22.19.19)(lightningcss@1.32.0)(yaml@2.9.0))(@types/node@22.19.19)(@types/react@19.2.15)(immer@11.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(prismjs@1.27.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.3))(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)): + dependencies: + '@sanity/icons': 3.7.4(react@19.2.6) + '@sanity/incompatible-plugin': 1.0.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@sanity/ui': 3.2.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)) + react: 19.2.6 + react-fast-compare: 3.2.2 + rxjs: 7.8.2 + sanity: 5.27.0(@emotion/is-prop-valid@1.4.0)(@noble/hashes@2.2.0)(@oclif/core@4.11.4)(@sanity/cli-core@1.3.2(@noble/hashes@2.2.0)(@types/node@22.19.19)(lightningcss@1.32.0)(yaml@2.9.0))(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(immer@11.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(prismjs@1.27.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.3) + styled-components: 6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - react-dom + - react-is + sanity@5.27.0(@emotion/is-prop-valid@1.4.0)(@noble/hashes@2.2.0)(@oclif/core@4.11.4)(@sanity/cli-core@1.3.2(@noble/hashes@2.2.0)(@types/node@22.19.19)(lightningcss@1.32.0)(yaml@2.9.0))(@types/node@22.19.19)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(immer@11.1.8)(jiti@2.7.0)(lightningcss@1.32.0)(prismjs@1.27.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(styled-components@6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@6.0.3): dependencies: '@algorithm.ts/lcs': 4.0.6 diff --git a/studio/config/sync/internationalizedArrayLanguages.ts b/studio/config/sync/supportedLanguages.ts similarity index 65% rename from studio/config/sync/internationalizedArrayLanguages.ts rename to studio/config/sync/supportedLanguages.ts index aa1ca46a..ce915a70 100644 --- a/studio/config/sync/internationalizedArrayLanguages.ts +++ b/studio/config/sync/supportedLanguages.ts @@ -1,7 +1,7 @@ import type { SanityClient } from "sanity"; -/** Same projection as `web/sanity/queries/snippets/settings.ts` / locale codegen. */ -export const siteLanguageSettingsInternationalizedQuery = `*[_id == "siteLanguageSettings"][0]{availableLanguages[]{id,title},defaultLanguageId}`; +/** Same projection as `web/sanity/queries/snippets/settings.ts` (siteLanguageSettings). */ +export const siteLanguageSettingsLanguagesQuery = `*[_id == "siteLanguageSettings"][0]{availableLanguages[]{id,title},defaultLanguageId}`; type SiteLanguageSettingsDoc = { availableLanguages?: Array<{ id?: string; title?: string }> | null; @@ -44,14 +44,20 @@ function normalizeFromDoc(doc: SiteLanguageSettingsDoc): Array<{ } /** - * Languages for `sanity-plugin-internationalized-array`, loaded on each Studio session - * from the `siteLanguageSettings` singleton (drafts when the Studio client uses preview). + * Languages for `@sanity/document-internationalization`, loaded on each Studio session + * from the `siteLanguageSettings` singleton. Falls back to the minimal `en` list when + * the singleton is missing or malformed (Studio still boots). */ -export async function internationalizedArrayLanguagesFromClient( +export async function supportedLanguagesFromClient( client: SanityClient, ): Promise> { - const doc = await client.fetch( - siteLanguageSettingsInternationalizedQuery, - ); - return normalizeFromDoc(doc); + try { + const doc = await client.fetch( + siteLanguageSettingsLanguagesQuery, + ); + return normalizeFromDoc(doc); + } catch (err) { + console.error("[supportedLanguages] fetch failed:", err); + return [...FALLBACK_LANGUAGES]; + } } diff --git a/studio/package.json b/studio/package.json index 11468b72..1b32614e 100644 --- a/studio/package.json +++ b/studio/package.json @@ -17,6 +17,7 @@ "@repo/sanity-dataset-resolve": "workspace:*", "@sanity/code-input": "^7.1.2", "@sanity/dashboard": "^5.0.1", + "@sanity/document-internationalization": "^6.2.1", "@sanity/icons": "^3.7.4", "@sanity/image-url": "^2.1.1", "@sanity/vision": "^5.27.0", diff --git a/studio/sanity.config.ts b/studio/sanity.config.ts index 99c61dc7..c93b9ee8 100644 --- a/studio/sanity.config.ts +++ b/studio/sanity.config.ts @@ -1,10 +1,10 @@ import { codeInput } from "@sanity/code-input"; import { dashboardTool, projectInfoWidget } from "@sanity/dashboard"; +import { documentInternationalization } from "@sanity/document-internationalization"; import { visionTool } from "@sanity/vision"; import { defineConfig } from "sanity"; import { presentationTool } from "sanity/presentation"; import { structureTool } from "sanity/structure"; -import { internationalizedArray } from "sanity-plugin-internationalized-array"; import { media } from "sanity-plugin-media"; import { muxInput } from "sanity-plugin-mux-input"; import { netlifyTool } from "sanity-plugin-netlify"; @@ -18,10 +18,25 @@ import { } from "./config/presentation/resolve"; import { filterSingletonDocumentActions } from "./config/singletons"; import { structure } from "./config/structure"; -import { internationalizedArrayLanguagesFromClient } from "./config/sync/internationalizedArrayLanguages"; import { studioDataset } from "./config/sync/studioDataset"; +import { supportedLanguagesFromClient } from "./config/sync/supportedLanguages"; import { schemaTypes } from "./schemas"; +/** + * Document types that carry a `language` field and have parallel-per-locale + * documents (see `@sanity/document-internationalization`). Add new translatable + * types here AND on the schema (`language` field + `translatable: true` in + * `singletons.ts` where it applies). + */ +const TRANSLATABLE_SCHEMA_TYPES = [ + "home", + "page", + "errorSettings", + "siteNav", + "siteSettings", + "siteCookieBanner", +]; + const projectId = process.env.SANITY_STUDIO_PROJECT_ID; const dataset = studioDataset; const previewOrigin = @@ -76,13 +91,11 @@ export default defineConfig({ media(), muxInput(), netlifyTool(), - internationalizedArray({ - languages: internationalizedArrayLanguagesFromClient, - /** Plugin only accepts a static list; runtime default comes from `siteLanguageSettings` in the fetch above. */ - defaultLanguages: [], - fieldTypes: ["string", "richText", "richTextMedia"], - /** Hide bulk “Add missing languages”; languages come only from Site languages (`siteLanguageSettings`). */ - buttonAddAll: false, + documentInternationalization({ + /** Loaded once per Studio session from `siteLanguageSettings`. */ + supportedLanguages: supportedLanguagesFromClient, + schemaTypes: TRANSLATABLE_SCHEMA_TYPES, + languageField: "language", }), ], schema: { diff --git a/studio/schemas/documents/page.ts b/studio/schemas/documents/page.ts index 62cd6419..4de5cb38 100644 --- a/studio/schemas/documents/page.ts +++ b/studio/schemas/documents/page.ts @@ -21,10 +21,16 @@ export const page = defineType({ }, ], fields: [ + { + name: "language", + type: "string", + readOnly: true, + hidden: true, + }, { name: "title", title: "Title", - type: "internationalizedArrayString", + type: "string", group: "editorial", validation: (rule) => rule.required(), }, @@ -50,12 +56,15 @@ export const page = defineType({ ], preview: { select: { + title: "title", slug: "slug", + language: "language", }, - prepare(selection) { - const { slug } = selection; + prepare({ title, slug, language }) { + const path = slug?.current?.trim() ? `/${slug.current}` : "Page"; return { - title: slug?.current?.trim() ? `/${slug.current}` : "Page", + title: typeof title === "string" && title.trim() ? title : path, + subtitle: language ? `${path} · ${language}` : path, }; }, }, diff --git a/studio/schemas/objects/modules/moduleCarousel.ts b/studio/schemas/objects/modules/moduleCarousel.ts index 3ca518d6..3618f6b0 100644 --- a/studio/schemas/objects/modules/moduleCarousel.ts +++ b/studio/schemas/objects/modules/moduleCarousel.ts @@ -1,8 +1,6 @@ import { ImagesIcon, PlayIcon } from "@sanity/icons"; import { defineType, type PreviewValue } from "sanity"; -import { firstLocalizedLabel } from "../../../utils/firstLocalizedLabel"; - export const moduleCarousel = defineType({ name: "module.carousel", title: "Carousel", @@ -12,7 +10,7 @@ export const moduleCarousel = defineType({ { name: "heading", title: "Heading", - type: "internationalizedArrayString", + type: "string", }, { name: "imagesOnly", @@ -96,7 +94,8 @@ export const moduleCarousel = defineType({ } return { - title: firstLocalizedLabel(heading, "Carousel"), + title: + typeof heading === "string" && heading.trim() ? heading : "Carousel", subtitle: `${count} slide${count === 1 ? "" : "s"}`, media, }; diff --git a/studio/schemas/objects/modules/moduleContentRefs.ts b/studio/schemas/objects/modules/moduleContentRefs.ts index cab4ff9c..eefd026e 100644 --- a/studio/schemas/objects/modules/moduleContentRefs.ts +++ b/studio/schemas/objects/modules/moduleContentRefs.ts @@ -1,7 +1,6 @@ import { DocumentsIcon } from "@sanity/icons"; import { defineType } from "sanity"; -import { firstLocalizedLabel } from "../../../utils/firstLocalizedLabel"; import { PAGE_REFERENCE_FILTER, PAGE_REFERENCES, @@ -16,7 +15,7 @@ export const moduleContentRefs = defineType({ { name: "heading", title: "Heading", - type: "internationalizedArrayString", + type: "string", }, { name: "allowMultiple", @@ -80,7 +79,10 @@ export const moduleContentRefs = defineType({ allowMultiple: "allowMultiple", }, prepare({ heading, allowMultiple }) { - const title = firstLocalizedLabel(heading, "Content references"); + const title = + typeof heading === "string" && heading.trim() + ? heading + : "Content references"; const mode = allowMultiple === true ? "Multiple references" : "Single reference"; return { diff --git a/studio/schemas/objects/modules/moduleText.ts b/studio/schemas/objects/modules/moduleText.ts index 1ed4ceb9..6868cf15 100644 --- a/studio/schemas/objects/modules/moduleText.ts +++ b/studio/schemas/objects/modules/moduleText.ts @@ -1,8 +1,6 @@ import { TextIcon } from "@sanity/icons"; import { defineType } from "sanity"; -import { firstLocalizedLabel } from "../../../utils/firstLocalizedLabel"; - export const moduleText = defineType({ name: "module.text", title: "Text", @@ -12,22 +10,22 @@ export const moduleText = defineType({ { name: "title", title: "Title", - type: "internationalizedArrayString", + type: "string", validation: (rule) => rule.required(), }, { name: "body", title: "Body", - type: "internationalizedArrayRichTextMedia", + type: "richTextMedia", }, ], preview: { select: { - titleEntries: "title", + title: "title", }, - prepare({ titleEntries }) { + prepare({ title }) { return { - title: firstLocalizedLabel(titleEntries, "Text"), + title: typeof title === "string" && title.trim() ? title : "Text", subtitle: "Text module", }; }, diff --git a/studio/schemas/settings/error.ts b/studio/schemas/settings/error.ts index c758807b..36da94ee 100644 --- a/studio/schemas/settings/error.ts +++ b/studio/schemas/settings/error.ts @@ -14,39 +14,47 @@ export const errorSettings = defineType({ }, ], fields: [ + { + name: "language", + type: "string", + readOnly: true, + hidden: true, + }, { name: "notFoundTitle", title: "404 — Title", - type: "internationalizedArrayString", + type: "string", group: "editorial", validation: (rule) => rule.required(), }, { name: "notFoundBody", title: "404 — Body", - type: "internationalizedArrayRichText", + type: "richText", description: "Basic rich text (no media modules).", group: "editorial", }, { name: "serverErrorTitle", title: "500 — Title", - type: "internationalizedArrayString", + type: "string", group: "editorial", validation: (rule) => rule.required(), }, { name: "serverErrorBody", title: "500 — Body", - type: "internationalizedArrayRichText", + type: "richText", description: "Basic rich text (no media modules).", group: "editorial", }, ], preview: { - prepare() { + select: { language: "language" }, + prepare({ language }) { return { title: "Error pages", + subtitle: language || undefined, }; }, }, diff --git a/studio/schemas/settings/siteCookieBanner.ts b/studio/schemas/settings/siteCookieBanner.ts index 1f48e79c..0f90967d 100644 --- a/studio/schemas/settings/siteCookieBanner.ts +++ b/studio/schemas/settings/siteCookieBanner.ts @@ -9,6 +9,12 @@ export const siteCookieBanner = defineType({ title: "Cookie Banner", icon: CodeBlockIcon, fields: [ + { + name: "language", + type: "string", + readOnly: true, + hidden: true, + }, { title: "Title", name: "title", @@ -101,10 +107,11 @@ export const siteCookieBanner = defineType({ }, ], preview: { - select: { title: "title" }, - prepare({ title }) { + select: { title: "title", language: "language" }, + prepare({ title, language }) { return { title: title ?? "Cookie Banner", + subtitle: language || undefined, }; }, }, diff --git a/studio/schemas/settings/siteNav.ts b/studio/schemas/settings/siteNav.ts index 89a23c5c..f8736fc6 100644 --- a/studio/schemas/settings/siteNav.ts +++ b/studio/schemas/settings/siteNav.ts @@ -8,6 +8,12 @@ export const siteNav = defineType({ title: "Navigation", icon: MenuIcon, fields: [ + { + name: "language", + type: "string", + readOnly: true, + hidden: true, + }, { title: "Title", name: "title", @@ -29,10 +35,11 @@ export const siteNav = defineType({ }, ], preview: { - select: { title: "title" }, - prepare({ title }) { + select: { title: "title", language: "language" }, + prepare({ title, language }) { return { title: title ?? "Navigation", + subtitle: language || undefined, }; }, }, diff --git a/studio/schemas/settings/siteSettings.ts b/studio/schemas/settings/siteSettings.ts index e565d4da..acd4d910 100644 --- a/studio/schemas/settings/siteSettings.ts +++ b/studio/schemas/settings/siteSettings.ts @@ -20,6 +20,12 @@ export const siteSettings = defineType({ }, ], fields: [ + { + name: "language", + type: "string", + readOnly: true, + hidden: true, + }, { title: "Site Title", name: "title", @@ -41,10 +47,11 @@ export const siteSettings = defineType({ }, ], preview: { - select: { title: "title" }, - prepare({ title }) { + select: { title: "title", language: "language" }, + prepare({ title, language }) { return { title: title || "Settings", + subtitle: language || undefined, }; }, }, diff --git a/studio/schemas/singletons/home.ts b/studio/schemas/singletons/home.ts index fa426ba1..733ba239 100644 --- a/studio/schemas/singletons/home.ts +++ b/studio/schemas/singletons/home.ts @@ -21,10 +21,16 @@ export const home = defineType({ }, ], fields: [ + { + name: "language", + type: "string", + readOnly: true, + hidden: true, + }, { name: "title", title: "Title", - type: "internationalizedArrayString", + type: "string", group: "editorial", validation: (rule) => rule.required(), }, @@ -38,9 +44,11 @@ export const home = defineType({ }, ], preview: { - prepare() { + select: { language: "language" }, + prepare({ language }) { return { title: "Home", + subtitle: language || undefined, }; }, }, diff --git a/studio/utils/firstLocalizedLabel.ts b/studio/utils/firstLocalizedLabel.ts deleted file mode 100644 index edde59ca..00000000 --- a/studio/utils/firstLocalizedLabel.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Returns the first non-empty value of an `internationalizedArrayString` field - * (array of `{ _key, value }` entries), trimmed — otherwise `fallback`. - * - * Used in schema `preview.prepare` blocks to derive a human label from a - * localized heading/title without committing to a specific locale. - */ -export function firstLocalizedLabel( - entries: unknown, - fallback: string, -): string { - if (!Array.isArray(entries)) { - return fallback; - } - const first = entries.find( - (entry: { value?: unknown }) => - typeof entry?.value === "string" && entry.value.trim().length > 0, - ); - return first && typeof first.value === "string" - ? first.value.trim() - : fallback; -} From 90c7264e69e211cb25e15eae66829c6b4eb2527a Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 28 May 2026 17:31:25 +0200 Subject: [PATCH 002/457] =?UTF-8?q?variant:=20Step=203+4=20=E2=80=94=20str?= =?UTF-8?q?ucture=20items,=20slug=20uniqueness,=20presentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio side of the document-level migration is now internally consistent (studio typecheck green; web side migrates in the next etappe). Structure items for the five singletons (home, siteSettings, siteNav, errorSettings, siteCookieBanner) switch from a fixed-id S.document() to S.documentTypeList() so the desk shows one row per language variant of each singleton; the plugin's Translations toolbar handles sibling switching inside each document. Slug uniqueness is now language-aware: `studio/utils/validateSlug.ts` exports `isUniqueLocaleAgnostic` which scopes uniqueness per (_type, language). `studio/schemas/documents/page.ts` wires it into the slug field's `options.isUnique`, so /about can exist in en AND de. Presentation: - `resolve.ts` `presentationMainDocuments` now registers four routes: `/` and `/:slug` (default-locale, no language constraint), plus `/:locale` and `/:locale/:slug` (`language == $locale`). Presentation prefers the most specific match, so per-locale routes win when the iframe URL carries a locale segment. - `locationsResolver.ts` SLUG_QUERY now selects `language` alongside `slug.current` and emits a `/{language}/{slug}` URL. The web proxy redirects default-locale prefixes to the canonical unprefixed URL. schema.json and sanity.types.gen.ts regenerated under the new schemas (52 types incl. `translation.metadata` from the plugin). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../config/presentation/locationsResolver.ts | 21 +- studio/config/presentation/resolve.ts | 20 +- .../config/structure/items/errorSettings.ts | 4 +- studio/config/structure/items/home.ts | 2 +- .../structure/items/siteCookieBanner.ts | 6 +- studio/config/structure/items/siteNav.ts | 2 +- studio/config/structure/items/siteSettings.ts | 2 +- studio/sanity.types.gen.ts | 419 +-- studio/schema.json | 2878 +++++++++-------- studio/schemas/documents/page.ts | 5 +- studio/utils/validateSlug.ts | 31 +- 11 files changed, 1800 insertions(+), 1590 deletions(-) diff --git a/studio/config/presentation/locationsResolver.ts b/studio/config/presentation/locationsResolver.ts index 42b96aa8..5667f89b 100644 --- a/studio/config/presentation/locationsResolver.ts +++ b/studio/config/presentation/locationsResolver.ts @@ -14,7 +14,18 @@ import { const SLUG_TYPE_SET = new Set(SLUG_BASED_DOCUMENT_TYPES); -const SLUG_QUERY = `*[_id in $ids][0]{ "slug": slug.current }`; +const SLUG_QUERY = `*[_id in $ids][0]{ "slug": slug.current, language }`; + +/** + * Builds the web URL for a slugged document. Always emits the language-prefixed + * path — the web app's `proxy.ts` redirects the default-locale prefix to the + * canonical unprefixed URL, so Presentation lands in the right place either way. + */ +function localizedPath(slug: string, language?: string | null): string { + const lang = typeof language === "string" ? language.trim() : ""; + if (!lang) return `/${slug}`; + return `/${lang}/${slug}`; +} /** * Types that need fully custom locations (no convention). @@ -73,12 +84,12 @@ export const presentationLocationsResolver: DocumentLocationResolver = ( return context.documentStore .listenQuery(SLUG_QUERY, { ids }, { perspective: "drafts" }) .pipe( - map((doc: { slug?: string } | null) => { - const raw = doc?.slug; - const slug = typeof raw === "string" ? raw.trim() : ""; + map((doc: { slug?: string; language?: string | null } | null) => { + const rawSlug = doc?.slug; + const slug = typeof rawSlug === "string" ? rawSlug.trim() : ""; if (slug) { - const path = `/${slug}`; + const path = localizedPath(slug, doc?.language); return { message: PRESENTATION_LOCATIONS_HEADER, locations: [ diff --git a/studio/config/presentation/resolve.ts b/studio/config/presentation/resolve.ts index a63135be..de422297 100644 --- a/studio/config/presentation/resolve.ts +++ b/studio/config/presentation/resolve.ts @@ -9,16 +9,34 @@ const slugTypeList = SLUG_BASED_DOCUMENT_TYPES.map((t) => `"${t}"`).join(","); /** * Which document opens when the Presentation iframe navigates to a route. - * Home at `/`; slugged types share `/:slug` (see `SLUG_BASED_DOCUMENT_TYPES`). + * + * The web app serves the default locale unprefixed (`/`, `/:slug`) and other + * locales under `/:locale/…`. We register both shapes for every routable type + * so editing any language variant lands on the right document: + * + * - `/` → default-locale `home` + * - `/:locale` → `home` filtered by `language` + * - `/:slug` → default-locale slugged doc + * - `/:locale/:slug` → slugged doc filtered by `language` + * + * Note: the default-locale routes do not constrain `language` because we don't + * know the default at config-load time (it lives in `siteLanguageSettings`). + * Presentation prefers the most specific match, so per-locale routes win when + * the URL carries a `:locale` segment. */ export const presentationMainDocuments = defineDocuments([ { route: "/", type: "home" }, + { route: "/:locale", filter: `_type == "home" && language == $locale` }, ...(SLUG_BASED_DOCUMENT_TYPES.length > 0 ? [ { route: "/:slug", filter: `_type in [${slugTypeList}] && slug.current == $slug`, }, + { + route: "/:locale/:slug", + filter: `_type in [${slugTypeList}] && slug.current == $slug && language == $locale`, + }, ] : []), ]); diff --git a/studio/config/structure/items/errorSettings.ts b/studio/config/structure/items/errorSettings.ts index e42169a1..a3b7ae3d 100644 --- a/studio/config/structure/items/errorSettings.ts +++ b/studio/config/structure/items/errorSettings.ts @@ -6,7 +6,5 @@ export function errorSettingsStructureItem(S: StructureBuilder) { .title("Error pages") .icon(ErrorOutlineIcon) .id("error-settings") - .child( - S.document().schemaType("errorSettings").documentId("errorSettings"), - ); + .child(S.documentTypeList("errorSettings").title("Error pages")); } diff --git a/studio/config/structure/items/home.ts b/studio/config/structure/items/home.ts index f62294f5..6733572d 100644 --- a/studio/config/structure/items/home.ts +++ b/studio/config/structure/items/home.ts @@ -6,5 +6,5 @@ export function homeStructureItem(S: StructureBuilder) { .title("Home") .icon(HomeIcon) .id("home") - .child(S.document().schemaType("home").documentId("home")); + .child(S.documentTypeList("home").title("Home")); } diff --git a/studio/config/structure/items/siteCookieBanner.ts b/studio/config/structure/items/siteCookieBanner.ts index 6b6cf79c..94c19715 100644 --- a/studio/config/structure/items/siteCookieBanner.ts +++ b/studio/config/structure/items/siteCookieBanner.ts @@ -6,9 +6,5 @@ export function siteCookieBannerStructureItem(S: StructureBuilder) { .title("Cookie Banner") .icon(CodeBlockIcon) .id("site-cookie-banner") - .child( - S.document() - .schemaType("siteCookieBanner") - .documentId("siteCookieBanner"), - ); + .child(S.documentTypeList("siteCookieBanner").title("Cookie Banner")); } diff --git a/studio/config/structure/items/siteNav.ts b/studio/config/structure/items/siteNav.ts index 2c3760e2..994cb7a6 100644 --- a/studio/config/structure/items/siteNav.ts +++ b/studio/config/structure/items/siteNav.ts @@ -6,5 +6,5 @@ export function siteNavStructureItem(S: StructureBuilder) { .title("Navigation") .icon(MenuIcon) .id("site-nav") - .child(S.document().schemaType("siteNav").documentId("siteNav")); + .child(S.documentTypeList("siteNav").title("Navigation")); } diff --git a/studio/config/structure/items/siteSettings.ts b/studio/config/structure/items/siteSettings.ts index 8f04c7f7..3254786c 100644 --- a/studio/config/structure/items/siteSettings.ts +++ b/studio/config/structure/items/siteSettings.ts @@ -6,5 +6,5 @@ export function siteSettingsStructureItem(S: StructureBuilder) { .title("Settings") .icon(CogIcon) .id("site-settings") - .child(S.document().schemaType("siteSettings").documentId("siteSettings")); + .child(S.documentTypeList("siteSettings").title("Settings")); } diff --git a/studio/sanity.types.gen.ts b/studio/sanity.types.gen.ts index a325998e..cf7d9375 100644 --- a/studio/sanity.types.gen.ts +++ b/studio/sanity.types.gen.ts @@ -26,83 +26,6 @@ export type VideoSettings = { controls?: boolean; }; -export type SiteCookieBanner = { - _id: string; - _type: "siteCookieBanner"; - _createdAt: string; - _updatedAt: string; - _rev: string; - title?: string; - useCookieBanner?: boolean; - consentModal?: { - description?: string; - acceptAllBtn?: string; - acceptNecessaryBtn?: string; - showPreferencesBtn?: string; - }; - preferencesModal?: { - title?: string; - acceptAllBtn?: string; - acceptNecessaryBtn?: string; - savePreferencesBtn?: string; - sections?: Code; - }; -}; - -export type Code = { - _type: "code"; - language?: string; - filename?: string; - code?: string; - highlightedLines?: Array; -}; - -export type ErrorSettings = { - _id: string; - _type: "errorSettings"; - _createdAt: string; - _updatedAt: string; - _rev: string; - notFoundTitle: InternationalizedArrayString; - notFoundBody?: InternationalizedArrayRichText; - serverErrorTitle: InternationalizedArrayString; - serverErrorBody?: InternationalizedArrayRichText; -}; - -export type InternationalizedArrayRichText = Array< - { - _key: string; - } & InternationalizedArrayRichTextValue ->; - -export type InternationalizedArrayString = Array< - { - _key: string; - } & InternationalizedArrayStringValue ->; - -export type SiteNav = { - _id: string; - _type: "siteNav"; - _createdAt: string; - _updatedAt: string; - _rev: string; - title?: string; - mainMenu?: Array< - | ({ - _key: string; - } & Link) - | ({ - _key: string; - } & NavLanguageSwitch) - >; - footerMenu?: Array< - { - _key: string; - } & Link - >; -}; - export type SiteLanguageSettings = { _id: string; _type: "siteLanguageSettings"; @@ -119,59 +42,6 @@ export type SiteLanguageSettings = { defaultLanguageId: string; }; -export type SanityImageAssetReference = { - _ref: string; - _type: "reference"; - _weak?: boolean; - [internalGroqTypeReferenceTo]?: "sanity.imageAsset"; -}; - -export type SiteSettings = { - _id: string; - _type: "siteSettings"; - _createdAt: string; - _updatedAt: string; - _rev: string; - title: string; - favicon?: { - asset?: SanityImageAssetReference; - media?: unknown; - hotspot?: SanityImageHotspot; - crop?: SanityImageCrop; - _type: "image"; - }; - seo?: SeoFallback; -}; - -export type SeoFallback = { - _type: "seo.fallback"; - title?: string; - description?: string; - image?: { - asset?: SanityImageAssetReference; - media?: unknown; - hotspot?: SanityImageHotspot; - crop?: SanityImageCrop; - _type: "image"; - }; -}; - -export type SanityImageCrop = { - _type: "sanity.imageCrop"; - top: number; - bottom: number; - left: number; - right: number; -}; - -export type SanityImageHotspot = { - _type: "sanity.imageHotspot"; - x: number; - y: number; - height: number; - width: number; -}; - export type RichTextMedia = Array< | { children?: Array<{ @@ -220,8 +90,8 @@ export type RichText = Array<{ export type ModuleText = { _type: "module.text"; - title: InternationalizedArrayString; - body?: InternationalizedArrayRichTextMedia; + title: string; + body?: RichTextMedia; }; export type HomeReference = { @@ -240,7 +110,7 @@ export type PageReference = { export type ModuleContentRefs = { _type: "module.contentRefs"; - heading?: InternationalizedArrayString; + heading?: string; allowMultiple?: boolean; reference?: HomeReference | PageReference; references?: ArrayOf; @@ -253,9 +123,16 @@ export type ModuleMedia = { videoContent?: MediaVideo; }; +export type SanityImageAssetReference = { + _ref: string; + _type: "reference"; + _weak?: boolean; + [internalGroqTypeReferenceTo]?: "sanity.imageAsset"; +}; + export type ModuleCarousel = { _type: "module.carousel"; - heading?: InternationalizedArrayString; + heading?: string; imagesOnly?: boolean; slides?: Array<{ asset?: SanityImageAssetReference; @@ -313,13 +190,204 @@ export type Link = { func?: LinkFunctions; }; +export type SeoFallback = { + _type: "seo.fallback"; + title?: string; + description?: string; + image?: { + asset?: SanityImageAssetReference; + media?: unknown; + hotspot?: SanityImageHotspot; + crop?: SanityImageCrop; + _type: "image"; + }; +}; + +export type SeoPage = { + _type: "seo.page"; + title?: string; + description?: string; + image?: { + asset?: SanityImageAssetReference; + media?: unknown; + hotspot?: SanityImageHotspot; + crop?: SanityImageCrop; + _type: "image"; + }; +}; + +export type LinkFunctions = { + _type: "linkFunctions"; + key: "scroll-to" | "open-modal"; + params?: string; +}; + +export type TranslationMetadata = { + _id: string; + _type: "translation.metadata"; + _createdAt: string; + _updatedAt: string; + _rev: string; + translations?: InternationalizedArrayReference; + schemaTypes?: Array; +}; + +export type InternationalizedArrayReference = Array< + { + _key: string; + } & InternationalizedArrayReferenceValue +>; + +export type ErrorSettingsReference = { + _ref: string; + _type: "reference"; + _weak?: boolean; + [internalGroqTypeReferenceTo]?: "errorSettings"; +}; + +export type SiteNavReference = { + _ref: string; + _type: "reference"; + _weak?: boolean; + [internalGroqTypeReferenceTo]?: "siteNav"; +}; + +export type SiteSettingsReference = { + _ref: string; + _type: "reference"; + _weak?: boolean; + [internalGroqTypeReferenceTo]?: "siteSettings"; +}; + +export type SiteCookieBannerReference = { + _ref: string; + _type: "reference"; + _weak?: boolean; + [internalGroqTypeReferenceTo]?: "siteCookieBanner"; +}; + +export type InternationalizedArrayReferenceValue = { + _type: "internationalizedArrayReferenceValue"; + value?: + | HomeReference + | PageReference + | ErrorSettingsReference + | SiteNavReference + | SiteSettingsReference + | SiteCookieBannerReference; + language: string; +}; + +export type SiteCookieBanner = { + _id: string; + _type: "siteCookieBanner"; + _createdAt: string; + _updatedAt: string; + _rev: string; + language?: string; + title?: string; + useCookieBanner?: boolean; + consentModal?: { + description?: string; + acceptAllBtn?: string; + acceptNecessaryBtn?: string; + showPreferencesBtn?: string; + }; + preferencesModal?: { + title?: string; + acceptAllBtn?: string; + acceptNecessaryBtn?: string; + savePreferencesBtn?: string; + sections?: Code; + }; +}; + +export type Code = { + _type: "code"; + language?: string; + filename?: string; + code?: string; + highlightedLines?: Array; +}; + +export type SiteSettings = { + _id: string; + _type: "siteSettings"; + _createdAt: string; + _updatedAt: string; + _rev: string; + language?: string; + title: string; + favicon?: { + asset?: SanityImageAssetReference; + media?: unknown; + hotspot?: SanityImageHotspot; + crop?: SanityImageCrop; + _type: "image"; + }; + seo?: SeoFallback; +}; + +export type SanityImageCrop = { + _type: "sanity.imageCrop"; + top: number; + bottom: number; + left: number; + right: number; +}; + +export type SanityImageHotspot = { + _type: "sanity.imageHotspot"; + x: number; + y: number; + height: number; + width: number; +}; + +export type SiteNav = { + _id: string; + _type: "siteNav"; + _createdAt: string; + _updatedAt: string; + _rev: string; + language?: string; + title?: string; + mainMenu?: Array< + | ({ + _key: string; + } & Link) + | ({ + _key: string; + } & NavLanguageSwitch) + >; + footerMenu?: Array< + { + _key: string; + } & Link + >; +}; + +export type ErrorSettings = { + _id: string; + _type: "errorSettings"; + _createdAt: string; + _updatedAt: string; + _rev: string; + language?: string; + notFoundTitle: string; + notFoundBody?: RichText; + serverErrorTitle: string; + serverErrorBody?: RichText; +}; + export type Page = { _id: string; _type: "page"; _createdAt: string; _updatedAt: string; _rev: string; - title: InternationalizedArrayString; + language?: string; + title: string; slug: Slug; modules?: Array< | ({ @@ -338,19 +406,6 @@ export type Page = { seo?: SeoPage; }; -export type SeoPage = { - _type: "seo.page"; - title?: string; - description?: string; - image?: { - asset?: SanityImageAssetReference; - media?: unknown; - hotspot?: SanityImageHotspot; - crop?: SanityImageCrop; - _type: "image"; - }; -}; - export type Slug = { _type: "slug"; current: string; @@ -363,7 +418,8 @@ export type Home = { _createdAt: string; _updatedAt: string; _rev: string; - title: InternationalizedArrayString; + language?: string; + title: string; modules?: Array< | ({ _key: string; @@ -381,36 +437,6 @@ export type Home = { seo?: SeoPage; }; -export type InternationalizedArrayRichTextMedia = Array< - { - _key: string; - } & InternationalizedArrayRichTextMediaValue ->; - -export type LinkFunctions = { - _type: "linkFunctions"; - key: "scroll-to" | "open-modal"; - params?: string; -}; - -export type InternationalizedArrayRichTextMediaValue = { - _type: "internationalizedArrayRichTextMediaValue"; - value?: RichTextMedia; - language: string; -}; - -export type InternationalizedArrayRichTextValue = { - _type: "internationalizedArrayRichTextValue"; - value?: RichText; - language: string; -}; - -export type InternationalizedArrayStringValue = { - _type: "internationalizedArrayStringValue"; - value?: string; - language: string; -}; - export type MuxVideoAssetReference = { _ref: string; _type: "reference"; @@ -622,18 +648,7 @@ export type Geopoint = { export type AllSanitySchemaTypes = | VideoSettings - | SiteCookieBanner - | Code - | ErrorSettings - | InternationalizedArrayRichText - | InternationalizedArrayString - | SiteNav | SiteLanguageSettings - | SanityImageAssetReference - | SiteSettings - | SeoFallback - | SanityImageCrop - | SanityImageHotspot | RichTextMedia | RichText | ModuleText @@ -641,20 +656,32 @@ export type AllSanitySchemaTypes = | PageReference | ModuleContentRefs | ModuleMedia + | SanityImageAssetReference | ModuleCarousel | MediaVideo | MediaImage | NavLanguageSwitch | Link - | Page + | SeoFallback | SeoPage + | LinkFunctions + | TranslationMetadata + | InternationalizedArrayReference + | ErrorSettingsReference + | SiteNavReference + | SiteSettingsReference + | SiteCookieBannerReference + | InternationalizedArrayReferenceValue + | SiteCookieBanner + | Code + | SiteSettings + | SanityImageCrop + | SanityImageHotspot + | SiteNav + | ErrorSettings + | Page | Slug | Home - | InternationalizedArrayRichTextMedia - | LinkFunctions - | InternationalizedArrayRichTextMediaValue - | InternationalizedArrayRichTextValue - | InternationalizedArrayStringValue | MuxVideoAssetReference | MuxVideo | MuxVideoAsset diff --git a/studio/schema.json b/studio/schema.json index ce0f4672..e893591e 100644 --- a/studio/schema.json +++ b/studio/schema.json @@ -23,7 +23,7 @@ } }, { - "name": "siteCookieBanner", + "name": "siteLanguageSettings", "type": "document", "attributes": { "_id": { @@ -36,7 +36,7 @@ "type": "objectAttribute", "value": { "type": "string", - "value": "siteCookieBanner" + "value": "siteLanguageSettings" } }, "_createdAt": { @@ -64,322 +64,344 @@ }, "optional": true }, - "useCookieBanner": { - "type": "objectAttribute", - "value": { - "type": "boolean" - }, - "optional": true - }, - "consentModal": { - "type": "objectAttribute", - "value": { - "type": "object", - "attributes": { - "description": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "acceptAllBtn": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "acceptNecessaryBtn": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "showPreferencesBtn": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - } - } - }, - "optional": true - }, - "preferencesModal": { + "availableLanguages": { "type": "objectAttribute", "value": { - "type": "object", - "attributes": { - "title": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "acceptAllBtn": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "acceptNecessaryBtn": { - "type": "objectAttribute", - "value": { - "type": "string" + "type": "array", + "of": { + "type": "object", + "attributes": { + "id": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": false }, - "optional": true - }, - "savePreferencesBtn": { - "type": "objectAttribute", - "value": { - "type": "string" + "title": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": false }, - "optional": true + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "siteLanguage" + } + } }, - "sections": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "code" - }, - "optional": true + "rest": { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + } } } }, - "optional": true - } - } - }, - { - "name": "code", - "type": "type", - "value": { - "type": "object", - "attributes": { - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "code" - } - }, - "language": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "filename": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "code": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "highlightedLines": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "number" - } - }, - "optional": true - } - } - } - }, - { - "name": "errorSettings", - "type": "document", - "attributes": { - "_id": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "errorSettings" - } - }, - "_createdAt": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_updatedAt": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_rev": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "notFoundTitle": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "internationalizedArrayString" - }, "optional": false }, - "notFoundBody": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "internationalizedArrayRichText" - }, - "optional": true - }, - "serverErrorTitle": { + "defaultLanguageId": { "type": "objectAttribute", "value": { - "type": "inline", - "name": "internationalizedArrayString" + "type": "string" }, "optional": false - }, - "serverErrorBody": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "internationalizedArrayRichText" - }, - "optional": true - } - } - }, - { - "name": "internationalizedArrayRichText", - "type": "type", - "value": { - "type": "array", - "of": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } - } - }, - "rest": { - "type": "inline", - "name": "internationalizedArrayRichTextValue" - } } } }, { - "name": "internationalizedArrayString", + "name": "richTextMedia", "type": "type", "value": { "type": "array", "of": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } - } - }, - "rest": { - "type": "inline", - "name": "internationalizedArrayStringValue" - } + "type": "union", + "of": [ + { + "type": "object", + "attributes": { + "children": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { + "type": "object", + "attributes": { + "marks": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { + "type": "string" + } + }, + "optional": true + }, + "text": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "span" + } + } + }, + "rest": { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + } + } + } + }, + "optional": true + }, + "style": { + "type": "objectAttribute", + "value": { + "type": "union", + "of": [ + { + "type": "string", + "value": "normal" + }, + { + "type": "string", + "value": "h2" + }, + { + "type": "string", + "value": "h3" + }, + { + "type": "string", + "value": "h4" + } + ] + }, + "optional": true + }, + "listItem": { + "type": "objectAttribute", + "value": { + "type": "union", + "of": [ + { + "type": "string", + "value": "bullet" + }, + { + "type": "string", + "value": "number" + } + ] + }, + "optional": true + }, + "markDefs": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "link" + } + } + }, + "optional": true + }, + "level": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": true + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "block" + } + } + }, + "rest": { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "module.media" + } + }, + { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "module.carousel" + } + } + ] } } }, { - "name": "siteNav", - "type": "document", - "attributes": { - "_id": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "siteNav" - } - }, - "_createdAt": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_updatedAt": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_rev": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "title": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "mainMenu": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "union", - "of": [ - { + "name": "richText", + "type": "type", + "value": { + "type": "array", + "of": { + "type": "object", + "attributes": { + "children": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { "type": "object", "attributes": { - "_key": { + "marks": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { + "type": "string" + } + }, + "optional": true + }, + "text": { "type": "objectAttribute", "value": { "type": "string" + }, + "optional": true + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "span" + } + } + }, + "rest": { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } } } + } + } + }, + "optional": true + }, + "style": { + "type": "objectAttribute", + "value": { + "type": "union", + "of": [ + { + "type": "string", + "value": "normal" }, - "rest": { - "type": "inline", - "name": "link" + { + "type": "string", + "value": "h2" + }, + { + "type": "string", + "value": "h3" + }, + { + "type": "string", + "value": "h4" } - }, - { + ] + }, + "optional": true + }, + "listItem": { + "type": "objectAttribute", + "value": { + "type": "union", + "of": [ + { + "type": "string", + "value": "bullet" + }, + { + "type": "string", + "value": "number" + } + ] + }, + "optional": true + }, + "markDefs": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { "type": "object", "attributes": { "_key": { @@ -391,130 +413,245 @@ }, "rest": { "type": "inline", - "name": "nav.languageSwitch" + "name": "link" } } - ] + }, + "optional": true + }, + "level": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": true + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "block" + } } }, - "optional": true - }, - "footerMenu": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } + "rest": { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" } - }, - "rest": { - "type": "inline", - "name": "link" } } - }, - "optional": true + } } } }, { - "name": "siteLanguageSettings", - "type": "document", - "attributes": { - "_id": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "siteLanguageSettings" - } - }, - "_createdAt": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_updatedAt": { - "type": "objectAttribute", - "value": { - "type": "string" + "name": "module.text", + "type": "type", + "value": { + "type": "object", + "attributes": { + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "module.text" + } + }, + "title": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": false + }, + "body": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "richTextMedia" + }, + "optional": true } - }, - "_rev": { - "type": "objectAttribute", - "value": { - "type": "string" + } + } + }, + { + "type": "type", + "name": "home.reference", + "value": { + "type": "object", + "attributes": { + "_ref": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "reference" + } + }, + "_weak": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true } }, - "title": { - "type": "objectAttribute", - "value": { - "type": "string" + "dereferencesTo": "home" + } + }, + { + "type": "type", + "name": "page.reference", + "value": { + "type": "object", + "attributes": { + "_ref": { + "type": "objectAttribute", + "value": { + "type": "string" + } }, - "optional": true + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "reference" + } + }, + "_weak": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + } }, - "availableLanguages": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "object", - "attributes": { - "id": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": false - }, - "title": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": false + "dereferencesTo": "page" + } + }, + { + "name": "module.contentRefs", + "type": "type", + "value": { + "type": "object", + "attributes": { + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "module.contentRefs" + } + }, + "heading": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "allowMultiple": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "reference": { + "type": "objectAttribute", + "value": { + "type": "union", + "of": [ + { + "type": "inline", + "name": "home.reference" }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "siteLanguage" - } + { + "type": "inline", + "name": "page.reference" } - }, - "rest": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } + ] + }, + "optional": true + }, + "references": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { + "type": "union", + "of": [ + { + "type": "inline", + "name": "home.reference" + }, + { + "type": "inline", + "name": "page.reference" } - } + ] } + }, + "optional": true + } + } + } + }, + { + "name": "module.media", + "type": "type", + "value": { + "type": "object", + "attributes": { + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "module.media" } }, - "optional": false - }, - "defaultLanguageId": { - "type": "objectAttribute", - "value": { - "type": "string" + "type": { + "type": "objectAttribute", + "value": { + "type": "union", + "of": [ + { + "type": "string", + "value": "image" + }, + { + "type": "string", + "value": "video" + } + ] + }, + "optional": false }, - "optional": false + "imageContent": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "media.image" + }, + "optional": true + }, + "videoContent": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "media.video" + }, + "optional": true + } } } }, @@ -549,106 +686,120 @@ } }, { - "name": "siteSettings", - "type": "document", - "attributes": { - "_id": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "siteSettings" - } - }, - "_createdAt": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_updatedAt": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "_rev": { - "type": "objectAttribute", - "value": { - "type": "string" - } - }, - "title": { - "type": "objectAttribute", - "value": { - "type": "string" + "name": "module.carousel", + "type": "type", + "value": { + "type": "object", + "attributes": { + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "module.carousel" + } }, - "optional": false - }, - "favicon": { - "type": "objectAttribute", - "value": { - "type": "object", - "attributes": { - "asset": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageAsset.reference" - }, - "optional": true - }, - "media": { - "type": "objectAttribute", - "value": { - "type": "unknown" - }, - "optional": true - }, - "hotspot": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageHotspot" - }, - "optional": true - }, - "crop": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageCrop" + "heading": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "imagesOnly": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "slides": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { + "type": "object", + "attributes": { + "asset": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageAsset.reference" + }, + "optional": true + }, + "media": { + "type": "objectAttribute", + "value": { + "type": "unknown" + }, + "optional": true + }, + "hotspot": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageHotspot" + }, + "optional": true + }, + "crop": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageCrop" + }, + "optional": true + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "image" + } + } }, - "optional": true - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "image" + "rest": { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + } } } - } - }, - "optional": true - }, - "seo": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "seo.fallback" + }, + "optional": true }, - "optional": true + "slidesMedia": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "module.media" + } + } + }, + "optional": true + } } } }, { - "name": "seo.fallback", + "name": "media.video", "type": "type", "value": { "type": "object", @@ -657,24 +808,18 @@ "type": "objectAttribute", "value": { "type": "string", - "value": "seo.fallback" + "value": "media.video" } }, - "title": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "description": { + "video": { "type": "objectAttribute", "value": { - "type": "string" + "type": "inline", + "name": "mux.video" }, "optional": true }, - "image": { + "poster": { "type": "objectAttribute", "value": { "type": "object", @@ -720,12 +865,27 @@ } }, "optional": true + }, + "videoSettings": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "videoSettings" + }, + "optional": true + }, + "caption": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true } } } }, { - "name": "sanity.imageCrop", + "name": "media.image", "type": "type", "value": { "type": "object", @@ -734,42 +894,91 @@ "type": "objectAttribute", "value": { "type": "string", - "value": "sanity.imageCrop" + "value": "media.image" } }, - "top": { + "image": { "type": "objectAttribute", "value": { - "type": "number" + "type": "object", + "attributes": { + "asset": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageAsset.reference" + }, + "optional": true + }, + "media": { + "type": "objectAttribute", + "value": { + "type": "unknown" + }, + "optional": true + }, + "hotspot": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageHotspot" + }, + "optional": true + }, + "crop": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageCrop" + }, + "optional": true + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "image" + } + } + } }, - "optional": false + "optional": true }, - "bottom": { + "caption": { "type": "objectAttribute", "value": { - "type": "number" + "type": "string" }, - "optional": false - }, - "left": { + "optional": true + } + } + } + }, + { + "name": "nav.languageSwitch", + "type": "type", + "value": { + "type": "object", + "attributes": { + "_type": { "type": "objectAttribute", "value": { - "type": "number" - }, - "optional": false + "type": "string", + "value": "nav.languageSwitch" + } }, - "right": { + "blockKind": { "type": "objectAttribute", "value": { - "type": "number" + "type": "string" }, - "optional": false + "optional": true } } } }, { - "name": "sanity.imageHotspot", + "name": "link", "type": "type", "value": { "type": "object", @@ -778,166 +987,139 @@ "type": "objectAttribute", "value": { "type": "string", - "value": "sanity.imageHotspot" + "value": "link" } }, - "x": { + "type": { "type": "objectAttribute", "value": { - "type": "number" + "type": "union", + "of": [ + { + "type": "string", + "value": "internal" + }, + { + "type": "string", + "value": "external" + }, + { + "type": "string", + "value": "function" + } + ] }, "optional": false }, - "y": { + "title": { "type": "objectAttribute", "value": { - "type": "number" + "type": "string" }, - "optional": false + "optional": true }, - "height": { + "reference": { "type": "objectAttribute", "value": { - "type": "number" + "type": "union", + "of": [ + { + "type": "inline", + "name": "home.reference" + }, + { + "type": "inline", + "name": "page.reference" + } + ] }, - "optional": false + "optional": true }, - "width": { + "url": { "type": "objectAttribute", "value": { - "type": "number" + "type": "string" }, - "optional": false + "optional": true + }, + "blank": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "func": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "linkFunctions" + }, + "optional": true } } } }, { - "name": "richTextMedia", + "name": "seo.fallback", "type": "type", "value": { - "type": "array", - "of": { - "type": "union", - "of": [ - { + "type": "object", + "attributes": { + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "seo.fallback" + } + }, + "title": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "description": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "image": { + "type": "objectAttribute", + "value": { "type": "object", "attributes": { - "children": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "object", - "attributes": { - "marks": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "string" - } - }, - "optional": true - }, - "text": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "span" - } - } - }, - "rest": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } - } - } - } - } - }, - "optional": true - }, - "style": { + "asset": { "type": "objectAttribute", "value": { - "type": "union", - "of": [ - { - "type": "string", - "value": "normal" - }, - { - "type": "string", - "value": "h2" - }, - { - "type": "string", - "value": "h3" - }, - { - "type": "string", - "value": "h4" - } - ] + "type": "inline", + "name": "sanity.imageAsset.reference" }, "optional": true }, - "listItem": { + "media": { "type": "objectAttribute", "value": { - "type": "union", - "of": [ - { - "type": "string", - "value": "bullet" - }, - { - "type": "string", - "value": "number" - } - ] + "type": "unknown" }, "optional": true }, - "markDefs": { + "hotspot": { "type": "objectAttribute", "value": { - "type": "array", - "of": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } - } - }, - "rest": { - "type": "inline", - "name": "link" - } - } + "type": "inline", + "name": "sanity.imageHotspot" }, "optional": true }, - "level": { + "crop": { "type": "objectAttribute", "value": { - "type": "number" + "type": "inline", + "name": "sanity.imageCrop" }, "optional": true }, @@ -945,206 +1127,95 @@ "type": "objectAttribute", "value": { "type": "string", - "value": "block" - } - } - }, - "rest": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } - } - } - } - }, - { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" + "value": "image" } } - }, - "rest": { - "type": "inline", - "name": "module.media" } }, - { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } - } - }, - "rest": { - "type": "inline", - "name": "module.carousel" - } - } - ] + "optional": true + } } } }, { - "name": "richText", + "name": "seo.page", "type": "type", "value": { - "type": "array", - "of": { - "type": "object", - "attributes": { - "children": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "object", - "attributes": { - "marks": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "string" - } - }, - "optional": true - }, - "text": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "span" - } - } - }, - "rest": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } - } - } - } - } - }, - "optional": true + "type": "object", + "attributes": { + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "seo.page" + } + }, + "title": { + "type": "objectAttribute", + "value": { + "type": "string" }, - "style": { - "type": "objectAttribute", - "value": { - "type": "union", - "of": [ - { - "type": "string", - "value": "normal" + "optional": true + }, + "description": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "image": { + "type": "objectAttribute", + "value": { + "type": "object", + "attributes": { + "asset": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageAsset.reference" }, - { - "type": "string", - "value": "h2" + "optional": true + }, + "media": { + "type": "objectAttribute", + "value": { + "type": "unknown" }, - { - "type": "string", - "value": "h3" + "optional": true + }, + "hotspot": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageHotspot" }, - { - "type": "string", - "value": "h4" - } - ] - }, - "optional": true - }, - "listItem": { - "type": "objectAttribute", - "value": { - "type": "union", - "of": [ - { - "type": "string", - "value": "bullet" + "optional": true + }, + "crop": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageCrop" }, - { + "optional": true + }, + "_type": { + "type": "objectAttribute", + "value": { "type": "string", - "value": "number" - } - ] - }, - "optional": true - }, - "markDefs": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } - } - }, - "rest": { - "type": "inline", - "name": "link" + "value": "image" } } - }, - "optional": true - }, - "level": { - "type": "objectAttribute", - "value": { - "type": "number" - }, - "optional": true - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "block" - } - } - }, - "rest": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } } - } + }, + "optional": true } } } }, { - "name": "module.text", + "name": "linkFunctions", "type": "type", "value": { "type": "object", @@ -1153,22 +1224,30 @@ "type": "objectAttribute", "value": { "type": "string", - "value": "module.text" + "value": "linkFunctions" } }, - "title": { + "key": { "type": "objectAttribute", "value": { - "type": "inline", - "name": "internationalizedArrayString" + "type": "union", + "of": [ + { + "type": "string", + "value": "scroll-to" + }, + { + "type": "string", + "value": "open-modal" + } + ] }, "optional": false }, - "body": { + "params": { "type": "objectAttribute", "value": { - "type": "inline", - "name": "internationalizedArrayRichTextMedia" + "type": "string" }, "optional": true } @@ -1176,8 +1255,85 @@ } }, { + "name": "translation.metadata", + "type": "document", + "attributes": { + "_id": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "translation.metadata" + } + }, + "_createdAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_updatedAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_rev": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "translations": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "internationalizedArrayReference" + }, + "optional": true + }, + "schemaTypes": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { + "type": "string" + } + }, + "optional": true + } + } + }, + { + "name": "internationalizedArrayReference", "type": "type", - "name": "home.reference", + "value": { + "type": "array", + "of": { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "internationalizedArrayReferenceValue" + } + } + } + }, + { + "type": "type", + "name": "errorSettings.reference", "value": { "type": "object", "attributes": { @@ -1202,12 +1358,12 @@ "optional": true } }, - "dereferencesTo": "home" + "dereferencesTo": "errorSettings" } }, { "type": "type", - "name": "page.reference", + "name": "siteNav.reference", "value": { "type": "object", "attributes": { @@ -1232,128 +1388,71 @@ "optional": true } }, - "dereferencesTo": "page" + "dereferencesTo": "siteNav" } }, { - "name": "module.contentRefs", "type": "type", + "name": "siteSettings.reference", "value": { "type": "object", "attributes": { - "_type": { + "_ref": { "type": "objectAttribute", "value": { - "type": "string", - "value": "module.contentRefs" + "type": "string" } - }, - "heading": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "internationalizedArrayString" - }, - "optional": true - }, - "allowMultiple": { - "type": "objectAttribute", - "value": { - "type": "boolean" - }, - "optional": true - }, - "reference": { - "type": "objectAttribute", - "value": { - "type": "union", - "of": [ - { - "type": "inline", - "name": "home.reference" - }, - { - "type": "inline", - "name": "page.reference" - } - ] - }, - "optional": true - }, - "references": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "union", - "of": [ - { - "type": "inline", - "name": "home.reference" - }, - { - "type": "inline", - "name": "page.reference" - } - ] - } + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "reference" + } + }, + "_weak": { + "type": "objectAttribute", + "value": { + "type": "boolean" }, "optional": true } - } + }, + "dereferencesTo": "siteSettings" } }, { - "name": "module.media", "type": "type", + "name": "siteCookieBanner.reference", "value": { "type": "object", "attributes": { - "_type": { + "_ref": { "type": "objectAttribute", "value": { - "type": "string", - "value": "module.media" + "type": "string" } }, - "type": { - "type": "objectAttribute", - "value": { - "type": "union", - "of": [ - { - "type": "string", - "value": "image" - }, - { - "type": "string", - "value": "video" - } - ] - }, - "optional": false - }, - "imageContent": { + "_type": { "type": "objectAttribute", "value": { - "type": "inline", - "name": "media.image" - }, - "optional": true + "type": "string", + "value": "reference" + } }, - "videoContent": { + "_weak": { "type": "objectAttribute", "value": { - "type": "inline", - "name": "media.video" + "type": "boolean" }, "optional": true } - } + }, + "dereferencesTo": "siteCookieBanner" } }, { - "name": "module.carousel", + "name": "internationalizedArrayReferenceValue", "type": "type", "value": { "type": "object", @@ -1362,112 +1461,194 @@ "type": "objectAttribute", "value": { "type": "string", - "value": "module.carousel" + "value": "internationalizedArrayReferenceValue" } }, - "heading": { + "value": { "type": "objectAttribute", "value": { - "type": "inline", - "name": "internationalizedArrayString" + "type": "union", + "of": [ + { + "type": "inline", + "name": "home.reference" + }, + { + "type": "inline", + "name": "page.reference" + }, + { + "type": "inline", + "name": "errorSettings.reference" + }, + { + "type": "inline", + "name": "siteNav.reference" + }, + { + "type": "inline", + "name": "siteSettings.reference" + }, + { + "type": "inline", + "name": "siteCookieBanner.reference" + } + ] }, "optional": true }, - "imagesOnly": { + "language": { "type": "objectAttribute", "value": { - "type": "boolean" + "type": "string" }, - "optional": true + "optional": false + } + } + } + }, + { + "name": "siteCookieBanner", + "type": "document", + "attributes": { + "_id": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "siteCookieBanner" + } + }, + "_createdAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_updatedAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_rev": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "language": { + "type": "objectAttribute", + "value": { + "type": "string" }, - "slides": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "object", - "attributes": { - "asset": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageAsset.reference" - }, - "optional": true - }, - "media": { - "type": "objectAttribute", - "value": { - "type": "unknown" - }, - "optional": true - }, - "hotspot": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageHotspot" - }, - "optional": true - }, - "crop": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageCrop" - }, - "optional": true - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "image" - } - } + "optional": true + }, + "title": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "useCookieBanner": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "consentModal": { + "type": "objectAttribute", + "value": { + "type": "object", + "attributes": { + "description": { + "type": "objectAttribute", + "value": { + "type": "string" }, - "rest": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } - } - } - } + "optional": true + }, + "acceptAllBtn": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "acceptNecessaryBtn": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "showPreferencesBtn": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true } - }, - "optional": true + } }, - "slidesMedia": { - "type": "objectAttribute", - "value": { - "type": "array", - "of": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } - } + "optional": true + }, + "preferencesModal": { + "type": "objectAttribute", + "value": { + "type": "object", + "attributes": { + "title": { + "type": "objectAttribute", + "value": { + "type": "string" }, - "rest": { + "optional": true + }, + "acceptAllBtn": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "acceptNecessaryBtn": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "savePreferencesBtn": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "sections": { + "type": "objectAttribute", + "value": { "type": "inline", - "name": "module.media" - } + "name": "code" + }, + "optional": true } - }, - "optional": true - } + } + }, + "optional": true } } }, { - "name": "media.video", + "name": "code", "type": "type", "value": { "type": "object", @@ -1476,76 +1657,37 @@ "type": "objectAttribute", "value": { "type": "string", - "value": "media.video" + "value": "code" } }, - "video": { + "language": { "type": "objectAttribute", "value": { - "type": "inline", - "name": "mux.video" + "type": "string" }, "optional": true }, - "poster": { + "filename": { "type": "objectAttribute", "value": { - "type": "object", - "attributes": { - "asset": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageAsset.reference" - }, - "optional": true - }, - "media": { - "type": "objectAttribute", - "value": { - "type": "unknown" - }, - "optional": true - }, - "hotspot": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageHotspot" - }, - "optional": true - }, - "crop": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageCrop" - }, - "optional": true - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "image" - } - } - } + "type": "string" }, "optional": true }, - "videoSettings": { + "code": { "type": "objectAttribute", "value": { - "type": "inline", - "name": "videoSettings" + "type": "string" }, "optional": true }, - "caption": { + "highlightedLines": { "type": "objectAttribute", "value": { - "type": "string" + "type": "array", + "of": { + "type": "number" + } }, "optional": true } @@ -1553,77 +1695,113 @@ } }, { - "name": "media.image", - "type": "type", - "value": { - "type": "object", - "attributes": { - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "media.image" - } + "name": "siteSettings", + "type": "document", + "attributes": { + "_id": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "siteSettings" + } + }, + "_createdAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_updatedAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_rev": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "language": { + "type": "objectAttribute", + "value": { + "type": "string" }, - "image": { - "type": "objectAttribute", - "value": { - "type": "object", - "attributes": { - "asset": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageAsset.reference" - }, - "optional": true + "optional": true + }, + "title": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": false + }, + "favicon": { + "type": "objectAttribute", + "value": { + "type": "object", + "attributes": { + "asset": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageAsset.reference" }, - "media": { - "type": "objectAttribute", - "value": { - "type": "unknown" - }, - "optional": true + "optional": true + }, + "media": { + "type": "objectAttribute", + "value": { + "type": "unknown" }, - "hotspot": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageHotspot" - }, - "optional": true + "optional": true + }, + "hotspot": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageHotspot" }, - "crop": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageCrop" - }, - "optional": true + "optional": true + }, + "crop": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "sanity.imageCrop" }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "image" - } + "optional": true + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "image" } } - }, - "optional": true + } }, - "caption": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - } + "optional": true + }, + "seo": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "seo.fallback" + }, + "optional": true } } }, { - "name": "nav.languageSwitch", + "name": "sanity.imageCrop", "type": "type", "value": { "type": "object", @@ -1632,21 +1810,42 @@ "type": "objectAttribute", "value": { "type": "string", - "value": "nav.languageSwitch" + "value": "sanity.imageCrop" } }, - "blockKind": { + "top": { "type": "objectAttribute", "value": { - "type": "string" + "type": "number" }, - "optional": true + "optional": false + }, + "bottom": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": false + }, + "left": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": false + }, + "right": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": false } } } }, { - "name": "link", + "name": "sanity.imageHotspot", "type": "type", "value": { "type": "object", @@ -1655,76 +1854,226 @@ "type": "objectAttribute", "value": { "type": "string", - "value": "link" + "value": "sanity.imageHotspot" } }, - "type": { + "x": { "type": "objectAttribute", "value": { - "type": "union", - "of": [ - { - "type": "string", - "value": "internal" - }, - { - "type": "string", - "value": "external" - }, - { - "type": "string", - "value": "function" - } - ] + "type": "number" }, "optional": false }, - "title": { + "y": { "type": "objectAttribute", "value": { - "type": "string" + "type": "number" }, - "optional": true + "optional": false }, - "reference": { + "height": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": false + }, + "width": { "type": "objectAttribute", "value": { + "type": "number" + }, + "optional": false + } + } + } + }, + { + "name": "siteNav", + "type": "document", + "attributes": { + "_id": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "siteNav" + } + }, + "_createdAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_updatedAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_rev": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "language": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "title": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "mainMenu": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { "type": "union", "of": [ { - "type": "inline", - "name": "home.reference" + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "link" + } }, { - "type": "inline", - "name": "page.reference" + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "nav.languageSwitch" + } } ] - }, - "optional": true + } }, - "url": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true + "optional": true + }, + "footerMenu": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "link" + } + } }, - "blank": { - "type": "objectAttribute", - "value": { - "type": "boolean" - }, - "optional": true + "optional": true + } + } + }, + { + "name": "errorSettings", + "type": "document", + "attributes": { + "_id": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "errorSettings" + } + }, + "_createdAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_updatedAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_rev": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "language": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "notFoundTitle": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": false + }, + "notFoundBody": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "richText" + }, + "optional": true + }, + "serverErrorTitle": { + "type": "objectAttribute", + "value": { + "type": "string" }, - "func": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "linkFunctions" - }, - "optional": true - } + "optional": false + }, + "serverErrorBody": { + "type": "objectAttribute", + "value": { + "type": "inline", + "name": "richText" + }, + "optional": true } } }, @@ -1763,11 +2112,17 @@ "type": "string" } }, + "language": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, "title": { "type": "objectAttribute", "value": { - "type": "inline", - "name": "internationalizedArrayString" + "type": "string" }, "optional": false }, @@ -1861,83 +2216,6 @@ } } }, - { - "name": "seo.page", - "type": "type", - "value": { - "type": "object", - "attributes": { - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "seo.page" - } - }, - "title": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "description": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "image": { - "type": "objectAttribute", - "value": { - "type": "object", - "attributes": { - "asset": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageAsset.reference" - }, - "optional": true - }, - "media": { - "type": "objectAttribute", - "value": { - "type": "unknown" - }, - "optional": true - }, - "hotspot": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageHotspot" - }, - "optional": true - }, - "crop": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "sanity.imageCrop" - }, - "optional": true - }, - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "image" - } - } - } - }, - "optional": true - } - } - } - }, { "name": "slug", "type": "type", @@ -2003,11 +2281,17 @@ "type": "string" } }, + "language": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, "title": { "type": "objectAttribute", "value": { - "type": "inline", - "name": "internationalizedArrayString" + "type": "string" }, "optional": false }, @@ -2093,160 +2377,6 @@ } } }, - { - "name": "internationalizedArrayRichTextMedia", - "type": "type", - "value": { - "type": "array", - "of": { - "type": "object", - "attributes": { - "_key": { - "type": "objectAttribute", - "value": { - "type": "string" - } - } - }, - "rest": { - "type": "inline", - "name": "internationalizedArrayRichTextMediaValue" - } - } - } - }, - { - "name": "linkFunctions", - "type": "type", - "value": { - "type": "object", - "attributes": { - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "linkFunctions" - } - }, - "key": { - "type": "objectAttribute", - "value": { - "type": "union", - "of": [ - { - "type": "string", - "value": "scroll-to" - }, - { - "type": "string", - "value": "open-modal" - } - ] - }, - "optional": false - }, - "params": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - } - } - } - }, - { - "name": "internationalizedArrayRichTextMediaValue", - "type": "type", - "value": { - "type": "object", - "attributes": { - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "internationalizedArrayRichTextMediaValue" - } - }, - "value": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "richTextMedia" - }, - "optional": true - }, - "language": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": false - } - } - } - }, - { - "name": "internationalizedArrayRichTextValue", - "type": "type", - "value": { - "type": "object", - "attributes": { - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "internationalizedArrayRichTextValue" - } - }, - "value": { - "type": "objectAttribute", - "value": { - "type": "inline", - "name": "richText" - }, - "optional": true - }, - "language": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": false - } - } - } - }, - { - "name": "internationalizedArrayStringValue", - "type": "type", - "value": { - "type": "object", - "attributes": { - "_type": { - "type": "objectAttribute", - "value": { - "type": "string", - "value": "internationalizedArrayStringValue" - } - }, - "value": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": true - }, - "language": { - "type": "objectAttribute", - "value": { - "type": "string" - }, - "optional": false - } - } - } - }, { "type": "type", "name": "mux.videoAsset.reference", diff --git a/studio/schemas/documents/page.ts b/studio/schemas/documents/page.ts index 4de5cb38..238fa509 100644 --- a/studio/schemas/documents/page.ts +++ b/studio/schemas/documents/page.ts @@ -1,6 +1,6 @@ import { DocumentTextIcon, SearchIcon, TextIcon } from "@sanity/icons"; import { defineType } from "sanity"; -import { validateSlug } from "../../utils/validateSlug"; +import { isUniqueLocaleAgnostic, validateSlug } from "../../utils/validateSlug"; import { modulesArrayField } from "../fields/modulesArrayField"; export const page = defineType({ @@ -38,10 +38,11 @@ export const page = defineType({ name: "slug", title: "Path", description: - "URL path for this page (e.g. yoursite.com/my-path). Use lowercase letters, numbers, and hyphens.", + "URL path for this page (e.g. yoursite.com/my-path). Use lowercase letters, numbers, and hyphens. The same slug may be reused on different language variants.", type: "slug", options: { maxLength: 96, + isUnique: isUniqueLocaleAgnostic, }, validation: validateSlug, group: "editorial", diff --git a/studio/utils/validateSlug.ts b/studio/utils/validateSlug.ts index d81f9f51..6740a581 100644 --- a/studio/utils/validateSlug.ts +++ b/studio/utils/validateSlug.ts @@ -1,7 +1,36 @@ -import type { Rule, SlugValue } from "sanity"; +import type { Rule, SlugIsUniqueValidator, SlugValue } from "sanity"; const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** + * Slug uniqueness scoped per `_type` AND `language`, so the same slug can + * coexist on different language variants of the same document — required + * under document-level translation (`@sanity/document-internationalization`). + */ +export const isUniqueLocaleAgnostic: SlugIsUniqueValidator = async ( + slug, + context, +) => { + const { document, getClient } = context; + if (!document) return true; + const id = document._id.replace(/^drafts\./, ""); + const client = getClient({ apiVersion: "2024-01-01" }); + const params = { + draft: `drafts.${id}`, + published: id, + slug, + type: document._type, + language: (document as { language?: string | null }).language ?? null, + }; + const query = `count(*[ + _type == $type && + !(_id in [$draft, $published]) && + slug.current == $slug && + language == $language + ]) == 0`; + return client.fetch(query, params); +}; + export function validateSlug(rule: Rule) { return rule.required().custom((value: SlugValue | undefined) => { const current = value?.current?.trim(); From 0c5799e1a331cc1b89aa8de335167fbcfd05f6f2 Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 28 May 2026 17:44:54 +0200 Subject: [PATCH 003/457] =?UTF-8?q?variant:=20Step=205=E2=80=939=20?= =?UTF-8?q?=E2=80=94=20full=20web=20migration=20to=20document-level=20tran?= =?UTF-8?q?slation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web is now consistent with the Studio's document-level model: QUERIES - `pages/home.ts` and `pages/page.ts` filter by `language == $locale` and project plain `title` (no field-level array wrapper). Both wrap in `defineQuery` — the typegen-blocking `richTextMediaQuery` recursion is gone, so the full pipeline can be typed now. - `snippets/settings.ts`: every settings/nav query takes `$locale` and filters at the document level; `internationalizedRichTextArrayField` helper deleted; error-body fields project plain `richText` blocks. - `snippets/sitemap.ts`: rows carry the document's `language` so each language variant is emitted as a separate URL. - `components/text/richTextMedia.ts`: rewrite from the recursive `buildRichTextMediaQuery(depth)` function to a static depth-2 inline literal (typegen-friendly, idempotent). - `components/modules/text.ts`: flatten body — no `{language, value}` wrapper. TYPES - `web/sanity/utils/sanityLocalizedText.ts` deleted; the utils barrel drops its re-exports of `parseLocalizedText`, `pickLocalizedString`, `pickLocalizedPortableTextBlocks`, `resolveLocalizedPortableTextDeep` and the `Intl*Entry` shapes. - Hand-written types in `web/sanity/types/{pages,errorSettings,modules/*}` switch from `Intl*Entry[]` to plain `string`/`PortableTextBlock[]`, and gain a top-level `language` field where the doc carries one. - `web/sanity/sanity.types.gen.ts` regenerates with 7 typed queries (HomeQueryResult and PageBySlugQueryResult now resolve cleanly — the full-pipeline bonus promised in the plan). FETCH WRAPPERS / ROUTES - `fetchSanityData.ts`: every per-document wrapper takes `locale: string` (`fetchHomeDocument`, `fetchPageBySlug`, `fetchErrorSettings`, `fetchSiteSettingsTitle`, `fetchSettingsSeoFallback`, `fetchSiteNavMenus`). `fetchSiteLanguageSettings` stays unparameterised — it is the locale registry itself. - `cachedSanityQuery.ts`: `cachedPageDocumentBySlug(slug, locale)` and `cachedHomeDocument(locale)`; cache keys/tags become `page-{slug}-{locale}` / `home-{locale}`. - `/api/revalidate/route.ts`: payload validation accepts `language`; the emitted tags include it for `home` and `page`. - Route pages (`[locale]/page.tsx`, `[locale]/[slug]/page.tsx`, `not-found.tsx`, `sitemap.ts`) thread `locale` through every fetch. - `generateStaticParams` for `[slug]` now uses each page document's declared `language` instead of the previous cartesian locale × slug product — only the slugs that actually exist for a language are pre- rendered. COMPONENTS - `ModuleText`, `ModulesRenderer`, `LocaleNotFoundContent`, `resolveSanityMetadata` access `module.title`/`data.title` directly; no more `pickLocalized*` calls. The `siteLocale` prop drops off the module renderer chain. VERIFICATION - `pnpm -r run typecheck`: green on studio AND web. - `pnpm --filter studio run generate` + `--filter web run generate`: both idempotent against the committed artifacts. - `pnpm --filter studio run build` and `pnpm --filter web run build`: green. - `pnpm lint`: clean (only the pre-existing `useOptionalChain` warning on `RichTextMedia.tsx`). The branch is now functionally complete. Issue #18 remains open as the tracking issue for the variant. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/sanity/cachedSanityQuery.ts | 80 +- web/sanity/fetchSanityData.ts | 64 +- web/sanity/queries/components/modules/text.ts | 11 +- .../queries/components/text/richTextMedia.ts | 54 +- web/sanity/queries/pages/home.ts | 8 +- web/sanity/queries/pages/page.ts | 8 +- web/sanity/queries/snippets/settings.ts | 59 +- web/sanity/queries/snippets/sitemap.ts | 21 +- web/sanity/sanity.types.gen.ts | 2541 +++++++++++++++-- web/sanity/seo/resolveSanityMetadata.ts | 10 +- web/sanity/types/errorSettings.ts | 12 +- web/sanity/types/modules/carousel.ts | 3 +- web/sanity/types/modules/contentRefs.ts | 6 +- web/sanity/types/modules/text.ts | 6 +- web/sanity/types/pages.ts | 7 +- web/sanity/utils/index.ts | 9 - web/sanity/utils/sanityLocalizedText.ts | 311 -- .../app/[locale]/LocaleNotFoundContent.tsx | 18 +- web/src/app/[locale]/[slug]/page.tsx | 44 +- web/src/app/[locale]/layout.tsx | 8 +- web/src/app/[locale]/not-found.tsx | 17 +- web/src/app/[locale]/page.tsx | 22 +- web/src/app/api/revalidate/route.ts | 19 +- web/src/app/sitemap.ts | 51 +- web/src/components/modules/ModuleText.tsx | 21 +- .../components/modules/ModulesRenderer.tsx | 43 +- 26 files changed, 2579 insertions(+), 874 deletions(-) delete mode 100644 web/sanity/utils/sanityLocalizedText.ts diff --git a/web/sanity/cachedSanityQuery.ts b/web/sanity/cachedSanityQuery.ts index b25ba6e9..dddc77ad 100644 --- a/web/sanity/cachedSanityQuery.ts +++ b/web/sanity/cachedSanityQuery.ts @@ -40,9 +40,9 @@ export const cachedSanityQuery = cache(async (query: string) => { // ── Tag constants (kept in sync with `/api/revalidate`) ───────────────────── export const SANITY_CACHE_TAGS = { - home: "home", + home: (locale: string) => `home-${locale}`, pages: "pages", - pageSlug: (slug: string) => `page-${slug}`, + pageSlug: (slug: string, locale: string) => `page-${slug}-${locale}`, sitemap: "site-pages", siteLanguageSettings: "site-language-settings", } as const; @@ -50,46 +50,52 @@ export const SANITY_CACHE_TAGS = { // ── Page-by-slug ──────────────────────────────────────────────────────────── /** - * `pageBySlugQuery` with `$slug` — one fetch per slug per request. + * `pageBySlugQuery` with `$slug` and `$locale` — one fetch per slug+locale per request. * * Combines React `cache()` (per-request) with `unstable_cache` (cross-request). - * Revalidates via tag `page-{slug}` or time-based after `SANITY_DOCUMENT_CACHE_REVALIDATE_SECONDS`. + * Revalidates via tag `page-{slug}-{locale}` or time-based after + * `SANITY_DOCUMENT_CACHE_REVALIDATE_SECONDS`. */ -export const cachedPageDocumentBySlug = cache(async (slug: string) => { - if (!isSanityConfigured) return { data: null as PageDocument | null }; - const fetchPage = unstable_cache( - async () => - client.fetch(pageBySlugQuery, { - slug, - }), - [`page-${slug}`], - { - revalidate: SANITY_DOCUMENT_CACHE_REVALIDATE_SECONDS, - tags: [`page-${slug}`, SANITY_CACHE_TAGS.pages], - }, - ); - const data = await fetchPage(); - return { data }; -}); - -// ── Home singleton ────────────────────────────────────────────────────────── - -const fetchHomeDocumentCached = unstable_cache( - async () => client.fetch(homeQuery), - ["home-document"], - { - revalidate: SANITY_DOCUMENT_CACHE_REVALIDATE_SECONDS, - tags: [SANITY_CACHE_TAGS.home], +export const cachedPageDocumentBySlug = cache( + async (slug: string, locale: string) => { + if (!isSanityConfigured) return { data: null as PageDocument | null }; + const tag = SANITY_CACHE_TAGS.pageSlug(slug, locale); + const fetchPage = unstable_cache( + async () => + client.fetch(pageBySlugQuery, { + slug, + locale, + }), + [tag], + { + revalidate: SANITY_DOCUMENT_CACHE_REVALIDATE_SECONDS, + tags: [tag, SANITY_CACHE_TAGS.pages], + }, + ); + const data = await fetchPage(); + return { data }; }, ); +// ── Home singleton (per language) ────────────────────────────────────────── + /** - * Home singleton — cross-request cached with tag `home`. - * Call from `generateMetadata` and the page: one Sanity request per request (React `cache` dedupe). + * Home singleton — one document per language. Cross-request cached with tag + * `home-{locale}`. React `cache` dedupes within a request when `generateMetadata` + * and the page component both request the same locale. */ -export const cachedHomeDocument = cache(async () => { +export const cachedHomeDocument = cache(async (locale: string) => { if (!isSanityConfigured) return { data: null as HomeDocument | null }; - const data = await fetchHomeDocumentCached(); + const tag = SANITY_CACHE_TAGS.home(locale); + const fetchHome = unstable_cache( + async () => client.fetch(homeQuery, { locale }), + [tag], + { + revalidate: SANITY_DOCUMENT_CACHE_REVALIDATE_SECONDS, + tags: [tag], + }, + ); + const data = await fetchHome(); return { data }; }); @@ -102,11 +108,7 @@ const fetchSitemapPagesCached = unstable_cache( ["sitemap-pages"], { revalidate: 3600, - tags: [ - SANITY_CACHE_TAGS.sitemap, - SANITY_CACHE_TAGS.pages, - SANITY_CACHE_TAGS.home, - ], + tags: [SANITY_CACHE_TAGS.sitemap, SANITY_CACHE_TAGS.pages], }, ); @@ -117,7 +119,7 @@ export const cachedSitemapPages = cache(async (): Promise => { return fetchSitemapPagesCached(); }); -// ── `generateStaticParams` slug list ──────────────────────────────────────── +// ── `generateStaticParams` slug list (all languages) ─────────────────────── const fetchPageSlugsCached = unstable_cache( async () => client.fetch(pageSlugsQuery), diff --git a/web/sanity/fetchSanityData.ts b/web/sanity/fetchSanityData.ts index e8824809..fd2a87a4 100644 --- a/web/sanity/fetchSanityData.ts +++ b/web/sanity/fetchSanityData.ts @@ -27,22 +27,25 @@ type LiveFetchOptions = { }; /** Dedupes home fetches; pass `{ stega: false }` in `generateMetadata`. */ -export const fetchHomeDocument = cache(async (options?: LiveFetchOptions) => { - if (!isSanityConfigured) return null; - const { data } = await sanityFetch({ - query: homeQuery, - ...options, - }); - return data as HomeDocument | null; -}); +export const fetchHomeDocument = cache( + async (locale: string, options?: LiveFetchOptions) => { + if (!isSanityConfigured) return null; + const { data } = await sanityFetch({ + query: homeQuery, + params: { locale }, + ...options, + }); + return data as HomeDocument | null; + }, +); /** Dedupes page fetches; pass `{ stega: false }` in `generateMetadata`. */ export const fetchPageBySlug = cache( - async (slug: string, options?: LiveFetchOptions) => { + async (slug: string, locale: string, options?: LiveFetchOptions) => { if (!isSanityConfigured) return null; const { data } = await sanityFetch({ query: pageBySlugQuery, - params: { slug }, + params: { slug, locale }, ...options, }); return data as PageDocument | null; @@ -54,10 +57,11 @@ export const fetchPageBySlug = cache( * Falls back to `"Site"` when Sanity is off or the document/title is missing. */ export const fetchSiteSettingsTitle = cache( - async (options?: LiveFetchOptions): Promise => { + async (locale: string, options?: LiveFetchOptions): Promise => { if (!isSanityConfigured) return "Site"; const { data } = await sanityFetch({ query: siteSettingsTitleQuery, + params: { locale }, ...options, }); const row = data as SiteSettingsTitleQueryResult; @@ -71,10 +75,11 @@ export const fetchSiteSettingsTitle = cache( * (React `cache`) instead of joining on every home/page GROQ query. */ export const fetchSettingsSeoFallback = cache( - async (options?: LiveFetchOptions): Promise => { + async (locale: string, options?: LiveFetchOptions): Promise => { if (!isSanityConfigured) return null; const { data } = await sanityFetch({ query: siteSettingsSeoFallbackQuery, + params: { locale }, ...options, }); return data as PageSeo; @@ -82,10 +87,11 @@ export const fetchSettingsSeoFallback = cache( ); /** `siteNav` main/footer menus with resolved links; no embedded modules. */ -export const fetchSiteNavMenus = cache(async () => { +export const fetchSiteNavMenus = cache(async (locale: string) => { if (!isSanityConfigured) return null; const { data } = await sanityFetch({ query: siteNavMenusQuery, + params: { locale }, }); return data as SiteNavMenusDocument | null; }); @@ -99,15 +105,16 @@ function draftClientForSiteLanguageSettings(token: string) { } /** - * Document id: `siteLanguageSettings` — locales, default, labels for routing + i18n resolution. + * Document id: `siteLanguageSettings` — the global locale registry. Drives routing + * (default + available locales) and is NOT itself per-language. * - * Uses `client.fetch` (not `sanityFetch`) so `generateStaticParams` can run at build time - * without `draftMode()`. The published path is wrapped in `unstable_cache` (tag + * Uses `client.fetch` (not `sanityFetch`) so `generateStaticParams` can run at build + * time without `draftMode()`. The published path is wrapped in `unstable_cache` (tag * `site-language-settings`) so cross-request reads are deduped — Sanity webhooks for * `siteLanguageSettings` invalidate the tag through `/api/revalidate`. * - * With `SANITY_API_READ_TOKEN`, uses **drafts** perspective so unpublished language changes - * show in dev. Token-mode skips `unstable_cache` (drafts must always be fresh). + * With `SANITY_API_READ_TOKEN`, uses **drafts** perspective so unpublished language + * changes show in dev. Token-mode skips `unstable_cache` (drafts must always be fresh). */ export const fetchSiteLanguageSettings = cache( async (_options?: LiveFetchOptions): Promise => { @@ -123,12 +130,15 @@ export const fetchSiteLanguageSettings = cache( }, ); -/** Document id: `errorSettings` — 404 / 500 copy from Studio. */ -export const fetchErrorSettings = cache(async (options?: LiveFetchOptions) => { - if (!isSanityConfigured) return null; - const { data } = await sanityFetch({ - query: errorSettingsQuery, - ...options, - }); - return data as ErrorSettingsDocument | null; -}); +/** Document type: `errorSettings` — 404 / 500 copy from Studio (one per language). */ +export const fetchErrorSettings = cache( + async (locale: string, options?: LiveFetchOptions) => { + if (!isSanityConfigured) return null; + const { data } = await sanityFetch({ + query: errorSettingsQuery, + params: { locale }, + ...options, + }); + return data as ErrorSettingsDocument | null; + }, +); diff --git a/web/sanity/queries/components/modules/text.ts b/web/sanity/queries/components/modules/text.ts index 6312815d..8e7f37c8 100644 --- a/web/sanity/queries/components/modules/text.ts +++ b/web/sanity/queries/components/modules/text.ts @@ -1,16 +1,9 @@ import { richTextMediaQuery } from "../text/richTextMedia"; -/** - * `module.text` (`objects/modules/moduleText.ts`): `title` = i18n strings; `body` = `internationalizedArrayRichTextMedia`. - */ +/** `module.text` (`objects/modules/moduleText.ts`): plain `title` + `body` (richTextMedia). */ export const moduleTextQuery = `_type == "module.text" => { title, body[]{ - _key, - _type, - language, - value[]{ - ${richTextMediaQuery} - } + ${richTextMediaQuery} } }`; diff --git a/web/sanity/queries/components/text/richTextMedia.ts b/web/sanity/queries/components/text/richTextMedia.ts index 9463ff84..e7cdd833 100644 --- a/web/sanity/queries/components/text/richTextMedia.ts +++ b/web/sanity/queries/components/text/richTextMedia.ts @@ -3,14 +3,8 @@ import { moduleCarouselInnerFields } from "../modules/carousel"; import { moduleContentRefsInnerFields } from "../modules/contentRefs"; import { moduleMediaInnerFields } from "../modules/media"; -/** - * Portable Text `value[]` for `internationalizedArrayRichTextMedia` / `richTextMedia` - * (`objects/editors/richTextMedia.ts`): blocks, `module.media`, `module.carousel`, - * `module.contentRefs`, `module.text` (nested `body` up to `depth` levels). - */ -function buildRichTextMediaQuery(depth: number): string { - if (depth <= 0) { - return ` +/** Base case: plain blocks with resolved `link` marks (no embedded modules). */ +const blockAndLinks = ` ..., _type == "block" => { ..., @@ -22,19 +16,15 @@ function buildRichTextMediaQuery(depth: number): string { } } `; - } - return ` - ..., - _type == "block" => { - ..., - markDefs[]{ - ..., - _type == "link" => { - ${linkQuery} - } - } - }, +/** + * Portable Text `value[]` for schema `richTextMedia` (`objects/editors/richTextMedia.ts`): + * blocks, `module.media`, `module.carousel`, `module.contentRefs`, and `module.text` + * with one level of nesting (`module.text` inside `module.text`); deeper nestings fall + * back to blocks + links only. The flat shape keeps Sanity Typegen happy. + */ +export const richTextMediaQuery = ` + ${blockAndLinks}, _type == "module.media" => { ${moduleMediaInnerFields} }, @@ -47,16 +37,22 @@ function buildRichTextMediaQuery(depth: number): string { _type == "module.text" => { title, body[]{ - _key, - _type, - language, - value[]{ - ${buildRichTextMediaQuery(depth - 1)} + ${blockAndLinks}, + _type == "module.media" => { + ${moduleMediaInnerFields} + }, + _type == "module.carousel" => { + ${moduleCarouselInnerFields} + }, + _type == "module.contentRefs" => { + ${moduleContentRefsInnerFields} + }, + _type == "module.text" => { + title, + body[]{ + ${blockAndLinks} + } } } } `; -} - -/** Default nesting depth for `module.text` inside rich text (each level adds one `body[]` → `value[]`). */ -export const richTextMediaQuery = buildRichTextMediaQuery(3); diff --git a/web/sanity/queries/pages/home.ts b/web/sanity/queries/pages/home.ts index db36e49a..53843e23 100644 --- a/web/sanity/queries/pages/home.ts +++ b/web/sanity/queries/pages/home.ts @@ -1,9 +1,13 @@ +import { defineQuery } from "next-sanity"; + import { modulesQuery } from "../components/modules"; import { pageSeoQuery } from "../snippets/seo"; -export const homeQuery = `*[_id == "home"][0]{ +export const homeQuery = + defineQuery(`*[_type == "home" && language == $locale][0]{ _id, title, + language, ${modulesQuery}, ${pageSeoQuery} -}`; +}`); diff --git a/web/sanity/queries/pages/page.ts b/web/sanity/queries/pages/page.ts index 16ab22cc..50e0c195 100644 --- a/web/sanity/queries/pages/page.ts +++ b/web/sanity/queries/pages/page.ts @@ -1,10 +1,14 @@ +import { defineQuery } from "next-sanity"; + import { modulesQuery } from "../components/modules"; import { pageSeoQuery } from "../snippets/seo"; -export const pageBySlugQuery = `*[_type == "page" && slug.current == $slug][0]{ +export const pageBySlugQuery = + defineQuery(`*[_type == "page" && slug.current == $slug && language == $locale][0]{ _id, title, slug, + language, ${modulesQuery}, ${pageSeoQuery} -}`; +}`); diff --git a/web/sanity/queries/snippets/settings.ts b/web/sanity/queries/snippets/settings.ts index 945a06f2..2250ef03 100644 --- a/web/sanity/queries/snippets/settings.ts +++ b/web/sanity/queries/snippets/settings.ts @@ -6,25 +6,18 @@ import { imageQuery } from "./media"; import { pageSeoQuery } from "./seo"; /** - * `internationalizedArrayRichText` field: array of { language, value: portable text }. - * Resolves link annotations like module text bodies. + * Portable Text `body[]` projection for plain `richText` fields (no embedded + * modules) — resolves `link` annotations. */ -function internationalizedRichTextArrayField(fieldName: string): string { - return `${fieldName}[]{ - _key, - _type, - language, - value[]{ +const richTextBody = `[]{ + ..., + markDefs[]{ ..., - markDefs[]{ - ..., - _type == "link" => { - ${linkQuery} - } + _type == "link" => { + ${linkQuery} } } }`; -} /** Main menu: links plus optional `nav.languageSwitch` blocks (order preserved). */ const navMainMenuQuery = `mainMenu[]{ @@ -54,27 +47,30 @@ export const navMenusQuery = ` "footerMenu": ${navFooterMenuQuery} `; -/** Document id: `siteNav` (see studio structure). */ -export const siteNavQuery = `*[_id == "siteNav"][0]{ +/** Document type: `siteNav` (one per language; see studio structure). */ +export const siteNavQuery = `*[_type == "siteNav" && language == $locale][0]{ _id, title, + language, ${navMenusQuery}, ${modulesQuery} }`; -/** Same resolved menus as `siteNavQuery` without `modules[]` (lighter layout fetch). */ /** + * Same resolved menus as `siteNavQuery` without `modules[]` (lighter layout fetch). + * * Not wrapped in `defineQuery`: Sanity Typegen cannot resolve the `link` * projection here and mis-types `mainMenu`/`footerMenu` as `null`. The * hand-written `SiteNavMenusDocument` (sanity/types/nav.ts) is authoritative. */ -export const siteNavMenusQuery = `*[_id == "siteNav"][0]{ +export const siteNavMenusQuery = `*[_type == "siteNav" && language == $locale][0]{ _id, title, + language, ${navMenusQuery} }`; -/** Document id: `siteLanguageSettings` — drives Next routes + `internationalizedArray` codegen in Studio. */ +/** Document id: `siteLanguageSettings` — the global locale registry. */ export const siteLanguageSettingsQuery = defineQuery(`*[_id == "siteLanguageSettings"][0]{ _id, @@ -84,40 +80,43 @@ export const siteLanguageSettingsQuery = /** Minimal fetch for `app/[locale]/layout.tsx` `generateMetadata` (tab title template). */ export const siteSettingsTitleQuery = defineQuery( - `*[_id == "siteSettings"][0]{title}`, + `*[_type == "siteSettings" && language == $locale][0]{title}`, ); /** Site-wide SEO fallback for route `generateMetadata` (deduped via `fetchSettingsSeoFallback`). */ export const siteSettingsSeoFallbackQuery = - defineQuery(`*[_id == "siteSettings"][0]{ + defineQuery(`*[_type == "siteSettings" && language == $locale][0]{ "title": seo.title, "description": seo.description, "imageUrl": seo.image.asset->url }`); -/** Document id: `siteSettings`. */ -export const siteSettingsQuery = `*[_id == "siteSettings"][0]{ +/** Document type: `siteSettings` (one per language). */ +export const siteSettingsQuery = `*[_type == "siteSettings" && language == $locale][0]{ _id, title, + language, "favicon": favicon${imageQuery}, ${modulesQuery}, ${pageSeoQuery} }`; -/** Document id: `errorSettings`. */ -export const errorSettingsQuery = `*[_id == "errorSettings"][0]{ +/** Document type: `errorSettings` (one per language). */ +export const errorSettingsQuery = `*[_type == "errorSettings" && language == $locale][0]{ _id, + language, notFoundTitle, - ${internationalizedRichTextArrayField("notFoundBody")}, + "notFoundBody": notFoundBody${richTextBody}, serverErrorTitle, - ${internationalizedRichTextArrayField("serverErrorBody")}, + "serverErrorBody": serverErrorBody${richTextBody}, ${modulesQuery} }`; -/** Document id: `siteCookieBanner`. */ -export const siteCookieBannerQuery = `*[_id == "siteCookieBanner"][0]{ +/** Document type: `siteCookieBanner` (one per language). */ +export const siteCookieBannerQuery = `*[_type == "siteCookieBanner" && language == $locale][0]{ _id, title, + language, useCookieBanner, consentModal, preferencesModal, @@ -126,7 +125,7 @@ export const siteCookieBannerQuery = `*[_id == "siteCookieBanner"][0]{ /** * Single fetch for app shell: settings, nav, errors, cookie banner. - * Document ids match Studio Documents: siteSettings, siteNav, errorSettings, siteCookieBanner. + * Document types match Studio Documents: siteSettings, siteNav, errorSettings, siteCookieBanner. * * NOTE: not consumed by any current route — provided as a convenience aggregate * (and the reason `siteSettingsQuery`, `siteNavQuery`, `siteCookieBannerQuery` diff --git a/web/sanity/queries/snippets/sitemap.ts b/web/sanity/queries/snippets/sitemap.ts index 4a98d279..5159d787 100644 --- a/web/sanity/queries/snippets/sitemap.ts +++ b/web/sanity/queries/snippets/sitemap.ts @@ -1,26 +1,33 @@ import { defineQuery } from "next-sanity"; /** - * Minimal list of routable pages — for `generateStaticParams` and simple slug lists. - * (Home lives at `/`, not under `[slug]`, so it is not included here.) + * Minimal list of routable page slugs across all languages — for + * `generateStaticParams` and simple slug lists. Home is excluded (it lives + * at `/` / `/:locale`, not under `[slug]`). */ export const pageSlugsQuery = defineQuery(`*[_type == "page" && defined(slug.current)]{ - "slug": slug.current + "slug": slug.current, + language }`); /** - * All public URL entries for a sitemap: slug-based `page` documents plus site-root - * singletons (e.g. `home`). Settings singletons are omitted — they are not public routes. + * All public URL entries for a sitemap across all languages: slug-based + * `page` documents plus site-root singletons (e.g. `home`). Settings + * singletons are omitted — they are not public routes. * - * - `path`: URL path from site root (`/` for home, `/{slug}` for pages). - * Extend the filter when you add new routable singletons (see Studio `SITE_ROOT_DOCUMENT_TYPES`). + * - `path`: URL path from site root (`/` for home, `/{slug}` for pages), + * without the locale prefix (the route layer adds it per `language`). + * - `language`: the document's locale (one row per language variant). + * + * Extend the filter when you add new routable singletons. */ export const sitemapPagesQuery = defineQuery(`*[_type == "home" || (_type == "page" && defined(slug.current))]{ _id, _type, _updatedAt, + language, "slug": select(_type == "home" => null, slug.current), "path": select(_type == "home" => "/", "/" + slug.current) }`); diff --git a/web/sanity/sanity.types.gen.ts b/web/sanity/sanity.types.gen.ts index 480e4181..d26185d5 100644 --- a/web/sanity/sanity.types.gen.ts +++ b/web/sanity/sanity.types.gen.ts @@ -26,83 +26,6 @@ export type VideoSettings = { controls?: boolean; }; -export type SiteCookieBanner = { - _id: string; - _type: "siteCookieBanner"; - _createdAt: string; - _updatedAt: string; - _rev: string; - title?: string; - useCookieBanner?: boolean; - consentModal?: { - description?: string; - acceptAllBtn?: string; - acceptNecessaryBtn?: string; - showPreferencesBtn?: string; - }; - preferencesModal?: { - title?: string; - acceptAllBtn?: string; - acceptNecessaryBtn?: string; - savePreferencesBtn?: string; - sections?: Code; - }; -}; - -export type Code = { - _type: "code"; - language?: string; - filename?: string; - code?: string; - highlightedLines?: Array; -}; - -export type ErrorSettings = { - _id: string; - _type: "errorSettings"; - _createdAt: string; - _updatedAt: string; - _rev: string; - notFoundTitle: InternationalizedArrayString; - notFoundBody?: InternationalizedArrayRichText; - serverErrorTitle: InternationalizedArrayString; - serverErrorBody?: InternationalizedArrayRichText; -}; - -export type InternationalizedArrayRichText = Array< - { - _key: string; - } & InternationalizedArrayRichTextValue ->; - -export type InternationalizedArrayString = Array< - { - _key: string; - } & InternationalizedArrayStringValue ->; - -export type SiteNav = { - _id: string; - _type: "siteNav"; - _createdAt: string; - _updatedAt: string; - _rev: string; - title?: string; - mainMenu?: Array< - | ({ - _key: string; - } & Link) - | ({ - _key: string; - } & NavLanguageSwitch) - >; - footerMenu?: Array< - { - _key: string; - } & Link - >; -}; - export type SiteLanguageSettings = { _id: string; _type: "siteLanguageSettings"; @@ -119,59 +42,6 @@ export type SiteLanguageSettings = { defaultLanguageId: string; }; -export type SanityImageAssetReference = { - _ref: string; - _type: "reference"; - _weak?: boolean; - [internalGroqTypeReferenceTo]?: "sanity.imageAsset"; -}; - -export type SiteSettings = { - _id: string; - _type: "siteSettings"; - _createdAt: string; - _updatedAt: string; - _rev: string; - title: string; - favicon?: { - asset?: SanityImageAssetReference; - media?: unknown; - hotspot?: SanityImageHotspot; - crop?: SanityImageCrop; - _type: "image"; - }; - seo?: SeoFallback; -}; - -export type SeoFallback = { - _type: "seo.fallback"; - title?: string; - description?: string; - image?: { - asset?: SanityImageAssetReference; - media?: unknown; - hotspot?: SanityImageHotspot; - crop?: SanityImageCrop; - _type: "image"; - }; -}; - -export type SanityImageCrop = { - _type: "sanity.imageCrop"; - top: number; - bottom: number; - left: number; - right: number; -}; - -export type SanityImageHotspot = { - _type: "sanity.imageHotspot"; - x: number; - y: number; - height: number; - width: number; -}; - export type RichTextMedia = Array< | { children?: Array<{ @@ -220,8 +90,8 @@ export type RichText = Array<{ export type ModuleText = { _type: "module.text"; - title: InternationalizedArrayString; - body?: InternationalizedArrayRichTextMedia; + title: string; + body?: RichTextMedia; }; export type HomeReference = { @@ -240,7 +110,7 @@ export type PageReference = { export type ModuleContentRefs = { _type: "module.contentRefs"; - heading?: InternationalizedArrayString; + heading?: string; allowMultiple?: boolean; reference?: HomeReference | PageReference; references?: ArrayOf; @@ -253,9 +123,16 @@ export type ModuleMedia = { videoContent?: MediaVideo; }; +export type SanityImageAssetReference = { + _ref: string; + _type: "reference"; + _weak?: boolean; + [internalGroqTypeReferenceTo]?: "sanity.imageAsset"; +}; + export type ModuleCarousel = { _type: "module.carousel"; - heading?: InternationalizedArrayString; + heading?: string; imagesOnly?: boolean; slides?: Array<{ asset?: SanityImageAssetReference; @@ -313,13 +190,204 @@ export type Link = { func?: LinkFunctions; }; +export type SeoFallback = { + _type: "seo.fallback"; + title?: string; + description?: string; + image?: { + asset?: SanityImageAssetReference; + media?: unknown; + hotspot?: SanityImageHotspot; + crop?: SanityImageCrop; + _type: "image"; + }; +}; + +export type SeoPage = { + _type: "seo.page"; + title?: string; + description?: string; + image?: { + asset?: SanityImageAssetReference; + media?: unknown; + hotspot?: SanityImageHotspot; + crop?: SanityImageCrop; + _type: "image"; + }; +}; + +export type LinkFunctions = { + _type: "linkFunctions"; + key: "scroll-to" | "open-modal"; + params?: string; +}; + +export type TranslationMetadata = { + _id: string; + _type: "translation.metadata"; + _createdAt: string; + _updatedAt: string; + _rev: string; + translations?: InternationalizedArrayReference; + schemaTypes?: Array; +}; + +export type InternationalizedArrayReference = Array< + { + _key: string; + } & InternationalizedArrayReferenceValue +>; + +export type ErrorSettingsReference = { + _ref: string; + _type: "reference"; + _weak?: boolean; + [internalGroqTypeReferenceTo]?: "errorSettings"; +}; + +export type SiteNavReference = { + _ref: string; + _type: "reference"; + _weak?: boolean; + [internalGroqTypeReferenceTo]?: "siteNav"; +}; + +export type SiteSettingsReference = { + _ref: string; + _type: "reference"; + _weak?: boolean; + [internalGroqTypeReferenceTo]?: "siteSettings"; +}; + +export type SiteCookieBannerReference = { + _ref: string; + _type: "reference"; + _weak?: boolean; + [internalGroqTypeReferenceTo]?: "siteCookieBanner"; +}; + +export type InternationalizedArrayReferenceValue = { + _type: "internationalizedArrayReferenceValue"; + value?: + | HomeReference + | PageReference + | ErrorSettingsReference + | SiteNavReference + | SiteSettingsReference + | SiteCookieBannerReference; + language: string; +}; + +export type SiteCookieBanner = { + _id: string; + _type: "siteCookieBanner"; + _createdAt: string; + _updatedAt: string; + _rev: string; + language?: string; + title?: string; + useCookieBanner?: boolean; + consentModal?: { + description?: string; + acceptAllBtn?: string; + acceptNecessaryBtn?: string; + showPreferencesBtn?: string; + }; + preferencesModal?: { + title?: string; + acceptAllBtn?: string; + acceptNecessaryBtn?: string; + savePreferencesBtn?: string; + sections?: Code; + }; +}; + +export type Code = { + _type: "code"; + language?: string; + filename?: string; + code?: string; + highlightedLines?: Array; +}; + +export type SiteSettings = { + _id: string; + _type: "siteSettings"; + _createdAt: string; + _updatedAt: string; + _rev: string; + language?: string; + title: string; + favicon?: { + asset?: SanityImageAssetReference; + media?: unknown; + hotspot?: SanityImageHotspot; + crop?: SanityImageCrop; + _type: "image"; + }; + seo?: SeoFallback; +}; + +export type SanityImageCrop = { + _type: "sanity.imageCrop"; + top: number; + bottom: number; + left: number; + right: number; +}; + +export type SanityImageHotspot = { + _type: "sanity.imageHotspot"; + x: number; + y: number; + height: number; + width: number; +}; + +export type SiteNav = { + _id: string; + _type: "siteNav"; + _createdAt: string; + _updatedAt: string; + _rev: string; + language?: string; + title?: string; + mainMenu?: Array< + | ({ + _key: string; + } & Link) + | ({ + _key: string; + } & NavLanguageSwitch) + >; + footerMenu?: Array< + { + _key: string; + } & Link + >; +}; + +export type ErrorSettings = { + _id: string; + _type: "errorSettings"; + _createdAt: string; + _updatedAt: string; + _rev: string; + language?: string; + notFoundTitle: string; + notFoundBody?: RichText; + serverErrorTitle: string; + serverErrorBody?: RichText; +}; + export type Page = { _id: string; _type: "page"; _createdAt: string; _updatedAt: string; _rev: string; - title: InternationalizedArrayString; + language?: string; + title: string; slug: Slug; modules?: Array< | ({ @@ -338,19 +406,6 @@ export type Page = { seo?: SeoPage; }; -export type SeoPage = { - _type: "seo.page"; - title?: string; - description?: string; - image?: { - asset?: SanityImageAssetReference; - media?: unknown; - hotspot?: SanityImageHotspot; - crop?: SanityImageCrop; - _type: "image"; - }; -}; - export type Slug = { _type: "slug"; current: string; @@ -363,7 +418,8 @@ export type Home = { _createdAt: string; _updatedAt: string; _rev: string; - title: InternationalizedArrayString; + language?: string; + title: string; modules?: Array< | ({ _key: string; @@ -381,36 +437,6 @@ export type Home = { seo?: SeoPage; }; -export type InternationalizedArrayRichTextMedia = Array< - { - _key: string; - } & InternationalizedArrayRichTextMediaValue ->; - -export type LinkFunctions = { - _type: "linkFunctions"; - key: "scroll-to" | "open-modal"; - params?: string; -}; - -export type InternationalizedArrayRichTextMediaValue = { - _type: "internationalizedArrayRichTextMediaValue"; - value?: RichTextMedia; - language: string; -}; - -export type InternationalizedArrayRichTextValue = { - _type: "internationalizedArrayRichTextValue"; - value?: RichText; - language: string; -}; - -export type InternationalizedArrayStringValue = { - _type: "internationalizedArrayStringValue"; - value?: string; - language: string; -}; - export type MuxVideoAssetReference = { _ref: string; _type: "reference"; @@ -622,18 +648,7 @@ export type Geopoint = { export type AllSanitySchemaTypes = | VideoSettings - | SiteCookieBanner - | Code - | ErrorSettings - | InternationalizedArrayRichText - | InternationalizedArrayString - | SiteNav | SiteLanguageSettings - | SanityImageAssetReference - | SiteSettings - | SeoFallback - | SanityImageCrop - | SanityImageHotspot | RichTextMedia | RichText | ModuleText @@ -641,20 +656,32 @@ export type AllSanitySchemaTypes = | PageReference | ModuleContentRefs | ModuleMedia + | SanityImageAssetReference | ModuleCarousel | MediaVideo | MediaImage | NavLanguageSwitch | Link - | Page + | SeoFallback | SeoPage + | LinkFunctions + | TranslationMetadata + | InternationalizedArrayReference + | ErrorSettingsReference + | SiteNavReference + | SiteSettingsReference + | SiteCookieBannerReference + | InternationalizedArrayReferenceValue + | SiteCookieBanner + | Code + | SiteSettings + | SanityImageCrop + | SanityImageHotspot + | SiteNav + | ErrorSettings + | Page | Slug | Home - | InternationalizedArrayRichTextMedia - | LinkFunctions - | InternationalizedArrayRichTextMediaValue - | InternationalizedArrayRichTextValue - | InternationalizedArrayStringValue | MuxVideoAssetReference | MuxVideo | MuxVideoAsset @@ -673,6 +700,2073 @@ export type AllSanitySchemaTypes = | SanityImageAsset | Geopoint; +// Source: sanity/queries/pages/home.ts +// Variable: homeQuery +// Query: *[_type == "home" && language == $locale][0]{ _id, title, language, modules[]{ _key, _type, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } } } } } }}, _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } )}, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } )}, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }}}, seo { title, description, "imageUrl": image.asset->url}} +export type HomeQueryResult = { + _id: string; + title: string; + language: string | null; + modules: Array< + | { + _key: string; + _type: "module.carousel"; + heading: string | null; + imagesOnly: boolean | null; + slides: Array<{ + _key: string; + _type: "image"; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + }; + }> | null; + slidesMedia: Array<{ + _key: string; + _type: "module.media"; + type: "image" | "video"; + imageContent: { + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + videoContent: { + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + }> | null; + resolvedSlides: + | Array<{ + _key: string; + _type: "image"; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + }; + }> + | Array<{ + _key: string; + _type: "module.media"; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + }> + | null; + } + | { + _key: string; + _type: "module.contentRefs"; + heading: string | null; + allowMultiple: boolean | null; + reference: + | { + _id: string; + _type: "home"; + title: string; + slug: null; + route: "index"; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + route: "slug"; + } + | null; + references: Array< + | { + _id: string; + _type: "home"; + title: string; + slug: null; + route: "index"; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + route: "slug"; + } + > | null; + } + | { + _key: string; + _type: "module.media"; + type: "image" | "video"; + imageContent: { + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + videoContent: { + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + } + | { + _key: string; + _type: "module.text"; + title: string; + body: Array< + | { + children?: Array<{ + marks?: Array; + text?: string; + _type: "span"; + _key: string; + }>; + style?: "h2" | "h3" | "h4" | "normal"; + listItem?: "bullet" | "number"; + markDefs: Array< + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title: string | null; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func?: LinkFunctions; + linkType: "linkInternal"; + route: "page" | "slug"; + slug: string | null; + resolvedReference: + | { + _id: string; + _type: "home"; + title: string; + slug: null; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + } + | null; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title?: string; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func?: LinkFunctions; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title?: string; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func: { + key: "open-modal" | "scroll-to"; + params: string | null; + } | null; + linkType: "linkFunction"; + route: "page" | "slug"; + slug: string | null; + resolvedReference: + | { + _id: string; + _type: "home"; + title: string; + slug: null; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + } + | null; + href: string | null; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title?: string; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func: { + key: "open-modal" | "scroll-to"; + params: string | null; + } | null; + linkType: "linkFunction"; + href: string | null; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title?: string; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func: { + key: "open-modal" | "scroll-to"; + params: string | null; + } | null; + linkType: "linkFunction"; + route: "page" | "slug"; + slug: string | null; + resolvedReference: + | { + _id: string; + _type: "home"; + title: string; + slug: null; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + } + | null; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title?: string; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func: { + key: "open-modal" | "scroll-to"; + params: string | null; + } | null; + linkType: "linkFunction"; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title: string | null; + reference?: HomeReference | PageReference; + url?: string; + blank: boolean | null; + func?: LinkFunctions; + linkType: "linkExternal"; + route: "page" | "slug"; + slug: string | null; + resolvedReference: + | { + _id: string; + _type: "home"; + title: string; + slug: null; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + } + | null; + href: string | null; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title: string | null; + reference?: HomeReference | PageReference; + url?: string; + blank: boolean | null; + func?: LinkFunctions; + linkType: "linkExternal"; + href: string | null; + } + > | null; + level?: number; + _type: "block"; + _key: string; + } + | { + _key: string; + _type: "module.carousel"; + heading: string | null; + imagesOnly: boolean | null; + slides: Array<{ + _key: string; + _type: "image"; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + }; + }> | null; + slidesMedia: Array<{ + _key: string; + _type: "module.media"; + type: "image" | "video"; + imageContent: { + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + videoContent: { + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + }> | null; + resolvedSlides: + | Array<{ + _key: string; + _type: "image"; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + }; + }> + | Array<{ + _key: string; + _type: "module.media"; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + }> + | null; + } + | { + _key: string; + _type: "module.media"; + type: "image" | "video"; + imageContent: { + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + videoContent: { + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + } + > | null; + } + > | null; + seo: { + title: string | null; + description: string | null; + imageUrl: string | null; + } | null; +} | null; + +// Source: sanity/queries/pages/page.ts +// Variable: pageBySlugQuery +// Query: *[_type == "page" && slug.current == $slug && language == $locale][0]{ _id, title, slug, language, modules[]{ _key, _type, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } } } } } }}, _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } )}, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } )}, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }}}, seo { title, description, "imageUrl": image.asset->url}} +export type PageBySlugQueryResult = { + _id: string; + title: string; + slug: Slug; + language: string | null; + modules: Array< + | { + _key: string; + _type: "module.carousel"; + heading: string | null; + imagesOnly: boolean | null; + slides: Array<{ + _key: string; + _type: "image"; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + }; + }> | null; + slidesMedia: Array<{ + _key: string; + _type: "module.media"; + type: "image" | "video"; + imageContent: { + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + videoContent: { + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + }> | null; + resolvedSlides: + | Array<{ + _key: string; + _type: "image"; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + }; + }> + | Array<{ + _key: string; + _type: "module.media"; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + }> + | null; + } + | { + _key: string; + _type: "module.contentRefs"; + heading: string | null; + allowMultiple: boolean | null; + reference: + | { + _id: string; + _type: "home"; + title: string; + slug: null; + route: "index"; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + route: "slug"; + } + | null; + references: Array< + | { + _id: string; + _type: "home"; + title: string; + slug: null; + route: "index"; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + route: "slug"; + } + > | null; + } + | { + _key: string; + _type: "module.media"; + type: "image" | "video"; + imageContent: { + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + videoContent: { + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + } + | { + _key: string; + _type: "module.text"; + title: string; + body: Array< + | { + children?: Array<{ + marks?: Array; + text?: string; + _type: "span"; + _key: string; + }>; + style?: "h2" | "h3" | "h4" | "normal"; + listItem?: "bullet" | "number"; + markDefs: Array< + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title: string | null; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func?: LinkFunctions; + linkType: "linkInternal"; + route: "page" | "slug"; + slug: string | null; + resolvedReference: + | { + _id: string; + _type: "home"; + title: string; + slug: null; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + } + | null; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title?: string; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func?: LinkFunctions; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title?: string; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func: { + key: "open-modal" | "scroll-to"; + params: string | null; + } | null; + linkType: "linkFunction"; + route: "page" | "slug"; + slug: string | null; + resolvedReference: + | { + _id: string; + _type: "home"; + title: string; + slug: null; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + } + | null; + href: string | null; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title?: string; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func: { + key: "open-modal" | "scroll-to"; + params: string | null; + } | null; + linkType: "linkFunction"; + href: string | null; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title?: string; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func: { + key: "open-modal" | "scroll-to"; + params: string | null; + } | null; + linkType: "linkFunction"; + route: "page" | "slug"; + slug: string | null; + resolvedReference: + | { + _id: string; + _type: "home"; + title: string; + slug: null; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + } + | null; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title?: string; + reference?: HomeReference | PageReference; + url?: string; + blank?: boolean; + func: { + key: "open-modal" | "scroll-to"; + params: string | null; + } | null; + linkType: "linkFunction"; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title: string | null; + reference?: HomeReference | PageReference; + url?: string; + blank: boolean | null; + func?: LinkFunctions; + linkType: "linkExternal"; + route: "page" | "slug"; + slug: string | null; + resolvedReference: + | { + _id: string; + _type: "home"; + title: string; + slug: null; + } + | { + _id: string; + _type: "page"; + title: string; + slug: string; + } + | null; + href: string | null; + } + | { + _key: string; + _type: "link"; + type: "external" | "function" | "internal"; + title: string | null; + reference?: HomeReference | PageReference; + url?: string; + blank: boolean | null; + func?: LinkFunctions; + linkType: "linkExternal"; + href: string | null; + } + > | null; + level?: number; + _type: "block"; + _key: string; + } + | { + _key: string; + _type: "module.carousel"; + heading: string | null; + imagesOnly: boolean | null; + slides: Array<{ + _key: string; + _type: "image"; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + }; + }> | null; + slidesMedia: Array<{ + _key: string; + _type: "module.media"; + type: "image" | "video"; + imageContent: { + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + videoContent: { + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + }> | null; + resolvedSlides: + | Array<{ + _key: string; + _type: "image"; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + }; + }> + | Array<{ + _key: string; + _type: "module.media"; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + }> + | null; + } + | { + _key: string; + _type: "module.media"; + type: "image" | "video"; + imageContent: { + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + videoContent: { + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } | null; + resolvedMedia: + | { + kind: "image"; + caption: string | null; + media: { + kind: "image"; + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + } + | { + kind: "video"; + caption: string | null; + videoSettings: VideoSettings | null; + media: + | { + kind: "image"; + crop: null; + hotspot: null; + alt: null; + asset: { + _id: string; + url: null; + metadata: null; + } | null; + } + | { + kind: "video"; + playbackId: string | null; + duration: number | null; + asset: { + playbackId: string | null; + data: MuxAssetData | null; + } | null; + } + | null; + poster: { + crop: SanityImageCrop | null; + hotspot: SanityImageHotspot | null; + alt: string | null; + asset: { + _id: string; + url: string; + metadata: { + dimensions: { + width: number; + height: number; + aspectRatio: number; + } | null; + lqip: string | null; + } | null; + } | null; + } | null; + }; + } + > | null; + } + > | null; + seo: { + title: string | null; + description: string | null; + imageUrl: string | null; + } | null; +} | null; + // Source: sanity/queries/snippets/settings.ts // Variable: siteLanguageSettingsQuery // Query: *[_id == "siteLanguageSettings"][0]{ _id, availableLanguages[]{id, title}, defaultLanguageId} @@ -694,53 +2788,37 @@ export type SiteLanguageSettingsQueryResult = // Source: sanity/queries/snippets/settings.ts // Variable: siteSettingsTitleQuery -// Query: *[_id == "siteSettings"][0]{title} -export type SiteSettingsTitleQueryResult = - | { - title: InternationalizedArrayString; - } - | { - title: null; - } - | { - title: string; - } - | { - title: string | null; - } - | null; +// Query: *[_type == "siteSettings" && language == $locale][0]{title} +export type SiteSettingsTitleQueryResult = { + title: string; +} | null; // Source: sanity/queries/snippets/settings.ts // Variable: siteSettingsSeoFallbackQuery -// Query: *[_id == "siteSettings"][0]{ "title": seo.title, "description": seo.description, "imageUrl": seo.image.asset->url} -export type SiteSettingsSeoFallbackQueryResult = - | { - title: null; - description: null; - imageUrl: null; - } - | { - title: string | null; - description: string | null; - imageUrl: string | null; - } - | null; +// Query: *[_type == "siteSettings" && language == $locale][0]{ "title": seo.title, "description": seo.description, "imageUrl": seo.image.asset->url} +export type SiteSettingsSeoFallbackQueryResult = { + title: string | null; + description: string | null; + imageUrl: string | null; +} | null; // Source: sanity/queries/snippets/sitemap.ts // Variable: pageSlugsQuery -// Query: *[_type == "page" && defined(slug.current)]{ "slug": slug.current} +// Query: *[_type == "page" && defined(slug.current)]{ "slug": slug.current, language} export type PageSlugsQueryResult = Array<{ slug: string; + language: string | null; }>; // Source: sanity/queries/snippets/sitemap.ts // Variable: sitemapPagesQuery -// Query: *[_type == "home" || (_type == "page" && defined(slug.current))]{ _id, _type, _updatedAt, "slug": select(_type == "home" => null, slug.current), "path": select(_type == "home" => "/", "/" + slug.current)} +// Query: *[_type == "home" || (_type == "page" && defined(slug.current))]{ _id, _type, _updatedAt, language, "slug": select(_type == "home" => null, slug.current), "path": select(_type == "home" => "/", "/" + slug.current)} export type SitemapPagesQueryResult = Array< | { _id: string; _type: "home"; _updatedAt: string; + language: string | null; slug: null; path: "/"; } @@ -748,6 +2826,7 @@ export type SitemapPagesQueryResult = Array< _id: string; _type: "page"; _updatedAt: string; + language: string | null; slug: string; path: string; } @@ -757,10 +2836,12 @@ export type SitemapPagesQueryResult = Array< import "@sanity/client"; declare module "@sanity/client" { interface SanityQueries { + '*[_type == "home" && language == $locale][0]{\n _id,\n title,\n language,\n modules[]{\n _key,\n _type,\n _type == "module.text" => {\n title,\n body[]{\n \n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n \n }\n }\n }\n }\n \n }\n},\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n},\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n},\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n}\n},\n seo {\n title,\n description,\n "imageUrl": image.asset->url\n}\n}': HomeQueryResult; + '*[_type == "page" && slug.current == $slug && language == $locale][0]{\n _id,\n title,\n slug,\n language,\n modules[]{\n _key,\n _type,\n _type == "module.text" => {\n title,\n body[]{\n \n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n \n }\n }\n }\n }\n \n }\n},\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n},\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n},\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n}\n},\n seo {\n title,\n description,\n "imageUrl": image.asset->url\n}\n}': PageBySlugQueryResult; '*[_id == "siteLanguageSettings"][0]{\n _id,\n availableLanguages[]{id, title},\n defaultLanguageId\n}': SiteLanguageSettingsQueryResult; - '*[_id == "siteSettings"][0]{title}': SiteSettingsTitleQueryResult; - '*[_id == "siteSettings"][0]{\n "title": seo.title,\n "description": seo.description,\n "imageUrl": seo.image.asset->url\n}': SiteSettingsSeoFallbackQueryResult; - '*[_type == "page" && defined(slug.current)]{\n "slug": slug.current\n}': PageSlugsQueryResult; - '*[_type == "home" || (_type == "page" && defined(slug.current))]{\n _id,\n _type,\n _updatedAt,\n "slug": select(_type == "home" => null, slug.current),\n "path": select(_type == "home" => "/", "/" + slug.current)\n}': SitemapPagesQueryResult; + '*[_type == "siteSettings" && language == $locale][0]{title}': SiteSettingsTitleQueryResult; + '*[_type == "siteSettings" && language == $locale][0]{\n "title": seo.title,\n "description": seo.description,\n "imageUrl": seo.image.asset->url\n}': SiteSettingsSeoFallbackQueryResult; + '*[_type == "page" && defined(slug.current)]{\n "slug": slug.current,\n language\n}': PageSlugsQueryResult; + '*[_type == "home" || (_type == "page" && defined(slug.current))]{\n _id,\n _type,\n _updatedAt,\n language,\n "slug": select(_type == "home" => null, slug.current),\n "path": select(_type == "home" => "/", "/" + slug.current)\n}': SitemapPagesQueryResult; } } diff --git a/web/sanity/seo/resolveSanityMetadata.ts b/web/sanity/seo/resolveSanityMetadata.ts index 1a8e589d..60c36236 100644 --- a/web/sanity/seo/resolveSanityMetadata.ts +++ b/web/sanity/seo/resolveSanityMetadata.ts @@ -4,7 +4,6 @@ import type { SiteLocaleConfig } from "@/src/i18n/fallbackSiteLocales"; import { createLanguagePathUtils } from "@/src/i18n/siteLocalePathUtils"; import type { HomeDocument, PageDocument, PageSeo } from "../types/pages"; -import { pickLocalizedString } from "../utils/sanityLocalizedText"; const SITE_BASE_URL = ( process.env.NEXT_PUBLIC_SITE_URL || "https://example.com" @@ -142,9 +141,10 @@ export type MetadataFromSanityPageDataInput = { }; /** - * `pickLocalizedString` + `resolveSanityMetadata` for route documents (home singleton or - * `page` by slug). Pass `settingsSeo` from `fetchSettingsSeoFallback` so empty page-level - * SEO falls back to site settings. + * `resolveSanityMetadata` for route documents (home singleton or `page` by slug). + * Pass `settingsSeo` from `fetchSettingsSeoFallback` so empty page-level SEO + * falls back to site settings. Under document-level translation `data.title` is + * already in the active locale (no resolver needed). */ export function metadataFromSanityPageData({ data, @@ -155,7 +155,7 @@ export function metadataFromSanityPageData({ path, siteBrandTitle, }: MetadataFromSanityPageDataInput): Metadata { - const heading = pickLocalizedString(data.title, locale, siteLocale); + const heading = data.title?.trim() ?? ""; const brand = typeof siteBrandTitle === "string" ? siteBrandTitle.trim() : ""; return resolveSanityMetadata({ seo: data.seo, diff --git a/web/sanity/types/errorSettings.ts b/web/sanity/types/errorSettings.ts index 7d02fd45..9b6cf5af 100644 --- a/web/sanity/types/errorSettings.ts +++ b/web/sanity/types/errorSettings.ts @@ -1,11 +1,13 @@ -import type { IntlRichTextEntry, IntlStringEntry } from "../utils"; +import type { PortableTextBlock } from "@portabletext/types"; + import type { ContentModule } from "./modules"; export type ErrorSettingsDocument = { _id: string; - notFoundTitle?: IntlStringEntry[] | null; - notFoundBody?: IntlRichTextEntry[] | null; - serverErrorTitle?: IntlStringEntry[] | null; - serverErrorBody?: IntlRichTextEntry[] | null; + language?: string | null; + notFoundTitle?: string | null; + notFoundBody?: PortableTextBlock[] | null; + serverErrorTitle?: string | null; + serverErrorBody?: PortableTextBlock[] | null; modules?: ContentModule[] | null; }; diff --git a/web/sanity/types/modules/carousel.ts b/web/sanity/types/modules/carousel.ts index 7acdfb83..79559b8c 100644 --- a/web/sanity/types/modules/carousel.ts +++ b/web/sanity/types/modules/carousel.ts @@ -1,10 +1,9 @@ -import type { IntlStringEntry } from "@/sanity/utils"; import type { ModuleMediaData, ResolvedMediaPayload } from "./media"; export type ModuleCarouselData = { _type: "module.carousel"; _key?: string; - heading?: IntlStringEntry[] | null; + heading?: string | null; imagesOnly?: boolean | null; /** Image slides: each slide has `media` from `mediaQuery` (kind + payload). */ slides?: Array<{ diff --git a/web/sanity/types/modules/contentRefs.ts b/web/sanity/types/modules/contentRefs.ts index c306d8c2..5d848b1f 100644 --- a/web/sanity/types/modules/contentRefs.ts +++ b/web/sanity/types/modules/contentRefs.ts @@ -1,16 +1,14 @@ -import type { IntlStringEntry } from "@/sanity/utils"; - export type ModuleContentRefTarget = { _id?: string; _type?: "home" | "page" | string; - title?: IntlStringEntry[] | null; + title?: string | null; slug?: string | null; } | null; export type ModuleContentRefsData = { _type: "module.contentRefs"; _key?: string; - heading?: IntlStringEntry[] | null; + heading?: string | null; allowMultiple?: boolean | null; reference?: ModuleContentRefTarget; references?: Array | null; diff --git a/web/sanity/types/modules/text.ts b/web/sanity/types/modules/text.ts index 002ba070..e1077b4e 100644 --- a/web/sanity/types/modules/text.ts +++ b/web/sanity/types/modules/text.ts @@ -1,8 +1,8 @@ -import type { IntlRichTextEntry, IntlStringEntry } from "@/sanity/utils"; +import type { PortableTextBlock } from "@portabletext/types"; export type ModuleTextData = { _type: "module.text"; _key?: string; - title?: IntlStringEntry[] | null; - body?: IntlRichTextEntry[] | null; + title?: string | null; + body?: PortableTextBlock[] | null; }; diff --git a/web/sanity/types/pages.ts b/web/sanity/types/pages.ts index dbcb277b..b4511fdd 100644 --- a/web/sanity/types/pages.ts +++ b/web/sanity/types/pages.ts @@ -3,7 +3,6 @@ * Import in app route pages next to fetchHomeDocument / fetchPageBySlug (`fetchSanityData`) — * types live here; fetches stay deduped per request via React cache. */ -import type { IntlStringEntry } from "../utils"; import type { ContentModule } from "./modules"; /** Resolved seo / seo.fallback projection from GROQ (snippets/seo.ts). */ @@ -15,14 +14,16 @@ export type PageSeo = { export type HomeDocument = { _id: string; - title?: IntlStringEntry[] | null; + language?: string | null; + title?: string | null; modules?: ContentModule[] | null; seo?: PageSeo; }; export type PageDocument = { _id: string; - title?: IntlStringEntry[] | null; + language?: string | null; + title?: string | null; slug?: { current?: string | null } | null; modules?: ContentModule[] | null; seo?: PageSeo; diff --git a/web/sanity/utils/index.ts b/web/sanity/utils/index.ts index fe3f4694..94e77e61 100644 --- a/web/sanity/utils/index.ts +++ b/web/sanity/utils/index.ts @@ -1,11 +1,2 @@ export * from "./sanityImageBuilder"; -export { - type IntlRichTextEntry, - type IntlStringEntry, - type IntlTextEntry, - parseLocalizedText, - pickLocalizedPortableTextBlocks, - pickLocalizedString, - resolveLocalizedPortableTextDeep, -} from "./sanityLocalizedText"; export * from "./sanityModuleLabel"; diff --git a/web/sanity/utils/sanityLocalizedText.ts b/web/sanity/utils/sanityLocalizedText.ts deleted file mode 100644 index b47b4503..00000000 --- a/web/sanity/utils/sanityLocalizedText.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Resolves `sanity-plugin-internationalized-array` fields for a requested locale. - * Fallback order: exact language tag → base tag → other configured site locales → any entry with content. - * Pass `siteLocale` from `fetchSiteLanguageSettings()` when resolving; otherwise built-in fallback (en/de) is used. - */ -import type { PortableTextBlock } from "@portabletext/types"; - -import { - FALLBACK_SITE_LOCALE_CONFIG, - type SiteLocaleConfig, -} from "@/src/i18n/fallbackSiteLocales"; - -type SiteLocaleSlice = Pick; - -function effectiveSiteLocale( - siteLocale?: SiteLocaleSlice | null, -): SiteLocaleSlice { - if ( - siteLocale && - siteLocale.localeIds.length > 0 && - siteLocale.defaultLocale.trim() - ) { - return siteLocale; - } - return { - localeIds: FALLBACK_SITE_LOCALE_CONFIG.localeIds, - defaultLocale: FALLBACK_SITE_LOCALE_CONFIG.defaultLocale, - }; -} - -type LocalizedEntryValue = string | PortableTextBlock[] | null | undefined; - -type LocalizedEntryBase = { - _key?: string; - language?: string; - value?: TValue; -}; - -export type IntlStringEntry = LocalizedEntryBase; -export type IntlRichTextEntry = LocalizedEntryBase; -export type IntlTextEntry = LocalizedEntryBase; - -function getLocaleCandidates(locale: string): string[] { - const normalized = locale.trim(); - if (!normalized) { - return []; - } - - const base = normalized.split("-")[0]; - if (base && base !== normalized) { - return [normalized, base]; - } - return [normalized]; -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function isNonEmptyPortableText(value: unknown): value is PortableTextBlock[] { - return Array.isArray(value) && value.length > 0; -} - -function hasUsableValue(value: LocalizedEntryValue): boolean { - return isNonEmptyString(value) || isNonEmptyPortableText(value); -} - -function pickPreferredEntry( - entries: IntlTextEntry[], - localeCandidates: string[], -): IntlTextEntry | undefined { - for (const candidateTag of localeCandidates) { - const matched = entries.find( - (entry) => - (entry.language === candidateTag || entry._key === candidateTag) && - hasUsableValue(entry.value), - ); - if (matched) { - return matched; - } - } - return undefined; -} - -/** Any entry with content (last resort if no configured locales match). */ -function pickFallbackEntry( - entries: IntlTextEntry[], -): IntlTextEntry | undefined { - return entries.find((entry) => hasUsableValue(entry.value)); -} - -function getLocaleFallbackChain( - locale: string, - site: SiteLocaleSlice, -): string[] { - const normalized = locale.trim(); - const base = normalized.split("-")[0] || site.defaultLocale; - const chain: string[] = []; - if (normalized && normalized !== base) { - chain.push(normalized); - } - if (!chain.includes(base)) { - chain.push(base); - } - for (const siteLocaleId of site.localeIds) { - if (siteLocaleId !== base && !chain.includes(siteLocaleId)) { - chain.push(siteLocaleId); - } - } - return chain; -} - -function coerceResolvedValue( - value: LocalizedEntryValue, -): string | PortableTextBlock[] | undefined { - if (isNonEmptyString(value)) { - return value.trim(); - } - if (isNonEmptyPortableText(value)) { - return value; - } - return undefined; -} - -function resolveLocalizedEntries( - entries: IntlTextEntry[] | null | undefined, - locale: string, - site: SiteLocaleSlice, -): string | PortableTextBlock[] | undefined { - if (!Array.isArray(entries) || entries.length === 0) { - return undefined; - } - - for (const localeSegment of getLocaleFallbackChain(locale, site)) { - const candidates = getLocaleCandidates(localeSegment); - const preferred = pickPreferredEntry(entries, candidates); - if (preferred) { - return coerceResolvedValue(preferred.value); - } - } - - const fallback = pickFallbackEntry(entries); - return coerceResolvedValue(fallback?.value); -} - -/** - * Detects `sanity-plugin-internationalized-array` entries (`language` / `_key` + `value`). - * Do not confuse with arbitrary object arrays: each element must have `value`. - */ -function looksLikeIntlEntryArray(value: unknown): value is IntlTextEntry[] { - if (!Array.isArray(value) || value.length === 0) { - return false; - } - return value.every( - (item) => - item != null && - typeof item === "object" && - "value" in item && - ("language" in item || "_key" in item), - ); -} - -function deepResolveLocalizedTree( - value: unknown, - locale: string, - site: SiteLocaleSlice, -): unknown { - if (value == null) { - return value; - } - - if (looksLikeIntlEntryArray(value)) { - const resolved = resolveLocalizedEntries(value, locale, site); - if (resolved === undefined) { - return undefined; - } - if (typeof resolved === "string") { - return resolved; - } - if (Array.isArray(resolved)) { - return resolved.map((item) => - deepResolveLocalizedTree(item, locale, site), - ); - } - return resolved; - } - - if (Array.isArray(value)) { - return value.map((item) => deepResolveLocalizedTree(item, locale, site)); - } - - if (typeof value === "object") { - const out: Record = {}; - for (const [propertyKey, propertyValue] of Object.entries( - value as object, - )) { - out[propertyKey] = deepResolveLocalizedTree(propertyValue, locale, site); - } - return out; - } - - return value; -} - -/** - * Picks the locale for `internationalizedArrayRichText*` and resolves all embedded i18n - * fields in blocks, mark defs, and modules. - */ -export function resolveLocalizedPortableTextDeep( - entries: IntlRichTextEntry[] | null | undefined, - locale: string, - siteLocale?: SiteLocaleSlice | null, -): PortableTextBlock[] { - const site = effectiveSiteLocale(siteLocale); - const raw = resolveLocalizedEntries(entries, locale, site); - if (!Array.isArray(raw) || raw.length === 0) { - return []; - } - return deepResolveLocalizedTree(raw, locale, site) as PortableTextBlock[]; -} - -export type ParseLocalizedTextOptions = { - /** `internationalizedArray*` field value from Sanity */ - entries: IntlTextEntry[] | null | undefined; - locale?: string; - /** - * - `auto` (default): string or Portable Text blocks, depending on the field - * - `string`: only plain string (rich text resolves to `undefined`) - * - `blocks`: only blocks (plain string resolves to `[]`) - */ - as?: "auto" | "string" | "blocks"; - /** From `fetchSiteLanguageSettings()` — drives fallback order. */ - siteLocale?: SiteLocaleSlice | null; -}; - -export function parseLocalizedText( - options: Omit & { as?: "auto" }, -): string | PortableTextBlock[] | undefined; -export function parseLocalizedText( - options: ParseLocalizedTextOptions & { as: "string" }, -): string | undefined; -export function parseLocalizedText( - options: ParseLocalizedTextOptions & { as: "blocks" }, -): PortableTextBlock[]; -export function parseLocalizedText({ - entries, - locale, - as = "auto", - siteLocale, -}: ParseLocalizedTextOptions): - | string - | PortableTextBlock[] - | undefined - | PortableTextBlock[] { - const site = effectiveSiteLocale(siteLocale); - const resolvedLocale = - locale?.trim() || - site.defaultLocale || - FALLBACK_SITE_LOCALE_CONFIG.defaultLocale; - const raw = resolveLocalizedEntries(entries, resolvedLocale, site); - - if (as === "string") { - return typeof raw === "string" ? raw : undefined; - } - - if (as === "blocks") { - if (!Array.isArray(raw) || raw.length === 0) { - return []; - } - return deepResolveLocalizedTree( - raw, - resolvedLocale, - site, - ) as PortableTextBlock[]; - } - - if (typeof raw === "string") { - return raw; - } - if (Array.isArray(raw) && raw.length > 0) { - return deepResolveLocalizedTree( - raw, - resolvedLocale, - site, - ) as PortableTextBlock[]; - } - return raw; -} - -/** Convenience for i18n string fields (`internationalizedArrayString`). */ -export function pickLocalizedString( - entries: IntlStringEntry[] | null | undefined, - locale?: string, - siteLocale?: SiteLocaleSlice | null, -): string | undefined { - const site = effectiveSiteLocale(siteLocale); - const loc = - locale?.trim() || - site.defaultLocale || - FALLBACK_SITE_LOCALE_CONFIG.defaultLocale; - return parseLocalizedText({ entries, locale: loc, as: "string", siteLocale }); -} - -/** Convenience for `internationalizedArrayRichText*` / `richTextMedia` bodies. */ -export function pickLocalizedPortableTextBlocks( - entries: IntlRichTextEntry[] | null | undefined, - locale: string, - siteLocale?: SiteLocaleSlice | null, -): PortableTextBlock[] { - return resolveLocalizedPortableTextDeep(entries, locale, siteLocale); -} diff --git a/web/src/app/[locale]/LocaleNotFoundContent.tsx b/web/src/app/[locale]/LocaleNotFoundContent.tsx index 18fb559a..2d796316 100644 --- a/web/src/app/[locale]/LocaleNotFoundContent.tsx +++ b/web/src/app/[locale]/LocaleNotFoundContent.tsx @@ -4,10 +4,6 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import type { ErrorSettingsDocument } from "@/sanity/types/errorSettings"; -import { - pickLocalizedPortableTextBlocks, - pickLocalizedString, -} from "@/sanity/utils/sanityLocalizedText"; import { RichTextMedia } from "@/src/components/text/RichTextMedia"; import { useLanguage } from "@/src/contexts/LanguageContext"; @@ -17,23 +13,17 @@ type Props = { export function LocaleNotFoundContent({ errorSettings }: Props) { const pathname = usePathname() ?? "/"; - const { localeFromPathname, siteLocale, localePath } = useLanguage(); + const { localeFromPathname, localePath } = useLanguage(); const locale = localeFromPathname(pathname); - const title = - pickLocalizedString(errorSettings?.notFoundTitle, locale, siteLocale) ?? - "Page not found"; - const body = pickLocalizedPortableTextBlocks( - errorSettings?.notFoundBody, - locale, - siteLocale, - ); + const title = errorSettings?.notFoundTitle?.trim() || "Page not found"; + const body = errorSettings?.notFoundBody ?? []; return (

{title}

- {body?.length ? ( + {body.length ? ( ) : (

diff --git a/web/src/app/[locale]/[slug]/page.tsx b/web/src/app/[locale]/[slug]/page.tsx index 0fb00829..6cbeb78b 100644 --- a/web/src/app/[locale]/[slug]/page.tsx +++ b/web/src/app/[locale]/[slug]/page.tsx @@ -22,25 +22,16 @@ type PageProps = { export const revalidate = 60 satisfies SanityDocumentCacheRevalidateSeconds; export async function generateStaticParams() { - const [rows, siteLocale] = await Promise.all([ - cachedPageSlugs(), - fetchSiteLanguageSettings({ stega: false }), - ]); - const list = rows ?? []; - const slugs = list - .map((row) => row.slug) + const rows = await cachedPageSlugs(); + return (rows ?? []) .filter( - (s: string | undefined): s is string => - typeof s === "string" && s.length > 0, - ); - - const out: { locale: string; slug: string }[] = []; - for (const locale of siteLocale.localeIds) { - for (const slug of slugs) { - out.push({ locale, slug }); - } - } - return out; + (row): row is { slug: string; language: string } => + typeof row.slug === "string" && + row.slug.length > 0 && + typeof row.language === "string" && + row.language.length > 0, + ) + .map((row) => ({ locale: row.language, slug: row.slug })); } export async function generateMetadata({ @@ -48,10 +39,10 @@ export async function generateMetadata({ }: PageProps): Promise { const { slug, locale } = await params; const [data, siteLocale, siteBrand, settingsSeo] = await Promise.all([ - fetchPageBySlug(slug, { stega: false }), + fetchPageBySlug(slug, locale, { stega: false }), fetchSiteLanguageSettings({ stega: false }), - fetchSiteSettingsTitle({ stega: false }), - fetchSettingsSeoFallback({ stega: false }), + fetchSiteSettingsTitle(locale, { stega: false }), + fetchSettingsSeoFallback(locale, { stega: false }), ]); if (!data) { return { @@ -73,10 +64,7 @@ export async function generateMetadata({ export default async function Page({ params }: PageProps) { const { slug, locale } = await params; - const [data, siteLocale] = await Promise.all([ - fetchPageBySlug(slug), - fetchSiteLanguageSettings(), - ]); + const data = await fetchPageBySlug(slug, locale); if (!data) { notFound(); @@ -88,11 +76,7 @@ export default async function Page({ params }: PageProps) { {data.modules?.length ? (

Modules

- +
) : null}
diff --git a/web/src/app/[locale]/layout.tsx b/web/src/app/[locale]/layout.tsx index e2bef118..5e00c19e 100644 --- a/web/src/app/[locale]/layout.tsx +++ b/web/src/app/[locale]/layout.tsx @@ -22,14 +22,12 @@ export const dynamicParams = true; export async function generateMetadata({ params }: Props): Promise { const { locale: raw } = await params; - const [siteLocale, siteTitle] = await Promise.all([ - fetchSiteLanguageSettings({ stega: false }), - fetchSiteSettingsTitle({ stega: false }), - ]); + const siteLocale = await fetchSiteLanguageSettings({ stega: false }); const pathUtils = createLanguagePathUtils(siteLocale); if (!pathUtils.isAppLocale(raw)) { notFound(); } + const siteTitle = await fetchSiteSettingsTitle(raw, { stega: false }); const suffix = siteTitle.trim() || "Site"; return { title: { @@ -48,7 +46,7 @@ export default async function LocaleLayout({ children, params }: Props) { notFound(); } const locale = raw; - const siteNav = await fetchSiteNavMenus(); + const siteNav = await fetchSiteNavMenus(locale); return ( diff --git a/web/src/app/[locale]/not-found.tsx b/web/src/app/[locale]/not-found.tsx index 6d2fb45f..a59e791f 100644 --- a/web/src/app/[locale]/not-found.tsx +++ b/web/src/app/[locale]/not-found.tsx @@ -1,13 +1,20 @@ -import { fetchErrorSettings } from "@/sanity/fetchSanityData"; +import { + fetchErrorSettings, + fetchSiteLanguageSettings, +} from "@/sanity/fetchSanityData"; import { LocaleNotFoundContent } from "./LocaleNotFoundContent"; /** - * Server: cached Sanity settings only. Locale comes from the URL on the client - * (`LocaleNotFoundContent`) so we never call `headers()` here — that would mark - * the whole `[locale]` segment as dynamic and disable SSG for `/[locale]`. + * Server: cached Sanity settings only. The actual URL locale is resolved + * client-side in `LocaleNotFoundContent` (`usePathname`) so we never call + * `headers()` here — that would mark the whole `[locale]` segment as dynamic + * and disable SSG for `/[locale]`. The server fetch uses the site default + * locale; if it doesn't match the URL the client can still render the + * generic fallback copy. */ export default async function NotFound() { - const errorSettings = await fetchErrorSettings(); + const siteLocale = await fetchSiteLanguageSettings(); + const errorSettings = await fetchErrorSettings(siteLocale.defaultLocale); return ; } diff --git a/web/src/app/[locale]/page.tsx b/web/src/app/[locale]/page.tsx index 33913deb..e407745d 100644 --- a/web/src/app/[locale]/page.tsx +++ b/web/src/app/[locale]/page.tsx @@ -29,10 +29,10 @@ export async function generateMetadata({ }: PageProps): Promise { const { locale } = await params; const [data, siteLocale, siteBrand, settingsSeo] = await Promise.all([ - fetchHomeDocument({ stega: false }), + fetchHomeDocument(locale, { stega: false }), fetchSiteLanguageSettings({ stega: false }), - fetchSiteSettingsTitle({ stega: false }), - fetchSettingsSeoFallback({ stega: false }), + fetchSiteSettingsTitle(locale, { stega: false }), + fetchSettingsSeoFallback(locale, { stega: false }), ]); if (!data) { return { @@ -53,19 +53,15 @@ export async function generateMetadata({ export default async function Home({ params }: PageProps) { const { locale } = await params; - const [data, siteLocale] = await Promise.all([ - fetchHomeDocument(), - fetchSiteLanguageSettings(), - ]); + const data = await fetchHomeDocument(locale); if (!data) { return (

- Home singleton is not in the dataset yet. Create it in Sanity Studio - (document id home - ). + No home document for {locale} yet. Create + one in Sanity Studio.

@@ -78,11 +74,7 @@ export default async function Home({ params }: PageProps) { {data.modules?.length ? (

Modules

- +
) : null} diff --git a/web/src/app/api/revalidate/route.ts b/web/src/app/api/revalidate/route.ts index c7893238..5e39deb5 100644 --- a/web/src/app/api/revalidate/route.ts +++ b/web/src/app/api/revalidate/route.ts @@ -23,6 +23,7 @@ type SanityWebhookPayload = { _id?: string; _type?: string; slug?: { current?: string } | null; + language?: string | null; }; // ── Defense-in-depth: simple in-memory token bucket per IP ─────────────────── @@ -66,6 +67,13 @@ function isValidPayload(value: unknown): value is SanityWebhookPayload { return false; } } + if ( + v.language !== undefined && + v.language !== null && + typeof v.language !== "string" + ) { + return false; + } return true; } @@ -110,20 +118,23 @@ function verifySignature( function getTagsForDocument(payload: SanityWebhookPayload): string[] { const tags: string[] = []; - const { _type, slug } = payload; + const { _type, slug, language } = payload; + const lang = + typeof language === "string" && language.trim() ? language.trim() : null; if (_type && !ALLOWED_DOCUMENT_TYPES.has(_type as never)) { return tags; } if (_type === "home") { - tags.push("home", "site-pages"); + if (lang) tags.push(`home-${lang}`); + tags.push("site-pages"); } if (_type === "page") { tags.push("pages", "site-pages"); - if (slug?.current) { - tags.push(`page-${slug.current}`); + if (slug?.current && lang) { + tags.push(`page-${slug.current}-${lang}`); } } diff --git a/web/src/app/sitemap.ts b/web/src/app/sitemap.ts index c6355e5e..7787c969 100644 --- a/web/src/app/sitemap.ts +++ b/web/src/app/sitemap.ts @@ -8,12 +8,19 @@ const BASE_URL = ( ).replace(/\/$/, ""); /** - * Refresh on tag invalidation (`pages`, `home`, `site-language-settings`, `site-pages`) - * via `/api/revalidate`. The 1 h fail-safe revalidate ensures editors who skip the - * webhook still get fresh sitemaps within reasonable time. + * Refresh on tag invalidation (`pages`, `site-pages`, `site-language-settings`) + * via `/api/revalidate`. The 1 h fail-safe revalidate ensures editors who skip + * the webhook still get fresh sitemaps within reasonable time. */ export const revalidate = 3600; +/** + * One sitemap entry per language variant of a routable document. With + * document-level translation each language is its own document — slugs may + * differ between languages, and the cross-locale relationship lives in + * `translation.metadata` (not queried here). Hreflang alternates are + * therefore omitted; add them in a follow-up if you need them. + */ export default async function sitemap(): Promise { const [pages, siteLocale] = await Promise.all([ cachedSitemapPages(), @@ -24,31 +31,19 @@ export default async function sitemap(): Promise { const entries: MetadataRoute.Sitemap = []; for (const page of pages) { - for (const locale of siteLocale.localeIds) { - const pathname = pathUtils.localePath(page.path, locale); - const url = `${BASE_URL}${pathname === "/" ? "" : pathname}` || BASE_URL; - - const languages: Record = {}; - for (const altLocale of siteLocale.localeIds) { - const altPath = pathUtils.localePath(page.path, altLocale); - languages[altLocale] = - `${BASE_URL}${altPath === "/" ? "" : altPath}` || BASE_URL; - } - const xDefaultPath = pathUtils.localePath( - page.path, - siteLocale.defaultLocale, - ); - languages["x-default"] = - `${BASE_URL}${xDefaultPath === "/" ? "" : xDefaultPath}` || BASE_URL; - - entries.push({ - url, - lastModified: page._updatedAt, - changeFrequency: page._type === "home" ? "daily" : "weekly", - priority: page._type === "home" ? 1.0 : 0.8, - alternates: { languages }, - }); - } + const language = + typeof page.language === "string" && page.language.trim() + ? page.language.trim() + : siteLocale.defaultLocale; + const pathname = pathUtils.localePath(page.path, language); + const url = `${BASE_URL}${pathname === "/" ? "" : pathname}` || BASE_URL; + + entries.push({ + url, + lastModified: page._updatedAt, + changeFrequency: page._type === "home" ? "daily" : "weekly", + priority: page._type === "home" ? 1.0 : 0.8, + }); } return entries; diff --git a/web/src/components/modules/ModuleText.tsx b/web/src/components/modules/ModuleText.tsx index 28aeafc4..3487dfff 100644 --- a/web/src/components/modules/ModuleText.tsx +++ b/web/src/components/modules/ModuleText.tsx @@ -1,28 +1,13 @@ import type { ModuleTextData } from "@/sanity/types/modules"; -import { - pickLocalizedPortableTextBlocks, - pickLocalizedString, -} from "@/sanity/utils/sanityLocalizedText"; -import type { SiteLocaleConfig } from "@/src/i18n/fallbackSiteLocales"; import { RichTextMedia } from "../text/RichTextMedia"; -// ─── Types ─────────────────────────────────────────────────────────────────── - type Props = { module: ModuleTextData; - locale: string; - siteLocale: Pick; }; -// ─── Component ─────────────────────────────────────────────────────────────── - -export function ModuleText({ module, locale, siteLocale }: Props) { - const title = pickLocalizedString(module.title, locale, siteLocale); - const blocks = pickLocalizedPortableTextBlocks( - module.body, - locale, - siteLocale, - ); +export function ModuleText({ module }: Props) { + const title = module.title?.trim() ?? ""; + const blocks = module.body ?? []; return (
diff --git a/web/src/components/modules/ModulesRenderer.tsx b/web/src/components/modules/ModulesRenderer.tsx index f57d1021..b0a8eefe 100644 --- a/web/src/components/modules/ModulesRenderer.tsx +++ b/web/src/components/modules/ModulesRenderer.tsx @@ -5,40 +5,24 @@ import type { ModuleMediaData, ModuleTextData, } from "@/sanity/types/modules"; -import { pickLocalizedString } from "@/sanity/utils/sanityLocalizedText"; import { getSanityModuleLabel } from "@/sanity/utils/sanityModuleLabel"; -import type { SiteLocaleConfig } from "@/src/i18n/fallbackSiteLocales"; import { ModuleMedia } from "./ModuleMedia"; import { ModuleText } from "./ModuleText"; -// ─── Types ─────────────────────────────────────────────────────────────────── - type Props = { modules: ContentModule[]; - locale: string; - siteLocale: Pick; }; const IS_DEV = process.env.NODE_ENV === "development"; -// ─── Sub-components ────────────────────────────────────────────────────────── - /** * Carousel placeholder — schema + GROQ exist, no production renderer yet. * Renders a labeled placeholder in dev only; nothing in production so empty * Studio modules don't surface visible warnings to end users. */ -function ModuleCarouselPlaceholder({ - module, - locale, - siteLocale, -}: { - module: ModuleCarouselData; - locale: string; - siteLocale: Pick; -}) { +function ModuleCarouselPlaceholder({ module }: { module: ModuleCarouselData }) { if (!IS_DEV) return null; - const heading = pickLocalizedString(module.heading, locale, siteLocale); + const heading = module.heading?.trim() ?? ""; const slideCount = module.resolvedSlides?.length ?? module.slidesMedia?.length ?? @@ -57,15 +41,11 @@ function ModuleCarouselPlaceholder({ function ModuleContentRefsPlaceholder({ module, - locale, - siteLocale, }: { module: ModuleContentRefsData; - locale: string; - siteLocale: Pick; }) { if (!IS_DEV) return null; - const heading = pickLocalizedString(module.heading, locale, siteLocale); + const heading = module.heading?.trim() ?? ""; const refCount = module.allowMultiple ? (module.references?.length ?? 0) : module.reference @@ -100,23 +80,14 @@ function UnknownModule({ moduleType }: { moduleType: string | undefined }) { ); } -// ─── Component ─────────────────────────────────────────────────────────────── - /** Renders the document `modules[]` stack (one UI block per `module.*` type). */ -export function ModulesRenderer({ modules, locale, siteLocale }: Props) { +export function ModulesRenderer({ modules }: Props) { return (
{modules.map((mod, index) => { const key = mod._key ?? `${mod._type ?? "module"}-${index}`; if (mod._type === "module.text") { - return ( - - ); + return ; } if (mod._type === "module.media") { return ; @@ -126,8 +97,6 @@ export function ModulesRenderer({ modules, locale, siteLocale }: Props) { ); } @@ -136,8 +105,6 @@ export function ModulesRenderer({ modules, locale, siteLocale }: Props) { ); } From b86135add29f9b32d6e26d669c6843e9abdad3d9 Mon Sep 17 00:00:00 2001 From: Marcell Lanczos Date: Thu, 28 May 2026 18:07:07 +0200 Subject: [PATCH 004/457] feat(MediaImage): enable client-side rendering by adding "use client" directive --- web/src/components/media/MediaImage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/components/media/MediaImage.tsx b/web/src/components/media/MediaImage.tsx index 706e17d7..e74524ee 100644 --- a/web/src/components/media/MediaImage.tsx +++ b/web/src/components/media/MediaImage.tsx @@ -1,3 +1,5 @@ +"use client"; + import clsx from "clsx"; import type { CSSProperties } from "react"; From 9afe8b7f47a0950e844878acffcb117b54ef7f6f Mon Sep 17 00:00:00 2001 From: Marcell Lanczos Date: Thu, 28 May 2026 18:30:18 +0200 Subject: [PATCH 005/457] refactor: remove fallback to NEXT_PUBLIC_SANITY_PROJECT_ID in dataset resolution Updated the dataset resolution logic to exclusively use SANITY_STUDIO_PROJECT_ID, removing the fallback to NEXT_PUBLIC_SANITY_PROJECT_ID in both the resolveStudioDatasetAsync function and getSanityStudioProjectId function. This change simplifies the project ID retrieval process. --- packages/sanity-dataset-resolve/src/index.ts | 3 +- web/sanity/resolveStudioDataset.ts | 1 - web/src/components/media/MediaImage.tsx | 36 ++++++++++---------- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/sanity-dataset-resolve/src/index.ts b/packages/sanity-dataset-resolve/src/index.ts index b42e3c99..69c2d4e5 100644 --- a/packages/sanity-dataset-resolve/src/index.ts +++ b/packages/sanity-dataset-resolve/src/index.ts @@ -132,8 +132,7 @@ export async function resolveStudioDatasetAsync( } const projectId = - env.SANITY_STUDIO_PROJECT_ID?.trim() || - env.NEXT_PUBLIC_SANITY_PROJECT_ID?.trim(); + env.SANITY_STUDIO_PROJECT_ID?.trim(); const devName = env.SANITY_STUDIO_DATASET_DEVELOPMENT?.trim() ?? "development"; const prodName = env.SANITY_STUDIO_DATASET_PRODUCTION?.trim() ?? "production"; diff --git a/web/sanity/resolveStudioDataset.ts b/web/sanity/resolveStudioDataset.ts index cf9caeab..11fe3b2e 100644 --- a/web/sanity/resolveStudioDataset.ts +++ b/web/sanity/resolveStudioDataset.ts @@ -15,7 +15,6 @@ export function getSanityStudioProjectId( ): string { return ( env.SANITY_STUDIO_PROJECT_ID?.trim() || - env.NEXT_PUBLIC_SANITY_PROJECT_ID?.trim() || "" ); } diff --git a/web/src/components/media/MediaImage.tsx b/web/src/components/media/MediaImage.tsx index e74524ee..37364f17 100644 --- a/web/src/components/media/MediaImage.tsx +++ b/web/src/components/media/MediaImage.tsx @@ -1,5 +1,3 @@ -"use client"; - import clsx from "clsx"; import type { CSSProperties } from "react"; @@ -78,7 +76,8 @@ function buildSrcSet(image: SanityImageField, maxWidth: number): string { * Sanity-driven image: **native ``** with deterministic Sanity CDN URLs (same SSR / client), * responsive `srcset` + `sizes` so the browser picks the right variant per container / viewport. * - * Avoids `next/image` optimizer `src` / `srcSet` hydration drift. + * Server Component (no `"use client"`) so URLs are built once on the server and the + * layout boot script may add `.img-loaded` without React hydration conflicts. */ export function MediaImage({ imagePayload, @@ -129,21 +128,22 @@ export function MediaImage({ : { aspectRatio: `${cropped.width} / ${cropped.height}` } } > - {/* biome-ignore lint/performance/noImgElement: deterministic Sanity URLs; next/image caused hydration mismatches */} - {imageAltFromField(image, + {/* biome-ignore lint/performance/noImgElement: deterministic Sanity URLs; next/image caused hydration mismatches */} + {imageAltFromField(image,
); } From 3a69f5944b76bc961cb2b5e7a9b95508780b759a Mon Sep 17 00:00:00 2001 From: Marcell Lanczos Date: Thu, 28 May 2026 18:36:59 +0200 Subject: [PATCH 006/457] refactor: streamline project ID retrieval in dataset resolution Consolidated the project ID retrieval logic in both the resolveStudioDatasetAsync and getSanityStudioProjectId functions by removing unnecessary line breaks. This enhances code readability without altering functionality. --- packages/sanity-dataset-resolve/src/index.ts | 3 +-- web/sanity/resolveStudioDataset.ts | 5 +---- web/src/components/media/MediaImage.tsx | 3 ++- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/sanity-dataset-resolve/src/index.ts b/packages/sanity-dataset-resolve/src/index.ts index 69c2d4e5..6a727fe1 100644 --- a/packages/sanity-dataset-resolve/src/index.ts +++ b/packages/sanity-dataset-resolve/src/index.ts @@ -131,8 +131,7 @@ export async function resolveStudioDatasetAsync( return explicit; } - const projectId = - env.SANITY_STUDIO_PROJECT_ID?.trim(); + const projectId = env.SANITY_STUDIO_PROJECT_ID?.trim(); const devName = env.SANITY_STUDIO_DATASET_DEVELOPMENT?.trim() ?? "development"; const prodName = env.SANITY_STUDIO_DATASET_PRODUCTION?.trim() ?? "production"; diff --git a/web/sanity/resolveStudioDataset.ts b/web/sanity/resolveStudioDataset.ts index 11fe3b2e..98cde252 100644 --- a/web/sanity/resolveStudioDataset.ts +++ b/web/sanity/resolveStudioDataset.ts @@ -13,8 +13,5 @@ export { resolveStudioDatasetAsync }; export function getSanityStudioProjectId( env: NodeJS.ProcessEnv = process.env, ): string { - return ( - env.SANITY_STUDIO_PROJECT_ID?.trim() || - "" - ); + return env.SANITY_STUDIO_PROJECT_ID?.trim() || ""; } diff --git a/web/src/components/media/MediaImage.tsx b/web/src/components/media/MediaImage.tsx index 37364f17..fd313e36 100644 --- a/web/src/components/media/MediaImage.tsx +++ b/web/src/components/media/MediaImage.tsx @@ -1,3 +1,5 @@ +"use client"; + import clsx from "clsx"; import type { CSSProperties } from "react"; @@ -142,7 +144,6 @@ export function MediaImage({ {...(!priority && { "data-lazy": "" })} className="absolute inset-0 block h-full w-full max-w-none" style={imgStyle} - suppressHydrationWarning />
); From e8baa5534ab396a0af9564dc83a19d7bfd89e58a Mon Sep 17 00:00:00 2001 From: Marcell Lanczos Date: Thu, 28 May 2026 18:37:13 +0200 Subject: [PATCH 007/457] refactor(MediaImage): remove unnecessary line breaks for improved readability Streamlined the JSX structure in the MediaImage component by eliminating redundant line breaks, enhancing code clarity without affecting functionality. --- web/src/components/media/MediaImage.tsx | 30 ++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/web/src/components/media/MediaImage.tsx b/web/src/components/media/MediaImage.tsx index fd313e36..b6962c4c 100644 --- a/web/src/components/media/MediaImage.tsx +++ b/web/src/components/media/MediaImage.tsx @@ -130,21 +130,21 @@ export function MediaImage({ : { aspectRatio: `${cropped.width} / ${cropped.height}` } } > - {/* biome-ignore lint/performance/noImgElement: deterministic Sanity URLs; next/image caused hydration mismatches */} - {imageAltFromField(image, + {/* biome-ignore lint/performance/noImgElement: deterministic Sanity URLs; next/image caused hydration mismatches */} + {imageAltFromField(image, ); } From c6b4efa89eabb473f3e7083517ce77bbd87b03d9 Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 28 May 2026 18:36:58 +0200 Subject: [PATCH 008/457] feat(carousel): implement module.carousel with embla (loop, dots, thumbs, autoplay) Adds CMS-driven carousel behavior fields (loop, showThumbnails, showNavDots, autoplay, autoplayDelayMs) and a production ModuleCarousel renderer using embla-carousel-react + embla-carousel-autoplay. Replaces the dev-only placeholder in ModulesRenderer and supports embeds in RichTextMedia. Co-authored-by: Cursor --- pnpm-lock.yaml | 40 +++ studio/sanity.types.gen.ts | 5 + studio/schema.json | 35 +++ studio/schemas/README.md | 2 +- .../schemas/objects/modules/moduleCarousel.ts | 50 ++++ web/package.json | 2 + .../queries/components/modules/carousel.ts | 5 + web/sanity/sanity.types.gen.ts | 33 ++- web/sanity/types/modules/carousel.ts | 5 + web/src/components/carousel/CarouselSlide.tsx | 88 ++++++ .../components/carousel/CarouselViewport.tsx | 267 ++++++++++++++++++ .../components/carousel/ModuleCarousel.tsx | 126 +++++++++ web/src/components/carousel/index.ts | 1 + .../components/modules/ModulesRenderer.tsx | 30 +- web/src/components/text/RichTextMedia.tsx | 12 +- 15 files changed, 666 insertions(+), 35 deletions(-) create mode 100644 web/src/components/carousel/CarouselSlide.tsx create mode 100644 web/src/components/carousel/CarouselViewport.tsx create mode 100644 web/src/components/carousel/ModuleCarousel.tsx create mode 100644 web/src/components/carousel/index.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e52a070..8ff975dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,12 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + embla-carousel-autoplay: + specifier: ^8.6.0 + version: 8.6.0(embla-carousel@8.6.0) + embla-carousel-react: + specifier: ^8.6.0 + version: 8.6.0(react@19.2.6) hls.js: specifier: ^1.6.16 version: 1.6.16 @@ -3490,6 +3496,24 @@ packages: electron-to-chromium@1.5.362: resolution: {integrity: sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==} + embla-carousel-autoplay@8.6.0: + resolution: {integrity: sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -9550,6 +9574,22 @@ snapshots: electron-to-chromium@1.5.362: {} + embla-carousel-autoplay@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-react@8.6.0(react@19.2.6): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.2.6 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} diff --git a/studio/sanity.types.gen.ts b/studio/sanity.types.gen.ts index cf7d9375..bafd8834 100644 --- a/studio/sanity.types.gen.ts +++ b/studio/sanity.types.gen.ts @@ -147,6 +147,11 @@ export type ModuleCarousel = { _key: string; } & ModuleMedia >; + loop?: boolean; + showThumbnails?: boolean; + showNavDots?: boolean; + autoplay?: boolean; + autoplayDelayMs?: number; }; export type MediaVideo = { diff --git a/studio/schema.json b/studio/schema.json index e893591e..52177450 100644 --- a/studio/schema.json +++ b/studio/schema.json @@ -794,6 +794,41 @@ } }, "optional": true + }, + "loop": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "showThumbnails": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "showNavDots": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "autoplay": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "autoplayDelayMs": { + "type": "objectAttribute", + "value": { + "type": "number" + }, + "optional": true } } } diff --git a/studio/schemas/README.md b/studio/schemas/README.md index d9c8b649..8ac8ef32 100644 --- a/studio/schemas/README.md +++ b/studio/schemas/README.md @@ -101,7 +101,7 @@ You do **not** need a Desk structure item for a module (modules are not document | Module | Role | |--------|------| | `module.media` | Image or Mux video via nested **`imageContent`** (`media.image`) / **`videoContent`** (`media.video`) depending on `type`. | -| `module.carousel` | Optional heading; **`imagesOnly`** (default `true`) uses **`slides`** (images only); when `false`, use **`slidesMedia`** (array of `module.media`). | +| `module.carousel` | Optional heading; **`imagesOnly`** (default `true`) uses **`slides`** (images only); when `false`, use **`slidesMedia`** (array of `module.media`). Behavior fieldset: **`loop`**, **`showThumbnails`**, **`showNavDots`**, **`autoplay`** + **`autoplayDelayMs`**. | | `module.contentRefs` | Optional heading; **`allowMultiple`** toggles single **`reference`** vs **`references`** array to **`PAGE_REFERENCES`** (same as internal links). | | `module.text` | Title + rich text body. | diff --git a/studio/schemas/objects/modules/moduleCarousel.ts b/studio/schemas/objects/modules/moduleCarousel.ts index 3618f6b0..9a05f157 100644 --- a/studio/schemas/objects/modules/moduleCarousel.ts +++ b/studio/schemas/objects/modules/moduleCarousel.ts @@ -6,6 +6,13 @@ export const moduleCarousel = defineType({ title: "Carousel", type: "object", icon: ImagesIcon, + fieldsets: [ + { + name: "behavior", + title: "Carousel behavior", + options: { collapsible: true, collapsed: false }, + }, + ], fields: [ { name: "heading", @@ -50,6 +57,49 @@ export const moduleCarousel = defineType({ return true; }), }, + { + name: "loop", + title: "Loop", + description: "Wrap from the last slide back to the first.", + type: "boolean", + initialValue: false, + fieldset: "behavior", + }, + { + name: "showThumbnails", + title: "Show thumbnails", + description: "Display a thumbnail strip below the carousel.", + type: "boolean", + initialValue: false, + fieldset: "behavior", + }, + { + name: "showNavDots", + title: "Navigation dots", + description: "Show pagination dots under the carousel.", + type: "boolean", + initialValue: true, + fieldset: "behavior", + }, + { + name: "autoplay", + title: "Autoplay slides", + description: + "Automatically advance to the next slide. Independent of per-video autoplay.", + type: "boolean", + initialValue: false, + fieldset: "behavior", + }, + { + name: "autoplayDelayMs", + title: "Autoplay delay (ms)", + description: "Time between slide changes when autoplay is enabled.", + type: "number", + initialValue: 5000, + hidden: ({ parent }) => parent?.autoplay !== true, + validation: (rule) => rule.min(1000).integer(), + fieldset: "behavior", + }, ], preview: { select: { diff --git a/web/package.json b/web/package.json index fd15180a..750dee1e 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,8 @@ "@sanity/client": "^7.22.0", "@sanity/image-url": "^2.1.1", "clsx": "^2.1.1", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", "hls.js": "^1.6.16", "next": "16.2.6", "next-sanity": "^13.0.4", diff --git a/web/sanity/queries/components/modules/carousel.ts b/web/sanity/queries/components/modules/carousel.ts index 01d9b331..8fabcb24 100644 --- a/web/sanity/queries/components/modules/carousel.ts +++ b/web/sanity/queries/components/modules/carousel.ts @@ -8,6 +8,11 @@ import { moduleMediaInnerFields, moduleMediaResolvedMediaQuery } from "./media"; export const moduleCarouselInnerFields = ` heading, imagesOnly, + loop, + showThumbnails, + showNavDots, + autoplay, + autoplayDelayMs, "slides": slides[]{ _key, _type, diff --git a/web/sanity/sanity.types.gen.ts b/web/sanity/sanity.types.gen.ts index d26185d5..92c56e5e 100644 --- a/web/sanity/sanity.types.gen.ts +++ b/web/sanity/sanity.types.gen.ts @@ -147,6 +147,11 @@ export type ModuleCarousel = { _key: string; } & ModuleMedia >; + loop?: boolean; + showThumbnails?: boolean; + showNavDots?: boolean; + autoplay?: boolean; + autoplayDelayMs?: number; }; export type MediaVideo = { @@ -702,7 +707,7 @@ export type AllSanitySchemaTypes = // Source: sanity/queries/pages/home.ts // Variable: homeQuery -// Query: *[_type == "home" && language == $locale][0]{ _id, title, language, modules[]{ _key, _type, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } } } } } }}, _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } )}, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } )}, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }}}, seo { title, description, "imageUrl": image.asset->url}} +// Query: *[_type == "home" && language == $locale][0]{ _id, title, language, modules[]{ _key, _type, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, loop, showThumbnails, showNavDots, autoplay, autoplayDelayMs, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, loop, showThumbnails, showNavDots, autoplay, autoplayDelayMs, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } } } } } }}, _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } )}, _type == "module.carousel" => { heading, imagesOnly, loop, showThumbnails, showNavDots, autoplay, autoplayDelayMs, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } )}, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }}}, seo { title, description, "imageUrl": image.asset->url}} export type HomeQueryResult = { _id: string; title: string; @@ -713,6 +718,11 @@ export type HomeQueryResult = { _type: "module.carousel"; heading: string | null; imagesOnly: boolean | null; + loop: boolean | null; + showThumbnails: boolean | null; + showNavDots: boolean | null; + autoplay: boolean | null; + autoplayDelayMs: number | null; slides: Array<{ _key: string; _type: "image"; @@ -1491,6 +1501,11 @@ export type HomeQueryResult = { } | null; }; }> | null; + loop: boolean | null; + showThumbnails: boolean | null; + showNavDots: boolean | null; + autoplay: boolean | null; + autoplayDelayMs: number | null; resolvedSlides: | Array<{ _key: string; @@ -1735,7 +1750,7 @@ export type HomeQueryResult = { // Source: sanity/queries/pages/page.ts // Variable: pageBySlugQuery -// Query: *[_type == "page" && slug.current == $slug && language == $locale][0]{ _id, title, slug, language, modules[]{ _key, _type, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } } } } } }}, _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } )}, _type == "module.carousel" => { heading, imagesOnly, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } )}, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }}}, seo { title, description, "imageUrl": image.asset->url}} +// Query: *[_type == "page" && slug.current == $slug && language == $locale][0]{ _id, title, slug, language, modules[]{ _key, _type, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, loop, showThumbnails, showNavDots, autoplay, autoplayDelayMs, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } , _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, _type == "module.carousel" => { heading, imagesOnly, loop, showThumbnails, showNavDots, autoplay, autoplayDelayMs, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } ) }, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) } }, _type == "module.text" => { title, body[]{ ..., _type == "block" => { ..., markDefs[]{ ..., _type == "link" => { ..., type == "internal" => { "linkType": "linkInternal", "title": coalesce(title, reference->title), "route": select( reference->_type == "home" => "page", reference->_type == "page" => "slug", "page" ), "slug": reference->slug.current, "resolvedReference": reference->{ _id, _type, title, "slug": slug.current } }, type == "external" => { ..., "linkType": "linkExternal", "href": url, "title": coalesce(title, url), blank }, type == "function" => { ..., "linkType": "linkFunction", "func": func { key, params } } } } } } } } } }}, _type == "module.media" => { type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } )}, _type == "module.carousel" => { heading, imagesOnly, loop, showThumbnails, showNavDots, autoplay, autoplayDelayMs, "slides": slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "slidesMedia": slidesMedia[]{ _key, _type, type, imageContent{ caption, "media": image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } }, videoContent{ caption, videoSettings, "media": video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) }, "resolvedSlides": select( imagesOnly == true => slides[]{ _key, _type, "media": select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, slidesMedia[]{ _key, _type, "resolvedMedia": select( type == "video" => { "kind": "video", "caption": videoContent.caption, "videoSettings": videoContent.videoSettings, "media": videoContent.video{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) }, "poster": videoContent.poster{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }, { "kind": "image", "caption": imageContent.caption, "media": imageContent.image{ ...select( defined(asset->playbackId) || defined(asset->data.playbackId) => { "kind": "video", ...{ "playbackId": coalesce( asset->playbackId, asset->data.playbackId, asset->data.playback_ids[0].id ), "duration": asset->data.duration, "asset": asset->{ playbackId, data }} }, { "kind": "image", ...{ crop, hotspot, "alt": asset->altText, "asset": asset->{ _id, url, metadata{ dimensions{ width, height, aspectRatio }, lqip } }} }) } } ) } )}, _type == "module.contentRefs" => { heading, allowMultiple, "reference": reference->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }, "references": references[]->{ _id, _type, title, "slug": slug.current, "route": select( _type == "home" => "index", _type == "page" => "slug", "index" ) }}}, seo { title, description, "imageUrl": image.asset->url}} export type PageBySlugQueryResult = { _id: string; title: string; @@ -1747,6 +1762,11 @@ export type PageBySlugQueryResult = { _type: "module.carousel"; heading: string | null; imagesOnly: boolean | null; + loop: boolean | null; + showThumbnails: boolean | null; + showNavDots: boolean | null; + autoplay: boolean | null; + autoplayDelayMs: number | null; slides: Array<{ _key: string; _type: "image"; @@ -2525,6 +2545,11 @@ export type PageBySlugQueryResult = { } | null; }; }> | null; + loop: boolean | null; + showThumbnails: boolean | null; + showNavDots: boolean | null; + autoplay: boolean | null; + autoplayDelayMs: number | null; resolvedSlides: | Array<{ _key: string; @@ -2836,8 +2861,8 @@ export type SitemapPagesQueryResult = Array< import "@sanity/client"; declare module "@sanity/client" { interface SanityQueries { - '*[_type == "home" && language == $locale][0]{\n _id,\n title,\n language,\n modules[]{\n _key,\n _type,\n _type == "module.text" => {\n title,\n body[]{\n \n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n \n }\n }\n }\n }\n \n }\n},\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n},\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n},\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n}\n},\n seo {\n title,\n description,\n "imageUrl": image.asset->url\n}\n}': HomeQueryResult; - '*[_type == "page" && slug.current == $slug && language == $locale][0]{\n _id,\n title,\n slug,\n language,\n modules[]{\n _key,\n _type,\n _type == "module.text" => {\n title,\n body[]{\n \n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n \n }\n }\n }\n }\n \n }\n},\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n},\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n},\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n}\n},\n seo {\n title,\n description,\n "imageUrl": image.asset->url\n}\n}': PageBySlugQueryResult; + '*[_type == "home" && language == $locale][0]{\n _id,\n title,\n language,\n modules[]{\n _key,\n _type,\n _type == "module.text" => {\n title,\n body[]{\n \n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n loop,\n showThumbnails,\n showNavDots,\n autoplay,\n autoplayDelayMs,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n loop,\n showThumbnails,\n showNavDots,\n autoplay,\n autoplayDelayMs,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n \n }\n }\n }\n }\n \n }\n},\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n},\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n loop,\n showThumbnails,\n showNavDots,\n autoplay,\n autoplayDelayMs,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n},\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n}\n},\n seo {\n title,\n description,\n "imageUrl": image.asset->url\n}\n}': HomeQueryResult; + '*[_type == "page" && slug.current == $slug && language == $locale][0]{\n _id,\n title,\n slug,\n language,\n modules[]{\n _key,\n _type,\n _type == "module.text" => {\n title,\n body[]{\n \n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n loop,\n showThumbnails,\n showNavDots,\n autoplay,\n autoplayDelayMs,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n ,\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n loop,\n showThumbnails,\n showNavDots,\n autoplay,\n autoplayDelayMs,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n },\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n },\n _type == "module.text" => {\n title,\n body[]{\n \n ...,\n _type == "block" => {\n ...,\n markDefs[]{\n ...,\n _type == "link" => {\n \n ...,\n type == "internal" => {\n "linkType": "linkInternal",\n "title": coalesce(title, reference->title),\n "route": select(\n reference->_type == "home" => "page",\n reference->_type == "page" => "slug",\n "page"\n ),\n "slug": reference->slug.current,\n "resolvedReference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current\n }\n },\n type == "external" => {\n ...,\n "linkType": "linkExternal",\n "href": url,\n "title": coalesce(title, url),\n blank\n },\n type == "function" => {\n ...,\n "linkType": "linkFunction",\n "func": func {\n key,\n params\n }\n }\n\n }\n }\n }\n \n }\n }\n }\n }\n \n }\n},\n _type == "module.media" => {\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n},\n _type == "module.carousel" => {\n \n heading,\n imagesOnly,\n loop,\n showThumbnails,\n showNavDots,\n autoplay,\n autoplayDelayMs,\n "slides": slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n "slidesMedia": slidesMedia[]{\n _key,\n _type,\n \n type,\n imageContent{\n caption,\n "media": image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n },\n videoContent{\n caption,\n videoSettings,\n "media": video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n\n },\n "resolvedSlides": select(\n imagesOnly == true => slides[]{\n _key,\n _type,\n "media": select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n)\n },\n slidesMedia[]{\n _key,\n _type,\n "resolvedMedia": \n select(\n type == "video" => {\n "kind": "video",\n "caption": videoContent.caption,\n "videoSettings": videoContent.videoSettings,\n "media": videoContent.video{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) },\n "poster": videoContent.poster{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n },\n {\n "kind": "image",\n "caption": imageContent.caption,\n "media": imageContent.image{ ...select(\n defined(asset->playbackId) || defined(asset->data.playbackId) => {\n "kind": "video",\n ...{\n "playbackId": coalesce(\n asset->playbackId,\n asset->data.playbackId,\n asset->data.playback_ids[0].id\n ),\n "duration": asset->data.duration,\n "asset": asset->{\n playbackId,\n data\n }\n}\n },\n {\n "kind": "image",\n ...{\n crop,\n hotspot,\n "alt": asset->altText,\n "asset": asset->{\n _id,\n url,\n metadata{\n dimensions{ width, height, aspectRatio },\n lqip\n }\n }\n}\n }\n) }\n }\n )\n\n }\n )\n\n},\n _type == "module.contentRefs" => {\n \n heading,\n allowMultiple,\n "reference": reference->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n },\n "references": references[]->{\n _id,\n _type,\n title,\n "slug": slug.current,\n "route": select(\n _type == "home" => "index",\n _type == "page" => "slug",\n "index"\n )\n }\n\n}\n},\n seo {\n title,\n description,\n "imageUrl": image.asset->url\n}\n}': PageBySlugQueryResult; '*[_id == "siteLanguageSettings"][0]{\n _id,\n availableLanguages[]{id, title},\n defaultLanguageId\n}': SiteLanguageSettingsQueryResult; '*[_type == "siteSettings" && language == $locale][0]{title}': SiteSettingsTitleQueryResult; '*[_type == "siteSettings" && language == $locale][0]{\n "title": seo.title,\n "description": seo.description,\n "imageUrl": seo.image.asset->url\n}': SiteSettingsSeoFallbackQueryResult; diff --git a/web/sanity/types/modules/carousel.ts b/web/sanity/types/modules/carousel.ts index 79559b8c..6f5d6dcd 100644 --- a/web/sanity/types/modules/carousel.ts +++ b/web/sanity/types/modules/carousel.ts @@ -5,6 +5,11 @@ export type ModuleCarouselData = { _key?: string; heading?: string | null; imagesOnly?: boolean | null; + loop?: boolean | null; + showThumbnails?: boolean | null; + showNavDots?: boolean | null; + autoplay?: boolean | null; + autoplayDelayMs?: number | null; /** Image slides: each slide has `media` from `mediaQuery` (kind + payload). */ slides?: Array<{ _key?: string; diff --git a/web/src/components/carousel/CarouselSlide.tsx b/web/src/components/carousel/CarouselSlide.tsx new file mode 100644 index 00000000..197afe83 --- /dev/null +++ b/web/src/components/carousel/CarouselSlide.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { MediaImage, MediaVideo, MediaVideoLoop } from "@/src/components/media"; + +export type NormalizedSlide = { + key: string; + kind: "image" | "video"; + media: unknown; + poster?: unknown; + caption?: string | null; + videoSettings?: { + autoplay?: boolean | null; + controls?: boolean | null; + } | null; +}; + +type Props = { + slide: NormalizedSlide; + isActive: boolean; +}; + +/** `autoplay && !controls` → silent loop (no MuxPlayer chrome). Mirrors `ModuleMedia`. */ +function isLoopIntent(settings: NormalizedSlide["videoSettings"]): boolean { + return Boolean(settings?.autoplay) && settings?.controls === false; +} + +/** + * The shared media components apply `min-h-[100dvh]` when `fillParent` is set (designed + * for hero sections). Inside a fixed-aspect carousel viewport that would blow out the + * layout, so we override it back to `0`. + */ +const FIT_TO_PARENT = "!min-h-0"; + +/** + * Renders a single carousel slide. Inactive full-player videos render the poster only, + * so audio/decoder work is scoped to the active slide. + */ +export function CarouselSlide({ slide, isActive }: Props) { + if (slide.kind === "image") { + return ( + + ); + } + + if (isLoopIntent(slide.videoSettings)) { + return ( + + ); + } + + if (!isActive) { + return ( + + ); + } + + return ( + + ); +} diff --git a/web/src/components/carousel/CarouselViewport.tsx b/web/src/components/carousel/CarouselViewport.tsx new file mode 100644 index 00000000..9b2c041e --- /dev/null +++ b/web/src/components/carousel/CarouselViewport.tsx @@ -0,0 +1,267 @@ +"use client"; + +import clsx from "clsx"; +import Autoplay from "embla-carousel-autoplay"; +import useEmblaCarousel from "embla-carousel-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + resolveSanityImageFieldForUrl, + urlForFetchedImage, +} from "@/sanity/utils/sanityImageBuilder"; +import { + extractMuxPlaybackId, + muxThumbnailTimeSec, + muxThumbnailUrl, +} from "@/src/utils/muxPlayback"; + +import { CarouselSlide, type NormalizedSlide } from "./CarouselSlide"; + +type CarouselOptions = { + loop: boolean; + showThumbnails: boolean; + showNavDots: boolean; + autoplay: boolean; + autoplayDelayMs: number; +}; + +type Props = { + slides: NormalizedSlide[]; + options: CarouselOptions; +}; + +const THUMBNAIL_WIDTH_PX = 160; + +function thumbnailUrlForSlide(slide: NormalizedSlide): string | null { + const imageSource = slide.kind === "image" ? slide.media : slide.poster; + const img = resolveSanityImageFieldForUrl(imageSource); + if (img) { + return urlForFetchedImage(img, THUMBNAIL_WIDTH_PX); + } + if (slide.kind === "video") { + const playbackId = extractMuxPlaybackId(slide.media); + if (playbackId) { + return muxThumbnailUrl(playbackId, muxThumbnailTimeSec(slide.media), { + width: THUMBNAIL_WIDTH_PX, + }); + } + } + return null; +} + +/** + * Embla viewport with optional autoplay, prev/next, dots, and a synced thumbnail strip. + * Reduced-motion users get a still carousel: autoplay is suspended but interaction works. + */ +export function CarouselViewport({ slides, options }: Props) { + const [reducedMotion, setReducedMotion] = useState(false); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + const sync = () => setReducedMotion(mq.matches); + sync(); + mq.addEventListener("change", sync); + return () => mq.removeEventListener("change", sync); + }, []); + + const autoplayPlugin = useRef( + options.autoplay + ? Autoplay({ + delay: Math.max(1000, options.autoplayDelayMs), + stopOnInteraction: true, + stopOnMouseEnter: true, + }) + : null, + ); + + const [emblaRef, emblaApi] = useEmblaCarousel( + { loop: options.loop, align: "start" }, + autoplayPlugin.current ? [autoplayPlugin.current] : [], + ); + const [thumbsRef, thumbsApi] = useEmblaCarousel({ + containScroll: "keepSnaps", + dragFree: true, + }); + + const [selectedIndex, setSelectedIndex] = useState(0); + const [snapCount, setSnapCount] = useState(0); + const [canScrollPrev, setCanScrollPrev] = useState(false); + const [canScrollNext, setCanScrollNext] = useState(false); + + const onSelect = useCallback(() => { + if (!emblaApi) return; + const idx = emblaApi.selectedScrollSnap(); + setSelectedIndex(idx); + setCanScrollPrev(emblaApi.canScrollPrev()); + setCanScrollNext(emblaApi.canScrollNext()); + thumbsApi?.scrollTo(idx); + }, [emblaApi, thumbsApi]); + + useEffect(() => { + if (!emblaApi) return; + setSnapCount(emblaApi.scrollSnapList().length); + onSelect(); + emblaApi.on("select", onSelect); + emblaApi.on("reInit", onSelect); + return () => { + emblaApi.off("select", onSelect); + emblaApi.off("reInit", onSelect); + }; + }, [emblaApi, onSelect]); + + useEffect(() => { + if (!options.autoplay) return; + const plugin = autoplayPlugin.current; + if (!plugin) return; + if (reducedMotion) { + plugin.stop(); + } + }, [reducedMotion, options.autoplay]); + + const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]); + const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]); + const scrollTo = useCallback( + (index: number) => emblaApi?.scrollTo(index), + [emblaApi], + ); + + const thumbnails = useMemo( + () => + options.showThumbnails + ? slides.map((slide) => thumbnailUrlForSlide(slide)) + : null, + [slides, options.showThumbnails], + ); + + if (slides.length === 0) return null; + + return ( +
+
+
+
+ {slides.map((slide, index) => ( +
+ +
+ ))} +
+
+ + {snapCount > 1 ? ( + <> + + + + ) : null} +
+ + {options.showNavDots && snapCount > 1 ? ( +
+ {slides.slice(0, snapCount).map((slide, index) => { + const isActive = index === selectedIndex; + return ( +
+ ) : null} + + {options.showThumbnails && thumbnails ? ( +
+
+ {slides.map((slide, index) => { + const url = thumbnails[index]; + const isActive = index === selectedIndex; + return ( + + ); + })} +
+
+ ) : null} +
+ ); +} diff --git a/web/src/components/carousel/ModuleCarousel.tsx b/web/src/components/carousel/ModuleCarousel.tsx new file mode 100644 index 00000000..d4940be0 --- /dev/null +++ b/web/src/components/carousel/ModuleCarousel.tsx @@ -0,0 +1,126 @@ +"use client"; + +import type { + ModuleCarouselData, + ModuleMediaData, + ResolvedMediaPayload, +} from "@/sanity/types/modules"; + +import type { NormalizedSlide } from "./CarouselSlide"; +import { CarouselViewport } from "./CarouselViewport"; + +type Props = { + module: ModuleCarouselData; +}; + +const DEFAULT_AUTOPLAY_DELAY_MS = 5000; + +function payloadKind( + payload: ResolvedMediaPayload | null | undefined, +): "image" | "video" | null { + if (!payload) return null; + if (payload.kind === "image" || payload.kind === "video") return payload.kind; + return null; +} + +/** Normalize the GROQ `resolvedSlides` into a single shape `CarouselSlide` understands. */ +function normalizeSlides(module: ModuleCarouselData): NormalizedSlide[] { + const imagesOnly = module.imagesOnly !== false; + + if (imagesOnly) { + const source = module.resolvedSlides ?? module.slides ?? []; + return source + .map((slide, index): NormalizedSlide | null => { + const media = slide.media ?? null; + const kind = payloadKind(media) ?? "image"; + if (!media) return null; + return { + key: slide._key ?? `slide-${index}`, + kind, + media, + }; + }) + .filter((s): s is NormalizedSlide => s !== null); + } + + const source = module.resolvedSlides; + if (source && source.length > 0) { + return source + .map((slide, index): NormalizedSlide | null => { + const resolved = slide.resolvedMedia; + if (!resolved) return null; + const kind = payloadKind(resolved.media) ?? resolved.kind ?? null; + if (!kind || !resolved.media) return null; + return { + key: slide._key ?? `slide-${index}`, + kind, + media: resolved.media, + poster: resolved.poster ?? undefined, + caption: resolved.caption ?? null, + videoSettings: resolved.videoSettings ?? null, + }; + }) + .filter((s): s is NormalizedSlide => s !== null); + } + + const fallback = module.slidesMedia ?? []; + return fallback + .map((slide: ModuleMediaData, index): NormalizedSlide | null => { + if (slide.type === "image") { + const imagePayload = + slide.imageContent?.media ?? slide.imageContent?.image ?? null; + if (!imagePayload) return null; + return { + key: slide._key ?? `slide-${index}`, + kind: "image", + media: imagePayload, + caption: slide.imageContent?.caption ?? null, + }; + } + if (slide.type === "video" && slide.videoContent) { + const muxField = + slide.videoContent.media ?? slide.videoContent.video ?? null; + if (!muxField) return null; + return { + key: slide._key ?? `slide-${index}`, + kind: "video", + media: muxField, + poster: slide.videoContent.poster ?? undefined, + caption: slide.videoContent.caption ?? null, + videoSettings: slide.videoContent.videoSettings ?? null, + }; + } + return null; + }) + .filter((s): s is NormalizedSlide => s !== null); +} + +/** + * `module.carousel` renderer. Behavior fields (`loop`, `showThumbnails`, `showNavDots`, + * `autoplay`, `autoplayDelayMs`) come from Sanity and drive the embla viewport. + */ +export function ModuleCarousel({ module }: Props) { + const slides = normalizeSlides(module); + if (slides.length === 0) return null; + + const heading = module.heading?.trim() ?? ""; + + return ( +
+ {heading ?

{heading}

: null} + +
+ ); +} diff --git a/web/src/components/carousel/index.ts b/web/src/components/carousel/index.ts new file mode 100644 index 00000000..6e9c0582 --- /dev/null +++ b/web/src/components/carousel/index.ts @@ -0,0 +1 @@ +export { ModuleCarousel } from "./ModuleCarousel"; diff --git a/web/src/components/modules/ModulesRenderer.tsx b/web/src/components/modules/ModulesRenderer.tsx index b0a8eefe..c4a56304 100644 --- a/web/src/components/modules/ModulesRenderer.tsx +++ b/web/src/components/modules/ModulesRenderer.tsx @@ -6,6 +6,7 @@ import type { ModuleTextData, } from "@/sanity/types/modules"; import { getSanityModuleLabel } from "@/sanity/utils/sanityModuleLabel"; +import { ModuleCarousel } from "@/src/components/carousel"; import { ModuleMedia } from "./ModuleMedia"; import { ModuleText } from "./ModuleText"; @@ -15,30 +16,6 @@ type Props = { const IS_DEV = process.env.NODE_ENV === "development"; -/** - * Carousel placeholder — schema + GROQ exist, no production renderer yet. - * Renders a labeled placeholder in dev only; nothing in production so empty - * Studio modules don't surface visible warnings to end users. - */ -function ModuleCarouselPlaceholder({ module }: { module: ModuleCarouselData }) { - if (!IS_DEV) return null; - const heading = module.heading?.trim() ?? ""; - const slideCount = - module.resolvedSlides?.length ?? - module.slidesMedia?.length ?? - module.slides?.length ?? - 0; - return ( -
- module.carousel - {heading ? heading: {heading} : null} - - slides: {slideCount} (no frontend renderer yet) - -
- ); -} - function ModuleContentRefsPlaceholder({ module, }: { @@ -94,10 +71,7 @@ export function ModulesRenderer({ modules }: Props) { } if (mod._type === "module.carousel") { return ( - + ); } if (mod._type === "module.contentRefs") { diff --git a/web/src/components/text/RichTextMedia.tsx b/web/src/components/text/RichTextMedia.tsx index 2a8a1762..19c32423 100644 --- a/web/src/components/text/RichTextMedia.tsx +++ b/web/src/components/text/RichTextMedia.tsx @@ -3,7 +3,11 @@ import type { PortableTextBlock } from "@portabletext/types"; import clsx from "clsx"; import type { ReactNode } from "react"; -import type { ModuleMediaData } from "@/sanity/types/modules"; +import type { + ModuleCarouselData, + ModuleMediaData, +} from "@/sanity/types/modules"; +import { ModuleCarousel } from "@/src/components/carousel"; import { ModuleMedia } from "@/src/components/modules/ModuleMedia"; /** @@ -132,7 +136,11 @@ function portableTextComponents(): Partial { ), - "module.carousel": () => null, + "module.carousel": ({ value }) => ( +
+ +
+ ), }, }; } From fae67db4705654918913dc9fd8e0549333f908e3 Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 28 May 2026 18:42:02 +0200 Subject: [PATCH 009/457] fix(layout): defer img-loaded class to next animation frame to avoid hydration mismatch The boot script synchronously added `.img-loaded` to images already complete in the browser cache, racing React 19 streaming hydration and producing a "tree hydrated but attributes didn't match" warning on every page that ships a non-priority Sanity image. Wrapping the class mutation in `requestAnimationFrame` lets React reconcile the SSR markup first while still keeping the fade-in transition imperceptible. Co-authored-by: Cursor --- web/src/app/layout.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 70acc685..62f5e16f 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -104,10 +104,15 @@ const bootScript = `(function(){ }catch(e){} document.documentElement.classList.add('js-enabled'); function handle(img){ - if(img.complete&&img.naturalWidth>0){img.classList.add('img-loaded');} + function add(){img.classList.add('img-loaded');} + /* Defer the className mutation past the current task so React 19 streaming + hydration reconciles the SSR markup before we touch it — otherwise images + served from cache produce a "tree hydrated but attributes didn't match" + warning (className diff: 'img-loaded' was added by this script). */ + if(img.complete&&img.naturalWidth>0){requestAnimationFrame(add);} else{ - img.addEventListener('load',function(){img.classList.add('img-loaded');},{once:true}); - img.addEventListener('error',function(){img.classList.add('img-loaded');},{once:true}); + img.addEventListener('load',add,{once:true}); + img.addEventListener('error',add,{once:true}); } } function scan(){document.querySelectorAll('img[data-lazy]').forEach(handle);} From c1f45dcc5f5567b165b10d7f8ab49d6ef36321cc Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 28 May 2026 18:58:19 +0200 Subject: [PATCH 010/457] fix(sanity): use NEXT_PUBLIC_* only for sync project id and dataset to fix hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `syncSanityProjectId` previously read `SANITY_STUDIO_PROJECT_ID`, which Next does not inline into the client bundle. SSR therefore built transformed Sanity image URLs (`?w=…&auto=format&q=85`) while the hydrating client had no project id and fell back to bare `image.asset.url`, producing a hydration mismatch on every Sanity image. Read `NEXT_PUBLIC_SANITY_PROJECT_ID` exclusively (and drop the equivalent server-only `SANITY_STUDIO_DATASET` branch from `syncDataset`) so server and client always compute identical URLs. Document the new env var requirement in `.env.example`. Co-authored-by: Cursor --- web/.env.example | 4 ++++ web/sanity/sanitySyncConfig.ts | 21 ++++++++++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/web/.env.example b/web/.env.example index b2fa4a85..b6802e0f 100644 --- a/web/.env.example +++ b/web/.env.example @@ -3,6 +3,10 @@ # Sanity — project (required; same id as `studio/.env`) SANITY_STUDIO_PROJECT_ID= +# Same project id, exposed to the **browser** (Next inlines `NEXT_PUBLIC_*` only) so +# Client Components can build matching Sanity image URLs. Without this, SSR and CSR +# generate different image URLs and React reports a hydration mismatch. +NEXT_PUBLIC_SANITY_PROJECT_ID= # --- Dataset override (optional; `@repo/sanity-dataset-resolve` / `sanity/sanityEnv.ts`) --- # Default: no vars — order is inferred (e.g. dev-first locally, prod-first on Vercel production). diff --git a/web/sanity/sanitySyncConfig.ts b/web/sanity/sanitySyncConfig.ts index 2e349b5c..7a0b0404 100644 --- a/web/sanity/sanitySyncConfig.ts +++ b/web/sanity/sanitySyncConfig.ts @@ -1,5 +1,3 @@ -import { getSanityStudioProjectId } from "./resolveStudioDataset"; - /** * **Synchronous** `projectId` + `dataset` for code that may run in the **browser** (e.g. * `sanityImageBuilder` used under Client Components). No top-level `await`. @@ -7,20 +5,25 @@ import { getSanityStudioProjectId } from "./resolveStudioDataset"; * The GROQ `client` in `client.ts` still uses async resolution in `sanityEnv.ts` — keep env * aligned so image URLs match API queries (set `SANITY_STUDIO_DATASET` or pin both places). * - * In client bundles, Next only inlines **`NEXT_PUBLIC_*`** — prefer - * **`NEXT_PUBLIC_SANITY_DATASET`** if image URLs must match a non-default dataset in the browser. + * In client bundles, Next only inlines **`NEXT_PUBLIC_*`**. We deliberately read **only** + * `NEXT_PUBLIC_SANITY_PROJECT_ID` / `NEXT_PUBLIC_SANITY_DATASET` here so server and client + * always compute identical URLs — otherwise SSR transforms (`?w=…&auto=format&q=85`) but + * the hydrating client falls back to bare `asset.url`, producing hydration mismatches on + * every Sanity image. If the public vars are unset, both sides degrade to bare `asset.url` + * (no transformations) but stay consistent. */ -export const syncSanityProjectId: string = getSanityStudioProjectId(); +export const syncSanityProjectId: string = + process.env.NEXT_PUBLIC_SANITY_PROJECT_ID?.trim() || ""; function syncDataset(): string { const fromPublic = process.env.NEXT_PUBLIC_SANITY_DATASET?.trim(); if (fromPublic) { return fromPublic; } - const fromStudio = process.env.SANITY_STUDIO_DATASET?.trim(); - if (fromStudio) { - return fromStudio; - } + /* `SANITY_STUDIO_DATASET` deliberately omitted: it's not inlined into client + bundles, so reading it here would diverge from the client and cause image + URL mismatches. `NODE_ENV` is inlined by Next, so this final branch matches + on both sides. Set `NEXT_PUBLIC_SANITY_DATASET` for non-default datasets. */ return process.env.NODE_ENV === "production" ? "production" : "development"; } From ff5e628edfb99effca9334970b04842b08dfd671 Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 28 May 2026 19:10:21 +0200 Subject: [PATCH 011/457] fix(media): manage img-loaded class via React state to fix hydration mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the `img-loaded` class from the inline boot script raced React 19's streaming hydration on cached images and produced "tree hydrated but attributes didn't match" warnings. `requestAnimationFrame` was not enough to push the DOM mutation past hydration reliably. Move the lazy fade-in inside `MediaImage`: a `useRef` + `useEffect` flips a `loaded` state once the image's `load` event fires (or immediately if the image is already complete) and React renders the class — no more DOM / hydration race. Strip the image-handling section from the boot script — theme + `js-enabled` stay so the CSS opacity gate keeps working. Co-authored-by: Cursor --- web/src/app/layout.tsx | 33 ++++------------------ web/src/components/media/MediaImage.tsx | 37 ++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 62f5e16f..f890aa32 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -89,9 +89,12 @@ export const metadata: Metadata = { * `@media (prefers-color-scheme: dark)` rule in `variables/colors.css` * takes over — works without JavaScript. * 2. Adds `js-enabled` to so CSS can safely start images at opacity:0. - * 3. Wires up the lazy-image fade-in: when an img[data-lazy] finishes loading, - * adds `.img-loaded` → CSS transition kicks in (0.2 s ease-in-out). - * 4. MutationObserver covers images injected after initial paint (client nav). + * + * The lazy-image `img-loaded` class is added by `MediaImage` itself via + * `useEffect` — managing it from this inline script raced React 19 streaming + * hydration on cached images and produced "tree hydrated but attributes didn't + * match" warnings. Doing it through React's render flow keeps SSR and the + * first client paint identical. * * Without JS the images remain fully visible (no opacity applied) — graceful degradation. */ @@ -103,30 +106,6 @@ const bootScript = `(function(){ } }catch(e){} document.documentElement.classList.add('js-enabled'); - function handle(img){ - function add(){img.classList.add('img-loaded');} - /* Defer the className mutation past the current task so React 19 streaming - hydration reconciles the SSR markup before we touch it — otherwise images - served from cache produce a "tree hydrated but attributes didn't match" - warning (className diff: 'img-loaded' was added by this script). */ - if(img.complete&&img.naturalWidth>0){requestAnimationFrame(add);} - else{ - img.addEventListener('load',add,{once:true}); - img.addEventListener('error',add,{once:true}); - } - } - function scan(){document.querySelectorAll('img[data-lazy]').forEach(handle);} - if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',scan);} - else{scan();} - new MutationObserver(function(recs){ - recs.forEach(function(r){ - r.addedNodes.forEach(function(n){ - if(n.nodeType!==1)return; - if(n.matches&&n.matches('img[data-lazy]'))handle(n); - n.querySelectorAll&&n.querySelectorAll('img[data-lazy]').forEach(handle); - }); - }); - }).observe(document.documentElement,{childList:true,subtree:true}); })();`; /** diff --git a/web/src/components/media/MediaImage.tsx b/web/src/components/media/MediaImage.tsx index b6962c4c..b3409511 100644 --- a/web/src/components/media/MediaImage.tsx +++ b/web/src/components/media/MediaImage.tsx @@ -1,7 +1,7 @@ "use client"; import clsx from "clsx"; -import type { CSSProperties } from "react"; +import { type CSSProperties, useEffect, useRef, useState } from "react"; import type { SanityImageField } from "@/sanity/types/modules"; import { @@ -78,8 +78,10 @@ function buildSrcSet(image: SanityImageField, maxWidth: number): string { * Sanity-driven image: **native ``** with deterministic Sanity CDN URLs (same SSR / client), * responsive `srcset` + `sizes` so the browser picks the right variant per container / viewport. * - * Server Component (no `"use client"`) so URLs are built once on the server and the - * layout boot script may add `.img-loaded` without React hydration conflicts. + * The `img-loaded` class is React-managed (via `useState` + `useEffect`) so the SSR markup + * stays stable through hydration — adding the class from an inline boot script raced React + * 19's streaming hydration on cached images and produced "tree hydrated but attributes + * didn't match" warnings. */ export function MediaImage({ imagePayload, @@ -92,6 +94,29 @@ export function MediaImage({ sizes = "100vw", }: MediaImageProps) { const image = resolveSanityImageFieldForUrl(imagePayload); + const imgRef = useRef(null); + /* Priority images skip the fade — they render visible immediately. Non-priority + images start at `loaded=false` (matches SSR) and flip to `true` from the load + handler in `useEffect` (or right away if cached). */ + const [loaded, setLoaded] = useState(priority); + + useEffect(() => { + if (priority) return; + const el = imgRef.current; + if (!el) return; + if (el.complete && el.naturalWidth > 0) { + setLoaded(true); + return; + } + const onSettled = () => setLoaded(true); + el.addEventListener("load", onSettled, { once: true }); + el.addEventListener("error", onSettled, { once: true }); + return () => { + el.removeEventListener("load", onSettled); + el.removeEventListener("error", onSettled); + }; + }, [priority]); + if (!image) return null; const cropped = getCroppedImageDisplayDimensions(image); @@ -132,6 +157,7 @@ export function MediaImage({ > {/* biome-ignore lint/performance/noImgElement: deterministic Sanity URLs; next/image caused hydration mismatches */} From 1d4ddd72a2f273aeed37b685b10682027654e6df Mon Sep 17 00:00:00 2001 From: Marcell Lanczos Date: Thu, 28 May 2026 19:14:06 +0200 Subject: [PATCH 012/457] feat(sanity): enhance siteNav queries for locale support Updated `siteNavQuery` and `siteNavMenusQuery` to utilize a new `siteNavByLocale` query, which retrieves the active locale's `siteNav` document while falling back to a legacy document if no locale-specific document exists. This change improves internationalization handling in the navigation structure. --- web/sanity/queries/snippets/settings.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/web/sanity/queries/snippets/settings.ts b/web/sanity/queries/snippets/settings.ts index 2250ef03..f9394ce1 100644 --- a/web/sanity/queries/snippets/settings.ts +++ b/web/sanity/queries/snippets/settings.ts @@ -47,8 +47,17 @@ export const navMenusQuery = ` "footerMenu": ${navFooterMenuQuery} `; +/** + * `siteNav` for the active locale. Falls back to a legacy document with no + * `language` (pre–document-level i18n) when no locale-specific doc exists. + */ +const siteNavByLocale = `coalesce( + *[_type == "siteNav" && language == $locale][0], + *[_type == "siteNav" && !defined(language)][0] +)`; + /** Document type: `siteNav` (one per language; see studio structure). */ -export const siteNavQuery = `*[_type == "siteNav" && language == $locale][0]{ +export const siteNavQuery = `${siteNavByLocale}{ _id, title, language, @@ -63,7 +72,7 @@ export const siteNavQuery = `*[_type == "siteNav" && language == $locale][0]{ * projection here and mis-types `mainMenu`/`footerMenu` as `null`. The * hand-written `SiteNavMenusDocument` (sanity/types/nav.ts) is authoritative. */ -export const siteNavMenusQuery = `*[_type == "siteNav" && language == $locale][0]{ +export const siteNavMenusQuery = `${siteNavByLocale}{ _id, title, language, From d3f1704b8c75eb5037e8d7bc94d4ad2dfb6fff28 Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 28 May 2026 19:19:44 +0200 Subject: [PATCH 013/457] refactor(web): expose SANITY_STUDIO_* to client via next.config env map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `web/.env` previously needed both `SANITY_STUDIO_PROJECT_ID` (server / Studio convention) and `NEXT_PUBLIC_SANITY_PROJECT_ID` (so Client Components could build matching Sanity image URLs). Same value, two names — confusing and error-prone (forgetting one half of the pair causes a hydration mismatch). Use Next's `next.config.ts` `env` map to inline `SANITY_STUDIO_PROJECT_ID` and `SANITY_STUDIO_DATASET` into the client bundle. `sanitySyncConfig.ts` now reads the Studio-named vars directly. `.env.example` drops the public aliases. One env var, one name, identical SSR / CSR Sanity URLs. Co-authored-by: Cursor --- web/.env.example | 15 +++++++-------- web/next.config.ts | 14 ++++++++++++++ web/sanity/sanitySyncConfig.ts | 27 +++++++++++++-------------- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/web/.env.example b/web/.env.example index b6802e0f..7d3a2df0 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,12 +1,12 @@ # Next.js (web) — copy to `.env.local` # https://nextjs.org/docs/app/building-your-application/configuring-environment-variables -# Sanity — project (required; same id as `studio/.env`) +# Sanity — project (required; same id as `studio/.env`). +# `next.config.ts` inlines `SANITY_STUDIO_*` into the **client** bundle (Next normally +# only inlines `NEXT_PUBLIC_*`) so Client Components and the server resolve to the same +# project id and dataset — otherwise SSR + CSR produce different Sanity image URLs and +# React reports a hydration mismatch. No `NEXT_PUBLIC_SANITY_PROJECT_ID` needed. SANITY_STUDIO_PROJECT_ID= -# Same project id, exposed to the **browser** (Next inlines `NEXT_PUBLIC_*` only) so -# Client Components can build matching Sanity image URLs. Without this, SSR and CSR -# generate different image URLs and React reports a hydration mismatch. -NEXT_PUBLIC_SANITY_PROJECT_ID= # --- Dataset override (optional; `@repo/sanity-dataset-resolve` / `sanity/sanityEnv.ts`) --- # Default: no vars — order is inferred (e.g. dev-first locally, prod-first on Vercel production). @@ -17,10 +17,9 @@ NEXT_PUBLIC_SANITY_PROJECT_ID= # - Any other value (e.g. `development`) — try a dataset with that name first, then dev/prod fallbacks. # SANITY_STUDIO_DEPLOYMENT_TARGET=development # -# Hard pin (skips auto-selection and `SANITY_STUDIO_DEPLOYMENT_TARGET`): +# Hard pin (skips auto-selection and `SANITY_STUDIO_DEPLOYMENT_TARGET`). +# `next.config.ts` exposes this to the browser too — set it once. # SANITY_STUDIO_DATASET= -# Same dataset name for **browser** image URLs (Client Components / Portable Text media): -# NEXT_PUBLIC_SANITY_DATASET= # Sanity — live preview & Visual Editing (Presentation, Draft Mode, Stega) # Also used for **Site languages** (`siteLanguageSettings`): with a token, Next reads draft/unpublished diff --git a/web/next.config.ts b/web/next.config.ts index 72dd515b..38360628 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -5,6 +5,20 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { transpilePackages: ["@repo/sanity-dataset-resolve"], + /** + * Inline `SANITY_STUDIO_*` into the **client** bundle. + * + * Next normally only exposes `NEXT_PUBLIC_*` variables to the browser. We keep a single + * env-var name across `web/` and `studio/` (`SANITY_STUDIO_PROJECT_ID`, `SANITY_STUDIO_DATASET`) + * by listing them here — `next.config`'s `env` map gets statically replaced at build time + * exactly like `NEXT_PUBLIC_*`. Without this, Client Components (image-URL builder etc.) + * would see `undefined` and SSR / CSR would diverge → hydration mismatch on Sanity images. + */ + env: { + SANITY_STUDIO_PROJECT_ID: process.env.SANITY_STUDIO_PROJECT_ID ?? "", + SANITY_STUDIO_DATASET: process.env.SANITY_STUDIO_DATASET ?? "", + }, + /** Drop the `X-Powered-By: Next.js` header — small fingerprinting reduction. */ poweredByHeader: false, diff --git a/web/sanity/sanitySyncConfig.ts b/web/sanity/sanitySyncConfig.ts index 7a0b0404..71fa9067 100644 --- a/web/sanity/sanitySyncConfig.ts +++ b/web/sanity/sanitySyncConfig.ts @@ -5,25 +5,24 @@ * The GROQ `client` in `client.ts` still uses async resolution in `sanityEnv.ts` — keep env * aligned so image URLs match API queries (set `SANITY_STUDIO_DATASET` or pin both places). * - * In client bundles, Next only inlines **`NEXT_PUBLIC_*`**. We deliberately read **only** - * `NEXT_PUBLIC_SANITY_PROJECT_ID` / `NEXT_PUBLIC_SANITY_DATASET` here so server and client - * always compute identical URLs — otherwise SSR transforms (`?w=…&auto=format&q=85`) but - * the hydrating client falls back to bare `asset.url`, producing hydration mismatches on - * every Sanity image. If the public vars are unset, both sides degrade to bare `asset.url` - * (no transformations) but stay consistent. + * Reads `SANITY_STUDIO_PROJECT_ID` / `SANITY_STUDIO_DATASET` directly. These are inlined + * into the **client** bundle by `next.config.ts`'s `env` map (Next normally only inlines + * `NEXT_PUBLIC_*`) so server and client always compute identical URLs — otherwise SSR + * transforms (`?w=…&auto=format&q=85`) but the hydrating client falls back to bare + * `asset.url`, producing hydration mismatches on every Sanity image. */ export const syncSanityProjectId: string = - process.env.NEXT_PUBLIC_SANITY_PROJECT_ID?.trim() || ""; + process.env.SANITY_STUDIO_PROJECT_ID?.trim() || ""; function syncDataset(): string { - const fromPublic = process.env.NEXT_PUBLIC_SANITY_DATASET?.trim(); - if (fromPublic) { - return fromPublic; + const explicit = process.env.SANITY_STUDIO_DATASET?.trim(); + if (explicit) { + return explicit; } - /* `SANITY_STUDIO_DATASET` deliberately omitted: it's not inlined into client - bundles, so reading it here would diverge from the client and cause image - URL mismatches. `NODE_ENV` is inlined by Next, so this final branch matches - on both sides. Set `NEXT_PUBLIC_SANITY_DATASET` for non-default datasets. */ + /* No explicit dataset → fall back to a name that's identical on server and client. + `NODE_ENV` is inlined by Next, so this branch matches on both sides. Set + `SANITY_STUDIO_DATASET` (which `next.config.ts` exposes to the browser) for + non-default datasets. */ return process.env.NODE_ENV === "production" ? "production" : "development"; } From b56489f279e91ca36f2add77aa54237be0366f7d Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 28 May 2026 19:26:33 +0200 Subject: [PATCH 014/457] fix(media): reveal images that failed to load instead of leaving them at opacity:0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useEffect` only set `loaded=true` synchronously when both `el.complete` and `naturalWidth > 0` were true. For images that errored before React's passive effect attached its listeners, the load/error events had already fired and were lost — `loaded` never flipped, the `img-loaded` class was never added and the broken image stayed invisible (opacity:0) forever. Drop the `naturalWidth > 0` guard. Both successful and failed loads need to reveal the `` (the alt text matters for failed ones); the running listeners still cover the in-flight case. Co-authored-by: Cursor --- web/src/components/media/MediaImage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/components/media/MediaImage.tsx b/web/src/components/media/MediaImage.tsx index b3409511..28c859ff 100644 --- a/web/src/components/media/MediaImage.tsx +++ b/web/src/components/media/MediaImage.tsx @@ -104,7 +104,10 @@ export function MediaImage({ if (priority) return; const el = imgRef.current; if (!el) return; - if (el.complete && el.naturalWidth > 0) { + /* `complete` is also true for failed loads — reveal the element either way, + otherwise broken images stay at opacity:0 forever (load/error events have + already fired before this effect attaches its listeners). */ + if (el.complete) { setLoaded(true); return; } From 9b8a32a9a7f05192cb45315296b686357c3b31e6 Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 28 May 2026 19:36:40 +0200 Subject: [PATCH 015/457] feat(sanity): wire Presentation overlay onto rendered modules via data-sanity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `dataAttr` helper that composes `next-sanity`'s `createDataAttribute` with the resolved `projectId` / `dataset` and the Studio URL, so callers only need to pass the field path (`{ id, type, path }`). Used in `ModulesRenderer` to wrap each module with `
` — Presentation tool can now reverse-map a rendered module to its `modules[_key=="…"]` GROQ slot and jump the Studio cursor into the corresponding field. Skips the attribute when a module has no `_key` (legacy data) — without a stable key the overlay would target the wrong array slot. Co-authored-by: Cursor --- web/sanity/utils/dataAttr.ts | 31 ++++++++++ web/sanity/utils/index.ts | 1 + web/src/app/[locale]/[slug]/page.tsx | 6 +- web/src/app/[locale]/page.tsx | 6 +- .../components/modules/ModulesRenderer.tsx | 62 ++++++++++++------- 5 files changed, 83 insertions(+), 23 deletions(-) create mode 100644 web/sanity/utils/dataAttr.ts diff --git a/web/sanity/utils/dataAttr.ts b/web/sanity/utils/dataAttr.ts new file mode 100644 index 00000000..49247236 --- /dev/null +++ b/web/sanity/utils/dataAttr.ts @@ -0,0 +1,31 @@ +import { + type CreateDataAttributeProps, + createDataAttribute, +} from "next-sanity"; + +import { dataset, projectId } from "../sanityEnv"; + +const studioUrl = + process.env.NEXT_PUBLIC_SANITY_STUDIO_URL ?? "http://localhost:3333"; + +type DataAttrConfig = CreateDataAttributeProps & + Required>; + +/** + * Builds a `data-sanity` attribute value so the Presentation tool overlay can + * map a rendered element back to its document + field path. Wrap editable + * surfaces with `data-sanity={dataAttr({ id, type, path })}` — clicking the + * element in Presentation jumps the Studio cursor straight into that field. + * + * `id` is the document `_id`, `type` is the document `_type`, `path` is the + * dot-notated GROQ path to the field (e.g. `"modules[_key==\"abc\"].heading"`). + */ +export function dataAttr(config: DataAttrConfig): string { + return createDataAttribute({ + projectId, + dataset, + baseUrl: studioUrl, + }) + .combine(config) + .toString(); +} diff --git a/web/sanity/utils/index.ts b/web/sanity/utils/index.ts index 94e77e61..641ace86 100644 --- a/web/sanity/utils/index.ts +++ b/web/sanity/utils/index.ts @@ -1,2 +1,3 @@ +export { dataAttr } from "./dataAttr"; export * from "./sanityImageBuilder"; export * from "./sanityModuleLabel"; diff --git a/web/src/app/[locale]/[slug]/page.tsx b/web/src/app/[locale]/[slug]/page.tsx index 6cbeb78b..49875f60 100644 --- a/web/src/app/[locale]/[slug]/page.tsx +++ b/web/src/app/[locale]/[slug]/page.tsx @@ -76,7 +76,11 @@ export default async function Page({ params }: PageProps) { {data.modules?.length ? (

Modules

- +
) : null} diff --git a/web/src/app/[locale]/page.tsx b/web/src/app/[locale]/page.tsx index e407745d..0fc4d308 100644 --- a/web/src/app/[locale]/page.tsx +++ b/web/src/app/[locale]/page.tsx @@ -74,7 +74,11 @@ export default async function Home({ params }: PageProps) { {data.modules?.length ? (

Modules

- +
) : null} diff --git a/web/src/components/modules/ModulesRenderer.tsx b/web/src/components/modules/ModulesRenderer.tsx index c4a56304..32162cee 100644 --- a/web/src/components/modules/ModulesRenderer.tsx +++ b/web/src/components/modules/ModulesRenderer.tsx @@ -5,6 +5,7 @@ import type { ModuleMediaData, ModuleTextData, } from "@/sanity/types/modules"; +import { dataAttr } from "@/sanity/utils/dataAttr"; import { getSanityModuleLabel } from "@/sanity/utils/sanityModuleLabel"; import { ModuleCarousel } from "@/src/components/carousel"; import { ModuleMedia } from "./ModuleMedia"; @@ -12,6 +13,11 @@ import { ModuleText } from "./ModuleText"; type Props = { modules: ContentModule[]; + /** Document `_id` of the page rendering these modules. Used to mark each + * module as Presentation-tool clickable via `data-sanity`. */ + documentId: string; + /** Document `_type` of the page rendering these modules. */ + documentType: string; }; const IS_DEV = process.env.NODE_ENV === "development"; @@ -58,31 +64,45 @@ function UnknownModule({ moduleType }: { moduleType: string | undefined }) { } /** Renders the document `modules[]` stack (one UI block per `module.*` type). */ -export function ModulesRenderer({ modules }: Props) { +export function ModulesRenderer({ modules, documentId, documentType }: Props) { return (
{modules.map((mod, index) => { const key = mod._key ?? `${mod._type ?? "module"}-${index}`; - if (mod._type === "module.text") { - return ; - } - if (mod._type === "module.media") { - return ; - } - if (mod._type === "module.carousel") { - return ( - - ); - } - if (mod._type === "module.contentRefs") { - return ( - - ); - } - return ; + // Only modules with a stable `_key` can be reverse-mapped to a + // GROQ path. Without `_key` (legacy data) the Presentation + // overlay would target the wrong array slot, so we skip it. + const sanityAttr = mod._key + ? dataAttr({ + id: documentId, + type: documentType, + path: `modules[_key=="${mod._key}"]`, + }) + : undefined; + const child = (() => { + if (mod._type === "module.text") { + return ; + } + if (mod._type === "module.media") { + return ; + } + if (mod._type === "module.carousel") { + return ; + } + if (mod._type === "module.contentRefs") { + return ( + + ); + } + return ; + })(); + return ( +
+ {child} +
+ ); })}
); From 84c1d21de1727d5311efa42cc764712ec29ffa8a Mon Sep 17 00:00:00 2001 From: Damian Date: Thu, 28 May 2026 19:37:14 +0200 Subject: [PATCH 016/457] chore(layout): log SanityLive connection errors to console.error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silent failures of the SanityLive socket made it hard to tell whether live content was syncing during draft mode. Pass an `onError` handler that emits the error plus the request context (`includeDrafts`, `waitFor`) to `console.error` — surfaces issues in DevTools without changing behavior. Co-authored-by: Cursor --- web/src/app/layout.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index f890aa32..9310a207 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -151,7 +151,17 @@ export default async function RootLayout({ {children} - {shouldMountSanityLive ? : null} + {shouldMountSanityLive ? ( + { + console.error("[SanityLive] live connection error", { + error, + includeDrafts: context.includeDrafts, + waitFor: context.waitFor, + }); + }} + /> + ) : null} {isDraft ? ( <> From f9d502ae40f94f493ac5dacff249a19dbac23bf3 Mon Sep 17 00:00:00 2001 From: Marcell Lanczos Date: Thu, 28 May 2026 19:53:12 +0200 Subject: [PATCH 017/457] feat(layout): integrate DocumentBootScript for theme management Replaced the inline boot script with a new DocumentBootScript component that sets the theme based on localStorage and adds a 'js-enabled' class to the element. This change enhances the structure and maintainability of the layout while ensuring the theme is applied before the first paint. Updated related comments in animations.css to reflect this change. --- web/src/app/layout.tsx | 35 ++----------------- web/src/assets/styles/animations.css | 2 +- .../components/theme/DocumentBootScript.tsx | 34 ++++++++++++++++++ 3 files changed, 38 insertions(+), 33 deletions(-) create mode 100644 web/src/components/theme/DocumentBootScript.tsx diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 9310a207..7b829b85 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -4,6 +4,7 @@ import { VisualEditing } from "next-sanity/visual-editing"; // import localFont from "next/font/local"; import { SanityLive } from "@/sanity/live"; import { DisableDraftMode } from "@/src/components/sanity/DisableDraftMode"; +import { DocumentBootScript } from "@/src/components/theme/DocumentBootScript"; import { ThemeProvider } from "@/src/contexts/ThemeContext"; import { FALLBACK_SITE_LOCALE_CONFIG } from "@/src/i18n/fallbackSiteLocales"; import "../assets/styles/tokens.css"; @@ -80,34 +81,6 @@ export const metadata: Metadata = { }, }; -/** - * Tiny blocking inline script — runs synchronously before hydration. - * - * 1. Applies the stored theme override (`localStorage["color-scheme"]`) as - * `data-theme="light" | "dark"` on **before** the first paint. For - * `"system"` or a missing value the attribute stays unset, so the - * `@media (prefers-color-scheme: dark)` rule in `variables/colors.css` - * takes over — works without JavaScript. - * 2. Adds `js-enabled` to so CSS can safely start images at opacity:0. - * - * The lazy-image `img-loaded` class is added by `MediaImage` itself via - * `useEffect` — managing it from this inline script raced React 19 streaming - * hydration on cached images and produced "tree hydrated but attributes didn't - * match" warnings. Doing it through React's render flow keeps SSR and the - * first client paint identical. - * - * Without JS the images remain fully visible (no opacity applied) — graceful degradation. - */ -const bootScript = `(function(){ - try{ - var t=localStorage.getItem('color-scheme'); - if(t==='light'||t==='dark'){ - document.documentElement.setAttribute('data-theme',t); - } - }catch(e){} - document.documentElement.classList.add('js-enabled'); -})();`; - /** * Root shell only — avoid `headers()` here (keeps static routes static where possible). * `draftMode()` only toggles Visual Editing UI; locale chrome lives in `app/[locale]/layout.tsx`. @@ -132,10 +105,6 @@ export default async function RootLayout({ suppressHydrationWarning > - {/* Theme class + lazy-image fade-in — must run before first paint */} - {/* biome-ignore lint/security/noDangerouslySetInnerHtml: controlled inline script, no user input */} - -
\ No newline at end of file From 45e25935bc64a16e1ee2619cfe86e4a19fae8fc2 Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 14:45:03 +0200 Subject: [PATCH 088/457] fix(scaffold): generate document-level modules on the variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The templates are shared with main and emitted an internationalizedArrayString title plus a firstLocalizedLabel import and pickLocalizedString/locale props — none of which exist or apply on this branch, so `pnpm gen:module` produced a schema and component that didn't compile. Generate a plain `string` title, a `{ module }` component reading plain values (like ModuleText), a `string | null` type, and print a renderModuleChild snippet that fits this branch's `ctx` signature. The components barrel exists here since 6d464d1, so patching it is required again instead of an optional skip. Verified by scaffolding a probe module: studio:generate, typecheck, check:wiring (5 modules fully wired) and Biome all pass on the output. Co-Authored-By: Claude Opus 5.5 --- .../scaffold-module/bin/scaffold-module.mjs | 19 ++++++------------ .../templates/component.tsx.tmpl | 20 ++++++++----------- .../scaffold-module/templates/query.ts.tmpl | 2 +- .../scaffold-module/templates/schema.ts.tmpl | 11 +++++----- .../scaffold-module/templates/type.ts.tmpl | 4 +--- 5 files changed, 21 insertions(+), 35 deletions(-) diff --git a/packages/scaffold-module/bin/scaffold-module.mjs b/packages/scaffold-module/bin/scaffold-module.mjs index b874a69a..6f525b57 100644 --- a/packages/scaffold-module/bin/scaffold-module.mjs +++ b/packages/scaffold-module/bin/scaffold-module.mjs @@ -119,9 +119,8 @@ async function writeNewFile(target, content, label) { /** * Replace the first match of `search` with `replacement` in the given file. * Returns true if a replacement was made. When `optional` is true, a missing - * file is treated as a skip (used for branch-specific files like the - * components/modules barrel that exists on `main` but not on - * `variant/document-level`). + * file is treated as a skip; otherwise the script aborts with a clear error + * instead of an ENOENT stack trace. */ async function patch(file, search, replacement, description, optional = false) { if (!existsSync(file)) { @@ -219,14 +218,13 @@ if (inline) { ); } -// ── 7: web/src/components/modules/index.ts barrel (optional — variant/document-level has no barrel) ── +// ── 7: web/src/components/modules/index.ts barrel ───────────────────────── const webBarrel = join(root, "web/src/components/modules/index.ts"); await patch( webBarrel, `export { ModuleText } from "./ModuleText";\n`, `export { ModuleText } from "./ModuleText";\nexport { ${componentName} } from "./${componentName}";\n`, "components/modules barrel", - true, ); // ── 8: web/sanity/queries/components/modules/index.ts barrel ────────────── @@ -285,15 +283,10 @@ YOU MUST add the following by hand to web/src/components/modules/ModulesRenderer 2. Add the import: import { ${componentName} } from "./${componentName}"; -3. Add a branch inside renderModuleChild(): +3. Add a branch inside renderModuleChild() (fields are plain values on this + branch; pass ctx.locale / ctx.siteLocale only if the module builds hrefs): if (mod._type === "${moduleId}") { - return ( - <${componentName} - module={mod as ${moduleDataType}} - locale={locale} - siteLocale={siteLocale} - /> - ); + return <${componentName} module={mod as ${moduleDataType}} />; } After editing, run: diff --git a/packages/scaffold-module/templates/component.tsx.tmpl b/packages/scaffold-module/templates/component.tsx.tmpl index 406b725b..d6e38f1a 100644 --- a/packages/scaffold-module/templates/component.tsx.tmpl +++ b/packages/scaffold-module/templates/component.tsx.tmpl @@ -1,28 +1,24 @@ import type { __MODULE_DATA_TYPE__ } from "@/sanity/types/modules"; -import { pickLocalizedString } from "@/sanity/utils/sanityLocalizedText"; -import type { SiteLocaleConfig } from "@/src/i18n/fallbackSiteLocales"; -import { - moduleHeadingClassName, - moduleSectionClassName, -} from "./moduleStyles"; +import { moduleHeadingClassName, moduleSectionClassName } from "./moduleStyles"; // ─── Types ─────────────────────────────────────────────────────────────────── type Props = { module: __MODULE_DATA_TYPE__; - locale: string; - siteLocale: Pick; }; // ─── Component ─────────────────────────────────────────────────────────────── -/** `__MODULE_NAME__` — TODO: describe what this module renders. */ -export function __MODULE_COMPONENT__({ module, locale, siteLocale }: Props) { - const title = pickLocalizedString(module.title, locale, siteLocale); +/** + * `__MODULE_NAME__` — TODO: describe what this module renders. + * Document-level i18n: fields are plain values (document is in one locale). + */ +export function __MODULE_COMPONENT__({ module }: Props) { + const title = typeof module.title === "string" ? module.title.trim() : ""; return ( -
+
{title ?

{title}

: null} {/* TODO: render the rest of the module data */}
diff --git a/packages/scaffold-module/templates/query.ts.tmpl b/packages/scaffold-module/templates/query.ts.tmpl index 5d4e2309..9ae5bbc3 100644 --- a/packages/scaffold-module/templates/query.ts.tmpl +++ b/packages/scaffold-module/templates/query.ts.tmpl @@ -1,5 +1,5 @@ /** - * `__MODULE_NAME__` (`objects/modules/__MODULE_FILE_BASE__.ts`): localized title placeholder. + * `__MODULE_NAME__` (`objects/modules/__MODULE_FILE_BASE__.ts`): title placeholder. * Extend the projection as fields are added to the schema. */ export const __MODULE_QUERY_VAR__ = `_type == "__MODULE_NAME__" => { diff --git a/packages/scaffold-module/templates/schema.ts.tmpl b/packages/scaffold-module/templates/schema.ts.tmpl index cdea8013..4f3ccc81 100644 --- a/packages/scaffold-module/templates/schema.ts.tmpl +++ b/packages/scaffold-module/templates/schema.ts.tmpl @@ -1,8 +1,6 @@ import { ComposeIcon } from "@sanity/icons/Compose"; import { defineType } from "sanity"; -import { firstLocalizedLabel } from "../../../utils/firstLocalizedLabel"; - export const __MODULE_VAR__ = defineType({ name: "__MODULE_NAME__", title: "__MODULE_TITLE__", @@ -12,16 +10,17 @@ export const __MODULE_VAR__ = defineType({ { name: "title", title: "Title", - type: "internationalizedArrayString", + type: "string", }, ], preview: { select: { - titleEntries: "title", + title: "title", }, - prepare({ titleEntries }) { + prepare({ title }) { return { - title: firstLocalizedLabel(titleEntries, "__MODULE_TITLE__"), + title: + typeof title === "string" && title.trim() ? title : "__MODULE_TITLE__", subtitle: "__MODULE_TITLE__ module", }; }, diff --git a/packages/scaffold-module/templates/type.ts.tmpl b/packages/scaffold-module/templates/type.ts.tmpl index 98336262..3d8f1acd 100644 --- a/packages/scaffold-module/templates/type.ts.tmpl +++ b/packages/scaffold-module/templates/type.ts.tmpl @@ -1,8 +1,6 @@ -import type { IntlStringEntry } from "@/sanity/utils"; - export type __MODULE_DATA_TYPE__ = { _type: "__MODULE_NAME__"; _key?: string; - title?: IntlStringEntry[] | null; + title?: string | null; // TODO: extend as fields are added to the schema. }; From 69233859f53dd399e48ddcee97c5412b20abdff3 Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 14:47:59 +0200 Subject: [PATCH 089/457] refactor(sanity): drop the unused NEXT_PUBLIC_SANITY_PROJECT_ID env type 9afe8b7 removed the fallback but left the env type entry. Drop it so the package matches main (#167). Co-Authored-By: Claude Opus 5.5 --- packages/sanity-dataset-resolve/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sanity-dataset-resolve/src/index.ts b/packages/sanity-dataset-resolve/src/index.ts index 6a727fe1..eed0ec3d 100644 --- a/packages/sanity-dataset-resolve/src/index.ts +++ b/packages/sanity-dataset-resolve/src/index.ts @@ -23,7 +23,6 @@ export type SanityDatasetResolveEnv = { /** Web: inlined at build when prefixed with `NEXT_PUBLIC_`. */ NEXT_PUBLIC_SANITY_DATASET?: string | undefined; SANITY_STUDIO_PROJECT_ID?: string | undefined; - NEXT_PUBLIC_SANITY_PROJECT_ID?: string | undefined; SANITY_STUDIO_DATASET_DEVELOPMENT?: string | undefined; SANITY_STUDIO_DATASET_PRODUCTION?: string | undefined; SANITY_STUDIO_DEPLOYMENT_TARGET?: string | undefined; From 56a93a7829d574d0d55f891af4af8b2d109fbf41 Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 15:02:28 +0200 Subject: [PATCH 090/457] fix(studio): scope reference pickers to the edited document's language Under document-level translation every language is its own document, but link.reference and project.categories offered all language variants, so a German nav or page body could link the English page. contentRefs already filtered by language; the three pickers now share one helper. Documents without a language keep the unscoped filter instead of matching nothing. Co-Authored-By: Claude Opus 5.5 --- studio/schemas/README.md | 2 +- studio/schemas/constants/references.ts | 24 +++++++++++++++++++ studio/schemas/documents/project.ts | 17 +++++++++++-- studio/schemas/objects/link.ts | 3 ++- .../objects/modules/moduleContentRefs.ts | 16 +++++-------- 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/studio/schemas/README.md b/studio/schemas/README.md index 84fd2ba9..712a4c1e 100644 --- a/studio/schemas/README.md +++ b/studio/schemas/README.md @@ -113,7 +113,7 @@ You do **not** need a Desk structure item for a module (modules are not document ### Internal page references -`module.contentRefs` uses **`PAGE_REFERENCES`** and **`PROJECT_REFERENCES`** in **`schemas/constants/references.ts`** (`contentRefReferenceFilter`, `contentRefTypesForScope`). Internal **`link`** marks still use **`PAGE_REFERENCES`** only. Routable types: **`page`**, **`project`** (slug), **`home`** and **`work`** (singletons). See section 4 for Presentation paths (`/work`, `/projects/{slug}`). +`module.contentRefs` uses **`PAGE_REFERENCES`** and **`PROJECT_REFERENCES`** in **`schemas/constants/references.ts`** (`contentRefReferenceFilter`, `contentRefTypesForScope`). Internal **`link`** marks still use **`PAGE_REFERENCES`** only. Every picker (links, content refs, project categories) is wrapped in **`sameLanguageReferenceFilter`**, so it only offers documents in the edited document's `language`. Routable types: **`page`**, **`project`** (slug), **`home`** and **`work`** (singletons). See section 4 for Presentation paths (`/work`, `/projects/{slug}`). ## 9. Mux / media diff --git a/studio/schemas/constants/references.ts b/studio/schemas/constants/references.ts index 8563b45e..3bcb1851 100644 --- a/studio/schemas/constants/references.ts +++ b/studio/schemas/constants/references.ts @@ -1,3 +1,5 @@ +import type { ReferenceFilterResolverContext } from "sanity"; + /** * Routable document types for internal links. Extend when adding new page types. */ @@ -12,6 +14,28 @@ export const PROJECT_REFERENCES = [{ type: "project" }] as const; export const PROJECT_REFERENCE_FILTER = '_type == "project" && defined(slug.current)'; +export const PROJECT_CATEGORY_REFERENCE_FILTER = '_type == "projectCategory"'; + +/** + * Narrows a reference picker to the language of the document being edited. + * Every translatable type carries `language` (`TRANSLATABLE_SCHEMA_TYPES` in + * `sanity.config.ts`), so without this a German nav or page body could link + * the English variant of a page. Documents without a language keep `filter`. + */ +export function sameLanguageReferenceFilter( + filter: string | ((context: ReferenceFilterResolverContext) => string), +) { + return (context: ReferenceFilterResolverContext) => { + const base = typeof filter === "function" ? filter(context) : filter; + const language = (context.document as { language?: unknown }).language; + if (typeof language !== "string" || !language) return { filter: base }; + return { + filter: `(${base}) && language == $language`, + params: { language }, + }; + }; +} + export const CONTENT_REF_SOURCE_SCOPES = ["all", "pages", "projects"] as const; export type ContentRefSourceScope = (typeof CONTENT_REF_SOURCE_SCOPES)[number]; diff --git a/studio/schemas/documents/project.ts b/studio/schemas/documents/project.ts index f2ae4246..614c143d 100644 --- a/studio/schemas/documents/project.ts +++ b/studio/schemas/documents/project.ts @@ -2,9 +2,12 @@ import { CaseIcon } from "@sanity/icons/Case"; import { SearchIcon } from "@sanity/icons/Search"; import { TextIcon } from "@sanity/icons/Text"; import { defineType, type PreviewValue } from "sanity"; - import { validateModuleMediaRequired } from "../../utils/validateModuleMedia"; import { isUniqueLocaleAgnostic, validateSlug } from "../../utils/validateSlug"; +import { + PROJECT_CATEGORY_REFERENCE_FILTER, + sameLanguageReferenceFilter, +} from "../constants/references"; export const project = defineType({ name: "project", @@ -49,7 +52,17 @@ export const project = defineType({ name: "categories", title: "Categories", type: "array", - of: [{ type: "reference", to: [{ type: "projectCategory" }] }], + of: [ + { + type: "reference", + to: [{ type: "projectCategory" }], + options: { + filter: sameLanguageReferenceFilter( + PROJECT_CATEGORY_REFERENCE_FILTER, + ), + }, + }, + ], group: "editorial", }, { diff --git a/studio/schemas/objects/link.ts b/studio/schemas/objects/link.ts index 9cd6f299..c5048cff 100644 --- a/studio/schemas/objects/link.ts +++ b/studio/schemas/objects/link.ts @@ -4,6 +4,7 @@ import { defineType } from "sanity"; import { PAGE_REFERENCE_FILTER, PAGE_REFERENCES, + sameLanguageReferenceFilter, } from "../constants/references"; export const link = defineType({ @@ -51,7 +52,7 @@ export const link = defineType({ weak: true, to: [...PAGE_REFERENCES], options: { - filter: PAGE_REFERENCE_FILTER, + filter: sameLanguageReferenceFilter(PAGE_REFERENCE_FILTER), }, hidden: ({ parent }) => parent?.type !== "internal", validation: (rule) => diff --git a/studio/schemas/objects/modules/moduleContentRefs.ts b/studio/schemas/objects/modules/moduleContentRefs.ts index 433de2d9..996a053b 100644 --- a/studio/schemas/objects/modules/moduleContentRefs.ts +++ b/studio/schemas/objects/modules/moduleContentRefs.ts @@ -7,6 +7,7 @@ import { contentRefReferenceFilter, PAGE_REFERENCES, PROJECT_REFERENCES, + sameLanguageReferenceFilter, } from "../../constants/references"; type ContentRefsParent = { @@ -91,16 +92,11 @@ export const moduleContentRefs = defineType({ weak: true, to: [...PAGE_REFERENCES, ...PROJECT_REFERENCES], options: { - filter: ({ parent, document }) => { - const scope = scopeFromParent(parent as ContentRefsParent); - const language = (document as { language?: string } | null) - ?.language; - const base = contentRefReferenceFilter(scope); - return { - filter: `(${base}) && language == $language`, - params: { language: language ?? "" }, - }; - }, + filter: sameLanguageReferenceFilter(({ parent }) => + contentRefReferenceFilter( + scopeFromParent(parent as ContentRefsParent), + ), + ), }, }, ], From 39f4cc147be67484fa957512ff7f08fd109eb7f7 Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 15:02:47 +0200 Subject: [PATCH 091/457] fix(web): show the 404 copy in the URL's language errorSettings is one document per language on this branch, but the 404 page only fetched the default locale's copy, so /de/missing rendered the English title and body. Fetch every language's copy (cached, one small document each) and let the client pick the URL locale, falling back to the default language when a translation is missing. Keeps the segment static. Co-Authored-By: Claude Opus 5.5 --- web/src/app/[locale]/LocaleNotFoundContent.tsx | 11 ++++++++--- web/src/app/[locale]/not-found.tsx | 18 +++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/web/src/app/[locale]/LocaleNotFoundContent.tsx b/web/src/app/[locale]/LocaleNotFoundContent.tsx index 10751d31..9d537199 100644 --- a/web/src/app/[locale]/LocaleNotFoundContent.tsx +++ b/web/src/app/[locale]/LocaleNotFoundContent.tsx @@ -8,13 +8,18 @@ import { RichTextMedia } from "@/src/components/text/RichTextMedia"; import { useLanguage } from "@/src/contexts/LanguageContext"; type Props = { - errorSettings: ErrorSettingsDocument | null; + /** `errorSettings` per locale id — one document per language. */ + errorSettingsByLocale: Readonly>; }; -export function LocaleNotFoundContent({ errorSettings }: Props) { +export function LocaleNotFoundContent({ errorSettingsByLocale }: Props) { const pathname = usePathname() ?? "/"; - const { localeFromPathname, localePath } = useLanguage(); + const { defaultLocale, localeFromPathname, localePath } = useLanguage(); const locale = localeFromPathname(pathname); + const errorSettings = + errorSettingsByLocale[locale] ?? + errorSettingsByLocale[defaultLocale] ?? + null; const title = errorSettings?.notFoundTitle?.trim() || "Page not found"; const body = errorSettings?.notFoundBody ?? []; diff --git a/web/src/app/[locale]/not-found.tsx b/web/src/app/[locale]/not-found.tsx index a59e791f..9df70b0e 100644 --- a/web/src/app/[locale]/not-found.tsx +++ b/web/src/app/[locale]/not-found.tsx @@ -9,12 +9,20 @@ import { LocaleNotFoundContent } from "./LocaleNotFoundContent"; * Server: cached Sanity settings only. The actual URL locale is resolved * client-side in `LocaleNotFoundContent` (`usePathname`) so we never call * `headers()` here — that would mark the whole `[locale]` segment as dynamic - * and disable SSG for `/[locale]`. The server fetch uses the site default - * locale; if it doesn't match the URL the client can still render the - * generic fallback copy. + * and disable SSG for `/[locale]`. `errorSettings` is one document per + * language, so every language's copy is fetched and the client picks the one + * matching the URL. */ export default async function NotFound() { const siteLocale = await fetchSiteLanguageSettings(); - const errorSettings = await fetchErrorSettings(siteLocale.defaultLocale); - return ; + const errorSettingsByLocale = Object.fromEntries( + await Promise.all( + siteLocale.localeIds.map( + async (locale) => [locale, await fetchErrorSettings(locale)] as const, + ), + ), + ); + return ( + + ); } From 242d84d4f8bdfcdc12544f7b766b902a264d232c Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 15:05:35 +0200 Subject: [PATCH 092/457] fix(seo): build hreflang from linked translations, not the current path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each language is its own document with its own slug on this branch, but the metadata alternates swapped the locale prefix of the current path, so /about advertised /de/about even when the German page lives at /de/ueber-uns or doesn't exist; the sitemap left alternates out entirely. Route queries and the sitemap now project `translations` — every published language variant via translation.metadata (page, project) or by type for the per-locale singletons (home, work). Metadata and sitemap share one hreflang builder that lists only variants that exist, plus x-default when the default language has one. Co-Authored-By: Claude Opus 5.5 --- web/sanity/queries/pages/home.ts | 4 +- web/sanity/queries/pages/page.ts | 4 +- web/sanity/queries/pages/project.ts | 4 +- web/sanity/queries/pages/work.ts | 4 +- web/sanity/queries/snippets/README.md | 1 + web/sanity/queries/snippets/sitemap.ts | 12 +-- web/sanity/queries/snippets/translations.ts | 34 ++++++++ web/sanity/sanity.types.gen.ts | 92 ++++++++++++++++++++- web/sanity/seo/resolveSanityMetadata.ts | 72 ++++++++++++---- web/sanity/types/index.ts | 7 +- web/sanity/types/pages.ts | 15 ++++ web/src/app/sitemap.ts | 15 +++- web/src/i18n/README.md | 2 +- web/src/i18n/siteLocalePathUtils.ts | 33 ++++++++ 14 files changed, 263 insertions(+), 36 deletions(-) create mode 100644 web/sanity/queries/snippets/translations.ts diff --git a/web/sanity/queries/pages/home.ts b/web/sanity/queries/pages/home.ts index 53843e23..d9afd947 100644 --- a/web/sanity/queries/pages/home.ts +++ b/web/sanity/queries/pages/home.ts @@ -2,6 +2,7 @@ import { defineQuery } from "next-sanity"; import { modulesQuery } from "../components/modules"; import { pageSeoQuery } from "../snippets/seo"; +import { translationsQuery } from "../snippets/translations"; export const homeQuery = defineQuery(`*[_type == "home" && language == $locale][0]{ @@ -9,5 +10,6 @@ export const homeQuery = title, language, ${modulesQuery}, - ${pageSeoQuery} + ${pageSeoQuery}, + ${translationsQuery} }`); diff --git a/web/sanity/queries/pages/page.ts b/web/sanity/queries/pages/page.ts index 50e0c195..9091483d 100644 --- a/web/sanity/queries/pages/page.ts +++ b/web/sanity/queries/pages/page.ts @@ -2,6 +2,7 @@ import { defineQuery } from "next-sanity"; import { modulesQuery } from "../components/modules"; import { pageSeoQuery } from "../snippets/seo"; +import { translationsQuery } from "../snippets/translations"; export const pageBySlugQuery = defineQuery(`*[_type == "page" && slug.current == $slug && language == $locale][0]{ @@ -10,5 +11,6 @@ export const pageBySlugQuery = slug, language, ${modulesQuery}, - ${pageSeoQuery} + ${pageSeoQuery}, + ${translationsQuery} }`); diff --git a/web/sanity/queries/pages/project.ts b/web/sanity/queries/pages/project.ts index 6417b20e..cf645dba 100644 --- a/web/sanity/queries/pages/project.ts +++ b/web/sanity/queries/pages/project.ts @@ -3,6 +3,7 @@ import { defineQuery } from "next-sanity"; import { moduleMediaInnerFields } from "../components/modules/media"; import { richTextMediaQuery } from "../components/text/richTextMedia"; import { pageSeoQuery } from "../snippets/seo"; +import { translationsQuery } from "../snippets/translations"; /** Project detail — one document per locale (doc-level i18n). */ export const projectBySlugQuery = @@ -18,5 +19,6 @@ export const projectBySlugQuery = body[]{ ${richTextMediaQuery} }, - ${pageSeoQuery} + ${pageSeoQuery}, + ${translationsQuery} }`); diff --git a/web/sanity/queries/pages/work.ts b/web/sanity/queries/pages/work.ts index 22e51f25..e69c6e55 100644 --- a/web/sanity/queries/pages/work.ts +++ b/web/sanity/queries/pages/work.ts @@ -2,6 +2,7 @@ import { defineQuery } from "next-sanity"; import { modulesQuery } from "../components/modules"; import { pageSeoQuery } from "../snippets/seo"; +import { translationsQuery } from "../snippets/translations"; /** `work` landing — one document per locale (doc-level i18n). */ export const workQuery = @@ -10,5 +11,6 @@ export const workQuery = title, language, ${modulesQuery}, - ${pageSeoQuery} + ${pageSeoQuery}, + ${translationsQuery} }`); diff --git a/web/sanity/queries/snippets/README.md b/web/sanity/queries/snippets/README.md index 5646918d..9c97259a 100644 --- a/web/sanity/queries/snippets/README.md +++ b/web/sanity/queries/snippets/README.md @@ -13,6 +13,7 @@ Exports are re-exported from `@/sanity/queries` (see `queries/index.ts`). | `media.ts` | `imageQuery`, `videoQuery`, `mediaQuery`, `mediaQuerySpread` | Image / Mux shapes; `mediaQuery` for keyed values, `mediaQuerySpread` inside `image{ … }` / `video{ … }` (GROQ requires spread there) | | `link.ts` | `linkQuery` | Portable Text `link` marks: `internal` (route, slug, `resolvedReference`), `external` (href, blank), `function` (`func.key` / `func.params` per `linkFunctions`) | | `settings.ts` | `siteNavMenusQuery`, `navMenusQuery`, `errorSettingsQuery`, `siteCookieBannerLayoutQuery`, `siteLanguageSettingsQuery`, `siteSettingsTitleQuery`, `siteSettingsFaviconQuery` | Lean singleton projections for the app shell (each fetched via its `fetch*` helper in `fetchSanityData.ts`) | +| `translations.ts` | `routePathProjection`, `translationsQuery` | Route path of a document without locale prefix; `translations` = every published language variant (`page`/`project` via `translation.metadata`, `home`/`work` by type) for `hreflang` and the language switch | | `sitemap.ts` | `pageSlugsQuery`, `sitemapPagesQuery` | Slug list for `[slug]` routes; sitemap rows include `home` + all `page` docs with `path` / `lastmod` fields | ## Using a snippet in a custom query (Next.js) diff --git a/web/sanity/queries/snippets/sitemap.ts b/web/sanity/queries/snippets/sitemap.ts index 7d078351..d798be74 100644 --- a/web/sanity/queries/snippets/sitemap.ts +++ b/web/sanity/queries/snippets/sitemap.ts @@ -1,5 +1,7 @@ import { defineQuery } from "next-sanity"; +import { routePathProjection, translationsQuery } from "./translations"; + /** * Minimal list of routable page slugs across all languages — for * `generateStaticParams` and simple slug lists. Home is excluded (it lives @@ -26,6 +28,8 @@ export const projectSlugsQuery = * - `path`: URL path from site root, without the locale prefix (the route * layer adds it per `language`). * - `language`: the document's locale (one row per language variant). + * - `translations`: every language variant of the row (itself included) for + * the sitemap's `hreflang` alternates. * * Extend the filter when you add new routable types. */ @@ -43,10 +47,6 @@ export const sitemapPagesQuery = defineQuery(`*[ _type in ["home", "work"] => null, slug.current ), - "path": select( - _type == "home" => "/", - _type == "work" => "/work", - _type == "project" => "/work/" + slug.current, - "/" + slug.current - ) + "path": ${routePathProjection}, + ${translationsQuery} }`); diff --git a/web/sanity/queries/snippets/translations.ts b/web/sanity/queries/snippets/translations.ts new file mode 100644 index 00000000..725ee00f --- /dev/null +++ b/web/sanity/queries/snippets/translations.ts @@ -0,0 +1,34 @@ +/** + * URL path of a routable document without the locale prefix — the route + * layer adds it per `language` (`createLanguagePathUtils().localePath`). + * Extend when adding routable types. + */ +export const routePathProjection = `select( + _type == "home" => "/", + _type == "work" => "/work", + _type == "project" => "/work/" + slug.current, + "/" + slug.current +)`; + +/** + * Every language variant of the current document as `{ language, path }`, + * the document itself included. Slugs differ per language, so this — not the + * current path — feeds `hreflang` alternates and the language switch. + * + * - `page` / `project`: linked through the `translation.metadata` document + * that `@sanity/document-internationalization` maintains. + * - `home` / `work`: one singleton per language, found by type. + * + * Variants that are unpublished (or deleted) resolve to `null` — callers drop + * entries without a `language` or `path`. + */ +export const translationsQuery = `"translations": select( + _type in ["home", "work"] => *[_type == ^._type && defined(language)]{ + language, + "path": ${routePathProjection} + }, + *[_type == "translation.metadata" && references(^._id)][0].translations[].value->{ + language, + "path": ${routePathProjection} + } +)`; diff --git a/web/sanity/sanity.types.gen.ts b/web/sanity/sanity.types.gen.ts index 971bb11a..17f1ae6b 100644 --- a/web/sanity/sanity.types.gen.ts +++ b/web/sanity/sanity.types.gen.ts @@ -918,7 +918,7 @@ export type ProjectSlugsQueryResult = Array<{ // Source: sanity/queries/snippets/sitemap.ts // Variable: sitemapPagesQuery -// Query: *[ _type == "home" || _type == "work" || (_type == "page" && defined(slug.current)) || (_type == "project" && defined(slug.current))]{ _id, _type, _updatedAt, language, "slug": select( _type in ["home", "work"] => null, slug.current ), "path": select( _type == "home" => "/", _type == "work" => "/work", _type == "project" => "/work/" + slug.current, "/" + slug.current )} +// Query: *[ _type == "home" || _type == "work" || (_type == "page" && defined(slug.current)) || (_type == "project" && defined(slug.current))]{ _id, _type, _updatedAt, language, "slug": select( _type in ["home", "work"] => null, slug.current ), "path": select( _type == "home" => "/", _type == "work" => "/work", _type == "project" => "/work/" + slug.current, "/" + slug.current), "translations": select( _type in ["home", "work"] => *[_type == ^._type && defined(language)]{ language, "path": select( _type == "home" => "/", _type == "work" => "/work", _type == "project" => "/work/" + slug.current, "/" + slug.current) }, *[_type == "translation.metadata" && references(^._id)][0].translations[].value->{ language, "path": select( _type == "home" => "/", _type == "work" => "/work", _type == "project" => "/work/" + slug.current, "/" + slug.current) })} export type SitemapPagesQueryResult = Array< | { _id: string; @@ -927,6 +927,31 @@ export type SitemapPagesQueryResult = Array< language: string | null; slug: null; path: "/"; + translations: + | Array<{ + language: string; + path: "/"; + }> + | Array< + | { + language: string | null; + path: null; + } + | { + language: string | null; + path: "/"; + } + | { + language: string | null; + path: "/work"; + } + | { + language: string | null; + path: string; + } + | null + > + | null; } | { _id: string; @@ -935,6 +960,25 @@ export type SitemapPagesQueryResult = Array< language: string | null; slug: string; path: string; + translations: Array< + | { + language: string | null; + path: null; + } + | { + language: string | null; + path: "/"; + } + | { + language: string | null; + path: "/work"; + } + | { + language: string | null; + path: string; + } + | null + > | null; } | { _id: string; @@ -943,6 +987,25 @@ export type SitemapPagesQueryResult = Array< language: string | null; slug: string; path: string; + translations: Array< + | { + language: string | null; + path: null; + } + | { + language: string | null; + path: "/"; + } + | { + language: string | null; + path: "/work"; + } + | { + language: string | null; + path: string; + } + | null + > | null; } | { _id: string; @@ -951,6 +1014,31 @@ export type SitemapPagesQueryResult = Array< language: string | null; slug: null; path: "/work"; + translations: + | Array<{ + language: string; + path: "/work"; + }> + | Array< + | { + language: string | null; + path: null; + } + | { + language: string | null; + path: "/"; + } + | { + language: string | null; + path: "/work"; + } + | { + language: string | null; + path: string; + } + | null + > + | null; } >; @@ -963,7 +1051,7 @@ declare global { '*[_type == "siteCookieBanner" && language == $locale][0]{\n _id,\n language,\n useCookieBanner,\n consentModal,\n preferencesModal\n}': SiteCookieBannerLayoutQueryResult; '*[_type == "page" && defined(slug.current)]{\n "slug": slug.current,\n language\n}': PageSlugsQueryResult; '*[_type == "project" && defined(slug.current)]{\n "slug": slug.current,\n language\n}': ProjectSlugsQueryResult; - '*[\n _type == "home" ||\n _type == "work" ||\n (_type == "page" && defined(slug.current)) ||\n (_type == "project" && defined(slug.current))\n]{\n _id,\n _type,\n _updatedAt,\n language,\n "slug": select(\n _type in ["home", "work"] => null,\n slug.current\n ),\n "path": select(\n _type == "home" => "/",\n _type == "work" => "/work",\n _type == "project" => "/work/" + slug.current,\n "/" + slug.current\n )\n}': SitemapPagesQueryResult; + '*[\n _type == "home" ||\n _type == "work" ||\n (_type == "page" && defined(slug.current)) ||\n (_type == "project" && defined(slug.current))\n]{\n _id,\n _type,\n _updatedAt,\n language,\n "slug": select(\n _type in ["home", "work"] => null,\n slug.current\n ),\n "path": select(\n _type == "home" => "/",\n _type == "work" => "/work",\n _type == "project" => "/work/" + slug.current,\n "/" + slug.current\n),\n "translations": select(\n _type in ["home", "work"] => *[_type == ^._type && defined(language)]{\n language,\n "path": select(\n _type == "home" => "/",\n _type == "work" => "/work",\n _type == "project" => "/work/" + slug.current,\n "/" + slug.current\n)\n },\n *[_type == "translation.metadata" && references(^._id)][0].translations[].value->{\n language,\n "path": select(\n _type == "home" => "/",\n _type == "work" => "/work",\n _type == "project" => "/work/" + slug.current,\n "/" + slug.current\n)\n }\n)\n}': SitemapPagesQueryResult; } } // Lets @sanity/client releases that predate the global registry read it too diff --git a/web/sanity/seo/resolveSanityMetadata.ts b/web/sanity/seo/resolveSanityMetadata.ts index ffc2a47d..10a8f71d 100644 --- a/web/sanity/seo/resolveSanityMetadata.ts +++ b/web/sanity/seo/resolveSanityMetadata.ts @@ -1,10 +1,15 @@ import type { Metadata } from "next"; import type { SiteLocaleConfig } from "@/src/i18n/fallbackSiteLocales"; -import { createLanguagePathUtils } from "@/src/i18n/siteLocalePathUtils"; +import { + createLanguagePathUtils, + type TranslationPath, + translationPathsByLocale, +} from "@/src/i18n/siteLocalePathUtils"; import { SITE_BASE_URL } from "@/src/utils/siteUrl"; import type { + DocumentTranslation, HomeDocument, PageDocument, PageSeo, @@ -12,6 +17,8 @@ import type { WorkDocument, } from "../types/pages"; +type SiteLocaleSlice = Pick; + function firstNonEmpty( ...values: (string | null | undefined)[] ): string | undefined { @@ -56,30 +63,52 @@ export type ResolveSanityMetadataInput = { path?: string; /** Active locale id for canonical URL. */ currentLocale?: string; - /** Site locale config used to build `hreflang` entries for every available language. */ - siteLocale?: Pick | null; + /** Site locale config — which `hreflang` ids are valid and which is `x-default`. */ + siteLocale?: SiteLocaleSlice | null; + /** Published language variants of the document (`translationsQuery`) for `hreflang`. */ + translations?: readonly DocumentTranslation[] | null; }; +/** + * `hreflang` map for one document: an absolute URL per published language + * variant, plus `x-default` → the default-language variant when there is one. + * Each language has its own document and slug here, so only variants that + * exist are listed — never the current path under every prefix. `self` always + * counts, so a document without linked translations still points at itself. + */ +export function hreflangLanguages( + self: { language: string; path: string }, + translations: readonly TranslationPath[] | null | undefined, + siteLocale: SiteLocaleSlice, +): Record { + const byLocale = translationPathsByLocale( + [self, ...(translations ?? [])], + createLanguagePathUtils(siteLocale), + ); + const languages: Record = {}; + for (const [id, path] of Object.entries(byLocale)) { + languages[id] = `${SITE_BASE_URL}${path}`; + } + const defaultPath = byLocale[siteLocale.defaultLocale]; + if (defaultPath) languages["x-default"] = `${SITE_BASE_URL}${defaultPath}`; + return languages; +} + function buildAlternates( path: string | undefined, currentLocale: string | undefined, - siteLocale: - | Pick - | null - | undefined, + siteLocale: SiteLocaleSlice | null | undefined, + translations: readonly DocumentTranslation[] | null | undefined, ): Metadata["alternates"] | undefined { if (!path || !currentLocale || !siteLocale) return undefined; const pathUtils = createLanguagePathUtils(siteLocale); - const canonicalPath = pathUtils.localePath(path, currentLocale); - const languages: Record = {}; - for (const id of siteLocale.localeIds) { - languages[id] = `${SITE_BASE_URL}${pathUtils.localePath(path, id)}`; - } - languages["x-default"] = - `${SITE_BASE_URL}${pathUtils.localePath(path, siteLocale.defaultLocale)}`; return { - canonical: `${SITE_BASE_URL}${canonicalPath}`, - languages, + canonical: `${SITE_BASE_URL}${pathUtils.localePath(path, currentLocale)}`, + languages: hreflangLanguages( + { language: currentLocale, path }, + translations, + siteLocale, + ), }; } @@ -95,6 +124,7 @@ export function resolveSanityMetadata({ path, currentLocale, siteLocale, + translations, }: ResolveSanityMetadataInput): Metadata { const merged = mergePageAndSettingsSeo(seo ?? null, settingsSeo ?? null); const metaTitle = @@ -102,7 +132,12 @@ export function resolveSanityMetadata({ const description = merged.description?.trim() || undefined; const ogImage = merged.imageUrl?.trim() || undefined; const resolvedSiteName = siteName || siteTitleFallback; - const alternates = buildAlternates(path, currentLocale, siteLocale); + const alternates = buildAlternates( + path, + currentLocale, + siteLocale, + translations, + ); return { title: metaTitle, @@ -136,7 +171,7 @@ export type MetadataFromSanityPageDataInput = { /** Site-wide SEO fallback from `siteSettings` (via `fetchSettingsSeoFallback`). */ settingsSeo?: PageSeo; /** Locale config for `hreflang` alternates. */ - siteLocale?: Pick | null; + siteLocale?: SiteLocaleSlice | null; /** Locale-agnostic path (`/` for home, `/{slug}` for pages) — required for canonical + alternates. */ path?: string; /** `siteSettings.title` — Open Graph `siteName` and last-resort meta title; tab suffix comes from layout `title.template`. */ @@ -167,6 +202,7 @@ export function metadataFromSanityPageData({ path, currentLocale: locale, siteLocale: siteLocale ?? null, + translations: data.translations, ...(brand ? { siteTitleFallback: brand, siteName: brand } : {}), }); } diff --git a/web/sanity/types/index.ts b/web/sanity/types/index.ts index f886923d..b7043055 100644 --- a/web/sanity/types/index.ts +++ b/web/sanity/types/index.ts @@ -1,5 +1,10 @@ export type { ErrorSettingsDocument } from "./errorSettings"; -export type { HomeDocument, PageDocument, PageSeo } from "./pages"; +export type { + DocumentTranslation, + HomeDocument, + PageDocument, + PageSeo, +} from "./pages"; export type { CookieConsentModalCopy, CookiePreferencesModalCopy, diff --git a/web/sanity/types/pages.ts b/web/sanity/types/pages.ts index 2080895a..744f6bdc 100644 --- a/web/sanity/types/pages.ts +++ b/web/sanity/types/pages.ts @@ -13,12 +13,21 @@ export type PageSeo = { imageUrl?: string | null; } | null; +/** One language variant of a route document (`translationsQuery`). */ +export type DocumentTranslation = { + language?: string | null; + /** Route path without the locale prefix (`/`, `/work`, `/about`, `/work/x`). */ + path?: string | null; +} | null; + export type HomeDocument = { _id: string; language?: string | null; title?: string | null; modules?: ContentModule[] | null; seo?: PageSeo; + /** Every published language variant, this document included. */ + translations?: DocumentTranslation[] | null; }; export type PageDocument = { @@ -28,6 +37,8 @@ export type PageDocument = { slug?: { current?: string | null } | null; modules?: ContentModule[] | null; seo?: PageSeo; + /** Every published language variant, this document included. */ + translations?: DocumentTranslation[] | null; }; export type WorkDocument = { @@ -36,6 +47,8 @@ export type WorkDocument = { title?: string | null; modules?: ContentModule[] | null; seo?: PageSeo; + /** Every published language variant, this document included. */ + translations?: DocumentTranslation[] | null; }; export type ProjectDocument = { @@ -46,4 +59,6 @@ export type ProjectDocument = { slug?: { current?: string | null } | null; body?: PortableTextBlock[] | null; seo?: PageSeo; + /** Every published language variant, this document included. */ + translations?: DocumentTranslation[] | null; }; diff --git a/web/src/app/sitemap.ts b/web/src/app/sitemap.ts index 335ccf06..4f55d87a 100644 --- a/web/src/app/sitemap.ts +++ b/web/src/app/sitemap.ts @@ -1,6 +1,7 @@ import type { MetadataRoute } from "next"; import { cachedSitemapPages } from "@/sanity/cachedSanityQuery"; import { fetchSiteLanguageSettings } from "@/sanity/fetchSanityData"; +import { hreflangLanguages } from "@/sanity/seo/resolveSanityMetadata"; import { createLanguagePathUtils } from "@/src/i18n/siteLocalePathUtils"; import { SITE_BASE_URL } from "@/src/utils/siteUrl"; @@ -13,10 +14,9 @@ export const revalidate = 3600; /** * One sitemap entry per language variant of a routable document. With - * document-level translation each language is its own document — slugs may - * differ between languages, and the cross-locale relationship lives in - * `translation.metadata` (not queried here). Hreflang alternates are - * therefore omitted; add them in a follow-up if you need them. + * document-level translation each language is its own document and slugs may + * differ, so the `hreflang` alternates come from the row's `translations` + * (linked via `translation.metadata`), not from the row's own path. */ export default async function sitemap(): Promise { const [pages, siteLocale] = await Promise.all([ @@ -41,6 +41,13 @@ export default async function sitemap(): Promise { lastModified: page._updatedAt, changeFrequency: page._type === "home" ? "daily" : "weekly", priority: page._type === "home" ? 1.0 : 0.8, + alternates: { + languages: hreflangLanguages( + { language, path: page.path }, + page.translations, + siteLocale, + ), + }, }); } diff --git a/web/src/i18n/README.md b/web/src/i18n/README.md index f70f2a8d..e2142048 100644 --- a/web/src/i18n/README.md +++ b/web/src/i18n/README.md @@ -17,7 +17,7 @@ This folder wires **URL language** to **`[locale]` routes** and shared helpers. | File | Role | |------|------| | **`fallbackSiteLocales.ts`** | Minimal **`en`** fallback when `siteLanguageSettings` is missing or invalid; also used for static bootstrap (e.g. root `` before client sync). | -| **`siteLocalePathUtils.ts`** | `createLanguagePathUtils({ defaultLocale, localeIds })` — `localePath`, prefixes, `isAppLocale`. | +| **`siteLocalePathUtils.ts`** | `createLanguagePathUtils({ defaultLocale, localeIds })` — `localePath`, prefixes, `isAppLocale`. `translationPathsByLocale(translations, pathUtils)` — locale → prefixed path for a document's language variants (slugs differ per language; feeds `hreflang`, the sitemap and the language switch). | | **`proxyLocaleFetch.ts`** | CDN fetch + short cache for **`proxy.ts`**. | | **`config.ts`** | `AppLocale` (string), `LOCALE_HEADER_NAME`. | | **`site-locales.ts`** | `LOCALE_HEADER_NAME` for `proxy.ts` / headers. | diff --git a/web/src/i18n/siteLocalePathUtils.ts b/web/src/i18n/siteLocalePathUtils.ts index 3bfcc9e8..56880152 100644 --- a/web/src/i18n/siteLocalePathUtils.ts +++ b/web/src/i18n/siteLocalePathUtils.ts @@ -61,3 +61,36 @@ export function createLanguagePathUtils( localePath, }; } + +/** One language variant of a document — see `translationsQuery`. */ +export type TranslationPath = { + language?: string | null; + path?: string | null; +} | null; + +/** + * Locale id → URL path (locale prefix applied) for every variant whose + * language is a site locale and that has a path. Slugs differ per language + * under document-level translation, so alternates come from here, never from + * swapping the prefix of the current path. Ordered by `localeIds`; the first + * entry per language wins. + */ +export function translationPathsByLocale( + translations: readonly TranslationPath[] | null | undefined, + pathUtils: LanguagePathUtils, +): Record { + const found = new Map(); + for (const entry of translations ?? []) { + const language = entry?.language?.trim(); + const path = entry?.path?.trim(); + if (!language || !path || found.has(language)) continue; + if (!pathUtils.isAppLocale(language)) continue; + found.set(language, pathUtils.localePath(path, language)); + } + const byLocale: Record = {}; + for (const id of pathUtils.localeIds) { + const path = found.get(id); + if (path) byLocale[id] = path; + } + return byLocale; +} From 966586182f2c435254254a2adac0d2b543b7d8fa Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 15:08:52 +0200 Subject: [PATCH 093/457] fix(web): switch languages to the translated document, not the same path setLocale swapped the locale prefix of the current path, which only works when every language shares the slug. On this branch /about's German page can live at /de/ueber-uns, so the switch sent readers to a 404. Route pages now render LocaleAlternates with the document's translations; the language switch navigates to the registered variant, or to the target locale's home when the document has no translation there. Routes that register nothing (the 404 page) keep the prefix swap. Registrations are keyed by pathname so a stale entry from the previous page is ignored. Co-Authored-By: Claude Opus 5.5 --- web/README.md | 2 +- web/src/app/[locale]/[slug]/page.tsx | 6 +++ web/src/app/[locale]/page.tsx | 6 +++ web/src/app/[locale]/work/[slug]/page.tsx | 6 +++ web/src/app/[locale]/work/page.tsx | 6 +++ .../navigation/LocaleAlternates.tsx | 41 ++++++++++++++++ web/src/contexts/LanguageContext.tsx | 48 +++++++++++++++++-- 7 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 web/src/components/navigation/LocaleAlternates.tsx diff --git a/web/README.md b/web/README.md index 6d082510..23201c71 100644 --- a/web/README.md +++ b/web/README.md @@ -29,7 +29,7 @@ If the singleton is missing or invalid, the web app falls back to **en** + **de* **Reserved path segments:** every **non-default** language `id` is a first path segment (for example `de` in `/de/about`). Do not use those strings as page slugs on the default-locale site. -**Routing:** [`src/proxy.ts`](./src/proxy.ts) reads locales from the Sanity CDN (cached). Client links use [`LanguageContext`](./src/contexts/LanguageContext.tsx) (`localePath`, etc.). +**Routing:** [`src/proxy.ts`](./src/proxy.ts) reads locales from the Sanity CDN (cached). Client links use [`LanguageContext`](./src/contexts/LanguageContext.tsx) (`localePath`, etc.). Slugs differ per language, so each route page renders [`LocaleAlternates`](./src/components/navigation/LocaleAlternates.tsx) with its `translations`; the language switch follows those instead of swapping the URL prefix. --- diff --git a/web/src/app/[locale]/[slug]/page.tsx b/web/src/app/[locale]/[slug]/page.tsx index 583222e2..f92789a7 100644 --- a/web/src/app/[locale]/[slug]/page.tsx +++ b/web/src/app/[locale]/[slug]/page.tsx @@ -10,6 +10,7 @@ import { } from "@/sanity/fetchSanityData"; import { metadataFromSanityPageData } from "@/sanity/seo/resolveSanityMetadata"; import { ModulesRenderer } from "@/src/components/modules/ModulesRenderer"; +import { LocaleAlternates } from "@/src/components/navigation/LocaleAlternates"; type PageProps = { params: Promise<{ locale: string; slug: string }>; @@ -75,6 +76,11 @@ export default async function Page({ params }: PageProps) { return (
+
{data.modules?.length ? (
diff --git a/web/src/app/[locale]/page.tsx b/web/src/app/[locale]/page.tsx index a2f8c303..1b8e0f91 100644 --- a/web/src/app/[locale]/page.tsx +++ b/web/src/app/[locale]/page.tsx @@ -8,6 +8,7 @@ import { } from "@/sanity/fetchSanityData"; import { metadataFromSanityPageData } from "@/sanity/seo/resolveSanityMetadata"; import { ModulesRenderer } from "@/src/components/modules/ModulesRenderer"; +import { LocaleAlternates } from "@/src/components/navigation/LocaleAlternates"; type PageProps = { params: Promise<{ locale: string }>; @@ -73,6 +74,11 @@ export default async function Home({ params }: PageProps) { return (
+
{data.modules?.length ? (
diff --git a/web/src/app/[locale]/work/[slug]/page.tsx b/web/src/app/[locale]/work/[slug]/page.tsx index 499848ff..d30cab42 100644 --- a/web/src/app/[locale]/work/[slug]/page.tsx +++ b/web/src/app/[locale]/work/[slug]/page.tsx @@ -9,6 +9,7 @@ import { fetchSiteSettingsTitle, } from "@/sanity/fetchSanityData"; import { metadataFromSanityPageData } from "@/sanity/seo/resolveSanityMetadata"; +import { LocaleAlternates } from "@/src/components/navigation/LocaleAlternates"; import { RichTextMedia } from "@/src/components/text/RichTextMedia"; type PageProps = { @@ -73,6 +74,11 @@ export default async function Project({ params }: PageProps) { return (
+
{projectTitle || hasTitleMedia ? (
diff --git a/web/src/app/[locale]/work/page.tsx b/web/src/app/[locale]/work/page.tsx index bcfe1648..e695b307 100644 --- a/web/src/app/[locale]/work/page.tsx +++ b/web/src/app/[locale]/work/page.tsx @@ -8,6 +8,7 @@ import { } from "@/sanity/fetchSanityData"; import { metadataFromSanityPageData } from "@/sanity/seo/resolveSanityMetadata"; import { ModulesRenderer } from "@/src/components/modules/ModulesRenderer"; +import { LocaleAlternates } from "@/src/components/navigation/LocaleAlternates"; type PageProps = { params: Promise<{ locale: string }>; @@ -69,6 +70,11 @@ export default async function Work({ params }: PageProps) { return (
+
{data.modules?.length ? (
diff --git a/web/src/components/navigation/LocaleAlternates.tsx b/web/src/components/navigation/LocaleAlternates.tsx new file mode 100644 index 00000000..3ec90cfe --- /dev/null +++ b/web/src/components/navigation/LocaleAlternates.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; + +import { useLanguage } from "@/src/contexts/LanguageContext"; +import type { TranslationPath } from "@/src/i18n/siteLocalePathUtils"; + +type LocaleAlternatesProps = { + /** Route locale of the rendered document. */ + locale: string; + /** Its path without the locale prefix (`/`, `/about`, `/work/x`). */ + path: string; + /** Its published language variants (`translationsQuery`). */ + translations?: readonly TranslationPath[] | null; +}; + +/** + * Tells `LanguageSwitch` where the rendered document lives in every other + * language, so switching from `/about` lands on `/de/ueber-uns` instead of + * a 404 at `/de/about`. Render it once per route page; it renders nothing. + * The document itself always counts, so a page without linked translations + * sends the switch to the other locale's home. + */ +export function LocaleAlternates({ + locale, + path, + translations, +}: LocaleAlternatesProps) { + const pathname = usePathname() ?? "/"; + const { setTranslations } = useLanguage(); + + useEffect(() => { + setTranslations(pathname, [ + { language: locale, path }, + ...(translations ?? []), + ]); + }, [locale, path, pathname, setTranslations, translations]); + + return null; +} diff --git a/web/src/contexts/LanguageContext.tsx b/web/src/contexts/LanguageContext.tsx index fc66ff68..ed14cc90 100644 --- a/web/src/contexts/LanguageContext.tsx +++ b/web/src/contexts/LanguageContext.tsx @@ -8,12 +8,15 @@ import { useContext, useEffect, useMemo, + useState, } from "react"; import type { SiteLocaleConfig } from "@/src/i18n/fallbackSiteLocales"; import { createLanguagePathUtils, type LanguagePathUtils, + type TranslationPath, + translationPathsByLocale, } from "@/src/i18n/siteLocalePathUtils"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -27,8 +30,23 @@ type LanguageContextValue = LanguagePathUtils & { languages: readonly StudioLanguageOption[]; /** Ids + default for `pickLocalizedString` / Portable Text resolution on the client. */ siteLocale: Pick; - /** Navigate to the same logical path in another locale. */ + /** + * Navigate to the current document in another locale — its registered + * translation, else that locale's home. Routes that register nothing + * keep the path and swap the prefix. + */ setLocale: (next: string) => void; + /** Called by `LocaleAlternates` with the language variants of the page at `pathname`. */ + setTranslations: ( + pathname: string, + translations: readonly TranslationPath[], + ) => void; +}; + +/** Locale → prefixed path of the document rendered at `pathname`. */ +type RegisteredTranslations = { + pathname: string; + byLocale: Readonly>; }; const LanguageContext = createContext(null); @@ -67,14 +85,37 @@ export function LanguageProvider({ [pathname, pathUtils], ); + /* Slugs differ per language under document-level translation, so the + * prefix swap only holds for routes that don't register their variants. + * Keyed by pathname: an entry left behind by the previous page is ignored. */ + const [registered, setRegistered] = useState( + null, + ); + + const setTranslations = useCallback( + (forPathname: string, translations: readonly TranslationPath[]) => { + setRegistered({ + pathname: forPathname, + byLocale: translationPathsByLocale(translations, pathUtils), + }); + }, + [pathUtils], + ); + const setLocale = useCallback( (next: string) => { if (!pathUtils.isAppLocale(next)) { return; } + if (registered?.pathname === pathname) { + router.push( + registered.byLocale[next] ?? pathUtils.localePath("/", next), + ); + return; + } router.push(pathUtils.localePath(pathWithoutLocale, next)); }, - [pathWithoutLocale, pathUtils, router], + [pathname, pathWithoutLocale, pathUtils, registered, router], ); const value = useMemo( @@ -87,8 +128,9 @@ export function LanguageProvider({ defaultLocale: siteLocaleConfig.defaultLocale, }, setLocale, + setTranslations, }), - [currentLocale, pathUtils, setLocale, siteLocaleConfig], + [currentLocale, pathUtils, setLocale, setTranslations, siteLocaleConfig], ); useEffect(() => { From 625203579ecb43087fbcef3d31c33f35a1350f1d Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 15:09:49 +0200 Subject: [PATCH 094/457] fix(seo): drop the Next.js default favicon.ico Browsers, bookmarks and search crawlers probe /favicon.ico whatever the tags say, so the create-next-app icon in public/ showed up beside or instead of the icon from siteSettings.favicon. Sanity is the only icon source now; with the field unset the site ships no favicon rather than someone else's logo. Ported from farbstudio.de (f41e23d). Co-Authored-By: Claude Opus 5.5 --- web/public/favicon.ico | Bin 25931 -> 0 bytes web/sanity/fetchSanityData.ts | 4 ++-- web/sanity/seo/siteFaviconIcons.ts | 4 ++-- web/src/app/layout.tsx | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) delete mode 100644 web/public/favicon.ico diff --git a/web/public/favicon.ico b/web/public/favicon.ico deleted file mode 100644 index 718d6fea4835ec2d246af9800eddb7ffb276240c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m diff --git a/web/sanity/fetchSanityData.ts b/web/sanity/fetchSanityData.ts index 99e92ec0..546af759 100644 --- a/web/sanity/fetchSanityData.ts +++ b/web/sanity/fetchSanityData.ts @@ -159,8 +159,8 @@ export type SiteFaviconData = { favicon?: SanityImageField | null } | null; /** * `siteSettings.favicon` for root metadata icons (per-locale document). The * image field travels whole (crop + hotspot); `siteFaviconIcons` derives the - * sizes on the Sanity CDN. Returns `null` when unset — the browser's implicit - * `/favicon.ico` probe then reaches the static `public/favicon.ico`. + * sizes on the Sanity CDN. Returns `null` when unset — there is no static + * fallback icon (see `app/layout.tsx`). */ export const fetchSiteSettingsFavicon = cache( async ( diff --git a/web/sanity/seo/siteFaviconIcons.ts b/web/sanity/seo/siteFaviconIcons.ts index dad75ba3..90688f71 100644 --- a/web/sanity/seo/siteFaviconIcons.ts +++ b/web/sanity/seo/siteFaviconIcons.ts @@ -25,8 +25,8 @@ const APPLE_ICON_SIZE = 180; * crop in the Studio: crop the source down to the part that should survive * (the signet, a single letter) and every size below follows. * - * Returns `undefined` when nothing is set, so `generateMetadata` can fall - * through to the static icon in `app/`. + * Returns `undefined` when nothing is set, so `generateMetadata` leaves + * `icons` out — there is no static fallback icon. */ export function siteFaviconIcons( favicon: SanityImageField | null | undefined, diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index b039d4fd..ebfac0b3 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -41,11 +41,11 @@ const geistMono = Geist_Mono({ * The icons come from `siteSettings.favicon` for the default locale, with every * size derived from that one asset on the Sanity CDN (`siteFaviconIcons`). * Declaring them here covers every route below — route metadata merges field by - * field, so no page has to repeat them. With the field unset the key stays - * absent and `public/favicon.ico` is picked up by the browser's implicit - * `/favicon.ico` probe; it lives in `public/` rather than `app/` because the - * file convention beats the metadata object, and with both in place two icons - * stood in the head. + * field, so no page has to repeat them. There is deliberately no static + * fallback: browsers, bookmarks and search crawlers probe `/favicon.ico` + * regardless of the `` tags, so the Next.js default icon that used to sit + * in `public/` showed up beside or instead of the Sanity one. With the field + * unset the site has no favicon. */ export async function generateMetadata(): Promise { const siteLocale = await fetchSiteLanguageSettings(); From 0c328bbe2dffedb2714c3b7d5f917c7c28ed8c2f Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 15:16:38 +0200 Subject: [PATCH 095/457] fix(web): refresh every language's entry when a translation changes Route documents now project their other-language variants, but home and work were only tagged per locale, so publishing the first German home left the English entry without its `de` alternate indefinitely (these entries never expire on a timer). Home and work fetches also carry a `*-all` tag the webhook emits on any home/work publish; page and project tags already span all languages. Linking or unlinking translations in Studio only edits translation.metadata, which the webhook ignored. It now invalidates pages, projects, home, work and the sitemap. Co-Authored-By: Claude Opus 5.5 --- web/sanity/cachedSanityQuery.ts | 5 ++++- web/sanity/fetchSanityData.ts | 4 ++-- web/src/app/api/revalidate/route.ts | 14 ++++++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/web/sanity/cachedSanityQuery.ts b/web/sanity/cachedSanityQuery.ts index f83110e4..d38cc5c8 100644 --- a/web/sanity/cachedSanityQuery.ts +++ b/web/sanity/cachedSanityQuery.ts @@ -44,6 +44,9 @@ export const cachedSanityQuery = cache(async (query: string) => { export const SANITY_CACHE_TAGS = { home: (locale: string) => `home-${locale}`, work: (locale: string) => `work-${locale}`, + /** Every language's home/work entry — each one projects its sibling variants (`translationsQuery`). */ + homeAll: "home-all", + workAll: "work-all", pages: "pages", pageSlug: (slug: string, locale: string) => `page-${slug}-${locale}`, projects: "projects", @@ -101,7 +104,7 @@ export const cachedHomeDocument = cache(async (locale: string) => { [tag], { revalidate: SANITY_DOCUMENT_CACHE_REVALIDATE_SECONDS, - tags: [tag], + tags: [tag, SANITY_CACHE_TAGS.homeAll], }, ); const data = await fetchHome(); diff --git a/web/sanity/fetchSanityData.ts b/web/sanity/fetchSanityData.ts index 99e92ec0..0562b29d 100644 --- a/web/sanity/fetchSanityData.ts +++ b/web/sanity/fetchSanityData.ts @@ -64,7 +64,7 @@ export const fetchHomeDocument = cache( if (!isSanityConfigured) return null; const { data } = await sanityFetch({ query: homeQuery, - tags: [SANITY_CACHE_TAGS.home(locale)], + tags: [SANITY_CACHE_TAGS.home(locale), SANITY_CACHE_TAGS.homeAll], params: { locale }, ...options, }); @@ -92,7 +92,7 @@ export const fetchWorkDocument = cache( if (!isSanityConfigured) return null; const { data } = await sanityFetch({ query: workQuery, - tags: [SANITY_CACHE_TAGS.work(locale)], + tags: [SANITY_CACHE_TAGS.work(locale), SANITY_CACHE_TAGS.workAll], params: { locale }, ...options, }); diff --git a/web/src/app/api/revalidate/route.ts b/web/src/app/api/revalidate/route.ts index 1991309f..3c1d6ef4 100644 --- a/web/src/app/api/revalidate/route.ts +++ b/web/src/app/api/revalidate/route.ts @@ -21,6 +21,7 @@ const ALLOWED_DOCUMENT_TYPES = new Set([ "siteCookieBanner", "errorSettings", "siteLanguageSettings", + "translation.metadata", ] as const); type SanityWebhookPayload = { @@ -130,14 +131,18 @@ function getTagsForDocument(payload: SanityWebhookPayload): string[] { return tags; } + // Every route document projects its other-language variants + // (`translationsQuery`) for hreflang and the language switch, so a publish + // in one language must refresh its siblings too. `pages` / `projects` + // already span all languages; home and work need their `*-all` tags. if (_type === "home") { if (lang) tags.push(`home-${lang}`); - tags.push("site-pages"); + tags.push("home-all", "site-pages"); } if (_type === "work") { if (lang) tags.push(`work-${lang}`); - tags.push("site-pages"); + tags.push("work-all", "site-pages"); } if (_type === "page") { @@ -180,6 +185,11 @@ function getTagsForDocument(payload: SanityWebhookPayload): string[] { tags.push("site-language-settings"); } + // Linking or unlinking translations in Studio only edits this document. + if (_type === "translation.metadata") { + tags.push("pages", "projects", "home-all", "work-all", "site-pages"); + } + return tags; } From 5be9f7d3c6c6699ba4bfcf10da5c62d1bc0f76e7 Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 15:16:39 +0200 Subject: [PATCH 096/457] refactor(web): tidy the translation registry and barrels Hold the language switch's registered translations in a ref instead of state (only read on click, so registering no longer re-renders every useLanguage consumer), alias DocumentTranslation to TranslationPath, and export the translation snippets and hreflangLanguages from their barrels. Co-Authored-By: Claude Opus 5.5 --- web/sanity/queries/index.ts | 4 ++++ web/sanity/seo/index.ts | 1 + web/sanity/types/pages.ts | 10 ++++------ web/src/contexts/LanguageContext.tsx | 17 +++++++++-------- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/web/sanity/queries/index.ts b/web/sanity/queries/index.ts index 0631bd69..ac866837 100644 --- a/web/sanity/queries/index.ts +++ b/web/sanity/queries/index.ts @@ -33,3 +33,7 @@ export { projectSlugsQuery, sitemapPagesQuery, } from "./snippets/sitemap"; +export { + routePathProjection, + translationsQuery, +} from "./snippets/translations"; diff --git a/web/sanity/seo/index.ts b/web/sanity/seo/index.ts index bb07b974..2c279570 100644 --- a/web/sanity/seo/index.ts +++ b/web/sanity/seo/index.ts @@ -1,4 +1,5 @@ export { + hreflangLanguages, mergePageAndSettingsSeo, metadataFromSanityPageData, type ResolveSanityMetadataInput, diff --git a/web/sanity/types/pages.ts b/web/sanity/types/pages.ts index 744f6bdc..05a2c1c7 100644 --- a/web/sanity/types/pages.ts +++ b/web/sanity/types/pages.ts @@ -4,6 +4,8 @@ * types live here; fetches stay deduped per request via React cache. */ import type { PortableTextBlock } from "@portabletext/types"; +import type { TranslationPath } from "@/src/i18n/siteLocalePathUtils"; + import type { ContentModule, ModuleMediaData } from "./modules"; /** Resolved seo / seo.fallback projection from GROQ (snippets/seo.ts). */ @@ -13,12 +15,8 @@ export type PageSeo = { imageUrl?: string | null; } | null; -/** One language variant of a route document (`translationsQuery`). */ -export type DocumentTranslation = { - language?: string | null; - /** Route path without the locale prefix (`/`, `/work`, `/about`, `/work/x`). */ - path?: string | null; -} | null; +/** One language variant of a route document (`translationsQuery`): language + path without locale prefix. */ +export type DocumentTranslation = TranslationPath; export type HomeDocument = { _id: string; diff --git a/web/src/contexts/LanguageContext.tsx b/web/src/contexts/LanguageContext.tsx index ed14cc90..5b76fee4 100644 --- a/web/src/contexts/LanguageContext.tsx +++ b/web/src/contexts/LanguageContext.tsx @@ -8,7 +8,7 @@ import { useContext, useEffect, useMemo, - useState, + useRef, } from "react"; import type { SiteLocaleConfig } from "@/src/i18n/fallbackSiteLocales"; @@ -87,17 +87,17 @@ export function LanguageProvider({ /* Slugs differ per language under document-level translation, so the * prefix swap only holds for routes that don't register their variants. - * Keyed by pathname: an entry left behind by the previous page is ignored. */ - const [registered, setRegistered] = useState( - null, - ); + * Keyed by pathname: an entry left behind by the previous page is ignored. + * A ref, not state — it is only read on click, so registering must not + * re-render every `useLanguage` consumer. */ + const registeredRef = useRef(null); const setTranslations = useCallback( (forPathname: string, translations: readonly TranslationPath[]) => { - setRegistered({ + registeredRef.current = { pathname: forPathname, byLocale: translationPathsByLocale(translations, pathUtils), - }); + }; }, [pathUtils], ); @@ -107,6 +107,7 @@ export function LanguageProvider({ if (!pathUtils.isAppLocale(next)) { return; } + const registered = registeredRef.current; if (registered?.pathname === pathname) { router.push( registered.byLocale[next] ?? pathUtils.localePath("/", next), @@ -115,7 +116,7 @@ export function LanguageProvider({ } router.push(pathUtils.localePath(pathWithoutLocale, next)); }, - [pathname, pathWithoutLocale, pathUtils, registered, router], + [pathname, pathWithoutLocale, pathUtils, router], ); const value = useMemo( From d4be9486d7719d716e114caaff0e54f318e5a521 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 13:47:28 +0000 Subject: [PATCH 097/457] feat(tracking): add CMS-configurable analytics and cookie consent integration Add siteAnalyticsSettings singleton for Google Analytics, Matomo, Microsoft Clarity, PostHog, and Plausible with per-provider cookie-free modes and cookie-banner labels. Integrate with the existing vanilla-cookieconsent banner: cookie-based trackers wait for analytics consent; cookie-free trackers load immediately when respecting the banner. --- netlify.toml | 2 +- studio/config/singletons.ts | 1 + studio/config/structure/index.ts | 2 + .../structure/items/siteAnalyticsSettings.ts | 14 ++ studio/schemas/index.ts | 12 ++ .../analytics/trackerGoogleAnalytics.ts | 63 +++++++ .../objects/analytics/trackerMatomo.ts | 72 ++++++++ .../analytics/trackerMicrosoftClarity.ts | 60 +++++++ .../objects/analytics/trackerPlausible.ts | 75 ++++++++ .../objects/analytics/trackerPostHog.ts | 72 ++++++++ .../schemas/settings/siteAnalyticsSettings.ts | 60 +++++++ web/sanity/cachedSanityQuery.ts | 1 + web/sanity/fetchSanityData.ts | 18 ++ web/sanity/queries/index.ts | 1 + web/sanity/queries/snippets/settings.ts | 34 ++++ web/sanity/types/index.ts | 1 + web/sanity/types/siteAnalyticsSettings.ts | 59 +++++++ web/src/app/[locale]/layout.tsx | 17 +- web/src/app/api/revalidate/route.ts | 5 + web/src/components/cookies/CookieConsent.tsx | 50 ++++-- .../components/cookies/cookieConsentApi.ts | 28 ++- web/src/components/tracking/SiteTracking.tsx | 74 ++++++++ .../components/tracking/SiteTrackingShell.tsx | 24 +++ .../tracking/lib/cookieConsentConfig.ts | 131 ++++++++++++++ .../tracking/lib/defaultCookieSections.ts | 37 ++++ .../components/tracking/lib/loadTrackers.ts | 164 ++++++++++++++++++ .../components/tracking/lib/trackingConfig.ts | 67 +++++++ .../tracking/lib/trackingRuntime.ts | 133 ++++++++++++++ 28 files changed, 1254 insertions(+), 23 deletions(-) create mode 100644 studio/config/structure/items/siteAnalyticsSettings.ts create mode 100644 studio/schemas/objects/analytics/trackerGoogleAnalytics.ts create mode 100644 studio/schemas/objects/analytics/trackerMatomo.ts create mode 100644 studio/schemas/objects/analytics/trackerMicrosoftClarity.ts create mode 100644 studio/schemas/objects/analytics/trackerPlausible.ts create mode 100644 studio/schemas/objects/analytics/trackerPostHog.ts create mode 100644 studio/schemas/settings/siteAnalyticsSettings.ts create mode 100644 web/sanity/types/siteAnalyticsSettings.ts create mode 100644 web/src/components/tracking/SiteTracking.tsx create mode 100644 web/src/components/tracking/SiteTrackingShell.tsx create mode 100644 web/src/components/tracking/lib/cookieConsentConfig.ts create mode 100644 web/src/components/tracking/lib/defaultCookieSections.ts create mode 100644 web/src/components/tracking/lib/loadTrackers.ts create mode 100644 web/src/components/tracking/lib/trackingConfig.ts create mode 100644 web/src/components/tracking/lib/trackingRuntime.ts diff --git a/netlify.toml b/netlify.toml index 95f2a9bf..69b5f280 100644 --- a/netlify.toml +++ b/netlify.toml @@ -69,7 +69,7 @@ Referrer-Policy = "strict-origin-when-cross-origin" Permissions-Policy = "camera=(), microphone=(), geolocation=(), interest-cohort=()" Strict-Transport-Security = "max-age=63072000; includeSubDomains; preload" - Content-Security-Policy = "default-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; img-src 'self' data: blob: https://cdn.sanity.io https://image.mux.com; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' https://*.api.sanity.io https://*.apicdn.sanity.io https://stream.mux.com https://image.mux.com; frame-src 'self' https://stream.mux.com https://*.sanity.studio; frame-ancestors 'self' http://localhost:3333 https://*.sanity.studio; worker-src 'self' blob:;" + Content-Security-Policy = "default-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; img-src 'self' data: blob: https://cdn.sanity.io https://image.mux.com https://www.google-analytics.com https://www.googletagmanager.com; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.clarity.ms https://*.posthog.com https://plausible.io; connect-src 'self' https://*.api.sanity.io https://*.apicdn.sanity.io https://stream.mux.com https://image.mux.com https://www.google-analytics.com https://region1.google-analytics.com https://*.google-analytics.com https://www.googletagmanager.com https://*.clarity.ms https://*.posthog.com https://plausible.io; frame-src 'self' https://stream.mux.com https://*.sanity.studio; frame-ancestors 'self' http://localhost:3333 https://*.sanity.studio; worker-src 'self' blob:;" # ── CDN Edge Cache ──────────────────────────────────────────────────────────── # diff --git a/studio/config/singletons.ts b/studio/config/singletons.ts index 5f00fd06..c04330d7 100644 --- a/studio/config/singletons.ts +++ b/studio/config/singletons.ts @@ -19,6 +19,7 @@ export const SINGLETON_PREVIEW_ROLE = { siteLanguageSettings: "none", siteNav: "none", siteCookieBanner: "none", + siteAnalyticsSettings: "none", errorSettings: "custom", } as const satisfies Record; diff --git a/studio/config/structure/index.ts b/studio/config/structure/index.ts index cb0ebd79..b0bf2941 100644 --- a/studio/config/structure/index.ts +++ b/studio/config/structure/index.ts @@ -4,6 +4,7 @@ import type { StructureResolver } from "sanity/structure"; import { errorSettingsStructureItem } from "./items/errorSettings"; import { homeStructureItem } from "./items/home"; import { pagesStructureItem } from "./items/pages"; +import { siteAnalyticsSettingsStructureItem } from "./items/siteAnalyticsSettings"; import { siteCookieBannerStructureItem } from "./items/siteCookieBanner"; import { siteLanguageSettingsStructureItem } from "./items/siteLanguageSettings"; import { siteNavStructureItem } from "./items/siteNav"; @@ -30,6 +31,7 @@ export const structure: StructureResolver = (S) => siteNavStructureItem(S), errorSettingsStructureItem(S), siteCookieBannerStructureItem(S), + siteAnalyticsSettingsStructureItem(S), ]), ), ]); diff --git a/studio/config/structure/items/siteAnalyticsSettings.ts b/studio/config/structure/items/siteAnalyticsSettings.ts new file mode 100644 index 00000000..914282a4 --- /dev/null +++ b/studio/config/structure/items/siteAnalyticsSettings.ts @@ -0,0 +1,14 @@ +import { ChartUpwardIcon } from "@sanity/icons/ChartUpward"; +import type { StructureBuilder } from "sanity/structure"; + +export function siteAnalyticsSettingsStructureItem(S: StructureBuilder) { + return S.listItem() + .title("Analytics & Tracking") + .icon(ChartUpwardIcon) + .id("site-analytics-settings") + .child( + S.document() + .schemaType("siteAnalyticsSettings") + .documentId("siteAnalyticsSettings"), + ); +} diff --git a/studio/schemas/index.ts b/studio/schemas/index.ts index 63189700..bf8c9891 100644 --- a/studio/schemas/index.ts +++ b/studio/schemas/index.ts @@ -2,6 +2,11 @@ import { page } from "./documents/page"; import { project } from "./documents/project"; import { projectCategory } from "./documents/projectCategory"; import { work } from "./documents/work"; +import { trackerGoogleAnalytics } from "./objects/analytics/trackerGoogleAnalytics"; +import { trackerMatomo } from "./objects/analytics/trackerMatomo"; +import { trackerMicrosoftClarity } from "./objects/analytics/trackerMicrosoftClarity"; +import { trackerPlausible } from "./objects/analytics/trackerPlausible"; +import { trackerPostHog } from "./objects/analytics/trackerPostHog"; import { richText } from "./objects/editors/richText"; import { richTextMedia } from "./objects/editors/richTextMedia"; import { link } from "./objects/link"; @@ -17,6 +22,7 @@ import { navLanguageSwitch } from "./objects/navLanguageSwitch"; import { navThemeToggle } from "./objects/navThemeToggle"; import { seoFallback, seoPage } from "./objects/seo/page"; import { errorSettings } from "./settings/error"; +import { siteAnalyticsSettings } from "./settings/siteAnalyticsSettings"; import { siteCookieBanner } from "./settings/siteCookieBanner"; import { siteLanguageSettings } from "./settings/siteLanguageSettings"; import { siteNav } from "./settings/siteNav"; @@ -43,6 +49,12 @@ export const schemaTypes = [ siteLanguageSettings, siteNav, errorSettings, + siteAnalyticsSettings, + trackerGoogleAnalytics, + trackerMatomo, + trackerMicrosoftClarity, + trackerPostHog, + trackerPlausible, siteCookieBanner, home, page, diff --git a/studio/schemas/objects/analytics/trackerGoogleAnalytics.ts b/studio/schemas/objects/analytics/trackerGoogleAnalytics.ts new file mode 100644 index 00000000..5e54e06d --- /dev/null +++ b/studio/schemas/objects/analytics/trackerGoogleAnalytics.ts @@ -0,0 +1,63 @@ +import { defineField, defineType } from "sanity"; + +export const trackerGoogleAnalytics = defineType({ + name: "trackerGoogleAnalytics", + title: "Google Analytics", + type: "object", + fields: [ + defineField({ + name: "enabled", + title: "Enabled", + type: "boolean", + initialValue: true, + }), + defineField({ + name: "measurementId", + title: "Measurement ID", + description: "GA4 measurement ID (e.g. G-XXXXXXXXXX).", + type: "string", + validation: (rule) => + rule.custom((value, context) => { + const parent = context.parent as { enabled?: boolean }; + if (!parent?.enabled) return true; + if (!value?.trim()) return "Measurement ID is required when enabled."; + if (!/^G-[A-Z0-9]+$/i.test(value.trim())) { + return "Use a valid GA4 measurement ID (G-XXXXXXXXXX)."; + } + return true; + }), + }), + defineField({ + name: "cookieFree", + title: "Cookie-free mode", + description: + "Uses gtag with client_storage: none — no analytics cookies in the browser. Suitable when respecting the cookie banner.", + type: "boolean", + initialValue: false, + }), + defineField({ + name: "cookieBannerLabel", + title: "Cookie banner label", + description: "Shown in cookie preferences for this provider.", + type: "string", + initialValue: "Google Analytics", + }), + defineField({ + name: "cookieBannerDescription", + title: "Cookie banner description", + type: "text", + rows: 2, + initialValue: + "Helps us understand how visitors use the site via Google Analytics.", + }), + ], + preview: { + select: { enabled: "enabled", measurementId: "measurementId" }, + prepare({ enabled, measurementId }) { + return { + title: `Google Analytics${enabled === false ? " (disabled)" : ""}`, + subtitle: measurementId || "No measurement ID", + }; + }, + }, +}); diff --git a/studio/schemas/objects/analytics/trackerMatomo.ts b/studio/schemas/objects/analytics/trackerMatomo.ts new file mode 100644 index 00000000..00dbc17d --- /dev/null +++ b/studio/schemas/objects/analytics/trackerMatomo.ts @@ -0,0 +1,72 @@ +import { defineField, defineType } from "sanity"; + +export const trackerMatomo = defineType({ + name: "trackerMatomo", + title: "Matomo (self-hosted)", + type: "object", + fields: [ + defineField({ + name: "enabled", + title: "Enabled", + type: "boolean", + initialValue: true, + }), + defineField({ + name: "url", + title: "Matomo URL", + description: + "Base URL of your Matomo instance (e.g. https://analytics.example.com/). Add this host to Content-Security-Policy in netlify.toml.", + type: "url", + validation: (rule) => + rule.custom((value, context) => { + const parent = context.parent as { enabled?: boolean }; + if (!parent?.enabled) return true; + if (!value?.trim()) return "Matomo URL is required when enabled."; + return true; + }), + }), + defineField({ + name: "siteId", + title: "Site ID", + type: "string", + validation: (rule) => + rule.custom((value, context) => { + const parent = context.parent as { enabled?: boolean }; + if (!parent?.enabled) return true; + if (!value?.trim()) return "Site ID is required when enabled."; + return true; + }), + }), + defineField({ + name: "cookieFree", + title: "Cookie-free mode", + description: + "Calls Matomo disableCookies — tracking without first-party analytics cookies.", + type: "boolean", + initialValue: false, + }), + defineField({ + name: "cookieBannerLabel", + title: "Cookie banner label", + type: "string", + initialValue: "Matomo", + }), + defineField({ + name: "cookieBannerDescription", + title: "Cookie banner description", + type: "text", + rows: 2, + initialValue: + "Privacy-friendly analytics via your self-hosted Matomo instance.", + }), + ], + preview: { + select: { enabled: "enabled", url: "url", siteId: "siteId" }, + prepare({ enabled, url, siteId }) { + return { + title: `Matomo${enabled === false ? " (disabled)" : ""}`, + subtitle: url ? `${url} — site ${siteId ?? "?"}` : "No URL configured", + }; + }, + }, +}); diff --git a/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts b/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts new file mode 100644 index 00000000..32031506 --- /dev/null +++ b/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts @@ -0,0 +1,60 @@ +import { defineField, defineType } from "sanity"; + +export const trackerMicrosoftClarity = defineType({ + name: "trackerMicrosoftClarity", + title: "Microsoft Clarity", + type: "object", + fields: [ + defineField({ + name: "enabled", + title: "Enabled", + type: "boolean", + initialValue: true, + }), + defineField({ + name: "projectId", + title: "Project ID", + description: "Clarity project ID from the Clarity dashboard.", + type: "string", + validation: (rule) => + rule.custom((value, context) => { + const parent = context.parent as { enabled?: boolean }; + if (!parent?.enabled) return true; + if (!value?.trim()) return "Project ID is required when enabled."; + return true; + }), + }), + defineField({ + name: "cookieFree", + title: "Cookie-free mode", + description: + "Clarity does not offer a fully cookie-free mode. When enabled, tracking still uses Clarity cookies — keep this off unless you accept reduced functionality.", + type: "boolean", + initialValue: false, + readOnly: true, + }), + defineField({ + name: "cookieBannerLabel", + title: "Cookie banner label", + type: "string", + initialValue: "Microsoft Clarity", + }), + defineField({ + name: "cookieBannerDescription", + title: "Cookie banner description", + type: "text", + rows: 2, + initialValue: + "Session recordings and heatmaps to improve usability (Microsoft Clarity).", + }), + ], + preview: { + select: { enabled: "enabled", projectId: "projectId" }, + prepare({ enabled, projectId }) { + return { + title: `Microsoft Clarity${enabled === false ? " (disabled)" : ""}`, + subtitle: projectId || "No project ID", + }; + }, + }, +}); diff --git a/studio/schemas/objects/analytics/trackerPlausible.ts b/studio/schemas/objects/analytics/trackerPlausible.ts new file mode 100644 index 00000000..954d8753 --- /dev/null +++ b/studio/schemas/objects/analytics/trackerPlausible.ts @@ -0,0 +1,75 @@ +import { defineField, defineType } from "sanity"; + +export const trackerPlausible = defineType({ + name: "trackerPlausible", + title: "Plausible Analytics", + type: "object", + fields: [ + defineField({ + name: "enabled", + title: "Enabled", + type: "boolean", + initialValue: true, + }), + defineField({ + name: "domain", + title: "Domain", + description: "Site domain registered in Plausible (e.g. example.com).", + type: "string", + validation: (rule) => + rule.custom((value, context) => { + const parent = context.parent as { enabled?: boolean }; + if (!parent?.enabled) return true; + if (!value?.trim()) return "Domain is required when enabled."; + return true; + }), + }), + defineField({ + name: "scriptUrl", + title: "Script URL", + description: + "Plausible script URL (default: https://plausible.io/js/script.js). Use your self-hosted URL if applicable.", + type: "url", + initialValue: "https://plausible.io/js/script.js", + validation: (rule) => + rule.custom((value, context) => { + const parent = context.parent as { enabled?: boolean }; + if (!parent?.enabled) return true; + if (!value?.trim()) return "Script URL is required when enabled."; + return true; + }), + }), + defineField({ + name: "cookieFree", + title: "Cookie-free mode", + description: + "Plausible is cookie-free by default. This option is enabled automatically.", + type: "boolean", + initialValue: true, + readOnly: true, + }), + defineField({ + name: "cookieBannerLabel", + title: "Cookie banner label", + type: "string", + initialValue: "Plausible Analytics", + }), + defineField({ + name: "cookieBannerDescription", + title: "Cookie banner description", + type: "text", + rows: 2, + initialValue: + "Privacy-friendly, cookie-less traffic analytics via Plausible.", + }), + ], + preview: { + select: { enabled: "enabled", domain: "domain" }, + prepare({ enabled, domain }) { + return { + title: `Plausible${enabled === false ? " (disabled)" : ""}`, + subtitle: domain || "No domain", + }; + }, + }, +}); diff --git a/studio/schemas/objects/analytics/trackerPostHog.ts b/studio/schemas/objects/analytics/trackerPostHog.ts new file mode 100644 index 00000000..b0b61e4a --- /dev/null +++ b/studio/schemas/objects/analytics/trackerPostHog.ts @@ -0,0 +1,72 @@ +import { defineField, defineType } from "sanity"; + +export const trackerPostHog = defineType({ + name: "trackerPostHog", + title: "PostHog", + type: "object", + fields: [ + defineField({ + name: "enabled", + title: "Enabled", + type: "boolean", + initialValue: true, + }), + defineField({ + name: "apiKey", + title: "Project API key", + type: "string", + validation: (rule) => + rule.custom((value, context) => { + const parent = context.parent as { enabled?: boolean }; + if (!parent?.enabled) return true; + if (!value?.trim()) return "API key is required when enabled."; + return true; + }), + }), + defineField({ + name: "apiHost", + title: "API host", + description: + "PostHog ingest host (e.g. https://eu.i.posthog.com or your self-hosted URL).", + type: "url", + initialValue: "https://eu.i.posthog.com", + validation: (rule) => + rule.custom((value, context) => { + const parent = context.parent as { enabled?: boolean }; + if (!parent?.enabled) return true; + if (!value?.trim()) return "API host is required when enabled."; + return true; + }), + }), + defineField({ + name: "cookieFree", + title: "Cookie-free mode", + description: + "Uses in-memory persistence only — no PostHog cookies stored in the browser.", + type: "boolean", + initialValue: false, + }), + defineField({ + name: "cookieBannerLabel", + title: "Cookie banner label", + type: "string", + initialValue: "PostHog", + }), + defineField({ + name: "cookieBannerDescription", + title: "Cookie banner description", + type: "text", + rows: 2, + initialValue: "Product analytics and feature flags via PostHog.", + }), + ], + preview: { + select: { enabled: "enabled", apiHost: "apiHost" }, + prepare({ enabled, apiHost }) { + return { + title: `PostHog${enabled === false ? " (disabled)" : ""}`, + subtitle: apiHost || "No API host", + }; + }, + }, +}); diff --git a/studio/schemas/settings/siteAnalyticsSettings.ts b/studio/schemas/settings/siteAnalyticsSettings.ts new file mode 100644 index 00000000..8ee5658b --- /dev/null +++ b/studio/schemas/settings/siteAnalyticsSettings.ts @@ -0,0 +1,60 @@ +import { ChartUpwardIcon } from "@sanity/icons/ChartUpward"; +import { defineArrayMember, defineField, defineType } from "sanity"; + +/** Analytics & tracking providers. Web Preview is disabled in Presentation (see `DOCUMENT_TYPES_WITHOUT_WEB_PREVIEW`). */ +export const siteAnalyticsSettings = defineType({ + name: "siteAnalyticsSettings", + type: "document", + title: "Analytics & Tracking", + icon: ChartUpwardIcon, + fields: [ + defineField({ + title: "Title", + name: "title", + type: "string", + initialValue: "Analytics & Tracking", + hidden: true, + }), + defineField({ + name: "loadMode", + title: "Loading mode", + description: + "On page load: trackers run immediately. Respect cookie banner: cookie-based trackers wait for analytics consent when the cookie banner is active; cookie-free trackers still load immediately.", + type: "string", + options: { + list: [ + { title: "Load on page load", value: "onPageLoad" }, + { title: "Respect cookie banner", value: "respectCookieBanner" }, + ], + layout: "radio", + }, + initialValue: "respectCookieBanner", + validation: (rule) => rule.required(), + }), + defineField({ + name: "trackers", + title: "Tracking providers", + description: + "Add one entry per provider. Only enabled providers with valid configuration are loaded on the site.", + type: "array", + of: [ + defineArrayMember({ type: "trackerGoogleAnalytics" }), + defineArrayMember({ type: "trackerMatomo" }), + defineArrayMember({ type: "trackerMicrosoftClarity" }), + defineArrayMember({ type: "trackerPostHog" }), + defineArrayMember({ type: "trackerPlausible" }), + ], + }), + ], + preview: { + select: { title: "title", loadMode: "loadMode" }, + prepare({ title, loadMode }) { + const modeLabel = + loadMode === "onPageLoad" ? "Page load" : "Cookie banner"; + return { + title: title ?? "Analytics & Tracking", + subtitle: modeLabel, + }; + }, + }, +}); diff --git a/web/sanity/cachedSanityQuery.ts b/web/sanity/cachedSanityQuery.ts index f83110e4..9d9d3ffd 100644 --- a/web/sanity/cachedSanityQuery.ts +++ b/web/sanity/cachedSanityQuery.ts @@ -53,6 +53,7 @@ export const SANITY_CACHE_TAGS = { siteSettings: "site-settings", siteNav: "site-nav", siteCookieBanner: "site-cookie-banner", + siteAnalyticsSettings: "site-analytics-settings", errorSettings: "error-settings", } as const; diff --git a/web/sanity/fetchSanityData.ts b/web/sanity/fetchSanityData.ts index 99e92ec0..61353e77 100644 --- a/web/sanity/fetchSanityData.ts +++ b/web/sanity/fetchSanityData.ts @@ -13,6 +13,7 @@ import { homeQuery, pageBySlugQuery, projectBySlugQuery, + siteAnalyticsSettingsQuery, siteCookieBannerLayoutQuery, siteLanguageSettingsQuery, siteNavMenusQuery, @@ -32,6 +33,7 @@ import type { ProjectDocument, WorkDocument, } from "./types/pages"; +import type { SiteAnalyticsSettingsDocument } from "./types/siteAnalyticsSettings"; import type { SiteCookieBannerDocument } from "./types/siteCookieBanner"; import type { SiteLanguageSettingsDocument } from "./types/siteLanguageSettings"; @@ -206,6 +208,22 @@ export const fetchSiteCookieBanner = cache( }, ); +/** Analytics & tracking providers — document id `siteAnalyticsSettings`. */ +export const fetchSiteAnalyticsSettings = cache( + async ( + options?: LiveFetchOptions, + ): Promise => { + if (!isSanityConfigured) return null; + const { data } = await sanityFetch({ + query: siteAnalyticsSettingsQuery, + tags: [SANITY_CACHE_TAGS.siteAnalyticsSettings], + stega: false, + ...options, + }); + return data as SiteAnalyticsSettingsDocument | null; + }, +); + function draftClientForSiteLanguageSettings(token: string) { return client.withConfig({ token, diff --git a/web/sanity/queries/index.ts b/web/sanity/queries/index.ts index 0631bd69..286bd955 100644 --- a/web/sanity/queries/index.ts +++ b/web/sanity/queries/index.ts @@ -21,6 +21,7 @@ export { pageSeoQuery } from "./snippets/seo"; export { errorSettingsQuery, navMenusQuery, + siteAnalyticsSettingsQuery, siteCookieBannerLayoutQuery, siteLanguageSettingsQuery, siteNavMenusQuery, diff --git a/web/sanity/queries/snippets/settings.ts b/web/sanity/queries/snippets/settings.ts index 0aac9e35..aaff10b7 100644 --- a/web/sanity/queries/snippets/settings.ts +++ b/web/sanity/queries/snippets/settings.ts @@ -136,3 +136,37 @@ export const siteCookieBannerLayoutQuery = consentModal, preferencesModal }`); + +/** Document id: `siteAnalyticsSettings` — tracking providers + load mode. */ +export const siteAnalyticsSettingsQuery = + defineQuery(`*[_id == "siteAnalyticsSettings"][0]{ + _id, + title, + loadMode, + trackers[]{ + _key, + _type, + enabled, + cookieFree, + cookieBannerLabel, + cookieBannerDescription, + _type == "trackerGoogleAnalytics" => { + measurementId + }, + _type == "trackerMatomo" => { + url, + siteId + }, + _type == "trackerMicrosoftClarity" => { + projectId + }, + _type == "trackerPostHog" => { + apiKey, + apiHost + }, + _type == "trackerPlausible" => { + domain, + scriptUrl + } + } +}`); diff --git a/web/sanity/types/index.ts b/web/sanity/types/index.ts index f886923d..28c9199f 100644 --- a/web/sanity/types/index.ts +++ b/web/sanity/types/index.ts @@ -1,5 +1,6 @@ export type { ErrorSettingsDocument } from "./errorSettings"; export type { HomeDocument, PageDocument, PageSeo } from "./pages"; +export type { SiteAnalyticsSettingsDocument } from "./siteAnalyticsSettings"; export type { CookieConsentModalCopy, CookiePreferencesModalCopy, diff --git a/web/sanity/types/siteAnalyticsSettings.ts b/web/sanity/types/siteAnalyticsSettings.ts new file mode 100644 index 00000000..4f85022e --- /dev/null +++ b/web/sanity/types/siteAnalyticsSettings.ts @@ -0,0 +1,59 @@ +export type AnalyticsLoadMode = "onPageLoad" | "respectCookieBanner"; + +export type TrackerProvider = + | "trackerGoogleAnalytics" + | "trackerMatomo" + | "trackerMicrosoftClarity" + | "trackerPostHog" + | "trackerPlausible"; + +type TrackerBase = { + _key: string; + _type: TrackerProvider; + enabled?: boolean | null; + cookieFree?: boolean | null; + cookieBannerLabel?: string | null; + cookieBannerDescription?: string | null; +}; + +export type TrackerGoogleAnalytics = TrackerBase & { + _type: "trackerGoogleAnalytics"; + measurementId?: string | null; +}; + +export type TrackerMatomo = TrackerBase & { + _type: "trackerMatomo"; + url?: string | null; + siteId?: string | null; +}; + +export type TrackerMicrosoftClarity = TrackerBase & { + _type: "trackerMicrosoftClarity"; + projectId?: string | null; +}; + +export type TrackerPostHog = TrackerBase & { + _type: "trackerPostHog"; + apiKey?: string | null; + apiHost?: string | null; +}; + +export type TrackerPlausible = TrackerBase & { + _type: "trackerPlausible"; + domain?: string | null; + scriptUrl?: string | null; +}; + +export type AnalyticsTracker = + | TrackerGoogleAnalytics + | TrackerMatomo + | TrackerMicrosoftClarity + | TrackerPostHog + | TrackerPlausible; + +export type SiteAnalyticsSettingsDocument = { + _id: string; + title?: string | null; + loadMode?: AnalyticsLoadMode | null; + trackers?: AnalyticsTracker[] | null; +}; diff --git a/web/src/app/[locale]/layout.tsx b/web/src/app/[locale]/layout.tsx index dfd5a74a..cd0e7b3f 100644 --- a/web/src/app/[locale]/layout.tsx +++ b/web/src/app/[locale]/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { + fetchSiteAnalyticsSettings, fetchSiteCookieBanner, fetchSiteLanguageSettings, fetchSiteNavMenus, @@ -10,6 +11,8 @@ import { import { CookieConsentBanner } from "@/src/components/cookies/CookieConsent"; import { Footer } from "@/src/components/navigation/Footer"; import { Header } from "@/src/components/navigation/Header"; +import { getEnabledTrackers } from "@/src/components/tracking/lib/trackingConfig"; +import { SiteTrackingShell } from "@/src/components/tracking/SiteTrackingShell"; import { LanguageProvider } from "@/src/contexts/LanguageContext"; import { createLanguagePathUtils } from "@/src/i18n/siteLocalePathUtils"; import { skipLinkLabel } from "@/src/i18n/skipLinkLabel"; @@ -50,12 +53,15 @@ export default async function LocaleLayout({ children, params }: Props) { const locale = raw; // `siteLocale` is shared by all locales (single doc) — the per-locale fetches - // run in parallel once we know the locale is valid. - const [siteNav, cookieBanner, siteBrand] = await Promise.all([ + // run in parallel once we know the locale is valid. Analytics settings are + // language-agnostic (one singleton) and join the same round. + const [siteNav, cookieBanner, siteBrand, analytics] = await Promise.all([ fetchSiteNavMenus(locale), fetchSiteCookieBanner(locale), fetchSiteSettingsTitle(locale), + fetchSiteAnalyticsSettings(), ]); + const enabledTrackers = getEnabledTrackers(analytics); return ( @@ -79,7 +85,12 @@ export default async function LocaleLayout({ children, params }: Props) { pathUtils={pathUtils} siteLocale={siteLocale} /> - + + ); } diff --git a/web/src/app/api/revalidate/route.ts b/web/src/app/api/revalidate/route.ts index 1991309f..80c5d98c 100644 --- a/web/src/app/api/revalidate/route.ts +++ b/web/src/app/api/revalidate/route.ts @@ -19,6 +19,7 @@ const ALLOWED_DOCUMENT_TYPES = new Set([ "siteSettings", "siteNav", "siteCookieBanner", + "siteAnalyticsSettings", "errorSettings", "siteLanguageSettings", ] as const); @@ -172,6 +173,10 @@ function getTagsForDocument(payload: SanityWebhookPayload): string[] { tags.push("site-cookie-banner"); } + if (_type === "siteAnalyticsSettings") { + tags.push("site-analytics-settings"); + } + if (_type === "errorSettings") { tags.push("error-settings"); } diff --git a/web/src/components/cookies/CookieConsent.tsx b/web/src/components/cookies/CookieConsent.tsx index 10dc485a..c7a3c944 100644 --- a/web/src/components/cookies/CookieConsent.tsx +++ b/web/src/components/cookies/CookieConsent.tsx @@ -3,38 +3,50 @@ import { useEffect } from "react"; import * as CookieConsent from "vanilla-cookieconsent"; +import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings"; import type { SiteCookieBannerDocument } from "@/sanity/types/siteCookieBanner"; - +import { buildCookieSections } from "@/src/components/tracking/lib/cookieConsentConfig"; +import { notifyAnalyticsConsentChange } from "./cookieConsentApi"; import { defaultSectionsFor } from "./defaultSections"; type Props = { doc: SiteCookieBannerDocument | null; locale: string; + trackers?: AnalyticsTracker[]; }; function parseSections( - raw: string | null | undefined, + doc: SiteCookieBannerDocument, locale: string, + trackers: AnalyticsTracker[], ): CookieConsent.Section[] { - if (typeof raw !== "string" || raw.trim().length === 0) { - return defaultSectionsFor(locale); - } - try { - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - return parsed as CookieConsent.Section[]; + const raw = doc.preferencesModal?.sections; + if (typeof raw === "string" && raw.trim().length > 0) { + try { + JSON.parse(raw); + return buildCookieSections(doc, trackers); + } catch (err) { + console.warn("[CookieConsent] Failed to parse `sections` JSON:", err); } - } catch (err) { - console.warn("[CookieConsent] Failed to parse `sections` JSON:", err); } - return defaultSectionsFor(locale); + return buildCookieSections( + { + ...doc, + preferencesModal: { + ...doc.preferencesModal, + sections: JSON.stringify(defaultSectionsFor(locale)), + }, + }, + trackers, + ); } function buildConfig( doc: SiteCookieBannerDocument, locale: string, + trackers: AnalyticsTracker[], ): CookieConsent.CookieConsentConfig { - const sections = parseSections(doc.preferencesModal?.sections, locale); + const sections = parseSections(doc, locale, trackers); const categoryKeys = new Set(["necessary"]); for (const section of sections) { @@ -79,16 +91,22 @@ function buildConfig( }, }, }, + onConsent: () => { + notifyAnalyticsConsentChange(); + }, + onChange: () => { + notifyAnalyticsConsentChange(); + }, }; } -export function CookieConsentBanner({ doc, locale }: Props) { +export function CookieConsentBanner({ doc, locale, trackers = [] }: Props) { useEffect(() => { if (!doc?.useCookieBanner) return; - CookieConsent.run(buildConfig(doc, locale)).catch((err) => { + CookieConsent.run(buildConfig(doc, locale, trackers)).catch((err) => { console.warn("[CookieConsent] run() failed:", err); }); - }, [doc, locale]); + }, [doc, locale, trackers]); return null; } diff --git a/web/src/components/cookies/cookieConsentApi.ts b/web/src/components/cookies/cookieConsentApi.ts index 1af6285f..4cd151fe 100644 --- a/web/src/components/cookies/cookieConsentApi.ts +++ b/web/src/components/cookies/cookieConsentApi.ts @@ -2,12 +2,34 @@ import * as CookieConsent from "vanilla-cookieconsent"; -/** Open the preferences modal — wired to the `open-cookie-preferences` linkFunction. */ -export function showCookiePreferences(): void { - CookieConsent.showPreferences(); +type ConsentListener = (analyticsAccepted: boolean) => void; + +const listeners = new Set(); + +/** Subscribe to analytics consent changes (after CookieConsent.run). */ +export function subscribeAnalyticsConsent( + listener: ConsentListener, +): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Called from CookieConsentBanner when consent changes. */ +export function notifyAnalyticsConsentChange(): void { + const accepted = CookieConsent.acceptedCategory("analytics"); + for (const listener of listeners) { + listener(accepted); + } } /** Whether the user has accepted a given category. Use to gate analytics/marketing scripts. */ export function hasConsent(category: string): boolean { return CookieConsent.acceptedCategory(category); } + +/** Open the preferences modal — wired to the `open-cookie-preferences` linkFunction. */ +export function showCookiePreferences(): void { + CookieConsent.showPreferences(); +} diff --git a/web/src/components/tracking/SiteTracking.tsx b/web/src/components/tracking/SiteTracking.tsx new file mode 100644 index 00000000..6911ad4a --- /dev/null +++ b/web/src/components/tracking/SiteTracking.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; + +import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings"; +import { + hasConsent, + subscribeAnalyticsConsent, +} from "@/src/components/cookies/cookieConsentApi"; +import { loadTrackers } from "@/src/components/tracking/lib/loadTrackers"; +import { + getEnabledTrackers, + type TrackingConfig, + trackerRequiresConsent, +} from "@/src/components/tracking/lib/trackingConfig"; + +type Props = { + config: TrackingConfig; +}; + +function trackersForConsent( + trackers: AnalyticsTracker[], + config: TrackingConfig, + analyticsAccepted: boolean, +): AnalyticsTracker[] { + return trackers.filter((tracker) => { + const needsConsent = trackerRequiresConsent( + tracker, + config.analytics?.loadMode, + config.cookieBanner, + ); + return !needsConsent || analyticsAccepted; + }); +} + +export function SiteTracking({ config }: Props) { + const initialized = useRef(false); + const consentLoaded = useRef(new Set()); + + const trackers = useMemo( + () => getEnabledTrackers(config.analytics), + [config.analytics], + ); + + const showBanner = + config.cookieBanner?.useCookieBanner === true && + config.analytics?.loadMode === "respectCookieBanner"; + + useEffect(() => { + if (initialized.current || trackers.length === 0) return; + initialized.current = true; + + const loadAllowed = (analyticsAccepted: boolean) => { + const allowed = trackersForConsent(trackers, config, analyticsAccepted); + const pending = allowed.filter( + (tracker) => !consentLoaded.current.has(tracker._key), + ); + if (pending.length === 0) return; + for (const tracker of pending) { + consentLoaded.current.add(tracker._key); + } + loadTrackers(pending); + }; + + if (showBanner) { + loadAllowed(hasConsent("analytics")); + return subscribeAnalyticsConsent(loadAllowed); + } + + loadAllowed(true); + }, [config, showBanner, trackers]); + + return null; +} diff --git a/web/src/components/tracking/SiteTrackingShell.tsx b/web/src/components/tracking/SiteTrackingShell.tsx new file mode 100644 index 00000000..fd13eb2c --- /dev/null +++ b/web/src/components/tracking/SiteTrackingShell.tsx @@ -0,0 +1,24 @@ +import { + fetchSiteAnalyticsSettings, + fetchSiteCookieBanner, +} from "@/sanity/fetchSanityData"; +import { getEnabledTrackers } from "@/src/components/tracking/lib/trackingConfig"; +import { SiteTracking } from "@/src/components/tracking/SiteTracking"; + +export async function SiteTrackingShell() { + const [analytics, cookieBanner] = await Promise.all([ + fetchSiteAnalyticsSettings(), + fetchSiteCookieBanner(), + ]); + + if (getEnabledTrackers(analytics).length === 0) return null; + + return ( + + ); +} diff --git a/web/src/components/tracking/lib/cookieConsentConfig.ts b/web/src/components/tracking/lib/cookieConsentConfig.ts new file mode 100644 index 00000000..ab7edac5 --- /dev/null +++ b/web/src/components/tracking/lib/cookieConsentConfig.ts @@ -0,0 +1,131 @@ +import type { Section } from "vanilla-cookieconsent"; +import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings"; +import type { SiteCookieBannerDocument } from "@/sanity/types/siteCookieBanner"; +import { defaultCookieSections } from "@/src/components/tracking/lib/defaultCookieSections"; +import { isCookieFreeTracker } from "@/src/components/tracking/lib/trackingConfig"; + +type CookieTableRow = Record; + +const DEFAULT_CONSENT_DESCRIPTION = + "Our website uses essential cookies for basic operation. Cookie-based analytics run only after you accept them; privacy-friendly analytics without cookies may load earlier."; + +function sectionsJsonFromSanity( + sections: string | { code?: string | null } | null | undefined, +): string | null { + if (typeof sections === "string") return sections; + if (sections && typeof sections === "object" && "code" in sections) { + const code = (sections as { code?: unknown }).code; + return typeof code === "string" ? code : null; + } + return null; +} + +function parseSectionsJson(raw: string | null | undefined): Section[] { + if (!raw?.trim()) return []; + try { + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? (parsed as Section[]) : []; + } catch { + return []; + } +} + +function trackerCookieRow(tracker: AnalyticsTracker): CookieTableRow { + const label = tracker.cookieBannerLabel?.trim() || tracker._type; + const baseDescription = + tracker.cookieBannerDescription?.trim() || + "Analytics provider configured in Sanity."; + const description = isCookieFreeTracker(tracker) + ? `${baseDescription} (cookie-free — loads without analytics consent)` + : baseDescription; + + let domain = "—"; + switch (tracker._type) { + case "trackerGoogleAnalytics": + domain = "google-analytics.com"; + break; + case "trackerMatomo": + domain = tracker.url ? new URL(tracker.url).hostname : "matomo"; + break; + case "trackerMicrosoftClarity": + domain = "clarity.ms"; + break; + case "trackerPostHog": + domain = tracker.apiHost + ? new URL(tracker.apiHost).hostname + : "posthog.com"; + break; + case "trackerPlausible": + domain = tracker.scriptUrl + ? new URL(tracker.scriptUrl).hostname + : "plausible.io"; + break; + } + + return { + name: label, + domain, + desc: description, + }; +} + +export function buildCookieSections( + cookieBanner: SiteCookieBannerDocument | null, + trackers: AnalyticsTracker[], +): Section[] { + const sectionsRaw = sectionsJsonFromSanity( + cookieBanner?.preferencesModal?.sections ?? null, + ); + const base = parseSectionsJson(sectionsRaw); + const fallback = parseSectionsJson(defaultCookieSections); + const sections = base.length > 0 ? base : fallback; + + return sections.map((section) => { + const sectionId = (section as Section & { id?: string }).id; + if (section.linkedCategory !== "analytics" && sectionId !== "analytics") { + return section; + } + + const cookieTable = section.cookieTable ?? { + headers: { name: "Name", domain: "Domain", desc: "Description" }, + body: [] as CookieTableRow[], + }; + + const trackerRows = trackers.map(trackerCookieRow); + + return { + ...section, + cookieTable: { + ...cookieTable, + body: [...(cookieTable.body ?? []), ...trackerRows], + }, + }; + }); +} + +export function buildCookieConsentTranslations( + cookieBanner: SiteCookieBannerDocument | null, + trackers: AnalyticsTracker[], +) { + const consent = cookieBanner?.consentModal; + const preferences = cookieBanner?.preferencesModal; + + return { + en: { + consentModal: { + description: consent?.description ?? DEFAULT_CONSENT_DESCRIPTION, + acceptAllBtn: consent?.acceptAllBtn ?? "Accept", + acceptNecessaryBtn: consent?.acceptNecessaryBtn ?? "Reject", + showPreferencesBtn: consent?.showPreferencesBtn ?? "Manage preferences", + }, + preferencesModal: { + title: preferences?.title ?? "Cookie preferences", + acceptAllBtn: preferences?.acceptAllBtn ?? "Accept all", + acceptNecessaryBtn: preferences?.acceptNecessaryBtn ?? "Reject all", + savePreferencesBtn: + preferences?.savePreferencesBtn ?? "Save preferences", + sections: buildCookieSections(cookieBanner, trackers), + }, + }, + }; +} diff --git a/web/src/components/tracking/lib/defaultCookieSections.ts b/web/src/components/tracking/lib/defaultCookieSections.ts new file mode 100644 index 00000000..a6dcc448 --- /dev/null +++ b/web/src/components/tracking/lib/defaultCookieSections.ts @@ -0,0 +1,37 @@ +/** Default cookie preferences sections (vanilla-cookieconsent shape). Mirrors Studio default. */ +export const defaultCookieSections = JSON.stringify( + [ + { + id: "necessary", + title: "Strictly Necessary", + description: + "Essential for basic website functionality and cannot be disabled.", + linkedCategory: "necessary", + cookieTable: { + headers: { + name: "Name", + domain: "Domain", + desc: "Description", + }, + body: [], + }, + }, + { + id: "analytics", + title: "Analytics", + description: + "Helps us understand usage patterns and improve the website experience.", + linkedCategory: "analytics", + cookieTable: { + headers: { + name: "Name", + domain: "Domain", + desc: "Description", + }, + body: [], + }, + }, + ], + null, + 2, +); diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts new file mode 100644 index 00000000..5bcf50cf --- /dev/null +++ b/web/src/components/tracking/lib/loadTrackers.ts @@ -0,0 +1,164 @@ +import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings"; +import { + isTrackerLoaded, + registerLoadedTracker, +} from "@/src/components/tracking/lib/trackingRuntime"; + +const POSTHOG_INIT_MAX_ATTEMPTS = 40; + +function appendScript( + src: string, + attributes?: Record, +): HTMLScriptElement { + const script = document.createElement("script"); + script.src = src; + script.async = true; + for (const [name, value] of Object.entries(attributes ?? {})) { + script.setAttribute(name, value); + } + document.head.appendChild(script); + return script; +} + +function loadGoogleAnalytics(tracker: AnalyticsTracker): void { + if (tracker._type !== "trackerGoogleAnalytics") return; + const id = tracker.measurementId?.trim(); + if (!id || isTrackerLoaded(tracker._key)) return; + + appendScript( + `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`, + ); + + const inline = document.createElement("script"); + const cookieFree = tracker.cookieFree === true; + inline.textContent = ` +window.dataLayer = window.dataLayer || []; +function gtag(){dataLayer.push(arguments);} +gtag('js', new Date()); +gtag('config', ${JSON.stringify(id)}, ${cookieFree ? "{ client_storage: 'none', anonymize_ip: true }" : "{}"}); +`; + document.head.appendChild(inline); + registerLoadedTracker(tracker); +} + +function loadMatomo(tracker: AnalyticsTracker): void { + if (tracker._type !== "trackerMatomo") return; + const baseUrl = tracker.url?.trim(); + const siteId = tracker.siteId?.trim(); + if (!baseUrl || !siteId || isTrackerLoaded(tracker._key)) return; + + const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + const cookieFree = tracker.cookieFree === true; + + const inline = document.createElement("script"); + inline.textContent = ` +var _paq = window._paq = window._paq || []; +${cookieFree ? "_paq.push(['disableCookies']);" : ""} +_paq.push(['trackPageView']); +_paq.push(['enableLinkTracking']); +(function() { + var u=${JSON.stringify(normalizedUrl)}; + _paq.push(['setTrackerUrl', u+'matomo.php']); + _paq.push(['setSiteId', ${JSON.stringify(siteId)}]); + var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; + g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); +})(); +`; + document.head.appendChild(inline); + registerLoadedTracker(tracker); +} + +function loadMicrosoftClarity(tracker: AnalyticsTracker): void { + if (tracker._type !== "trackerMicrosoftClarity") return; + const projectId = tracker.projectId?.trim(); + if (!projectId || isTrackerLoaded(tracker._key)) return; + + const inline = document.createElement("script"); + inline.textContent = ` +(function(c,l,a,r,i,t,y){ + c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)}; + t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i; + y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y); +})(window, document, "clarity", "script", ${JSON.stringify(projectId)}); +`; + document.head.appendChild(inline); + registerLoadedTracker(tracker); +} + +function loadPostHog(tracker: AnalyticsTracker): void { + if (tracker._type !== "trackerPostHog") return; + const apiKey = tracker.apiKey?.trim(); + const apiHost = tracker.apiHost?.trim(); + if (!apiKey || !apiHost || isTrackerLoaded(tracker._key)) return; + + const cookieFree = tracker.cookieFree === true; + const scriptHost = apiHost.replace(/\/$/, ""); + const script = appendScript(`${scriptHost}/static/array.js`); + + let attempts = 0; + const tryInit = () => { + attempts += 1; + const posthog = ( + window as Window & { + posthog?: { + init?: ( + key: string, + options: { api_host: string; persistence: string }, + ) => void; + }; + } + ).posthog; + if (posthog?.init) { + posthog.init(apiKey, { + api_host: apiHost, + persistence: cookieFree ? "memory" : "localStorage+cookie", + }); + registerLoadedTracker(tracker); + return; + } + if (attempts < POSTHOG_INIT_MAX_ATTEMPTS) { + window.setTimeout(tryInit, 50); + } + }; + + script.addEventListener("load", tryInit); + script.addEventListener("error", () => { + console.warn("[PostHog] Failed to load analytics script."); + }); + tryInit(); +} + +function loadPlausible(tracker: AnalyticsTracker): void { + if (tracker._type !== "trackerPlausible") return; + const domain = tracker.domain?.trim(); + const scriptUrl = tracker.scriptUrl?.trim(); + if (!domain || !scriptUrl || isTrackerLoaded(tracker._key)) return; + + appendScript(scriptUrl, { + defer: "", + "data-domain": domain, + }); + registerLoadedTracker(tracker); +} + +const loaders: Record< + AnalyticsTracker["_type"], + (tracker: AnalyticsTracker) => void +> = { + trackerGoogleAnalytics: loadGoogleAnalytics, + trackerMatomo: loadMatomo, + trackerMicrosoftClarity: loadMicrosoftClarity, + trackerPostHog: loadPostHog, + trackerPlausible: loadPlausible, +}; + +export function loadTracker(tracker: AnalyticsTracker): void { + const loader = loaders[tracker._type]; + if (loader) loader(tracker); +} + +export function loadTrackers(trackers: AnalyticsTracker[]): void { + for (const tracker of trackers) { + loadTracker(tracker); + } +} diff --git a/web/src/components/tracking/lib/trackingConfig.ts b/web/src/components/tracking/lib/trackingConfig.ts new file mode 100644 index 00000000..966ac6e0 --- /dev/null +++ b/web/src/components/tracking/lib/trackingConfig.ts @@ -0,0 +1,67 @@ +import type { + AnalyticsLoadMode, + AnalyticsTracker, + SiteAnalyticsSettingsDocument, +} from "@/sanity/types/siteAnalyticsSettings"; +import type { SiteCookieBannerDocument } from "@/sanity/types/siteCookieBanner"; + +export type TrackingConfig = { + analytics: SiteAnalyticsSettingsDocument | null; + cookieBanner: SiteCookieBannerDocument | null; +}; + +export type { AnalyticsLoadMode, AnalyticsTracker }; + +export function isTrackerEnabled(tracker: AnalyticsTracker): boolean { + if (tracker.enabled === false) return false; + + switch (tracker._type) { + case "trackerGoogleAnalytics": + return Boolean(tracker.measurementId?.trim()); + case "trackerMatomo": + return Boolean(tracker.url?.trim() && tracker.siteId?.trim()); + case "trackerMicrosoftClarity": + return Boolean(tracker.projectId?.trim()); + case "trackerPostHog": + return Boolean(tracker.apiKey?.trim() && tracker.apiHost?.trim()); + case "trackerPlausible": + return Boolean(tracker.domain?.trim() && tracker.scriptUrl?.trim()); + default: + return false; + } +} + +export function getEnabledTrackers( + analytics: SiteAnalyticsSettingsDocument | null, +): AnalyticsTracker[] { + return (analytics?.trackers ?? []).filter(isTrackerEnabled); +} + +export function isCookieBannerActive( + cookieBanner: SiteCookieBannerDocument | null, +): boolean { + return cookieBanner?.useCookieBanner === true; +} + +export function shouldRespectCookieBanner( + loadMode: AnalyticsLoadMode | null | undefined, + cookieBanner: SiteCookieBannerDocument | null, +): boolean { + if (loadMode === "onPageLoad") return false; + return isCookieBannerActive(cookieBanner); +} + +/** Cookie-free trackers may load without analytics consent when the banner is active. */ +export function isCookieFreeTracker(tracker: AnalyticsTracker): boolean { + if (tracker._type === "trackerPlausible") return true; + return tracker.cookieFree === true; +} + +export function trackerRequiresConsent( + tracker: AnalyticsTracker, + loadMode: AnalyticsLoadMode | null | undefined, + cookieBanner: SiteCookieBannerDocument | null, +): boolean { + if (!shouldRespectCookieBanner(loadMode, cookieBanner)) return false; + return !isCookieFreeTracker(tracker); +} diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts new file mode 100644 index 00000000..33bde87c --- /dev/null +++ b/web/src/components/tracking/lib/trackingRuntime.ts @@ -0,0 +1,133 @@ +import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings"; + +type LoadedTracker = { + tracker: AnalyticsTracker; +}; + +const loadedTrackers = new Map(); + +export function isTrackerLoaded(key: string): boolean { + return loadedTrackers.has(key); +} + +export function registerLoadedTracker(tracker: AnalyticsTracker): void { + loadedTrackers.set(tracker._key, { tracker }); +} + +export function unregisterLoadedTracker(key: string): void { + loadedTrackers.delete(key); +} + +export function trackPageView(pathname: string, search = ""): void { + const url = `${pathname}${search}`; + + for (const { tracker } of loadedTrackers.values()) { + switch (tracker._type) { + case "trackerGoogleAnalytics": { + const id = tracker.measurementId?.trim(); + if (!id) break; + const gtag = ( + window as Window & { + gtag?: (...args: unknown[]) => void; + } + ).gtag; + gtag?.("event", "page_view", { + page_path: url, + send_to: id, + }); + break; + } + case "trackerMatomo": { + const _paq = ( + window as Window & { + _paq?: unknown[][]; + } + )._paq; + _paq?.push(["setCustomUrl", url]); + _paq?.push(["trackPageView"]); + break; + } + case "trackerPostHog": { + const posthog = ( + window as Window & { + posthog?: { capture?: (event: string) => void }; + } + ).posthog; + posthog?.capture?.("$pageview"); + break; + } + case "trackerPlausible": { + const plausible = ( + window as Window & { + plausible?: (event: string, options?: { u: string }) => void; + } + ).plausible; + plausible?.("pageview", { u: url }); + break; + } + default: + break; + } + } +} + +export function unloadTracker(tracker: AnalyticsTracker): void { + switch (tracker._type) { + case "trackerGoogleAnalytics": { + const id = tracker.measurementId?.trim(); + if (id) { + (window as unknown as Record)[ + `ga-disable-${id}` + ] = true; + } + break; + } + case "trackerMatomo": { + const _paq = ( + window as Window & { + _paq?: unknown[][]; + } + )._paq; + _paq?.push(["optUserOut"]); + _paq?.push(["deleteCookies"]); + break; + } + case "trackerMicrosoftClarity": { + const clarity = ( + window as Window & { + clarity?: ((...args: unknown[]) => void) & { q?: unknown[] }; + } + ).clarity; + if (clarity) { + clarity.q = []; + ( + window as Window & { clarity?: (...args: unknown[]) => void } + ).clarity = () => {}; + } + break; + } + case "trackerPostHog": { + const posthog = ( + window as Window & { + posthog?: { + opt_out_capturing?: () => void; + reset?: () => void; + }; + } + ).posthog; + posthog?.opt_out_capturing?.(); + posthog?.reset?.(); + break; + } + case "trackerPlausible": + break; + } + + unregisterLoadedTracker(tracker._key); +} + +export function unloadTrackers(trackers: AnalyticsTracker[]): void { + for (const tracker of trackers) { + unloadTracker(tracker); + } +} From cfe1fc0364884d4cf84e496066c3899c2e730553 Mon Sep 17 00:00:00 2001 From: Arne Spremberg Date: Thu, 6 Aug 2026 16:44:10 +0200 Subject: [PATCH 098/457] docs(studio): surface the privacy risks of each analytics provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An editor choosing providers in the Studio cannot see which of them sends personal data before the visitor answers the banner, which needs an agreement signed elsewhere, or which one records what visitors type. Put that at the point of the decision instead of in a document nobody opens. Field-level notes: - Each tracker gains a type description covering its specific risk — Clarity's session recordings and the DPIA question, PostHog's replay being configured outside the CMS, GA's US transfer, Matomo being self-hosted and lowest-risk, Plausible's server-side IP hashing. - "Cookie-free" is explained as not meaning consent-free: it removes the ePrivacy cookie trigger, not the GDPR basis for the data still sent. - GA's cookie-free note records that `anonymize_ip` is a no-op in GA4 (a Universal Analytics setting) so the flag is not read as a safeguard. - Clarity's cookie-free toggle explains why it is locked off, and its `enabled` field documents that consent withdrawal does not tear down the already-loaded script until the visitor reloads. - The generated cookie table's limits are noted on the sections field: provider domains rather than the real cookie host, and no durations. Warnings where the unsafe state is invisible from one document: - Selecting "Load on page load" with trackers enabled. - Enabling trackers while the cookie banner is off, and the same warning from the banner side — the two halves live in separate singletons, so either document alone looks fine. - A consent description with no link to a privacy policy. The reject-button labels become required: vanilla-cookieconsent only renders that button when the label is non-empty, so clearing the field silently leaves Accept as the only option. Co-Authored-By: Claude Opus 5 (1M context) --- .../objects/analytics/complianceNotes.ts | 19 ++++++ .../analytics/trackerGoogleAnalytics.ts | 11 +++- .../objects/analytics/trackerMatomo.ts | 6 +- .../analytics/trackerMicrosoftClarity.ts | 8 ++- .../objects/analytics/trackerPlausible.ts | 4 +- .../objects/analytics/trackerPostHog.ts | 9 ++- .../schemas/settings/siteAnalyticsSettings.ts | 62 +++++++++++++++++-- studio/schemas/settings/siteCookieBanner.ts | 46 ++++++++++++++ 8 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 studio/schemas/objects/analytics/complianceNotes.ts diff --git a/studio/schemas/objects/analytics/complianceNotes.ts b/studio/schemas/objects/analytics/complianceNotes.ts new file mode 100644 index 00000000..a3689aa7 --- /dev/null +++ b/studio/schemas/objects/analytics/complianceNotes.ts @@ -0,0 +1,19 @@ +/** + * Shared privacy copy for the analytics tracker schemas. + * + * An editor picking providers in the Studio cannot see which of them transmits + * personal data before the visitor answers the banner, which needs an + * agreement signed outside Sanity, or which one quietly records what visitors + * type. These strings put that in front of them at the point of the decision. + * + * Practical guidance for editors, not legal advice — the lawful basis for each + * provider belongs to whoever owns privacy for the site. + */ + +/** Why "cookie-free" is not the same thing as "consent-free". */ +export const COOKIE_FREE_NOTE = + "Cookie-free means nothing is written to the visitor's device, so the ePrivacy consent rule for cookies does not apply — which is why trackers marked cookie-free load *before* the visitor answers the banner. The provider still receives personal data (at minimum IP address and user agent), and that processing needs its own lawful basis under the GDPR. Confirm that is acceptable for this provider before switching it on."; + +/** Obligations that live outside the CMS and are easy to forget. */ +export const CONTROLLER_CHECKLIST = + "Before enabling: name this provider and its purpose in your privacy policy, have a data processing agreement in place, and record the transfer basis if the data leaves the EEA."; diff --git a/studio/schemas/objects/analytics/trackerGoogleAnalytics.ts b/studio/schemas/objects/analytics/trackerGoogleAnalytics.ts index 5e54e06d..450a69cc 100644 --- a/studio/schemas/objects/analytics/trackerGoogleAnalytics.ts +++ b/studio/schemas/objects/analytics/trackerGoogleAnalytics.ts @@ -1,9 +1,13 @@ import { defineField, defineType } from "sanity"; +import { CONTROLLER_CHECKLIST } from "./complianceNotes"; export const trackerGoogleAnalytics = defineType({ name: "trackerGoogleAnalytics", title: "Google Analytics", type: "object", + description: + "Sends visitor data to Google in the US. Needs a Google Analytics data processing amendment and a documented transfer basis (Google self-certifies under the EU-US Data Privacy Framework). Several EU regulators have found specific GA deployments unlawful, so check the current position for your jurisdiction rather than assuming the DPF settles it. " + + CONTROLLER_CHECKLIST, fields: [ defineField({ name: "enabled", @@ -31,9 +35,14 @@ export const trackerGoogleAnalytics = defineType({ name: "cookieFree", title: "Cookie-free mode", description: - "Uses gtag with client_storage: none — no analytics cookies in the browser. Suitable when respecting the cookie banner.", + "Sets client_storage: 'none' so GA4 writes no cookies — and, because the site treats cookie-free trackers as safe to start early, this also makes Google Analytics load before the visitor answers the banner. GA4 still sends the full hit, including the IP address, to Google in the US. Two things worth knowing: anonymize_ip has no effect in GA4 (it was a Universal Analytics setting), and turning this on is the riskiest combination available in this document — personal data reaching a US processor with no consent. Prefer leaving it off and letting GA wait for consent.", type: "boolean", initialValue: false, + validation: (rule) => + rule.warning().custom((value) => { + if (value !== true) return true; + return "Cookie-free mode makes Google Analytics load before consent while still sending IP addresses to Google in the US. Only keep this on if that transfer has been assessed and documented."; + }), }), defineField({ name: "cookieBannerLabel", diff --git a/studio/schemas/objects/analytics/trackerMatomo.ts b/studio/schemas/objects/analytics/trackerMatomo.ts index 00dbc17d..1485acfb 100644 --- a/studio/schemas/objects/analytics/trackerMatomo.ts +++ b/studio/schemas/objects/analytics/trackerMatomo.ts @@ -1,9 +1,12 @@ import { defineField, defineType } from "sanity"; +import { COOKIE_FREE_NOTE } from "./complianceNotes"; export const trackerMatomo = defineType({ name: "trackerMatomo", title: "Matomo (self-hosted)", type: "object", + description: + "Lowest-risk option here: self-hosted, so the analytics data stays with you and there is no third-party processor or transfer to account for. You are the controller for it. Two things to set on the Matomo side: anonymise IP addresses (Administration → Privacy → Anonymize data) and set a data retention period. Still name it in your privacy policy.", fields: [ defineField({ name: "enabled", @@ -40,8 +43,7 @@ export const trackerMatomo = defineType({ defineField({ name: "cookieFree", title: "Cookie-free mode", - description: - "Calls Matomo disableCookies — tracking without first-party analytics cookies.", + description: `Calls Matomo's disableCookies, so no first-party analytics cookies are set. ${COOKIE_FREE_NOTE}`, type: "boolean", initialValue: false, }), diff --git a/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts b/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts index 32031506..5ec35e20 100644 --- a/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts +++ b/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts @@ -1,13 +1,19 @@ import { defineField, defineType } from "sanity"; +import { CONTROLLER_CHECKLIST } from "./complianceNotes"; export const trackerMicrosoftClarity = defineType({ name: "trackerMicrosoftClarity", title: "Microsoft Clarity", type: "object", + description: + "Highest-risk provider here. Clarity records sessions — pointer movement, clicks, scrolls, and DOM snapshots of what the visitor sees — so anything on screen can end up in a recording, including text typed into a form. Mask every input and any element that can display personal data in Clarity's settings first. Systematic monitoring of visitor behaviour is one of the GDPR Art. 35 criteria, so consider whether a data protection impact assessment is needed. Data goes to Microsoft in the US. " + + CONTROLLER_CHECKLIST, fields: [ defineField({ name: "enabled", title: "Enabled", + description: + "Known limitation on withdrawal: when a visitor revokes analytics consent the site stops further Clarity API calls, but the already-loaded Clarity script is not torn down and its cookies are not removed until the visitor reloads the page.", type: "boolean", initialValue: true, }), @@ -28,7 +34,7 @@ export const trackerMicrosoftClarity = defineType({ name: "cookieFree", title: "Cookie-free mode", description: - "Clarity does not offer a fully cookie-free mode. When enabled, tracking still uses Clarity cookies — keep this off unless you accept reduced functionality.", + "Not available — Clarity has no cookie-free mode, so it must never load before consent. Locked off deliberately: the site treats cookie-free trackers as safe to start early, and Clarity does not qualify.", type: "boolean", initialValue: false, readOnly: true, diff --git a/studio/schemas/objects/analytics/trackerPlausible.ts b/studio/schemas/objects/analytics/trackerPlausible.ts index 954d8753..ae23c02b 100644 --- a/studio/schemas/objects/analytics/trackerPlausible.ts +++ b/studio/schemas/objects/analytics/trackerPlausible.ts @@ -4,6 +4,8 @@ export const trackerPlausible = defineType({ name: "trackerPlausible", title: "Plausible Analytics", type: "object", + description: + "Least intrusive third-party option: no cookies, no cross-site identifiers, and Plausible Cloud is EU-hosted. Because it counts as cookie-free it loads before the visitor answers the banner. It does still process the IP address server-side to derive a daily visitor hash, so it needs a lawful basis — usually legitimate interest — and a mention in your privacy policy. Regulators generally accept this for plain audience measurement, but the assessment is yours to make and record.", fields: [ defineField({ name: "enabled", @@ -43,7 +45,7 @@ export const trackerPlausible = defineType({ name: "cookieFree", title: "Cookie-free mode", description: - "Plausible is cookie-free by default. This option is enabled automatically.", + "Always on — Plausible is cookie-free by design, which is why it loads without waiting for analytics consent. Locked so it cannot be switched off and misrepresent the provider in the cookie banner.", type: "boolean", initialValue: true, readOnly: true, diff --git a/studio/schemas/objects/analytics/trackerPostHog.ts b/studio/schemas/objects/analytics/trackerPostHog.ts index b0b61e4a..3f20d8c6 100644 --- a/studio/schemas/objects/analytics/trackerPostHog.ts +++ b/studio/schemas/objects/analytics/trackerPostHog.ts @@ -1,9 +1,13 @@ import { defineField, defineType } from "sanity"; +import { CONTROLLER_CHECKLIST, COOKIE_FREE_NOTE } from "./complianceNotes"; export const trackerPostHog = defineType({ name: "trackerPostHog", title: "PostHog", type: "object", + description: + "Important: session replay, autocapture, and heatmaps are switched on in the PostHog project settings, not here. If replay is enabled there, PostHog records visitor sessions with the same risks as Microsoft Clarity — and nothing in this document will tell you, so the banner description you write below can understate what is actually collected. Check the PostHog project before enabling. " + + CONTROLLER_CHECKLIST, fields: [ defineField({ name: "enabled", @@ -27,7 +31,7 @@ export const trackerPostHog = defineType({ name: "apiHost", title: "API host", description: - "PostHog ingest host (e.g. https://eu.i.posthog.com or your self-hosted URL).", + "PostHog ingest host. Keep this on the EU cloud (https://eu.i.posthog.com) or your own self-hosted instance — pointing it at the US cloud (us.i.posthog.com) turns every event into a third-country transfer that needs its own basis.", type: "url", initialValue: "https://eu.i.posthog.com", validation: (rule) => @@ -41,8 +45,7 @@ export const trackerPostHog = defineType({ defineField({ name: "cookieFree", title: "Cookie-free mode", - description: - "Uses in-memory persistence only — no PostHog cookies stored in the browser.", + description: `Uses in-memory persistence only, so no PostHog cookies or localStorage entries are written, and every page load counts as a new anonymous user. ${COOKIE_FREE_NOTE}`, type: "boolean", initialValue: false, }), diff --git a/studio/schemas/settings/siteAnalyticsSettings.ts b/studio/schemas/settings/siteAnalyticsSettings.ts index 8ee5658b..cf5e2773 100644 --- a/studio/schemas/settings/siteAnalyticsSettings.ts +++ b/studio/schemas/settings/siteAnalyticsSettings.ts @@ -1,5 +1,42 @@ import { ChartUpwardIcon } from "@sanity/icons/ChartUpward"; -import { defineArrayMember, defineField, defineType } from "sanity"; +import { + defineArrayMember, + defineField, + defineType, + type ValidationContext, +} from "sanity"; + +type TrackerValue = { enabled?: boolean }; + +/** Enabled trackers only — `enabled` is opt-out, so a missing flag counts as on. */ +function enabledTrackerCount(trackers: unknown): number { + if (!Array.isArray(trackers)) return 0; + return trackers.filter( + (tracker) => (tracker as TrackerValue)?.enabled !== false, + ).length; +} + +/** + * Whether the cookie banner is switched on, read from its singleton. + * + * The two settings that decide whether anything is gated by consent live in + * separate documents, so an editor can leave tracking completely ungated + * without ever seeing the other half of the configuration. Reading across lets + * us warn where the mistake is actually made. + */ +async function cookieBannerIsActive( + context: ValidationContext, +): Promise { + const client = context.getClient({ apiVersion: "2024-01-01" }); + const useCookieBanner = await client.fetch( + `coalesce( + *[_id == "drafts.siteCookieBanner"][0].useCookieBanner, + *[_id == "siteCookieBanner"][0].useCookieBanner, + false + )`, + ); + return useCookieBanner === true; +} /** Analytics & tracking providers. Web Preview is disabled in Presentation (see `DOCUMENT_TYPES_WITHOUT_WEB_PREVIEW`). */ export const siteAnalyticsSettings = defineType({ @@ -19,7 +56,7 @@ export const siteAnalyticsSettings = defineType({ name: "loadMode", title: "Loading mode", description: - "On page load: trackers run immediately. Respect cookie banner: cookie-based trackers wait for analytics consent when the cookie banner is active; cookie-free trackers still load immediately.", + "Respect cookie banner (recommended): cookie-based trackers wait for analytics consent; trackers marked cookie-free still load immediately. Load on page load: every enabled tracker runs before the visitor has answered the banner — for EEA/UK visitors that is generally unlawful for any tracker that sets cookies or reads device storage (ePrivacy Art. 5(3)), so use it only after your own legal review.", type: "string", options: { list: [ @@ -29,14 +66,31 @@ export const siteAnalyticsSettings = defineType({ layout: "radio", }, initialValue: "respectCookieBanner", - validation: (rule) => rule.required(), + validation: (rule) => [ + rule.required(), + rule.warning().custom((value, context) => { + if (value !== "onPageLoad") return true; + const count = enabledTrackerCount( + (context.document as { trackers?: unknown })?.trackers, + ); + if (count === 0) return true; + return `"Load on page load" starts ${count === 1 ? "this tracker" : `all ${count} enabled trackers`} before the visitor consents, and the cookie banner is bypassed entirely. Unless legal has signed off on this specific setup, switch to "Respect cookie banner".`; + }), + ], }), defineField({ name: "trackers", title: "Tracking providers", description: - "Add one entry per provider. Only enabled providers with valid configuration are loaded on the site.", + "Add one entry per provider. Only enabled providers with valid configuration are loaded on the site. Each provider carries its own privacy obligations — open the entry and read the notes there before switching it on.", type: "array", + validation: (rule) => + rule.warning().custom(async (trackers, context) => { + const count = enabledTrackerCount(trackers); + if (count === 0) return true; + if (await cookieBannerIsActive(context)) return true; + return `The cookie banner is switched off in Cookie Banner settings, so ${count === 1 ? "this tracker runs" : `all ${count} enabled trackers run`} with no consent at all — the loading mode above has no effect without a banner to wait for. Switch "Use Cookie Banner" on, or disable the trackers.`; + }), of: [ defineArrayMember({ type: "trackerGoogleAnalytics" }), defineArrayMember({ type: "trackerMatomo" }), diff --git a/studio/schemas/settings/siteCookieBanner.ts b/studio/schemas/settings/siteCookieBanner.ts index ccfbebbc..d3ffbba9 100644 --- a/studio/schemas/settings/siteCookieBanner.ts +++ b/studio/schemas/settings/siteCookieBanner.ts @@ -28,8 +28,27 @@ export const siteCookieBanner = defineType({ { title: "Use Cookie Banner", name: "useCookieBanner", + description: + "Turning this off does not stop tracking — it removes the gate. With no banner there is no consent to wait for, so every tracker enabled under Analytics & Tracking loads immediately regardless of its loading mode. Keep this on whenever a tracker is enabled and the site has EEA/UK visitors.", type: "boolean", initialValue: false, + validation: (rule) => + rule.warning().custom(async (value, context) => { + if (value === true) return true; + const client = context.getClient({ apiVersion: "2024-01-01" }); + const trackers = await client.fetch<{ enabled?: boolean }[] | null>( + `coalesce( + *[_id == "drafts.siteAnalyticsSettings"][0].trackers, + *[_id == "siteAnalyticsSettings"][0].trackers, + [] + )`, + ); + const count = (trackers ?? []).filter( + (tracker) => tracker?.enabled !== false, + ).length; + if (count === 0) return true; + return `${count === 1 ? "A tracker is" : `${count} trackers are`} enabled under Analytics & Tracking and will run with no consent while the banner is off.`; + }), }, { title: "Consent Modal Texts", @@ -40,9 +59,18 @@ export const siteCookieBanner = defineType({ { title: "Description", name: "description", + description: + 'Shown in the banner. To be transparent under GDPR Art. 13 this should say who is collecting the data and why, and link to your privacy or cookie policy — HTML is rendered here, so `Privacy policy` works. If a provider sends data outside the EEA (Google Analytics, Microsoft Clarity), say so.', type: "string", initialValue: "Our website uses essential cookies to ensure proper operation and tracking cookies to understand your interaction. Tracking is only activated after consent.", + validation: (rule) => + rule.warning().custom((value) => { + if (typeof value !== "string" || value.includes("href=")) { + return true; + } + return "No link found. The banner should link to your privacy or cookie policy so visitors can see what they are consenting to."; + }), }, { title: "Accept All Button", @@ -53,8 +81,16 @@ export const siteCookieBanner = defineType({ { title: "Accept Necessary Button", name: "acceptNecessaryBtn", + description: + "The reject button. Must stay filled in: the banner library only renders this button when the label is non-empty, so clearing it leaves Accept as the only option — refusing consent has to be as easy as giving it, and regulators treat a missing reject button as invalid consent.", type: "string", initialValue: "Reject", + validation: (rule) => + rule + .required() + .error( + "Required — an empty label hides the reject button entirely, which makes the consent invalid.", + ), }, { title: "Show Preferences Button", @@ -85,8 +121,16 @@ export const siteCookieBanner = defineType({ { title: "Accept Necessary Button", name: "acceptNecessaryBtn", + description: + "The reject button in the preferences dialog. As above, an empty label removes the button.", type: "string", initialValue: "Reject all", + validation: (rule) => + rule + .required() + .error( + "Required — an empty label hides the reject button entirely, which makes the consent invalid.", + ), }, { title: "Save Preferences Button", @@ -97,6 +141,8 @@ export const siteCookieBanner = defineType({ { title: "Sections", name: "sections", + description: + "Rows for the analytics category are appended automatically from the enabled trackers. Two caveats if your jurisdiction expects an exact cookie disclosure: the generated rows show the provider's own domain, whereas cookies like _ga are actually set on your domain, and no retention period is shown. Add precise names and durations here when that matters.", type: "code", options: { language: "json", From bffdc72bdc4d75483ae50bc38f91b97039950ae2 Mon Sep 17 00:00:00 2001 From: Arne Spremberg Date: Thu, 6 Aug 2026 17:02:25 +0200 Subject: [PATCH 099/457] fix(tracking): address review findings on consent, pageviews, and PostHog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consent withdrawal did not stop collection. The consent callback only ever computed which trackers to *add*, so revoking analytics left GA, Matomo, Clarity, and PostHog running until a reload — consent has to be as easy to withdraw as to give. `syncConsent` now unloads anything running that consent no longer covers and clears its key so a later grant reloads it. Dropped the `initialized` one-shot ref. The effect owns the consent subscription, so returning early on re-run (Strict Mode remount, or any `config` change) unsubscribed permanently and consent never reached the loaders again. `consentLoaded` already makes loads idempotent. `trackPageView` was dead code — nothing observed route changes, so App Router navigations emitted no pageviews. Wired to `pathname`, skipping the first run because providers emit their own view on init. PostHog: the SDK is served from PostHog Cloud's assets host, not the ingest host, so the default EU config never loaded `/static/array.js`. Rewrites the two known cloud hosts and leaves self-hosted URLs alone. Also guards `tryInit` with the runtime registry — it ran immediately, on script load, and on a retry timer, so a pending timer could re-init after the load handler had already succeeded. Cookie sections: the query returned `preferencesModal` whole, so `sections` arrived as the Studio `code` object while the type claimed `string` — every CMS-authored section list fell through to the locale defaults. Projects `sections.code` and normalises in `parseSections` as well. Separately, rows were only appended when the authored JSON already contained an analytics section; if an editor renamed or removed it there was no disclosure *and* no `analytics` category to grant, so gated trackers could never load. One is now synthesised when trackers exist. Co-Authored-By: Claude Opus 5 (1M context) --- web/sanity/queries/snippets/settings.ts | 8 ++- web/src/components/cookies/CookieConsent.tsx | 11 +++- web/src/components/tracking/SiteTracking.tsx | 49 ++++++++++++--- .../tracking/lib/cookieConsentConfig.ts | 62 +++++++++++++------ .../components/tracking/lib/loadTrackers.ts | 21 ++++++- 5 files changed, 118 insertions(+), 33 deletions(-) diff --git a/web/sanity/queries/snippets/settings.ts b/web/sanity/queries/snippets/settings.ts index aaff10b7..1d412005 100644 --- a/web/sanity/queries/snippets/settings.ts +++ b/web/sanity/queries/snippets/settings.ts @@ -134,7 +134,13 @@ export const siteCookieBannerLayoutQuery = language, useCookieBanner, consentModal, - preferencesModal + preferencesModal { + title, + acceptAllBtn, + acceptNecessaryBtn, + savePreferencesBtn, + "sections": sections.code + } }`); /** Document id: `siteAnalyticsSettings` — tracking providers + load mode. */ diff --git a/web/src/components/cookies/CookieConsent.tsx b/web/src/components/cookies/CookieConsent.tsx index c7a3c944..29b5f86e 100644 --- a/web/src/components/cookies/CookieConsent.tsx +++ b/web/src/components/cookies/CookieConsent.tsx @@ -5,7 +5,10 @@ import * as CookieConsent from "vanilla-cookieconsent"; import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings"; import type { SiteCookieBannerDocument } from "@/sanity/types/siteCookieBanner"; -import { buildCookieSections } from "@/src/components/tracking/lib/cookieConsentConfig"; +import { + buildCookieSections, + sectionsJsonFromSanity, +} from "@/src/components/tracking/lib/cookieConsentConfig"; import { notifyAnalyticsConsentChange } from "./cookieConsentApi"; import { defaultSectionsFor } from "./defaultSections"; @@ -20,8 +23,10 @@ function parseSections( locale: string, trackers: AnalyticsTracker[], ): CookieConsent.Section[] { - const raw = doc.preferencesModal?.sections; - if (typeof raw === "string" && raw.trim().length > 0) { + // The query projects `sections.code`, but normalise anyway so an unprojected + // `code` object does not silently fall through to the defaults. + const raw = sectionsJsonFromSanity(doc.preferencesModal?.sections); + if (raw && raw.trim().length > 0) { try { JSON.parse(raw); return buildCookieSections(doc, trackers); diff --git a/web/src/components/tracking/SiteTracking.tsx b/web/src/components/tracking/SiteTracking.tsx index 6911ad4a..f812fc13 100644 --- a/web/src/components/tracking/SiteTracking.tsx +++ b/web/src/components/tracking/SiteTracking.tsx @@ -1,5 +1,6 @@ "use client"; +import { usePathname } from "next/navigation"; import { useEffect, useMemo, useRef } from "react"; import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings"; @@ -13,6 +14,10 @@ import { type TrackingConfig, trackerRequiresConsent, } from "@/src/components/tracking/lib/trackingConfig"; +import { + trackPageView, + unloadTrackers, +} from "@/src/components/tracking/lib/trackingRuntime"; type Props = { config: TrackingConfig; @@ -34,8 +39,9 @@ function trackersForConsent( } export function SiteTracking({ config }: Props) { - const initialized = useRef(false); const consentLoaded = useRef(new Set()); + const initialPageView = useRef(true); + const pathname = usePathname(); const trackers = useMemo( () => getEnabledTrackers(config.analytics), @@ -47,11 +53,26 @@ export function SiteTracking({ config }: Props) { config.analytics?.loadMode === "respectCookieBanner"; useEffect(() => { - if (initialized.current || trackers.length === 0) return; - initialized.current = true; + if (trackers.length === 0) return; - const loadAllowed = (analyticsAccepted: boolean) => { + const syncConsent = (analyticsAccepted: boolean) => { const allowed = trackersForConsent(trackers, config, analyticsAccepted); + const allowedKeys = new Set(allowed.map((tracker) => tracker._key)); + + // Withdrawal has to stop collection, not just stop new loads — so tear + // down anything already running that consent no longer covers. + const revoked = trackers.filter( + (tracker) => + consentLoaded.current.has(tracker._key) && + !allowedKeys.has(tracker._key), + ); + if (revoked.length > 0) { + unloadTrackers(revoked); + for (const tracker of revoked) { + consentLoaded.current.delete(tracker._key); + } + } + const pending = allowed.filter( (tracker) => !consentLoaded.current.has(tracker._key), ); @@ -62,13 +83,27 @@ export function SiteTracking({ config }: Props) { loadTrackers(pending); }; + // No one-shot guard here: the effect owns the consent subscription, so an + // early return on re-run would drop it for good (Strict Mode remounts, or + // any `config` change) and consent would never reach the loaders again. + // `consentLoaded` is what keeps loads idempotent. if (showBanner) { - loadAllowed(hasConsent("analytics")); - return subscribeAnalyticsConsent(loadAllowed); + syncConsent(hasConsent("analytics")); + return subscribeAnalyticsConsent(syncConsent); } - loadAllowed(true); + syncConsent(true); }, [config, showBanner, trackers]); + // Providers emit their own view when they initialise; only client-side + // navigations after that need one, hence skipping the first run. + useEffect(() => { + if (initialPageView.current) { + initialPageView.current = false; + return; + } + trackPageView(pathname, window.location.search); + }, [pathname]); + return null; } diff --git a/web/src/components/tracking/lib/cookieConsentConfig.ts b/web/src/components/tracking/lib/cookieConsentConfig.ts index ab7edac5..8c9da7e5 100644 --- a/web/src/components/tracking/lib/cookieConsentConfig.ts +++ b/web/src/components/tracking/lib/cookieConsentConfig.ts @@ -9,7 +9,7 @@ type CookieTableRow = Record; const DEFAULT_CONSENT_DESCRIPTION = "Our website uses essential cookies for basic operation. Cookie-based analytics run only after you accept them; privacy-friendly analytics without cookies may load earlier."; -function sectionsJsonFromSanity( +export function sectionsJsonFromSanity( sections: string | { code?: string | null } | null | undefined, ): string | null { if (typeof sections === "string") return sections; @@ -69,6 +69,30 @@ function trackerCookieRow(tracker: AnalyticsTracker): CookieTableRow { }; } +function isAnalyticsSection(section: Section): boolean { + const sectionId = (section as Section & { id?: string }).id; + return section.linkedCategory === "analytics" || sectionId === "analytics"; +} + +/** Append one disclosure row per tracker to an analytics section. */ +function withTrackerRows( + section: Section, + trackers: AnalyticsTracker[], +): Section { + const cookieTable = section.cookieTable ?? { + headers: { name: "Name", domain: "Domain", desc: "Description" }, + body: [] as CookieTableRow[], + }; + + return { + ...section, + cookieTable: { + ...cookieTable, + body: [...(cookieTable.body ?? []), ...trackers.map(trackerCookieRow)], + }, + }; +} + export function buildCookieSections( cookieBanner: SiteCookieBannerDocument | null, trackers: AnalyticsTracker[], @@ -80,27 +104,25 @@ export function buildCookieSections( const fallback = parseSectionsJson(defaultCookieSections); const sections = base.length > 0 ? base : fallback; - return sections.map((section) => { - const sectionId = (section as Section & { id?: string }).id; - if (section.linkedCategory !== "analytics" && sectionId !== "analytics") { - return section; - } - - const cookieTable = section.cookieTable ?? { - headers: { name: "Name", domain: "Domain", desc: "Description" }, - body: [] as CookieTableRow[], - }; + const withRows = sections.map((section) => + isAnalyticsSection(section) ? withTrackerRows(section, trackers) : section, + ); - const trackerRows = trackers.map(trackerCookieRow); + if (trackers.length === 0 || withRows.some(isAnalyticsSection)) { + return withRows; + } - return { - ...section, - cookieTable: { - ...cookieTable, - body: [...(cookieTable.body ?? []), ...trackerRows], - }, - }; - }); + // The editor-authored JSON is free-form, so the analytics section can be + // renamed or deleted. Without one there is no `analytics` category to grant, + // which both hides the disclosure and leaves consent-gated trackers unable to + // ever load — so synthesise it rather than silently dropping both. + return [ + ...withRows, + withTrackerRows( + { title: "Analytics", linkedCategory: "analytics" }, + trackers, + ), + ]; } export function buildCookieConsentTranslations( diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts index 5bcf50cf..77def15e 100644 --- a/web/src/components/tracking/lib/loadTrackers.ts +++ b/web/src/components/tracking/lib/loadTrackers.ts @@ -85,6 +85,21 @@ function loadMicrosoftClarity(tracker: AnalyticsTracker): void { registerLoadedTracker(tracker); } +/** + * PostHog Cloud serves the SDK from a separate assets host — the ingest host + * has no `/static/array.js`, so the default EU config would never load. Only + * the two known cloud hosts are rewritten; self-hosted instances serve both + * from the same origin and are left alone. + */ +function posthogAssetHost(apiHost: string): string { + return apiHost + .replace(/\/$/, "") + .replace( + /^(https?:\/\/)(us|eu)\.i\.posthog\.com$/i, + "$1$2-assets.i.posthog.com", + ); +} + function loadPostHog(tracker: AnalyticsTracker): void { if (tracker._type !== "trackerPostHog") return; const apiKey = tracker.apiKey?.trim(); @@ -92,11 +107,13 @@ function loadPostHog(tracker: AnalyticsTracker): void { if (!apiKey || !apiHost || isTrackerLoaded(tracker._key)) return; const cookieFree = tracker.cookieFree === true; - const scriptHost = apiHost.replace(/\/$/, ""); - const script = appendScript(`${scriptHost}/static/array.js`); + const script = appendScript(`${posthogAssetHost(apiHost)}/static/array.js`); let attempts = 0; const tryInit = () => { + // tryInit runs immediately, on script load, and on a retry timer — without + // this the pending timer re-initialises after the load handler succeeded. + if (isTrackerLoaded(tracker._key)) return; attempts += 1; const posthog = ( window as Window & { From 36e508d533475ca74cbdea162441304f551fa91c Mon Sep 17 00:00:00 2001 From: Arne Spremberg Date: Thu, 6 Aug 2026 17:02:40 +0200 Subject: [PATCH 100/457] docs(studio): tighten the privacy notes to labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass read like privacy consulting. Editors need the risk named, not the reasoning: cut the how and why, keep what they cannot see from the CMS — what loads before consent, what leaves the EEA, what needs signing elsewhere. Validation messages shortened to one clause each. Co-Authored-By: Claude Opus 5 (1M context) --- .../objects/analytics/complianceNotes.ts | 20 +++++++--------- .../analytics/trackerGoogleAnalytics.ts | 6 ++--- .../objects/analytics/trackerMatomo.ts | 4 ++-- .../analytics/trackerMicrosoftClarity.ts | 6 ++--- .../objects/analytics/trackerPlausible.ts | 4 ++-- .../objects/analytics/trackerPostHog.ts | 6 ++--- .../schemas/settings/siteAnalyticsSettings.ts | 8 +++---- studio/schemas/settings/siteCookieBanner.ts | 23 ++++++++----------- 8 files changed, 34 insertions(+), 43 deletions(-) diff --git a/studio/schemas/objects/analytics/complianceNotes.ts b/studio/schemas/objects/analytics/complianceNotes.ts index a3689aa7..8ba0e0d0 100644 --- a/studio/schemas/objects/analytics/complianceNotes.ts +++ b/studio/schemas/objects/analytics/complianceNotes.ts @@ -1,19 +1,15 @@ /** - * Shared privacy copy for the analytics tracker schemas. + * Shared privacy notes for the analytics tracker schemas. * - * An editor picking providers in the Studio cannot see which of them transmits - * personal data before the visitor answers the banner, which needs an - * agreement signed outside Sanity, or which one quietly records what visitors - * type. These strings put that in front of them at the point of the decision. - * - * Practical guidance for editors, not legal advice — the lawful basis for each - * provider belongs to whoever owns privacy for the site. + * Deliberately terse: name the risk an editor cannot see from the CMS — what + * loads before consent, what leaves the EEA, what has to be signed elsewhere — + * and stop there. Not legal advice. */ -/** Why "cookie-free" is not the same thing as "consent-free". */ +/** Cookie-free removes the ePrivacy cookie trigger, not the GDPR basis for what is still sent. */ export const COOKIE_FREE_NOTE = - "Cookie-free means nothing is written to the visitor's device, so the ePrivacy consent rule for cookies does not apply — which is why trackers marked cookie-free load *before* the visitor answers the banner. The provider still receives personal data (at minimum IP address and user agent), and that processing needs its own lawful basis under the GDPR. Confirm that is acceptable for this provider before switching it on."; + "Loads before consent. The provider still receives IP and user agent."; -/** Obligations that live outside the CMS and are easy to forget. */ +/** Obligations that live outside the CMS. */ export const CONTROLLER_CHECKLIST = - "Before enabling: name this provider and its purpose in your privacy policy, have a data processing agreement in place, and record the transfer basis if the data leaves the EEA."; + "Needs: privacy policy entry, DPA, transfer basis if data leaves the EEA."; diff --git a/studio/schemas/objects/analytics/trackerGoogleAnalytics.ts b/studio/schemas/objects/analytics/trackerGoogleAnalytics.ts index 450a69cc..c7754527 100644 --- a/studio/schemas/objects/analytics/trackerGoogleAnalytics.ts +++ b/studio/schemas/objects/analytics/trackerGoogleAnalytics.ts @@ -6,7 +6,7 @@ export const trackerGoogleAnalytics = defineType({ title: "Google Analytics", type: "object", description: - "Sends visitor data to Google in the US. Needs a Google Analytics data processing amendment and a documented transfer basis (Google self-certifies under the EU-US Data Privacy Framework). Several EU regulators have found specific GA deployments unlawful, so check the current position for your jurisdiction rather than assuming the DPF settles it. " + + "Sends data to Google in the US. Some EU regulators have ruled specific GA setups unlawful. " + CONTROLLER_CHECKLIST, fields: [ defineField({ @@ -35,13 +35,13 @@ export const trackerGoogleAnalytics = defineType({ name: "cookieFree", title: "Cookie-free mode", description: - "Sets client_storage: 'none' so GA4 writes no cookies — and, because the site treats cookie-free trackers as safe to start early, this also makes Google Analytics load before the visitor answers the banner. GA4 still sends the full hit, including the IP address, to Google in the US. Two things worth knowing: anonymize_ip has no effect in GA4 (it was a Universal Analytics setting), and turning this on is the riskiest combination available in this document — personal data reaching a US processor with no consent. Prefer leaving it off and letting GA wait for consent.", + "No GA cookies (client_storage: 'none'), and loads before consent. The full hit including IP still goes to Google. Note: anonymize_ip is a no-op in GA4.", type: "boolean", initialValue: false, validation: (rule) => rule.warning().custom((value) => { if (value !== true) return true; - return "Cookie-free mode makes Google Analytics load before consent while still sending IP addresses to Google in the US. Only keep this on if that transfer has been assessed and documented."; + return "Sends IPs to Google in the US with no consent."; }), }), defineField({ diff --git a/studio/schemas/objects/analytics/trackerMatomo.ts b/studio/schemas/objects/analytics/trackerMatomo.ts index 1485acfb..38479d7c 100644 --- a/studio/schemas/objects/analytics/trackerMatomo.ts +++ b/studio/schemas/objects/analytics/trackerMatomo.ts @@ -6,7 +6,7 @@ export const trackerMatomo = defineType({ title: "Matomo (self-hosted)", type: "object", description: - "Lowest-risk option here: self-hosted, so the analytics data stays with you and there is no third-party processor or transfer to account for. You are the controller for it. Two things to set on the Matomo side: anonymise IP addresses (Administration → Privacy → Anonymize data) and set a data retention period. Still name it in your privacy policy.", + "Self-hosted — you are the controller and there is no third-party transfer. Set IP anonymisation and a retention period in Matomo, and name it in your privacy policy.", fields: [ defineField({ name: "enabled", @@ -43,7 +43,7 @@ export const trackerMatomo = defineType({ defineField({ name: "cookieFree", title: "Cookie-free mode", - description: `Calls Matomo's disableCookies, so no first-party analytics cookies are set. ${COOKIE_FREE_NOTE}`, + description: `Calls disableCookies, so no analytics cookies are set. ${COOKIE_FREE_NOTE}`, type: "boolean", initialValue: false, }), diff --git a/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts b/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts index 5ec35e20..1070d5c0 100644 --- a/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts +++ b/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts @@ -6,14 +6,14 @@ export const trackerMicrosoftClarity = defineType({ title: "Microsoft Clarity", type: "object", description: - "Highest-risk provider here. Clarity records sessions — pointer movement, clicks, scrolls, and DOM snapshots of what the visitor sees — so anything on screen can end up in a recording, including text typed into a form. Mask every input and any element that can display personal data in Clarity's settings first. Systematic monitoring of visitor behaviour is one of the GDPR Art. 35 criteria, so consider whether a data protection impact assessment is needed. Data goes to Microsoft in the US. " + + "Session recording: pointer, clicks, and DOM snapshots — can capture form input. Mask inputs in Clarity first. Data goes to Microsoft in the US. Systematic monitoring, so a DPIA may be required (GDPR Art. 35). " + CONTROLLER_CHECKLIST, fields: [ defineField({ name: "enabled", title: "Enabled", description: - "Known limitation on withdrawal: when a visitor revokes analytics consent the site stops further Clarity API calls, but the already-loaded Clarity script is not torn down and its cookies are not removed until the visitor reloads the page.", + "On withdrawal the loaded script is not torn down and its cookies remain until the next page load.", type: "boolean", initialValue: true, }), @@ -34,7 +34,7 @@ export const trackerMicrosoftClarity = defineType({ name: "cookieFree", title: "Cookie-free mode", description: - "Not available — Clarity has no cookie-free mode, so it must never load before consent. Locked off deliberately: the site treats cookie-free trackers as safe to start early, and Clarity does not qualify.", + "Not available — Clarity always uses cookies, so it never loads before consent.", type: "boolean", initialValue: false, readOnly: true, diff --git a/studio/schemas/objects/analytics/trackerPlausible.ts b/studio/schemas/objects/analytics/trackerPlausible.ts index ae23c02b..87dc1b5f 100644 --- a/studio/schemas/objects/analytics/trackerPlausible.ts +++ b/studio/schemas/objects/analytics/trackerPlausible.ts @@ -5,7 +5,7 @@ export const trackerPlausible = defineType({ title: "Plausible Analytics", type: "object", description: - "Least intrusive third-party option: no cookies, no cross-site identifiers, and Plausible Cloud is EU-hosted. Because it counts as cookie-free it loads before the visitor answers the banner. It does still process the IP address server-side to derive a daily visitor hash, so it needs a lawful basis — usually legitimate interest — and a mention in your privacy policy. Regulators generally accept this for plain audience measurement, but the assessment is yours to make and record.", + "Cookie-free, no cross-site identifiers, EU-hosted. Loads before consent. The IP is hashed server-side, so it still needs a lawful basis (usually legitimate interest) and a privacy policy entry.", fields: [ defineField({ name: "enabled", @@ -45,7 +45,7 @@ export const trackerPlausible = defineType({ name: "cookieFree", title: "Cookie-free mode", description: - "Always on — Plausible is cookie-free by design, which is why it loads without waiting for analytics consent. Locked so it cannot be switched off and misrepresent the provider in the cookie banner.", + "Always on — Plausible is cookie-free by design, so it loads without waiting for consent.", type: "boolean", initialValue: true, readOnly: true, diff --git a/studio/schemas/objects/analytics/trackerPostHog.ts b/studio/schemas/objects/analytics/trackerPostHog.ts index 3f20d8c6..d7d02047 100644 --- a/studio/schemas/objects/analytics/trackerPostHog.ts +++ b/studio/schemas/objects/analytics/trackerPostHog.ts @@ -6,7 +6,7 @@ export const trackerPostHog = defineType({ title: "PostHog", type: "object", description: - "Important: session replay, autocapture, and heatmaps are switched on in the PostHog project settings, not here. If replay is enabled there, PostHog records visitor sessions with the same risks as Microsoft Clarity — and nothing in this document will tell you, so the banner description you write below can understate what is actually collected. Check the PostHog project before enabling. " + + "Session replay and autocapture are configured in PostHog, not here — if replay is on, treat this like Microsoft Clarity. " + CONTROLLER_CHECKLIST, fields: [ defineField({ @@ -31,7 +31,7 @@ export const trackerPostHog = defineType({ name: "apiHost", title: "API host", description: - "PostHog ingest host. Keep this on the EU cloud (https://eu.i.posthog.com) or your own self-hosted instance — pointing it at the US cloud (us.i.posthog.com) turns every event into a third-country transfer that needs its own basis.", + "Ingest host. Keep on the EU cloud (https://eu.i.posthog.com) or self-hosted; us.i.posthog.com is a US transfer.", type: "url", initialValue: "https://eu.i.posthog.com", validation: (rule) => @@ -45,7 +45,7 @@ export const trackerPostHog = defineType({ defineField({ name: "cookieFree", title: "Cookie-free mode", - description: `Uses in-memory persistence only, so no PostHog cookies or localStorage entries are written, and every page load counts as a new anonymous user. ${COOKIE_FREE_NOTE}`, + description: `No cookies or localStorage; every page load counts as a new anonymous user. ${COOKIE_FREE_NOTE}`, type: "boolean", initialValue: false, }), diff --git a/studio/schemas/settings/siteAnalyticsSettings.ts b/studio/schemas/settings/siteAnalyticsSettings.ts index cf5e2773..9bb52469 100644 --- a/studio/schemas/settings/siteAnalyticsSettings.ts +++ b/studio/schemas/settings/siteAnalyticsSettings.ts @@ -56,7 +56,7 @@ export const siteAnalyticsSettings = defineType({ name: "loadMode", title: "Loading mode", description: - "Respect cookie banner (recommended): cookie-based trackers wait for analytics consent; trackers marked cookie-free still load immediately. Load on page load: every enabled tracker runs before the visitor has answered the banner — for EEA/UK visitors that is generally unlawful for any tracker that sets cookies or reads device storage (ePrivacy Art. 5(3)), so use it only after your own legal review.", + "Respect cookie banner (recommended): cookie-based trackers wait for consent, cookie-free ones load immediately. Load on page load: everything runs before consent — unlawful in the EEA/UK for any tracker using cookies or device storage (ePrivacy Art. 5(3)).", type: "string", options: { list: [ @@ -74,7 +74,7 @@ export const siteAnalyticsSettings = defineType({ (context.document as { trackers?: unknown })?.trackers, ); if (count === 0) return true; - return `"Load on page load" starts ${count === 1 ? "this tracker" : `all ${count} enabled trackers`} before the visitor consents, and the cookie banner is bypassed entirely. Unless legal has signed off on this specific setup, switch to "Respect cookie banner".`; + return `Runs ${count === 1 ? "1 tracker" : `${count} trackers`} before consent and bypasses the banner.`; }), ], }), @@ -82,14 +82,14 @@ export const siteAnalyticsSettings = defineType({ name: "trackers", title: "Tracking providers", description: - "Add one entry per provider. Only enabled providers with valid configuration are loaded on the site. Each provider carries its own privacy obligations — open the entry and read the notes there before switching it on.", + "One entry per provider. Only enabled, fully configured providers load. Each has its own privacy notes — read them before enabling.", type: "array", validation: (rule) => rule.warning().custom(async (trackers, context) => { const count = enabledTrackerCount(trackers); if (count === 0) return true; if (await cookieBannerIsActive(context)) return true; - return `The cookie banner is switched off in Cookie Banner settings, so ${count === 1 ? "this tracker runs" : `all ${count} enabled trackers run`} with no consent at all — the loading mode above has no effect without a banner to wait for. Switch "Use Cookie Banner" on, or disable the trackers.`; + return `Cookie banner is off, so ${count === 1 ? "1 tracker runs" : `${count} trackers run`} with no consent. Loading mode has no effect without a banner.`; }), of: [ defineArrayMember({ type: "trackerGoogleAnalytics" }), diff --git a/studio/schemas/settings/siteCookieBanner.ts b/studio/schemas/settings/siteCookieBanner.ts index d3ffbba9..49002366 100644 --- a/studio/schemas/settings/siteCookieBanner.ts +++ b/studio/schemas/settings/siteCookieBanner.ts @@ -29,7 +29,7 @@ export const siteCookieBanner = defineType({ title: "Use Cookie Banner", name: "useCookieBanner", description: - "Turning this off does not stop tracking — it removes the gate. With no banner there is no consent to wait for, so every tracker enabled under Analytics & Tracking loads immediately regardless of its loading mode. Keep this on whenever a tracker is enabled and the site has EEA/UK visitors.", + "Off does not stop tracking — it removes the gate. Every tracker enabled under Analytics & Tracking then loads with no consent, whatever its loading mode.", type: "boolean", initialValue: false, validation: (rule) => @@ -47,7 +47,7 @@ export const siteCookieBanner = defineType({ (tracker) => tracker?.enabled !== false, ).length; if (count === 0) return true; - return `${count === 1 ? "A tracker is" : `${count} trackers are`} enabled under Analytics & Tracking and will run with no consent while the banner is off.`; + return `${count === 1 ? "1 tracker is" : `${count} trackers are`} enabled under Analytics & Tracking and will run with no consent.`; }), }, { @@ -60,7 +60,7 @@ export const siteCookieBanner = defineType({ title: "Description", name: "description", description: - 'Shown in the banner. To be transparent under GDPR Art. 13 this should say who is collecting the data and why, and link to your privacy or cookie policy — HTML is rendered here, so `Privacy policy` works. If a provider sends data outside the EEA (Google Analytics, Microsoft Clarity), say so.', + 'Must name the controller and link to your privacy policy (GDPR Art. 13) — HTML is rendered, so `Privacy policy` works. Note any provider outside the EEA.', type: "string", initialValue: "Our website uses essential cookies to ensure proper operation and tracking cookies to understand your interaction. Tracking is only activated after consent.", @@ -69,7 +69,7 @@ export const siteCookieBanner = defineType({ if (typeof value !== "string" || value.includes("href=")) { return true; } - return "No link found. The banner should link to your privacy or cookie policy so visitors can see what they are consenting to."; + return "No link — should link to your privacy or cookie policy."; }), }, { @@ -82,15 +82,13 @@ export const siteCookieBanner = defineType({ title: "Accept Necessary Button", name: "acceptNecessaryBtn", description: - "The reject button. Must stay filled in: the banner library only renders this button when the label is non-empty, so clearing it leaves Accept as the only option — refusing consent has to be as easy as giving it, and regulators treat a missing reject button as invalid consent.", + "Reject button. An empty label removes the button entirely, leaving Accept as the only option — which invalidates the consent.", type: "string", initialValue: "Reject", validation: (rule) => rule .required() - .error( - "Required — an empty label hides the reject button entirely, which makes the consent invalid.", - ), + .error("Required — an empty label hides the reject button."), }, { title: "Show Preferences Button", @@ -121,16 +119,13 @@ export const siteCookieBanner = defineType({ { title: "Accept Necessary Button", name: "acceptNecessaryBtn", - description: - "The reject button in the preferences dialog. As above, an empty label removes the button.", + description: "Reject button. An empty label removes it entirely.", type: "string", initialValue: "Reject all", validation: (rule) => rule .required() - .error( - "Required — an empty label hides the reject button entirely, which makes the consent invalid.", - ), + .error("Required — an empty label hides the reject button."), }, { title: "Save Preferences Button", @@ -142,7 +137,7 @@ export const siteCookieBanner = defineType({ title: "Sections", name: "sections", description: - "Rows for the analytics category are appended automatically from the enabled trackers. Two caveats if your jurisdiction expects an exact cookie disclosure: the generated rows show the provider's own domain, whereas cookies like _ga are actually set on your domain, and no retention period is shown. Add precise names and durations here when that matters.", + "Analytics rows are appended automatically from the enabled trackers. Generated rows show the provider domain, not the actual cookie host, and no durations — add exact names and retention here if your jurisdiction expects them.", type: "code", options: { language: "json", From 02cc1c06cce2703f04181763de534a49ff45055a Mon Sep 17 00:00:00 2001 From: Arne Spremberg Date: Thu, 6 Aug 2026 17:28:32 +0200 Subject: [PATCH 101/457] fix(tracking): close the consent bypasses found in review round two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fail-open paths, all of which loaded or kept running a tracker the visitor had not agreed to: - Clarity could load before consent. `isCookieFreeTracker` trusted the stored `cookieFree` flag, and Studio `readOnly` is a UI affordance rather than a data invariant — an imported or API-written document could set it true. Clarity is now hard-coded as never cookie-free. - A document missing `loadMode` failed open. `showBanner` compared for equality with "respectCookieBanner" while `shouldRespectCookieBanner` treats everything except "onPageLoad" as gated, so legacy or API-written content skipped the banner and loaded cookie trackers. Both now derive from the same helper. - Withdrawal during PostHog's async init did nothing. `unloadTracker` found no instance yet, then the pending timer initialised it anyway. Loads now carry a generation that `unregisterLoadedTracker` invalidates. - Re-granting consent produced a dead tracker. `ga-disable-`, `optUserOut`, and `opt_out_capturing` all outlive the script, so a reloaded provider stayed silently opted out. Each loader now clears its own opt-out. Also: withdrawal deletes the first-party cookies each provider leaves behind (GA `_ga*`/`_gac_*`, Clarity `_clck`/`_clsk`, PostHog `ph_*`), and revoking Clarity reloads the page — its recorder holds its own listeners, so replacing the global never stopped collection, only new API calls. No loop risk: consent is already withdrawn when the page comes back. Schema copy notes the CSP entry self-hosted PostHog and Plausible need, and Clarity's field now describes the reload instead of the old limitation. Co-Authored-By: Claude Opus 5 (1M context) --- .../analytics/trackerMicrosoftClarity.ts | 4 +- .../objects/analytics/trackerPlausible.ts | 2 +- .../objects/analytics/trackerPostHog.ts | 2 +- .../schemas/settings/siteAnalyticsSettings.ts | 2 +- studio/schemas/settings/siteCookieBanner.ts | 2 +- web/src/components/tracking/SiteTracking.tsx | 14 ++-- .../components/tracking/lib/loadTrackers.ts | 18 +++++ .../components/tracking/lib/trackingConfig.ts | 30 +++++++++ .../tracking/lib/trackingRuntime.ts | 66 ++++++++++++++++++- 9 files changed, 129 insertions(+), 11 deletions(-) diff --git a/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts b/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts index 1070d5c0..e094aed7 100644 --- a/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts +++ b/studio/schemas/objects/analytics/trackerMicrosoftClarity.ts @@ -6,14 +6,14 @@ export const trackerMicrosoftClarity = defineType({ title: "Microsoft Clarity", type: "object", description: - "Session recording: pointer, clicks, and DOM snapshots — can capture form input. Mask inputs in Clarity first. Data goes to Microsoft in the US. Systematic monitoring, so a DPIA may be required (GDPR Art. 35). " + + "Session recording: pointer, clicks, and DOM snapshots — can capture form input. Mask inputs in Clarity first. Microsoft (US). Systematic monitoring, so may need a DPIA (Art. 35). " + CONTROLLER_CHECKLIST, fields: [ defineField({ name: "enabled", title: "Enabled", description: - "On withdrawal the loaded script is not torn down and its cookies remain until the next page load.", + "Withdrawing consent reloads the page — Clarity's recorder cannot be stopped in place.", type: "boolean", initialValue: true, }), diff --git a/studio/schemas/objects/analytics/trackerPlausible.ts b/studio/schemas/objects/analytics/trackerPlausible.ts index 87dc1b5f..430cada0 100644 --- a/studio/schemas/objects/analytics/trackerPlausible.ts +++ b/studio/schemas/objects/analytics/trackerPlausible.ts @@ -30,7 +30,7 @@ export const trackerPlausible = defineType({ name: "scriptUrl", title: "Script URL", description: - "Plausible script URL (default: https://plausible.io/js/script.js). Use your self-hosted URL if applicable.", + "Default https://plausible.io/js/script.js. Self-hosted URLs need adding to Content-Security-Policy in netlify.toml.", type: "url", initialValue: "https://plausible.io/js/script.js", validation: (rule) => diff --git a/studio/schemas/objects/analytics/trackerPostHog.ts b/studio/schemas/objects/analytics/trackerPostHog.ts index d7d02047..08479464 100644 --- a/studio/schemas/objects/analytics/trackerPostHog.ts +++ b/studio/schemas/objects/analytics/trackerPostHog.ts @@ -31,7 +31,7 @@ export const trackerPostHog = defineType({ name: "apiHost", title: "API host", description: - "Ingest host. Keep on the EU cloud (https://eu.i.posthog.com) or self-hosted; us.i.posthog.com is a US transfer.", + "Ingest host. Keep on the EU cloud (https://eu.i.posthog.com) or self-hosted; us.i.posthog.com is a US transfer. Self-hosted hosts need adding to Content-Security-Policy in netlify.toml.", type: "url", initialValue: "https://eu.i.posthog.com", validation: (rule) => diff --git a/studio/schemas/settings/siteAnalyticsSettings.ts b/studio/schemas/settings/siteAnalyticsSettings.ts index 9bb52469..e97d5162 100644 --- a/studio/schemas/settings/siteAnalyticsSettings.ts +++ b/studio/schemas/settings/siteAnalyticsSettings.ts @@ -56,7 +56,7 @@ export const siteAnalyticsSettings = defineType({ name: "loadMode", title: "Loading mode", description: - "Respect cookie banner (recommended): cookie-based trackers wait for consent, cookie-free ones load immediately. Load on page load: everything runs before consent — unlawful in the EEA/UK for any tracker using cookies or device storage (ePrivacy Art. 5(3)).", + "Respect cookie banner (recommended): cookie-based trackers wait for consent, cookie-free ones load immediately. Load on page load: everything runs before consent — unlawful in the EEA/UK for anything using cookies or storage.", type: "string", options: { list: [ diff --git a/studio/schemas/settings/siteCookieBanner.ts b/studio/schemas/settings/siteCookieBanner.ts index 49002366..f277a87e 100644 --- a/studio/schemas/settings/siteCookieBanner.ts +++ b/studio/schemas/settings/siteCookieBanner.ts @@ -137,7 +137,7 @@ export const siteCookieBanner = defineType({ title: "Sections", name: "sections", description: - "Analytics rows are appended automatically from the enabled trackers. Generated rows show the provider domain, not the actual cookie host, and no durations — add exact names and retention here if your jurisdiction expects them.", + "Analytics rows are appended from the enabled trackers. Generated rows show the provider domain, not the real cookie host, and no durations — add exact names and retention if your jurisdiction expects them.", type: "code", options: { language: "json", diff --git a/web/src/components/tracking/SiteTracking.tsx b/web/src/components/tracking/SiteTracking.tsx index f812fc13..3d92f450 100644 --- a/web/src/components/tracking/SiteTracking.tsx +++ b/web/src/components/tracking/SiteTracking.tsx @@ -10,6 +10,7 @@ import { } from "@/src/components/cookies/cookieConsentApi"; import { loadTrackers } from "@/src/components/tracking/lib/loadTrackers"; import { + bannerGatesLoading, getEnabledTrackers, type TrackingConfig, trackerRequiresConsent, @@ -48,9 +49,7 @@ export function SiteTracking({ config }: Props) { [config.analytics], ); - const showBanner = - config.cookieBanner?.useCookieBanner === true && - config.analytics?.loadMode === "respectCookieBanner"; + const showBanner = bannerGatesLoading(config); useEffect(() => { if (trackers.length === 0) return; @@ -67,10 +66,17 @@ export function SiteTracking({ config }: Props) { !allowedKeys.has(tracker._key), ); if (revoked.length > 0) { - unloadTrackers(revoked); + const needsReload = unloadTrackers(revoked); for (const tracker of revoked) { consentLoaded.current.delete(tracker._key); } + // Clarity's recorder cannot be torn down in place, so a reload is the + // only thing that genuinely stops it. Safe from looping: after the + // reload consent is already withdrawn, so it never loads again. + if (needsReload) { + window.location.reload(); + return; + } } const pending = allowed.filter( diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts index 77def15e..7bb87062 100644 --- a/web/src/components/tracking/lib/loadTrackers.ts +++ b/web/src/components/tracking/lib/loadTrackers.ts @@ -1,5 +1,7 @@ import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings"; import { + beginTrackerLoad, + isCurrentTrackerLoad, isTrackerLoaded, registerLoadedTracker, } from "@/src/components/tracking/lib/trackingRuntime"; @@ -25,6 +27,12 @@ function loadGoogleAnalytics(tracker: AnalyticsTracker): void { const id = tracker.measurementId?.trim(); if (!id || isTrackerLoaded(tracker._key)) return; + // A previous withdrawal set `ga-disable-` on window, and that outlives the + // script — without clearing it, re-granting consent loads a disabled GA. + (window as unknown as Record)[ + `ga-disable-${id}` + ] = false; + appendScript( `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`, ); @@ -53,6 +61,7 @@ function loadMatomo(tracker: AnalyticsTracker): void { const inline = document.createElement("script"); inline.textContent = ` var _paq = window._paq = window._paq || []; +_paq.push(['forgetUserOptOut']); ${cookieFree ? "_paq.push(['disableCookies']);" : ""} _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); @@ -107,10 +116,15 @@ function loadPostHog(tracker: AnalyticsTracker): void { if (!apiKey || !apiHost || isTrackerLoaded(tracker._key)) return; const cookieFree = tracker.cookieFree === true; + const generation = beginTrackerLoad(tracker._key); const script = appendScript(`${posthogAssetHost(apiHost)}/static/array.js`); let attempts = 0; const tryInit = () => { + // Consent can be withdrawn while the script or the retry timer is still in + // flight; without this the pending callback initialises PostHog *after* the + // withdrawal and starts collecting again. + if (!isCurrentTrackerLoad(tracker._key, generation)) return; // tryInit runs immediately, on script load, and on a retry timer — without // this the pending timer re-initialises after the load handler succeeded. if (isTrackerLoaded(tracker._key)) return; @@ -122,6 +136,7 @@ function loadPostHog(tracker: AnalyticsTracker): void { key: string, options: { api_host: string; persistence: string }, ) => void; + opt_in_capturing?: () => void; }; } ).posthog; @@ -130,6 +145,9 @@ function loadPostHog(tracker: AnalyticsTracker): void { api_host: apiHost, persistence: cookieFree ? "memory" : "localStorage+cookie", }); + // A previous withdrawal's `opt_out_capturing` persists in storage, so + // without this a re-grant would init an opted-out instance. + posthog.opt_in_capturing?.(); registerLoadedTracker(tracker); return; } diff --git a/web/src/components/tracking/lib/trackingConfig.ts b/web/src/components/tracking/lib/trackingConfig.ts index 966ac6e0..e812d8eb 100644 --- a/web/src/components/tracking/lib/trackingConfig.ts +++ b/web/src/components/tracking/lib/trackingConfig.ts @@ -51,12 +51,42 @@ export function shouldRespectCookieBanner( return isCookieBannerActive(cookieBanner); } +/** + * Providers that never qualify as cookie-free, whatever the document says. + * + * Clarity has no cookie-free mode, and its Studio field is `readOnly` — but + * that is a UI affordance, not a data invariant. An imported or API-written + * document carrying `cookieFree: true` would otherwise load Clarity before + * consent, which is exactly the case that must never happen. + */ +const NEVER_COOKIE_FREE: ReadonlySet = new Set([ + "trackerMicrosoftClarity", +]); + /** Cookie-free trackers may load without analytics consent when the banner is active. */ export function isCookieFreeTracker(tracker: AnalyticsTracker): boolean { + if (NEVER_COOKIE_FREE.has(tracker._type)) return false; if (tracker._type === "trackerPlausible") return true; return tracker.cookieFree === true; } +/** + * Whether the banner gates loading for this document. + * + * Derived from `shouldRespectCookieBanner` rather than an equality check on + * `loadMode`, so a document missing that field (legacy or API-written) still + * waits for consent instead of failing open. + */ +export function bannerGatesLoading(config: { + analytics: SiteAnalyticsSettingsDocument | null; + cookieBanner: SiteCookieBannerDocument | null; +}): boolean { + return shouldRespectCookieBanner( + config.analytics?.loadMode, + config.cookieBanner, + ); +} + export function trackerRequiresConsent( tracker: AnalyticsTracker, loadMode: AnalyticsLoadMode | null | undefined, diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts index 33bde87c..32de7723 100644 --- a/web/src/components/tracking/lib/trackingRuntime.ts +++ b/web/src/components/tracking/lib/trackingRuntime.ts @@ -6,16 +6,40 @@ type LoadedTracker = { const loadedTrackers = new Map(); +/** + * Monotonic counter per tracker key, bumped on every load and unload. + * + * PostHog initialises asynchronously (script load plus a retry timer). If + * consent is withdrawn while that is in flight, the pending callback would + * otherwise still run `posthog.init` and start collecting *after* the + * withdrawal. A loader captures the generation it started under and bails when + * it no longer matches. + */ +const loadGenerations = new Map(); + export function isTrackerLoaded(key: string): boolean { return loadedTrackers.has(key); } +/** Claim a load attempt; the returned generation guards async continuations. */ +export function beginTrackerLoad(key: string): number { + const generation = (loadGenerations.get(key) ?? 0) + 1; + loadGenerations.set(key, generation); + return generation; +} + +export function isCurrentTrackerLoad(key: string, generation: number): boolean { + return loadGenerations.get(key) === generation; +} + export function registerLoadedTracker(tracker: AnalyticsTracker): void { loadedTrackers.set(tracker._key, { tracker }); } export function unregisterLoadedTracker(key: string): void { loadedTrackers.delete(key); + // Invalidates any load still in flight for this key. + beginTrackerLoad(key); } export function trackPageView(pathname: string, search = ""): void { @@ -71,6 +95,33 @@ export function trackPageView(pathname: string, search = ""): void { } } +/** + * Best-effort cookie removal. The same name can exist on both the host and the + * dot-prefixed domain, and we cannot know which the provider used, so clear + * every plausible scope. + */ +function deleteCookies(matches: (name: string) => boolean): void { + const { hostname } = window.location; + const scopes = ["", `; domain=${hostname}`, `; domain=.${hostname}`]; + for (const entry of document.cookie.split(";")) { + const name = entry.split("=")[0]?.trim(); + if (!name || !matches(name)) continue; + for (const scope of scopes) { + // biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is Chromium-only and cannot target a parent domain, which is where analytics cookies usually live. + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/${scope}`; + } + } +} + +/** + * Providers with no in-page teardown. Clarity's recorder keeps its own + * references and listeners, so replacing the global only blocks new API calls — + * a reload is the only thing that actually stops it collecting. + */ +function requiresReloadToStop(tracker: AnalyticsTracker): boolean { + return tracker._type === "trackerMicrosoftClarity"; +} + export function unloadTracker(tracker: AnalyticsTracker): void { switch (tracker._type) { case "trackerGoogleAnalytics": { @@ -80,6 +131,13 @@ export function unloadTracker(tracker: AnalyticsTracker): void { `ga-disable-${id}` ] = true; } + deleteCookies( + (name) => + name === "_ga" || + name === "_gid" || + name.startsWith("_ga_") || + name.startsWith("_gac_"), + ); break; } case "trackerMatomo": { @@ -104,6 +162,7 @@ export function unloadTracker(tracker: AnalyticsTracker): void { window as Window & { clarity?: (...args: unknown[]) => void } ).clarity = () => {}; } + deleteCookies((name) => name === "_clck" || name === "_clsk"); break; } case "trackerPostHog": { @@ -117,6 +176,7 @@ export function unloadTracker(tracker: AnalyticsTracker): void { ).posthog; posthog?.opt_out_capturing?.(); posthog?.reset?.(); + deleteCookies((name) => name.startsWith("ph_")); break; } case "trackerPlausible": @@ -126,8 +186,12 @@ export function unloadTracker(tracker: AnalyticsTracker): void { unregisterLoadedTracker(tracker._key); } -export function unloadTrackers(trackers: AnalyticsTracker[]): void { +/** Returns true when a provider was stopped that only a reload fully tears down. */ +export function unloadTrackers(trackers: AnalyticsTracker[]): boolean { + let needsReload = false; for (const tracker of trackers) { unloadTracker(tracker); + if (requiresReloadToStop(tracker)) needsReload = true; } + return needsReload; } From 9c2239a1ced242e1bec183afa260ec433a0475c0 Mon Sep 17 00:00:00 2001 From: Arne Spremberg Date: Thu, 6 Aug 2026 17:50:58 +0200 Subject: [PATCH 102/457] fix(studio): align the banner's default copy with what actually loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema seeded "Tracking is only activated after consent", but cookie-free providers load before the visitor answers — and the code's own DEFAULT_CONSENT_DESCRIPTION already said so. A banner that overstates the gating is worse than no banner, so the two now match. Co-Authored-By: Claude Opus 5 (1M context) --- studio/schemas/settings/siteCookieBanner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/schemas/settings/siteCookieBanner.ts b/studio/schemas/settings/siteCookieBanner.ts index f277a87e..a950ca0c 100644 --- a/studio/schemas/settings/siteCookieBanner.ts +++ b/studio/schemas/settings/siteCookieBanner.ts @@ -63,7 +63,7 @@ export const siteCookieBanner = defineType({ 'Must name the controller and link to your privacy policy (GDPR Art. 13) — HTML is rendered, so `Privacy policy` works. Note any provider outside the EEA.', type: "string", initialValue: - "Our website uses essential cookies to ensure proper operation and tracking cookies to understand your interaction. Tracking is only activated after consent.", + "Our website uses essential cookies for basic operation. Cookie-based analytics run only after you accept them; privacy-friendly analytics without cookies may load earlier.", validation: (rule) => rule.warning().custom((value) => { if (typeof value !== "string" || value.includes("href=")) { From edf252852837e9bc01d72049cc058bcb689d1ef6 Mon Sep 17 00:00:00 2001 From: Arne Spremberg Date: Thu, 6 Aug 2026 18:15:36 +0200 Subject: [PATCH 103/457] fix(tracking): reconcile against running trackers, harden banner construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round three of review. Five issues, all reachable from ordinary CMS edits: - Disabling or deleting a tracker in Sanity left it collecting. The effect compared consent against the *current* document, so a tracker that had vanished from it was never considered for teardown — and an empty list returned before doing anything at all. Now reconciles against what is running, and compares the value too, so editing a tracker in place (same `_key`) reloads it instead of silently keeping the old config. - Cookie deletion missed the domain that actually set the cookie. On `www.example.com` GA writes `_ga` for `.example.com`, which was never in the attempted scopes, so it survived withdrawal. Walks the hostname suffixes down to two labels. - Re-granting consent stacked duplicate script tags: unload cleared the registry but left the DOM alone. Injected tags are tracked per key and removed on unload; Matomo's and Clarity's snippets no longer inject their own script so those are tracked too. - A malformed or relative URL in the CMS threw from `new URL` while building the banner, and the throw happens before `CookieConsent.run` returns a promise — so the catch never fired and the banner never rendered, leaving no way to give or withdraw consent. Hostname extraction now falls back. - A section authored with `id: "analytics"` but no `linkedCategory` matched as the analytics section, skipping the synthesised fallback, while `buildConfig` derives categories from `linkedCategory` alone — so there was no category to grant. Matched sections get the link applied. Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/tracking/SiteTracking.tsx | 38 +++++----- .../tracking/lib/cookieConsentConfig.ts | 31 ++++++-- .../components/tracking/lib/loadTrackers.ts | 73 ++++++++++++------- .../tracking/lib/trackingRuntime.ts | 44 +++++++++-- 4 files changed, 130 insertions(+), 56 deletions(-) diff --git a/web/src/components/tracking/SiteTracking.tsx b/web/src/components/tracking/SiteTracking.tsx index 3d92f450..d1d4e341 100644 --- a/web/src/components/tracking/SiteTracking.tsx +++ b/web/src/components/tracking/SiteTracking.tsx @@ -40,7 +40,7 @@ function trackersForConsent( } export function SiteTracking({ config }: Props) { - const consentLoaded = useRef(new Set()); + const loaded = useRef(new Map()); const initialPageView = useRef(true); const pathname = usePathname(); @@ -52,23 +52,24 @@ export function SiteTracking({ config }: Props) { const showBanner = bannerGatesLoading(config); useEffect(() => { - if (trackers.length === 0) return; - const syncConsent = (analyticsAccepted: boolean) => { const allowed = trackersForConsent(trackers, config, analyticsAccepted); - const allowedKeys = new Set(allowed.map((tracker) => tracker._key)); - - // Withdrawal has to stop collection, not just stop new loads — so tear - // down anything already running that consent no longer covers. - const revoked = trackers.filter( - (tracker) => - consentLoaded.current.has(tracker._key) && - !allowedKeys.has(tracker._key), + const allowedByKey = new Map( + allowed.map((tracker) => [tracker._key, tracker]), ); - if (revoked.length > 0) { - const needsReload = unloadTrackers(revoked); - for (const tracker of revoked) { - consentLoaded.current.delete(tracker._key); + + // Reconcile against what is running, not against the current document. + // A tracker disabled or deleted in Sanity vanishes from `trackers` + // entirely, so comparing the two would leave it collecting; a tracker + // edited in place keeps its `_key`, so compare the value too. + const stale = Array.from(loaded.current.values()).filter((tracker) => { + const next = allowedByKey.get(tracker._key); + return !next || JSON.stringify(next) !== JSON.stringify(tracker); + }); + if (stale.length > 0) { + const needsReload = unloadTrackers(stale); + for (const tracker of stale) { + loaded.current.delete(tracker._key); } // Clarity's recorder cannot be torn down in place, so a reload is the // only thing that genuinely stops it. Safe from looping: after the @@ -80,11 +81,13 @@ export function SiteTracking({ config }: Props) { } const pending = allowed.filter( - (tracker) => !consentLoaded.current.has(tracker._key), + (tracker) => !loaded.current.has(tracker._key), ); if (pending.length === 0) return; + // Recorded before loading, not after: PostHog initialises + // asynchronously, so waiting would let a re-run inject it twice. for (const tracker of pending) { - consentLoaded.current.add(tracker._key); + loaded.current.set(tracker._key, tracker); } loadTrackers(pending); }; @@ -108,6 +111,7 @@ export function SiteTracking({ config }: Props) { initialPageView.current = false; return; } + if (loaded.current.size === 0) return; trackPageView(pathname, window.location.search); }, [pathname]); diff --git a/web/src/components/tracking/lib/cookieConsentConfig.ts b/web/src/components/tracking/lib/cookieConsentConfig.ts index 8c9da7e5..f40271b2 100644 --- a/web/src/components/tracking/lib/cookieConsentConfig.ts +++ b/web/src/components/tracking/lib/cookieConsentConfig.ts @@ -30,6 +30,23 @@ function parseSectionsJson(raw: string | null | undefined): Section[] { } } +/** + * Never throws. These values come from the CMS, and `new URL` on a relative or + * malformed one would take down the whole consent banner — leaving no way to + * give or withdraw consent at all. + */ +function hostnameOf( + value: string | null | undefined, + fallback: string, +): string { + if (!value) return fallback; + try { + return new URL(value).hostname; + } catch { + return fallback; + } +} + function trackerCookieRow(tracker: AnalyticsTracker): CookieTableRow { const label = tracker.cookieBannerLabel?.trim() || tracker._type; const baseDescription = @@ -45,20 +62,16 @@ function trackerCookieRow(tracker: AnalyticsTracker): CookieTableRow { domain = "google-analytics.com"; break; case "trackerMatomo": - domain = tracker.url ? new URL(tracker.url).hostname : "matomo"; + domain = hostnameOf(tracker.url, "matomo"); break; case "trackerMicrosoftClarity": domain = "clarity.ms"; break; case "trackerPostHog": - domain = tracker.apiHost - ? new URL(tracker.apiHost).hostname - : "posthog.com"; + domain = hostnameOf(tracker.apiHost, "posthog.com"); break; case "trackerPlausible": - domain = tracker.scriptUrl - ? new URL(tracker.scriptUrl).hostname - : "plausible.io"; + domain = hostnameOf(tracker.scriptUrl, "plausible.io"); break; } @@ -86,6 +99,10 @@ function withTrackerRows( return { ...section, + // A section identified only by `id` still needs the link: consent + // categories are derived from `linkedCategory`, so without it there is no + // `analytics` category to grant and gated trackers can never load. + linkedCategory: "analytics", cookieTable: { ...cookieTable, body: [...(cookieTable.body ?? []), ...trackers.map(trackerCookieRow)], diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts index 7bb87062..18d9233b 100644 --- a/web/src/components/tracking/lib/loadTrackers.ts +++ b/web/src/components/tracking/lib/loadTrackers.ts @@ -3,12 +3,19 @@ import { beginTrackerLoad, isCurrentTrackerLoad, isTrackerLoaded, + registerInjectedScript, registerLoadedTracker, } from "@/src/components/tracking/lib/trackingRuntime"; const POSTHOG_INIT_MAX_ATTEMPTS = 40; +/** + * Every injected tag is registered against the tracker so `unloadTracker` can + * remove it — otherwise withdrawing and re-granting consent stacks a duplicate + * copy of each provider's script on every cycle. + */ function appendScript( + key: string, src: string, attributes?: Record, ): HTMLScriptElement { @@ -19,6 +26,15 @@ function appendScript( script.setAttribute(name, value); } document.head.appendChild(script); + registerInjectedScript(key, script); + return script; +} + +function appendInlineScript(key: string, source: string): HTMLScriptElement { + const script = document.createElement("script"); + script.textContent = source; + document.head.appendChild(script); + registerInjectedScript(key, script); return script; } @@ -34,18 +50,20 @@ function loadGoogleAnalytics(tracker: AnalyticsTracker): void { ] = false; appendScript( + tracker._key, `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`, ); - const inline = document.createElement("script"); const cookieFree = tracker.cookieFree === true; - inline.textContent = ` + appendInlineScript( + tracker._key, + ` window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', ${JSON.stringify(id)}, ${cookieFree ? "{ client_storage: 'none', anonymize_ip: true }" : "{}"}); -`; - document.head.appendChild(inline); +`, + ); registerLoadedTracker(tracker); } @@ -58,22 +76,21 @@ function loadMatomo(tracker: AnalyticsTracker): void { const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; const cookieFree = tracker.cookieFree === true; - const inline = document.createElement("script"); - inline.textContent = ` + // Same queue order as Matomo's official snippet; only the script insertion is + // hoisted out so the tag can be removed again on withdrawal. + appendInlineScript( + tracker._key, + ` var _paq = window._paq = window._paq || []; _paq.push(['forgetUserOptOut']); ${cookieFree ? "_paq.push(['disableCookies']);" : ""} _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); -(function() { - var u=${JSON.stringify(normalizedUrl)}; - _paq.push(['setTrackerUrl', u+'matomo.php']); - _paq.push(['setSiteId', ${JSON.stringify(siteId)}]); - var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; - g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); -})(); -`; - document.head.appendChild(inline); +_paq.push(['setTrackerUrl', ${JSON.stringify(`${normalizedUrl}matomo.php`)}]); +_paq.push(['setSiteId', ${JSON.stringify(siteId)}]); +`, + ); + appendScript(tracker._key, `${normalizedUrl}matomo.js`); registerLoadedTracker(tracker); } @@ -82,15 +99,16 @@ function loadMicrosoftClarity(tracker: AnalyticsTracker): void { const projectId = tracker.projectId?.trim(); if (!projectId || isTrackerLoaded(tracker._key)) return; - const inline = document.createElement("script"); - inline.textContent = ` -(function(c,l,a,r,i,t,y){ - c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)}; - t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i; - y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y); -})(window, document, "clarity", "script", ${JSON.stringify(projectId)}); -`; - document.head.appendChild(inline); + appendInlineScript( + tracker._key, + ` +window.clarity = window.clarity || function(){(window.clarity.q=window.clarity.q||[]).push(arguments)}; +`, + ); + appendScript( + tracker._key, + `https://www.clarity.ms/tag/${encodeURIComponent(projectId)}`, + ); registerLoadedTracker(tracker); } @@ -117,7 +135,10 @@ function loadPostHog(tracker: AnalyticsTracker): void { const cookieFree = tracker.cookieFree === true; const generation = beginTrackerLoad(tracker._key); - const script = appendScript(`${posthogAssetHost(apiHost)}/static/array.js`); + const script = appendScript( + tracker._key, + `${posthogAssetHost(apiHost)}/static/array.js`, + ); let attempts = 0; const tryInit = () => { @@ -169,7 +190,7 @@ function loadPlausible(tracker: AnalyticsTracker): void { const scriptUrl = tracker.scriptUrl?.trim(); if (!domain || !scriptUrl || isTrackerLoaded(tracker._key)) return; - appendScript(scriptUrl, { + appendScript(tracker._key, scriptUrl, { defer: "", "data-domain": domain, }); diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts index 32de7723..31d0a785 100644 --- a/web/src/components/tracking/lib/trackingRuntime.ts +++ b/web/src/components/tracking/lib/trackingRuntime.ts @@ -17,10 +17,29 @@ const loadedTrackers = new Map(); */ const loadGenerations = new Map(); +/** Script tags a loader injected, so a re-grant does not stack duplicates. */ +const injectedScripts = new Map(); + export function isTrackerLoaded(key: string): boolean { return loadedTrackers.has(key); } +export function registerInjectedScript( + key: string, + script: HTMLScriptElement, +): void { + const scripts = injectedScripts.get(key); + if (scripts) scripts.push(script); + else injectedScripts.set(key, [script]); +} + +function removeInjectedScripts(key: string): void { + for (const script of injectedScripts.get(key) ?? []) { + script.remove(); + } + injectedScripts.delete(key); +} + /** Claim a load attempt; the returned generation guards async continuations. */ export function beginTrackerLoad(key: string): number { const generation = (loadGenerations.get(key) ?? 0) + 1; @@ -38,6 +57,7 @@ export function registerLoadedTracker(tracker: AnalyticsTracker): void { export function unregisterLoadedTracker(key: string): void { loadedTrackers.delete(key); + removeInjectedScripts(key); // Invalidates any load still in flight for this key. beginTrackerLoad(key); } @@ -96,17 +116,29 @@ export function trackPageView(pathname: string, search = ""): void { } /** - * Best-effort cookie removal. The same name can exist on both the host and the - * dot-prefixed domain, and we cannot know which the provider used, so clear - * every plausible scope. + * Every domain a cookie on this page could have been scoped to. + * + * A cookie can only be deleted from the exact domain that set it, and analytics + * usually writes to the registrable domain rather than the host — on + * `www.example.com`, GA sets `_ga` for `.example.com`. So walk the hostname + * suffixes down to two labels and try each, dot-prefixed and not. */ +function cookieDomainScopes(): string[] { + const labels = window.location.hostname.split("."); + const scopes = [""]; // host-only cookie, no domain attribute + for (let i = 0; labels.length - i >= 2; i++) { + const domain = labels.slice(i).join("."); + scopes.push(`; domain=${domain}`, `; domain=.${domain}`); + } + return scopes; +} + +/** Best-effort cookie removal across every scope the provider might have used. */ function deleteCookies(matches: (name: string) => boolean): void { - const { hostname } = window.location; - const scopes = ["", `; domain=${hostname}`, `; domain=.${hostname}`]; for (const entry of document.cookie.split(";")) { const name = entry.split("=")[0]?.trim(); if (!name || !matches(name)) continue; - for (const scope of scopes) { + for (const scope of cookieDomainScopes()) { // biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is Chromium-only and cannot target a parent domain, which is where analytics cookies usually live. document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/${scope}`; } From e4bb8c9271d3fdc7f1153b66bd3f07b43989d11b Mon Sep 17 00:00:00 2001 From: Arne Spremberg Date: Thu, 6 Aug 2026 19:01:50 +0200 Subject: [PATCH 104/457] fix(tracking): escape banner copy, stop overriding provider-side opt-outs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round four. Two of these are regressions from my own earlier fixes: - Stored XSS. The consent description is assigned with `innerHTML` by vanilla-cookieconsent, and the schema help text I added actively told editors to put an `` tag in it — with `unsafe-inline` still in the CSP that is script execution for anyone with Studio write access. The description is now escaped and rendered as text; the policy link comes from dedicated `privacyPolicyUrl`/`privacyPolicyLabel` fields and is built in trusted code behind an http(s)-or-relative protocol allowlist. - Overriding the visitor's own opt-out. `forgetUserOptOut` and `opt_in_capturing` ran on every load, not only after a banner-driven re-grant, so someone who had opted out through Matomo's or PostHog's own mechanism was quietly re-enabled. Withdrawal now records that the opt-out was ours, and only that is reversed. - A copy edit forced a page reload. Staleness compared whole documents, so editing a display-only field like `cookieBannerLabel` counted as a config change — and for Clarity a teardown means `location.reload()`. Compares a load signature of the fields that actually affect loading. - The `open-cookie-preferences` link can sit in navigation while the banner is disabled, where `showPreferences()` has nothing to show. Guarded. Not changed, for the record: Bugbot re-reported the parent-domain cookie scopes, script re-injection, and re-grant opt-out as open on the commit that fixed them. Copilot's `...doc.preferencesModal` warning is also wrong — object spread of null yields `{}`. Co-Authored-By: Claude Opus 5 (1M context) --- studio/schemas/settings/siteCookieBanner.ts | 27 +++++++++---- web/sanity/types/siteCookieBanner.ts | 3 ++ web/src/components/cookies/CookieConsent.tsx | 3 +- .../components/cookies/cookieConsentApi.ts | 11 +++++- web/src/components/tracking/SiteTracking.tsx | 7 +++- .../tracking/lib/cookieConsentConfig.ts | 38 ++++++++++++++++++- .../components/tracking/lib/loadTrackers.ts | 12 ++++-- .../components/tracking/lib/trackingConfig.ts | 25 ++++++++++++ .../tracking/lib/trackingRuntime.ts | 20 ++++++++++ 9 files changed, 130 insertions(+), 16 deletions(-) diff --git a/studio/schemas/settings/siteCookieBanner.ts b/studio/schemas/settings/siteCookieBanner.ts index a950ca0c..7bcc24be 100644 --- a/studio/schemas/settings/siteCookieBanner.ts +++ b/studio/schemas/settings/siteCookieBanner.ts @@ -60,17 +60,30 @@ export const siteCookieBanner = defineType({ title: "Description", name: "description", description: - 'Must name the controller and link to your privacy policy (GDPR Art. 13) — HTML is rendered, so `Privacy policy` works. Note any provider outside the EEA.', + "Must name the controller and say why (GDPR Art. 13). Plain text — it is escaped before rendering; use the policy link fields below rather than HTML. Note any provider outside the EEA.", type: "string", initialValue: "Our website uses essential cookies for basic operation. Cookie-based analytics run only after you accept them; privacy-friendly analytics without cookies may load earlier.", + }, + { + title: "Privacy Policy URL", + name: "privacyPolicyUrl", + description: + "Linked from the banner. Visitors must be able to read what they are agreeing to before they agree.", + type: "url", + options: { allowRelative: true }, + initialValue: "/privacy", validation: (rule) => - rule.warning().custom((value) => { - if (typeof value !== "string" || value.includes("href=")) { - return true; - } - return "No link — should link to your privacy or cookie policy."; - }), + rule + .warning() + .required() + .error("Without this the banner has no policy link."), + }, + { + title: "Privacy Policy Link Label", + name: "privacyPolicyLabel", + type: "string", + initialValue: "Privacy policy", }, { title: "Accept All Button", diff --git a/web/sanity/types/siteCookieBanner.ts b/web/sanity/types/siteCookieBanner.ts index aadbe82e..2cd2ebba 100644 --- a/web/sanity/types/siteCookieBanner.ts +++ b/web/sanity/types/siteCookieBanner.ts @@ -1,5 +1,8 @@ export type CookieConsentModalCopy = { + /** Plain text — escaped before rendering, so markup here will not run. */ description?: string | null; + privacyPolicyUrl?: string | null; + privacyPolicyLabel?: string | null; acceptAllBtn?: string | null; acceptNecessaryBtn?: string | null; showPreferencesBtn?: string | null; diff --git a/web/src/components/cookies/CookieConsent.tsx b/web/src/components/cookies/CookieConsent.tsx index 29b5f86e..9463931b 100644 --- a/web/src/components/cookies/CookieConsent.tsx +++ b/web/src/components/cookies/CookieConsent.tsx @@ -7,6 +7,7 @@ import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings"; import type { SiteCookieBannerDocument } from "@/sanity/types/siteCookieBanner"; import { buildCookieSections, + consentDescriptionHtml, sectionsJsonFromSanity, } from "@/src/components/tracking/lib/cookieConsentConfig"; import { notifyAnalyticsConsentChange } from "./cookieConsentApi"; @@ -77,7 +78,7 @@ function buildConfig( [locale]: { consentModal: { title: "", - description: doc.consentModal?.description ?? "", + description: consentDescriptionHtml(doc.consentModal), acceptAllBtn: doc.consentModal?.acceptAllBtn ?? "Accept", acceptNecessaryBtn: doc.consentModal?.acceptNecessaryBtn ?? "Reject", diff --git a/web/src/components/cookies/cookieConsentApi.ts b/web/src/components/cookies/cookieConsentApi.ts index 4cd151fe..a3644145 100644 --- a/web/src/components/cookies/cookieConsentApi.ts +++ b/web/src/components/cookies/cookieConsentApi.ts @@ -31,5 +31,14 @@ export function hasConsent(category: string): boolean { /** Open the preferences modal — wired to the `open-cookie-preferences` linkFunction. */ export function showCookiePreferences(): void { - CookieConsent.showPreferences(); + // The link can be placed in navigation regardless of settings, but + // showPreferences() has nothing to show unless run() started the banner. + try { + CookieConsent.showPreferences(); + } catch (err) { + console.warn( + "[CookieConsent] Preferences unavailable — the banner is not enabled.", + err, + ); + } } diff --git a/web/src/components/tracking/SiteTracking.tsx b/web/src/components/tracking/SiteTracking.tsx index d1d4e341..9455e312 100644 --- a/web/src/components/tracking/SiteTracking.tsx +++ b/web/src/components/tracking/SiteTracking.tsx @@ -13,6 +13,7 @@ import { bannerGatesLoading, getEnabledTrackers, type TrackingConfig, + trackerLoadSignature, trackerRequiresConsent, } from "@/src/components/tracking/lib/trackingConfig"; import { @@ -61,10 +62,12 @@ export function SiteTracking({ config }: Props) { // Reconcile against what is running, not against the current document. // A tracker disabled or deleted in Sanity vanishes from `trackers` // entirely, so comparing the two would leave it collecting; a tracker - // edited in place keeps its `_key`, so compare the value too. + // edited in place keeps its `_key`, so compare what affects loading. const stale = Array.from(loaded.current.values()).filter((tracker) => { const next = allowedByKey.get(tracker._key); - return !next || JSON.stringify(next) !== JSON.stringify(tracker); + return ( + !next || trackerLoadSignature(next) !== trackerLoadSignature(tracker) + ); }); if (stale.length > 0) { const needsReload = unloadTrackers(stale); diff --git a/web/src/components/tracking/lib/cookieConsentConfig.ts b/web/src/components/tracking/lib/cookieConsentConfig.ts index f40271b2..8c11bf8a 100644 --- a/web/src/components/tracking/lib/cookieConsentConfig.ts +++ b/web/src/components/tracking/lib/cookieConsentConfig.ts @@ -142,6 +142,42 @@ export function buildCookieSections( ]; } +const HTML_ESCAPES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char); +} + +/** + * Banner description as HTML. + * + * vanilla-cookieconsent assigns this with `innerHTML`, so editor text is + * escaped rather than trusted — otherwise anyone with Studio write access has a + * stored-XSS path, and the CSP still allows inline script. The policy link is + * the one bit of markup, built here from a URL field with a protocol allowlist + * so `javascript:` cannot slip through. + */ +export function consentDescriptionHtml( + consent: SiteCookieBannerDocument["consentModal"] | null | undefined, +): string { + const text = escapeHtml( + consent?.description?.trim() || DEFAULT_CONSENT_DESCRIPTION, + ); + const url = consent?.privacyPolicyUrl?.trim(); + if (!url || !/^(https?:\/\/|\/)/i.test(url)) return text; + + const label = escapeHtml( + consent?.privacyPolicyLabel?.trim() || "Privacy policy", + ); + return `${text} ${label}`; +} + export function buildCookieConsentTranslations( cookieBanner: SiteCookieBannerDocument | null, trackers: AnalyticsTracker[], @@ -152,7 +188,7 @@ export function buildCookieConsentTranslations( return { en: { consentModal: { - description: consent?.description ?? DEFAULT_CONSENT_DESCRIPTION, + description: consentDescriptionHtml(consent), acceptAllBtn: consent?.acceptAllBtn ?? "Accept", acceptNecessaryBtn: consent?.acceptNecessaryBtn ?? "Reject", showPreferencesBtn: consent?.showPreferencesBtn ?? "Manage preferences", diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts index 18d9233b..f2b3af1f 100644 --- a/web/src/components/tracking/lib/loadTrackers.ts +++ b/web/src/components/tracking/lib/loadTrackers.ts @@ -1,6 +1,7 @@ import type { AnalyticsTracker } from "@/sanity/types/siteAnalyticsSettings"; import { beginTrackerLoad, + consumeSelfOptOut, isCurrentTrackerLoad, isTrackerLoaded, registerInjectedScript, @@ -75,6 +76,9 @@ function loadMatomo(tracker: AnalyticsTracker): void { const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; const cookieFree = tracker.cookieFree === true; + // Only reverse an opt-out this integration set. A visitor who opted out + // through Matomo's own mechanism stays opted out. + const reverseOwnOptOut = consumeSelfOptOut(tracker._key); // Same queue order as Matomo's official snippet; only the script insertion is // hoisted out so the tag can be removed again on withdrawal. @@ -82,7 +86,7 @@ function loadMatomo(tracker: AnalyticsTracker): void { tracker._key, ` var _paq = window._paq = window._paq || []; -_paq.push(['forgetUserOptOut']); +${reverseOwnOptOut ? "_paq.push(['forgetUserOptOut']);" : ""} ${cookieFree ? "_paq.push(['disableCookies']);" : ""} _paq.push(['trackPageView']); _paq.push(['enableLinkTracking']); @@ -166,9 +170,9 @@ function loadPostHog(tracker: AnalyticsTracker): void { api_host: apiHost, persistence: cookieFree ? "memory" : "localStorage+cookie", }); - // A previous withdrawal's `opt_out_capturing` persists in storage, so - // without this a re-grant would init an opted-out instance. - posthog.opt_in_capturing?.(); + // A withdrawal *we* made persists in storage, so a re-grant has to + // reverse it — but a provider-side opt-out is left alone. + if (consumeSelfOptOut(tracker._key)) posthog.opt_in_capturing?.(); registerLoadedTracker(tracker); return; } diff --git a/web/src/components/tracking/lib/trackingConfig.ts b/web/src/components/tracking/lib/trackingConfig.ts index e812d8eb..f694cfec 100644 --- a/web/src/components/tracking/lib/trackingConfig.ts +++ b/web/src/components/tracking/lib/trackingConfig.ts @@ -87,6 +87,31 @@ export function bannerGatesLoading(config: { ); } +/** + * The fields that actually change how a tracker loads. + * + * Reconciliation compares this rather than the whole document: an edit to a + * display-only field like `cookieBannerLabel` would otherwise count as a config + * change and tear the provider down — and for Clarity a teardown means a full + * page reload, which is not something a copy edit should cause. + */ +export function trackerLoadSignature(tracker: AnalyticsTracker): string { + switch (tracker._type) { + case "trackerGoogleAnalytics": + return `ga:${tracker.measurementId}:${tracker.cookieFree}`; + case "trackerMatomo": + return `matomo:${tracker.url}:${tracker.siteId}:${tracker.cookieFree}`; + case "trackerMicrosoftClarity": + return `clarity:${tracker.projectId}`; + case "trackerPostHog": + return `posthog:${tracker.apiKey}:${tracker.apiHost}:${tracker.cookieFree}`; + case "trackerPlausible": + return `plausible:${tracker.domain}:${tracker.scriptUrl}`; + default: + return JSON.stringify(tracker); + } +} + export function trackerRequiresConsent( tracker: AnalyticsTracker, loadMode: AnalyticsLoadMode | null | undefined, diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts index 31d0a785..24e42981 100644 --- a/web/src/components/tracking/lib/trackingRuntime.ts +++ b/web/src/components/tracking/lib/trackingRuntime.ts @@ -20,6 +20,24 @@ const loadGenerations = new Map(); /** Script tags a loader injected, so a re-grant does not stack duplicates. */ const injectedScripts = new Map(); +/** + * Trackers *we* opted out of, so a later grant only reverses our own decision. + * + * Matomo's and PostHog's opt-outs persist in storage and can also be set by the + * visitor through the provider's own mechanism. Clearing them unconditionally on + * load would silently re-enable tracking for someone who opted out elsewhere. + */ +const selfOptedOut = new Set(); + +export function markSelfOptedOut(key: string): void { + selfOptedOut.add(key); +} + +/** True once, if this integration was what opted the visitor out. */ +export function consumeSelfOptOut(key: string): boolean { + return selfOptedOut.delete(key); +} + export function isTrackerLoaded(key: string): boolean { return loadedTrackers.has(key); } @@ -180,6 +198,7 @@ export function unloadTracker(tracker: AnalyticsTracker): void { )._paq; _paq?.push(["optUserOut"]); _paq?.push(["deleteCookies"]); + markSelfOptedOut(tracker._key); break; } case "trackerMicrosoftClarity": { @@ -208,6 +227,7 @@ export function unloadTracker(tracker: AnalyticsTracker): void { ).posthog; posthog?.opt_out_capturing?.(); posthog?.reset?.(); + markSelfOptedOut(tracker._key); deleteCookies((name) => name.startsWith("ph_")); break; } From d0f82d2f6d6da6abf48218058d05b3735b4c830a Mon Sep 17 00:00:00 2001 From: Arne Spremberg Date: Fri, 7 Aug 2026 09:04:57 +0200 Subject: [PATCH 105/457] fix(tracking): persist the self-opt-out marker across reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from the previous commit. Gating `forgetUserOptOut` and `opt_in_capturing` on "did we opt this visitor out" was right, but the marker lived in a module-level Set while the provider opt-outs it mirrors live in cookies and localStorage. Any reload cleared it — including the reload that withdrawal triggers for Clarity — so a later consent grant found no marker and left Matomo and PostHog opted out for good. Stored in localStorage now, which matches the durability of what it tracks. Recording a consent decision is strictly necessary processing, so it needs no consent itself, and a storage failure degrades to "not ours to reverse". Co-Authored-By: Claude Opus 5 (1M context) --- .../tracking/lib/trackingRuntime.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts index 24e42981..e33adeb0 100644 --- a/web/src/components/tracking/lib/trackingRuntime.ts +++ b/web/src/components/tracking/lib/trackingRuntime.ts @@ -21,21 +21,36 @@ const loadGenerations = new Map(); const injectedScripts = new Map(); /** - * Trackers *we* opted out of, so a later grant only reverses our own decision. + * Marks a tracker *we* opted out of, so a later grant only reverses our own + * decision — a visitor who opted out through Matomo's or PostHog's own + * mechanism must stay opted out. * - * Matomo's and PostHog's opt-outs persist in storage and can also be set by the - * visitor through the provider's own mechanism. Clearing them unconditionally on - * load would silently re-enable tracking for someone who opted out elsewhere. + * Persisted rather than held in memory: the provider opt-outs this mirrors live + * in cookies and localStorage and survive reloads, so an in-memory marker would + * be lost on the next page load — including the reload withdrawal triggers for + * Clarity — leaving those providers permanently opted out. Storing a consent + * decision is strictly necessary processing, so it needs no consent of its own. */ -const selfOptedOut = new Set(); +const SELF_OPT_OUT_PREFIX = "analytics-self-opt-out:"; export function markSelfOptedOut(key: string): void { - selfOptedOut.add(key); + try { + window.localStorage.setItem(`${SELF_OPT_OUT_PREFIX}${key}`, "1"); + } catch { + // Private browsing or storage disabled — nothing to reverse later either. + } } /** True once, if this integration was what opted the visitor out. */ export function consumeSelfOptOut(key: string): boolean { - return selfOptedOut.delete(key); + try { + const storageKey = `${SELF_OPT_OUT_PREFIX}${key}`; + if (window.localStorage.getItem(storageKey) === null) return false; + window.localStorage.removeItem(storageKey); + return true; + } catch { + return false; + } } export function isTrackerLoaded(key: string): boolean { From 1c2500e64da253dadd04b64d1a423e7acc0ba7e6 Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 15:58:17 +0200 Subject: [PATCH 106/457] chore(typegen): regenerate for the analytics schemas Co-Authored-By: Claude Opus 5.5 --- studio/sanity.types.gen.ts | 87 ++++++- studio/schema.json | 442 ++++++++++++++++++++++++++++++++- web/sanity/sanity.types.gen.ts | 185 +++++++++++++- 3 files changed, 700 insertions(+), 14 deletions(-) diff --git a/studio/sanity.types.gen.ts b/studio/sanity.types.gen.ts index 2d8ea527..fd5618a0 100644 --- a/studio/sanity.types.gen.ts +++ b/studio/sanity.types.gen.ts @@ -26,6 +26,81 @@ export type VideoSettings = { controls?: boolean; }; +export type TrackerPlausible = { + _type: "trackerPlausible"; + enabled?: boolean; + domain?: string; + scriptUrl?: string; + cookieFree?: boolean; + cookieBannerLabel?: string; + cookieBannerDescription?: string; +}; + +export type TrackerPostHog = { + _type: "trackerPostHog"; + enabled?: boolean; + apiKey?: string; + apiHost?: string; + cookieFree?: boolean; + cookieBannerLabel?: string; + cookieBannerDescription?: string; +}; + +export type TrackerMicrosoftClarity = { + _type: "trackerMicrosoftClarity"; + enabled?: boolean; + projectId?: string; + cookieFree?: boolean; + cookieBannerLabel?: string; + cookieBannerDescription?: string; +}; + +export type TrackerMatomo = { + _type: "trackerMatomo"; + enabled?: boolean; + url?: string; + siteId?: string; + cookieFree?: boolean; + cookieBannerLabel?: string; + cookieBannerDescription?: string; +}; + +export type TrackerGoogleAnalytics = { + _type: "trackerGoogleAnalytics"; + enabled?: boolean; + measurementId?: string; + cookieFree?: boolean; + cookieBannerLabel?: string; + cookieBannerDescription?: string; +}; + +export type SiteAnalyticsSettings = { + _id: string; + _type: "siteAnalyticsSettings"; + _createdAt: string; + _updatedAt: string; + _rev: string; + title?: string; + loadMode: "onPageLoad" | "respectCookieBanner"; + trackers?: Array< + | ({ + _key: string; + } & TrackerGoogleAnalytics) + | ({ + _key: string; + } & TrackerMatomo) + | ({ + _key: string; + } & TrackerMicrosoftClarity) + | ({ + _key: string; + } & TrackerPostHog) + | ({ + _key: string; + } & TrackerPlausible) + >; +}; + export type SiteLanguageSettings = { _id: string; _type: "siteLanguageSettings"; @@ -340,14 +415,16 @@ export type SiteCookieBanner = { useCookieBanner?: boolean; consentModal?: { description?: string; + privacyPolicyUrl: string; + privacyPolicyLabel?: string; acceptAllBtn?: string; - acceptNecessaryBtn?: string; + acceptNecessaryBtn: string; showPreferencesBtn?: string; }; preferencesModal?: { title?: string; acceptAllBtn?: string; - acceptNecessaryBtn?: string; + acceptNecessaryBtn: string; savePreferencesBtn?: string; sections?: Code; }; @@ -780,6 +857,12 @@ export type Geopoint = { export type AllSanitySchemaTypes = | VideoSettings + | TrackerPlausible + | TrackerPostHog + | TrackerMicrosoftClarity + | TrackerMatomo + | TrackerGoogleAnalytics + | SiteAnalyticsSettings | SiteLanguageSettings | RichTextMedia | RichText diff --git a/studio/schema.json b/studio/schema.json index 20c81436..1734d572 100644 --- a/studio/schema.json +++ b/studio/schema.json @@ -22,6 +22,430 @@ } } }, + { + "name": "trackerPlausible", + "type": "type", + "value": { + "type": "object", + "attributes": { + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "trackerPlausible" + } + }, + "enabled": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "domain": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "scriptUrl": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "cookieFree": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "cookieBannerLabel": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "cookieBannerDescription": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + } + } + } + }, + { + "name": "trackerPostHog", + "type": "type", + "value": { + "type": "object", + "attributes": { + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "trackerPostHog" + } + }, + "enabled": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "apiKey": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "apiHost": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "cookieFree": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "cookieBannerLabel": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "cookieBannerDescription": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + } + } + } + }, + { + "name": "trackerMicrosoftClarity", + "type": "type", + "value": { + "type": "object", + "attributes": { + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "trackerMicrosoftClarity" + } + }, + "enabled": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "projectId": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "cookieFree": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "cookieBannerLabel": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "cookieBannerDescription": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + } + } + } + }, + { + "name": "trackerMatomo", + "type": "type", + "value": { + "type": "object", + "attributes": { + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "trackerMatomo" + } + }, + "enabled": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "url": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "siteId": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "cookieFree": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "cookieBannerLabel": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "cookieBannerDescription": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + } + } + } + }, + { + "name": "trackerGoogleAnalytics", + "type": "type", + "value": { + "type": "object", + "attributes": { + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "trackerGoogleAnalytics" + } + }, + "enabled": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "measurementId": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "cookieFree": { + "type": "objectAttribute", + "value": { + "type": "boolean" + }, + "optional": true + }, + "cookieBannerLabel": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "cookieBannerDescription": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + } + } + } + }, + { + "name": "siteAnalyticsSettings", + "type": "document", + "attributes": { + "_id": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_type": { + "type": "objectAttribute", + "value": { + "type": "string", + "value": "siteAnalyticsSettings" + } + }, + "_createdAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_updatedAt": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "_rev": { + "type": "objectAttribute", + "value": { + "type": "string" + } + }, + "title": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "loadMode": { + "type": "objectAttribute", + "value": { + "type": "union", + "of": [ + { + "type": "string", + "value": "onPageLoad" + }, + { + "type": "string", + "value": "respectCookieBanner" + } + ] + }, + "optional": false + }, + "trackers": { + "type": "objectAttribute", + "value": { + "type": "array", + "of": { + "type": "union", + "of": [ + { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "trackerGoogleAnalytics" + } + }, + { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "trackerMatomo" + } + }, + { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "trackerMicrosoftClarity" + } + }, + { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "trackerPostHog" + } + }, + { + "type": "object", + "attributes": { + "_key": { + "type": "objectAttribute", + "value": { + "type": "string" + } + } + }, + "rest": { + "type": "inline", + "name": "trackerPlausible" + } + } + ] + } + }, + "optional": true + } + } + }, { "name": "siteLanguageSettings", "type": "document", @@ -1884,6 +2308,20 @@ }, "optional": true }, + "privacyPolicyUrl": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": false + }, + "privacyPolicyLabel": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, "acceptAllBtn": { "type": "objectAttribute", "value": { @@ -1896,7 +2334,7 @@ "value": { "type": "string" }, - "optional": true + "optional": false }, "showPreferencesBtn": { "type": "objectAttribute", @@ -1933,7 +2371,7 @@ "value": { "type": "string" }, - "optional": true + "optional": false }, "savePreferencesBtn": { "type": "objectAttribute", diff --git a/web/sanity/sanity.types.gen.ts b/web/sanity/sanity.types.gen.ts index 971bb11a..fae90421 100644 --- a/web/sanity/sanity.types.gen.ts +++ b/web/sanity/sanity.types.gen.ts @@ -26,6 +26,81 @@ export type VideoSettings = { controls?: boolean; }; +export type TrackerPlausible = { + _type: "trackerPlausible"; + enabled?: boolean; + domain?: string; + scriptUrl?: string; + cookieFree?: boolean; + cookieBannerLabel?: string; + cookieBannerDescription?: string; +}; + +export type TrackerPostHog = { + _type: "trackerPostHog"; + enabled?: boolean; + apiKey?: string; + apiHost?: string; + cookieFree?: boolean; + cookieBannerLabel?: string; + cookieBannerDescription?: string; +}; + +export type TrackerMicrosoftClarity = { + _type: "trackerMicrosoftClarity"; + enabled?: boolean; + projectId?: string; + cookieFree?: boolean; + cookieBannerLabel?: string; + cookieBannerDescription?: string; +}; + +export type TrackerMatomo = { + _type: "trackerMatomo"; + enabled?: boolean; + url?: string; + siteId?: string; + cookieFree?: boolean; + cookieBannerLabel?: string; + cookieBannerDescription?: string; +}; + +export type TrackerGoogleAnalytics = { + _type: "trackerGoogleAnalytics"; + enabled?: boolean; + measurementId?: string; + cookieFree?: boolean; + cookieBannerLabel?: string; + cookieBannerDescription?: string; +}; + +export type SiteAnalyticsSettings = { + _id: string; + _type: "siteAnalyticsSettings"; + _createdAt: string; + _updatedAt: string; + _rev: string; + title?: string; + loadMode: "onPageLoad" | "respectCookieBanner"; + trackers?: Array< + | ({ + _key: string; + } & TrackerGoogleAnalytics) + | ({ + _key: string; + } & TrackerMatomo) + | ({ + _key: string; + } & TrackerMicrosoftClarity) + | ({ + _key: string; + } & TrackerPostHog) + | ({ + _key: string; + } & TrackerPlausible) + >; +}; + export type SiteLanguageSettings = { _id: string; _type: "siteLanguageSettings"; @@ -340,14 +415,16 @@ export type SiteCookieBanner = { useCookieBanner?: boolean; consentModal?: { description?: string; + privacyPolicyUrl: string; + privacyPolicyLabel?: string; acceptAllBtn?: string; - acceptNecessaryBtn?: string; + acceptNecessaryBtn: string; showPreferencesBtn?: string; }; preferencesModal?: { title?: string; acceptAllBtn?: string; - acceptNecessaryBtn?: string; + acceptNecessaryBtn: string; savePreferencesBtn?: string; sections?: Code; }; @@ -780,6 +857,12 @@ export type Geopoint = { export type AllSanitySchemaTypes = | VideoSettings + | TrackerPlausible + | TrackerPostHog + | TrackerMicrosoftClarity + | TrackerMatomo + | TrackerGoogleAnalytics + | SiteAnalyticsSettings | SiteLanguageSettings | RichTextMedia | RichText @@ -880,26 +963,107 @@ export type SiteSettingsSeoFallbackQueryResult = { // Source: sanity/queries/snippets/settings.ts // Variable: siteCookieBannerLayoutQuery -// Query: *[_type == "siteCookieBanner" && language == $locale][0]{ _id, language, useCookieBanner, consentModal, preferencesModal} +// Query: *[_type == "siteCookieBanner" && language == $locale][0]{ _id, language, useCookieBanner, consentModal, preferencesModal { title, acceptAllBtn, acceptNecessaryBtn, savePreferencesBtn, "sections": sections.code }} export type SiteCookieBannerLayoutQueryResult = { _id: string; language: string | null; useCookieBanner: boolean | null; consentModal: { description?: string; + privacyPolicyUrl: string; + privacyPolicyLabel?: string; acceptAllBtn?: string; - acceptNecessaryBtn?: string; + acceptNecessaryBtn: string; showPreferencesBtn?: string; } | null; preferencesModal: { - title?: string; - acceptAllBtn?: string; - acceptNecessaryBtn?: string; - savePreferencesBtn?: string; - sections?: Code; + title: string | null; + acceptAllBtn: string | null; + acceptNecessaryBtn: string; + savePreferencesBtn: string | null; + sections: string | null; } | null; } | null; +// Source: sanity/queries/snippets/settings.ts +// Variable: siteAnalyticsSettingsQuery +// Query: *[_id == "siteAnalyticsSettings"][0]{ _id, title, loadMode, trackers[]{ _key, _type, enabled, cookieFree, cookieBannerLabel, cookieBannerDescription, _type == "trackerGoogleAnalytics" => { measurementId }, _type == "trackerMatomo" => { url, siteId }, _type == "trackerMicrosoftClarity" => { projectId }, _type == "trackerPostHog" => { apiKey, apiHost }, _type == "trackerPlausible" => { domain, scriptUrl } }} +export type SiteAnalyticsSettingsQueryResult = + | { + _id: "siteAnalyticsSettings"; + title: null; + loadMode: null; + trackers: null; + } + | { + _id: "siteAnalyticsSettings"; + title: string; + loadMode: null; + trackers: null; + } + | { + _id: "siteAnalyticsSettings"; + title: string | null; + loadMode: null; + trackers: null; + } + | { + _id: "siteAnalyticsSettings"; + title: string | null; + loadMode: "onPageLoad" | "respectCookieBanner"; + trackers: Array< + | { + _key: string; + _type: "trackerGoogleAnalytics"; + enabled: boolean | null; + cookieFree: boolean | null; + cookieBannerLabel: string | null; + cookieBannerDescription: string | null; + measurementId: string | null; + } + | { + _key: string; + _type: "trackerMatomo"; + enabled: boolean | null; + cookieFree: boolean | null; + cookieBannerLabel: string | null; + cookieBannerDescription: string | null; + url: string | null; + siteId: string | null; + } + | { + _key: string; + _type: "trackerMicrosoftClarity"; + enabled: boolean | null; + cookieFree: boolean | null; + cookieBannerLabel: string | null; + cookieBannerDescription: string | null; + projectId: string | null; + } + | { + _key: string; + _type: "trackerPlausible"; + enabled: boolean | null; + cookieFree: boolean | null; + cookieBannerLabel: string | null; + cookieBannerDescription: string | null; + domain: string | null; + scriptUrl: string | null; + } + | { + _key: string; + _type: "trackerPostHog"; + enabled: boolean | null; + cookieFree: boolean | null; + cookieBannerLabel: string | null; + cookieBannerDescription: string | null; + apiKey: string | null; + apiHost: string | null; + } + > | null; + } + | null; + // Source: sanity/queries/snippets/sitemap.ts // Variable: pageSlugsQuery // Query: *[_type == "page" && defined(slug.current)]{ "slug": slug.current, language} @@ -960,7 +1124,8 @@ declare global { '*[_id == "siteLanguageSettings"][0]{\n _id,\n availableLanguages[]{id, title},\n defaultLanguageId\n}': SiteLanguageSettingsQueryResult; '*[_type == "siteSettings" && language == $locale][0]{title}': SiteSettingsTitleQueryResult; '*[_type == "siteSettings" && language == $locale][0]{\n "title": seo.title,\n "description": seo.description,\n "imageUrl": seo.image.asset->url\n}': SiteSettingsSeoFallbackQueryResult; - '*[_type == "siteCookieBanner" && language == $locale][0]{\n _id,\n language,\n useCookieBanner,\n consentModal,\n preferencesModal\n}': SiteCookieBannerLayoutQueryResult; + '*[_type == "siteCookieBanner" && language == $locale][0]{\n _id,\n language,\n useCookieBanner,\n consentModal,\n preferencesModal {\n title,\n acceptAllBtn,\n acceptNecessaryBtn,\n savePreferencesBtn,\n "sections": sections.code\n }\n}': SiteCookieBannerLayoutQueryResult; + '*[_id == "siteAnalyticsSettings"][0]{\n _id,\n title,\n loadMode,\n trackers[]{\n _key,\n _type,\n enabled,\n cookieFree,\n cookieBannerLabel,\n cookieBannerDescription,\n _type == "trackerGoogleAnalytics" => {\n measurementId\n },\n _type == "trackerMatomo" => {\n url,\n siteId\n },\n _type == "trackerMicrosoftClarity" => {\n projectId\n },\n _type == "trackerPostHog" => {\n apiKey,\n apiHost\n },\n _type == "trackerPlausible" => {\n domain,\n scriptUrl\n }\n }\n}': SiteAnalyticsSettingsQueryResult; '*[_type == "page" && defined(slug.current)]{\n "slug": slug.current,\n language\n}': PageSlugsQueryResult; '*[_type == "project" && defined(slug.current)]{\n "slug": slug.current,\n language\n}': ProjectSlugsQueryResult; '*[\n _type == "home" ||\n _type == "work" ||\n (_type == "page" && defined(slug.current)) ||\n (_type == "project" && defined(slug.current))\n]{\n _id,\n _type,\n _updatedAt,\n language,\n "slug": select(\n _type in ["home", "work"] => null,\n slug.current\n ),\n "path": select(\n _type == "home" => "/",\n _type == "work" => "/work",\n _type == "project" => "/work/" + slug.current,\n "/" + slug.current\n )\n}': SitemapPagesQueryResult; From 93921cd0540bcb5e43c4eb526eb0b01148a7afb6 Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 15:58:18 +0200 Subject: [PATCH 107/457] fix(tracking): adapt the analytics port to per-language cookie banners The cookie banner is one document per language on this branch, so the tracking shell fetches it with the route locale, and the Studio warning counts any banner variant that is switched on instead of reading a fixed siteCookieBanner id that does not exist here. Co-Authored-By: Claude Opus 5.5 --- studio/schemas/settings/siteAnalyticsSettings.ts | 16 +++++++--------- web/src/app/[locale]/layout.tsx | 2 +- .../components/tracking/SiteTrackingShell.tsx | 5 +++-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/studio/schemas/settings/siteAnalyticsSettings.ts b/studio/schemas/settings/siteAnalyticsSettings.ts index e97d5162..916692bb 100644 --- a/studio/schemas/settings/siteAnalyticsSettings.ts +++ b/studio/schemas/settings/siteAnalyticsSettings.ts @@ -17,25 +17,23 @@ function enabledTrackerCount(trackers: unknown): number { } /** - * Whether the cookie banner is switched on, read from its singleton. + * Whether the cookie banner is switched on in any language. * * The two settings that decide whether anything is gated by consent live in * separate documents, so an editor can leave tracking completely ungated * without ever seeing the other half of the configuration. Reading across lets - * us warn where the mistake is actually made. + * us warn where the mistake is actually made. On this branch the banner is one + * document per language (no fixed `_id`), so any draft or published variant + * with the banner on counts. */ async function cookieBannerIsActive( context: ValidationContext, ): Promise { const client = context.getClient({ apiVersion: "2024-01-01" }); - const useCookieBanner = await client.fetch( - `coalesce( - *[_id == "drafts.siteCookieBanner"][0].useCookieBanner, - *[_id == "siteCookieBanner"][0].useCookieBanner, - false - )`, + const activeBanners = await client.fetch( + `count(*[_type == "siteCookieBanner" && useCookieBanner == true])`, ); - return useCookieBanner === true; + return activeBanners > 0; } /** Analytics & tracking providers. Web Preview is disabled in Presentation (see `DOCUMENT_TYPES_WITHOUT_WEB_PREVIEW`). */ diff --git a/web/src/app/[locale]/layout.tsx b/web/src/app/[locale]/layout.tsx index cd0e7b3f..a5f93340 100644 --- a/web/src/app/[locale]/layout.tsx +++ b/web/src/app/[locale]/layout.tsx @@ -90,7 +90,7 @@ export default async function LocaleLayout({ children, params }: Props) { locale={locale} trackers={enabledTrackers} /> - + ); } diff --git a/web/src/components/tracking/SiteTrackingShell.tsx b/web/src/components/tracking/SiteTrackingShell.tsx index fd13eb2c..5142dba0 100644 --- a/web/src/components/tracking/SiteTrackingShell.tsx +++ b/web/src/components/tracking/SiteTrackingShell.tsx @@ -5,10 +5,11 @@ import { import { getEnabledTrackers } from "@/src/components/tracking/lib/trackingConfig"; import { SiteTracking } from "@/src/components/tracking/SiteTracking"; -export async function SiteTrackingShell() { +/** `locale` picks the cookie-banner document — one per language on this branch. */ +export async function SiteTrackingShell({ locale }: { locale: string }) { const [analytics, cookieBanner] = await Promise.all([ fetchSiteAnalyticsSettings(), - fetchSiteCookieBanner(), + fetchSiteCookieBanner(locale), ]); if (getEnabledTrackers(analytics).length === 0) return null; From 8b3a358709c2ee2589464f7fbee6cbc03ede1336 Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 15:58:44 +0200 Subject: [PATCH 108/457] fix(styles): stop the white/black/grey theme tokens from cycling `theme.css` declared `--color-white: var(--color-white)` (same for black and grey). Tailwind emits those theme variables on `:root` after `variables/colors.css`, so the declaration wins and becomes a custom-property self-cycle: the variable is invalid and `bg-black`, `text-white` and `bg-black/40` render transparent (loop mute button, error CTA, carousel and player backgrounds). Move the three tokens into `@theme reference`, which registers the utilities without re-emitting the variable. The utilities now read the `colors.css` token directly, including its dark-mode grey override. farbstudio fixed the same bug with hex literals (f869052); that would drop the dark-mode value, so it is not ported verbatim. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 32424e3df9f374009f315206682133d16f656073) --- web/src/assets/styles/tailwind/theme.css | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/web/src/assets/styles/tailwind/theme.css b/web/src/assets/styles/tailwind/theme.css index 4a229c72..d2e36b5a 100644 --- a/web/src/assets/styles/tailwind/theme.css +++ b/web/src/assets/styles/tailwind/theme.css @@ -26,12 +26,10 @@ --font-headline: var(--font-family-headline), ui-monospace, monospace; --font-mono: var(--font-family-mono), ui-monospace, monospace; - /* Primitives — same keys as former `theme.extend.colors` (resolve from `variables/colors.css`) */ + /* Primitives — same keys as former `theme.extend.colors`. `white`/`black`/`grey` sit in the + * `@theme reference` block below because their token names collide with Tailwind's. */ --color-current: currentColor; --color-transparent: transparent; - --color-white: var(--color-white); - --color-black: var(--color-black); - --color-grey: var(--color-grey); --color-color-brand: var(--color-brand); --color-color-accent: var(--color-accent); @@ -78,3 +76,16 @@ --spacing-container-gutter: var(--container-gutter); */ } + +/* + * Tokens whose name already is the Tailwind theme name (`--color-white` in `variables/colors.css`). + * Declared in plain `@theme`, Tailwind re-emits `--color-white: var(--color-white)` on `:root` + * after `colors.css`; the custom-property self-cycle is invalid and `bg-black` / `text-white` + * render transparent. `reference` registers the utilities without re-emitting the variable, so + * they read the `colors.css` token (and its dark-mode override) directly. + */ +@theme reference { + --color-white: var(--color-white); + --color-black: var(--color-black); + --color-grey: var(--color-grey); +} From 949388686114e090a190cebf88d5fed04a2554ef Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 16:07:58 +0200 Subject: [PATCH 109/457] fix(tracking): stop double-counting client navigations in GA4 and Plausible GA4's enhanced measurement (on by default) and Plausible's default script already send a page view on every History API change. The manual page_view on top of that counted each client-side navigation twice. Matomo and PostHog (initial view only by default) still get one. Co-Authored-By: Claude Opus 5.5 --- .../tracking/lib/trackingRuntime.ts | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts index e33adeb0..20f71430 100644 --- a/web/src/components/tracking/lib/trackingRuntime.ts +++ b/web/src/components/tracking/lib/trackingRuntime.ts @@ -95,25 +95,18 @@ export function unregisterLoadedTracker(key: string): void { beginTrackerLoad(key); } +/** + * Page view for a client-side navigation, for the providers that need one. + * + * GA4 (enhanced measurement, on by default) and Plausible's default script + * listen for History API changes and send their own page view, so sending one + * here would count every navigation twice. Clarity records sessions, not views. + */ export function trackPageView(pathname: string, search = ""): void { const url = `${pathname}${search}`; for (const { tracker } of loadedTrackers.values()) { switch (tracker._type) { - case "trackerGoogleAnalytics": { - const id = tracker.measurementId?.trim(); - if (!id) break; - const gtag = ( - window as Window & { - gtag?: (...args: unknown[]) => void; - } - ).gtag; - gtag?.("event", "page_view", { - page_path: url, - send_to: id, - }); - break; - } case "trackerMatomo": { const _paq = ( window as Window & { @@ -133,15 +126,6 @@ export function trackPageView(pathname: string, search = ""): void { posthog?.capture?.("$pageview"); break; } - case "trackerPlausible": { - const plausible = ( - window as Window & { - plausible?: (event: string, options?: { u: string }) => void; - } - ).plausible; - plausible?.("pageview", { u: url }); - break; - } default: break; } From 1736df90619d4cf4f59c37a8daaf0d5e67358eff Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 16:08:12 +0200 Subject: [PATCH 110/457] fix(tracking): reuse the live PostHog instance when consent is re-granted Withdrawal opts PostHog out but cannot unload the SDK. Re-granting in the same page injected array.js again, which starts a second instance next to the first; once opted back in, both collected. Opt the existing instance back in instead. Picked from the Bugbot branches cursor/analytics-consent-tracking-issues-3dcb and cursor/tracking-consent-issues-de28; the rest of those branches is already covered by later commits on this PR. Co-Authored-By: Claude Opus 5.5 --- web/src/components/tracking/lib/loadTrackers.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts index f2b3af1f..12232d9d 100644 --- a/web/src/components/tracking/lib/loadTrackers.ts +++ b/web/src/components/tracking/lib/loadTrackers.ts @@ -137,6 +137,21 @@ function loadPostHog(tracker: AnalyticsTracker): void { const apiHost = tracker.apiHost?.trim(); if (!apiKey || !apiHost || isTrackerLoaded(tracker._key)) return; + // A re-grant within the same page finds the SDK still in memory, only opted + // out. Injecting `array.js` again would start a second instance next to it, + // and both would collect once opted back in. PostHog cannot re-initialise in + // place, so a changed key or host takes effect on the next page load. + const live = ( + window as Window & { + posthog?: { __loaded?: boolean; opt_in_capturing?: () => void }; + } + ).posthog; + if (live?.__loaded) { + if (consumeSelfOptOut(tracker._key)) live.opt_in_capturing?.(); + registerLoadedTracker(tracker); + return; + } + const cookieFree = tracker.cookieFree === true; const generation = beginTrackerLoad(tracker._key); const script = appendScript( From 860fd63a3a9350f001c307b2f221c5c1cc04102d Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 16:08:28 +0200 Subject: [PATCH 111/457] fix(tracking): require absolute http(s) URLs for URL-based trackers Matomo, PostHog and Plausible counted as configured as soon as their URL field was non-empty. A relative value from an imported or API-written document then loaded the "SDK" from the site's own origin. Treat such a tracker as not configured, so it neither loads nor shows in the banner. Co-Authored-By: Claude Opus 5.5 --- .../components/tracking/lib/trackingConfig.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/web/src/components/tracking/lib/trackingConfig.ts b/web/src/components/tracking/lib/trackingConfig.ts index f694cfec..c46f51d9 100644 --- a/web/src/components/tracking/lib/trackingConfig.ts +++ b/web/src/components/tracking/lib/trackingConfig.ts @@ -12,6 +12,21 @@ export type TrackingConfig = { export type { AnalyticsLoadMode, AnalyticsTracker }; +/** + * Loaders turn these CMS values into script URLs, so a relative one would load + * from the site's own origin. Studio validation only runs in the editor; an + * imported or API-written document skips it. + */ +function isAbsoluteHttpUrl(value: string | null | undefined): boolean { + if (!value?.trim()) return false; + try { + const { protocol } = new URL(value.trim()); + return protocol === "https:" || protocol === "http:"; + } catch { + return false; + } +} + export function isTrackerEnabled(tracker: AnalyticsTracker): boolean { if (tracker.enabled === false) return false; @@ -19,13 +34,17 @@ export function isTrackerEnabled(tracker: AnalyticsTracker): boolean { case "trackerGoogleAnalytics": return Boolean(tracker.measurementId?.trim()); case "trackerMatomo": - return Boolean(tracker.url?.trim() && tracker.siteId?.trim()); + return isAbsoluteHttpUrl(tracker.url) && Boolean(tracker.siteId?.trim()); case "trackerMicrosoftClarity": return Boolean(tracker.projectId?.trim()); case "trackerPostHog": - return Boolean(tracker.apiKey?.trim() && tracker.apiHost?.trim()); + return ( + Boolean(tracker.apiKey?.trim()) && isAbsoluteHttpUrl(tracker.apiHost) + ); case "trackerPlausible": - return Boolean(tracker.domain?.trim() && tracker.scriptUrl?.trim()); + return ( + Boolean(tracker.domain?.trim()) && isAbsoluteHttpUrl(tracker.scriptUrl) + ); default: return false; } From 009a4e9d360bda171eca765b05a6f833dd813895 Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 16:08:40 +0200 Subject: [PATCH 112/457] fix(tracking): send Clarity's consent-denied signal on withdrawal Replacing window.clarity only blocks new API calls. Clarity documents consentv2 with both storages denied as the way to withdraw: it switches to no-consent mode and stops setting cookies. Send it before the stub and the reload, so collection stops even before the reload lands, or if the browser does not carry it out. Co-Authored-By: Claude Opus 5.5 --- web/src/components/tracking/lib/trackingRuntime.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts index 20f71430..92b5a393 100644 --- a/web/src/components/tracking/lib/trackingRuntime.ts +++ b/web/src/components/tracking/lib/trackingRuntime.ts @@ -207,6 +207,14 @@ export function unloadTracker(tracker: AnalyticsTracker): void { } ).clarity; if (clarity) { + // Clarity's own withdrawal signal (Consent API v2): it drops to its + // no-consent mode and stops setting cookies. The reload that follows + // is what actually stops the recorder; this covers the moment before + // it, and a reload the browser does not carry out. + clarity("consentv2", { + ad_Storage: "denied", + analytics_Storage: "denied", + }); clarity.q = []; ( window as Window & { clarity?: (...args: unknown[]) => void } From 0b44a6c6cb6a18624bea9d4770122a10bc0f975c Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 16:16:36 +0200 Subject: [PATCH 113/457] fix(tracking): remove PostHog's storage when consent is withdrawn reset() mints fresh IDs and writes them to the ph_* cookie and localStorage after our cookie cleanup ran, so a new identifier outlived every withdrawal. Init with opt_out_persistence_by_default, which makes opt_out_capturing() remove that storage, and reset before opting out so the fresh IDs land in storage that is then removed. Only PostHog's ID-free opt-out flag stays behind. Verified locally: after withdrawal only cc_cookie and the opt-out flag remain; a re-grant opts the same instance back in under a new ID. Co-Authored-By: Claude Opus 5.5 --- web/src/components/tracking/lib/loadTrackers.ts | 9 ++++++++- web/src/components/tracking/lib/trackingRuntime.ts | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/web/src/components/tracking/lib/loadTrackers.ts b/web/src/components/tracking/lib/loadTrackers.ts index 12232d9d..b863964d 100644 --- a/web/src/components/tracking/lib/loadTrackers.ts +++ b/web/src/components/tracking/lib/loadTrackers.ts @@ -174,7 +174,11 @@ function loadPostHog(tracker: AnalyticsTracker): void { posthog?: { init?: ( key: string, - options: { api_host: string; persistence: string }, + options: { + api_host: string; + persistence: string; + opt_out_persistence_by_default: boolean; + }, ) => void; opt_in_capturing?: () => void; }; @@ -184,6 +188,9 @@ function loadPostHog(tracker: AnalyticsTracker): void { posthog.init(apiKey, { api_host: apiHost, persistence: cookieFree ? "memory" : "localStorage+cookie", + // Opting out then removes PostHog's cookie and localStorage entry; + // without it they stay behind after consent is withdrawn. + opt_out_persistence_by_default: true, }); // A withdrawal *we* made persists in storage, so a re-grant has to // reverse it — but a provider-side opt-out is left alone. diff --git a/web/src/components/tracking/lib/trackingRuntime.ts b/web/src/components/tracking/lib/trackingRuntime.ts index 92b5a393..31666f8d 100644 --- a/web/src/components/tracking/lib/trackingRuntime.ts +++ b/web/src/components/tracking/lib/trackingRuntime.ts @@ -232,8 +232,11 @@ export function unloadTracker(tracker: AnalyticsTracker): void { }; } ).posthog; - posthog?.opt_out_capturing?.(); + // Reset first: it mints fresh IDs and persists them. The opt-out that + // follows then removes that storage (`opt_out_persistence_by_default`), + // so nothing identifying outlives the withdrawal. posthog?.reset?.(); + posthog?.opt_out_capturing?.(); markSelfOptedOut(tracker._key); deleteCookies((name) => name.startsWith("ph_")); break; From e60dda5b92563e76acd3d84d04829b971a709eb8 Mon Sep 17 00:00:00 2001 From: Damian Date: Tue, 13 Oct 2026 16:18:52 +0200 Subject: [PATCH 114/457] perf(web): keep visual editing out of the published bundle `VisualEditing` and `DisableDraftMode` were imported statically in the root layout and rendered only in Draft Mode. Next preloads every client chunk a route can reach as `