← Back to Plan

Linking Architecture

Linking Architecture

How links, transclusions, images, and attachments work throughout Steel Notes,

end to end: syntax β†’ editor token model β†’ rendering β†’ indexing β†’ navigation β†’

lifecycle (rename/move/delete/GC).

Verified against the codebase on 2026-07-30. The [[wikilink]] grammar is

fully retired (tokenizer, classifier, extractors, and renderers recognize only

the forms below); VaultManager.migrateLegacyLinks() converts legacy content

once per vault, gated by a .steel/link-migration-v1 marker

(VaultManager.kt:50-75). History and rationale: LINK_FORMAT_MIGRATION_PLAN.md.

The reference grammar

One markdown syntax family, distinguished only by the URL:

SyntaxMeaningTarget semantics
TitleNote linkStable note **id** (= filename stem)
!TitleTransclusion (embed)Stable note id
textExternal linkhttp/https/mailto β€” never indexed as a reference
textFile linkVault-**root**-relative path
!altImageVault-root-relative path, in practice attachments/<file>
Frontmatter photo: / photos: / cover_image: / thumbnail:Structured image refsVault-root-relative path
Frontmatter resources: [id, …], companion: id, Quote resource: idStructured note refsNote/Resource id

Key asymmetry: **steel:// targets identity (note id), path links target

location.** Ids never change (title renames don't touch the filename), so

note links survive any reorganization with zero content rewriting. Path

targets are only stable because attachments/ is flat and its generated

names (below) are never renamed.

The link title is display-only and may go stale after a rename;

resolution consults the id alone. Titles are sanitized at insertion

([, ], (, ), newlines β†’ space) so they can't break the surrounding

markdown.

The steel:// scheme β€” SteelUri.kt

shared/src/commonMain/kotlin/com/steelnotes/parser/SteelUri.kt is the single

source of truth: scheme detection (hasScheme), id extraction (noteId β€”

trims, rejects empty host), builders (noteLink, transclusion,

sanitizeTitle), and the canonical regexes every layer matches through

(noteLinkPattern, transclusionPattern, and per-id variants for whole-vault

rewrites). Never inline a steel:// regex elsewhere β€” the patterns used to be

four hand-rolled copies that disagreed on case and whitespace.

Two deliberate properties:

  • A malformed steel:// (no id) is treated as a mistyped note reference,
  • never as a file path β€” nothing tries to open a file named steel://.

  • The id capture is [^)\s]+, so extracted ids are never blank or padded.
  • There is no OS-registered steel:// URL scheme β€” the scheme lives only

    inside .md files; nothing outside the app can open one.

    Vault layout and ids

    ```

    vault/

    notes/<id>.md (plus user folders: notes/<folder>/…/<id>.md)

    resources/<id>.md quotes/<id>.md

    attachments/<YYMMDD-8hex>.<ext> .steel/ (local markers)

    ```

  • User-created folders live **under notes/** (listFolders,
  • moveNote β€” VaultManager.kt:958-999); resources, quotes,

    attachments, notes, inbox, .steel are structural, never user

    folders.

  • Note id = filename stem (e.g. note-2026-07-22-cce1abf7,
  • share-2026-07-20-154839). Ids never contain /.

  • Attachment names come from AttachmentNaming.generate(ext)
  • (model/AttachmentNaming.kt): flat YYMMDD-{8hex}.{ext}. A one-time

    vault migration flattened legacy attachment subdirectories and rewrote

    the referencing markdown (VaultManager.kt:126, 227-290).

    Layer 1 β€” Editor token model (KMP shared)

    shared/src/commonMain/kotlin/com/steelnotes/editor/

  • InlineTokens.kt β€” flat styled-runs model (sealed interface InlineToken):
  • MarkerToken (hidden syntax chars), TextToken (visible text with style

    flags), and AtomicToken(raw, kind, text, payload) with

    Kind { LINK, NOTE_LINK }:

  • NOTE_LINK: text = title, payload = bare note id (scheme stripped via
  • SteelUri).

  • LINK: text = link text, payload = URL (external or path).
  • **Invariant:** concatenating every token's raw reconstructs the resource
  • line exactly β€” the offset map (resource↔display) is built on it. Any new

    link syntax must preserve this.

  • InlineTokenizer.kt β€” inside the text branch, a steel target emits
  • NOTE_LINK (:216), everything else LINK. A whole-line

    !… is not an inline token β€” transclusions are block-level.

  • MarkdownLineClassifier.kt:91-95 β€” whole-line !… classifies
  • as TRANSCLUSION, other whole-line !… as IMAGE; a malformed steel

    target is neither.

    Layer 2 β€” Insertion

  • FormattingEngine.kt:584 insertNoteLink(text, cursor, targetId, title) β†’
  • <title> (blank title falls back to the id).

  • FormattingEngine.kt:596 insertTransclusion(…) β†’ !<title>
  • on its own line.

  • iOS pickers (modal search sheets β€” there is no [[-typed autocomplete):
  • NoteLinkPickerView.swift and QuotePickerView.swift deliver (id, title)

    through UnifiedEditorViewController to the engine.

  • Images: EditorAttachmentController.swift:220 insertImageMarkdown(path:)
  • inserts !…; photo capture writes bytes to

    vault/attachments/ under a generated name, then inserts (:274-281).

    On load, the controller hydrates missing attachment bytes from sync (:40).

    Layer 3 β€” Rendering (iOS)

    Live editor (ParagraphBuilder.swift)

    Hidden-marker WYSIWYG: syntax characters become hidden offset-map segments

    (displayLength 0).

  • **Note link** (:452-478): the leading [ renders as a one-glyph
  • NSTextAttachment icon β€” an .attachment(1,1) map segment, the tested

    atomic-cursor primitive β€” showing the linked note's type icon and color

    (via itemLookup, generic note icon as fallback); the title is visible in

    blue; the ](steel://id) suffix is hidden. Anchoring the icon on [ keeps

    edits near it on the full-rebuild path.

  • **Markdown link** (:479-495): [ hidden, text blue + underlined,
  • ](url) suffix hidden.

  • Neither is tappable β€” there is no textView(_:shouldInteractWith:) anywhere.
  • Block-level whole-line matches:
  • !alt β†’ parseImageLine (:514) β†’ inline image (rejects steel
  • targets).

  • !Title β†’ parseTransclusionLine (:524) β†’ line collapsed;
  • the note surfaces as a "Linked Notes" card below the editor

    (UnifiedEditorViewController), ids via TransclusionExtractor; tapping a

    card navigates.

    Read-only views (InlineMarkdownParser.swift)

    Same KMP tokenizer rendered to SwiftUI Text: NOTE_LINK = type icon +

    accent title; LINK = accent + underline. Not tappable.

    Tap-to-navigate surfaces today: transclusion cards and Resource

    table-of-contents rows. Navigation is AppNavigation navPath.append(noteId)

    resolved by getNoteById.

    Layer 4 β€” Indexing (two parallel systems)

    A. Id-keyed links table (backlinks graph)

  • Schema SteelNotes.sq:39: links(resource_id, target_path, link_type) β€”
  • despite the column name, target_path holds the note id.

  • Populated by NoteIndexer.kt:118-140 per index pass, matching through
  • SteelUri.ids(...) over code-stripped prose:

    note links β†’ 'reference', transclusions β†’ 'transclusion', structured

    refs (Note.resources, Quote.resourceId) β†’ 'structured'.

    Markdown path links are not indexed here.

  • Consumed by getBacklinks (SteelNotes.sq:163) β†’
  • VaultManager.getBacklinks β†’ SharedCoreService.getBacklinks

    (SharedCoreService.swift:331). No UI consumes it yet.

    B. Path-keyed file_reference table (dependency graph)

  • Schema SteelNotes.sq:87; populated by ReferenceIndexer.kt from
  • ReferenceParser.kt.

  • ReferenceParser extracts LINK / TRANSCLUSION / IMAGE / ATTACHMENT from
  • body and frontmatter (photo, photos, cover_image, thumbnail).

    Steel targets are stored as the bare id; path targets are resolved via

    resolveRelativePath (./, ../, else vault-root-relative); external URLs

    are excluded; code fences and inline code are skipped.

    Layer 5 β€” Structured frontmatter links

    Serialized by NoteSerializer.kt, parsed back by NoteFactory.kt:

  • resources: [<id>, …] β€” notes citing Resources (reference list).
  • companion: <id> + resource_url: <url> β€” the identity fact that a Note *is*
  • the notes-tab companion of a Resource (Note.companionOf / resourceUrl,

    Note.kt:36-50). resource_url is stamped once at creation so the raw .md

    names a reachable article, not just an opaque id. See COMPANION_NOTES.md.

  • Quotes carry resource: <id>; Resources carry cover_image / thumbnail
  • paths and url.

    Both keys are omitted when unset so ordinary notes serialize byte-identically

    (no reindex churn).

    Layer 6 β€” Lifecycle behavior

  • **Title rename**: filename/id unchanged β†’ nothing rewritten; link titles
  • elsewhere go stale (accepted, Obsidian-alias-style). No staleness refresh

    exists.

  • **Move** (VaultManager.moveNote): patches the file_reference index only;
  • file contents never rewritten. Safe for steel links (id unchanged); would

    break body path links, which in practice only target the never-moving

    attachments/.

  • **Delete** (VaultManager.deleteNote:769): removes file + index rows, then
  • deletes the note's attachments if no other note references them. It does

    not rewrite other notes' content β€” inbound steel:// links dangle

    (render fine; navigation target missing). ReferenceRewriter.rewrite /

    removeReferences (whole-vault content rewrites incl. frontmatter fields)

    exist and are tested but have no production caller β€” dormant machinery

    for id-rewrites/unlinking if ever needed.

  • **Attachment GC** (VaultManager.kt:819): scans attachments/ against the
  • union of all references parsed by ReferenceParser (body + frontmatter),

    with an mtime safety window, a pending-sync-push exclusion, and a

    continuous-orphan grace period before deletion.

  • **Sync/server/CLI**: sync moves file bytes; link semantics are client-only.
  • No server or CLI parsing of links.

    Known warts

    1. Nothing is tappable inline β€” note links, path links, and web links all

    render as links but only transclusion cards navigate.

    2. Two disjoint index systems (links id-keyed, file_reference

    path-keyed) built from two parsers (SteelUri extractors vs

    ReferenceParser).

    3. Backlinks are computed but never shown.

    4. Deleting a note leaves dangling inbound links (the unlink rewriter is dead

    code).

    5. Body image paths are vault-root-relative while notes live in notes/, so

    the raw files don't render in strict external md tooling β€” see

    MD_EXPORT_EVALUATION.md.