chore: align local-first control plane and editor fixes

- wire SQLite control-plane access/session paths into Rust web local-folder routes

- preserve local Markdown attachment semantics across upload, reload, and secondary-pane resource tabs

- refresh design governance docs, Reasonix task templates, and bug records

- retire root .mcp.json local MCP config
This commit is contained in:
lix-2026
2026-05-23 23:38:42 +08:00
parent 42fb58310c
commit 5f97800489
110 changed files with 5344 additions and 889 deletions
@@ -1,6 +1,7 @@
use comrak::nodes::{AstNode, ListType, NodeValue, TableAlignment};
use comrak::{parse_document, Arena, Options};
use serde_json::{json, Map, Value};
use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub struct ParsedLocalMarkdownPage {
@@ -134,7 +135,14 @@ fn find_first_h1(body: &str) -> Option<String> {
}
pub fn markdown_to_blocks(markdown: &str) -> Value {
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown))
markdown_to_blocks_with_attachment_paths(markdown, &BTreeSet::new())
}
pub fn markdown_to_blocks_with_attachment_paths(
markdown: &str,
attachment_paths: &BTreeSet<String>,
) -> Value {
markdown_ast_document_to_blocks(&parse_markdown_ast_document(markdown, attachment_paths))
}
fn markdown_options() -> Options<'static> {
@@ -149,6 +157,13 @@ fn markdown_options() -> Options<'static> {
}
pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)> {
parse_markdown_attachment_link_with_paths(trimmed, &BTreeSet::new())
}
fn parse_markdown_attachment_link_with_paths(
trimmed: &str,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let value = trimmed
.strip_prefix('!')
.unwrap_or(trimmed)
@@ -170,7 +185,9 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)>
}
let target_path = std::path::Path::new(target);
let extension = target_path.extension().and_then(|value| value.to_str())?;
if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") {
if (extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown"))
&& !attachment_paths.contains(target)
{
return None;
}
let fallback_name = target_path
@@ -186,20 +203,27 @@ pub fn parse_markdown_attachment_link(trimmed: &str) -> Option<(String, String)>
Some((name.to_string(), target.to_string()))
}
fn parse_markdown_ast_document(markdown: &str) -> MarkdownAstDocument {
fn parse_markdown_ast_document(
markdown: &str,
attachment_paths: &BTreeSet<String>,
) -> MarkdownAstDocument {
let arena = Arena::new();
let options = markdown_options();
let root = parse_document(&arena, markdown, &options);
let mut blocks = Vec::new();
for node in root.children() {
append_ast_block(node, &mut blocks);
append_ast_block(node, &mut blocks, attachment_paths);
}
MarkdownAstDocument { blocks }
}
fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
fn append_ast_block<'a>(
node: &'a AstNode<'a>,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
match node.data.borrow().value.clone() {
NodeValue::Paragraph => append_ast_paragraph(node, blocks),
NodeValue::Paragraph => append_ast_paragraph(node, blocks, attachment_paths),
NodeValue::Heading(heading) => blocks.push(MarkdownBlock::Heading {
level: heading.level,
content: collect_inline_children(node),
@@ -212,7 +236,12 @@ fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>)
}),
NodeValue::List(list) => {
for item in node.children() {
append_ast_list_item(item, list.list_type == ListType::Ordered, blocks);
append_ast_list_item(
item,
list.list_type == ListType::Ordered,
blocks,
attachment_paths,
);
}
}
NodeValue::Table(table) => blocks.push(ast_table_to_ir(node, table.alignments)),
@@ -233,7 +262,11 @@ fn append_ast_block<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>)
}
}
fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBlock>) {
fn append_ast_paragraph<'a>(
node: &'a AstNode<'a>,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
if let Some((alt, source_path)) = paragraph_image(node) {
blocks.push(MarkdownBlock::Image { alt, source_path });
return;
@@ -242,11 +275,13 @@ fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBloc
blocks.push(MarkdownBlock::Mindmap { name, source_path });
return;
}
if let Some((name, source_path)) = paragraph_attachment_media(node) {
if let Some((name, source_path)) = paragraph_attachment_media(node, attachment_paths) {
blocks.push(MarkdownBlock::Media { name, source_path });
return;
}
if let Some((name, source_path, remaining)) = paragraph_leading_attachment_media(node) {
if let Some((name, source_path, remaining)) =
paragraph_leading_attachment_media(node, attachment_paths)
{
blocks.push(MarkdownBlock::Media { name, source_path });
let content = merge_adjacent_inline_nodes(remaining);
if !content.is_empty() {
@@ -257,17 +292,22 @@ fn append_ast_paragraph<'a>(node: &'a AstNode<'a>, blocks: &mut Vec<MarkdownBloc
blocks.push(MarkdownBlock::Paragraph(collect_inline_children(node)));
}
fn append_ast_list_item<'a>(node: &'a AstNode<'a>, ordered: bool, blocks: &mut Vec<MarkdownBlock>) {
fn append_ast_list_item<'a>(
node: &'a AstNode<'a>,
ordered: bool,
blocks: &mut Vec<MarkdownBlock>,
attachment_paths: &BTreeSet<String>,
) {
let (is_task, checked) = match node.data.borrow().value.clone() {
NodeValue::TaskItem(task_item) => (true, task_item.symbol.is_some()),
NodeValue::Item(_) => (false, false),
_ => return append_ast_block(node, blocks),
_ => return append_ast_block(node, blocks, attachment_paths),
};
let mut content = Vec::new();
for child in node.children() {
match child.data.borrow().value.clone() {
NodeValue::Paragraph => content.extend(collect_inline_children(child)),
_ => append_ast_block(child, blocks),
_ => append_ast_block(child, blocks, attachment_paths),
}
}
@@ -404,13 +444,16 @@ fn merge_adjacent_inline_nodes(nodes: Vec<MarkdownInline>) -> Vec<MarkdownInline
merged
}
fn paragraph_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn paragraph_attachment_media<'a>(
node: &'a AstNode<'a>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let mut children = node.children();
let first = children.next()?;
if children.next().is_some() {
return None;
}
link_attachment_media(first)
link_attachment_media(first, attachment_paths)
}
fn paragraph_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
@@ -437,10 +480,11 @@ fn paragraph_image<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn paragraph_leading_attachment_media<'a>(
node: &'a AstNode<'a>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String, Vec<MarkdownInline>)> {
let mut children = node.children();
let first = children.next()?;
let (name, source_path) = link_attachment_media(first)?;
let (name, source_path) = link_attachment_media(first, attachment_paths)?;
let second = children.next()?;
if !matches!(
second.data.borrow().value,
@@ -455,11 +499,17 @@ fn paragraph_leading_attachment_media<'a>(
Some((name, source_path, remaining))
}
fn link_attachment_media<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {
fn link_attachment_media<'a>(
node: &'a AstNode<'a>,
attachment_paths: &BTreeSet<String>,
) -> Option<(String, String)> {
let NodeValue::Link(link) = &node.data.borrow().value else {
return None;
};
parse_markdown_attachment_link(&format!("[{}]({})", collect_plain_text(node), link.url))
parse_markdown_attachment_link_with_paths(
&format!("[{}]({})", collect_plain_text(node), link.url),
attachment_paths,
)
}
fn link_mindmap<'a>(node: &'a AstNode<'a>) -> Option<(String, String)> {