After extensive testing, I am pleased to share that I have refined the NeoCodeMap format so that it can serve as a proper outlining tool for Typst. You can click on the Firefox-rendered document, the source.typ file, or any location within the outline, and the three of them synchronise dynamically. It behaves like a dedicated editor (though many exist, few match the capabilities of ST Latest Typst IDEs).
"""
Indenter and patches for Typst in NeoCodeMap (Sublime Text).
https://github.com/klmp200/NeoCodeMap
Installation:
Preferences -> Browse Packages... -> User/
Copy this file as-is into that folder (any .py filename is acceptable).
The plugin reloads automatically when you save; no need to restart Sublime Text.
What it does:
1. Indentation by nesting level (tested and confirmed working).
Registers an indenter for the "text.typst" scope that calculates the nesting
level by counting the "=" characters in each heading (=, ==, === ...), using
the same approach that NeoCodeMap already applies to Markdown (counting the
number in "markup.heading.N"). Typst does not include the level in the scope
name, so we extract it directly from the line text using a regex.
2. Hide the "kind" badge (the yellow "S") in front of each entry.
The initial attempt (remapping the kind_id MARKUP to the already-hidden
"kind_ambiguous" class) had no visible effect. Rather than guessing which
kind_id Sublime uses for Typst sections, we directly hide the CSS class
".kind" for all entries by adding a rule to the stylesheet that NeoCodeMap
already loads. This is done in "plugin_loaded" with a set_timeout(...,0) to
ensure that NeoCodeMap's own plugin_loaded() has already run and populated
its global "css" variable; otherwise, our change would be overwritten
immediately afterwards.
Note: this hides the kind badge for ALL languages, not just Typst
(NeoCodeMap uses the same stylesheet for everything). If you ever want to
restore it for other languages, delete block 2 entirely.
3. Select the entire line when clicking an entry in the map, rather than just
moving the cursor (a single point with no selection). NeoCodeMap by default
leaves a "simple" cursor (sublime.View.sel().add(region.begin())), and this
does not trigger the Tinymist/LSP jump during rendering, as you mentioned
happened before fixing it in nextprevsect.py. We patch
CodeMapManager.move_to_region so that after the goto_line, it selects the
entire heading line (view.line(...)) instead of a single point. If
nextprevsect.py selects something more specific (for example, just the
heading text), let me know and I will adjust it accordingly.
4. Remove the "⧉" icon (goto reference) at the end of each entry.
This icon is the second <a> element generated by get_html() for each symbol,
with no dedicated class to reliably hide it via CSS (and Sublime's HTML
engine — minihtml — does not reliably support selectors like :last-child).
Therefore, we wrap get_html() and strip that <a> from the resulting HTML
using a regex, matching the "neo_code_map_goto_reference" command name in
the href.
5. Smaller font size in the outline (adjust OUTLINE_FONT_SIZE below).
Note: minihtml (Sublime's CSS engine) supports only simple selectors
(a single class or tag), nothing like comma-separated lists or descendant
combinators (".item, .item a" does not work; you must use ".item {...}" and
"a {...}" separately). The rest of NeoCodeMap's CSS follows this same pattern.
Note on synchronisation loss after multiple consecutive saves:
Sublime reloads this file on the fly each time you save it, but it does not
reload NeoCodeMap or undo our previous patches. If each reload were to wrap
an already-patched method (for example, get_html) over itself again, a new
wrapping layer would accumulate with each save, potentially leaving the
plugin in an unstable state after several consecutive edits (which is why a
Sublime Text restart would fix it). Therefore, all patches below check
whether they have already been applied before reapplying them (idempotent):
you can save this file as many times as you like without needing to restart.
"""
import re
import sublime
from NeoCodeMap import indenter
from NeoCodeMap import NeoCodeMap as _neocodemap
from NeoCodeMap.NeoCodeMap import CodeMapManager
# Adjust this scope if your Typst syntax package uses a different one.
# Check it with "Tools -> Developer -> Show Scope Name" (ctrl+alt+shift+p)
# on a .typ file: it should start with something like "text.typst".
TYPST_SCOPE = "text.typst"
_HEADING_RE = re.compile(r"^\s*(=+)\s")
def typst_indenter(view: sublime.View, symbol: sublime.SymbolRegion) -> int:
"""Nesting level of a Typst heading according to its number of '='.
'=' Title -> level 0
'== Section' -> level 1
'=== Subsection' -> level 2
etc.
If the line is not a recognisable heading (e.g. it comes from another type of
symbol), we delegate to the default indenter by raising IndenterError,
just as the Markdown indenter included in NeoCodeMap does.
"""
line_text = view.substr(view.line(symbol.region.a))
match = _HEADING_RE.match(line_text)
if not match:
raise indenter.IndenterError
level = len(match.group(1))
return level - 1
indenter.register_indenter(TYPST_SCOPE, typst_indenter)
# ---------------------------------------------------------------------------
# 2) + 5) Visual adjustments: hide the kind badge and reduce font size
# (+ matching line spacing). Added as extra CSS rules to the sheet
# loaded by NeoCodeMap.
#
# Odd finding (for the ST forum): a rule ".item { font-size: ... }"
# alone, in its own block, had no effect—not even after restarting ST—.
# But if in the SAME block ".item { ... }" you also change some other
# property already present in the base sheet (here, "padding-bottom",
# which NeoCodeMap's original CSS already sets to 5px), then font-size
# IS applied. It appears that set_contents() on an HtmlSheet does not
# recalculate font metrics if the only property that changes from the
# previous render is font-size by itself—you need to "touch" some other
# property of the same selector to force a complete relayout—. And even
# then, line-height was not recalculated automatically; you also have to
# set it explicitly (absolute units; minihtml does not accept line-height
# without a unit).
# ---------------------------------------------------------------------------
OUTLINE_FONT_SIZE = "0.7rem" # baja/sube este valor a tu gusto
OUTLINE_LINE_HEIGHT = "0.8rem" # ajustalo junto con el de arriba
OUTLINE_PADDING_BOTTOM = "1px" # distinto del 5px original: es lo que
# parece forzar a minihtml a recalcular
_CSS_OVERRIDES_MARKER = "/* --- typst_neocodemap overrides --- */"
_CSS_OVERRIDES = f"""
{_CSS_OVERRIDES_MARKER}
.kind {{ display: none; }}
.item {{
padding-bottom: {OUTLINE_PADDING_BOTTOM};
font-size: {OUTLINE_FONT_SIZE};
line-height: {OUTLINE_LINE_HEIGHT};
}}
"""
# No separate rule is needed for "a": the font-size of ".item" is inherited by
# the <a> elements inside it (normal CSS inheritance works fine; the problem was
# specifically that the property wasn't being recalculated).
def plugin_loaded():
sublime.set_timeout(_apply_css_overrides, 0)
def _apply_css_overrides():
# If we had already added overrides in a previous reload, we
# cut them and add them again (allows changing OUTLINE_FONT_SIZE
# and seeing the result just by saving, without accumulating old rules).
marker_index = _neocodemap.css.find(_CSS_OVERRIDES_MARKER)
base_css = _neocodemap.css if marker_index == -1 else _neocodemap.css[:marker_index]
_neocodemap.css = base_css + _CSS_OVERRIDES
if _neocodemap.map_manager:
_neocodemap.map_manager.refresh_all()
# ---------------------------------------------------------------------------
# 3) Select the complete line when jumping from the map, so that
# Tinymist/LSP sync listener reacts the same way as with
# nextprevsect.py.
# ---------------------------------------------------------------------------
def _move_to_region_full_line(self, view: sublime.View, region: sublime.Region) -> None:
if window := view.window():
window.focus_view(view)
view.sel().clear()
view.run_command("goto_line", {"line": view.rowcol(region.begin())[0] + 1})
view.sel().clear()
view.sel().add(view.line(region.begin()))
CodeMapManager.move_to_region = _move_to_region_full_line
# ---------------------------------------------------------------------------
# 4) Remove the "⧉" icon (goto reference) at the end of each entry, and
# 5b) reduce the indentation per level.
# Wrap get_html() ONLY once: if you save this file several times in
# succession (hot reload), we don't want to wrap an already-wrapped
# version on each save (that's what probably caused the synchronisation
# loss you saw, and why restarting ST was necessary). The "_typst_patched"
# marker prevents this stacking.
#
# The indentation is calculated internally by NeoCodeMap, it's not an
# exposed setting: each entry carries an inline style='margin-left: Xrem;'
# where X = 0.5 + level * 1.6 (see CodeMapManager.get_html/indent_css in
# NeoCodeMap.py). Since we can't touch that formula without rewriting the
# entire method, the generated HTML is rewritten with a regex, scaling each
# "margin-left: Xrem;" by INDENT_SCALE.
# ---------------------------------------------------------------------------
INDENT_SCALE = 0.3 # 1.0 = indentacion original, 0.5 = mitad, etc.
_REFERENCE_LINK_RE = re.compile(
r"<a[^>]*neo_code_map_goto_reference[^>]*>\s*⧉\s*</a>"
)
_MARGIN_LEFT_RE = re.compile(r"margin-left:\s*([\d.]+)rem;")
def _scale_margin_left(match: "re.Match[str]") -> str:
value = float(match.group(1)) * INDENT_SCALE
return f"margin-left: {value}rem;"
if not getattr(CodeMapManager.get_html, "_typst_patched", False):
_original_get_html = CodeMapManager.get_html
def _get_html_without_reference_link(self, view: sublime.View = None) -> str:
html = _original_get_html(self, view)
html = _REFERENCE_LINK_RE.sub("", html)
html = _MARGIN_LEFT_RE.sub(_scale_margin_left, html)
return html
_get_html_without_reference_link._typst_patched = True
CodeMapManager.get_html = _get_html_without_reference_link