Making my own Svelte preprocessor
written 4/6/2026, edited 7/29/2026

This came about because Paraglide V2 took away automatic link localization , so if your site uses the URL strategy for Paraglide, your links are unaffected by your language selection. In layman’s terms, say your default language is English, and all Spanish pages have a /es/ prefix in the URL: as soon as you click one link anywhere, you’re going straight back into the English site.

Now, you’re supposed to solve this with localizeHref() as seen here  — but come on, I’m not gonna import that function and manually type it literally everywhere I have a link. In the thread it was suggested I make a custom Vite plugin to import localizeHref() everywhere I need it, but a Svelte preprocessor seemed way more straightforward to me. There is however annoyingly little documentation on what a preprocessor even looks like, so I got started by copying and modifying Joy of Code’s emoji preprocessor .

Essentially, there are 3 methods you can define in your preprocessor:

  • markup looks through the html markup, and is of the type MarkupPreprocessor .
  • script only looks through the content of <script> tags and is of the type Preprocessor .
  • style only looks through <style> tags and has the same type as script, but it’s not relevant for this particular use case.

All three expects to return an object of type Processed .

The documentation probably doesn’t say a lot about how to actually use them in practice, so the short version is that we want to grab content (the part to modify) and filename (which conveniently includes the filepath in case you want to filter). We do whatever operation we want on content, then send it back as data.

The following is the exact code that runs on my website, which as you can see is pretty straightforward. Feel free to steal it for your own use:

localizeLinksPreprocess.js
function isRouteSvelteFile(filename?: string) { // conveniently, filename also includes the filepath if (!filename) return false; const normalized = filename.replace(/\/g, '/'); return //src/.*.svelte$/.test(normalized); } /** @returns {import('svelte/compiler').PreprocessorGroup} */ export function localizeLinksPreprocessor() { return { name: 'localize-links', markup: ({ content, filename }) => { if (!isRouteSvelteFile(filename)) return; // if it isnt a svelte file inside /src, do nothing return { code: content.replace( /href=("/[^"]*")/g, // find all occurrences of href="..." (_match, quotedHref) => 'href={localizeHref(' + quotedHref + ')}' // replace it with href={localizeHref('...')} ) } }, script: ({ content, filename }) => { if (!isRouteSvelteFile(filename)) return; // if it isnt a svelte file inside /src, do nothing if (content.includes('localizeHref')) return { code: content }; // if it already has localizeHref, don't import it again return { code: 'import { localizeHref } from "$lib/paraglide/runtime";\n' + content }; } }; }

As you can see in the code comments, it replaces all occurrences of href="..." with href={localizeHref('...')}. Now, I am by no means an expert on preprocessors, but I’m pretty sure the content you receive in the markup method is already rendered, vanilla HTML, so there won’t be anything in the form of href={...}. For good measure, this preprocessor comes last.

© hexakon 2026