- Help us expand the frontier.
+ {{ labels.home.contributeTitle }}
@@ -363,28 +431,26 @@ const compareRows: {
-
Let’s start building.
-
- Quick Start
+ {{ labels.home.buildTitle }}
+
+ {{ labels.home.quickStart }}
→
diff --git a/docs/.vuepress/components/SiteFooter.vue b/docs/.vuepress/components/SiteFooter.vue
index 8eb28bc..c1e61df 100644
--- a/docs/.vuepress/components/SiteFooter.vue
+++ b/docs/.vuepress/components/SiteFooter.vue
@@ -1,5 +1,8 @@
@@ -17,75 +32,74 @@ const aoLabLogoDark = withBase('/ao_lab_logo_dark.png');
diff --git a/docs/.vuepress/composables/formatLabel.ts b/docs/.vuepress/composables/formatLabel.ts
new file mode 100644
index 0000000..d2b46a5
--- /dev/null
+++ b/docs/.vuepress/composables/formatLabel.ts
@@ -0,0 +1,39 @@
+const PLACEHOLDER = /\{\{\s*(\w+)\s*\}\}/g;
+
+const escapeHtml = (text: string) =>
+ text.replace(
+ /[&<>"']/g,
+ (c) =>
+ ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ })[c]!
+ );
+
+/** Replaces the `{{ name }}` placeholders in a label. */
+export function fillLabel(
+ label: string,
+ values: Record
+): string {
+ return label.replace(PLACEHOLDER, (match, name) =>
+ name in values ? String(values[name]) : match
+ );
+}
+
+/**
+ * Renders a label that uses `**bold**` or whose placeholders are filled with
+ * markup, for `v-html`. The label itself is escaped; `html` values are
+ * inserted as they are, so they must be trusted markup.
+ */
+export function labelHtml(
+ label: string,
+ html: Record = {}
+): string {
+ return fillLabel(
+ escapeHtml(label).replace(/\*\*(.+?)\*\*/g, '$1'),
+ html
+ );
+}
diff --git a/docs/.vuepress/composables/useLocale.ts b/docs/.vuepress/composables/useLocale.ts
new file mode 100644
index 0000000..0d05b96
--- /dev/null
+++ b/docs/.vuepress/composables/useLocale.ts
@@ -0,0 +1,58 @@
+import {
+ computed,
+ inject,
+ onMounted,
+ provide,
+ ref,
+ type ComputedRef,
+ type InjectionKey,
+} from 'vue';
+import { useRouteLocale } from 'vuepress/client';
+import type { Labels } from '../labels';
+
+// Every locale's labels, keyed by locale path. Defined in config.ts.
+declare const __SITE_LABELS__: Record;
+
+const localeKey: InjectionKey> = Symbol('locale');
+
+/**
+ * The locale path ("/", "/es/") the custom layouts should render in: the
+ * route's, unless an ancestor chose otherwise with `provideLocaleAfterMount`.
+ */
+export function useLocale(): ComputedRef {
+ return inject(localeKey, null) ?? useRouteLocale();
+}
+
+/**
+ * There is one 404 page for every locale, rendered at build time in English.
+ * Rendering it in the reader's language straight away would make the browser
+ * disagree with the build-time HTML, so switch to it once mounted.
+ */
+export function provideLocaleAfterMount(): ComputedRef {
+ const routeLocale = useRouteLocale();
+ const mounted = ref(false);
+ onMounted(() => {
+ mounted.value = true;
+ });
+ const locale = computed(() => (mounted.value ? routeLocale.value : '/'));
+ provide(localeKey, locale);
+ // A component cannot inject what it provides, so the caller passes this
+ // to useLabels() and useLocalePath() itself.
+ return locale;
+}
+
+/** The text of the custom layouts, in the language being viewed. */
+export function useLabels(locale = useLocale()): ComputedRef {
+ return computed(
+ () => __SITE_LABELS__[locale.value] ?? __SITE_LABELS__['/']
+ );
+}
+
+/**
+ * Returns a function that turns an English page path ("/guide/") into the
+ * same page in the language being viewed ("/es/guide/"), so links in the
+ * custom layouts do not drop readers back into the English docs.
+ */
+export function useLocalePath(locale = useLocale()) {
+ return (path: string) => locale.value + path.replace(/^\//, '');
+}
diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts
index 41828ab..6224d29 100644
--- a/docs/.vuepress/config.ts
+++ b/docs/.vuepress/config.ts
@@ -8,6 +8,16 @@ import { seoPlugin } from '@vuepress/plugin-seo';
import { shikiPlugin } from '@vuepress/plugin-shiki';
import { sitemapPlugin } from '@vuepress/plugin-sitemap';
import { markdownIncludePlugin } from '@vuepress/plugin-markdown-include';
+import type {
+ DefaultThemeLocaleData,
+ NavbarOptions,
+ SidebarOptions,
+} from '@vuepress/theme-default';
+import type { SiteLocaleConfig } from 'vuepress';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import path from 'node:path';
+import { mergeLabels, type Labels } from './labels';
+import type { Verses } from './verses';
const hostname = 'https://bible.helloao.org';
const base = '/docs/';
@@ -68,9 +78,217 @@ const sectionNames: Record = {
'/reference/': 'Reference',
};
+/**
+ * Written by tools/translate-docs.ts to the root of every machine-translated
+ * copy of the docs.
+ */
+const TRANSLATED_DOCS_MARKER = '.translated-docs';
+
+/**
+ * Names that read better in the language selector than the ones
+ * `Intl.DisplayNames` gives ("Indonesia", "中文(中国)"), or than capitalizing
+ * them below would (isiZulu).
+ */
+const languageNameOverrides: Record = {
+ id: 'Bahasa Indonesia',
+ 'zh-CN': '简体中文',
+ zu: 'isiZulu',
+};
+
+/**
+ * The language codes of the translated docs, found by looking for the
+ * directories that tools/translate-docs.ts generated. A newly translated
+ * language shows up in the language selector without touching this file.
+ */
+const docsDir = path.resolve(__dirname, '..');
+const translatedLanguages = readdirSync(docsDir, { withFileTypes: true })
+ .filter(
+ (entry) =>
+ entry.isDirectory() &&
+ existsSync(path.join(docsDir, entry.name, TRANSLATED_DOCS_MARKER))
+ )
+ .map((entry) => entry.name)
+ .sort();
+
+/** The name of a language, written in that language. */
+const nativeLanguageName = (lang: string) => {
+ if (languageNameOverrides[lang]) {
+ return languageNameOverrides[lang];
+ }
+ const name = new Intl.DisplayNames([lang], { type: 'language' }).of(lang);
+ if (!name || name === lang) {
+ return lang;
+ }
+ // Some languages write their own names in lowercase ("español"), which
+ // looks out of place in a menu.
+ return name.charAt(0).toLocaleUpperCase(lang) + name.slice(1);
+};
+
+const englishLabels: Labels = JSON.parse(
+ readFileSync(path.join(__dirname, 'labels.json'), 'utf8')
+);
+
+/** The labels for a translation, with English for any it is missing. */
+const labelsFor = (lang: string): Labels => {
+ const file = path.join(docsDir, lang, 'labels.json');
+ return existsSync(file)
+ ? mergeLabels(englishLabels, JSON.parse(readFileSync(file, 'utf8')))
+ : englishLabels;
+};
+
+const navbar = (prefix: string, labels: Labels): NavbarOptions => [
+ {
+ text: labels.nav.guide,
+ link: `${prefix}guide/`,
+ },
+ {
+ text: labels.nav.reference,
+ link: `${prefix}reference/`,
+ },
+ {
+ text: labels.nav.sdks,
+ link: `${prefix}sdks/`,
+ },
+ {
+ text: 'GitHub',
+ link: 'https://github.com/HelloAOLab/bible-api',
+ },
+ {
+ text: labels.nav.donate,
+ link: 'https://better.giving/marketplace/1118469',
+ },
+];
+
+const sidebar = (prefix: string, labels: Labels): SidebarOptions => ({
+ [`${prefix}guide/`]: [
+ {
+ text: labels.nav.guide,
+ collapsible: false,
+ children: [
+ '',
+ 'getting-started',
+ 'making-requests',
+ 'downloads',
+ 'a-biblical-model-for-licensing-the-bible',
+ ],
+ },
+ ],
+ [`${prefix}reference/`]: [
+ {
+ text: labels.nav.reference,
+ collapsible: false,
+ children: [
+ '',
+ {
+ text: labels.nav.translationsBooksChapters,
+ collapsible: true,
+ children: [
+ 'translations/',
+ 'translations/standard',
+ 'translations/simplified',
+ ],
+ },
+ {
+ text: labels.nav.commentaries,
+ collapsible: true,
+ children: ['commentaries/'],
+ },
+ {
+ text: labels.nav.datasets,
+ collapsible: true,
+ children: ['datasets/'],
+ },
+ 'openapi',
+ ],
+ },
+ ],
+ [`${prefix}sdks/`]: [
+ {
+ text: labels.nav.sdks,
+ collapsible: false,
+ children: ['', 'javascript'],
+ },
+ ],
+});
+
+/** Per-language site settings, keyed by the path each language lives under. */
+const siteLocales: Record = {
+ '/': { lang: 'en-US' },
+ ...Object.fromEntries(
+ translatedLanguages.map((lang) => [`/${lang}/`, { lang }])
+ ),
+};
+
+/**
+ * Per-language theme settings. `selectLanguageName` is what the navbar's
+ * language selector lists, which the theme only shows when there is more
+ * than one locale.
+ */
+const themeLocales: Record = {
+ '/': {
+ selectLanguageName: 'English',
+ navbar: navbar('/', englishLabels),
+ sidebar: sidebar('/', englishLabels),
+ },
+ ...Object.fromEntries(
+ translatedLanguages.map((lang) => {
+ const labels = labelsFor(lang);
+ return [
+ `/${lang}/`,
+ {
+ selectLanguageName: nativeLanguageName(lang),
+ selectLanguageText: labels.nav.languages,
+ selectLanguageAriaLabel: labels.nav.selectLanguage,
+ navbar: navbar(`/${lang}/`, labels),
+ sidebar: sidebar(`/${lang}/`, labels),
+ },
+ ];
+ })
+ ),
+};
+
+/**
+ * Every locale's labels, keyed like `locales`, for the custom layouts to read
+ * through `useLabels()`.
+ */
+const siteLabels: Record = {
+ '/': englishLabels,
+ ...Object.fromEntries(
+ translatedLanguages.map((lang) => [`/${lang}/`, labelsFor(lang)])
+ ),
+};
+
+/**
+ * Every locale's home page verses, keyed like `locales`. See verses.ts.
+ */
+const readVerses = (file: string): Verses =>
+ existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
+const siteVerses: Record = {
+ '/': readVerses(path.join(__dirname, 'verses.json')),
+ ...Object.fromEntries(
+ translatedLanguages.map((lang) => [
+ `/${lang}/`,
+ readVerses(path.join(docsDir, lang, 'verses.json')),
+ ])
+ ),
+};
+
+// Translated section landing pages get translated crumb labels too.
+for (const lang of translatedLanguages) {
+ const labels = siteLabels[`/${lang}/`];
+ sectionNames[`/${lang}/guide/`] = labels.nav.guide;
+ sectionNames[`/${lang}/reference/`] = labels.nav.reference;
+}
+
export default defineUserConfig({
base,
lang: 'en-US',
+ locales: siteLocales,
+
+ define: {
+ __SITE_LABELS__: siteLabels,
+ __SITE_VERSES__: siteVerses,
+ },
title,
description: description,
@@ -124,79 +342,7 @@ export default defineUserConfig({
editLinkText: '',
lastUpdated: false,
contributors: false,
- navbar: [
- {
- text: 'Guide',
- link: '/guide/',
- },
- {
- text: 'Reference',
- link: '/reference/',
- },
- {
- text: 'SDKs',
- link: '/sdks/',
- },
- {
- text: 'GitHub',
- link: 'https://github.com/HelloAOLab/bible-api',
- },
- {
- text: 'Donate',
- link: 'https://better.giving/marketplace/1118469',
- },
- ],
- sidebar: {
- '/guide/': [
- {
- text: 'Guide',
- collapsible: false,
- children: [
- '',
- 'getting-started',
- 'making-requests',
- 'downloads',
- 'a-biblical-model-for-licensing-the-bible',
- ],
- },
- ],
- '/reference/': [
- {
- text: 'Reference',
- collapsible: false,
- children: [
- '',
- {
- text: 'Translations, Books, & Chapters',
- collapsible: true,
- children: [
- 'translations/',
- 'translations/standard',
- 'translations/simplified',
- ],
- },
- {
- text: 'Commentaries',
- collapsible: true,
- children: ['commentaries/'],
- },
- {
- text: 'Datasets',
- collapsible: true,
- children: ['datasets/'],
- },
- 'openapi',
- ],
- },
- ],
- '/sdks/': [
- {
- text: 'SDKs',
- collapsible: false,
- children: ['', 'javascript'],
- },
- ],
- },
+ locales: themeLocales,
}),
plugins: [
diff --git a/docs/.vuepress/labels.json b/docs/.vuepress/labels.json
new file mode 100644
index 0000000..d3be76c
--- /dev/null
+++ b/docs/.vuepress/labels.json
@@ -0,0 +1,137 @@
+{
+ "nav": {
+ "guide": "Guide",
+ "reference": "Reference",
+ "sdks": "SDKs",
+ "donate": "Donate",
+ "translationsBooksChapters": "Translations, Books, & Chapters",
+ "commentaries": "Commentaries",
+ "datasets": "Datasets",
+ "languages": "Languages",
+ "selectLanguage": "Select language"
+ },
+ "home": {
+ "tagline": "An easy-to-use and fully featured JSON API for Scripture.",
+ "quickStart": "Quick Start",
+ "donate": "Donate",
+ "openVerse": "Open {{ reference }} in Seed Bible",
+ "translationCount": "{{ count }} translations",
+ "languageCount": "across {{ count }} languages",
+ "noApiKey": "No API key",
+ "noApiKeyDetail": "Nothing to sign up for",
+ "noUsageLimits": "No usage limits",
+ "noUsageLimitsDetail": "Free forever",
+ "welcome": "Welcome to freedom.",
+ "welcomeDetail": "Get started today with **no limits** and **no red tape**.",
+ "compareLink": "Compare us to other Bible APIs",
+ "translationsTitle": "Bible Translations",
+ "translationsBody": "{{ count }} translations as static JSON, with headings, poetry, line breaks and footnotes kept intact.",
+ "translationsLink": "API reference",
+ "audioTitle": "Bible Audio",
+ "audioBody": "Scripture plainly read aloud and dramatized, for easy listening and accessibility.",
+ "audioLink": "Audio endpoints",
+ "datasetsTitle": "Bible Data Sets",
+ "datasetsBody": "Cross references and other Bible data sets, in formats you can build with rather than parse around.",
+ "datasetsLink": "Browse datasets",
+ "seedBibleTitle": "Seed Bible Developer Docs",
+ "seedBibleBody": "Utilize and extend our open platform instead of starting from scratch.",
+ "seedBibleComing": "Coming December",
+ "compareTitle": "A Few Bible APIs Compared",
+ "compareCaption": "What the Free Use Bible API and three widely used Bible APIs let you do",
+ "compareCorner": "What you may do",
+ "compareColumns": {
+ "account": "No account",
+ "usage": "No usage limit",
+ "permission": "Permissionless",
+ "storage": "Store & cache*",
+ "openSource": "Open source"
+ },
+ "compareRows": {
+ "ours": {
+ "account": "No account, key or approval",
+ "usage": "No published usage limit",
+ "permission": "Nothing to accept. AO Lab adds no restrictions; each text keeps its own notice",
+ "storage": "Cache, store and download without expiry",
+ "openSource": "MIT licensed"
+ },
+ "apiBible": {
+ "account": "Account and API key required",
+ "usage": "5,000 calls a month on Starter",
+ "permission": "Accept API.Bible’s terms; commercial use adds a licence for each translation",
+ "storage": "Cache allowed, but must refresh at least every 30 days",
+ "openSource": "Client SDKs are open source; the API itself is not"
+ },
+ "youVersion": {
+ "account": "App registration and key required",
+ "usage": "Rate limited, no published number",
+ "permission": "Accept the platform terms, then a publisher agreement for each set of translations",
+ "storage": "Set by each publisher agreement; some allow in-app offline use, some limit how much is shown at once",
+ "openSource": "Client SDKs are open source; the API itself is not"
+ },
+ "bibleBrain": {
+ "account": "Key required, granted at their discretion",
+ "usage": "None published, and access can be limited or revoked at any time",
+ "permission": "Accept the licence; keys are approved at their discretion",
+ "storage": "Offline use only via their download endpoint",
+ "openSource": "The API is MIT licensed"
+ }
+ },
+ "markYes": "Yes",
+ "markConditional": "Conditional",
+ "markNotGranted": "Not granted",
+ "storageNote": "*Copy and store on your own hardware, servers, or on a device so it works offline.",
+ "contributeTitle": "Help us expand the frontier.",
+ "contributeBody": "Let’s create an open ecosystem of public domain content and open source tools.",
+ "contributeDiscord": "Contribute on Discord",
+ "buildTitle": "Let’s start building.",
+ "newsletterPrompt": "Want to stay in the loop?",
+ "newsletterLink": "Sign up for the newsletter",
+ "newsletterBody": "Updates from the people building it, in your inbox.",
+ "examplesPrompt": "Want something to copy?",
+ "examplesLink": "See examples",
+ "examplesBody": "Common requests and working patterns you can adapt."
+ },
+ "footer": {
+ "madeWith": "Made with {{ love }} by {{ aoLab }}",
+ "love": "love",
+ "joinUs": "Join us on our journey with any of the links below.",
+ "github": "Source code on GitHub",
+ "discord": "AO Lab on Discord",
+ "youtube": "AO Lab on YouTube",
+ "contributePrompt": "Want to contribute or found a bug?",
+ "openIssue": "Open an issue on GitHub",
+ "updatesPrompt": "Want updates?",
+ "newsletter": "Sign up for the newsletter",
+ "docs": "Docs",
+ "quickStart": "Quick Start",
+ "guide": "Guide",
+ "reference": "Reference",
+ "sdks": "SDKs",
+ "downloads": "Downloads",
+ "reportIssue": "Report an issue",
+ "resources": "Resources",
+ "translations": "Bible translations",
+ "audio": "Bible audio",
+ "commentaries": "Bible commentaries",
+ "datasets": "Bible data sets",
+ "seedBibleSoon": "Seed Bible (Coming soon)",
+ "aboutUs": "About us",
+ "donate": "Donate to AO Lab",
+ "nonprofit": "A 501(c)(3) nonprofit",
+ "established": "Established 2/2/2022",
+ "privacy": "Privacy",
+ "terms": "Terms"
+ },
+ "notFound": {
+ "title": "Page not found",
+ "code": "Error 404",
+ "lead": "We couldn't find the page you were looking for. The link may be broken, or the page may have moved.",
+ "search": "Search the docs",
+ "tryThese": "Or try one of these",
+ "home": "Home page",
+ "homeDetail": "Start from the beginning of the docs.",
+ "guide": "Guide",
+ "guideDetail": "Learn how to use the API, step by step.",
+ "scene": "Decorative ASCII art scene"
+ }
+}
diff --git a/docs/.vuepress/labels.ts b/docs/.vuepress/labels.ts
new file mode 100644
index 0000000..dffd563
--- /dev/null
+++ b/docs/.vuepress/labels.ts
@@ -0,0 +1,31 @@
+import type englishLabels from './labels.json';
+
+/**
+ * The navbar, sidebar, home page and 404 page text, as written in
+ * `labels.json`. tools/translate-docs.ts translates that file into
+ * `docs//labels.json`.
+ */
+export type Labels = typeof englishLabels;
+
+type LabelTree = { [key: string]: string | LabelTree };
+
+/**
+ * Fills in anything a translation is missing (say, a label added since it
+ * was last translated) from the English labels.
+ */
+export function mergeLabels(english: Labels, translated: unknown): Labels {
+ const merge = (base: LabelTree, over: unknown): LabelTree =>
+ Object.fromEntries(
+ Object.entries(base).map(([key, value]) => {
+ const other =
+ over && typeof over === 'object'
+ ? (over as Record)[key]
+ : undefined;
+ if (typeof value === 'string') {
+ return [key, typeof other === 'string' ? other : value];
+ }
+ return [key, merge(value, other)];
+ })
+ );
+ return merge(english, translated) as Labels;
+}
diff --git a/docs/.vuepress/layouts/NotFound.vue b/docs/.vuepress/layouts/NotFound.vue
index d3ddc57..054001a 100644
--- a/docs/.vuepress/layouts/NotFound.vue
+++ b/docs/.vuepress/layouts/NotFound.vue
@@ -1,16 +1,27 @@