link: refactor href rewrite

- uses prop instead of attempting to read env variable
- uses URL type instead of custom heuristics
This commit is contained in:
Elijah Duffy
2025-12-12 13:59:21 -08:00
parent ae3abad769
commit 3885ac09a1
4 changed files with 74 additions and 22 deletions

View File

@@ -1,30 +1,43 @@
<script lang="ts" module>
import { env } from '$env/dynamic/public';
/**
* Rewrites the href based on a given basepath.
* If the href is absolute, it is returned as is.
* If the href is relative, the basepath is prepended.
* @param href The original href.
* @returns The rewritten href.
*/
export const rewriteHref = (href: string, basepath?: string | null): string => {
// If no base path is set, return the href as is
if (!basepath) return href;
const { PUBLIC_BASEPATH } = env;
const trim = (str: string, char: string, trimStart?: boolean, trimEnd?: boolean) => {
let start = 0,
end = str.length;
if (trimStart || trimStart === undefined) {
while (start < end && str[start] === char) start++;
// Use URL API to determine if href is relative or absolute
try {
// this will only succeed if href is absolute
const independentUrl = new URL(href);
return independentUrl.toString();
} catch {
// now we can assume that href is relative or entirely invalid
// test with a generic baseURI to see if it's valid relative
try {
const relativeUrl = new URL(href, 'http://example.com');
// if we reach here, it's a valid relative URL
const prefix = trimEdges(basepath, '/');
return `/${prefix}/${trimEdges(relativeUrl.pathname, '/', true, false)}`;
} catch {
throw new Error(`Attempted to rewrite invalid href: ${href}`);
}
}
if (trimEnd || trimEnd === undefined) {
while (end > start && str[end - 1] === char) end--;
}
return str.substring(start, end);
};
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ClassValue, MouseEventHandler } from 'svelte/elements';
import { trimEdges } from './util';
interface Props {
href: string;
basepath?: string | null;
disabled?: boolean;
tab?: 'current' | 'new';
class?: ClassValue | null | undefined;
@@ -34,6 +47,7 @@
let {
href,
basepath,
disabled = false,
tab = 'current',
class: classValue,
@@ -41,10 +55,7 @@
onclick
}: Props = $props();
if (PUBLIC_BASEPATH && !href.startsWith('http://') && !href.startsWith('https://')) {
let prefix = trim(PUBLIC_BASEPATH, '/');
href = `/${prefix}/${trim(href, '/', true, false)}`;
}
const computedHref = $derived(rewriteHref(href, basepath));
</script>
<a
@@ -53,7 +64,7 @@
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
classValue
]}
{href}
href={computedHref}
target={tab === 'new' ? '_blank' : undefined}
rel={tab === 'new' ? 'noopener noreferrer' : undefined}
{onclick}

View File

@@ -18,7 +18,7 @@ export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte';
export { default as InjectUmami } from './InjectUmami.svelte';
export { default as InputGroup } from './InputGroup.svelte';
export { default as Label } from './Label.svelte';
export { default as Link } from './Link.svelte';
export { default as Link, rewriteHref } from './Link.svelte';
export { default as PhoneInput } from './PhoneInput.svelte';
export { default as PinInput } from './PinInput.svelte';
export { default as RadioGroup } from './RadioGroup.svelte';
@@ -47,7 +47,8 @@ export {
getValue,
targetMust,
capitalizeFirstLetter,
prefixZero
prefixZero,
trimEdges
} from './util';
export {
type ToolbarToggleState,

View File

@@ -120,3 +120,26 @@ export const prefixZero = (str: string): string => {
}
return str;
};
/**
* Trims the specified character from the start and/or end of the string.
* @param str The string to trim.
* @param char The character to trim.
* @param trimStart Whether to trim from the start of the string. Default: true.
* @param trimEnd Whether to trim from the end of the string. Default: true.
* @returns The trimmed string.
*/
export const trimEdges = (str: string, char: string, trimStart?: boolean, trimEnd?: boolean) => {
let start = 0,
end = str.length;
if (trimStart || trimStart === undefined) {
while (start < end && str[start] === char) start++;
}
if (trimEnd || trimEnd === undefined) {
while (end > start && str[end - 1] === char) end--;
}
return str.substring(start, end);
};

View File

@@ -476,6 +476,23 @@
{/snippet}
</div>
<!-- Link with href rewriting -->
<div class="component">
<p class="title">Link (with href rewriting)</p>
<p class="mb-3">
href rewriting allows you to prepend a basepath to relative links, making it easier to manage
URLs in your application. It is recommended to wrap this element with your own, e.g. AppLink,
that automatically provides the basepath from your app's configuration.
</p>
<div class="flex flex-col gap-3">
<Link href="/about" basepath="/sui-demo">Go to About Page (with basepath)</Link>
<Link href="https://svelte.dev" basepath="/sui-demo">External Svelte Site</Link>
<Link href="contact">Contact Us (relative link, no basepath)</Link>
</div>
</div>
<!-- Regular Dialog Demo -->
<Dialog
bind:open={dialogOpen}