use std::collections::{BTreeMap, HashMap};
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{Result, anyhow};
use oxc::allocator::Allocator;
use oxc::codegen::Codegen;
use oxc::parser::Parser;
use oxc::semantic::SemanticBuilder;
use oxc::span::SourceType;
use oxc::transformer::{JsxRuntime, TransformOptions, Transformer};
use sha2::{Digest, Sha256};
use crate::addresses::{Owned, address};
use crate::document::Document;
pub const SYSTEM: &[(&str, &str)] = &[
("Callout", include_str!("../assets/widgets/Callout.tsx")),
(
SIDEBAR,
include_str!("../assets/widgets/DevpageSidebar.tsx"),
),
("Cards", include_str!("../assets/widgets/Cards.tsx")),
("Example", include_str!("../assets/widgets/Example.tsx")),
("FileTree", include_str!("../assets/widgets/FileTree.tsx")),
("Keys", include_str!("../assets/widgets/Keys.tsx")),
("Steps", include_str!("../assets/widgets/Steps.tsx")),
("Tab", include_str!("../assets/widgets/Tab.tsx")),
("Tabs", include_str!("../assets/widgets/Tabs.tsx")),
("Terminal", include_str!("../assets/widgets/Terminal.tsx")),
];
pub const SIDEBAR: &str = "DevpageSidebar";
pub const NOTES: &str = "Notes";
const SHOWS_SOURCE: &[&str] = &["Example"];
#[derive(Debug, Default)]
pub struct Widgets {
pub modules: BTreeMap<String, String>,
served: PathBuf,
files: BTreeMap<String, PathBuf>,
known: Vec<String>,
pub problems: Vec<String>,
}
impl Widgets {
pub fn of(document: &Document) -> Self {
let mut widgets = Self::default();
for (name, _) in SYSTEM {
widgets
.modules
.insert(name.to_string(), address(Owned::SystemWidget(name)));
}
widgets.served = document.served.clone();
widgets.known = widgets.modules.keys().cloned().collect();
let Some(folder) = &document.widgets else {
return widgets;
};
let Ok(entries) = std::fs::read_dir(folder) else {
return widgets;
};
let mut files: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
files.sort();
let mut own = Vec::new();
for file in files {
if file.extension().is_none_or(|ext| ext != "tsx") {
continue;
}
let name = file
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let url = document.url(&file);
if !name.starts_with(|c: char| c.is_ascii_uppercase()) {
continue;
}
if SYSTEM.iter().any(|(system, _)| *system == name) {
widgets.problems.push(format!(
"{} — {name} is a system widget's name",
url.trim_start_matches('/')
));
continue;
}
if name == NOTES {
widgets.problems.push(format!(
"{} — {NOTES} is devpage's tag for a slide's notes",
url.trim_start_matches('/')
));
continue;
}
own.push((name, file));
}
let known: Vec<String> = widgets
.modules
.keys()
.cloned()
.chain(own.iter().map(|(name, _)| name.clone()))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
for (name, file) in own {
let url = address(Owned::Module {
snapshot: &Snapshot::of(&document.served, &file),
file: &file,
known: &known,
});
widgets.modules.insert(name.clone(), url);
widgets.files.insert(name, file);
}
widgets.known = known;
widgets
}
pub fn head(&self, content: &str, chrome: &[&str]) -> String {
let modules = serde_json::to_string(&self.modules).unwrap_or_default();
let react = address(Owned::Asset("react.js"));
let (preloads, fetches) = self.load_set(content, chrome);
let mut links = String::new();
for module in preloads {
links.push_str(&format!("\n<link rel=\"modulepreload\" href=\"{module}\">"));
}
for fetch in fetches {
links.push_str(&format!(
"\n<link rel=\"preload\" href=\"{fetch}\" as=\"fetch\" crossorigin fetchpriority=\"low\">"
));
}
format!(
r#"<script type="importmap">{{"imports":{{"react":"{react}","react/jsx-runtime":"{react}","react-dom/client":"{react}"}}}}</script>
<script>window.devpageWidgets = {modules};</script>{links}"#
)
}
pub fn load_set(&self, content: &str, chrome: &[&str]) -> (Vec<String>, Vec<String>) {
let mut queue: Vec<String> = content
.split("data-widget=\"")
.skip(1)
.filter_map(|rest| rest.split('"').next())
.map(str::to_string)
.collect();
queue.extend(chrome.iter().map(|name| name.to_string()));
let mut seen = std::collections::BTreeSet::new();
let mut widgets = Vec::new();
let mut helpers = Vec::new();
let mut next = 0;
while let Some(name) = queue.get(next).cloned() {
next += 1;
if !self.modules.contains_key(&name) || !seen.insert(name.clone()) {
continue;
}
widgets.push(self.modules[&name].clone());
let compiled = match self.files.get(&name) {
Some(file) => {
let snapshot = Snapshot::of(&self.served, file);
for (helper, _) in snapshot.closure(file).into_iter().skip(1) {
helpers.push(address(Owned::Module {
snapshot: &snapshot,
file: &helper,
known: &self.known,
}));
}
snapshot.compile(file, &self.known)
}
None => compile_system(&name).unwrap_or_else(|| Err(anyhow!("no {name}"))),
};
queue.extend(compiled.map(|js| uses(&js)).unwrap_or_default());
}
let mut modules = Vec::new();
if !seen.is_empty() {
modules.push(address(Owned::Asset("react.js")));
}
modules.extend(widgets);
for helper in helpers {
if !modules.contains(&helper) {
modules.push(helper);
}
}
let mut fetches = Vec::new();
if content.contains("<canvas class=\"design\"") {
modules.push(address(Owned::Asset("design.js")));
modules.push(address(Owned::Asset("design-engine.js")));
fetches.push(address(Owned::Asset("design-engine.wasm")));
}
(modules, fetches)
}
}
#[derive(Clone, Debug)]
struct Read {
changed: Option<std::time::SystemTime>,
len: u64,
source: Arc<str>,
imports: Vec<(String, PathBuf)>,
specified: Vec<PathBuf>,
}
const IMPORT_EXTENSIONS: [&str; 3] = ["", ".tsx", ".ts"];
type ReadCache = Option<HashMap<PathBuf, Read>>;
static READ: Mutex<ReadCache> = Mutex::new(None);
fn read(file: &Path) -> Option<Read> {
let meta = std::fs::metadata(file).ok().filter(|m| m.is_file())?;
let (changed, len) = (meta.modified().ok(), meta.len());
if let Some(found) = READ.lock().ok().and_then(|cache| {
cache
.as_ref()?
.get(file)
.filter(|r| r.changed == changed && r.len == len)
.cloned()
}) {
return Some(found);
}
let source: Arc<str> = std::fs::read_to_string(file).ok()?.into();
let folder = file.parent().unwrap_or(file);
let specifiers = relative_imports(file, &source);
let specified = specifiers
.iter()
.map(|specifier| lexical(&folder.join(specifier)))
.collect();
let imports = specifiers
.into_iter()
.filter_map(|specifier| {
let base = folder.join(&specifier);
IMPORT_EXTENSIONS
.iter()
.map(|ext| PathBuf::from(format!("{}{ext}", base.display())))
.find(|path| path.is_file())
.and_then(|path| path.canonicalize().ok())
.map(|path| (specifier, path))
})
.collect();
let read = Read {
changed,
len,
source,
imports,
specified,
};
if let Ok(mut cache) = READ.lock() {
let cache = cache.get_or_insert_with(HashMap::new);
if cache.len() > 4096 {
cache.clear();
}
cache.insert(file.to_path_buf(), read.clone());
}
Some(read)
}
pub fn forget(file: &Path) {
let file = lexical(file);
if let Ok(mut cache) = READ.lock()
&& let Some(cache) = cache.as_mut()
{
cache.remove(&file);
cache.retain(|_, read| {
!read.specified.iter().any(|base| {
IMPORT_EXTENSIONS
.iter()
.any(|ext| file.as_os_str() == format!("{}{ext}", base.display()).as_str())
})
});
}
}
fn lexical(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
fn relative_imports(path: &Path, source: &str) -> Vec<String> {
use oxc::ast::ast::Statement;
let allocator = Allocator::default();
let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::tsx());
let parsed = Parser::new(&allocator, source, source_type).parse();
parsed
.program
.body
.iter()
.filter_map(|statement| match statement {
Statement::ImportDeclaration(decl) => Some(decl.source.value.as_str()),
Statement::ExportAllDeclaration(decl) => Some(decl.source.value.as_str()),
Statement::ExportFromDeclaration(decl) => Some(decl.source.value.as_str()),
_ => None,
})
.filter(|value| value.starts_with("./") || value.starts_with("../"))
.map(str::to_string)
.collect()
}
#[derive(Debug)]
pub struct Snapshot {
served: PathBuf,
reads: BTreeMap<PathBuf, Read>,
}
impl Snapshot {
pub fn of(served: &Path, file: &Path) -> Self {
let mut reads = BTreeMap::new();
let mut queue = vec![file.to_path_buf()];
while let Some(next) = queue.pop() {
if reads.contains_key(&next) || (next != file && !next.starts_with(served)) {
continue;
}
let Some(read) = read(&next) else { continue };
queue.extend(read.imports.iter().map(|(_, p)| p.clone()));
reads.insert(next, read);
}
Self {
served: served.to_path_buf(),
reads,
}
}
pub fn served(&self) -> &Path {
&self.served
}
pub fn closure(&self, file: &Path) -> Vec<(PathBuf, Arc<str>)> {
let Some(root) = self.reads.get(file) else {
return Vec::new();
};
let mut found: BTreeMap<PathBuf, Arc<str>> = BTreeMap::new();
let mut queue: Vec<PathBuf> = root.imports.iter().map(|(_, p)| p.clone()).collect();
while let Some(next) = queue.pop() {
if next == file || found.contains_key(&next) || !next.starts_with(&self.served) {
continue;
}
let Some(read) = self.reads.get(&next) else {
continue;
};
queue.extend(read.imports.iter().map(|(_, p)| p.clone()));
found.insert(next, read.source.clone());
}
std::iter::once((file.to_path_buf(), root.source.clone()))
.chain(found)
.collect()
}
pub fn hash(&self, file: &Path, known: &[String]) -> String {
let mut hash = Sha256::new();
hash.update(crate::addresses::BUILD);
hash.update(known.join("\0"));
for (path, source) in self.closure(file) {
hash.update([0]);
hash.update(
path.strip_prefix(&self.served)
.unwrap_or(&path)
.to_string_lossy()
.as_bytes(),
);
hash.update([0]);
hash.update(source.as_bytes());
}
crate::addresses::short(&hash.finalize())
}
pub fn compile(&self, file: &Path, known: &[String]) -> Result<String> {
let read = self
.reads
.get(file)
.ok_or_else(|| anyhow!("{} can't be read", file.display()))?;
let imports = read
.imports
.iter()
.filter(|(_, path)| path.starts_with(&self.served))
.map(|(specifier, path)| {
let url = address(Owned::Module {
snapshot: self,
file: path,
known,
});
(specifier.clone(), url)
})
.collect();
compile_cached(file, &read.source, known, &imports)
}
}
pub fn compile_module(served: &Path, file: &Path, known: &[String]) -> Result<String> {
Snapshot::of(served, file).compile(file, known)
}
pub fn is_widget_file(path: &Path, folder: &Path) -> bool {
path.parent() == Some(folder)
&& path
.extension()
.is_some_and(|ext| ext == "ts" || ext == "tsx")
}
pub fn expand(source: &str, widgets: &Widgets, problems: &mut Vec<String>) -> String {
expand_in(source, widgets, problems, None, 0)
}
fn expand_in(
source: &str,
widgets: &Widgets,
problems: &mut Vec<String>,
parent: Option<&str>,
lines_before: usize,
) -> String {
let code = code_ranges(source);
let in_code = |at: usize| code.iter().any(|r| r.contains(&at));
let bytes = source.as_bytes();
let mut out = String::with_capacity(source.len());
let mut at = 0;
while let Some(offset) = source[at..].find('<') {
let start = at + offset;
out.push_str(&source[at..start]);
at = start + 1;
let is_open = bytes.get(start + 1).is_some_and(|b| b.is_ascii_uppercase());
if !is_open || in_code(start) {
out.push('<');
continue;
}
let line = lines_before + source[..start].matches('\n').count() + 1;
let Some(tag) = parse_open(&source[start..]) else {
if let Some(len) = unreadable_notes(&source[start..]) {
problems.push(format!("{line}: {UNREADABLE_NOTES}"));
at = find_close(source, start + len, NOTES, &in_code)
.map_or(source.len(), |(_, end)| end);
continue;
}
out.push('<');
continue;
};
let after_open = start + tag.len;
let (inner, end, closed) = if tag.self_closing {
("", after_open, true)
} else {
match find_close(source, after_open, &tag.name, &in_code) {
Some((close_start, close_end)) => {
(&source[after_open..close_start], close_end, true)
}
None => ("", after_open, false),
}
};
let starts_line = source[..start]
.rsplit('\n')
.next()
.is_some_and(|before| before.trim().is_empty());
let ends_line = source[end..]
.split('\n')
.next()
.is_some_and(|after| after.trim().is_empty());
let block = starts_line && ends_line;
let element = if block { "div" } else { "span" };
if tag.name == NOTES {
if !closed {
problems.push(format!("{line}: a <{NOTES}> without its </{NOTES}>"));
}
if tag.has_prop("delivery") && tag.string_prop("delivery").is_none() {
problems.push(format!(
"{line}: a <{NOTES}> delivery must be a quoted string"
));
}
at = if closed { end } else { source.len() };
continue;
}
if !widgets.modules.contains_key(&tag.name) {
let message = format!("<{}> is not a widget", tag.name);
problems.push(format!("{line}: {message}"));
out.push_str(&format!(
r#"<{element} class="widget-error">{}</{element}>"#,
escape(&message)
));
at = end;
continue;
}
if tag.name == "Tab" && parent != Some("Tabs") {
problems.push(format!("{line}: <Tab> is outside a <Tabs>"));
}
let inner_lines = lines_before + source[..after_open].matches('\n').count();
let icons;
let inner = if tag.name == "Cards" {
icons = card_icons(inner, inner_lines, problems);
icons.as_str()
} else {
inner
};
let props = escape(&tag.props);
let source_html = if SHOWS_SOURCE.contains(&tag.name.as_str()) {
format!(
r#" data-widget-source-html="{}""#,
escape(&crate::highlight::highlight(
"markdown",
inner.trim_matches('\n')
))
)
} else {
String::new()
};
let open = format!(
r#"<{element} data-widget="{name}" data-props="{props}" data-widget-source="{src}"{source_html}>"#,
name = tag.name,
src = escape(inner.trim_matches('\n')),
);
let inner = expand_in(inner, widgets, problems, Some(&tag.name), inner_lines);
if block {
out.push_str(&format!(
"{open}\n\n{}\n\n</{element}>",
inner.trim_matches('\n')
));
} else {
out.push_str(&format!("{open}{inner}</{element}>"));
}
at = end;
}
out.push_str(&source[at..]);
out
}
#[derive(Debug, Clone, PartialEq)]
pub struct Note {
pub line: usize,
pub text: String,
pub delivery: Option<String>,
}
pub fn notes(source: &str) -> Vec<Note> {
let code = code_ranges(source);
let in_code = |at: usize| code.iter().any(|r| r.contains(&at));
let open = format!("<{NOTES}");
let mut found = Vec::new();
let mut at = 0;
while let Some(offset) = source[at..].find(&open) {
let start = at + offset;
at = start + 1;
if in_code(start) {
continue;
}
let Some(tag) = parse_open(&source[start..])
.filter(|tag| tag.name == NOTES)
.or_else(|| {
unreadable_notes(&source[start..]).map(|len| OpenTag {
name: NOTES.to_string(),
props: "{}".to_string(),
len,
self_closing: false,
})
})
else {
continue;
};
let after_open = start + tag.len;
let (inner, end) = if tag.self_closing {
("", after_open)
} else {
match find_close(source, after_open, NOTES, &in_code) {
Some((close_start, close_end)) => (&source[after_open..close_start], close_end),
None => (&source[after_open..], source.len()),
}
};
found.push(Note {
line: source[..start].matches('\n').count() + 1,
text: inner.trim().to_string(),
delivery: tag.string_prop("delivery"),
});
at = end;
}
found
}
fn card_icons(inner: &str, lines_before: usize, problems: &mut Vec<String>) -> String {
let mut out = String::with_capacity(inner.len());
for (i, line) in inner.split_inclusive('\n').enumerate() {
let body = line.trim_start();
let rest = body
.strip_prefix("- ")
.or_else(|| body.strip_prefix("* "))
.or_else(|| body.strip_prefix("+ "));
let icon = rest.and_then(|rest| {
let name = rest.strip_prefix('(')?.split_once(')')?.0;
let valid = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
valid.then_some(name)
});
let Some(name) = icon else {
out.push_str(line);
continue;
};
let indent = &line[..line.len() - body.len()];
let after = &body[2 + name.len() + 2..];
let drawn = match crate::icons::svg(name) {
Some(svg) => format!("{svg} "),
None => {
problems.push(format!("{}: unknown icon ({name})", lines_before + i + 1));
String::new()
}
};
out.push_str(&format!(
"{indent}{}{drawn}{}",
&body[..2],
after.trim_start()
));
}
out
}
struct OpenTag {
name: String,
props: String,
len: usize,
self_closing: bool,
}
const UNREADABLE_NOTES: &str =
"a <Notes> whose attributes can't be read: write delivery=\"…\" with no double quotes inside";
fn unreadable_notes(text: &str) -> Option<usize> {
let rest = text.strip_prefix(&format!("<{NOTES}"))?;
if !rest.starts_with(char::is_whitespace) || parse_open(text).is_some() {
return None;
}
text.find('>').map(|end| end + 1)
}
impl OpenTag {
fn has_prop(&self, key: &str) -> bool {
self.props.contains(&format!("\"{key}\": "))
}
fn string_prop(&self, key: &str) -> Option<String> {
let at = self.props.find(&format!("\"{key}\": \""))? + key.len() + 4;
let mut strings =
serde_json::Deserializer::from_str(&self.props[at..]).into_iter::<String>();
strings.next()?.ok()
}
}
fn parse_open(text: &str) -> Option<OpenTag> {
let chars: Vec<(usize, char)> = text.char_indices().collect();
let mut i = 1;
let mut name = String::new();
while let Some(&(_, c)) = chars.get(i) {
if c.is_ascii_alphanumeric() {
name.push(c);
i += 1;
} else {
break;
}
}
let mut props = Vec::new();
loop {
while chars.get(i).is_some_and(|(_, c)| c.is_whitespace()) {
i += 1;
}
let &(_, c) = chars.get(i)?;
if c == '>' {
return Some(OpenTag {
name,
props: format!("{{{}}}", props.join(", ")),
len: chars.get(i + 1).map_or(text.len(), |(at, _)| *at),
self_closing: false,
});
}
if c == '/' && chars.get(i + 1).is_some_and(|(_, c)| *c == '>') {
return Some(OpenTag {
name,
props: format!("{{{}}}", props.join(", ")),
len: chars.get(i + 2).map_or(text.len(), |(at, _)| *at),
self_closing: true,
});
}
let mut attr = String::new();
while let Some(&(_, c)) = chars.get(i) {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
attr.push(c);
i += 1;
} else {
break;
}
}
if attr.is_empty() {
return None;
}
let key = serde_json::to_string(&attr).ok()?;
if chars.get(i).is_some_and(|(_, c)| *c == '=') {
i += 1;
match chars.get(i)?.1 {
quote @ ('"' | '\'') => {
let mut value = String::new();
i += 1;
while let Some(&(_, c)) = chars.get(i) {
i += 1;
if c == quote {
break;
}
value.push(c);
}
props.push(format!("{key}: {}", serde_json::to_string(&value).ok()?));
}
'{' => {
let mut depth = 0;
let mut expr = String::new();
let mut string: Option<char> = None;
while let Some(&(_, c)) = chars.get(i) {
i += 1;
if let Some(q) = string {
if c == q {
string = None;
}
} else if matches!(c, '"' | '\'' | '`') {
string = Some(c);
} else if c == '{' {
depth += 1;
if depth == 1 {
continue;
}
} else if c == '}' {
depth -= 1;
if depth == 0 {
break;
}
}
expr.push(c);
}
props.push(format!("{key}: ({expr})"));
}
_ => return None,
}
} else {
props.push(format!("{key}: true"));
}
}
}
fn find_close(
source: &str,
from: usize,
name: &str,
in_code: &dyn Fn(usize) -> bool,
) -> Option<(usize, usize)> {
let open = format!("<{name}");
let close = format!("</{name}>");
let mut depth = 1;
let mut at = from;
loop {
let next_close = source[at..].find(&close).map(|o| at + o)?;
let next_open = source[at..]
.find(&open)
.map(|o| at + o)
.filter(|&o| o < next_close);
if let Some(o) = next_open {
at = o + open.len();
let boundary = source[at..].starts_with(|c: char| c.is_whitespace() || c == '>');
if !in_code(o) && boundary {
depth += 1;
}
continue;
}
at = next_close + close.len();
if in_code(next_close) {
continue;
}
depth -= 1;
if depth == 0 {
return Some((next_close, at));
}
}
}
fn code_ranges(source: &str) -> Vec<std::ops::Range<usize>> {
let mut ranges = Vec::new();
let mut fence: Option<(usize, String)> = None;
let mut offset = 0;
let mut previous_blank = true;
let mut in_list = false;
let mut indented: Option<usize> = None;
for line in source.split_inclusive('\n') {
let trimmed = line.trim_start();
let blank = trimmed.is_empty();
let is_indented = line.starts_with(" ") || line.starts_with('\t');
if fence.is_none() {
if let Some(start) = indented {
if !blank && !is_indented {
ranges.push(start..offset);
indented = None;
}
} else if is_indented && !blank && previous_blank && !in_list {
indented = Some(offset);
}
if indented.is_some() {
previous_blank = blank;
offset += line.len();
continue;
}
if !blank && !is_indented {
in_list = is_list_item(trimmed);
}
}
previous_blank = blank;
let marker: String = trimmed
.chars()
.take_while(|c| *c == '`' || *c == '~')
.collect();
match &fence {
Some((start, open)) => {
if marker.len() >= open.len() && marker.starts_with(&open[..1]) {
ranges.push(*start..offset + line.len());
fence = None;
}
}
None if marker.len() >= 3 => fence = Some((offset, marker)),
None => {
let mut i = 0;
let b = line.as_bytes();
while i < b.len() {
if b[i] == b'`' {
let run = b[i..].iter().take_while(|c| **c == b'`').count();
let ticks = "`".repeat(run);
if let Some(end) = line[i + run..].find(&ticks) {
let close = i + run + end + run;
ranges.push(offset + i..offset + close);
i = close;
continue;
}
i += run;
} else {
i += 1;
}
}
}
}
offset += line.len();
}
if let Some((start, _)) = fence {
ranges.push(start..source.len());
}
if let Some(start) = indented {
ranges.push(start..source.len());
}
let mut from = 0;
while let Some(open) = source[from..].find("<!--") {
let open = from + open;
if ranges.iter().any(|r| r.contains(&open)) {
from = open + 4;
continue;
}
let close = source[open + 4..]
.find("-->")
.map_or(source.len(), |end| open + 4 + end + 3);
ranges.push(open..close);
from = close;
}
ranges.sort_by_key(|r| r.start);
ranges
}
fn is_list_item(trimmed: &str) -> bool {
let bullet =
trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ");
let digits = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
bullet || (digits > 0 && matches!(trimmed[digits..].chars().next(), Some('.' | ')')))
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('"', """)
.replace('<', "<")
.replace('>', ">")
.replace('\n', " ")
}
static COMPILED: Mutex<Option<HashMap<u64, Result<String, String>>>> = Mutex::new(None);
pub fn compile_cached(
path: &Path,
source: &str,
known: &[String],
imports: &BTreeMap<String, String>,
) -> Result<String> {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
(path, source, known, imports).hash(&mut hasher);
let key = hasher.finish();
if let Some(found) = COMPILED
.lock()
.ok()
.and_then(|cache| cache.as_ref().and_then(|c| c.get(&key).cloned()))
{
return found.map_err(|e| anyhow!(e));
}
let result = compile(path, source, known, imports).map_err(|e| e.to_string());
if let Ok(mut cache) = COMPILED.lock() {
let cache = cache.get_or_insert_with(HashMap::new);
if cache.len() > 512 {
cache.clear();
}
cache.insert(key, result.clone());
}
result.map_err(|e| anyhow!(e))
}
pub fn compile(
path: &Path,
source: &str,
known: &[String],
imports: &BTreeMap<String, String>,
) -> Result<String> {
let allocator = Allocator::default();
let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::tsx());
let parsed = Parser::new(&allocator, source, source_type).parse();
let errors =
|d: &&oxc::diagnostics::OxcDiagnostic| d.severity == oxc::diagnostics::Severity::Error;
if let Some(error) = parsed.diagnostics.iter().find(errors) {
let line = error.labels.first().map(|label| {
source[..(label.offset() as usize).min(source.len())]
.matches('\n')
.count()
+ 1
});
return Err(anyhow!(
"{}{}",
line.map(|l| format!("line {l}: ")).unwrap_or_default(),
error.message
));
}
let mut program = parsed.program;
rewrite_imports(&allocator, &mut program, imports);
let semantic = SemanticBuilder::new().build(&program).semantic;
let scoping = semantic.into_scoping();
let mut used: Vec<String> = scoping
.root_unresolved_references()
.keys()
.map(|name| name.to_string())
.filter(|name| known.contains(name))
.collect();
used.sort();
let mut options = TransformOptions::default();
options.jsx.runtime = JsxRuntime::Automatic;
options.jsx.import_source = Some("react".into());
let transformed =
Transformer::new(&allocator, path, &options).build_with_scoping(scoping, &mut program);
if let Some(error) = transformed.diagnostics.iter().find(errors) {
return Err(anyhow!("{}", error.message));
}
let code = Codegen::new().build(&program).code;
let shims: String = used
.iter()
.map(|name| {
format!(
"const {name} = (props) => __devpageCreate(globalThis.__devpageWidget({name:?}), props);\n"
)
})
.collect();
let import = if shims.is_empty() {
String::new()
} else {
"import { createElement as __devpageCreate } from \"react\";\n".to_string()
};
let uses = serde_json::to_string(&used).unwrap_or_else(|_| "[]".into());
Ok(format!(
"{import}{shims}{code}\nexport const __devpageUses = {uses};\n"
))
}
fn uses(compiled: &str) -> Vec<String> {
compiled
.rsplit_once("export const __devpageUses = ")
.and_then(|(_, list)| serde_json::from_str(list.trim().trim_end_matches(';')).ok())
.unwrap_or_default()
}
pub fn compile_system(name: &str) -> Option<Result<String>> {
let source = system(name)?;
let known: Vec<String> = SYSTEM.iter().map(|(name, _)| name.to_string()).collect();
Some(compile_cached(
Path::new(&format!("{name}.tsx")),
source,
&known,
&Default::default(),
))
}
fn rewrite_imports<'a>(
allocator: &'a Allocator,
program: &mut oxc::ast::ast::Program<'a>,
imports: &BTreeMap<String, String>,
) {
use oxc::ast::ast::Statement;
let versioned = |value: &str| -> Option<&'a str> {
imports
.get(value)
.map(|address| allocator.alloc_str(address) as &str)
};
for statement in program.body.iter_mut() {
let source = match statement {
Statement::ImportDeclaration(decl) => Some(&mut decl.source),
Statement::ExportAllDeclaration(decl) => Some(&mut decl.source),
Statement::ExportFromDeclaration(decl) => Some(&mut decl.source),
_ => None,
};
if let Some(source) = source
&& let Some(value) = versioned(source.value.as_str())
{
source.value = value.into();
source.raw = None;
}
}
}
pub fn system(name: &str) -> Option<&'static str> {
SYSTEM.iter().find(|(n, _)| *n == name).map(|(_, s)| *s)
}
#[cfg(test)]
mod tests {
use super::*;
fn module_hash(served: &Path, file: &Path, known: &[String]) -> String {
Snapshot::of(served, file).hash(file, known)
}
fn set(names: &[&str]) -> Widgets {
let mut widgets = Widgets::default();
for name in names {
widgets.modules.insert(name.to_string(), format!("/{name}"));
}
widgets
}
#[test]
fn expands_a_block_widget_with_markdown_content() {
let mut problems = Vec::new();
let out = expand(
"<Callout kind=\"warning\">\nSome **markdown**\n</Callout>\n",
&set(&["Callout"]),
&mut problems,
);
assert!(
out.contains(
r#"<div data-widget="Callout" data-props="{"kind": "warning"}""#
),
"{out}"
);
assert!(out.contains("\n\nSome **markdown**\n\n</div>"), "{out}");
assert!(problems.is_empty());
let html = crate::render::markdown(&out);
assert!(html.contains("<strong>markdown</strong>"), "{html}");
}
#[test]
fn parses_expression_bare_and_self_closing_props() {
let mut problems = Vec::new();
let out = expand(
"<Chart values={[3, {a: \"}\"}]} stacked title='T' />",
&set(&["Chart"]),
&mut problems,
);
assert!(out.contains(r#"data-props="{"values": ([3, {a: "}"}]), "stacked": true, "title": "T"}""#), "{out}");
}
#[test]
fn leaves_code_alone_and_reports_unknown_tags() {
let mut problems = Vec::new();
let out = expand(
"`<Callout>` and\n\n```\n<Callout>x</Callout>\n```\n\n<Nope />\n",
&set(&["Callout"]),
&mut problems,
);
assert!(!out.contains("data-widget"), "{out}");
assert_eq!(problems, vec!["7: <Nope> is not a widget".to_string()]);
}
#[test]
fn leaves_indented_code_and_html_comments_alone() {
let mut problems = Vec::new();
let source = "Code:\n\n let v: Vec<String> = Vec::new();\n <Callout>shown as code</Callout>\n\nAfter.\n\n<!-- old: <Todo> -->\n<!--\n<Callout>draft</Callout>\n-->\n";
let out = expand(source, &set(&["Callout"]), &mut problems);
assert_eq!(out, source, "{out}");
assert!(problems.is_empty(), "{problems:?}");
let html = crate::render::markdown(&out);
assert!(
html.contains("let v: Vec<String> = Vec::new();"),
"{html}"
);
assert!(
html.contains("<Callout>shown as code</Callout>"),
"{html}"
);
}
#[test]
fn indented_lines_in_a_list_are_not_code() {
let mut problems = Vec::new();
let out = expand(
"- item\n\n more of the item with <Callout>x</Callout>\n",
&set(&["Callout"]),
&mut problems,
);
assert!(out.contains("data-widget=\"Callout\""), "{out}");
}
#[test]
fn an_unknown_tag_is_replaced_by_an_error_not_passed_through() {
let mut problems = Vec::new();
let out = expand(
"Before.\n\n<Nope>hello</Nope>\n\nSay <Gone /> inline.\n",
&set(&["Callout"]),
&mut problems,
);
let html = crate::render::markdown(&out);
assert!(!html.contains("<nope>"), "{html}");
assert!(!html.contains("hello"), "{html}");
assert!(!html.contains("<gone"), "{html}");
assert!(
html.contains(r#"<div class="widget-error"><Nope> is not a widget</div>"#),
"{html}"
);
assert!(
html.contains(
r#"Say <span class="widget-error"><Gone> is not a widget</span> inline."#
),
"{html}"
);
assert_eq!(
problems,
vec![
"3: <Nope> is not a widget".to_string(),
"5: <Gone> is not a widget".to_string()
]
);
}
#[test]
fn a_tab_outside_a_tabs_is_a_problem_with_its_page_line() {
let mut problems = Vec::new();
let widgets = set(&["Tabs", "Tab", "Callout"]);
expand(
"# T\n\n<Tabs>\n<Tab title=\"a\">\nfine\n</Tab>\n</Tabs>\n\n<Callout>\n\n<Tab title=\"b\">\nlost\n</Tab>\n\n</Callout>\n\n<Tab title=\"c\">x</Tab>\n",
&widgets,
&mut problems,
);
assert_eq!(
problems,
vec![
"11: <Tab> is outside a <Tabs>".to_string(),
"17: <Tab> is outside a <Tabs>".to_string()
]
);
}
#[test]
fn nests_widgets_and_keeps_inline_ones_inline() {
let mut problems = Vec::new();
let out = expand(
"<Tabs>\n<Tab title=\"a\">\n\nPress <Keys>⌘ P</Keys>.\n\n</Tab>\n</Tabs>\n",
&set(&["Tabs", "Tab", "Keys"]),
&mut problems,
);
let html = crate::render::markdown(&out);
assert!(html.contains(r#"<div data-widget="Tabs""#), "{html}");
assert!(html.contains(r#"<div data-widget="Tab""#), "{html}");
assert!(
html.contains(
r#"<span data-widget="Keys" data-props="{}" data-widget-source="⌘ P">⌘ P</span>"#
),
"{html}"
);
assert!(!html.contains("data-source"), "{html}");
}
#[test]
fn compiles_tsx_with_the_automatic_runtime_and_widget_shims() {
let js = compile(
Path::new("Card.tsx"),
"export default function Card({ n }: { n: number }) { new DOMParser(); return <Badge n={n}><b>{n}</b></Badge>; }",
&["Badge".to_string()],
&BTreeMap::new(),
)
.unwrap();
assert!(js.contains("react/jsx-runtime"), "{js}");
assert!(js.contains("const Badge = (props) =>"), "{js}");
assert!(!js.contains(": number"), "{js}");
assert!(!js.contains("const DOMParser"), "{js}");
}
#[test]
fn a_compile_error_names_its_line() {
let error = compile(
Path::new("Bad.tsx"),
"export default 1;\nconst = ;\n",
&[],
&BTreeMap::new(),
)
.unwrap_err();
assert!(error.to_string().starts_with("line 2:"), "{error}");
}
#[test]
fn a_module_lists_the_widgets_it_renders_and_imports_its_helpers_by_their_addresses() {
let imports = BTreeMap::from([
(
"./label".to_string(),
"/__devpage/widgets/w/label.ts?v=1".to_string(),
),
(
"./tone".to_string(),
"/__devpage/widgets/w/tone.ts?v=2".to_string(),
),
]);
let js = compile(
Path::new("Card.tsx"),
"import { label } from \"./label\";\nexport { tone } from \"./tone\";\nimport React from \"react\";\nexport default function Card() { return <Badge>{label}</Badge>; }",
&["Badge".to_string()],
&imports,
)
.unwrap();
assert!(js.contains("\"/__devpage/widgets/w/label.ts?v=1\""), "{js}");
assert!(js.contains("\"/__devpage/widgets/w/tone.ts?v=2\""), "{js}");
assert!(
js.contains("from \"react\""),
"a package import is left alone: {js}"
);
assert!(
js.contains("export const __devpageUses = [\"Badge\"];"),
"{js}"
);
}
#[test]
fn compiling_is_remembered_until_the_source_or_an_import_changes() {
let path = Path::new("Same.tsx");
let none = BTreeMap::new();
let first = compile_cached(path, "export default () => <b>1</b>;", &[], &none).unwrap();
let again = compile_cached(path, "export default () => <b>1</b>;", &[], &none).unwrap();
assert_eq!(first, again);
let edited = compile_cached(path, "export default () => <b>2</b>;", &[], &none).unwrap();
assert!(edited.contains('2'), "{edited}");
let source = "export { x } from \"./x\";";
let imports = |v: &str| BTreeMap::from([("./x".to_string(), format!("/x.ts?v={v}"))]);
let one = compile_cached(path, source, &[], &imports("1")).unwrap();
let two = compile_cached(path, source, &[], &imports("2")).unwrap();
assert!(
one.contains("/x.ts?v=1") && two.contains("/x.ts?v=2"),
"{two}"
);
let error = compile_cached(path, "export default (", &[], &none).unwrap_err();
assert!(!error.to_string().is_empty());
}
#[test]
fn editing_a_helper_changes_its_importers_addresses_only() {
let dir = std::env::temp_dir().join(format!("devpage-wversion-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("w")).unwrap();
let dir = dir.canonicalize().unwrap();
let w = dir.join("w");
std::fs::write(
w.join("Card.tsx"),
"import { label } from \"./label\";\nexport default () => label;",
)
.unwrap();
std::fs::write(
w.join("Deep.tsx"),
"export * from \"./Card\";\nexport default () => null;",
)
.unwrap();
std::fs::write(w.join("Other.tsx"), "export default () => null;").unwrap();
std::fs::write(w.join("label.ts"), "export const label = 1;").unwrap();
let known = vec!["Card".to_string(), "Deep".to_string(), "Other".to_string()];
let hashes = || {
["Card.tsx", "Deep.tsx", "Other.tsx", "label.ts"]
.map(|name| module_hash(&dir, &w.join(name), &known))
};
let before = hashes();
std::fs::write(w.join("label.ts"), "export const label = 2;").unwrap();
forget(&w.join("label.ts"));
let after = hashes();
assert_ne!(before[0], after[0], "the widget importing it");
assert_ne!(before[1], after[1], "a widget importing that widget");
assert_eq!(before[2], after[2], "a widget that doesn't import it");
assert_ne!(before[3], after[3], "the helper itself");
let card = compile_module(&dir, &w.join("Card.tsx"), &known).unwrap();
let label = address(Owned::Module {
snapshot: &Snapshot::of(&dir, &w.join("label.ts")),
file: &w.join("label.ts"),
known: &known,
});
assert!(card.contains(&format!("\"{label}\"")), "{card}");
assert!(label.ends_with(&format!("?v={}", after[3])), "{label}");
assert_ne!(
module_hash(&dir, &w.join("Other.tsx"), &known[..2]),
after[2]
);
assert!(is_widget_file(&w.join("label.ts"), &w));
assert!(!is_widget_file(&w.join("notes.md"), &w));
}
#[test]
fn a_helper_created_after_its_importer_moves_the_importers_address() {
let dir = std::env::temp_dir().join(format!("devpage-wlate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("w")).unwrap();
let dir = dir.canonicalize().unwrap();
let w = dir.join("w");
let other = w.join("Other.tsx");
std::fs::write(
&other,
"import { label } from \"./label\";\nexport default () => label;",
)
.unwrap();
let known = vec!["Other".to_string()];
let missing = module_hash(&dir, &other, &known);
std::fs::write(w.join("label.ts"), "export const label = 1;").unwrap();
forget(&w.join("label.ts"));
let created = module_hash(&dir, &other, &known);
assert_ne!(missing, created, "creating the helper");
let label = address(Owned::Module {
snapshot: &Snapshot::of(&dir, &w.join("label.ts")),
file: &w.join("label.ts"),
known: &known,
});
let compiled = compile_module(&dir, &other, &known).unwrap();
assert!(compiled.contains(&format!("\"{label}\"")), "{compiled}");
std::fs::write(w.join("label.ts"), "export const label = 2;").unwrap();
forget(&w.join("label.ts"));
assert_ne!(
created,
module_hash(&dir, &other, &known),
"editing the helper"
);
let edited = module_hash(&dir, &other, &known);
std::fs::remove_file(w.join("label.ts")).unwrap();
forget(&w.join("label.ts"));
assert_ne!(edited, module_hash(&dir, &other, &known), "removing it");
}
#[test]
fn a_snapshot_hashes_and_compiles_what_it_read_whatever_is_saved_since() {
let dir = std::env::temp_dir().join(format!("devpage-wsnap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("w")).unwrap();
let dir = dir.canonicalize().unwrap();
let w = dir.join("w");
let card = w.join("Card.tsx");
std::fs::write(
&card,
"import { label } from \"./label\";\nexport default () => \"old\" + label;",
)
.unwrap();
std::fs::write(w.join("label.ts"), "export const label = 1;").unwrap();
let known = vec!["Card".to_string()];
let snapshot = Snapshot::of(&dir, &card);
let hash = snapshot.hash(&card, &known);
std::fs::write(
&card,
"import { label } from \"./label\";\nexport default () => \"new\" + label;",
)
.unwrap();
std::fs::write(w.join("label.ts"), "export const label = 2;").unwrap();
forget(&card);
forget(&w.join("label.ts"));
assert_ne!(
hash,
module_hash(&dir, &card, &known),
"the files did change"
);
assert_eq!(hash, snapshot.hash(&card, &known));
let compiled = snapshot.compile(&card, &known).unwrap();
assert!(compiled.contains("\"old\""), "{compiled}");
let label = address(Owned::Module {
snapshot: &snapshot,
file: &w.join("label.ts"),
known: &known,
});
assert!(compiled.contains(&format!("\"{label}\"")), "{compiled}");
assert_ne!(
label,
address(Owned::Module {
snapshot: &Snapshot::of(&dir, &w.join("label.ts")),
file: &w.join("label.ts"),
known: &known,
})
);
}
#[test]
fn lowercase_files_are_helpers_and_a_system_name_is_a_problem() {
let dir = std::env::temp_dir().join(format!("devpage-wset-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("widgets")).unwrap();
std::fs::write(
dir.join("devpage.yml"),
"kind: site\ntitle: T\nroot: README.md\nwidgets: widgets\n",
)
.unwrap();
std::fs::write(dir.join("README.md"), "# T\n").unwrap();
std::fs::write(dir.join("widgets/Card.tsx"), "export default () => null;").unwrap();
std::fs::write(dir.join("widgets/cards.tsx"), "export const x = 1;").unwrap();
std::fs::write(
dir.join("widgets/Callout.tsx"),
"export default () => null;",
)
.unwrap();
let dir = dir.canonicalize().unwrap();
let document = Document::load(&dir, &dir).unwrap();
let widgets = Widgets::of(&document);
assert!(widgets.modules.contains_key("Card"));
assert!(!widgets.modules.contains_key("cards"));
assert!(widgets.modules["Callout"].starts_with("/__devpage/system-widgets/"));
assert_eq!(
widgets.problems,
vec!["widgets/Callout.tsx — Callout is a system widget's name".to_string()]
);
let known: Vec<String> = widgets.modules.keys().cloned().collect();
assert_eq!(
widgets.modules["Card"],
address(Owned::Module {
snapshot: &Snapshot::of(&dir, &dir.join("widgets/Card.tsx")),
file: &dir.join("widgets/Card.tsx"),
known: &known
})
);
}
#[test]
fn devpage_sidebar_is_reserved_and_a_project_sidebar_is_not() {
let dir = std::env::temp_dir().join(format!("devpage-wsidebar-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("widgets")).unwrap();
std::fs::write(
dir.join("devpage.yml"),
"kind: site\ntitle: T\nroot: README.md\nwidgets: widgets\n",
)
.unwrap();
std::fs::write(dir.join("README.md"), "# T\n").unwrap();
for name in ["DevpageSidebar", "Sidebar"] {
std::fs::write(
dir.join(format!("widgets/{name}.tsx")),
"export default () => null;",
)
.unwrap();
}
let dir = dir.canonicalize().unwrap();
let widgets = Widgets::of(&Document::load(&dir, &dir).unwrap());
assert_eq!(
widgets.problems,
vec![
"widgets/DevpageSidebar.tsx — DevpageSidebar is a system widget's name".to_string()
]
);
assert!(widgets.modules["DevpageSidebar"].starts_with("/__devpage/system-widgets/"));
assert!(widgets.modules["Sidebar"].starts_with("/__devpage/widgets/"));
}
#[test]
fn an_inline_widget_at_the_start_of_a_line_stays_in_its_paragraph() {
let mut problems = Vec::new();
let out = expand(
"<Keys>⌘ P</Keys> to **print**, then <Keys>⏎</Keys>.\n",
&set(&["Keys"]),
&mut problems,
);
let html = crate::render::markdown(&out);
assert!(html.starts_with("<p><span data-widget=\"Keys\""), "{html}");
assert!(html.contains("<strong>print</strong>"), "{html}");
assert_eq!(html.matches("data-widget=\"Keys\"").count(), 2, "{html}");
}
#[test]
fn an_example_gets_its_source_highlighted() {
let mut problems = Vec::new();
let out = expand(
"<Example>\nA **word**\n</Example>\n",
&set(&["Example"]),
&mut problems,
);
assert!(out.contains("data-widget-source-html=\""), "{out}");
assert!(out.contains("hl-"), "{out}");
let callout = expand("<Callout>x</Callout>\n", &set(&["Callout"]), &mut problems);
assert!(!callout.contains("data-widget-source-html"), "{callout}");
}
#[test]
fn a_cards_icon_is_drawn_and_an_unknown_one_is_a_problem() {
let mut problems = Vec::new();
let out = expand(
"# T\n\n<Cards>\n\n- (rocket) [Start](a.md) — begin here\n- [Plain](b.md) — no icon\n- (no-such-icon) [Odd](c.md)\n\n</Cards>\n",
&set(&["Cards"]),
&mut problems,
);
assert!(out.contains(r#"<svg class="icon""#), "{out}");
assert!(!out.contains("(rocket)"), "{out}");
assert!(out.contains("[Plain](b.md)"), "{out}");
assert_eq!(problems, vec!["7: unknown icon (no-such-icon)".to_string()]);
}
#[test]
fn notes_are_taken_out_of_the_page_and_their_text_kept() {
let source = "# Title\n\n<Notes>\n Say **this**, word for word.\n</Notes>\n\nAfter.\n";
assert_eq!(
notes(source),
vec![Note {
line: 3,
text: "Say **this**, word for word.".to_string(),
delivery: None
}]
);
let mut problems = Vec::new();
let html = crate::render::markdown(&expand(source, &set(&["Callout"]), &mut problems));
assert!(!html.contains("word for word"), "{html}");
assert!(!html.contains("widget-error"), "{html}");
assert!(html.contains("<p>After.</p>"), "{html}");
assert!(problems.is_empty(), "{problems:?}");
}
#[test]
fn unclosed_notes_are_a_problem_and_never_shown() {
let source = "# Title\n\nShown.\n\n<Notes>\nSay this.\n</Note>\n\nAfter.\n";
assert_eq!(
notes(source)[0].text,
"Say this.\n</Note>\n\nAfter.".to_string()
);
let mut problems = Vec::new();
let html = crate::render::markdown(&expand(source, &set(&[]), &mut problems));
assert!(html.contains("<p>Shown.</p>"), "{html}");
assert!(!html.contains("Say this"), "{html}");
assert!(!html.contains("After."), "{html}");
assert_eq!(
problems,
vec!["5: a <Notes> without its </Notes>".to_string()]
);
}
#[test]
fn a_notes_delivery_is_kept_and_never_shown() {
let source = "<Notes delivery=\"Honest and a little teasing: you caught them.\">\nSay this.\n</Notes>\n";
assert_eq!(
notes(source)[0].delivery.as_deref(),
Some("Honest and a little teasing: you caught them.")
);
let html = crate::render::markdown(&expand(source, &set(&[]), &mut Vec::new()));
assert!(!html.contains("teasing"), "{html}");
}
#[test]
fn a_notes_with_unreadable_attributes_is_a_problem_and_never_shown() {
for source in [
"<Notes delivery=\"the \"aha\" moment\">\nSAYTHIS\n</Notes>\n\nAfter.\n",
"<Notes delivery=\"say \\\"wow\\\" now\">\nSAYTHIS\n</Notes>\n\nAfter.\n",
] {
let mut problems = Vec::new();
let html = crate::render::markdown(&expand(source, &set(&[]), &mut problems));
assert!(
!html.contains("SAYTHIS") && !html.contains("Notes"),
"{html}"
);
assert!(html.contains("<p>After.</p>"), "{html}");
assert_eq!(problems, vec![format!("1: {UNREADABLE_NOTES}")]);
assert_eq!(notes(source)[0].text, "SAYTHIS");
assert_eq!(notes(source)[0].delivery, None);
}
}
#[test]
fn a_notes_delivery_that_is_not_a_quoted_string_is_a_problem() {
let source = "<Notes delivery={\"warm\"}>\nSay this.\n</Notes>\n";
let mut problems = Vec::new();
let html = crate::render::markdown(&expand(source, &set(&[]), &mut problems));
assert!(!html.contains("Say this"), "{html}");
assert_eq!(
problems,
vec!["1: a <Notes> delivery must be a quoted string".to_string()]
);
assert_eq!(notes(source)[0].delivery, None);
}
#[test]
fn notes_inside_code_and_comments_are_not_notes() {
let source = "`<Notes>x</Notes>`\n\n```\n<Notes>fenced</Notes>\n```\n\n <Notes>indented</Notes>\n\n<!-- <Notes>hidden</Notes> -->\n";
assert!(notes(source).is_empty(), "{:?}", notes(source));
let html = crate::render::markdown(&expand(source, &set(&[]), &mut Vec::new()));
assert!(
html.contains("fenced") && html.contains("indented"),
"{html}"
);
}
#[test]
fn every_notes_block_is_found_with_its_line() {
let found = notes("<Notes>one</Notes>\n\ntext\n\n<Notes>\ntwo\n</Notes>\n<Notes/>\n");
let found: Vec<(usize, String)> = found.into_iter().map(|n| (n.line, n.text)).collect();
assert_eq!(
found,
vec![
(1, "one".to_string()),
(5, "two".to_string()),
(8, String::new())
]
);
}
#[test]
fn a_project_widget_named_notes_clashes() {
let dir = std::env::temp_dir().join(format!("devpage-wnotes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("widgets")).unwrap();
std::fs::write(
dir.join("devpage.yml"),
"kind: site\ntitle: T\nroot: README.md\nwidgets: widgets\n",
)
.unwrap();
std::fs::write(dir.join("README.md"), "# T\n").unwrap();
std::fs::write(dir.join("widgets/Notes.tsx"), "export default () => null;").unwrap();
let dir = dir.canonicalize().unwrap();
let widgets = Widgets::of(&Document::load(&dir, &dir).unwrap());
assert!(!widgets.modules.contains_key("Notes"));
assert_eq!(
widgets.problems,
vec!["widgets/Notes.tsx — Notes is devpage's tag for a slide's notes".to_string()]
);
}
}