Skip to content

Commit 292762e

Browse files
afflomclaude
andcommitted
Website: expose the full concept catalog on the concepts page
The website /concepts/ page listed only the 12 curated pillar concepts; the 33 comprehensive reference concepts authored under docs/content/concepts/ were not surfaced there. The concept catalog is now merged from both content roots (the website's own curated set wins on slug overlap, keeping its hand-tuned relations, SVG hooks, and reading path), so every authored concept gets a /concepts/<slug>.html page and a card in the index — 39 unique concepts. - ConceptPage records its source markdown path; concept_page_list merges multiple content roots and dedupes by slug (earlier root wins). - render_concept_from_file retargets the docs-relative links some imported concepts author (../namespaces/<x>.html, ../guides/<x>.html) to absolute docs URLs, since those targets live only in the docs tree. Verified: 39 concept pages render with real content, no unresolved cross-reference directives, 0 broken internal links across 17,930 checked, conformance 546/0, fmt + website tests pass. No version change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fa48a83 commit 292762e

3 files changed

Lines changed: 98 additions & 46 deletions

File tree

website/src/concepts.rs

Lines changed: 72 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! `{@ns prefix}`, `{@concept slug}`, and `{@count:KEY}` directives that expand
99
//! to Markdown links pointing at website namespace pages.
1010
11-
use std::path::Path;
11+
use std::path::{Path, PathBuf};
1212

1313
use anyhow::{Context, Result};
1414
use uor_ontology::Ontology;
@@ -98,53 +98,64 @@ pub fn concepts_for_namespace(prefix: &str) -> Vec<&'static str> {
9898
/// # Errors
9999
///
100100
/// Returns an error if the concepts directory cannot be read.
101-
pub fn concept_page_list(content_dir: &Path, base_path: &str) -> Result<Vec<ConceptPage>> {
102-
let concepts_dir = content_dir.join("concepts");
103-
if !concepts_dir.exists() {
104-
return Ok(Vec::new());
105-
}
106-
107-
let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(&concepts_dir)
108-
.with_context(|| format!("Failed to read {}", concepts_dir.display()))?
109-
.filter_map(|e| e.ok())
110-
.filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false))
111-
// prism.md is merged into the pipeline page — skip it as a standalone concept
112-
.filter(|e| {
113-
e.path()
114-
.file_stem()
115-
.and_then(|s| s.to_str())
116-
.map(|s| s != "prism")
117-
.unwrap_or(true)
118-
})
119-
.collect();
120-
121-
entries.sort_by_key(|e| e.file_name());
122-
123-
entries
124-
.iter()
125-
.map(|entry| {
126-
let slug = entry
127-
.path()
128-
.file_stem()
129-
.and_then(|s| s.to_str())
130-
.unwrap_or("")
131-
.to_string();
132-
let raw = std::fs::read_to_string(entry.path())
133-
.with_context(|| format!("Failed to read {}", entry.path().display()))?;
101+
pub fn concept_page_list(concept_dirs: &[PathBuf], base_path: &str) -> Result<Vec<ConceptPage>> {
102+
// Merge every concept-content root into one catalog. Earlier roots win on
103+
// slug conflict, so the website's own curated page is preferred over the
104+
// docs reference page for the six overlapping concepts. Every concept the
105+
// project authors — in any root — therefore gets a `/concepts/<slug>.html`
106+
// page and a card in the concepts index.
107+
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
108+
let mut pages: Vec<ConceptPage> = Vec::new();
109+
110+
for dir in concept_dirs {
111+
if !dir.exists() {
112+
continue;
113+
}
114+
let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(dir)
115+
.with_context(|| format!("Failed to read {}", dir.display()))?
116+
.filter_map(|e| e.ok())
117+
.filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false))
118+
// prism.md is merged into the pipeline page — skip it as a standalone concept
119+
.filter(|e| {
120+
e.path()
121+
.file_stem()
122+
.and_then(|s| s.to_str())
123+
.map(|s| s != "prism")
124+
.unwrap_or(true)
125+
})
126+
.collect();
127+
entries.sort_by_key(|e| e.file_name());
128+
129+
for entry in &entries {
130+
let path = entry.path();
131+
let slug = match path.file_stem().and_then(|s| s.to_str()) {
132+
Some(s) => s.to_string(),
133+
None => continue,
134+
};
135+
if !seen.insert(slug.clone()) {
136+
continue; // already provided by an earlier (higher-priority) root
137+
}
138+
let raw = std::fs::read_to_string(&path)
139+
.with_context(|| format!("Failed to read {}", path.display()))?;
134140
let title = first_h1(&raw).unwrap_or_else(|| slug.replace('-', " "));
135141
let description = first_paragraph(&raw)
136142
.map(|d| strip_directives_to_plain_text(&d))
137143
.unwrap_or_default();
138144
let space = infer_space(&slug);
139-
Ok(ConceptPage {
145+
pages.push(ConceptPage {
140146
url: format!("{base_path}/concepts/{slug}.html"),
141147
slug,
142148
title,
143149
description,
144150
space,
145-
})
146-
})
147-
.collect()
151+
source: path,
152+
});
153+
}
154+
}
155+
156+
// Deterministic, alphabetical catalog order for the concept grid.
157+
pages.sort_by(|a, b| a.slug.cmp(&b.slug));
158+
Ok(pages)
148159
}
149160

150161
// ── Rendering ───────────────────────────────────────────────────────────────
@@ -166,7 +177,29 @@ pub fn render_concept_from_file(
166177
let raw = std::fs::read_to_string(path)
167178
.with_context(|| format!("Failed to read concept file {}", path.display()))?;
168179
let expanded = expand_directives(&raw, ontology, concept_list, base_path);
169-
Ok(markdown_to_html(&expanded))
180+
let html = markdown_to_html(&expanded);
181+
Ok(retarget_docs_relative_links(&html, base_path))
182+
}
183+
184+
/// Retargets the docs-relative links some concept sources author
185+
/// (`../namespaces/<x>.html`, `../guides/<x>.html`) to absolute docs URLs.
186+
///
187+
/// Concept pages are rendered into `/concepts/` on the website, but a concept
188+
/// imported from `docs/content/concepts/` may link to its docs siblings using
189+
/// docs-relative paths that only resolve under `/docs/concepts/`. Those targets
190+
/// (per-namespace reference pages, how-to guides) live only in the docs tree,
191+
/// so the links are rewritten to point there. The website's own concepts use
192+
/// `{@ns}`/`{@class}` directives and `../pipeline/` (a real website path), none
193+
/// of which match these patterns, so they are left untouched.
194+
fn retarget_docs_relative_links(html: &str, base_path: &str) -> String {
195+
html.replace(
196+
"href=\"../namespaces/",
197+
&format!("href=\"{base_path}/docs/namespaces/"),
198+
)
199+
.replace(
200+
"href=\"../guides/",
201+
&format!("href=\"{base_path}/docs/guides/"),
202+
)
170203
}
171204

172205
/// Converts CommonMark markdown to HTML.

website/src/lib.rs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,20 @@ pub fn generate(out_dir: &Path) -> Result<()> {
9999
let content_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("content");
100100

101101
// Discover concept pages early (needed by about page, concept rendering, and
102-
// pipeline narrative directive expansion)
103-
let concept_list = concepts::concept_page_list(&content_dir, base_path)?;
102+
// pipeline narrative directive expansion). The catalog is merged from the
103+
// website's own curated concepts plus the comprehensive reference set under
104+
// `docs/content/concepts/`, so the concepts index exposes EVERY authored
105+
// concept — not just the dozen pillar pages. The website root wins on slug
106+
// overlap, keeping its hand-tuned page (relations, SVG hooks, reading path).
107+
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
108+
.parent()
109+
.map(Path::to_path_buf)
110+
.unwrap_or_default();
111+
let concept_dirs = [
112+
content_dir.join("concepts"),
113+
workspace_root.join("docs").join("content").join("concepts"),
114+
];
115+
let concept_list = concepts::concept_page_list(&concept_dirs, base_path)?;
104116

105117
// Track all pages for sitemap
106118
let mut sitemap_paths: Vec<String> = Vec::new();
@@ -292,11 +304,12 @@ pub fn generate(out_dir: &Path) -> Result<()> {
292304
sitemap_paths.push("/concepts/".to_string());
293305

294306
for concept in &concept_list {
295-
let content_path = content_dir
296-
.join("concepts")
297-
.join(format!("{}.md", concept.slug));
298-
let content_html =
299-
concepts::render_concept_from_file(&content_path, ontology, &concept_list, base_path)?;
307+
let content_html = concepts::render_concept_from_file(
308+
&concept.source,
309+
ontology,
310+
&concept_list,
311+
base_path,
312+
)?;
300313
let extra_svg = pipeline::CONCEPT_SVG_HOOKS
301314
.iter()
302315
.find(|(slug, _)| *slug == concept.slug.as_str())

website/src/model.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ pub struct ConceptPage {
8282
pub url: String,
8383
/// Space color coding: `"kernel"`, `"bridge"`, `"user"`, or `"cert"`.
8484
pub space: String,
85+
/// Absolute path to the source markdown file this page renders from. The
86+
/// concept catalog is merged from several content roots (the website's own
87+
/// `content/concepts/` plus the comprehensive `docs/content/concepts/`), so
88+
/// the source is recorded per page rather than reconstructed from the slug.
89+
#[serde(skip)]
90+
pub source: std::path::PathBuf,
8591
}
8692

8793
/// An `op:Identity` individual for the identities browser.

0 commit comments

Comments
 (0)