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 6293337c..dd9ab973 100644 --- a/.github/workflows/validate-build.yml +++ b/.github/workflows/validate-build.yml @@ -95,6 +95,12 @@ 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 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: Test the introduced-in version badge run: npm run test:introduced-in-badge - name: Test the Ask AI latest-version tool diff --git a/CLAUDE.md b/CLAUDE.md index d466a5cb..9946080e 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: @@ -98,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/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 878a0336..8274b19f 100644 --- a/package.json +++ b/package.json @@ -96,8 +96,11 @@ "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: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:latest-version-tool && npm run test:introduced-in-badge && 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:latest-version-tool && npm run test:introduced-in-badge && 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/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/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/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/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 new file mode 100644 index 00000000..cee86af1 --- /dev/null +++ b/src/ui-attributes.json @@ -0,0 +1,1690 @@ +{ + "$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. 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. 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}": { + "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-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", + "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-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", + "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-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": "Landing pages (home, labs home): hero eyebrow text.", + "readBy": ["src/layouts/labs-home.hbs", "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": "Landing pages (home, labs home): hero subtitle.", + "readBy": ["src/layouts/labs-home.hbs", "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, 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"], + "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-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", + "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-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-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..729d56de --- /dev/null +++ b/tests/json-attribute-guard/json-attribute-guard.test.js @@ -0,0 +1,77 @@ +'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', () => { + 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 [ + ['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/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/) +}) diff --git a/tests/ui-attributes/scan-attribute-reads.js b/tests/ui-attributes/scan-attribute-reads.js new file mode 100644 index 00000000..40b63b6e --- /dev/null +++ b/tests/ui-attributes/scan-attribute-reads.js @@ -0,0 +1,246 @@ +'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 +// - {{#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. + +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}') +} + +// 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 = [] + 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 + 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`) + } + } + } + + 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/) +})