This document outlines the systematic implementation of English/Spanish multilingual support for the Quartz site.
| Aspect | Decision |
|---|---|
| Content structure | Spanish at root (/), English in /en/ |
| UI locale | Fixed Spanish UI regardless of content language |
| Languages | English + Spanish only (extensible design) |
| Navigation | Language-filtered (Explorer, RecentNotes, Tags) |
| Tag pages | Separate per language (/tags/* and /en/tags/*) |
| Language detection | Path-based: slug.startsWith("en/") = English, else Spanish |
content/
├── index.md # Spanish homepage
├── articulo.md # Spanish articles (existing)
├── 2024/ # Spanish content by year
│ └── mi-post.md
└── en/
├── index.md # English homepage
├── article.md # English translations
└── 2024/ # English content by year
└── my-post.md
# Spanish article: content/2024/mi-post.md
---
title: Mi Post
lang: es
translations:
en: en/2024/my-post
---
# English translation: content/en/2024/my-post.md
---
title: My Post
lang: en
translations:
es: 2024/mi-post
---- Create
quartz/util/multilang.ts
import { FullSlug } from "./path"
export type SupportedLanguage = "es" | "en"
export const DEFAULT_LANGUAGE: SupportedLanguage = "es"
export const LANGUAGE_LABELS: Record<SupportedLanguage, string> = {
es: "Español",
en: "English",
}
/**
* Detect language from slug based on path prefix
*/
export function getLanguage(slug: FullSlug): SupportedLanguage {
return slug.startsWith("en/") ? "en" : "es"
}
/**
* Check if slug belongs to English content
*/
export function isEnglish(slug: FullSlug): boolean {
return slug.startsWith("en/")
}
/**
* Check if two slugs are in the same language
*/
export function isSameLanguage(slug1: FullSlug, slug2: FullSlug): boolean {
return getLanguage(slug1) === getLanguage(slug2)
}
/**
* Get display label for a language code
*/
export function getLanguageLabel(lang: SupportedLanguage): string {
return LANGUAGE_LABELS[lang] ?? lang
}
/**
* Get the language prefix for URLs (empty for default language)
*/
export function getLanguagePrefix(lang: SupportedLanguage): string {
return lang === DEFAULT_LANGUAGE ? "" : `${lang}/`
}- Modify
quartz/plugins/vfile.ts- Add translations type
Location: Around line 138, in the DataMap interface extension:
// Add to the Partial<{}> block:
translations: Record<string, string> // { "en": "en/path/to/translation", "es": "path/to/original" }- Modify
quartz/plugins/transformers/frontmatter.ts
Location: After line 120 (after published handling), add:
// Handle translations field
const translations = data.translations
if (translations && typeof translations === "object") {
data.translations = translations as Record<string, string>
}- Create
quartz/components/LanguageSwitcher.tsx
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { resolveRelative, FullSlug } from "../util/path"
import { getLanguage, getLanguageLabel, SupportedLanguage, LANGUAGE_LABELS } from "../util/multilang"
import style from "./styles/languageSwitcher.scss"
interface Options {
/** Position in the page */
position?: "header" | "beforeBody"
}
const defaultOptions: Options = {
position: "beforeBody",
}
export default ((userOpts?: Partial<Options>) => {
const opts = { ...defaultOptions, ...userOpts }
const LanguageSwitcher: QuartzComponent = ({ fileData, cfg }: QuartzComponentProps) => {
const translations = fileData.frontmatter?.translations as Record<string, string> | undefined
const currentLang = getLanguage(fileData.slug!)
// If no translations defined, don't render anything
if (!translations || Object.keys(translations).length === 0) {
return null
}
// Build list of available languages (current + translations)
const availableLanguages: Array<{ lang: SupportedLanguage; slug: FullSlug; isCurrent: boolean }> = [
{ lang: currentLang, slug: fileData.slug!, isCurrent: true },
]
for (const [lang, slug] of Object.entries(translations)) {
availableLanguages.push({
lang: lang as SupportedLanguage,
slug: slug as FullSlug,
isCurrent: false,
})
}
// Sort: current language first, then alphabetically
availableLanguages.sort((a, b) => {
if (a.isCurrent) return -1
if (b.isCurrent) return 1
return a.lang.localeCompare(b.lang)
})
return (
<nav class="language-switcher" aria-label="Language selection">
<ul>
{availableLanguages.map(({ lang, slug, isCurrent }) => (
<li key={lang}>
{isCurrent ? (
<span class="current-lang" aria-current="page">
{getLanguageLabel(lang)}
</span>
) : (
<a href={resolveRelative(fileData.slug!, slug)} hreflang={lang}>
{getLanguageLabel(lang)}
</a>
)}
</li>
))}
</ul>
</nav>
)
}
LanguageSwitcher.css = style
return LanguageSwitcher
}) satisfies QuartzComponentConstructor- Create
quartz/components/styles/languageSwitcher.scss
.language-switcher {
margin: 0.5rem 0;
ul {
display: flex;
gap: 0.75rem;
list-style: none;
padding: 0;
margin: 0;
font-size: 0.9rem;
}
li {
display: flex;
align-items: center;
&:not(:last-child)::after {
content: "|";
margin-left: 0.75rem;
color: var(--gray);
}
}
.current-lang {
font-weight: 600;
color: var(--secondary);
}
a {
color: var(--gray);
text-decoration: none;
&:hover {
color: var(--secondary);
text-decoration: underline;
}
}
}- Modify
quartz/components/index.ts
Add export:
export { default as LanguageSwitcher } from "./LanguageSwitcher"- Modify
quartz.layout.ts
In defaultContentPageLayout.beforeBody, add after ArticleTitle:
Component.LanguageSwitcher(),- Modify
quartz/plugins/emitters/componentResources.ts
Location: In addGlobalPageResources() function, add to resources.additionalHead:
// Add hreflang tags for multilingual SEO
resources.additionalHead.push((fileData) => {
const translations = fileData.frontmatter?.translations as Record<string, string> | undefined
if (!translations || Object.keys(translations).length === 0) {
return null
}
const baseUrl = cfg.baseUrl
if (!baseUrl) return null
const currentLang = fileData.slug?.startsWith("en/") ? "en" : "es"
const currentUrl = `https://${baseUrl}/${fileData.slug}`
const hreflangTags = [
// Current page
<link rel="alternate" hreflang={currentLang} href={currentUrl} />,
// Translations
...Object.entries(translations).map(([lang, slug]) => (
<link rel="alternate" hreflang={lang} href={`https://${baseUrl}/${slug}`} />
)),
// x-default (use current page as default)
<link rel="alternate" hreflang="x-default" href={currentUrl} />,
]
return <>{hreflangTags}</>
})Note: Will need to import the Fragment or use array return. Check existing patterns in the file.
- Modify
quartz/components/RecentNotes.tsx
Add to Options interface (around line 14):
interface Options {
title?: string
limit: number
linkToMore: SimpleSlug | false
showTags: boolean
filter: (f: QuartzPluginData) => boolean
sort: (f1: QuartzPluginData, f2: QuartzPluginData) => number
languageAware?: boolean // NEW: Filter by current page's language
}Update defaultOptions (around line 23):
const defaultOptions = (cfg: GlobalConfiguration): Options => ({
limit: 3,
linkToMore: false,
showTags: true,
filter: () => true,
sort: byDateAndAlphabetical(cfg),
languageAware: false, // NEW
})Modify the component function (around line 40) to add language filtering:
const RecentNotes: QuartzComponent = ({
allFiles,
fileData,
displayClass,
cfg,
}: QuartzComponentProps) => {
const opts = { ...defaultOptions(cfg), ...userOpts }
// Apply language filtering if enabled
let filteredFiles = allFiles.filter(opts.filter)
if (opts.languageAware) {
const currentLang = fileData.slug?.startsWith("en/") ? "en" : "es"
filteredFiles = filteredFiles.filter((file) => {
const fileLang = file.slug?.startsWith("en/") ? "en" : "es"
return fileLang === currentLang
})
}
const pages = filteredFiles.sort(opts.sort)
// ... rest of component
}- Modify
quartz.layout.ts
Update RecentNotes usage:
Component.RecentNotes({
limit: 5,
showTags: true,
linkToMore: "recent/" as SimpleSlug,
filter: (file) => !file.slug!.startsWith("draft/"),
languageAware: true, // NEW
}),- Modify
quartz/components/Explorer.tsx
The Explorer uses client-side filtering via serialized functions. We need to pass the current page's language to the client.
Add data attribute to the explorer div (around line 67):
<div
class={classNames(displayClass, "explorer", "collapsed")}
data-behavior={opts.folderClickBehavior}
data-collapsed={opts.folderDefaultState}
data-savestate={opts.useSavedState}
data-language-aware="true" // NEW
data-data-fns={JSON.stringify({
order: opts.order,
sortFn: opts.sortFn.toString(),
filterFn: opts.filterFn.toString(),
mapFn: opts.mapFn.toString(),
})}
>- Modify
quartz/components/scripts/explorer.inline.ts
Location: In the tree-building logic, add language filtering.
Find where nodes are filtered/processed and add:
// Get current page language from body data attribute or URL
function getCurrentLanguage(): "es" | "en" {
const slug = document.body.dataset.slug ?? window.location.pathname.slice(1)
return slug.startsWith("en/") ? "en" : "es"
}
function isNodeSameLanguage(slug: string): boolean {
const currentLang = getCurrentLanguage()
const nodeLang = slug.startsWith("en/") ? "en" : "es"
return currentLang === nodeLang
}
// Apply this filter during tree construction
// In the recursive tree building, check isNodeSameLanguage before including nodeNote: This requires careful integration with the existing explorer logic. The exact location depends on how the file tree is processed. Look for where filterFn is applied and add the language check there.
- Modify
quartz/plugins/emitters/tagPage.tsx
This is the most complex change. The tag page emitter needs to:
- Generate separate tag pages for each language
- Filter content by language when building tag listings
- Use language-prefixed slugs for English tags (
en/tags/...)
Location: Modify computeTagInfo() function (around line 20):
function computeTagInfo(
allFiles: QuartzPluginData[],
content: ProcessedContent[],
locale: keyof typeof TRANSLATIONS,
language: "es" | "en", // NEW parameter
): [Set<string>, Record<string, ProcessedContent>] {
// Filter files by language first
const languageFiles = allFiles.filter((file) => {
const fileLang = file.slug?.startsWith("en/") ? "en" : "es"
return fileLang === language
})
const tags: Set<string> = new Set(
languageFiles.flatMap((data) => data.frontmatter?.tags ?? []).flatMap(getAllSegmentPrefixes),
)
// ... rest of function, using languageFiles instead of allFiles
}Location: Modify emit() function (around line 124):
async *emit(ctx, content, resources) {
const cfg = ctx.cfg.configuration
// Generate tag pages for Spanish (default)
const allFilesEs = content
.map((c) => c[1].data)
.filter((file) => !file.slug!.startsWith("draft/") && !file.slug!.startsWith("en/"))
const [tagsEs, tagDescriptionsEs] = computeTagInfo(allFilesEs, content, cfg.locale, "es")
for (const tag of tagsEs) {
yield processTagPage(ctx, tag, tagDescriptionsEs[tag], allFilesEs, opts, resources)
}
// Generate tag pages for English (prefixed with en/)
const allFilesEn = content
.map((c) => c[1].data)
.filter((file) => !file.slug!.startsWith("draft/") && file.slug!.startsWith("en/"))
const [tagsEn, tagDescriptionsEn] = computeTagInfo(allFilesEn, content, cfg.locale, "en")
for (const tag of tagsEn) {
// Prefix English tag pages with "en/"
yield processTagPage(ctx, `en/${tag}`, tagDescriptionsEn[tag], allFilesEn, opts, resources)
}
}Note: Also need to update partialEmit() similarly for watch mode.
- Verify
quartz/components/renderPage.tsxpasses slug to body
Check that document.body.dataset.slug is set. If not, add:
<body data-slug={slug}>- Create test English content
Create content/en/index.md:
---
title: Home
lang: en
translations:
es: index
---
Welcome to the English version of the site.Create content/en/test-article.md:
---
title: Test Article
lang: en
tags:
- test
translations:
es: articulo-prueba
---
This is a test article in English.Create corresponding Spanish article content/articulo-prueba.md:
---
title: Articulo de Prueba
lang: es
tags:
- test
translations:
en: en/test-article
---
Este es un articulo de prueba en Español.- Build succeeds:
npx quartz build - Spanish pages show only Spanish content in Explorer
- English pages show only English content in Explorer
- RecentNotes filters by language
- LanguageSwitcher appears on pages with translations
- LanguageSwitcher links work correctly
- Tag page
/tags/testshows only Spanish posts - Tag page
/en/tags/testshows only English posts - View source shows correct hreflang tags
- No console errors in browser
| File | Purpose |
|---|---|
quartz/util/multilang.ts |
Language detection utilities |
quartz/components/LanguageSwitcher.tsx |
Translation links component |
quartz/components/styles/languageSwitcher.scss |
Component styling |
content/en/index.md |
English homepage (test) |
| File | Changes |
|---|---|
quartz/plugins/vfile.ts |
Add translations type |
quartz/plugins/transformers/frontmatter.ts |
Parse translations field |
quartz/plugins/emitters/componentResources.ts |
Hreflang injection |
quartz/plugins/emitters/tagPage.tsx |
Language-prefixed tag pages |
quartz/components/index.ts |
Export LanguageSwitcher |
quartz/components/RecentNotes.tsx |
languageAware option |
quartz/components/scripts/explorer.inline.ts |
Language filtering |
quartz.layout.ts |
Add LanguageSwitcher, enable language-aware options |
-
Explorer filtering is client-side - The
filterFnis serialized and runs in browser. Language filtering must integrate with this mechanism carefully. -
Tag page slug generation - English tag pages need
en/prefix in their slugs. TheprocessTagPagefunction usesjoinSegments("tags", tag)which needs adjustment. -
Link resolution - The
resolveRelativefunction should work correctly with language-prefixed slugs, but verify cross-language links resolve properly. -
Incremental builds - The
partialEmit()functions in tag page emitter need similar language-aware logic for watch mode to work correctly. -
Search index - The search will still show all languages mixed. If language-filtered search is needed later, that's a separate enhancement to
contentIndex.tsx. -
RSS feeds - Currently generates a single RSS feed. Could be extended to generate per-language feeds later.
- Language-filtered search results
- Per-language RSS feeds
- Automatic language detection from browser
Accept-Language - Language toggle in mobile menu
- Folder pages language filtering
- "Also available in" callout component
Last updated: 2024-12-18