diff --git a/.changeset/plain-dolls-yawn.md b/.changeset/plain-dolls-yawn.md new file mode 100644 index 000000000..daf8391cc --- /dev/null +++ b/.changeset/plain-dolls-yawn.md @@ -0,0 +1,5 @@ +--- +'@fuzdev/fuz_ui': minor +--- + +feat: add relative path auto-linking and base prop for path resolution 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.svelte b/src/lib/Mdz.svelte index 365f9061a..a69b24a22 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(); + mdz_base_context.set(() => base); + const nodes = $derived(mdz_parse(content)); diff --git a/src/lib/MdzNodeView.svelte b/src/lib/MdzNodeView.svelte index 8fc8b0445..a1fa83a14 100644 --- a/src/lib/MdzNodeView.svelte +++ b/src/lib/MdzNodeView.svelte @@ -2,10 +2,14 @@ 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 {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 get_mdz_base = mdz_base_context.get_maybe(); // TODO make `Code` customizable via context, maybe registered as component Codeblock? @@ -53,12 +58,18 @@ {:else if node.type === 'Link'} {@const {reference} = node} {#if node.link_type === 'internal'} - {@const is_fragment_or_query_only = reference.startsWith('#') || reference.startsWith('?')} - - - {@render render_children(node.children)} + {@const skip_resolve = reference.startsWith('#') || reference.startsWith('?')} + {@const mdz_base = get_mdz_base?.()} + {#if reference.startsWith('.') && mdz_base} + {@const resolved = resolve_relative_path(reference, mdz_base)} + {@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 f63030b24..a3a54e641 100644 --- a/src/lib/mdz.ts +++ b/src/lib/mdz.ts @@ -56,8 +56,11 @@ import { is_letter, is_tag_name_char, is_word_char, + PERIOD, is_valid_path_char, trim_trailing_punctuation, + is_at_absolute_path, + is_at_relative_path, extract_single_tag, } from './mdz_helpers.js'; @@ -123,7 +126,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 { @@ -994,31 +997,6 @@ export class MdzParser { return false; } - /** - * Check if current position is the start of an internal path (starts with /). - */ - #is_at_internal_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) { - 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. @@ -1062,10 +1040,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 @@ -1104,12 +1082,15 @@ 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 absolute/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 ( + is_at_absolute_path(this.#template, this.#index) || + is_at_relative_path(this.#template, this.#index) + ) { + return this.#parse_auto_link_path(); } while (this.#index < this.#template.length) { @@ -1149,10 +1130,11 @@ export class MdzParser { } } - // Check for URL or internal path mid-text (char code guard avoids startsWith on every char) + // 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_internal_path()) + (char_code === SLASH && is_at_absolute_path(this.#template, this.#index)) || + (char_code === PERIOD && is_at_relative_path(this.#template, this.#index)) ) { break; } @@ -1613,3 +1595,33 @@ 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. + * 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/'`). 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('/'); + // 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 (segments.length > 1) segments.pop(); // clamp at root + } else { + segments.push(segment); + } + } + if (trailing) segments.push(''); + return segments.join('/'); +}; diff --git a/src/lib/mdz_components.ts b/src/lib/mdz_components.ts index 511d4988e..0e72d14b4 100644 --- a/src/lib/mdz_components.ts +++ b/src/lib/mdz_components.ts @@ -32,3 +32,12 @@ 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 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<() => string | undefined>(); diff --git a/src/lib/mdz_helpers.ts b/src/lib/mdz_helpers.ts index e994af685..9f616a595 100644 --- a/src/lib/mdz_helpers.ts +++ b/src/lib/mdz_helpers.ts @@ -184,6 +184,51 @@ 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. + * 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 e020d6e2f..3189f33d9 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, @@ -37,6 +38,8 @@ import { MAX_HEADING_LEVEL, HTTPS_PREFIX_LENGTH, HTTP_PREFIX_LENGTH, + is_at_absolute_path, + is_at_relative_path, } from './mdz_helpers.js'; // ============================================================================ @@ -836,7 +839,11 @@ 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; + } + if (is_at_relative_path(this.#text, this.#index)) { this.#tokenize_auto_link_internal(); return; } @@ -876,7 +883,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 && is_at_absolute_path(this.#text, this.#index)) || + (char_code === PERIOD && is_at_relative_path(this.#text, this.#index)) ) { break; } @@ -981,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/lib/mdz_to_svelte.ts b/src/lib/mdz_to_svelte.ts index 7f206dd76..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. @@ -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,7 +84,16 @@ 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('.') && base) { + const resolved = resolve_relative_path(node.reference, base); + imports.set('resolve', {path: '$app/paths', kind: 'named'}); + return `${children_markup}`; + } + 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/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 f0fa7a4e1..5b851b0f5 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,25 @@ -

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 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 new file mode 100644 index 000000000..24e2036fb --- /dev/null +++ b/src/routes/docs/mdz/CLAUDE.md @@ -0,0 +1,41 @@ +# 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 + +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 +`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). + +## Preprocessor + +Static `` usages are compiled at build time by +`svelte_preprocess_mdz` into `MdzPrecompiled` with pre-rendered children, +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 f5bb655be..db0f6e2a9 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 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. --- @@ -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..1bc7dab07 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`) 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. --- @@ -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 8a357227e..d0d9265e2 100644 --- a/src/routes/library.json +++ b/src/routes/library.json @@ -4741,10 +4741,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 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"], - "dependents": ["MdzNodeView.svelte"] + "dependents": ["Mdz.svelte", "MdzNodeView.svelte"] }, { "path": "mdz_helpers.ts", @@ -5059,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": [ @@ -5083,7 +5125,7 @@ { "name": "MdzTokenBase", "kind": "type", - "source_line": 46, + "source_line": 49, "type_signature": "MdzTokenBase", "properties": [ { @@ -5101,13 +5143,13 @@ { "name": "MdzToken", "kind": "type", - "source_line": 51, + "source_line": 54, "type_signature": "MdzToken" }, { "name": "MdzTokenText", "kind": "type", - "source_line": 73, + "source_line": 76, "type_signature": "MdzTokenText", "extends": ["MdzTokenBase"], "properties": [ @@ -5126,7 +5168,7 @@ { "name": "MdzTokenCode", "kind": "type", - "source_line": 78, + "source_line": 81, "type_signature": "MdzTokenCode", "extends": ["MdzTokenBase"], "properties": [ @@ -5145,7 +5187,7 @@ { "name": "MdzTokenCodeblock", "kind": "type", - "source_line": 83, + "source_line": 86, "type_signature": "MdzTokenCodeblock", "extends": ["MdzTokenBase"], "properties": [ @@ -5169,7 +5211,7 @@ { "name": "MdzTokenBoldOpen", "kind": "type", - "source_line": 89, + "source_line": 92, "type_signature": "MdzTokenBoldOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5183,7 +5225,7 @@ { "name": "MdzTokenBoldClose", "kind": "type", - "source_line": 93, + "source_line": 96, "type_signature": "MdzTokenBoldClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5197,7 +5239,7 @@ { "name": "MdzTokenItalicOpen", "kind": "type", - "source_line": 97, + "source_line": 100, "type_signature": "MdzTokenItalicOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5211,7 +5253,7 @@ { "name": "MdzTokenItalicClose", "kind": "type", - "source_line": 101, + "source_line": 104, "type_signature": "MdzTokenItalicClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5225,7 +5267,7 @@ { "name": "MdzTokenStrikethroughOpen", "kind": "type", - "source_line": 105, + "source_line": 108, "type_signature": "MdzTokenStrikethroughOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5239,7 +5281,7 @@ { "name": "MdzTokenStrikethroughClose", "kind": "type", - "source_line": 109, + "source_line": 112, "type_signature": "MdzTokenStrikethroughClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5253,7 +5295,7 @@ { "name": "MdzTokenLinkTextOpen", "kind": "type", - "source_line": 113, + "source_line": 116, "type_signature": "MdzTokenLinkTextOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5267,7 +5309,7 @@ { "name": "MdzTokenLinkTextClose", "kind": "type", - "source_line": 117, + "source_line": 120, "type_signature": "MdzTokenLinkTextClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5281,7 +5323,7 @@ { "name": "MdzTokenLinkRef", "kind": "type", - "source_line": 121, + "source_line": 124, "type_signature": "MdzTokenLinkRef", "extends": ["MdzTokenBase"], "properties": [ @@ -5305,7 +5347,7 @@ { "name": "MdzTokenAutolink", "kind": "type", - "source_line": 127, + "source_line": 130, "type_signature": "MdzTokenAutolink", "extends": ["MdzTokenBase"], "properties": [ @@ -5329,7 +5371,7 @@ { "name": "MdzTokenHeadingStart", "kind": "type", - "source_line": 133, + "source_line": 136, "type_signature": "MdzTokenHeadingStart", "extends": ["MdzTokenBase"], "properties": [ @@ -5348,7 +5390,7 @@ { "name": "MdzTokenHr", "kind": "type", - "source_line": 138, + "source_line": 141, "type_signature": "MdzTokenHr", "extends": ["MdzTokenBase"], "properties": [ @@ -5362,7 +5404,7 @@ { "name": "MdzTokenTagOpen", "kind": "type", - "source_line": 142, + "source_line": 145, "type_signature": "MdzTokenTagOpen", "extends": ["MdzTokenBase"], "properties": [ @@ -5386,7 +5428,7 @@ { "name": "MdzTokenTagSelfClose", "kind": "type", - "source_line": 148, + "source_line": 151, "type_signature": "MdzTokenTagSelfClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5410,7 +5452,7 @@ { "name": "MdzTokenTagClose", "kind": "type", - "source_line": 154, + "source_line": 157, "type_signature": "MdzTokenTagClose", "extends": ["MdzTokenBase"], "properties": [ @@ -5429,7 +5471,7 @@ { "name": "MdzTokenHeadingEnd", "kind": "type", - "source_line": 159, + "source_line": 162, "type_signature": "MdzTokenHeadingEnd", "extends": ["MdzTokenBase"], "properties": [ @@ -5443,7 +5485,7 @@ { "name": "MdzTokenParagraphBreak", "kind": "type", - "source_line": 163, + "source_line": 166, "type_signature": "MdzTokenParagraphBreak", "extends": ["MdzTokenBase"], "properties": [ @@ -5457,7 +5499,7 @@ { "name": "MdzLexer", "kind": "class", - "source_line": 171, + "source_line": 174, "members": [ { "name": "constructor", @@ -5518,8 +5560,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": [ { @@ -5536,11 +5578,18 @@ "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." } ] } ], "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"] }, { @@ -5584,12 +5633,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"] }, { @@ -5599,7 +5653,7 @@ "name": "mdz_parse", "kind": "function", "doc_comment": "Parses text to an array of `MdzNode`.", - "source_line": 69, + "source_line": 72, "type_signature": "(text: string): MdzNode[]", "return_type": "MdzNode[]", "parameters": [ @@ -5612,13 +5666,13 @@ { "name": "MdzNode", "kind": "type", - "source_line": 71, + "source_line": 74, "type_signature": "MdzNode" }, { "name": "MdzBaseNode", "kind": "type", - "source_line": 85, + "source_line": 88, "type_signature": "MdzBaseNode", "properties": [ { @@ -5641,7 +5695,7 @@ { "name": "MdzTextNode", "kind": "type", - "source_line": 91, + "source_line": 94, "type_signature": "MdzTextNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5660,7 +5714,7 @@ { "name": "MdzCodeNode", "kind": "type", - "source_line": 96, + "source_line": 99, "type_signature": "MdzCodeNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5679,7 +5733,7 @@ { "name": "MdzCodeblockNode", "kind": "type", - "source_line": 101, + "source_line": 104, "type_signature": "MdzCodeblockNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5703,7 +5757,7 @@ { "name": "MdzBoldNode", "kind": "type", - "source_line": 107, + "source_line": 110, "type_signature": "MdzBoldNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5722,7 +5776,7 @@ { "name": "MdzItalicNode", "kind": "type", - "source_line": 112, + "source_line": 115, "type_signature": "MdzItalicNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5741,7 +5795,7 @@ { "name": "MdzStrikethroughNode", "kind": "type", - "source_line": 117, + "source_line": 120, "type_signature": "MdzStrikethroughNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5760,7 +5814,7 @@ { "name": "MdzLinkNode", "kind": "type", - "source_line": 122, + "source_line": 125, "type_signature": "MdzLinkNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5789,7 +5843,7 @@ { "name": "MdzParagraphNode", "kind": "type", - "source_line": 129, + "source_line": 132, "type_signature": "MdzParagraphNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5808,7 +5862,7 @@ { "name": "MdzHrNode", "kind": "type", - "source_line": 134, + "source_line": 137, "type_signature": "MdzHrNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5822,7 +5876,7 @@ { "name": "MdzHeadingNode", "kind": "type", - "source_line": 138, + "source_line": 141, "type_signature": "MdzHeadingNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5846,7 +5900,7 @@ { "name": "MdzElementNode", "kind": "type", - "source_line": 144, + "source_line": 147, "type_signature": "MdzElementNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5870,7 +5924,7 @@ { "name": "MdzComponentNode", "kind": "type", - "source_line": 150, + "source_line": 153, "type_signature": "MdzComponentNode", "extends": ["MdzBaseNode"], "properties": [ @@ -5895,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": 161, + "source_line": 164, "members": [ { "name": "constructor", @@ -5921,7 +5975,7 @@ { "name": "mdz_is_url", "kind": "function", - "source_line": 1615, + "source_line": 1597, "type_signature": "(s: string): boolean", "return_type": "boolean", "parameters": [ @@ -5930,11 +5984,39 @@ "type": "string" } ] + }, + { + "name": "resolve_relative_path", + "kind": "function", + "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", + "type": "string", + "description": "A relative path starting with `./` or `../`." + }, + { + "name": "base", + "type": "string", + "description": "An absolute base path (e.g., `'/docs/mdz/'`). Empty string is treated as root." + } + ] } ], "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.", "dependencies": ["mdz_helpers.ts"], - "dependents": ["Mdz.svelte", "mdz_lexer.ts", "svelte_preprocess_mdz.ts", "tsdoc_mdz.ts"] + "dependents": [ + "Mdz.svelte", + "MdzNodeView.svelte", + "mdz_lexer.ts", + "mdz_to_svelte.ts", + "svelte_preprocess_mdz.ts", + "tsdoc_mdz.ts" + ] }, { "path": "MdzNodeView.svelte", @@ -5951,7 +6033,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/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..1687dfe90 --- /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": "./grammar", + "children": [ + { + "type": "Text", + "content": "./grammar", + "start": 4, + "end": 13 + } + ], + "link_type": "internal", + "start": 4, + "end": 13 + }, + { + "type": "Text", + "content": " and ", + "start": 13, + "end": 18 + }, + { + "type": "Link", + "reference": "../mdz", + "children": [ + { + "type": "Text", + "content": "../mdz", + "start": 18, + "end": 24 + } + ], + "link_type": "internal", + "start": 18, + "end": 24 + }, + { + "type": "Text", + "content": " for details.", + "start": 24, + "end": 37 + } + ], + "start": 0, + "end": 37 + } +] 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..e1e7968bb --- /dev/null +++ b/src/test/fixtures/mdz/link_path_relative/input.mdz @@ -0,0 +1 @@ +See ./grammar and ../mdz 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 @@ + + + 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.test.ts b/src/test/mdz.test.ts index 7953ff9ce..0268e4368 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 {mdz_parse_lexer} from '$lib/mdz_token_parser.js'; import { load_fixtures, @@ -98,3 +98,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 a3a9b5784..657fd0915 100644 --- a/src/test/mdz_to_svelte.test.ts +++ b/src/test/mdz_to_svelte.test.ts @@ -382,6 +382,86 @@ 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 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/'); + 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"')); + }); + + 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', () => { 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..7c237bddb 100644 --- a/src/test/svelte_preprocess_mdz.skip.test.ts +++ b/src/test/svelte_preprocess_mdz.skip.test.ts @@ -239,6 +239,47 @@ 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'); + }); + + 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', () => { test('skips excluded files with regex', async () => { const input = `