Skip to content

Commit 0d12fc0

Browse files
authored
fix: repair API docs and playground assets
Closes #467 and closes #468.
1 parent fd9a0c5 commit 0d12fc0

23 files changed

Lines changed: 915 additions & 38 deletions

crates/ox_content_docs/src/extractor/items.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,12 @@ impl<'a> DocVisitor<'a> {
7474
oxc_ast::ast::MethodDefinitionKind::Method => DocItemKind::Method,
7575
};
7676

77-
let (method_jsdoc, method_doc, method_tags) = self
77+
let (method_jsdoc, method_doc, mut method_tags) = self
7878
.extract_jsdoc(method.span.start)
7979
.map_or((None, None, Vec::new()), |(jsdoc, doc, tags)| {
8080
(Some(jsdoc), (!doc.is_empty()).then_some(doc), tags)
8181
});
82+
Self::apply_ts_private_accessibility(method.accessibility, &mut method_tags);
8283
if self.should_skip_by_visibility(&method_tags) {
8384
continue;
8485
}
@@ -129,11 +130,12 @@ impl<'a> DocVisitor<'a> {
129130
_ => continue,
130131
};
131132

132-
let (prop_jsdoc, prop_doc, prop_tags) = self
133+
let (prop_jsdoc, prop_doc, mut prop_tags) = self
133134
.extract_jsdoc(prop.span.start)
134135
.map_or((None, None, Vec::new()), |(jsdoc, doc, tags)| {
135136
(Some(jsdoc), (!doc.is_empty()).then_some(doc), tags)
136137
});
138+
Self::apply_ts_private_accessibility(prop.accessibility, &mut prop_tags);
137139
if self.should_skip_by_visibility(&prop_tags) {
138140
continue;
139141
}

crates/ox_content_docs/src/extractor/tags.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ impl<'a> DocVisitor<'a> {
1818
|| (!self.include_internal && Self::has_internal_tag(tags))
1919
}
2020

21+
/// Folds a TypeScript `private` accessibility modifier into the tag list,
22+
/// so class members declared `private` flow through the same visibility
23+
/// filtering (and `private` output flag) as members tagged `@private`.
24+
pub(super) fn apply_ts_private_accessibility(
25+
accessibility: Option<oxc_ast::ast::TSAccessibility>,
26+
tags: &mut Vec<DocTag>,
27+
) {
28+
if matches!(accessibility, Some(oxc_ast::ast::TSAccessibility::Private))
29+
&& !Self::has_private_tag(tags)
30+
{
31+
tags.push(DocTag::new("private".to_string(), String::new()));
32+
}
33+
}
34+
2135
fn split_leading_jsdoc_type(value: &str) -> (Option<String>, &str) {
2236
let value = value.trim_start();
2337
let Some(rest) = value.strip_prefix('{') else {

crates/ox_content_docs/src/extractor/tests/member_shapes.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,32 @@ export class DefaultTranslation implements TranslationAdapter {
142142
Some("export class DefaultTranslation implements TranslationAdapter")
143143
);
144144
}
145+
146+
#[test]
147+
fn ts_private_class_members_are_filtered_like_private_tags() {
148+
let source = r"
149+
/** A counter. */
150+
export class Counter {
151+
private count: number = 0;
152+
/** Documented, still private. */
153+
private readonly step: number = 1;
154+
private bump(): void {}
155+
/** Public API. */
156+
increment(): number { return 0; }
157+
}
158+
";
159+
160+
let public_only =
161+
DocExtractor::new().extract_source(source, "counter.ts", SourceType::ts()).unwrap();
162+
let counter = public_only.iter().find(|item| item.name == "Counter").unwrap();
163+
let names: Vec<&str> = counter.children.iter().map(|child| child.name.as_str()).collect();
164+
assert_eq!(names, vec!["increment"]);
165+
166+
let with_private = DocExtractor::with_visibility(true, false)
167+
.extract_source(source, "counter.ts", SourceType::ts())
168+
.unwrap();
169+
let counter = with_private.iter().find(|item| item.name == "Counter").unwrap();
170+
assert_eq!(counter.children.len(), 4);
171+
let count = counter.children.iter().find(|child| child.name == "count").unwrap();
172+
assert!(count.tags.iter().any(|tag| tag.tag == "private"));
173+
}

crates/ox_content_parser/src/parser/tests.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ fn test_parse_image() {
2323
assert_eq!(img.alt, "Alt text");
2424
assert_eq!(img.url, "/path/to/image.png");
2525
}
26-
_ => panic!("expected image, got {:?}", &p.children[0]),
26+
_ => panic!("expected image, got {:?}", p.children[0]),
2727
}
2828
}
2929
_ => panic!("expected paragraph"),
@@ -59,7 +59,7 @@ fn indented_heading_like_text_does_not_loop() {
5959
assert!(
6060
matches!(&doc.children[0], Node::Paragraph(_)),
6161
"expected leading-indented `#` to parse as paragraph text, got {:?}",
62-
&doc.children[0]
62+
doc.children[0]
6363
);
6464
}
6565

@@ -168,7 +168,7 @@ fn test_parse_table() {
168168
Node::Table(t) => {
169169
assert_eq!(t.children.len(), 2); // header + 1 body row
170170
}
171-
_ => panic!("expected table, got {:?}", &doc.children[0]),
171+
_ => panic!("expected table, got {:?}", doc.children[0]),
172172
}
173173
}
174174

@@ -184,7 +184,7 @@ fn test_parse_unordered_list() {
184184
assert!(!list.ordered);
185185
assert_eq!(list.children.len(), 3);
186186
}
187-
_ => panic!("expected list, got {:?}", &doc.children[0]),
187+
_ => panic!("expected list, got {:?}", doc.children[0]),
188188
}
189189
}
190190

@@ -200,7 +200,7 @@ fn test_parse_ordered_list() {
200200
assert!(list.ordered);
201201
assert_eq!(list.children.len(), 3);
202202
}
203-
_ => panic!("expected list, got {:?}", &doc.children[0]),
203+
_ => panic!("expected list, got {:?}", doc.children[0]),
204204
}
205205
}
206206

@@ -214,7 +214,7 @@ fn test_parse_block_quote() {
214214
assert_eq!(bq.children.len(), 1);
215215
assert!(matches!(&bq.children[0], Node::Paragraph(_)));
216216
}
217-
_ => panic!("expected block quote, got {:?}", &doc.children[0]),
217+
_ => panic!("expected block quote, got {:?}", doc.children[0]),
218218
}
219219
}
220220

@@ -227,7 +227,7 @@ fn test_parse_block_quote_multiline() {
227227
Node::BlockQuote(bq) => {
228228
assert_eq!(bq.children.len(), 1);
229229
}
230-
_ => panic!("expected block quote, got {:?}", &doc.children[0]),
230+
_ => panic!("expected block quote, got {:?}", doc.children[0]),
231231
}
232232
}
233233

@@ -241,6 +241,6 @@ fn test_parse_nested_block_quote() {
241241
assert_eq!(bq.children.len(), 1);
242242
assert!(matches!(&bq.children[0], Node::BlockQuote(_)));
243243
}
244-
_ => panic!("expected block quote, got {:?}", &doc.children[0]),
244+
_ => panic!("expected block quote, got {:?}", doc.children[0]),
245245
}
246246
}

crates/ox_content_renderer/src/html/renderer/links.rs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ impl HtmlRenderer {
7272
continue;
7373
};
7474
let value = &html[value_start..value_end];
75-
if let Some(rewritten) = self.apply_base_to_root_absolute_url(value) {
75+
// Raw anchors link pages the same way Markdown links
76+
// do; convert .md targets first, then fall back to
77+
// rebasing root-absolute URLs.
78+
let rewritten = self.convert_markdown_url(value);
79+
if let Some(rewritten) = rewritten {
7680
output.push_str(&html[i..value_start]);
7781
output.push_str(&rewritten);
7882
i = value_end;
@@ -132,6 +136,9 @@ impl HtmlRenderer {
132136
let base = &self.options.base_url;
133137
if path_without_slash.is_empty() || path_without_slash == "index" {
134138
join2(base, "index.html")
139+
} else if let Some(dir) = path_without_slash.strip_suffix("/index") {
140+
// /lib/index.md names the lib/ directory page
141+
join3(base, dir, "/index.html")
135142
} else {
136143
join3(base, path_without_slash, "/index.html")
137144
}
@@ -141,6 +148,13 @@ impl HtmlRenderer {
141148
if name == "index" {
142149
// ./index.md -> ./index.html (stay in same directory)
143150
"./index.html".to_string()
151+
} else if let Some(dir) = name.strip_suffix("/index") {
152+
// ./lib/index.md names the lib/ directory page
153+
if source_is_index {
154+
join3("./", dir, "/index.html")
155+
} else {
156+
join3("../", dir, "/index.html")
157+
}
144158
} else if source_is_index {
145159
// Source is index.md, so we're at directory level
146160
// ./types.md -> ./types/index.html
@@ -157,8 +171,9 @@ impl HtmlRenderer {
157171
if source_is_index {
158172
// Source is index.md at directory level
159173
// ../types.md -> ../types/index.html
160-
if rest == "index" || rest.ends_with("/index") {
161-
let dir = rest.trim_end_matches("/index").trim_end_matches("index");
174+
if let Some(dir) =
175+
rest.strip_suffix("/index").or_else(|| (rest == "index").then_some(""))
176+
{
162177
if dir.is_empty() {
163178
"../index.html".to_string()
164179
} else {
@@ -170,8 +185,9 @@ impl HtmlRenderer {
170185
} else {
171186
// Source is not index.md, need extra ../
172187
// ../types.md -> ../../types/index.html
173-
if rest == "index" || rest.ends_with("/index") {
174-
let dir = rest.trim_end_matches("/index").trim_end_matches("index");
188+
if let Some(dir) =
189+
rest.strip_suffix("/index").or_else(|| (rest == "index").then_some(""))
190+
{
175191
if dir.is_empty() {
176192
"../../index.html".to_string()
177193
} else {
@@ -183,8 +199,10 @@ impl HtmlRenderer {
183199
}
184200
} else {
185201
// Plain relative path: types.md
186-
if path_without_ext == "index" || path_without_ext.ends_with("/index") {
187-
let dir = path_without_ext.trim_end_matches("/index").trim_end_matches("index");
202+
if let Some(dir) = path_without_ext
203+
.strip_suffix("/index")
204+
.or_else(|| (path_without_ext == "index").then_some(""))
205+
{
188206
if dir.is_empty() {
189207
"./index.html".to_string()
190208
} else if source_is_index {

crates/ox_content_renderer/src/html/tests/links.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,74 @@ fn test_convert_md_link_parent_relative_from_non_index() {
9494
let html = renderer.render(&doc);
9595
insta::assert_snapshot!(html);
9696
}
97+
98+
#[test]
99+
fn test_convert_md_link_to_child_index_file() {
100+
// A link to a directory's index page (./lib/index.md) names the directory
101+
// page itself — it must become ./lib/index.html, never ./lib/index/index.html
102+
// (a page that does not exist in the output tree). Same for absolute links.
103+
let allocator = Allocator::new();
104+
let doc =
105+
Parser::new(&allocator, "[Lib](./lib/index.md) [Abs](/lib/index.md)").parse().unwrap();
106+
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
107+
convert_md_links: true,
108+
base_url: "/".to_string(),
109+
source_path: "api/index.md".to_string(),
110+
..Default::default()
111+
});
112+
let html = renderer.render(&doc);
113+
insta::assert_snapshot!(html);
114+
}
115+
116+
#[test]
117+
fn test_convert_md_link_to_sibling_dir_index_from_non_index() {
118+
// From a non-index page, ./lib/index.md resolves one level up like every
119+
// other ./ link, then collapses the index segment.
120+
let allocator = Allocator::new();
121+
let doc = Parser::new(&allocator, "[Lib](./lib/index.md)").parse().unwrap();
122+
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
123+
convert_md_links: true,
124+
base_url: "/".to_string(),
125+
source_path: "api/types.md".to_string(),
126+
..Default::default()
127+
});
128+
let html = renderer.render(&doc);
129+
insta::assert_snapshot!(html);
130+
}
131+
132+
#[test]
133+
fn test_convert_md_link_removes_only_the_final_index_segment() {
134+
let allocator = Allocator::new();
135+
let doc = Parser::new(&allocator, "[Plain](a/index/index.md) [Parent](../a/index/index.md)")
136+
.parse()
137+
.unwrap();
138+
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
139+
convert_md_links: true,
140+
base_url: "/".to_string(),
141+
source_path: "api/types.md".to_string(),
142+
..Default::default()
143+
});
144+
let html = renderer.render(&doc);
145+
insta::assert_snapshot!(html);
146+
}
147+
148+
#[test]
149+
fn test_convert_md_href_inside_raw_html_anchor() {
150+
// The docs generator emits raw <a href="X.md"> anchors alongside Markdown
151+
// links; both must be converted or the raw ones 404 in the output tree.
152+
let allocator = Allocator::new();
153+
let doc = Parser::new(
154+
&allocator,
155+
"<a class=\"x\" href=\"../type-aliases/CounterOptions.md\">CounterOptions</a>",
156+
)
157+
.parse()
158+
.unwrap();
159+
let mut renderer = HtmlRenderer::with_options(HtmlRendererOptions {
160+
convert_md_links: true,
161+
base_url: "/".to_string(),
162+
source_path: "lib/functions/createCounter.md".to_string(),
163+
..Default::default()
164+
});
165+
let html = renderer.render(&doc);
166+
insta::assert_snapshot!(html);
167+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: crates/ox_content_renderer/src/html/tests/links.rs
3+
assertion_line: 149
4+
expression: html
5+
---
6+
<p><a class="x" href="../../type-aliases/CounterOptions/index.html">CounterOptions</a></p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
source: crates/ox_content_renderer/src/html/tests/links.rs
3+
expression: html
4+
---
5+
<p><a href="../a/index/index.html">Plain</a> <a href="../../a/index/index.html">Parent</a></p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: crates/ox_content_renderer/src/html/tests/links.rs
3+
assertion_line: 112
4+
expression: html
5+
---
6+
<p><a href="./lib/index.html">Lib</a> <a href="/lib/index.html">Abs</a></p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: crates/ox_content_renderer/src/html/tests/links.rs
3+
assertion_line: 128
4+
expression: html
5+
---
6+
<p><a href="../lib/index.html">Lib</a></p>

0 commit comments

Comments
 (0)