feat: Adding support for i18n (closes #462) (#738)
* fix: alt error mix with height/width
More granular detection of alt and resize in image
* fix: format
* feat: init i18n
* feat: add translation
* style: prettier for test
* fix: build-up the locale to fusion with dateLocale
* style: run prettier
* remove cursed file
* refactor: remove i18n library and use locale way instead
* format with prettier
* forgot to remove test
* prevent merging error
* format
* format
* fix: allow string for locale
- Check during translation if valid / existing locale
- Allow to use "en" and "en-US" for example
- Add fallback directly in the function
- Add default key in the function
- Add docstring to cfg.ts
* forgot item translation
* remove unused locale variable
* forgot to remove fr-FR testing
* format
3 files added
14 files modified
| | |
| | | analytics: { |
| | | provider: "plausible", |
| | | }, |
| | | locale: "en-US", |
| | | baseUrl: "quartz.jzhao.xyz", |
| | | ignorePatterns: ["private", "templates", ".obsidian"], |
| | | defaultDateType: "created", |
| | |
| | | baseUrl?: string |
| | | theme: Theme |
| | | /** |
| | | * The locale to use for date formatting. Default to "en-US" |
| | | * Allow to translate the date in the language of your choice. |
| | | * Also used for UI translation (default: en-US) |
| | | * Need to be formated following the IETF language tag format (https://en.wikipedia.org/wiki/IETF_language_tag) |
| | | */ |
| | | locale?: string |
| | |
| | | import { QuartzComponentConstructor, QuartzComponentProps } from "./types" |
| | | import style from "./styles/backlinks.scss" |
| | | import { resolveRelative, simplifySlug } from "../util/path" |
| | | import { i18n } from "../i18n/i18next" |
| | | import { classNames } from "../util/lang" |
| | | |
| | | function Backlinks({ fileData, allFiles, displayClass }: QuartzComponentProps) { |
| | | function Backlinks({ fileData, allFiles, displayClass, cfg }: QuartzComponentProps) { |
| | | const slug = simplifySlug(fileData.slug!) |
| | | const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug)) |
| | | return ( |
| | | <div class={classNames(displayClass, "backlinks")}> |
| | | <h3>Backlinks</h3> |
| | | <h3>{i18n(cfg.locale, "backlinks.backlinks")}</h3> |
| | | <ul class="overflow"> |
| | | {backlinkFiles.length > 0 ? ( |
| | | backlinkFiles.map((f) => ( |
| | |
| | | </li> |
| | | )) |
| | | ) : ( |
| | | <li>No backlinks found</li> |
| | | <li>{i18n(cfg.locale, "backlinks.noBlacklinksFound")}</li> |
| | | )} |
| | | </ul> |
| | | </div> |
| | |
| | | import darkmodeScript from "./scripts/darkmode.inline" |
| | | import styles from "./styles/darkmode.scss" |
| | | import { QuartzComponentConstructor, QuartzComponentProps } from "./types" |
| | | import { i18n } from "../i18n/i18next" |
| | | import { classNames } from "../util/lang" |
| | | |
| | | function Darkmode({ displayClass }: QuartzComponentProps) { |
| | | function Darkmode({ displayClass, cfg }: QuartzComponentProps) { |
| | | return ( |
| | | <div class={classNames(displayClass, "darkmode")}> |
| | | <input class="toggle" id="darkmode-toggle" type="checkbox" tabIndex={-1} /> |
| | |
| | | style="enable-background:new 0 0 35 35" |
| | | xmlSpace="preserve" |
| | | > |
| | | <title>Light mode</title> |
| | | <title>{i18n(cfg.locale, "darkmode.lightMode")}</title> |
| | | <path d="M6,17.5C6,16.672,5.328,16,4.5,16h-3C0.672,16,0,16.672,0,17.5 S0.672,19,1.5,19h3C5.328,19,6,18.328,6,17.5z M7.5,26c-0.414,0-0.789,0.168-1.061,0.439l-2,2C4.168,28.711,4,29.086,4,29.5 C4,30.328,4.671,31,5.5,31c0.414,0,0.789-0.168,1.06-0.44l2-2C8.832,28.289,9,27.914,9,27.5C9,26.672,8.329,26,7.5,26z M17.5,6 C18.329,6,19,5.328,19,4.5v-3C19,0.672,18.329,0,17.5,0S16,0.672,16,1.5v3C16,5.328,16.671,6,17.5,6z M27.5,9 c0.414,0,0.789-0.168,1.06-0.439l2-2C30.832,6.289,31,5.914,31,5.5C31,4.672,30.329,4,29.5,4c-0.414,0-0.789,0.168-1.061,0.44 l-2,2C26.168,6.711,26,7.086,26,7.5C26,8.328,26.671,9,27.5,9z M6.439,8.561C6.711,8.832,7.086,9,7.5,9C8.328,9,9,8.328,9,7.5 c0-0.414-0.168-0.789-0.439-1.061l-2-2C6.289,4.168,5.914,4,5.5,4C4.672,4,4,4.672,4,5.5c0,0.414,0.168,0.789,0.439,1.06 L6.439,8.561z M33.5,16h-3c-0.828,0-1.5,0.672-1.5,1.5s0.672,1.5,1.5,1.5h3c0.828,0,1.5-0.672,1.5-1.5S34.328,16,33.5,16z M28.561,26.439C28.289,26.168,27.914,26,27.5,26c-0.828,0-1.5,0.672-1.5,1.5c0,0.414,0.168,0.789,0.439,1.06l2,2 C28.711,30.832,29.086,31,29.5,31c0.828,0,1.5-0.672,1.5-1.5c0-0.414-0.168-0.789-0.439-1.061L28.561,26.439z M17.5,29 c-0.829,0-1.5,0.672-1.5,1.5v3c0,0.828,0.671,1.5,1.5,1.5s1.5-0.672,1.5-1.5v-3C19,29.672,18.329,29,17.5,29z M17.5,7 C11.71,7,7,11.71,7,17.5S11.71,28,17.5,28S28,23.29,28,17.5S23.29,7,17.5,7z M17.5,25c-4.136,0-7.5-3.364-7.5-7.5 c0-4.136,3.364-7.5,7.5-7.5c4.136,0,7.5,3.364,7.5,7.5C25,21.636,21.636,25,17.5,25z"></path> |
| | | </svg> |
| | | </label> |
| | |
| | | style="enable-background:new 0 0 100 100" |
| | | xmlSpace="preserve" |
| | | > |
| | | <title>Dark mode</title> |
| | | <title>{i18n(cfg.locale, "darkmode.lightMode")}</title> |
| | | <path d="M96.76,66.458c-0.853-0.852-2.15-1.064-3.23-0.534c-6.063,2.991-12.858,4.571-19.655,4.571 C62.022,70.495,50.88,65.88,42.5,57.5C29.043,44.043,25.658,23.536,34.076,6.47c0.532-1.08,0.318-2.379-0.534-3.23 c-0.851-0.852-2.15-1.064-3.23-0.534c-4.918,2.427-9.375,5.619-13.246,9.491c-9.447,9.447-14.65,22.008-14.65,35.369 c0,13.36,5.203,25.921,14.65,35.368s22.008,14.65,35.368,14.65c13.361,0,25.921-5.203,35.369-14.65 c3.872-3.871,7.064-8.328,9.491-13.246C97.826,68.608,97.611,67.309,96.76,66.458z"></path> |
| | | </svg> |
| | | </label> |
| | |
| | | import { QuartzComponentConstructor, QuartzComponentProps } from "./types" |
| | | import style from "./styles/footer.scss" |
| | | import { version } from "../../package.json" |
| | | import { i18n } from "../i18n/i18next" |
| | | |
| | | interface Options { |
| | | links: Record<string, string> |
| | | } |
| | | |
| | | export default ((opts?: Options) => { |
| | | function Footer({ displayClass }: QuartzComponentProps) { |
| | | function Footer({ displayClass, cfg }: QuartzComponentProps) { |
| | | const year = new Date().getFullYear() |
| | | const links = opts?.links ?? [] |
| | | return ( |
| | | <footer class={`${displayClass ?? ""}`}> |
| | | <hr /> |
| | | <p> |
| | | Created with <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, © {year} |
| | | {i18n(cfg.locale, "footer.createdWith")}{" "} |
| | | <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, © {year} |
| | | </p> |
| | | <ul> |
| | | {Object.entries(links).map(([text, link]) => ( |
| | |
| | | // @ts-ignore |
| | | import script from "./scripts/graph.inline" |
| | | import style from "./styles/graph.scss" |
| | | import { i18n } from "../i18n/i18next" |
| | | import { classNames } from "../util/lang" |
| | | |
| | | export interface D3Config { |
| | |
| | | } |
| | | |
| | | export default ((opts?: GraphOptions) => { |
| | | function Graph({ displayClass }: QuartzComponentProps) { |
| | | function Graph({ displayClass, cfg }: QuartzComponentProps) { |
| | | const localGraph = { ...defaultOptions.localGraph, ...opts?.localGraph } |
| | | const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph } |
| | | return ( |
| | | <div class={classNames(displayClass, "graph")}> |
| | | <h3>Graph View</h3> |
| | | <h3>{i18n(cfg.locale, "graph.graphView")}</h3> |
| | | <div class="graph-outer"> |
| | | <div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div> |
| | | <svg |
| | |
| | | import { i18n } from "../i18n/i18next" |
| | | import { FullSlug, _stripSlashes, joinSegments, pathToRoot } from "../util/path" |
| | | import { JSResourceToScriptElement } from "../util/resources" |
| | | import { QuartzComponentConstructor, QuartzComponentProps } from "./types" |
| | | |
| | | export default (() => { |
| | | function Head({ cfg, fileData, externalResources }: QuartzComponentProps) { |
| | | const title = fileData.frontmatter?.title ?? "Untitled" |
| | | const description = fileData.description?.trim() ?? "No description provided" |
| | | const title = fileData.frontmatter?.title ?? i18n(cfg.locale, "head.untitled") |
| | | const description = |
| | | fileData.description?.trim() ?? i18n(cfg.locale, "head.noDescriptionProvided") |
| | | const { css, js } = externalResources |
| | | |
| | | const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`) |
| | |
| | | import style from "./styles/recentNotes.scss" |
| | | import { Date, getDate } from "./Date" |
| | | import { GlobalConfiguration } from "../cfg" |
| | | import { i18n } from "../i18n/i18next" |
| | | import { classNames } from "../util/lang" |
| | | |
| | | interface Options { |
| | |
| | | </ul> |
| | | {opts.linkToMore && remaining > 0 && ( |
| | | <p> |
| | | <a href={resolveRelative(fileData.slug!, opts.linkToMore)}>See {remaining} more →</a> |
| | | <a href={resolveRelative(fileData.slug!, opts.linkToMore)}> |
| | | {" "} |
| | | {i18n(cfg.locale, "recentNotes.seeRemainingMore", { |
| | | remaining: remaining.toString(), |
| | | })}{" "} |
| | | → |
| | | </a> |
| | | </p> |
| | | )} |
| | | </div> |
| | |
| | | // @ts-ignore |
| | | import script from "./scripts/search.inline" |
| | | import { classNames } from "../util/lang" |
| | | import { i18n } from "../i18n/i18next" |
| | | |
| | | export interface SearchOptions { |
| | | enablePreview: boolean |
| | |
| | | } |
| | | |
| | | export default ((userOpts?: Partial<SearchOptions>) => { |
| | | function Search({ displayClass }: QuartzComponentProps) { |
| | | function Search({ displayClass, cfg }: QuartzComponentProps) { |
| | | const opts = { ...defaultOptions, ...userOpts } |
| | | |
| | | return ( |
| | | <div class={classNames(displayClass, "search")}> |
| | | <div id="search-icon"> |
| | | <p>Search</p> |
| | | <p>{i18n(cfg.locale, "search")}</p> |
| | | <div></div> |
| | | <svg |
| | | tabIndex={0} |
| | |
| | | |
| | | // @ts-ignore |
| | | import script from "./scripts/toc.inline" |
| | | import { i18n } from "../i18n/i18next" |
| | | |
| | | interface Options { |
| | | layout: "modern" | "legacy" |
| | |
| | | layout: "modern", |
| | | } |
| | | |
| | | function TableOfContents({ fileData, displayClass }: QuartzComponentProps) { |
| | | function TableOfContents({ fileData, displayClass, cfg }: QuartzComponentProps) { |
| | | if (!fileData.toc) { |
| | | return null |
| | | } |
| | |
| | | return ( |
| | | <div class={classNames(displayClass, "toc")}> |
| | | <button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}> |
| | | <h3>Table of Contents</h3> |
| | | <h3>{i18n(cfg.locale, "tableOfContent")}</h3> |
| | | <svg |
| | | xmlns="http://www.w3.org/2000/svg" |
| | | width="24" |
| | |
| | | TableOfContents.css = modernStyle |
| | | TableOfContents.afterDOMLoaded = script |
| | | |
| | | function LegacyTableOfContents({ fileData }: QuartzComponentProps) { |
| | | function LegacyTableOfContents({ fileData, cfg }: QuartzComponentProps) { |
| | | if (!fileData.toc) { |
| | | return null |
| | | } |
| | | |
| | | return ( |
| | | <details id="toc" open={!fileData.collapseToc}> |
| | | <summary> |
| | | <h3>Table of Contents</h3> |
| | | <h3>{i18n(cfg.locale, "tableOfContent")}</h3> |
| | | </summary> |
| | | <ul> |
| | | {fileData.toc.map((tocEntry) => ( |
| | |
| | | import { QuartzComponentConstructor } from "../types" |
| | | import { i18n } from "../../i18n/i18next" |
| | | import { QuartzComponentConstructor, QuartzComponentProps } from "../types" |
| | | |
| | | function NotFound() { |
| | | function NotFound({ cfg }: QuartzComponentProps) { |
| | | return ( |
| | | <article class="popover-hint"> |
| | | <h1>404</h1> |
| | | <p>Either this page is private or doesn't exist.</p> |
| | | <p>{i18n(cfg.locale, "404")}</p> |
| | | </article> |
| | | ) |
| | | } |
| | |
| | | import { Root } from "hast" |
| | | import { pluralize } from "../../util/lang" |
| | | import { htmlToJsx } from "../../util/jsx" |
| | | import { i18n } from "../../i18n/i18next" |
| | | |
| | | interface FolderContentOptions { |
| | | /** |
| | |
| | | const options: FolderContentOptions = { ...defaultOptions, ...opts } |
| | | |
| | | function FolderContent(props: QuartzComponentProps) { |
| | | const { tree, fileData, allFiles } = props |
| | | const { tree, fileData, allFiles, cfg } = props |
| | | const folderSlug = _stripSlashes(simplifySlug(fileData.slug!)) |
| | | const allPagesInFolder = allFiles.filter((file) => { |
| | | const fileSlug = _stripSlashes(simplifySlug(file.slug!)) |
| | |
| | | </article> |
| | | <div class="page-listing"> |
| | | {options.showFolderCount && ( |
| | | <p>{pluralize(allPagesInFolder.length, "item")} under this folder.</p> |
| | | <p> |
| | | {pluralize(allPagesInFolder.length, i18n(cfg.locale, "common.item"))}{" "} |
| | | {i18n(cfg.locale, "folderContent.underThisFolder")}. |
| | | </p> |
| | | )} |
| | | <div> |
| | | <PageList {...listProps} /> |
| | |
| | | import { Root } from "hast" |
| | | import { pluralize } from "../../util/lang" |
| | | import { htmlToJsx } from "../../util/jsx" |
| | | import { i18n } from "../../i18n/i18next" |
| | | |
| | | const numPages = 10 |
| | | function TagContent(props: QuartzComponentProps) { |
| | | const { tree, fileData, allFiles } = props |
| | | const { tree, fileData, allFiles, cfg } = props |
| | | const slug = fileData.slug |
| | | |
| | | if (!(slug?.startsWith("tags/") || slug === "tags")) { |
| | |
| | | <article> |
| | | <p>{content}</p> |
| | | </article> |
| | | <p>Found {tags.length} total tags.</p> |
| | | <p> |
| | | {i18n(cfg.locale, "tagContent.found")} {tags.length}{" "} |
| | | {i18n(cfg.locale, "tagContent.totalTags")}. |
| | | </p> |
| | | <div> |
| | | {tags.map((tag) => { |
| | | const pages = tagItemMap.get(tag)! |
| | |
| | | {content && <p>{content}</p>} |
| | | <div class="page-listing"> |
| | | <p> |
| | | {pluralize(pages.length, "item")} with this tag.{" "} |
| | | {pages.length > numPages && `Showing first ${numPages}.`} |
| | | {pluralize(pages.length, i18n(cfg.locale, "common.item"))}{" "} |
| | | {i18n(cfg.locale, "tagContent.withThisTag")}.{" "} |
| | | {pages.length > numPages && |
| | | `${i18n(cfg.locale, "tagContent.showingFirst")} ${numPages}.`} |
| | | </p> |
| | | <PageList limit={numPages} {...listProps} /> |
| | | </div> |
| | |
| | | <div class={classes}> |
| | | <article>{content}</article> |
| | | <div class="page-listing"> |
| | | <p>{pluralize(pages.length, "item")} with this tag.</p> |
| | | <p> |
| | | {pluralize(pages.length, i18n(cfg.locale, "common.item"))}{" "} |
| | | {i18n(cfg.locale, "tagContent.withThisTag")}. |
| | | </p> |
| | | <div> |
| | | <PageList {...listProps} /> |
| | | </div> |
| | |
| | | itemTile.classList.add("result-card") |
| | | itemTile.id = slug |
| | | itemTile.href = resolveUrl(slug).toString() |
| | | itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}<p class="preview">${content}</p>` |
| | | itemTile.innerHTML = `<h3>${title}</h3>${htmlTags}${ |
| | | enablePreview && window.innerWidth > 600 ? "" : `<p>${content}</p>` |
| | | }` |
| | | itemTile.addEventListener("click", (event) => { |
| | | if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return |
| | | hideSearch() |
| | | }) |
| | | |
| | | const handler = (event: MouseEvent) => { |
| | | if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return |
| New file |
| | |
| | | import en from "./locales/en.json" |
| | | import fr from "./locales/fr.json" |
| | | |
| | | const TRANSLATION = { |
| | | "en-US": en, |
| | | "fr-FR": fr, |
| | | } as const |
| | | |
| | | type TranslationOptions = { |
| | | [key: string]: string |
| | | } |
| | | |
| | | export const i18n = (lang = "en-US", key: string, options?: TranslationOptions) => { |
| | | const locale = |
| | | Object.keys(TRANSLATION).find( |
| | | (key) => |
| | | key.toLowerCase() === lang.toLowerCase() || key.toLowerCase().includes(lang.toLowerCase()), |
| | | ) ?? "en-US" |
| | | const getTranslation = (key: string) => { |
| | | const keys = key.split(".") |
| | | let translationString: string | Record<string, unknown> = |
| | | TRANSLATION[locale as keyof typeof TRANSLATION] |
| | | keys.forEach((key) => { |
| | | // @ts-ignore |
| | | translationString = translationString[key] |
| | | }) |
| | | return translationString |
| | | } |
| | | if (options) { |
| | | let translationString = getTranslation(key).toString() |
| | | Object.keys(options).forEach((key) => { |
| | | translationString = translationString.replace(`{{${key}}}`, options[key]) |
| | | }) |
| | | return translationString |
| | | } |
| | | return getTranslation(key).toString() |
| | | } |
| New file |
| | |
| | | { |
| | | "404": "Either this page is private or doesn't exist.", |
| | | "backlinks": { |
| | | "backlinks": "Backlinks", |
| | | "noBlacklinksFound": "No backlinks found" |
| | | }, |
| | | "common": { |
| | | "item": "item" |
| | | }, |
| | | "darkmode": { |
| | | "lightMode": "Light mode" |
| | | }, |
| | | "folderContent": { |
| | | "underThisFolder": "under this folder" |
| | | }, |
| | | "footer": { |
| | | "createdWith": "Created with" |
| | | }, |
| | | "graph": { |
| | | "graphView": "Graph View" |
| | | }, |
| | | "head": { |
| | | "noDescriptionProvided": "No description provided", |
| | | "untitled": "Untitled" |
| | | }, |
| | | "recentNotes": { |
| | | "seeRemainingMore": "See {{remaining}} more" |
| | | }, |
| | | "search": "Search", |
| | | "tableOfContent": "Table of Contents", |
| | | "tagContent": { |
| | | "showingFirst": "Showing first", |
| | | "totalTags": "total tags", |
| | | "withThisTag": "with this tag", |
| | | "found": "Found" |
| | | } |
| | | } |
| New file |
| | |
| | | { |
| | | "404": "Soit cette page est privée, soit elle n'existe pas.", |
| | | "backlinks": { |
| | | "backlinks": "Rétroliens", |
| | | "noBlacklinksFound": "Aucun rétrolien trouvé" |
| | | }, |
| | | "common": { |
| | | "item": "fichier" |
| | | }, |
| | | "darkmode": { |
| | | "darkmode": "Thème sombre", |
| | | "lightMode": "Thème clair" |
| | | }, |
| | | "folderContent": { |
| | | "underThisFolder": "dans ce dossier" |
| | | }, |
| | | "footer": { |
| | | "createdWith": "Créé avec" |
| | | }, |
| | | "graph": { |
| | | "graphView": "Vue Graphique" |
| | | }, |
| | | "head": { |
| | | "noDescriptionProvided": "Aucune description n'a été fournie", |
| | | "untitled": "Sans titre" |
| | | }, |
| | | "recentNotes": { |
| | | "seeRemainingMore": "Voir {{remaining}} plus" |
| | | }, |
| | | "search": "Rechercher", |
| | | "tableOfContent": "Table des Matières", |
| | | "tagContent": { |
| | | "showingFirst": "Afficher en premier", |
| | | "totalTags": "tags totaux", |
| | | "withThisTag": "avec ce tag", |
| | | "found": "Trouvé" |
| | | } |
| | | } |