Skip to content

Make the a11y linter aware of child templates - #573

Open
LuLaValva wants to merge 19 commits into
mainfrom
claude/a11y-linter-child-templates-chkwyt
Open

Make the a11y linter aware of child templates#573
LuLaValva wants to merge 19 commits into
mainfrom
claude/a11y-linter-child-templates-chkwyt

Conversation

@LuLaValva

Copy link
Copy Markdown
Member

The accessibility linter now takes custom tags' child templates into account, catching issues that span a tag usage and the template it renders (and enabling the listitem/dlitem rules) without reporting anything that isn't provably true for the rendered output.


Generated by Claude Code

@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 159a225

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@marko/language-tools Minor
@marko/language-server Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The HTML extractor now records conditional, dynamic, body-slot, root, uncertainty, and inline-region metadata while resolving child templates. The language server caches extraction results, maps accessibility violations to inline usage sites, and applies context-based rule exceptions. New fixtures cover child templates, conditional content, full documents, and parent contexts. Axe rule documentation is updated to version 4.12, a new ARIA rule is cataloged, and release, spellcheck, and bug-tracking files are updated.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: the a11y linter now accounts for child templates.
Description check ✅ Passed The description is directly related to the changes and matches the PR's accessibility-linter focus.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/a11y-linter-child-templates-chkwyt

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@agent-feedback/bugs.md`:
- Around line 8-11: Record both suspected-bug findings from the diff in
agent-feedback/README.md, including their affected symbols, reproduction
examples, and proposed fix direction. Preserve the existing entries in
agent-feedback/bugs.md and do not implement the extractor or validation changes
in this task.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 423a3bad-589c-4343-8d90-5f183d425c87

📥 Commits

Reviewing files that changed from the base of the PR and between 335621b and d38a10e.

⛔ Files ignored due to path filters (81)
  • packages/language-server/src/__tests__/fixtures/html/child-template-context/__snapshots__/child-template-context.expected/components/fancy-item.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-context/__snapshots__/child-template-context.expected/components/fancy-item.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-context/__snapshots__/child-template-context.expected/components/fancy-item.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-context/__snapshots__/child-template-context.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-context/__snapshots__/child-template-context.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-context/__snapshots__/child-template-context.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/__snapshots__/child-template-link-name.expected/components/icon-arrow.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/__snapshots__/child-template-link-name.expected/components/icon-arrow.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/__snapshots__/child-template-link-name.expected/components/icon-arrow.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/__snapshots__/child-template-link-name.expected/components/text-label.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/__snapshots__/child-template-link-name.expected/components/text-label.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/__snapshots__/child-template-link-name.expected/components/text-label.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/__snapshots__/child-template-link-name.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/__snapshots__/child-template-link-name.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/__snapshots__/child-template-link-name.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-recursive/__snapshots__/child-template-recursive.expected/components/tree-item.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-recursive/__snapshots__/child-template-recursive.expected/components/tree-item.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-recursive/__snapshots__/child-template-recursive.expected/components/tree-item.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-recursive/__snapshots__/child-template-recursive.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-recursive/__snapshots__/child-template-recursive.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-recursive/__snapshots__/child-template-recursive.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/__snapshots__/child-template-slot.expected/components/my-card.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/__snapshots__/child-template-slot.expected/components/my-card.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/__snapshots__/child-template-slot.expected/components/my-card.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/__snapshots__/child-template-slot.expected/components/my-list.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/__snapshots__/child-template-slot.expected/components/my-list.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/__snapshots__/child-template-slot.expected/components/my-list.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/__snapshots__/child-template-slot.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/__snapshots__/child-template-slot.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/__snapshots__/child-template-slot.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/__snapshots__/child-template-uncertain.expected/components/plain-box.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/__snapshots__/child-template-uncertain.expected/components/plain-box.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/__snapshots__/child-template-uncertain.expected/components/plain-box.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/__snapshots__/child-template-uncertain.expected/components/raw-html.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/__snapshots__/child-template-uncertain.expected/components/raw-html.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/__snapshots__/child-template-uncertain.expected/components/raw-html.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/__snapshots__/child-template-uncertain.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/__snapshots__/child-template-uncertain.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/__snapshots__/child-template-uncertain.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/control-flow-conditional/__snapshots__/control-flow-conditional.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/control-flow-conditional/__snapshots__/control-flow-conditional.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/control-flow-conditional/__snapshots__/control-flow-conditional.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/dlitem-context/__snapshots__/dlitem-context.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/dlitem-context/__snapshots__/dlitem-context.expected/index.md is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/html/dlitem-context/__snapshots__/dlitem-context.expected/index.ts is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-class-id-shorthands/__snapshots__/attr-class-id-shorthands.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-class-shorthand-merge/__snapshots__/attr-class-shorthand-merge.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-dynamic-for-typed/__snapshots__/attr-tags-dynamic-for-typed.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-dynamic-for-typed/__snapshots__/attr-tags-dynamic-for-typed.expected/tags/my-menu/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-dynamic-for/__snapshots__/attr-tags-dynamic-for.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-dynamic-if/__snapshots__/attr-tags-dynamic-if.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-dynamic-while/__snapshots__/attr-tags-dynamic-while.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-dynamic-with-scriptlets/__snapshots__/attr-tags-dynamic-with-scriptlets.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-for-narrowing/__snapshots__/attr-tags-for-narrowing.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-generic/__snapshots__/attr-tags-generic.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-nested-dynamic-for-typed/__snapshots__/attr-tags-nested-dynamic-for-typed.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-nested-dynamic-for-typed/__snapshots__/attr-tags-nested-dynamic-for-typed.expected/tags/my-layout/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-nested-type/__snapshots__/attr-tags-nested-type.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/attr-tags-static-dynamic-renderbody/__snapshots__/attr-tags-static-dynamic-renderbody.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/bound-attr-mut-ident/__snapshots__/bound-attr-mut-ident.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/class-api-basic/__snapshots__/class-api-basic.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/custom-tag-args/__snapshots__/custom-tag-args.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/el-ref-hoist/__snapshots__/el-ref-hoist.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/for-tag-attr-tags/__snapshots__/for-tag-attr-tags.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/for-tag-input/__snapshots__/for-tag-input.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/for-tag/__snapshots__/for-tag.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/if-tag/__snapshots__/if-tag.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/prefer-local-identifier-tag-name/__snapshots__/prefer-local-identifier-tag-name.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/recursive-define-tag/__snapshots__/recursive-define-tag.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/render-body-basic/__snapshots__/render-body-basic.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/return-tag-basic/__snapshots__/return-tag-basic.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/return-tag-nested/__snapshots__/return-tag-nested.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/simple-hoist/__snapshots__/simple-hoist.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/tag-param-mutation/__snapshots__/tag-param-mutation.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/tag-var-hoisting-from-control-flow/__snapshots__/tag-var-hoisting-from-control-flow.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/tag-var-mutation-patterns/__snapshots__/tag-var-mutation-patterns.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/tag-var-mutation-shadowing/__snapshots__/tag-var-mutation-shadowing.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/tag-var-mutations-nested/__snapshots__/tag-var-mutations-nested.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/tag-var-mutations/__snapshots__/tag-var-mutations.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/tags-api-basic/__snapshots__/tags-api-basic.expected/index.html is excluded by !**/__snapshots__/** and included by **
  • packages/language-server/src/__tests__/fixtures/script/while-tag/__snapshots__/while-tag.expected/index.html is excluded by !**/__snapshots__/** and included by **
📒 Files selected for processing (22)
  • .changeset/spotty-moons-repeat.md
  • agent-feedback/bugs.md
  • cspell.json
  • packages/language-server/src/__tests__/fixtures/html/child-template-context/components/fancy-item.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-context/index.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/components/icon-arrow.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/components/text-label.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-link-name/index.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-recursive/components/tree-item.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-recursive/index.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/components/my-card.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/components/my-list.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-slot/index.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/components/plain-box.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/components/raw-html.marko
  • packages/language-server/src/__tests__/fixtures/html/child-template-uncertain/index.marko
  • packages/language-server/src/__tests__/fixtures/html/control-flow-conditional/index.marko
  • packages/language-server/src/__tests__/fixtures/html/dlitem-context/index.marko
  • packages/language-server/src/service/html/axe-rules/rule-exceptions.ts
  • packages/language-server/src/service/html/index.ts
  • packages/language-tools/src/extractors/html/index.ts
  • packages/language-tools/src/util/extractor.ts

Comment thread agent-feedback/bugs.md Outdated
Comment on lines +8 to +11

- `packages/language-tools/src/extractors/html/index.ts` › `HTMLExtractor.#visitNode` | 2026-07-22 | impact:med | effort:med — Dynamic _attribute_ values on descendants do not propagate into an ancestor's `hasDynamicBody`, so `unknownBody` rule exceptions can miss. Example: `<div role="list"><div role=input.x></div></div>` extracts the child as `role="dynamic"` (an invalid role) while the outer element's `hasDynamicBody` stays false, so axe's `aria-required-children` reports a violation even though the runtime role could be `listitem` — a false positive that predates child-template inlining. `#visitNode` returns `isDynamic || hasDynamicBody` and deliberately ignores `hasDynamicAttrs`; a fix could propagate a separate "semantics-affecting dynamic attr" flag (role/aria-\*/tabindex only, to avoid over-suppressing on eg dynamic `class`) into ancestors. Re-verify: `<div role="list"><div role=input.x></div></div>` in an html fixture produces an `aria-required-children` diagnostic.

- `packages/language-server/src/service/html/index.ts` › `doValidate` | 2026-07-22 | impact:low | effort:med — The `conditionalContent` exception only suppresses duplicate-detection rules (accesskeys, landmark-unique, identical-links-same-purpose, ...) when the _violating_ element is inside a control flow branch. When the violating element is unconditional but its duplicate counterpart is conditional (eg a static `<label>` plus a second `<label>` inside `<if>` for `form-field-multiple-labels`), the false positive remains. axe exposes the counterparts via each check's `relatedNodes`; suppressing when any related node maps to an `inConditional` element would close the gap. Re-verify: fixture with `<main>` plus `<if=x><main></if>` — the unconditional `<main>` may still be reported by `landmark-no-duplicate-main`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Record these actionable findings in agent-feedback/README.md.

These suspected-bug records are currently added to agent-feedback/bugs.md, but the repository instruction requires actionable out-of-scope findings to be recorded in agent-feedback/README.md.

As per coding guidelines: “Record actionable out-of-scope findings in agent-feedback/README.md before finishing; do not silently omit them or fix them in an unrelated change.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent-feedback/bugs.md` around lines 8 - 11, Record both suspected-bug
findings from the diff in agent-feedback/README.md, including their affected
symbols, reproduction examples, and proposed fix direction. Preserve the
existing entries in agent-feedback/bugs.md and do not implement the extractor or
validation changes in this task.

Source: Coding guidelines

The axe-core HTML plugin previously replaced every custom tag with a
bare <div> wrapper, so it could never see what a child template renders
and any component usage suppressed most content rules on its ancestors
(via the unknownBody exception). Extraction now inlines the extracted
skeleton of resolvable child templates at the usage site, guarded so a
diagnostic is only ever produced when it is provably true for rendered
output:

- Child skeletons are resolved through the taglib lookup, cached per
  template and project version, and bounded by cycle detection, a depth
  limit, and a byte budget; anything unresolvable falls back to the old
  <div> behavior.
- A template's default body slot (`<${input.renderBody}/>`) is detected
  so usage-site body content is spliced into the child's true position;
  templates without a single known slot keep the previous behavior.
- Any uncertainty inside a child (unescaped placeholders, unresolvable
  tags, dynamic body content) propagates to the usage site so existing
  unknownBody suppression still applies.
- Diagnostics on a child skeleton's top-level elements are re-anchored
  to the tag name at the usage site; deeper elements are left to the
  child template's own validation.
- Control flow tags (if/else/for/while) emit their body directly
  instead of a fabricated <div>, so structural rules see real
  parent/child relationships.
- listitem and dlitem are un-blacklisted, gated by a new
  requiresKnownParent exception that mirrors exactly the ancestor chain
  axe consults (including dlitem's presentational-div lookthrough), and
  never fires for elements whose rendered parent is unknown.
- Duplicate-detection rules (accesskeys, landmark-unique, ...) are
  suppressed for elements in control flow branches, removing a class of
  pre-existing false positives from mutually exclusive branches.

No diagnostics changed on the existing fixture corpus; new fixtures
cover inlined context, slot splicing, uncertainty suppression,
recursion, control flow, and dlitem gating.
The child-template-uncertain fixture's only diagnostic was an
accidental TypeScript error (raw-html required an input the fixture
never passed), so nothing in its snapshots demonstrated accessibility
behavior. The input is now optional, and the fixture gained a
contrasting certain child (plain-box rendering a <div>) so the
snapshot shows the `list` rule firing for the known child while the
uncertain child stays suppressed.
A diagnostic re-anchored from an inlined child template's root element
to the usage site read as if it were about the tag itself (eg "List
item does not have a <ul>, <ol> parent element" pointing at
<fancy-item/>). The message now leads with which element the tag
renders, found during a self-audit of the child template inlining
work.
Condense the JSDoc and inline commentary added with child template
inlining, and drop the long-dead commented-out severity mapping.
Delete interface JSDoc and narration that restated the code; rename
regionAt to innermostRegionAt so the selection rule is in the name.
The requiresKnownParent gates mirror axe-core 4.12's parent checks, so
flag that for future axe upgrades, and strip attribute-unsafe
characters from template basenames used in data-marko-node-id.
@LuLaValva
LuLaValva force-pushed the claude/a11y-linter-child-templates-chkwyt branch from d38a10e to 14a0504 Compare July 23, 2026 16:06
LuLaValva added 13 commits July 23, 2026 16:15
Regenerating axe-rules.ts against the installed axe-core 4.12.1 (the
file was generated from 4.10) picks up the new aria-tab-name rule,
whitelisted with the same exceptions as its sibling name rules.

The extracted HTML is now exact enough for a stronger guarantee: when a
file renders an authored <html> containing no dynamic attributes,
placeholders, or unresolvable content, the extraction IS the rendered
page. A new requiresFullDocument exception runs rules gated on that
exactness, re-enabling all fifteen previously blacklisted
page/ancestor-context rules (label, region, bypass, heading-order,
nested-interactive, aria-required-parent, landmark placement, and
friends) for fully static documents while suppressing them everywhere
else.

Extraction also now emits static shorthand ids/classes (dynamic ones
become "dynamic" like other attribute values) and tracks whether any
placeholders exist, both feeding the exactness check.
A single-quoted (or escape-containing) attribute value with an embedded
double quote was copied verbatim between double quotes, garbling the
generated attribute — which the new full-document exactness gate would
then wrongly trust. Such values now extract as "dynamic", which both
keeps the attribute well-formed and blocks the gate. Also drops two
cspell words orphaned by earlier comment trims.
The language server was slicing skeleton strings at body-slot offsets
and re-deriving extraction exactness by scanning generated HTML — both
extraction-domain work. The extractor now assembles inline child
templates itself (extractChildTemplate) and reports a single `exact`
flag computed where dynamic values are written, which is also more
precise than the string scan (an authored literal "dynamic" no longer
blocks the full-document gate). The public extractHTML result shrinks
to what hosts consume: extracted, nodeDetails, inlineRegions, exact.
- Collapse the uncertain/exact flag pair into a single three-state
  fidelity (exact | approximate | uncertain), removing a representable
  illegal state and the confusing dual-flag computation.
- Group the body-slot metadata into an optional bodySlot object so the
  fields can't outlive the slot they describe.
- Unify the server's "skeleton" vocabulary with language-tools'
  "child template", and rename requiresFullDocument to
  requiresExactDocument to match the gate it feeds.
- Make isKnownElement a type guard (drops the parent! assertions),
  extract violation anchoring out of the doValidate callback, and
  invert the body-slot branch for a linear early return.
Name the extractHTML result (HTMLExtraction) instead of ReturnType
indirection in the server, rename #writeDynamicTag to #visitNonHTMLTag
to match its dispatch role, and restore the in-progress cache marker
comment a fresh reader flagged as missing.
Child template resolution goes through the compiler's TaglibLookup, so
directory conventions are the compiler's concern; converting one
fixture from components/ to tags/ locks that in without growing the
suite.
Validation results are now cached per document, keyed on the
extraction's semantic content (generated HTML, node details, inline
regions, fidelity); a cache hit re-maps stored generated-space anchors
through the fresh extraction instead of re-running jsdom and axe.
Edits inside scriptlets, event handlers, styles, or placeholder
expressions hit this path, dropping revalidation from ~200ms to ~2ms
on a 24-section benchmark page.

Adds a BENCH-gated perf suite (pnpm --filter @marko/language-server
run bench) that generates a synthetic component-heavy app, guards
against benching a broken page, and measures cold/warm validation and
extraction with and without inlining, plus a regression test proving
cache hits re-map diagnostic ranges after extraction-neutral edits.
Profiling the bench page showed no pathological rule — cost is spread
across axe's per-node work — but two lossless cuts: skip axe's CSSOM
preload (no enabled rule reads CSS), and when the exact-document gate
is closed, drop the fifteen requiresExactDocument rules from runOnly
instead of running them and filtering every violation. Cold validation
on the bench page drops ~10%; the remaining gap versus the legacy
extraction is the added analysis surface itself (mostly inlined
component roots, which are the point of the feature).
CPU profiling showed cold validation dominated not by axe's rule logic
but by jsdom recomputing styles element-by-element for axe's
visibility checks. Extracted documents carry no stylesheets, so an
element's computed style is a pure function of the UA sheet and its
ancestor chain of tags and attributes (jsdom implements inheritance
and the UA sheet uses only tag/attr child combinators — verified
against jsdom's computed-style helper and default stylesheet).
getComputedStyle is now memoized per ancestor-chain key, excluding the
style-inert data-marko-node-id so repeated component instances share
entries. Also passes iframes:false since extracted frames never have
content documents.

Bench page cold validation drops ~200ms to ~156ms (legacy shape
~103ms to ~74ms); axe results verified identical on the corpus and on
an inheritance/visibility torture page.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant