checkpoint before gfm ast parser design
This commit is contained in:
@@ -6,6 +6,10 @@ use crate::routes::documents::{
|
||||
load_document_content_result, load_document_meta_result, DocumentContentQuery,
|
||||
DocumentMetaQuery,
|
||||
};
|
||||
use crate::routes::local_folder_source::{
|
||||
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
|
||||
resolve_local_markdown_page_aggregate,
|
||||
};
|
||||
use crate::routes::query_support::execute_runtime_query_against_data;
|
||||
use crate::routes::snapshot_support::{
|
||||
execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec,
|
||||
@@ -39,6 +43,8 @@ const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id";
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentShellQuery {
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -59,6 +65,8 @@ pub async fn document_page_shell(
|
||||
&context,
|
||||
&document_id,
|
||||
query.workspace_id.as_deref(),
|
||||
query.source_kind.as_deref(),
|
||||
query.root_uri.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let title = aggregate.head.title.as_str();
|
||||
@@ -73,14 +81,28 @@ pub async fn document_page_shell(
|
||||
)
|
||||
.await;
|
||||
apply_active_page(&mut workspace_projection, Some(&document_id));
|
||||
let sidebar_tree_html =
|
||||
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let file_tree_html =
|
||||
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let is_local_folder = query
|
||||
.source_kind
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
== Some("local_folder");
|
||||
let (sidebar_tree_html, file_tree_html) = if is_local_folder {
|
||||
let root_uri = query.root_uri.as_deref().unwrap_or_default();
|
||||
(
|
||||
render_local_sidebar_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
|
||||
render_local_file_tree_html(root_uri, Some(&document_id)).unwrap_or_default(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
load_sidebar_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
load_file_tree_html(state.config(), &context, &workspace_id, Some(&document_id))
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
let workspace_sidebar_html = render_workspace_shell_sidebar_html(
|
||||
&workspace_projection,
|
||||
Some(sidebar_tree_html.as_str()),
|
||||
@@ -92,7 +114,12 @@ pub async fn document_page_shell(
|
||||
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
|
||||
.unwrap_or_else(|_| "null".to_string());
|
||||
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
|
||||
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
|
||||
let bootstrap_json = build_editor_bootstrap_json(
|
||||
&aggregate,
|
||||
&context,
|
||||
query.source_kind.as_deref(),
|
||||
query.root_uri.as_deref(),
|
||||
);
|
||||
let body_content = crate::ssr::render_view(leptos::view! {
|
||||
<DocumentPage
|
||||
title={title.to_string()}
|
||||
@@ -139,11 +166,21 @@ pub async fn document_page_shell(
|
||||
pub(crate) fn build_editor_bootstrap_json(
|
||||
aggregate: &PageAggregate,
|
||||
context: &RequestContext,
|
||||
source_kind: Option<&str>,
|
||||
root_uri: Option<&str>,
|
||||
) -> String {
|
||||
serde_json::to_string(&json!({
|
||||
"schema": "mnote.editor_bootstrap.v1",
|
||||
"documentId": aggregate.identity.document_id,
|
||||
"workspaceId": aggregate.identity.workspace_id,
|
||||
"sourceKind": source_kind
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("convex_workspace"),
|
||||
"rootUri": root_uri
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(""),
|
||||
"pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__",
|
||||
"saveEndpoint": "/api/documents/save",
|
||||
"titleEndpoint": "/api/documents/title",
|
||||
@@ -165,7 +202,10 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
input.setAttribute('data-title-controller', CONTRACT);
|
||||
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
|
||||
const documentId = (input.getAttribute('data-document-id') || document.body?.dataset.documentId || '').trim();
|
||||
const workspaceId = (input.getAttribute('data-workspace-id') || new URLSearchParams(window.location.search).get('workspaceId') || '').trim();
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const workspaceId = (input.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim();
|
||||
const sourceKind = (query.get('sourceKind') || '').trim();
|
||||
const rootUri = (query.get('rootUri') || '').trim();
|
||||
let lastSavedTitle = input.value.trim() || '无标题';
|
||||
let saving = false;
|
||||
|
||||
@@ -233,6 +273,8 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
|
||||
body: JSON.stringify({
|
||||
documentId,
|
||||
workspaceId: workspaceId || null,
|
||||
sourceKind: sourceKind || undefined,
|
||||
rootUri: rootUri || undefined,
|
||||
title,
|
||||
commandName: 'page.head.updateTitle',
|
||||
}),
|
||||
@@ -317,6 +359,37 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return '';
|
||||
};
|
||||
|
||||
const legacyStylesToTiptapMarks = (styles) => {
|
||||
if (!styles || typeof styles !== 'object') return [];
|
||||
const marks = [];
|
||||
if (styles.bold) marks.push({ type: 'bold' });
|
||||
if (styles.italic) marks.push({ type: 'italic' });
|
||||
if (styles.underline) marks.push({ type: 'underline' });
|
||||
if (styles.strike || styles.strikethrough) marks.push({ type: 'strike' });
|
||||
if (styles.code || styles.inlineCode) marks.push({ type: 'code' });
|
||||
const href = typeof styles.link === 'string' && styles.link.trim()
|
||||
? styles.link.trim()
|
||||
: typeof styles.href === 'string' && styles.href.trim()
|
||||
? styles.href.trim()
|
||||
: '';
|
||||
if (href) marks.push({ type: 'link', attrs: { href } });
|
||||
return marks;
|
||||
};
|
||||
|
||||
const legacyInlineContentToTiptap = (value) => {
|
||||
if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : [];
|
||||
if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap);
|
||||
if (value && typeof value === 'object') {
|
||||
const text = typeof value.text === 'string' ? value.text : '';
|
||||
if (text) {
|
||||
const marks = legacyStylesToTiptapMarks(value.styles);
|
||||
return [{ type: 'text', text, ...(marks.length ? { marks } : {}) }];
|
||||
}
|
||||
return legacyInlineContentToTiptap(value.content || value.contentNodes);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const legacyBlockToTiptap = (block, index = 0) => {
|
||||
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
|
||||
const blockId = typeof block?.id === 'string' && block.id.trim()
|
||||
@@ -324,8 +397,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: typeof block?.blockId === 'string' && block.blockId.trim()
|
||||
? block.blockId.trim()
|
||||
: `block-${index + 1}`;
|
||||
const text = flattenText(block?.content);
|
||||
const content = text ? [{ type: 'text', text }] : [];
|
||||
const content = legacyInlineContentToTiptap(block?.content ?? block?.contentNodes);
|
||||
const textAlign = typeof block?.props?.textAlign === 'string'
|
||||
? block.props.textAlign
|
||||
: typeof block?.props?.text_align === 'string'
|
||||
@@ -337,10 +409,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: [];
|
||||
const withListChildren = (itemType, listType, attrs = {}) => ({
|
||||
type: listType,
|
||||
attrs: { blockId, ...attrs },
|
||||
attrs: { blockId },
|
||||
content: [{
|
||||
type: itemType,
|
||||
attrs: { blockId },
|
||||
attrs: { blockId, ...attrs },
|
||||
content: [
|
||||
{ type: 'paragraph', attrs: { blockId }, content },
|
||||
...nestedChildren,
|
||||
@@ -458,7 +530,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return node.content.flatMap((child) => {
|
||||
if (child?.type === 'text') {
|
||||
const text = typeof child.text === 'string' ? child.text : '';
|
||||
return text ? [{ type: 'text', text }] : [];
|
||||
if (!text) return [];
|
||||
const styles = {};
|
||||
for (const mark of Array.isArray(child.marks) ? child.marks : []) {
|
||||
if (mark?.type === 'bold') styles.bold = true;
|
||||
if (mark?.type === 'italic') styles.italic = true;
|
||||
if (mark?.type === 'underline') styles.underline = true;
|
||||
if (mark?.type === 'strike') styles.strike = true;
|
||||
if (mark?.type === 'code') styles.code = true;
|
||||
if (mark?.type === 'link') {
|
||||
const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : '';
|
||||
if (href) styles.link = href;
|
||||
}
|
||||
}
|
||||
return [{ type: 'text', text, ...(Object.keys(styles).length ? { styles } : {}) }];
|
||||
}
|
||||
if (child?.type === 'hardBreak') {
|
||||
return [{ type: 'text', text: '\n' }];
|
||||
@@ -544,7 +629,16 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
? { ...(block.props || {}) }
|
||||
: undefined,
|
||||
content: Array.isArray(block.contentNodes)
|
||||
? block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')
|
||||
? block.contentNodes.map((node) => {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
const text = typeof node.text === 'string' ? node.text : '';
|
||||
if (!text) return null;
|
||||
return {
|
||||
type: 'text',
|
||||
text,
|
||||
...(node.styles && typeof node.styles === 'object' ? { styles: node.styles } : {}),
|
||||
};
|
||||
}).filter(Boolean)
|
||||
: '',
|
||||
}));
|
||||
|
||||
@@ -577,6 +671,21 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
pageOptions: aggregate.layout?.pageOptions || {},
|
||||
};
|
||||
|
||||
const clearEmbeddedLocalDraft = () => {
|
||||
if (bootstrap.sourceKind !== 'local_folder') return;
|
||||
try {
|
||||
const storage = window.localStorage;
|
||||
const base = 'mnote.leptos-tiptap-spike.document';
|
||||
const keys = [
|
||||
`${base}:${bootstrap.workspaceId}:${bootstrap.documentId}`,
|
||||
`${base}:${bootstrap.documentId}`,
|
||||
];
|
||||
for (const key of keys) storage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.warn('mnote local folder 草稿清理失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
let saveTimer = 0;
|
||||
let lastSavedSerialized = '';
|
||||
const normalizeBridgeValue = (value) => {
|
||||
@@ -623,6 +732,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
body: JSON.stringify({
|
||||
documentId: bootstrap.documentId,
|
||||
workspaceId: bootstrap.workspaceId,
|
||||
sourceKind: bootstrap.sourceKind,
|
||||
rootUri: bootstrap.rootUri,
|
||||
revision: editorMeta.revision,
|
||||
conflictDetectionKey: editorMeta.conflictDetectionKey,
|
||||
editorDocument,
|
||||
@@ -687,6 +798,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
throw new Error('island runtime 导出不完整');
|
||||
}
|
||||
await runtime.default(wasmUrl);
|
||||
clearEmbeddedLocalDraft();
|
||||
const mountId = runtime.mount(root, mountOptions);
|
||||
root.setAttribute('data-runtime-mount-id', String(mountId));
|
||||
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
|
||||
@@ -812,6 +924,8 @@ pub async fn documents_page_compat(
|
||||
&context,
|
||||
&document_id,
|
||||
query.workspace_id.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let projection_owner = aggregate.source_label();
|
||||
@@ -847,6 +961,8 @@ pub async fn page_aggregate(
|
||||
&context,
|
||||
&document_id,
|
||||
query.workspace_id.as_deref(),
|
||||
query.source_kind.as_deref(),
|
||||
query.root_uri.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let projection_owner = aggregate.source_label();
|
||||
@@ -876,7 +992,19 @@ pub(crate) async fn build_page_aggregate_snapshot(
|
||||
context: &RequestContext,
|
||||
document_id: &str,
|
||||
workspace_id: Option<&str>,
|
||||
source_kind: Option<&str>,
|
||||
root_uri: Option<&str>,
|
||||
) -> Result<PageAggregate, WebError> {
|
||||
if source_kind.map(str::trim).filter(|value| !value.is_empty()) == Some("local_folder") {
|
||||
let root_uri = root_uri
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri")
|
||||
})?;
|
||||
return resolve_local_markdown_page_aggregate(root_uri, document_id);
|
||||
}
|
||||
|
||||
let meta = load_document_meta_result(
|
||||
state,
|
||||
context,
|
||||
@@ -944,6 +1072,7 @@ fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) {
|
||||
if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) {
|
||||
headers.insert(name, HeaderValue::from_static(shell));
|
||||
}
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
}
|
||||
|
||||
pub(crate) fn escape_html(value: &str) -> String {
|
||||
@@ -1123,11 +1252,35 @@ pub(crate) async fn load_file_tree_html(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_local_sidebar_tree_html(
|
||||
root_uri: &str,
|
||||
active_document_id: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let snapshot = load_local_folder_page_tree_snapshot(root_uri)?;
|
||||
let rows = collect_page_tree_render_rows(&snapshot.projection);
|
||||
Ok(render_initial_page_tree_html(&PageTreeInitialRenderInput {
|
||||
rows,
|
||||
active_node_id: active_document_id.map(ToOwned::to_owned),
|
||||
focused_node_id: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn render_local_file_tree_html(
|
||||
root_uri: &str,
|
||||
active_document_id: Option<&str>,
|
||||
) -> Result<String, WebError> {
|
||||
let snapshot = load_local_folder_file_tree_snapshot(root_uri)?;
|
||||
let rows = collect_filetree_render_rows(&snapshot.projection, active_document_id);
|
||||
Ok(render_initial_filetree_html(&FileTreeInitialRenderInput {
|
||||
rows,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::http::{header, Request, StatusCode};
|
||||
use serde_json::Value;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
@@ -1262,6 +1415,13 @@ mod tests {
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("rust-kernel")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-store")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
@@ -1273,4 +1433,130 @@ mod tests {
|
||||
assert_eq!(payload["result"]["identity"]["documentId"], "doc_1");
|
||||
assert_eq!(payload["result"]["body"]["revision"], 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_aggregate_endpoint_returns_local_markdown_readonly_snapshot() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-page-aggregate-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).expect("create local root");
|
||||
std::fs::write(
|
||||
root.join("README.md"),
|
||||
"---\ntitle: Local Aggregate\n---\n# Local Heading\n正文内容\n",
|
||||
)
|
||||
.expect("write local md");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/api/page-aggregate/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}"
|
||||
))
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-store")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["schema"], "mnote.page_aggregate.v1");
|
||||
assert_eq!(
|
||||
payload["result"]["identity"]["documentId"],
|
||||
"local-md:README.md"
|
||||
);
|
||||
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
|
||||
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
|
||||
assert_eq!(payload["result"]["body"]["revision"], 0);
|
||||
assert!(payload["result"]["body"]["content"]
|
||||
.to_string()
|
||||
.contains("Local Heading"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_renders_local_markdown_with_same_sidebar_surfaces() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("mnote-local-document-shell-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("docs")).expect("create local docs");
|
||||
std::fs::write(root.join("README.md"), "# Local Shell\n正文\n").expect("write root md");
|
||||
std::fs::write(root.join("docs").join("child.md"), "# Child Page\n")
|
||||
.expect("write child md");
|
||||
std::fs::write(root.join("asset.png"), b"png").expect("write asset");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/documents/local-md:README.md?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-store")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("Local Shell"));
|
||||
assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\""));
|
||||
assert!(html.contains("Child Page"));
|
||||
assert!(html.contains("asset.png"));
|
||||
assert!(html.contains("data-row-kind=\"markdown\""));
|
||||
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn document_shell_bootstrap_preserves_inline_mark_conversion() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/documents/doc_1?workspaceId=ws_demo")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("html");
|
||||
assert!(html.contains("legacyInlineContentToTiptap"));
|
||||
assert!(html.contains("legacyStylesToTiptapMarks"));
|
||||
assert!(html.contains("marks.push({ type: 'code' })"));
|
||||
assert!(html.contains("marks.push({ type: 'link', attrs: { href } })"));
|
||||
assert!(html.contains("styles.link = href"));
|
||||
assert!(html.contains("contentNodes.map((node) => {"));
|
||||
assert!(!html.contains(
|
||||
"block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user