Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- When `rw confluence render --out -` cannot parse the current page's XHTML piped on stdin, it no longer prints an error log line ahead of the XHTML on stdout, where it ended up in the published page. rw now writes all logs, including `rw serve --verbose`, to stderr.
- A PlantUML `!include` now expands only on its own line. Before, rw also expanded its path inside a longer one (`a.iuml` within `!include a.iuml.bak`) or a commented-out `' !include`, and could drop a repeated include's indentation.
- A `[[target\|text]]` wikilink, the form a GFM table needs, no longer leaves a `\` on the target, which broke the link.
- A list item holding only a link reference definition, such as `- [r]: http://x`, followed by an indented blank line no longer crashes rendering its page in `rw serve` and `@rwdocs/core`, or aborts `rw backstage publish` and `rw confluence render`. rw still prints a harmless `pulldown-cmark` panic message for such a page.
- A list item containing only a link reference definition followed by an indented blank line no longer crashes rw or prints a panic message.
- In the viewer, a relative link to something other than a `.md` file, such as `[x](sibling)` or `[x](../api/)`, now opens the page relative to the current one, as the address bar shows. Before, the viewer loaded that path from the site root, and the embedded viewer passed the unresolved link to the host.
- An embedded viewer mounted without `resolveSectionRefs` now opens a page's links into the mounted section under its `basePath`. Before, it handed the host's `onNavigate` the bare site path, such as `/api`, outside the docs.
- An embedded viewer based at `/x/docs` now hands a link to `/x/docs-v2/page` to the host's `onNavigate` instead of opening a broken `/-v2/page` itself.
Expand Down Expand Up @@ -254,6 +254,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `rw serve` now answers only requests addressed to `localhost`, a `*.localhost` name, or an IP address, so a website you visit can no longer read your docs or post comments through DNS rebinding. To use another name, such as `mylaptop.local` or a cloud IDE address, add it with `--allowed-host` or `[server].allowed_hosts`. See [Allowed hosts](docs/configuration.md#allowed-hosts).
- A project-root `README.md` that is a symlink out of the project, such as to `/etc/hosts`, is no longer the homepage. Before, `rw serve` and `@rwdocs/core` served the target file, and `rw backstage publish` uploaded it. A `README.md` linked to a file inside the project still works. See [README.md as Homepage](docs/configuration.md#readmemd-as-homepage).
- PlantUML `!include` now reads only files inside `include_dirs`. rw refuses an absolute path such as `!include /etc/hosts`, or a path leading outside through `..` or a symlink, with a warning. A symlink's target must be in an include directory: one inside the project, or one passed to `rw confluence render -I`. Before, it inlined the file into diagrams sent to Kroki and into `rw backstage publish` bundles. See [PlantUML Includes](docs/diagrams.md#plantuml-includes).
- Documents containing repeated wikilinks with empty labels, such as `[[page|]]`, no longer make rw repeat the following content exponentially, potentially exhausting memory. Comment rendering is unaffected.

## [0.1.36] - 2026-09-07

Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ hex = "0.4"
ignore = "0.4"
md-5 = "0.11"
pretty_assertions = "1.4"
pulldown-cmark = { version = "0.13", default-features = false, features = ["simd"] }
pulldown-cmark = { version = "0.13.5", default-features = false, features = ["simd"] }
rayon = "1.10"
regex = "1.11"
serde_json = "1"
Expand Down
172 changes: 0 additions & 172 deletions crates/rw-parser/src/cmark_events.rs

This file was deleted.

1 change: 0 additions & 1 deletion crates/rw-parser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@

mod alert;
mod attrs;
mod cmark_events;
mod directive;
mod event;
mod frontmatter;
Expand Down
67 changes: 50 additions & 17 deletions crates/rw-parser/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ use std::iter::{Fuse, Peekable};
use std::ops::Range;

use pulldown_cmark as cmark;
use pulldown_cmark::{CodeBlockKind, CowStr, HeadingLevel, Options};
use pulldown_cmark::{CodeBlockKind, CowStr, HeadingLevel, OffsetIter, Options};

use crate::alert::AlertKind;
use crate::attrs::{Attrs, parse_fence_info};
use crate::cmark_events::CmarkEvents;
use crate::directive::DirectiveArgs;
use crate::directive::line::{
ContainerLine, Escapes, InlineMatch, has_text_after_name, parse_container_line,
Expand Down Expand Up @@ -113,7 +112,7 @@ impl From<InlineMatch> for RunSegment {
reason = "independent flags, not the states of one machine"
)]
pub struct Parser<'a> {
inner: Peekable<Fuse<CmarkEvents<'a>>>,
inner: Peekable<Fuse<OffsetIter<'a>>>,
source: &'a str,
/// Source offset just past the last character judged escaped.
escaped_until: usize,
Expand Down Expand Up @@ -218,7 +217,8 @@ impl<'a> Parser<'a> {
pub fn new(markdown: &'a str, wikilinks: bool, directives: bool) -> Self {
let (frontmatter, markdown) = frontmatter::split(markdown);
Self {
inner: CmarkEvents::new(markdown, cmark_options(wikilinks))
inner: cmark::Parser::new_ext(markdown, cmark_options(wikilinks))
.into_offset_iter()
.fuse()
.peekable(),
source: markdown,
Expand Down Expand Up @@ -1291,7 +1291,7 @@ impl<'a> Parser<'a> {
}

#[cfg(test)]
pub(crate) mod tests {
mod tests {
use super::*;
use crate::event::{Event, Tag, TagEnd};

Expand All @@ -1310,7 +1310,7 @@ pub(crate) mod tests {

/// Collect the whole stream, owning nothing borrowed — `next` lends, so
/// each event must be consumed before the next call.
pub(crate) fn debug_stream(markdown: &str) -> Vec<String> {
fn debug_stream(markdown: &str) -> Vec<String> {
let mut parser = Parser::new(markdown, false, true);
let mut out = Vec::new();
while let Some(event) = parser.next() {
Expand Down Expand Up @@ -1427,6 +1427,39 @@ pub(crate) mod tests {
);
}

#[test]
fn empty_wikilink_labels_do_not_repeat_following_content() {
for repetitions in [1, 4, 8, 12] {
let markdown = format!("{}tail", "[[a|]] ".repeat(repetitions));
for directives in [false, true] {
let mut parser = Parser::new(&markdown, true, directives);
let mut events = 0;
let mut links = 0;
let mut ends = 0;
let mut text = String::new();
while let Some(event) = parser.next() {
events += 1;
// Bound the failure before a regressed parser expands the rest.
assert!(events <= 4 * repetitions + 4, "{markdown:?}");
match event {
Event::Start(Tag::Link {
kind: LinkKind::Wiki { has_pothole: true },
dest_url,
}) => {
assert_eq!(dest_url.as_ref(), "a");
links += 1;
}
Event::End(TagEnd::Link) => ends += 1,
Event::Text(value) => text.push_str(&value),
_ => {}
}
}
assert_eq!((links, ends), (repetitions, repetitions));
assert_eq!(text, format!("{}tail", " ".repeat(repetitions)));
}
}
}

#[test]
fn an_escape_opening_a_wikilink_display_is_decoded() {
for (md, text) in [
Expand Down Expand Up @@ -2490,18 +2523,18 @@ pub(crate) mod tests {
}

#[test]
fn a_paragraph_cmark_opens_off_content_is_not_lone() {
// After a link reference definition, cmark opens an empty paragraph
// at the end of the source, or one at a tab.
for md in [
"[a]: y\n\t",
"[a]: y\n \t",
"[a]: y\r\n\t",
"x\n\n[a]: y\n\t",
] {
assert!(lone_images(md).iter().all(Option::is_none), "{md:?}");
fn reference_definitions_and_blank_lines_do_not_create_paragraphs() {
for md in ["[a]: y\n\t", "[a]: y\n \t", "[a]: y\r\n\t"] {
assert_eq!(lone_images(md), [], "{md:?}");
}
assert_eq!(lone_images("x\n\n[a]: y\n\t"), [None]);
}

#[test]
fn an_image_after_a_reference_definition_and_blank_line_is_lone() {
for md in ["[a]: y\n\t\n![b](x.puml)", "[a]: y\r\n\t\r\n![b](x.puml)"] {
assert_eq!(lone_images(md), lone("x.puml"), "{md:?}");
}
assert_eq!(lone_images("[a]: y\n\t\n![b](x.puml)"), [None]);
}

#[test]
Expand Down
Loading
Loading