diff --git a/.agents/instructions/layouts.md b/.agents/instructions/layouts.md index 95afbde1c0..6a18b77794 100644 --- a/.agents/instructions/layouts.md +++ b/.agents/instructions/layouts.md @@ -18,6 +18,50 @@ paths: - Shortcode implementation best practices - Complete TDD workflow for Hugo templates +## No Magic Values in Template Logic + +Templates operate on data and stay ignorant of the values in that data. +A product name, version segment, or `data/products.yml` key must never appear +as a string literal in template logic. +Nobody should have to edit a template because a product was renamed or added. + +Never write any of these in `layouts/**`: + +- A slice of product names or version segments used in a condition, such as a + list of the versions that count as current or the products that support Flux. +- A single hardcoded product comparison that branches behavior, such as testing + whether the first path segment equals a specific product. +- Deriving a `data/products.yml` key by matching the URL path when the page + already declares one. + +This file is generated into `layouts/AGENTS.md`, and Hugo parses every file +under `layouts/` as a template, so it carries no Go template examples. +For the annotated before and after, see the +[hugo-template-dev skill](../skills/hugo-template-dev/SKILL.md). + +Do this instead: + +1. Put the fact in `data/products.yml` as a per-product field — a boolean such + as `supports_flux`, `has_support_contract`, or `search_includes_resources` — + and read it with a `| default` that covers products that don't set it. +2. Resolve the product with `partial "product/get-data.html"` or + `partial "product/get-context.html"`, which read the page's cascade `product` + param. + Every product section declares `product` and `version` by cascade in its + section `_index.md`, so the key is stated rather than guessed. +3. When two templates need the same decision, extract it into one partial so + the two can't drift. + `layouts/partials/product/is-latest.html` is the worked example. + +The one exception is a value that must match an external system rather than a +product fact. +The Algolia search tag in `layouts/partials/header/search-attributes.html` +stays path-derived because Algolia indexed every record under the crawled URL. +Comment any such case in the template so the next reader doesn't "fix" it. + +For the before/after example and the incident behind this rule, see +[hugo-template-dev skill](../skills/hugo-template-dev/SKILL.md). + ## Implementing Shortcodes When creating or modifying Hugo layouts and shortcodes: diff --git a/.agents/skills/hugo-template-dev/SKILL.md b/.agents/skills/hugo-template-dev/SKILL.md index aed9bf2819..56bccfaa50 100644 --- a/.agents/skills/hugo-template-dev/SKILL.md +++ b/.agents/skills/hugo-template-dev/SKILL.md @@ -330,6 +330,107 @@ layouts/ - Group related partials in subdirectories - Include comments at the top describing purpose and required context +## No Magic Values in Template Logic + +**Principle:** Templates operate on data and stay ignorant of the values in that data. A product name, version segment, or `data/products.yml` key must never appear as a string literal in template logic. Nobody should have to edit a template because a product was renamed or added. + +### Why this rule exists + +`header/coveo-meta-data.html` and `header/search-attributes.html` both answered the same question — does this page document the current version of its product? — and each answered it with its own hardcoded list of version segments. The lists drifted. `explorer` and `controller` made it into the Algolia list but not the Coveo list, so InfluxDB 3 Explorer and Telegraf Controller were indexed as current by one search system and as stale by the other. Neither list was wrong on its face. The duplication was. + +### What counts as a magic value + +| Pattern | Example | +| ------------------------------------------- | --------------------------------------------------------------- | +| A list of product names or version segments | `{{ $alwaysLatest := slice "cloud" "core" "enterprise" }}` | +| A hardcoded comparison that branches | `{{ if eq $product "platform" }}` | +| An exclusion list | `{{ if not (in (slice "chronograf" "kapacitor") $product) }}` | +| A key inferred from the URL | `{{ findRE "[^/]+.*?" .RelPermalink }}` to build a products key | + +Find them with: + +```bash +grep -rnE '(slice|in |eq |ne )[^}]*"(core|enterprise|cloud|clustered|explorer|controller|platform|resources|influxdb|telegraf|chronograf|kapacitor|flux)' layouts/ --exclude=AGENTS.md +``` + +Not every hit is a violation. String literals in class names, URLs, and display text are fine. The rule is about **branching on product identity**. + +### Fix 1: Move the fact into products.yml + +Name the field after the fact, not the product, and choose the `default` so only +the exceptions need the field. + +**Before** (`layouts/partials/footer/search.html`): + +```go +{{ $productPathData := findRE "[^/]+.*?" .RelPermalink }} +{{ $product := index $productPathData 0 }} +{{ $version := index $productPathData 1 }} +{{ $fluxSupported := slice "influxdb" "enterprise_influxdb" }} +{{ $influxdbFluxSupport := slice "v1" "v2" "cloud" }} +{{ $includeFlux := and (in $fluxSupported $product) (in $influxdbFluxSupport $version) }} +{{ $includeResources := not (in (slice "cloud-serverless" "cloud-dedicated" "clustered" "core" "enterprise" "explorer") $version) }} +``` + +**After:** + +```go +{{ $ctx := partial "product/get-context.html" . }} +{{/* + Both flags come from data/products.yml so adding a product never requires + editing this template. +*/}} +{{ $includeFlux := $ctx.data.supports_flux | default false }} +{{ $includeResources := $ctx.data.search_includes_resources | default true }} +``` + +`data/products.yml`: + +```yaml +influxdb: + supports_flux: true +influxdb3_core: + search_includes_resources: false +``` + +### Fix 2: Resolve the product from the page, not the path + +`layouts/partials/product/get-data.html` and +`layouts/partials/product/get-context.html` read the page's cascade `product` +param. Every product section declares `product` and `version` by cascade in its +section `_index.md`, so the key is stated rather than guessed. + +```go +{{ $productData := partial "product/get-data.html" . }} +{{ $ctx := partial "product/get-context.html" . }} +{{ $ctx.key }} {{/* "influxdb3_cloud_dedicated" */}} +{{ $ctx.data }} {{/* the products.yml entry */}} +{{ $ctx.product }} {{/* first path segment, for path-scoped rules */}} +``` + +Parsing `.RelPermalink` gets the key wrong under `/influxdb3/`, where the path +segment is `influxdb3` but the keys are `influxdb3_core`, `influxdb3_cloud`, and +so on. It also breaks under a subpath-mounted baseURL, where the PR preview's +`/pr-preview/pr-N/` prefix becomes the "product." + +### Fix 3: Extract a shared decision into one partial + +When two templates need the same answer, give them one partial to call. +`layouts/partials/product/is-latest.html` is the worked example: both search +templates now call it, so they can't disagree about which pages are current. + +```go +{{ $isLatest := partial "product/is-latest.html" . }} +``` + +### The one exception + +A value that must match an external system rather than a product fact stays as +it is. The Algolia search tag in `header/search-attributes.html` is path-derived +because Algolia indexed every record under the crawled URL, and changing the tag +would orphan those records. Comment any such case in the template so the next +reader doesn't "fix" it. + ## Separation of Concerns: Templates vs TypeScript **Principle:** Hugo templates handle structure and data binding. TypeScript handles behavior and interactivity. @@ -561,6 +662,8 @@ pre-commit: 3. **Check error output first** before declaring success 4. **Use `isset` and `index`** for safe data access 5. **Hyphenated keys require `index` function** - dot notation fails +6. **No product names in template logic** - put the fact in `data/products.yml` + and resolve the product with the `product/get-context.html` partial ## Related Resources diff --git a/.claude/rules/layouts.md b/.claude/rules/layouts.md index fb3e7a285a..ce0b0ebea7 100644 --- a/.claude/rules/layouts.md +++ b/.claude/rules/layouts.md @@ -20,6 +20,50 @@ paths: - Shortcode implementation best practices - Complete TDD workflow for Hugo templates +## No Magic Values in Template Logic + +Templates operate on data and stay ignorant of the values in that data. +A product name, version segment, or `data/products.yml` key must never appear +as a string literal in template logic. +Nobody should have to edit a template because a product was renamed or added. + +Never write any of these in `layouts/**`: + +- A slice of product names or version segments used in a condition, such as a + list of the versions that count as current or the products that support Flux. +- A single hardcoded product comparison that branches behavior, such as testing + whether the first path segment equals a specific product. +- Deriving a `data/products.yml` key by matching the URL path when the page + already declares one. + +This file is generated into `layouts/AGENTS.md`, and Hugo parses every file +under `layouts/` as a template, so it carries no Go template examples. +For the annotated before and after, see the +[hugo-template-dev skill](../../.agents/skills/hugo-template-dev/SKILL.md). + +Do this instead: + +1. Put the fact in `data/products.yml` as a per-product field — a boolean such + as `supports_flux`, `has_support_contract`, or `search_includes_resources` — + and read it with a `| default` that covers products that don't set it. +2. Resolve the product with `partial "product/get-data.html"` or + `partial "product/get-context.html"`, which read the page's cascade `product` + param. + Every product section declares `product` and `version` by cascade in its + section `_index.md`, so the key is stated rather than guessed. +3. When two templates need the same decision, extract it into one partial so + the two can't drift. + `layouts/partials/product/is-latest.html` is the worked example. + +The one exception is a value that must match an external system rather than a +product fact. +The Algolia search tag in `layouts/partials/header/search-attributes.html` +stays path-derived because Algolia indexed every record under the crawled URL. +Comment any such case in the template so the next reader doesn't "fix" it. + +For the before/after example and the incident behind this rule, see +[hugo-template-dev skill](../../.agents/skills/hugo-template-dev/SKILL.md). + ## Implementing Shortcodes When creating or modifying Hugo layouts and shortcodes: diff --git a/.github/instructions/layouts.instructions.md b/.github/instructions/layouts.instructions.md index 7055dae225..7921707bfc 100644 --- a/.github/instructions/layouts.instructions.md +++ b/.github/instructions/layouts.instructions.md @@ -19,6 +19,50 @@ applyTo: "layouts/**/*.html" - Shortcode implementation best practices - Complete TDD workflow for Hugo templates +## No Magic Values in Template Logic + +Templates operate on data and stay ignorant of the values in that data. +A product name, version segment, or `data/products.yml` key must never appear +as a string literal in template logic. +Nobody should have to edit a template because a product was renamed or added. + +Never write any of these in `layouts/**`: + +- A slice of product names or version segments used in a condition, such as a + list of the versions that count as current or the products that support Flux. +- A single hardcoded product comparison that branches behavior, such as testing + whether the first path segment equals a specific product. +- Deriving a `data/products.yml` key by matching the URL path when the page + already declares one. + +This file is generated into `layouts/AGENTS.md`, and Hugo parses every file +under `layouts/` as a template, so it carries no Go template examples. +For the annotated before and after, see the +[hugo-template-dev skill](../../.agents/skills/hugo-template-dev/SKILL.md). + +Do this instead: + +1. Put the fact in `data/products.yml` as a per-product field — a boolean such + as `supports_flux`, `has_support_contract`, or `search_includes_resources` — + and read it with a `| default` that covers products that don't set it. +2. Resolve the product with `partial "product/get-data.html"` or + `partial "product/get-context.html"`, which read the page's cascade `product` + param. + Every product section declares `product` and `version` by cascade in its + section `_index.md`, so the key is stated rather than guessed. +3. When two templates need the same decision, extract it into one partial so + the two can't drift. + `layouts/partials/product/is-latest.html` is the worked example. + +The one exception is a value that must match an external system rather than a +product fact. +The Algolia search tag in `layouts/partials/header/search-attributes.html` +stays path-derived because Algolia indexed every record under the crawled URL. +Comment any such case in the template so the next reader doesn't "fix" it. + +For the before/after example and the incident behind this rule, see +[hugo-template-dev skill](../../.agents/skills/hugo-template-dev/SKILL.md). + ## Implementing Shortcodes When creating or modifying Hugo layouts and shortcodes: diff --git a/AGENTS.md b/AGENTS.md index 3c55e1be09..e3bcb11b89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,8 @@ See [README.md](README.md) and the - For InfluxDB 3, shared directories contain the prose; product directories are usually thin stubs with `source:` references. - Product names and versions come from `data/products.yml`. + Templates under `layouts/` read that data; they never hardcode a product name + or version segment in template logic. - Commit format is `type(scope): description`. - Network-restricted environments may fail on Cypress downloads, Docker builds, or Alpine package installs. diff --git a/data/products.yml b/data/products.yml index 548cdf15a7..c45bd3e83b 100644 --- a/data/products.yml +++ b/data/products.yml @@ -11,6 +11,7 @@ influxdb3_core: menu_category: self-managed versions: [core] list_order: 2 + search_includes_resources: false latest: core latest_patch: 3.10.5 placeholder_host: localhost:8181 @@ -56,6 +57,7 @@ influxdb3_enterprise: menu_category: self-managed versions: [enterprise] list_order: 2 + search_includes_resources: false latest: enterprise latest_patch: 3.10.5 placeholder_host: localhost:8181 @@ -127,6 +129,7 @@ influxdb3_explorer: label_group: explorer menu_category: tools list_order: 1 + search_includes_resources: false latest: explorer latest_patch: 1.9.0 placeholder_host: localhost:8080 @@ -153,6 +156,7 @@ influxdb3_cloud_serverless: menu_category: managed versions: [cloud-serverless] list_order: 2 + search_includes_resources: false latest: cloud-serverless placeholder_host: cloud2.influxdata.com scheme: https @@ -192,6 +196,7 @@ influxdb3_cloud_dedicated: menu_category: managed versions: [cloud-dedicated] list_order: 3 + search_includes_resources: false latest: cloud-dedicated link: 'https://www.influxdata.com/contact-sales-cloud-dedicated/' latest_cli: 2.12.1 @@ -229,6 +234,7 @@ influxdb3_clustered: menu_category: self-managed versions: [clustered] list_order: 3 + search_includes_resources: false latest: clustered link: 'https://www.influxdata.com/contact-sales-influxdb-clustered/' placeholder_host: cluster-host.com @@ -284,6 +290,7 @@ influxdb: versions: - v2 - v1 + supports_flux: true latest: v2.9 latest_patches: v2: 2.9.1 @@ -333,6 +340,7 @@ influxdb_cloud: menu_category: managed versions: [cloud] list_order: 1 + supports_flux: true latest: cloud placeholder_host: cloud2.influxdata.com scheme: https @@ -396,7 +404,7 @@ telegraf_controller: menu_category: other link: 'https://influxdata.com/contact-sales-telegraf-enterprise/?utm_source=website&utm_medium=direct&utm_campaign=Telegraf-Enterprise' versions: [v1] - latest: 1.0 + latest: controller latest_patch: 1.0.1 schema: operating_system: "Docker" @@ -410,7 +418,7 @@ telegraf_enterprise: menu_category: other link: 'https://influxdata.com/contact-sales-telegraf-enterprise/?utm_source=website&utm_medium=direct&utm_campaign=Telegraf-Enterprise' versions: [v1] - latest: 1.0 + latest: enterprise latest_patch: 1.0.1 schema: operating_system: "Linux, macOS, Windows, Docker" @@ -428,6 +436,7 @@ chronograf: menu_category: other list_order: 7 versions: [v1] + has_support_contract: false latest: v1.11 latest_patches: v1: 1.11.4 @@ -455,6 +464,7 @@ kapacitor: menu_category: other list_order: 7 versions: [v1] + has_support_contract: false latest: v1.8 latest_patches: v1: 1.8.6 @@ -478,6 +488,7 @@ enterprise_influxdb: menu_category: self-managed list_order: 5 versions: [v1] + supports_flux: true latest: v1.12 latest_patches: v1: 1.12.4 diff --git a/layouts/AGENTS.md b/layouts/AGENTS.md index 95c4b9afe7..10d686e719 100644 --- a/layouts/AGENTS.md +++ b/layouts/AGENTS.md @@ -19,6 +19,50 @@ These instructions apply when working in `layouts/`. - Shortcode implementation best practices - Complete TDD workflow for Hugo templates +### No Magic Values in Template Logic + +Templates operate on data and stay ignorant of the values in that data. +A product name, version segment, or `data/products.yml` key must never appear +as a string literal in template logic. +Nobody should have to edit a template because a product was renamed or added. + +Never write any of these in `layouts/**`: + +- A slice of product names or version segments used in a condition, such as a + list of the versions that count as current or the products that support Flux. +- A single hardcoded product comparison that branches behavior, such as testing + whether the first path segment equals a specific product. +- Deriving a `data/products.yml` key by matching the URL path when the page + already declares one. + +This file is generated into `layouts/AGENTS.md`, and Hugo parses every file +under `layouts/` as a template, so it carries no Go template examples. +For the annotated before and after, see the +[hugo-template-dev skill](../.agents/skills/hugo-template-dev/SKILL.md). + +Do this instead: + +1. Put the fact in `data/products.yml` as a per-product field — a boolean such + as `supports_flux`, `has_support_contract`, or `search_includes_resources` — + and read it with a `| default` that covers products that don't set it. +2. Resolve the product with `partial "product/get-data.html"` or + `partial "product/get-context.html"`, which read the page's cascade `product` + param. + Every product section declares `product` and `version` by cascade in its + section `_index.md`, so the key is stated rather than guessed. +3. When two templates need the same decision, extract it into one partial so + the two can't drift. + `layouts/partials/product/is-latest.html` is the worked example. + +The one exception is a value that must match an external system rather than a +product fact. +The Algolia search tag in `layouts/partials/header/search-attributes.html` +stays path-derived because Algolia indexed every record under the crawled URL. +Comment any such case in the template so the next reader doesn't "fix" it. + +For the before/after example and the incident behind this rule, see +[hugo-template-dev skill](../.agents/skills/hugo-template-dev/SKILL.md). + ### Implementing Shortcodes When creating or modifying Hugo layouts and shortcodes: diff --git a/layouts/partials/article/feedback.html b/layouts/partials/article/feedback.html index 081654c4d0..fb3bf3f4e7 100644 --- a/layouts/partials/article/feedback.html +++ b/layouts/partials/article/feedback.html @@ -1,44 +1,18 @@ -{{/* - Derive product and version from the content path. Prefer .File.Dir - (content-relative, immune to baseURL subpaths like /pr-preview/pr-N/); - fall back to .RelPermalink only for virtual pages that have no source - file. Using .RelPermalink under a subpath-mounted baseURL would pick - up `pr-preview`/`pr-N` as `$product`, breaking every product lookup. -*/}} -{{ $productPath := .RelPermalink }} -{{ with .File }} - {{ $productPath = .Dir }} -{{ end }} -{{ $productPathData := findRE "[^/]+" $productPath }} -{{ $product := "" }} -{{ $version := "" }} -{{ if gt (len $productPathData) 0 }}{{ $product = index $productPathData 0 }}{{ end }} -{{ if gt (len $productPathData) 1 }}{{ $version = index $productPathData 1 }}{{ end }} -{{ $productKey := $product }} -{{ if eq $product "influxdb3" }} - {{ $productKey = print "influxdb3_" (replaceRE "-" "_" $version) }} -{{ else if and (eq $product "influxdb") (eq $version "cloud") }} - {{/* /influxdb/cloud/* is Cloud (TSM), a separate product entry. */}} - {{ $productKey = "influxdb_cloud" }} -{{ else if and (eq $product "telegraf") (in (slice "controller" "enterprise") $version) }} - {{/* - Telegraf Controller and Telegraf Enterprise are licensed products with - their own entries. Without this, both fall back to the `telegraf` entry - and render its public issue-tracker button. - */}} - {{ $productKey = print "telegraf_" $version }} -{{ end }} -{{ $productData := dict }} -{{ if $productKey }} - {{ with index .Site.Data.products $productKey }}{{ $productData = . }}{{ end }} -{{ end }} +{{ $ctx := partial "product/get-context.html" . }} +{{ $product := $ctx.product }} +{{ $version := $ctx.version }} +{{ $productData := $ctx.data }} {{ $productName := $productData.name }} {{/* Prefer version-specific name (name__v2, name__v1) when available. */}} {{ with (index $productData (print "name__" $version)) }} {{ $productName = . }} {{ end }} {{ $productIssueUrl := $productData.product_issue_url }} -{{ $supportBlacklist := slice "chronograf" "kapacitor" }} +{{/* + Products without a paid support offering set has_support_contract: false + in data/products.yml, so this template needs no list of exceptions. +*/}} +{{ $hasSupportContract := $productData.has_support_contract | default true }} {{ if .File }} {{ .Scratch.Set "pageGithubLink" (print "https://github.com/influxdata/docs-v2/edit/master/content/" .File.Path) }} @@ -95,7 +69,7 @@
Customers with an annual or support contract can contact InfluxData Support. {{ if eq $version "enterprise" }}Customers using a trial license can email trial@influxdata.com for assistance.
{{ end }} diff --git a/layouts/partials/footer/search.html b/layouts/partials/footer/search.html index f61459af54..708d617916 100644 --- a/layouts/partials/footer/search.html +++ b/layouts/partials/footer/search.html @@ -1,11 +1,13 @@ -{{ $productPathData := findRE "[^/]+.*?" .RelPermalink }} -{{ $product := index $productPathData 0 }} -{{ $version := index $productPathData 1 }} -{{ $latestV2 := .Site.Data.products.influxdb.latest }} -{{ $fluxSupported := slice "influxdb" "enterprise_influxdb" }} -{{ $influxdbFluxSupport := slice "v1" "v2" "cloud" }} -{{ $includeFlux := and (in $fluxSupported $product) (in $influxdbFluxSupport $version) }} -{{ $includeResources := not (in (slice "cloud-serverless" "cloud-dedicated" "clustered" "core" "enterprise" "explorer") $version) }} +{{ $ctx := partial "product/get-context.html" . }} +{{ $product := $ctx.product }} +{{ $version := $ctx.version }} +{{/* + Both flags come from data/products.yml so adding a product never requires + editing this template. See `supports_flux` and `search_includes_resources` + in that file for what each one means. +*/}} +{{ $includeFlux := $ctx.data.supports_flux | default false }} +{{ $includeResources := $ctx.data.search_includes_resources | default true }}