Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6188372
Make the a11y linter aware of child templates
LuLaValva Jul 22, 2026
237c81a
Make the uncertain-child fixture actually exercise a11y suppression
LuLaValva Jul 23, 2026
15787cb
Clarify re-anchored diagnostics with the rendered element
LuLaValva Jul 23, 2026
3a9ad44
Trim comments to at most two lines
LuLaValva Jul 23, 2026
4a9906a
Strip comments down to load-bearing one-liners
LuLaValva Jul 23, 2026
14a0504
Note axe version coupling and sanitize node id prefixes
LuLaValva Jul 23, 2026
322e78e
Shorten the changeset description
LuLaValva Jul 23, 2026
f0812f8
Regenerate axe rules and enable page rules for static documents
LuLaValva Jul 23, 2026
a8ced57
Merge into a single changeset
LuLaValva Jul 23, 2026
72f9350
Downgrade quoted attribute values containing a raw quote
LuLaValva Jul 23, 2026
3418d8b
Contain extraction knowledge inside the HTML extractor
LuLaValva Jul 23, 2026
c140c40
Apply code-smell probe findings
LuLaValva Jul 23, 2026
e2af740
Apply second-round probe findings
LuLaValva Jul 23, 2026
dd65365
Remove comments that restate the code
LuLaValva Jul 23, 2026
7750a72
Cover tags/ discovery in a child template fixture
LuLaValva Jul 23, 2026
265e1fc
Rename requiresKnownParent modes to a generic convention
LuLaValva Jul 24, 2026
71409e2
Skip axe when an edit leaves the extraction unchanged
LuLaValva Jul 24, 2026
7111a9a
Trim axe work that can't produce diagnostics
LuLaValva Jul 24, 2026
159a225
Memoize computed styles for axe and skip frame plumbing
LuLaValva Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/spotty-moons-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@marko/language-tools": minor
"@marko/language-server": minor
---

Take child templates into account in the accessibility linter, and enable page-scoped rules for fully static documents.
12 changes: 12 additions & 0 deletions agent-feedback/bugs.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,15 @@ The runtime accepts a string OR number loop key — `assertValidLoopKey` throws
`packages/language-tools/src/extractors/script/index.ts` › `#writeTag` | 2026-07-19 | impact:high | effort:med

A custom tag that fails to resolve (a component referenced by kebab tag name with no `import`, or a typo'd tag name) is a hard build error but produces ZERO diagnostics under `mtc` — the type-check tool agents are told to run. Reproduced in a real @marko/run scaffold: Marko 6 auto-discovers only `tags/` dirs (`runtime-tags` sets `tagDiscoveryDirs = ["tags"]` at `packages/runtime-tags/src/translator/index.ts:40`), so `src/components/*.marko` is NOT auto-registered — `<user-card name="X" age="alsoWrong" bogus=true/>` (no import) and the typo `<char-cont max="wrong" nope=1/>` both pass `mtc` with exit 0, while `npm run compile -o html` / `marko-run build` throw `Unable to find entry point for custom tag <user-card>` (`resolveTagImport` at `tags.js:353`; `tagNotFoundError` at `packages/runtime-tags/src/translator/visitors/tag/custom-tag.ts:411`). The identical wrong attr on a RESOLVED tag correctly errors TS2322 (verified against `<char-count max="not-a-number">`), proving tag resolution — not the attribute — is the gate: `#writeTag` lowers an unresolvable tag to `renderDynamicTag(...)` whose input is `Record<string, unknown>`, so every attribute and callback param goes unchecked. This is the worst shape for an agent whose deterministic verify loop is `mtc`: it creates a component, references it by tag (natural for anyone used to auto-registering `components/` dirs), sees a clean type-check, and ships wrong props or a misspelled tag; the build then fails with an "entry point" error that looks unrelated to the type loop. Direction: have @marko/language-tools emit a distinct "cannot resolve tag `<x>`" diagnostic mirroring the compiler instead of degrading to an untyped dynamic tag. The compiler-side authority is marko-js/marko's `packages/compiler/src/babel-utils/tags.js` › `resolveTagImport` and `custom-tag.ts` › `tagNotFoundError`. Distinct from the run dx.md route-types entries (missing `Run` global / stale `routes.d.ts`), which concern generated route types, not custom-tag resolution.

## Propagate descendant dynamic attribute values into `hasDynamicBody` for a11y rule suppression

`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.

## Suppress duplicate-detection a11y rules when the duplicate _counterpart_ is conditional, via axe relatedNodes

`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`.
1 change: 1 addition & 0 deletions packages/language-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"!**/*.tsbuildinfo"
],
"scripts": {
"bench": "BENCH=1 mocha './src/__tests__/bench.test.ts'",
"build": "tsx build.mts",
"test": "mocha './src/**/__tests__/*.test.ts'",
"test:update": "mocha './src/**/__tests__/*.test.ts' --update"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";

import { Project } from "@marko/language-tools";
import path from "path";
import { URI } from "vscode-uri";

import { documents } from "../service";
import HTMLPlugin from "../service/html";
import { clearMarkoCacheForFile } from "../utils/file";

Project.setDefaultTypePaths({
internalTypesFile:
require.resolve("@marko/language-tools/marko.internal.d.ts"),
markoTypesFile: require.resolve("marko/index.d.ts"),
});

describe("a11y validation cache", () => {
const uri = URI.file(
path.join(__dirname, "a11y-cache-virtual.marko"),
).toString();

it("re-maps diagnostics after edits that leave extraction unchanged", async () => {
documents.doOpen({
textDocument: {
uri,
languageId: "marko",
version: 1,
text: '<script>\n const a = 1;\n</script>\n<img src="x.png">\n',
},
});
const doc = documents.get(uri)!;

const first = (await HTMLPlugin.doValidate!(doc))!;
assert.equal(first.length, 1);
assert.match(first[0].message, /alt attribute/);
assert.equal(first[0].range.start.line, 3);

documents.doChange({
textDocument: { uri, version: 2 },
contentChanges: [
{
range: {
start: { line: 1, character: 0 },
end: { line: 1, character: 0 },
},
text: " const b = 2;\n",
},
],
});
// The server binary wires this into documents.onFileChange.
clearMarkoCacheForFile(doc);

const second = (await HTMLPlugin.doValidate!(doc))!;
assert.equal(second.length, 1);
assert.equal(second[0].message, first[0].message);
assert.equal(second[0].range.start.line, 4);

documents.doClose({ textDocument: { uri } });
});
});
201 changes: 201 additions & 0 deletions packages/language-server/src/__tests__/bench.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import assert from "node:assert/strict";

import fs from "fs";
import path from "path";
import type { TextDocument } from "vscode-languageserver-textdocument";
import { URI } from "vscode-uri";

import { documents } from "../service";
import HTMLPlugin from "../service/html";
import { clearMarkoCacheForFile } from "../utils/file";

const SHOULD_BENCH = process.env.BENCH;
const LEAVES = 20;
const WRAPPERS = 10;
const SECTIONS = 24;

(SHOULD_BENCH ? describe : describe.skip)("a11y validation bench", () => {
let appDir: string;
let tick = 0;
let edit = 0;

const openPage = (name: string, text: string) => {
const uri = URI.file(path.join(appDir, name)).toString();
documents.doOpen({
textDocument: { uri, languageId: "marko", version: 1, text },
});
return documents.get(uri)!;
};

// Any open/close bumps projectVersion, invalidating the extraction caches
// the way a keystroke would.
const invalidate = () => {
const uri = URI.file(path.join(appDir, `tick.marko`)).toString();
documents.doOpen({
textDocument: {
uri,
languageId: "marko",
version: ++tick,
text: `<div>${tick}</div>`,
},
});
documents.doClose({ textDocument: { uri } });
};

const replaceAt = (
doc: TextDocument,
search: string,
length: number,
text: string,
) => {
const offset = doc.getText().indexOf(search);
assert.notEqual(offset, -1, `bench edit target missing: ${search}`);
documents.doChange({
textDocument: { uri: doc.uri, version: doc.version + 1 },
contentChanges: [
{
range: {
start: doc.positionAt(offset),
end: doc.positionAt(offset + length),
},
text,
},
],
});
clearMarkoCacheForFile(doc);
};

const contentEdits = new WeakMap<TextDocument, number>();
const contentEdit = (doc: TextDocument) => {
const n = contentEdits.get(doc) ?? 0;
contentEdits.set(doc, n + 1);
replaceAt(doc, n % 2 ? "Note" : "Copy", 4, n % 2 ? "Copy" : "Note");
};
const scriptEdit = (doc: TextDocument) =>
replaceAt(doc, "marker = ", 10, `marker = ${++edit % 10}`);

const measure = async (
label: string,
iterations: number,
fn: () => unknown,
) => {
for (let i = 0; i < 3; i++) await fn();
const samples: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
await fn();
samples.push(performance.now() - start);
}
samples.sort((a, b) => a - b);
const mean = samples.reduce((sum, s) => sum + s, 0) / samples.length;
console.log(
` ${label}: mean ${mean.toFixed(2)}ms | p50 ${samples[
iterations >> 1
].toFixed(2)}ms | min ${samples[0].toFixed(2)}ms`,
);
};

before(function () {
this.timeout(0);
// Inside the repo tree so the workspace marko compiler resolves.
appDir = path.join(__dirname, "../../node_modules/.cache/a11y-bench-app");
const componentsDir = path.join(appDir, "components");
fs.rmSync(appDir, { recursive: true, force: true });
fs.mkdirSync(componentsDir, { recursive: true });

for (let i = 0; i < LEAVES; i++) {
fs.writeFileSync(
path.join(componentsDir, `leaf-${i}.marko`),
i % 4 === 0
? `<li class="leaf">item ${i}</li>\n`
: i % 4 === 1
? `<img src="art-${i}.png" alt="art ${i}">\n`
: i % 4 === 2
? `<button type="button">action ${i}</button>\n`
: `<span class="badge">badge ${i}</span>\n`,
);
}
for (let i = 0; i < WRAPPERS; i++) {
fs.writeFileSync(
path.join(componentsDir, `wrap-${i}.marko`),
`export interface Input {\n renderBody: Marko.Body;\n}\n\n` +
(i % 2 === 0
? `<section class="wrap-${i}"><\${input.renderBody}/></section>\n`
: `<ul class="wrap-${i}"><\${input.renderBody}/></ul>\n`),
);
}
});

after(() => {
fs.rmSync(appDir, { recursive: true, force: true });
});

const pageSource = (prefix: string) => {
let body = "";
for (let s = 0; s < SECTIONS; s++) {
const w = s % WRAPPERS;
const listItems = Array.from(
{ length: 4 },
(_, i) => ` <${prefix}leaf-${(s + i * 4) % LEAVES}/>\n`,
).join("");
body += `<section>
<h2>Section ${s}</h2>
<${prefix}wrap-${w}>
${listItems} </${prefix}wrap-${w}>
<if=input.expanded>
<${prefix}leaf-${(s + 1) % LEAVES}/>
</if>
<p>Copy for section ${s} with <a href="/s/${s}">details ${s}</a>.</p>
${s % 6 === 0 ? `<img src="banner-${s}.png">` : `<img src="banner-${s}.png" alt="banner ${s}">`}
</section>
`;
}
return `export interface Input {\n expanded: boolean;\n}\n\n<script>\n const marker = 0;\n</script>\n<main>\n${body}</main>\n`;
};

it("runs benches", async function () {
this.timeout(0);
const inlined = openPage("page-inlined.marko", pageSource(""));
const legacy = openPage("page-legacy.marko", pageSource("zz-"));

const inlinedHtml = (
await HTMLPlugin.commands!["$/showHtmlOutput"](inlined.uri)
)?.content as string;
assert.ok(inlinedHtml.length > 10_000, "bench page extraction too small");
assert.ok(
inlinedHtml.includes("leaf-0.marko#"),
"components were not inlined",
);
const baseline = (await HTMLPlugin.doValidate!(inlined))!.length;
assert.ok(baseline > 0, "bench page produced no diagnostics");

const validated = async (doc: TextDocument) => {
const diags = (await HTMLPlugin.doValidate!(doc))!;
assert.ok(diags.length > 0, "bench page lost its diagnostics");
};

console.log(
` page: ${inlined.getText().length} chars source, ${inlinedHtml.length} chars extracted, ${baseline} diagnostics`,
);
await measure("doValidate, content edit, components inlined", 20, () => {
contentEdit(inlined);
return validated(inlined);
});
await measure("doValidate, content edit, legacy (unresolvable)", 20, () => {
contentEdit(legacy);
return HTMLPlugin.doValidate!(legacy);
});
await measure("doValidate, script-only edit (axe skipped)", 100, () => {
scriptEdit(inlined);
return validated(inlined);
});
await measure("extraction only, components inlined", 200, () => {
invalidate();
return HTMLPlugin.commands!["$/showHtmlOutput"](inlined.uri);
});
await measure("extraction only, legacy (unresolvable)", 200, () => {
invalidate();
return HTMLPlugin.commands!["$/showHtmlOutput"](legacy.uri);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<ul data-marko-node-id="0">
<li data-marko-node-id="fancy-item.marko#0">fancy</li>
</ul><div data-marko-node-id="1">
<li data-marko-node-id="fancy-item.marko#0">fancy</li>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## Diagnostics
### Ln 6, Col 4
```marko
4 |
5 | <div>
> 6 | <fancy-item/>
| ^^^^^^^^^^ This tag renders a `<li>` element here — Fix any of the following:
List item does not have a <ul>, <ol> parent element
7 | </div>
8 |
```

Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
export interface Input {}
abstract class Component extends Marko.Component<Input> {}
export { type Component };
(function (this: void) {
const input = Marko._.any as Input;
const component = Marko._.any as Component;
const state = Marko._.state(component);
const out = Marko._.any as Marko.Out;
const $signal = Marko._.any as AbortSignal;
const $global = Marko._.getGlobal(
// @ts-expect-error We expect the compiler to error because we are checking if the MarkoRun.Context is defined.
(Marko._.error, Marko._.any as MarkoRun.Context),
);
Marko._.renderNativeTag("ul")()()({
[Marko._.content]: (() => {
const __marko_internal_tag_1 = Marko._.resolveTemplate(
import("./tags/fancy-item.marko"),
);
Marko._.renderTemplate(__marko_internal_tag_1)()()({});
return () => {
return Marko._.voidReturn;
};
})(),
});
Marko._.renderNativeTag("div")()()({
[Marko._.content]: (() => {
const __marko_internal_tag_2 = Marko._.resolveTemplate(
import("./tags/fancy-item.marko"),
);
Marko._.renderTemplate(__marko_internal_tag_2)()()({});
return () => {
return Marko._.voidReturn;
};
})(),
});
Marko._.noop({ component, state, out, input, $global, $signal });
return;
})();
const __marko_internal_api = "class";
export { __marko_internal_api as "~api" };
export default new (class Template extends Marko._.Template<{
render(
input: Marko.TemplateInput<Input>,
stream?: {
write: (chunk: string) => void;
end: (chunk?: string) => void;
},
): Marko.Out<Component>;

render(
input: Marko.TemplateInput<Input>,
cb?: (err: Error | null, result: Marko.RenderResult<Component>) => void,
): Marko.Out<Component>;

renderSync(input: Marko.TemplateInput<Input>): Marko.RenderResult<Component>;

renderToString(input: Marko.TemplateInput<Input>): string;

stream(
input: Marko.TemplateInput<Input>,
): ReadableStream<string> & NodeJS.ReadableStream;

mount(
input: Marko.TemplateInput<Input>,
reference: Node,
position?: "afterbegin" | "afterend" | "beforebegin" | "beforeend",
): Marko.MountedTemplate<typeof input>;

api: typeof __marko_internal_api;
_(): () => <__marko_internal_input extends unknown>(
input: Marko.Directives &
Input &
Marko._.Relate<__marko_internal_input, Marko.Directives & Input>,
) => Marko._.ReturnWithScope<__marko_internal_input, void>;
}> {})();
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<li data-marko-node-id="0">fancy</li>
Loading