From e3f8d4201bb35bb779796976e5b8f03a0a960f74 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Wed, 25 Feb 2026 14:24:08 -0500 Subject: [PATCH 01/11] wip --- .changeset/yellow-coins-show.md | 5 ++ src/lib/MdzNodeView.svelte | 7 ++- src/lib/mdz.ts | 53 +++++++++++++---- src/lib/mdz_to_svelte.ts | 6 +- src/routes/docs/mdz/+page.svelte | 26 ++++----- src/routes/docs/mdz/CLAUDE.md | 31 ++++++++++ src/routes/docs/mdz/grammar/mdz_grammar.mdz | 16 +++--- src/routes/docs/mdz/spec/mdz_spec.mdz | 13 +++-- src/routes/library.json | 2 +- .../mdz/link_path_invalid_relative/input.mdz | 1 - .../mdz/link_path_relative/expected.json | 57 +++++++++++++++++++ .../fixtures/mdz/link_path_relative/input.mdz | 1 + .../expected.json | 6 +- .../mdz/link_path_relative_no_match/input.mdz | 1 + .../autolink_relative_path/expected.json | 3 + .../autolink_relative_path/input.svelte | 5 ++ 16 files changed, 187 insertions(+), 46 deletions(-) create mode 100644 .changeset/yellow-coins-show.md create mode 100644 src/routes/docs/mdz/CLAUDE.md delete mode 100644 src/test/fixtures/mdz/link_path_invalid_relative/input.mdz create mode 100644 src/test/fixtures/mdz/link_path_relative/expected.json create mode 100644 src/test/fixtures/mdz/link_path_relative/input.mdz rename src/test/fixtures/mdz/{link_path_invalid_relative => link_path_relative_no_match}/expected.json (52%) create mode 100644 src/test/fixtures/mdz/link_path_relative_no_match/input.mdz create mode 100644 src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path/expected.json create mode 100644 src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path/input.svelte diff --git a/.changeset/yellow-coins-show.md b/.changeset/yellow-coins-show.md new file mode 100644 index 000000000..4584bfb65 --- /dev/null +++ b/.changeset/yellow-coins-show.md @@ -0,0 +1,5 @@ +--- +'@fuzdev/fuz_ui': minor +--- + +feat: add relative link support to mdz diff --git a/src/lib/MdzNodeView.svelte b/src/lib/MdzNodeView.svelte index 8fc8b0445..e6d293275 100644 --- a/src/lib/MdzNodeView.svelte +++ b/src/lib/MdzNodeView.svelte @@ -53,10 +53,11 @@ {:else if node.type === 'Link'} {@const {reference} = node} {#if node.link_type === 'internal'} - {@const is_fragment_or_query_only = reference.startsWith('#') || reference.startsWith('?')} - + {@const skip_resolve = + reference.startsWith('#') || reference.startsWith('?') || reference.startsWith('.')} + - {@render render_children(node.children)} {:else} diff --git a/src/lib/mdz.ts b/src/lib/mdz.ts index d5857f385..f673b20ef 100644 --- a/src/lib/mdz.ts +++ b/src/lib/mdz.ts @@ -91,7 +91,7 @@ export interface MdzLinkNode extends MdzBaseNode { type: 'Link'; reference: string; // URL or path children: Array; // Display content (can include inline formatting) - link_type: 'external' | 'internal'; // external: https/http, internal: /path + link_type: 'external' | 'internal'; // external: https/http, internal: /path, ./path, ../path } export interface MdzParagraphNode extends MdzBaseNode { @@ -1124,14 +1124,13 @@ export class MdzParser { } /** - * Check if current position is the start of an internal path (starts with /). + * Check if current position is the start of an absolute path (starts with /). */ - #is_at_internal_path(): boolean { + #is_at_absolute_path(): boolean { if (this.#template.charCodeAt(this.#index) !== SLASH) { return false; } // Check previous character - must be whitespace or start of string - // (to avoid matching / within relative paths like ./a/b or ../a/b) if (this.#index > 0) { const prev_char = this.#template.charCodeAt(this.#index - 1); if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) { @@ -1148,6 +1147,38 @@ export class MdzParser { return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE; } + /** + * Check if current position is the start of a relative path (`./` or `../`). + */ + #is_at_relative_path(): boolean { + if (this.#template.charCodeAt(this.#index) !== PERIOD) { + return false; + } + // Check previous character - must be whitespace or start of string + if (this.#index > 0) { + const prev_char = this.#template.charCodeAt(this.#index - 1); + if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) { + return false; + } + } + const remaining = this.#template.length - this.#index; + // Check for ../ (at least 4 chars: ../x) + if ( + remaining >= 4 && + this.#template.charCodeAt(this.#index + 1) === PERIOD && + this.#template.charCodeAt(this.#index + 2) === SLASH + ) { + const after = this.#template.charCodeAt(this.#index + 3); + return after !== SPACE && after !== NEWLINE && after !== SLASH; + } + // Check for ./ (at least 3 chars: ./x) + if (remaining >= 3 && this.#template.charCodeAt(this.#index + 1) === SLASH) { + const after = this.#template.charCodeAt(this.#index + 2); + return after !== SPACE && after !== NEWLINE && after !== SLASH; + } + return false; + } + /** * Parse auto-detected external URL (`https://` or `http://`). * Uses RFC 3986 whitelist validation for valid URI characters. @@ -1191,10 +1222,10 @@ export class MdzParser { } /** - * Parse auto-detected internal path (starts with /). + * Parse auto-detected path (absolute `/`, relative `./` or `../`). * Uses RFC 3986 whitelist validation for valid URI characters. */ - #parse_auto_link_internal(): MdzLinkNode { + #parse_auto_link_path(): MdzLinkNode { const start = this.#index; // Collect path characters using RFC 3986 whitelist @@ -1287,12 +1318,12 @@ export class MdzParser { #parse_text(): MdzTextNode | MdzLinkNode { const start = this.#index; - // Check for URL or internal path at current position + // Check for URL or internal/relative path at current position if (this.#is_at_url()) { return this.#parse_auto_link_url(); } - if (this.#is_at_internal_path()) { - return this.#parse_auto_link_internal(); + if (this.#is_at_absolute_path() || this.#is_at_relative_path()) { + return this.#parse_auto_link_path(); } while (this.#index < this.#template.length) { @@ -1332,8 +1363,8 @@ export class MdzParser { } } - // Check for URL or internal path mid-text - if (this.#is_at_url() || this.#is_at_internal_path()) { + // Check for URL or internal/relative path mid-text + if (this.#is_at_url() || this.#is_at_absolute_path() || this.#is_at_relative_path()) { break; } diff --git a/src/lib/mdz_to_svelte.ts b/src/lib/mdz_to_svelte.ts index 7f206dd76..457469e61 100644 --- a/src/lib/mdz_to_svelte.ts +++ b/src/lib/mdz_to_svelte.ts @@ -80,7 +80,11 @@ export const mdz_to_svelte = ( case 'Link': { const children_markup = render_nodes(node.children); if (node.link_type === 'internal') { - if (node.reference.startsWith('#') || node.reference.startsWith('?')) { + if ( + node.reference.startsWith('#') || + node.reference.startsWith('?') || + node.reference.startsWith('.') + ) { return `${children_markup}`; } imports.set('resolve', {path: '$app/paths', kind: 'named'}); diff --git a/src/routes/docs/mdz/+page.svelte b/src/routes/docs/mdz/+page.svelte index f0fa7a4e1..0723fb6dc 100644 --- a/src/routes/docs/mdz/+page.svelte +++ b/src/routes/docs/mdz/+page.svelte @@ -32,6 +32,7 @@ const code_plain_example = 'This `identifier` does not exist.'; const link_external_example = '[Fuz API docs](https://fuz.dev/docs/api) and https://fuz.dev/docs/api and /docs/api'; + const link_relative_example = 'See ./grammar and ./spec and ../mdz for relative paths.'; const linebreak_example = 'First line.\nSecond line.\nThird line.'; const paragraph_example = 'First paragraph.\n\nSecond paragraph.\nLinebreak in second paragraph.'; const triple_linebreak_example = @@ -52,9 +53,10 @@

mdz is a small markdown dialect that supports Svelte components, auto-detected URLs prefixed - with https:// and /, and Fuz integration like linkified identifiers - and modules in `backticks`. The goal is to securely integrate markdown with the - environment's capabilities, while being simple and friendly to nontechnical users. + with https://, /, ./, and ../, and Fuz + integration like linkified identifiers and modules in `backticks`. The goal is to + securely integrate markdown with the environment's capabilities, while being simple and + friendly to nontechnical users.

mdz prioritizes predictability with one canonical pattern per feature, preferring false @@ -155,27 +157,21 @@ -

mdz supports three kinds of links:

+

mdz supports four kinds of links:

  • standard markdown link syntax
  • external URLs starting with https:// or http://
  • -
  • internal paths starting with /
  • +
  • absolute paths starting with /
  • +
  • relative paths starting with ./ or ../
-

- Note: Relative paths (./, ../) are not supported - (currently, I think this will be changed). mdz content may be rendered at different URLs than - where source files live (e.g., TSDoc comments from src/lib/foo.ts render at - /docs/api/foo.ts). Root-relative paths (/docs/...) have unambiguous - meaning regardless of render location, making them more portable. However it seems very useful - to make - ../ and ./ links work, maybe we can support it and make the renderer accept - a custom base path? -

+

Relative paths use raw hrefs (the browser resolves them against the current URL):

+ + diff --git a/src/routes/docs/mdz/CLAUDE.md b/src/routes/docs/mdz/CLAUDE.md new file mode 100644 index 000000000..a2f4ffc27 --- /dev/null +++ b/src/routes/docs/mdz/CLAUDE.md @@ -0,0 +1,31 @@ +# mdz docs + +mdz (minimal markdown dialect) documentation routes. + +## Pages + +- `+page.svelte` - main docs page with interactive examples and usage guide +- `grammar/mdz_grammar.mdz` - formal grammar specification (rendered via Mdz) +- `spec/mdz_spec.mdz` - comprehensive spec with examples (rendered via Mdz) +- `fixtures/` - debug page rendering all test fixtures with JSON output + +The grammar and spec are `.mdz` files imported with `?raw` and rendered by the +`Mdz` component — mdz documenting itself. + +## Auto-linking + +mdz auto-links four path patterns: + +- `https://` and `http://` - external URLs (`link_type: 'external'`) +- `/path` - absolute internal paths, resolved via SvelteKit `resolve()` +- `./path` and `../path` - relative internal paths, raw hrefs (browser resolves) + +All auto-linked paths must be preceded by whitespace or start of string. +Trailing punctuation (`.,:;!?]`) is trimmed per GFM conventions. + +## Preprocessor + +Static `` usages are compiled at build time by +`svelte_preprocess_mdz` into `MdzPrecompiled` with pre-rendered children, +eliminating runtime parsing. Relative paths skip the `resolve()` import +since the browser handles resolution. diff --git a/src/routes/docs/mdz/grammar/mdz_grammar.mdz b/src/routes/docs/mdz/grammar/mdz_grammar.mdz index f5bb655be..c4c38a09b 100644 --- a/src/routes/docs/mdz/grammar/mdz_grammar.mdz +++ b/src/routes/docs/mdz/grammar/mdz_grammar.mdz @@ -162,7 +162,9 @@ _UrlChar_ = ~[`)` | `\n` | WHITESPACE] _AutoUrl_ = (`https://` | `http://`) _UrlChar_+ -_AutoPath_ = `/` _PathChar_+ +_AutoPath_ = _AbsolutePath_ | _RelativePath_ +_AbsolutePath_ = `/` _PathChar_+ +_RelativePath_ = (`./` | `../`) _PathChar_+ _PathChar_ = ~[WHITESPACE | _TrailingPunctuation_] _TrailingPunctuation_ = `.` | `,` | `;` | `:` | `!` | `?` | `]` @@ -178,7 +180,7 @@ Closing `)` terminates the URL. Link nodes have a `link_type` field: - `'external'` - URL starts with `https://` or `http://` -- `'internal'` - URL starts with `/` (root-relative path) +- `'internal'` - absolute (`/path`) or relative (`./path`, `../path`) **Auto-detected URLs:** @@ -189,14 +191,14 @@ Balanced parentheses: `https://fuz.dev/page_(disambiguation)` keeps the closing **Auto-detected paths:** -Must start with `/` (root-relative only). -Relative paths (`./`, `../`) are NOT supported. +Absolute paths start with `/`. Relative paths start with `./` or `../`. +All path types must be preceded by whitespace or start of string. Continues until whitespace or trailing punctuation. Trailing punctuation handling same as auto-URLs. -**Rationale for path restrictions:** +**Rendering note:** -mdz content may be rendered at different URLs than where the source file lives. Relative paths would be ambiguous. Root-relative paths (`/docs/api`) have unambiguous meaning regardless of where the mdz content is rendered. +Absolute paths are resolved through SvelteKit's `resolve()` to apply the base path. Relative paths use raw hrefs, letting the browser resolve them against the current URL. --- @@ -557,6 +559,6 @@ interface MdzComponentNode extends MdzBaseNode { - Code nodes have `content` field with the code string (identifier/module name) - Codeblock nodes have `lang` (language hint, nullable) and `content` (raw code) - Heading nodes have `level` (1-6) and `children` (inline content) -- Link nodes have `reference` (URL/path), `link_type` ('external' | 'internal'), and `children` (display content) +- Link nodes have `reference` (URL/path), `link_type` ('external' | 'internal'), and `children` (display content). Internal covers both absolute (`/path`) and relative (`./path`, `../path`) references - Component and Element nodes have `name` and `children` - Bold, Italic, Strikethrough, and Paragraph nodes have `children` arrays diff --git a/src/routes/docs/mdz/spec/mdz_spec.mdz b/src/routes/docs/mdz/spec/mdz_spec.mdz index 4669199ee..d0150ff1a 100644 --- a/src/routes/docs/mdz/spec/mdz_spec.mdz +++ b/src/routes/docs/mdz/spec/mdz_spec.mdz @@ -196,16 +196,18 @@ https://en.wikipedia.org/wiki/Markdown_(markup_language) The closing `)` is included in the URL. -### Auto-Detected Internal Paths +### Auto-Detected Paths -Paths starting with `/` are automatically linked (root-relative paths): +Paths starting with `/`, `./`, or `../` are automatically linked: ``` See /docs/api for the API documentation. Visit /docs/mdz/grammar for the formal grammar. +See ./sibling for a sibling file. +See ../shared/utils for a parent-relative path. ``` -**Important:** Relative paths (`./`, `../`) are NOT supported. mdz content may be rendered at different URLs than its source location, making relative paths ambiguous. Root-relative paths have unambiguous meaning regardless of context. +Absolute paths (`/docs/api`) are resolved through SvelteKit's `resolve()` to apply the base path. Relative paths (`./foo`, `../bar`) use raw hrefs, letting the browser resolve them against the current URL. --- @@ -528,6 +530,7 @@ mdz deliberately diverges from CommonMark and GitHub Flavored Markdown to minimi | Block indent | Up to 3 spaces allowed | Must be column 0 (all block elements) | | Heading no space | `#text` valid | Invalid (space required) | | Setext headings | `text\n---` = `

` | NOT supported (`---` always = HR) | +| Relative path links | Not auto-linked | `./path` and `../path` auto-link | **Rationale:** @@ -549,6 +552,8 @@ mdz is designed for documentation and content where predictability and simplicit **Reference documentation** - Inline code formatting works well for documenting APIs, configuration options, and technical references. +**File-structured static content** - Relative path auto-linking (`./sibling.md`, `../shared/utils`) makes mdz natural for wikis, indexes, and documentation trees where files reference their neighbors. + --- ## Future Features @@ -581,7 +586,7 @@ mdz is an **early proof of concept**: - Well-defined syntax and parsing rules - Core inline formatting (bold, italic, strikethrough, code) -- Links (markdown, auto-detected URLs and paths) +- Links (markdown, auto-detected URLs, absolute and relative paths) - Block elements (headings, horizontal rules, codeblocks) - Component and element integration - Whitespace preservation diff --git a/src/routes/library.json b/src/routes/library.json index 52d4ec848..3c9f62c34 100644 --- a/src/routes/library.json +++ b/src/routes/library.json @@ -5161,7 +5161,7 @@ { "name": "mdz_is_url", "kind": "function", - "source_line": 1823, + "source_line": 1854, "type_signature": "(s: string): boolean", "return_type": "boolean", "parameters": [ diff --git a/src/test/fixtures/mdz/link_path_invalid_relative/input.mdz b/src/test/fixtures/mdz/link_path_invalid_relative/input.mdz deleted file mode 100644 index 90a8c25a7..000000000 --- a/src/test/fixtures/mdz/link_path_invalid_relative/input.mdz +++ /dev/null @@ -1 +0,0 @@ -Relative paths like ./a/b and ../a/b should not auto-link. \ No newline at end of file diff --git a/src/test/fixtures/mdz/link_path_relative/expected.json b/src/test/fixtures/mdz/link_path_relative/expected.json new file mode 100644 index 000000000..caeba86e0 --- /dev/null +++ b/src/test/fixtures/mdz/link_path_relative/expected.json @@ -0,0 +1,57 @@ +[ + { + "type": "Paragraph", + "children": [ + { + "type": "Text", + "content": "See ", + "start": 0, + "end": 4 + }, + { + "type": "Link", + "reference": "./a/b", + "children": [ + { + "type": "Text", + "content": "./a/b", + "start": 4, + "end": 9 + } + ], + "link_type": "internal", + "start": 4, + "end": 9 + }, + { + "type": "Text", + "content": " and ", + "start": 9, + "end": 14 + }, + { + "type": "Link", + "reference": "../a/b", + "children": [ + { + "type": "Text", + "content": "../a/b", + "start": 14, + "end": 20 + } + ], + "link_type": "internal", + "start": 14, + "end": 20 + }, + { + "type": "Text", + "content": " for details.", + "start": 20, + "end": 33 + } + ], + "start": 0, + "end": 33 + } +] diff --git a/src/test/fixtures/mdz/link_path_relative/input.mdz b/src/test/fixtures/mdz/link_path_relative/input.mdz new file mode 100644 index 000000000..8752354e2 --- /dev/null +++ b/src/test/fixtures/mdz/link_path_relative/input.mdz @@ -0,0 +1 @@ +See ./a/b and ../a/b for details. \ No newline at end of file diff --git a/src/test/fixtures/mdz/link_path_invalid_relative/expected.json b/src/test/fixtures/mdz/link_path_relative_no_match/expected.json similarity index 52% rename from src/test/fixtures/mdz/link_path_invalid_relative/expected.json rename to src/test/fixtures/mdz/link_path_relative_no_match/expected.json index be02b2bcb..635277387 100644 --- a/src/test/fixtures/mdz/link_path_invalid_relative/expected.json +++ b/src/test/fixtures/mdz/link_path_relative_no_match/expected.json @@ -4,12 +4,12 @@ "children": [ { "type": "Text", - "content": "Relative paths like ./a/b and ../a/b should not auto-link.", + "content": "No match: x./foo or ./ alone or .. alone or ..", "start": 0, - "end": 58 + "end": 46 } ], "start": 0, - "end": 58 + "end": 46 } ] diff --git a/src/test/fixtures/mdz/link_path_relative_no_match/input.mdz b/src/test/fixtures/mdz/link_path_relative_no_match/input.mdz new file mode 100644 index 000000000..da142d66b --- /dev/null +++ b/src/test/fixtures/mdz/link_path_relative_no_match/input.mdz @@ -0,0 +1 @@ +No match: x./foo or ./ alone or .. alone or .. \ No newline at end of file diff --git a/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path/expected.json b/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path/expected.json new file mode 100644 index 000000000..78ce0bca9 --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path/expected.json @@ -0,0 +1,3 @@ +{ + "code": "\n\n

see ./docs/api for details

\n" +} diff --git a/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path/input.svelte b/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path/input.svelte new file mode 100644 index 000000000..a3e14f31f --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path/input.svelte @@ -0,0 +1,5 @@ + + + From 00b7539771f482f7f8124f5bf75b3f7901238520 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Wed, 25 Feb 2026 14:52:15 -0500 Subject: [PATCH 02/11] wip --- src/lib/Mdz.svelte | 5 ++ src/lib/MdzNodeView.svelte | 25 ++++++---- src/lib/mdz.ts | 4 +- src/lib/mdz_components.ts | 8 ++++ src/lib/mdz_to_svelte.ts | 9 ++++ src/lib/svelte_preprocess_mdz.ts | 30 +++++++++--- src/routes/docs/mdz/+page.svelte | 8 +++- src/routes/docs/mdz/CLAUDE.md | 16 +++++-- src/routes/docs/mdz/grammar/mdz_grammar.mdz | 2 +- src/routes/docs/mdz/spec/mdz_spec.mdz | 2 +- src/routes/library.json | 26 ++++++++-- .../expected.json | 3 ++ .../input.svelte | 5 ++ src/test/mdz_to_svelte.test.ts | 48 +++++++++++++++++++ src/test/svelte_preprocess_mdz.skip.test.ts | 27 +++++++++++ 15 files changed, 190 insertions(+), 28 deletions(-) create mode 100644 src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path_with_base/expected.json create mode 100644 src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path_with_base/input.svelte diff --git a/src/lib/Mdz.svelte b/src/lib/Mdz.svelte index 365f9061a..54b8d0fe3 100644 --- a/src/lib/Mdz.svelte +++ b/src/lib/Mdz.svelte @@ -3,18 +3,23 @@ import {mdz_parse} from './mdz.js'; import MdzNodeView from './MdzNodeView.svelte'; + import {mdz_base_context} from './mdz_components.js'; const { content, inline = false, nowrap = false, + base, ...rest }: (SvelteHTMLElements['div'] | SvelteHTMLElements['span']) & { content: string; inline?: boolean; nowrap?: boolean; + base?: string; } = $props(); + if (base !== undefined) mdz_base_context.set(base); + const nodes = $derived(mdz_parse(content)); diff --git a/src/lib/MdzNodeView.svelte b/src/lib/MdzNodeView.svelte index e6d293275..49da434c5 100644 --- a/src/lib/MdzNodeView.svelte +++ b/src/lib/MdzNodeView.svelte @@ -5,7 +5,11 @@ import type {MdzNode} from './mdz.js'; import DocsLink from './DocsLink.svelte'; import MdzNodeView from './MdzNodeView.svelte'; - import {mdz_components_context, mdz_elements_context} from './mdz_components.js'; + import { + mdz_components_context, + mdz_elements_context, + mdz_base_context, + } from './mdz_components.js'; const { node, @@ -15,6 +19,7 @@ const components = mdz_components_context.get_maybe(); const elements = mdz_elements_context.get_maybe(); + const mdz_base = mdz_base_context.get_maybe(); // TODO make `Code` customizable via context, maybe registered as component Codeblock? @@ -53,13 +58,17 @@ {:else if node.type === 'Link'} {@const {reference} = node} {#if node.link_type === 'internal'} - {@const skip_resolve = - reference.startsWith('#') || reference.startsWith('?') || reference.startsWith('.')} - - - {@render render_children(node.children)} + {@const skip_resolve = reference.startsWith('#') || reference.startsWith('?')} + {#if reference.startsWith('.') && mdz_base} + {@const resolved = new URL(reference, 'file://' + mdz_base).pathname} + {@render render_children(node.children)} + {:else if skip_resolve || reference.startsWith('.')} + + + {@render render_children(node.children)} + {:else} + {@render render_children(node.children)} + {/if} {:else} diff --git a/src/lib/mdz.ts b/src/lib/mdz.ts index f673b20ef..3c2c32447 100644 --- a/src/lib/mdz.ts +++ b/src/lib/mdz.ts @@ -1318,7 +1318,7 @@ export class MdzParser { #parse_text(): MdzTextNode | MdzLinkNode { const start = this.#index; - // Check for URL or internal/relative path at current position + // Check for URL or internal absolute/relative path at current position if (this.#is_at_url()) { return this.#parse_auto_link_url(); } @@ -1363,7 +1363,7 @@ export class MdzParser { } } - // Check for URL or internal/relative path mid-text + // Check for URL or internal absolute/relative path mid-text if (this.#is_at_url() || this.#is_at_absolute_path() || this.#is_at_relative_path()) { break; } diff --git a/src/lib/mdz_components.ts b/src/lib/mdz_components.ts index 511d4988e..5c3feb40f 100644 --- a/src/lib/mdz_components.ts +++ b/src/lib/mdz_components.ts @@ -32,3 +32,11 @@ export const mdz_components_context = create_context(); * By default, no HTML elements are allowed. */ export const mdz_elements_context = create_context(); + +/** + * Context for providing a base path for resolving relative links in mdz content. + * When set (e.g., `'/docs/mdz/'`), relative paths like `./grammar` resolve + * to absolute paths like `/docs/mdz/grammar` before rendering. + * When not set, relative paths use raw hrefs (browser resolves them). + */ +export const mdz_base_context = create_context(); diff --git a/src/lib/mdz_to_svelte.ts b/src/lib/mdz_to_svelte.ts index 457469e61..d915091e0 100644 --- a/src/lib/mdz_to_svelte.ts +++ b/src/lib/mdz_to_svelte.ts @@ -39,11 +39,15 @@ export interface MdzToSvelteResult { * If content references a component not in this map, `has_unconfigured_tags` is set. * @param elements Allowed HTML element names (e.g., `new Set(['aside', 'details'])`). * If content references an element not in this set, `has_unconfigured_tags` is set. + * @param base Base path for resolving relative links (e.g., `'/docs/mdz/'`). + * When provided, relative references (`./`, `../`) are resolved to absolute paths + * and passed through `resolve()`. Trailing slash recommended. */ export const mdz_to_svelte = ( nodes: Array, components: Record, elements: ReadonlySet, + base?: string, ): MdzToSvelteResult => { const imports: Map = new Map(); let has_unconfigured_tags = false; @@ -80,6 +84,11 @@ export const mdz_to_svelte = ( case 'Link': { const children_markup = render_nodes(node.children); if (node.link_type === 'internal') { + if (node.reference.startsWith('.') && base) { + const resolved = new URL(node.reference, 'file://' + base).pathname; + imports.set('resolve', {path: '$app/paths', kind: 'named'}); + return `${children_markup}`; + } if ( node.reference.startsWith('#') || node.reference.startsWith('?') || diff --git a/src/lib/svelte_preprocess_mdz.ts b/src/lib/svelte_preprocess_mdz.ts index aea98e76a..2704a2a84 100644 --- a/src/lib/svelte_preprocess_mdz.ts +++ b/src/lib/svelte_preprocess_mdz.ts @@ -305,6 +305,22 @@ const find_mdz_usages = ( const content_attr = find_attribute(node, 'content'); if (!content_attr) return; + // Extract optional static base prop for relative path resolution. + // If base is present but dynamic, skip precompilation entirely — + // MdzPrecompiled doesn't resolve relative paths at runtime, + // so precompiling with unresolved relative links would be wrong. + const base_attr = find_attribute(node, 'base'); + let base: string | undefined; + if (base_attr) { + const base_value = extract_static_string(base_attr.value, context.bindings); + if (base_value === null) return; // dynamic base — fall back to runtime + base = base_value; + } + + // Collect attributes to exclude from precompiled output + const exclude_attrs: Set = new Set([content_attr]); + if (base_attr) exclude_attrs.add(base_attr); + // Extract static string value const content_value = extract_static_string(content_attr.value, context.bindings); if (content_value !== null) { @@ -312,7 +328,7 @@ const find_mdz_usages = ( let result; try { const nodes = mdz_parse(content_value); - result = mdz_to_svelte(nodes, context.components, context.elements); + result = mdz_to_svelte(nodes, context.components, context.elements, base); } catch (error) { handle_preprocess_error(error, '[fuz-mdz]', context.filename, context.on_error); return; @@ -322,7 +338,7 @@ const find_mdz_usages = ( if (result.has_unconfigured_tags) return; const consumed = collect_consumed_bindings(content_attr.value, context.bindings); - const replacement = build_replacement(node, content_attr, result.markup, context.source); + const replacement = build_replacement(node, exclude_attrs, result.markup, context.source); transformed_usages.set(node.name, (transformed_usages.get(node.name) ?? 0) + 1); transformations.push({ start: node.start, @@ -349,7 +365,7 @@ const find_mdz_usages = ( try { for (const branch of chain) { const nodes = mdz_parse(branch.value); - const result = mdz_to_svelte(nodes, context.components, context.elements); + const result = mdz_to_svelte(nodes, context.components, context.elements, base); if (result.has_unconfigured_tags) return; branch_results.push({markup: result.markup, imports: result.imports}); } @@ -373,7 +389,7 @@ const find_mdz_usages = ( } children_markup += '{/if}'; - const replacement = build_replacement(node, content_attr, children_markup, context.source); + const replacement = build_replacement(node, exclude_attrs, children_markup, context.source); // Merge imports from all branches const merged_imports: Map = new Map(); @@ -478,14 +494,14 @@ const remove_dead_const_bindings = ( */ const build_replacement = ( node: AST.Component, - content_attr: AST.Attribute, + exclude_attrs: ReadonlySet, children_markup: string, source: string, ): string => { - // Collect source ranges of all attributes EXCEPT content + // Collect source ranges of all attributes except excluded ones (content, base when resolved) const other_attr_ranges: Array<{start: number; end: number}> = []; for (const attr of node.attributes) { - if (attr === content_attr) continue; + if (exclude_attrs.has(attr as AST.Attribute)) continue; other_attr_ranges.push({start: attr.start, end: attr.end}); } diff --git a/src/routes/docs/mdz/+page.svelte b/src/routes/docs/mdz/+page.svelte index 0723fb6dc..5b851b0f5 100644 --- a/src/routes/docs/mdz/+page.svelte +++ b/src/routes/docs/mdz/+page.svelte @@ -169,9 +169,13 @@ -

Relative paths use raw hrefs (the browser resolves them against the current URL):

+

+ Relative paths are resolved against the base prop when provided, producing + correct absolute paths. Without base, they use raw hrefs (the browser resolves + them against the current URL): +

- + diff --git a/src/routes/docs/mdz/CLAUDE.md b/src/routes/docs/mdz/CLAUDE.md index a2f4ffc27..24a8d444b 100644 --- a/src/routes/docs/mdz/CLAUDE.md +++ b/src/routes/docs/mdz/CLAUDE.md @@ -18,14 +18,24 @@ mdz auto-links four path patterns: - `https://` and `http://` - external URLs (`link_type: 'external'`) - `/path` - absolute internal paths, resolved via SvelteKit `resolve()` -- `./path` and `../path` - relative internal paths, raw hrefs (browser resolves) +- `./path` and `../path` - relative internal paths All auto-linked paths must be preceded by whitespace or start of string. Trailing punctuation (`.,:;!?]`) is trimmed per GFM conventions. +## Base path resolution + +The `base` prop on `Mdz` (and `mdz_base_context` context) controls how +relative paths (`./`, `../`) are resolved. When `base` is set (e.g., +`'/docs/mdz/'`), relative paths are resolved to absolute paths using +`new URL(reference, 'file://' + base).pathname` and then passed through +SvelteKit's `resolve()`. Without `base`, relative paths use raw hrefs +(browser resolves them against the current URL). + ## Preprocessor Static `` usages are compiled at build time by `svelte_preprocess_mdz` into `MdzPrecompiled` with pre-rendered children, -eliminating runtime parsing. Relative paths skip the `resolve()` import -since the browser handles resolution. +eliminating runtime parsing. When a static `base` prop is present, relative +paths are resolved at build time and the `base` prop is excluded from the +precompiled output. Without `base`, relative paths use raw hrefs. diff --git a/src/routes/docs/mdz/grammar/mdz_grammar.mdz b/src/routes/docs/mdz/grammar/mdz_grammar.mdz index c4c38a09b..db0f6e2a9 100644 --- a/src/routes/docs/mdz/grammar/mdz_grammar.mdz +++ b/src/routes/docs/mdz/grammar/mdz_grammar.mdz @@ -198,7 +198,7 @@ Trailing punctuation handling same as auto-URLs. **Rendering note:** -Absolute paths are resolved through SvelteKit's `resolve()` to apply the base path. Relative paths use raw hrefs, letting the browser resolve them against the current URL. +Absolute paths are resolved through SvelteKit's `resolve()` to apply the base path. Relative paths are resolved against the `base` prop/context when provided, producing absolute paths passed through `resolve()`. Without `base`, relative paths use raw hrefs, letting the browser resolve them against the current URL. --- diff --git a/src/routes/docs/mdz/spec/mdz_spec.mdz b/src/routes/docs/mdz/spec/mdz_spec.mdz index d0150ff1a..1bc7dab07 100644 --- a/src/routes/docs/mdz/spec/mdz_spec.mdz +++ b/src/routes/docs/mdz/spec/mdz_spec.mdz @@ -207,7 +207,7 @@ See ./sibling for a sibling file. See ../shared/utils for a parent-relative path. ``` -Absolute paths (`/docs/api`) are resolved through SvelteKit's `resolve()` to apply the base path. Relative paths (`./foo`, `../bar`) use raw hrefs, letting the browser resolve them against the current URL. +Absolute paths (`/docs/api`) are resolved through SvelteKit's `resolve()` to apply the base path. Relative paths (`./foo`, `../bar`) are resolved against the `base` prop/context when provided, producing absolute paths passed through `resolve()`. Without `base`, relative paths use raw hrefs, letting the browser resolve them against the current URL. --- diff --git a/src/routes/library.json b/src/routes/library.json index 3c9f62c34..b487bb862 100644 --- a/src/routes/library.json +++ b/src/routes/library.json @@ -4740,10 +4740,17 @@ "doc_comment": "Context for providing allowed HTML elements.\nMust be set by the application using mdz.\nBy default, no HTML elements are allowed.", "source_line": 34, "type_signature": "{ get: (error_message?: string | undefined) => MdzElements; get_maybe: () => MdzElements | undefined; set: (value: MdzElements) => MdzElements; }" + }, + { + "name": "mdz_base_context", + "kind": "variable", + "doc_comment": "Context for providing a base path for resolving relative links in mdz content.\nWhen set (e.g., `'/docs/mdz/'`), relative paths like `./grammar` resolve\nto absolute paths like `/docs/mdz/grammar` before rendering.\nWhen not set, relative paths use raw hrefs (browser resolves them).", + "source_line": 42, + "type_signature": "{ get: (error_message?: string | undefined) => string; get_maybe: () => string | undefined; set: (value: string) => string; }" } ], "dependencies": ["context_helpers.ts"], - "dependents": ["MdzNodeView.svelte"] + "dependents": ["Mdz.svelte", "MdzNodeView.svelte"] }, { "path": "mdz_to_svelte.ts", @@ -4779,8 +4786,8 @@ "name": "mdz_to_svelte", "kind": "function", "doc_comment": "Converts an array of `MdzNode` to a Svelte markup string.\n\nEach node type produces output matching what `MdzNodeView.svelte` renders at runtime.\nCollects required imports and flags unconfigured component/element references.", - "source_line": 43, - "type_signature": "(nodes: MdzNode[], components: Record, elements: ReadonlySet): MdzToSvelteResult", + "source_line": 46, + "type_signature": "(nodes: MdzNode[], components: Record, elements: ReadonlySet, base?: string | undefined): MdzToSvelteResult", "return_type": "MdzToSvelteResult", "parameters": [ { @@ -4797,6 +4804,12 @@ "name": "elements", "type": "ReadonlySet", "description": "Allowed HTML element names (e.g., `new Set(['aside', 'details'])`).\nIf content references an element not in this set, `has_unconfigured_tags` is set." + }, + { + "name": "base", + "type": "string | undefined", + "optional": true, + "description": "Base path for resolving relative links (e.g., `'/docs/mdz/'`).\nWhen provided, relative references (`./`, `../`) are resolved to absolute paths\nand passed through `resolve()`. Trailing slash recommended." } ] } @@ -4824,12 +4837,17 @@ "name": "nowrap", "type": "boolean", "optional": true + }, + { + "name": "base", + "type": "string", + "optional": true } ], "source_line": 1 } ], - "dependencies": ["MdzNodeView.svelte", "mdz.ts"], + "dependencies": ["MdzNodeView.svelte", "mdz.ts", "mdz_components.ts"], "dependents": ["ApiModule.svelte", "DeclarationDetail.svelte"] }, { diff --git a/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path_with_base/expected.json b/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path_with_base/expected.json new file mode 100644 index 000000000..607dda6da --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path_with_base/expected.json @@ -0,0 +1,3 @@ +{ + "code": "\n\n

see ./grammar and ../mdz for details

\n" +} diff --git a/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path_with_base/input.svelte b/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path_with_base/input.svelte new file mode 100644 index 000000000..2591f2e63 --- /dev/null +++ b/src/test/fixtures/svelte_preprocess_mdz/autolink_relative_path_with_base/input.svelte @@ -0,0 +1,5 @@ + + + diff --git a/src/test/mdz_to_svelte.test.ts b/src/test/mdz_to_svelte.test.ts index a3a9b5784..7bece342c 100644 --- a/src/test/mdz_to_svelte.test.ts +++ b/src/test/mdz_to_svelte.test.ts @@ -382,6 +382,54 @@ describe('mdz_to_svelte', () => { }); }); + describe('relative path links', () => { + test('renders relative path without base as raw href', () => { + const result = convert('see ./docs/api'); + assert.ok(result.markup.includes("href={'./docs/api'}")); + assert.ok(!result.imports.has('resolve')); + }); + + test('renders parent-relative path without base as raw href', () => { + const result = convert('see ../shared/utils'); + assert.ok(result.markup.includes("href={'../shared/utils'}")); + assert.ok(!result.imports.has('resolve')); + }); + + test('resolves relative path with base and adds resolve import', () => { + const nodes = mdz_parse('see ./grammar'); + const result = mdz_to_svelte(nodes, {}, new Set(), '/docs/mdz/'); + assert.ok(result.markup.includes("href={resolve('/docs/mdz/grammar')}")); + assert_import(result, 'resolve', '$app/paths', 'named'); + }); + + test('resolves parent-relative path with base', () => { + const nodes = mdz_parse('see ../mdz'); + const result = mdz_to_svelte(nodes, {}, new Set(), '/docs/mdz/'); + assert.ok(result.markup.includes("href={resolve('/docs/mdz')}")); + assert_import(result, 'resolve', '$app/paths', 'named'); + }); + + test('base does not affect absolute paths', () => { + const nodes = mdz_parse('see /docs/api'); + const result = mdz_to_svelte(nodes, {}, new Set(), '/docs/mdz/'); + assert.ok(result.markup.includes("href={resolve('/docs/api')}")); + }); + + test('base does not affect fragment links', () => { + const nodes = mdz_parse('[section](#foo)'); + const result = mdz_to_svelte(nodes, {}, new Set(), '/docs/mdz/'); + assert.ok(result.markup.includes("href={'#foo'}")); + assert.ok(!result.imports.has('resolve')); + }); + + test('base does not affect external links', () => { + const nodes = mdz_parse('https://example.com'); + const result = mdz_to_svelte(nodes, {}, new Set(), '/docs/mdz/'); + assert.ok(result.markup.includes("href={'https://example.com'}")); + assert.ok(result.markup.includes('target="_blank"')); + }); + }); + describe('edge cases', () => { test('handles empty node array', () => { const result = mdz_to_svelte([], {}, new Set()); diff --git a/src/test/svelte_preprocess_mdz.skip.test.ts b/src/test/svelte_preprocess_mdz.skip.test.ts index dd8f6fabe..c6cbd13c0 100644 --- a/src/test/svelte_preprocess_mdz.skip.test.ts +++ b/src/test/svelte_preprocess_mdz.skip.test.ts @@ -239,6 +239,33 @@ describe('missing or wrong imports', () => { }); }); +describe('dynamic base prop', () => { + test('skips precompilation when base is dynamic', async () => { + const input = ` + +`; + + const result = await run_preprocess(input); + assert.equal(result, input, 'should be unchanged for dynamic base'); + }); + + test('transforms when base is static string', async () => { + const input = ` + +`; + + const result = await run_preprocess(input); + assert.ok(result.includes("resolve('/docs/mdz/grammar')"), 'should resolve relative path'); + assert.ok(result.includes(''), 'should use MdzPrecompiled'); + assert.ok(!result.includes('base='), 'should exclude base from output'); + }); +}); + describe('excluded files', () => { test('skips excluded files with regex', async () => { const input = ` diff --git a/src/lib/MdzNodeView.svelte b/src/lib/MdzNodeView.svelte index 49da434c5..a1fa83a14 100644 --- a/src/lib/MdzNodeView.svelte +++ b/src/lib/MdzNodeView.svelte @@ -2,7 +2,7 @@ import Code from '@fuzdev/fuz_code/Code.svelte'; import {resolve} from '$app/paths'; - import type {MdzNode} from './mdz.js'; + import {type MdzNode, resolve_relative_path} from './mdz.js'; import DocsLink from './DocsLink.svelte'; import MdzNodeView from './MdzNodeView.svelte'; import { @@ -19,7 +19,7 @@ const components = mdz_components_context.get_maybe(); const elements = mdz_elements_context.get_maybe(); - const mdz_base = mdz_base_context.get_maybe(); + const get_mdz_base = mdz_base_context.get_maybe(); // TODO make `Code` customizable via context, maybe registered as component Codeblock? @@ -59,8 +59,9 @@ {@const {reference} = node} {#if node.link_type === 'internal'} {@const skip_resolve = reference.startsWith('#') || reference.startsWith('?')} + {@const mdz_base = get_mdz_base?.()} {#if reference.startsWith('.') && mdz_base} - {@const resolved = new URL(reference, 'file://' + mdz_base).pathname} + {@const resolved = resolve_relative_path(reference, mdz_base)} {@render render_children(node.children)} {:else if skip_resolve || reference.startsWith('.')} diff --git a/src/lib/mdz.ts b/src/lib/mdz.ts index 3c2c32447..89b6f01d4 100644 --- a/src/lib/mdz.ts +++ b/src/lib/mdz.ts @@ -1852,3 +1852,34 @@ export class MdzParser { */ const URL_PATTERN = /^https?:\/\/[^\s)\]}<>.,:/?#!]/; export const mdz_is_url = (s: string): boolean => URL_PATTERN.test(s); + +/** + * Resolves a relative path (`./` or `../`) against a base path. + * Normalizes the base to have a trailing slash if missing. + * Handles embedded `.` and `..` segments and clamps at root. + * + * @param reference A relative path starting with `./` or `../`. + * @param base An absolute base path (e.g., `'/docs/mdz/'`). + */ +export const resolve_relative_path = (reference: string, base: string): string => { + // Normalize base to have trailing slash, then split into segments. + // For short paths (typical: 3-8 segments), split/join is fast and correct. + const base_segments = (base.endsWith('/') ? base : base + '/').split('/'); + base_segments.pop(); // remove trailing empty from the final '/' + const ref_segments = reference.split('/'); + for (let i = 0; i < ref_segments.length; i++) { + const segment = ref_segments[i]!; + if (segment === '.') continue; + if (segment === '') { + // Preserve trailing slash (last empty segment), skip internal empty segments. + if (i === ref_segments.length - 1) base_segments.push(''); + continue; + } + if (segment === '..') { + if (base_segments.length > 1) base_segments.pop(); // clamp at root + } else { + base_segments.push(segment); + } + } + return base_segments.join('/'); +}; diff --git a/src/lib/mdz_components.ts b/src/lib/mdz_components.ts index 5c3feb40f..0e72d14b4 100644 --- a/src/lib/mdz_components.ts +++ b/src/lib/mdz_components.ts @@ -34,9 +34,10 @@ export const mdz_components_context = create_context(); export const mdz_elements_context = create_context(); /** - * Context for providing a base path for resolving relative links in mdz content. - * When set (e.g., `'/docs/mdz/'`), relative paths like `./grammar` resolve - * to absolute paths like `/docs/mdz/grammar` before rendering. + * Context for providing a base path getter for resolving relative links in mdz content. + * Set to a getter (e.g., `() => base`) so changes to the base prop are reflected + * without needing an effect. When the getter returns a path like `'/docs/mdz/'`, + * relative paths like `./grammar` resolve to `/docs/mdz/grammar` before rendering. * When not set, relative paths use raw hrefs (browser resolves them). */ -export const mdz_base_context = create_context(); +export const mdz_base_context = create_context<() => string | undefined>(); diff --git a/src/lib/mdz_to_svelte.ts b/src/lib/mdz_to_svelte.ts index d915091e0..1a92e680e 100644 --- a/src/lib/mdz_to_svelte.ts +++ b/src/lib/mdz_to_svelte.ts @@ -12,7 +12,7 @@ import {UnreachableError} from '@fuzdev/fuz_util/error.js'; import {escape_svelte_text} from '@fuzdev/fuz_util/svelte_preprocess_helpers.js'; import {escape_js_string} from '@fuzdev/fuz_util/string.js'; -import type {MdzNode} from './mdz.js'; +import {type MdzNode, resolve_relative_path} from './mdz.js'; /** * Result of converting `MdzNode` arrays to Svelte markup. @@ -85,7 +85,7 @@ export const mdz_to_svelte = ( const children_markup = render_nodes(node.children); if (node.link_type === 'internal') { if (node.reference.startsWith('.') && base) { - const resolved = new URL(node.reference, 'file://' + base).pathname; + const resolved = resolve_relative_path(node.reference, base); imports.set('resolve', {path: '$app/paths', kind: 'named'}); return `${children_markup}`; } diff --git a/src/routes/library.json b/src/routes/library.json index b487bb862..7888cdb34 100644 --- a/src/routes/library.json +++ b/src/routes/library.json @@ -4744,9 +4744,9 @@ { "name": "mdz_base_context", "kind": "variable", - "doc_comment": "Context for providing a base path for resolving relative links in mdz content.\nWhen set (e.g., `'/docs/mdz/'`), relative paths like `./grammar` resolve\nto absolute paths like `/docs/mdz/grammar` before rendering.\nWhen not set, relative paths use raw hrefs (browser resolves them).", - "source_line": 42, - "type_signature": "{ get: (error_message?: string | undefined) => string; get_maybe: () => string | undefined; set: (value: string) => string; }" + "doc_comment": "Context for providing a base path getter for resolving relative links in mdz content.\nSet to a getter (e.g., `() => base`) so changes to the base prop are reflected\nwithout needing an effect. When the getter returns a path like `'/docs/mdz/'`,\nrelative paths like `./grammar` resolve to `/docs/mdz/grammar` before rendering.\nWhen not set, relative paths use raw hrefs (browser resolves them).", + "source_line": 43, + "type_signature": "{ get: (error_message?: string | undefined) => () => string | undefined; get_maybe: () => (() => string | undefined) | undefined; set: (value: () => string | undefined) => () => string | undefined; }" } ], "dependencies": ["context_helpers.ts"], @@ -4815,6 +4815,7 @@ } ], "module_comment": "Converts parsed `MdzNode` arrays to Svelte markup strings.\n\nUsed by the `svelte_preprocess_mdz` preprocessor to expand static `` usages\ninto pre-rendered Svelte markup at build time. The output for each node type matches what\n`MdzNodeView.svelte` renders at runtime, so precompiled and runtime rendering are identical.", + "dependencies": ["mdz.ts"], "dependents": ["svelte_preprocess_mdz.ts"] }, { @@ -5188,10 +5189,36 @@ "type": "string" } ] + }, + { + "name": "resolve_relative_path", + "kind": "function", + "doc_comment": "Resolves a relative path (`./` or `../`) against a base path.\nNormalizes the base to have a trailing slash if missing.\nHandles embedded `.` and `..` segments and clamps at root.", + "source_line": 1864, + "type_signature": "(reference: string, base: string): string", + "return_type": "string", + "parameters": [ + { + "name": "reference", + "type": "string", + "description": "A relative path starting with `./` or `../`." + }, + { + "name": "base", + "type": "string", + "description": "An absolute base path (e.g., `'/docs/mdz/'`)." + } + ] } ], "module_comment": "mdz - minimal markdown dialect for Fuz documentation.\n\nParses an enhanced markdown dialect with:\n- inline formatting: `code`, **bold**, _italic_, ~strikethrough~\n- auto-detected links: external URLs (`https://...`) and internal paths (`/path`)\n- markdown links: `[text](url)` with custom display text\n- inline code in backticks (creates `Code` nodes; auto-linking to identifiers/modules\n is handled by the rendering layer via `MdzNodeView.svelte`)\n- paragraph breaks (double newline)\n- block elements: headings, horizontal rules, code blocks\n- HTML elements and Svelte components (opt-in via context)\n\nKey constraint: preserves ALL whitespace exactly as authored,\nand is rendered with white-space pre or pre-wrap.\n\n## Design philosophy\n\n- **False negatives over false positives**: When in doubt, treat as plain text.\n Block elements can interrupt paragraphs without blank lines; inline formatting is strict.\n- **One way to do things**: Single unambiguous syntax per feature. No alternatives.\n- **Explicit over implicit**: Clear delimiters and column-0 requirements avoid ambiguity.\n- **Simple over complete**: Prefer simple parsing rules over complex edge case handling.\n\n## Status\n\nThis is an early proof of concept with missing features and edge cases.", - "dependents": ["Mdz.svelte", "svelte_preprocess_mdz.ts", "tsdoc_mdz.ts"] + "dependents": [ + "Mdz.svelte", + "MdzNodeView.svelte", + "mdz_to_svelte.ts", + "svelte_preprocess_mdz.ts", + "tsdoc_mdz.ts" + ] }, { "path": "MdzNodeView.svelte", @@ -5208,7 +5235,7 @@ "source_line": 1 } ], - "dependencies": ["DocsLink.svelte", "MdzNodeView.svelte", "mdz_components.ts"], + "dependencies": ["DocsLink.svelte", "MdzNodeView.svelte", "mdz.ts", "mdz_components.ts"], "dependents": ["Mdz.svelte", "MdzNodeView.svelte"] }, { diff --git a/src/test/mdz.test.ts b/src/test/mdz.test.ts index 33446903a..0ac9b6ea1 100644 --- a/src/test/mdz.test.ts +++ b/src/test/mdz.test.ts @@ -1,6 +1,6 @@ import {test, assert, describe, beforeAll} from 'vitest'; -import {mdz_parse, mdz_is_url} from '$lib/mdz.js'; +import {mdz_parse, mdz_is_url, resolve_relative_path} from '$lib/mdz.js'; import { load_fixtures, validate_positions, @@ -90,3 +90,73 @@ describe('mdz_is_url', () => { assert.equal(mdz_is_url('ftp://example.com'), false); }); }); + +describe('resolve_relative_path', () => { + test('resolves ./ path', () => { + assert.equal(resolve_relative_path('./grammar', '/docs/mdz/'), '/docs/mdz/grammar'); + }); + + test('resolves ../ path', () => { + assert.equal(resolve_relative_path('../mdz', '/docs/mdz/'), '/docs/mdz'); + }); + + test('resolves nested ./ path', () => { + assert.equal(resolve_relative_path('./a/b/c', '/docs/mdz/'), '/docs/mdz/a/b/c'); + }); + + test('resolves multiple ../ segments', () => { + assert.equal(resolve_relative_path('../../foo', '/docs/mdz/'), '/foo'); + }); + + test('normalizes base without trailing slash', () => { + assert.equal(resolve_relative_path('./grammar', '/docs/mdz'), '/docs/mdz/grammar'); + }); + + test('normalizes base without trailing slash for ../', () => { + assert.equal(resolve_relative_path('../foo', '/docs/mdz'), '/docs/foo'); + }); + + test('clamps at root', () => { + assert.equal(resolve_relative_path('../../../foo', '/docs/'), '/foo'); + }); + + test('handles embedded .. segments', () => { + assert.equal(resolve_relative_path('./a/../b', '/docs/mdz/'), '/docs/mdz/b'); + }); + + test('handles embedded . segments', () => { + assert.equal(resolve_relative_path('./a/./b', '/docs/mdz/'), '/docs/mdz/a/b'); + }); + + test('resolves to base directory for ../dirname', () => { + assert.equal(resolve_relative_path('../mdz', '/docs/mdz/'), '/docs/mdz'); + }); + + test('handles root base', () => { + assert.equal(resolve_relative_path('./foo', '/'), '/foo'); + }); + + test('handles root base with ../ clamped', () => { + assert.equal(resolve_relative_path('../foo', '/'), '/foo'); + }); + + test('handles multiple embedded ..', () => { + assert.equal(resolve_relative_path('./a/b/../../c', '/docs/mdz/'), '/docs/mdz/c'); + }); + + test('preserves trailing slash in reference', () => { + assert.equal(resolve_relative_path('./foo/', '/docs/mdz/'), '/docs/mdz/foo/'); + }); + + test('handles deeply nested base', () => { + assert.equal(resolve_relative_path('../bar', '/a/b/c/d/e/'), '/a/b/c/d/bar'); + }); + + test('handles empty base', () => { + assert.equal(resolve_relative_path('./foo', ''), '/foo'); + }); + + test('skips internal double slashes', () => { + assert.equal(resolve_relative_path('.//foo', '/docs/mdz/'), '/docs/mdz/foo'); + }); +}); diff --git a/src/test/mdz_to_svelte.test.ts b/src/test/mdz_to_svelte.test.ts index 7bece342c..657fd0915 100644 --- a/src/test/mdz_to_svelte.test.ts +++ b/src/test/mdz_to_svelte.test.ts @@ -409,6 +409,12 @@ describe('mdz_to_svelte', () => { assert_import(result, 'resolve', '$app/paths', 'named'); }); + test('base without trailing slash resolves correctly', () => { + const nodes = mdz_parse('see ./grammar'); + const result = mdz_to_svelte(nodes, {}, new Set(), '/docs/mdz'); + assert.ok(result.markup.includes("href={resolve('/docs/mdz/grammar')}")); + }); + test('base does not affect absolute paths', () => { const nodes = mdz_parse('see /docs/api'); const result = mdz_to_svelte(nodes, {}, new Set(), '/docs/mdz/'); @@ -428,6 +434,32 @@ describe('mdz_to_svelte', () => { assert.ok(result.markup.includes("href={'https://example.com'}")); assert.ok(result.markup.includes('target="_blank"')); }); + + test('markdown-syntax relative link resolves with base', () => { + const nodes = mdz_parse('[grammar](./grammar)'); + const result = mdz_to_svelte(nodes, {}, new Set(), '/docs/mdz/'); + assert.ok(result.markup.includes("href={resolve('/docs/mdz/grammar')}")); + assert.ok(result.markup.includes('>grammar')); + }); + + test('markdown-syntax relative link without base uses raw href', () => { + const result = convert('[grammar](./grammar)'); + assert.ok(result.markup.includes("href={'./grammar'}")); + assert.ok(!result.imports.has('resolve')); + }); + + test('markdown-syntax parent-relative link resolves with base', () => { + const nodes = mdz_parse('[up](../foo)'); + const result = mdz_to_svelte(nodes, {}, new Set(), '/docs/mdz/'); + assert.ok(result.markup.includes("href={resolve('/docs/foo')}")); + }); + + test('multiple relative links in same content resolve independently', () => { + const nodes = mdz_parse('see ./grammar and ../foo'); + const result = mdz_to_svelte(nodes, {}, new Set(), '/docs/mdz/'); + assert.ok(result.markup.includes("href={resolve('/docs/mdz/grammar')}")); + assert.ok(result.markup.includes("href={resolve('/docs/foo')}")); + }); }); describe('edge cases', () => { diff --git a/src/test/svelte_preprocess_mdz.skip.test.ts b/src/test/svelte_preprocess_mdz.skip.test.ts index c6cbd13c0..7c237bddb 100644 --- a/src/test/svelte_preprocess_mdz.skip.test.ts +++ b/src/test/svelte_preprocess_mdz.skip.test.ts @@ -264,6 +264,20 @@ describe('dynamic base prop', () => { assert.ok(result.includes(''), 'should use MdzPrecompiled'); assert.ok(!result.includes('base='), 'should exclude base from output'); }); + + test('normalizes base without trailing slash', async () => { + const input = ` + +`; + + const result = await run_preprocess(input); + assert.ok( + result.includes("resolve('/docs/mdz/grammar')"), + 'should resolve with normalized trailing slash', + ); + }); }); describe('excluded files', () => { From cc3b5151ba47da366f1eabcdfd3292affc349a86 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Wed, 25 Feb 2026 17:16:39 -0500 Subject: [PATCH 05/11] wip --- src/lib/mdz.ts | 29 ++++++++++++----------------- src/routes/library.json | 2 +- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/lib/mdz.ts b/src/lib/mdz.ts index 89b6f01d4..1c0aff141 100644 --- a/src/lib/mdz.ts +++ b/src/lib/mdz.ts @@ -1855,31 +1855,26 @@ export const mdz_is_url = (s: string): boolean => URL_PATTERN.test(s); /** * Resolves a relative path (`./` or `../`) against a base path. - * Normalizes the base to have a trailing slash if missing. + * Base without trailing slash is treated as a directory (same as with). * Handles embedded `.` and `..` segments and clamps at root. * * @param reference A relative path starting with `./` or `../`. * @param base An absolute base path (e.g., `'/docs/mdz/'`). */ export const resolve_relative_path = (reference: string, base: string): string => { - // Normalize base to have trailing slash, then split into segments. - // For short paths (typical: 3-8 segments), split/join is fast and correct. - const base_segments = (base.endsWith('/') ? base : base + '/').split('/'); - base_segments.pop(); // remove trailing empty from the final '/' - const ref_segments = reference.split('/'); - for (let i = 0; i < ref_segments.length; i++) { - const segment = ref_segments[i]!; - if (segment === '.') continue; - if (segment === '') { - // Preserve trailing slash (last empty segment), skip internal empty segments. - if (i === ref_segments.length - 1) base_segments.push(''); - continue; - } + const segments = base.split('/'); + // Remove trailing empty from split (e.g., '/docs/mdz/' → ['', 'docs', 'mdz', '']) + // but keep the root segment ([''] from '' base or ['', ''] from '/'). + if (segments.length > 1 && segments.at(-1) === '') segments.pop(); + const trailing = reference.endsWith('/'); + for (const segment of reference.split('/')) { + if (segment === '.' || segment === '') continue; if (segment === '..') { - if (base_segments.length > 1) base_segments.pop(); // clamp at root + if (segments.length > 1) segments.pop(); // clamp at root } else { - base_segments.push(segment); + segments.push(segment); } } - return base_segments.join('/'); + if (trailing) segments.push(''); + return segments.join('/'); }; diff --git a/src/routes/library.json b/src/routes/library.json index 7888cdb34..3bfbc18e1 100644 --- a/src/routes/library.json +++ b/src/routes/library.json @@ -5193,7 +5193,7 @@ { "name": "resolve_relative_path", "kind": "function", - "doc_comment": "Resolves a relative path (`./` or `../`) against a base path.\nNormalizes the base to have a trailing slash if missing.\nHandles embedded `.` and `..` segments and clamps at root.", + "doc_comment": "Resolves a relative path (`./` or `../`) against a base path.\nBase without trailing slash is treated as a directory (same as with).\nHandles embedded `.` and `..` segments and clamps at root.", "source_line": 1864, "type_signature": "(reference: string, base: string): string", "return_type": "string", From 81ba721476f89b84672a13cd15d5e4055233b77a Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 26 Feb 2026 09:12:17 -0500 Subject: [PATCH 06/11] wip --- .changeset/yellow-coins-show.md | 5 ----- src/routes/docs/mdz/CLAUDE.md | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/yellow-coins-show.md diff --git a/.changeset/yellow-coins-show.md b/.changeset/yellow-coins-show.md deleted file mode 100644 index 4584bfb65..000000000 --- a/.changeset/yellow-coins-show.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@fuzdev/fuz_ui': minor ---- - -feat: add relative link support to mdz diff --git a/src/routes/docs/mdz/CLAUDE.md b/src/routes/docs/mdz/CLAUDE.md index 24a8d444b..24e2036fb 100644 --- a/src/routes/docs/mdz/CLAUDE.md +++ b/src/routes/docs/mdz/CLAUDE.md @@ -28,7 +28,7 @@ Trailing punctuation (`.,:;!?]`) is trimmed per GFM conventions. The `base` prop on `Mdz` (and `mdz_base_context` context) controls how relative paths (`./`, `../`) are resolved. When `base` is set (e.g., `'/docs/mdz/'`), relative paths are resolved to absolute paths using -`new URL(reference, 'file://' + base).pathname` and then passed through +`resolve_relative_path()` from `$lib/mdz.js` and then passed through SvelteKit's `resolve()`. Without `base`, relative paths use raw hrefs (browser resolves them against the current URL). From 8e95b9a1fb0f0409cc7e675bed60c73e776b920d Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 5 Mar 2026 11:27:09 -0500 Subject: [PATCH 07/11] wip --- src/lib/mdz_lexer.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/lib/mdz_lexer.ts b/src/lib/mdz_lexer.ts index e020d6e2f..28e7b24b0 100644 --- a/src/lib/mdz_lexer.ts +++ b/src/lib/mdz_lexer.ts @@ -26,6 +26,7 @@ import { LEFT_ANGLE, RIGHT_ANGLE, SLASH, + PERIOD, LEFT_BRACKET, LEFT_PAREN, RIGHT_PAREN, @@ -840,6 +841,10 @@ export class MdzLexer { this.#tokenize_auto_link_internal(); return; } + if (this.#is_at_relative_path()) { + this.#tokenize_auto_link_internal(); + return; + } while (this.#index < this.#text.length) { const char_code = this.#text.charCodeAt(this.#index); @@ -876,7 +881,8 @@ export class MdzLexer { // Check for URL or internal path mid-text (char code guard avoids startsWith on every char) if ( (char_code === 104 /* h */ && this.#is_at_url()) || - (char_code === SLASH && this.#is_at_internal_path()) + (char_code === SLASH && this.#is_at_internal_path()) || + (char_code === PERIOD && this.#is_at_relative_path()) ) { break; } @@ -992,6 +998,30 @@ export class MdzLexer { return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE; } + #is_at_relative_path(): boolean { + if (this.#text.charCodeAt(this.#index) !== PERIOD) return false; + if (this.#index > 0) { + const prev_char = this.#text.charCodeAt(this.#index - 1); + if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) return false; + } + const remaining = this.#text.length - this.#index; + // Check for ../ (at least 4 chars: ../x) + if ( + remaining >= 4 && + this.#text.charCodeAt(this.#index + 1) === PERIOD && + this.#text.charCodeAt(this.#index + 2) === SLASH + ) { + const after = this.#text.charCodeAt(this.#index + 3); + return after !== SPACE && after !== NEWLINE && after !== SLASH; + } + // Check for ./ (at least 3 chars: ./x) + if (remaining >= 3 && this.#text.charCodeAt(this.#index + 1) === SLASH) { + const after = this.#text.charCodeAt(this.#index + 2); + return after !== SPACE && after !== NEWLINE && after !== SLASH; + } + return false; + } + #is_at_word_boundary(index: number, check_before: boolean, check_after: boolean): boolean { if (check_before && index > 0) { const prev = this.#text.charCodeAt(index - 1); From 7413c1f7839bb4dd7e1b87b43dd6abe57d720323 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 5 Mar 2026 11:29:08 -0500 Subject: [PATCH 08/11] wip --- src/routes/library.json | 46 ++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/routes/library.json b/src/routes/library.json index 74f0ab5b1..78b630b83 100644 --- a/src/routes/library.json +++ b/src/routes/library.json @@ -5090,7 +5090,7 @@ { "name": "MdzTokenBase", "kind": "type", - "source_line": 46, + "source_line": 47, "type_signature": "MdzTokenBase", "properties": [ { @@ -5108,13 +5108,13 @@ { "name": "MdzToken", "kind": "type", - "source_line": 51, + "source_line": 52, "type_signature": "MdzToken" }, { "name": "MdzTokenText", "kind": "type", - "source_line": 73, + "source_line": 74, "type_signature": "MdzTokenText", "extends": ["MdzTokenBase"], "properties": [ @@ -5133,7 +5133,7 @@ { "name": "MdzTokenCode", "kind": "type", - "source_line": 78, + "source_line": 79, "type_signature": "MdzTokenCode", "extends": ["MdzTokenBase"], "properties": [ @@ -5152,7 +5152,7 @@ { "name": "MdzTokenCodeblock", "kind": "type", - "source_line": 83, + "source_line": 84, "type_signature": "MdzTokenCodeblock", "extends": ["MdzTokenBase"], "properties": [ @@ -5176,7 +5176,7 @@ { "name": "MdzTokenBoldOpen", "kind": "type", - "source_line": 89, + "source_line": 90, "type_signature": "MdzTokenBoldOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5190,7 +5190,7 @@ { "name": "MdzTokenBoldClose", "kind": "type", - "source_line": 93, + "source_line": 94, "type_signature": "MdzTokenBoldClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5204,7 +5204,7 @@ { "name": "MdzTokenItalicOpen", "kind": "type", - "source_line": 97, + "source_line": 98, "type_signature": "MdzTokenItalicOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5218,7 +5218,7 @@ { "name": "MdzTokenItalicClose", "kind": "type", - "source_line": 101, + "source_line": 102, "type_signature": "MdzTokenItalicClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5232,7 +5232,7 @@ { "name": "MdzTokenStrikethroughOpen", "kind": "type", - "source_line": 105, + "source_line": 106, "type_signature": "MdzTokenStrikethroughOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5246,7 +5246,7 @@ { "name": "MdzTokenStrikethroughClose", "kind": "type", - "source_line": 109, + "source_line": 110, "type_signature": "MdzTokenStrikethroughClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5260,7 +5260,7 @@ { "name": "MdzTokenLinkTextOpen", "kind": "type", - "source_line": 113, + "source_line": 114, "type_signature": "MdzTokenLinkTextOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5274,7 +5274,7 @@ { "name": "MdzTokenLinkTextClose", "kind": "type", - "source_line": 117, + "source_line": 118, "type_signature": "MdzTokenLinkTextClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5288,7 +5288,7 @@ { "name": "MdzTokenLinkRef", "kind": "type", - "source_line": 121, + "source_line": 122, "type_signature": "MdzTokenLinkRef", "extends": ["MdzTokenBase"], "properties": [ @@ -5312,7 +5312,7 @@ { "name": "MdzTokenAutolink", "kind": "type", - "source_line": 127, + "source_line": 128, "type_signature": "MdzTokenAutolink", "extends": ["MdzTokenBase"], "properties": [ @@ -5336,7 +5336,7 @@ { "name": "MdzTokenHeadingStart", "kind": "type", - "source_line": 133, + "source_line": 134, "type_signature": "MdzTokenHeadingStart", "extends": ["MdzTokenBase"], "properties": [ @@ -5355,7 +5355,7 @@ { "name": "MdzTokenHr", "kind": "type", - "source_line": 138, + "source_line": 139, "type_signature": "MdzTokenHr", "extends": ["MdzTokenBase"], "properties": [ @@ -5369,7 +5369,7 @@ { "name": "MdzTokenTagOpen", "kind": "type", - "source_line": 142, + "source_line": 143, "type_signature": "MdzTokenTagOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5393,7 +5393,7 @@ { "name": "MdzTokenTagSelfClose", "kind": "type", - "source_line": 148, + "source_line": 149, "type_signature": "MdzTokenTagSelfClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5417,7 +5417,7 @@ { "name": "MdzTokenTagClose", "kind": "type", - "source_line": 154, + "source_line": 155, "type_signature": "MdzTokenTagClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5436,7 +5436,7 @@ { "name": "MdzTokenHeadingEnd", "kind": "type", - "source_line": 159, + "source_line": 160, "type_signature": "MdzTokenHeadingEnd", "extends": ["MdzTokenBase"], "properties": [ @@ -5450,7 +5450,7 @@ { "name": "MdzTokenParagraphBreak", "kind": "type", - "source_line": 163, + "source_line": 164, "type_signature": "MdzTokenParagraphBreak", "extends": ["MdzTokenBase"], "properties": [ @@ -5464,7 +5464,7 @@ { "name": "MdzLexer", "kind": "class", - "source_line": 171, + "source_line": 172, "members": [ { "name": "constructor", From ce6740a44c25fbe088196f52743697860cce0a2e Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 5 Mar 2026 11:42:51 -0500 Subject: [PATCH 09/11] wip --- src/lib/Mdz.svelte | 2 +- src/lib/mdz.ts | 47 +++++++++--------------------------------- src/lib/mdz_helpers.ts | 29 ++++++++++++++++++++++++++ src/lib/mdz_lexer.ts | 29 +++----------------------- 4 files changed, 43 insertions(+), 64 deletions(-) diff --git a/src/lib/Mdz.svelte b/src/lib/Mdz.svelte index fda4ee8d4..a69b24a22 100644 --- a/src/lib/Mdz.svelte +++ b/src/lib/Mdz.svelte @@ -18,7 +18,7 @@ base?: string; } = $props(); - if (base !== undefined) mdz_base_context.set(() => base); + mdz_base_context.set(() => base); const nodes = $derived(mdz_parse(content)); diff --git a/src/lib/mdz.ts b/src/lib/mdz.ts index cdc591fbc..b5a924505 100644 --- a/src/lib/mdz.ts +++ b/src/lib/mdz.ts @@ -59,6 +59,7 @@ import { PERIOD, is_valid_path_char, trim_trailing_punctuation, + is_at_relative_path, extract_single_tag, } from './mdz_helpers.js'; @@ -1019,38 +1020,6 @@ export class MdzParser { return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE; } - /** - * Check if current position is the start of a relative path (`./` or `../`). - */ - #is_at_relative_path(): boolean { - if (this.#template.charCodeAt(this.#index) !== PERIOD) { - return false; - } - // Check previous character - must be whitespace or start of string - if (this.#index > 0) { - const prev_char = this.#template.charCodeAt(this.#index - 1); - if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) { - return false; - } - } - const remaining = this.#template.length - this.#index; - // Check for ../ (at least 4 chars: ../x) - if ( - remaining >= 4 && - this.#template.charCodeAt(this.#index + 1) === PERIOD && - this.#template.charCodeAt(this.#index + 2) === SLASH - ) { - const after = this.#template.charCodeAt(this.#index + 3); - return after !== SPACE && after !== NEWLINE && after !== SLASH; - } - // Check for ./ (at least 3 chars: ./x) - if (remaining >= 3 && this.#template.charCodeAt(this.#index + 1) === SLASH) { - const after = this.#template.charCodeAt(this.#index + 2); - return after !== SPACE && after !== NEWLINE && after !== SLASH; - } - return false; - } - /** * Parse auto-detected external URL (`https://` or `http://`). * Uses RFC 3986 whitelist validation for valid URI characters. @@ -1140,7 +1109,7 @@ export class MdzParser { if (this.#is_at_url()) { return this.#parse_auto_link_url(); } - if (this.#is_at_absolute_path() || this.#is_at_relative_path()) { + if (this.#is_at_absolute_path() || is_at_relative_path(this.#template, this.#index)) { return this.#parse_auto_link_path(); } @@ -1185,7 +1154,7 @@ export class MdzParser { if ( (char_code === 104 /* h */ && this.#is_at_url()) || (char_code === SLASH && this.#is_at_absolute_path()) || - (char_code === PERIOD && this.#is_at_relative_path()) + (char_code === PERIOD && is_at_relative_path(this.#template, this.#index)) ) { break; } @@ -1649,11 +1618,15 @@ export const mdz_is_url = (s: string): boolean => URL_PATTERN.test(s); /** * Resolves a relative path (`./` or `../`) against a base path. - * Base without trailing slash is treated as a directory (same as with). - * Handles embedded `.` and `..` segments and clamps at root. + * The base is treated as a directory regardless of trailing slash + * (`'/docs/mdz'` and `'/docs/mdz/'` behave identically). + * Handles embedded `.` and `..` segments within the reference + * (e.g., `'./a/../b'` → navigates up then down). + * Clamps at root — excess `..` segments stop at `/` rather than escaping. * * @param reference A relative path starting with `./` or `../`. - * @param base An absolute base path (e.g., `'/docs/mdz/'`). + * @param base An absolute base path (e.g., `'/docs/mdz/'`). Empty string is treated as root. + * @returns An absolute resolved path (e.g., `'/docs/mdz/grammar'`). */ export const resolve_relative_path = (reference: string, base: string): string => { const segments = base.split('/'); diff --git a/src/lib/mdz_helpers.ts b/src/lib/mdz_helpers.ts index e994af685..ddce6b974 100644 --- a/src/lib/mdz_helpers.ts +++ b/src/lib/mdz_helpers.ts @@ -184,6 +184,35 @@ export const trim_trailing_punctuation = (url: string): string => { * Returns the tag node if paragraph wrapping should be skipped (MDX convention), * or null if the content should be wrapped in a paragraph. */ +/** + * Check if position in text is the start of a relative path (`./` or `../`). + * Must be preceded by whitespace or be at the start of the string. + * Requires at least one path character after the prefix. + */ +export const is_at_relative_path = (text: string, index: number): boolean => { + if (text.charCodeAt(index) !== PERIOD) return false; + if (index > 0) { + const prev_char = text.charCodeAt(index - 1); + if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) return false; + } + const remaining = text.length - index; + // Check for ../ (at least 4 chars: ../x) + if ( + remaining >= 4 && + text.charCodeAt(index + 1) === PERIOD && + text.charCodeAt(index + 2) === SLASH + ) { + const after = text.charCodeAt(index + 3); + return after !== SPACE && after !== NEWLINE && after !== SLASH; + } + // Check for ./ (at least 3 chars: ./x) + if (remaining >= 3 && text.charCodeAt(index + 1) === SLASH) { + const after = text.charCodeAt(index + 2); + return after !== SPACE && after !== NEWLINE && after !== SLASH; + } + return false; +}; + export const extract_single_tag = ( nodes: Array, ): MdzComponentNode | MdzElementNode | null => { diff --git a/src/lib/mdz_lexer.ts b/src/lib/mdz_lexer.ts index 28e7b24b0..06c1eb0a1 100644 --- a/src/lib/mdz_lexer.ts +++ b/src/lib/mdz_lexer.ts @@ -38,6 +38,7 @@ import { MAX_HEADING_LEVEL, HTTPS_PREFIX_LENGTH, HTTP_PREFIX_LENGTH, + is_at_relative_path, } from './mdz_helpers.js'; // ============================================================================ @@ -841,7 +842,7 @@ export class MdzLexer { this.#tokenize_auto_link_internal(); return; } - if (this.#is_at_relative_path()) { + if (is_at_relative_path(this.#text, this.#index)) { this.#tokenize_auto_link_internal(); return; } @@ -882,7 +883,7 @@ export class MdzLexer { if ( (char_code === 104 /* h */ && this.#is_at_url()) || (char_code === SLASH && this.#is_at_internal_path()) || - (char_code === PERIOD && this.#is_at_relative_path()) + (char_code === PERIOD && is_at_relative_path(this.#text, this.#index)) ) { break; } @@ -998,30 +999,6 @@ export class MdzLexer { return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE; } - #is_at_relative_path(): boolean { - if (this.#text.charCodeAt(this.#index) !== PERIOD) return false; - if (this.#index > 0) { - const prev_char = this.#text.charCodeAt(this.#index - 1); - if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) return false; - } - const remaining = this.#text.length - this.#index; - // Check for ../ (at least 4 chars: ../x) - if ( - remaining >= 4 && - this.#text.charCodeAt(this.#index + 1) === PERIOD && - this.#text.charCodeAt(this.#index + 2) === SLASH - ) { - const after = this.#text.charCodeAt(this.#index + 3); - return after !== SPACE && after !== NEWLINE && after !== SLASH; - } - // Check for ./ (at least 3 chars: ./x) - if (remaining >= 3 && this.#text.charCodeAt(this.#index + 1) === SLASH) { - const after = this.#text.charCodeAt(this.#index + 2); - return after !== SPACE && after !== NEWLINE && after !== SLASH; - } - return false; - } - #is_at_word_boundary(index: number, check_before: boolean, check_after: boolean): boolean { if (check_before && index > 0) { const prev = this.#text.charCodeAt(index - 1); From 76ec5f0884dcb1d9eef9be04a610979c90c55a0b Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 5 Mar 2026 12:04:54 -0500 Subject: [PATCH 10/11] wip --- CLAUDE.md | 2 + src/lib/mdz.ts | 32 ++-------- src/lib/mdz_helpers.ts | 16 +++++ src/lib/mdz_lexer.ts | 16 +---- src/routes/library.json | 126 ++++++++++++++++++++++++++-------------- 5 files changed, 108 insertions(+), 84 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 407e6adca..e0a7c24ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,6 +225,7 @@ The preprocessor leaves `Mdz` untouched (falls back to runtime) when: - `content` prop is dynamic (variable, function call, `$state`, `$derived`) - Spread attributes present (`{...props}`) - Content references unconfigured components or elements +- `base` prop is dynamic (falls back to runtime for correct resolution) - A ternary branch has dynamic content or unconfigured tags ### What gets transformed @@ -260,6 +261,7 @@ All contexts use the standardized pattern via `context_helpers.ts`: - `tome_context` - current documentation page (Tome) - `docs_links_context` - documentation navigation (DocsLinks class) - `mdz_components_context` - custom mdz components +- `mdz_base_context` - base path for relative link resolution **Contextmenu:** diff --git a/src/lib/mdz.ts b/src/lib/mdz.ts index b5a924505..a3a54e641 100644 --- a/src/lib/mdz.ts +++ b/src/lib/mdz.ts @@ -59,6 +59,7 @@ import { PERIOD, is_valid_path_char, trim_trailing_punctuation, + is_at_absolute_path, is_at_relative_path, extract_single_tag, } from './mdz_helpers.js'; @@ -996,30 +997,6 @@ export class MdzParser { return false; } - /** - * Check if current position is the start of an absolute path (starts with /). - */ - #is_at_absolute_path(): boolean { - if (this.#template.charCodeAt(this.#index) !== SLASH) { - return false; - } - // Check previous character - must be whitespace or start of string - if (this.#index > 0) { - const prev_char = this.#template.charCodeAt(this.#index - 1); - if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) { - return false; - } - } - // Must have at least one more character after /, and it must NOT be: - // - another / (to avoid matching // which is used for comments or protocol-relative URLs) - // - whitespace (a bare / followed by space is not a useful link) - if (this.#index + 1 >= this.#template.length) { - return false; - } - const next_char = this.#template.charCodeAt(this.#index + 1); - return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE; - } - /** * Parse auto-detected external URL (`https://` or `http://`). * Uses RFC 3986 whitelist validation for valid URI characters. @@ -1109,7 +1086,10 @@ export class MdzParser { if (this.#is_at_url()) { return this.#parse_auto_link_url(); } - if (this.#is_at_absolute_path() || is_at_relative_path(this.#template, this.#index)) { + if ( + is_at_absolute_path(this.#template, this.#index) || + is_at_relative_path(this.#template, this.#index) + ) { return this.#parse_auto_link_path(); } @@ -1153,7 +1133,7 @@ export class MdzParser { // Check for URL or internal absolute/relative path mid-text (char code guard avoids startsWith on every char) if ( (char_code === 104 /* h */ && this.#is_at_url()) || - (char_code === SLASH && this.#is_at_absolute_path()) || + (char_code === SLASH && is_at_absolute_path(this.#template, this.#index)) || (char_code === PERIOD && is_at_relative_path(this.#template, this.#index)) ) { break; diff --git a/src/lib/mdz_helpers.ts b/src/lib/mdz_helpers.ts index ddce6b974..9f616a595 100644 --- a/src/lib/mdz_helpers.ts +++ b/src/lib/mdz_helpers.ts @@ -184,6 +184,22 @@ export const trim_trailing_punctuation = (url: string): string => { * Returns the tag node if paragraph wrapping should be skipped (MDX convention), * or null if the content should be wrapped in a paragraph. */ +/** + * Check if position in text is the start of an absolute path (starts with `/`). + * Must be preceded by whitespace or be at the start of the string. + * Rejects `//` (comments/protocol-relative) and `/ ` (bare slash). + */ +export const is_at_absolute_path = (text: string, index: number): boolean => { + if (text.charCodeAt(index) !== SLASH) return false; + if (index > 0) { + const prev_char = text.charCodeAt(index - 1); + if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) return false; + } + if (index + 1 >= text.length) return false; + const next_char = text.charCodeAt(index + 1); + return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE; +}; + /** * Check if position in text is the start of a relative path (`./` or `../`). * Must be preceded by whitespace or be at the start of the string. diff --git a/src/lib/mdz_lexer.ts b/src/lib/mdz_lexer.ts index 06c1eb0a1..3189f33d9 100644 --- a/src/lib/mdz_lexer.ts +++ b/src/lib/mdz_lexer.ts @@ -38,6 +38,7 @@ import { MAX_HEADING_LEVEL, HTTPS_PREFIX_LENGTH, HTTP_PREFIX_LENGTH, + is_at_absolute_path, is_at_relative_path, } from './mdz_helpers.js'; @@ -838,7 +839,7 @@ export class MdzLexer { this.#tokenize_auto_link_url(); return; } - if (this.#is_at_internal_path()) { + if (is_at_absolute_path(this.#text, this.#index)) { this.#tokenize_auto_link_internal(); return; } @@ -882,7 +883,7 @@ export class MdzLexer { // Check for URL or internal path mid-text (char code guard avoids startsWith on every char) if ( (char_code === 104 /* h */ && this.#is_at_url()) || - (char_code === SLASH && this.#is_at_internal_path()) || + (char_code === SLASH && is_at_absolute_path(this.#text, this.#index)) || (char_code === PERIOD && is_at_relative_path(this.#text, this.#index)) ) { break; @@ -988,17 +989,6 @@ export class MdzLexer { return false; } - #is_at_internal_path(): boolean { - if (this.#text.charCodeAt(this.#index) !== SLASH) return false; - if (this.#index > 0) { - const prev_char = this.#text.charCodeAt(this.#index - 1); - if (prev_char !== SPACE && prev_char !== NEWLINE && prev_char !== TAB) return false; - } - if (this.#index + 1 >= this.#text.length) return false; - const next_char = this.#text.charCodeAt(this.#index + 1); - return next_char !== SLASH && next_char !== SPACE && next_char !== NEWLINE; - } - #is_at_word_boundary(index: number, check_before: boolean, check_after: boolean): boolean { if (check_before && index > 0) { const prev = this.#text.charCodeAt(index - 1); diff --git a/src/routes/library.json b/src/routes/library.json index 78b630b83..d0d9265e2 100644 --- a/src/routes/library.json +++ b/src/routes/library.json @@ -5066,11 +5066,46 @@ } ] }, + { + "name": "is_at_absolute_path", + "kind": "function", + "doc_comment": "Check if position in text is the start of an absolute path (starts with `/`).\nMust be preceded by whitespace or be at the start of the string.\nRejects `//` (comments/protocol-relative) and `/ ` (bare slash).", + "source_line": 192, + "type_signature": "(text: string, index: number): boolean", + "return_type": "boolean", + "parameters": [ + { + "name": "text", + "type": "string" + }, + { + "name": "index", + "type": "number" + } + ] + }, + { + "name": "is_at_relative_path", + "kind": "function", + "doc_comment": "Check if position in text is the start of a relative path (`./` or `../`).\nMust be preceded by whitespace or be at the start of the string.\nRequires at least one path character after the prefix.", + "source_line": 208, + "type_signature": "(text: string, index: number): boolean", + "return_type": "boolean", + "parameters": [ + { + "name": "text", + "type": "string" + }, + { + "name": "index", + "type": "number" + } + ] + }, { "name": "extract_single_tag", "kind": "function", - "doc_comment": "Extract a single tag (component or element) if it's the only non-whitespace content.\nReturns the tag node if paragraph wrapping should be skipped (MDX convention),\nor null if the content should be wrapped in a paragraph.", - "source_line": 187, + "source_line": 232, "type_signature": "(nodes: MdzNode[]): MdzElementNode | MdzComponentNode | null", "return_type": "MdzElementNode | MdzComponentNode | null", "parameters": [ @@ -5090,7 +5125,7 @@ { "name": "MdzTokenBase", "kind": "type", - "source_line": 47, + "source_line": 49, "type_signature": "MdzTokenBase", "properties": [ { @@ -5108,13 +5143,13 @@ { "name": "MdzToken", "kind": "type", - "source_line": 52, + "source_line": 54, "type_signature": "MdzToken" }, { "name": "MdzTokenText", "kind": "type", - "source_line": 74, + "source_line": 76, "type_signature": "MdzTokenText", "extends": ["MdzTokenBase"], "properties": [ @@ -5133,7 +5168,7 @@ { "name": "MdzTokenCode", "kind": "type", - "source_line": 79, + "source_line": 81, "type_signature": "MdzTokenCode", "extends": ["MdzTokenBase"], "properties": [ @@ -5152,7 +5187,7 @@ { "name": "MdzTokenCodeblock", "kind": "type", - "source_line": 84, + "source_line": 86, "type_signature": "MdzTokenCodeblock", "extends": ["MdzTokenBase"], "properties": [ @@ -5176,7 +5211,7 @@ { "name": "MdzTokenBoldOpen", "kind": "type", - "source_line": 90, + "source_line": 92, "type_signature": "MdzTokenBoldOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5190,7 +5225,7 @@ { "name": "MdzTokenBoldClose", "kind": "type", - "source_line": 94, + "source_line": 96, "type_signature": "MdzTokenBoldClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5204,7 +5239,7 @@ { "name": "MdzTokenItalicOpen", "kind": "type", - "source_line": 98, + "source_line": 100, "type_signature": "MdzTokenItalicOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5218,7 +5253,7 @@ { "name": "MdzTokenItalicClose", "kind": "type", - "source_line": 102, + "source_line": 104, "type_signature": "MdzTokenItalicClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5232,7 +5267,7 @@ { "name": "MdzTokenStrikethroughOpen", "kind": "type", - "source_line": 106, + "source_line": 108, "type_signature": "MdzTokenStrikethroughOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5246,7 +5281,7 @@ { "name": "MdzTokenStrikethroughClose", "kind": "type", - "source_line": 110, + "source_line": 112, "type_signature": "MdzTokenStrikethroughClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5260,7 +5295,7 @@ { "name": "MdzTokenLinkTextOpen", "kind": "type", - "source_line": 114, + "source_line": 116, "type_signature": "MdzTokenLinkTextOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5274,7 +5309,7 @@ { "name": "MdzTokenLinkTextClose", "kind": "type", - "source_line": 118, + "source_line": 120, "type_signature": "MdzTokenLinkTextClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5288,7 +5323,7 @@ { "name": "MdzTokenLinkRef", "kind": "type", - "source_line": 122, + "source_line": 124, "type_signature": "MdzTokenLinkRef", "extends": ["MdzTokenBase"], "properties": [ @@ -5312,7 +5347,7 @@ { "name": "MdzTokenAutolink", "kind": "type", - "source_line": 128, + "source_line": 130, "type_signature": "MdzTokenAutolink", "extends": ["MdzTokenBase"], "properties": [ @@ -5336,7 +5371,7 @@ { "name": "MdzTokenHeadingStart", "kind": "type", - "source_line": 134, + "source_line": 136, "type_signature": "MdzTokenHeadingStart", "extends": ["MdzTokenBase"], "properties": [ @@ -5355,7 +5390,7 @@ { "name": "MdzTokenHr", "kind": "type", - "source_line": 139, + "source_line": 141, "type_signature": "MdzTokenHr", "extends": ["MdzTokenBase"], "properties": [ @@ -5369,7 +5404,7 @@ { "name": "MdzTokenTagOpen", "kind": "type", - "source_line": 143, + "source_line": 145, "type_signature": "MdzTokenTagOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5393,7 +5428,7 @@ { "name": "MdzTokenTagSelfClose", "kind": "type", - "source_line": 149, + "source_line": 151, "type_signature": "MdzTokenTagSelfClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5417,7 +5452,7 @@ { "name": "MdzTokenTagClose", "kind": "type", - "source_line": 155, + "source_line": 157, "type_signature": "MdzTokenTagClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5436,7 +5471,7 @@ { "name": "MdzTokenHeadingEnd", "kind": "type", - "source_line": 160, + "source_line": 162, "type_signature": "MdzTokenHeadingEnd", "extends": ["MdzTokenBase"], "properties": [ @@ -5450,7 +5485,7 @@ { "name": "MdzTokenParagraphBreak", "kind": "type", - "source_line": 164, + "source_line": 166, "type_signature": "MdzTokenParagraphBreak", "extends": ["MdzTokenBase"], "properties": [ @@ -5464,7 +5499,7 @@ { "name": "MdzLexer", "kind": "class", - "source_line": 172, + "source_line": 174, "members": [ { "name": "constructor", @@ -5618,7 +5653,7 @@ "name": "mdz_parse", "kind": "function", "doc_comment": "Parses text to an array of `MdzNode`.", - "source_line": 70, + "source_line": 72, "type_signature": "(text: string): MdzNode[]", "return_type": "MdzNode[]", "parameters": [ @@ -5631,13 +5666,13 @@ { "name": "MdzNode", "kind": "type", - "source_line": 72, + "source_line": 74, "type_signature": "MdzNode" }, { "name": "MdzBaseNode", "kind": "type", - "source_line": 86, + "source_line": 88, "type_signature": "MdzBaseNode", "properties": [ { @@ -5660,7 +5695,7 @@ { "name": "MdzTextNode", "kind": "type", - "source_line": 92, + "source_line": 94, "type_signature": "MdzTextNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5679,7 +5714,7 @@ { "name": "MdzCodeNode", "kind": "type", - "source_line": 97, + "source_line": 99, "type_signature": "MdzCodeNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5698,7 +5733,7 @@ { "name": "MdzCodeblockNode", "kind": "type", - "source_line": 102, + "source_line": 104, "type_signature": "MdzCodeblockNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5722,7 +5757,7 @@ { "name": "MdzBoldNode", "kind": "type", - "source_line": 108, + "source_line": 110, "type_signature": "MdzBoldNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5741,7 +5776,7 @@ { "name": "MdzItalicNode", "kind": "type", - "source_line": 113, + "source_line": 115, "type_signature": "MdzItalicNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5760,7 +5795,7 @@ { "name": "MdzStrikethroughNode", "kind": "type", - "source_line": 118, + "source_line": 120, "type_signature": "MdzStrikethroughNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5779,7 +5814,7 @@ { "name": "MdzLinkNode", "kind": "type", - "source_line": 123, + "source_line": 125, "type_signature": "MdzLinkNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5808,7 +5843,7 @@ { "name": "MdzParagraphNode", "kind": "type", - "source_line": 130, + "source_line": 132, "type_signature": "MdzParagraphNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5827,7 +5862,7 @@ { "name": "MdzHrNode", "kind": "type", - "source_line": 135, + "source_line": 137, "type_signature": "MdzHrNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5841,7 +5876,7 @@ { "name": "MdzHeadingNode", "kind": "type", - "source_line": 139, + "source_line": 141, "type_signature": "MdzHeadingNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5865,7 +5900,7 @@ { "name": "MdzElementNode", "kind": "type", - "source_line": 145, + "source_line": 147, "type_signature": "MdzElementNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5889,7 +5924,7 @@ { "name": "MdzComponentNode", "kind": "type", - "source_line": 151, + "source_line": 153, "type_signature": "MdzComponentNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5914,7 +5949,7 @@ "name": "MdzParser", "kind": "class", "doc_comment": "Parser for mdz format.\nSingle-pass lexer/parser with text accumulation for efficiency.\nUsed by `mdz_parse`, which should be preferred for simple usage.", - "source_line": 162, + "source_line": 164, "members": [ { "name": "constructor", @@ -5940,7 +5975,7 @@ { "name": "mdz_is_url", "kind": "function", - "source_line": 1648, + "source_line": 1597, "type_signature": "(s: string): boolean", "return_type": "boolean", "parameters": [ @@ -5953,10 +5988,11 @@ { "name": "resolve_relative_path", "kind": "function", - "doc_comment": "Resolves a relative path (`./` or `../`) against a base path.\nBase without trailing slash is treated as a directory (same as with).\nHandles embedded `.` and `..` segments and clamps at root.", - "source_line": 1658, + "doc_comment": "Resolves a relative path (`./` or `../`) against a base path.\nThe base is treated as a directory regardless of trailing slash\n(`'/docs/mdz'` and `'/docs/mdz/'` behave identically).\nHandles embedded `.` and `..` segments within the reference\n(e.g., `'./a/../b'` → navigates up then down).\nClamps at root — excess `..` segments stop at `/` rather than escaping.", + "source_line": 1611, "type_signature": "(reference: string, base: string): string", "return_type": "string", + "return_description": "An absolute resolved path (e.g., `'/docs/mdz/grammar'`).", "parameters": [ { "name": "reference", @@ -5966,7 +6002,7 @@ { "name": "base", "type": "string", - "description": "An absolute base path (e.g., `'/docs/mdz/'`)." + "description": "An absolute base path (e.g., `'/docs/mdz/'`). Empty string is treated as root." } ] } From c75669fd068a85061a63360094d11b310ae22976 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 5 Mar 2026 12:30:58 -0500 Subject: [PATCH 11/11] wip --- .../mdz/link_path_relative/expected.json | 30 +++++++++---------- .../fixtures/mdz/link_path_relative/input.mdz | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/test/fixtures/mdz/link_path_relative/expected.json b/src/test/fixtures/mdz/link_path_relative/expected.json index caeba86e0..1687dfe90 100644 --- a/src/test/fixtures/mdz/link_path_relative/expected.json +++ b/src/test/fixtures/mdz/link_path_relative/expected.json @@ -10,48 +10,48 @@ }, { "type": "Link", - "reference": "./a/b", + "reference": "./grammar", "children": [ { "type": "Text", - "content": "./a/b", + "content": "./grammar", "start": 4, - "end": 9 + "end": 13 } ], "link_type": "internal", "start": 4, - "end": 9 + "end": 13 }, { "type": "Text", "content": " and ", - "start": 9, - "end": 14 + "start": 13, + "end": 18 }, { "type": "Link", - "reference": "../a/b", + "reference": "../mdz", "children": [ { "type": "Text", - "content": "../a/b", - "start": 14, - "end": 20 + "content": "../mdz", + "start": 18, + "end": 24 } ], "link_type": "internal", - "start": 14, - "end": 20 + "start": 18, + "end": 24 }, { "type": "Text", "content": " for details.", - "start": 20, - "end": 33 + "start": 24, + "end": 37 } ], "start": 0, - "end": 33 + "end": 37 } ] diff --git a/src/test/fixtures/mdz/link_path_relative/input.mdz b/src/test/fixtures/mdz/link_path_relative/input.mdz index 8752354e2..e1e7968bb 100644 --- a/src/test/fixtures/mdz/link_path_relative/input.mdz +++ b/src/test/fixtures/mdz/link_path_relative/input.mdz @@ -1 +1 @@ -See ./a/b and ../a/b for details. \ No newline at end of file +See ./grammar and ../mdz for details. \ No newline at end of file