diff --git a/app/application.ml b/app/application.ml index 941deb5..4956f1c 100644 --- a/app/application.ml +++ b/app/application.ml @@ -2141,6 +2141,10 @@ let render_source state = Reference_sources.find_opt id state.reference_sources) ;; +let render_title state = + Journal_model.Title.render ~lookup:(fun id -> Reference_sources.find_opt id state.reference_sources) +;; + let favorites_view ~render_source ~render_media @@ -2466,6 +2470,7 @@ let timeline_page ~timeline_store ~on_region ~render_source + ~render_title ~render_media ~render_row_media ~platform @@ -2523,6 +2528,7 @@ let timeline_page ?store:timeline_store ~on_region ~render_source + ~render_title ~render_media:render_row_media ~state:timeline_state ~day_presentation @@ -3057,11 +3063,13 @@ let detail_page let field ~key name value = V.column ~spacing:4. ~alignment:Leading [ V.of_lui (Lui_elements.text ~value:name ~style_class:"caption" ~foreground:"secondary" []) - ; Journal_header.detail_text ~id:key ~depth:2 (render_source state value) ] + ; Journal_header.detail_text ~title:(render_title state value) + ~on_reference:(fun id -> local.send (Detail_open id)) ~id:key ~depth:2 value ] in let text = if property_value then field ~key:id (Option.value property_name ~default:"Property") source - else Journal_header.detail_text ~id ~depth (render_source state source) + else Journal_header.detail_text ~title:(render_title state source) + ~on_reference:(fun id -> local.send (Detail_open id)) ~id ~depth source in let fields = List.map (fun (name, value) -> field ~key:("detail-property:" ^ Journal_model.id block ^ ":" ^ name) name value) named_sources in @@ -6685,6 +6693,7 @@ let start ~timeline_store:(Some timeline_store) ~on_region:on_view_region ~render_source:(render_source state) + ~render_title:(render_title state) ~render_media: (media_label ~store:media_store ~on_region:on_view_region state dispatch) ~render_row_media:(fun ~title ~root ~image_children child -> @@ -7388,6 +7397,7 @@ module For_testing = struct ~timeline_store:None ~on_region:(fun _ -> ()) ~render_source:(render_source initial_state) + ~render_title:(render_title initial_state) ~render_media: (media_label ~store:(Journal_media_view.Store.create ()) diff --git a/app/dune b/app/dune index f480954..f7eb75c 100644 --- a/app/dune +++ b/app/dune @@ -41,6 +41,7 @@ rrbvec base core + mldoc lui ocaml-signal datascript-ocaml-native diff --git a/app/journal_header.ml b/app/journal_header.ml index e9c7578..4be8f16 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -99,14 +99,36 @@ let bottom_controls ~key ~controls body = |> V.Body.static ;; -let detail_text ~id ~depth text = - Ui.Native_widget.widget - chrome - ~key:(Ui.Key.string ("detail-label:" ^ id)) - ~props:(props ~mode:"detail-text" ~id ~level:(min 2 depth) ~text ()) - ~on_event:(fun _ -> ()) - ~children:[] - () +(* The same wire representation drives bounded previews and unbounded Detail. *) +let title_text ?on_reference ~mode ?id ~level ?max_lines title = + let fields = ["mode", `String mode; "text", `String (Journal_model.Title.plain title); + "runs", Journal_model.Title.to_yojson title; "level", `Int level] + @ Option.to_list (Option.map (fun id -> "id", `String id) id) + @ Option.to_list (Option.map (fun n -> "maxLines", `Int n) max_lines) in + let on_event (event : Journal_lui_native.event) = + match on_reference with + | None -> () + | Some open_reference -> + (try match Yojson.Safe.from_string event.payload with + | `Assoc [("reference", `String id)] + when Journal_validation.is_uuid id + && List.exists (fun (run : Journal_model.Title.run) -> run.reference = Some id) title -> + open_reference id + | _ -> () with _ -> ()) + in + V.of_lui (Journal_lui_native.mount ?key:(Option.map (fun id -> "detail-label:" ^ id) id) + ~payload:(Yojson.Safe.to_string (`Assoc fields)) ~children:[] ~on_event + Journal_lui_native.chrome_identifier) +;; + +let detail_text ?title ?on_reference ~id ~depth text = + title_text ?on_reference ~mode:"detail-text" ~id ~level:(min 2 depth) + (match title with Some title -> title | None -> Journal_model.Title.render text) +;; + +let bounded_text ?on_reference ~child title = + title_text ?on_reference ~mode:"bounded-text" ~level:(if child then 1 else 0) + ~max_lines:(if child then 2 else 4) title ;; let detail_row ~depth body = diff --git a/app/journal_header.mli b/app/journal_header.mli index 4dcb8b1..c7ac76f 100644 --- a/app/journal_header.mli +++ b/app/journal_header.mli @@ -67,7 +67,8 @@ val feedback val date_header : ?relative_label:string -> title:string -> unit -> Journal_view.View.t -val detail_text : id:string -> depth:int -> string -> Journal_view.View.t +val detail_text : ?title:Journal_model.Title.t -> ?on_reference:(string -> unit) -> id:string -> depth:int -> string -> Journal_view.View.t +val bounded_text : ?on_reference:(string -> unit) -> child:bool -> Journal_model.Title.t -> Journal_view.View.t val detail_row : depth:int -> Journal_view.View.t -> Journal_view.View.t val detail diff --git a/app/journal_lui_native.ml b/app/journal_lui_native.ml index fe76720..136f402 100644 --- a/app/journal_lui_native.ml +++ b/app/journal_lui_native.ml @@ -39,7 +39,7 @@ let registry = in register_component registry - (component chrome_identifier apple_profiles true children [ payload_property ] []); + (component chrome_identifier apple_profiles true children [ payload_property ] [ event_schema ]); register_component registry (component diff --git a/app/journal_model.ml b/app/journal_model.ml index 65eed80..391fbff 100644 --- a/app/journal_model.ml +++ b/app/journal_model.ml @@ -309,17 +309,6 @@ let fold_references source ~init ~f = scan 0 init ;; -let reference_ids source = - let seen = Hashtbl.create 8 in - fold_references source ~init:[] ~f:(fun ids _ id -> - if Hashtbl.mem seen id - then ids - else ( - Hashtbl.add seen id (); - id :: ids)) - |> List.rev -;; - let maximum_reference_depth = 16 let render_references ~lookup source = @@ -365,3 +354,356 @@ let render_references ~lookup source = in Option.value (expand [] 0 source) ~default:source ;; + + +(* Parse once per source, independently of graph identity. Resolved output is + never cached: graph-session sources and their epoch fences remain authoritative. *) +module Title = struct + module I = Mldoc.Inline + module B = Mldoc.Type + + type run = + { text : string + ; styles : string list + ; url : string option + ; reference : string option + } + [@@deriving yojson] + + type t = run list + + type atom = + | Text of string + | Span of string * atom list + | External of string * atom list + | Reference of string * string * atom list option + + let config = + Mldoc.Conf.of_yojson + (`Assoc + [ "format", `String "Markdown" + ; "keep_line_break", `Bool true + ; "parse_marker", `Bool false + ; "parse_priority", `Bool false + ]) + |> Result.get_ok + ;; + + let reference_id = function + | I.Page_ref id when Journal_validation.is_uuid id -> Some (String.lowercase_ascii id) + | _ -> None + ;; + + let external_url url = + let value = + match url with + | I.Complex { protocol; link } + when String.lowercase_ascii protocol = "https" + || String.lowercase_ascii protocol = "http" -> + String.lowercase_ascii protocol + ^ ":" + ^ if String.starts_with ~prefix:"//" link then link else "//" ^ link + | _ -> I.string_of_url url + in + match + String.lowercase_ascii (Uri.scheme (Uri.of_string value) |> Option.value ~default:"") + with + | "https" | "http" | "mailto" -> Some value + | _ -> None + ;; + + exception Title_budget + + let spend budget = + decr budget; + if !budget < 0 then raise Title_budget + ;; + + let rec inline budget value = + spend budget; + match value with + | I.Plain text | Spaces text | Verbatim text -> Text text + | Code text -> Span ("code", [ Text text ]) + | Break_Line | Hard_Break_Line -> Text "\n" + | Emphasis (kind, content) -> + Span + ( (match kind with + | `Bold -> "bold" + | `Italic -> "italic" + | `Underline -> "underline" + | `Strike_through -> "strike" + | `Highlight -> "highlight") + , List.map (inline budget) content ) + | Link link -> + let labeled = + match link.label with + | [] | [ Plain "" ] -> false + | _ -> true + in + let label = + if labeled + then List.map (inline budget) link.label + else + [ Text + (Option.value (external_url link.url) ~default:(I.string_of_url link.url)) + ] + in + (match reference_id link.url with + | Some id -> Reference (id, link.full_text, if labeled then Some label else None) + | None -> + (match external_url link.url with + | Some url when not (String.starts_with ~prefix:"!" link.full_text) -> + External (url, label) + | _ -> Text link.full_text)) + | Tag content -> Span ("tag", Text "#" :: List.map (inline budget) content) + | Subscript content | Superscript content -> + Span ("", List.map (inline budget) content) + | Entity entity -> Text entity.unicode + | Latex_Fragment (Inline text | Displayed text) -> Text text + | Target text | Radio_Target text | Inline_Html text | Inline_Hiccup text -> Text text + | Export_Snippet (_, text) -> Text text + | Inline_Source_Block block -> Span ("code", [ Text block.code ]) + | Email email -> Text (email.local_part ^ "@" ^ email.domain) + | Nested_link value -> Text value.content + | Cookie (Percent value) -> Text (Printf.sprintf "[%d%%]" value) + | Cookie (Absolute (current, total)) -> Text (Printf.sprintf "[%d/%d]" current total) + | Footnote_Reference value -> Text ("[^" ^ value.name ^ "]") + | Timestamp (Date value) -> Text (Mldoc.Timestamp.to_string value) + | Macro value -> + Text + ("{{" + ^ value.name + ^ (match value.arguments with + | [] -> "" + | args -> " " ^ String.concat ", " args) + ^ "}}") + | Timestamp (Scheduled value) -> Text ("SCHEDULED: " ^ Mldoc.Timestamp.to_string value) + | Timestamp (Deadline value) -> Text ("DEADLINE: " ^ Mldoc.Timestamp.to_string value) + | Timestamp (Closed value) -> Text ("CLOSED: " ^ Mldoc.Timestamp.to_string value) + | Timestamp (Clock (Started value)) -> + Text ("CLOCK: " ^ Mldoc.Timestamp.to_string value) + | Timestamp (Clock (Stopped value)) -> + Text + ("CLOCK: " + ^ Mldoc.Timestamp.to_string value.start + ^ "--" + ^ Mldoc.Timestamp.to_string value.stop) + | Timestamp (Range value) -> + Text + (Mldoc.Timestamp.to_string value.start + ^ "--" + ^ Mldoc.Timestamp.to_string value.stop) + ;; + + let inlines budget values = List.map (fun (value, _) -> inline budget value) values + + let rec blocks budget values = + let rec join = function + | [] -> [] + | [ value ] -> block budget value + | value :: (next :: _ as rest) -> + block budget value + @ (match value, next with + | B.Paragraph_Sep _, _ | _, B.Paragraph_Sep _ -> [] + | _ -> [ Text "\n" ]) + @ join rest + in + join values + + and block budget value = + spend budget; + match value with + | B.Paragraph content -> inlines budget content + | Heading heading -> inlines budget heading.title + | Paragraph_Sep _ -> [ Text "\n\n" ] + | Paragraph_line text -> [ Text text ] + | Quote content -> blocks budget content + | List items -> List.concat_map (item budget) items + | Src source -> [ Span ("code", [ Text (String.concat "" source.lines) ]) ] + | Example lines -> [ Span ("code", [ Text (String.concat "" lines) ]) ] + | Property_Drawer _ | Drawer _ | Comment _ | CommentBlock _ -> [] + | Footnote_Definition (_, content) -> inlines budget content + | Table table -> + let rows = Option.to_list table.header @ List.concat table.groups in + List.concat_map + (fun row -> + List.concat_map + (fun column -> List.map (inline budget) column @ [ Text " " ]) + row + @ [ Text "\n" ]) + rows + | Custom (_, _, content, _) -> blocks budget content + | Displayed_Math text | Raw_Html text | Hiccup text -> [ Text text ] + | Latex_Fragment (Inline text | Displayed text) -> [ Text text ] + | Latex_Environment (_, _, text) | Export (_, _, text) -> [ Text text ] + | Horizontal_Rule -> [ Text "—" ] + | Directive (name, value) -> [ Text (name ^ ": " ^ value) ] + | Results -> [] + + and item budget value = + [ Text + (match value.B.number with + | Some n -> string_of_int n ^ ". " + | None -> "• ") + ] + @ blocks budget value.content + @ [ Text "\n" ] + @ List.concat_map (item budget) value.items + ;; + + let cache = Hashtbl.create 128 + let order = Queue.create () + let bytes = ref 0 + let mutex = Mutex.create () + let maximum_cache_entries = 512 + let maximum_cache_bytes = 2 * 1024 * 1024 + + (* A complexity admission check, not a second Markdown parser. mldoc's + nested-link backtracking can otherwise spend seconds on a short title. *) + let safe_to_parse source = + let depth = ref 0 + and markup = ref 0 + and safe = ref true in + String.iter + (function + | '[' | '(' | '{' -> + incr depth; + incr markup; + if !depth > 32 || !markup > 4096 then safe := false + | ']' | ')' | '}' -> depth := max 0 (!depth - 1) + | _ -> ()) + source; + !safe + ;; + + let parse source = + if String.length source > 65536 + then [ Text source ] + else ( + Mutex.lock mutex; + Fun.protect + ~finally:(fun () -> Mutex.unlock mutex) + (fun () -> + match Hashtbl.find_opt cache source with + | Some value -> value + | None -> + let value = + try + if safe_to_parse source + then Mldoc.Parser.parse config source |> List.map fst |> blocks (ref 512) + else [ Text source ] + with + | _ -> [ Text source ] + in + while + Hashtbl.length cache >= maximum_cache_entries + || !bytes + String.length source > maximum_cache_bytes + do + let old = Queue.pop order in + Hashtbl.remove cache old; + bytes := !bytes - String.length old + done; + Hashtbl.add cache source value; + Queue.push source order; + bytes := !bytes + String.length source; + value)) + ;; + + let reference_ids source = + let seen = Hashtbl.create 8 + and ids = ref [] in + let rec visit = function + | Reference (id, _, _) -> + if not (Hashtbl.mem seen id) + then ( + Hashtbl.add seen id (); + ids := id :: !ids) + | Span (_, content) | External (_, content) -> List.iter visit content + | Text _ -> () + in + List.iter visit (parse source); + List.rev !ids + ;; + + let render ?(lookup = fun _ -> None) source = + let remaining = ref 256 + and work = ref 512 in + let rec expand path depth styles url reference atoms = + let output = ref [] + and size = ref 0 + and cyclic = ref false in + let emit runs = + List.iter + (fun run -> + size := !size + String.length run.text; + output := run :: !output) + runs + in + List.iter + (fun atom -> + decr work; + if !work >= 0 + then ( + match atom with + | Text text -> emit [ { text; styles; url; reference } ] + | Span (style, content) -> + let runs, cycle = + expand + path + depth + (if style = "" then styles else style :: styles) + url + reference + content + in + cyclic := !cyclic || cycle; + emit runs + | External (target, content) -> + let runs, cycle = + expand path depth styles (Some target) reference content + in + cyclic := !cyclic || cycle; + emit runs + | Reference (id, token, label) -> + let fallback () = emit [ { text = token; styles; url; reference } ] in + if List.mem id path + then ( + cyclic := true; + fallback ()) + else if depth >= maximum_reference_depth || !remaining = 0 + then fallback () + else ( + match lookup id with + | None -> fallback () + | Some target -> + decr remaining; + let content = Option.value label ~default:(parse target) in + let runs, cycle = + expand (id :: path) (depth + 1) styles None + (match reference with Some _ -> reference | None -> Some id) content + in + if cycle + then ( + cyclic := true; + fallback ()) + else if + !size + + List.fold_left (fun n run -> n + String.length run.text) 0 runs + > 65536 + then fallback () + else emit runs))) + atoms; + List.rev !output, !cyclic + in + let runs, _ = expand [] 0 [] None None (parse source) in + if + List.fold_left (fun n run -> n + String.length run.text) 0 runs > 65536 || !work < 0 + then [ { text = source; styles = []; url = None; reference = None } ] + else runs + ;; + + let plain runs = String.concat "" (List.map (fun run -> run.text) runs) + let to_yojson runs = `List (List.map run_to_yojson runs) +end + +let reference_ids = Title.reference_ids diff --git a/app/journal_model.mli b/app/journal_model.mli index 2b97faa..b2874eb 100644 --- a/app/journal_model.mli +++ b/app/journal_model.mli @@ -83,3 +83,13 @@ val with_properties : ?localtime:(float -> Unix.tm) -> t -> properties:Logseq_db (** Visible named property values. UUID values use the existing bounded reference syntax. *) val property_sources : ?localtime:(float -> Unix.tm) -> Logseq_db_types.Graph_types.property_summary list -> (string * string) list + + +(** mldoc owns syntax; native views consume these shared presentation runs. *) +module Title : sig + type run = { text : string; styles : string list; url : string option; reference : string option } + type t = run list + val render : ?lookup:(string -> string option) -> string -> t + val plain : t -> string + val to_yojson : t -> Yojson.Safe.t +end diff --git a/app/journal_row.ml b/app/journal_row.ml index a9e4666..ec6e35d 100644 --- a/app/journal_row.ml +++ b/app/journal_row.ml @@ -2,24 +2,9 @@ module Ui = Journal_view module V = Ui.View module L = Lui_elements -(* OCaml owns the preview policy; native Text owns visual wrapping at the - available width and Dynamic Type size. Keep the full source for Detail. *) -let preview_text ?(child = false) source = - V.of_lui - (Journal_lui_native.mount - Journal_lui_native.chrome_identifier - ~payload: - (Yojson.Basic.to_string - (`Assoc - [ "mode", `String "bounded-text" - ; "text", `String source - ; "maxLines", `Int (if child then 2 else 4) - ; "level", `Int (if child then 1 else 0) - ])) - ~children:[]) -;; - -let body ?(child = false) source = if String.trim source = "" then V.column [] else preview_text ~child source +let body ?on_reference ?(child = false) title = + if String.trim (Journal_model.Title.plain title) = "" then V.column [] + else Journal_header.bounded_text ?on_reference ~child title let metadata block = let status = Journal_model.task_state block in @@ -61,6 +46,8 @@ let metadata block = let view ?(render_source = Fun.id) + ?render_title + ?on_reference ~render_media ~show_timestamp (entry : Journal_graph_projection.timeline_entry) @@ -89,12 +76,15 @@ let view (id, file_type) :: image_children | _ -> image_children in + let render source = match render_title with + | Some render -> render source + | None -> Journal_model.Title.render (render_source source) in let source = if is_image_asset (Journal_model.asset_file_type block) - then "" - else render_source (Journal_model.source block) + then [] + else render (Journal_model.source block) in - let content = V.column ~spacing:8. ~alignment:Leading (body source :: metadata block) in + let content = V.column ~spacing:8. ~alignment:Leading (body ?on_reference source :: metadata block) in let labels = [ render_media ~title:(Journal_model.source block) ~root:id ~image_children content ] in @@ -106,7 +96,7 @@ let view ~title:summary.source ~root:summary.block_id ~image_children:[] - (body ~child:true (render_source summary.source))) + (body ?on_reference ~child:true (render summary.source))) text_children in let labels = diff --git a/app/journal_row.mli b/app/journal_row.mli index 796193d..a4e33ae 100644 --- a/app/journal_row.mli +++ b/app/journal_row.mli @@ -1,5 +1,7 @@ val view : ?render_source:(string -> string) + -> ?render_title:(string -> Journal_model.Title.t) + -> ?on_reference:(string -> unit) -> render_media: (title:string -> root:string diff --git a/app/journal_timeline.ml b/app/journal_timeline.ml index a099623..a2d783d 100644 --- a/app/journal_timeline.ml +++ b/app/journal_timeline.ml @@ -183,6 +183,7 @@ let view ?store ?(on_region = fun _ -> ()) ~render_source + ?render_title ~render_media ~state ~day_presentation @@ -231,7 +232,10 @@ let view | Top_level entry -> let render entry show_timestamp = on_region "timeline-item-build"; - Journal_row.view ~render_source ~render_media ~show_timestamp entry + Journal_row.view ~render_source ?render_title + ?on_reference:(if actions_enabled then Some (fun id -> + Ui.Event.Handler.Private.invoke on_open_block (Ui.Event.Payload.Text id)) else None) + ~render_media ~show_timestamp entry in (match store with | None -> render entry (should_show_timestamp ~today ~previous_slot slot) diff --git a/app/journal_timeline.mli b/app/journal_timeline.mli index f898cad..64802f4 100644 --- a/app/journal_timeline.mli +++ b/app/journal_timeline.mli @@ -19,6 +19,7 @@ val view -> ?store:Store.t -> ?on_region:(string -> unit) -> render_source:(string -> string) + -> ?render_title:(string -> Journal_model.Title.t) -> render_media: (title:string -> root:string diff --git a/apple-tests/native-list/JournalMldocTitleAcceptance.swift b/apple-tests/native-list/JournalMldocTitleAcceptance.swift new file mode 100644 index 0000000..7417b55 --- /dev/null +++ b/apple-tests/native-list/JournalMldocTitleAcceptance.swift @@ -0,0 +1,108 @@ +import XCTest + +/// Run only against the disposable rich-titles fixture in the isolated probe. +final class JournalMldocTitleAcceptance: XCTestCase { + private let rootID = "detail-block:80000000-0000-4000-a000-000000000001" + private var app: XCUIApplication! + + override func setUp() { + continueAfterFailure = false + app = XCUIApplication(bundleIdentifier: "org.logseq.journal.mldoc-probe") + app.launchEnvironment = [ + "LOGSEQ_JOURNAL_E2EE_TEST_PRIVATE_KEY_STORAGE": "memory", + "LOGSEQ_JOURNAL_E2EE_TEST_WRAPPED_KEY_STORAGE": "memory", + ] + app.launchArguments = ["--fixture", "rich-titles.json", "--support-root", "support-rich", "--application-only", "--light-appearance"] + app.launch() + XCTAssertTrue(text("Mldoc fixture:").waitForExistence(timeout: 30)) + } + + private func text(_ prefix: String) -> XCUIElement { + app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", prefix)).firstMatch + } + + private func screenshot(_ name: String) { + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + print("MLDOC_AX_\(name)\n\(app.debugDescription)") + } + + func testTimelineAndDetailLayout() { + let root = text("Mldoc fixture:") + XCTAssertLessThanOrEqual(root.frame.height, 92) + XCTAssertLessThanOrEqual(text("Child labels:").frame.height, 44) + XCTAssertTrue(text("Child resolved:").exists) + XCTAssertTrue(text("Child missing:").exists) + XCTAssertFalse(text("Native child 04").exists) + XCTAssertTrue(text("Missing:").exists) + XCTAssertTrue(text("Cycle:").exists) + screenshot("timeline-rich-titles") + root.coordinate(withNormalizedOffset: CGVector(dx: 0.03, dy: 0.08)).tap() + let detail = app.staticTexts.matching(identifier: rootID).firstMatch + XCTAssertTrue(detail.waitForExistence(timeout: 10)) + XCTAssertGreaterThan(detail.frame.height, 180) + screenshot("detail-rich-root") + app.swipeUp() + app.swipeUp() + XCTAssertTrue(text("Child labels:").waitForExistence(timeout: 5)) + XCTAssertTrue(text("Child labels:").label.contains("THIRD LINE hidden in Timeline")) + screenshot("detail-rich-children") + app.navigationBars.buttons.firstMatch.tap() + XCTAssertTrue(text("Mldoc fixture:").waitForExistence(timeout: 5)) + app.swipeUp() + let cjk = text("中文长文") + XCTAssertTrue(cjk.waitForExistence(timeout: 5)) + XCTAssertGreaterThan(cjk.label.count, 300) + let previewHeight = cjk.frame.height + screenshot("timeline-long-cjk") + // Native extension accessibility frames include the host's reserved space; + // the screenshot verifies the four rendered lines, while Detail must grow. + cjk.tap() + let cjkDetail = app.staticTexts.matching(identifier: "detail-block:80000000-0000-4000-a000-000000000006").firstMatch + XCTAssertTrue(cjkDetail.waitForExistence(timeout: 10)) + XCTAssertGreaterThan(cjkDetail.frame.height, previewHeight * 3) + screenshot("detail-long-cjk") + } + + func testMissingAndCyclicReferencesKeepRowNavigation() { + XCTAssertEqual(app.links.matching(NSPredicate(format: "label CONTAINS %@", "000000000099")).count, 0) + text("Missing:").tap() + XCTAssertTrue(app.staticTexts.matching(identifier: "detail-block:80000000-0000-4000-a000-000000000003").firstMatch.waitForExistence(timeout: 10)) + screenshot("detail-missing-reference") + app.navigationBars.buttons.firstMatch.tap() + text("Cycle:").tap() + XCTAssertTrue(app.staticTexts.matching(identifier: "detail-block:80000000-0000-4000-a000-000000000004").firstMatch.waitForExistence(timeout: 10)) + screenshot("detail-cyclic-reference") + } + + func testLinksAndReferenceNavigation() { + screenshot("links-before-taps") + print("MLDOC_LINKS\n\(app.links.debugDescription)") + let reference = app.links.matching(NSPredicate(format: "label CONTAINS %@", "Referenced")).firstMatch + XCTAssertTrue(reference.waitForExistence(timeout: 5)) + reference.tap() + XCTAssertTrue(app.staticTexts.matching(identifier: "detail-block:82000000-0000-4000-a000-000000000001").firstMatch.waitForExistence(timeout: 10)) + screenshot("reference-target-detail") + app.navigationBars.buttons.firstMatch.tap() + let labeled = app.links["标签链接"].firstMatch + XCTAssertTrue(labeled.waitForExistence(timeout: 5)) + labeled.tap() + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + XCTAssertTrue(safari.wait(for: .runningForeground, timeout: 10)) + let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + attachment.name = "labeled-url-safari" + attachment.lifetime = .keepAlways + add(attachment) + app.activate() + let plain = app.links.matching(NSPredicate(format: "label CONTAINS %@", "example.com/plain")).firstMatch + XCTAssertTrue(plain.waitForExistence(timeout: 5)) + plain.tap() + XCTAssertTrue(safari.wait(for: .runningForeground, timeout: 10)) + let plainShot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + plainShot.name = "plain-url-safari" + plainShot.lifetime = .keepAlways + add(plainShot) + } +} diff --git a/docs/agent-guide/implemented/feature/2026-10-14-mldoc-block-titles.md b/docs/agent-guide/implemented/feature/2026-10-14-mldoc-block-titles.md new file mode 100644 index 0000000..7d44b96 --- /dev/null +++ b/docs/agent-guide/implemented/feature/2026-10-14-mldoc-block-titles.md @@ -0,0 +1,35 @@ +# Parse block titles with mldoc + +## Problem + +Timeline and Detail expose markup and raw URLs; string reference expansion misreads code and escapes and loses nested styling. + +## Decision + +Use official native mldoc AST in Journal_model.Title to emit styled runs with explicit URL/block targets. Timeline root/child and Detail share this adapter and graph-session reference cache. Swift maps runs to AttributedString and native Text for wrapping, truncation and clicks; it does not parse Markdown. Only UUID Page_ref nodes written as [[uuid]] are business references. Code, escapes and ordinary page names remain literal; missing/cyclic targets fall back to their tokens. Expanded nested labels retain their outer reference click target, while explicit URL fragments keep their URL. + +Admit at most 64 KiB source, 32 delimiter depth and 4096 open delimiters before parsing, then cap semantic conversion at 512 nodes. Keep a FIFO 512-entry/2 MiB source-byte syntax cache. Expansion is limited to 16 reference levels, 256 targets, 512 work nodes and 64 KiB output. Timeline keeps four parent lines and at most three smaller/fainter children of two lines; Detail retains status, properties, ten-level pagination, 100 ms loading and asset ownership. + +Pin the merged core package to b3e06c030b7396f7992ebe1c3a4f35f5e6983200 and XMLm fork to eb469d536e98c98f2754c0ff8813c92b3a17fe9f. The user approved the app/dune library entry and the exact dune-project/opam/locked dependency range. Only mldoc.dev and xmlm.dev are new locked packages; CLI, JS and benchmark packages are separate upstream packages. + +## Alternatives considered + +### Swift Markdown parser + +A Swift Markdown parser would duplicate Logseq syntax and diverge between views. The shared OCaml adapter preserves mldoc semantics and leaves native layout to SwiftUI. + +## Consequences + +The graph-session target/missing cache, pending deduplication and invalidation retain bounded hydration. Cold targets use the existing indexed single-UUID worker API; no render-driven DB read, all-graph query or new batch protocol is added. Complex source outside the parser/conversion/expansion budgets remains literal. The conservative parser admission also avoids mldoc nested-link backtracking on adversarial delimiter input. + +The Simulator continues to use the existing ARM64 complete-object restamp route with simulator crypto. Physical-device/full cross-runtime ABI acceptance remains outside this task. + +## Validation + +The merged core was fetched at its public immutable SHA, built as package mldoc and installed to a private prefix with the fixed XMLm fork. Native link manifests record source and archive hashes. The complete forced Dune @runtest plus native_embed.exe.o build returned exit 0: 21 Alcotest suites, 784 cases, plus standalone package checks. The existing sealed-interface test now locates its own build's three public CMI paths from the test executable; its full positive/negative compilation assertions are unchanged. Actual external-cwd RED then GREEN proves the path fix without a repository _build symlink. + +Three actual native XCTest cases pass on the isolated iPhone 13/iOS 26.1 synthetic fixture with the merged core. Screenshots verify parent four-line and child two-line truncation, three Timeline children, Detail full text, nested style, long bilingual/CJK text, missing/cyclic fallback and target Detail. Both URL forms open Safari. AX extension frames include host reservation; CJK acceptance compares Detail expansion and uses pixel inspection for four visible lines. Safari webpage content/loading is not asserted. No real graph changes, physical-device installation, push or PR occurred. All temporary evidence stays outside Git. + +## Test ownership + +The nested-reference click defect belongs to pure Title.render, which has no reducer-event boundary. Its public output regression failed before the minimal fix and passed afterward, without injected DB/effect results. The external-build path defect belongs to the existing public compiler test harness; its direct execution and unchanged compiler assertions exercise the narrowest boundary. Native XCTest is the separately requested feature acceptance. diff --git a/dune-project b/dune-project index 3bbcef4..ee12dad 100644 --- a/dune-project +++ b/dune-project @@ -82,6 +82,7 @@ (name logseq_journal) (allow_empty) (depends + (mldoc (= dev)) (rrbvec (= dev)) (ocaml (>= 5.4)) (dune (= 3.23.1)) diff --git a/logseq_journal.opam b/logseq_journal.opam index 4ecb5f1..5885053 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -20,6 +20,7 @@ depends: [ "base" {= "v0.17.3"} "bigstringaf" {= "0.10.0"} "core" {= "v0.17.2"} + "mldoc" {= "dev"} "ca-certs-nss" {= "3.126"} "cstruct" {= "6.2.0"} "datascript_ocaml" {= "dev"} @@ -46,6 +47,8 @@ depends: [ "uutf" {= "1.0.4"} ] pin-depends: [ + ["mldoc.dev" "git+https://github.com/logseq/mldoc.git#b3e06c030b7396f7992ebe1c3a4f35f5e6983200"] + ["xmlm.dev" "git+https://github.com/logseq/xmlm.git#eb469d536e98c98f2754c0ff8813c92b3a17fe9f"] ["rrbvec.dev" "git+https://github.com/logseq/rrbvec.git#main"] ["lui.0.1.0" "git+https://github.com/logseq/lui.git#4fdb9136026af7863a8d025133871e34864eace1"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#02e18cdc5e0ad672aa223f638eaa3ba52fc9421e"] diff --git a/logseq_journal.opam.locked b/logseq_journal.opam.locked index 79bb9ee..725e7bd 100644 --- a/logseq_journal.opam.locked +++ b/logseq_journal.opam.locked @@ -9,6 +9,8 @@ homepage: "https://github.com/logseq/logseq_journal" bug-reports: "https://github.com/logseq/logseq_journal/issues" build: ["dune" "build" "-p" name "-j" jobs] pin-depends: [ + ["mldoc.dev" "git+https://github.com/logseq/mldoc.git#b3e06c030b7396f7992ebe1c3a4f35f5e6983200"] + ["xmlm.dev" "git+https://github.com/logseq/xmlm.git#eb469d536e98c98f2754c0ff8813c92b3a17fe9f"] [ "angstrom.dev" "git+https://github.com/logseq/angstrom.git#fork" @@ -52,6 +54,8 @@ pin-depends: [ ["rrbvec.dev" "git+https://github.com/logseq/rrbvec.git#main"] ] depends: [ + "mldoc" {= "dev"} + "xmlm" {= "dev"} "angstrom" {= "dev"} "asn1-combinators" {= "0.3.3"} "astring" {= "0.8.5"} diff --git a/logseq_sync/test/test_asset_cache.ml b/logseq_sync/test/test_asset_cache.ml index ff3ff59..9661eee 100644 --- a/logseq_sync/test/test_asset_cache.ml +++ b/logseq_sync/test/test_asset_cache.ml @@ -1,14 +1,29 @@ (* Cache filesystem scenarios run through Core.step/Effect_runner.submit in runner_contract and transport_contract. This existing test entry checks the public compiler boundary, without including implementation/private CMIs. *) -let rec repository_root directory = - if Sys.file_exists (Filename.concat directory ".git") +let public_interfaces = + [ ( "logseq_sync/spec/pure_reducer/.logseq_sync_pure_reducer.objs/byte" + , "logseq_sync_pure_reducer.cmi" ) + ; ( "logseq_sync/spec/effect_runner/.logseq_sync_effect_runner.objs/byte" + , "logseq_sync_effect_runner.cmi" ) + ; "logseq_db_types/lib/.logseq_db_types.objs/byte", "logseq_db_types.cmi" + ] +;; + +(* Compiler inputs belong to this executable's build, including --build-dir + builds outside the source checkout. Only public interface paths are used. *) +let rec compilation_root directory = + if + List.for_all + (fun (relative, interface) -> + Sys.file_exists (Filename.concat (Filename.concat directory relative) interface)) + public_interfaces then directory else ( let parent = Filename.dirname directory in if parent = directory - then failwith "repository root not found" - else repository_root parent) + then failwith "public compiler interfaces not found beside test executable" + else compilation_root parent) ;; let read_file path = In_channel.with_open_bin path In_channel.input_all @@ -34,11 +49,7 @@ let compile root source = (fun () -> Out_channel.with_open_bin filename (fun channel -> output_string channel source); let directories = - [ "logseq_sync/spec/pure_reducer/.logseq_sync_pure_reducer.objs/byte" - ; "logseq_sync/spec/effect_runner/.logseq_sync_effect_runner.objs/byte" - ; "logseq_db_types/lib/.logseq_db_types.objs/byte" - ] - |> List.map (Filename.concat (Filename.concat root "_build/default")) + List.map (fun (relative, _) -> Filename.concat root relative) public_interfaces in let arguments = Array.of_list @@ -71,7 +82,12 @@ let negative root source reason = ;; let sealed_public_interface () = - let root = repository_root (Sys.getcwd ()) in + let executable = + if Filename.is_relative Sys.executable_name + then Filename.concat (Sys.getcwd ()) Sys.executable_name + else Sys.executable_name + in + let root = compilation_root (Filename.dirname executable) in let prefix = "module C = Logseq_sync_pure_reducer.Core\n" in positive root diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index 1e98055..38a4a8f 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -39,6 +39,7 @@ struct JournalSheetDismissButton: SwiftUI.View { let pending: Bool? let error: String? let text: String? + let runs: [TitleRun]? let maxLines: Int? let id: String? let level: Int? @@ -49,13 +50,52 @@ struct JournalSheetDismissButton: SwiftUI.View { let message: String? } - private struct DetailText: SwiftUI.View { + /// Syntax and reference resolution belong to OCaml; this maps presentation data. + private struct TitleRun: Decodable { let text: String + let styles: [String] + let url: String? + let reference: String? + } + + private static func attributed(_ runs: [TitleRun]?, fallback: String) -> AttributedString { + guard let runs else { return AttributedString(fallback) } + var result = AttributedString() + for run in runs { + var fragment = AttributedString(run.text) + var intent: InlinePresentationIntent = [] + if run.styles.contains("bold") { intent.insert(.stronglyEmphasized) } + if run.styles.contains("italic") { intent.insert(.emphasized) } + if run.styles.contains("code") { intent.insert(.code) } + fragment.inlinePresentationIntent = intent + if run.styles.contains("underline") { fragment.underlineStyle = .single } + if run.styles.contains("strike") { fragment.strikethroughStyle = .single } + if run.styles.contains("highlight") { fragment.backgroundColor = .yellow.opacity(0.25) } + if let target = run.url.flatMap(URL.init(string:)) { + fragment.link = target + } else if let id = run.reference { + fragment.link = URL(string: "logseq-journal://block/" + id) + } + result.append(fragment) + } + return result + } + + private static func openTitleURL(_ url: URL, context: LUIAppleExtensionViewContext, runs: [TitleRun]?) -> OpenURLAction.Result { + guard url.scheme == "logseq-journal" else { return .systemAction } + let id = String(url.path.dropFirst()) + guard url.host == "block", runs?.contains(where: { $0.reference == id }) == true, + let payload = try? JSONEncoder().encode(["reference": id]) else { return .discarded } + return JournalExtensions.emit(context: context, payload: payload) ? .handled : .discarded + } + + private struct DetailText: SwiftUI.View { + let text: AttributedString let id: String @ScaledMetric(relativeTo: .body) private var size: CGFloat = 17 @ScaledMetric(relativeTo: .body) private var targetLineHeight: CGFloat = 27 - init(text: String, id: String, level: Int) { + init(text: AttributedString, id: String, level: Int) { self.text = text self.id = id let tier = min(2, max(0, level)) @@ -248,7 +288,10 @@ struct JournalSheetDismissButton: SwiftUI.View { case .boundedText: if let text = properties?.text, let maxLines = properties?.maxLines, maxLines > 0, context.childIDs.isEmpty { - Text(verbatim: text) + Text(JournalChrome.attributed(properties?.runs, fallback: text)) + .environment(\.openURL, OpenURLAction { url in + JournalChrome.openTitleURL(url, context: context, runs: properties?.runs) + }) .font(properties?.level == 1 ? .subheadline : .body) .foregroundStyle(properties?.level == 1 ? .secondary : .primary) .multilineTextAlignment(.leading) @@ -260,7 +303,10 @@ struct JournalSheetDismissButton: SwiftUI.View { case .detailText: if let properties, let text = properties.text, let id = properties.id, let level = properties.level, context.childIDs.isEmpty { - DetailText(text: text, id: id, level: level) + DetailText(text: JournalChrome.attributed(properties.runs, fallback: text), id: id, level: level) + .environment(\.openURL, OpenURLAction { url in + JournalChrome.openTitleURL(url, context: context, runs: properties.runs) + }) } case .detailRow: if let level = properties?.level, context.childIDs.count == 1 { diff --git a/swift/JournalExtensions.swift b/swift/JournalExtensions.swift index abd3a4c..0b6d652 100644 --- a/swift/JournalExtensions.swift +++ b/swift/JournalExtensions.swift @@ -134,7 +134,7 @@ enum JournalExtensionFingerprint { static func chromeExtension() -> LUIAppleExtension { journalExtension(identifier: "journal-chrome", profiles: appleProfiles, - standardChildren: true, events: false) { context in + standardChildren: true, events: true) { context in AnyView(JournalChrome.View(context: context)) } } diff --git a/test/journal_model_test.ml b/test/journal_model_test.ml index f5aadc9..a86ef39 100644 --- a/test/journal_model_test.ml +++ b/test/journal_model_test.ml @@ -295,7 +295,69 @@ let test_reference_text () = "reference discovery did not deduplicate UUIDs or preserve escapes" ;; +let test_rich_title_boundaries () = + let link id = "[[" ^ id ^ "]]" in + let render = Journal_model.Title.render ~lookup:(fun target -> + List.assoc_opt target [id, "**Target 中文**"; parent_id, link id; mutation_id, "**" ^ link mutation_id ^ "**"]) in + let plain source = render source |> Journal_model.Title.plain in + List.iter (fun (source, expected) -> require (plain source = expected) "rich title: %s" source) + [ "prefix " ^ link parent_id ^ " suffix", "prefix Target 中文 suffix" + ; link mutation_id, link mutation_id + ; link "00000000-0000-4000-a000-000000000099", link "00000000-0000-4000-a000-000000000099" + ; "`" ^ link id ^ "`", link id + ; "\\" ^ link id, link id + ; "((" ^ id ^ "))", "((" ^ id ^ "))" + ; "# First\n# Second", "First\nSecond" + ; "[](https://example.org/path)", "https://example.org/path" + ; "", "admin@example.org" + ; "before [1/3] after", "before [1/3] after" + ; "before {{renderer :hello}} after", "before {{renderer :hello}} after" + ; "{{renderer :hello}} " ^ link id, "{{renderer :hello}} Target 中文" + ; "before [[outer [[inner]]]] after", "before [[outer [[inner]]]] after" + ; "SCHEDULED: <2026-10-14 Wed> " ^ link id, "SCHEDULED: <2026-10-14 Wed> Target 中文" ]; + require + (List.for_all (fun (run : Journal_model.Title.run) -> run.reference = Some parent_id) (render (link parent_id))) + "nested expansion changed the outer reference click target"; + let runs = render ("**" ^ link id ^ "**") in + require (List.exists (fun (run : Journal_model.Title.run) -> run.text = "Target 中文" && run.reference = Some id && List.mem "bold" run.styles) runs) "reference lost styles/target"; + let runs = render "[**标签**](HTTP://example.org/path)" in + require (List.exists (fun (run : Journal_model.Title.run) -> run.url = Some "http://example.org/path" && run.text = "标签" && List.mem "bold" run.styles) runs) "URL lost nested style or authority"; + let repeated = String.concat " " (List.init 600 (fun _ -> link id)) in + require (String.length (plain repeated) <= 65536) "title work/output budget exceeded"; + let long = String.concat "" (List.init 2000 (fun _ -> "中文 English ")) in + require (plain long = long) "long bilingual title truncated in OCaml"; + require (Journal_model.Title.plain (Journal_model.Title.render ~lookup:(fun _ -> Some "") (link id)) = "") "empty target changed"; + let pathological = String.make 4000 '[' ^ "x" ^ String.make 4000 ']' in + let before = Unix.gettimeofday () in + require (plain pathological = pathological) "complex title lost literal fallback"; + require (Unix.gettimeofday () -. before < 0.1) "mldoc complexity admission did not bound parse time"; + let first = Journal_model.Title.render ~lookup:(fun _ -> Some "first") (link id) + and next = Journal_model.Title.render ~lookup:(fun _ -> Some "next") (link id) in + require (Journal_model.Title.plain first = "first" && Journal_model.Title.plain next = "next") "parse cache retained stale reference output" +;; + +let test_ast_reference_discovery () = + let a = id and b = parent_id in + let page id = "[[" ^ id ^ "]]" and ref id = "((" ^ id ^ "))" in + let cases = + [ "deprecated syntax is literal", ref a ^ " " ^ ref a, [] + ; "nested formatting", "**" ^ page a ^ "** and _" ^ page b ^ "_", [a;b] + ; "code", "`" ^ page a ^ "` ``" ^ ref b ^ "``", [] + ; "fenced code", "```\n" ^ ref a ^ "\n```", [] + ; "escaped", "\\" ^ page a ^ " \\" ^ ref b, [] + ; "labeled reference", "[label](" ^ page a ^ ")", [a] + ; "URLs", "https://example.com [label](https://example.com)", [] + ; "missing valid UUID", page mutation_id, [mutation_id] + ; "plain CJK", "普通文本 English 中英文长文", [] ] + in + List.iter (fun (name, source, expected) -> + require (Journal_model.reference_ids source = expected) + "AST reference discovery: %s" name) cases +;; + let () = + test_rich_title_boundaries (); + test_ast_reference_discovery (); test_reference_text (); test_squuid_layout (); test_squuid_monotonic_transitions (); diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml index 3a86ec9..6f7fd41 100644 --- a/test/journal_semantics_test.ml +++ b/test/journal_semantics_test.ml @@ -783,15 +783,15 @@ let test_timeline_body_four_lines () = | _ -> None) (ops ()) in require (List.length payloads = 1) "Timeline body has no native bounded text"; let payload = List.hd payloads in - require (Yojson.Basic.Util.member "text" payload = `String source) - "Timeline truncated or changed the full source"; + require (Yojson.Basic.Util.member "text" payload = `String (Journal_model.Title.render source |> Journal_model.Title.plain)) + "Timeline truncated the parsed title"; require (Yojson.Basic.Util.member "maxLines" payload = `Int 4) "Timeline body is not limited to four visual lines"; require (not (has_text (ops ()) "Show more" || has_text (ops ()) "Show less")) "Timeline retained a local expansion action"); with_mounted (Journal_header.detail_text ~id:"full-detail" ~depth:0 source) (fun _ ops -> - require (has_text (ops ()) source) "Detail lost the full source"; + require (has_text (ops ()) (Journal_model.Title.render source |> Journal_model.Title.plain)) "Detail lost the full parsed title"; require (not (List.exists (function | Lui_protocol.SetExtensionProp (_, "payload", StringValue json) -> Yojson.Basic.Util.member "maxLines" (Yojson.Basic.from_string json) <> `Null @@ -1064,6 +1064,59 @@ let test_status_and_tags_mount () = require (has_text (ops ()) "#阅读 #工作") "named tags must accompany status") ;; +(* Title layout is owned by the mounted view, outside the pure reducers. *) +let test_rich_title_reference_events () = + let received = ref [] in + let target = "20000000-0000-4000-a000-000000000099" in + let title = Journal_model.Title.render ~lookup:(fun _ -> Some "Target") ("[[" ^ target ^ "]]") in + with_mounted (Journal_header.detail_text ~title ~on_reference:(fun id -> received := id :: !received) ~id:"rich-event" ~depth:0 "") (fun app ops -> + let node = List.find_map (function Lui_protocol.CreateExtension (node, name, _) when name = Journal_lui_native.chrome_identifier -> Some node | _ -> None) (ops ()) |> Option.get in + let send payload = + let values = Lui_protocol.String_map.empty + |> Lui_protocol.String_map.add "id" (Lui_protocol.IntValue 1) + |> Lui_protocol.String_map.add "payload" (Lui_protocol.StringValue payload) in + ignore (Lui_app.dispatch_event app (Lui_protocol.ExtensionEvent (node, Journal_lui_native.chrome_identifier, "event", values))); + ignore (Lui_app.flush app) in + send (Yojson.Basic.to_string (`Assoc ["reference", `String target])); + send (Yojson.Basic.to_string (`Assoc ["reference", `String block_id])); + send "broken"; + require (!received = [target]) "reference event did not bind to rendered target"); + with_mounted (Journal_header.detail_text ~on_reference:(fun id -> received := id :: !received) ~id:"missing-event" ~depth:0 ("[[" ^ target ^ "]]")) (fun app ops -> + let node = List.find_map (function Lui_protocol.CreateExtension (node, name, _) when name = Journal_lui_native.chrome_identifier -> Some node | _ -> None) (ops ()) |> Option.get in + let values = Lui_protocol.String_map.empty |> Lui_protocol.String_map.add "id" (Lui_protocol.IntValue 1) + |> Lui_protocol.String_map.add "payload" (Lui_protocol.StringValue (Yojson.Basic.to_string (`Assoc ["reference", `String target]))) in + ignore (Lui_app.dispatch_event app (Lui_protocol.ExtensionEvent (node, Journal_lui_native.chrome_identifier, "event", values))); + ignore (Lui_app.flush app); + require (!received = [target]) "missing reference became clickable") +;; + +let test_mldoc_titles_share_runs () = + let source = "普通文本 **bold _nested_** `code` https://example.com [标签](https://example.org/path)" in + let entry = { Journal_graph_projection.block = block ~source () + ; child_summaries = [{ block_id = "rich-child"; source; asset_file_type = None }] } in + let check mode expected_lines view = + with_mounted view (fun _ ops -> + let payloads = List.filter_map (function + | Lui_protocol.SetExtensionProp (_, "payload", StringValue json) -> + let value = Yojson.Basic.from_string json in + if Yojson.Basic.Util.member "mode" value = `String mode then Some value else None + | _ -> None) (ops ()) in + require (List.length payloads = List.length expected_lines) "missing rich title surface"; + List.iter2 (fun payload lines -> + require (Yojson.Basic.Util.member "maxLines" payload = lines) "rich title line policy changed"; + let runs = match Yojson.Basic.Util.member "runs" payload with `List runs -> runs | _ -> [] in + require (runs <> []) "title did not use shared mldoc runs"; + let text = String.concat "" (List.map (fun run -> Yojson.Basic.Util.member "text" run |> Yojson.Basic.Util.to_string) runs) in + require (text = "普通文本 bold nested code https://example.com 标签") "AST title labels/styles were flattened incorrectly"; + require (List.exists (fun run -> Yojson.Basic.Util.member "url" run = `String "https://example.org/path" && Yojson.Basic.Util.member "text" run = `String "标签") runs) "labeled URL lost target"; + require (List.exists (fun run -> Yojson.Basic.Util.member "styles" run = `List [`String "italic"; `String "bold"] || Yojson.Basic.Util.member "styles" run = `List [`String "bold"; `String "italic"]) runs) "nested styles lost" ) payloads expected_lines) + in + check "bounded-text" [`Int 4; `Int 2] + (Journal_row.view ~show_timestamp:false ~render_media:(fun ~title:_ ~root:_ ~image_children:_ child -> child) entry); + check "detail-text" [`Null] + (Journal_header.detail_text ~id:"rich-detail" ~depth:0 source) +;; + let test_reference_body_and_summary () = let target = "20000000-0000-4000-a000-000000000099" in let token = "[[" ^ target ^ "]]" in @@ -2773,6 +2826,8 @@ let tests = ; "top image asset body", test_top_image_asset_has_no_filename_body ; "scoped theme wire properties", test_scoped_theme_wire_properties ; "loading indicator and message", test_loading_keeps_indicator_and_message + ; "rich title reference events", test_rich_title_reference_events + ; "shared mldoc title runs", test_mldoc_titles_share_runs ; "UUID reference body and summary", test_reference_body_and_summary ; "status and tags mount", test_status_and_tags_mount ; "timeline no chevron", test_timeline_has_no_chevron