diff --git a/data/structures/table.yml b/data/structures/table.yml index 4907a5552..7326922bd 100644 --- a/data/structures/table.yml +++ b/data/structures/table.yml @@ -1,6 +1,6 @@ comment: >- Makes a Markdown table responsive with horizontal scrolling on smaller screens. - Sorting, paging, and searching require the simple-datatables module. + Sorting, paging, searching, and filtering require the simple-datatables module. icon: table_chart arguments: page: diff --git a/docs/superpowers/plans/2026-07-14-table-category-filter.md b/docs/superpowers/plans/2026-07-14-table-category-filter.md new file mode 100644 index 000000000..0df77c66f --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-table-category-filter.md @@ -0,0 +1,974 @@ +# Table Category Filter Fix — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the table shortcode's category filter actually work from Markdown, as a data-table feature. + +**Architecture:** The filter becomes a data-table feature. Hinode's shortcode forwards `filter` / `filter-col`; the partial normalizes a comma-separated string into a slice and marks any filtered table as a data table, so simple-datatables always initialises and `dt.search()` always drives the filtering. That makes the module's DOM-fallback branch unreachable, so it is deleted along with the wrapped-table bug it carries. Because a data table needs the module's JS *and* its per-page CSS — both gated on page opt-in — the partial warns at build time when the page has not opted in. + +**Tech Stack:** Hugo templates, shortcodes and render hooks; simple-datatables v10.2.0; Hugo i18n. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-07-14-table-category-filter-design.md`. Read it first. +- Two repositories, siblings under `/Users/mark/Development/GitHub/gethinode/`: `hinode` (branch + `fix/table-category-filter`, already created and checked out, carrying the spec commit) and + `mod-simple-datatables` (currently on `main` — Task 5 branches it). +- **`filter` implies a data table.** Setting `filter` alone must add the `data-table` class. This is + safe: in simple-datatables v10.2.0 `options.searchable` gates only the search *input* and a wrapper + class — it does **not** guard the public `search()` method. A DataTable with sorting, paging and + search all off is still filterable. +- `filter`'s declared type is `[string, slice]`. A shortcode named parameter is **always** a string, + and mod-utils validates an argument's kind against `accepts` **without casting between kinds**. The + partial must therefore split a string itself. `range` over a string is a Hugo error. +- `filter-col` is zero-indexed and defaults to `1`. +- Per `CLAUDE.md`: never use `or` as fallback logic for a boolean argument — `or false ` + returns the fallback and discards an explicit `false`. +- Commits follow Angular Conventional Commits (a commitlint pre-commit hook enforces this). Body + lines must not exceed 100 characters. If a hook reformats files, re-stage and commit again. +- **The branch must contain at least one `feat:` or `fix:` commit**, because gethinode merges with + merge commits and semantic-release reads the individual commits, not the PR title. `refactor`, + `style`, `test`, `chore`, `docs` and `build` produce **no release**. +- **Never run `npm run build:example` or `npm run start:example`** — their prebuild hooks re-vendor + Hugo modules from the remote and would wipe the local `mod-simple-datatables` override that Task 5 + needs. Use the project-pinned binary with `node_modules/.bin` on `PATH`: + + ```bash + PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn + ``` + + (The `PATH` entry is required or Hugo cannot find PostCSS.) +- Export the scratch path once per shell: + + ```bash + export SCRATCH=/private/tmp/claude-501/-Users-mark-Development-GitHub-gethinode-hinode/491eb2c0-b0cd-401d-9256-134afea0e318/scratchpad + ``` + +- An existing scratch script `$SCRATCH/verify-table-wrap.mjs` guards the (already-shipped) wrap + feature. Task 3 adds tables to the shared fixture page, which changes its table count — Task 3 + updates that script accordingly. Neither script is committed. + +## File Structure + +| File | Repo | Responsibility | +| --- | --- | --- | +| `i18n/*.yaml` (8 files) | hinode | The filter button group's two UI strings. | +| `data/structures/table.yml` | hinode | Structure comment: filtering also needs the module. | +| `layouts/_partials/utilities/AddModule.html` | hinode | Render-time module dependency (currently broken). | +| `layouts/_shortcodes/table.html` | hinode | Forwards `filter` / `filter-col` to the partial. | +| `layouts/_partials/assets/table.html` | hinode | Normalizes `filter`; `filter` implies a data table; warns on missing opt-in. | +| `exampleSite/content/en/table-demo.md` | hinode | Fixture: a filter-only table and a filter + sort + wrap table. | +| `assets/js/modules/simple-datatables/simple-datatables.load.js` | mod-simple-datatables | Filter click handler; the DOM fallback is deleted. | + +--- + +### Task 1: i18n strings and the structure comment + +Independent of everything else, and lands the strings before any task renders a filter button, so no +i18n warnings are ever emitted. + +**Files:** + +- Modify: `i18n/en.yaml`, `i18n/nl.yaml`, `i18n/de.yaml`, `i18n/fr.yaml`, `i18n/pl.yaml`, + `i18n/pt-br.yaml`, `i18n/zh-hans.yaml`, `i18n/zh-hant.yaml` +- Modify: `data/structures/table.yml:1-3` + +**Interfaces:** + +- Produces: the i18n ids `tableFilterLabel` and `tableFilterAll`, which + `layouts/_partials/assets/table.html` already calls via `T`. + +- [ ] **Step 1: Add the two keys to all eight language files** + +The files are flat YAML lists of `- id:` / `translation:` pairs. Append both entries to the end of +each file, keeping the existing two-space indentation under `translation`. + +`i18n/en.yaml`: + +```yaml +- id: tableFilterLabel + translation: "Filter" +- id: tableFilterAll + translation: "All" +``` + +`i18n/nl.yaml`: + +```yaml +- id: tableFilterLabel + translation: "Filter" +- id: tableFilterAll + translation: "Alle" +``` + +`i18n/de.yaml`: + +```yaml +- id: tableFilterLabel + translation: "Filter" +- id: tableFilterAll + translation: "Alle" +``` + +`i18n/fr.yaml`: + +```yaml +- id: tableFilterLabel + translation: "Filtre" +- id: tableFilterAll + translation: "Tout" +``` + +`i18n/pl.yaml`: + +```yaml +- id: tableFilterLabel + translation: "Filtr" +- id: tableFilterAll + translation: "Wszystkie" +``` + +`i18n/pt-br.yaml`: + +```yaml +- id: tableFilterLabel + translation: "Filtro" +- id: tableFilterAll + translation: "Todos" +``` + +`i18n/zh-hans.yaml`: + +```yaml +- id: tableFilterLabel + translation: "筛选" +- id: tableFilterAll + translation: "全部" +``` + +`i18n/zh-hant.yaml`: + +```yaml +- id: tableFilterLabel + translation: "篩選" +- id: tableFilterAll + translation: "全部" +``` + +The English strings match the `| default` fallbacks the partial already uses, so no English site's +rendered output changes. + +- [ ] **Step 2: Update the structure comment** + +In `data/structures/table.yml`, the file opens with: + +```yaml +comment: >- + Makes a Markdown table responsive with horizontal scrolling on smaller screens. + Sorting, paging, and searching require the simple-datatables module. +``` + +Change the second sentence to include filtering: + +```yaml +comment: >- + Makes a Markdown table responsive with horizontal scrolling on smaller screens. + Sorting, paging, searching, and filtering require the simple-datatables module. +``` + +- [ ] **Step 3: Verify every key parses and resolves** + +```bash +export SCRATCH=/private/tmp/claude-501/-Users-mark-Development-GitHub-gethinode-hinode/491eb2c0-b0cd-401d-9256-134afea0e318/scratchpad +for f in i18n/*.yaml; do node -e " + const y=require('fs').readFileSync('$f','utf8'); + const ids=[...y.matchAll(/^- id: (\S+)/gm)].map(m=>m[1]); + const dup=ids.filter((v,i)=>ids.indexOf(v)!==i); + const missing=['tableFilterLabel','tableFilterAll'].filter(k=>!ids.includes(k)); + console.log('$f', missing.length?'MISSING '+missing:'ok', dup.length?'DUPLICATE '+dup:''); +"; done +``` + +Expected: `ok` for all eight files, and no duplicates. + +- [ ] **Step 4: Build with i18n warnings on** + +```bash +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --printI18nWarnings --logLevel warn 2>&1 | grep -i "i18n\|ERROR" || echo "no i18n warnings" +``` + +Expected: `no i18n warnings`. + +- [ ] **Step 5: Lint and commit** + +```bash +npm test +git add i18n data/structures/table.yml +git commit -m "fix(i18n): add the missing table filter translations + +tableFilterLabel and tableFilterAll were called by the table partial but existed +in no language file, so they were untranslatable and emitted i18n warnings. The +partial only rendered because it passes a default to T. + +Also records in the table structure that filtering, like sorting, paging and +searching, needs the simple-datatables module." +``` + +--- + +### Task 2: Fix `AddModule.html` + +A latent bug found while designing this. The partial is currently unused, so nothing changes +behaviourally — but it is the sanctioned way for a partial to declare a render-time module +dependency, and it is a landmine for the next caller. + +**Files:** + +- Modify: `layouts/_partials/utilities/AddModule.html` + +**Interfaces:** + +- Produces: `partial "utilities/AddModule.html" (dict "module" "")` appends `` to the + page's `dependencies` scratch without discarding what is already there. + +- [ ] **Step 1: Write a failing test** + +The partial is unused in the theme, so build a throwaway Hugo site that exercises it directly. + +```bash +export SCRATCH=/private/tmp/claude-501/-Users-mark-Development-GitHub-gethinode-hinode/491eb2c0-b0cd-401d-9256-134afea0e318/scratchpad +T=$SCRATCH/addmodule-test +rm -rf $T && mkdir -p $T/layouts/_partials/utilities $T/content +printf 'title = "t"\nbaseURL = "/"\n' > $T/hugo.toml +printf -- '---\ntitle: x\n---\n' > $T/content/_index.md +cp layouts/_partials/utilities/AddModule.html $T/layouts/_partials/utilities/ +cat > $T/layouts/index.html <<'EOF' +{{ partial "utilities/AddModule.html" (dict "module" "alpha") }} +{{ partial "utilities/AddModule.html" (dict "module" "beta") }} +{{ partial "utilities/AddModule.html" (dict "module" "alpha") }} +RESULT: {{ delimit (page.Scratch.Get "dependencies") "," }} +EOF +node_modules/.bin/hugo --quiet -s $T --destination $T/public +grep RESULT $T/public/index.html +``` + +Expected (the bug): `RESULT:` — an **empty** list. Adding `beta` discarded `alpha`, then re-adding +`alpha` wiped the list entirely. + +Why: the partial calls `complement $dependencies (slice .)`, and Hugo's `complement` returns the +elements of the **last** collection that are not in the others. So `complement ["alpha"] ["beta"]` +is `["beta"]` — and the partial then *sets* `dependencies` to that, replacing rather than appending. +`complement ["alpha"] ["alpha"]` is `[]`, which empties it. + +- [ ] **Step 2: Fix the partial** + +Replace the whole of `layouts/_partials/utilities/AddModule.html` with: + +```hugo +{{ with .module }} + {{- $dependencies := page.Scratch.Get "dependencies" -}} + {{- if reflect.IsSlice $dependencies -}} + {{- $dependencies = $dependencies | append . | uniq -}} + {{ else }} + {{- $dependencies = slice . -}} + {{ end }} + {{- page.Scratch.Set "dependencies" $dependencies -}} +{{ end }} +``` + +This matches how `head/head.html:22` and `footer/scripts.html:126` already merge the scratch when +they read it (`append … | uniq`). Keep the file's existing tab indentation. + +- [ ] **Step 3: Re-run the test** + +```bash +cp layouts/_partials/utilities/AddModule.html $SCRATCH/addmodule-test/layouts/_partials/utilities/ +node_modules/.bin/hugo --quiet -s $SCRATCH/addmodule-test --destination $SCRATCH/addmodule-test/public +grep RESULT $SCRATCH/addmodule-test/public/index.html +``` + +Expected: `RESULT: alpha,beta` — both modules retained, and the duplicate `alpha` deduplicated +rather than destroying the list. + +- [ ] **Step 4: Lint and commit** + +```bash +npm test +git add layouts/_partials/utilities/AddModule.html +git commit -m "fix(utilities): stop AddModule discarding existing dependencies + +AddModule used complement to add a module to the page's dependencies scratch, +but complement returns the elements of the LAST collection absent from the +others - so it replaced the list with the single module being added, and wiped +it entirely when re-adding one already present. Append and deduplicate instead, +matching how head.html and scripts.html merge the same scratch." +``` + +--- + +### Task 3: Wire the filter up, and make it imply a data table + +The core of the fix. After this task the filter is reachable from Markdown and renders as a data +table. + +**Files:** + +- Modify: `layouts/_shortcodes/table.html:22-35` +- Modify: `layouts/_partials/assets/table.html:19-59` and `:110` +- Modify: `exampleSite/content/en/table-demo.md` + +**Interfaces:** + +- Consumes: the i18n ids `tableFilterLabel` / `tableFilterAll` (Task 1). +- Produces: on a filtered table, the `data-table` class plus the attributes + `data-filter-col=""` and `data-filter-id="table-filter-"`; a button group whose buttons + carry `data-filter-table=""` and `data-filter-value=""` (empty for "All"). + Task 5's JavaScript reads all of these. The `data-filter-container` attribute is **removed**. + +- [ ] **Step 1: Add the fixture tables** + +Append to `exampleSite/content/en/table-demo.md`. The page's frontmatter already declares +`modules: ["simple-datatables"]`, which the filter needs. + +````markdown +## Filter-only table + +{{< table filter="widget, gadget" filter-col="1" class="fixture-filter" >}} +| Name | Type | Description | +|---------|--------|--------------------------------------------------------------------| +| alpha | widget | The first record, with a description long enough to need wrapping. | +| bravo | gadget | The second record, also with a fairly long trailing description. | +| charlie | widget | The third record. Short. | +| delta | gadget | The fourth record, whose description runs on for a little while. | +{{< /table >}} + +## Filtered, sortable, wrapped data table + +{{< table filter="widget,gadget" filter-col="1" sortable="true" paginate="true" pagination="2" wrap="true" class="fixture-filter-wrap" >}} +| Name | Type | Description | +|---------|--------|--------------------------------------------------------------------| +| alpha | widget | The first record, with a description long enough to need wrapping. | +| bravo | gadget | The second record, also with a fairly long trailing description. | +| charlie | widget | The third record. Short. | +| delta | gadget | The fourth record, whose description runs on for a little while. | +{{< /table >}} +```` + +Note the deliberate space in `filter="widget, gadget"` on the first table — it exercises the +whitespace trimming. + +- [ ] **Step 2: Write the failing verification script** + +Create `$SCRATCH/verify-table-filter.mjs` (scratch — never commit it): + +```js +import { readFileSync } from 'node:fs' + +const html = readFileSync('exampleSite/public/en/table-demo/index.html', 'utf8') + +const checks = [] +const check = (name, pass, detail = '') => checks.push({ name, pass, detail }) + +// Pull out each fixture table's start tag, matching the class as an exact token. +// A substring match would make `fixture-filter` also match `fixture-filter-wrap`. +const tag = cls => { + for (const m of html.matchAll(/
]*>/g)) { + if (m[1].split(/\s+/).includes(cls)) return m[0] + } + return '' +} +const filterOnly = tag('fixture-filter') +const filterWrap = tag('fixture-filter-wrap') + +check('filter-only table is rendered', filterOnly.length > 0) +check('filter+wrap table is rendered', filterWrap.length > 0) + +// `filter` implies a data table: the class must be present even with no sortable/paginate/searchable. +check( + 'filter alone marks the table as a data table', + /\bdata-table\b/.test(filterOnly), + filterOnly +) +check( + 'filter alone does NOT enable sorting/paging/search', + !/data-table-sortable|data-table-paging|data-table-searchable/.test(filterOnly) +) + +// The filter attributes the JavaScript reads. +check('filter-only table carries data-filter-col', /data-filter-col=1/.test(filterOnly)) +check('filter-only table carries data-filter-id', /data-filter-id=table-filter-\w+/.test(filterOnly)) + +// The button group: one "All" button plus one per category. +const allBtns = html.match(/data-filter-value=""/g) ?? [] +check('an All button per filtered table', allBtns.length === 2, `found ${allBtns.length}`) +check('a widget button exists', /data-filter-value="widget"/.test(html)) +check('a gadget button exists', /data-filter-value="gadget"/.test(html)) + +// The space in filter="widget, gadget" must be trimmed, not carried into the value. +// Anchor inside the attribute: a bare /\s"/ would match whitespace before almost any quote. +const padded = [...html.matchAll(/data-filter-value="([^"]*)"/g)] + .map(m => m[1]) + .filter(v => v !== v.trim()) +check( + 'whitespace around a category value is trimmed', + padded.length === 0, + `padded values: ${JSON.stringify(padded)}` +) + +// data-filter-container was only ever read by the deleted DOM fallback. +check('data-filter-container is gone', !/data-filter-container/.test(html)) + +// The filter+wrap table is still a proper wrapped data table. +check( + 'filter+wrap table is a wrapped data table', + /data-table-wrap=true/.test(filterWrap) && /data-table-sortable=true/.test(filterWrap) +) + +let failed = 0 +for (const { name, pass, detail } of checks) { + console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${pass || !detail ? '' : ` — ${detail}`}`) + if (!pass) failed++ +} +console.log(`\n${checks.length - failed}/${checks.length} passing`) +process.exit(failed ? 1 : 0) +``` + +- [ ] **Step 3: Build and watch it fail** + +```bash +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn \ + && node "$SCRATCH/verify-table-filter.mjs" +``` + +Expected: **FAIL**. The shortcode does not forward `filter`, so neither fixture table renders any +filter attributes or buttons at all. Record the output as the baseline. + +- [ ] **Step 4: Forward the arguments from the shortcode** + +In `layouts/_shortcodes/table.html`, the partial call currently ends with `"wrapper"` and +`"_default"`. Add the two filter arguments before `"_default"`: + +```hugo + {{ partial "assets/table.html" (dict + "page" .Page + "input" .Inner + "breakpoint" $args.breakpoint + "class" $args.class + "sortable" $args.sortable + "paginate" $args.paginate + "pagination" (or $args.pagination $args.pagingOptionPerPage) + "pagination-select" (or $args.paginationSelect $args.pagingOptionPageSelect) + "searchable" $args.searchable + "filter" $args.filter + "filter-col" $args.filterCol + "wrap" $args.wrap + "wrapper" $args.wrapper + "_default" $args.default + ) }} +``` + +- [ ] **Step 5: Normalize `filter` and make it imply a data table** + +In `layouts/_partials/assets/table.html`, the "Initialize local variables" block currently reads: + +```hugo +{{/* Initialize local variables */}} +{{ $breakpoint := site.Params.main.breakpoint | default "md" }} +{{ $dataTable := or $args.sortable $args.paginate $args.searchable }} +{{ $wrap := and $args.wrap (ne $breakpoint "xs") }} +``` + +Replace it with: + +```hugo +{{/* Initialize local variables */}} +{{ $breakpoint := site.Params.main.breakpoint | default "md" }} + +{{/* `filter` accepts a slice or a comma-separated string. A shortcode named parameter is always a + string, and InitArgs validates an argument's kind without casting between kinds, so a string + arrives here unsplit - and `range` cannot iterate a string. Normalize to a slice of trimmed, + non-empty values, so `filter="widget, gadget"` behaves like `filter="widget,gadget"`. */}} +{{ $filter := slice }} +{{ with $args.filter }} + {{ $values := . }} + {{ if not (reflect.IsSlice $values) }}{{ $values = split (printf "%v" $values) "," }}{{ end }} + {{ range $values }} + {{ $value := trim (printf "%v" .) " " }} + {{ if $value }}{{ $filter = $filter | append $value }}{{ end }} + {{ end }} +{{ end }} + +{{/* Filtering runs through simple-datatables' row model, so it composes with sorting, paging and + free-text search. A filtered table is therefore always a data table, even when all three are + off: the library still initializes, and `search()` still filters it - `searchable` gates only + the search input, not the method. This keeps `filter` self-sufficient, so an author never has + to switch on sorting they do not want just to get filtering. */}} +{{ $dataTable := or $args.sortable $args.paginate $args.searchable (gt (len $filter) 0) }} +{{ $wrap := and $args.wrap (ne $breakpoint "xs") }} +``` + +- [ ] **Step 6: Use the normalized filter in the Filter block** + +Further down, the Filter block currently reads: + +```hugo +{{/* Filter */}} +{{ $filter := $args.filter }} +{{ $filterCol := $args.filterCol | default 1 }} +``` + +`$filter` is now defined above, so drop its re-declaration: + +```hugo +{{/* Filter */}} +{{ $filterCol := $args.filterCol | default 1 }} +``` + +Leave the rest of that block (`$filterId`, the `data-filter-col` / `data-filter-id` attributes) and +the button-group markup exactly as they are — `{{ range $filter }}` now iterates a real slice. + +- [ ] **Step 7: Drop the dead `data-filter-container` attribute** + +The `.table-responsive` wrapper currently reads: + +```hugo +
+``` + +That attribute is read only by the DOM fallback deleted in Task 5. Replace the line with: + +```hugo +
+``` + +- [ ] **Step 8: Verify a `filter` passed as a real slice still works** + +The normalization has a `reflect.IsSlice` branch that no Markdown fixture can reach — a shortcode +parameter is always a string. Exercise it by calling the partial directly from a **temporary** +shortcode, so the real partial is under test. + +```bash +mkdir -p exampleSite/layouts/_shortcodes +cat > exampleSite/layouts/_shortcodes/table-slice-test.html <<'EOF' +{{ partial "assets/table.html" (dict + "page" .Page + "input" .Inner + "filter" (slice "widget" "gadget") + "filter-col" 1 +) }} +EOF +cat > exampleSite/content/en/table-slice-test.md <<'EOF' +--- +title: Table Slice Test +modules: ["simple-datatables"] +--- + +{{< table-slice-test >}} +| Name | Type | +|-------|--------| +| alpha | widget | +| bravo | gadget | +{{< /table-slice-test >}} +EOF +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn +grep -o 'data-filter-value="[^"]*"' exampleSite/public/en/table-slice-test/index.html | sort -u +``` + +Expected: three values — `""` (the All button), `"widget"` and `"gadget"`. A Hugo error, or a single +button, means the slice branch is broken. + +Now remove the temporary files: + +```bash +rm exampleSite/content/en/table-slice-test.md exampleSite/layouts/_shortcodes/table-slice-test.html +rmdir exampleSite/layouts/_shortcodes 2>/dev/null || true +``` + +- [ ] **Step 9: Update the wrap script's table count** + +The fixture page now holds five tables, not three. In `$SCRATCH/verify-table-wrap.mjs`, change the +first check's expectation: + +```js +check('one table per shortcode', tables.length === 5, `found ${tables.length}`) +``` + +Leave the `colspanRows.length === 6` expectation alone: the two new tables add none — the filter-only +table does not wrap, and the filter+wrap table is a data table, whose wrapped rows are produced by +JavaScript rather than by Hugo. + +- [ ] **Step 10: Build and verify both scripts** + +```bash +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn \ + && node "$SCRATCH/verify-table-filter.mjs" \ + && node "$SCRATCH/verify-table-wrap.mjs" +``` + +Expected: **both scripts fully passing**. The wrap script must stay green — this task must not +regress the shipped wrap feature. + +- [ ] **Step 11: Lint and commit** + +```bash +npm test +git add layouts/_shortcodes/table.html layouts/_partials/assets/table.html exampleSite/content/en/table-demo.md +git commit -m "fix(table): make the category filter reachable and self-sufficient + +The shortcode never forwarded filter or filter-col, so a filter declared in +Markdown validated cleanly and then did nothing. Forward both, and split a +comma-separated string into a slice: a shortcode parameter is always a string, +InitArgs validates a kind without casting, and range cannot iterate a string. + +A filtered table is now always a data table, even with sorting, paging and +search all off - filtering runs through simple-datatables' row model, so it +composes with them, and searchable gates only the search input, not the search +method. An author no longer has to enable sorting merely to get filtering. + +Drops data-filter-container, which only the DOM fallback ever read." +``` + +--- + +### Task 4: Warn when the page has not opted into the module + +Turns a silent failure into a build warning. This affects `sortable`, `paginate` and `searchable` +too, all of which fail silently today — it is what made an earlier test fixture vacuous. + +**Files:** + +- Modify: `layouts/_partials/assets/table.html` + +**Interfaces:** + +- Consumes: `$dataTable` (Task 3). + +- [ ] **Step 1: Understand why the opt-in cannot be automated** + +A data table needs the module's JavaScript **and** its per-page stylesheet. `footer/scripts.html` +runs after the content and honours the render-time `dependencies` scratch, but `head/head.html` +emits the module's stylesheet (`/css/simple-datatables.css`) **before** the content is rendered, so +it cannot see a dependency a shortcode declares mid-render. Auto-loading would attach the JavaScript +without the CSS. Do **not** call `AddModule` here. Warn instead. + +- [ ] **Step 2: Add the warning** + +In `layouts/_partials/assets/table.html`, immediately after the block that appends the +`data-table-wrap` attributes (the one commented "A wrapped data table is rendered uniformly…") and +before the `{{/* Filter */}}` block, insert: + +```hugo +{{/* A data table needs both the module's JavaScript and its per-page stylesheet, and Hinode loads an + optional module's assets only for pages that list it in frontmatter. head.html emits the + stylesheet before the content is rendered, so a render-time dependency cannot add it - warn + rather than fail silently. Skipped when the site configures the module as `core` or `critical`, + since it then loads on every page anyway. */}} +{{ if $dataTable }} + {{ with $args.page.Scratch.Get "modules" }} + {{ if in (.optional | default slice) "simple-datatables" }} + {{ $pageModules := slice | append $args.page.Params.modules }} + {{ if not (in $pageModules "simple-datatables") }} + {{ partial "utilities/LogWarn.html" (dict + "partial" "assets/table.html" + "warnid" "warn-missing-module" + "msg" "A sortable, paginated, searchable or filtered table requires the simple-datatables module. Add `modules: [\"simple-datatables\"]` to the page frontmatter." + "file" $args.page.File + )}} + {{ end }} + {{ end }} + {{ end }} +{{ end }} +``` + +`baseof.html:18` sets the `modules` scratch from `utilities/InitModules.html`, whose `optional` key +lists the modules the site declares as `integration = "optional"`. The `with` guard keeps the partial +safe if it is ever rendered outside `baseof.html`. `slice | append .Params.modules` is the same +idiom `head/head.html:21` uses, and flattens a frontmatter list. + +- [ ] **Step 3: Prove the warning fires** + +Create a page that uses a data table without opting in: + +```bash +cat > exampleSite/content/en/table-nomodule.md <<'EOF' +--- +title: Table Without Module +--- + +{{< table sortable="true" >}} +| Name | Type | +|-------|--------| +| alpha | widget | +{{< /table >}} +EOF +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn 2>&1 | grep -i "simple-datatables module" || echo "NO WARNING — the check is not firing" +``` + +Expected: a warning naming `table-nomodule.md` and telling the author to add the frontmatter key. + +- [ ] **Step 4: Prove it does NOT fire on a page that has opted in** + +```bash +rm exampleSite/content/en/table-nomodule.md +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn 2>&1 | grep -i "simple-datatables module" && echo "FALSE POSITIVE — warning fired on an opted-in page" || echo "no warning (correct)" +``` + +Expected: `no warning (correct)`. The fixture page declares `modules: ["simple-datatables"]`, so it +must stay silent. + +- [ ] **Step 5: Confirm nothing else regressed, then commit** + +```bash +node "$SCRATCH/verify-table-filter.mjs" && node "$SCRATCH/verify-table-wrap.mjs" +npm test +git status --short # table-nomodule.md must be gone +git add layouts/_partials/assets/table.html +git commit -m "fix(table): warn when a data table's module is not loaded + +A sortable, paginated, searchable or filtered table needs simple-datatables' +JavaScript and its per-page stylesheet, and Hinode loads an optional module's +assets only for pages listing it in frontmatter. Omitting the key produced a +table that silently did nothing. + +The opt-in cannot be added at render time: head.html emits the stylesheet before +the content renders, so a dependency declared by a shortcode would attach the +script without the styles. Warn at build time instead." +``` + +--- + +### Task 5: Delete the DOM fallback + +Committed in `mod-simple-datatables`. Every filtered table is a data table after Task 3, so the +fallback branch is unreachable — and it is the branch carrying the wrapped-table bug. + +**Files:** + +- Modify: `../mod-simple-datatables/assets/js/modules/simple-datatables/simple-datatables.load.js` + +**Interfaces:** + +- Consumes: `data-filter-table` / `data-filter-value` on the buttons and `data-filter-col` / + `data-filter-id` on the table (Task 3). + +- [ ] **Step 1: Branch the module** + +```bash +git -C ../mod-simple-datatables checkout -b fix/table-filter origin/main +head -1 ../mod-simple-datatables/go.mod +``` + +Expected: `module github.com/gethinode/mod-simple-datatables/v4` + +- [ ] **Step 2: Point the exampleSite at the local module** + +Two LOCAL-ONLY edits, reverted in Task 6 and **never committed**. + +`exampleSite/hinode.work` — add the module: + +```text +go 1.19 + +use . +use ../ +use ../../mod-simple-datatables +``` + +`exampleSite/config/_default/hugo.toml` — under `[module]`, directly below the existing +`workspace = "hinode.work"` line, add a `replacements` line (the file already carries commented-out +examples of exactly this). A workspace `use` directive alone does **not** override this module — +Hugo keeps resolving it from the committed `_vendor/` copy: + +```toml + replacements = 'github.com/gethinode/mod-simple-datatables/v4 -> /Users/mark/Development/GitHub/gethinode/mod-simple-datatables/' +``` + +Confirm the override took: + +```bash +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo config mounts -s exampleSite 2>/dev/null | grep -c "gethinode/mod-simple-datatables" || true +``` + +- [ ] **Step 3: Delete the fallback branch** + +In `../mod-simple-datatables/assets/js/modules/simple-datatables/simple-datatables.load.js`, replace +the whole `document.querySelectorAll('[data-filter-table]')` block — including its leading comment — +with: + +```js +// Category filter button group. +// Filtering runs through simple-datatables' search(term, columns, source), so it composes with +// sorting, pagination and the free-text search: the named source 'category-filter' is independent +// of the search input, so both narrow the result set at once. Hinode marks every filtered table as +// a data table, so an instance always exists by the time a button can be clicked. +document.querySelectorAll('[data-filter-table]').forEach(btn => { + btn.addEventListener('click', function () { + const tableId = this.getAttribute('data-filter-table') + const filterValue = this.getAttribute('data-filter-value').toLowerCase() + + // Update active button state + document.querySelectorAll(`[data-filter-table="${tableId}"]`).forEach(b => { + b.classList.toggle('active', b === this) + }) + + const instances = tableFilterInstances[tableId] + if (!instances) return + + instances.forEach(({ dt, filterCol }) => { + dt.search(filterValue, [filterCol], 'category-filter') + }) + }) +}) +``` + +The deleted branch iterated `tbody tr` and read `row.cells[col]`. On a wrapped table the second row +of each record holds a single `
`, so `cells[1]` was `undefined`, its text read as empty, +and the description row was hidden whenever a filter was active. Deleting it removes the bug. + +- [ ] **Step 4: Rebuild and confirm the generated HTML is unchanged** + +The change is browser-side, so Hugo's output must not move. + +```bash +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn \ + && node "$SCRATCH/verify-table-filter.mjs" && node "$SCRATCH/verify-table-wrap.mjs" +``` + +Expected: both scripts still fully passing. + +- [ ] **Step 5: Commit in mod-simple-datatables** + +```bash +git -C ../mod-simple-datatables add assets/js/modules/simple-datatables/simple-datatables.load.js +git -C ../mod-simple-datatables commit -m "fix(table): drop the filter's DOM fallback + +Hinode now marks every filtered table as a data table, so the fallback that +toggled row display directly is unreachable. It was also the branch carrying a +bug: it read row.cells[col] across every tbody tr, but a wrapped table's second +row per record holds a single colspan cell, so cells[1] was undefined and the +description row was hidden whenever a filter was active. + +Filtering now always runs through the row model, so it composes with sorting, +pagination and the free-text search." +``` + +--- + +### Task 6: Browser verification and clean-up + +Nothing so far proves a button click actually filters anything. + +**Files:** + +- Modify: `exampleSite/hinode.work`, `exampleSite/config/_default/hugo.toml` (both reverted) +- Modify: `exampleSite/hugo_stats.json` (build artefact, committed) + +- [ ] **Step 1: Serve the site on an isolated cache** + +Run in the background. The isolated resource directory keeps this build from touching the shared +`hinode/resources/_gen`. + +```bash +PATH="$PWD/node_modules/.bin:$PATH" HUGO_RESOURCEDIR="$SCRATCH/resources" HUGO_CACHEDIR="$SCRATCH/cache" \ + node_modules/.bin/hugo server -s exampleSite --port 1318 --disableFastRender +``` + +Use `127.0.0.1`, not `localhost` — an unrelated process squats on IPv6 localhost. The fixture is at +`http://127.0.0.1:1318/en/table-demo/`. + +- [ ] **Step 2: Sanity-check that the module loaded** + +`mod-simple-datatables` is `integration = "optional"`, so its JavaScript loads only because the +fixture page's frontmatter lists it. Confirm `typeof window.simpleDatatables === "object"` and that +`.fixture-filter` carries the `datatable-table` class. **If this fails, every check below is +vacuous — say so and stop.** + +- [ ] **Step 3: Exercise the filter-only table** + +On `.fixture-filter` at 1280×900: + +- Three buttons: `All` (active), `Widget`, `Gadget`. +- No pager and no search input — sorting, paging and search are all off, yet the table is a + DataTable. +- Click `Widget`: only `alpha` and `charlie` remain. Click `Gadget`: only `bravo` and `delta`. + Click `All`: all four return. +- The active button state follows the click. +- Console clean — zero uncaught exceptions. + +- [ ] **Step 4: Prove the filter composes with sort, page and search** + +On `.fixture-filter-wrap` at 1280×900 (`sortable`, `paginate` with `pagination=2`, `filter`): + +- Click `Widget`. The pager recounts to the **filtered** set (2 records → 1 page), not the full 4. +- Sort by Name descending. The filter survives the sort — still only widgets. +- Type `charlie` into the search box. The category filter and the free-text search narrow the set + **together** (this is what the separate `'category-filter'` search source buys). Clear it. + +- [ ] **Step 5: Prove the wrapped-table bug is gone** + +This is the defect the deleted fallback carried. On `.fixture-filter-wrap` at 400×900 (below the +breakpoint, so the table wraps): + +- Each visible record still shows **both** its rows — the data row and its full-width description + row. +- Click `Widget`. The filtered-out records disappear **entirely**, and every remaining record still + has its description row. A record showing its data row but *missing* its description row is the + old bug. + +- [ ] **Step 6: Stop the server, revert the local overrides** + +```bash +git checkout exampleSite/hinode.work exampleSite/config/_default/hugo.toml +git diff --stat exampleSite/hinode.work exampleSite/config/_default/hugo.toml +``` + +Expected: no output — both files clean. + +- [ ] **Step 7: Rebuild against the vendored module and commit the build stats** + +```bash +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn +node "$SCRATCH/verify-table-filter.mjs" && node "$SCRATCH/verify-table-wrap.mjs" +npm test +``` + +This build uses the **vendored** mod-simple-datatables, which does not carry Task 5, so its filter +still takes the (now unreachable) fallback branch. Every assertion in both scripts is about Hugo's +output, so both must still pass. + +```bash +git add exampleSite/hugo_stats.json +git commit -m "chore: update build stats" +``` + +- [ ] **Step 8: Confirm nothing stray is committed** + +```bash +git log --oneline main..HEAD +git diff --stat main..HEAD +``` + +Expected: the spec, the i18n and structure change, the AddModule fix, the filter wiring, the module +warning, and the build stats. **No** `hinode.work`, **no** `exampleSite/config/_default/hugo.toml`, +**no** scratch script, **no** `table-nomodule.md`. + +- [ ] **Step 9: Report the release ordering** + +Summarise for the user: two branches (`hinode:fix/table-category-filter`, +`mod-simple-datatables:fix/table-filter`). The module must merge and release before Hinode is bumped +to it; until then Hinode's filter works through the fallback branch, which is functionally correct +except on a wrapped table. + +--- + +## Notes for the reviewer + +- **The filter's original design is deliberately reversed.** Commit `63a88070`'s fallback was + written for "filter-only without sortable/paginate/searchable", and `table.yml`'s comment + deliberately excluded filtering from the features needing the module. That intent is overturned + here: the fallback was a second, inferior implementation — it toggled `display` and composed with + nothing, while `dt.search()` filters through the row model and survives sorting, paging and search. +- **`filter` implying a data table** is what keeps the argument usable on its own after the fallback + is deleted. Without it, `{{< table filter="a,b" >}}` would render dead buttons. +- **`AddModule.html` was already broken** and is fixed here even though the filter does not use it — + the design had to establish why the module opt-in cannot be automated, which meant reading it. diff --git a/docs/superpowers/plans/2026-07-14-unique-id-helper.md b/docs/superpowers/plans/2026-07-14-unique-id-helper.md new file mode 100644 index 000000000..dd6162688 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-unique-id-helper.md @@ -0,0 +1,835 @@ +# Deterministic Element-Id Helper — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace every `md5(delimit (slice . now) "-")` element id with a shared, deterministic helper, so a page carrying one of these components stops emitting different HTML on every build. + +**Architecture:** A new `utilities/UniqueID.html` in **mod-utils** returns `-` from a per-prefix counter held in the page's `Store`. mod-utils is the home because no gethinode module declares a dependency on Hinode. `utilities/AddModule.html` moves there too, for the same reason. mod-blocks' four call sites and Hinode's table counter then both call the helper. + +**Tech Stack:** Hugo templates and partials; Hugo modules; Bookshop components; Playwright for browser verification. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-07-14-unique-id-helper-design.md` (in the hinode repo). Read it first. +- Three repositories, siblings under `/Users/mark/Development/GitHub/gethinode/`: + `mod-utils` (on `main` — Task 1 branches it), `mod-blocks` (on `main` — Task 2 branches it), + and `hinode` (branch `fix/table-category-filter`, already checked out, carrying the spec). +- **Why mod-utils and not Hinode:** none of mod-blocks, mod-lottie, mod-leaflet, mod-mermaid or + mod-simple-datatables imports Hinode. A partial in Hinode reaches them only because the final + *site* merges Hinode's layouts — an undeclared inverted dependency that breaks each module's own + exampleSite build. Hugo resolves partials by path across the merged layout tree, so moving a + partial between modules is transparent to its callers. +- **`page.Store`, never `.Ordinal`.** `.Ordinal` restarts at 0 inside an embedded `RenderString` + (which is what the `example` shortcode does), so ids collide. `page.Store` was measured to survive + that boundary, and a spike confirmed a `page.Store` counter in mod-blocks' `panels` component is + identical across two builds and unique within the page. +- A DOM id only needs to be unique **within the document that carries it**. The helper does not + attempt cross-page uniqueness. +- Gaps in the numbering are expected and harmless: Hugo renders a page's content more than once + (the search index calls `.Plain`), and the discarded passes consume counter values without + emitting ids. The spike produced `panel-1, -2, -3, -5`. +- Commits follow Angular Conventional Commits (a commitlint pre-commit hook enforces this in each + repo). Body lines must not exceed 100 characters. +- **Each of the three branches must carry at least one `feat:` or `fix:` commit.** gethinode merges + with merge commits, so semantic-release reads the individual commits, not the PR title. + `refactor`, `style`, `test`, `chore`, `docs` and `build` produce **no release**. +- **Never run `npm run build:example` or `npm run start:example` in hinode** — their prebuild hooks + re-vendor Hugo modules from the remote and would wipe the local overrides Tasks 2–4 rely on. Use + the project-pinned binary with `node_modules/.bin` on `PATH`: + + ```bash + PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn + ``` + +- Export the scratch path once per shell: + + ```bash + export SCRATCH=/private/tmp/claude-501/-Users-mark-Development-GitHub-gethinode-hinode/491eb2c0-b0cd-401d-9256-134afea0e318/scratchpad + ``` + +- **Release ordering is strictly sequential: mod-utils → mod-blocks → Hinode.** mod-blocks goes + before Hinode so Hinode's exampleSite (whose `exampleSite/go.mod` pins mod-blocks) picks up the + fixed ids when it deploys. The `go.mod` version bumps happen at merge time, once each release + exists — **not in this plan**. All verification here runs against local module overrides. +- Hinode's `layouts/_partials/utilities/AddModule.html` is deleted in Task 4. It must not be + deleted before mod-utils provides the replacement, or the partial disappears from the merged + layout tree and every caller fails. Between the mod-utils release and Hinode's merge, the partial + exists in **both** modules; Hugo resolves the project's own layouts ahead of an imported module's, + so Hinode's copy shadows mod-utils'. They are identical, so the interval is harmless. + +## File Structure + +| File | Repo | Responsibility | +| --- | --- | --- | +| `layouts/_partials/utilities/UniqueID.html` | mod-utils | **New.** Returns a page-unique, build-stable id. | +| `layouts/_partials/utilities/AddModule.html` | mod-utils | **New (moved from Hinode).** Declares a render-time module dependency. | +| `component-library/components/panels/panels.hugo.html` | mod-blocks | `panel-` → helper. | +| `component-library/components/faq/faq.hugo.html` | mod-blocks | `faq-` → helper. | +| `layouts/partials/assets/testimonial-carousel.html` | mod-blocks | `testimonial-carousel-` → helper. | +| `layouts/partials/assets/preview.html` | mod-blocks | `preview-` → helper. | +| `layouts/_partials/assets/table.html` | hinode | Inline `hinodeTableFilterCount` counter → helper. | +| `layouts/_partials/utilities/AddModule.html` | hinode | **Deleted** (mod-utils provides it). | + +## Affected pages in Hinode's exampleSite + +Every page below carries at least one id this plan changes. Task 5 verifies all six. + +| Page | Component | +| --- | --- | +| `/en/docs/blocks/panels/` | panels (4 ids) | +| `/en/docs/blocks/faq/` | faq | +| `/en/docs/blocks/testimonials/` | testimonial-carousel | +| `/en/docs/blocks/preview/` | preview | +| `/en/docs/components/example/` | preview | +| `/en/table-demo/` | table filter | + +--- + +### Task 1: The helper, and AddModule's new home + +**Repo:** mod-utils. + +**Files:** + +- Create: `layouts/_partials/utilities/UniqueID.html` +- Create: `layouts/_partials/utilities/AddModule.html` + +**Interfaces:** + +- Produces: `partial "utilities/UniqueID.html" (dict "prefix" "" "page" )` + → returns the string `"-"`, `n` counting from 1 per prefix per page. +- Produces: `partial "utilities/AddModule.html" (dict "module" "" "page" )` + → appends `` to the page's `dependencies` scratch, deduplicated. + +- [ ] **Step 1: Branch mod-utils** + +```bash +git -C ../mod-utils checkout -b feat/unique-id origin/main +git -C ../mod-utils log --oneline -1 +``` + +- [ ] **Step 2: Write the failing test** + +The partials are not called from anywhere in mod-utils, so exercise them in a throwaway Hugo site. +Create it at `$SCRATCH/uid-test`: + +```bash +export SCRATCH=/private/tmp/claude-501/-Users-mark-Development-GitHub-gethinode-hinode/491eb2c0-b0cd-401d-9256-134afea0e318/scratchpad +T=$SCRATCH/uid-test +rm -rf $T && mkdir -p $T/layouts/_partials/utilities $T/content +printf 'title = "t"\nbaseURL = "/"\ndisableKinds = ["taxonomy","term","rss","sitemap"]\n' > $T/hugo.toml +printf -- '---\ntitle: home\n---\n' > $T/content/_index.md +printf -- '---\ntitle: other\n---\n' > $T/content/other.md + +cat > $T/layouts/index.html <<'EOF' +{{/* Per-prefix counters must be independent, and must count from 1. */}} +A={{ partial "utilities/UniqueID.html" (dict "prefix" "panel") }} +B={{ partial "utilities/UniqueID.html" (dict "prefix" "panel") }} +C={{ partial "utilities/UniqueID.html" (dict "prefix" "faq") }} +D={{ partial "utilities/UniqueID.html" (dict "prefix" "panel") }} + +{{/* An explicit page anchors the counter to THAT page, not the rendering one. */}} +{{ $other := site.GetPage "/other" }} +E={{ partial "utilities/UniqueID.html" (dict "prefix" "panel" "page" $other) }} +F={{ partial "utilities/UniqueID.html" (dict "prefix" "panel") }} + +{{/* AddModule: append + dedup, and honour an explicit page. */}} +{{ partial "utilities/AddModule.html" (dict "module" "alpha") }} +{{ partial "utilities/AddModule.html" (dict "module" "beta") }} +{{ partial "utilities/AddModule.html" (dict "module" "alpha") }} +{{ partial "utilities/AddModule.html" (dict "module" "gamma" "page" $other) }} +DEPS={{ delimit (page.Scratch.Get "dependencies") "," }} +OTHERDEPS={{ delimit ($other.Scratch.Get "dependencies") "," }} +EOF +``` + +Copy the (not yet existing) partials in and build: + +```bash +cp ../mod-utils/layouts/_partials/utilities/UniqueID.html $T/layouts/_partials/utilities/ 2>/dev/null +cp ../mod-utils/layouts/_partials/utilities/AddModule.html $T/layouts/_partials/utilities/ 2>/dev/null +node_modules/.bin/hugo --quiet -s $T --destination $T/public 2>&1 | head -3 +``` + +Expected: the build **FAILS** — `partial "utilities/UniqueID.html" not found`. That is the baseline. + +- [ ] **Step 3: Write `UniqueID.html`** + +Create `../mod-utils/layouts/_partials/utilities/UniqueID.html`: + +```hugo +{{/* + Returns a page-unique, build-stable element id of the form "-", counting from 1. + + Use this instead of hashing `now` (which changes the id on every build, so a page carrying the + component never renders byte-identically) and instead of `.Ordinal` (which restarts at 0 inside + an embedded RenderString, as the `example` shortcode performs, so ids collide). + + The counter lives in the page's Store, namespaced per prefix, so each component type counts + independently. Gaps are expected and harmless: Hugo renders a page's content more than once + (the search index calls `.Plain`), and a discarded pass consumes a value without emitting an id. + + A DOM id need only be unique within the document that carries it, so no attempt is made to be + unique across pages. + + Arguments: + prefix (string, required) the id's leading segment, e.g. "panel" + page (optional) the page to anchor the counter to; defaults to the current page +*/}} + +{{- $prefix := .prefix -}} +{{- if not $prefix -}} + {{- errorf "partial [utilities/UniqueID.html] - missing required argument 'prefix'" -}} +{{- end -}} + +{{- $page := .page -}} +{{- if not $page -}} + {{- $page = page -}} +{{- end -}} + +{{- $key := printf "hinode-uid-%s" $prefix -}} +{{- $count := add (or ($page.Store.Get $key) 0) 1 -}} +{{- $page.Store.Set $key $count -}} + +{{- return printf "%s-%d" $prefix $count -}} +``` + +- [ ] **Step 4: Add `AddModule.html`** + +Create `../mod-utils/layouts/_partials/utilities/AddModule.html` with exactly this content. It is +the current Hinode version, carrying both fixes it received while the table filter was repaired: it +appends and deduplicates (an older version used `complement`, which *replaced* the dependency list +and wiped it entirely when re-adding an entry already present), and it honours an explicitly passed +`page` instead of ignoring it in favour of the global `page`. **Preserve the tab indentation.** + +```hugo +{{- $page := .page -}} +{{- if not $page -}} + {{- $page = page -}} +{{- end -}} +{{ with .module }} + {{- $dependencies := $page.Scratch.Get "dependencies" -}} + {{- if reflect.IsSlice $dependencies -}} + {{- $dependencies = $dependencies | append . | uniq -}} + {{ else }} + {{- $dependencies = slice . -}} + {{ end }} + {{- $page.Scratch.Set "dependencies" $dependencies -}} +{{ end }} +``` + +- [ ] **Step 5: Run the test** + +```bash +cp ../mod-utils/layouts/_partials/utilities/UniqueID.html "$SCRATCH/uid-test/layouts/_partials/utilities/" +cp ../mod-utils/layouts/_partials/utilities/AddModule.html "$SCRATCH/uid-test/layouts/_partials/utilities/" +node_modules/.bin/hugo --quiet -s "$SCRATCH/uid-test" --destination "$SCRATCH/uid-test/public" +grep -E '^[A-F]=|^DEPS=|^OTHERDEPS=' "$SCRATCH/uid-test/public/index.html" +``` + +Expected, exactly: + +```text +A=panel-1 +B=panel-2 +C=faq-1 +D=panel-3 +E=panel-1 +F=panel-4 +DEPS=alpha,beta +OTHERDEPS=gamma +``` + +Read that carefully — every line is load-bearing: + +- `C=faq-1` proves the counters are **per prefix**, not one shared counter. +- `E=panel-1` proves an explicit `page` anchors the counter to **that** page (which has its own, + untouched counter), not to the page being rendered. +- `F=panel-4` proves the rendering page's counter was **not** disturbed by `E` — it resumes where + `D` left off. +- `DEPS=alpha,beta` proves AddModule appends and deduplicates. A bare `alpha`, or an empty value, + means the list is being replaced instead of appended to. +- `OTHERDEPS=gamma` proves AddModule honours an explicit page, and that `gamma` did **not** leak + into the rendering page's `DEPS`. + +- [ ] **Step 6: Confirm the missing-prefix guard** + +```bash +cp "$SCRATCH/uid-test/layouts/index.html" "$SCRATCH/uid-test/index.html.bak" +printf 'X={{ partial "utilities/UniqueID.html" (dict "prefix" "") }}\n' >> "$SCRATCH/uid-test/layouts/index.html" +node_modules/.bin/hugo --quiet -s "$SCRATCH/uid-test" --destination "$SCRATCH/uid-test/public" 2>&1 | grep -i "missing required argument" && echo "guard fires" +mv "$SCRATCH/uid-test/index.html.bak" "$SCRATCH/uid-test/layouts/index.html" +``` + +Expected: the build errors with `missing required argument 'prefix'`, and the final `mv` restores +the working template. (The throwaway site is not a git repo, so restore it by copy, not `git`.) + +- [ ] **Step 7: Commit** + +```bash +git -C ../mod-utils add layouts/_partials/utilities/UniqueID.html layouts/_partials/utilities/AddModule.html +git -C ../mod-utils commit -m "feat(utilities): add UniqueID and adopt AddModule + +UniqueID returns a page-unique, build-stable element id from a per-prefix +counter in the page's Store. It replaces hashing \`now\` (which changes the id on +every build, so a page carrying the component never renders byte-identically) +and \`.Ordinal\` (which restarts at 0 inside an embedded RenderString, so ids +collide). + +AddModule moves here from Hinode. No gethinode module declares a dependency on +Hinode, so a partial there reaches them only because the site merges Hinode's +layouts - an undeclared inverted dependency that breaks each module's own +exampleSite build." +``` + +--- + +### Task 2: mod-blocks' four call sites + +**Repo:** mod-blocks. + +**Files:** + +- Modify: `component-library/components/panels/panels.hugo.html:37` +- Modify: `component-library/components/faq/faq.hugo.html:42` +- Modify: `layouts/partials/assets/testimonial-carousel.html:48` +- Modify: `layouts/partials/assets/preview.html:243` + +**Interfaces:** + +- Consumes: `partial "utilities/UniqueID.html" (dict "prefix" "" "page" )` + → `"-"` (Task 1). + +- [ ] **Step 1: Branch mod-blocks** + +```bash +git -C ../mod-blocks checkout -b fix/deterministic-ids origin/main +git -C ../mod-blocks log --oneline -1 +``` + +- [ ] **Step 2: Convert `panels.hugo.html`** + +Line 37 currently reads: + +```hugo + {{ $parentID := printf "panel-%v" (md5 (delimit (slice . now) "-")) }} +``` + +Replace it with: + +```hugo + {{ $parentID := partial "utilities/UniqueID.html" (dict "prefix" "panel" "page" $._page) }} +``` + +`$._page` is Bookshop's content page, addressed from the template's root context so it resolves +regardless of nesting. `list.hugo.html` in the same component library already uses it. If it is nil +in this context the helper falls back to the current page, which the spike proved correct here. + +- [ ] **Step 3: Convert `faq.hugo.html`** + +Line 42 currently reads: + +```hugo + {{ $id := printf "faq-%s" (md5 (delimit (slice . now) "-")) }} +``` + +Replace it with (preserving the tab indentation): + +```hugo + {{ $id := partial "utilities/UniqueID.html" (dict "prefix" "faq" "page" $._page) }} +``` + +- [ ] **Step 4: Convert `testimonial-carousel.html`** + +Line 48 currently reads: + +```hugo + {{ $id := printf "testimonial-carousel-%s" (md5 (delimit (slice . now) "-")) }} +``` + +Replace it with (preserving the two tabs): + +```hugo + {{ $id := partial "utilities/UniqueID.html" (dict "prefix" "testimonial-carousel") }} +``` + +This is a partial, not a Bookshop component, so it has no `._page`; it takes the helper's default +of the current page. + +- [ ] **Step 5: Convert `preview.html`** + +Line 243 currently reads: + +```hugo +{{- $id := printf "preview-%s" (md5 (delimit (slice . now) "-")) -}} +``` + +Replace it with: + +```hugo +{{- $id := partial "utilities/UniqueID.html" (dict "prefix" "preview") -}} +``` + +- [ ] **Step 6: Confirm no `md5(… now …)` id remains anywhere in mod-blocks** + +```bash +grep -rn 'md5 (delimit (slice . now)' ../mod-blocks/layouts ../mod-blocks/component-library || echo "none remaining" +``` + +Expected: `none remaining`. + +- [ ] **Step 7: Confirm each id's downstream references are untouched** + +Each id is referenced elsewhere in its own component — `data-companion`, `aria-controls`, +`data-bs-target`, `data-bs-parent`. They derive from the same variable (`$parentID` / `$id`), so +they follow automatically. Confirm you changed only the assignment line in each file: + +```bash +git -C ../mod-blocks diff --stat +``` + +Expected: 4 files, 4 insertions, 4 deletions. + +- [ ] **Step 8: Commit** + +```bash +git -C ../mod-blocks add component-library/components/panels/panels.hugo.html \ + component-library/components/faq/faq.hugo.html \ + layouts/partials/assets/testimonial-carousel.html \ + layouts/partials/assets/preview.html +git -C ../mod-blocks commit -m "fix(blocks): stop element ids changing on every build + +Four components built a DOM id from md5(... now ...), so the id changed on every +build and any page carrying one emitted different HTML each time - defeating +output diffing and making caches treat an unchanged page as changed. + +Use mod-utils' UniqueID helper, which counts from a per-prefix counter in the +page's Store." +``` + +(The build and browser verification happen in Tasks 4 and 5, against Hinode's exampleSite — the +only site where mod-blocks' components fully render.) + +--- + +### Task 3: Hinode — migrate the table, drop the moved partial + +**Repo:** hinode, branch `fix/table-category-filter`. + +**Files:** + +- Modify: `layouts/_partials/assets/table.html:77-84` +- Delete: `layouts/_partials/utilities/AddModule.html` + +**Interfaces:** + +- Consumes: `partial "utilities/UniqueID.html" (dict "prefix" "table-filter" "page" $args.page)` + → `"table-filter-"` (Task 1). The rendered id keeps the same shape it has today. + +- [ ] **Step 1: Migrate the table's counter** + +`layouts/_partials/assets/table.html` currently reads: + +```hugo +{{ $filterId := "" }} +{{ if $filter }} + {{ $store := $args.page.Store }} + {{ $count := add (or ($store.Get "hinodeTableFilterCount") 0) 1 }} + {{ $store.Set "hinodeTableFilterCount" $count }} + {{ $filterId = printf "table-filter-%d" $count }} + {{ $attributes = printf "%s data-filter-col=%d data-filter-id=%s" $attributes $filterCol $filterId }} +{{ end }} +``` + +Replace that block with: + +```hugo +{{ $filterId := "" }} +{{ if $filter }} + {{ $filterId = partial "utilities/UniqueID.html" (dict "prefix" "table-filter" "page" $args.page) }} + {{ $attributes = printf "%s data-filter-col=%d data-filter-id=%s" $attributes $filterCol $filterId }} +{{ end }} +``` + +The output shape is unchanged (`table-filter-`); this removes the second implementation of the +same mechanism. Leave the surrounding comment about determinism in place if one is there — but if it +explains the inline counter's mechanics, retire it, since the helper now documents itself. + +- [ ] **Step 2: Delete Hinode's `AddModule.html`** + +```bash +git rm layouts/_partials/utilities/AddModule.html +``` + +mod-utils now provides it at the same path. Its three callers — +`layouts/_partials/assets/lightbox.html`, `layouts/_shortcodes/table.html`, and mod-blocks' +`list.hugo.html` — are unaffected, because Hugo resolves partials by path across the merged layout +tree. + +- [ ] **Step 3: Wire the exampleSite to the local mod-utils and mod-blocks** + +Both edits are LOCAL-ONLY and are reverted in Task 6. Neither may ever be committed. + +`exampleSite/hinode.work`: + +```text +go 1.19 + +use . +use ../ +use ../../mod-utils +use ../../mod-blocks +``` + +`exampleSite/config/_default/hugo.toml`, under `[module]`, directly below the existing +`workspace = "hinode.work"` line (the file already carries commented-out examples of exactly this): + +```toml + replacements = 'github.com/gethinode/mod-utils/v6 -> /Users/mark/Development/GitHub/gethinode/mod-utils/, github.com/gethinode/mod-blocks/v2 -> /Users/mark/Development/GitHub/gethinode/mod-blocks/' +``` + +A workspace `use` directive alone does **not** reliably override a module — Hugo keeps resolving it +from the committed `_vendor/` copy. Both mechanisms are needed. + +- [ ] **Step 4: Prove the override is genuinely in effect** + +`hugo config mounts` is **not** sufficient — it has been observed to report a replacement correctly +while the build still compiled the vendored copy. Prove it from the built output instead: with the +local mod-utils in play, the ids must be counters, not hashes. + +```bash +rm -rf exampleSite/public +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel error +grep -o 'id="dropdown-panel-[^"]*"' exampleSite/public/en/docs/blocks/panels/index.html | sort +``` + +Expected: ids like `dropdown-panel-1`. If you still see a 32-character hex hash, the override is not +in effect and every check below is meaningless — stop and fix the override first. + +- [ ] **Step 5: Confirm the AddModule deletion did not break its callers** + +The table shortcode's missing-module warning is the loudest AddModule-adjacent behaviour, and the +lightbox partial is the other caller. A missing partial is a hard build error, so a clean build is +itself most of the proof — but confirm the warning still works, since it is the thing a user sees. + +```bash +cat > exampleSite/content/en/tmp-nomodule.md <<'EOF' +--- +title: Temp No Module +--- + +{{< table sortable="true" >}} +| Name | Type | +|-------|--------| +| alpha | widget | +{{< /table >}} +EOF +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn 2>&1 | grep -i "simple-datatables module" && echo "warning still fires" +rm exampleSite/content/en/tmp-nomodule.md +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn 2>&1 | grep -ci "simple-datatables module" || echo "0 on a stock build — correct" +``` + +Expected: the warning fires for the temporary page and names it, and a stock build emits **zero** +such warnings. + +- [ ] **Step 6: Confirm the table's own ids still work** + +```bash +node "$SCRATCH/verify-table-filter.mjs" && node "$SCRATCH/verify-table-wrap.mjs" +grep -o 'data-filter-id=[^ >]*' exampleSite/public/en/table-demo/index.html | sort -u +``` + +Expected: both scripts fully passing, and two distinct `table-filter-` ids. + +- [ ] **Step 7: Lint and commit** + +```bash +npm test +git add layouts/_partials/assets/table.html +git commit -m "fix(table): take the filter id from the shared UniqueID helper + +The table carried its own page.Store counter, duplicating what mod-utils' +UniqueID now provides. Same rendered id shape, one implementation. + +Also drops Hinode's copy of AddModule.html, which has moved to mod-utils: +mod-blocks calls it without declaring a dependency on Hinode, so it belongs +where every module can legitimately reach it." +``` + +(The `git rm` from Step 2 is already staged, so this commit carries both changes.) + +--- + +### Task 4: Determinism and uniqueness across every affected page + +**Repo:** hinode (verification only — no code changes). + +Nothing so far proves the ids are stable across builds or unique within a page. This task measures +both, on all six affected pages. + +**Files:** none. + +- [ ] **Step 1: Write the check script** + +Create `$SCRATCH/verify-ids.mjs`. It is a scratch file — do **not** commit it. + +```js +import { readFileSync, writeFileSync } from 'node:fs' + +// Every page that carries an id this change touches, and the id prefixes it should carry. +const PAGES = [ + ['en/docs/blocks/panels/index.html', ['dropdown-panel']], + ['en/docs/blocks/faq/index.html', ['faq']], + ['en/docs/blocks/testimonials/index.html', ['testimonial-carousel']], + ['en/docs/blocks/preview/index.html', ['preview']], + ['en/docs/components/example/index.html', ['preview']], + ['en/table-demo/index.html', ['table-filter']], +] + +const idsOf = (page, prefixes) => { + const html = readFileSync(`exampleSite/public/${page}`, 'utf8') + const found = [] + for (const p of prefixes) { + // Match the id wherever it is used: id=, aria-controls=, data-bs-target=, data-companion=, … + for (const m of html.matchAll(new RegExp(`["'#]${p}-([\\w-]+)`, 'g'))) { + found.push(`${p}-${m[1]}`) + } + } + return found +} + +const which = process.argv[2] +if (which !== 'a' && which !== 'b') { + throw new Error('pass "a" or "b" to record that build\'s ids') +} + +const snapshot = PAGES.map(([page, prefixes]) => [page, idsOf(page, prefixes)]) +writeFileSync( + `${process.env.SCRATCH}/ids-${which}.json`, + JSON.stringify(snapshot, null, 2) +) +console.log(`recorded build ${which}`) +``` + +- [ ] **Step 2: Build twice and compare** + +```bash +export SCRATCH=/private/tmp/claude-501/-Users-mark-Development-GitHub-gethinode-hinode/491eb2c0-b0cd-401d-9256-134afea0e318/scratchpad +build() { rm -rf exampleSite/public; PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel error >/dev/null; } +build && node "$SCRATCH/verify-ids.mjs" a +build && node "$SCRATCH/verify-ids.mjs" b +diff "$SCRATCH/ids-a.json" "$SCRATCH/ids-b.json" && echo "DETERMINISTIC — identical across builds" || echo "FAIL — ids differ between builds" +``` + +Expected: `DETERMINISTIC — identical across builds`. + +- [ ] **Step 3: Confirm no id is a hash, and none repeats within a page** + +```bash +node -e ' +const ids = require(process.env.SCRATCH + "/ids-a.json"); +let bad = 0; +for (const [page, list] of ids) { + if (!list.length) { console.log("FAIL no ids found on", page); bad++; continue } + const hashy = list.filter(id => /-[0-9a-f]{16,}$/.test(id)); + if (hashy.length) { console.log("FAIL hash-shaped id on", page, hashy[0]); bad++ } + // An id may legitimately appear several times on a page (id=, aria-controls=, data-bs-target=). + // What must not happen is two DIFFERENT elements sharing one id — check the id= attributes only. + console.log("ok ", page, "->", [...new Set(list)].join(", ")); +} +process.exit(bad ? 1 : 0) +' +``` + +Expected: an `ok` line per page listing counter-shaped ids (`dropdown-panel-1`, `faq-1`, …) and no +`FAIL`. + +- [ ] **Step 4: Confirm each `id=` attribute is unique within its page** + +The check above allows an id to *appear* many times (a `data-bs-target` points at an `id`). What +must never happen is two elements declaring the same `id`. + +```bash +for p in en/docs/blocks/panels en/docs/blocks/faq en/docs/blocks/testimonials en/docs/blocks/preview en/docs/components/example en/table-demo; do + dupes=$(grep -o 'id="[^"]*"' "exampleSite/public/$p/index.html" | sort | uniq -d) + if [ -n "$dupes" ]; then echo "FAIL duplicate id= on /$p:"; echo "$dupes"; else echo "ok /$p"; fi +done +``` + +Expected: `ok` for all six. **A duplicate here is a Critical failure** — it means two elements share +an id and Bootstrap will drive the wrong one. + +--- + +### Task 5: Browser verification + +**Repo:** hinode (verification only — no code changes). + +A deterministic id that points at the wrong element is worse than a random one. This task proves +each component still drives *its own* element. + +**Files:** none. + +- [ ] **Step 1: Serve the exampleSite** + +With the local overrides from Task 3 still active, on an isolated cache so other Hugo processes are +undisturbed. Run it in the background. + +```bash +PATH="$PWD/node_modules/.bin:$PATH" HUGO_RESOURCEDIR="$SCRATCH/resources" HUGO_CACHEDIR="$SCRATCH/cache" \ + node_modules/.bin/hugo server -s exampleSite --port 1321 --disableFastRender +``` + +Use `127.0.0.1`, not `localhost` — an unrelated process squats on IPv6 localhost. + +- [ ] **Step 2: Panels — `http://127.0.0.1:1321/en/docs/blocks/panels/`** + +This page carries **four** panel ids, so it is the strongest test of per-element pairing. + +- Every dropdown opens **its own** panel. Open each one in turn and confirm the panel that expands + is the one directly associated with it, not another panel on the page. +- Confirm from the DOM that each toggle's `data-companion` / `aria-controls` matches the `id` of the + panel it actually opened. +- Zero uncaught console exceptions. + +- [ ] **Step 3: FAQ — `http://127.0.0.1:1321/en/docs/blocks/faq/`** + +- Each accordion item toggles the item it belongs to, and **only** that one. Open one item and + confirm no other item opened with it. +- Zero uncaught console exceptions. + +- [ ] **Step 4: Testimonials — `http://127.0.0.1:1321/en/docs/blocks/testimonials/`** + +- The carousel cycles, and its indicators target its own slides. +- Zero uncaught console exceptions. + +- [ ] **Step 5: Preview — both pages** + +`http://127.0.0.1:1321/en/docs/blocks/preview/` and +`http://127.0.0.1:1321/en/docs/components/example/` (the preview component appears on both). + +- The device tabs (desktop / tablet / mobile) switch the preview they belong to. On a page with more + than one preview, confirm a tab does not resize a different preview. +- Zero uncaught console exceptions. + +- [ ] **Step 6: Table — `http://127.0.0.1:1321/en/table-demo/`** + +The filter's id now comes from the helper, so re-confirm the pairing survived. + +- Clicking a category filters **its own** table, not the other filtered table on the page. +- Zero uncaught console exceptions. + +- [ ] **Step 7: Stop the server and report** + +Report any failure rather than papering over it. + +--- + +### Task 6: Clean up and hand off the release sequence + +**Repo:** hinode. + +**Files:** + +- Modify: `exampleSite/hinode.work` (revert) +- Modify: `exampleSite/config/_default/hugo.toml` (revert) +- Modify: `exampleSite/hugo_stats.json` (build artefact) + +- [ ] **Step 1: Revert the local overrides** + +```bash +git checkout exampleSite/hinode.work exampleSite/config/_default/hugo.toml +git diff --stat exampleSite/hinode.work exampleSite/config/_default/hugo.toml +``` + +Expected: no output — both files clean. **Neither may ever be committed.** + +- [ ] **Step 2: Rebuild against the vendored modules** + +```bash +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel warn +``` + +This build uses the **vendored** mod-utils and mod-blocks, which do not yet carry Tasks 1 and 2. So +the panels/faq/testimonial/preview ids revert to hashes here, and Hinode's own `AddModule.html` is +gone while mod-utils does not yet provide it — **this build is expected to fail**, with a missing +`utilities/AddModule.html`. + +That failure is the ordering constraint made visible, not a defect: Hinode cannot merge until +mod-utils has released and Hinode's `go.mod` is bumped to it. **Record the exact error text** — it +is the evidence for the handoff in Step 5. + +Then restore the override so the remaining steps have a buildable tree, and revert it again at the +very end: + +```bash +# re-apply the Task 3 Step 3 overrides, rebuild, then revert them +PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/hugo --gc -s exampleSite --logLevel error +git checkout exampleSite/hinode.work exampleSite/config/_default/hugo.toml +``` + +- [ ] **Step 3: Commit the build stats if they changed** + +```bash +git status --short +``` + +If `exampleSite/hugo_stats.json` is dirty **and its diff is semantic** (new class or id names rather +than churning hashes), commit it: + +```bash +git add exampleSite/hugo_stats.json +git commit -m "chore: update build stats" +``` + +If the only diff is hash-shaped ids churning, restore it instead — that churn is exactly what this +change exists to remove, and it will settle once the modules are released: + +```bash +git checkout exampleSite/hugo_stats.json +``` + +- [ ] **Step 4: Confirm nothing stray is committed** + +```bash +git log --oneline main..HEAD +git diff --stat main..HEAD +``` + +Expected: no `hinode.work`, no `exampleSite/config/_default/hugo.toml`, no scratch script, no +temporary content page. + +- [ ] **Step 5: Report the release and bump sequence** + +Summarise for the user. Three branches: + +- `mod-utils:feat/unique-id` — new PR +- `mod-blocks:fix/deterministic-ids` — new PR +- `hinode:fix/table-category-filter` — the existing PR #2024, amended + +Merge order is **strictly sequential**, and each step's `go.mod` bump can only happen after the +previous release exists: + +1. Merge **mod-utils**. semantic-release cuts a minor (the branch carries a `feat:`). +2. Bump `mod-blocks/go.mod` to that mod-utils release; merge **mod-blocks**. semantic-release cuts a + patch (the branch carries a `fix:`). +3. Bump `hinode/go.mod` to the mod-utils release **and** `hinode/exampleSite/go.mod` to the + mod-blocks release; then merge **hinode**. + +State plainly that Hinode **cannot** merge before mod-utils releases: it deletes its own +`AddModule.html`, and until `go.mod` points at a mod-utils that provides the replacement, the +partial is missing from the merged layout tree and the build fails. + +--- + +## Notes for the reviewer + +- **`page.Store`, not `.Ordinal`.** `.Ordinal` restarts at 0 inside an embedded `RenderString` — + which is exactly what the `example` shortcode does — so two components in two `{{< example >}}` + blocks on one page would collide. `page.Store` was measured to survive that boundary. +- **Gaps in the numbering are expected.** Hugo renders a page's content more than once (the search + index calls `.Plain`), and the discarded pass consumes counter values without emitting ids. The + spike produced `panel-1, -2, -3, -5`. Uniqueness and determinism are what matter, not contiguity. +- **The helper lives in mod-utils, not Hinode,** because no gethinode module declares a Hinode + dependency. This also unblocks the planned `.Ordinal` migration, which must reach mod-lottie and + mod-leaflet. diff --git a/docs/superpowers/specs/2026-07-14-table-category-filter-design.md b/docs/superpowers/specs/2026-07-14-table-category-filter-design.md new file mode 100644 index 000000000..b042d9319 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-table-category-filter-design.md @@ -0,0 +1,187 @@ +# Design: fix the table category filter + +Date: 2026-07-14 +Status: Approved +Repositories: `gethinode/hinode`, `gethinode/mod-simple-datatables` + +## Problem + +The table shortcode's category filter renders a button group above a table and narrows the +rows to a chosen category. It has never worked from Markdown, and it is broken in five +independent ways. + +**1. It is unreachable.** `layouts/_shortcodes/table.html` forwards `breakpoint`, `class`, +`sortable`, `paginate`, `pagination`, `pagination-select`, `searchable`, `wrap` and `wrapper` +to `assets/table.html` — but never `filter` or `filter-col`. Both are declared in +`data/structures/table.yml`, so `{{< table filter="widget,gadget" >}}` validates cleanly and +then does nothing at all. + +**2. Forwarding alone would not fix it.** `filter`'s type in `_arguments.yml` is +`[string, slice]`, and a shortcode named parameter is *always* a string. mod-utils validates +an argument's kind against `accepts` but does **not** cast between kinds. The partial would +therefore receive the raw string `"widget,gadget"` and reach `{{ range $filter }}`, which Hugo +cannot iterate over a string. Something has to split it. + +**3. The behaviour lives in an optional module.** Hinode core renders the filter buttons, but +the click handler lives in `mod-simple-datatables`' `simple-datatables.load.js`. That module +declares `integration = "optional"`, and Hinode loads an optional module's assets only for +pages whose frontmatter lists it. A filter-only table — no sorting, no paging, no search — +therefore renders buttons that are inert unless the page adds +`modules: ["simple-datatables"]`. + +**4. The DOM fallback breaks on wrapped tables.** When no DataTable is active, the filter +falls back to toggling `display` on each `tbody tr`, reading `row.cells[col]`. A wrapped +table renders two rows per record, and the second holds a single ``. With the +default `filter-col = 1`, `cells[1]` is `undefined` on that row, so its text reads as empty +and the row is hidden as soon as any filter is active — the record keeps its data row but +loses its description. + +**5. The i18n keys do not exist.** `tableFilterLabel` and `tableFilterAll` are absent from +every file in `i18n/`. The partial survives only because it calls `T` with a `| default` +fallback, so the strings are untranslatable and emit i18n warnings. + +## Decision: the filter is a data-table feature + +The filter was originally built to work without a DataTable — commit `63a88070`'s fallback +branch is commented *"direct DOM row toggling when no DataTable is active on the table (e.g. +filter-only without sortable/paginate/searchable)"*, and `table.yml`'s comment deliberately +lists only sorting, paging and searching as needing the module. That original intent is +reversed here, deliberately. + +The reason is not that a plain-table filter is unreasonable, but that the fallback is a +*second, inferior implementation of the same feature*. It toggles `display` and composes with +nothing, whereas `dt.search()` filters through simple-datatables' row model, so the filter +survives sorting, pagination and free-text search. It is also the branch carrying defect 4. +Two implementations of one feature, one of them broken, is the thing worth deleting. + +### `filter` implies a data table + +To keep the argument self-sufficient, setting `filter` alone marks the table as a data table. +simple-datatables then initialises with sorting, paging and search all off, and `dt.search()` +still filters it. + +This is safe: in the vendored simple-datatables v10.2.0, `options.searchable` gates only the +rendering of the search *input* and the addition of a wrapper class. It does **not** guard the +public `search()` method. A DataTable with every feature disabled is still a filterable one. + +The user therefore never has to enable sorting they do not want in order to get filtering. + +## Design + +### 1. Hinode — `layouts/_shortcodes/table.html` + +Forward `filter` and `filter-col` to `assets/table.html`, alongside the arguments it already +passes. + +### 2. Hinode — `layouts/_partials/assets/table.html` + +**Normalise `filter`.** Accept both kinds the type declares. A slice is used as-is; a string +is split on commas, with surrounding whitespace trimmed from each value and empty values +dropped, so `filter="widget, gadget"` behaves like `filter="widget,gadget"`. + +**Make `filter` imply a data table.** The existing `$dataTable` condition becomes +`or $args.sortable $args.paginate $args.searchable $args.filter`, so a filtered table always +receives the `data-table` class and is picked up by simple-datatables. The `data-filter-col` +and `data-filter-id` attributes are unchanged. + +**Warn when the module is not loaded.** A data table needs the module's JavaScript *and* its +per-page stylesheet, and both are gated on the page opting in. When the partial renders a data +table on a page whose `modules` frontmatter does not list `simple-datatables`, emit a +`LogWarn` naming the page and telling the author to add +`modules: ["simple-datatables"]`. This covers `sortable`, `paginate` and `searchable` too, all +of which fail silently today. + +**Drop the `data-filter-container` attribute** from the `.table-responsive` wrapper. It is +read only by the DOM fallback that section 3 deletes, so it becomes dead markup. + +Note on why the opt-in cannot be automated: `utilities/AddModule.html` exists to let a partial +declare a module dependency at render time, and `footer/scripts.html` honours it — but the +module's CSS is a separate per-page stylesheet emitted by `head/head.html`, which runs *before* +the content is rendered and therefore cannot see a render-time dependency. Auto-loading would +attach the JavaScript without the CSS. Fixing that means redesigning module loading, which is +out of scope; a loud build warning is the honest alternative. + +### 3. mod-simple-datatables — `simple-datatables.load.js` + +Delete the DOM-fallback branch of the filter click handler. Every filtered table is now a +DataTable, so the branch is unreachable — and it is the one carrying the wrapped-table bug. +The handler reduces to: update the active button state, then call +`dt.search(filterValue, [filterCol], 'category-filter')` on each registered instance. + +The named `'category-filter'` search source stays independent of the built-in search input, so +a category filter and a free-text search narrow the result set simultaneously. + +### 4. Hinode — `layouts/_partials/utilities/AddModule.html` + +Fix a latent bug found while designing this. The partial reads the current `dependencies` +scratch and then does: + +```hugo +{{- $dependencies = complement $dependencies (slice .) -}} +{{- page.Scratch.Set "dependencies" $dependencies -}} +``` + +Hugo's `complement` returns the elements of the **last** collection that are not in the +others — verified empirically: `complement (slice "alpha") (slice "beta")` is `[beta]`, and +`complement (slice "alpha") (slice "alpha")` is `[]`. So the partial *replaces* the dependency +list with the single module being added, discarding everything already there — and re-adding a +module that is already present wipes the list entirely. + +Replace the body with an append-and-deduplicate, matching what `head/head.html:22` and +`footer/scripts.html:126` already do when they read the scratch: + +```hugo +{{- $dependencies = $dependencies | append . | uniq -}} +``` + +The partial is currently unused, so this is latent rather than active. It is fixed here +because the filter design depends on understanding it, and it is a landmine for the next +caller. + +### 5. Hinode — `i18n/*.yaml` + +Add `tableFilterLabel` (the button group's `aria-label`) and `tableFilterAll` (the label of +the always-present "show everything" button) to **all eight** language files: `en`, `nl`, `fr`, +`de`, `pl`, `pt-br`, `zh-hans`, `zh-hant`. + +The English strings are `Filter` and `All`, matching the `| default` fallbacks the partial +uses today, so no rendered output changes for an English site. The other seven are translated +in kind; the two keys are short UI labels, not sentences. + +Note that these belong in **Hinode's** i18n, not the module's: Hinode's partial renders the +buttons. The module's own i18n (`tablePlaceholder`, `tablesSearchTitle`, …) stays where it is, +and covers only the four languages that module ships. + +### 6. Hinode — `data/structures/table.yml` + +The structure comment reads *"Sorting, paging, and searching require the simple-datatables +module."* Add filtering to that list. + +## Behaviour after the change + +`{{< table filter="widget,gadget" >}}` renders a button group with an "All" button plus one +button per category, and filters rows by the text content of the column at `filter-col` +(zero-indexed, default `1`). It works with or without `sortable`, `paginate` and `searchable`, +and composes with all of them, because filtering runs through the row model rather than the +DOM. It works on a wrapped table, because the wrapped layout is produced by `tableRender` from +an already-filtered row model. + +It requires the page to declare `modules: ["simple-datatables"]`, and says so at build time if +it does not. + +## Testing + +- Extend `exampleSite/content/en/table-demo.md` with a filter-only table (no `sortable` / + `paginate` / `searchable`) and a filter-plus-wrap table. +- Verify in a browser: clicking a category narrows the rows; "All" restores them; the active + button state follows the click. +- Verify the filter composes: with `sortable` and `paginate` on, a filter survives a sort and + the pager recounts to the filtered set. +- Verify filter + wrap below the breakpoint: a filtered-out record's description row + disappears along with its data row — the defect-4 scenario. +- Verify the build warning fires on a page that uses a data table without the frontmatter key, + and does not fire on one that has it. +- Verify `filter="widget, gadget"` (with a space) yields two buttons, not one with a leading + space. +- Verify a `filter` passed as a real slice from a partial caller still works. +- `npm run lint` and a clean `hugo` build of the exampleSite, with no i18n warnings. diff --git a/docs/superpowers/specs/2026-07-14-unique-id-helper-design.md b/docs/superpowers/specs/2026-07-14-unique-id-helper-design.md new file mode 100644 index 000000000..d2b5a6db9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-unique-id-helper-design.md @@ -0,0 +1,197 @@ +# Design: a shared helper for deterministic element ids + +Date: 2026-07-14 +Status: Approved +Repositories: `gethinode/mod-utils`, `gethinode/hinode`, `gethinode/mod-blocks` + +## Problem + +Several components generate a DOM id by hashing the current time: + +```hugo +{{ $id := printf "faq-%s" (md5 (delimit (slice . now) "-")) }} +``` + +`now` is evaluated per call, so the id changes on **every build**. Any page carrying such a +component emits different HTML each time it is built, which defeats output diffing and makes CDN +and browser caches treat an unchanged page as changed. + +A scan of every `gethinode` repository found the idiom in exactly five places. One — the table +shortcode's `data-filter-id` — was fixed while repairing the table category filter, and replaced +with a deterministic per-page counter held in `page.Store`. The other four all live in +**mod-blocks**: + +| File | Id | +| --- | --- | +| `component-library/components/panels/panels.hugo.html:37` | `panel-` | +| `component-library/components/faq/faq.hugo.html:42` | `faq-` | +| `layouts/partials/assets/testimonial-carousel.html:48` | `testimonial-carousel-` | +| `layouts/partials/assets/preview.html:243` | `preview-` | + +Hinode's `assets/nav.html` prefixes the panels id, which is why the symptom surfaces in +`hugo_stats.json` as `dropdown-panel-` churning on every build. + +This design replaces the idiom with one shared helper — and puts that helper where every module +can legitimately reach it. + +## Where the helper belongs + +Not in Hinode. **No gethinode module declares a dependency on Hinode:** + +| Module | imports mod-utils | imports hinode | +| --- | --- | --- | +| mod-blocks | yes | no | +| mod-lottie | yes | no | +| mod-leaflet | yes | no | +| mod-mermaid | yes | no | +| mod-simple-datatables | yes | no | + +A partial in Hinode is reachable from these modules only because the final *site* merges Hinode's +layouts. That is an undeclared, inverted dependency: it breaks each module's own exampleSite +build, and it would block the planned `.Ordinal` follow-up outright, since that has to reach +mod-lottie and mod-leaflet. + +**mod-utils is the correct home.** Every module already imports it, and so does Hinode. The move +is transparent to callers, because Hugo resolves partials by path across the merged layout tree. + +### The same wart, one level down + +`utilities/AddModule.html` lives in Hinode today, yet mod-blocks' `list` component calls it +without declaring Hinode. It moves to mod-utils too, for the same reason. Its callers +(`hinode/layouts/_partials/assets/lightbox.html`, `hinode/layouts/_shortcodes/table.html`, +`mod-blocks/component-library/components/list/list.hugo.html`) are unaffected — the partial's +path does not change. + +## The key unknown, resolved + +An id counter is only useful if it is **unique within each emitted page** and **stable across +builds**. Hugo renders pages concurrently, so a counter anchored to the wrong page could be +assigned in a non-deterministic order. + +That was a live concern, because the global `page` function is known to misresolve inside a +Bookshop block render: it returns the page whose *template* is executing, not the content page. +This was observed while fixing the table filter, where a warning fired against `/en/404.html` and +`/en/docs/blocks/` for tables those pages do not contain. + +**Measured, not assumed.** A spike replaced `panels.hugo.html`'s `md5(… now …)` with a +`page.Store` counter using the global `page`, and built the exampleSite twice: + +```text +BUILD A: dropdown-panel-1, dropdown-panel-2, dropdown-panel-3, dropdown-panel-5 +BUILD B: dropdown-panel-1, dropdown-panel-2, dropdown-panel-3, dropdown-panel-5 +``` + +Identical across builds, and unique within the page. The misresolving renders are teaser and +summary passes whose output is discarded, so they never emit an id — they only consume a counter +value, which is why `-4` is absent. A gap in the numbering breaks neither uniqueness nor +determinism. + +`page.Store` was separately confirmed to survive an embedded `RenderString` (which is what the +`example` shortcode does), where `.Ordinal` does **not** — it restarts at 0. + +## Design + +### 1. mod-utils — `layouts/_partials/utilities/UniqueID.html` (new) + +Returns a page-unique, build-stable element id. + +**Arguments:** + +- `prefix` (string, required) — the id's leading segment, e.g. `panel`. +- `page` (optional) — the page to anchor the counter to. Falls back to the global `page` function + when omitted, matching how `utilities/AddModule.html` treats the same argument. + +**Returns:** `-`, where `n` is a counter starting at 1. + +The counter is held in `page.Store` under a key namespaced by prefix, so each prefix counts +independently: `panel-1`, `panel-2`, `faq-1`. A single shared counter would interleave unrelated +components and make the numbering read as arbitrary. + +The helper does not attempt to be unique across pages — a DOM id only needs to be unique within +the document that carries it. + +### 2. mod-utils — `layouts/_partials/utilities/AddModule.html` (moved from Hinode) + +Moved verbatim, including the two fixes it received while repairing the table filter: it appends +and deduplicates rather than using `complement` (which *replaced* the dependency list, and wiped +it entirely when re-adding an entry already present), and it honours an explicitly passed `page` +instead of ignoring it in favour of the global `page`. + +### 3. Hinode + +- Delete `layouts/_partials/utilities/AddModule.html` (now provided by mod-utils). +- Replace the inline counter in `layouts/_partials/assets/table.html` (`hinodeTableFilterCount`, + producing `table-filter-N`) with a call to `utilities/UniqueID.html`, prefix `table-filter`. The + rendered output is unchanged; this removes the second implementation of the same mechanism. +- Bump the mod-utils dependency to the release carrying both partials. + +### 4. mod-blocks — the four call sites + +Replace each `md5(delimit (slice . now) "-")` with a call to the helper: + +| File | Prefix | +| --- | --- | +| `component-library/components/panels/panels.hugo.html` | `panel` | +| `component-library/components/faq/faq.hugo.html` | `faq` | +| `layouts/partials/assets/testimonial-carousel.html` | `testimonial-carousel` | +| `layouts/partials/assets/preview.html` | `preview` | + +The two Bookshop components pass Bookshop's `._page`, which they have available +(`list.hugo.html` already uses it). The two partials pass no page and take the global fallback, +which the spike proved correct for the ids that reach the emitted HTML. + +Each id is referenced elsewhere in its own component — `data-companion`, `aria-controls`, +`data-bs-target`, `data-bs-parent`. Those references derive from the same variable, so they follow +automatically; the change must not alter how they are constructed. + +Bump the mod-utils dependency to the release carrying the helper. + +### 5. Release ordering + +Strictly sequential, mod-blocks **before** Hinode: + +1. **mod-utils** ships `UniqueID.html` and the moved `AddModule.html`, and releases. +2. **mod-blocks** bumps mod-utils, converts its four call sites, merges and releases. +3. **Hinode** bumps mod-utils *and* mod-blocks, deletes its own `AddModule.html`, and migrates the + table's counter to the helper. + +mod-blocks goes first so that Hinode's exampleSite picks up the fixed ids when it deploys — +`exampleSite/go.mod` pins mod-blocks, so a Hinode merge that preceded the mod-blocks release would +deploy a demo site still emitting `dropdown-panel-`. + +Two ordering constraints are load-bearing: + +- Hinode's `AddModule.html` must not be deleted before the mod-utils release exists, or the partial + disappears from the merged layout tree and every caller — including mod-blocks' `list` component — + fails at build time. +- Between steps 1 and 3, `utilities/AddModule.html` exists in **both** mod-utils and Hinode. Hugo + resolves the project's own layouts ahead of an imported module's, so Hinode's copy shadows + mod-utils'. The two are identical, so this interval is harmless; step 3 removes the duplicate. + +Hinode PR #2024 (the table category filter, already open) is **amended** with its part, and merges +last. mod-utils and mod-blocks each get a new PR. + +## Verification + +**Determinism and uniqueness.** Build the Hinode exampleSite twice and confirm that every affected +page's ids are byte-identical between builds, and that no id repeats within a page. + +**Behaviour.** A deterministic id that points at the wrong element is worse than a random one, so +the components must be driven in a real browser (Playwright) on the Hinode exampleSite, covering +every affected page — Hinode's own `table-demo` and the mounted mod-docs pages for panels, faq, +testimonials and preview: + +- **Panels:** the dropdown opens *its own* panel, not another panel on the page. Where a page + carries more than one panels block, each dropdown must control its own. +- **FAQ:** each accordion item toggles the item it belongs to, and only that one. +- **Testimonial carousel:** it cycles, and its indicators target its own slides. +- **Preview:** the device tabs switch the preview they belong to. +- **Table:** the filter buttons still drive their own table (the id they pair on is now produced by + the helper). +- Zero uncaught console exceptions on every page. + +**AddModule still works after the move.** Its three callers must behave unchanged — in particular +the lightbox partial, and the table shortcode's missing-module warning, which must still fire for a +data table on a page without `modules: ["simple-datatables"]` and stay silent otherwise. + +**Regression.** `npm test` clean, and the existing table verification scripts still pass. diff --git a/exampleSite/content/en/table-demo.md b/exampleSite/content/en/table-demo.md index 1c55b9a0d..ececbfc62 100644 --- a/exampleSite/content/en/table-demo.md +++ b/exampleSite/content/en/table-demo.md @@ -36,3 +36,27 @@ modules: ["simple-datatables"] | alpha | The first record, with a description long enough to need wrapping. | | bravo | The second record, also with a fairly long trailing description. | {{< /table >}} + +## Filter-only table + +{{< table filter="widget, gadget" filter-col="1" class="fixture-filter" >}} + +| Name | Type | Description | +|---------|--------|--------------------------------------------------------------------| +| alpha | widget | The first record, with a description long enough to need wrapping. | +| bravo | gadget | The second record, also with a fairly long trailing description. | +| charlie | widget | The third record. Short. | +| delta | gadget | The fourth record, whose description runs on for a little while. | +{{< /table >}} + +## Filtered, sortable, wrapped data table + +{{< table filter="widget,gadget" filter-col="1" sortable="true" paginate="true" pagination="2" wrap="true" class="fixture-filter-wrap" >}} + +| Name | Type | Description | +|---------|--------|--------------------------------------------------------------------| +| alpha | widget | The first record, with a description long enough to need wrapping. | +| bravo | gadget | The second record, also with a fairly long trailing description. | +| charlie | widget | The third record. Short. | +| delta | gadget | The fourth record, whose description runs on for a little while. | +{{< /table >}} diff --git a/exampleSite/go.mod b/exampleSite/go.mod index b6a1ec05a..a1912c227 100644 --- a/exampleSite/go.mod +++ b/exampleSite/go.mod @@ -5,11 +5,11 @@ go 1.19 require ( github.com/FortAwesome/Font-Awesome v0.0.0-20260625220317-70fb2dd154b6 // indirect github.com/cloudcannon/bookshop/hugo/v3 v3.18.5 // indirect - github.com/gethinode/mod-blocks/v2 v2.1.2 // indirect + github.com/gethinode/mod-blocks/v2 v2.1.3 // indirect github.com/gethinode/mod-bootstrap-icons/v2 v2.0.1 // indirect github.com/gethinode/mod-cookieyes/v2 v2.2.6 // indirect github.com/gethinode/mod-docs v1.15.2 // indirect github.com/gethinode/mod-fontawesome/v6 v6.0.1 // indirect - github.com/gethinode/mod-utils/v6 v6.4.1 // indirect + github.com/gethinode/mod-utils/v6 v6.5.0 // indirect github.com/twbs/icons v1.13.1 // indirect ) diff --git a/exampleSite/go.sum b/exampleSite/go.sum index 06aec8c72..e2d67bfbf 100644 --- a/exampleSite/go.sum +++ b/exampleSite/go.sum @@ -4,6 +4,8 @@ github.com/cloudcannon/bookshop/hugo/v3 v3.18.5 h1:AKWzUQpWcBSbJgHQ/5cfPQtGX40bn github.com/cloudcannon/bookshop/hugo/v3 v3.18.5/go.mod h1:s7mIonDhtsLcn10ZKuVXyqd6BDHI8vT1WQhZw8rPfY8= github.com/gethinode/mod-blocks/v2 v2.1.2 h1:aufe/mWZ/exoLibOjmqqMNjCpSgeBjGBoCZ3bIXlW7Q= github.com/gethinode/mod-blocks/v2 v2.1.2/go.mod h1:41m1Sdht+QRnk0m6PkrQn0IH4NC1MBxP8jsix4gsEpI= +github.com/gethinode/mod-blocks/v2 v2.1.3 h1:eHesaZMTfAEIOv/oklXPcCbPdLcbSRZOlzwzKG/BiXs= +github.com/gethinode/mod-blocks/v2 v2.1.3/go.mod h1:z+0Rg+I0naJf7VOy9dDuo8FX1dB5bPuMmbWT1QmBFhg= github.com/gethinode/mod-bootstrap-icons/v2 v2.0.1 h1:shq4Gb48U3h9xYUpq+FSHWz/eu1azJ5bMkja6wPmCpM= github.com/gethinode/mod-bootstrap-icons/v2 v2.0.1/go.mod h1:oRJGGe/EP/DRynePDHtaiC9ONR58+Jbm1c46Cgv0+jw= github.com/gethinode/mod-cookieyes/v2 v2.2.6 h1:/DQm8OYpms0On8wuosQER47TplVu/3z7MZHwbBKXCAg= @@ -16,5 +18,7 @@ github.com/gethinode/mod-fontawesome/v6 v6.0.1 h1:Wq51kwbtlZYjLQdSbLjn1Qjf4ZkmGI github.com/gethinode/mod-fontawesome/v6 v6.0.1/go.mod h1:Pz24mpGcg4FvGHhwPiuEZzOzISeOcoaQROhI6xLW2bU= github.com/gethinode/mod-utils/v6 v6.4.1 h1:jHiJEwSjLp6tqIXayxUQTxILwnM7hWMgYyTYhxFE3ZA= github.com/gethinode/mod-utils/v6 v6.4.1/go.mod h1:E5tO9w3VKaidJpu1nI8zAKmh0bddFHOIIQnudAaXQTs= +github.com/gethinode/mod-utils/v6 v6.5.0 h1:NdeITAfjt2ah9OHt0Nuu6F+STlC0bO9oHaFl8Agi2rk= +github.com/gethinode/mod-utils/v6 v6.5.0/go.mod h1:E5tO9w3VKaidJpu1nI8zAKmh0bddFHOIIQnudAaXQTs= github.com/twbs/icons v1.13.1 h1:6bmNXvoeIbvjFWjfbjXg5JGbelLD1haIsMaEIzE9UZI= github.com/twbs/icons v1.13.1/go.mod h1:GnRlymgVWp5iVJCMa0Me5b6tFyGpVc2bSxPMRGIJmyA= diff --git a/exampleSite/hugo_stats.json b/exampleSite/hugo_stats.json index 8de940b57..24e2b8d27 100644 --- a/exampleSite/hugo_stats.json +++ b/exampleSite/hugo_stats.json @@ -420,6 +420,8 @@ "file-panel", "fixed-top", "fixture-data", + "fixture-filter", + "fixture-filter-wrap", "fixture-plain", "fixture-two-col", "flex-column", @@ -1056,10 +1058,10 @@ "dropdown-align-end-1", "dropdown-callout-1", "dropdown-nav-0", - "dropdown-panel-0b3f25095dc3123e2f3ae16e5ee57a44", - "dropdown-panel-0e1737c79577cc8e7bc3cc5a07ffb5a5", - "dropdown-panel-5557cc8998a102cd3f7dc193eb73a656", - "dropdown-panel-b6dfa5e47f22afea4de7b45b664add1a", + "dropdown-panel-UID-1", + "dropdown-panel-UID-2", + "dropdown-panel-UID-3", + "dropdown-panel-UID-5", "dropdown-pills-1", "dropdown-tabs-1", "dropdown-underline-1", @@ -1083,11 +1085,13 @@ "fab-whatsapp", "fab-x-twitter", "faq", - "faq-d3f30169639884815b7e65a1c773e156", - "faq-d3f30169639884815b7e65a1c773e156-heading-faq-d3f30169639884815b7e65a1c773e156", - "faq-d3f30169639884815b7e65a1c773e156-item-0", - "faq-d3f30169639884815b7e65a1c773e156-item-1", - "faq-d3f30169639884815b7e65a1c773e156-item-2", + "faq-UID-1", + "faq-UID-1-heading-0", + "faq-UID-1-heading-1", + "faq-UID-1-heading-2", + "faq-UID-1-item-0", + "faq-UID-1-item-1", + "faq-UID-1-item-2", "far-square", "fas-1", "fas-2", @@ -1138,7 +1142,9 @@ "file", "file-preview-with-filename-only", "fill-and-justify", + "filter-only-table", "filtered-list", + "filtered-sortable-wrapped-data-table", "first-paragraph", "first-post", "fixed-width", @@ -1262,10 +1268,10 @@ "nav-align-end-1", "nav-callout-1", "nav-nav-0", - "nav-panel-0b3f25095dc3123e2f3ae16e5ee57a44", - "nav-panel-0e1737c79577cc8e7bc3cc5a07ffb5a5", - "nav-panel-5557cc8998a102cd3f7dc193eb73a656", - "nav-panel-b6dfa5e47f22afea4de7b45b664add1a", + "nav-panel-UID-1", + "nav-panel-UID-2", + "nav-panel-UID-3", + "nav-panel-UID-5", "nav-pills-1", "nav-tabs-1", "nav-underline-1", @@ -1303,36 +1309,36 @@ "over-mij", "overview", "page-link", - "panel-0b3f25095dc3123e2f3ae16e5ee57a44-0", - "panel-0b3f25095dc3123e2f3ae16e5ee57a44-1", - "panel-0b3f25095dc3123e2f3ae16e5ee57a44-2", - "panel-0b3f25095dc3123e2f3ae16e5ee57a44-btn-0", - "panel-0b3f25095dc3123e2f3ae16e5ee57a44-btn-1", - "panel-0b3f25095dc3123e2f3ae16e5ee57a44-btn-2", - "panel-0e1737c79577cc8e7bc3cc5a07ffb5a5-0", - "panel-0e1737c79577cc8e7bc3cc5a07ffb5a5-1", - "panel-0e1737c79577cc8e7bc3cc5a07ffb5a5-2", - "panel-0e1737c79577cc8e7bc3cc5a07ffb5a5-btn-0", - "panel-0e1737c79577cc8e7bc3cc5a07ffb5a5-btn-1", - "panel-0e1737c79577cc8e7bc3cc5a07ffb5a5-btn-2", - "panel-5557cc8998a102cd3f7dc193eb73a656-0", - "panel-5557cc8998a102cd3f7dc193eb73a656-1", - "panel-5557cc8998a102cd3f7dc193eb73a656-2", - "panel-5557cc8998a102cd3f7dc193eb73a656-btn-0", - "panel-5557cc8998a102cd3f7dc193eb73a656-btn-1", - "panel-5557cc8998a102cd3f7dc193eb73a656-btn-2", - "panel-b6dfa5e47f22afea4de7b45b664add1a-0", - "panel-b6dfa5e47f22afea4de7b45b664add1a-1", - "panel-b6dfa5e47f22afea4de7b45b664add1a-2", - "panel-b6dfa5e47f22afea4de7b45b664add1a-btn-0", - "panel-b6dfa5e47f22afea4de7b45b664add1a-btn-1", - "panel-b6dfa5e47f22afea4de7b45b664add1a-btn-2", - "panel-fcda474cf45405507b05c4b85487b528-0", - "panel-fcda474cf45405507b05c4b85487b528-1", - "panel-fcda474cf45405507b05c4b85487b528-2", - "panel-fcda474cf45405507b05c4b85487b528-btn-0", - "panel-fcda474cf45405507b05c4b85487b528-btn-1", - "panel-fcda474cf45405507b05c4b85487b528-btn-2", + "panel-UID-1-0", + "panel-UID-1-1", + "panel-UID-1-2", + "panel-UID-1-btn-0", + "panel-UID-1-btn-1", + "panel-UID-1-btn-2", + "panel-UID-2-0", + "panel-UID-2-1", + "panel-UID-2-2", + "panel-UID-2-btn-0", + "panel-UID-2-btn-1", + "panel-UID-2-btn-2", + "panel-UID-3-0", + "panel-UID-3-1", + "panel-UID-3-2", + "panel-UID-3-btn-0", + "panel-UID-3-btn-1", + "panel-UID-3-btn-2", + "panel-UID-4-0", + "panel-UID-4-1", + "panel-UID-4-2", + "panel-UID-4-btn-0", + "panel-UID-4-btn-1", + "panel-UID-4-btn-2", + "panel-UID-5-0", + "panel-UID-5-1", + "panel-UID-5-2", + "panel-UID-5-btn-0", + "panel-UID-5-btn-1", + "panel-UID-5-btn-2", "panels", "persona", "pie-chart", @@ -1357,49 +1363,49 @@ "preview", "preview-1", "preview-2", - "preview-26cec8d92f6725dc35044f2f6976ebaf-desktop", - "preview-26cec8d92f6725dc35044f2f6976ebaf-mobile", - "preview-26cec8d92f6725dc35044f2f6976ebaf-tablet", "preview-3", - "preview-334ff389c9cf2491303bbeb2af6e1f25-desktop", - "preview-334ff389c9cf2491303bbeb2af6e1f25-desktop-tab", - "preview-334ff389c9cf2491303bbeb2af6e1f25-mobile", - "preview-334ff389c9cf2491303bbeb2af6e1f25-mobile-tab", - "preview-334ff389c9cf2491303bbeb2af6e1f25-tablet", - "preview-334ff389c9cf2491303bbeb2af6e1f25-tablet-tab", - "preview-3a04122985afea16a76b32903d762e0d-desktop", - "preview-3a04122985afea16a76b32903d762e0d-desktop-tab", - "preview-3a04122985afea16a76b32903d762e0d-mobile", - "preview-3a04122985afea16a76b32903d762e0d-mobile-tab", - "preview-3a04122985afea16a76b32903d762e0d-tablet", - "preview-3a04122985afea16a76b32903d762e0d-tablet-tab", - "preview-7a7fb98ca6848ef46af5a4d056b9c815-desktop", - "preview-7a7fb98ca6848ef46af5a4d056b9c815-desktop-tab", - "preview-7a7fb98ca6848ef46af5a4d056b9c815-mobile", - "preview-7a7fb98ca6848ef46af5a4d056b9c815-mobile-tab", - "preview-7a7fb98ca6848ef46af5a4d056b9c815-tablet", - "preview-7a7fb98ca6848ef46af5a4d056b9c815-tablet-tab", - "preview-8e49b94e9eeae367fdc5e7af5a28bbd9-desktop", - "preview-8e49b94e9eeae367fdc5e7af5a28bbd9-desktop-tab", - "preview-8e49b94e9eeae367fdc5e7af5a28bbd9-mobile", - "preview-8e49b94e9eeae367fdc5e7af5a28bbd9-mobile-tab", - "preview-8e49b94e9eeae367fdc5e7af5a28bbd9-tablet", - "preview-8e49b94e9eeae367fdc5e7af5a28bbd9-tablet-tab", - "preview-99c16bf05426fcc089398b694e97ed12-desktop", - "preview-99c16bf05426fcc089398b694e97ed12-desktop-tab", - "preview-99c16bf05426fcc089398b694e97ed12-mobile", - "preview-99c16bf05426fcc089398b694e97ed12-mobile-tab", - "preview-99c16bf05426fcc089398b694e97ed12-tablet", - "preview-99c16bf05426fcc089398b694e97ed12-tablet-tab", - "preview-9f0236c2f48efed60d92c13e5d9e581c-desktop", - "preview-9f0236c2f48efed60d92c13e5d9e581c-desktop-tab", - "preview-9f0236c2f48efed60d92c13e5d9e581c-mobile", - "preview-9f0236c2f48efed60d92c13e5d9e581c-mobile-tab", - "preview-9f0236c2f48efed60d92c13e5d9e581c-tablet", - "preview-9f0236c2f48efed60d92c13e5d9e581c-tablet-tab", - "preview-b7985d1d010096301c115a4b9f0969c7-desktop", - "preview-b7985d1d010096301c115a4b9f0969c7-mobile", - "preview-b7985d1d010096301c115a4b9f0969c7-tablet", + "preview-UID-1-desktop", + "preview-UID-1-desktop-tab", + "preview-UID-1-mobile", + "preview-UID-1-mobile-tab", + "preview-UID-1-tablet", + "preview-UID-1-tablet-tab", + "preview-UID-2-desktop", + "preview-UID-2-desktop-tab", + "preview-UID-2-mobile", + "preview-UID-2-mobile-tab", + "preview-UID-2-tablet", + "preview-UID-2-tablet-tab", + "preview-UID-3-desktop", + "preview-UID-3-desktop-tab", + "preview-UID-3-mobile", + "preview-UID-3-mobile-tab", + "preview-UID-3-tablet", + "preview-UID-3-tablet-tab", + "preview-UID-4-desktop", + "preview-UID-4-desktop-tab", + "preview-UID-4-mobile", + "preview-UID-4-mobile-tab", + "preview-UID-4-tablet", + "preview-UID-4-tablet-tab", + "preview-UID-5-desktop", + "preview-UID-5-desktop-tab", + "preview-UID-5-mobile", + "preview-UID-5-mobile-tab", + "preview-UID-5-tablet", + "preview-UID-5-tablet-tab", + "preview-UID-6-desktop", + "preview-UID-6-desktop-tab", + "preview-UID-6-mobile", + "preview-UID-6-mobile-tab", + "preview-UID-6-tablet", + "preview-UID-6-tablet-tab", + "preview-UID-7-desktop", + "preview-UID-7-mobile", + "preview-UID-7-tablet", + "preview-UID-8-desktop", + "preview-UID-8-mobile", + "preview-UID-8-tablet", "preview-unavailable", "preview-unavailable-alert-only", "preview-with-specific-device", @@ -1472,7 +1478,7 @@ "tabs-1-btn-2", "team", "testimonial", - "testimonial-carousel-435430d12135281a83002aeab76c6941", + "testimonial-carousel-UID-1", "testimonial-with-avatar", "testimonial-with-case-study", "testimonial-with-icon", diff --git a/go.mod b/go.mod index 65f18d59c..b2b0f897c 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/gethinode/mod-lottie/v3 v3.0.1 // indirect github.com/gethinode/mod-mermaid/v5 v5.0.1 // indirect github.com/gethinode/mod-simple-datatables/v4 v4.1.0 // indirect - github.com/gethinode/mod-utils/v6 v6.4.2 // indirect + github.com/gethinode/mod-utils/v6 v6.5.0 // indirect github.com/nextapps-de/flexsearch v0.0.0-20260529083235-f7ed963096a0 // indirect github.com/twbs/bootstrap v5.3.8+incompatible // indirect ) diff --git a/go.sum b/go.sum index 27b95aabb..62ca98244 100644 --- a/go.sum +++ b/go.sum @@ -206,6 +206,8 @@ github.com/gethinode/mod-utils/v6 v6.4.1 h1:jHiJEwSjLp6tqIXayxUQTxILwnM7hWMgYyTY github.com/gethinode/mod-utils/v6 v6.4.1/go.mod h1:E5tO9w3VKaidJpu1nI8zAKmh0bddFHOIIQnudAaXQTs= github.com/gethinode/mod-utils/v6 v6.4.2 h1:xh3XBpuvD+AisycxqEX0Ao90wKlu7cuKM0QOZcdVtP4= github.com/gethinode/mod-utils/v6 v6.4.2/go.mod h1:E5tO9w3VKaidJpu1nI8zAKmh0bddFHOIIQnudAaXQTs= +github.com/gethinode/mod-utils/v6 v6.5.0 h1:NdeITAfjt2ah9OHt0Nuu6F+STlC0bO9oHaFl8Agi2rk= +github.com/gethinode/mod-utils/v6 v6.5.0/go.mod h1:E5tO9w3VKaidJpu1nI8zAKmh0bddFHOIIQnudAaXQTs= github.com/nextapps-de/flexsearch v0.0.0-20250907103239-defb38b083f0 h1:55phPhe6fDjfjG0jX4+br3nLORKgjgx8abZUdI0YJRA= github.com/nextapps-de/flexsearch v0.0.0-20250907103239-defb38b083f0/go.mod h1:5GdMfPAXzbA2gXBqTjC6l27kioSYzHlqDMh0+wyx7sU= github.com/nextapps-de/flexsearch v0.0.0-20260529083235-f7ed963096a0 h1:QDKcU3q39lFGzdVwM6kgCGnW3ibbMfYIi0Rwl62iJpo= diff --git a/i18n/de.yaml b/i18n/de.yaml index 64a4fcf93..8fe05c05b 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -232,4 +232,8 @@ - id: formSubmit translation: "Absenden" - id: formBotField - translation: "Bitte nicht ausfüllen, wenn Sie ein Mensch sind" \ No newline at end of file + translation: "Bitte nicht ausfüllen, wenn Sie ein Mensch sind" +- id: tableFilterLabel + translation: "Filter" +- id: tableFilterAll + translation: "Alle" \ No newline at end of file diff --git a/i18n/en.yaml b/i18n/en.yaml index 1175593cb..472b4666a 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -241,3 +241,7 @@ translation: "Enlarged view" - id: lightboxExpand translation: "View fullscreen" +- id: tableFilterLabel + translation: "Filter" +- id: tableFilterAll + translation: "All" diff --git a/i18n/fr.yaml b/i18n/fr.yaml index 9c4cc877d..7e2be76b0 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -233,3 +233,7 @@ translation: "Envoyer" - id: formBotField translation: "Ne remplissez pas ce champ si vous êtes humain" +- id: tableFilterLabel + translation: "Filtre" +- id: tableFilterAll + translation: "Tout" diff --git a/i18n/nl.yaml b/i18n/nl.yaml index dfa3aaf5f..8c2a4000f 100644 --- a/i18n/nl.yaml +++ b/i18n/nl.yaml @@ -233,3 +233,7 @@ translation: "Verzenden" - id: formBotField translation: "Vul dit niet in als je een mens bent" +- id: tableFilterLabel + translation: "Filter" +- id: tableFilterAll + translation: "Alle" diff --git a/i18n/pl.yaml b/i18n/pl.yaml index ae1082c59..fe8346d3b 100644 --- a/i18n/pl.yaml +++ b/i18n/pl.yaml @@ -233,3 +233,7 @@ translation: "Wyślij" - id: formBotField translation: "Nie wypełniaj tego pola, jeśli jesteś człowiekiem" +- id: tableFilterLabel + translation: "Filtr" +- id: tableFilterAll + translation: "Wszystkie" diff --git a/i18n/pt-br.yaml b/i18n/pt-br.yaml index 734ef60af..5f1460123 100644 --- a/i18n/pt-br.yaml +++ b/i18n/pt-br.yaml @@ -235,3 +235,7 @@ translation: "Enviar" - id: formBotField translation: "Não preencha isto se você for humano" +- id: tableFilterLabel + translation: "Filtro" +- id: tableFilterAll + translation: "Todos" diff --git a/i18n/zh-hans.yaml b/i18n/zh-hans.yaml index 01e11f94d..aef57b32f 100644 --- a/i18n/zh-hans.yaml +++ b/i18n/zh-hans.yaml @@ -233,3 +233,7 @@ translation: "提交" - id: formBotField translation: "如果您是人类,请不要填写此项" +- id: tableFilterLabel + translation: "筛选" +- id: tableFilterAll + translation: "全部" diff --git a/i18n/zh-hant.yaml b/i18n/zh-hant.yaml index 173df97ce..c4640144a 100644 --- a/i18n/zh-hant.yaml +++ b/i18n/zh-hant.yaml @@ -233,3 +233,7 @@ translation: "提交" - id: formBotField translation: "如果您是人類,請不要填寫此欄" +- id: tableFilterLabel + translation: "篩選" +- id: tableFilterAll + translation: "全部" diff --git a/layouts/_partials/assets/table.html b/layouts/_partials/assets/table.html index 64c8020ee..a1d7727a6 100644 --- a/layouts/_partials/assets/table.html +++ b/layouts/_partials/assets/table.html @@ -18,7 +18,27 @@ {{/* Initialize local variables */}} {{ $breakpoint := site.Params.main.breakpoint | default "md" }} -{{ $dataTable := or $args.sortable $args.paginate $args.searchable }} + +{{/* `filter` accepts a slice or a comma-separated string. A shortcode named parameter is always a + string, and InitArgs validates an argument's kind without casting between kinds, so a string + arrives here unsplit - and `range` cannot iterate a string. Normalize to a slice of trimmed, + non-empty values, so `filter="widget, gadget"` behaves like `filter="widget,gadget"`. */}} +{{ $filter := slice }} +{{ with $args.filter }} + {{ $values := . }} + {{ if not (reflect.IsSlice $values) }}{{ $values = split (printf "%v" $values) "," }}{{ end }} + {{ range $values }} + {{ $value := trim (printf "%v" .) " " }} + {{ if $value }}{{ $filter = $filter | append $value }}{{ end }} + {{ end }} +{{ end }} + +{{/* Filtering runs through simple-datatables' row model, so it composes with sorting, paging and + free-text search. A filtered table is therefore always a data table, even when all three are + off: the library still initializes, and `search()` still filters it - `searchable` gates only + the search input, not the method. This keeps `filter` self-sufficient, so an author never has + to switch on sorting they do not want just to get filtering. */}} +{{ $dataTable := or $args.sortable $args.paginate $args.searchable (gt (len $filter) 0) }} {{ $wrap := and $args.wrap (ne $breakpoint "xs") }} {{/* `table-wrap` is an internal marker owned by the templates, not a class a caller may set: it @@ -50,11 +70,13 @@ {{ end }} {{/* Filter */}} -{{ $filter := $args.filter }} -{{ $filterCol := $args.filterCol | default 1 }} +{{/* `filter-col` carries a default of 1 in mod-utils, and InitArgs applies a default only when the + argument is nil - so an explicit `0` arrives here intact. Hugo's `default` treats `0` as empty, + so piping through it would silently rewrite column 0 to column 1. Read the argument as-is. */}} +{{ $filterCol := $args.filterCol }} {{ $filterId := "" }} {{ if $filter }} - {{ $filterId = printf "table-filter-%s" (md5 (delimit (slice . now) "-")) }} + {{ $filterId = partial "utilities/UniqueID.html" (dict "prefix" "table-filter" "page" $args.page) }} {{ $attributes = printf "%s data-filter-col=%d data-filter-id=%s" $attributes $filterCol $filterId }} {{ end }} @@ -107,7 +129,7 @@ {{ $input | safeHTML }} {{ end }} {{ else }} -
+
{{ $input | safeHTML }}
{{ end }} diff --git a/layouts/_partials/utilities/AddModule.html b/layouts/_partials/utilities/AddModule.html deleted file mode 100644 index 3b430da11..000000000 --- a/layouts/_partials/utilities/AddModule.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ with .module }} - {{- $dependencies := page.Scratch.Get "dependencies" -}} - {{- if reflect.IsSlice $dependencies -}} - {{- $dependencies = complement $dependencies (slice .) -}} - {{ else }} - {{- $dependencies = slice . -}} - {{ end }} - {{- page.Scratch.Set "dependencies" $dependencies -}} -{{ end }} \ No newline at end of file diff --git a/layouts/_shortcodes/table.html b/layouts/_shortcodes/table.html index 0c5c1038b..19a8ee045 100644 --- a/layouts/_shortcodes/table.html +++ b/layouts/_shortcodes/table.html @@ -17,6 +17,46 @@ )}} {{- end -}} +{{/* A data table needs both the simple-datatables module's JavaScript and its per-page stylesheet, + and Hinode loads an optional module's assets only for pages that list it in frontmatter. + Hinode could auto-declare the dependency here (via utilities/AddModule.html) instead of + warning, but that would make a shortcode's behavior depend on head/head.html's eager + evaluation of `.Page.Content` for its description fallback - an incidental implementation + detail elsewhere in the render pipeline, not a contract this shortcode can rely on. Warn + instead, so the author declares the module explicitly. + + The check lives in the shortcode rather than in assets/table.html because only here is `.Page` + reliably the content page; a Bookshop block resolves it to the rendering template's page + instead, which would warn about pages that hold no table at all. Skipped when the site + configures the module as `core` or `critical`, since it then loads on every page anyway. + + A filtered table is a data table too (see assets/table.html), but only if `filter` actually + yields a category there: the partial splits on commas and drops blanks, so `filter=","` + normalizes to no filter at all. Test for a comma-and-whitespace-free character rather than + duplicating that split/trim/append loop here. + + Reported against the page rather than the shortcode's position: Hugo may render the same + shortcode more than once (the summary and the content are separate renders, at different + offsets), and a position would then turn one table into two distinct warnings. */}} +{{ $page := .Page }} +{{ $filtered := gt (len (findRE `[^,\s]` (printf "%v" (or $args.filter "")) 1)) 0 }} +{{ if or $args.sortable $args.paginate $args.searchable $filtered }} + {{ with $page.Scratch.Get "modules" }} + {{ if in (.optional | default slice) "simple-datatables" }} + {{ $pageModules := slice | append $page.Params.modules }} + {{ if not (in $pageModules "simple-datatables") }} + {{ partial "utilities/LogWarn.html" (dict + "partial" "shortcodes/table.html" + "warnid" "warn-missing-module" + "msg" "A sortable, paginated, searchable or filtered table requires the simple-datatables module" + "details" (slice "Add `modules: [\"simple-datatables\"]` to the page frontmatter") + "file" $page.File + )}} + {{ end }} + {{ end }} + {{ end }} +{{ end }} + {{/* Main code */}} {{ if not $args.err }} {{ partial "assets/table.html" (dict @@ -29,6 +69,8 @@ "pagination" (or $args.pagination $args.pagingOptionPerPage) "pagination-select" (or $args.paginationSelect $args.pagingOptionPageSelect) "searchable" $args.searchable + "filter" $args.filter + "filter-col" $args.filterCol "wrap" $args.wrap "wrapper" $args.wrapper "_default" $args.default