diff --git a/crates/nuwiki-core/tests/tags.rs b/crates/nuwiki-core/tests/tags.rs
index f006bb9..83afb16 100644
--- a/crates/nuwiki-core/tests/tags.rs
+++ b/crates/nuwiki-core/tests/tags.rs
@@ -190,6 +190,7 @@ fn file_tags_are_deduped_in_metadata() {
fn renderer_emits_div_with_tag_spans() {
let html = render(":todo:done:\n");
assert!(html.contains("
"));
+ // Bare tag name as the id (vimwiki parity), so `[[Page#todo]]` resolves.
assert!(html.contains("
todo"));
assert!(html.contains("
done"));
}
diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs
index a8f8049..73c0cb6 100644
--- a/crates/nuwiki-lsp/src/export.rs
+++ b/crates/nuwiki-lsp/src/export.rs
@@ -227,17 +227,32 @@ pub fn render_page_html(
}
let mut r = HtmlRenderer::new();
- if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) {
+ {
+ // Same-page anchor links (`[[#Section]]`) resolve to the *current*
+ // page's html file plus the fragment (`index.html#Section`), matching
+ // vimwiki — rather than a bare `#Section`.
+ let current_html = format!("{}.html", name.rsplit('/').next().unwrap_or(name));
// Strip the wiki extension from page links before the default resolver
// turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather
// than `todo.wiki.html`. Only wiki/interwiki targets are touched;
// file:/local: paths keep their literal extension.
- let ext = ext.to_string();
+ let ext = wiki_extension
+ .filter(|e| !e.trim_start_matches('.').is_empty())
+ .map(|e| e.to_string());
r = r.with_link_resolver(move |target| {
+ if matches!(target.kind, LinkKind::AnchorOnly) {
+ return match &target.anchor {
+ Some(a) => format!("{current_html}#{a}"),
+ None => current_html.clone(),
+ };
+ }
let mut t = target.clone();
- if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
- if let Some(p) = t.path.take() {
- t.path = Some(crate::index::strip_wiki_extension(&p, Some(&ext)).to_string());
+ if let Some(ext) = &ext {
+ if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
+ if let Some(p) = t.path.take() {
+ t.path =
+ Some(crate::index::strip_wiki_extension(&p, Some(ext)).to_string());
+ }
}
}
nuwiki_core::render::html::default_link_resolver(&t)
@@ -382,14 +397,195 @@ pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
cfg.html_path.join(&cfg.css_name)
}
-/// Minimal default CSS. Used by `nuwiki.export.*` commands when no CSS
-/// file exists yet at [`css_path`]. Deliberately tiny — users are
-/// expected to replace it with their own.
-pub const DEFAULT_CSS: &str =
- "body{font-family:sans-serif;max-width:46em;margin:2em auto;padding:0 1em;line-height:1.55}\
-h1,h2,h3,h4,h5,h6{font-family:serif;line-height:1.2}\
-pre{background:#f4f4f4;padding:.5em;overflow:auto}\
-code{background:#f4f4f4;padding:.1em .25em;border-radius:3px}\
-table{border-collapse:collapse}\
-table td,table th{border:1px solid #ccc;padding:.25em .5em}\
-ul.toc{padding-left:1.5em}\n";
+/// Default CSS written by `nuwiki.export.*` commands when no CSS file exists
+/// yet at [`css_path`]. This is vimwiki's stock `style.css` verbatim (MIT
+/// licensed) so exports look identical to upstream out of the box and the
+/// `.header` / `.tag` / `.toc` / `done*` classes our HTML emits are styled.
+pub const DEFAULT_CSS: &str = r#"body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;;
+ margin: 2em 4em 2em 4em;
+ font-size: 120%;
+ line-height: 130%;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ font-weight: bold;
+ line-height:100%;
+ margin-top: 1.5em;
+ margin-bottom: 0.5em;
+}
+
+h1 {font-size: 2em; color: #000000;}
+h2 {font-size: 1.8em; color: #404040;}
+h3 {font-size: 1.6em; color: #707070;}
+h4 {font-size: 1.4em; color: #909090;}
+h5 {font-size: 1.2em; color: #989898;}
+h6 {font-size: 1em; color: #9c9c9c;}
+
+p, pre, blockquote, table, ul, ol, dl {
+ margin-top: 1em;
+ margin-bottom: 1em;
+}
+
+ul ul, ul ol, ol ol, ol ul {
+ margin-top: 0.5em;
+ margin-bottom: 0.5em;
+}
+
+li { margin: 0.3em auto; }
+
+ul {
+ margin-left: 2em;
+ padding-left: 0;
+}
+
+dt { font-weight: bold; }
+
+img { border: none; }
+
+pre {
+ border-left: 5px solid #dcdcdc;
+ background-color: #f5f5f5;
+ padding-left: 1em;
+ font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
+ font-size: 0.8em;
+ border-radius: 6px;
+}
+
+p > a {
+ color: white;
+ text-decoration: none;
+ font-size: 0.7em;
+ padding: 3px 6px;
+ border-radius: 3px;
+ background-color: #1e90ff;
+ text-transform: uppercase;
+ font-weight: bold;
+}
+
+p > a:hover {
+ color: #dcdcdc;
+ background-color: #484848;
+}
+
+li > a {
+ color: #1e90ff;
+ font-weight: bold;
+ text-decoration: none;
+}
+
+li > a:hover { color: #ff4500; }
+
+blockquote {
+ color: #686868;
+ font-size: 0.8em;
+ line-height: 120%;
+ padding: 0.8em;
+ border-left: 5px solid #dcdcdc;
+}
+
+th, td {
+ border: 1px solid #ccc;
+ padding: 0.3em;
+}
+
+th { background-color: #f0f0f0; }
+
+hr {
+ border: none;
+ border-top: 1px solid #ccc;
+ width: 100%;
+}
+
+del {
+ text-decoration: line-through;
+ color: #777777;
+}
+
+.toc li { list-style-type: none; }
+
+.todo {
+ font-weight: bold;
+ background-color: #ff4500 ;
+ color: white;
+ font-size: 0.8em;
+ padding: 3px 6px;
+ border-radius: 3px;
+}
+
+.justleft { text-align: left; }
+.justright { text-align: right; }
+.justcenter { text-align: center; }
+
+.center {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.tag {
+ background-color: #eeeeee;
+ font-family: monospace;
+ padding: 2px;
+}
+
+.header a {
+ text-decoration: none;
+ color: inherit;
+}
+
+/* classes for items of todo lists */
+
+.rejected {
+ /* list-style: none; */
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAACXBIWXMAAADFAAAAxQEdzbqoAAAAB3RJTUUH4QgEFhAtuWgv9wAAAPZQTFRFmpqam5iYnJaWnJeXnpSUn5OTopCQpoqKpouLp4iIqIiIrYCAt3V1vW1tv2xsmZmZmpeXnpKS/x4e/x8f/yAg/yIi/yQk/yUl/yYm/ygo/ykp/yws/zAw/zIy/zMz/zQ0/zU1/zY2/zw8/0BA/0ZG/0pK/1FR/1JS/1NT/1RU/1VV/1ZW/1dX/1pa/15e/19f/2Zm/2lp/21t/25u/3R0/3p6/4CA/4GB/4SE/4iI/46O/4+P/52d/6am/6ur/66u/7Oz/7S0/7e3/87O/9fX/9zc/93d/+Dg/+vr/+3t/+/v//Dw//Ly//X1//f3//n5//z8////gzaKowAAAA90Uk5T/Pz8/Pz8/Pz8/Pz8/f39ppQKWQAAAAFiS0dEEnu8bAAAAACuSURBVAhbPY9ZF4FQFEZPSKbIMmWep4gMGTKLkIv6/3/GPbfF97b3w17rA0kQOPgvAeHW6uJ6+5h7HqLdwowgOzejXRXBdx6UdSquml4xuOMBHHNU0clTzeSUA6EhF8V8kqroluMiU6HKcuf4phGPr1o2q9kYZWwNq1qfRRmTaXpqsyjj17KkWCxKBUBgXWueHIyiAIg18gsse4KHkLF5IKIY10WQgv7fOy4ST34BRiopZ8WLNrgAAAAASUVORK5CYII=);
+ background-repeat: no-repeat;
+ background-position: 0 .2em;
+ padding-left: 1.5em;
+}
+.done0 {
+ /* list-style: none; */
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAA7SURBVCiR7dMxEgAgCANBI3yVRzF5KxNbW6wsuH7LQ2YKQK1mkswBVERYF5Os3UV3gwd/jF2SkXy66gAZkxS6BniubAAAAABJRU5ErkJggg==);
+ background-repeat: no-repeat;
+ background-position: 0 .2em;
+ padding-left: 1.5em;
+}
+.done1 {
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABtSURBVCiR1ZO7DYAwDER9BDmTeZQMFXmUbGYpOjrEryA0wOvO8itOslFrJYAug5BMM4BeSkmjsrv3aVTa8p48Xw1JSkSsWVUFwD05IqS1tmYzk5zzae9jnVVVzGyXb8sALjse+euRkEzu/uirFomVIdDGOLjuAAAAAElFTkSuQmCC);
+ background-repeat: no-repeat;
+ background-position: 0 .15em;
+ padding-left: 1.5em;
+}
+.done2 {
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAB1SURBVCiRzdO5DcAgDAVQGxjAYgTvxlDIu1FTIRYAp8qlFISkSH7l5kk+ZIwxKiI2mIyqWoeILYRgZ7GINDOLjnmF3VqklKCUMgTee2DmM661Qs55iI3Zm/1u5h9sm4ig9z4ERHTFzLyd4G4+nFlVrYg8+qoF/c0kdpeMsmcAAAAASUVORK5CYII=);
+ background-repeat: no-repeat;
+ background-position: 0 .15em;
+ padding-left: 1.5em;
+}
+.done3 {
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABoSURBVCiR7dOxDcAgDATA/0DtUdiKoZC3YhLkHjkVKF3idJHiztKfvrHZWnOSE8Fx95RJzlprimJVnXktvXeY2S0SEZRSAAAbmxnGGKH2I5T+8VfxPhIReQSuuY3XyYWa3T2p6quvOgGrvSFGlewuUAAAAABJRU5ErkJggg==);
+ background-repeat: no-repeat;
+ background-position: 0 .15em;
+ padding-left: 1.5em;
+}
+.done4 {
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAQCAYAAAAbBi9cAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAzgAAAM4BlP6ToAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIISURBVDiNnZQ9SFtRFMd/773kpTaGJoQk1im4VDpWQcTNODhkFBcVTCNCF0NWyeDiIIiCm82QoIMIUkHUxcFBg1SEQoZszSat6cdTn1qNue92CMbEr9Of5+vd8/3nPux/3O+f8h6ukUil3sVg0+M+4cFxk42/jH2wAqqqKSCSiPQdwcHHAnDHH9s/tN1h8V28ETdP+eU8fT9Nt62ancYdIPvJNtsu87bmjrJlrTDVM4RROJs1JrHPrD4Bar7A6cpc54iKOaTdJXCUI2UMVrQZ0Js7YPN18ECKkYNQcJe/OE/4dZsw7VqNXQMvHy3QZXQypQ6ycrtwDjf8aJ+PNEDSCzLpn7+m2pD8ZKHlKarYhy6XjEoCYGcN95qansQeA3fNdki+SaJZGTMQIOoL3W/Z89rxv+tokubNajlvk/vm+LFpF2XnUKZHI0I+QrI7Dw0OZTqdzUkpsM7mZTyfy5OPGyw1tK7AFSvmB/Ks8w8YwbUYbe6/3QEKv0vugfxWPnMLJun+d/kI/WLdizpNjMbAIKrhMF4OuwadBALqqs+RfInwUvuNi+fBd+wjogfogAFVRmffO02q01mZZ0HHdgXIzdz0QQLPezIQygX6llxNKKgOFARYCC49CqhoHIUTlss/Vx2phlYwjw8j1CAlfAiwQiJpiy7o1VHnsG5FISkoJu7Q/2YmmaV+i0ei7v38L2CBguSi5AAAAAElFTkSuQmCC);
+ background-repeat: no-repeat;
+ background-position: 0 .15em;
+ padding-left: 1.5em;
+}
+
+code {
+ font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
+ -webkit-border-radius: 1px;
+ -moz-border-radius: 1px;
+ border-radius: 1px;
+ -moz-background-clip: padding;
+ -webkit-background-clip: padding-box;
+ background-clip: padding-box;
+ padding: 0px 3px;
+ display: inline-block;
+ color: #52595d;
+ border: 1px solid #ccc;
+ background-color: #f9f9f9;
+}
+"#;
diff --git a/crates/nuwiki-lsp/tests/html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs
index 69446d1..4b4f35e 100644
--- a/crates/nuwiki-lsp/tests/html_export.rs
+++ b/crates/nuwiki-lsp/tests/html_export.rs
@@ -385,19 +385,27 @@ fn render_page_html_numbers_headers_when_enabled() {
)
.unwrap();
assert!(
- html.contains("
"),
+ html.contains(
+ "
"
+ ),
"got: {html}"
);
assert!(
- html.contains("
"),
+ html.contains(
+ "
"
+ ),
"got: {html}"
);
assert!(
- html.contains("
"),
+ html.contains(
+ "
"
+ ),
"got: {html}"
);
assert!(
- html.contains("
"),
+ html.contains(
+ "
"
+ ),
"got: {html}"
);
}
@@ -419,14 +427,45 @@ fn render_page_html_numbering_skips_headers_above_start_level() {
)
.unwrap();
assert!(
- html.contains("
"),
+ html.contains(
+ "
"
+ ),
"h1 unnumbered, got: {html}"
);
assert!(
- html.contains("
"),
+ html.contains(
+ "
"
+ ),
+ "got: {html}"
+ );
+ assert!(
+ html.contains(
+ "
"
+ ),
+ "got: {html}"
+ );
+}
+
+#[test]
+fn render_page_html_same_page_anchor_uses_current_filename() {
+ // vimwiki parity (#4): `[[#Section]]` resolves to `
.html#Section`,
+ // not a bare `#Section`, where is the file being rendered.
+ let ast = parse("= Contents =\n\n[[#Contents]]\n");
+ let c = cfg("/tmp/x");
+ let html = export::render_page_html(
+ &ast,
+ None,
+ "index",
+ DiaryDate::from_ymd(2026, 5, 11).unwrap(),
+ &c.html,
+ None,
+ HashMap::new(),
+ )
+ .unwrap();
+ assert!(
+ html.contains(""),
"got: {html}"
);
- assert!(html.contains(""), "got: {html}");
}
#[test]
@@ -443,8 +482,18 @@ fn render_page_html_no_header_numbering_by_default() {
HashMap::new(),
)
.unwrap();
- assert!(html.contains(""), "got: {html}");
- assert!(html.contains(""), "got: {html}");
+ assert!(
+ html.contains(
+ ""
+ ),
+ "got: {html}"
+ );
+ assert!(
+ html.contains(
+ ""
+ ),
+ "got: {html}"
+ );
}
#[test]