Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new config schema allOf condition support #4791

Open
wants to merge 12 commits into
base: main
Choose a base branch
from

Conversation

colinrotherham
Copy link
Contributor

To offload some old code here's support for allOf schema conditions (alongside anyOf)

Might never need this, but to prevent i18n or rememberExpanded being accidentally overridden by null/undefined:

/**
  * Accordion config schema
  *
  * @constant
  * @satisfies {Schema}
  */
static schema = Object.freeze({
  allOf: [
    {
      required: ['i18n'],
      errorMessage: '"i18n" must be provided'
    },
    {
      required: ['rememberExpanded'],
      errorMessage: '"rememberExpanded" must be provided'
    }
  ]
})

@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-4791 February 23, 2024 17:39 Inactive
Copy link

github-actions bot commented Feb 23, 2024

📋 Stats

File sizes

File Size
dist/govuk-frontend-development.min.css 112.86 KiB
dist/govuk-frontend-development.min.js 39.47 KiB
packages/govuk-frontend/dist/govuk/all.bundle.js 81.45 KiB
packages/govuk-frontend/dist/govuk/all.bundle.mjs 76.5 KiB
packages/govuk-frontend/dist/govuk/all.mjs 3.86 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend-component.mjs 359 B
packages/govuk-frontend/dist/govuk/govuk-frontend.min.css 112.85 KiB
packages/govuk-frontend/dist/govuk/govuk-frontend.min.js 39.46 KiB
packages/govuk-frontend/dist/govuk/i18n.mjs 5.55 KiB

Modules

File Size (bundled) Size (minified)
all.mjs 72.33 KiB 37.64 KiB
accordion.mjs 22.71 KiB 12.85 KiB
button.mjs 5.98 KiB 2.69 KiB
character-count.mjs 22.49 KiB 9.96 KiB
checkboxes.mjs 5.83 KiB 2.83 KiB
error-summary.mjs 7.89 KiB 3.46 KiB
exit-this-page.mjs 17.1 KiB 9.26 KiB
header.mjs 4.46 KiB 2.6 KiB
notification-banner.mjs 6.26 KiB 2.62 KiB
radios.mjs 4.83 KiB 2.38 KiB
skip-link.mjs 4.39 KiB 2.18 KiB
tabs.mjs 10.13 KiB 6.11 KiB

View stats and visualisations on the review app


Action run for fd0af85

Copy link

github-actions bot commented Feb 23, 2024

JavaScript changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
index 32ea76aa4..62af9cc9b 100644
--- a/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
+++ b/packages/govuk-frontend/dist/govuk/govuk-frontend.min.js
@@ -1,108 +1,119 @@
 const version = "development";
 
-function mergeConfigs(...t) {
-    function flattenObject(t) {
-        const e = {};
-        return function flattenLoop(t, n) {
-            for (const [i, s] of Object.entries(t)) {
-                const t = n ? `${n}.${i}` : i;
-                s && "object" == typeof s ? flattenLoop(s, t) : e[t] = s
-            }
-        }(t), e
-    }
-    const e = {};
-    for (const n of t) {
-        const t = flattenObject(n);
-        for (const [n, i] of Object.entries(t)) e[n] = i
-    }
-    return e
+function normaliseString(e, t) {
+    const n = e ? e.trim() : "";
+    let i, s = null == t ? void 0 : t.type;
+    switch (s || (["true", "false"].includes(n) && (s = "boolean"), n.length > 0 && isFinite(Number(n)) && (s = "number")), s) {
+        case "boolean":
+            i = "true" === n;
+            break;
+        case "number":
+            i = Number(n);
+            break;
+        default:
+            i = e
+    }
+    return i
 }
 
-function extractConfigByNamespace(t, e) {
-    const n = {};
-    for (const [i, s] of Object.entries(t)) {
-        const t = i.split(".");
-        if (t[0] === e) {
-            t.length > 1 && t.shift();
-            n[t.join(".")] = s
+function mergeConfigs(...e) {
+    const t = {};
+    for (const n of e)
+        for (const e of Object.keys(n)) {
+            const i = t[e],
+                s = n[e];
+            isObject(i) && isObject(s) ? t[e] = mergeConfigs(i, s) : t[e] = s
         }
-    }
-    return n
+    return t
+}
+
+function extractConfigByNamespace(e, t, n) {
+    const i = e.schema.properties[n];
+    if ("object" !== (null == i ? void 0 : i.type)) return;
+    const s = {
+        [n]: {}
+    };
+    for (const [o, r] of Object.entries(t)) {
+        let e = s;
+        const t = o.split(".");
+        for (const [i, s] of t.entries()) "object" == typeof e && (i < t.length - 1 ? (isObject(e[s]) || (e[s] = {}), e = e[s]) : o !== n && (e[s] = normaliseString(r)))
+    }
+    return s[n]
 }
 
-function getFragmentFromUrl(t) {
-    if (t.includes("#")) return t.split("#").pop()
+function getFragmentFromUrl(e) {
+    if (e.includes("#")) return e.split("#").pop()
 }
 
-function getBreakpoint(t) {
-    const e = `--govuk-frontend-breakpoint-${t}`;
+function getBreakpoint(e) {
+    const t = `--govuk-frontend-breakpoint-${e}`;
     return {
-        property: e,
-        value: window.getComputedStyle(document.documentElement).getPropertyValue(e) || void 0
+        property: t,
+        value: window.getComputedStyle(document.documentElement).getPropertyValue(t) || void 0
     }
 }
 
-function setFocus(t, e = {}) {
+function setFocus(e, t = {}) {
     var n;
-    const i = t.getAttribute("tabindex");
+    const i = e.getAttribute("tabindex");
 
     function onBlur() {
         var n;
-        null == (n = e.onBlur) || n.call(t), i || t.removeAttribute("tabindex")
+        null == (n = t.onBlur) || n.call(e), i || e.removeAttribute("tabindex")
     }
-    i || t.setAttribute("tabindex", "-1"), t.addEventListener("focus", (function() {
-        t.addEventListener("blur", onBlur, {
+    i || e.setAttribute("tabindex", "-1"), e.addEventListener("focus", (function() {
+        e.addEventListener("blur", onBlur, {
             once: !0
         })
     }), {
         once: !0
-    }), null == (n = e.onBeforeFocus) || n.call(t), t.focus()
+    }), null == (n = t.onBeforeFocus) || n.call(e), e.focus()
 }
 
-function isSupported(t = document.body) {
-    return !!t && t.classList.contains("govuk-frontend-supported")
+function isSupported(e = document.body) {
+    return !!e && e.classList.contains("govuk-frontend-supported")
 }
 
-function normaliseString(t) {
-    if ("string" != typeof t) return t;
-    const e = t.trim();
-    return "true" === e || "false" !== e && (e.length > 0 && isFinite(Number(e)) ? Number(e) : t)
+function isObject(e) {
+    return !!e && "object" == typeof e && ! function(e) {
+        return Array.isArray(e)
+    }(e)
 }
 
-function normaliseDataset(t) {
-    const e = {};
-    for (const [n, i] of Object.entries(t)) e[n] = normaliseString(i);
-    return e
+function normaliseDataset(e, t) {
+    const n = {};
+    for (const [i, s] of Object.entries(e.schema.properties)) i in t && (n[i] = normaliseString(t[i], s)), "object" === (null == s ? void 0 : s.type) && (n[i] = extractConfigByNamespace(e, t, i));
+    return n
 }
 class GOVUKFrontendError extends Error {
-    constructor(...t) {
-        super(...t), this.name = "GOVUKFrontendError"
+    constructor(...e) {
+        super(...e), this.name = "GOVUKFrontendError"
     }
 }
 class SupportError extends GOVUKFrontendError {
-    constructor(t = document.body) {
-        const e = "noModule" in HTMLScriptElement.prototype ? 'GOV.UK Frontend initialised without `<body class="govuk-frontend-supported">` from template `<script>` snippet' : "GOV.UK Frontend is not supported in this browser";
-        super(t ? e : 'GOV.UK Frontend initialised without `<script type="module">`'), this.name = "SupportError"
+    constructor(e = document.body) {
+        const t = "noModule" in HTMLScriptElement.prototype ? 'GOV.UK Frontend initialised without `<body class="govuk-frontend-supported">` from template `<script>` snippet' : "GOV.UK Frontend is not supported in this browser";
+        super(e ? t : 'GOV.UK Frontend initialised without `<script type="module">`'), this.name = "SupportError"
     }
 }
 class ConfigError extends GOVUKFrontendError {
-    constructor(...t) {
-        super(...t), this.name = "ConfigError"
+    constructor(...e) {
+        super(...e), this.name = "ConfigError"
     }
 }
 class ElementError extends GOVUKFrontendError {
-    constructor(t) {
-        let e = "string" == typeof t ? t : "";
-        if ("object" == typeof t) {
+    constructor(e) {
+        let t = "string" == typeof e ? e : "";
+        if ("object" == typeof e) {
             const {
                 componentName: n,
                 identifier: i,
                 element: s,
                 expectedType: o
-            } = t;
-            e = `${n}: ${i}`, e += s ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found"
+            } = e;
+            t = `${n}: ${i}`, t += s ? ` is not of type ${null!=o?o:"HTMLElement"}` : " not found"
         }
-        super(e), this.name = "ElementError"
+        super(t), this.name = "ElementError"
     }
 }
 class GOVUKFrontendComponent {
@@ -114,53 +125,59 @@ class GOVUKFrontendComponent {
     }
 }
 class I18n {
-    constructor(t = {}, e = {}) {
+    constructor(e = {}, t = {}) {
         var n;
-        this.translations = void 0, this.locale = void 0, this.translations = t, this.locale = null != (n = e.locale) ? n : document.documentElement.lang || "en"
-    }
-    t(t, e) {
-        if (!t) throw new Error("i18n: lookup key missing");
-        "number" == typeof(null == e ? void 0 : e.count) && (t = `${t}.${this.getPluralSuffix(t,e.count)}`);
-        const n = this.translations[t];
+        this.translations = void 0, this.locale = void 0, this.translations = e, this.locale = null != (n = t.locale) ? n : document.documentElement.lang || "en"
+    }
+    t(e, t) {
+        if (!e) throw new Error("i18n: lookup key missing");
+        let n = this.translations[e];
+        if ("number" == typeof(null == t ? void 0 : t.count) && "object" == typeof n) {
+            const i = n[this.getPluralSuffix(e, t.count)];
+            i && (n = i)
+        }
         if ("string" == typeof n) {
             if (n.match(/%{(.\S+)}/)) {
-                if (!e) throw new Error("i18n: cannot replace placeholders in string if no option data provided");
-                return this.replacePlaceholders(n, e)
+                if (!t) throw new Error("i18n: cannot replace placeholders in string if no option data provided");
+                return this.replacePlaceholders(n, t)
             }
             return n
         }
-        return t
+        return e
     }
-    replacePlaceholders(t, e) {
+    replacePlaceholders(e, t) {
         const n = Intl.NumberFormat.supportedLocalesOf(this.locale).length ? new Intl.NumberFormat(this.locale) : void 0;
-        return t.replace(/%{(.\S+)}/g, (function(t, i) {
-            if (Object.prototype.hasOwnProperty.call(e, i)) {
-                const t = e[i];
-                return !1 === t || "number" != typeof t && "string" != typeof t ? "" : "number" == typeof t ? n ? n.format(t) : `${t}` : t
+        return e.replace(/%{(.\S+)}/g, (function(e, i) {
+            if (Object.prototype.hasOwnProperty.call(t, i)) {
+                const e = t[i];
+                return !1 === e || "number" != typeof e && "string" != typeof e ? "" : "number" == typeof e ? n ? n.format(e) : `${e}` : e
             }
-            throw new Error(`i18n: no data found to replace ${t} placeholder in string`)
+            throw new Error(`i18n: no data found to replace ${e} placeholder in string`)
         }))
     }
     hasIntlPluralRulesSupport() {
         return Boolean("PluralRules" in window.Intl && Intl.PluralRules.supportedLocalesOf(this.locale).length)
     }
-    getPluralSuffix(t, e) {
-        if (e = Number(e), !isFinite(e)) return "other";
-        const n = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(e) : this.selectPluralFormUsingFallbackRules(e);
-        if (`${t}.${n}` in this.translations) return n;
-        if (`${t}.other` in this.translations) return console.warn(`i18n: Missing plural form ".${n}" for "${this.locale}" locale. Falling back to ".other".`), "other";
+    getPluralSuffix(e, t) {
+        if (t = Number(t), !isFinite(t)) return "other";
+        const n = this.translations[e],
+            i = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(t) : this.selectPluralFormUsingFallbackRules(t);
+        if ("object" == typeof n) {
+            if (i in n) return i;
+            if ("other" in n) return console.warn(`i18n: Missing plural form ".${i}" for "${this.locale}" locale. Falling back to ".other".`), "other"
+        }
         throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`)
     }
-    selectPluralFormUsingFallbackRules(t) {
-        t = Math.abs(Math.floor(t));
-        const e = this.getPluralRulesForLocale();
-        return e ? I18n.pluralRules[e](t) : "other"
+    selectPluralFormUsingFallbackRules(e) {
+        e = Math.abs(Math.floor(e));
+        const t = this.getPluralRulesForLocale();
+        return t ? I18n.pluralRules[t](e) : "other"
     }
     getPluralRulesForLocale() {
-        const t = this.locale.split("-")[0];
-        for (const e in I18n.pluralRulesMap) {
-            const n = I18n.pluralRulesMap[e];
-            if (n.includes(this.locale) || n.includes(t)) return e
+        const e = this.locale.split("-")[0];
+        for (const t in I18n.pluralRulesMap) {
+            const n = I18n.pluralRulesMap[t];
+            if (n.includes(this.locale) || n.includes(e)) return t
         }
     }
 }
@@ -175,56 +192,56 @@ I18n.pluralRulesMap = {
     spanish: ["pt-PT", "it", "es"],
     welsh: ["cy"]
 }, I18n.pluralRules = {
-    arabic: t => 0 === t ? "zero" : 1 === t ? "one" : 2 === t ? "two" : t % 100 >= 3 && t % 100 <= 10 ? "few" : t % 100 >= 11 && t % 100 <= 99 ? "many" : "other",
+    arabic: e => 0 === e ? "zero" : 1 === e ? "one" : 2 === e ? "two" : e % 100 >= 3 && e % 100 <= 10 ? "few" : e % 100 >= 11 && e % 100 <= 99 ? "many" : "other",
     chinese: () => "other",
-    french: t => 0 === t || 1 === t ? "one" : "other",
-    german: t => 1 === t ? "one" : "other",
-    irish: t => 1 === t ? "one" : 2 === t ? "two" : t >= 3 && t <= 6 ? "few" : t >= 7 && t <= 10 ? "many" : "other",
-    russian(t) {
-        const e = t % 100,
-            n = e % 10;
-        return 1 === n && 11 !== e ? "one" : n >= 2 && n <= 4 && !(e >= 12 && e <= 14) ? "few" : 0 === n || n >= 5 && n <= 9 || e >= 11 && e <= 14 ? "many" : "other"
+    french: e => 0 === e || 1 === e ? "one" : "other",
+    german: e => 1 === e ? "one" : "other",
+    irish: e => 1 === e ? "one" : 2 === e ? "two" : e >= 3 && e <= 6 ? "few" : e >= 7 && e <= 10 ? "many" : "other",
+    russian(e) {
+        const t = e % 100,
+            n = t % 10;
+        return 1 === n && 11 !== t ? "one" : n >= 2 && n <= 4 && !(t >= 12 && t <= 14) ? "few" : 0 === n || n >= 5 && n <= 9 || t >= 11 && t <= 14 ? "many" : "other"
     },
-    scottish: t => 1 === t || 11 === t ? "one" : 2 === t || 12 === t ? "two" : t >= 3 && t <= 10 || t >= 13 && t <= 19 ? "few" : "other",
-    spanish: t => 1 === t ? "one" : t % 1e6 == 0 && 0 !== t ? "many" : "other",
-    welsh: t => 0 === t ? "zero" : 1 === t ? "one" : 2 === t ? "two" : 3 === t ? "few" : 6 === t ? "many" : "other"
+    scottish: e => 1 === e || 11 === e ? "one" : 2 === e || 12 === e ? "two" : e >= 3 && e <= 10 || e >= 13 && e <= 19 ? "few" : "other",
+    spanish: e => 1 === e ? "one" : e % 1e6 == 0 && 0 !== e ? "many" : "other",
+    welsh: e => 0 === e ? "zero" : 1 === e ? "one" : 2 === e ? "two" : 3 === e ? "few" : 6 === e ? "many" : "other"
 };
 class Accordion extends GOVUKFrontendComponent {
-    constructor(e, n = {}) {
-        if (super(), this.$module = void 0, this.config = void 0, this.i18n = void 0, this.controlsClass = "govuk-accordion__controls", this.showAllClass = "govuk-accordion__show-all", this.showAllTextClass = "govuk-accordion__show-all-text", this.sectionClass = "govuk-accordion__section", this.sectionExpandedClass = "govuk-accordion__section--expanded", this.sectionButtonClass = "govuk-accordion__section-button", this.sectionHeaderClass = "govuk-accordion__section-header", this.sectionHeadingClass = "govuk-accordion__section-heading", this.sectionHeadingDividerClass = "govuk-accordion__section-heading-divider", this.sectionHeadingTextClass = "govuk-accordion__section-heading-text", this.sectionHeadingTextFocusClass = "govuk-accordion__section-heading-text-focus", this.sectionShowHideToggleClass = "govuk-accordion__section-toggle", this.sectionShowHideToggleFocusClass = "govuk-accordion__section-toggle-focus", this.sectionShowHideTextClass = "govuk-accordion__section-toggle-text", this.upChevronIconClass = "govuk-accordion-nav__chevron", this.downChevronIconClass = "govuk-accordion-nav__chevron--down", this.sectionSummaryClass = "govuk-accordion__section-summary", this.sectionSummaryFocusClass = "govuk-accordion__section-summary-focus", this.sectionContentClass = "govuk-accordion__section-content", this.$sections = void 0, this.browserSupportsSessionStorage = !1, this.$showAllButton = null, this.$showAllIcon = null, this.$showAllText = null, !(e instanceof HTMLElement)) throw new ElementError({
+    constructor(t, n = {}) {
+        if (super(), this.$module = void 0, this.config = void 0, this.i18n = void 0, this.controlsClass = "govuk-accordion__controls", this.showAllClass = "govuk-accordion__show-all", this.showAllTextClass = "govuk-accordion__show-all-text", this.sectionClass = "govuk-accordion__section", this.sectionExpandedClass = "govuk-accordion__section--expanded", this.sectionButtonClass = "govuk-accordion__section-button", this.sectionHeaderClass = "govuk-accordion__section-header", this.sectionHeadingClass = "govuk-accordion__section-heading", this.sectionHeadingDividerClass = "govuk-accordion__section-heading-divider", this.sectionHeadingTextClass = "govuk-accordion__section-heading-text", this.sectionHeadingTextFocusClass = "govuk-accordion__section-heading-text-focus", this.sectionShowHideToggleClass = "govuk-accordion__section-toggle", this.sectionShowHideToggleFocusClass = "govuk-accordion__section-toggle-focus", this.sectionShowHideTextClass = "govuk-accordion__section-toggle-text", this.upChevronIconClass = "govuk-accordion-nav__chevron", this.downChevronIconClass = "govuk-accordion-nav__chevron--down", this.sectionSummaryClass = "govuk-accordion__section-summary", this.sectionSummaryFocusClass = "govuk-accordion__section-summary-focus", this.sectionContentClass = "govuk-accordion__section-content", this.$sections = void 0, this.browserSupportsSessionStorage = !1, this.$showAllButton = null, this.$showAllIcon = null, this.$showAllText = null, !(t instanceof HTMLElement)) throw new ElementError({
             componentName: "Accordion",
-            element: e,
+            element: t,
             identifier: "Root element (`$module`)"
         });
-        this.$module = e, this.config = mergeConfigs(Accordion.defaults, n, normaliseDataset(e.dataset)), this.i18n = new I18n(extractConfigByNamespace(this.config, "i18n"));
+        this.$module = t, this.config = mergeConfigs(Accordion.defaults, n, normaliseDataset(Accordion, t.dataset)), this.i18n = new I18n(this.config.i18n);
         const i = this.$module.querySelectorAll(`.${this.sectionClass}`);
         if (!i.length) throw new ElementError({
             componentName: "Accordion",
             identifier: `Sections (\`<div class="${this.sectionClass}">\`)`
         });
-        this.$sections = i, this.browserSupportsSessionStorage = t.checkForSessionStorage(), this.initControls(), this.initSectionHeaders();
+        this.$sections = i, this.browserSupportsSessionStorage = e.checkForSessionStorage(), this.initControls(), this.initSectionHeaders();
         const s = this.checkIfAllSectionsOpen();
         this.updateShowAllButton(s)
     }
     initControls() {
         this.$showAllButton = document.createElement("button"), this.$showAllButton.setAttribute("type", "button"), this.$showAllButton.setAttribute("class", this.showAllClass), this.$showAllButton.setAttribute("aria-expanded", "false"), this.$showAllIcon = document.createElement("span"), this.$showAllIcon.classList.add(this.upChevronIconClass), this.$showAllButton.appendChild(this.$showAllIcon);
-        const t = document.createElement("div");
-        t.setAttribute("class", this.controlsClass), t.appendChild(this.$showAllButton), this.$module.insertBefore(t, this.$module.firstChild), this.$showAllText = document.createElement("span"), this.$showAllText.classList.add(this.showAllTextClass), this.$showAllButton.appendChild(this.$showAllText), this.$showAllButton.addEventListener("click", (() => this.onShowOrHideAllToggle())), "onbeforematch" in document && document.addEventListener("beforematch", (t => this.onBeforeMatch(t)))
+        const e = document.createElement("div");
+        e.setAttribute("class", this.controlsClass), e.appendChild(this.$showAllButton), this.$module.insertBefore(e, this.$module.firstChild), this.$showAllText = document.createElement("span"), this.$showAllText.classList.add(this.showAllTextClass), this.$showAllButton.appendChild(this.$showAllText), this.$showAllButton.addEventListener("click", (() => this.onShowOrHideAllToggle())), "onbeforematch" in document && document.addEventListener("beforematch", (e => this.onBeforeMatch(e)))
     }
     initSectionHeaders() {
-        this.$sections.forEach(((t, e) => {
-            const n = t.querySelector(`.${this.sectionHeaderClass}`);
+        this.$sections.forEach(((e, t) => {
+            const n = e.querySelector(`.${this.sectionHeaderClass}`);
             if (!n) throw new ElementError({
                 componentName: "Accordion",
                 identifier: `Section headers (\`<div class="${this.sectionHeaderClass}">\`)`
             });
-            this.constructHeaderMarkup(n, e), this.setExpanded(this.isExpanded(t), t), n.addEventListener("click", (() => this.onSectionToggle(t))), this.setInitialState(t)
+            this.constructHeaderMarkup(n, t), this.setExpanded(this.isExpanded(e), e), n.addEventListener("click", (() => this.onSectionToggle(e))), this.setInitialState(e)
         }))
     }
-    constructHeaderMarkup(t, e) {
-        const n = t.querySelector(`.${this.sectionButtonClass}`),
-            i = t.querySelector(`.${this.sectionHeadingClass}`),
-            s = t.querySelector(`.${this.sectionSummaryClass}`);
+    constructHeaderMarkup(e, t) {
+        const n = e.querySelector(`.${this.sectionButtonClass}`),
+            i = e.querySelector(`.${this.sectionHeadingClass}`),
+            s = e.querySelector(`.${this.sectionSummaryClass}`);
         if (!i) throw new ElementError({
             componentName: "Accordion",
             identifier: `Section heading (\`.${this.sectionHeadingClass}\`)`
@@ -234,7 +251,7 @@ class Accordion extends GOVUKFrontendComponent {
             identifier: `Section button placeholder (\`<span class="${this.sectionButtonClass}">\`)`
         });
         const o = document.createElement("button");
-        o.setAttribute("type", "button"), o.setAttribute("aria-controls", `${this.$module.id}-content-${e+1}`);
+        o.setAttribute("type", "button"), o.setAttribute("aria-controls", `${this.$module.id}-content-${t+1}`);
         for (const d of Array.from(n.attributes)) "id" !== d.nodeName && o.setAttribute(d.nodeName, `${d.nodeValue}`);
         const r = document.createElement("span");
         r.classList.add(this.sectionHeadingTextClass), r.id = n.id;
@@ -247,84 +264,84 @@ class Accordion extends GOVUKFrontendComponent {
         const h = document.createElement("span"),
             u = document.createElement("span");
         if (u.classList.add(this.upChevronIconClass), c.appendChild(u), h.classList.add(this.sectionShowHideTextClass), c.appendChild(h), o.appendChild(r), o.appendChild(this.getButtonPunctuationEl()), null != s && s.parentNode) {
-            const t = document.createElement("span"),
-                e = document.createElement("span");
-            e.classList.add(this.sectionSummaryFocusClass), t.appendChild(e);
-            for (const n of Array.from(s.attributes)) t.setAttribute(n.nodeName, `${n.nodeValue}`);
-            e.innerHTML = s.innerHTML, s.parentNode.replaceChild(t, s), o.appendChild(t), o.appendChild(this.getButtonPunctuationEl())
+            const e = document.createElement("span"),
+                t = document.createElement("span");
+            t.classList.add(this.sectionSummaryFocusClass), e.appendChild(t);
+            for (const n of Array.from(s.attributes)) e.setAttribute(n.nodeName, `${n.nodeValue}`);
+            t.innerHTML = s.innerHTML, s.parentNode.replaceChild(e, s), o.appendChild(e), o.appendChild(this.getButtonPunctuationEl())
         }
         o.appendChild(l), i.removeChild(n), i.appendChild(o)
     }
-    onBeforeMatch(t) {
-        const e = t.target;
-        if (!(e instanceof Element)) return;
-        const n = e.closest(`.${this.sectionClass}`);
+    onBeforeMatch(e) {
+        const t = e.target;
+        if (!(t instanceof Element)) return;
+        const n = t.closest(`.${this.sectionClass}`);
         n && this.setExpanded(!0, n)
     }
-    onSectionToggle(t) {
-        const e = this.isExpanded(t);
-        this.setExpanded(!e, t), this.storeState(t)
+    onSectionToggle(e) {
+        const t = this.isExpanded(e);
+        this.setExpanded(!t, e), this.storeState(e)
     }
     onShowOrHideAllToggle() {
-        const t = !this.checkIfAllSectionsOpen();
-        this.$sections.forEach((e => {
-            this.setExpanded(t, e), this.storeState(e)
-        })), this.updateShowAllButton(t)
-    }
-    setExpanded(t, e) {
-        const n = e.querySelector(`.${this.upChevronIconClass}`),
-            i = e.querySelector(`.${this.sectionShowHideTextClass}`),
-            s = e.querySelector(`.${this.sectionButtonClass}`),
-            o = e.querySelector(`.${this.sectionContentClass}`);
+        const e = !this.checkIfAllSectionsOpen();
+        this.$sections.forEach((t => {
+            this.setExpanded(e, t), this.storeState(t)
+        })), this.updateShowAllButton(e)
+    }
+    setExpanded(e, t) {
+        const n = t.querySelector(`.${this.upChevronIconClass}`),
+            i = t.querySelector(`.${this.sectionShowHideTextClass}`),
+            s = t.querySelector(`.${this.sectionButtonClass}`),
+            o = t.querySelector(`.${this.sectionContentClass}`);
         if (!o) throw new ElementError({
             componentName: "Accordion",
             identifier: `Section content (\`<div class="${this.sectionContentClass}">\`)`
         });
         if (!n || !i || !s) return;
-        const r = t ? this.i18n.t("hideSection") : this.i18n.t("showSection");
-        i.textContent = r, s.setAttribute("aria-expanded", `${t}`);
+        const r = e ? this.i18n.t("hideSection") : this.i18n.t("showSection");
+        i.textContent = r, s.setAttribute("aria-expanded", `${e}`);
         const a = [],
-            l = e.querySelector(`.${this.sectionHeadingTextClass}`);
+            l = t.querySelector(`.${this.sectionHeadingTextClass}`);
         l && a.push(`${l.textContent}`.trim());
-        const c = e.querySelector(`.${this.sectionSummaryClass}`);
+        const c = t.querySelector(`.${this.sectionSummaryClass}`);
         c && a.push(`${c.textContent}`.trim());
-        const h = t ? this.i18n.t("hideSectionAriaLabel") : this.i18n.t("showSectionAriaLabel");
-        a.push(h), s.setAttribute("aria-label", a.join(" , ")), t ? (o.removeAttribute("hidden"), e.classList.add(this.sectionExpandedClass), n.classList.remove(this.downChevronIconClass)) : (o.setAttribute("hidden", "until-found"), e.classList.remove(this.sectionExpandedClass), n.classList.add(this.downChevronIconClass));
+        const h = e ? this.i18n.t("hideSectionAriaLabel") : this.i18n.t("showSectionAriaLabel");
+        a.push(h), s.setAttribute("aria-label", a.join(" , ")), e ? (o.removeAttribute("hidden"), t.classList.add(this.sectionExpandedClass), n.classList.remove(this.downChevronIconClass)) : (o.setAttribute("hidden", "until-found"), t.classList.remove(this.sectionExpandedClass), n.classList.add(this.downChevronIconClass));
         const u = this.checkIfAllSectionsOpen();
         this.updateShowAllButton(u)
     }
-    isExpanded(t) {
-        return t.classList.contains(this.sectionExpandedClass)
+    isExpanded(e) {
+        return e.classList.contains(this.sectionExpandedClass)
     }
     checkIfAllSectionsOpen() {
         return this.$sections.length === this.$module.querySelectorAll(`.${this.sectionExpandedClass}`).length
     }
-    updateShowAllButton(t) {
-        this.$showAllButton && this.$showAllText && this.$showAllIcon && (this.$showAllButton.setAttribute("aria-expanded", t.toString()), this.$showAllText.textContent = t ? this.i18n.t("hideAllSections") : this.i18n.t("showAllSections"), this.$showAllIcon.classList.toggle(this.downChevronIconClass, !t))
+    updateShowAllButton(e) {
+        this.$showAllButton && this.$showAllText && this.$showAllIcon && (this.$showAllButton.setAttribute("aria-expanded", e.toString()), this.$showAllText.textContent = e ? this.i18n.t("hideAllSections") : this.i18n.t("showAllSections"), this.$showAllIcon.classList.toggle(this.downChevronIconClass, !e))
     }
-    storeState(t) {
+    storeState(e) {
         if (this.browserSupportsSessionStorage && this.config.rememberExpanded) {
-            const e = t.querySelector(`.${this.sectionButtonClass}`);
-            if (e) {
-                const t = e.getAttribute("aria-controls"),
-                    n = e.getAttribute("aria-expanded");
-                t && n && window.sessionStorage.setItem(t, n)
+            const t = e.querySelector(`.${this.sectionButtonClass}`);
+            if (t) {
+                const e = t.getAttribute("aria-controls"),
+                    n = t.getAttribute("aria-expanded");
+                e && n && window.sessionStorage.setItem(e, n)
             }
         }
     }
-    setInitialState(t) {
+    setInitialState(e) {
         if (this.browserSupportsSessionStorage && this.config.rememberExpanded) {
-            const e = t.querySelector(`.${this.sectionButtonClass}`);
-            if (e) {
-                const n = e.getAttribute("aria-controls"),
+            const t = e.querySelector(`.${this.sectionButtonClass}`);
+            if (t) {
+                const n = t.getAttribute("aria-controls"),
                     i = n ? window.sessionStorage.getItem(n) : null;
-                null !== i && this.setExpanded("true" === i, t)
+                null !== i && this.setExpanded("true" === i, e)
             }
         }
     }
     getButtonPunctuationEl() {
-        const t = document.createElement("span");
-        return t.classList.add("govuk-visually-hidden", this.sectionHeadingDividerClass), t.innerHTML = ", ", t
+        const e = document.createElement("span");
+        return e.classList.add("govuk-visually-hidden", this.sectionHeadingDividerClass), e.innerHTML = ", ", e
     }
 }
 Accordion.moduleName = "govuk-accordion", Accordion.defaults = Object.freeze({
@@ -337,83 +354,100 @@ Accordion.moduleName = "govuk-accordion", Accordion.defaults = Object.freeze({
         showSectionAriaLabel: "Show this section"
     },
     rememberExpanded: !0
+}), Accordion.schema = Object.freeze({
+    properties: {
+        i18n: {
+            type: "object"
+        },
+        rememberExpanded: {
+            type: "boolean"
+        }
+    }
 });
-const t = {
+const e = {
     checkForSessionStorage: function() {
-        const t = "this is the test string";
-        let e;
+        const e = "this is the test string";
+        let t;
         try {
-            return window.sessionStorage.setItem(t, t), e = window.sessionStorage.getItem(t) === t.toString(), window.sessionStorage.removeItem(t), e
+            return window.sessionStorage.setItem(e, e), t = window.sessionStorage.getItem(e) === e.toString(), window.sessionStorage.removeItem(e), t
         } catch (n) {
             return !1
         }
     }
 };
 class Button extends GOVUKFrontendComponent {
-    constructor(t, e = {}) {
-        if (super(), this.$module = void 0, this.config = void 0, this.debounceFormSubmitTimer = null, !(t instanceof HTMLElement)) throw new ElementError({
+    constructor(e, t = {}) {
+        if (super(), this.$module = void 0, this.config = void 0, this.debounceFormSubmitTimer = null, !(e instanceof HTMLElement)) throw new ElementError({
             componentName: "Button",
-            element: t,
+            element: e,
             identifier: "Root element (`$module`)"
         });
-        this.$module = t, this.config = mergeConfigs(Button.defaults, e, normaliseDataset(t.dataset)), this.$module.addEventListener("keydown", (t => this.handleKeyDown(t))), this.$module.addEventListener("click", (t => this.debounce(t)))
+        this.$module = e, this.config = mergeConfigs(Button.defaults, t, normaliseDataset(Button, e.dataset)), this.$module.addEventListener("keydown", (e => this.handleKeyDown(e))), this.$module.addEventListener("click", (e => this.debounce(e)))
     }
-    handleKeyDown(t) {
-        const e = t.target;
-        " " === t.key && e instanceof HTMLElement && "button" === e.getAttribute("role") && (t.preventDefault(), e.click())
+    handleKeyDown(e) {
+        const t = e.target;
+        " " === e.key && t instanceof HTMLElement && "button" === t.getAttribute("role") && (e.preventDefault(), t.click())
     }
-    debounce(t) {
-        if (this.config.preventDoubleClick) return this.debounceFormSubmitTimer ? (t.preventDefault(), !1) : void(this.debounceFormSubmitTimer = window.setTimeout((() => {
+    debounce(e) {
+        if (this.config.preventDoubleClick) return this.debounceFormSubmitTimer ? (e.preventDefault(), !1) : void(this.debounceFormSubmitTimer = window.setTimeout((() => {
             this.debounceFormSubmitTimer = null
         }), 1e3))
     }
 }
 
-function closestAttributeValue(t, e) {
-    const n = t.closest(`[${e}]`);
-    return n ? n.getAttribute(e) : null
+function closestAttributeValue(e, t) {
+    const n = e.closest(`[${t}]`);
+    return n ? n.getAttribute(t) : null
 }
 Button.moduleName = "govuk-button", Button.defaults = Object.freeze({
     preventDoubleClick: !1
+}), Button.schema = Object.freeze({
+    properties: {
+        preventDoubleClick: {
+            type: "boolean"
+        }
+    }
 });
 class CharacterCount extends GOVUKFrontendComponent {
-    constructor(t, e = {}) {
+    constructor(e, t = {}) {
         var n, i;
-        if (super(), this.$module = void 0, this.$textarea = void 0, this.$visibleCountMessage = void 0, this.$screenReaderCountMessage = void 0, this.lastInputTimestamp = null, this.lastInputValue = "", this.valueChecker = null, this.config = void 0, this.i18n = void 0, this.maxLength = void 0, !(t instanceof HTMLElement)) throw new ElementError({
+        if (super(), this.$module = void 0, this.$textarea = void 0, this.$visibleCountMessage = void 0, this.$screenReaderCountMessage = void 0, this.lastInputTimestamp = null, this.lastInputValue = "", this.valueChecker = null, this.config = void 0, this.i18n = void 0, this.maxLength = void 0, !(e instanceof HTMLElement)) throw new ElementError({
             componentName: "Character count",
-            element: t,
+            element: e,
             identifier: "Root element (`$module`)"
         });
-        const s = t.querySelector(".govuk-js-character-count");
+        const s = e.querySelector(".govuk-js-character-count");
         if (!(s instanceof HTMLTextAreaElement || s instanceof HTMLInputElement)) throw new ElementError({
             componentName: "Character count",
             element: s,
             expectedType: "HTMLTextareaElement or HTMLInputElement",
             identifier: "Form field (`.govuk-js-character-count`)"
         });
-        const o = normaliseDataset(t.dataset);
+        const o = normaliseDataset(CharacterCount, e.dataset);
         let r = {};
         ("maxwords" in o || "maxlength" in o) && (r = {
             maxlength: void 0,
             maxwords: void 0
-        }), this.config = mergeConfigs(CharacterCount.defaults, e, r, o);
-        const a = function(t, e) {
+        }), this.config = mergeConfigs(CharacterCount.defaults, t, r, o);
+        const a = function(e, t) {
             const n = [];
-            for (const [i, s] of Object.entries(t)) {
-                const t = [];
-                for (const {
-                        required: n,
-                        errorMessage: i
-                    }
-                    of s) n.every((t => !!e[t])) || t.push(i);
-                "anyOf" !== i || s.length - t.length >= 1 || n.push(...t)
+            for (const [i, s] of Object.entries(e)) {
+                const e = [];
+                if (Array.isArray(s)) {
+                    for (const {
+                            required: n,
+                            errorMessage: i
+                        }
+                        of s) n.every((e => !!t[e])) || e.push(i);
+                    "allOf" === i && e.length && n.push(...e), "anyOf" !== i || s.length - e.length >= 1 || n.push(...e)
+                }
             }
             return n
         }(CharacterCount.schema, this.config);
         if (a[0]) throw new ConfigError(`Character count: ${a[0]}`);
-        this.i18n = new I18n(extractConfigByNamespace(this.config, "i18n"), {
-            locale: closestAttributeValue(t, "lang")
-        }), this.maxLength = null != (n = null != (i = this.config.maxwords) ? i : this.config.maxlength) ? n : 1 / 0, this.$module = t, this.$textarea = s;
+        this.i18n = new I18n(this.config.i18n, {
+            locale: closestAttributeValue(e, "lang")
+        }), this.maxLength = null != (n = null != (i = this.config.maxwords) ? i : this.config.maxlength) ? n : 1 / 0, this.$module = e, this.$textarea = s;
         const l = `${this.$textarea.id}-info`,
             c = document.getElementById(l);
         if (!c) throw new ElementError({
@@ -450,35 +484,35 @@ class CharacterCount extends GOVUKFrontendComponent {
         this.updateVisibleCountMessage(), this.updateScreenReaderCountMessage()
     }
     updateVisibleCountMessage() {
-        const t = this.maxLength - this.count(this.$textarea.value) < 0;
-        this.$visibleCountMessage.classList.toggle("govuk-character-count__message--disabled", !this.isOverThreshold()), this.$textarea.classList.toggle("govuk-textarea--error", t), this.$visibleCountMessage.classList.toggle("govuk-error-message", t), this.$visibleCountMessage.classList.toggle("govuk-hint", !t), this.$visibleCountMessage.textContent = this.getCountMessage()
+        const e = this.maxLength - this.count(this.$textarea.value) < 0;
+        this.$visibleCountMessage.classList.toggle("govuk-character-count__message--disabled", !this.isOverThreshold()), this.$textarea.classList.toggle("govuk-textarea--error", e), this.$visibleCountMessage.classList.toggle("govuk-error-message", e), this.$visibleCountMessage.classList.toggle("govuk-hint", !e), this.$visibleCountMessage.textContent = this.getCountMessage()
     }
     updateScreenReaderCountMessage() {
         this.isOverThreshold() ? this.$screenReaderCountMessage.removeAttribute("aria-hidden") : this.$screenReaderCountMessage.setAttribute("aria-hidden", "true"), this.$screenReaderCountMessage.textContent = this.getCountMessage()
     }
-    count(t) {
+    count(e) {
         if (this.config.maxwords) {
-            var e;
-            return (null != (e = t.match(/\S+/g)) ? e : []).length
+            var t;
+            return (null != (t = e.match(/\S+/g)) ? t : []).length
         }
-        return t.length
+        return e.length
     }
     getCountMessage() {
-        const t = this.maxLength - this.count(this.$textarea.value),
-            e = this.config.maxwords ? "words" : "characters";
-        return this.formatCountMessage(t, e)
-    }
-    formatCountMessage(t, e) {
-        if (0 === t) return this.i18n.t(`${e}AtLimit`);
-        const n = t < 0 ? "OverLimit" : "UnderLimit";
-        return this.i18n.t(`${e}${n}`, {
-            count: Math.abs(t)
+        const e = this.maxLength - this.count(this.$textarea.value),
+            t = this.config.maxwords ? "words" : "characters";
+        return this.formatCountMessage(e, t)
+    }
+    formatCountMessage(e, t) {
+        if (0 === e) return this.i18n.t(`${t}AtLimit`);
+        const n = e < 0 ? "OverLimit" : "UnderLimit";
+        return this.i18n.t(`${t}${n}`, {
+            count: Math.abs(e)
         })
     }
     isOverThreshold() {
         if (!this.config.threshold) return !0;
-        const t = this.count(this.$textarea.value);
-        return this.maxLength * this.config.threshold / 100 <= t
+        const e = this.count(this.$textarea.value);
+        return this.maxLength * this.config.threshold / 100 <= e
     }
 }
 CharacterCount.moduleName = "govuk-character-count", CharacterCount.defaults = Object.freeze({
@@ -507,6 +541,20 @@ CharacterCount.moduleName = "govuk-character-count", CharacterCount.defaults = O
         }
     }
 }), CharacterCount.schema = Object.freeze({
+    properties: {
+        i18n: {
+            type: "object"
+        },
+        maxwords: {
+            type: "number"
+        },
+        maxlength: {
+            type: "number"
+        },
+        threshold: {
+            type: "number"
+        }
+    },
     anyOf: [{
         required: ["maxwords"],
         errorMessage: 'Either "maxlength" or "maxwords" must be provided'
@@ -516,118 +564,124 @@ CharacterCount.moduleName = "govuk-character-count", CharacterCount.defaults = O
     }]
 });
 class Checkboxes extends GOVUKFrontendComponent {
-    constructor(t) {
-        if (super(), this.$module = void 0, this.$inputs = void 0, !(t instanceof HTMLElement)) throw new ElementError({
+    constructor(e) {
+        if (super(), this.$module = void 0, this.$inputs = void 0, !(e instanceof HTMLElement)) throw new ElementError({
             componentName: "Checkboxes",
-            element: t,
+            element: e,
             identifier: "Root element (`$module`)"
         });
-        const e = t.querySelectorAll('input[type="checkbox"]');
-        if (!e.length) throw new ElementError({
+        const t = e.querySelectorAll('input[type="checkbox"]');
+        if (!t.length) throw new ElementError({
             componentName: "Checkboxes",
             identifier: 'Form inputs (`<input type="checkbox">`)'
         });
-        this.$module = t, this.$inputs = e, this.$inputs.forEach((t => {
-            const e = t.getAttribute("data-aria-controls");
-            if (e) {
-                if (!document.getElementById(e)) throw new ElementError({
+        this.$module = e, this.$inputs = t, this.$inputs.forEach((e => {
+            const t = e.getAttribute("data-aria-controls");
+            if (t) {
+                if (!document.getElementById(t)) throw new ElementError({
                     componentName: "Checkboxes",
-                    identifier: `Conditional reveal (\`id="${e}"\`)`
+                    identifier: `Conditional reveal (\`id="${t}"\`)`
                 });
-                t.setAttribute("aria-controls", e), t.removeAttribute("data-aria-controls")
+                e.setAttribute("aria-controls", t), e.removeAttribute("data-aria-controls")
             }
-        })), window.addEventListener("pageshow", (() => this.syncAllConditionalReveals())), this.syncAllConditionalReveals(), this.$module.addEventListener("click", (t => this.handleClick(t)))
+        })), window.addEventListener("pageshow", (() => this.syncAllConditionalReveals())), this.syncAllConditionalReveals(), this.$module.addEventListener("click", (e => this.handleClick(e)))
     }
     syncAllConditionalReveals() {
-        this.$inputs.forEach((t => this.syncConditionalRevealWithInputState(t)))
+        this.$inputs.forEach((e => this.syncConditionalRevealWithInputState(e)))
     }
-    syncConditionalRevealWithInputState(t) {
-        const e = t.getAttribute("aria-controls");
-        if (!e) return;
-        const n = document.getElementById(e);
+    syncConditionalRevealWithInputState(e) {
+        const t = e.getAttribute("aria-controls");
+        if (!t) return;
+        const n = document.getElementById(t);
         if (n && n.classList.contains("govuk-checkboxes__conditional")) {
-            const e = t.checked;
-            t.setAttribute("aria-expanded", e.toString()), n.classList.toggle("govuk-checkboxes__conditional--hidden", !e)
+            const t = e.checked;
+            e.setAttribute("aria-expanded", t.toString()), n.classList.toggle("govuk-checkboxes__conditional--hidden", !t)
         }
     }
-    unCheckAllInputsExcept(t) {
-        document.querySelectorAll(`input[type="checkbox"][name="${t.name}"]`).forEach((e => {
-            t.form === e.form && e !== t && (e.checked = !1, this.syncConditionalRevealWithInputState(e))
+    unCheckAllInputsExcept(e) {
+        document.querySelectorAll(`input[type="checkbox"][name="${e.name}"]`).forEach((t => {
+            e.form === t.form && t !== e && (t.checked = !1, this.syncConditionalRevealWithInputState(t))
         }))
     }
-    unCheckExclusiveInputs(t) {
-        document.querySelectorAll(`input[data-behaviour="exclusive"][type="checkbox"][name="${t.name}"]`).forEach((e => {
-            t.form === e.form && (e.checked = !1, this.syncConditionalRevealWithInputState(e))
+    unCheckExclusiveInputs(e) {
+        document.querySelectorAll(`input[data-behaviour="exclusive"][type="checkbox"][name="${e.name}"]`).forEach((t => {
+            e.form === t.form && (t.checked = !1, this.syncConditionalRevealWithInputState(t))
         }))
     }
-    handleClick(t) {
-        const e = t.target;
-        if (!(e instanceof HTMLInputElement) || "checkbox" !== e.type) return;
-        if (e.getAttribute("aria-controls") && this.syncConditionalRevealWithInputState(e), !e.checked) return;
-        "exclusive" === e.getAttribute("data-behaviour") ? this.unCheckAllInputsExcept(e) : this.unCheckExclusiveInputs(e)
+    handleClick(e) {
+        const t = e.target;
+        if (!(t instanceof HTMLInputElement) || "checkbox" !== t.type) return;
+        if (t.getAttribute("aria-controls") && this.syncConditionalRevealWithInputState(t), !t.checked) return;
+        "exclusive" === t.getAttribute("data-behaviour") ? this.unCheckAllInputsExcept(t) : this.unCheckExclusiveInputs(t)
     }
 }
 Checkboxes.moduleName = "govuk-checkboxes";
 class ErrorSummary extends GOVUKFrontendComponent {
-    constructor(t, e = {}) {
-        if (super(), this.$module = void 0, this.config = void 0, !(t instanceof HTMLElement)) throw new ElementError({
+    constructor(e, t = {}) {
+        if (super(), this.$module = void 0, this.config = void 0, !(e instanceof HTMLElement)) throw new ElementError({
             componentName: "Error summary",
-            element: t,
+            element: e,
             identifier: "Root element (`$module`)"
         });
-        this.$module = t, this.config = mergeConfigs(ErrorSummary.defaults, e, normaliseDataset(t.dataset)), this.config.disableAutoFocus || setFocus(this.$module), this.$module.addEventListener("click", (t => this.handleClick(t)))
+        this.$module = e, this.config = mergeConfigs(ErrorSummary.defaults, t, normaliseDataset(ErrorSummary, e.dataset)), this.config.disableAutoFocus || setFocus(this.$module), this.$module.addEventListener("click", (e => this.handleClick(e)))
     }
-    handleClick(t) {
-        const e = t.target;
-        e && this.focusTarget(e) && t.preventDefault()
+    handleClick(e) {
+        const t = e.target;
+        t && this.focusTarget(t) && e.preventDefault()
     }
-    focusTarget(t) {
-        if (!(t instanceof HTMLAnchorElement)) return !1;
-        const e = getFragmentFromUrl(t.href);
-        if (!e) return !1;
-        const n = document.getElementById(e);
+    focusTarget(e) {
+        if (!(e instanceof HTMLAnchorElement)) return !1;
+        const t = getFragmentFromUrl(e.href);
+        if (!t) return !1;
+        const n = document.getElementById(t);
         if (!n) return !1;
         const i = this.getAssociatedLegendOrLabel(n);
         return !!i && (i.scrollIntoView(), n.focus({
             preventScroll: !0
         }), !0)
     }
-    getAssociatedLegendOrLabel(t) {
-        var e;
-        const n = t.closest("fieldset");
+    getAssociatedLegendOrLabel(e) {
+        var t;
+        const n = e.closest("fieldset");
         if (n) {
-            const e = n.getElementsByTagName("legend");
-            if (e.length) {
-                const n = e[0];
-                if (t instanceof HTMLInputElement && ("checkbox" === t.type || "radio" === t.type)) return n;
+            const t = n.getElementsByTagName("legend");
+            if (t.length) {
+                const n = t[0];
+                if (e instanceof HTMLInputElement && ("checkbox" === e.type || "radio" === e.type)) return n;
                 const i = n.getBoundingClientRect().top,
-                    s = t.getBoundingClientRect();
+                    s = e.getBoundingClientRect();
                 if (s.height && window.innerHeight) {
                     if (s.top + s.height - i < window.innerHeight / 2) return n
                 }
             }
         }
-        return null != (e = document.querySelector(`label[for='${t.getAttribute("id")}']`)) ? e : t.closest("label")
+        return null != (t = document.querySelector(`label[for='${e.getAttribute("id")}']`)) ? t : e.closest("label")
     }
 }
 ErrorSummary.moduleName = "govuk-error-summary", ErrorSummary.defaults = Object.freeze({
     disableAutoFocus: !1
+}), ErrorSummary.schema = Object.freeze({
+    properties: {
+        disableAutoFocus: {
+            type: "boolean"
+        }
+    }
 });
 class ExitThisPage extends GOVUKFrontendComponent {
-    constructor(t, e = {}) {
-        if (super(), this.$module = void 0, this.config = void 0, this.i18n = void 0, this.$button = void 0, this.$skiplinkButton = null, this.$updateSpan = null, this.$indicatorContainer = null, this.$overlay = null, this.keypressCounter = 0, this.lastKeyWasModified = !1, this.timeoutTime = 5e3, this.keypressTimeoutId = null, this.timeoutMessageId = null, !(t instanceof HTMLElement)) throw new ElementError({
+    constructor(e, t = {}) {
+        if (super(), this.$module = void 0, this.config = void 0, this.i18n = void 0, this.$button = void 0, this.$skiplinkButton = null, this.$updateSpan = null, this.$indicatorContainer = null, this.$overlay = null, this.keypressCounter = 0, this.lastKeyWasModified = !1, this.timeoutTime = 5e3, this.keypressTimeoutId = null, this.timeoutMessageId = null, !(e instanceof HTMLElement)) throw new ElementError({
             componentName: "Exit this page",
-            element: t,
+            element: e,
             identifier: "Root element (`$module`)"
         });
-        const n = t.querySelector(".govuk-exit-this-page__button");
+        const n = e.querySelector(".govuk-exit-this-page__button");
         if (!(n instanceof HTMLAnchorElement)) throw new ElementError({
             componentName: "Exit this page",
             element: n,
             expectedType: "HTMLAnchorElement",
             identifier: "Button (`.govuk-exit-this-page__button`)"
         });
-        this.config = mergeConfigs(ExitThisPage.defaults, e, normaliseDataset(t.dataset)), this.i18n = new I18n(extractConfigByNamespace(this.config, "i18n")), this.$module = t, this.$button = n;
+        this.config = mergeConfigs(ExitThisPage.defaults, t, normaliseDataset(ExitThisPage, e.dataset)), this.i18n = new I18n(this.config.i18n), this.$module = e, this.$button = n;
         const i = document.querySelector(".govuk-js-exit-this-page-skiplink");
         i instanceof HTMLAnchorElement && (this.$skiplinkButton = i), this.buildIndicator(), this.initUpdateSpan(), this.initButtonClickHandler(), "govukFrontendExitThisPageKeypress" in document.body.dataset || (document.addEventListener("keyup", this.handleKeypress.bind(this), !0), document.body.dataset.govukFrontendExitThisPageKeypress = "true"), window.addEventListener("pageshow", this.resetPage.bind(this))
     }
@@ -639,27 +693,27 @@ class ExitThisPage extends GOVUKFrontendComponent {
     }
     buildIndicator() {
         this.$indicatorContainer = document.createElement("div"), this.$indicatorContainer.className = "govuk-exit-this-page__indicator", this.$indicatorContainer.setAttribute("aria-hidden", "true");
-        for (let t = 0; t < 3; t++) {
-            const t = document.createElement("div");
-            t.className = "govuk-exit-this-page__indicator-light", this.$indicatorContainer.appendChild(t)
+        for (let e = 0; e < 3; e++) {
+            const e = document.createElement("div");
+            e.className = "govuk-exit-this-page__indicator-light", this.$indicatorContainer.appendChild(e)
         }
         this.$button.appendChild(this.$indicatorContainer)
     }
     updateIndicator() {
         if (!this.$indicatorContainer) return;
         this.$indicatorContainer.classList.toggle("govuk-exit-this-page__indicator--visible", this.keypressCounter > 0);
-        this.$indicatorContainer.querySelectorAll(".govuk-exit-this-page__indicator-light").forEach(((t, e) => {
-            t.classList.toggle("govuk-exit-this-page__indicator-light--on", e < this.keypressCounter)
+        this.$indicatorContainer.querySelectorAll(".govuk-exit-this-page__indicator-light").forEach(((e, t) => {
+            e.classList.toggle("govuk-exit-this-page__indicator-light--on", t < this.keypressCounter)
         }))
     }
     exitPage() {
         this.$updateSpan && (this.$updateSpan.textContent = "", document.body.classList.add("govuk-exit-this-page-hide-content"), this.$overlay = document.createElement("div"), this.$overlay.className = "govuk-exit-this-page-overlay", this.$overlay.setAttribute("role", "alert"), document.body.appendChild(this.$overlay), this.$overlay.textContent = this.i18n.t("activated"), window.location.href = this.$button.href)
     }
-    handleClick(t) {
-        t.preventDefault(), this.exitPage()
+    handleClick(e) {
+        e.preventDefault(), this.exitPage()
     }
-    handleKeypress(t) {
-        this.$updateSpan && ("Shift" !== t.key || this.lastKeyWasModified ? this.keypressTimeoutId && this.resetKeypressTimer() : (this.keypressCounter += 1, this.updateIndicator(), this.timeoutMessageId && (window.clearTimeout(this.timeoutMessageId), this.timeoutMessageId = null), this.keypressCounter >= 3 ? (this.keypressCounter = 0, this.keypressTimeoutId && (window.clearTimeout(this.keypressTimeoutId), this.keypressTimeoutId = null), this.exitPage()) : 1 === this.keypressCounter ? this.$updateSpan.textContent = this.i18n.t("pressTwoMoreTimes") : this.$updateSpan.textContent = this.i18n.t("pressOneMoreTime"), this.setKeypressTimer()), this.lastKeyWasModified = t.shiftKey)
+    handleKeypress(e) {
+        this.$updateSpan && ("Shift" !== e.key || this.lastKeyWasModified ? this.keypressTimeoutId && this.resetKeypressTimer() : (this.keypressCounter += 1, this.updateIndicator(), this.timeoutMessageId && (window.clearTimeout(this.timeoutMessageId), this.timeoutMessageId = null), this.keypressCounter >= 3 ? (this.keypressCounter = 0, this.keypressTimeoutId && (window.clearTimeout(this.keypressTimeoutId), this.keypressTimeoutId = null), this.exitPage()) : 1 === this.keypressCounter ? this.$updateSpan.textContent = this.i18n.t("pressTwoMoreTimes") : this.$updateSpan.textContent = this.i18n.t("pressOneMoreTime"), this.setKeypressTimer()), this.lastKeyWasModified = e.shiftKey)
     }
     setKeypressTimer() {
         this.keypressTimeoutId && window.clearTimeout(this.keypressTimeoutId), this.keypressTimeoutId = window.setTimeout(this.resetKeypressTimer.bind(this), this.timeoutTime)
@@ -667,9 +721,9 @@ class ExitThisPage extends GOVUKFrontendComponent {
     resetKeypressTimer() {
         if (!this.$updateSpan) return;
         this.keypressTimeoutId && (window.clearTimeout(this.keypressTimeoutId), this.keypressTimeoutId = null);
-        const t = this.$updateSpan;
-        this.keypressCounter = 0, t.textContent = this.i18n.t("timedOut"), this.timeoutMessageId = window.setTimeout((() => {
-            t.textContent = ""
+        const e = this.$updateSpan;
+        this.keypressCounter = 0, e.textContent = this.i18n.t("timedOut"), this.timeoutMessageId = window.setTimeout((() => {
+            e.textContent = ""
         }), this.timeoutTime), this.updateIndicator()
     }
     resetPage() {
@@ -683,18 +737,24 @@ ExitThisPage.moduleName = "govuk-exit-this-page", ExitThisPage.defaults = Object
         pressTwoMoreTimes: "Shift, press 2 more times to exit.",
         pressOneMoreTime: "Shift, press 1 more time to exit."
     }
+}), ExitThisPage.schema = Object.freeze({
+    properties: {
+        i18n: {
+            type: "object"
+        }
+    }
 });
 class Header extends GOVUKFrontendComponent {
-    constructor(t) {
-        if (super(), this.$module = void 0, this.$menuButton = void 0, this.$menu = void 0, this.menuIsOpen = !1, this.mql = null, !t) throw new ElementError({
+    constructor(e) {
+        if (super(), this.$module = void 0, this.$menuButton = void 0, this.$menu = void 0, this.menuIsOpen = !1, this.mql = null, !e) throw new ElementError({
             componentName: "Header",
-            element: t,
+            element: e,
             identifier: "Root element (`$module`)"
         });
-        this.$module = t;
-        const e = t.querySelector(".govuk-js-header-toggle");
-        if (!e) return this;
-        const n = e.getAttribute("aria-controls");
+        this.$module = e;
+        const t = e.querySelector(".govuk-js-header-toggle");
+        if (!t) return this;
+        const n = t.getAttribute("aria-controls");
         if (!n) throw new ElementError({
             componentName: "Header",
             identifier: 'Navigation button (`<button class="govuk-js-header-toggle">`) attribute (`aria-controls`)'
@@ -705,15 +765,15 @@ class Header extends GOVUKFrontendComponent {
             element: i,
             identifier: `Navigation (\`<ul id="${n}">\`)`
         });
-        this.$menu = i, this.$menuButton = e, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
+        this.$menu = i, this.$menuButton = t, this.setupResponsiveChecks(), this.$menuButton.addEventListener("click", (() => this.handleMenuButtonClick()))
     }
     setupResponsiveChecks() {
-        const t = getBreakpoint("desktop");
-        if (!t.value) throw new ElementError({
+        const e = getBreakpoint("desktop");
+        if (!e.value) throw new ElementError({
             componentName: "Header",
-            identifier: `CSS custom property (\`${t.property}\`) on pseudo-class \`:root\``
+            identifier: `CSS custom property (\`${e.property}\`) on pseudo-class \`:root\``
         });
-        this.mql = window.matchMedia(`(min-width: ${t.value})`), "addEventListener" in this.mql ? this.mql.addEventListener("change", (() => this.checkMode())) : this.mql.addListener((() => this.checkMode())), this.checkMode()
+        this.mql = window.matchMedia(`(min-width: ${e.value})`), "addEventListener" in this.mql ? this.mql.addEventListener("change", (() => this.checkMode())) : this.mql.addListener((() => this.checkMode())), this.checkMode()
     }
     checkMode() {
         this.mql && this.$menu && this.$menuButton && (this.mql.matches ? (this.$menu.removeAttribute("hidden"), this.$menuButton.setAttribute("hidden", "")) : (this.$menuButton.removeAttribute("hidden"), this.$menuButton.setAttribute("aria-expanded", this.menuIsOpen.toString()), this.menuIsOpen ? this.$menu.removeAttribute("hidden") : this.$menu.setAttribute("hidden", "")))
@@ -724,78 +784,84 @@ class Header extends GOVUKFrontendComponent {
 }
 Header.moduleName = "govuk-header";
 class NotificationBanner extends GOVUKFrontendComponent {
-    constructor(t, e = {}) {
-        if (super(), this.$module = void 0, this.config = void 0, !(t instanceof HTMLElement)) throw new ElementError({
+    constructor(e, t = {}) {
+        if (super(), this.$module = void 0, this.config = void 0, !(e instanceof HTMLElement)) throw new ElementError({
             componentName: "Notification banner",
-            element: t,
+            element: e,
             identifier: "Root element (`$module`)"
         });
-        this.$module = t, this.config = mergeConfigs(NotificationBanner.defaults, e, normaliseDataset(t.dataset)), "alert" !== this.$module.getAttribute("role") || this.config.disableAutoFocus || setFocus(this.$module)
+        this.$module = e, this.config = mergeConfigs(NotificationBanner.defaults, t, normaliseDataset(NotificationBanner, e.dataset)), "alert" !== this.$module.getAttribute("role") || this.config.disableAutoFocus || setFocus(this.$module)
     }
 }
 NotificationBanner.moduleName = "govuk-notification-banner", NotificationBanner.defaults = Object.freeze({
     disableAutoFocus: !1
+}), NotificationBanner.schema = Object.freeze({
+    properties: {
+        disableAutoFocus: {
+            type: "boolean"
+        }
+    }
 });
 class Radios extends GOVUKFrontendComponent {
-    constructor(t) {
-        if (super(), this.$module = void 0, this.$inputs = void 0, !(t instanceof HTMLElement)) throw new ElementError({
+    constructor(e) {
+        if (super(), this.$module = void 0, this.$inputs = void 0, !(e instanceof HTMLElement)) throw new ElementError({
             componentName: "Radios",
-            element: t,
+            element: e,
             identifier: "Root element (`$module`)"
         });
-        const e = t.querySelectorAll('input[type="radio"]');
-        if (!e.length) throw new ElementError({
+        const t = e.querySelectorAll('input[type="radio"]');
+        if (!t.length) throw new ElementError({
             componentName: "Radios",
             identifier: 'Form inputs (`<input type="radio">`)'
         });
-        this.$module = t, this.$inputs = e, this.$inputs.forEach((t => {
-            const e = t.getAttribute("data-aria-controls");
-            if (e) {
-                if (!document.getElementById(e)) throw new ElementError({
+        this.$module = e, this.$inputs = t, this.$inputs.forEach((e => {
+            const t = e.getAttribute("data-aria-controls");
+            if (t) {
+                if (!document.getElementById(t)) throw new ElementError({
                     componentName: "Radios",
-                    identifier: `Conditional reveal (\`id="${e}"\`)`
+                    identifier: `Conditional reveal (\`id="${t}"\`)`
                 });
-                t.setAttribute("aria-controls", e), t.removeAttribute("data-aria-controls")
+                e.setAttribute("aria-controls", t), e.removeAttribute("data-aria-controls")
             }
-        })), window.addEventListener("pageshow", (() => this.syncAllConditionalReveals())), this.syncAllConditionalReveals(), this.$module.addEventListener("click", (t => this.handleClick(t)))
+        })), window.addEventListener("pageshow", (() => this.syncAllConditionalReveals())), this.syncAllConditionalReveals(), this.$module.addEventListener("click", (e => this.handleClick(e)))
     }
     syncAllConditionalReveals() {
-        this.$inputs.forEach((t => this.syncConditionalRevealWithInputState(t)))
+        this.$inputs.forEach((e => this.syncConditionalRevealWithInputState(e)))
     }
-    syncConditionalRevealWithInputState(t) {
-        const e = t.getAttribute("aria-controls");
-        if (!e) return;
-        const n = document.getElementById(e);
+    syncConditionalRevealWithInputState(e) {
+        const t = e.getAttribute("aria-controls");
+        if (!t) return;
+        const n = document.getElementById(t);
         if (null != n && n.classList.contains("govuk-radios__conditional")) {
-            const e = t.checked;
-            t.setAttribute("aria-expanded", e.toString()), n.classList.toggle("govuk-radios__conditional--hidden", !e)
+            const t = e.checked;
+            e.setAttribute("aria-expanded", t.toString()), n.classList.toggle("govuk-radios__conditional--hidden", !t)
         }
     }
-    handleClick(t) {
-        const e = t.target;
-        if (!(e instanceof HTMLInputElement) || "radio" !== e.type) return;
+    handleClick(e) {
+        const t = e.target;
+        if (!(t instanceof HTMLInputElement) || "radio" !== t.type) return;
         const n = document.querySelectorAll('input[type="radio"][aria-controls]'),
-            i = e.form,
-            s = e.name;
-        n.forEach((t => {
-            const e = t.form === i;
-            t.name === s && e && this.syncConditionalRevealWithInputState(t)
+            i = t.form,
+            s = t.name;
+        n.forEach((e => {
+            const t = e.form === i;
+            e.name === s && t && this.syncConditionalRevealWithInputState(e)
         }))
     }
 }
 Radios.moduleName = "govuk-radios";
 class SkipLink extends GOVUKFrontendComponent {
-    constructor(t) {
-        var e;
-        if (super(), this.$module = void 0, !(t instanceof HTMLAnchorElement)) throw new ElementError({
+    constructor(e) {
+        var t;
+        if (super(), this.$module = void 0, !(e instanceof HTMLAnchorElement)) throw new ElementError({
             componentName: "Skip link",
-            element: t,
+            element: e,
             expectedType: "HTMLAnchorElement",
             identifier: "Root element (`$module`)"
         });
-        this.$module = t;
+        this.$module = e;
         const n = this.$module.hash,
-            i = null != (e = this.$module.getAttribute("href")) ? e : "";
+            i = null != (t = this.$module.getAttribute("href")) ? t : "";
         let s;
         try {
             s = new window.URL(this.$module.href)
@@ -823,18 +889,18 @@ class SkipLink extends GOVUKFrontendComponent {
 }
 SkipLink.moduleName = "govuk-skip-link";
 class Tabs extends GOVUKFrontendComponent {
-    constructor(t) {
-        if (super(), this.$module = void 0, this.$tabs = void 0, this.$tabList = void 0, this.$tabListItems = void 0, this.jsHiddenClass = "govuk-tabs__panel--hidden", this.changingHash = !1, this.boundTabClick = void 0, this.boundTabKeydown = void 0, this.boundOnHashChange = void 0, this.mql = null, !t) throw new ElementError({
+    constructor(e) {
+        if (super(), this.$module = void 0, this.$tabs = void 0, this.$tabList = void 0, this.$tabListItems = void 0, this.jsHiddenClass = "govuk-tabs__panel--hidden", this.changingHash = !1, this.boundTabClick = void 0, this.boundTabKeydown = void 0, this.boundOnHashChange = void 0, this.mql = null, !e) throw new ElementError({
             componentName: "Tabs",
-            element: t,
+            element: e,
             identifier: "Root element (`$module`)"
         });
-        const e = t.querySelectorAll("a.govuk-tabs__tab");
-        if (!e.length) throw new ElementError({
+        const t = e.querySelectorAll("a.govuk-tabs__tab");
+        if (!t.length) throw new ElementError({
             componentName: "Tabs",
             identifier: 'Links (`<a class="govuk-tabs__tab">`)'
         });
-        this.$module = t, this.$tabs = e, this.boundTabClick = this.onTabClick.bind(this), this.boundTabKeydown = this.onTabKeydown.bind(this), this.boundOnHashChange = this.onHashChange.bind(this);
+        this.$module = e, this.$tabs = t, this.boundTabClick = this.onTabClick.bind(this), this.boundTabKeydown = this.onTabKeydown.bind(this), this.boundOnHashChange = this.onHashChange.bind(this);
         const n = this.$module.querySelector(".govuk-tabs__list"),
             i = this.$module.querySelectorAll("li.govuk-tabs__list-item");
         if (!n) throw new ElementError({
@@ -848,149 +914,149 @@ class Tabs extends GOVUKFrontendComponent {
         this.$tabList = n, this.$tabListItems = i, this.setupResponsiveChecks()
     }
     setupResponsiveChecks() {
-        const t = getBreakpoint("tablet");
-        if (!t.value) throw new ElementError({
+        const e = getBreakpoint("tablet");
+        if (!e.value) throw new ElementError({
             componentName: "Tabs",
-            identifier: `CSS custom property (\`${t.property}\`) on pseudo-class \`:root\``
+            identifier: `CSS custom property (\`${e.property}\`) on pseudo-class \`:root\``
         });
-        this.mql = window.matchMedia(`(min-width: ${t.value})`), "addEventListener" in this.mql ? this.mql.addEventListener("change", (() => this.checkMode())) : this.mql.addListener((() => this.checkMode())), this.checkMode()
+        this.mql = window.matchMedia(`(min-width: ${e.value})`), "addEventListener" in this.mql ? this.mql.addEventListener("change", (() => this.checkMode())) : this.mql.addListener((() => this.checkMode())), this.checkMode()
     }
     checkMode() {
-        var t;
-        null != (t = this.mql) && t.matches ? this.setup() : this.teardown()
+        var e;
+        null != (e = this.mql) && e.matches ? this.setup() : this.teardown()
     }
     setup() {
-        var t;
-        this.$tabList.setAttribute("role", "tablist"), this.$tabListItems.forEach((t => {
-            t.setAttribute("role", "presentation")
-        })), this.$tabs.forEach((t => {
-            this.setAttributes(t), t.addEventListener("click", this.boundTabClick, !0), t.addEventListener("keydown", this.boundTabKeydown, !0), this.hideTab(t)
+        var e;
+        this.$tabList.setAttribute("role", "tablist"), this.$tabListItems.forEach((e => {
+            e.setAttribute("role", "presentation")
+        })), this.$tabs.forEach((e => {
+            this.setAttributes(e), e.addEventListener("click", this.boundTabClick, !0), e.addEventListener("keydown", this.boundTabKeydown, !0), this.hideTab(e)
         }));
-        const e = null != (t = this.getTab(window.location.hash)) ? t : this.$tabs[0];
-        this.showTab(e), window.addEventListener("hashchange", this.boundOnHashChange, !0)
+        const t = null != (e = this.getTab(window.location.hash)) ? e : this.$tabs[0];
+        this.showTab(t), window.addEventListener("hashchange", this.boundOnHashChange, !0)
     }
     teardown() {
-        this.$tabList.removeAttribute("role"), this.$tabListItems.forEach((t => {
-            t.removeAttribute("role")
-        })), this.$tabs.forEach((t => {
-            t.removeEventListener("click", this.boundTabClick, !0), t.removeEventListener("keydown", this.boundTabKeydown, !0), this.unsetAttributes(t)
+        this.$tabList.removeAttribute("role"), this.$tabListItems.forEach((e => {
+            e.removeAttribute("role")
+        })), this.$tabs.forEach((e => {
+            e.removeEventListener("click", this.boundTabClick, !0), e.removeEventListener("keydown", this.boundTabKeydown, !0), this.unsetAttributes(e)
         })), window.removeEventListener("hashchange", this.boundOnHashChange, !0)
     }
     onHashChange() {
-        const t = window.location.hash,
-            e = this.getTab(t);
-        if (!e) return;
+        const e = window.location.hash,
+            t = this.getTab(e);
+        if (!t) return;
         if (this.changingHash) return void(this.changingHash = !1);
         const n = this.getCurrentTab();
-        n && (this.hideTab(n), this.showTab(e), e.focus())
-    }
-    hideTab(t) {
-        this.unhighlightTab(t), this.hidePanel(t)
-    }
-    showTab(t) {
-        this.highlightTab(t), this.showPanel(t)
-    }
-    getTab(t) {
-        return this.$module.querySelector(`a.govuk-tabs__tab[href="${t}"]`)
-    }
-    setAttributes(t) {
-        const e = getFragmentFromUrl(t.href);
-        if (!e) return;
-        t.setAttribute("id", `tab_${e}`), t.setAttribute("role", "tab"), t.setAttribute("aria-controls", e), t.setAttribute("aria-selected", "false"), t.setAttribute("tabindex", "-1");
-        const n = this.getPanel(t);
-        n && (n.setAttribute("role", "tabpanel"), n.setAttribute("aria-labelledby", t.id), n.classList.add(this.jsHiddenClass))
-    }
-    unsetAttributes(t) {
-        t.removeAttribute("id"), t.removeAttribute("role"), t.removeAttribute("aria-controls"), t.removeAttribute("aria-selected"), t.removeAttribute("tabindex");
-        const e = this.getPanel(t);
-        e && (e.removeAttribute("role"), e.removeAttribute("aria-labelledby"), e.classList.remove(this.jsHiddenClass))
-    }
-    onTabClick(t) {
-        const e = this.getCurrentTab(),
-            n = t.currentTarget;
-        e && n instanceof HTMLAnchorElement && (t.preventDefault(), this.hideTab(e), this.showTab(n), this.createHistoryEntry(n))
-    }
-    createHistoryEntry(t) {
-        const e = this.getPanel(t);
-        if (!e) return;
-        const n = e.id;
-        e.id = "", this.changingHash = !0, window.location.hash = n, e.id = n
-    }
-    onTabKeydown(t) {
-        switch (t.key) {
+        n && (this.hideTab(n), this.showTab(t), t.focus())
+    }
+    hideTab(e) {
+        this.unhighlightTab(e), this.hidePanel(e)
+    }
+    showTab(e) {
+        this.highlightTab(e), this.showPanel(e)
+    }
+    getTab(e) {
+        return this.$module.querySelector(`a.govuk-tabs__tab[href="${e}"]`)
+    }
+    setAttributes(e) {
+        const t = getFragmentFromUrl(e.href);
+        if (!t) return;
+        e.setAttribute("id", `tab_${t}`), e.setAttribute("role", "tab"), e.setAttribute("aria-controls", t), e.setAttribute("aria-selected", "false"), e.setAttribute("tabindex", "-1");
+        const n = this.getPanel(e);
+        n && (n.setAttribute("role", "tabpanel"), n.setAttribute("aria-labelledby", e.id), n.classList.add(this.jsHiddenClass))
+    }
+    unsetAttributes(e) {
+        e.removeAttribute("id"), e.removeAttribute("role"), e.removeAttribute("aria-controls"), e.removeAttribute("aria-selected"), e.removeAttribute("tabindex");
+        const t = this.getPanel(e);
+        t && (t.removeAttribute("role"), t.removeAttribute("aria-labelledby"), t.classList.remove(this.jsHiddenClass))
+    }
+    onTabClick(e) {
+        const t = this.getCurrentTab(),
+            n = e.currentTarget;
+        t && n instanceof HTMLAnchorElement && (e.preventDefault(), this.hideTab(t), this.showTab(n), this.createHistoryEntry(n))
+    }
+    createHistoryEntry(e) {
+        const t = this.getPanel(e);
+        if (!t) return;
+        const n = t.id;
+        t.id = "", this.changingHash = !0, window.location.hash = n, t.id = n
+    }
+    onTabKeydown(e) {
+        switch (e.key) {
             case "ArrowLeft":
             case "ArrowUp":
             case "Left":
             case "Up":
-                this.activatePreviousTab(), t.preventDefault();
+                this.activatePreviousTab(), e.preventDefault();
                 break;
             case "ArrowRight":
             case "ArrowDown":
             case "Right":
             case "Down":
-                this.activateNextTab(), t.preventDefault()
+                this.activateNextTab(), e.preventDefault()
         }
     }
     activateNextTab() {
-        const t = this.getCurrentTab();
-        if (null == t || !t.parentElement) return;
-        const e = t.parentElement.nextElementSibling;
-        if (!e) return;
-        const n = e.querySelector("a.govuk-tabs__tab");
-        n && (this.hideTab(t), this.showTab(n), n.focus(), this.createHistoryEntry(n))
+        const e = this.getCurrentTab();
+        if (null == e || !e.parentElement) return;
+        const t = e.parentElement.nextElementSibling;
+        if (!t) return;
+        const n = t.querySelector("a.govuk-tabs__tab");
+        n && (this.hideTab(e), this.showTab(n), n.focus(), this.createHistoryEntry(n))
     }
     activatePreviousTab() {
-        const t = this.getCurrentTab();
-        if (null == t || !t.parentElement) return;
-        const e = t.parentElement.previousElementSibling;
-        if (!e) return;
-        const n = e.querySelector("a.govuk-tabs__tab");
-        n && (this.hideTab(t), this.showTab(n), n.focus(), this.createHistoryEntry(n))
+        const e = this.getCurrentTab();
+        if (null == e || !e.parentElement) return;
+        const t = e.parentElement.previousElementSibling;
+        if (!t) return;
+        const n = t.querySelector("a.govuk-tabs__tab");
+        n && (this.hideTab(e), this.showTab(n), n.focus(), this.createHistoryEntry(n))
     }
-    getPanel(t) {
-        const e = getFragmentFromUrl(t.href);
-        return e ? this.$module.querySelector(`#${e}`) : null
+    getPanel(e) {
+        const t = getFragmentFromUrl(e.href);
+        return t ? this.$module.querySelector(`#${t}`) : null
     }
-    showPanel(t) {
-        const e = this.getPanel(t);
-        e && e.classList.remove(this.jsHiddenClass)
+    showPanel(e) {
+        const t = this.getPanel(e);
+        t && t.classList.remove(this.jsHiddenClass)
     }
-    hidePanel(t) {
-        const e = this.getPanel(t);
-        e && e.classList.add(this.jsHiddenClass)
+    hidePanel(e) {
+        const t = this.getPanel(e);
+        t && t.classList.add(this.jsHiddenClass)
     }
-    unhighlightTab(t) {
-        t.parentElement && (t.setAttribute("aria-selected", "false"), t.parentElement.classList.remove("govuk-tabs__list-item--selected"), t.setAttribute("tabindex", "-1"))
+    unhighlightTab(e) {
+        e.parentElement && (e.setAttribute("aria-selected", "false"), e.parentElement.classList.remove("govuk-tabs__list-item--selected"), e.setAttribute("tabindex", "-1"))
     }
-    highlightTab(t) {
-        t.parentElement && (t.setAttribute("aria-selected", "true"), t.parentElement.classList.add("govuk-tabs__list-item--selected"), t.setAttribute("tabindex", "0"))
+    highlightTab(e) {
+        e.parentElement && (e.setAttribute("aria-selected", "true"), e.parentElement.classList.add("govuk-tabs__list-item--selected"), e.setAttribute("tabindex", "0"))
     }
     getCurrentTab() {
         return this.$module.querySelector(".govuk-tabs__list-item--selected a.govuk-tabs__tab")
     }
 }
 
-function initAll(t) {
-    var e;
-    if (t = void 0 !== t ? t : {}, !isSupported()) return void console.log(new SupportError);
+function initAll(e) {
+    var t;
+    if (e = void 0 !== e ? e : {}, !isSupported()) return void console.log(new SupportError);
     const n = [
-            [Accordion, t.accordion],
-            [Button, t.button],
-            [CharacterCount, t.characterCount],
+            [Accordion, e.accordion],
+            [Button, e.button],
+            [CharacterCount, e.characterCount],
             [Checkboxes],
-            [ErrorSummary, t.errorSummary],
-            [ExitThisPage, t.exitThisPage],
+            [ErrorSummary, e.errorSummary],
+            [ExitThisPage, e.exitThisPage],
             [Header],
-            [NotificationBanner, t.notificationBanner],
+            [NotificationBanner, e.notificationBanner],
             [Radios],
             [SkipLink],
             [Tabs]
         ],
-        i = null != (e = t.scope) ? e : document;
-    n.forEach((([t, e]) => {
-        i.querySelectorAll(`[data-module="${t.moduleName}"]`).forEach((n => {
+        i = null != (t = e.scope) ? t : document;
+    n.forEach((([e, t]) => {
+        i.querySelectorAll(`[data-module="${e.moduleName}"]`).forEach((n => {
             try {
-                "defaults" in t ? new t(n, e) : new t(n)
+                "defaults" in e ? new e(n, t) : new e(n)
             } catch (i) {
                 console.log(i)
             }

Action run for fd0af85

Copy link

github-actions bot commented Feb 23, 2024

Other changes to npm package

diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.js b/packages/govuk-frontend/dist/govuk/all.bundle.js
index 319a2fe70..dcd15bcf9 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.js
@@ -6,44 +6,75 @@
 
   const version = 'development';
 
+  function normaliseString(value, property) {
+    const trimmedValue = value ? value.trim() : '';
+    let output;
+    let outputType = property == null ? void 0 : property.type;
+    if (!outputType) {
+      if (['true', 'false'].includes(trimmedValue)) {
+        outputType = 'boolean';
+      }
+      if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+        outputType = 'number';
+      }
+    }
+    switch (outputType) {
+      case 'boolean':
+        output = trimmedValue === 'true';
+        break;
+      case 'number':
+        output = Number(trimmedValue);
+        break;
+      default:
+        output = value;
+    }
+    return output;
+  }
+
+  /**
+   * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+   */
+
   function mergeConfigs(...configObjects) {
-    function flattenObject(configObject) {
-      const flattenedObject = {};
-      function flattenLoop(obj, prefix) {
-        for (const [key, value] of Object.entries(obj)) {
-          const prefixedKey = prefix ? `${prefix}.${key}` : key;
-          if (value && typeof value === 'object') {
-            flattenLoop(value, prefixedKey);
-          } else {
-            flattenedObject[prefixedKey] = value;
-          }
-        }
-      }
-      flattenLoop(configObject);
-      return flattenedObject;
-    }
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
-      const obj = flattenObject(configObject);
-      for (const [key, value] of Object.entries(obj)) {
-        formattedConfigObject[key] = value;
+      for (const key of Object.keys(configObject)) {
+        const option = formattedConfigObject[key];
+        const override = configObject[key];
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
       }
     }
     return formattedConfigObject;
   }
-  function extractConfigByNamespace(configObject, namespace) {
-    const newObject = {};
-    for (const [key, value] of Object.entries(configObject)) {
+  function extractConfigByNamespace(Component, dataset, namespace) {
+    const property = Component.schema.properties[namespace];
+    if ((property == null ? void 0 : property.type) !== 'object') {
+      return;
+    }
+    const newObject = {
+      [namespace]: ({})
+    };
+    for (const [key, value] of Object.entries(dataset)) {
+      let current = newObject;
       const keyParts = key.split('.');
-      if (keyParts[0] === namespace) {
-        if (keyParts.length > 1) {
-          keyParts.shift();
+      for (const [index, name] of keyParts.entries()) {
+        if (typeof current === 'object') {
+          if (index < keyParts.length - 1) {
+            if (!isObject(current[name])) {
+              current[name] = {};
+            }
+            current = current[name];
+          } else if (key !== namespace) {
+            current[name] = normaliseString(value);
+          }
         }
-        const newKey = keyParts.join('.');
-        newObject[newKey] = value;
       }
     }
-    return newObject;
+    return newObject[namespace];
   }
   function getFragmentFromUrl(url) {
     if (!url.includes('#')) {
@@ -93,26 +124,46 @@
     const validationErrors = [];
     for (const [name, conditions] of Object.entries(schema)) {
       const errors = [];
-      for (const {
-        required,
-        errorMessage
-      } of conditions) {
-        if (!required.every(key => !!config[key])) {
-          errors.push(errorMessage);
+      if (Array.isArray(conditions)) {
+        for (const {
+          required,
+          errorMessage
+        } of conditions) {
+          if (!required.every(key => !!config[key])) {
+            errors.push(errorMessage);
+          }
+        }
+        if (name === 'allOf' && errors.length) {
+          validationErrors.push(...errors);
+        }
+        if (name === 'anyOf' && !(conditions.length - errors.length >= 1)) {
+          validationErrors.push(...errors);
         }
-      }
-      if (name === 'anyOf' && !(conditions.length - errors.length >= 1)) {
-        validationErrors.push(...errors);
       }
     }
     return validationErrors;
   }
+  function isArray(option) {
+    return Array.isArray(option);
+  }
+  function isObject(option) {
+    return !!option && typeof option === 'object' && !isArray(option);
+  }
 
   /**
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
@@ -123,26 +174,15 @@
    * @property {string} errorMessage - Error message when required config fields not provided
    */
 
-  function normaliseString(value) {
-    if (typeof value !== 'string') {
-      return value;
-    }
-    const trimmedValue = value.trim();
-    if (trimmedValue === 'true') {
-      return true;
-    }
-    if (trimmedValue === 'false') {
-      return false;
-    }
-    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-      return Number(trimmedValue);
-    }
-    return value;
-  }
-  function normaliseDataset(dataset) {
+  function normaliseDataset(Component, dataset) {
     const out = {};
-    for (const [key, value] of Object.entries(dataset)) {
-      out[key] = normaliseString(value);
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
     }
     return out;
   }
@@ -212,18 +252,21 @@
       if (!lookupKey) {
         throw new Error('i18n: lookup key missing');
       }
-      if (typeof (options == null ? void 0 : options.count) === 'number') {
-        lookupKey = `${lookupKey}.${this.getPluralSuffix(lookupKey, options.count)}`;
+      let translation = this.translations[lookupKey];
+      if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+        const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+        if (translationPluralForm) {
+          translation = translationPluralForm;
+        }
       }
-      const translationString = this.translations[lookupKey];
-      if (typeof translationString === 'string') {
-        if (translationString.match(/%{(.\S+)}/)) {
+      if (typeof translation === 'string') {
+        if (translation.match(/%{(.\S+)}/)) {
           if (!options) {
             throw new Error('i18n: cannot replace placeholders in string if no option data provided');
           }
-          return this.replacePlaceholders(translationString, options);
+          return this.replacePlaceholders(translation, options);
         }
-        return translationString;
+        return translation;
       }
       return lookupKey;
     }
@@ -251,12 +294,15 @@
       if (!isFinite(count)) {
         return 'other';
       }
+      const translation = this.translations[lookupKey];
       const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
-      if (`${lookupKey}.${preferredForm}` in this.translations) {
-        return preferredForm;
-      } else if (`${lookupKey}.other` in this.translations) {
-        console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
-        return 'other';
+      if (typeof translation === 'object') {
+        if (preferredForm in translation) {
+          return preferredForm;
+        } else if ('other' in translation) {
+          console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+          return 'other';
+        }
       }
       throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
     }
@@ -443,8 +489,8 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset($module.dataset));
-      this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'));
+      this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset(Accordion, $module.dataset));
+      this.i18n = new I18n(this.config.i18n);
       const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`);
       if (!$sections.length) {
         throw new ElementError({
@@ -680,6 +726,16 @@
     },
     rememberExpanded: true
   });
+  Accordion.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      },
+      rememberExpanded: {
+        type: 'boolean'
+      }
+    }
+  });
   const helper = {
     /**
      * Check for `window.sessionStorage`, and that it actually works.
@@ -733,6 +789,10 @@
    *   'Show' button's accessible name when a section is expanded.
    */
 
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   */
+
   const DEBOUNCE_TIMEOUT_IN_SECONDS = 1;
 
   /**
@@ -758,7 +818,7 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(Button.defaults, config, normaliseDataset($module.dataset));
+      this.config = mergeConfigs(Button.defaults, config, normaliseDataset(Button, $module.dataset));
       this.$module.addEventListener('keydown', event => this.handleKeyDown(event));
       this.$module.addEventListener('click', event => this.debounce(event));
     }
@@ -793,10 +853,21 @@
    * @property {boolean} [preventDoubleClick=false] - Prevent accidental double
    *   clicks on submit buttons from submitting forms multiple times.
    */
+
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   */
   Button.moduleName = 'govuk-button';
   Button.defaults = Object.freeze({
     preventDoubleClick: false
   });
+  Button.schema = Object.freeze({
+    properties: {
+      preventDoubleClick: {
+        type: 'boolean'
+      }
+    }
+  });
 
   function closestAttributeValue($element, attributeName) {
     const $closestElementWithAttribute = $element.closest(`[${attributeName}]`);
@@ -849,7 +920,7 @@
           identifier: 'Form field (`.govuk-js-character-count`)'
         });
       }
-      const datasetConfig = normaliseDataset($module.dataset);
+      const datasetConfig = normaliseDataset(CharacterCount, $module.dataset);
       let configOverrides = {};
       if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
         configOverrides = {
@@ -862,7 +933,7 @@
       if (errors[0]) {
         throw new ConfigError(`Character count: ${errors[0]}`);
       }
-      this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'), {
+      this.i18n = new I18n(this.config.i18n, {
         locale: closestAttributeValue($module, 'lang')
       });
       this.maxLength = (_ref = (_this$config$maxwords = this.config.maxwords) != null ? _this$config$maxwords : this.config.maxlength) != null ? _ref : Infinity;
@@ -1075,6 +1146,20 @@
     }
   });
   CharacterCount.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      },
+      maxwords: {
+        type: 'number'
+      },
+      maxlength: {
+        type: 'number'
+      },
+      threshold: {
+        type: 'number'
+      }
+    },
     anyOf: [{
       required: ['maxwords'],
       errorMessage: 'Either "maxlength" or "maxwords" must be provided'
@@ -1224,7 +1309,7 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset($module.dataset));
+      this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset(ErrorSummary, $module.dataset));
       if (!this.config.disableAutoFocus) {
         setFocus(this.$module);
       }
@@ -1289,10 +1374,21 @@
    * @property {boolean} [disableAutoFocus=false] - If set to `true` the error
    *   summary will not be focussed when the page loads.
    */
+
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   */
   ErrorSummary.moduleName = 'govuk-error-summary';
   ErrorSummary.defaults = Object.freeze({
     disableAutoFocus: false
   });
+  ErrorSummary.schema = Object.freeze({
+    properties: {
+      disableAutoFocus: {
+        type: 'boolean'
+      }
+    }
+  });
 
   /**
    * Exit this page component
@@ -1335,8 +1431,8 @@
           identifier: 'Button (`.govuk-exit-this-page__button`)'
         });
       }
-      this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset($module.dataset));
-      this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'));
+      this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset(ExitThisPage, $module.dataset));
+      this.i18n = new I18n(this.config.i18n);
       this.$module = $module;
       this.$button = $button;
       const $skiplinkButton = document.querySelector('.govuk-js-exit-this-page-skiplink');
@@ -1500,6 +1596,10 @@
    * @property {string} [pressOneMoreTime] - Screen reader announcement informing
    *   the user they must press the activation key one more time.
    */
+
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   */
   ExitThisPage.moduleName = 'govuk-exit-this-page';
   ExitThisPage.defaults = Object.freeze({
     i18n: {
@@ -1509,6 +1609,13 @@
       pressOneMoreTime: 'Shift, press 1 more time to exit.'
     }
   });
+  ExitThisPage.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      }
+    }
+  });
 
   /**
    * Header component
@@ -1623,7 +1730,7 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset($module.dataset));
+      this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset(NotificationBanner, $module.dataset));
       if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
         setFocus(this.$module);
       }
@@ -1639,10 +1746,21 @@
    *   applies if the component has a `role` of `alert` – in other cases the
    *   component will not be focused on page load, regardless of this option.
    */
+
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   */
   NotificationBanner.moduleName = 'govuk-notification-banner';
   NotificationBanner.defaults = Object.freeze({
     disableAutoFocus: false
   });
+  NotificationBanner.schema = Object.freeze({
+    properties: {
+      disableAutoFocus: {
+        type: 'boolean'
+      }
+    }
+  });
 
   /**
    * Radios component
diff --git a/packages/govuk-frontend/dist/govuk/all.bundle.mjs b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
index 1e8dbc738..025aaae7c 100644
--- a/packages/govuk-frontend/dist/govuk/all.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/all.bundle.mjs
@@ -1,43 +1,74 @@
 const version = 'development';
 
+function normaliseString(value, property) {
+  const trimmedValue = value ? value.trim() : '';
+  let output;
+  let outputType = property == null ? void 0 : property.type;
+  if (!outputType) {
+    if (['true', 'false'].includes(trimmedValue)) {
+      outputType = 'boolean';
+    }
+    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+      outputType = 'number';
+    }
+  }
+  switch (outputType) {
+    case 'boolean':
+      output = trimmedValue === 'true';
+      break;
+    case 'number':
+      output = Number(trimmedValue);
+      break;
+    default:
+      output = value;
+  }
+  return output;
+}
+
+/**
+ * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+ */
+
 function mergeConfigs(...configObjects) {
-  function flattenObject(configObject) {
-    const flattenedObject = {};
-    function flattenLoop(obj, prefix) {
-      for (const [key, value] of Object.entries(obj)) {
-        const prefixedKey = prefix ? `${prefix}.${key}` : key;
-        if (value && typeof value === 'object') {
-          flattenLoop(value, prefixedKey);
-        } else {
-          flattenedObject[prefixedKey] = value;
-        }
-      }
-    }
-    flattenLoop(configObject);
-    return flattenedObject;
-  }
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
-    const obj = flattenObject(configObject);
-    for (const [key, value] of Object.entries(obj)) {
-      formattedConfigObject[key] = value;
+    for (const key of Object.keys(configObject)) {
+      const option = formattedConfigObject[key];
+      const override = configObject[key];
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs(option, override);
+      } else {
+        formattedConfigObject[key] = override;
+      }
     }
   }
   return formattedConfigObject;
 }
-function extractConfigByNamespace(configObject, namespace) {
-  const newObject = {};
-  for (const [key, value] of Object.entries(configObject)) {
+function extractConfigByNamespace(Component, dataset, namespace) {
+  const property = Component.schema.properties[namespace];
+  if ((property == null ? void 0 : property.type) !== 'object') {
+    return;
+  }
+  const newObject = {
+    [namespace]: ({})
+  };
+  for (const [key, value] of Object.entries(dataset)) {
+    let current = newObject;
     const keyParts = key.split('.');
-    if (keyParts[0] === namespace) {
-      if (keyParts.length > 1) {
-        keyParts.shift();
+    for (const [index, name] of keyParts.entries()) {
+      if (typeof current === 'object') {
+        if (index < keyParts.length - 1) {
+          if (!isObject(current[name])) {
+            current[name] = {};
+          }
+          current = current[name];
+        } else if (key !== namespace) {
+          current[name] = normaliseString(value);
+        }
       }
-      const newKey = keyParts.join('.');
-      newObject[newKey] = value;
     }
   }
-  return newObject;
+  return newObject[namespace];
 }
 function getFragmentFromUrl(url) {
   if (!url.includes('#')) {
@@ -87,26 +118,46 @@ function validateConfig(schema, config) {
   const validationErrors = [];
   for (const [name, conditions] of Object.entries(schema)) {
     const errors = [];
-    for (const {
-      required,
-      errorMessage
-    } of conditions) {
-      if (!required.every(key => !!config[key])) {
-        errors.push(errorMessage);
+    if (Array.isArray(conditions)) {
+      for (const {
+        required,
+        errorMessage
+      } of conditions) {
+        if (!required.every(key => !!config[key])) {
+          errors.push(errorMessage);
+        }
+      }
+      if (name === 'allOf' && errors.length) {
+        validationErrors.push(...errors);
+      }
+      if (name === 'anyOf' && !(conditions.length - errors.length >= 1)) {
+        validationErrors.push(...errors);
       }
-    }
-    if (name === 'anyOf' && !(conditions.length - errors.length >= 1)) {
-      validationErrors.push(...errors);
     }
   }
   return validationErrors;
 }
+function isArray(option) {
+  return Array.isArray(option);
+}
+function isObject(option) {
+  return !!option && typeof option === 'object' && !isArray(option);
+}
 
 /**
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
@@ -117,26 +168,15 @@ function validateConfig(schema, config) {
  * @property {string} errorMessage - Error message when required config fields not provided
  */
 
-function normaliseString(value) {
-  if (typeof value !== 'string') {
-    return value;
-  }
-  const trimmedValue = value.trim();
-  if (trimmedValue === 'true') {
-    return true;
-  }
-  if (trimmedValue === 'false') {
-    return false;
-  }
-  if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-    return Number(trimmedValue);
-  }
-  return value;
-}
-function normaliseDataset(dataset) {
+function normaliseDataset(Component, dataset) {
   const out = {};
-  for (const [key, value] of Object.entries(dataset)) {
-    out[key] = normaliseString(value);
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
   }
   return out;
 }
@@ -206,18 +246,21 @@ class I18n {
     if (!lookupKey) {
       throw new Error('i18n: lookup key missing');
     }
-    if (typeof (options == null ? void 0 : options.count) === 'number') {
-      lookupKey = `${lookupKey}.${this.getPluralSuffix(lookupKey, options.count)}`;
+    let translation = this.translations[lookupKey];
+    if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+      const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+      if (translationPluralForm) {
+        translation = translationPluralForm;
+      }
     }
-    const translationString = this.translations[lookupKey];
-    if (typeof translationString === 'string') {
-      if (translationString.match(/%{(.\S+)}/)) {
+    if (typeof translation === 'string') {
+      if (translation.match(/%{(.\S+)}/)) {
         if (!options) {
           throw new Error('i18n: cannot replace placeholders in string if no option data provided');
         }
-        return this.replacePlaceholders(translationString, options);
+        return this.replacePlaceholders(translation, options);
       }
-      return translationString;
+      return translation;
     }
     return lookupKey;
   }
@@ -245,12 +288,15 @@ class I18n {
     if (!isFinite(count)) {
       return 'other';
     }
+    const translation = this.translations[lookupKey];
     const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
-    if (`${lookupKey}.${preferredForm}` in this.translations) {
-      return preferredForm;
-    } else if (`${lookupKey}.other` in this.translations) {
-      console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
-      return 'other';
+    if (typeof translation === 'object') {
+      if (preferredForm in translation) {
+        return preferredForm;
+      } else if ('other' in translation) {
+        console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+        return 'other';
+      }
     }
     throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
   }
@@ -437,8 +483,8 @@ class Accordion extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset($module.dataset));
-    this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'));
+    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset(Accordion, $module.dataset));
+    this.i18n = new I18n(this.config.i18n);
     const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`);
     if (!$sections.length) {
       throw new ElementError({
@@ -674,6 +720,16 @@ Accordion.defaults = Object.freeze({
   },
   rememberExpanded: true
 });
+Accordion.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    },
+    rememberExpanded: {
+      type: 'boolean'
+    }
+  }
+});
 const helper = {
   /**
    * Check for `window.sessionStorage`, and that it actually works.
@@ -727,6 +783,10 @@ const helper = {
  *   'Show' button's accessible name when a section is expanded.
  */
 
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
+
 const DEBOUNCE_TIMEOUT_IN_SECONDS = 1;
 
 /**
@@ -752,7 +812,7 @@ class Button extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Button.defaults, config, normaliseDataset($module.dataset));
+    this.config = mergeConfigs(Button.defaults, config, normaliseDataset(Button, $module.dataset));
     this.$module.addEventListener('keydown', event => this.handleKeyDown(event));
     this.$module.addEventListener('click', event => this.debounce(event));
   }
@@ -787,10 +847,21 @@ class Button extends GOVUKFrontendComponent {
  * @property {boolean} [preventDoubleClick=false] - Prevent accidental double
  *   clicks on submit buttons from submitting forms multiple times.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 Button.moduleName = 'govuk-button';
 Button.defaults = Object.freeze({
   preventDoubleClick: false
 });
+Button.schema = Object.freeze({
+  properties: {
+    preventDoubleClick: {
+      type: 'boolean'
+    }
+  }
+});
 
 function closestAttributeValue($element, attributeName) {
   const $closestElementWithAttribute = $element.closest(`[${attributeName}]`);
@@ -843,7 +914,7 @@ class CharacterCount extends GOVUKFrontendComponent {
         identifier: 'Form field (`.govuk-js-character-count`)'
       });
     }
-    const datasetConfig = normaliseDataset($module.dataset);
+    const datasetConfig = normaliseDataset(CharacterCount, $module.dataset);
     let configOverrides = {};
     if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
       configOverrides = {
@@ -856,7 +927,7 @@ class CharacterCount extends GOVUKFrontendComponent {
     if (errors[0]) {
       throw new ConfigError(`Character count: ${errors[0]}`);
     }
-    this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'), {
+    this.i18n = new I18n(this.config.i18n, {
       locale: closestAttributeValue($module, 'lang')
     });
     this.maxLength = (_ref = (_this$config$maxwords = this.config.maxwords) != null ? _this$config$maxwords : this.config.maxlength) != null ? _ref : Infinity;
@@ -1069,6 +1140,20 @@ CharacterCount.defaults = Object.freeze({
   }
 });
 CharacterCount.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    },
+    maxwords: {
+      type: 'number'
+    },
+    maxlength: {
+      type: 'number'
+    },
+    threshold: {
+      type: 'number'
+    }
+  },
   anyOf: [{
     required: ['maxwords'],
     errorMessage: 'Either "maxlength" or "maxwords" must be provided'
@@ -1218,7 +1303,7 @@ class ErrorSummary extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset($module.dataset));
+    this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset(ErrorSummary, $module.dataset));
     if (!this.config.disableAutoFocus) {
       setFocus(this.$module);
     }
@@ -1283,10 +1368,21 @@ class ErrorSummary extends GOVUKFrontendComponent {
  * @property {boolean} [disableAutoFocus=false] - If set to `true` the error
  *   summary will not be focussed when the page loads.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 ErrorSummary.moduleName = 'govuk-error-summary';
 ErrorSummary.defaults = Object.freeze({
   disableAutoFocus: false
 });
+ErrorSummary.schema = Object.freeze({
+  properties: {
+    disableAutoFocus: {
+      type: 'boolean'
+    }
+  }
+});
 
 /**
  * Exit this page component
@@ -1329,8 +1425,8 @@ class ExitThisPage extends GOVUKFrontendComponent {
         identifier: 'Button (`.govuk-exit-this-page__button`)'
       });
     }
-    this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset($module.dataset));
-    this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'));
+    this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset(ExitThisPage, $module.dataset));
+    this.i18n = new I18n(this.config.i18n);
     this.$module = $module;
     this.$button = $button;
     const $skiplinkButton = document.querySelector('.govuk-js-exit-this-page-skiplink');
@@ -1494,6 +1590,10 @@ class ExitThisPage extends GOVUKFrontendComponent {
  * @property {string} [pressOneMoreTime] - Screen reader announcement informing
  *   the user they must press the activation key one more time.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 ExitThisPage.moduleName = 'govuk-exit-this-page';
 ExitThisPage.defaults = Object.freeze({
   i18n: {
@@ -1503,6 +1603,13 @@ ExitThisPage.defaults = Object.freeze({
     pressOneMoreTime: 'Shift, press 1 more time to exit.'
   }
 });
+ExitThisPage.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
 
 /**
  * Header component
@@ -1617,7 +1724,7 @@ class NotificationBanner extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset($module.dataset));
+    this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset(NotificationBanner, $module.dataset));
     if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
       setFocus(this.$module);
     }
@@ -1633,10 +1740,21 @@ class NotificationBanner extends GOVUKFrontendComponent {
  *   applies if the component has a `role` of `alert` – in other cases the
  *   component will not be focused on page load, regardless of this option.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 NotificationBanner.moduleName = 'govuk-notification-banner';
 NotificationBanner.defaults = Object.freeze({
   disableAutoFocus: false
 });
+NotificationBanner.schema = Object.freeze({
+  properties: {
+    disableAutoFocus: {
+      type: 'boolean'
+    }
+  }
+});
 
 /**
  * Radios component
diff --git a/packages/govuk-frontend/dist/govuk/common/index.mjs b/packages/govuk-frontend/dist/govuk/common/index.mjs
index 1d272c1ba..933785466 100644
--- a/packages/govuk-frontend/dist/govuk/common/index.mjs
+++ b/packages/govuk-frontend/dist/govuk/common/index.mjs
@@ -1,41 +1,45 @@
+import { normaliseString } from './normalise-string.mjs';
+
 function mergeConfigs(...configObjects) {
-  function flattenObject(configObject) {
-    const flattenedObject = {};
-    function flattenLoop(obj, prefix) {
-      for (const [key, value] of Object.entries(obj)) {
-        const prefixedKey = prefix ? `${prefix}.${key}` : key;
-        if (value && typeof value === 'object') {
-          flattenLoop(value, prefixedKey);
-        } else {
-          flattenedObject[prefixedKey] = value;
-        }
-      }
-    }
-    flattenLoop(configObject);
-    return flattenedObject;
-  }
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
-    const obj = flattenObject(configObject);
-    for (const [key, value] of Object.entries(obj)) {
-      formattedConfigObject[key] = value;
+    for (const key of Object.keys(configObject)) {
+      const option = formattedConfigObject[key];
+      const override = configObject[key];
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs(option, override);
+      } else {
+        formattedConfigObject[key] = override;
+      }
     }
   }
   return formattedConfigObject;
 }
-function extractConfigByNamespace(configObject, namespace) {
-  const newObject = {};
-  for (const [key, value] of Object.entries(configObject)) {
+function extractConfigByNamespace(Component, dataset, namespace) {
+  const property = Component.schema.properties[namespace];
+  if ((property == null ? void 0 : property.type) !== 'object') {
+    return;
+  }
+  const newObject = {
+    [namespace]: ({})
+  };
+  for (const [key, value] of Object.entries(dataset)) {
+    let current = newObject;
     const keyParts = key.split('.');
-    if (keyParts[0] === namespace) {
-      if (keyParts.length > 1) {
-        keyParts.shift();
+    for (const [index, name] of keyParts.entries()) {
+      if (typeof current === 'object') {
+        if (index < keyParts.length - 1) {
+          if (!isObject(current[name])) {
+            current[name] = {};
+          }
+          current = current[name];
+        } else if (key !== namespace) {
+          current[name] = normaliseString(value);
+        }
       }
-      const newKey = keyParts.join('.');
-      newObject[newKey] = value;
     }
   }
-  return newObject;
+  return newObject[namespace];
 }
 function getFragmentFromUrl(url) {
   if (!url.includes('#')) {
@@ -85,26 +89,46 @@ function validateConfig(schema, config) {
   const validationErrors = [];
   for (const [name, conditions] of Object.entries(schema)) {
     const errors = [];
-    for (const {
-      required,
-      errorMessage
-    } of conditions) {
-      if (!required.every(key => !!config[key])) {
-        errors.push(errorMessage);
+    if (Array.isArray(conditions)) {
+      for (const {
+        required,
+        errorMessage
+      } of conditions) {
+        if (!required.every(key => !!config[key])) {
+          errors.push(errorMessage);
+        }
+      }
+      if (name === 'allOf' && errors.length) {
+        validationErrors.push(...errors);
+      }
+      if (name === 'anyOf' && !(conditions.length - errors.length >= 1)) {
+        validationErrors.push(...errors);
       }
-    }
-    if (name === 'anyOf' && !(conditions.length - errors.length >= 1)) {
-      validationErrors.push(...errors);
     }
   }
   return validationErrors;
 }
+function isArray(option) {
+  return Array.isArray(option);
+}
+function isObject(option) {
+  return !!option && typeof option === 'object' && !isArray(option);
+}
 
 /**
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
diff --git a/packages/govuk-frontend/dist/govuk/common/normalise-dataset.mjs b/packages/govuk-frontend/dist/govuk/common/normalise-dataset.mjs
index f75acc88d..8ca07860e 100644
--- a/packages/govuk-frontend/dist/govuk/common/normalise-dataset.mjs
+++ b/packages/govuk-frontend/dist/govuk/common/normalise-dataset.mjs
@@ -1,26 +1,18 @@
-function normaliseString(value) {
-  if (typeof value !== 'string') {
-    return value;
-  }
-  const trimmedValue = value.trim();
-  if (trimmedValue === 'true') {
-    return true;
-  }
-  if (trimmedValue === 'false') {
-    return false;
-  }
-  if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-    return Number(trimmedValue);
-  }
-  return value;
-}
-function normaliseDataset(dataset) {
+import { extractConfigByNamespace } from './index.mjs';
+import { normaliseString } from './normalise-string.mjs';
+
+function normaliseDataset(Component, dataset) {
   const out = {};
-  for (const [key, value] of Object.entries(dataset)) {
-    out[key] = normaliseString(value);
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
   }
   return out;
 }
 
-export { normaliseDataset, normaliseString };
+export { normaliseDataset };
 //# sourceMappingURL=normalise-dataset.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/common/normalise-string.mjs b/packages/govuk-frontend/dist/govuk/common/normalise-string.mjs
new file mode 100644
index 000000000..e0891c04e
--- /dev/null
+++ b/packages/govuk-frontend/dist/govuk/common/normalise-string.mjs
@@ -0,0 +1,31 @@
+function normaliseString(value, property) {
+  const trimmedValue = value ? value.trim() : '';
+  let output;
+  let outputType = property == null ? void 0 : property.type;
+  if (!outputType) {
+    if (['true', 'false'].includes(trimmedValue)) {
+      outputType = 'boolean';
+    }
+    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+      outputType = 'number';
+    }
+  }
+  switch (outputType) {
+    case 'boolean':
+      output = trimmedValue === 'true';
+      break;
+    case 'number':
+      output = Number(trimmedValue);
+      break;
+    default:
+      output = value;
+  }
+  return output;
+}
+
+/**
+ * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+ */
+
+export { normaliseString };
+//# sourceMappingURL=normalise-string.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js
index d5dca973f..eaa2ccf7e 100644
--- a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.js
@@ -4,44 +4,75 @@
   (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GOVUKFrontend = {}));
 })(this, (function (exports) { 'use strict';
 
+  function normaliseString(value, property) {
+    const trimmedValue = value ? value.trim() : '';
+    let output;
+    let outputType = property == null ? void 0 : property.type;
+    if (!outputType) {
+      if (['true', 'false'].includes(trimmedValue)) {
+        outputType = 'boolean';
+      }
+      if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+        outputType = 'number';
+      }
+    }
+    switch (outputType) {
+      case 'boolean':
+        output = trimmedValue === 'true';
+        break;
+      case 'number':
+        output = Number(trimmedValue);
+        break;
+      default:
+        output = value;
+    }
+    return output;
+  }
+
+  /**
+   * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+   */
+
   function mergeConfigs(...configObjects) {
-    function flattenObject(configObject) {
-      const flattenedObject = {};
-      function flattenLoop(obj, prefix) {
-        for (const [key, value] of Object.entries(obj)) {
-          const prefixedKey = prefix ? `${prefix}.${key}` : key;
-          if (value && typeof value === 'object') {
-            flattenLoop(value, prefixedKey);
-          } else {
-            flattenedObject[prefixedKey] = value;
-          }
-        }
-      }
-      flattenLoop(configObject);
-      return flattenedObject;
-    }
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
-      const obj = flattenObject(configObject);
-      for (const [key, value] of Object.entries(obj)) {
-        formattedConfigObject[key] = value;
+      for (const key of Object.keys(configObject)) {
+        const option = formattedConfigObject[key];
+        const override = configObject[key];
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
       }
     }
     return formattedConfigObject;
   }
-  function extractConfigByNamespace(configObject, namespace) {
-    const newObject = {};
-    for (const [key, value] of Object.entries(configObject)) {
+  function extractConfigByNamespace(Component, dataset, namespace) {
+    const property = Component.schema.properties[namespace];
+    if ((property == null ? void 0 : property.type) !== 'object') {
+      return;
+    }
+    const newObject = {
+      [namespace]: ({})
+    };
+    for (const [key, value] of Object.entries(dataset)) {
+      let current = newObject;
       const keyParts = key.split('.');
-      if (keyParts[0] === namespace) {
-        if (keyParts.length > 1) {
-          keyParts.shift();
+      for (const [index, name] of keyParts.entries()) {
+        if (typeof current === 'object') {
+          if (index < keyParts.length - 1) {
+            if (!isObject(current[name])) {
+              current[name] = {};
+            }
+            current = current[name];
+          } else if (key !== namespace) {
+            current[name] = normaliseString(value);
+          }
         }
-        const newKey = keyParts.join('.');
-        newObject[newKey] = value;
       }
     }
-    return newObject;
+    return newObject[namespace];
   }
   function isSupported($scope = document.body) {
     if (!$scope) {
@@ -49,12 +80,27 @@
     }
     return $scope.classList.contains('govuk-frontend-supported');
   }
+  function isArray(option) {
+    return Array.isArray(option);
+  }
+  function isObject(option) {
+    return !!option && typeof option === 'object' && !isArray(option);
+  }
 
   /**
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
@@ -65,26 +111,15 @@
    * @property {string} errorMessage - Error message when required config fields not provided
    */
 
-  function normaliseString(value) {
-    if (typeof value !== 'string') {
-      return value;
-    }
-    const trimmedValue = value.trim();
-    if (trimmedValue === 'true') {
-      return true;
-    }
-    if (trimmedValue === 'false') {
-      return false;
-    }
-    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-      return Number(trimmedValue);
-    }
-    return value;
-  }
-  function normaliseDataset(dataset) {
+  function normaliseDataset(Component, dataset) {
     const out = {};
-    for (const [key, value] of Object.entries(dataset)) {
-      out[key] = normaliseString(value);
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
     }
     return out;
   }
@@ -148,18 +183,21 @@
       if (!lookupKey) {
         throw new Error('i18n: lookup key missing');
       }
-      if (typeof (options == null ? void 0 : options.count) === 'number') {
-        lookupKey = `${lookupKey}.${this.getPluralSuffix(lookupKey, options.count)}`;
+      let translation = this.translations[lookupKey];
+      if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+        const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+        if (translationPluralForm) {
+          translation = translationPluralForm;
+        }
       }
-      const translationString = this.translations[lookupKey];
-      if (typeof translationString === 'string') {
-        if (translationString.match(/%{(.\S+)}/)) {
+      if (typeof translation === 'string') {
+        if (translation.match(/%{(.\S+)}/)) {
           if (!options) {
             throw new Error('i18n: cannot replace placeholders in string if no option data provided');
           }
-          return this.replacePlaceholders(translationString, options);
+          return this.replacePlaceholders(translation, options);
         }
-        return translationString;
+        return translation;
       }
       return lookupKey;
     }
@@ -187,12 +225,15 @@
       if (!isFinite(count)) {
         return 'other';
       }
+      const translation = this.translations[lookupKey];
       const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
-      if (`${lookupKey}.${preferredForm}` in this.translations) {
-        return preferredForm;
-      } else if (`${lookupKey}.other` in this.translations) {
-        console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
-        return 'other';
+      if (typeof translation === 'object') {
+        if (preferredForm in translation) {
+          return preferredForm;
+        } else if ('other' in translation) {
+          console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+          return 'other';
+        }
       }
       throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
     }
@@ -379,8 +420,8 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset($module.dataset));
-      this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'));
+      this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset(Accordion, $module.dataset));
+      this.i18n = new I18n(this.config.i18n);
       const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`);
       if (!$sections.length) {
         throw new ElementError({
@@ -616,6 +657,16 @@
     },
     rememberExpanded: true
   });
+  Accordion.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      },
+      rememberExpanded: {
+        type: 'boolean'
+      }
+    }
+  });
   const helper = {
     /**
      * Check for `window.sessionStorage`, and that it actually works.
@@ -669,6 +720,10 @@
    *   'Show' button's accessible name when a section is expanded.
    */
 
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   */
+
   exports.Accordion = Accordion;
 
 }));
diff --git a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs
index eecebe24c..15dafe282 100644
--- a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.bundle.mjs
@@ -1,41 +1,72 @@
+function normaliseString(value, property) {
+  const trimmedValue = value ? value.trim() : '';
+  let output;
+  let outputType = property == null ? void 0 : property.type;
+  if (!outputType) {
+    if (['true', 'false'].includes(trimmedValue)) {
+      outputType = 'boolean';
+    }
+    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+      outputType = 'number';
+    }
+  }
+  switch (outputType) {
+    case 'boolean':
+      output = trimmedValue === 'true';
+      break;
+    case 'number':
+      output = Number(trimmedValue);
+      break;
+    default:
+      output = value;
+  }
+  return output;
+}
+
+/**
+ * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+ */
+
 function mergeConfigs(...configObjects) {
-  function flattenObject(configObject) {
-    const flattenedObject = {};
-    function flattenLoop(obj, prefix) {
-      for (const [key, value] of Object.entries(obj)) {
-        const prefixedKey = prefix ? `${prefix}.${key}` : key;
-        if (value && typeof value === 'object') {
-          flattenLoop(value, prefixedKey);
-        } else {
-          flattenedObject[prefixedKey] = value;
-        }
-      }
-    }
-    flattenLoop(configObject);
-    return flattenedObject;
-  }
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
-    const obj = flattenObject(configObject);
-    for (const [key, value] of Object.entries(obj)) {
-      formattedConfigObject[key] = value;
+    for (const key of Object.keys(configObject)) {
+      const option = formattedConfigObject[key];
+      const override = configObject[key];
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs(option, override);
+      } else {
+        formattedConfigObject[key] = override;
+      }
     }
   }
   return formattedConfigObject;
 }
-function extractConfigByNamespace(configObject, namespace) {
-  const newObject = {};
-  for (const [key, value] of Object.entries(configObject)) {
+function extractConfigByNamespace(Component, dataset, namespace) {
+  const property = Component.schema.properties[namespace];
+  if ((property == null ? void 0 : property.type) !== 'object') {
+    return;
+  }
+  const newObject = {
+    [namespace]: ({})
+  };
+  for (const [key, value] of Object.entries(dataset)) {
+    let current = newObject;
     const keyParts = key.split('.');
-    if (keyParts[0] === namespace) {
-      if (keyParts.length > 1) {
-        keyParts.shift();
+    for (const [index, name] of keyParts.entries()) {
+      if (typeof current === 'object') {
+        if (index < keyParts.length - 1) {
+          if (!isObject(current[name])) {
+            current[name] = {};
+          }
+          current = current[name];
+        } else if (key !== namespace) {
+          current[name] = normaliseString(value);
+        }
       }
-      const newKey = keyParts.join('.');
-      newObject[newKey] = value;
     }
   }
-  return newObject;
+  return newObject[namespace];
 }
 function isSupported($scope = document.body) {
   if (!$scope) {
@@ -43,12 +74,27 @@ function isSupported($scope = document.body) {
   }
   return $scope.classList.contains('govuk-frontend-supported');
 }
+function isArray(option) {
+  return Array.isArray(option);
+}
+function isObject(option) {
+  return !!option && typeof option === 'object' && !isArray(option);
+}
 
 /**
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
@@ -59,26 +105,15 @@ function isSupported($scope = document.body) {
  * @property {string} errorMessage - Error message when required config fields not provided
  */
 
-function normaliseString(value) {
-  if (typeof value !== 'string') {
-    return value;
-  }
-  const trimmedValue = value.trim();
-  if (trimmedValue === 'true') {
-    return true;
-  }
-  if (trimmedValue === 'false') {
-    return false;
-  }
-  if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-    return Number(trimmedValue);
-  }
-  return value;
-}
-function normaliseDataset(dataset) {
+function normaliseDataset(Component, dataset) {
   const out = {};
-  for (const [key, value] of Object.entries(dataset)) {
-    out[key] = normaliseString(value);
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
   }
   return out;
 }
@@ -142,18 +177,21 @@ class I18n {
     if (!lookupKey) {
       throw new Error('i18n: lookup key missing');
     }
-    if (typeof (options == null ? void 0 : options.count) === 'number') {
-      lookupKey = `${lookupKey}.${this.getPluralSuffix(lookupKey, options.count)}`;
+    let translation = this.translations[lookupKey];
+    if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+      const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+      if (translationPluralForm) {
+        translation = translationPluralForm;
+      }
     }
-    const translationString = this.translations[lookupKey];
-    if (typeof translationString === 'string') {
-      if (translationString.match(/%{(.\S+)}/)) {
+    if (typeof translation === 'string') {
+      if (translation.match(/%{(.\S+)}/)) {
         if (!options) {
           throw new Error('i18n: cannot replace placeholders in string if no option data provided');
         }
-        return this.replacePlaceholders(translationString, options);
+        return this.replacePlaceholders(translation, options);
       }
-      return translationString;
+      return translation;
     }
     return lookupKey;
   }
@@ -181,12 +219,15 @@ class I18n {
     if (!isFinite(count)) {
       return 'other';
     }
+    const translation = this.translations[lookupKey];
     const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
-    if (`${lookupKey}.${preferredForm}` in this.translations) {
-      return preferredForm;
-    } else if (`${lookupKey}.other` in this.translations) {
-      console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
-      return 'other';
+    if (typeof translation === 'object') {
+      if (preferredForm in translation) {
+        return preferredForm;
+      } else if ('other' in translation) {
+        console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+        return 'other';
+      }
     }
     throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
   }
@@ -373,8 +414,8 @@ class Accordion extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset($module.dataset));
-    this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'));
+    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset(Accordion, $module.dataset));
+    this.i18n = new I18n(this.config.i18n);
     const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`);
     if (!$sections.length) {
       throw new ElementError({
@@ -610,6 +651,16 @@ Accordion.defaults = Object.freeze({
   },
   rememberExpanded: true
 });
+Accordion.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    },
+    rememberExpanded: {
+      type: 'boolean'
+    }
+  }
+});
 const helper = {
   /**
    * Check for `window.sessionStorage`, and that it actually works.
@@ -663,5 +714,9 @@ const helper = {
  *   'Show' button's accessible name when a section is expanded.
  */
 
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
+
 export { Accordion };
 //# sourceMappingURL=accordion.bundle.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs
index 1ccea5293..1c57601ca 100644
--- a/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/accordion/accordion.mjs
@@ -1,4 +1,4 @@
-import { mergeConfigs, extractConfigByNamespace } from '../../common/index.mjs';
+import { mergeConfigs } from '../../common/index.mjs';
 import { normaliseDataset } from '../../common/normalise-dataset.mjs';
 import { ElementError } from '../../errors/index.mjs';
 import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
@@ -60,8 +60,8 @@ class Accordion extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset($module.dataset));
-    this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'));
+    this.config = mergeConfigs(Accordion.defaults, config, normaliseDataset(Accordion, $module.dataset));
+    this.i18n = new I18n(this.config.i18n);
     const $sections = this.$module.querySelectorAll(`.${this.sectionClass}`);
     if (!$sections.length) {
       throw new ElementError({
@@ -297,6 +297,16 @@ Accordion.defaults = Object.freeze({
   },
   rememberExpanded: true
 });
+Accordion.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    },
+    rememberExpanded: {
+      type: 'boolean'
+    }
+  }
+});
 const helper = {
   /**
    * Check for `window.sessionStorage`, and that it actually works.
@@ -350,5 +360,9 @@ const helper = {
  *   'Show' button's accessible name when a section is expanded.
  */
 
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
+
 export { Accordion };
 //# sourceMappingURL=accordion.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js
index e8db70624..4e6878da5 100644
--- a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js
@@ -4,43 +4,103 @@
   (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GOVUKFrontend = {}));
 })(this, (function (exports) { 'use strict';
 
-  function mergeConfigs(...configObjects) {
-    function flattenObject(configObject) {
-      const flattenedObject = {};
-      function flattenLoop(obj, prefix) {
-        for (const [key, value] of Object.entries(obj)) {
-          const prefixedKey = prefix ? `${prefix}.${key}` : key;
-          if (value && typeof value === 'object') {
-            flattenLoop(value, prefixedKey);
-          } else {
-            flattenedObject[prefixedKey] = value;
-          }
-        }
+  function normaliseString(value, property) {
+    const trimmedValue = value ? value.trim() : '';
+    let output;
+    let outputType = property == null ? void 0 : property.type;
+    if (!outputType) {
+      if (['true', 'false'].includes(trimmedValue)) {
+        outputType = 'boolean';
+      }
+      if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+        outputType = 'number';
       }
-      flattenLoop(configObject);
-      return flattenedObject;
     }
+    switch (outputType) {
+      case 'boolean':
+        output = trimmedValue === 'true';
+        break;
+      case 'number':
+        output = Number(trimmedValue);
+        break;
+      default:
+        output = value;
+    }
+    return output;
+  }
+
+  /**
+   * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+   */
+
+  function mergeConfigs(...configObjects) {
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
-      const obj = flattenObject(configObject);
-      for (const [key, value] of Object.entries(obj)) {
-        formattedConfigObject[key] = value;
+      for (const key of Object.keys(configObject)) {
+        const option = formattedConfigObject[key];
+        const override = configObject[key];
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
       }
     }
     return formattedConfigObject;
   }
+  function extractConfigByNamespace(Component, dataset, namespace) {
+    const property = Component.schema.properties[namespace];
+    if ((property == null ? void 0 : property.type) !== 'object') {
+      return;
+    }
+    const newObject = {
+      [namespace]: ({})
+    };
+    for (const [key, value] of Object.entries(dataset)) {
+      let current = newObject;
+      const keyParts = key.split('.');
+      for (const [index, name] of keyParts.entries()) {
+        if (typeof current === 'object') {
+          if (index < keyParts.length - 1) {
+            if (!isObject(current[name])) {
+              current[name] = {};
+            }
+            current = current[name];
+          } else if (key !== namespace) {
+            current[name] = normaliseString(value);
+          }
+        }
+      }
+    }
+    return newObject[namespace];
+  }
   function isSupported($scope = document.body) {
     if (!$scope) {
       return false;
     }
     return $scope.classList.contains('govuk-frontend-supported');
   }
+  function isArray(option) {
+    return Array.isArray(option);
+  }
+  function isObject(option) {
+    return !!option && typeof option === 'object' && !isArray(option);
+  }
 
   /**
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
@@ -51,26 +111,15 @@
    * @property {string} errorMessage - Error message when required config fields not provided
    */
 
-  function normaliseString(value) {
-    if (typeof value !== 'string') {
-      return value;
-    }
-    const trimmedValue = value.trim();
-    if (trimmedValue === 'true') {
-      return true;
-    }
-    if (trimmedValue === 'false') {
-      return false;
-    }
-    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-      return Number(trimmedValue);
-    }
-    return value;
-  }
-  function normaliseDataset(dataset) {
+  function normaliseDataset(Component, dataset) {
     const out = {};
-    for (const [key, value] of Object.entries(dataset)) {
-      out[key] = normaliseString(value);
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
     }
     return out;
   }
@@ -147,7 +196,7 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(Button.defaults, config, normaliseDataset($module.dataset));
+      this.config = mergeConfigs(Button.defaults, config, normaliseDataset(Button, $module.dataset));
       this.$module.addEventListener('keydown', event => this.handleKeyDown(event));
       this.$module.addEventListener('click', event => this.debounce(event));
     }
@@ -182,10 +231,21 @@
    * @property {boolean} [preventDoubleClick=false] - Prevent accidental double
    *   clicks on submit buttons from submitting forms multiple times.
    */
+
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   */
   Button.moduleName = 'govuk-button';
   Button.defaults = Object.freeze({
     preventDoubleClick: false
   });
+  Button.schema = Object.freeze({
+    properties: {
+      preventDoubleClick: {
+        type: 'boolean'
+      }
+    }
+  });
 
   exports.Button = Button;
 
diff --git a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs
index 5e00ee6d9..c5e9c8f74 100644
--- a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs
@@ -1,40 +1,100 @@
-function mergeConfigs(...configObjects) {
-  function flattenObject(configObject) {
-    const flattenedObject = {};
-    function flattenLoop(obj, prefix) {
-      for (const [key, value] of Object.entries(obj)) {
-        const prefixedKey = prefix ? `${prefix}.${key}` : key;
-        if (value && typeof value === 'object') {
-          flattenLoop(value, prefixedKey);
-        } else {
-          flattenedObject[prefixedKey] = value;
-        }
-      }
+function normaliseString(value, property) {
+  const trimmedValue = value ? value.trim() : '';
+  let output;
+  let outputType = property == null ? void 0 : property.type;
+  if (!outputType) {
+    if (['true', 'false'].includes(trimmedValue)) {
+      outputType = 'boolean';
+    }
+    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+      outputType = 'number';
     }
-    flattenLoop(configObject);
-    return flattenedObject;
   }
+  switch (outputType) {
+    case 'boolean':
+      output = trimmedValue === 'true';
+      break;
+    case 'number':
+      output = Number(trimmedValue);
+      break;
+    default:
+      output = value;
+  }
+  return output;
+}
+
+/**
+ * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+ */
+
+function mergeConfigs(...configObjects) {
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
-    const obj = flattenObject(configObject);
-    for (const [key, value] of Object.entries(obj)) {
-      formattedConfigObject[key] = value;
+    for (const key of Object.keys(configObject)) {
+      const option = formattedConfigObject[key];
+      const override = configObject[key];
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs(option, override);
+      } else {
+        formattedConfigObject[key] = override;
+      }
     }
   }
   return formattedConfigObject;
 }
+function extractConfigByNamespace(Component, dataset, namespace) {
+  const property = Component.schema.properties[namespace];
+  if ((property == null ? void 0 : property.type) !== 'object') {
+    return;
+  }
+  const newObject = {
+    [namespace]: ({})
+  };
+  for (const [key, value] of Object.entries(dataset)) {
+    let current = newObject;
+    const keyParts = key.split('.');
+    for (const [index, name] of keyParts.entries()) {
+      if (typeof current === 'object') {
+        if (index < keyParts.length - 1) {
+          if (!isObject(current[name])) {
+            current[name] = {};
+          }
+          current = current[name];
+        } else if (key !== namespace) {
+          current[name] = normaliseString(value);
+        }
+      }
+    }
+  }
+  return newObject[namespace];
+}
 function isSupported($scope = document.body) {
   if (!$scope) {
     return false;
   }
   return $scope.classList.contains('govuk-frontend-supported');
 }
+function isArray(option) {
+  return Array.isArray(option);
+}
+function isObject(option) {
+  return !!option && typeof option === 'object' && !isArray(option);
+}
 
 /**
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
@@ -45,26 +105,15 @@ function isSupported($scope = document.body) {
  * @property {string} errorMessage - Error message when required config fields not provided
  */
 
-function normaliseString(value) {
-  if (typeof value !== 'string') {
-    return value;
-  }
-  const trimmedValue = value.trim();
-  if (trimmedValue === 'true') {
-    return true;
-  }
-  if (trimmedValue === 'false') {
-    return false;
-  }
-  if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-    return Number(trimmedValue);
-  }
-  return value;
-}
-function normaliseDataset(dataset) {
+function normaliseDataset(Component, dataset) {
   const out = {};
-  for (const [key, value] of Object.entries(dataset)) {
-    out[key] = normaliseString(value);
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
   }
   return out;
 }
@@ -141,7 +190,7 @@ class Button extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Button.defaults, config, normaliseDataset($module.dataset));
+    this.config = mergeConfigs(Button.defaults, config, normaliseDataset(Button, $module.dataset));
     this.$module.addEventListener('keydown', event => this.handleKeyDown(event));
     this.$module.addEventListener('click', event => this.debounce(event));
   }
@@ -176,10 +225,21 @@ class Button extends GOVUKFrontendComponent {
  * @property {boolean} [preventDoubleClick=false] - Prevent accidental double
  *   clicks on submit buttons from submitting forms multiple times.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 Button.moduleName = 'govuk-button';
 Button.defaults = Object.freeze({
   preventDoubleClick: false
 });
+Button.schema = Object.freeze({
+  properties: {
+    preventDoubleClick: {
+      type: 'boolean'
+    }
+  }
+});
 
 export { Button };
 //# sourceMappingURL=button.bundle.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/button/button.mjs b/packages/govuk-frontend/dist/govuk/components/button/button.mjs
index 541e56fab..db2683005 100644
--- a/packages/govuk-frontend/dist/govuk/components/button/button.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/button/button.mjs
@@ -28,7 +28,7 @@ class Button extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(Button.defaults, config, normaliseDataset($module.dataset));
+    this.config = mergeConfigs(Button.defaults, config, normaliseDataset(Button, $module.dataset));
     this.$module.addEventListener('keydown', event => this.handleKeyDown(event));
     this.$module.addEventListener('click', event => this.debounce(event));
   }
@@ -63,10 +63,21 @@ class Button extends GOVUKFrontendComponent {
  * @property {boolean} [preventDoubleClick=false] - Prevent accidental double
  *   clicks on submit buttons from submitting forms multiple times.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 Button.moduleName = 'govuk-button';
 Button.defaults = Object.freeze({
   preventDoubleClick: false
 });
+Button.schema = Object.freeze({
+  properties: {
+    preventDoubleClick: {
+      type: 'boolean'
+    }
+  }
+});
 
 export { Button };
 //# sourceMappingURL=button.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js
index 482acde8d..e7969c2cf 100644
--- a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.js
@@ -9,44 +9,75 @@
     return $closestElementWithAttribute ? $closestElementWithAttribute.getAttribute(attributeName) : null;
   }
 
+  function normaliseString(value, property) {
+    const trimmedValue = value ? value.trim() : '';
+    let output;
+    let outputType = property == null ? void 0 : property.type;
+    if (!outputType) {
+      if (['true', 'false'].includes(trimmedValue)) {
+        outputType = 'boolean';
+      }
+      if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+        outputType = 'number';
+      }
+    }
+    switch (outputType) {
+      case 'boolean':
+        output = trimmedValue === 'true';
+        break;
+      case 'number':
+        output = Number(trimmedValue);
+        break;
+      default:
+        output = value;
+    }
+    return output;
+  }
+
+  /**
+   * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+   */
+
   function mergeConfigs(...configObjects) {
-    function flattenObject(configObject) {
-      const flattenedObject = {};
-      function flattenLoop(obj, prefix) {
-        for (const [key, value] of Object.entries(obj)) {
-          const prefixedKey = prefix ? `${prefix}.${key}` : key;
-          if (value && typeof value === 'object') {
-            flattenLoop(value, prefixedKey);
-          } else {
-            flattenedObject[prefixedKey] = value;
-          }
-        }
-      }
-      flattenLoop(configObject);
-      return flattenedObject;
-    }
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
-      const obj = flattenObject(configObject);
-      for (const [key, value] of Object.entries(obj)) {
-        formattedConfigObject[key] = value;
+      for (const key of Object.keys(configObject)) {
+        const option = formattedConfigObject[key];
+        const override = configObject[key];
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
       }
     }
     return formattedConfigObject;
   }
-  function extractConfigByNamespace(configObject, namespace) {
-    const newObject = {};
-    for (const [key, value] of Object.entries(configObject)) {
+  function extractConfigByNamespace(Component, dataset, namespace) {
+    const property = Component.schema.properties[namespace];
+    if ((property == null ? void 0 : property.type) !== 'object') {
+      return;
+    }
+    const newObject = {
+      [namespace]: ({})
+    };
+    for (const [key, value] of Object.entries(dataset)) {
+      let current = newObject;
       const keyParts = key.split('.');
-      if (keyParts[0] === namespace) {
-        if (keyParts.length > 1) {
-          keyParts.shift();
+      for (const [index, name] of keyParts.entries()) {
+        if (typeof current === 'object') {
+          if (index < keyParts.length - 1) {
+            if (!isObject(current[name])) {
+              current[name] = {};
+            }
+            current = current[name];
+          } else if (key !== namespace) {
+            current[name] = normaliseString(value);
+          }
         }
-        const newKey = keyParts.join('.');
-        newObject[newKey] = value;
       }
     }
-    return newObject;
+    return newObject[namespace];
   }
   function isSupported($scope = document.body) {
     if (!$scope) {
@@ -58,26 +89,46 @@
     const validationErrors = [];
     for (const [name, conditions] of Object.entries(schema)) {
       const errors = [];
-      for (const {
-        required,
-        errorMessage
-      } of conditions) {
-        if (!required.every(key => !!config[key])) {
-          errors.push(errorMessage);
+      if (Array.isArray(conditions)) {
+        for (const {
+          required,
+          errorMessage
+        } of conditions) {
+          if (!required.every(key => !!config[key])) {
+            errors.push(errorMessage);
+          }
+        }
+        if (name === 'allOf' && errors.length) {
+          validationErrors.push(...errors);
+        }
+        if (name === 'anyOf' && !(conditions.length - errors.length >= 1)) {
+          validationErrors.push(...errors);
         }
-      }
-      if (name === 'anyOf' && !(conditions.length - errors.length >= 1)) {
-        validationErrors.push(...errors);
       }
     }
     return validationErrors;
   }
+  function isArray(option) {
+    return Array.isArray(option);
+  }
+  function isObject(option) {
+    return !!option && typeof option === 'object' && !isArray(option);
+  }
 
   /**
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
@@ -88,26 +139,15 @@
    * @property {string} errorMessage - Error message when required config fields not provided
    */
 
-  function normaliseString(value) {
-    if (typeof value !== 'string') {
-      return value;
-    }
-    const trimmedValue = value.trim();
-    if (trimmedValue === 'true') {
-      return true;
-    }
-    if (trimmedValue === 'false') {
-      return false;
-    }
-    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-      return Number(trimmedValue);
-    }
-    return value;
-  }
-  function normaliseDataset(dataset) {
+  function normaliseDataset(Component, dataset) {
     const out = {};
-    for (const [key, value] of Object.entries(dataset)) {
-      out[key] = normaliseString(value);
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
     }
     return out;
   }
@@ -177,18 +217,21 @@
       if (!lookupKey) {
         throw new Error('i18n: lookup key missing');
       }
-      if (typeof (options == null ? void 0 : options.count) === 'number') {
-        lookupKey = `${lookupKey}.${this.getPluralSuffix(lookupKey, options.count)}`;
+      let translation = this.translations[lookupKey];
+      if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+        const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+        if (translationPluralForm) {
+          translation = translationPluralForm;
+        }
       }
-      const translationString = this.translations[lookupKey];
-      if (typeof translationString === 'string') {
-        if (translationString.match(/%{(.\S+)}/)) {
+      if (typeof translation === 'string') {
+        if (translation.match(/%{(.\S+)}/)) {
           if (!options) {
             throw new Error('i18n: cannot replace placeholders in string if no option data provided');
           }
-          return this.replacePlaceholders(translationString, options);
+          return this.replacePlaceholders(translation, options);
         }
-        return translationString;
+        return translation;
       }
       return lookupKey;
     }
@@ -216,12 +259,15 @@
       if (!isFinite(count)) {
         return 'other';
       }
+      const translation = this.translations[lookupKey];
       const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
-      if (`${lookupKey}.${preferredForm}` in this.translations) {
-        return preferredForm;
-      } else if (`${lookupKey}.other` in this.translations) {
-        console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
-        return 'other';
+      if (typeof translation === 'object') {
+        if (preferredForm in translation) {
+          return preferredForm;
+        } else if ('other' in translation) {
+          console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+          return 'other';
+        }
       }
       throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
     }
@@ -398,7 +444,7 @@
           identifier: 'Form field (`.govuk-js-character-count`)'
         });
       }
-      const datasetConfig = normaliseDataset($module.dataset);
+      const datasetConfig = normaliseDataset(CharacterCount, $module.dataset);
       let configOverrides = {};
       if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
         configOverrides = {
@@ -411,7 +457,7 @@
       if (errors[0]) {
         throw new ConfigError(`Character count: ${errors[0]}`);
       }
-      this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'), {
+      this.i18n = new I18n(this.config.i18n, {
         locale: closestAttributeValue($module, 'lang')
       });
       this.maxLength = (_ref = (_this$config$maxwords = this.config.maxwords) != null ? _this$config$maxwords : this.config.maxlength) != null ? _ref : Infinity;
@@ -624,6 +670,20 @@
     }
   });
   CharacterCount.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      },
+      maxwords: {
+        type: 'number'
+      },
+      maxlength: {
+        type: 'number'
+      },
+      threshold: {
+        type: 'number'
+      }
+    },
     anyOf: [{
       required: ['maxwords'],
       errorMessage: 'Either "maxlength" or "maxwords" must be provided'
diff --git a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs
index 8fcb8512d..dd47b8279 100644
--- a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.bundle.mjs
@@ -3,44 +3,75 @@ function closestAttributeValue($element, attributeName) {
   return $closestElementWithAttribute ? $closestElementWithAttribute.getAttribute(attributeName) : null;
 }
 
+function normaliseString(value, property) {
+  const trimmedValue = value ? value.trim() : '';
+  let output;
+  let outputType = property == null ? void 0 : property.type;
+  if (!outputType) {
+    if (['true', 'false'].includes(trimmedValue)) {
+      outputType = 'boolean';
+    }
+    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+      outputType = 'number';
+    }
+  }
+  switch (outputType) {
+    case 'boolean':
+      output = trimmedValue === 'true';
+      break;
+    case 'number':
+      output = Number(trimmedValue);
+      break;
+    default:
+      output = value;
+  }
+  return output;
+}
+
+/**
+ * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+ */
+
 function mergeConfigs(...configObjects) {
-  function flattenObject(configObject) {
-    const flattenedObject = {};
-    function flattenLoop(obj, prefix) {
-      for (const [key, value] of Object.entries(obj)) {
-        const prefixedKey = prefix ? `${prefix}.${key}` : key;
-        if (value && typeof value === 'object') {
-          flattenLoop(value, prefixedKey);
-        } else {
-          flattenedObject[prefixedKey] = value;
-        }
-      }
-    }
-    flattenLoop(configObject);
-    return flattenedObject;
-  }
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
-    const obj = flattenObject(configObject);
-    for (const [key, value] of Object.entries(obj)) {
-      formattedConfigObject[key] = value;
+    for (const key of Object.keys(configObject)) {
+      const option = formattedConfigObject[key];
+      const override = configObject[key];
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs(option, override);
+      } else {
+        formattedConfigObject[key] = override;
+      }
     }
   }
   return formattedConfigObject;
 }
-function extractConfigByNamespace(configObject, namespace) {
-  const newObject = {};
-  for (const [key, value] of Object.entries(configObject)) {
+function extractConfigByNamespace(Component, dataset, namespace) {
+  const property = Component.schema.properties[namespace];
+  if ((property == null ? void 0 : property.type) !== 'object') {
+    return;
+  }
+  const newObject = {
+    [namespace]: ({})
+  };
+  for (const [key, value] of Object.entries(dataset)) {
+    let current = newObject;
     const keyParts = key.split('.');
-    if (keyParts[0] === namespace) {
-      if (keyParts.length > 1) {
-        keyParts.shift();
+    for (const [index, name] of keyParts.entries()) {
+      if (typeof current === 'object') {
+        if (index < keyParts.length - 1) {
+          if (!isObject(current[name])) {
+            current[name] = {};
+          }
+          current = current[name];
+        } else if (key !== namespace) {
+          current[name] = normaliseString(value);
+        }
       }
-      const newKey = keyParts.join('.');
-      newObject[newKey] = value;
     }
   }
-  return newObject;
+  return newObject[namespace];
 }
 function isSupported($scope = document.body) {
   if (!$scope) {
@@ -52,26 +83,46 @@ function validateConfig(schema, config) {
   const validationErrors = [];
   for (const [name, conditions] of Object.entries(schema)) {
     const errors = [];
-    for (const {
-      required,
-      errorMessage
-    } of conditions) {
-      if (!required.every(key => !!config[key])) {
-        errors.push(errorMessage);
+    if (Array.isArray(conditions)) {
+      for (const {
+        required,
+        errorMessage
+      } of conditions) {
+        if (!required.every(key => !!config[key])) {
+          errors.push(errorMessage);
+        }
+      }
+      if (name === 'allOf' && errors.length) {
+        validationErrors.push(...errors);
+      }
+      if (name === 'anyOf' && !(conditions.length - errors.length >= 1)) {
+        validationErrors.push(...errors);
       }
-    }
-    if (name === 'anyOf' && !(conditions.length - errors.length >= 1)) {
-      validationErrors.push(...errors);
     }
   }
   return validationErrors;
 }
+function isArray(option) {
+  return Array.isArray(option);
+}
+function isObject(option) {
+  return !!option && typeof option === 'object' && !isArray(option);
+}
 
 /**
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
@@ -82,26 +133,15 @@ function validateConfig(schema, config) {
  * @property {string} errorMessage - Error message when required config fields not provided
  */
 
-function normaliseString(value) {
-  if (typeof value !== 'string') {
-    return value;
-  }
-  const trimmedValue = value.trim();
-  if (trimmedValue === 'true') {
-    return true;
-  }
-  if (trimmedValue === 'false') {
-    return false;
-  }
-  if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-    return Number(trimmedValue);
-  }
-  return value;
-}
-function normaliseDataset(dataset) {
+function normaliseDataset(Component, dataset) {
   const out = {};
-  for (const [key, value] of Object.entries(dataset)) {
-    out[key] = normaliseString(value);
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
   }
   return out;
 }
@@ -171,18 +211,21 @@ class I18n {
     if (!lookupKey) {
       throw new Error('i18n: lookup key missing');
     }
-    if (typeof (options == null ? void 0 : options.count) === 'number') {
-      lookupKey = `${lookupKey}.${this.getPluralSuffix(lookupKey, options.count)}`;
+    let translation = this.translations[lookupKey];
+    if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+      const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+      if (translationPluralForm) {
+        translation = translationPluralForm;
+      }
     }
-    const translationString = this.translations[lookupKey];
-    if (typeof translationString === 'string') {
-      if (translationString.match(/%{(.\S+)}/)) {
+    if (typeof translation === 'string') {
+      if (translation.match(/%{(.\S+)}/)) {
         if (!options) {
           throw new Error('i18n: cannot replace placeholders in string if no option data provided');
         }
-        return this.replacePlaceholders(translationString, options);
+        return this.replacePlaceholders(translation, options);
       }
-      return translationString;
+      return translation;
     }
     return lookupKey;
   }
@@ -210,12 +253,15 @@ class I18n {
     if (!isFinite(count)) {
       return 'other';
     }
+    const translation = this.translations[lookupKey];
     const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
-    if (`${lookupKey}.${preferredForm}` in this.translations) {
-      return preferredForm;
-    } else if (`${lookupKey}.other` in this.translations) {
-      console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
-      return 'other';
+    if (typeof translation === 'object') {
+      if (preferredForm in translation) {
+        return preferredForm;
+      } else if ('other' in translation) {
+        console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+        return 'other';
+      }
     }
     throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
   }
@@ -392,7 +438,7 @@ class CharacterCount extends GOVUKFrontendComponent {
         identifier: 'Form field (`.govuk-js-character-count`)'
       });
     }
-    const datasetConfig = normaliseDataset($module.dataset);
+    const datasetConfig = normaliseDataset(CharacterCount, $module.dataset);
     let configOverrides = {};
     if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
       configOverrides = {
@@ -405,7 +451,7 @@ class CharacterCount extends GOVUKFrontendComponent {
     if (errors[0]) {
       throw new ConfigError(`Character count: ${errors[0]}`);
     }
-    this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'), {
+    this.i18n = new I18n(this.config.i18n, {
       locale: closestAttributeValue($module, 'lang')
     });
     this.maxLength = (_ref = (_this$config$maxwords = this.config.maxwords) != null ? _this$config$maxwords : this.config.maxlength) != null ? _ref : Infinity;
@@ -618,6 +664,20 @@ CharacterCount.defaults = Object.freeze({
   }
 });
 CharacterCount.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    },
+    maxwords: {
+      type: 'number'
+    },
+    maxlength: {
+      type: 'number'
+    },
+    threshold: {
+      type: 'number'
+    }
+  },
   anyOf: [{
     required: ['maxwords'],
     errorMessage: 'Either "maxlength" or "maxwords" must be provided'
diff --git a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs
index 953feb6f5..955e4ef16 100644
--- a/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/character-count/character-count.mjs
@@ -1,5 +1,5 @@
 import { closestAttributeValue } from '../../common/closest-attribute-value.mjs';
-import { mergeConfigs, validateConfig, extractConfigByNamespace } from '../../common/index.mjs';
+import { mergeConfigs, validateConfig } from '../../common/index.mjs';
 import { normaliseDataset } from '../../common/normalise-dataset.mjs';
 import { ElementError, ConfigError } from '../../errors/index.mjs';
 import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
@@ -51,7 +51,7 @@ class CharacterCount extends GOVUKFrontendComponent {
         identifier: 'Form field (`.govuk-js-character-count`)'
       });
     }
-    const datasetConfig = normaliseDataset($module.dataset);
+    const datasetConfig = normaliseDataset(CharacterCount, $module.dataset);
     let configOverrides = {};
     if ('maxwords' in datasetConfig || 'maxlength' in datasetConfig) {
       configOverrides = {
@@ -64,7 +64,7 @@ class CharacterCount extends GOVUKFrontendComponent {
     if (errors[0]) {
       throw new ConfigError(`Character count: ${errors[0]}`);
     }
-    this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'), {
+    this.i18n = new I18n(this.config.i18n, {
       locale: closestAttributeValue($module, 'lang')
     });
     this.maxLength = (_ref = (_this$config$maxwords = this.config.maxwords) != null ? _this$config$maxwords : this.config.maxlength) != null ? _ref : Infinity;
@@ -277,6 +277,20 @@ CharacterCount.defaults = Object.freeze({
   }
 });
 CharacterCount.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    },
+    maxwords: {
+      type: 'number'
+    },
+    maxlength: {
+      type: 'number'
+    },
+    threshold: {
+      type: 'number'
+    }
+  },
   anyOf: [{
     required: ['maxwords'],
     errorMessage: 'Either "maxlength" or "maxwords" must be provided'
diff --git a/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.js b/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.js
index cd1ad83e7..b09f7db3f 100644
--- a/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.js
@@ -51,7 +51,16 @@
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
diff --git a/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.mjs
index e526485d6..d54dec11a 100644
--- a/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.mjs
@@ -45,7 +45,16 @@ function isSupported($scope = document.body) {
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
diff --git a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.js b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.js
index 649a5eb30..0e4296846 100644
--- a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.js
@@ -4,31 +4,76 @@
   (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GOVUKFrontend = {}));
 })(this, (function (exports) { 'use strict';
 
-  function mergeConfigs(...configObjects) {
-    function flattenObject(configObject) {
-      const flattenedObject = {};
-      function flattenLoop(obj, prefix) {
-        for (const [key, value] of Object.entries(obj)) {
-          const prefixedKey = prefix ? `${prefix}.${key}` : key;
-          if (value && typeof value === 'object') {
-            flattenLoop(value, prefixedKey);
-          } else {
-            flattenedObject[prefixedKey] = value;
-          }
-        }
+  function normaliseString(value, property) {
+    const trimmedValue = value ? value.trim() : '';
+    let output;
+    let outputType = property == null ? void 0 : property.type;
+    if (!outputType) {
+      if (['true', 'false'].includes(trimmedValue)) {
+        outputType = 'boolean';
       }
-      flattenLoop(configObject);
-      return flattenedObject;
+      if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+        outputType = 'number';
+      }
+    }
+    switch (outputType) {
+      case 'boolean':
+        output = trimmedValue === 'true';
+        break;
+      case 'number':
+        output = Number(trimmedValue);
+        break;
+      default:
+        output = value;
     }
+    return output;
+  }
+
+  /**
+   * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+   */
+
+  function mergeConfigs(...configObjects) {
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
-      const obj = flattenObject(configObject);
-      for (const [key, value] of Object.entries(obj)) {
-        formattedConfigObject[key] = value;
+      for (const key of Object.keys(configObject)) {
+        const option = formattedConfigObject[key];
+        const override = configObject[key];
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
       }
     }
     return formattedConfigObject;
   }
+  function extractConfigByNamespace(Component, dataset, namespace) {
+    const property = Component.schema.properties[namespace];
+    if ((property == null ? void 0 : property.type) !== 'object') {
+      return;
+    }
+    const newObject = {
+      [namespace]: ({})
+    };
+    for (const [key, value] of Object.entries(dataset)) {
+      let current = newObject;
+      const keyParts = key.split('.');
+      for (const [index, name] of keyParts.entries()) {
+        if (typeof current === 'object') {
+          if (index < keyParts.length - 1) {
+            if (!isObject(current[name])) {
+              current[name] = {};
+            }
+            current = current[name];
+          } else if (key !== namespace) {
+            current[name] = normaliseString(value);
+          }
+        }
+      }
+    }
+    return newObject[namespace];
+  }
   function getFragmentFromUrl(url) {
     if (!url.includes('#')) {
       return undefined;
@@ -65,12 +110,27 @@
     }
     return $scope.classList.contains('govuk-frontend-supported');
   }
+  function isArray(option) {
+    return Array.isArray(option);
+  }
+  function isObject(option) {
+    return !!option && typeof option === 'object' && !isArray(option);
+  }
 
   /**
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
@@ -81,26 +141,15 @@
    * @property {string} errorMessage - Error message when required config fields not provided
    */
 
-  function normaliseString(value) {
-    if (typeof value !== 'string') {
-      return value;
-    }
-    const trimmedValue = value.trim();
-    if (trimmedValue === 'true') {
-      return true;
-    }
-    if (trimmedValue === 'false') {
-      return false;
-    }
-    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-      return Number(trimmedValue);
-    }
-    return value;
-  }
-  function normaliseDataset(dataset) {
+  function normaliseDataset(Component, dataset) {
     const out = {};
-    for (const [key, value] of Object.entries(dataset)) {
-      out[key] = normaliseString(value);
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
     }
     return out;
   }
@@ -177,7 +226,7 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset($module.dataset));
+      this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset(ErrorSummary, $module.dataset));
       if (!this.config.disableAutoFocus) {
         setFocus(this.$module);
       }
@@ -242,10 +291,21 @@
    * @property {boolean} [disableAutoFocus=false] - If set to `true` the error
    *   summary will not be focussed when the page loads.
    */
+
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   */
   ErrorSummary.moduleName = 'govuk-error-summary';
   ErrorSummary.defaults = Object.freeze({
     disableAutoFocus: false
   });
+  ErrorSummary.schema = Object.freeze({
+    properties: {
+      disableAutoFocus: {
+        type: 'boolean'
+      }
+    }
+  });
 
   exports.ErrorSummary = ErrorSummary;
 
diff --git a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.mjs
index 02bf81818..e773b18d3 100644
--- a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.bundle.mjs
@@ -1,28 +1,73 @@
-function mergeConfigs(...configObjects) {
-  function flattenObject(configObject) {
-    const flattenedObject = {};
-    function flattenLoop(obj, prefix) {
-      for (const [key, value] of Object.entries(obj)) {
-        const prefixedKey = prefix ? `${prefix}.${key}` : key;
-        if (value && typeof value === 'object') {
-          flattenLoop(value, prefixedKey);
-        } else {
-          flattenedObject[prefixedKey] = value;
-        }
-      }
+function normaliseString(value, property) {
+  const trimmedValue = value ? value.trim() : '';
+  let output;
+  let outputType = property == null ? void 0 : property.type;
+  if (!outputType) {
+    if (['true', 'false'].includes(trimmedValue)) {
+      outputType = 'boolean';
     }
-    flattenLoop(configObject);
-    return flattenedObject;
+    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+      outputType = 'number';
+    }
+  }
+  switch (outputType) {
+    case 'boolean':
+      output = trimmedValue === 'true';
+      break;
+    case 'number':
+      output = Number(trimmedValue);
+      break;
+    default:
+      output = value;
   }
+  return output;
+}
+
+/**
+ * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+ */
+
+function mergeConfigs(...configObjects) {
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
-    const obj = flattenObject(configObject);
-    for (const [key, value] of Object.entries(obj)) {
-      formattedConfigObject[key] = value;
+    for (const key of Object.keys(configObject)) {
+      const option = formattedConfigObject[key];
+      const override = configObject[key];
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs(option, override);
+      } else {
+        formattedConfigObject[key] = override;
+      }
     }
   }
   return formattedConfigObject;
 }
+function extractConfigByNamespace(Component, dataset, namespace) {
+  const property = Component.schema.properties[namespace];
+  if ((property == null ? void 0 : property.type) !== 'object') {
+    return;
+  }
+  const newObject = {
+    [namespace]: ({})
+  };
+  for (const [key, value] of Object.entries(dataset)) {
+    let current = newObject;
+    const keyParts = key.split('.');
+    for (const [index, name] of keyParts.entries()) {
+      if (typeof current === 'object') {
+        if (index < keyParts.length - 1) {
+          if (!isObject(current[name])) {
+            current[name] = {};
+          }
+          current = current[name];
+        } else if (key !== namespace) {
+          current[name] = normaliseString(value);
+        }
+      }
+    }
+  }
+  return newObject[namespace];
+}
 function getFragmentFromUrl(url) {
   if (!url.includes('#')) {
     return undefined;
@@ -59,12 +104,27 @@ function isSupported($scope = document.body) {
   }
   return $scope.classList.contains('govuk-frontend-supported');
 }
+function isArray(option) {
+  return Array.isArray(option);
+}
+function isObject(option) {
+  return !!option && typeof option === 'object' && !isArray(option);
+}
 
 /**
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
@@ -75,26 +135,15 @@ function isSupported($scope = document.body) {
  * @property {string} errorMessage - Error message when required config fields not provided
  */
 
-function normaliseString(value) {
-  if (typeof value !== 'string') {
-    return value;
-  }
-  const trimmedValue = value.trim();
-  if (trimmedValue === 'true') {
-    return true;
-  }
-  if (trimmedValue === 'false') {
-    return false;
-  }
-  if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-    return Number(trimmedValue);
-  }
-  return value;
-}
-function normaliseDataset(dataset) {
+function normaliseDataset(Component, dataset) {
   const out = {};
-  for (const [key, value] of Object.entries(dataset)) {
-    out[key] = normaliseString(value);
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
   }
   return out;
 }
@@ -171,7 +220,7 @@ class ErrorSummary extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset($module.dataset));
+    this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset(ErrorSummary, $module.dataset));
     if (!this.config.disableAutoFocus) {
       setFocus(this.$module);
     }
@@ -236,10 +285,21 @@ class ErrorSummary extends GOVUKFrontendComponent {
  * @property {boolean} [disableAutoFocus=false] - If set to `true` the error
  *   summary will not be focussed when the page loads.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 ErrorSummary.moduleName = 'govuk-error-summary';
 ErrorSummary.defaults = Object.freeze({
   disableAutoFocus: false
 });
+ErrorSummary.schema = Object.freeze({
+  properties: {
+    disableAutoFocus: {
+      type: 'boolean'
+    }
+  }
+});
 
 export { ErrorSummary };
 //# sourceMappingURL=error-summary.bundle.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.mjs b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.mjs
index f0e343e1b..1fd0f166e 100644
--- a/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/error-summary/error-summary.mjs
@@ -28,7 +28,7 @@ class ErrorSummary extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset($module.dataset));
+    this.config = mergeConfigs(ErrorSummary.defaults, config, normaliseDataset(ErrorSummary, $module.dataset));
     if (!this.config.disableAutoFocus) {
       setFocus(this.$module);
     }
@@ -93,10 +93,21 @@ class ErrorSummary extends GOVUKFrontendComponent {
  * @property {boolean} [disableAutoFocus=false] - If set to `true` the error
  *   summary will not be focussed when the page loads.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 ErrorSummary.moduleName = 'govuk-error-summary';
 ErrorSummary.defaults = Object.freeze({
   disableAutoFocus: false
 });
+ErrorSummary.schema = Object.freeze({
+  properties: {
+    disableAutoFocus: {
+      type: 'boolean'
+    }
+  }
+});
 
 export { ErrorSummary };
 //# sourceMappingURL=error-summary.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.js b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.js
index 4d726b847..57ddadca2 100644
--- a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.js
@@ -4,44 +4,75 @@
   (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GOVUKFrontend = {}));
 })(this, (function (exports) { 'use strict';
 
+  function normaliseString(value, property) {
+    const trimmedValue = value ? value.trim() : '';
+    let output;
+    let outputType = property == null ? void 0 : property.type;
+    if (!outputType) {
+      if (['true', 'false'].includes(trimmedValue)) {
+        outputType = 'boolean';
+      }
+      if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+        outputType = 'number';
+      }
+    }
+    switch (outputType) {
+      case 'boolean':
+        output = trimmedValue === 'true';
+        break;
+      case 'number':
+        output = Number(trimmedValue);
+        break;
+      default:
+        output = value;
+    }
+    return output;
+  }
+
+  /**
+   * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+   */
+
   function mergeConfigs(...configObjects) {
-    function flattenObject(configObject) {
-      const flattenedObject = {};
-      function flattenLoop(obj, prefix) {
-        for (const [key, value] of Object.entries(obj)) {
-          const prefixedKey = prefix ? `${prefix}.${key}` : key;
-          if (value && typeof value === 'object') {
-            flattenLoop(value, prefixedKey);
-          } else {
-            flattenedObject[prefixedKey] = value;
-          }
-        }
-      }
-      flattenLoop(configObject);
-      return flattenedObject;
-    }
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
-      const obj = flattenObject(configObject);
-      for (const [key, value] of Object.entries(obj)) {
-        formattedConfigObject[key] = value;
+      for (const key of Object.keys(configObject)) {
+        const option = formattedConfigObject[key];
+        const override = configObject[key];
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
       }
     }
     return formattedConfigObject;
   }
-  function extractConfigByNamespace(configObject, namespace) {
-    const newObject = {};
-    for (const [key, value] of Object.entries(configObject)) {
+  function extractConfigByNamespace(Component, dataset, namespace) {
+    const property = Component.schema.properties[namespace];
+    if ((property == null ? void 0 : property.type) !== 'object') {
+      return;
+    }
+    const newObject = {
+      [namespace]: ({})
+    };
+    for (const [key, value] of Object.entries(dataset)) {
+      let current = newObject;
       const keyParts = key.split('.');
-      if (keyParts[0] === namespace) {
-        if (keyParts.length > 1) {
-          keyParts.shift();
+      for (const [index, name] of keyParts.entries()) {
+        if (typeof current === 'object') {
+          if (index < keyParts.length - 1) {
+            if (!isObject(current[name])) {
+              current[name] = {};
+            }
+            current = current[name];
+          } else if (key !== namespace) {
+            current[name] = normaliseString(value);
+          }
         }
-        const newKey = keyParts.join('.');
-        newObject[newKey] = value;
       }
     }
-    return newObject;
+    return newObject[namespace];
   }
   function isSupported($scope = document.body) {
     if (!$scope) {
@@ -49,12 +80,27 @@
     }
     return $scope.classList.contains('govuk-frontend-supported');
   }
+  function isArray(option) {
+    return Array.isArray(option);
+  }
+  function isObject(option) {
+    return !!option && typeof option === 'object' && !isArray(option);
+  }
 
   /**
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
@@ -65,26 +111,15 @@
    * @property {string} errorMessage - Error message when required config fields not provided
    */
 
-  function normaliseString(value) {
-    if (typeof value !== 'string') {
-      return value;
-    }
-    const trimmedValue = value.trim();
-    if (trimmedValue === 'true') {
-      return true;
-    }
-    if (trimmedValue === 'false') {
-      return false;
-    }
-    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-      return Number(trimmedValue);
-    }
-    return value;
-  }
-  function normaliseDataset(dataset) {
+  function normaliseDataset(Component, dataset) {
     const out = {};
-    for (const [key, value] of Object.entries(dataset)) {
-      out[key] = normaliseString(value);
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
     }
     return out;
   }
@@ -148,18 +183,21 @@
       if (!lookupKey) {
         throw new Error('i18n: lookup key missing');
       }
-      if (typeof (options == null ? void 0 : options.count) === 'number') {
-        lookupKey = `${lookupKey}.${this.getPluralSuffix(lookupKey, options.count)}`;
+      let translation = this.translations[lookupKey];
+      if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+        const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+        if (translationPluralForm) {
+          translation = translationPluralForm;
+        }
       }
-      const translationString = this.translations[lookupKey];
-      if (typeof translationString === 'string') {
-        if (translationString.match(/%{(.\S+)}/)) {
+      if (typeof translation === 'string') {
+        if (translation.match(/%{(.\S+)}/)) {
           if (!options) {
             throw new Error('i18n: cannot replace placeholders in string if no option data provided');
           }
-          return this.replacePlaceholders(translationString, options);
+          return this.replacePlaceholders(translation, options);
         }
-        return translationString;
+        return translation;
       }
       return lookupKey;
     }
@@ -187,12 +225,15 @@
       if (!isFinite(count)) {
         return 'other';
       }
+      const translation = this.translations[lookupKey];
       const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
-      if (`${lookupKey}.${preferredForm}` in this.translations) {
-        return preferredForm;
-      } else if (`${lookupKey}.other` in this.translations) {
-        console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
-        return 'other';
+      if (typeof translation === 'object') {
+        if (preferredForm in translation) {
+          return preferredForm;
+        } else if ('other' in translation) {
+          console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+          return 'other';
+        }
       }
       throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
     }
@@ -364,8 +405,8 @@
           identifier: 'Button (`.govuk-exit-this-page__button`)'
         });
       }
-      this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset($module.dataset));
-      this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'));
+      this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset(ExitThisPage, $module.dataset));
+      this.i18n = new I18n(this.config.i18n);
       this.$module = $module;
       this.$button = $button;
       const $skiplinkButton = document.querySelector('.govuk-js-exit-this-page-skiplink');
@@ -529,6 +570,10 @@
    * @property {string} [pressOneMoreTime] - Screen reader announcement informing
    *   the user they must press the activation key one more time.
    */
+
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   */
   ExitThisPage.moduleName = 'govuk-exit-this-page';
   ExitThisPage.defaults = Object.freeze({
     i18n: {
@@ -538,6 +583,13 @@
       pressOneMoreTime: 'Shift, press 1 more time to exit.'
     }
   });
+  ExitThisPage.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      }
+    }
+  });
 
   exports.ExitThisPage = ExitThisPage;
 
diff --git a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.mjs
index a8e072477..bed353b6b 100644
--- a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.bundle.mjs
@@ -1,41 +1,72 @@
+function normaliseString(value, property) {
+  const trimmedValue = value ? value.trim() : '';
+  let output;
+  let outputType = property == null ? void 0 : property.type;
+  if (!outputType) {
+    if (['true', 'false'].includes(trimmedValue)) {
+      outputType = 'boolean';
+    }
+    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+      outputType = 'number';
+    }
+  }
+  switch (outputType) {
+    case 'boolean':
+      output = trimmedValue === 'true';
+      break;
+    case 'number':
+      output = Number(trimmedValue);
+      break;
+    default:
+      output = value;
+  }
+  return output;
+}
+
+/**
+ * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+ */
+
 function mergeConfigs(...configObjects) {
-  function flattenObject(configObject) {
-    const flattenedObject = {};
-    function flattenLoop(obj, prefix) {
-      for (const [key, value] of Object.entries(obj)) {
-        const prefixedKey = prefix ? `${prefix}.${key}` : key;
-        if (value && typeof value === 'object') {
-          flattenLoop(value, prefixedKey);
-        } else {
-          flattenedObject[prefixedKey] = value;
-        }
-      }
-    }
-    flattenLoop(configObject);
-    return flattenedObject;
-  }
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
-    const obj = flattenObject(configObject);
-    for (const [key, value] of Object.entries(obj)) {
-      formattedConfigObject[key] = value;
+    for (const key of Object.keys(configObject)) {
+      const option = formattedConfigObject[key];
+      const override = configObject[key];
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs(option, override);
+      } else {
+        formattedConfigObject[key] = override;
+      }
     }
   }
   return formattedConfigObject;
 }
-function extractConfigByNamespace(configObject, namespace) {
-  const newObject = {};
-  for (const [key, value] of Object.entries(configObject)) {
+function extractConfigByNamespace(Component, dataset, namespace) {
+  const property = Component.schema.properties[namespace];
+  if ((property == null ? void 0 : property.type) !== 'object') {
+    return;
+  }
+  const newObject = {
+    [namespace]: ({})
+  };
+  for (const [key, value] of Object.entries(dataset)) {
+    let current = newObject;
     const keyParts = key.split('.');
-    if (keyParts[0] === namespace) {
-      if (keyParts.length > 1) {
-        keyParts.shift();
+    for (const [index, name] of keyParts.entries()) {
+      if (typeof current === 'object') {
+        if (index < keyParts.length - 1) {
+          if (!isObject(current[name])) {
+            current[name] = {};
+          }
+          current = current[name];
+        } else if (key !== namespace) {
+          current[name] = normaliseString(value);
+        }
       }
-      const newKey = keyParts.join('.');
-      newObject[newKey] = value;
     }
   }
-  return newObject;
+  return newObject[namespace];
 }
 function isSupported($scope = document.body) {
   if (!$scope) {
@@ -43,12 +74,27 @@ function isSupported($scope = document.body) {
   }
   return $scope.classList.contains('govuk-frontend-supported');
 }
+function isArray(option) {
+  return Array.isArray(option);
+}
+function isObject(option) {
+  return !!option && typeof option === 'object' && !isArray(option);
+}
 
 /**
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
@@ -59,26 +105,15 @@ function isSupported($scope = document.body) {
  * @property {string} errorMessage - Error message when required config fields not provided
  */
 
-function normaliseString(value) {
-  if (typeof value !== 'string') {
-    return value;
-  }
-  const trimmedValue = value.trim();
-  if (trimmedValue === 'true') {
-    return true;
-  }
-  if (trimmedValue === 'false') {
-    return false;
-  }
-  if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-    return Number(trimmedValue);
-  }
-  return value;
-}
-function normaliseDataset(dataset) {
+function normaliseDataset(Component, dataset) {
   const out = {};
-  for (const [key, value] of Object.entries(dataset)) {
-    out[key] = normaliseString(value);
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
   }
   return out;
 }
@@ -142,18 +177,21 @@ class I18n {
     if (!lookupKey) {
       throw new Error('i18n: lookup key missing');
     }
-    if (typeof (options == null ? void 0 : options.count) === 'number') {
-      lookupKey = `${lookupKey}.${this.getPluralSuffix(lookupKey, options.count)}`;
+    let translation = this.translations[lookupKey];
+    if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+      const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+      if (translationPluralForm) {
+        translation = translationPluralForm;
+      }
     }
-    const translationString = this.translations[lookupKey];
-    if (typeof translationString === 'string') {
-      if (translationString.match(/%{(.\S+)}/)) {
+    if (typeof translation === 'string') {
+      if (translation.match(/%{(.\S+)}/)) {
         if (!options) {
           throw new Error('i18n: cannot replace placeholders in string if no option data provided');
         }
-        return this.replacePlaceholders(translationString, options);
+        return this.replacePlaceholders(translation, options);
       }
-      return translationString;
+      return translation;
     }
     return lookupKey;
   }
@@ -181,12 +219,15 @@ class I18n {
     if (!isFinite(count)) {
       return 'other';
     }
+    const translation = this.translations[lookupKey];
     const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
-    if (`${lookupKey}.${preferredForm}` in this.translations) {
-      return preferredForm;
-    } else if (`${lookupKey}.other` in this.translations) {
-      console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
-      return 'other';
+    if (typeof translation === 'object') {
+      if (preferredForm in translation) {
+        return preferredForm;
+      } else if ('other' in translation) {
+        console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+        return 'other';
+      }
     }
     throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
   }
@@ -358,8 +399,8 @@ class ExitThisPage extends GOVUKFrontendComponent {
         identifier: 'Button (`.govuk-exit-this-page__button`)'
       });
     }
-    this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset($module.dataset));
-    this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'));
+    this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset(ExitThisPage, $module.dataset));
+    this.i18n = new I18n(this.config.i18n);
     this.$module = $module;
     this.$button = $button;
     const $skiplinkButton = document.querySelector('.govuk-js-exit-this-page-skiplink');
@@ -523,6 +564,10 @@ class ExitThisPage extends GOVUKFrontendComponent {
  * @property {string} [pressOneMoreTime] - Screen reader announcement informing
  *   the user they must press the activation key one more time.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 ExitThisPage.moduleName = 'govuk-exit-this-page';
 ExitThisPage.defaults = Object.freeze({
   i18n: {
@@ -532,6 +577,13 @@ ExitThisPage.defaults = Object.freeze({
     pressOneMoreTime: 'Shift, press 1 more time to exit.'
   }
 });
+ExitThisPage.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
 
 export { ExitThisPage };
 //# sourceMappingURL=exit-this-page.bundle.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.mjs b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.mjs
index fa157c93e..b4cd38985 100644
--- a/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/exit-this-page/exit-this-page.mjs
@@ -1,4 +1,4 @@
-import { mergeConfigs, extractConfigByNamespace } from '../../common/index.mjs';
+import { mergeConfigs } from '../../common/index.mjs';
 import { normaliseDataset } from '../../common/normalise-dataset.mjs';
 import { ElementError } from '../../errors/index.mjs';
 import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs';
@@ -45,8 +45,8 @@ class ExitThisPage extends GOVUKFrontendComponent {
         identifier: 'Button (`.govuk-exit-this-page__button`)'
       });
     }
-    this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset($module.dataset));
-    this.i18n = new I18n(extractConfigByNamespace(this.config, 'i18n'));
+    this.config = mergeConfigs(ExitThisPage.defaults, config, normaliseDataset(ExitThisPage, $module.dataset));
+    this.i18n = new I18n(this.config.i18n);
     this.$module = $module;
     this.$button = $button;
     const $skiplinkButton = document.querySelector('.govuk-js-exit-this-page-skiplink');
@@ -210,6 +210,10 @@ class ExitThisPage extends GOVUKFrontendComponent {
  * @property {string} [pressOneMoreTime] - Screen reader announcement informing
  *   the user they must press the activation key one more time.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 ExitThisPage.moduleName = 'govuk-exit-this-page';
 ExitThisPage.defaults = Object.freeze({
   i18n: {
@@ -219,6 +223,13 @@ ExitThisPage.defaults = Object.freeze({
     pressOneMoreTime: 'Shift, press 1 more time to exit.'
   }
 });
+ExitThisPage.schema = Object.freeze({
+  properties: {
+    i18n: {
+      type: 'object'
+    }
+  }
+});
 
 export { ExitThisPage };
 //# sourceMappingURL=exit-this-page.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/header/header.bundle.js b/packages/govuk-frontend/dist/govuk/components/header/header.bundle.js
index ee3b1e1c6..c1a7ad7a5 100644
--- a/packages/govuk-frontend/dist/govuk/components/header/header.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/header/header.bundle.js
@@ -23,7 +23,16 @@
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
diff --git a/packages/govuk-frontend/dist/govuk/components/header/header.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/header/header.bundle.mjs
index 98e82f86d..51c30de8b 100644
--- a/packages/govuk-frontend/dist/govuk/components/header/header.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/header/header.bundle.mjs
@@ -17,7 +17,16 @@ function isSupported($scope = document.body) {
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
diff --git a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.js b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.js
index dbe944a6a..aed335b87 100644
--- a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.js
@@ -4,31 +4,76 @@
   (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.GOVUKFrontend = {}));
 })(this, (function (exports) { 'use strict';
 
-  function mergeConfigs(...configObjects) {
-    function flattenObject(configObject) {
-      const flattenedObject = {};
-      function flattenLoop(obj, prefix) {
-        for (const [key, value] of Object.entries(obj)) {
-          const prefixedKey = prefix ? `${prefix}.${key}` : key;
-          if (value && typeof value === 'object') {
-            flattenLoop(value, prefixedKey);
-          } else {
-            flattenedObject[prefixedKey] = value;
-          }
-        }
+  function normaliseString(value, property) {
+    const trimmedValue = value ? value.trim() : '';
+    let output;
+    let outputType = property == null ? void 0 : property.type;
+    if (!outputType) {
+      if (['true', 'false'].includes(trimmedValue)) {
+        outputType = 'boolean';
+      }
+      if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+        outputType = 'number';
       }
-      flattenLoop(configObject);
-      return flattenedObject;
     }
+    switch (outputType) {
+      case 'boolean':
+        output = trimmedValue === 'true';
+        break;
+      case 'number':
+        output = Number(trimmedValue);
+        break;
+      default:
+        output = value;
+    }
+    return output;
+  }
+
+  /**
+   * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+   */
+
+  function mergeConfigs(...configObjects) {
     const formattedConfigObject = {};
     for (const configObject of configObjects) {
-      const obj = flattenObject(configObject);
-      for (const [key, value] of Object.entries(obj)) {
-        formattedConfigObject[key] = value;
+      for (const key of Object.keys(configObject)) {
+        const option = formattedConfigObject[key];
+        const override = configObject[key];
+        if (isObject(option) && isObject(override)) {
+          formattedConfigObject[key] = mergeConfigs(option, override);
+        } else {
+          formattedConfigObject[key] = override;
+        }
       }
     }
     return formattedConfigObject;
   }
+  function extractConfigByNamespace(Component, dataset, namespace) {
+    const property = Component.schema.properties[namespace];
+    if ((property == null ? void 0 : property.type) !== 'object') {
+      return;
+    }
+    const newObject = {
+      [namespace]: ({})
+    };
+    for (const [key, value] of Object.entries(dataset)) {
+      let current = newObject;
+      const keyParts = key.split('.');
+      for (const [index, name] of keyParts.entries()) {
+        if (typeof current === 'object') {
+          if (index < keyParts.length - 1) {
+            if (!isObject(current[name])) {
+              current[name] = {};
+            }
+            current = current[name];
+          } else if (key !== namespace) {
+            current[name] = normaliseString(value);
+          }
+        }
+      }
+    }
+    return newObject[namespace];
+  }
   function setFocus($element, options = {}) {
     var _options$onBeforeFocu;
     const isFocusable = $element.getAttribute('tabindex');
@@ -59,12 +104,27 @@
     }
     return $scope.classList.contains('govuk-frontend-supported');
   }
+  function isArray(option) {
+    return Array.isArray(option);
+  }
+  function isObject(option) {
+    return !!option && typeof option === 'object' && !isArray(option);
+  }
 
   /**
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
@@ -75,26 +135,15 @@
    * @property {string} errorMessage - Error message when required config fields not provided
    */
 
-  function normaliseString(value) {
-    if (typeof value !== 'string') {
-      return value;
-    }
-    const trimmedValue = value.trim();
-    if (trimmedValue === 'true') {
-      return true;
-    }
-    if (trimmedValue === 'false') {
-      return false;
-    }
-    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-      return Number(trimmedValue);
-    }
-    return value;
-  }
-  function normaliseDataset(dataset) {
+  function normaliseDataset(Component, dataset) {
     const out = {};
-    for (const [key, value] of Object.entries(dataset)) {
-      out[key] = normaliseString(value);
+    for (const [field, property] of Object.entries(Component.schema.properties)) {
+      if (field in dataset) {
+        out[field] = normaliseString(dataset[field], property);
+      }
+      if ((property == null ? void 0 : property.type) === 'object') {
+        out[field] = extractConfigByNamespace(Component, dataset, field);
+      }
     }
     return out;
   }
@@ -168,7 +217,7 @@
         });
       }
       this.$module = $module;
-      this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset($module.dataset));
+      this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset(NotificationBanner, $module.dataset));
       if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
         setFocus(this.$module);
       }
@@ -184,10 +233,21 @@
    *   applies if the component has a `role` of `alert` – in other cases the
    *   component will not be focused on page load, regardless of this option.
    */
+
+  /**
+   * @typedef {import('../../common/index.mjs').Schema} Schema
+   */
   NotificationBanner.moduleName = 'govuk-notification-banner';
   NotificationBanner.defaults = Object.freeze({
     disableAutoFocus: false
   });
+  NotificationBanner.schema = Object.freeze({
+    properties: {
+      disableAutoFocus: {
+        type: 'boolean'
+      }
+    }
+  });
 
   exports.NotificationBanner = NotificationBanner;
 
diff --git a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.mjs
index f0fcf2532..5df4b7fc6 100644
--- a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.bundle.mjs
@@ -1,28 +1,73 @@
-function mergeConfigs(...configObjects) {
-  function flattenObject(configObject) {
-    const flattenedObject = {};
-    function flattenLoop(obj, prefix) {
-      for (const [key, value] of Object.entries(obj)) {
-        const prefixedKey = prefix ? `${prefix}.${key}` : key;
-        if (value && typeof value === 'object') {
-          flattenLoop(value, prefixedKey);
-        } else {
-          flattenedObject[prefixedKey] = value;
-        }
-      }
+function normaliseString(value, property) {
+  const trimmedValue = value ? value.trim() : '';
+  let output;
+  let outputType = property == null ? void 0 : property.type;
+  if (!outputType) {
+    if (['true', 'false'].includes(trimmedValue)) {
+      outputType = 'boolean';
+    }
+    if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
+      outputType = 'number';
     }
-    flattenLoop(configObject);
-    return flattenedObject;
   }
+  switch (outputType) {
+    case 'boolean':
+      output = trimmedValue === 'true';
+      break;
+    case 'number':
+      output = Number(trimmedValue);
+      break;
+    default:
+      output = value;
+  }
+  return output;
+}
+
+/**
+ * @typedef {import('./index.mjs').SchemaProperty} SchemaProperty
+ */
+
+function mergeConfigs(...configObjects) {
   const formattedConfigObject = {};
   for (const configObject of configObjects) {
-    const obj = flattenObject(configObject);
-    for (const [key, value] of Object.entries(obj)) {
-      formattedConfigObject[key] = value;
+    for (const key of Object.keys(configObject)) {
+      const option = formattedConfigObject[key];
+      const override = configObject[key];
+      if (isObject(option) && isObject(override)) {
+        formattedConfigObject[key] = mergeConfigs(option, override);
+      } else {
+        formattedConfigObject[key] = override;
+      }
     }
   }
   return formattedConfigObject;
 }
+function extractConfigByNamespace(Component, dataset, namespace) {
+  const property = Component.schema.properties[namespace];
+  if ((property == null ? void 0 : property.type) !== 'object') {
+    return;
+  }
+  const newObject = {
+    [namespace]: ({})
+  };
+  for (const [key, value] of Object.entries(dataset)) {
+    let current = newObject;
+    const keyParts = key.split('.');
+    for (const [index, name] of keyParts.entries()) {
+      if (typeof current === 'object') {
+        if (index < keyParts.length - 1) {
+          if (!isObject(current[name])) {
+            current[name] = {};
+          }
+          current = current[name];
+        } else if (key !== namespace) {
+          current[name] = normaliseString(value);
+        }
+      }
+    }
+  }
+  return newObject[namespace];
+}
 function setFocus($element, options = {}) {
   var _options$onBeforeFocu;
   const isFocusable = $element.getAttribute('tabindex');
@@ -53,12 +98,27 @@ function isSupported($scope = document.body) {
   }
   return $scope.classList.contains('govuk-frontend-supported');
 }
+function isArray(option) {
+  return Array.isArray(option);
+}
+function isObject(option) {
+  return !!option && typeof option === 'object' && !isArray(option);
+}
 
 /**
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
@@ -69,26 +129,15 @@ function isSupported($scope = document.body) {
  * @property {string} errorMessage - Error message when required config fields not provided
  */
 
-function normaliseString(value) {
-  if (typeof value !== 'string') {
-    return value;
-  }
-  const trimmedValue = value.trim();
-  if (trimmedValue === 'true') {
-    return true;
-  }
-  if (trimmedValue === 'false') {
-    return false;
-  }
-  if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
-    return Number(trimmedValue);
-  }
-  return value;
-}
-function normaliseDataset(dataset) {
+function normaliseDataset(Component, dataset) {
   const out = {};
-  for (const [key, value] of Object.entries(dataset)) {
-    out[key] = normaliseString(value);
+  for (const [field, property] of Object.entries(Component.schema.properties)) {
+    if (field in dataset) {
+      out[field] = normaliseString(dataset[field], property);
+    }
+    if ((property == null ? void 0 : property.type) === 'object') {
+      out[field] = extractConfigByNamespace(Component, dataset, field);
+    }
   }
   return out;
 }
@@ -162,7 +211,7 @@ class NotificationBanner extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset($module.dataset));
+    this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset(NotificationBanner, $module.dataset));
     if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
       setFocus(this.$module);
     }
@@ -178,10 +227,21 @@ class NotificationBanner extends GOVUKFrontendComponent {
  *   applies if the component has a `role` of `alert` – in other cases the
  *   component will not be focused on page load, regardless of this option.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 NotificationBanner.moduleName = 'govuk-notification-banner';
 NotificationBanner.defaults = Object.freeze({
   disableAutoFocus: false
 });
+NotificationBanner.schema = Object.freeze({
+  properties: {
+    disableAutoFocus: {
+      type: 'boolean'
+    }
+  }
+});
 
 export { NotificationBanner };
 //# sourceMappingURL=notification-banner.bundle.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.mjs b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.mjs
index 1af8f3d96..1ef1621bc 100644
--- a/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/notification-banner/notification-banner.mjs
@@ -25,7 +25,7 @@ class NotificationBanner extends GOVUKFrontendComponent {
       });
     }
     this.$module = $module;
-    this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset($module.dataset));
+    this.config = mergeConfigs(NotificationBanner.defaults, config, normaliseDataset(NotificationBanner, $module.dataset));
     if (this.$module.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
       setFocus(this.$module);
     }
@@ -41,10 +41,21 @@ class NotificationBanner extends GOVUKFrontendComponent {
  *   applies if the component has a `role` of `alert` – in other cases the
  *   component will not be focused on page load, regardless of this option.
  */
+
+/**
+ * @typedef {import('../../common/index.mjs').Schema} Schema
+ */
 NotificationBanner.moduleName = 'govuk-notification-banner';
 NotificationBanner.defaults = Object.freeze({
   disableAutoFocus: false
 });
+NotificationBanner.schema = Object.freeze({
+  properties: {
+    disableAutoFocus: {
+      type: 'boolean'
+    }
+  }
+});
 
 export { NotificationBanner };
 //# sourceMappingURL=notification-banner.mjs.map
diff --git a/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.js b/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.js
index 11cb7cbe4..347c854b8 100644
--- a/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.js
@@ -51,7 +51,16 @@
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
diff --git a/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.mjs
index 076d273b2..81d429877 100644
--- a/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.mjs
@@ -45,7 +45,16 @@ function isSupported($scope = document.body) {
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
diff --git a/packages/govuk-frontend/dist/govuk/components/skip-link/skip-link.bundle.js b/packages/govuk-frontend/dist/govuk/components/skip-link/skip-link.bundle.js
index 03f8c198f..676467a38 100644
--- a/packages/govuk-frontend/dist/govuk/components/skip-link/skip-link.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/skip-link/skip-link.bundle.js
@@ -45,7 +45,16 @@
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
diff --git a/packages/govuk-frontend/dist/govuk/components/skip-link/skip-link.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/skip-link/skip-link.bundle.mjs
index 8386205c1..e6ac8d153 100644
--- a/packages/govuk-frontend/dist/govuk/components/skip-link/skip-link.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/skip-link/skip-link.bundle.mjs
@@ -39,7 +39,16 @@ function isSupported($scope = document.body) {
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
diff --git a/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.js b/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.js
index 8b0bdb9c0..8f468d611 100644
--- a/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.js
@@ -29,7 +29,16 @@
    * Schema for component config
    *
    * @typedef {object} Schema
-   * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+   * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+   * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+   */
+
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
    */
 
   /**
diff --git a/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.mjs b/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.mjs
index db1378320..b40c05db6 100644
--- a/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.mjs
@@ -23,7 +23,16 @@ function isSupported($scope = document.body) {
  * Schema for component config
  *
  * @typedef {object} Schema
- * @property {SchemaCondition[]} [anyOf] - List of schema conditions
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
+ * @property {SchemaCondition[]} [allOf] - List of schema conditions, all must pass
+ * @property {SchemaCondition[]} [anyOf] - List of schema conditions, any must pass
+ */
+
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
  */
 
 /**
diff --git a/packages/govuk-frontend/dist/govuk/i18n.mjs b/packages/govuk-frontend/dist/govuk/i18n.mjs
index 3ea5259bb..f7a76fb04 100644
--- a/packages/govuk-frontend/dist/govuk/i18n.mjs
+++ b/packages/govuk-frontend/dist/govuk/i18n.mjs
@@ -10,18 +10,21 @@ class I18n {
     if (!lookupKey) {
       throw new Error('i18n: lookup key missing');
     }
-    if (typeof (options == null ? void 0 : options.count) === 'number') {
-      lookupKey = `${lookupKey}.${this.getPluralSuffix(lookupKey, options.count)}`;
+    let translation = this.translations[lookupKey];
+    if (typeof (options == null ? void 0 : options.count) === 'number' && typeof translation === 'object') {
+      const translationPluralForm = translation[this.getPluralSuffix(lookupKey, options.count)];
+      if (translationPluralForm) {
+        translation = translationPluralForm;
+      }
     }
-    const translationString = this.translations[lookupKey];
-    if (typeof translationString === 'string') {
-      if (translationString.match(/%{(.\S+)}/)) {
+    if (typeof translation === 'string') {
+      if (translation.match(/%{(.\S+)}/)) {
         if (!options) {
           throw new Error('i18n: cannot replace placeholders in string if no option data provided');
         }
-        return this.replacePlaceholders(translationString, options);
+        return this.replacePlaceholders(translation, options);
       }
-      return translationString;
+      return translation;
     }
     return lookupKey;
   }
@@ -49,12 +52,15 @@ class I18n {
     if (!isFinite(count)) {
       return 'other';
     }
+    const translation = this.translations[lookupKey];
     const preferredForm = this.hasIntlPluralRulesSupport() ? new Intl.PluralRules(this.locale).select(count) : this.selectPluralFormUsingFallbackRules(count);
-    if (`${lookupKey}.${preferredForm}` in this.translations) {
-      return preferredForm;
-    } else if (`${lookupKey}.other` in this.translations) {
-      console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
-      return 'other';
+    if (typeof translation === 'object') {
+      if (preferredForm in translation) {
+        return preferredForm;
+      } else if ('other' in translation) {
+        console.warn(`i18n: Missing plural form ".${preferredForm}" for "${this.locale}" locale. Falling back to ".other".`);
+        return 'other';
+      }
     }
     throw new Error(`i18n: Plural form ".other" is required for "${this.locale}" locale`);
   }

Action run for fd0af85

colinrotherham and others added 8 commits March 8, 2024 16:43
Since we only pass in `DOMStringMap` values, this removes all tests for `normaliseDataset()` that don’t pass in strings

We also prevent unnecessary data attributes (e.g. `data-module="govuk-accordion"’) from being merged into the config

See: #4230
We can reduce the code we use by expanding dot-separated data-attributes at the point we read them

This lets us remove `flattenConfigs()` since we can use nested configs everywhere
Do you want a `data-example="2024"` to stay as a string?

Data attributes now understand component config schema types

Extra checks added to guard against objects, arrays and non-finite numbers (NaN, Infinity)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants