Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"lint": "eslint src/**/*",
"transform-rule": "ts-node src/cli/rule-transform.ts",
"build-examples": "ts-node src/cli/build-examples.ts",
"generate-glossary": "ts-node src/cli/generate-glossary.ts",
"map-implementation": "ts-node src/cli/map-implementation.ts",
"implementations-update": "ts-node src/cli/implementations-update.ts",
"test": "jest",
Expand Down
12 changes: 11 additions & 1 deletion src/build-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,17 @@ export async function buildExamples({
// Copy test assets
if (testAssetsDir) {
const targetDir = path.resolve(assetsPath, "test-assets");
fs.copyFileSync(testAssetsDir, targetDir);

if (
fs.existsSync(testAssetsDir) &&
fs.lstatSync(testAssetsDir).isDirectory()
) {
fs.mkdirSync(targetDir, { recursive: true });
fs.cpSync(testAssetsDir, targetDir, { recursive: true });
} else {
fs.copyFileSync(testAssetsDir, targetDir);
}

console.log(`Copied test assets to ${targetDir}`);
}
}
17 changes: 17 additions & 0 deletions src/cli/__tests__/generate-glossary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { normalizeHeadingLevels } from "../generate-glossary";

describe("normalizeHeadingLevels", () => {
it("decrements all heading hashes by one level, preserves #", () => {
const input = `#### Item\n##### Subitem\n###### Deep`;
const output = normalizeHeadingLevels(input);

expect(output).toBe(`### Item\n#### Subitem\n##### Deep`);
});

it("preserves leading spaces before headings", () => {
const input = ` #### indented`;
const output = normalizeHeadingLevels(input);

expect(output).toBe(" ### indented");
});
});
180 changes: 180 additions & 0 deletions src/cli/generate-glossary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/usr/bin/env ts-node
import fs from "fs";
import path from "path";
import yaml from "js-yaml";
import { Command } from "commander";
import { DefinitionPage } from "../types";
import { getRulePages, getDefinitionPages } from "../utils/get-page-data";
import {
getGlossaryBody,
getGlossaryHeading,
normalizeHeadingLevels as normalizeGlossaryHeadingLevels,
} from "../utils/glossary";
import { getRuleDefinitions } from "../act/get-rule-definitions";

interface GlossaryOptions {
rulesDir: string;
glossaryDir: string;
testAssetsDir?: string;
outDir: string;
wcagActRulesDir?: string;
}

const program = new Command();
program
.requiredOption("-r, --rulesDir <dirname>", "Path to _rules directory")
.requiredOption("-g, --glossaryDir <dirname>", "Path to glossary directory")
.option("-t, --testAssetsDir <dirname>", "Path to test-assets directory", "")
.requiredOption("-o, --outDir <dirname>", "Path to output directory")
.option(
"--wcagActRulesDir <dirname>",
"Path to wcag-act-rules checkout directory for config nav injection",
);

function buildUsedInRulesMap(
rulesDir: string,
glossaryDir: string,
testAssetsDir: string,
) {
const rules = getRulePages(rulesDir, testAssetsDir || ".");
const glossary = getDefinitionPages(glossaryDir);

const usedInRules = new Map<string, Set<{ id: string; name: string }>>();
glossary.forEach((definition) => {
usedInRules.set(definition.frontmatter.key, new Set());
});

rules.forEach((rule) => {
const ruleDefinitions = getRuleDefinitions(rule, glossary);
const ruleDefKeys = new Set(
ruleDefinitions.map((def) => def.frontmatter.key),
);

ruleDefKeys.forEach((key) => {
if (!usedInRules.has(key)) return;
usedInRules
.get(key)
?.add({ id: rule.frontmatter.id, name: rule.frontmatter.name });
});
});

return { glossary, usedInRules };
}

export function normalizeHeadingLevels(body: string): string {
return normalizeGlossaryHeadingLevels(body);
}

function generateGlossaryContent(
glossaryDefinitions: DefinitionPage[],
usedInRules: Map<string, Set<{ id: string; name: string }>>,
): string {
const lines: string[] = [];

lines.push("---");
lines.push("layout: standalone_resource");
lines.push('title: "ACT Rules Glossary"');
lines.push("permalink: /standards-guidelines/act/rules/terms/");
lines.push("ref: /standards-guidelines/act/rules/terms/");
lines.push("lang: en");
lines.push('type_of_guidance: ""');
lines.push("feedbackmail: public-wcag-act@w3.org");
lines.push('footer: ""');
lines.push("github:");
lines.push(" repository: w3c/wcag-act-rules");
lines.push(" path: content/terms.md");
lines.push("---");
lines.push("");

glossaryDefinitions.forEach((def) => {
const key = def.frontmatter.key;
const title = def.frontmatter.title;
const body = getGlossaryBody(def, {
mode: "full",
normalizeHeadings: true,
});

lines.push(getGlossaryHeading({ title, key }, 2));
lines.push("");
lines.push(body);
lines.push("");
lines.push("### Used in rules");

const rules = [...(usedInRules.get(key) || new Set())].sort((a, b) =>
a.id.localeCompare(b.id),
);
if (rules.length === 0) {
lines.push("- None");
} else {
rules.forEach((rule) => {
lines.push(
`- [${rule.name}](/standards-guidelines/act/rules/${rule.id}/proposed/)`,
);
});
}
Comment on lines +101 to +114
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The "Used in Rules" list can become quite big and require a lot of scrolling from users. For example, the list for the "Outcome" term consists of 94 items.

Before considering possible solutions, could you clarify who this section is intended for?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I agree, this adds a large amount of visual space. I think embedding this content in summary and details elements is probably the best solution but I that would be assuming use cases myself. This came from the issue requirements:

In scope: New glossary index (and/or per-term pages), navigation entry, working anchors and links on WAI, “used in rules” lists with correct WAI rule URLs, full definition bodies as above.

I think it may also be a good option to move forward with the page as it is and adjust this later if it holds us up.

@WilcoFiers , do you have any context or suggestions to add here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi.

I am not even sure if we should have this "used in rules" at all.

I think the primary use case for a dedicated (self-contained) terms page is for rule authors to know whether or not something is or is not currently defined by ACT.

If you want to know which rules use which terms, you could get to the specific rule pages and see the terms at the bottom. I see this as a secondary need that is not worth the extra clutter.

If folks think strongly about keeping this, then I would support the details/summary alternative, but that should be done in alignment with how the expand/collapse single and expand/collapse multiple currently work on the wai website theme

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we should keep this section. Yeah it's more for rule authors than other people, but this page is mostly for rule authors anyway. I'm okay with using a details / summary thing here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I second (or third?) keeping the "used in rules" section. It is a common use case for me to search all rules using a given definition, typically to assess which rules will be impacted by changes in the definition. I'm currently doing that somewhat manually through grep, which is not perfect due to transitive definition inclusion, … (and going through all rules pages is not a real possibility).

A details/summary is perfect for that, indeed.

(for a recent case: searching all rules that use "marked as decorative" to evaluate impact of "whitespace alt is decorative")


lines.push("");
});


return lines.join("\n");
}

async function generateFile(options: GlossaryOptions): Promise<void> {
const { glossary, usedInRules } = buildUsedInRulesMap(
options.rulesDir,
options.glossaryDir,
options.testAssetsDir || "",
);

const content = generateGlossaryContent(glossary, usedInRules);
const outputDir = path.join(options.outDir, "content");
const outputFile = path.join(outputDir, "terms.md");

await fs.promises.mkdir(outputDir, { recursive: true });
await fs.promises.writeFile(outputFile, content, "utf8");
console.log(`Created glossary at ${outputFile}`);

await updateWcagConfigNav(options.outDir);
}

async function updateWcagConfigNav(outputDir: string) {
const configPath = path.join(outputDir, "_config.yml");
const configContent = await fs.promises.readFile(configPath, "utf8");
const configData: any = yaml.load(configContent);

if (!configData?.defaults) return;

const defaultValues = configData.defaults.find(
(item: any) => item?.values?.standalone_resource_nav_links,
);
if (!defaultValues) return;

const navLinks = defaultValues.values.standalone_resource_nav_links;
const hasGlossary = navLinks.some(
(link: any) => link.ref === "/standards-guidelines/act/rules/terms/",
);

if (!hasGlossary) {
navLinks.push({
name: "Glossary",
ref: "/standards-guidelines/act/rules/terms/",
});
await fs.promises.writeFile(configPath, yaml.dump(configData), "utf8");
console.log(
"Updated wcag-act-rules _config.yml to include glossary nav link.",
);
}
}

if (require.main === module) {
program.parse(process.argv);
const options = program.opts<GlossaryOptions>();

generateFile(options)
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});
}
28 changes: 3 additions & 25 deletions src/rule-transform/rule-content/get-glossary.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DefinitionPage } from "../../types";
import { getGlossaryBody, getGlossaryHeading } from "../../utils/glossary";
import { joinStrings } from "../../utils/join-strings";

export const getGlossary = (_: unknown, glossary: DefinitionPage[]): string => {
Expand All @@ -7,30 +8,7 @@ export const getGlossary = (_: unknown, glossary: DefinitionPage[]): string => {
};

function getGlossaryMarkdown(definition: DefinitionPage): string {
const { title, key } = definition.frontmatter;
const heading = `### ${title} {#${key}}`;
const body = getDefinitionBody(definition);
const heading = getGlossaryHeading(definition.frontmatter, 3);
const body = getGlossaryBody(definition, { mode: "rule" });
return joinStrings(heading, body);
}

function getDefinitionBody(definition: DefinitionPage): string | string[] {
// Delete all lines after the first heading
// References are mixed into the bottom of the rule page later
const lines = definition.body.split("\n");
const headingLineNum = lines.findIndex((line) => line.match(/^##/));
if (headingLineNum === -1) {
return stripDefinitions(definition);
}

lines.splice(headingLineNum);
return lines;
}

function stripDefinitions({ body, markdownAST }: DefinitionPage): string {
const firstRefLink = markdownAST.children.find(
({ type }) => type === "definition"
);
const refLinkOffset = firstRefLink?.position?.start?.offset;

return !refLinkOffset ? body : body.substr(0, refLinkOffset);
}
119 changes: 119 additions & 0 deletions src/utils/__tests__/glossary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import outdent from "outdent";
import {
getGlossaryHeading,
normalizeHeadingLevels,
getGlossaryBody,
} from "../glossary";

describe("utils", () => {
describe("getGlossaryHeading", () => {
it("returns a heading with the correct level", () => {
const heading = getGlossaryHeading(
{ title: "Outcome", key: "outcome" },
2,
);
expect(heading).toBe("## Outcome {#outcome}");
});

it("returns a level-3 heading when level is 3", () => {
const heading = getGlossaryHeading(
{ title: "Visible", key: "visible" },
3,
);
expect(heading).toBe("### Visible {#visible}");
});
});

describe("normalizeHeadingLevels", () => {
it("decrements all heading levels by one", () => {
const input = "#### Item\n##### Subitem\n###### Deep";
expect(normalizeHeadingLevels(input)).toBe(
"### Item\n#### Subitem\n##### Deep",
);
});

it("does not decrement level-1 headings", () => {
const input = "# Top\n## Section";
expect(normalizeHeadingLevels(input)).toBe("# Top\n# Section");
});

it("preserves leading spaces before headings", () => {
expect(normalizeHeadingLevels(" #### indented")).toBe(" ### indented");
});
});

describe("getGlossaryBody", () => {
const noMarkdownAST = { children: [] };

describe('mode: "full"', () => {
it("returns the trimmed full body", () => {
const definition = {
body: " Some content\n\n### Sub\n\nMore. ",
markdownAST: noMarkdownAST,
};
expect(getGlossaryBody(definition, { mode: "full" })).toBe(
"Some content\n\n### Sub\n\nMore.",
);
});

it("normalizes heading levels when normalizeHeadings is true", () => {
const definition = {
body: "First.\n\n### Sub\n",
markdownAST: noMarkdownAST,
};
expect(
getGlossaryBody(definition, {
mode: "full",
normalizeHeadings: true,
}),
).toBe("First.\n\n## Sub");
});
});

describe('mode: "rule"', () => {
it("strips content from the first ## heading onwards", () => {
const definition = {
body: outdent`
You can see it.

## References

Ignore me
`,
markdownAST: noMarkdownAST,
};
expect(getGlossaryBody(definition, { mode: "rule" })).toBe(
"You can see it.",
);
});

it("strips trailing reference definitions when there is no heading", () => {
const refOffset = "You can see [it][].\n\n".length;
const definition = {
body: "You can see [it][].\n\n[it]: https://w3.org/\n",
markdownAST: {
children: [
{
type: "definition",
position: { start: { offset: refOffset } },
},
],
},
};
expect(getGlossaryBody(definition, { mode: "rule" })).toBe(
"You can see [it][].",
);
});

it("returns full trimmed body when there is no heading and no reference definitions", () => {
const definition = {
body: " Plain content. ",
markdownAST: noMarkdownAST,
};
expect(getGlossaryBody(definition, { mode: "rule" })).toBe(
"Plain content.",
);
});
});
});
});
Loading