diff --git a/profiles/aom.js b/profiles/aom.js index db075aa1d6..c9df5652bc 100644 --- a/profiles/aom.js +++ b/profiles/aom.js @@ -54,6 +54,7 @@ const modules = [ import("../src/core/linter-rules/no-unused-vars.js"), import("../src/core/linter-rules/privsec-section.js"), import("../src/core/linter-rules/no-http-props.js"), + import("../src/core/linter-rules/prefer-xref.js"), ]; Promise.all(modules) diff --git a/profiles/dini.js b/profiles/dini.js index 0315e6d46e..e94aebbf5b 100644 --- a/profiles/dini.js +++ b/profiles/dini.js @@ -54,6 +54,7 @@ const modules = [ import("../src/core/linter-rules/no-unused-vars.js"), import("../src/core/linter-rules/privsec-section.js"), import("../src/core/linter-rules/no-http-props.js"), + import("../src/core/linter-rules/prefer-xref.js"), ]; Promise.all(modules) diff --git a/profiles/geonovum.js b/profiles/geonovum.js index 798a323b35..da072db60b 100644 --- a/profiles/geonovum.js +++ b/profiles/geonovum.js @@ -52,6 +52,7 @@ const modules = [ import("../src/core/linter-rules/no-unused-vars.js"), import("../src/core/linter-rules/privsec-section.js"), import("../src/core/linter-rules/no-http-props.js"), + import("../src/core/linter-rules/prefer-xref.js"), ]; Promise.all(modules) diff --git a/profiles/w3c.js b/profiles/w3c.js index cdf270af71..434c549b00 100644 --- a/profiles/w3c.js +++ b/profiles/w3c.js @@ -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) diff --git a/src/core/linter-rules/prefer-xref.js b/src/core/linter-rules/prefer-xref.js new file mode 100644 index 0000000000..2b42952da7 --- /dev/null +++ b/src/core/linter-rules/prefer-xref.js @@ -0,0 +1,127 @@ +// @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} */ +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\`.`; + }, + }, + cs: { + msg(specKey) { + return `Specifikace \`${specKey}\` je dostupná v xref. Zvažte použití zkráceného zápisu (např. \`[= pojem =]\`) místo \`data-cite="${specKey}#…"\`.`; + }, + get hint() { + return docLink`Zkrácený zápis ${"[xref]"} je kratší, nezávislý na verzi specifikace a umožňuje ReSpecu ověřit existenci pojmu. Pro potlačení tohoto varování u konkrétního prvku přidejte \`class="lint-ignore"\`. Pro úplné vypnutí pravidla nastavte \`lint: { "${ruleName}": false }\` ve vašem \`respecConfig\`.`; + }, + }, +}; +const l10n = getIntlData(localizationStrings); + +/** + * @param {Conf["xref"]} xref + * @returns {Set | null} + */ +function getXrefSpecSet(xref) { + if (!xref || xref === true) return null; + + /** @type {Set} */ + const specs = new Set(); + + if (typeof xref === "string") { + const profile = xref.toLowerCase(); + if (profile in profiles) { + /** @type {Record} */ (profiles)[profile].forEach( + (/** @type {string} */ 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) { + /** @type {Record} */ (profiles)[key].forEach( + (/** @type {string} */ 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(); +} + +/** + * @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} */ + const elems = document.querySelectorAll( + ":is(a, dfn)[data-cite*='#']:not([data-cite^='#']):not(.lint-ignore)" + ); + + /** @type {Map} */ + const offenders = new Map(); + + elems.forEach(elem => { + const rawCite = /** @type {string} */ (elem.dataset.cite); + const specKey = extractSpecKey(rawCite); + if (!specKey || !xrefSpecSet.has(specKey)) return; + + if (!offenders.has(specKey)) { + offenders.set(specKey, []); + } + /** @type {HTMLElement[]} */ (offenders.get(specKey)).push(elem); + }); + + // @ts-expect-error -- ruleName is a string literal but LintConfig index signature doesn't cover it + const logger = conf.lint?.[ruleName] === "error" ? showError : showWarning; + offenders.forEach((elements, specKey) => { + logger(l10n.msg(specKey), name, { + hint: l10n.hint, + elements, + }); + }); +} diff --git a/src/core/xref.js b/src/core/xref.js index 459af7e97e..e9bd8baeab 100644 --- a/src/core/xref.js +++ b/src/core/xref.js @@ -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"], }; diff --git a/tests/spec/core/linter-rules/prefer-xref-spec.js b/tests/spec/core/linter-rules/prefer-xref-spec.js new file mode 100644 index 0000000000..166d928058 --- /dev/null +++ b/tests/spec/core/linter-rules/prefer-xref-spec.js @@ -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 = ` +
+

Test

+

a element

+
`; + 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 = ` +
+

Test

+

a element

+
`; + 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 = ` +
+

Test

+

a element

+
`; + 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 = ` +
+

Test

+

a element

+
`; + 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 = ` +
+

Test

+

a element

+

p element

+

list

+
`; + 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 = ` +
+

Test

+

event handler

+
`; + 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 = ` +
+

Test

+

some term

+
`; + 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 = ` +
+

Test

+

some term

+
`; + 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 = ` +
+

Test

+

EventTarget

+
`; + 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 = ` +
+

Test

+

a element

+

p element

+
`; + 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 = ` +
+

Test

+

a element

+
`; + 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 = ` +
+

Test

+

a element

+
`; + 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 = ` +
+

Test

+

interface

+
`; + 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 = ` +
+

Test

+ something +

something

+
`; + const ops = makeStandardOps( + { lint: { "prefer-xref": true }, xref: "web-platform" }, + body + ); + const doc = await makeRSDoc(ops); + expect(lintWarnings(doc)).toHaveSize(0); + }); +});