Jacky Zhao
2024-02-05 36e4cc41a9e74faddabfd22878ea13b6c504209c
chore(i18n): refactor and cleanup (#805)

* checkpoint

* finish

* docs
3 files deleted
5 files added
29 files modified
537 ■■■■■ changed files
docs/advanced/making plugins.md 2 ●●● patch | view | raw | blame | history
docs/configuration.md 1 ●●●● patch | view | raw | blame | history
docs/features/i18n.md 18 ●●●●● patch | view | raw | blame | history
docs/index.md 2 ●●● patch | view | raw | blame | history
quartz/cfg.ts 8 ●●●● patch | view | raw | blame | history
quartz/components/ArticleTitle.tsx 1 ●●●● patch | view | raw | blame | history
quartz/components/Backlinks.tsx 6 ●●●● patch | view | raw | blame | history
quartz/components/Darkmode.tsx 6 ●●●● patch | view | raw | blame | history
quartz/components/Date.tsx 5 ●●●●● patch | view | raw | blame | history
quartz/components/Explorer.tsx 6 ●●●● patch | view | raw | blame | history
quartz/components/ExplorerNode.tsx 2 ●●● patch | view | raw | blame | history
quartz/components/Footer.tsx 6 ●●●● patch | view | raw | blame | history
quartz/components/Graph.tsx 4 ●●●● patch | view | raw | blame | history
quartz/components/Head.tsx 6 ●●●● patch | view | raw | blame | history
quartz/components/PageTitle.tsx 3 ●●●● patch | view | raw | blame | history
quartz/components/RecentNotes.tsx 15 ●●●●● patch | view | raw | blame | history
quartz/components/Search.tsx 10 ●●●● patch | view | raw | blame | history
quartz/components/TableOfContents.tsx 6 ●●●● patch | view | raw | blame | history
quartz/components/pages/404.tsx 4 ●●●● patch | view | raw | blame | history
quartz/components/pages/FolderContent.tsx 8 ●●●● patch | view | raw | blame | history
quartz/components/pages/TagContent.tsx 23 ●●●●● patch | view | raw | blame | history
quartz/components/renderPage.tsx 20 ●●●● patch | view | raw | blame | history
quartz/i18n/i18next.ts 37 ●●●●● patch | view | raw | blame | history
quartz/i18n/index.ts 11 ●●●●● patch | view | raw | blame | history
quartz/i18n/locales/definition.ts 63 ●●●●● patch | view | raw | blame | history
quartz/i18n/locales/en-US.ts 65 ●●●●● patch | view | raw | blame | history
quartz/i18n/locales/en.json 37 ●●●●● patch | view | raw | blame | history
quartz/i18n/locales/fr-FR.ts 65 ●●●●● patch | view | raw | blame | history
quartz/i18n/locales/fr.json 38 ●●●●● patch | view | raw | blame | history
quartz/plugins/emitters/404.tsx 10 ●●●●● patch | view | raw | blame | history
quartz/plugins/emitters/contentIndex.ts 5 ●●●●● patch | view | raw | blame | history
quartz/plugins/emitters/contentPage.tsx 2 ●●● patch | view | raw | blame | history
quartz/plugins/emitters/folderPage.tsx 8 ●●●● patch | view | raw | blame | history
quartz/plugins/emitters/tagPage.tsx 8 ●●●● patch | view | raw | blame | history
quartz/plugins/transformers/frontmatter.ts 5 ●●●●● patch | view | raw | blame | history
quartz/plugins/transformers/toc.ts 13 ●●●●● patch | view | raw | blame | history
quartz/util/lang.ts 8 ●●●●● patch | view | raw | blame | history
docs/advanced/making plugins.md
@@ -278,7 +278,7 @@
          allFiles,
        }
        const content = renderPage(slug, componentData, opts, externalResources)
        const content = renderPage(cfg, slug, componentData, opts, externalResources)
        const fp = await emit({
          content,
          slug: file.data.slug!,
docs/configuration.md
@@ -27,6 +27,7 @@
  - `null`: don't use analytics;
  - `{ provider: 'plausible' }`: use [Plausible](https://plausible.io/), a privacy-friendly alternative to Google Analytics; or
  - `{ provider: 'google', tagId: <your-google-tag> }`: use Google Analytics
- `locale`: used for [[i18n]] and date formatting
- `baseUrl`: this is used for sitemaps and RSS feeds that require an absolute URL to know where the canonical 'home' of your site lives. This is normally the deployed URL of your site (e.g. `quartz.jzhao.xyz` for this site). Do not include the protocol (i.e. `https://`) or any leading or trailing slashes.
  - This should also include the subpath if you are [[hosting]] on GitHub pages without a custom domain. For example, if my repository is `jackyzha0/quartz`, GitHub pages would deploy to `https://jackyzha0.github.io/quartz` and the `baseUrl` would be `jackyzha0.github.io/quartz`
  - Note that Quartz 4 will avoid using this as much as possible and use relative URLs whenever it can to make sure your site works no matter _where_ you end up actually deploying it.
docs/features/i18n.md
New file
@@ -0,0 +1,18 @@
---
title: Internationalization
---
Internationalization allows users to translate text in the Quartz interface into various supported languages without needing to make extensive code changes. This can be changed via the `locale` [[configuration]] field in `quartz.config.ts`.
The locale field generally follows a certain format: `{language}-{REGION}`
- `{language}` is usually a [2-letter lowercase language code](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes).
- `{REGION}` is usually a [2-letter uppercase region code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)
> [!tip] Interested in contributing?
> We [gladly welcome translation PRs](https://github.com/jackyzha0/quartz/tree/v4/quartz/i18n/locales)! To contribute a translation, do the following things:
>
> 1. In the `quartz/i18n/locales` folder, copy the `en-US.ts` file.
> 2. Rename it to `{language}-{REGION}.ts` so it matches a locale of the format shown above.
> 3. Fill in the translations!
> 4. Add the entry under `TRANSLATIONS` in `quartz/i18n/index.ts`.
docs/index.md
@@ -31,7 +31,7 @@
## 🔧 Features
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], and [many more](./features) right out of the box
- [[Obsidian compatibility]], [[full-text search]], [[graph view]], note transclusion, [[wikilinks]], [[backlinks]], [[Latex]], [[syntax highlighting]], [[popover previews]], [[Docker Support]], [[i18n|internationalization]] and [many more](./features) right out of the box
- Hot-reload for both configuration and content
- Simple JSX layouts and [[creating components|page components]]
- [[SPA Routing|Ridiculously fast page loads]] and tiny bundle sizes
quartz/cfg.ts
@@ -1,5 +1,6 @@
import { ValidDateType } from "./components/Date"
import { QuartzComponent } from "./components/types"
import { ValidLocale } from "./i18n"
import { PluginTypes } from "./plugins/types"
import { Theme } from "./util/theme"
@@ -39,9 +40,12 @@
  /**
   * 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)
   * Need to be formated following BCP 47: https://en.wikipedia.org/wiki/IETF_language_tag
   * The first part is the language (en) and the second part is the script/region (US)
   * Language Codes: https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes
   * Region Codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
   */
  locale?: string
  locale: ValidLocale
}
export interface QuartzConfig {
quartz/components/ArticleTitle.tsx
@@ -9,6 +9,7 @@
    return null
  }
}
ArticleTitle.css = `
.article-title {
  margin: 2rem 0 0 0;
quartz/components/Backlinks.tsx
@@ -1,7 +1,7 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/backlinks.scss"
import { resolveRelative, simplifySlug } from "../util/path"
import { i18n } from "../i18n/i18next"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
function Backlinks({ fileData, allFiles, displayClass, cfg }: QuartzComponentProps) {
@@ -9,7 +9,7 @@
  const backlinkFiles = allFiles.filter((file) => file.links?.includes(slug))
  return (
    <div class={classNames(displayClass, "backlinks")}>
      <h3>{i18n(cfg.locale, "backlinks.backlinks")}</h3>
      <h3>{i18n(cfg.locale).components.backlinks.title}</h3>
      <ul class="overflow">
        {backlinkFiles.length > 0 ? (
          backlinkFiles.map((f) => (
@@ -20,7 +20,7 @@
            </li>
          ))
        ) : (
          <li>{i18n(cfg.locale, "backlinks.noBacklinksFound")}</li>
          <li>{i18n(cfg.locale).components.backlinks.noBacklinksFound}</li>
        )}
      </ul>
    </div>
quartz/components/Darkmode.tsx
@@ -4,7 +4,7 @@
import darkmodeScript from "./scripts/darkmode.inline"
import styles from "./styles/darkmode.scss"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { i18n } from "../i18n/i18next"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
function Darkmode({ displayClass, cfg }: QuartzComponentProps) {
@@ -23,7 +23,7 @@
          style="enable-background:new 0 0 35 35"
          xmlSpace="preserve"
        >
          <title>{i18n(cfg.locale, "darkmode.lightMode")}</title>
          <title>{i18n(cfg.locale).components.themeToggle.darkMode}</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>
@@ -39,7 +39,7 @@
          style="enable-background:new 0 0 100 100"
          xmlSpace="preserve"
        >
          <title>{i18n(cfg.locale, "darkmode.lightMode")}</title>
          <title>{i18n(cfg.locale).components.themeToggle.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>
quartz/components/Date.tsx
@@ -1,9 +1,10 @@
import { GlobalConfiguration } from "../cfg"
import { ValidLocale } from "../i18n"
import { QuartzPluginData } from "../plugins/vfile"
interface Props {
  date: Date
  locale?: string
  locale?: ValidLocale
}
export type ValidDateType = keyof Required<QuartzPluginData>["dates"]
@@ -17,7 +18,7 @@
  return data.dates?.[cfg.defaultDateType]
}
export function formatDate(d: Date, locale = "en-US"): string {
export function formatDate(d: Date, locale: ValidLocale = "en-US"): string {
  return d.toLocaleDateString(locale, {
    year: "numeric",
    month: "short",
quartz/components/Explorer.tsx
@@ -6,10 +6,10 @@
import { ExplorerNode, FileNode, Options } from "./ExplorerNode"
import { QuartzPluginData } from "../plugins/vfile"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
// Options interface defined in `ExplorerNode` to avoid circular dependency
const defaultOptions = {
  title: "Explorer",
  folderClickBehavior: "collapse",
  folderDefaultState: "collapsed",
  useSavedState: true,
@@ -75,7 +75,7 @@
    jsonTree = JSON.stringify(folders)
  }
  function Explorer({ allFiles, displayClass, fileData }: QuartzComponentProps) {
  function Explorer({ cfg, allFiles, displayClass, fileData }: QuartzComponentProps) {
    constructFileTree(allFiles)
    return (
      <div class={classNames(displayClass, "explorer")}>
@@ -87,7 +87,7 @@
          data-savestate={opts.useSavedState}
          data-tree={jsonTree}
        >
          <h1>{opts.title}</h1>
          <h1>{opts.title ?? i18n(cfg.locale).components.explorer.title}</h1>
          <svg
            xmlns="http://www.w3.org/2000/svg"
            width="14"
quartz/components/ExplorerNode.tsx
@@ -12,7 +12,7 @@
type OrderEntries = "sort" | "filter" | "map"
export interface Options {
  title: string
  title?: string
  folderDefaultState: "collapsed" | "open"
  folderClickBehavior: "collapse" | "link"
  useSavedState: boolean
quartz/components/Footer.tsx
@@ -1,7 +1,7 @@
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import style from "./styles/footer.scss"
import { version } from "../../package.json"
import { i18n } from "../i18n/i18next"
import { i18n } from "../i18n"
interface Options {
  links: Record<string, string>
@@ -15,8 +15,8 @@
      <footer class={`${displayClass ?? ""}`}>
        <hr />
        <p>
          {i18n(cfg.locale, "footer.createdWith")}{" "}
          <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a>, © {year}
          {i18n(cfg.locale).components.footer.createdWith}{" "}
          <a href="https://quartz.jzhao.xyz/">Quartz v{version}</a> © {year}
        </p>
        <ul>
          {Object.entries(links).map(([text, link]) => (
quartz/components/Graph.tsx
@@ -2,7 +2,7 @@
// @ts-ignore
import script from "./scripts/graph.inline"
import style from "./styles/graph.scss"
import { i18n } from "../i18n/i18next"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
export interface D3Config {
@@ -59,7 +59,7 @@
    const globalGraph = { ...defaultOptions.globalGraph, ...opts?.globalGraph }
    return (
      <div class={classNames(displayClass, "graph")}>
        <h3>{i18n(cfg.locale, "graph.graphView")}</h3>
        <h3>{i18n(cfg.locale).components.graph.title}</h3>
        <div class="graph-outer">
          <div id="graph-container" data-cfg={JSON.stringify(localGraph)}></div>
          <svg
quartz/components/Head.tsx
@@ -1,13 +1,13 @@
import { i18n } from "../i18n/i18next"
import { i18n } from "../i18n"
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 ?? i18n(cfg.locale, "head.untitled")
    const title = fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
    const description =
      fileData.description?.trim() ?? i18n(cfg.locale, "head.noDescriptionProvided")
      fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description
    const { css, js } = externalResources
    const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
quartz/components/PageTitle.tsx
@@ -1,9 +1,10 @@
import { pathToRoot } from "../util/path"
import { QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
import { i18n } from "../i18n"
function PageTitle({ fileData, cfg, displayClass }: QuartzComponentProps) {
  const title = cfg?.pageTitle ?? "Untitled Quartz"
  const title = cfg?.pageTitle ?? i18n(cfg.locale).propertyDefaults.title
  const baseDir = pathToRoot(fileData.slug!)
  return (
    <h1 class={classNames(displayClass, "page-title")}>
quartz/components/RecentNotes.tsx
@@ -5,11 +5,11 @@
import style from "./styles/recentNotes.scss"
import { Date, getDate } from "./Date"
import { GlobalConfiguration } from "../cfg"
import { i18n } from "../i18n/i18next"
import { i18n } from "../i18n"
import { classNames } from "../util/lang"
interface Options {
  title: string
  title?: string
  limit: number
  linkToMore: SimpleSlug | false
  filter: (f: QuartzPluginData) => boolean
@@ -17,7 +17,6 @@
}
const defaultOptions = (cfg: GlobalConfiguration): Options => ({
  title: "Recent Notes",
  limit: 3,
  linkToMore: false,
  filter: () => true,
@@ -31,10 +30,10 @@
    const remaining = Math.max(0, pages.length - opts.limit)
    return (
      <div class={classNames(displayClass, "recent-notes")}>
        <h3>{opts.title}</h3>
        <h3>{opts.title ?? i18n(cfg.locale).components.recentNotes.title}</h3>
        <ul class="recent-ul">
          {pages.slice(0, opts.limit).map((page) => {
            const title = page.frontmatter?.title
            const title = page.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title
            const tags = page.frontmatter?.tags ?? []
            return (
@@ -72,11 +71,7 @@
        {opts.linkToMore && remaining > 0 && (
          <p>
            <a href={resolveRelative(fileData.slug!, opts.linkToMore)}>
              {" "}
              {i18n(cfg.locale, "recentNotes.seeRemainingMore", {
                remaining: remaining.toString(),
              })}{" "}
              →
              {i18n(cfg.locale).components.recentNotes.seeRemainingMore({ remaining })}
            </a>
          </p>
        )}
quartz/components/Search.tsx
@@ -3,7 +3,7 @@
// @ts-ignore
import script from "./scripts/search.inline"
import { classNames } from "../util/lang"
import { i18n } from "../i18n/i18next"
import { i18n } from "../i18n"
export interface SearchOptions {
  enablePreview: boolean
@@ -16,11 +16,11 @@
export default ((userOpts?: Partial<SearchOptions>) => {
  function Search({ displayClass, cfg }: QuartzComponentProps) {
    const opts = { ...defaultOptions, ...userOpts }
    const searchPlaceholder = i18n(cfg.locale).components.search.searchBarPlaceholder
    return (
      <div class={classNames(displayClass, "search")}>
        <div id="search-icon">
          <p>{i18n(cfg.locale, "search")}</p>
          <p>{i18n(cfg.locale).components.search.title}</p>
          <div></div>
          <svg
            tabIndex={0}
@@ -44,8 +44,8 @@
              id="search-bar"
              name="search"
              type="text"
              aria-label="Search for something"
              placeholder="Search for something"
              aria-label={searchPlaceholder}
              placeholder={searchPlaceholder}
            />
            <div id="search-layout" data-preview={opts.enablePreview}></div>
          </div>
quartz/components/TableOfContents.tsx
@@ -5,7 +5,7 @@
// @ts-ignore
import script from "./scripts/toc.inline"
import { i18n } from "../i18n/i18next"
import { i18n } from "../i18n"
interface Options {
  layout: "modern" | "legacy"
@@ -23,7 +23,7 @@
  return (
    <div class={classNames(displayClass, "toc")}>
      <button type="button" id="toc" class={fileData.collapseToc ? "collapsed" : ""}>
        <h3>{i18n(cfg.locale, "tableOfContent")}</h3>
        <h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="24"
@@ -63,7 +63,7 @@
  return (
    <details id="toc" open={!fileData.collapseToc}>
      <summary>
        <h3>{i18n(cfg.locale, "tableOfContent")}</h3>
        <h3>{i18n(cfg.locale).components.tableOfContents.title}</h3>
      </summary>
      <ul>
        {fileData.toc.map((tocEntry) => (
quartz/components/pages/404.tsx
@@ -1,11 +1,11 @@
import { i18n } from "../../i18n/i18next"
import { i18n } from "../../i18n"
import { QuartzComponentConstructor, QuartzComponentProps } from "../types"
function NotFound({ cfg }: QuartzComponentProps) {
  return (
    <article class="popover-hint">
      <h1>404</h1>
      <p>{i18n(cfg.locale, "404")}</p>
      <p>{i18n(cfg.locale).pages.error.notFound}</p>
    </article>
  )
}
quartz/components/pages/FolderContent.tsx
@@ -5,9 +5,8 @@
import { PageList } from "../PageList"
import { _stripSlashes, simplifySlug } from "../../util/path"
import { Root } from "hast"
import { pluralize } from "../../util/lang"
import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n/i18next"
import { i18n } from "../../i18n"
interface FolderContentOptions {
  /**
@@ -54,8 +53,9 @@
        <div class="page-listing">
          {options.showFolderCount && (
            <p>
              {pluralize(allPagesInFolder.length, i18n(cfg.locale, "common.item"))}{" "}
              {i18n(cfg.locale, "folderContent.underThisFolder")}.
              {i18n(cfg.locale).pages.folderContent.itemsUnderFolder({
                count: allPagesInFolder.length,
              })}
            </p>
          )}
          <div>
quartz/components/pages/TagContent.tsx
@@ -4,9 +4,8 @@
import { FullSlug, getAllSegmentPrefixes, simplifySlug } from "../../util/path"
import { QuartzPluginData } from "../../plugins/vfile"
import { Root } from "hast"
import { pluralize } from "../../util/lang"
import { htmlToJsx } from "../../util/jsx"
import { i18n } from "../../i18n/i18next"
import { i18n } from "../../i18n"
const numPages = 10
function TagContent(props: QuartzComponentProps) {
@@ -44,10 +43,7 @@
        <article>
          <p>{content}</p>
        </article>
        <p>
          {i18n(cfg.locale, "tagContent.found")} {tags.length}{" "}
          {i18n(cfg.locale, "tagContent.totalTags")}.
        </p>
        <p>{i18n(cfg.locale).pages.tagContent.totalTags({ count: tags.length })}</p>
        <div>
          {tags.map((tag) => {
            const pages = tagItemMap.get(tag)!
@@ -68,10 +64,12 @@
                {content && <p>{content}</p>}
                <div class="page-listing">
                  <p>
                    {pluralize(pages.length, i18n(cfg.locale, "common.item"))}{" "}
                    {i18n(cfg.locale, "tagContent.withThisTag")}.{" "}
                    {pages.length > numPages &&
                      `${i18n(cfg.locale, "tagContent.showingFirst")} ${numPages}.`}
                    {i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}
                    {pages.length > numPages && (
                      <span>
                        {i18n(cfg.locale).pages.tagContent.showingFirst({ count: numPages })}
                      </span>
                    )}
                  </p>
                  <PageList limit={numPages} {...listProps} />
                </div>
@@ -92,10 +90,7 @@
      <div class={classes}>
        <article>{content}</article>
        <div class="page-listing">
          <p>
            {pluralize(pages.length, i18n(cfg.locale, "common.item"))}{" "}
            {i18n(cfg.locale, "tagContent.withThisTag")}.
          </p>
          <p>{i18n(cfg.locale).pages.tagContent.itemsUnderTag({ count: pages.length })}</p>
          <div>
            <PageList {...listProps} />
          </div>
quartz/components/renderPage.tsx
@@ -7,6 +7,8 @@
import { visit } from "unist-util-visit"
import { Root, Element, ElementContent } from "hast"
import { QuartzPluginData } from "../plugins/vfile"
import { GlobalConfiguration } from "../cfg"
import { i18n } from "../i18n"
interface RenderComponents {
  head: QuartzComponent
@@ -63,6 +65,7 @@
}
export function renderPage(
  cfg: GlobalConfiguration,
  slug: FullSlug,
  componentData: QuartzComponentProps,
  components: RenderComponents,
@@ -136,7 +139,9 @@
              type: "element",
              tagName: "a",
              properties: { href: inner.properties?.href, class: ["internal"] },
              children: [{ type: "text", value: `Link to original` }],
              children: [
                { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
              ],
            },
          ]
        } else if (page.htmlAst) {
@@ -147,7 +152,14 @@
              tagName: "h1",
              properties: {},
              children: [
                { type: "text", value: page.frontmatter?.title ?? `Transclude of ${page.slug}` },
                {
                  type: "text",
                  value:
                    page.frontmatter?.title ??
                    i18n(cfg.locale).components.transcludes.transcludeOf({
                      targetSlug: page.slug!,
                    }),
                },
              ],
            },
            ...(page.htmlAst.children as ElementContent[]).map((child) =>
@@ -157,7 +169,9 @@
              type: "element",
              tagName: "a",
              properties: { href: inner.properties?.href, class: ["internal"] },
              children: [{ type: "text", value: `Link to original` }],
              children: [
                { type: "text", value: i18n(cfg.locale).components.transcludes.linkToOriginal },
              ],
            },
          ]
        }
quartz/i18n/i18next.ts
File was deleted
quartz/i18n/index.ts
New file
@@ -0,0 +1,11 @@
import { Translation } from "./locales/definition"
import en from "./locales/en-US"
import fr from "./locales/fr-FR"
export const TRANSLATIONS = {
  "en-US": en,
  "fr-FR": fr,
} as const
export const i18n = (locale: ValidLocale): Translation => TRANSLATIONS[locale]
export type ValidLocale = keyof typeof TRANSLATIONS
quartz/i18n/locales/definition.ts
New file
@@ -0,0 +1,63 @@
import { FullSlug } from "../../util/path"
export interface Translation {
  propertyDefaults: {
    title: string
    description: string
  }
  components: {
    backlinks: {
      title: string
      noBacklinksFound: string
    }
    themeToggle: {
      lightMode: string
      darkMode: string
    }
    explorer: {
      title: string
    }
    footer: {
      createdWith: string
    }
    graph: {
      title: string
    }
    recentNotes: {
      title: string
      seeRemainingMore: (variables: { remaining: number }) => string
    }
    transcludes: {
      transcludeOf: (variables: { targetSlug: FullSlug }) => string
      linkToOriginal: string
    }
    search: {
      title: string
      searchBarPlaceholder: string
    }
    tableOfContents: {
      title: string
    }
  }
  pages: {
    rss: {
      recentNotes: string
      lastFewNotes: (variables: { count: number }) => string
    }
    error: {
      title: string
      notFound: string
    }
    folderContent: {
      folder: string
      itemsUnderFolder: (variables: { count: number }) => string
    }
    tagContent: {
      tag: string
      tagIndex: string
      itemsUnderTag: (variables: { count: number }) => string
      showingFirst: (variables: { count: number }) => string
      totalTags: (variables: { count: number }) => string
    }
  }
}
quartz/i18n/locales/en-US.ts
New file
@@ -0,0 +1,65 @@
import { Translation } from "./definition"
export default {
  propertyDefaults: {
    title: "Untitled",
    description: "No description provided",
  },
  components: {
    backlinks: {
      title: "Backlinks",
      noBacklinksFound: "No backlinks found",
    },
    themeToggle: {
      lightMode: "Light mode",
      darkMode: "Dark mode",
    },
    explorer: {
      title: "Explorer",
    },
    footer: {
      createdWith: "Created with",
    },
    graph: {
      title: "Graph View",
    },
    recentNotes: {
      title: "Recent Notes",
      seeRemainingMore: ({ remaining }) => `See ${remaining} more →`,
    },
    transcludes: {
      transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`,
      linkToOriginal: "Link to original",
    },
    search: {
      title: "Search",
      searchBarPlaceholder: "Search for something",
    },
    tableOfContents: {
      title: "Table of Contents",
    },
  },
  pages: {
    rss: {
      recentNotes: "Recent notes",
      lastFewNotes: ({ count }) => `Last ${count} notes`,
    },
    error: {
      title: "Not Found",
      notFound: "Either this page is private or doesn't exist.",
    },
    folderContent: {
      folder: "Folder",
      itemsUnderFolder: ({ count }) =>
        count === 1 ? "1 item under this folder" : `${count} items under this folder.`,
    },
    tagContent: {
      tag: "Tag",
      tagIndex: "Tag Index",
      itemsUnderTag: ({ count }) =>
        count === 1 ? "1 item with this tag" : `${count} items with this tag.`,
      showingFirst: ({ count }) => `Showing first ${count} tags.`,
      totalTags: ({ count }) => `Found ${count} total tags.`,
    },
  },
} as const satisfies Translation
quartz/i18n/locales/en.json
File was deleted
quartz/i18n/locales/fr-FR.ts
New file
@@ -0,0 +1,65 @@
import { Translation } from "./definition"
export default {
  propertyDefaults: {
    title: "Sans titre",
    description: "Aucune description fournie",
  },
  components: {
    backlinks: {
      title: "Liens retour",
      noBacklinksFound: "Aucun lien retour trouvé",
    },
    themeToggle: {
      lightMode: "Mode clair",
      darkMode: "Mode sombre",
    },
    explorer: {
      title: "Explorateur",
    },
    footer: {
      createdWith: "Créé avec",
    },
    graph: {
      title: "Vue Graphique",
    },
    recentNotes: {
      title: "Notes Récentes",
      seeRemainingMore: ({ remaining }) => `Voir ${remaining} de plus →`,
    },
    transcludes: {
      transcludeOf: ({ targetSlug }) => `Transclusion de ${targetSlug}`,
      linkToOriginal: "Lien vers l'original",
    },
    search: {
      title: "Recherche",
      searchBarPlaceholder: "Rechercher quelque chose",
    },
    tableOfContents: {
      title: "Table des Matières",
    },
  },
  pages: {
    rss: {
      recentNotes: "Notes récentes",
      lastFewNotes: ({ count }) => `Les dernières ${count} notes`,
    },
    error: {
      title: "Pas trouvé",
      notFound: "Cette page est soit privée, soit elle n'existe pas.",
    },
    folderContent: {
      folder: "Dossier",
      itemsUnderFolder: ({ count }) =>
        count === 1 ? "1 élément sous ce dossier" : `${count} éléments sous ce dossier.`,
    },
    tagContent: {
      tag: "Étiquette",
      tagIndex: "Index des étiquettes",
      itemsUnderTag: ({ count }) =>
        count === 1 ? "1 élément avec cette étiquette" : `${count} éléments avec cette étiquette.`,
      showingFirst: ({ count }) => `Affichage des premières ${count} étiquettes.`,
      totalTags: ({ count }) => `Trouvé ${count} étiquettes au total.`,
    },
  },
} as const satisfies Translation
quartz/i18n/locales/fr.json
File was deleted
quartz/plugins/emitters/404.tsx
@@ -8,6 +8,7 @@
import { NotFound } from "../../components"
import { defaultProcessedContent } from "../vfile"
import { write } from "./helpers"
import { i18n } from "../../i18n"
export const NotFoundPage: QuartzEmitterPlugin = () => {
  const opts: FullPageLayout = {
@@ -33,11 +34,12 @@
      const url = new URL(`https://${cfg.baseUrl ?? "example.com"}`)
      const path = url.pathname as FullSlug
      const externalResources = pageResources(path, resources)
      const notFound = i18n(cfg.locale).pages.error.title
      const [tree, vfile] = defaultProcessedContent({
        slug,
        text: "Not Found",
        description: "Not Found",
        frontmatter: { title: "Not Found", tags: [] },
        text: notFound,
        description: notFound,
        frontmatter: { title: notFound, tags: [] },
      })
      const componentData: QuartzComponentProps = {
        fileData: vfile.data,
@@ -51,7 +53,7 @@
      return [
        await write({
          ctx,
          content: renderPage(slug, componentData, opts, externalResources),
          content: renderPage(cfg, slug, componentData, opts, externalResources),
          slug,
          ext: ".html",
        }),
quartz/plugins/emitters/contentIndex.ts
@@ -6,6 +6,7 @@
import { QuartzEmitterPlugin } from "../types"
import { toHtml } from "hast-util-to-html"
import { write } from "./helpers"
import { i18n } from "../../i18n"
export type ContentIndex = Map<FullSlug, ContentDetails>
export type ContentDetails = {
@@ -38,7 +39,7 @@
  const base = cfg.baseUrl ?? ""
  const createURLEntry = (slug: SimpleSlug, content: ContentDetails): string => `<url>
    <loc>https://${joinSegments(base, encodeURI(slug))}</loc>
    <lastmod>${content.date?.toISOString()}</lastmod>
    ${content.date && `<lastmod>${content.date.toISOString()}</lastmod>`}
  </url>`
  const urls = Array.from(idx)
    .map(([slug, content]) => createURLEntry(simplifySlug(slug), content))
@@ -78,7 +79,7 @@
    <channel>
      <title>${escapeHTML(cfg.pageTitle)}</title>
      <link>https://${base}</link>
      <description>${!!limit ? `Last ${limit} notes` : "Recent notes"} on ${escapeHTML(
      <description>${!!limit ? i18n(cfg.locale).pages.rss.lastFewNotes({ count: limit }) : i18n(cfg.locale).pages.rss.recentNotes} on ${escapeHTML(
        cfg.pageTitle,
      )}</description>
      <generator>Quartz -- quartz.jzhao.xyz</generator>
quartz/plugins/emitters/contentPage.tsx
@@ -49,7 +49,7 @@
          allFiles,
        }
        const content = renderPage(slug, componentData, opts, externalResources)
        const content = renderPage(cfg, slug, componentData, opts, externalResources)
        const fp = await write({
          ctx,
          content,
quartz/plugins/emitters/folderPage.tsx
@@ -18,6 +18,7 @@
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { FolderContent } from "../../components"
import { write } from "./helpers"
import { i18n } from "../../i18n"
export const FolderPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
  const opts: FullPageLayout = {
@@ -57,7 +58,10 @@
          folder,
          defaultProcessedContent({
            slug: joinSegments(folder, "index") as FullSlug,
            frontmatter: { title: `Folder: ${folder}`, tags: [] },
            frontmatter: {
              title: `${i18n(cfg.locale).pages.folderContent.folder}: ${folder}`,
              tags: [],
            },
          }),
        ]),
      )
@@ -82,7 +86,7 @@
          allFiles,
        }
        const content = renderPage(slug, componentData, opts, externalResources)
        const content = renderPage(cfg, slug, componentData, opts, externalResources)
        const fp = await write({
          ctx,
          content,
quartz/plugins/emitters/tagPage.tsx
@@ -15,6 +15,7 @@
import { defaultListPageLayout, sharedPageComponents } from "../../../quartz.layout"
import { TagContent } from "../../components"
import { write } from "./helpers"
import { i18n } from "../../i18n"
export const TagPage: QuartzEmitterPlugin<Partial<FullPageLayout>> = (userOpts) => {
  const opts: FullPageLayout = {
@@ -47,7 +48,10 @@
      const tagDescriptions: Record<string, ProcessedContent> = Object.fromEntries(
        [...tags].map((tag) => {
          const title = tag === "index" ? "Tag Index" : `Tag: #${tag}`
          const title =
            tag === "index"
              ? i18n(cfg.locale).pages.tagContent.tagIndex
              : `${i18n(cfg.locale).pages.tagContent.tag}: #${tag}`
          return [
            tag,
            defaultProcessedContent({
@@ -81,7 +85,7 @@
          allFiles,
        }
        const content = renderPage(slug, componentData, opts, externalResources)
        const content = renderPage(cfg, slug, componentData, opts, externalResources)
        const fp = await write({
          ctx,
          content,
quartz/plugins/transformers/frontmatter.ts
@@ -5,6 +5,7 @@
import toml from "toml"
import { slugTag } from "../../util/path"
import { QuartzPluginData } from "../vfile"
import { i18n } from "../../i18n"
export interface Options {
  delims: string | string[]
@@ -43,7 +44,7 @@
  const opts = { ...defaultOptions, ...userOpts }
  return {
    name: "FrontMatter",
    markdownPlugins() {
    markdownPlugins({ cfg }) {
      return [
        [remarkFrontmatter, ["yaml", "toml"]],
        () => {
@@ -59,7 +60,7 @@
            if (data.title) {
              data.title = data.title.toString()
            } else if (data.title === null || data.title === undefined) {
              data.title = file.stem ?? "Untitled"
              data.title = file.stem ?? i18n(cfg.configuration.locale).propertyDefaults.title
            }
            const tags = coerceToArray(coalesceAliases(data, ["tags", "tag"]))
quartz/plugins/transformers/toc.ts
@@ -3,7 +3,6 @@
import { visit } from "unist-util-visit"
import { toString } from "mdast-util-to-string"
import Slugger from "github-slugger"
import { wikilinkRegex } from "./ofm"
export interface Options {
  maxDepth: 1 | 2 | 3 | 4 | 5 | 6
@@ -25,7 +24,6 @@
  slug: string // this is just the anchor (#some-slug), not the canonical slug
}
const regexMdLinks = new RegExp(/\[([^\[]+)\](\(.*\))/, "g")
const slugAnchor = new Slugger()
export const TableOfContents: QuartzTransformerPlugin<Partial<Options> | undefined> = (
  userOpts,
@@ -44,16 +42,7 @@
              let highestDepth: number = opts.maxDepth
              visit(tree, "heading", (node) => {
                if (node.depth <= opts.maxDepth) {
                  let text = toString(node)
                  // strip link formatting from toc entries
                  text = text.replace(wikilinkRegex, (_, rawFp, __, rawAlias) => {
                    const fp = rawFp?.trim() ?? ""
                    const alias = rawAlias?.slice(1).trim()
                    return alias ?? fp
                  })
                  text = text.replace(regexMdLinks, "$1")
                  const text = toString(node)
                  highestDepth = Math.min(highestDepth, node.depth)
                  toc.push({
                    depth: node.depth,
quartz/util/lang.ts
@@ -1,11 +1,3 @@
export function pluralize(count: number, s: string): string {
  if (count === 1) {
    return `1 ${s}`
  } else {
    return `${count} ${s}s`
  }
}
export function capitalize(s: string): string {
  return s.substring(0, 1).toUpperCase() + s.substring(1)
}