Skip to content

Commit 0703f57

Browse files
committed
docs: apply the site base to the safety manual's links
Astro doesn't apply `base` to markdown links, so writing them from the site root broke the deployed build, which serves the manual under a versioned path: link validation rejected all 13 as pointing nowhere. Add the base in a remark plugin instead, ahead of the validator's rehype pass, and let every source keep writing links from the site root.
1 parent 3528a9b commit 0703f57

4 files changed

Lines changed: 69 additions & 61 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright © SixtyFPS GmbH <info@slint.dev>
2+
// SPDX-License-Identifier: MIT
3+
4+
// Prefix the site base to the internal links of a page. Astro doesn't apply
5+
// `base` to markdown links, so a site served under a path would need every
6+
// link to spell that path out. Sources instead write links from the site root
7+
// -- `/language/properties/` -- and this adds the base, in one place, at build
8+
// time. That keeps the sources free of the deployment layout, which the
9+
// release job rewrites per version.
10+
//
11+
// It matters for more than the output: starlight-links-validator resolves a
12+
// link against the deployed path, so an unprefixed link fails validation as a
13+
// link to an unknown page. Remark runs before the validator's rehype pass, so
14+
// what gets checked is the prefixed link.
15+
16+
/** Rewrite the `url` of every link and link definition in the tree. */
17+
function walk(node, fn) {
18+
if (node.type === "link" || node.type === "definition") {
19+
fn(node);
20+
}
21+
for (const child of node.children ?? []) {
22+
walk(child, fn);
23+
}
24+
}
25+
26+
export default function remarkBaseLinks({ base = "/" } = {}) {
27+
const prefix = base.replace(/\/+$/, "");
28+
// Served at the root, so the links already read as they should.
29+
if (prefix === "") {
30+
return () => {};
31+
}
32+
return (tree) => {
33+
walk(tree, (node) => {
34+
// A protocol-relative URL also starts with a slash and leaves the
35+
// site, so it isn't ours to rewrite.
36+
if (node.url?.startsWith("/") && !node.url.startsWith("//")) {
37+
node.url = `${prefix}${node.url}`;
38+
}
39+
});
40+
};
41+
}

docs/safety/astro.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
SAFETY_DOCS_BASE_PATH,
1616
} from "./src/safety-site-config.mjs";
1717
import rehypeSlsIds from "@slint/common-files/src/utils/rehype-sls-ids.mjs";
18+
import remarkBaseLinks from "@slint/common-files/src/utils/remark-base-links.mjs";
1819

1920
const _safetyOrigin = String(SAFETY_DOCS_BASE_URL).replace(/\/+$/, "");
2021
const _safetyAtRoot = SAFETY_DOCS_BASE_PATH === "/";
@@ -33,6 +34,7 @@ export default defineConfig({
3334
markdown: {
3435
// Only SC-covered content reaches this site's generated reference, so
3536
// every paragraph of it carries a traceability id.
37+
remarkPlugins: [[remarkBaseLinks, { base: _safetyBase ?? "/" }]],
3638
rehypePlugins: [
3739
rehypeExternalLinksSlint,
3840
[rehypeSlsIds, { referenceRequiresIds: true }],

docs/safety/scripts/sync-language-spec.mjs

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,6 @@
1717
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1818
import { dirname, join } from "node:path";
1919
import { fileURLToPath } from "node:url";
20-
import { SAFETY_DOCS_BASE_PATH } from "../src/safety-site-config.mjs";
21-
22-
// The path this site is served under, with a trailing `/`.
23-
const BASE = `/${SAFETY_DOCS_BASE_PATH.replace(/^\/+|\/+$/g, "")}/`.replace("//", "/");
2420

2521
// Links that leave the specification directory: canonical (docs/astro) form
2622
// on the left, safety-manual form on the right.
@@ -42,11 +38,12 @@ function stripNotInSC(content) {
4238
return content.replace(/<NotInSC>[\s\S]*?<\/NotInSC>\n?/g, "");
4339
}
4440

45-
// Rewrite the markdown links of a page served at `pageUrl` from the site base,
46-
// so that starlight-links-validator checks them. Resolving against the page's
47-
// own URL keeps the sources readable: they stay relative, and only the copies
48-
// this script writes carry the site's layout.
49-
function linksFromBase(content, pageUrl) {
41+
// Rewrite the markdown links of a page served at `pageUrl` from the site root,
42+
// so that starlight-links-validator checks them: it skips relative links
43+
// rather than resolving them. Resolving against the page's own URL keeps the
44+
// sources readable, since they stay relative. remark-base-links.mjs adds the
45+
// base at build time.
46+
function linksFromRoot(content, pageUrl) {
5047
return content.replace(/\]\((\.\.?\/[^)]*)\)/g, (_, url) => {
5148
const resolved = new URL(url, `https://slint.dev${pageUrl}`);
5249
return `](${resolved.pathname}${resolved.hash})`;
@@ -109,7 +106,7 @@ for (const entry of readdirSync(source)) {
109106
for (const [from, to] of LINK_MAP) {
110107
content = content.replaceAll(from, to);
111108
}
112-
content = linksFromBase(stripNotInSC(content), pageUrl(`${BASE}language/`, entry));
109+
content = linksFromRoot(stripNotInSC(content), pageUrl("/language/", entry));
113110
const targetFile = join(target, entry);
114111
if (!existsSync(targetFile) || readFileSync(targetFile, "utf-8") !== content) {
115112
writeFileSync(targetFile, content);
@@ -130,10 +127,7 @@ syncDir(
130127
join(here, "../src/content/docs/reference/property-types"),
131128
(content) => !isNotInSC(content),
132129
(content, entry) =>
133-
linksFromBase(
134-
stripNotInSC(content),
135-
pageUrl(`${BASE}reference/property-types/`, entry),
136-
),
130+
linksFromRoot(stripNotInSC(content), pageUrl("/reference/property-types/", entry)),
137131
);
138132

139133
console.log(`Synced language specification from ${source}`);

docs/slint-doc-generator/traceability.rs

Lines changed: 18 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,17 @@ const SAFETY_DOCS_EXCLUDE: &[&str] = &["generated", "language"];
5353
/// [`Config::qualification_plan_dir`], the section it belongs to.
5454
const MATRIX_FILE: &str = "traceability-matrix.mdx";
5555

56-
/// Where the safety manual declares the path it is served under. The matrix
57-
/// links from that base rather than relatively, because
58-
/// starlight-links-validator skips relative links: a relative one here would
59-
/// go unchecked, and could rot into a dead anchor unnoticed.
60-
const SAFETY_SITE_CONFIG: &str = "docs/safety/src/safety-site-config.mjs";
61-
6256
const REPO_URL: &str = env!("CARGO_PKG_REPOSITORY");
6357

6458
struct SpecPage {
6559
/// Repository-relative path with `/` separators, for error messages.
6660
file: String,
6761
/// From the frontmatter.
6862
title: String,
69-
/// URL of the page from the site base, for linking its anchors from the
70-
/// matrix. See [`SAFETY_SITE_CONFIG`] for why it isn't relative.
63+
/// URL of the page from the site root, for linking its anchors from the
64+
/// matrix. Not relative, because starlight-links-validator skips relative
65+
/// links: one here would go unchecked and could rot into a dead anchor.
66+
/// remark-base-links.mjs adds the site base at build time.
7167
base: String,
7268
/// The specification index heads its section; every other page nests
7369
/// under one.
@@ -104,11 +100,10 @@ impl TestRef {
104100

105101
pub fn generate(cfg: &Config) -> Result<(), Box<dyn std::error::Error>> {
106102
let root = crate::root_dir();
107-
let base = safety_base(&root)?;
108-
let mut spec_pages = scan_spec_pages(&root.join(SPEC_DIR), &base)?;
109-
spec_pages.extend(scan_property_type_pages(&root, &base)?);
110-
let reference_pages = scan_reference_pages(cfg, &root, &base)?;
111-
let safety_pages = scan_safety_pages(&root, &base)?;
103+
let mut spec_pages = scan_spec_pages(&root.join(SPEC_DIR))?;
104+
spec_pages.extend(scan_property_type_pages(&root)?);
105+
let reference_pages = scan_reference_pages(cfg, &root)?;
106+
let safety_pages = scan_safety_pages(&root)?;
112107

113108
let mut refs = Vec::new();
114109
for kind in TEST_ROOTS {
@@ -132,7 +127,7 @@ pub fn generate(cfg: &Config) -> Result<(), Box<dyn std::error::Error>> {
132127
}
133128
}
134129

135-
write_matrix(cfg, &root, &base, &spec_pages, &reference_pages, &safety_pages, &tests_by_id)
130+
write_matrix(cfg, &root, &spec_pages, &reference_pages, &safety_pages, &tests_by_id)
136131
}
137132

138133
/// Validate the parsed pages and test references, returning one message per
@@ -266,19 +261,6 @@ fn parse_spec_page(file: &str, text: &str) -> (SpecPage, Option<String>) {
266261
(page, slug)
267262
}
268263

269-
/// The path the safety manual is served under, with leading and trailing `/`.
270-
fn safety_base(repo_root: &Path) -> Result<String, Box<dyn std::error::Error>> {
271-
let path = repo_root.join(SAFETY_SITE_CONFIG);
272-
let text = std::fs::read_to_string(&path).context(format!("error reading {path:?}"))?;
273-
let value = text
274-
.lines()
275-
.find_map(|line| line.trim().strip_prefix("export const SAFETY_DOCS_BASE_PATH ="))
276-
.map(|value| value.trim().trim_end_matches(';').trim().trim_matches('"'))
277-
.ok_or_else(|| anyhow::anyhow!("{SAFETY_SITE_CONFIG}: no SAFETY_DOCS_BASE_PATH"))?;
278-
let trimmed = value.trim_matches('/');
279-
Ok(if trimmed.is_empty() { "/".to_string() } else { format!("/{trimmed}/") })
280-
}
281-
282264
/// Repository-relative path with `/` separators.
283265
fn repo_relative(path: &Path, repo_root: &Path) -> String {
284266
let relative = path.strip_prefix(repo_root).unwrap_or(path).to_string_lossy().into_owned();
@@ -289,7 +271,7 @@ fn repo_relative(path: &Path, repo_root: &Path) -> String {
289271
}
290272
}
291273

292-
fn scan_spec_pages(dir: &Path, base: &str) -> Result<Vec<SpecPage>, Box<dyn std::error::Error>> {
274+
fn scan_spec_pages(dir: &Path) -> Result<Vec<SpecPage>, Box<dyn std::error::Error>> {
293275
let mut paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
294276
.context(format!("error reading {dir:?}"))?
295277
.filter_map(|e| Some(e.ok()?.path()))
@@ -311,11 +293,8 @@ fn scan_spec_pages(dir: &Path, base: &str) -> Result<Vec<SpecPage>, Box<dyn std:
311293
}
312294
// The index page is served at the root of the specification.
313295
page.top_level = stem == "index";
314-
page.base = if page.top_level {
315-
format!("{base}language/")
316-
} else {
317-
format!("{base}language/{stem}/")
318-
};
296+
page.base =
297+
if page.top_level { format!("/language/") } else { format!("/language/{stem}/") };
319298
if !page.draft {
320299
pages.push(page);
321300
}
@@ -327,10 +306,7 @@ fn scan_spec_pages(dir: &Path, base: &str) -> Result<Vec<SpecPage>, Box<dyn std:
327306
/// their anchors. Pages marked `notInSC: true` cover the full language only, so
328307
/// they carry no requirements; the rest are served in the safety manual under
329308
/// `reference/property-types/`.
330-
fn scan_property_type_pages(
331-
root: &Path,
332-
base: &str,
333-
) -> Result<Vec<SpecPage>, Box<dyn std::error::Error>> {
309+
fn scan_property_type_pages(root: &Path) -> Result<Vec<SpecPage>, Box<dyn std::error::Error>> {
334310
let dir = root.join(PROPERTY_TYPES_DIR);
335311
let mut paths: Vec<std::path::PathBuf> = std::fs::read_dir(&dir)
336312
.context(format!("error reading {dir:?}"))?
@@ -348,7 +324,7 @@ fn scan_property_type_pages(
348324
if page.not_in_sc || page.anchors.is_empty() || page.draft {
349325
continue;
350326
}
351-
page.base = format!("{base}reference/property-types/{stem}/");
327+
page.base = format!("/reference/property-types/{stem}/");
352328
pages.push(page);
353329
}
354330
Ok(pages)
@@ -359,7 +335,6 @@ fn scan_property_type_pages(
359335
fn scan_reference_pages(
360336
cfg: &Config,
361337
repo_root: &Path,
362-
base: &str,
363338
) -> Result<Vec<SpecPage>, Box<dyn std::error::Error>> {
364339
let mut pages = Vec::new();
365340
for entry in walkdir::WalkDir::new(cfg.reference_dir()).sort_by_file_name() {
@@ -379,7 +354,7 @@ fn scan_reference_pages(
379354
// couldn't link to the anchors it just found.
380355
let slug = slug
381356
.ok_or_else(|| anyhow::anyhow!("{file}: generated page carries anchors but no slug"))?;
382-
page.base = format!("{base}{slug}/");
357+
page.base = format!("/{slug}/");
383358
pages.push(page);
384359
}
385360
Ok(pages)
@@ -394,10 +369,7 @@ fn safety_page_slug(relative: &str) -> &str {
394369
}
395370

396371
/// Parse the handwritten safety-manual pages for their anchors.
397-
fn scan_safety_pages(
398-
repo_root: &Path,
399-
base: &str,
400-
) -> Result<Vec<SpecPage>, Box<dyn std::error::Error>> {
372+
fn scan_safety_pages(repo_root: &Path) -> Result<Vec<SpecPage>, Box<dyn std::error::Error>> {
401373
let dir = repo_root.join(SAFETY_DOCS_DIR);
402374
let mut pages = Vec::new();
403375
for entry in walkdir::WalkDir::new(&dir)
@@ -428,7 +400,7 @@ fn scan_safety_pages(
428400
}
429401
let relative = repo_relative(path, &dir);
430402
let slug = slug.unwrap_or_else(|| safety_page_slug(&relative).to_string());
431-
page.base = format!("{base}{slug}/");
403+
page.base = format!("/{slug}/");
432404
pages.push(page);
433405
}
434406
Ok(pages)
@@ -484,7 +456,6 @@ fn git_head(repo_root: &Path) -> String {
484456
fn write_matrix(
485457
cfg: &Config,
486458
repo_root: &Path,
487-
base: &str,
488459
spec_pages: &[SpecPage],
489460
reference_pages: &[SpecPage],
490461
safety_pages: &[SpecPage],
@@ -512,7 +483,7 @@ description: Mapping between the requirement paragraphs of the Language Specific
512483
slug: qualification-plan/traceability-matrix
513484
---
514485
515-
Each requirement paragraph in the [Language Specification]({base}language/), the [SC API Reference]({base}reference/), and the other chapters of this manual carries a unique identifier,
486+
Each requirement paragraph in the [Language Specification](/language/), the [SC API Reference](/reference/), and the other chapters of this manual carries a unique identifier,
516487
shown as a `[sls.…]` badge at the end of the paragraph.
517488
A test case declares which requirements it verifies by listing their identifiers in `//#sls.…` comments.
518489
This matrix lists every requirement paragraph with the test cases that declare it.

0 commit comments

Comments
 (0)