Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
1 change: 1 addition & 0 deletions profiles/w3c.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const modules = [
import("../src/core/linter-rules/no-http-props.js"),
import("../src/core/linter-rules/a11y.js"),
import("../src/core/linter-rules/informative-dfn.js"),
import("../src/core/linter-rules/prefer-xref.js"),
];

Promise.all(modules)
Expand Down
114 changes: 114 additions & 0 deletions src/core/linter-rules/prefer-xref.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// @ts-check
import { docLink, getIntlData, showError, showWarning } from "../utils.js";
import { profiles } from "../xref.js";

const ruleName = "prefer-xref";
export const name = "core/linter-rules/prefer-xref";

/** @satisfies {Record<string, { msg(specKey: string): string; readonly hint: string }>} */
const localizationStrings = {
en: {
msg(specKey) {
return `Spec \`${specKey}\` is available in xref. Consider using shorthand syntax (e.g. \`[= term =]\`) instead of \`data-cite="${specKey}#…"\`.`;
},
get hint() {
return docLink`Using ${"[xref]"} shorthand syntax is shorter, spec-version-agnostic, and lets ReSpec verify the term exists. To silence this warning for a specific element, add \`class="lint-ignore"\`. To disable this rule entirely, set \`lint: { "${ruleName}": false }\` in your \`respecConfig\`.`;
},
},
};
const l10n = getIntlData(localizationStrings);

/**
* @param {Conf["xref"]} xref
* @returns {Set<string> | null}
*/
function getXrefSpecSet(xref) {
if (!xref || xref === true) return null;

/** @type {Set<string>} */
const specs = new Set();

if (typeof xref === "string") {
const profile = xref.toLowerCase();
if (profile in profiles) {
profiles[profile].forEach(s => specs.add(s.toUpperCase()));
}
return specs.size ? specs : null;
}

if (Array.isArray(xref)) {
xref.forEach(s => specs.add(s.toUpperCase()));
return specs.size ? specs : null;
}

if (typeof xref === "object") {
const { profile, specs: specList } =
/** @type {{ profile?: string; specs?: string[] }} */ (xref);
if (profile) {
const key = profile.toLowerCase();
if (key in profiles) {
profiles[key].forEach(s => specs.add(s.toUpperCase()));
}
}
if (specList) {
specList.forEach(s => specs.add(s.toUpperCase()));
}
return specs.size ? specs : null;
}

return null;
}

/**
* @param {string} rawCite
* @returns {string}
*/
function extractSpecKey(rawCite) {
return rawCite.replace(/^[?!]/, "").split(/[/#]/)[0].toUpperCase();
Comment thread
marcoscaceres marked this conversation as resolved.
}

/**
* @param {Conf} conf
*/
export function run(conf) {
// @ts-expect-error -- LintConfig can be false; ?. only short-circuits null/undefined in TS
if (!conf.lint?.[ruleName]) {
return;
}

if (!conf.xref) {
return;
}

const xrefSpecSet = getXrefSpecSet(conf.xref);
if (!xrefSpecSet) {
return;
}

/** @type {NodeListOf<HTMLElement>} */
const elems = document.querySelectorAll(
":is(a, dfn)[data-cite*='#']:not([data-cite^='#']):not(.lint-ignore)"
);

/** @type {Map<string, HTMLElement[]>} */
const offenders = new Map();

elems.forEach(elem => {
const rawCite = elem.dataset.cite;
const specKey = extractSpecKey(rawCite);
if (!specKey || !xrefSpecSet.has(specKey)) return;

if (!offenders.has(specKey)) {
offenders.set(specKey, []);
}
offenders.get(specKey).push(elem);
});
Comment thread
marcoscaceres marked this conversation as resolved.

const logger = conf.lint?.[ruleName] === "error" ? showError : showWarning;
offenders.forEach((elements, specKey) => {
logger(l10n.msg(specKey), name, {
hint: l10n.hint,
elements,
});
});
Comment thread
marcoscaceres marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion src/core/xref.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { sub } from "./pubsubhub.js";

export const name = "core/xref";

const profiles = {
export const profiles = {
"web-platform": ["HTML", "INFRA", "URL", "WEBIDL", "DOM", "FETCH"],
};

Expand Down
243 changes: 243 additions & 0 deletions tests/spec/core/linter-rules/prefer-xref-spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
"use strict";

import {
flushIframes,
makeRSDoc,
makeStandardOps,
warningFilters,
} from "../../SpecHelper.js";

describe("Core — linter-rules - prefer-xref", () => {
const name = "core/linter-rules/prefer-xref";
const lintWarnings = warningFilters.filter(name);

afterAll(() => {
flushIframes();
});

it("does not warn when the rule is turned off", async () => {
const body = `
<section>
<h2>Test</h2>
<p><a data-cite="HTML#the-a-element">a element</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": false }, xref: "web-platform" },
body
);
const doc = await makeRSDoc(ops);
expect(lintWarnings(doc)).toHaveSize(0);
});

it("does not warn when xref is not enabled", async () => {
const body = `
<section>
<h2>Test</h2>
<p><a data-cite="HTML#the-a-element">a element</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: false },
body
);
const doc = await makeRSDoc(ops);
expect(lintWarnings(doc)).toHaveSize(0);
});

it("does not warn when xref is enabled with no spec list (xref: true)", async () => {
// When xref=true we cannot determine coverage without a network call,
// so we conservatively skip the check.
const body = `
<section>
<h2>Test</h2>
<p><a data-cite="HTML#the-a-element">a element</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: true },
body
);
const doc = await makeRSDoc(ops);
expect(lintWarnings(doc)).toHaveSize(0);
});

it("warns for data-cite with fragment on a spec in the xref profile", async () => {
const body = `
<section>
<h2>Test</h2>
<p><a data-cite="HTML#the-a-element">a element</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: "web-platform" },
body
);
const doc = await makeRSDoc(ops);
const warnings = lintWarnings(doc);
expect(warnings).toHaveSize(1);
expect(warnings[0].message).toContain("HTML");
});

it("warns once per spec key, grouping all offending elements together", async () => {
const body = `
<section>
<h2>Test</h2>
<p><a data-cite="HTML#the-a-element">a element</a></p>
<p><a data-cite="HTML#the-p-element">p element</a></p>
<p><a data-cite="INFRA#list">list</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: "web-platform" },
body
);
const doc = await makeRSDoc(ops);
const warnings = lintWarnings(doc);
// One warning per spec key (HTML and INFRA each get one warning).
expect(warnings).toHaveSize(2);
const specKeys = warnings.map(w => w.message).join(" ");
expect(specKeys).toContain("HTML");
expect(specKeys).toContain("INFRA");
});

it("does not warn for data-cite without a fragment (context-only use)", async () => {
// data-cite="HTML" (no #) is a legitimate context hint — xref uses it.
const body = `
<section data-cite="HTML">
<h2>Test</h2>
<p><a>event handler</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: "web-platform" },
body
);
const doc = await makeRSDoc(ops);
expect(lintWarnings(doc)).toHaveSize(0);
});

it("does not warn for specs not in the configured xref list", async () => {
const body = `
<section>
<h2>Test</h2>
<p><a data-cite="SOME-OTHER-SPEC#a-fragment">some term</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: "web-platform" },
body
);
const doc = await makeRSDoc(ops);
expect(lintWarnings(doc)).toHaveSize(0);
});

it("warns for specs in a custom xref array", async () => {
const body = `
<section>
<h2>Test</h2>
<p><a data-cite="MYSPEC#some-term">some term</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: ["MYSPEC"] },
body
);
const doc = await makeRSDoc(ops);
const warnings = lintWarnings(doc);
expect(warnings).toHaveSize(1);
expect(warnings[0].message).toContain("MYSPEC");
});

it("warns for specs in a custom xref object with specs array", async () => {
const body = `
<section>
<h2>Test</h2>
<p><a data-cite="DOM#eventtarget">EventTarget</a></p>
</section>`;
const ops = makeStandardOps(
{
lint: { "prefer-xref": true },
xref: { profile: "web-platform", specs: ["DOM"] },
},
body
);
const doc = await makeRSDoc(ops);
const warnings = lintWarnings(doc);
expect(warnings).toHaveSize(1);
expect(warnings[0].message).toContain("DOM");
});

it("ignores elements with class='lint-ignore'", async () => {
const body = `
<section>
<h2>Test</h2>
<p><a class="lint-ignore" data-cite="HTML#the-a-element">a element</a></p>
<p><a data-cite="HTML#the-p-element">p element</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: "web-platform" },
body
);
const doc = await makeRSDoc(ops);
const warnings = lintWarnings(doc);
// Only the second element (without lint-ignore) should warn.
expect(warnings).toHaveSize(1);
expect(warnings[0].elements).toHaveSize(1);
});

it("handles case-insensitive spec key matching", async () => {
// Authors may write lowercase "html" — should still match "HTML" in the profile.
const body = `
<section>
<h2>Test</h2>
<p><a data-cite="html#the-a-element">a element</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: "web-platform" },
body
);
const doc = await makeRSDoc(ops);
const warnings = lintWarnings(doc);
expect(warnings).toHaveSize(1);
});

it("handles informative prefix '?' in data-cite value", async () => {
const body = `
<section>
<h2>Test</h2>
<p><a data-cite="?HTML#the-a-element">a element</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: "web-platform" },
body
);
const doc = await makeRSDoc(ops);
const warnings = lintWarnings(doc);
expect(warnings).toHaveSize(1);
expect(warnings[0].message).toContain("HTML");
});

it("warns for dfn elements with data-cite fragment in xref specs", async () => {
const body = `
<section>
<h2>Test</h2>
<p><dfn data-cite="WEBIDL#dfn-interface">interface</dfn></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: "web-platform" },
body
);
const doc = await makeRSDoc(ops);
const warnings = lintWarnings(doc);
expect(warnings).toHaveSize(1);
expect(warnings[0].message).toContain("WEBIDL");
});

it("does not warn for self-referencing data-cite (data-cite='#foo')", async () => {
const body = `
<section>
<h2>Test</h2>
<dfn id="something">something</dfn>
<p><a data-cite="#something">something</a></p>
</section>`;
const ops = makeStandardOps(
{ lint: { "prefer-xref": true }, xref: "web-platform" },
body
);
const doc = await makeRSDoc(ops);
expect(lintWarnings(doc)).toHaveSize(0);
});
});
Loading