Skip to content

Commit c09f535

Browse files
authored
docs(docs-infra): Throw build error on invalid guide links
The list of valid links is generated from navigation data configuration in the ADEV app. Redirections are knowingly exclude so we stop referencing them.
1 parent 8bdd98e commit c09f535

112 files changed

Lines changed: 2310 additions & 1939 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

adev/BUILD.bazel

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,19 @@ APPLICATION_FILES = [
2525
"//adev/src/assets:context",
2626
"//adev/src/content/examples:embeddable",
2727
"tailwind.config.js",
28+
29+
# For the routes that are generated at build time
30+
"//adev/src/assets:docs_api_manifest",
31+
"//adev/scripts/routes:generate_route",
32+
"//adev/src/app/routing/navigation-entries",
33+
"//adev/src/app/routing/navigation-entries:navigation_types",
34+
"//adev/src/content/reference/errors:route-nav-items",
35+
"//adev/src/content/reference/extended-diagnostics:route-nav-items",
36+
"//adev/src/content/tutorials/deferrable-views",
37+
"//adev/src/content/tutorials/first-app",
38+
"//adev/src/content/tutorials/learn-angular",
39+
"//adev/src/content/tutorials/signal-forms",
40+
"//adev/src/content/tutorials/signals",
2841
] + glob(
2942
["src/**/*"],
3043
exclude = ["src/**/*.spec.ts"],

adev/scripts/routes/BUILD.bazel

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
load("@aspect_rules_ts//ts:defs.bzl", "ts_project")
2+
load("//adev/shared-docs:defaults.bzl", "js_binary", "js_run_binary")
3+
4+
package(default_visibility = ["//adev:__subpackages__"])
5+
6+
ts_project(
7+
name = "generate_routes_lib",
8+
srcs = [
9+
"generate-routes.mts",
10+
],
11+
data = [
12+
"//adev/src/assets:docs_api_manifest",
13+
"//adev/src/content/tutorials/deferrable-views",
14+
"//adev/src/content/tutorials/first-app",
15+
"//adev/src/content/tutorials/learn-angular",
16+
"//adev/src/content/tutorials/signal-forms",
17+
"//adev/src/content/tutorials/signals",
18+
],
19+
tsconfig = {
20+
"compilerOptions": {
21+
"target": "ES2022",
22+
"moduleResolution": "node",
23+
"types": ["node"],
24+
"rootDir": ".",
25+
"skipLibCheck": True,
26+
},
27+
},
28+
deps = [
29+
"//adev:node_modules/@angular/docs",
30+
"//adev:node_modules/@types/node",
31+
"//adev/src/app/routing/navigation-entries",
32+
"//adev/src/content/reference/errors:route-nav-items",
33+
"//adev/src/content/reference/extended-diagnostics:route-nav-items",
34+
],
35+
)
36+
37+
js_binary(
38+
name = "generate_route",
39+
data = [
40+
"generate-routes.mjs",
41+
":generate_routes_lib",
42+
"//adev/src/content:guide_files",
43+
],
44+
entry_point = ":generate-routes.mjs",
45+
)
46+
47+
js_run_binary(
48+
name = "run_generate_route",
49+
outs = ["defined-routes.json"],
50+
chdir = package_name(),
51+
tool = ":generate_route",
52+
)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*!
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {ALL_ITEMS} from '../../src/app/routing/navigation-entries';
10+
import {NavigationItem} from '@angular/docs';
11+
import {writeFileSync, readFileSync} from 'fs';
12+
import {join, resolve} from 'path';
13+
14+
const outputFile = 'defined-routes.json';
15+
const contentRoot = resolve(process.cwd(), '../../src/content');
16+
17+
/**
18+
* The scripts generates a list of all defined routes in the guides section and stores them
19+
* in a JSON file. This file then used by other bazel targets to know which routes are valid.
20+
*/
21+
22+
function extractRoutes(items: NavigationItem[]): string[] {
23+
const routes: string[] = [];
24+
for (const item of items) {
25+
if (item.path && !item.path.startsWith('http')) {
26+
routes.push(item.path);
27+
if (item.contentPath) {
28+
const content = readFileSync(join(contentRoot, `${item.contentPath}.md`), {
29+
encoding: 'utf-8',
30+
});
31+
const headings = extractHeadings(content);
32+
routes.push(
33+
...headings.map(
34+
(heading) => `${item.path}#${heading.toLowerCase().replace(/\s+/g, '-')}`,
35+
),
36+
);
37+
}
38+
}
39+
if (item.children) {
40+
routes.push(...extractRoutes(item.children));
41+
}
42+
}
43+
return routes;
44+
}
45+
46+
function extractHeadings(content: string): string[] {
47+
const headings = content
48+
.split('\n')
49+
// Top level heading (H1) are used for the page title only
50+
// and yes, headings can have leading spaces
51+
.filter((line) => line.trim().startsWith('##'))
52+
.map((line) => line.replace(/^#+\s*/, '').trim());
53+
54+
const stepRegex = /<docs-step[^>]*title="([^"]*)"/g;
55+
let match;
56+
while ((match = stepRegex.exec(content)) !== null) {
57+
headings.push(match[1]);
58+
}
59+
60+
return headings.map((heading: string) => getIdFromHeading(heading));
61+
}
62+
63+
function main() {
64+
const allRoutes: string[] = [];
65+
66+
allRoutes.push(...extractRoutes(ALL_ITEMS));
67+
68+
const uniqueRoutes = Array.from(new Set(allRoutes.filter((r) => !!r)));
69+
70+
console.warn('Generated routes:', JSON.stringify(uniqueRoutes, null, 2));
71+
writeFileSync(outputFile, JSON.stringify(uniqueRoutes, null, 2));
72+
}
73+
74+
main();
75+
76+
// TODO: refactor so this function is shared with the generation pipeline (adev/shared-docs/pipeline/shared/marked/transformations/heading.mts)
77+
function getIdFromHeading(heading: string): string {
78+
return heading
79+
.toLowerCase()
80+
.replace(/\s|\//g, '-') // replace spaces and slashes with dashes
81+
.replace(/[^\p{L}\d\-]/gu, ''); // only keep letters, digits & dashes
82+
}

adev/shared-docs/index.bzl

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@ load("//adev/shared-docs/pipeline:_previews.bzl", _generate_previews = "generate
55
load("//adev/shared-docs/pipeline:_stackblitz.bzl", _generate_stackblitz = "generate_stackblitz")
66
load("//adev/shared-docs/pipeline:_tutorial.bzl", _generate_tutorial = "generate_tutorial")
77

8-
generate_guides = _generate_guides
98
generate_stackblitz = _generate_stackblitz
109
generate_previews = _generate_previews
1110
generate_playground = _generate_playground
1211
generate_tutorial = _generate_tutorial
1312
generate_nav_items = _generate_nav_items
13+
14+
def generate_guides(**kwargs):
15+
_generate_guides(
16+
api_manifest = "//adev/src/assets:docs_api_manifest",
17+
defined_routes = "//adev/scripts/routes:defined-routes.json",
18+
**kwargs
19+
)

adev/shared-docs/pipeline/_guides.bzl

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ def _generate_guides(ctx):
2222
else:
2323
args.add("")
2424

25+
if ctx.attr.defined_routes:
26+
args.add(ctx.file.defined_routes.path)
27+
else:
28+
args.add("")
29+
2530
# Determine the set of html output files. For each input markdown file, produce an html
2631
# file with the same name (replacing the markdown extension with `.html`).
2732
html_outputs = []
@@ -39,6 +44,8 @@ def _generate_guides(ctx):
3944
inputs = ctx.files.srcs + ctx.files.data
4045
if ctx.attr.api_manifest:
4146
inputs.append(ctx.file.api_manifest)
47+
if ctx.attr.defined_routes:
48+
inputs.append(ctx.file.defined_routes)
4249

4350
if (ctx.attr.mermaid_blocks):
4451
ctx.actions.run(
@@ -85,6 +92,10 @@ generate_guides = rule(
8592
doc = """A file containing API entries to be used in the markdown.""",
8693
allow_single_file = True,
8794
),
95+
"defined_routes": attr.label(
96+
doc = """List of defined routes.""",
97+
allow_single_file = [".json"],
98+
),
8899
"mermaid_blocks": attr.bool(
89100
doc = """Whether to transform mermaid blocks.""",
90101
default = False,

adev/shared-docs/pipeline/api-gen/rendering/transforms/jsdoc-transforms.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ function getHtmlForJsDocText(text: string): string {
109109
const parsed = parseMarkdown(mdToParse, {
110110
apiEntries: getSymbolsAsApiEntries(),
111111
highlighter: getHighlighterInstance(),
112+
definedRoutes: [],
112113
});
113114
return addApiLinksToHtml(parsed);
114115
}

adev/shared-docs/pipeline/guides/index.mts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
import {readFile, writeFile} from 'fs/promises';
1010
import path from 'path';
11-
import {initHighlighter} from '../shared/shiki.mjs';
1211
import {parseMarkdownAsync} from '../shared/marked/parse.mjs';
12+
import {initHighlighter} from '../shared/shiki.mjs';
1313
import {hasUnknownAnchors} from './helpers.mjs';
1414

1515
type ApiManifest = ApiManifestPackage[];
@@ -21,33 +21,45 @@ interface ApiManifestPackage {
2121
async function main() {
2222
const [paramFilePath] = process.argv.slice(2);
2323
const rawParamLines = (await readFile(paramFilePath, {encoding: 'utf8'})).split('\n');
24-
const [srcs, outputFilenameExecRootRelativePath, apiManifestPath] = rawParamLines;
24+
const [srcs, outputFilenameExecRootRelativePath, apiManifestPath, definedRoutesAsStr] =
25+
rawParamLines;
2526

2627
// The highlighter needs to be setup asynchronously
2728
// so we're doing it at the start of the pipeline
2829
const highlighter = await initHighlighter();
2930

31+
let apiManifest: ApiManifest = [];
32+
if (!apiManifestPath) {
33+
throw new Error(
34+
'No API manifest path provided to the markdown parser. Check the failing generate_guides target.',
35+
);
36+
}
37+
38+
const apiManifestStr = await readFile(apiManifestPath, {encoding: 'utf8'});
39+
apiManifest = JSON.parse(apiManifestStr);
40+
41+
let definedRoutes: string[] = [];
42+
if (!definedRoutesAsStr) {
43+
throw new Error(
44+
'No defined routes path provided to the markdown parser. Check the failing generate_guides target.',
45+
);
46+
}
47+
48+
const definedGuideRoutes = await readFile(definedRoutesAsStr, {encoding: 'utf8'});
49+
definedRoutes = JSON.parse(definedGuideRoutes) as string[];
50+
3051
await Promise.all(
3152
srcs.split(',').map(async (filePath) => {
3253
if (!filePath.endsWith('.md')) {
3354
throw new Error(`Input file "${filePath}" does not end in a ".md" file extension.`);
3455
}
3556

36-
let apiManifest: ApiManifest = [];
37-
if (apiManifestPath) {
38-
try {
39-
const apiManifestStr = await readFile(apiManifestPath, {encoding: 'utf8'});
40-
apiManifest = JSON.parse(apiManifestStr);
41-
} catch (error) {
42-
console.warn('Failed to load API entries:', error);
43-
}
44-
}
45-
4657
const markdownContent = await readFile(filePath, {encoding: 'utf8'});
4758
const htmlOutputContent = await parseMarkdownAsync(markdownContent, {
4859
markdownFilePath: filePath,
4960
apiEntries: mapManifestToEntries(apiManifest),
5061
highlighter,
62+
definedRoutes,
5163
});
5264

5365
// The expected file name structure is the [name of the file].md.html.

adev/shared-docs/pipeline/shared/marked/extensions/docs-pill/docs-pill.mts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {Token, Tokens, RendererThis, TokenizerThis} from 'marked';
1010
import {anchorTarget, isExternalLink} from '../../helpers.mjs';
11+
import {AdevDocsRenderer} from '../../renderer.mjs';
1112

1213
interface DocsPillToken extends Tokens.Generic {
1314
type: 'docs-pill';
@@ -59,6 +60,14 @@ export const docsPillExtension = {
5960
renderer(this: RendererThis, token: DocsPillToken) {
6061
const downloadAttr = token.download ? ` download="${token.download}"` : '';
6162
const targetAttr = token.target ? ` target="${token.target}"` : anchorTarget(token.href);
63+
const renderer = this.parser.renderer as AdevDocsRenderer;
64+
65+
if (!renderer.isKnownRoute(token.href)) {
66+
throw new Error(
67+
`Link target "${token.href}" is invalid in <docs-pill> in ${renderer.context.markdownFilePath} does not exist in the defined guide routes.`,
68+
);
69+
}
70+
6271
return `
6372
<a class="docs-pill" href="${token.href}"${targetAttr}${downloadAttr}>
6473
${this.parser.parseInline(token.tokens)}${

0 commit comments

Comments
 (0)