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

Switch to nested (not flattened) configs with stricter checks #4792

Merged
merged 11 commits into from
Mar 8, 2024

Conversation

colinrotherham
Copy link
Contributor

@colinrotherham colinrotherham commented Feb 23, 2024

Closes #4230 to fix known issues flagged again during Password input testing in:

Config snags

  1. Custom data attributes get merged into component configs 😬
  2. We flatten all configs but public API says “nested objects” only for i18n
  3. Dataset values like "2024" or "false" become number/boolean (even if you want strings)

Further reading

Example with custom attributes

Configuring Password input with formGroup.attributes

{{ govukPasswordInput({
  label: {
    text: "Password"
  },
  id: "password-input",
  name: "password",
  formGroup: {
    attributes: {
      "data-sneaky1": "Am I allowed?",
      "data-sneaky2": "Don’t think so"
    }
  }
}) }}

Before

Merged component config options are flattened and include other formGroup.attributes

E.g. Password input this.config shows module, sneaky and sneaky2 options:

{
  disableFormSubmitCheck: false,

  // All nested options are unexpectedly flattened ❌
  'i18n.hidePassword': 'Hide',
  'i18n.hidePasswordAriaLabel': 'Hide password',
  'i18n.passwordHiddenAnnouncement': 'Your password is hidden',
  'i18n.passwordShownAnnouncement': 'Your password is visible',
  'i18n.showPassword': 'Show',
  'i18n.showPasswordAriaLabel': 'Show password',
  
  // All non-config data-attributes are also merged in ❌
  module: 'govuk-password-input',
  sneaky1: 'Am I allowed?',
  sneaky2: 'Don’t think so'
}

After

Merged component config options are nested and match the schema

{
  disableFormSubmitCheck: false,
  i18n: {
    hidePassword: 'Hide',
    hidePasswordAriaLabel: 'Hide password',
    passwordHiddenAnnouncement: 'Your password is hidden',
    passwordShownAnnouncement: 'Your password is visible',
    showPassword: 'Show',
    showPasswordAriaLabel: 'Show password',
  }
}

@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-4792 February 23, 2024 17:40 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.44 KiB
packages/govuk-frontend/dist/govuk/all.bundle.js 81.25 KiB
packages/govuk-frontend/dist/govuk/all.bundle.mjs 76.31 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.42 KiB
packages/govuk-frontend/dist/govuk/i18n.mjs 5.55 KiB

Modules

File Size (bundled) Size (minified)
all.mjs 72.23 KiB 37.61 KiB
accordion.mjs 22.71 KiB 12.85 KiB
button.mjs 5.98 KiB 2.69 KiB
character-count.mjs 22.4 KiB 9.92 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 8ce1dc4

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..4fa94bfc7 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);
+                    "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 8ce1dc4

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..a83698cb6 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,28 +124,44 @@
     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 === '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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
@@ -123,26 +170,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 +248,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 +290,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 +485,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 +722,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 +785,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 +814,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 +849,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 +916,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 +929,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 +1142,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 +1305,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 +1370,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 +1427,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 +1592,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 +1605,13 @@
       pressOneMoreTime: 'Shift, press 1 more time to exit.'
     }
   });
+  ExitThisPage.schema = Object.freeze({
+    properties: {
+      i18n: {
+        type: 'object'
+      }
+    }
+  });
 
   /**
    * Header component
@@ -1623,7 +1726,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 +1742,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..7662df517 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,28 +118,44 @@ 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 === '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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
@@ -117,26 +164,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 +242,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 +284,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 +479,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 +716,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 +779,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 +808,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 +843,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 +910,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 +923,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 +1136,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 +1299,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 +1364,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 +1421,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 +1586,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 +1599,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 +1720,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 +1736,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..8b1e29b27 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,28 +89,44 @@ 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 === '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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
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..aa392371c 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,14 +80,28 @@
     }
     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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
@@ -65,26 +110,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 +182,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 +224,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 +419,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 +656,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 +719,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..74fcadec2 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,14 +74,28 @@ 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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
@@ -59,26 +104,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 +176,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 +218,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 +413,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 +650,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 +713,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..d67071a8f 100644
--- a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.js
@@ -4,45 +4,104 @@
   (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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
@@ -51,26 +110,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 +195,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 +230,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..38af24c35 100644
--- a/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/button/button.bundle.mjs
@@ -1,42 +1,101 @@
-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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
@@ -45,26 +104,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 +189,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 +224,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..baeeae62c 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,28 +89,44 @@
     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 === '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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
@@ -88,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;
   }
@@ -177,18 +213,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 +255,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 +440,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 +453,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 +666,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..473db45b5 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,28 +83,44 @@ 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 === '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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
@@ -82,26 +129,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 +207,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 +249,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 +434,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 +447,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 +660,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..e8fb370f5 100644
--- a/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.js
@@ -51,9 +51,17 @@
    * Schema for component config
    *
    * @typedef {object} Schema
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
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..8e414cdba 100644
--- a/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/checkboxes/checkboxes.bundle.mjs
@@ -45,9 +45,17 @@ function isSupported($scope = document.body) {
  * Schema for component config
  *
  * @typedef {object} Schema
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
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..32f091022 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,14 +110,28 @@
     }
     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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
@@ -81,26 +140,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 +225,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 +290,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..900032a18 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,14 +104,28 @@ 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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
@@ -75,26 +134,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 +219,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 +284,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..bf0fc9157 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,14 +80,28 @@
     }
     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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
@@ -65,26 +110,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 +182,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 +224,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 +404,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 +569,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 +582,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..34b812e2e 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,14 +74,28 @@ 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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
@@ -59,26 +104,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 +176,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 +218,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 +398,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 +563,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 +576,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..aa732880f 100644
--- a/packages/govuk-frontend/dist/govuk/components/header/header.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/header/header.bundle.js
@@ -23,9 +23,17 @@
    * Schema for component config
    *
    * @typedef {object} Schema
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
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..14d66876b 100644
--- a/packages/govuk-frontend/dist/govuk/components/header/header.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/header/header.bundle.mjs
@@ -17,9 +17,17 @@ function isSupported($scope = document.body) {
  * Schema for component config
  *
  * @typedef {object} Schema
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
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..e1450e006 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,14 +104,28 @@
     }
     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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
@@ -75,26 +134,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 +216,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 +232,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..b9bddd534 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,14 +98,28 @@ 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 {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
@@ -69,26 +128,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 +210,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 +226,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..4ade86db5 100644
--- a/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.js
@@ -51,9 +51,17 @@
    * Schema for component config
    *
    * @typedef {object} Schema
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
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..fcaf82356 100644
--- a/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/radios/radios.bundle.mjs
@@ -45,9 +45,17 @@ function isSupported($scope = document.body) {
  * Schema for component config
  *
  * @typedef {object} Schema
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
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..1e8879f6b 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,9 +45,17 @@
    * Schema for component config
    *
    * @typedef {object} Schema
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
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..0b6822202 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,9 +39,17 @@ function isSupported($scope = document.body) {
  * Schema for component config
  *
  * @typedef {object} Schema
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
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..63cce73bc 100644
--- a/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.js
+++ b/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.js
@@ -29,9 +29,17 @@
    * Schema for component config
    *
    * @typedef {object} Schema
+   * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
    * @property {SchemaCondition[]} [anyOf] - List of schema conditions
    */
 
+  /**
+   * Schema property for component config
+   *
+   * @typedef {object} SchemaProperty
+   * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+   */
+
   /**
    * Schema condition for component config
    *
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..01efd7654 100644
--- a/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.mjs
+++ b/packages/govuk-frontend/dist/govuk/components/tabs/tabs.bundle.mjs
@@ -23,9 +23,17 @@ function isSupported($scope = document.body) {
  * Schema for component config
  *
  * @typedef {object} Schema
+ * @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
  * @property {SchemaCondition[]} [anyOf] - List of schema conditions
  */
 
+/**
+ * Schema property for component config
+ *
+ * @typedef {object} SchemaProperty
+ * @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
+ */
+
 /**
  * Schema condition for component config
  *
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 8ce1dc4

@romaricpascal
Copy link
Member

romaricpascal commented Feb 26, 2024

A little thought raise by the following line in #4971:

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

With the move to nested configs rather than flattened, are we at risk of:

  • wiping nested values (i18n) with something like: data-i18n.messageKey="message" data-i18n
  • having different results for data-i18n data-i18n.messageKey="message"

Should we ignore empty values when merging, maybe?

@colinrotherham
Copy link
Contributor Author

colinrotherham commented Feb 26, 2024

Should we ignore empty values when merging, maybe?

@romaricpascal Ah we kept this as a feature though

E.g. Character Count with maxlength: 50 in Nunjucks overridden to maxlength: undefined, maxwords: 50 in JS

Update: See tests in 48213c0 for those at risk scenarios

Copy link
Member

@romaricpascal romaricpascal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like the direction, and I think we can sort the slight difference of behaviour with the flattened config in a separate PR (with the non-flattened one, shallower keys – when set –, override deeper ones, while this doesn't happen in the flattened one). Left a few questions/comments, but I don't think it's anything major (hopefully) 😊

@@ -86,7 +86,7 @@ export function extractConfigByNamespace(Component, dataset, namespace) {
// Drop down to what we assume is a nested object
// but check for object type on the next loop
if (index < keyParts.length - 1) {
current = current[name] = current[name] ?? {}
current = current[name] = isObject(current[name]) ? current[name] : {}
Copy link
Member

@romaricpascal romaricpascal Mar 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issue with the change of logic, but the commit attracted my attention to this line. I think it's compact but a bit tricky to follow. Can it be split into the following (which if I understood wrong further highlights the need to separate the statements 😆 )?

Suggested change
current = current[name] = isObject(current[name]) ? current[name] : {}
current[name] = isObject(current[name]) ? current[name] : {}
current = current[name]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@romaricpascal Found the perfect ESLint rule for you no-multi-assign

Will do

Copy link
Member

@romaricpascal romaricpascal Mar 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh dreamy, thanks for pointing me to it. I'll raise a separate PR to enable it (in case there's a couple multi-assign in the rest of the code).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 21bc20b with extra space for comments too

@colinrotherham
Copy link
Contributor Author

colinrotherham commented Mar 8, 2024

@romaricpascal last fixes are now in

Thanks for helping with the new tests in 4a40d63

Like the direction, and I think we can sort the slight difference of behaviour with the flattened config in a separate PR (with the non-flattened one, shallower keys – when set –, override deeper ones, while this doesn't happen in the flattened one). Left a few questions/comments, but I don't think it's anything major (hopefully) 😊

The only behaviour differences I can see are probably fixes we want to keep

Can you double check?

Behaviour differences

Comparing the new tests in 4a40d63 (nested configs) with 57391be (flat configs) shows we've resolved:

Bug 1: Stray dot-separated values were not discarded

For example l: "elk" was included alongside l: { e: "elephant" } previously:

<div id="app-example"
  data-f.l="elk"
  data-f.l.e="elephant">
</div>

// Before: Flat config behaviour
// expect(result).toEqual({ l: 'elk', 'l.e': 'elephant' })
// After: Nested config behaviour
expect(result).toEqual({ l: { e: 'elephant' } })
})

Bug 2: String values were hoisted into objects merged over them

For example "jellyfish" made its way into result.c previously:

<div id="app-example"
  data-c="jellyfish"
  data-c.a="cat"
  data-c.o="cow">
</div>

// Before: Flat config behaviour
// expect(result).toEqual({ a: 'cat', c: 'jellyfish', o: 'cow' })
// After: Nested config behaviour
expect(result).toEqual({ a: 'cat', o: 'cow' })
})

Bug 3: Objects merged over string values preserved the hoisted value, not the object value

For example c: { c: "ccrow" } was overwritten by c: "jellyfish" hoisted as c: { c: "jellyfish" }:

<div id="app-example"
  data-c.c="ccrow"
  data-c="jellyfish"
  data-c.a="cat"
  data-c.o="cow">
</div>

// Before: Flat config behaviour
// expect(result).toEqual({ a: 'cat', c: 'jellyfish', o: 'cow' })
// After: Nested config behaviour
expect(result).toEqual({ a: 'cat', c: 'ccrow', o: 'cow' })

@colinrotherham
Copy link
Contributor Author

@romaricpascal I should flip the params of these two to match?

normaliseDataset($module.dataset, Accordion)
extractConfigByNamespace(Accordion, $module.dataset, 'i18n')

Copy link
Member

@romaricpascal romaricpascal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to go for me! It's nice to have everything as objects in the config now.

@romaricpascal
Copy link
Member

@romaricpascal I should flip the params of these two to match?

normaliseDataset($module.dataset, Accordion)
extractConfigByNamespace(Accordion, $module.dataset, 'i18n')

@colinrotherham That sounds a sensible change so that both calls are consistent. Not blocking though if time is short.

colinrotherham and others added 7 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)
@govuk-design-system-ci govuk-design-system-ci temporarily deployed to govuk-frontend-pr-4792 March 8, 2024 16:47 Inactive
@colinrotherham colinrotherham merged commit 6aabc95 into main Mar 8, 2024
45 checks passed
@colinrotherham colinrotherham deleted the flatten-configs branch March 8, 2024 17:00
owenatgov pushed a commit that referenced this pull request Apr 4, 2024
Switch to nested (not flattened) configs with stricter checks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Prevent JavaScript component config being flattened
3 participants