diff --git a/doc/UsersGuide/Releases/Assemble.lean b/doc/UsersGuide/Releases/Assemble.lean index 318a3e47..ea9af9ab 100644 --- a/doc/UsersGuide/Releases/Assemble.lean +++ b/doc/UsersGuide/Releases/Assemble.lean @@ -97,13 +97,13 @@ public def bucket if hasSection entry then -- The entry's own metadata is kept; only its permalink comes from the release note. some { entry with - metadata := some { entry.metadata.getD {} with tag := some (metadata.tag : Manual.Tag) }, + metadata := some { entry.metadata.getD {} with tag := some metadata.tag }, content := entry.content.extract 1 } else none some <| Part.mk #[Inline.text title] title - (some { tag := some (tag : Manual.Tag) }) + (some { tag := some tag }) #[ Doc.Block.other (Block.release (toString version) { title, version }) #[], @@ -252,7 +252,7 @@ private def summaryTags (part : Part Manual) : Array (Option String) := (bucket ⟨4, 33, 0⟩ false #[(testMetadata, entry)]).any fun part => part.subParts.all fun s => (s.metadata.map (·.draft)).getD false && - (s.metadata.bind (·.tag)) == some ("entry" : Manual.Tag) + (s.metadata.bind (·.tag)) == some "entry" end Tests diff --git a/src/multi-verso/MultiVerso.lean b/src/multi-verso/MultiVerso.lean index 70fe016b..5f3ac028 100644 --- a/src/multi-verso/MultiVerso.lean +++ b/src/multi-verso/MultiVerso.lean @@ -285,6 +285,12 @@ public instance : GetElem? RefDomain String (Array RefObject) fun dom name => na getElem dom name ok := dom.contents[name]'ok getElem? dom name := dom.contents[name]? +/-- +Returns the canonical names used in {name}`domain`, in sorted order. +-/ +public def RefDomain.canonicalNames (domain : RefDomain) : Array String := + domain.contents.keysArray.qsortOrd + private def RefDomain.structEq (x y : RefDomain) := let ⟨t1, d1, c1⟩ := x let ⟨t2, d2, c2⟩ := y diff --git a/src/tests/Tests/Tags.lean b/src/tests/Tests/Tags.lean index 18c19fa8..f75eb58d 100644 --- a/src/tests/Tests/Tags.lean +++ b/src/tests/Tests/Tags.lean @@ -5,6 +5,7 @@ Author: David Thrane Christiansen -/ module import VersoManual +meta import VersoManual set_option doc.verso true @@ -94,4 +95,171 @@ info: (false, some "my-tag", none, true) pure (tag, machine, chosen) pure (tag.isSome, htmlId state machine, htmlId state chosen, failed) +/- +A name containing a space gives the element its slug as an HTML id. +-/ +/-- info: (true, some "some-tag", false) -/ +#guard_msgs in +#eval show IO _ from do + let ((tag, id), state, failed) ← run do + let id ← freshId + let tag ← providedTag id #["page"] "some tag" + pure (tag, id) + pure (tag.isSome, htmlId state id, failed) + +/- +Names that share a slug are duplicates, because both need the same HTML id. The error names the +slug when it differs from the name as written. +-/ +/-- +info: Duplicate tag 'some tag': its slug 'some-tag' is already in use +An error was encountered! +--- +info: (false, some "some-tag", none, true) +-/ +#guard_msgs in +#eval show IO _ from do + let ((tag, first, second), state, failed) ← run do + let first ← freshId + let second ← freshId + let _ ← providedTag first #["page"] "some-tag" + let tag ← providedTag second #["page"] "some tag" + pure (tag, first, second) + pure (tag.isSome, htmlId state first, htmlId state second, failed) + +/-- +info: Duplicate tag 'some-tag' +An error was encountered! +--- +info: (false, some "some-tag", none, true) +-/ +#guard_msgs in +#eval show IO _ from do + let ((tag, first, second), state, failed) ← run do + let first ← freshId + let second ← freshId + let _ ← providedTag first #["page"] "some tag" + let tag ← providedTag second #["page"] "some-tag" + pure (tag, first, second) + pure (tag.isSome, htmlId state first, htmlId state second, failed) + +/-! Tests for assigning tags to parts with {name}`tagPart`. -/ + +/-- +Runs a traversal action with no extensions against an empty state and context, returning its +result, the resulting state, and whether any errors were logged. +-/ +private def runTraverse (act : TraverseM α) : IO (α × TraverseState × Bool) := do + let logger ← Logger.new + let (result, state) ← + (TraverseM.run (ExtensionImpls.fromLists [] []) {} (TraverseState.initialize {}) act).run logger + let failed ← logger.failIfErrors + return (result, state, failed != 0) + +/- +Traversal registers a part in the section domain under its name exactly as written, resolvable by +{name}`TraverseState.resolveDomainObject`, while its HTML id is the slug. The name in the part's +metadata is untouched. +-/ +/-- info: (some "some tag", some "some-tag", true, some "some-tag", false) -/ +#guard_msgs in +#eval show IO _ from do + let ((name, id), state, failed) ← runTraverse do + let id ← freshId + let md : PartMetadata := { tag := some "some tag", id := some id } + let part : Doc.Part Manual := .mk #[Doc.Inline.text "Some Tag"] "Some Tag" (some md) #[] #[] + -- Two rounds, as the traversal driver would run them + let t ← tagPart part md (·.id) (·.xrefTag) (·.tag) savePartXref + let md := { md with xrefTag := some t } + let _ ← tagPart part md (·.id) (·.xrefTag) (·.tag) savePartXref + pure (md.tag, id) + let resolved := + match state.resolveDomainObject sectionDomain "some tag" with + | .ok link => some link.htmlId.toString + | .error _ => none + pure (name, htmlId state id, + (state.getDomainObject? sectionDomain "some-tag").isNone, resolved, failed) + +/- +Two parts that claim the same name produce a single, readable duplicate error. +-/ +/-- +info: Duplicate tag 'some tag': its slug 'some-tag' is already in use +An error was encountered! +--- +info: true +-/ +#guard_msgs in +#eval show IO _ from do + let (_, _, failed) ← runTraverse do + let first ← freshId + let md1 : PartMetadata := { tag := some "some tag", id := some first } + let part1 : Doc.Part Manual := .mk #[Doc.Inline.text "A"] "A" (some md1) #[] #[] + let _ ← tagPart part1 md1 (·.id) (·.xrefTag) (·.tag) savePartXref + let second ← freshId + let md2 : PartMetadata := { tag := some "some tag", id := some second } + let part2 : Doc.Part Manual := .mk #[Doc.Inline.text "B"] "B" (some md2) #[] #[] + let _ ← tagPart part2 md2 (·.id) (·.xrefTag) (·.tag) savePartXref + pure failed + +/-! Tests for suggesting alternatives to unresolved cross-references. -/ + +/-- info: "" -/ +#guard_msgs in +#eval suggestRefTargets #["alpha", "beta"] "zzzzzzzzzzzz" + +/-- info: "\nDid you mean one of these?\n * 'some tag'\n * 'some-tag'" -/ +#guard_msgs in +#eval suggestRefTargets #["some-tag", "some tag", "other"] "some tg" + +/- +At most five targets are suggested. +-/ +/-- info: "\nDid you mean one of these?\n * 'tag1'\n * 'tag2'\n * 'tag3'\n * 'tag4'\n * 'tag5'" -/ +#guard_msgs in +#eval suggestRefTargets #["tag6", "tag5", "tag4", "tag3", "tag2", "tag1"] "tag" + +/-! Tests for the unresolved-reference error message with {name}`unresolvedRefMessage`. -/ + +/- +A name that is absent from the domain gets suggestions of nearby names. +-/ +/-- +info: "No destination found for tag 'some tg' in Verso.Genre.Manual.section\nDid you mean one of these?\n * 'some tag'" +-/ +#guard_msgs in +#eval show IO _ from do + let (_, state, _) ← runTraverse do + let id ← freshId + modify (·.saveDomainObject sectionDomain "some tag" id) + pure (unresolvedRefMessage state none "some tg") + +/- +A name that is present in the domain failed to resolve for another reason, which the message +states instead of suggesting the name to itself. +-/ +/-- +info: "Ref some tag in Verso.Genre.Manual.section has 2 targets, can only link to one" +-/ +#guard_msgs in +#eval show IO _ from do + let (_, state, _) ← runTraverse do + let first ← freshId + let second ← freshId + modify (·.saveDomainObject sectionDomain "some tag" first + |>.saveDomainObject sectionDomain "some tag" second) + pure (unresolvedRefMessage state none "some tag") + +/- +A name whose domain object has no targets at all, which happens when only data was saved for it. +-/ +/-- +info: "No link target registered for some tag in Verso.Genre.Manual.section" +-/ +#guard_msgs in +#eval show IO _ from do + let (_, state, _) ← runTraverse do + modify (·.saveDomainObjectData sectionDomain "some tag" .null) + pure (unresolvedRefMessage state none "some tag") + end Verso.Tests.Tags diff --git a/src/verso-manual/VersoManual.lean b/src/verso-manual/VersoManual.lean index 62ca5ee3..10707cce 100644 --- a/src/verso-manual/VersoManual.lean +++ b/src/verso-manual/VersoManual.lean @@ -17,6 +17,7 @@ public import Verso.Output.Html.ElasticLunr public import Verso.Doc.Lsp public import Verso.Doc.Elab public import Verso.FS +public import Verso.SmartSuggestions public import VersoSearch public import VersoSearch.DomainSearch @@ -84,6 +85,34 @@ deriving BEq, ToJson, FromJson defmethod Part.htmlToc (part : Part Manual) : Bool := part.metadata.map (·.htmlToc) |>.getD true +/-- +Renders the names among {name}`candidates` that are close to {name}`name`, as a suffix for the +error message about an unresolved cross-reference. Returns the empty string when no candidate is +close enough. +-/ +def suggestRefTargets (candidates : Array String) (name : String) : String := + let suggestions := smartSuggestions candidates name (count := 5) + if suggestions.isEmpty then "" + else suggestions.foldl (init := "\nDid you mean one of these?") (· ++ s!"\n * '{·}'") + +/-- +The error message for a cross-reference to {name}`name` that traversal could not resolve. + +When the name is absent from the domain, nearby names from the domain's contents in {name}`st` +are suggested. When the name is present, resolution failed for another reason, such as the name +having multiple targets. The resulting message preserves this. +-/ +def unresolvedRefMessage (st : TraverseState) (domain : Option Name) (name : String) : String := + let domain := domain.getD sectionDomain + if (st.getDomainObject? domain name).isSome then + match st.resolveDomainObject domain name with + | .error e => e + | .ok _ => + s!"'{name}' in {domain} was not resolved during traversal; the document may need more traversal passes" + else + let candidates := st.domains[domain]?.map (·.canonicalNames) |>.getD #[] + s!"No destination found for tag '{name}' in {domain}{suggestRefTargets candidates name}" + inline_extension Inline.ref (canonicalName : String) (domain : Option Name) (remote : Option String) (resolvedDestination : Option Link := none) where data := ToJson.toJson (RefInfo.mk canonicalName domain remote resolvedDestination) traverse := fun _ info content => do @@ -108,7 +137,7 @@ inline_extension Inline.ref (canonicalName : String) (domain : Option Name) (rem | .error e => reportError e; content.mapM go | .ok { canonicalName := name, domain, remote := none, resolvedDestination := none } => - reportError ("No destination found for tag '" ++ name ++ "' in " ++ toString domain); content.mapM go + reportError (unresolvedRefMessage (← Doc.TeX.state) domain name); content.mapM go | .ok { canonicalName := name, domain, remote := some remote, resolvedDestination := none } => reportError ("No destination found for remote '" ++ remote ++ "' tag '" ++ name ++ "' in " ++ toString domain); content.mapM go | .ok {resolvedDestination := some dest, remote, ..} => @@ -127,7 +156,7 @@ inline_extension Inline.ref (canonicalName : String) (domain : Option Name) (rem | .error e => reportError e; content.mapM go | .ok { canonicalName := name, domain, remote := none, resolvedDestination := none } => - reportError ("No destination found for tag '" ++ name ++ "' in " ++ toString domain); content.mapM go + reportError (unresolvedRefMessage (← Doc.Html.HtmlT.state) domain name); content.mapM go | .ok { canonicalName := name, domain, remote := some remote, resolvedDestination := _ } => let domain := domain |>.getD sectionDomain let remoteData ← readThe AllRemotes @@ -141,7 +170,7 @@ inline_extension Inline.ref (canonicalName : String) (domain : Option Name) (rem else let dests := objs.map (s!" * {·.link.link}") |>.toList |> "\n".intercalate reportError s!"Remote '{remote}' domain '{domain}' contains multiple destinations for '{name}':\n{dests}" - else reportError s!"Remote '{remote}' contains domain '{domain}, but it not item '{name}'" + else reportError s!"Remote '{remote}' contains domain '{domain}', but not item '{name}'{suggestRefTargets dom.canonicalNames name}" else reportError s!"Remote '{remote}' does not contain domain '{domain}' (looking up '{name}')" else reportError s!"Remote '{remote}' not found for tag '{name}' in domain '{domain}'" -- If any error was logged, just don't emit a link diff --git a/src/verso-manual/VersoManual/Basic.lean b/src/verso-manual/VersoManual/Basic.lean index 92721f64..deb3798a 100644 --- a/src/verso-manual/VersoManual/Basic.lean +++ b/src/verso-manual/VersoManual/Basic.lean @@ -220,8 +220,20 @@ structure PartMetadata where authorshipNote : Option String := none /-- The publication date -/ date : Option String := none - /-- The main tag for the part, used for cross-references. -/ - tag : Option Tag := none + /-- + This part's canonical name. + + The canonical name is used for stable cross references. It also serves as the basis + for the section's HTML ID. + -/ + tag : Option String := none + /-- + This part's cross-referencing tag. + + This field is set during traversal, which derives a unique external tag from the part's + canonical name. + -/ + xrefTag : Option Tag := none /-- If this part ends up as the root of a file, use this name for it -/ file : Option String := none /-- The internal unique ID, which is automatically assigned during traversal. -/ @@ -1072,7 +1084,10 @@ def providedTag [Monad m] [MonadState TraverseState m] [MonadBuildLog m] if let some id' := (← get).tags[tag]? then if id' != id then -- Another element holds this tag, so this one is left without an external tag. - reportError s!"Duplicate tag '{name}'" + if slug.toString == name then + reportError s!"Duplicate tag '{name}'" + else + reportError s!"Duplicate tag '{name}': its slug '{slug.toString}' is already in use" return none modify fun st => { st with tags := st.tags.insert tag id, @@ -1376,7 +1391,7 @@ def ancestorSearchPriority (headers : Array PartHeader) : Int := let p : Int := (h.metadata.map (·.searchPriority.val) |>.getD 50 : Nat) acc + (p - 50) -def savePartXref (slug : Slug) (id : InternalId) (part : Part Manual) : TraverseM Unit := do +def savePartXref (name : String) (id : InternalId) (part : Part Manual) : TraverseM Unit := do let jsonMetadata := Json.arr ((← read).inPart part |>.headers.map (fun h => json%{ "title": $h.titleString, @@ -1392,7 +1407,7 @@ def savePartXref (slug : Slug) (id : InternalId) (part : Part Manual) : Traverse ((← read).inPart part |>.headers[1:]).toArray.map (fun (h : PartHeader) => h.metadata.bind (·.assignedNumber)) |>.mapM _root_.id |>.map sectionNumberString let searchPriority := ancestorSearchPriority ((← read).inPart part |>.headers) - modify fun (st : TraverseState) => st.saveDomainObject sectionDomain slug.toString id |>.saveDomainObjectData sectionDomain slug.toString (json%{ + modify fun (st : TraverseState) => st.saveDomainObject sectionDomain name id |>.saveDomainObjectData sectionDomain name (json%{ "context": $jsonMetadata, "title": $title, "shortTitle": $shortTitle, @@ -1403,25 +1418,26 @@ def savePartXref (slug : Slug) (id : InternalId) (part : Part Manual) : Traverse /-- Assigns a tag to a part during traversal. -This operation is careful to preserve and prioritize user-selected tags. In the first round, the +This operation is careful to preserve and prioritize user-chosen names. In the first round, the following may occur: - * If there's no tag at all, then an internal tag is applied. + * If the part has a name (from {name}`getName`), its provided tag is converted to a unique + external tag whose slug becomes the part's HTML id. - * If there's a provided tag, it is converted to an external tag and added as an xref target. + * If the part has no name at all, then an internal tag is applied. In subsequent rounds, the internal tags are converted to external tags. At this point, the -user-provided tags have already been made external. It also ensures that auto-generated tags are -never added as xref targets. +tags based on the user's {name (full := PartMetadata.tag)}`tag` field have already been made +external. -/ def tagPart (part : Lean.Doc.Part Manual.Inline Manual.Block m) (metadata : m) - (getId : m → Option InternalId) (getTag : m → Option Tag) - (saveXref : Slug → InternalId → Lean.Doc.Part Manual.Inline Manual.Block m → TraverseM Unit) : + (getId : m → Option InternalId) (getTag : m → Option Tag) (getName : m → Option String) + (saveXref : String → InternalId → Lean.Doc.Part Manual.Inline Manual.Block m → TraverseM Unit) : TraverseM Tag := do let some id := getId metadata | reportError "No internal ID assigned while tagging part"; return default - match getTag metadata with + match getTag metadata <|> (getName metadata).map .provided with | none => -- Assign an internal tag - the next round will make it external. This is done in two rounds to -- give priority to user-provided tags that might otherwise anticipate the name-mangling scheme @@ -1432,13 +1448,14 @@ def tagPart -- Ensure uniqueness if let some id' := (← get).tags[t]? then if id != id' then - reportError s!"Duplicate tag '{t}'" + unless t matches Tag.provided _ do + reportError s!"Duplicate tag '{t}'" else modify fun st => {st with tags := st.tags.insert t id} let path := (← readThe TraverseContext).path match t with | Tag.external name => - saveXref name id { part with metadata := some metadata } + saveXref ((getName metadata).getD name.toString) id { part with metadata := some metadata } -- These are the actual IDs to use in generated HTML and links and such modify fun st : TraverseState => { st with externalTags := st.externalTags.insert id { path, htmlId := name } } return t @@ -1462,7 +1479,7 @@ instance : Traverse Manual TraverseM where «meta» := { «meta» with id := some id } -- Next, assign a tag, prioritizing user-chosen external IDs - «meta» := { «meta» with tag := ← tagPart part «meta» (·.id) (·.tag) savePartXref } + «meta» := { «meta» with xrefTag := ← tagPart part «meta» (·.id) (·.xrefTag) (·.tag) savePartXref } -- Assign section numbers to subsections let mut i := 1 @@ -1581,6 +1598,9 @@ def permalink (id : InternalId) (st : TraverseState) (inline : Bool := true) : H -- If there's multiple, select one arbitrarily. let (domain, canonicalName) := candidates[0] let classes := "permalink-widget " ++ if inline then "inline" else "block" + -- The names may contain characters with meaning in URLs, such as spaces or ampersands. + let domain := System.Uri.escapeUri (toString domain) + let canonicalName := System.Uri.escapeUri canonicalName {{ "🔗" diff --git a/src/verso-manual/VersoManual/Glossary.lean b/src/verso-manual/VersoManual/Glossary.lean index 723035fe..ec064c9c 100644 --- a/src/verso-manual/VersoManual/Glossary.lean +++ b/src/verso-manual/VersoManual/Glossary.lean @@ -248,7 +248,7 @@ public def tech.descr : InlineDescr where content.mapM go else let keys := remote.domains[technicalTermDomain]? - |>.map (·.contents.keysArray.qsortOrd.toList |> ", ".intercalate) + |>.map (·.canonicalNames.toList |> ", ".intercalate) |>.map ("Keys are: " ++ ·) |>.getD "Technical term domain not found." reportError s!"No term def with key \"{key}\" in remote {r.quote}. {keys}" loc diff --git a/src/verso-tutorial/VersoTutorial/Basic.lean b/src/verso-tutorial/VersoTutorial/Basic.lean index 8689668d..bef18bb9 100644 --- a/src/verso-tutorial/VersoTutorial/Basic.lean +++ b/src/verso-tutorial/VersoTutorial/Basic.lean @@ -58,8 +58,20 @@ deriving BEq, Hashable, DecidableEq, Inhabited, Repr, ToJson, FromJson open Manual (Tag InternalId) in /-- Metadata on tutorials. -/ structure Tutorial.PartMetadata where - /-- The main tag for the part, used for cross-references. -/ - tag : Option Tag := none + /-- + This part's canonical name. + + The canonical name is used for stable cross references. It also serves as the basis + for the section's HTML ID. + -/ + tag : Option String := none + /-- + This part's cross-referencing tag. + + This field is set during traversal, which derives a unique external tag from the part's + canonical name. + -/ + xrefTag : Option Tag := none /-- Use this filename component in the URL. -/ slug : String /-- The internal unique ID, which is automatically assigned during traversal. -/ @@ -142,7 +154,7 @@ instance : TraverseBlock Tutorial where /-- Saves a cross-reference to a part to the section domain. -/ -def savePartXref (slug : Slug) (id : InternalId) (part : Part Tutorial) : Manual.TraverseM Unit := do +def savePartXref (name : String) (id : InternalId) (part : Part Tutorial) : Manual.TraverseM Unit := do let jsonMetadata := Json.arr (TraversePart.inPart part (← read) |>.headers.map (fun h => json%{ "title": $h.titleString @@ -150,8 +162,8 @@ def savePartXref (slug : Slug) (id : InternalId) (part : Part Tutorial) : Manual let title := TraversePart.inPart part (← read) |>.headers |>.back? |>.map (·.titleString) modify fun (st : Manual.TraverseState) => - st.saveDomainObject Manual.sectionDomain slug.toString id - |>.saveDomainObjectData Manual.sectionDomain slug.toString (json%{ + st.saveDomainObject Manual.sectionDomain name id + |>.saveDomainObjectData Manual.sectionDomain name (json%{ "context": $jsonMetadata, "title": $title, "shortTitle": null, @@ -203,7 +215,7 @@ instance : Traverse Tutorial TraverseM where «meta» := { «meta» with id := some id } -- Next, assign a tag, prioritizing user-chosen external IDs. - «meta» := { «meta» with tag := ← tagPart part «meta» (·.id) (·.tag) savePartXref } + «meta» := { «meta» with xrefTag := ← tagPart part «meta» (·.id) (·.xrefTag) (·.tag) savePartXref } -- Traverse the metadata's description «meta» := { «meta» with summary := ← withReader (TraversePart.inPart part) <| Genre.traverseInline Manual «meta».summary } diff --git a/src/verso/Verso.lean b/src/verso/Verso.lean index 6e09efd1..f7195352 100644 --- a/src/verso/Verso.lean +++ b/src/verso/Verso.lean @@ -33,5 +33,6 @@ public import Verso.Output.Html.CssVars public import Verso.Output.Html.ElasticLunr public import Verso.Output.Html.KaTeX public import Verso.Output.TeX +public import Verso.SmartSuggestions public import Verso.SyntaxUtils public import Verso.WithoutAsync diff --git a/src/verso/Verso/Code/External.lean b/src/verso/Verso/Code/External.lean index 0757a4e8..29046b26 100644 --- a/src/verso/Verso/Code/External.lean +++ b/src/verso/Verso/Code/External.lean @@ -20,6 +20,7 @@ public meta import Verso.ExpectString public meta import Verso.Doc.Suggestion public meta import Verso.Hint public meta import Verso.Log +public meta import Verso.SmartSuggestions import SubVerso.Highlighting public meta import SubVerso.Examples.Messages @@ -76,31 +77,6 @@ Adds a newline to a string if it doesn't already end with one. -/ public meta def withNl (s : String) : String := if s.endsWith "\n" then s else s ++ "\n" -/-- -Default suggestion threshold function: a suggestion is sufficiently close if - * the input is shorter than 5 and their Levenshtein distance is 1 or less, - * the input is shorter than 10 and their distance is 2 or less, or - * the distance is shorter than 3. --/ -meta def suggestionThreshold (input _candidate : String) := if input.length < 5 then 1 else if input.length < 10 then 2 else 3 - -/-- -Adds up to {name}`count` suggestions. - -{name}`candidates` are the valid inputs and {name}`input` is the provided input. Suggestions are added if -they are "sufficiently close" to the input, as determined by {name}`threshold`. --/ -meta def smartSuggestions (candidates : Array String) (input : String) (count : Nat := 10) (threshold := suggestionThreshold) : Array String := - let toks := candidates.filterMap fun t => - let limit := threshold input t - EditDistance.levenshtein t input limit <&> (t, ·) - let toks := toks.qsort (fun x y => x.2 < y.2 || (x.2 == y.2 && x.1 < y.1)) - let toks := toks.take count - -- TODO test thresholds/sorting - toks.map fun (t, _) => t - - - /-- Loads the contents of a module, parsed by anchor. The results are cached. -/ diff --git a/src/verso/Verso/SmartSuggestions.lean b/src/verso/Verso/SmartSuggestions.lean new file mode 100644 index 00000000..4a5ef7a9 --- /dev/null +++ b/src/verso/Verso/SmartSuggestions.lean @@ -0,0 +1,43 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +import Lean.Data.EditDistance + +set_option doc.verso true + +/-! +Suggestions of valid alternatives to invalid inputs, ranked by edit distance. +-/ + +namespace Verso + +open Lean + +/-- +Default suggestion threshold function: a suggestion is sufficiently close if + * the input is shorter than 5 and their Levenshtein distance is 1 or less, + * the input is shorter than 10 and their distance is 2 or less, or + * the distance is shorter than 3. +-/ +public def suggestionThreshold (input _candidate : String) := if input.length < 5 then 1 else if input.length < 10 then 2 else 3 + +/-- +Adds up to {name}`count` suggestions. + +{name}`candidates` are the valid inputs and {name}`input` is the provided input. Suggestions are added if +they are "sufficiently close" to the input, as determined by {name}`threshold`. +-/ +public def smartSuggestions (candidates : Array String) (input : String) (count : Nat := 10) (threshold := suggestionThreshold) : Array String := + let toks := candidates.filterMap fun t => + let limit := threshold input t + EditDistance.levenshtein t input limit <&> (t, ·) + let toks := toks.qsort (fun x y => x.2 < y.2 || (x.2 == y.2 && x.1 < y.1)) + let toks := toks.take count + -- TODO test thresholds/sorting + toks.map fun (t, _) => t + +end Verso diff --git a/static-web/find.js b/static-web/find.js index 7ffbbb62..4e61b321 100644 --- a/static-web/find.js +++ b/static-web/find.js @@ -28,6 +28,46 @@ let paramName = params.get("name"); let xref = /** @type {{xref: XRef}} */ (/** @type {unknown} */ (window)).xref; +/** + * The replacements for characters that are not valid in slugs. This mirrors the mangling table in + * MultiVerso.Slug. + * @type {Record} + */ +const slugReplacements = { + "<": "_LT_", + ">": "_GT_", + ";": "_SEMI_", + "‹": "_FLQ_", + "›": "_FRQ_", + "«": "_FLQQ_", + "»": "_FLQQ_", + "⟨": "_LANGLE_", + "⟩": "_RANGLE_", + "(": "_LPAR_", + ")": "_RPAR_", + "[": "_LSQ_", + "]": "_RSQ_", + "→": "_ARR_", + "↦": "_MAPSTO_", + "⊢": "_VDASH_", +}; + +/** + * Converts a string to a valid slug, mangling as appropriate. This mirrors the slug computation in + * MultiVerso.Slug, so names can be compared with links that were minted from their slugs. + * @param {string} s + * @returns {string} + */ +function sluggify(s) { + let out = ""; + for (const c of s) { + if (/^[a-zA-Z0-9_-]$/.test(c)) out += c; + else if (c === " " || c === "\t" || c === "\r" || c === "\n") out += "-"; + else out += slugReplacements[c] ?? "___"; + } + return out; +} + if (paramName) { /** * @type (Item & {domain: string})[] @@ -57,6 +97,27 @@ if (paramName) { } } + if (options.length == 0) { + // Permalinks from before a bug fix that separated sections' HTML IDs from their + // canonical names in the section domain were mangled as slugs. If we don't find + // any results, we try to recover by checking equality modulo sluggification. + // This workaround was introduced in August 2026 and should be removed when it + // seems unlikely for old permalinks to be a significant problem. + const nameSlug = sluggify(paramName); + const searchDomains = domains && domains.length > 0 ? domains : Object.keys(xref); + for (const domain of searchDomains) { + if (!xref.hasOwnProperty(domain)) continue; + const contents = xref[domain]["contents"]; + for (const key of Object.keys(contents)) { + if (sluggify(key) === nameSlug) { + for (const i of contents[key]) { + options.push(Object.assign(i, { domain: domain })); + } + } + } + } + } + if (options.length == 0) { addEventListener("DOMContentLoaded", (_event) => { document.title = "Not found: '" + paramName + "'";