Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plain-dolls-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fuzdev/fuz_ui': minor
---

feat: add relative path auto-linking and base prop for path resolution
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:**

Expand Down
5 changes: 5 additions & 0 deletions src/lib/Mdz.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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));
</script>

Expand Down
27 changes: 19 additions & 8 deletions src/lib/MdzNodeView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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?
</script>

Expand Down Expand Up @@ -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('?')}
<!-- Fragment/query-only links skip resolve() to avoid unwanted `/` prefix -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={is_fragment_or_query_only ? reference : resolve(reference as any)}
>{@render render_children(node.children)}</a
>
{@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)}
<a href={resolve(resolved as any)}>{@render render_children(node.children)}</a>
{:else if skip_resolve || reference.startsWith('.')}
<!-- Fragment, query, and relative links without base skip resolve() -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={reference}>{@render render_children(node.children)}</a>
{:else}
<a href={resolve(reference as any)}>{@render render_children(node.children)}</a>
{/if}
{:else}
<!-- external link -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
Expand Down
78 changes: 45 additions & 33 deletions src/lib/mdz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -123,7 +126,7 @@ export interface MdzLinkNode extends MdzBaseNode {
type: 'Link';
reference: string; // URL or path
children: Array<MdzNode>; // 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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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('/');
};
9 changes: 9 additions & 0 deletions src/lib/mdz_components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,12 @@ export const mdz_components_context = create_context<MdzComponents>();
* By default, no HTML elements are allowed.
*/
export const mdz_elements_context = create_context<MdzElements>();

/**
* 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>();
45 changes: 45 additions & 0 deletions src/lib/mdz_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MdzNode>,
): MdzComponentNode | MdzElementNode | null => {
Expand Down
23 changes: 10 additions & 13 deletions src/lib/mdz_lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
LEFT_ANGLE,
RIGHT_ANGLE,
SLASH,
PERIOD,
LEFT_BRACKET,
LEFT_PAREN,
RIGHT_PAREN,
Expand All @@ -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';

// ============================================================================
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 15 additions & 2 deletions src/lib/mdz_to_svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<MdzNode>,
components: Record<string, string>,
elements: ReadonlySet<string>,
base?: string,
): MdzToSvelteResult => {
const imports: Map<string, {path: string; kind: 'default' | 'named'}> = new Map();
let has_unconfigured_tags = false;
Expand Down Expand Up @@ -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 `<a href={resolve('${escape_js_string(resolved)}')}>${children_markup}</a>`;
}
if (
node.reference.startsWith('#') ||
node.reference.startsWith('?') ||
node.reference.startsWith('.')
) {
return `<a href={'${escape_js_string(node.reference)}'}>${children_markup}</a>`;
}
imports.set('resolve', {path: '$app/paths', kind: 'named'});
Expand Down
Loading