From a392f942cebba70fbe565e1213ccc497a5c04d91 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 26 Oct 2026 10:20:03 +0000 Subject: [PATCH 1/3] Add a manifest of the page attributes the UI reads src/ui-attributes.json declares every attribute the templates and helpers read, with its type, allowed values or format, where it is set, and which files read it. It ships in the UI bundle and as a release asset so the docs language server can offer completion, hover, and diagnostics without copying template logic. tests/ui-attributes scans layouts, partials, helpers, and scripts and fails on an undeclared read, a declared attribute nothing reads, or a stale readBy. obj, add-suggested-labs, and list-related-labs now log and skip malformed JSON instead of throwing and failing the site build. --- .github/workflows/publish-release.yml | 2 +- .github/workflows/validate-build.yml | 4 + CLAUDE.md | 1 + gulp.d/tasks/build.js | 2 + package.json | 4 +- src/helpers/add-suggested-labs.js | 18 +- src/helpers/get-ai-suggestions.js | 6 +- src/helpers/list-related-labs.js | 18 +- src/helpers/obj.js | 16 +- src/ui-attributes.json | 1695 +++++++++++++++++ .../json-attribute-guard.test.js | 71 + tests/ui-attributes/scan-attribute-reads.js | 218 +++ tests/ui-attributes/ui-attributes.test.js | 108 ++ 13 files changed, 2154 insertions(+), 9 deletions(-) create mode 100644 src/ui-attributes.json create mode 100644 tests/json-attribute-guard/json-attribute-guard.test.js create mode 100644 tests/ui-attributes/scan-attribute-reads.js create mode 100644 tests/ui-attributes/ui-attributes.test.js diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index f971f247..17aed9ac 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -50,7 +50,7 @@ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | TAG="${{ github.ref_name }}" - ASSET=./build/ui-bundle.zip + ASSET="./build/ui-bundle.zip ./src/ui-attributes.json" FLAGS="--generate-notes" if [[ "$TAG" =~ - ]]; then FLAGS+=" --prerelease" diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml index 7ff38012..51db7611 100644 --- a/.github/workflows/validate-build.yml +++ b/.github/workflows/validate-build.yml @@ -95,6 +95,10 @@ jobs: run: npm run test:hero-scroll - name: Test docs page weight (lazy images, trimmed Prism, on-demand highlighting, head) run: npm run test:docs-page-weight + - name: Test the UI attribute manifest matches the templates + run: npm run test:ui-attributes + - name: Test malformed JSON attributes do not fail the build + run: npm run test:json-attribute-guard - name: Cache generated files uses: actions/cache@v4 with: diff --git a/CLAUDE.md b/CLAUDE.md index d466a5cb..fbb25819 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ The project uses Gulp 4 with task definitions in `gulpfile.js` and task implemen - `font/` - Web fonts - `img/` - Images and icons - `ui.yml` - Defines which static files to include in bundle +- `ui-attributes.json` - Every page and component attribute the templates and helpers read, with type, values, and readers. Ships in the bundle and as a release asset for the docs language server. When a template or helper starts or stops reading an attribute, update this file; `npm run test:ui-attributes` fails until it matches ### WebAssembly Integration Two WASM modules built from Go: diff --git a/gulp.d/tasks/build.js b/gulp.d/tasks/build.js index cb3659a6..c3de15c4 100644 --- a/gulp.d/tasks/build.js +++ b/gulp.d/tasks/build.js @@ -59,6 +59,8 @@ module.exports = (src, dest, preview) => () => { return merge( vfs.src('ui.yml', { ...opts, allowEmpty: true }), + // Machine-readable list of the page attributes the templates read, for the docs language server. + vfs.src('ui-attributes.json', opts), vfs .src('js/+([0-9])-*.js', { ...opts, read: false, sourcemaps }) .pipe(bundle(opts)) diff --git a/package.json b/package.json index 75b7f4d3..5ad6063e 100644 --- a/package.json +++ b/package.json @@ -96,8 +96,10 @@ "test:has-code-blocks": "node --test tests/has-code-blocks/*.test.js", "test:hero-scroll": "node --test tests/hero-scroll/*.test.js", "test:docs-page-weight": "node --test tests/docs-page-weight/*.test.js", + "test:ui-attributes": "node --test tests/ui-attributes/*.test.js", + "test:json-attribute-guard": "node --test tests/json-attribute-guard/*.test.js", "test:callout-preservation": "npx gulp generate:prism && node tests/callout-preservation/test-runner.js", - "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:property-tooltips && npm run test:signin-nudge && npm run test:chat-panel-navigation && npm run test:toc-collapsible && npm run test:toc-active-anchor && npm run test:nav-scroll && npm run test:kapa-source-groups && npm run test:tooltip-touch && npm run test:tooltip-single-open && npm run test:lazy-askai && npm run test:nav-lazy-buckets && npm run test:has-code-blocks && npm run test:docs-page-weight && npm run test:anon-quota && npm run test:callout-preservation", + "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:property-tooltips && npm run test:signin-nudge && npm run test:chat-panel-navigation && npm run test:toc-collapsible && npm run test:toc-active-anchor && npm run test:nav-scroll && npm run test:kapa-source-groups && npm run test:tooltip-touch && npm run test:tooltip-single-open && npm run test:lazy-askai && npm run test:nav-lazy-buckets && npm run test:has-code-blocks && npm run test:docs-page-weight && npm run test:ui-attributes && npm run test:json-attribute-guard && npm run test:anon-quota && npm run test:callout-preservation", "build:wasm": "cd blobl-editor/wasm && GOOS=js GOARCH=wasm go build -o ../../src/static/blobl.wasm .", "copy:wasm-exec": "cp \"$(go env GOROOT)/lib/wasm/wasm_exec.js\" src/js/vendor/", "serve:playground": "npx serve .", diff --git a/src/helpers/add-suggested-labs.js b/src/helpers/add-suggested-labs.js index a48d05a0..29f7311a 100644 --- a/src/helpers/add-suggested-labs.js +++ b/src/helpers/add-suggested-labs.js @@ -7,7 +7,7 @@ module.exports = (attributes, content, { data: { root } }) => { if (!contentCatalog) return content // Extract related labs from attributes - const relatedLabs = attributes['related-labs'] ? JSON.parse(attributes['related-labs']) : [] + const relatedLabs = parseRelatedLabs(attributes['related-labs'], root) if (!relatedLabs.length) return content @@ -55,3 +55,19 @@ module.exports = (attributes, content, { data: { root } }) => { const modifiedContent = Buffer.from(contentString, 'utf8') return modifiedContent } + +// Malformed page-related-labs JSON is logged and skipped instead of failing the build. +function parseRelatedLabs (value, root) { + if (!value) return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch (err) { + const { page, logger } = root || {} + const where = (page && page.src && page.src.path) || (page && page.relativeSrcPath) || 'unknown page' + const message = `${where}: ignoring malformed JSON in page-related-labs (${err.message})` + if (logger && typeof logger.warn === 'function') logger.warn(message) + else console.warn(`[add-suggested-labs] ${message}`) + return [] + } +} diff --git a/src/helpers/get-ai-suggestions.js b/src/helpers/get-ai-suggestions.js index be1686ba..2f75219a 100644 --- a/src/helpers/get-ai-suggestions.js +++ b/src/helpers/get-ai-suggestions.js @@ -37,9 +37,9 @@ module.exports = function (options) { // Collect up to 4 suggestions from component attributes for (let i = 1; i <= 4; i++) { - const key = `ai-suggestion-${i}` - if (attrs[key]) { - suggestions.push(attrs[key]) + const suggestion = attrs[`ai-suggestion-${i}`] + if (suggestion) { + suggestions.push(suggestion) } } diff --git a/src/helpers/list-related-labs.js b/src/helpers/list-related-labs.js index 95d7c0ff..e82e90a4 100644 --- a/src/helpers/list-related-labs.js +++ b/src/helpers/list-related-labs.js @@ -6,7 +6,7 @@ module.exports = (title, attributes, content, { data: { root } }) => { if (!contentCatalog) return content // Extract related labs from attributes - const relatedLabs = attributes['related-labs'] ? JSON.parse(attributes['related-labs']) : [] + const relatedLabs = parseRelatedLabs(attributes['related-labs'], root) if (!relatedLabs.length) return content let contentString = content.toString('utf8') @@ -20,3 +20,19 @@ module.exports = (title, attributes, content, { data: { root } }) => { const modifiedContent = Buffer.from(contentString, 'utf8') return modifiedContent } + +// Malformed page-related-labs JSON is logged and skipped instead of failing the build. +function parseRelatedLabs (value, root) { + if (!value) return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch (err) { + const { page, logger } = root || {} + const where = (page && page.src && page.src.path) || (page && page.relativeSrcPath) || 'unknown page' + const message = `${where}: ignoring malformed JSON in page-related-labs (${err.message})` + if (logger && typeof logger.warn === 'function') logger.warn(message) + else console.warn(`[list-related-labs] ${message}`) + return [] + } +} diff --git a/src/helpers/obj.js b/src/helpers/obj.js index 4e0a4279..dca6a765 100644 --- a/src/helpers/obj.js +++ b/src/helpers/obj.js @@ -1,8 +1,20 @@ 'use strict' -module.exports = (a) => { +// Parses a JSON attribute value, such as page-context-switcher, page-faqs, or +// toc-tools-links. Malformed JSON is logged and treated as empty so one bad +// attribute cannot fail the whole site build. +module.exports = (a, options) => { if (typeof a === 'undefined' || a === null) { return {} } - return JSON.parse(a) + try { + return JSON.parse(a) + } catch (err) { + const { page, logger } = (options && options.data && options.data.root) || {} + const where = (page && page.src && page.src.path) || (page && page.relativeSrcPath) || 'unknown page' + const message = `${where}: ignoring malformed JSON attribute value (${err.message}): ${String(a).slice(0, 80)}` + if (logger && typeof logger.warn === 'function') logger.warn(message) + else console.warn(`[obj] ${message}`) + return {} + } } diff --git a/src/ui-attributes.json b/src/ui-attributes.json new file mode 100644 index 00000000..710266d9 --- /dev/null +++ b/src/ui-attributes.json @@ -0,0 +1,1695 @@ +{ + "$comment": "Every AsciiDoc attribute the docs UI templates, helpers, and scripts read. tests/ui-attributes checks this file against the source: a read that is not declared here fails, and so does an entry nothing reads. Keys are the names writers type in a page header (page-*) or in antora.yml (component). {n} in a key stands for each number in range.", + "schemaVersion": 1, + "types": { + "string": "Plain text.", + "html": "Text rendered without escaping, so inline HTML works.", + "flag": "Tested for truthiness. See caution for how empty and false values behave.", + "boolean": "A YAML boolean in antora.yml.", + "boolean-string": "Compared with the exact string true.", + "enum": "One of values.", + "number": "An integer.", + "date": "A date in YYYY-MM-DD form.", + "color": "A six-digit hex color.", + "url": "An absolute or root-relative URL.", + "resource-id": "An Antora resource ID, root-relative path, or URL resolved by the resolve-resource helper.", + "list": "Several values in one string. format gives the separator.", + "json": "A JSON string. format gives the shape.", + "array": "A structured array, which only an extension or YAML can provide.", + "object": "A structured object, which only an extension or YAML can provide." + }, + "attributes": { + "agent-suggestion-{n}": { + "setIn": ["component"], + "setBy": "writer", + "type": "string", + "range": [1, 4], + "description": "Suggested agent prompts for the component, overriding the defaults.", + "readBy": ["src/helpers/get-agent-suggestions.js"] + }, + "ai-suggestion-{n}": { + "setIn": ["component"], + "setBy": "writer", + "type": "string", + "range": [1, 4], + "description": "Suggested Ask AI questions for the component. Defaults apply when none are set.", + "readBy": ["src/helpers/get-ai-suggestions.js"] + }, + "announcement": { + "setIn": ["component"], + "setBy": "writer", + "type": "flag", + "description": "Shows the announcement bar for this component version.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/partials/announcement-bar.hbs"] + }, + "announcement-link": { + "setIn": ["component"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Announcement bar link target.", + "readBy": ["src/partials/announcement-bar.hbs"] + }, + "announcement-link-text": { + "setIn": ["component"], + "setBy": "writer", + "type": "string", + "description": "Announcement bar link text.", + "readBy": ["src/partials/announcement-bar.hbs"] + }, + "announcement-text": { + "setIn": ["component"], + "setBy": "writer", + "type": "string", + "description": "Announcement bar text.", + "readBy": ["src/partials/announcement-bar.hbs"] + }, + "available-connect-version": { + "setIn": ["component"], + "setBy": "extension", + "type": "string", + "description": "Version tag used to build the property and connector dataset URLs in head metadata. Set by the version-fetcher and set-available-attachment-versions extensions.", + "readBy": ["src/partials/head-meta.hbs"] + }, + "available-properties-tag": { + "setIn": ["component"], + "setBy": "extension", + "type": "string", + "description": "Version tag used to build the property and connector dataset URLs in head metadata. Set by the version-fetcher and set-available-attachment-versions extensions.", + "readBy": ["src/partials/head-meta.hbs"] + }, + "component-metadata": { + "setIn": ["component"], + "setBy": "writer", + "type": "object", + "format": "YAML object in antora.yml", + "description": "Header and color settings for the component, such as color and heroGradient.", + "readBy": ["src/helpers/aggregate-whats-new.js", "src/helpers/get-component-color.js", "src/helpers/get-component-header-data.js", "src/helpers/get-header-color.js", "src/helpers/get-header-data.js", "src/helpers/get-hero-gradient.js", "src/helpers/get-whats-new-color.js", "src/helpers/sort-components.js", "src/helpers/test-home-component.js"] + }, + "component-whats-new-{n}-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 10], + "description": "Aggregated what's new: item desc, set on a component home page.", + "readBy": ["src/helpers/aggregate-whats-new.js"] + }, + "component-whats-new-{n}-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "range": [1, 10], + "description": "Aggregated what's new: item link, set on a component home page.", + "readBy": ["src/helpers/aggregate-whats-new.js"] + }, + "component-whats-new-{n}-tag": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 10], + "description": "Aggregated what's new: item tag, set on a component home page.", + "readBy": ["src/helpers/aggregate-whats-new.js"] + }, + "component-whats-new-{n}-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 10], + "description": "Aggregated what's new: item title, set on a component home page.", + "readBy": ["src/helpers/aggregate-whats-new.js"] + }, + "description": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Page description for meta tags, structured data, and page cards.", + "readBy": ["src/helpers/get-page-info.js"] + }, + "kapa-source-groups": { + "setIn": ["component", "site"], + "setBy": "writer", + "type": "json", + "format": "JSON; malformed values are skipped", + "description": "Kapa source groups that scope Ask AI retrieval. Checked on the component version, then the component, then the page, then the site.", + "readBy": ["src/helpers/get-kapa-source-groups.js"] + }, + "kapa_source_groups": { + "setIn": ["component", "site"], + "setBy": "writer", + "type": "json", + "description": "Alternative spelling of kapa-source-groups.", + "readBy": ["src/helpers/get-kapa-source-groups.js"] + }, + "latest-connect-version": { + "setIn": ["component"], + "setBy": "extension", + "type": "string", + "description": "Version tag used to build the property and connector dataset URLs in head metadata. Set by the version-fetcher and set-available-attachment-versions extensions.", + "readBy": ["src/partials/head-meta.hbs"] + }, + "latest-redpanda-tag": { + "setIn": ["component"], + "setBy": "extension", + "type": "string", + "description": "Version tag used to build the property and connector dataset URLs in head metadata. Set by the version-fetcher and set-available-attachment-versions extensions.", + "readBy": ["src/partials/head-meta.hbs"] + }, + "learning-objective-{n}": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Learning objectives listed in structured data as what the page teaches.", + "readBy": ["src/partials/head-structured-data.hbs"] + }, + "page-additional-resources": { + "setIn": ["page"], + "setBy": "writer", + "type": "array", + "format": "Array iterated with each. AsciiDoc header values are strings, so this must come from an extension or YAML.", + "description": "Links listed at the bottom of the On this page sidebar.", + "readBy": ["src/partials/toc.hbs"] + }, + "page-adp": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Marks an Agentic Data Plane page. Hides the Cloud and Self-Managed availability badges and enables the ADP deployment badges.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/partials/article.hbs", "src/partials/nav-tree.hbs"] + }, + "page-adp-docs": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Home page (role home): Agentic Data Plane docs link.", + "readBy": ["src/partials/home.hbs"] + }, + "page-adp-home": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Home page (role home): Agentic Data Plane home link.", + "readBy": ["src/partials/home.hbs"] + }, + "page-adp-quickstart": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Home page (role home): Agentic Data Plane quickstart link.", + "readBy": ["src/partials/home.hbs"] + }, + "page-agent-handoff": { + "setIn": ["page"], + "setBy": "writer", + "type": "enum", + "values": ["project", "ui"], + "description": "Adds the agent handoff action to the page and selects its mode. Defaults to project.", + "caution": "Unknown values fall back to project.", + "readBy": ["src/helpers/agent-handoff-mode.js", "src/helpers/has-agent-handoff.js"] + }, + "page-announcement": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Shows the announcement bar for this page.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/partials/announcement-bar.hbs"] + }, + "page-announcement-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Announcement bar link target.", + "readBy": ["src/partials/announcement-bar.hbs"] + }, + "page-announcement-link-text": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Announcement bar link text.", + "readBy": ["src/partials/announcement-bar.hbs"] + }, + "page-announcement-text": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Announcement bar text.", + "readBy": ["src/partials/announcement-bar.hbs"] + }, + "page-beta": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Shows a beta badge in the page header and marks the page beta in the navigation.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/helpers/is-beta-feature.js", "src/partials/article.hbs", "src/partials/component-home-v3.hbs"] + }, + "page-beta-text": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Tooltip text for the beta badge. Defaults to a generic beta notice.", + "readBy": ["src/partials/article.hbs"] + }, + "page-bloblang-samples": { + "setIn": ["page"], + "setBy": "extension", + "type": "json", + "format": "JSON object", + "description": "Samples for the Bloblang playground. Set by the collect-bloblang-samples extension.", + "caution": "Malformed JSON is logged and ignored.", + "readBy": ["src/partials/bloblang-playground.hbs"] + }, + "page-breadcrumb-hierarchy": { + "setIn": ["page"], + "setBy": "extension", + "type": "json", + "format": "JSON array", + "description": "Breadcrumb trail. Set by the unified-navigation extension.", + "readBy": ["src/partials/breadcrumbs.hbs"] + }, + "page-byoc": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Legacy BYOC badge on non-ADP pages.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/partials/article.hbs"] + }, + "page-byoc-only": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "On an ADP page, shows the BYOC-only badge. Also marks the page in the navigation.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/helpers/is-byoc-feature.js", "src/partials/article.hbs"] + }, + "page-byoc-text": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Tooltip text for the BYOC badge.", + "readBy": ["src/partials/article.hbs"] + }, + "page-categories": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Structured data keywords when the page has no keywords.", + "readBy": ["src/partials/head-structured-data.hbs"] + }, + "page-cloud": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Marks a Cloud page. Suppresses the \"view latest version\" banner and changes head metadata.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/partials/article.hbs", "src/partials/head-meta.hbs"] + }, + "page-cloud-available": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Shows an \"also available in Cloud\" badge linking to page-cloud-available-url.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/partials/article.hbs"] + }, + "page-cloud-available-url": { + "setIn": ["page"], + "setBy": "writer", + "type": "url", + "description": "Link target for the \"also available in Cloud\" badge.", + "readBy": ["src/partials/article.hbs"] + }, + "page-cloud-description": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Data Platform landing page: Cloud card description.", + "readBy": ["src/partials/data-platform.hbs"] + }, + "page-cloud-docs": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Home page (role home): Cloud docs link.", + "readBy": ["src/partials/home.hbs"] + }, + "page-cloud-home": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Data Platform landing page: Cloud card link.", + "readBy": ["src/partials/data-platform.hbs"] + }, + "page-cloud-only": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Shows the Cloud-only badge (or the Serverless and Dedicated badge on ADP pages) and marks the page in the navigation.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/helpers/is-cloud-feature.js", "src/partials/article.hbs"] + }, + "page-cloud-only-text": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Tooltip text for the Cloud-only badge.", + "readBy": ["src/partials/article.hbs"] + }, + "page-cloud-tagline": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Data Platform landing page: Cloud card tagline.", + "readBy": ["src/partials/data-platform.hbs"] + }, + "page-cloud-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Data Platform landing page: Cloud card title.", + "readBy": ["src/partials/data-platform.hbs"] + }, + "page-component-cards-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Landing pages (component-home-v3, data-platform): heading of the component cards.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-component-name": { + "setIn": ["page"], + "setBy": "antora", + "type": "string", + "description": "Antora sets this to the component name of the page. Suggested and related labs are skipped on labs pages.", + "readBy": ["src/helpers/add-suggested-labs.js", "src/helpers/list-related-labs.js"] + }, + "page-component-{n}-cta-text": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Landing pages: component card cta-text.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-component-{n}-deployments": { + "setIn": ["page"], + "setBy": "writer", + "type": "enum", + "values": ["all", "byoc"], + "range": [1, 3], + "description": "Landing pages: deployments the component supports. Anything other than all shows a deployment badge.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-component-{n}-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Landing pages: component card desc.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-component-{n}-features": { + "setIn": ["page"], + "setBy": "writer", + "type": "list", + "format": "Items separated by ;;", + "range": [1, 3], + "description": "Landing pages: component card features.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-component-{n}-is-new": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "range": [1, 3], + "description": "Landing pages: shows a NEW badge on the component card.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-component-{n}-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "range": [1, 3], + "description": "Landing pages: component card link.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-component-{n}-name": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Landing pages: component card name.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-concept-{n}-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 6], + "description": "Component home v3: concept card desc.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-concept-{n}-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "range": [1, 6], + "description": "Component home v3: concept card link.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-concept-{n}-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 6], + "description": "Component home v3: concept card title.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-concepts-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: heading of the concepts grid.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-connect-docs": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Home page (role home): Connect docs link.", + "readBy": ["src/partials/home.hbs"] + }, + "page-console-config-migrator": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Adds the Console config migrator tool to the page.", + "readBy": ["src/partials/article.hbs"] + }, + "page-context-switcher": { + "setIn": ["page"], + "setBy": "writer", + "type": "json", + "format": "JSON array of {name, to}; to is a resource ID or current", + "description": "Dropdown that switches between variants of the page. The process-context-switcher extension rewrites the to values.", + "caution": "Malformed JSON is logged and ignored.", + "readBy": ["src/partials/article.hbs", "src/partials/context-switcher.hbs"] + }, + "page-contribution": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Shows the edit and contribution links in the feedback footer.", + "caution": "Unset or the string false hides them. Any other value shows them.", + "readBy": ["src/partials/feedback-footer.hbs"] + }, + "page-custom-navigation": { + "setIn": ["page"], + "setBy": "extension", + "type": "json", + "format": "JSON array", + "description": "Navigation tree for unified navigation. Set by the unified-navigation extension.", + "readBy": ["src/partials/nav-menu-scroll.hbs", "src/partials/nav-menu.hbs"] + }, + "page-data-platform-home": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Home page (role home): Data Platform home link.", + "readBy": ["src/partials/home.hbs"] + }, + "page-deploy-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: heading of the deploy cards.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-deploy-{n}-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Component home v3: deploy card desc.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-deploy-{n}-icon": { + "setIn": ["page"], + "setBy": "writer", + "type": "enum", + "values": ["cloud", "lightning", "package", "plug", "plug-connected", "terminal"], + "range": [1, 3], + "description": "Component home v3: deploy card icon.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-deploy-{n}-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "range": [1, 3], + "description": "Component home v3: deploy card link.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-deploy-{n}-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Component home v3: deploy card title.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-deployment-cards-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Landing pages: heading of the deployment cards.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-deployment-{n}-cta-text": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Landing pages: deployment card cta-text.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-deployment-{n}-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Landing pages: deployment card desc.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-deployment-{n}-features": { + "setIn": ["page"], + "setBy": "writer", + "type": "list", + "format": "Items separated by ;;", + "range": [1, 3], + "description": "Landing pages: deployment card features.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-deployment-{n}-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "range": [1, 3], + "description": "Landing pages: deployment card link.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-deployment-{n}-name": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Landing pages: deployment card name.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-disable-property-tooltips": { + "setIn": ["page"], + "setBy": "extension", + "type": "flag", + "description": "Turns off property tooltips. Set by the set-available-attachment-versions extension.", + "readBy": ["src/partials/head-meta.hbs"] + }, + "page-enterprise": { + "setIn": ["page"], + "setBy": "writer", + "type": "boolean-string", + "description": "Read by the is-enterprise helper, which no template calls.", + "caution": "Only the exact string true turns it on.", + "readBy": ["src/helpers/is-enterprise.js"] + }, + "page-eol": { + "setIn": ["page", "component"], + "setBy": "writer", + "type": "boolean", + "description": "Legacy flag that marks a version end of life.", + "caution": "The EOL banner needs a YAML boolean true. The version selector also accepts the string true.", + "readBy": ["src/helpers/is-eol.js", "src/helpers/version-status.js", "src/partials/latest-banner.hbs"] + }, + "page-eol-date": { + "setIn": ["page", "component"], + "setBy": "extension", + "type": "date", + "description": "Computed end-of-life date shown in the EOL banner and version selector.", + "readBy": ["src/partials/latest-banner.hbs", "src/partials/version-selector.hbs"] + }, + "page-eol-doc": { + "setIn": ["page"], + "setBy": "extension", + "type": "url", + "description": "Link to the end-of-life policy shown in the EOL banner.", + "readBy": ["src/partials/latest-banner.hbs"] + }, + "page-exclude-from-dropdown-selector": { + "setIn": ["component"], + "setBy": "writer", + "type": "flag", + "description": "Set in antora.yml to leave a component out of the component dropdown.", + "readBy": ["src/helpers/length.js"] + }, + "page-exclude-related-labs": { + "setIn": ["page"], + "setBy": "writer", + "type": "boolean-string", + "description": "Turns off the Suggested labs section at the end of the page.", + "caution": "Only the exact string true turns it on.", + "readBy": ["src/helpers/add-suggested-labs.js"] + }, + "page-faqs": { + "setIn": ["page"], + "setBy": "extension", + "type": "json", + "format": "JSON array the extension builds from :page-faq-N-question: and :page-faq-N-answer:", + "description": "FAQ entries for FAQPage structured data. Set by the add-faq-structured-data extension.", + "caution": "Malformed JSON is logged and ignored.", + "readBy": ["src/partials/head-structured-data.hbs"] + }, + "page-feature-list": { + "setIn": ["page"], + "setBy": "writer", + "type": "array", + "description": "Component home v1: feature list.", + "readBy": ["src/partials/component-home.hbs"] + }, + "page-features-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: heading of the feature cards.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-features-{n}-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Component home v3: feature card desc.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-features-{n}-icon": { + "setIn": ["page"], + "setBy": "writer", + "type": "enum", + "values": ["chart", "flow", "plug"], + "range": [1, 3], + "description": "Component home v3: feature card icon.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-features-{n}-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "range": [1, 3], + "description": "Component home v3: feature card link.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-features-{n}-new": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "range": [1, 3], + "description": "Component home v3: shows a NEW badge on the feature card.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-features-{n}-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Component home v3: feature card title.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-footer-menu-list": { + "setIn": ["page"], + "setBy": "writer", + "type": "array", + "description": "Component home v1: footer menu.", + "readBy": ["src/partials/component-home.hbs"] + }, + "page-footer-note-intro": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: footer note lead-in.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-footer-note-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Component home v3: footer note link.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-footer-note-link-text": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: footer note link text.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-footer-note-suffix": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: footer note text after the link.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-footer-note-text": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: footer note text.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-git-created-date": { + "setIn": ["page"], + "setBy": "extension", + "type": "date", + "description": "First commit date of the page. Set by the add-git-dates extension.", + "readBy": ["src/partials/head-structured-data.hbs"] + }, + "page-git-modified-date": { + "setIn": ["page"], + "setBy": "extension", + "type": "date", + "description": "Last commit date of the page. Set by the add-git-dates extension.", + "readBy": ["src/partials/head-structured-data.hbs"] + }, + "page-has-custom-nav": { + "setIn": ["page"], + "setBy": "extension", + "type": "flag", + "description": "Set by the unified-navigation extension when page-custom-navigation is present.", + "readBy": ["src/partials/nav-menu-scroll.hbs", "src/partials/nav-menu.hbs"] + }, + "page-has-faqs": { + "setIn": ["page"], + "setBy": "extension", + "type": "flag", + "description": "Set by the add-faq-structured-data extension when page-faqs is present.", + "readBy": ["src/partials/head-structured-data.hbs"] + }, + "page-has-markdown": { + "setIn": ["page"], + "setBy": "extension", + "type": "flag", + "description": "Set by the convert-to-markdown extension when the page has a Markdown twin. Shows the Page options menu.", + "readBy": ["src/helpers/has-markdown.js"] + }, + "page-hero-ask-placeholder": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Landing pages: placeholder text in the hero Ask AI box.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs", "src/partials/home.hbs"] + }, + "page-hero-color": { + "setIn": ["page"], + "setBy": "writer", + "type": "color", + "format": "#RRGGBB", + "description": "Base color for the hero gradient.", + "readBy": ["src/helpers/get-hero-gradient.js"] + }, + "page-hero-description": { + "setIn": ["page"], + "setBy": "writer", + "type": "html", + "description": "Component home v3: hero lede. Required.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-hero-eyebrow": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): hero eyebrow text.", + "readBy": ["src/partials/home.hbs"] + }, + "page-hero-gradient": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "format": "CSS linear-gradient()", + "description": "CSS gradient that overrides the hero background.", + "readBy": ["src/helpers/get-hero-gradient.js"] + }, + "page-hero-stat2-number": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Data Platform landing page: second hero statistic.", + "readBy": ["src/partials/data-platform.hbs"] + }, + "page-hero-subtitle": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): hero subtitle.", + "readBy": ["src/partials/home.hbs"] + }, + "page-hero-suggestion-{n}": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Landing pages: suggested questions under the hero Ask AI box.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs", "src/partials/home.hbs"] + }, + "page-hero-suggestions-label": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Landing pages: label above the hero Ask AI suggestions.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs", "src/partials/home.hbs"] + }, + "page-hero-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Landing pages (home, component-home-v3): hero title.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/home.hbs"] + }, + "page-hide-view-latest": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Hides the \"view the latest version\" banner on older versions.", + "caution": "Any value, including an empty one, hides the banner.", + "readBy": ["src/partials/latest-banner.hbs"] + }, + "page-home-ai-summary": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v2: summary text.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-home-image": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v2: hero image file name in _images.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-home-image-alt": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v2: hero image alt text.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-home-intro": { + "setIn": ["page"], + "setBy": "writer", + "type": "html", + "description": "Component home v2: introduction.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-home-intro-learn-more": { + "setIn": ["page"], + "setBy": "writer", + "type": "url", + "description": "Component home v2: Learn more link.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-home-overview-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v2: overview heading.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-home-primary-row": { + "setIn": ["page"], + "setBy": "writer", + "type": "array", + "format": "Array of {url, title, description, link-text}", + "description": "Component home v2: primary row cards.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-home-primary-row-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v2: primary row heading.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-home-secondary-row": { + "setIn": ["page"], + "setBy": "writer", + "type": "array", + "format": "Array of {url, title, description, link-text}", + "description": "Component home v2: secondary row cards.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-home-secondary-row-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v2: secondary row heading.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-home-tertiary-row": { + "setIn": ["page"], + "setBy": "writer", + "type": "array", + "format": "Array of {url, title}", + "description": "Component home v2: tertiary row links.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-home-tertiary-row-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v2: tertiary row heading.", + "readBy": ["src/partials/component-home-v2.hbs"] + }, + "page-index-data": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Name of a component attribute that holds JSON index data for the index-list role.", + "readBy": ["src/helpers/get-index-data.js"] + }, + "page-intent-adp-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): Agentic Data Plane intent card description.", + "readBy": ["src/partials/home.hbs"] + }, + "page-intent-adp-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): Agentic Data Plane intent card title.", + "readBy": ["src/partials/home.hbs"] + }, + "page-intent-dp-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): Data Platform intent card description.", + "readBy": ["src/partials/home.hbs"] + }, + "page-intent-dp-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): Data Platform intent card title.", + "readBy": ["src/partials/home.hbs"] + }, + "page-intent-heading": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): heading of the intent cards.", + "readBy": ["src/partials/home.hbs"] + }, + "page-is-nearing-eol": { + "setIn": ["page"], + "setBy": "extension", + "type": "boolean-string", + "description": "Set to true when the version is close to end of life. Shows the nearing-EOL banner.", + "readBy": ["src/partials/latest-banner.hbs"] + }, + "page-is-past-eol": { + "setIn": ["page", "component"], + "setBy": "extension", + "type": "boolean-string", + "description": "Set to true when the version is past end of life. Shows the EOL banner.", + "readBy": ["src/helpers/is-eol.js", "src/partials/latest-banner.hbs"] + }, + "page-is-umbrella-nav": { + "setIn": ["page"], + "setBy": "extension", + "type": "flag", + "description": "Set by the unified-navigation extension on umbrella navigation pages.", + "readBy": ["src/partials/nav-bucket-recursive.hbs"] + }, + "page-is-unified-nav": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Shows the unified product switcher.", + "readBy": ["src/partials/product-switcher.hbs"] + }, + "page-kapa-source-groups": { + "setIn": ["page"], + "setBy": "writer", + "type": "json", + "description": "Page-level kapa-source-groups.", + "readBy": ["src/helpers/get-kapa-source-groups.js"] + }, + "page-kapa_source_groups": { + "setIn": ["page"], + "setBy": "writer", + "type": "json", + "description": "Page-level alternative spelling of kapa-source-groups.", + "readBy": ["src/helpers/get-kapa-source-groups.js"] + }, + "page-labs-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "url", + "description": "Component home v3: link to all labs.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-labs-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: heading of the labs section.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-labs-{n}-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "url", + "range": [1, 4], + "description": "Component home v3: lab link.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-labs-{n}-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 4], + "description": "Component home v3: lab title.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-limited-availability": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Shows a limited availability badge in the page header and navigation.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/helpers/is-limited-availability-feature.js", "src/partials/article.hbs"] + }, + "page-limited-availability-text": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Tooltip text for the limited availability badge.", + "readBy": ["src/partials/article.hbs"] + }, + "page-match-component-version": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "With page-index-data, keeps only entries from the same component version.", + "readBy": ["src/helpers/get-index-data.js"] + }, + "page-navtitle": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Alternative headline in structured data when it differs from the page title.", + "readBy": ["src/partials/head-structured-data.hbs"] + }, + "page-new": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Read by the is-new-resource helper, which no template calls.", + "readBy": ["src/helpers/is-new-resource.js"] + }, + "page-no-toc": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Hides the On this page table of contents.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/partials/article.hbs", "src/partials/toc.hbs"] + }, + "page-page-cloud-landing": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Data Platform landing page: Cloud landing link.", + "caution": "The template reads a doubled page- prefix, so the writer must type :page-page-cloud-landing:.", + "readBy": ["src/partials/data-platform.hbs"] + }, + "page-page-commercial-names": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Extra meta keywords.", + "caution": "The template reads a doubled page- prefix. The add-commercial-names tool writes :page-commercial-names:, which this read never sees.", + "readBy": ["src/partials/head-info.hbs"] + }, + "page-page-hero-eyebrow": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Labs home layout: hero eyebrow.", + "caution": "The template reads a doubled page- prefix, so the writer must type :page-page-hero-...:.", + "readBy": ["src/layouts/labs-home.hbs"] + }, + "page-page-hero-subtitle": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Labs home layout: hero subtitle.", + "caution": "The template reads a doubled page- prefix, so the writer must type :page-page-hero-...:.", + "readBy": ["src/layouts/labs-home.hbs"] + }, + "page-page-hero-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Labs home layout: hero title.", + "caution": "The template reads a doubled page- prefix, so the writer must type :page-page-hero-...:.", + "readBy": ["src/layouts/labs-home.hbs"] + }, + "page-pagination": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Turns on previous and next links at the bottom of the page.", + "caution": "Any value, including an empty one, turns it on. Leave it unset to turn it off.", + "readBy": ["src/partials/head-info.hbs", "src/partials/pagination.hbs"] + }, + "page-path-cloud-cta": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: Cloud column button text.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-path-cloud-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: Cloud column description.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-path-cloud-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Component home v3: Cloud column link.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-path-cloud-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: Cloud column title.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-path-cloud-{n}": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Component home v3: Cloud column bullet.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-path-self-cta": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: Self-Managed column button text.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-path-self-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: Self-Managed column description.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-path-self-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Component home v3: Self-Managed column link.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-path-self-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: Self-Managed column title.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-path-self-{n}": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 3], + "description": "Component home v3: Self-Managed column bullet.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-path-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: heading of the Self-Managed versus Cloud comparison. The comparison shows only when this is set.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-pathway-{n}-cta": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 2], + "description": "Component home v3: pathway card cta.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-pathway-{n}-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 2], + "description": "Component home v3: pathway card desc.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-pathway-{n}-icon": { + "setIn": ["page"], + "setBy": "writer", + "type": "enum", + "values": ["connect", "sparkles"], + "range": [1, 2], + "description": "Component home v3: pathway card icon.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-pathway-{n}-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "range": [1, 2], + "description": "Component home v3: pathway card link.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-pathway-{n}-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 2], + "description": "Component home v3: pathway card title.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-pathways-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: heading of the pathway cards.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-popular-heading": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Data Platform landing page: heading of the popular topics.", + "readBy": ["src/partials/data-platform.hbs"] + }, + "page-popular-subtitle": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Data Platform landing page: subtitle of the popular topics.", + "readBy": ["src/partials/data-platform.hbs"] + }, + "page-popular-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Component home v3: heading of the popular topics.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-popular-{n}-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 4], + "description": "Component home v3: popular topic description.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-popular-{n}-icon": { + "setIn": ["page"], + "setBy": "writer", + "type": "enum", + "values": ["book", "chart", "docker", "flow", "kubernetes", "lightning", "linux", "package", "plug", "sparkles", "terminal"], + "range": [1, 4], + "description": "Component home v3: popular topic icon.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-popular-{n}-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "range": [1, 6], + "description": "Landing pages: popular topic link.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-popular-{n}-tag": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 6], + "description": "Data Platform landing page: popular topic tag.", + "readBy": ["src/partials/data-platform.hbs"] + }, + "page-popular-{n}-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 6], + "description": "Landing pages: popular topic title.", + "readBy": ["src/partials/component-home-v3.hbs", "src/partials/data-platform.hbs"] + }, + "page-prerequisites": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Prerequisites for structured data.", + "readBy": ["src/partials/head-structured-data.hbs"] + }, + "page-preview": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Shows a preview badge in the page header and marks the page preview in the navigation.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/helpers/is-preview-feature.js", "src/partials/article.hbs"] + }, + "page-preview-text": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Tooltip text for the preview badge.", + "readBy": ["src/partials/article.hbs"] + }, + "page-product-cloud-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): cloud product card description.", + "readBy": ["src/partials/home.hbs"] + }, + "page-product-cloud-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): cloud product card title.", + "readBy": ["src/partials/home.hbs"] + }, + "page-product-connect-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): connect product card description.", + "readBy": ["src/partials/home.hbs"] + }, + "page-product-connect-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): connect product card title.", + "readBy": ["src/partials/home.hbs"] + }, + "page-product-self-managed-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): self-managed product card description.", + "readBy": ["src/partials/home.hbs"] + }, + "page-product-self-managed-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): self-managed product card title.", + "readBy": ["src/partials/home.hbs"] + }, + "page-product-streaming-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): streaming product card description.", + "readBy": ["src/partials/home.hbs"] + }, + "page-product-streaming-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Home page (role home): streaming product card title.", + "readBy": ["src/partials/home.hbs"] + }, + "page-related-docs": { + "setIn": ["page"], + "setBy": "extension", + "type": "json", + "format": "JSON array", + "description": "Related docs listed in the lab navigation. Set by the find-related-docs extension.", + "readBy": ["src/partials/nav-tree-lab.hbs"] + }, + "page-related-labs": { + "setIn": ["page"], + "setBy": "extension", + "type": "json", + "format": "JSON array of {title, url, description}", + "description": "Related labs for the Suggested labs section and the lab navigation. Set by the find-related-labs extension.", + "caution": "Malformed JSON is logged and ignored.", + "readBy": ["src/helpers/add-suggested-labs.js", "src/helpers/list-related-labs.js", "src/partials/nav-tree-lab.hbs"] + }, + "page-release-date": { + "setIn": ["page", "component"], + "setBy": "writer", + "type": "date", + "format": "YYYY-MM-DD", + "description": "Release date of the component version. Drives the EOL calculation and the structured-data datePublished fallback.", + "caution": "Set in antora.yml. The compute-end-of-life extension copies it onto pages.", + "readBy": ["src/helpers/is-eol.js", "src/helpers/version-status.js", "src/partials/head-structured-data.hbs", "src/partials/version-selector.hbs"] + }, + "page-role": { + "setIn": ["page"], + "setBy": "writer", + "type": "enum", + "values": ["home", "component-home", "component-home-v2", "component-home-v3", "bloblang-playground", "bloblang-snippets", "enable-ace-editor", "index-list", "related-labs", "two-column"], + "description": "Switches the page body to a special layout inside the default template. Also added as a CSS class on the body or main element.", + "caution": "An unknown value falls back silently to a normal article.", + "readBy": ["src/helpers/has-markdown.js", "src/layouts/default.hbs", "src/layouts/index.hbs", "src/layouts/lab.hbs", "src/partials/article.hbs", "src/partials/feedback-footer.hbs", "src/partials/head-info.hbs", "src/partials/head-scripts.hbs", "src/partials/header-scripts.hbs", "src/partials/main.hbs", "src/partials/pagination.hbs", "src/partials/product-switcher.hbs", "src/partials/toc.hbs"] + }, + "page-self-managed-available": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Shows an \"also available in Self-Managed\" badge linking to page-self-managed-available-url.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/partials/article.hbs"] + }, + "page-self-managed-available-url": { + "setIn": ["page"], + "setBy": "writer", + "type": "url", + "description": "Link target for the \"also available in Self-Managed\" badge.", + "readBy": ["src/partials/article.hbs"] + }, + "page-self-managed-docs": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Home page (role home): Self-Managed docs link.", + "readBy": ["src/partials/home.hbs"] + }, + "page-self-managed-home": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Data Platform landing page: Self-Managed card link.", + "readBy": ["src/partials/data-platform.hbs"] + }, + "page-self-managed-only": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Shows the Self-Managed-only badge.", + "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", + "readBy": ["src/partials/article.hbs"] + }, + "page-stats": { + "setIn": ["page"], + "setBy": "writer", + "type": "list", + "format": "value|label pairs separated by ;;", + "description": "Component home v3: hero statistics.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-status-badge": { + "setIn": ["page"], + "setBy": "writer", + "type": "html", + "description": "Component home v3: status chip next to the title, for example SELF-MANAGED • v25.1 | GA.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-support-months": { + "setIn": ["component"], + "setBy": "extension", + "type": "number", + "description": "Number of months a version is supported after its release date. Defaults to 12.", + "readBy": ["src/helpers/is-eol.js", "src/partials/version-selector.hbs"] + }, + "page-tabs": { + "setIn": ["page"], + "setBy": "writer", + "type": "list", + "format": "Labels separated by ;;", + "description": "Component home v3: tab labels.", + "readBy": ["src/partials/component-home-v3.hbs"] + }, + "page-toc-collapsible": { + "setIn": ["page"], + "setBy": "writer", + "type": "boolean-string", + "description": "Makes On this page groups collapsible.", + "caution": "Only the exact string true turns it on.", + "readBy": ["src/partials/toc.hbs"] + }, + "page-toclevels": { + "setIn": ["page"], + "setBy": "writer", + "type": "number", + "default": "2", + "description": "Section depth for the On this page table of contents. Defaults to 2.", + "readBy": ["src/partials/toc.hbs"] + }, + "page-toctitle": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Title of the On this page table of contents.", + "readBy": ["src/partials/toc.hbs"] + }, + "page-topic-type": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Topic type for structured data.", + "readBy": ["src/partials/head-structured-data.hbs"] + }, + "page-upgrade-doc": { + "setIn": ["page", "component"], + "setBy": "extension", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "Upgrade guide linked from the EOL banner and version selector.", + "readBy": ["src/partials/latest-banner.hbs", "src/partials/version-selector.hbs"] + }, + "page-version": { + "setIn": ["page"], + "setBy": "antora", + "type": "string", + "description": "Antora sets this to the component version of the page.", + "readBy": ["src/helpers/is-prerelease.js", "src/partials/nav-explore.hbs"] + }, + "page-whats-new-doc": { + "setIn": ["component"], + "setBy": "extension", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "description": "What's new page linked from the version selector.", + "readBy": ["src/partials/version-selector.hbs"] + }, + "page-whats-new-{n}-desc": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 10], + "description": "What's new section: item desc.", + "readBy": ["src/helpers/get-whats-new-items.js"] + }, + "page-whats-new-{n}-link": { + "setIn": ["page"], + "setBy": "writer", + "type": "resource-id", + "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", + "range": [1, 10], + "description": "What's new section: item link.", + "readBy": ["src/helpers/get-whats-new-color.js", "src/helpers/get-whats-new-items.js"] + }, + "page-whats-new-{n}-tag": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 10], + "description": "What's new section: item tag.", + "readBy": ["src/helpers/get-whats-new-items.js"] + }, + "page-whats-new-{n}-title": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "range": [1, 10], + "description": "What's new section: item title.", + "readBy": ["src/helpers/get-whats-new-items.js"] + }, + "personas": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Audience type in structured data.", + "readBy": ["src/partials/head-structured-data.hbs"] + }, + "primary-site-url": { + "setIn": ["component"], + "setBy": "writer", + "type": "url", + "description": "Primary site URL used to rewrite latest-version links when PRIMARY_SITE_SUPPORTS_CURRENT_URL is set.", + "readBy": ["src/helpers/latest-page-url.js"] + }, + "removals-without-aliases": { + "setIn": ["component"], + "setBy": "writer", + "type": "string", + "description": "Pages intentionally removed from the latest version, so the missing-page warning skips them.", + "readBy": ["src/helpers/log-missing.js"] + }, + "toc-tools-links": { + "setIn": ["component"], + "setBy": "writer", + "type": "json", + "format": "JSON array of {text, url, icon, external, description}; icon one of terminal, layers, book, link", + "description": "Links in the tools block.", + "caution": "Must be a JSON string, not a YAML array. Malformed JSON is logged and ignored.", + "readBy": ["src/partials/component-tools-fab.hbs", "src/partials/toc.hbs"] + }, + "toc-tools-title": { + "setIn": ["component"], + "setBy": "writer", + "type": "string", + "description": "Title of the tools block in the On this page sidebar and the mobile tools button.", + "readBy": ["src/partials/body.hbs", "src/partials/component-tools-fab.hbs", "src/partials/toc.hbs"] + } + } +} diff --git a/tests/json-attribute-guard/json-attribute-guard.test.js b/tests/json-attribute-guard/json-attribute-guard.test.js new file mode 100644 index 00000000..d5ecf046 --- /dev/null +++ b/tests/json-attribute-guard/json-attribute-guard.test.js @@ -0,0 +1,71 @@ +'use strict' + +// JSON-valued page attributes (page-context-switcher, page-faqs, +// toc-tools-links, page-bloblang-samples, page-related-labs) used to go through +// an unguarded JSON.parse, so one malformed value threw and failed the whole +// site build. The helpers now log a warning naming the page and treat the +// value as empty. + +const test = require('node:test') +const assert = require('node:assert/strict') +const path = require('node:path') + +const helpers = path.join(__dirname, '..', '..', 'src', 'helpers') +const obj = require(path.join(helpers, 'obj.js')) +const addSuggestedLabs = require(path.join(helpers, 'add-suggested-labs.js')) +const listRelatedLabs = require(path.join(helpers, 'list-related-labs.js')) + +function context () { + const warnings = [] + const root = { + page: { src: { path: 'modules/ROOT/pages/broken.adoc' } }, + logger: { warn: (msg) => warnings.push(msg) }, + contentCatalog: {}, + } + return { warnings, options: { data: { root } } } +} + +test('obj parses valid JSON', () => { + const { warnings, options } = context() + assert.deepEqual(obj('[{"name":"Cloud","to":"current"}]', options), [{ name: 'Cloud', to: 'current' }]) + assert.deepEqual(warnings, []) +}) + +test('obj returns an empty object for an unset value', () => { + assert.deepEqual(obj(undefined, context().options), {}) +}) + +test('obj warns and returns an empty object for malformed JSON', () => { + const { warnings, options } = context() + assert.deepEqual(obj('[{"name": "Cloud", to: current}]', options), {}) + assert.equal(warnings.length, 1) + assert.match(warnings[0], /modules\/ROOT\/pages\/broken\.adoc: ignoring malformed JSON attribute value/) +}) + +test('obj still warns when no logger is available', (t) => { + const warn = t.mock.method(console, 'warn', () => {}) + assert.deepEqual(obj('- text: YAML', { data: { root: {} } }), {}) + assert.equal(warn.mock.callCount(), 1) +}) + +for (const [name, call] of [ + ['add-suggested-labs', (attrs, content, options) => addSuggestedLabs(attrs, content, options)], + ['list-related-labs', (attrs, content, options) => listRelatedLabs('Title', attrs, content, options)], +]) { + test(`${name} appends valid related labs`, () => { + const { warnings, options } = context() + const labs = JSON.stringify([{ title: 'A lab', url: '/labs/a/', description: 'Does A' }]) + const out = call({ 'related-labs': labs }, Buffer.from('

body

'), options).toString() + assert.match(out, /A lab<\/a>/) + assert.deepEqual(warnings, []) + }) + + test(`${name} warns and leaves the content alone for malformed JSON`, () => { + const { warnings, options } = context() + const content = Buffer.from('

body

') + const out = call({ 'related-labs': '[{"title": "A lab",' }, content, options) + assert.equal(out.toString(), '

body

') + assert.equal(warnings.length, 1) + assert.match(warnings[0], /broken\.adoc: ignoring malformed JSON in page-related-labs/) + }) +} diff --git a/tests/ui-attributes/scan-attribute-reads.js b/tests/ui-attributes/scan-attribute-reads.js new file mode 100644 index 00000000..0fd3b971 --- /dev/null +++ b/tests/ui-attributes/scan-attribute-reads.js @@ -0,0 +1,218 @@ +'use strict' + +// Finds every AsciiDoc attribute the UI templates, helpers, and browser +// scripts read, and reports each one by the name a writer types (for +// example `page-beta`, not the `beta` key Antora exposes on page.attributes). +// +// ui-attributes.test.js compares the result with src/ui-attributes.json. +// +// Reads the scanner understands: +// - page.attributes.X, page.attributes.[X], page.attributes['X'], +// page.attributes?.['X'] (Antora strips the page- prefix, so the writer +// name is page-X) +// - .asciidoc.attributes.X and ['X'] (the writer name is X) +// - (page-attribute "X") in templates (the writer name is X) +// - template literals such as `whats-new-${i}-title`, reported as the +// pattern whats-new-{n}-title +// - helpers that receive an attributes object as a parameter, listed in +// INDIRECT below +// +// Anything it cannot classify is returned in `unclassified` so the test fails +// instead of silently missing a read. + +const fs = require('node:fs') +const path = require('node:path') + +const SRC = path.join(__dirname, '..', '..', 'src') + +// Helpers that read attribute keys from an object they are handed rather than +// from page.attributes directly. `vars` are the identifiers that hold the +// object; `prefixes` turn a key into the writer name (page.attributes keys +// need `page-`, asciidoc.attributes keys are used as is). +const INDIRECT = { + 'helpers/add-suggested-labs.js': { vars: ['attributes'], prefixes: ['page-'] }, + 'helpers/list-related-labs.js': { vars: ['attributes'], prefixes: ['page-'] }, + 'helpers/get-whats-new-color.js': { vars: ['attributes'], prefixes: ['page-'] }, + 'helpers/is-eol.js': { vars: ['input'], prefixes: [''] }, + 'helpers/version-status.js': { vars: ['attributes'], prefixes: [''] }, + // Checks component, page, and site attributes in turn. + 'helpers/get-kapa-source-groups.js': { vars: ['attrs'], prefixes: ['', 'page-'] }, + 'helpers/get-ai-suggestions.js': { vars: ['attrs'], prefixes: [''] }, + 'helpers/get-agent-suggestions.js': { vars: ['attrs'], prefixes: [''] }, + 'helpers/aggregate-whats-new.js': { vars: ['attrs'], prefixes: [''] }, +} + +// Template helpers that may receive a whole attributes object, mapped to the +// helper file that reads from it (which must be in INDIRECT). +const HELPERS_TAKING_ATTRIBUTES = { + 'add-suggested-labs': 'helpers/add-suggested-labs.js', + 'list-related-labs': 'helpers/list-related-labs.js', + 'get-whats-new-color': 'helpers/get-whats-new-color.js', + 'is-eol': 'helpers/is-eol.js', + 'version-status': 'helpers/version-status.js', +} + +const KEY = '[A-Za-z0-9_][A-Za-z0-9_-]*' +const QUOTED = '[\'"`]([^\'"`]+)[\'"`]' +// `?.` and `.` both allowed between the parts of an accessor chain. +const DOT = '\\??\\.' + +function listFiles (dir, exts) { + const out = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'vendor') continue + out.push(...listFiles(full, exts)) + } else if (exts.some((ext) => entry.name.endsWith(ext))) { + out.push(full) + } + } + return out +} + +function stripJsComments (text) { + return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') +} + +function stripHbsComments (text) { + return text.replace(/\{\{!--[\s\S]*?--\}\}/g, '').replace(/\{\{![\s\S]*?\}\}/g, '') +} + +// `whats-new-${i}-title` -> `whats-new-{n}-title` +function normalizeKey (key) { + return key.replace(/\$\{[^}]+\}/g, '{n}') +} + +function scan (srcDir = SRC) { + const reads = new Map() // writer name -> Set of files + const unclassified = [] + const add = (name, file) => { + if (!reads.has(name)) reads.set(name, new Set()) + reads.get(name).add(file) + } + + const templates = listFiles(path.join(srcDir, 'layouts'), ['.hbs']).concat( + listFiles(path.join(srcDir, 'partials'), ['.hbs']) + ) + for (const full of templates) { + const file = path.relative(srcDir, full).split(path.sep).join('/') + const text = stripHbsComments(fs.readFileSync(full, 'utf8')) + let m + const pageRe = new RegExp(`page\\.attributes\\.(?:\\[(${KEY})\\]|(${KEY}))`, 'g') + while ((m = pageRe.exec(text))) add(`page-${m[1] || m[2]}`, file) + const adocRe = new RegExp(`asciidoc\\.attributes\\.(?:\\[(${KEY})\\]|(${KEY}))`, 'g') + while ((m = adocRe.exec(text))) add(m[1] || m[2], file) + const helperRe = /\(page-attribute\s+["']([^"']+)["']/g + while ((m = helperRe.exec(text))) add(m[1], file) + // A whole attributes object handed to a helper or partial. + const bareRe = /(?:page|asciidoc)\.attributes(?![.\w[-])/g + while ((m = bareRe.exec(text))) { + const before = text.slice(0, m.index) + // The nearest enclosing `(helper ...)` or `{{helper ...}}`. + const open = Math.max(before.lastIndexOf('('), before.lastIndexOf('{{')) + const helper = open >= 0 ? (before.slice(open).match(/^(?:\(|\{\{[#^]?)([a-z][a-z-]*)\s/) || [])[1] : undefined + if (!helper || !HELPERS_TAKING_ATTRIBUTES[helper]) { + unclassified.push(`${file}: attributes object passed to ${helper || 'something'} the scanner does not know`) + } else if (!INDIRECT[HELPERS_TAKING_ATTRIBUTES[helper]]) { + unclassified.push(`${file}: helper ${helper} takes attributes but is missing from INDIRECT`) + } + } + } + + const scripts = listFiles(path.join(srcDir, 'helpers'), ['.js']).concat( + listFiles(path.join(srcDir, 'js'), ['.js', '.jsx']) + ) + for (const full of scripts) { + const file = path.relative(srcDir, full).split(path.sep).join('/') + const text = stripJsComments(fs.readFileSync(full, 'utf8')) + let m + const access = `(?:(?:\\?\\.)?\\[${QUOTED}\\]|${DOT}(${KEY}))` + const pageRe = new RegExp(`\\b(?:page|currentPage)${DOT}attributes${access}`, 'g') + while ((m = pageRe.exec(text))) add(`page-${normalizeKey(m[1] || m[2])}`, file) + const adocRe = new RegExp(`asciidoc${DOT}attributes${access}`, 'g') + while ((m = adocRe.exec(text))) add(normalizeKey(m[1] || m[2]), file) + + const indirect = INDIRECT[file] + if (indirect) { + for (const v of indirect.vars) { + const re = new RegExp(`(? manifest key + for (const [key, entry] of Object.entries(manifest.attributes)) { + for (const name of expand(key, entry)) owner.set(name, key) + } + const undeclared = [...reads.keys()].filter((name) => !owner.has(name)).sort() + + const unread = [] + const readByMismatch = [] + for (const [key, entry] of Object.entries(manifest.attributes)) { + const names = expand(key, entry) + const files = new Set() + for (const name of names) for (const f of reads.get(name) || []) files.add(`src/${f}`) + if (key.includes('{n}')) { + // A loop read (the pattern itself) covers the whole range. Otherwise + // every number in the range must be read, or the range is too wide. + if (!reads.has(key)) { + const missing = names.slice(1).filter((name) => !reads.has(name)) + if (missing.length) unread.push(`${key} (nothing reads ${missing.join(', ')})`) + } + } else if (!reads.has(key)) { + unread.push(key) + } + const expected = [...files].sort() + const actual = [...(entry.readBy || [])].sort() + if (expected.join('\n') !== actual.join('\n')) { + readByMismatch.push(`${key}: readBy should be ${JSON.stringify(expected)}`) + } + } + return { undeclared, unread, readByMismatch } +} + +module.exports = { scan, compare, expand, INDIRECT, HELPERS_TAKING_ATTRIBUTES } + +if (require.main === module) { + const { reads, unclassified } = scan() + for (const name of [...reads.keys()].sort()) console.log(`${name}\t${[...reads.get(name)].sort().join(', ')}`) + if (unclassified.length) console.error(unclassified.join('\n')) +} diff --git a/tests/ui-attributes/ui-attributes.test.js b/tests/ui-attributes/ui-attributes.test.js new file mode 100644 index 00000000..04112653 --- /dev/null +++ b/tests/ui-attributes/ui-attributes.test.js @@ -0,0 +1,108 @@ +'use strict' + +// Checks src/ui-attributes.json against the attributes the templates, +// helpers, and scripts actually read. The manifest ships in the UI bundle and +// as a release asset so the docs language server can offer completion, hover, +// and diagnostics for UI attributes without copying template logic. +// +// Fails when: +// - a template or helper reads an attribute the manifest does not declare +// - the manifest declares an attribute nothing reads +// - an entry's readBy list is out of date +// - the scanner meets a read it cannot classify + +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { scan, compare } = require('./scan-attribute-reads') + +const MANIFEST_PATH = path.join(__dirname, '..', '..', 'src', 'ui-attributes.json') +const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')) + +const SCOPES = ['page', 'component', 'site'] +const SETTERS = ['writer', 'extension', 'antora'] + +test('every entry is well formed', () => { + const problems = [] + for (const [key, entry] of Object.entries(manifest.attributes)) { + if (!manifest.types[entry.type]) problems.push(`${key}: unknown type ${entry.type}`) + if (!entry.description) problems.push(`${key}: no description`) + const setIn = Array.isArray(entry.setIn) ? entry.setIn : [] + if (!setIn.length || setIn.some((s) => !SCOPES.includes(s))) problems.push(`${key}: setIn must list ${SCOPES}`) + if (!SETTERS.includes(entry.setBy)) problems.push(`${key}: setBy must be one of ${SETTERS}`) + if (entry.type === 'enum' && !(Array.isArray(entry.values) && entry.values.length)) { + problems.push(`${key}: enum needs values`) + } + if (key.includes('{n}') !== Array.isArray(entry.range)) { + problems.push(`${key}: range goes with {n} keys and only those`) + } + } + assert.deepEqual(problems, []) +}) + +test('the scanner classifies every attribute read', () => { + assert.deepEqual(scan().unclassified, []) +}) + +test('every attribute the UI reads is declared, and every declared attribute is read', () => { + const result = compare(manifest, scan()) + assert.deepEqual(result.undeclared, [], 'Read by the UI but missing from src/ui-attributes.json') + assert.deepEqual(result.unread, [], 'Declared in src/ui-attributes.json but nothing reads it') + assert.deepEqual(result.readByMismatch, [], 'readBy is out of date') +}) + +// Negative controls: prove the checks above can fail. +function fakeSrc (files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-attributes-')) + for (const sub of ['layouts', 'partials', 'helpers', 'js']) fs.mkdirSync(path.join(dir, sub)) + for (const [rel, text] of Object.entries(files)) fs.writeFileSync(path.join(dir, rel), text) + return dir +} +const tiny = { + types: manifest.types, + attributes: { + 'page-beta': { setIn: ['page'], setBy: 'writer', type: 'flag', description: 'x', readBy: ['src/partials/a.hbs'] }, + 'page-popular-{n}-title': { + setIn: ['page'], setBy: 'writer', type: 'string', range: [1, 2], description: 'x', readBy: ['src/partials/a.hbs'], + }, + }, +} + +test('negative control: an undeclared read fails', () => { + const dir = fakeSrc({ + 'partials/a.hbs': + '{{page.attributes.beta}} {{page.attributes.popular-1-title}} {{page.attributes.popular-2-title}}' + + ' {{page.attributes.brand-new}}', + }) + const result = compare(tiny, scan(dir)) + assert.deepEqual(result.undeclared, ['page-brand-new']) + assert.deepEqual(result.unread, []) +}) + +test('negative control: a declared attribute nothing reads fails', () => { + const dir = fakeSrc({ 'partials/a.hbs': '{{page.attributes.popular-1-title}}' }) + const result = compare(tiny, scan(dir)) + assert.deepEqual(result.unread, ['page-beta', 'page-popular-{n}-title (nothing reads page-popular-2-title)']) +}) + +test('negative control: a stale readBy fails', () => { + const dir = fakeSrc({ + 'partials/a.hbs': '{{page.attributes.popular-1-title}} {{page.attributes.popular-2-title}}', + 'helpers/b.js': "module.exports = (page) => page.attributes['beta']\n", + }) + const result = compare(tiny, scan(dir)) + assert.deepEqual(result.readByMismatch, ['page-beta: readBy should be ["src/helpers/b.js"]']) +}) + +test('negative control: an attributes object passed to an unknown helper is flagged', () => { + const dir = fakeSrc({ 'partials/a.hbs': '{{#with (mystery page.attributes)}}{{/with}}' }) + assert.equal(scan(dir).unclassified.length, 1) +}) + +test('negative control: a computed key in an indirect helper is flagged', () => { + // add-suggested-labs.js is listed in INDIRECT, so a computed key there is caught. + const dir = fakeSrc({ 'helpers/add-suggested-labs.js': 'module.exports = (attributes, k) => attributes[k]\n' }) + assert.match(scan(dir).unclassified.join('\n'), /computed attribute key/) +}) From 2e2de5c96db0f9849b1d23f5f4b029f558ab8ccc Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Mon, 26 Oct 2026 13:35:00 +0000 Subject: [PATCH 2/3] Fix page- prefixed attribute reads and drop unused helpers Antora strips the page- prefix when it builds page.attributes, so head-info.hbs never rendered :page-commercial-names: as keywords, and labs-home.hbs and data-platform.hbs never saw :page-hero-*: or :page-cloud-landing:. Read the unprefixed names and test the keywords and labs hero output. Remove the is-enterprise and is-new-resource helpers, which no template calls, and update the attribute manifest to match. Replace t.mock.method in the JSON guard test, which needs Node 18.13, with a console.warn override. --- .github/workflows/validate-build.yml | 2 + CLAUDE.md | 2 +- package.json | 3 +- preview-src/connect-test.adoc | 1 + src/helpers/is-enterprise.js | 25 ------ src/helpers/is-new-resource.js | 40 --------- src/layouts/labs-home.hbs | 6 +- src/partials/data-platform.hbs | 2 +- src/partials/head-info.hbs | 4 +- src/ui-attributes.json | 84 +++++-------------- .../json-attribute-guard.test.js | 14 +++- .../page-attribute-prefix.test.js | 62 ++++++++++++++ 12 files changed, 106 insertions(+), 139 deletions(-) delete mode 100644 src/helpers/is-enterprise.js delete mode 100644 src/helpers/is-new-resource.js create mode 100644 tests/page-attribute-prefix/page-attribute-prefix.test.js diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml index 51db7611..ceccf02e 100644 --- a/.github/workflows/validate-build.yml +++ b/.github/workflows/validate-build.yml @@ -97,6 +97,8 @@ jobs: run: npm run test:docs-page-weight - name: Test the UI attribute manifest matches the templates run: npm run test:ui-attributes + - name: Test templates read page attributes without the page- prefix + run: npm run test:page-attribute-prefix - name: Test malformed JSON attributes do not fail the build run: npm run test:json-attribute-guard - name: Cache generated files diff --git a/CLAUDE.md b/CLAUDE.md index fbb25819..9946080e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,7 @@ The `compile-partial.js` script compiles specific Handlebars partials into stand Context is provided via JSON files in `context/` directory. ### Handlebars Templates -Layouts select partials to compose pages. Helpers in `src/helpers/` provide template logic (e.g., `is-beta-feature.js`, `is-enterprise.js`, `get-page-info.js`). Pages specify layout via `page-layout` AsciiDoc attribute. +Layouts select partials to compose pages. Helpers in `src/helpers/` provide template logic (e.g., `is-beta-feature.js`, `get-page-info.js`). Pages specify layout via `page-layout` AsciiDoc attribute. ### Preview System `preview-src/` contains AsciiDoc sample pages for testing UI locally. UI model is defined in `preview-src/ui-model.yml`. Preview pages are built with Asciidoctor and rendered using the UI templates. diff --git a/package.json b/package.json index 5ad6063e..acddd796 100644 --- a/package.json +++ b/package.json @@ -97,9 +97,10 @@ "test:hero-scroll": "node --test tests/hero-scroll/*.test.js", "test:docs-page-weight": "node --test tests/docs-page-weight/*.test.js", "test:ui-attributes": "node --test tests/ui-attributes/*.test.js", + "test:page-attribute-prefix": "node --test tests/page-attribute-prefix/*.test.js", "test:json-attribute-guard": "node --test tests/json-attribute-guard/*.test.js", "test:callout-preservation": "npx gulp generate:prism && node tests/callout-preservation/test-runner.js", - "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:property-tooltips && npm run test:signin-nudge && npm run test:chat-panel-navigation && npm run test:toc-collapsible && npm run test:toc-active-anchor && npm run test:nav-scroll && npm run test:kapa-source-groups && npm run test:tooltip-touch && npm run test:tooltip-single-open && npm run test:lazy-askai && npm run test:nav-lazy-buckets && npm run test:has-code-blocks && npm run test:docs-page-weight && npm run test:ui-attributes && npm run test:json-attribute-guard && npm run test:anon-quota && npm run test:callout-preservation", + "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:property-tooltips && npm run test:signin-nudge && npm run test:chat-panel-navigation && npm run test:toc-collapsible && npm run test:toc-active-anchor && npm run test:nav-scroll && npm run test:kapa-source-groups && npm run test:tooltip-touch && npm run test:tooltip-single-open && npm run test:lazy-askai && npm run test:nav-lazy-buckets && npm run test:has-code-blocks && npm run test:docs-page-weight && npm run test:ui-attributes && npm run test:page-attribute-prefix && npm run test:json-attribute-guard && npm run test:anon-quota && npm run test:callout-preservation", "build:wasm": "cd blobl-editor/wasm && GOOS=js GOARCH=wasm go build -o ../../src/static/blobl.wasm .", "copy:wasm-exec": "cp \"$(go env GOROOT)/lib/wasm/wasm_exec.js\" src/js/vendor/", "serve:playground": "npx serve .", diff --git a/preview-src/connect-test.adoc b/preview-src/connect-test.adoc index 5d5665cd..1f8b88e7 100644 --- a/preview-src/connect-test.adoc +++ b/preview-src/connect-test.adoc @@ -1,6 +1,7 @@ = Redpanda Connect :page-layout: component-home-v3 :page-role: component-home-v3 +:page-commercial-names: Apache Kafka, Kafka :page-hero-color: #A21CAF :hero-description: Stream processing and data integration with 200+ connectors. Build real-time pipelines without writing code. :hero-ask-placeholder: Ask AI about Redpanda Connect... diff --git a/src/helpers/is-enterprise.js b/src/helpers/is-enterprise.js deleted file mode 100644 index 21a91d72..00000000 --- a/src/helpers/is-enterprise.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict' - -// Cache: Map -let urlCache = null -let cachedComponent = null - -module.exports = (navUrl, { data: { root } }) => { - const { contentCatalog, page } = root - if (!contentCatalog) return false - - // Build URL map once per component (O(n) once, then O(1) lookups) - if (cachedComponent !== page.component.name) { - urlCache = new Map() - cachedComponent = page.component.name - - const pages = contentCatalog.findBy({ component: page.component.name, family: 'page' }) - for (const p of pages) { - if (p.pub?.url) { - urlCache.set(p.pub.url, p.asciidoc?.attributes?.['page-enterprise'] === 'true') - } - } - } - - return urlCache.get(navUrl) || false -} diff --git a/src/helpers/is-new-resource.js b/src/helpers/is-new-resource.js deleted file mode 100644 index b2ee63c5..00000000 --- a/src/helpers/is-new-resource.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict' - -// Cache: Map -let urlCache = null - -/** - * Checks if a resource URL points to a page with page-new attribute - * Usage: {{#if (is-new-resource url)}}...{{/if}} - * - * @param {string} resourceUrl - The resolved URL to check - * @param {object} options - Handlebars context - * @returns {boolean} True if the page has page-new attribute - */ -module.exports = (resourceUrl, { data: { root } }) => { - const { contentCatalog, page } = root - if (page.layout === '404') return false - if (!contentCatalog) return false - if (!resourceUrl || typeof resourceUrl !== 'string') return false - - // Build URL map once for all pages (O(n) once, O(1) lookups) - if (!urlCache) { - urlCache = new Map() - - const pages = contentCatalog.findBy({ family: 'page' }) - for (const p of pages) { - if (p.pub?.url) { - urlCache.set(p.pub.url, !!p.asciidoc?.attributes?.['page-new']) - } - } - } - - // Normalize URL (handle both /path and path formats) - // Additional safety check before calling string methods - if (typeof resourceUrl !== 'string' || !resourceUrl) return false - - const normalizedUrl = resourceUrl.startsWith('/') ? resourceUrl.slice(1) : resourceUrl - const checkUrl = `/${normalizedUrl}` - - return urlCache.get(checkUrl) || urlCache.get(resourceUrl) || false -} diff --git a/src/layouts/labs-home.hbs b/src/layouts/labs-home.hbs index 0947ab65..aa6ca23c 100644 --- a/src/layouts/labs-home.hbs +++ b/src/layouts/labs-home.hbs @@ -115,11 +115,11 @@
- {{#if page.attributes.page-hero-eyebrow}}{{page.attributes.page-hero-eyebrow}}{{else}}Redpanda Labs{{/if}} + {{#if page.attributes.hero-eyebrow}}{{page.attributes.hero-eyebrow}}{{else}}Redpanda Labs{{/if}}
-

{{#if page.attributes.page-hero-title}}{{page.attributes.page-hero-title}}{{else}}Real projects you can build with Redpanda.{{/if}}

+

{{#if page.attributes.hero-title}}{{page.attributes.hero-title}}{{else}}Real projects you can build with Redpanda.{{/if}}

- {{#if page.attributes.page-hero-subtitle}}{{page.attributes.page-hero-subtitle}}{{else}}Hands-on tutorials and example applications that show what's possible with Redpanda. Clone a repo, run the code, and learn by doing.{{/if}} + {{#if page.attributes.hero-subtitle}}{{page.attributes.hero-subtitle}}{{else}}Hands-on tutorials and example applications that show what's possible with Redpanda. Clone a repo, run the code, and learn by doing.{{/if}}

diff --git a/src/partials/data-platform.hbs b/src/partials/data-platform.hbs index bbefb25e..ab94dca9 100644 --- a/src/partials/data-platform.hbs +++ b/src/partials/data-platform.hbs @@ -62,7 +62,7 @@ {{!-- Cloud landing: component cards --}} - {{#if page.attributes.page-cloud-landing}} + {{#if page.attributes.cloud-landing}}

{{#if page.attributes.component-cards-title}}{{page.attributes.component-cards-title}}{{else}}Choose your component{{/if}}

diff --git a/src/partials/head-info.hbs b/src/partials/head-info.hbs index ceb3ddea..513a73ea 100644 --- a/src/partials/head-info.hbs +++ b/src/partials/head-info.hbs @@ -25,8 +25,8 @@ {{/if}} - {{#if (or page.keywords page.attributes.page-commercial-names)}} - + {{#if (or page.keywords page.attributes.commercial-names)}} + {{/if}} {{#with (or antoraVersion site.antoraVersion)}} diff --git a/src/ui-attributes.json b/src/ui-attributes.json index 710266d9..044f458d 100644 --- a/src/ui-attributes.json +++ b/src/ui-attributes.json @@ -351,6 +351,14 @@ "description": "Data Platform landing page: Cloud card link.", "readBy": ["src/partials/data-platform.hbs"] }, + "page-cloud-landing": { + "setIn": ["page"], + "setBy": "writer", + "type": "flag", + "description": "Data Platform landing page: shows the component cards section.", + "caution": "Any non-empty value turns it on.", + "readBy": ["src/partials/data-platform.hbs"] + }, "page-cloud-only": { "setIn": ["page"], "setBy": "writer", @@ -380,6 +388,14 @@ "description": "Data Platform landing page: Cloud card title.", "readBy": ["src/partials/data-platform.hbs"] }, + "page-commercial-names": { + "setIn": ["page"], + "setBy": "writer", + "type": "list", + "format": "Names separated by commas", + "description": "Commercial names for a Connect component, appended to the meta keywords. The add-commercial-names tool writes it, and the generate-rp-connect-info extension adds names from the connector catalog.", + "readBy": ["src/partials/head-info.hbs"] + }, "page-component-cards-title": { "setIn": ["page"], "setBy": "writer", @@ -630,14 +646,6 @@ "description": "Turns off property tooltips. Set by the set-available-attachment-versions extension.", "readBy": ["src/partials/head-meta.hbs"] }, - "page-enterprise": { - "setIn": ["page"], - "setBy": "writer", - "type": "boolean-string", - "description": "Read by the is-enterprise helper, which no template calls.", - "caution": "Only the exact string true turns it on.", - "readBy": ["src/helpers/is-enterprise.js"] - }, "page-eol": { "setIn": ["page", "component"], "setBy": "writer", @@ -844,8 +852,8 @@ "setIn": ["page"], "setBy": "writer", "type": "string", - "description": "Home page (role home): hero eyebrow text.", - "readBy": ["src/partials/home.hbs"] + "description": "Landing pages (home, labs home): hero eyebrow text.", + "readBy": ["src/layouts/labs-home.hbs", "src/partials/home.hbs"] }, "page-hero-gradient": { "setIn": ["page"], @@ -866,8 +874,8 @@ "setIn": ["page"], "setBy": "writer", "type": "string", - "description": "Home page (role home): hero subtitle.", - "readBy": ["src/partials/home.hbs"] + "description": "Landing pages (home, labs home): hero subtitle.", + "readBy": ["src/layouts/labs-home.hbs", "src/partials/home.hbs"] }, "page-hero-suggestion-{n}": { "setIn": ["page"], @@ -888,8 +896,8 @@ "setIn": ["page"], "setBy": "writer", "type": "string", - "description": "Landing pages (home, component-home-v3): hero title.", - "readBy": ["src/partials/component-home-v3.hbs", "src/partials/home.hbs"] + "description": "Landing pages (home, component-home-v3, labs home): hero title.", + "readBy": ["src/layouts/labs-home.hbs", "src/partials/component-home-v3.hbs", "src/partials/home.hbs"] }, "page-hide-view-latest": { "setIn": ["page"], @@ -1129,13 +1137,6 @@ "description": "Alternative headline in structured data when it differs from the page title.", "readBy": ["src/partials/head-structured-data.hbs"] }, - "page-new": { - "setIn": ["page"], - "setBy": "writer", - "type": "flag", - "description": "Read by the is-new-resource helper, which no template calls.", - "readBy": ["src/helpers/is-new-resource.js"] - }, "page-no-toc": { "setIn": ["page"], "setBy": "writer", @@ -1144,47 +1145,6 @@ "caution": "Any non-empty value turns it on. An empty value is off, and so is leaving it unset. The string false still counts as on.", "readBy": ["src/partials/article.hbs", "src/partials/toc.hbs"] }, - "page-page-cloud-landing": { - "setIn": ["page"], - "setBy": "writer", - "type": "resource-id", - "format": "An Antora resource ID (for example streaming:get-started:quick-start.adoc), a root-relative path, or an absolute URL. Resolved by the resolve-resource helper, which logs a warning when the target does not exist.", - "description": "Data Platform landing page: Cloud landing link.", - "caution": "The template reads a doubled page- prefix, so the writer must type :page-page-cloud-landing:.", - "readBy": ["src/partials/data-platform.hbs"] - }, - "page-page-commercial-names": { - "setIn": ["page"], - "setBy": "writer", - "type": "string", - "description": "Extra meta keywords.", - "caution": "The template reads a doubled page- prefix. The add-commercial-names tool writes :page-commercial-names:, which this read never sees.", - "readBy": ["src/partials/head-info.hbs"] - }, - "page-page-hero-eyebrow": { - "setIn": ["page"], - "setBy": "writer", - "type": "string", - "description": "Labs home layout: hero eyebrow.", - "caution": "The template reads a doubled page- prefix, so the writer must type :page-page-hero-...:.", - "readBy": ["src/layouts/labs-home.hbs"] - }, - "page-page-hero-subtitle": { - "setIn": ["page"], - "setBy": "writer", - "type": "string", - "description": "Labs home layout: hero subtitle.", - "caution": "The template reads a doubled page- prefix, so the writer must type :page-page-hero-...:.", - "readBy": ["src/layouts/labs-home.hbs"] - }, - "page-page-hero-title": { - "setIn": ["page"], - "setBy": "writer", - "type": "string", - "description": "Labs home layout: hero title.", - "caution": "The template reads a doubled page- prefix, so the writer must type :page-page-hero-...:.", - "readBy": ["src/layouts/labs-home.hbs"] - }, "page-pagination": { "setIn": ["page"], "setBy": "writer", diff --git a/tests/json-attribute-guard/json-attribute-guard.test.js b/tests/json-attribute-guard/json-attribute-guard.test.js index d5ecf046..729d56de 100644 --- a/tests/json-attribute-guard/json-attribute-guard.test.js +++ b/tests/json-attribute-guard/json-attribute-guard.test.js @@ -42,10 +42,16 @@ test('obj warns and returns an empty object for malformed JSON', () => { assert.match(warnings[0], /modules\/ROOT\/pages\/broken\.adoc: ignoring malformed JSON attribute value/) }) -test('obj still warns when no logger is available', (t) => { - const warn = t.mock.method(console, 'warn', () => {}) - assert.deepEqual(obj('- text: YAML', { data: { root: {} } }), {}) - assert.equal(warn.mock.callCount(), 1) +test('obj still warns when no logger is available', () => { + const original = console.warn + let calls = 0 + console.warn = () => { calls++ } + try { + assert.deepEqual(obj('- text: YAML', { data: { root: {} } }), {}) + } finally { + console.warn = original + } + assert.equal(calls, 1) }) for (const [name, call] of [ diff --git a/tests/page-attribute-prefix/page-attribute-prefix.test.js b/tests/page-attribute-prefix/page-attribute-prefix.test.js new file mode 100644 index 00000000..725302ba --- /dev/null +++ b/tests/page-attribute-prefix/page-attribute-prefix.test.js @@ -0,0 +1,62 @@ +'use strict' + +// Antora drops the page- prefix when it builds page.attributes: a page that +// sets :page-commercial-names: is read as page.attributes.commercial-names +// (@antora/page-composer build-ui-model.js). head-info.hbs and labs-home.hbs +// once read page.attributes.page-*, so the keywords and hero text that pages +// set never rendered. These tests build page.attributes the way Antora does +// and render the real template blocks. + +const test = require('node:test') +const assert = require('node:assert/strict') +const path = require('node:path') +const fs = require('node:fs') +const Handlebars = require('handlebars') + +const ROOT = path.join(__dirname, '..', '..') +Handlebars.registerHelper('or', require(path.join(ROOT, 'src/helpers/or.js'))) + +// Same mapping as Antora's build-ui-model.js. +const pageAttributes = (asciidocAttributes) => { + const attributes = {} + for (const [name, val] of Object.entries(asciidocAttributes)) { + if (name.startsWith('page-')) attributes[name.substr(5)] = val + } + return attributes +} + +const block = (file, from, to) => { + const src = fs.readFileSync(path.join(ROOT, file), 'utf8') + const start = src.indexOf(from) + const end = src.indexOf(to, start) + assert.ok(start >= 0 && end > start, `${file} changed under this test: could not find ${from} .. ${to}`) + return Handlebars.compile(src.slice(start, end)) +} + +const KEYWORDS = block('src/partials/head-info.hbs', '{{#if (or page.keywords', '{{#with (or antoraVersion') +const LABS_HERO = block('src/layouts/labs-home.hbs', '
', '
') + +test('head-info renders :page-commercial-names: as meta keywords', () => { + const html = KEYWORDS({ page: { attributes: pageAttributes({ 'page-commercial-names': 'Apache Kafka, Kafka' }) } }) + assert.match(html, //) +}) + +test('head-info joins page keywords and commercial names', () => { + const page = { keywords: 'franz', attributes: pageAttributes({ 'page-commercial-names': 'Apache Kafka' }) } + assert.match(KEYWORDS({ page }), //) +}) + +test('labs-home renders the :page-hero-*: attributes', () => { + const html = LABS_HERO({ + page: { + attributes: pageAttributes({ + 'page-hero-eyebrow': 'Eyebrow X', + 'page-hero-title': 'Title X', + 'page-hero-subtitle': 'Subtitle X', + }), + }, + }) + assert.match(html, /Eyebrow X/) + assert.match(html, /

Title X<\/h1>/) + assert.match(html, /Subtitle X/) +}) From dea872674cf552ae1476512e56f74092d9cb0402 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 27 Oct 2026 10:35:57 +0000 Subject: [PATCH 3/3] Declare the introduced-in and latest-release attributes from main Main added page-introduced-in, page-introduced-in-product, and a head-meta.hbs block that reads latest-* attributes inside {{#with ...attributes}}. Teach the scanner that form and declare the new reads in the manifest. --- src/ui-attributes.json | 39 +++++++++++++++++++-- tests/ui-attributes/scan-attribute-reads.js | 30 +++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/ui-attributes.json b/src/ui-attributes.json index 044f458d..cee86af1 100644 --- a/src/ui-attributes.json +++ b/src/ui-attributes.json @@ -146,14 +146,35 @@ "setIn": ["component"], "setBy": "extension", "type": "string", - "description": "Version tag used to build the property and connector dataset URLs in head metadata. Set by the version-fetcher and set-available-attachment-versions extensions.", + "description": "Version tag used to build the property and connector dataset URLs in head metadata. Set by the version-fetcher and set-available-attachment-versions extensions. Also emitted as the latest-connect-release meta tag for the Ask AI latest-version tool.", + "readBy": ["src/partials/head-meta.hbs"] + }, + "latest-console-version": { + "setIn": ["component"], + "setBy": "extension", + "type": "string", + "description": "Latest Redpanda Console release, emitted as the latest-console-release meta tag for the Ask AI latest-version tool. Set by the version-fetcher extension.", + "readBy": ["src/partials/head-meta.hbs"] + }, + "latest-operator-version": { + "setIn": ["component"], + "setBy": "extension", + "type": "string", + "description": "Latest Redpanda Operator release, emitted as the latest-operator-release meta tag for the Ask AI latest-version tool. Set by the version-fetcher extension.", + "readBy": ["src/partials/head-meta.hbs"] + }, + "latest-redpanda-helm-chart-version": { + "setIn": ["component"], + "setBy": "extension", + "type": "string", + "description": "Latest Redpanda Helm chart release, emitted as the latest-helm-chart-release meta tag for the Ask AI latest-version tool. Set by the version-fetcher extension.", "readBy": ["src/partials/head-meta.hbs"] }, "latest-redpanda-tag": { "setIn": ["component"], "setBy": "extension", "type": "string", - "description": "Version tag used to build the property and connector dataset URLs in head metadata. Set by the version-fetcher and set-available-attachment-versions extensions.", + "description": "Version tag used to build the property and connector dataset URLs in head metadata. Set by the version-fetcher and set-available-attachment-versions extensions. Also emitted as the latest-redpanda-release meta tag for the Ask AI latest-version tool.", "readBy": ["src/partials/head-meta.hbs"] }, "learning-objective-{n}": { @@ -1036,6 +1057,20 @@ "description": "Home page (role home): heading of the intent cards.", "readBy": ["src/partials/home.hbs"] }, + "page-introduced-in": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Release that introduced the page's feature, as X.Y.Z (a leading v or trailing period is stripped). Shows a \"Since X.Y.Z\" badge in the page header. Ignored on Cloud pages.", + "readBy": ["src/partials/article.hbs"] + }, + "page-introduced-in-product": { + "setIn": ["page"], + "setBy": "writer", + "type": "string", + "description": "Product named in the page-introduced-in badge tooltip, for example Redpanda Connect. Without it the tooltip says \"Introduced in version X.Y.Z\".", + "readBy": ["src/partials/article.hbs"] + }, "page-is-nearing-eol": { "setIn": ["page"], "setBy": "extension", diff --git a/tests/ui-attributes/scan-attribute-reads.js b/tests/ui-attributes/scan-attribute-reads.js index 0fd3b971..40b63b6e 100644 --- a/tests/ui-attributes/scan-attribute-reads.js +++ b/tests/ui-attributes/scan-attribute-reads.js @@ -16,6 +16,8 @@ // pattern whats-new-{n}-title // - helpers that receive an attributes object as a parameter, listed in // INDIRECT below +// - {{#with .attributes}} blocks, where {{key}}, {{#if key}}, and +// {{#unless key}} inside the block read key // // Anything it cannot classify is returned in `unclassified` so the test fails // instead of silently missing a read. @@ -84,6 +86,24 @@ function normalizeKey (key) { return key.replace(/\$\{[^}]+\}/g, '{n}') } +// For `{{#with .attributes}}` opened at `open`, returns the text up to the +// matching `{{/with}}`, or undefined when the `with` has an `as |x|` alias or +// wraps more than the attributes object. +function withBlockBody (text, open, match) { + const tagEnd = text.indexOf('}}', match.index) + if (tagEnd < 0) return undefined + if (!/^\{\{#with\s+[\w.]*\.attributes\s*\}\}$/.test(text.slice(open, tagEnd + 2))) return undefined + const re = /\{\{#with\b|\{\{\/with\s*\}\}/g + re.lastIndex = tagEnd + 2 + let depth = 1 + let m + while ((m = re.exec(text))) { + depth += m[0] === '{{#with' ? 1 : -1 + if (depth === 0) return text.slice(tagEnd + 2, m.index) + } + return undefined +} + function scan (srcDir = SRC) { const reads = new Map() // writer name -> Set of files const unclassified = [] @@ -112,7 +132,15 @@ function scan (srcDir = SRC) { // The nearest enclosing `(helper ...)` or `{{helper ...}}`. const open = Math.max(before.lastIndexOf('('), before.lastIndexOf('{{')) const helper = open >= 0 ? (before.slice(open).match(/^(?:\(|\{\{[#^]?)([a-z][a-z-]*)\s/) || [])[1] : undefined - if (!helper || !HELPERS_TAKING_ATTRIBUTES[helper]) { + const withBody = helper === 'with' ? withBlockBody(text, open, m) : undefined + if (withBody !== undefined) { + // {{#with page.attributes}} ... {{latest-x}} ... {{/with}}: bare keys + // inside the block are attribute reads. + const prefix = m[0].startsWith('page') ? 'page-' : '' + const keyRe = new RegExp(`\\{\\{(?:[#^]?(?:if|unless)\\s+)?(${KEY})\\s*\\}\\}`, 'g') + let k + while ((k = keyRe.exec(withBody))) if (k[1] !== 'else' && k[1] !== 'this') add(prefix + k[1], file) + } else if (!helper || !HELPERS_TAKING_ATTRIBUTES[helper]) { unclassified.push(`${file}: attributes object passed to ${helper || 'something'} the scanner does not know`) } else if (!INDIRECT[HELPERS_TAKING_ATTRIBUTES[helper]]) { unclassified.push(`${file}: helper ${helper} takes attributes but is missing from INDIRECT`)