Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,35 @@ In this case, the path to the stylesheet is resolved relative to the page’s Ma

The app’s title. If specified, this text is appended to page titles with a separating pipe symbol (“|”). For instance, a page titled “Sales” in an app titled “ACME, Inc.” will display “Sales | ACME, Inc.” in the browser’s title bar. See also the [**home** option](#home).

## locale

The app’s default locale, such as `en-US`, `fr-FR`, or `ar-EG`. This locale is used for Framework-owned UI defaults such as the footer date, search placeholder, pager labels, and other built-in labels. If [**lang**](#lang) is not specified, it defaults to the locale’s language subtag.

Page-level YAML front matter may override the locale for an individual page.

## lang

The document language, used to set the root HTML `lang` attribute. If not specified, it defaults to the language subtag of [**locale**](#locale), if any.

Page-level YAML front matter may override the language for an individual page.

## dir

The document direction, either `ltr` or `rtl`, used to set the root HTML `dir` attribute. If not specified, Framework derives the direction from the page language when possible.

Page-level YAML front matter may override the direction for an individual page.

## sidebar

Whether to show the sidebar. Defaults to true if **pages** is not empty.

## home <a href="https://github.com/observablehq/framework/releases/tag/v1.12.0" class="observablehq-version-badge" data-version="^1.12.0" title="Added in 1.12.0"></a>

An HTML fragment to render the link to the home page in the top of the sidebar. Defaults to the [app’s title](#title), if any, and otherwise the word “Home”. If specified as a function, receives an object with the page’s `title`, (front-matter) `data`, and `path`, and must return a string.
An HTML fragment to render the link to the home page in the top of the sidebar. Defaults to the [app’s title](#title), if any, and otherwise a localized equivalent of “Home” based on [**locale**](#locale) or [**lang**](#lang). If specified as a function, receives an object with the page’s `title`, (front-matter) `data`, and `path`, and must return a string.

## pages

An array containing pages and sections. If not specified, it defaults to all Markdown files found in the source root in directory listing order.
An array containing pages and sections. If not specified, it defaults to all Markdown files found in the source root in directory listing order. Pages without an inferred title use a localized equivalent of “Untitled” based on [**locale**](#locale) or [**lang**](#lang).

Both pages and sections have a **name**, which typically corresponds to the page’s title. The name gets displayed in the sidebar. Sections are used to group related pages; each section must specify an array of **pages**. (Sections can only contain pages; nested sections are not currently supported.)

Expand Down Expand Up @@ -185,7 +203,7 @@ By default, the header is fixed to the top of the window. To instead have the he

## footer

An HTML fragment to add to the footer. Defaults to “Built with Observable.” If specified as a function, receives an object with the page’s `title`, (front-matter) `data`, and `path`, and must return a string.
An HTML fragment to add to the footer. By default, Framework renders a localized “Built with Observable on [date].” footer based on [**locale**](#locale) or [**lang**](#lang), and page-level front matter locale overrides are respected. If specified as a function, receives an object with the page’s `title`, (front-matter) `data`, and `path`, and must return a string.

For example, the following adds a link to the bottom of each page:

Expand Down Expand Up @@ -218,7 +236,7 @@ export interface TableOfContents {
}
```

If **show** is not set, it defaults to true. If **label** is not set, it defaults to “Contents”. The **toc** option can also be set to a boolean, in which case it is shorthand for **toc.show**.
If **show** is not set, it defaults to true. If **label** is not set, it defaults to a localized equivalent of “Contents” based on [**locale**](#locale) or [**lang**](#lang). The **toc** option can also be set to a boolean, in which case it is shorthand for **toc.show**.

If shown, the table of contents enumerates the second-level headings (H2 elements, such as `## Section name`) on the right-hand side of the page. The currently-shown section is highlighted in the table of contents.

Expand All @@ -232,7 +250,7 @@ toc: false

## search

If true, enable [search](./search); defaults to false. The **search** option may also be specified as an object with an **index** method <a href="https://github.com/observablehq/framework/releases/tag/v1.9.0" class="observablehq-version-badge" data-version="^1.9.0" title="Added in 1.9.0"></a>, in which case additional results can be added to the search index. Each result is specified as:
If true, enable [search](./search); defaults to false. Framework localizes the built-in search placeholder from [**locale**](#locale) or [**lang**](#lang). The **search** option may also be specified as an object with an **index** method <a href="https://github.com/observablehq/framework/releases/tag/v1.9.0" class="observablehq-version-badge" data-version="^1.9.0" title="Added in 1.9.0"></a>, in which case additional results can be added to the search index. Each result is specified as:

```ts run=false
interface SearchResult {
Expand Down
3 changes: 2 additions & 1 deletion src/client/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ if (toggle) {
event.preventDefault();
}
});
const title = `Toggle sidebar ${
const baseTitle = toggle.dataset.title || "Toggle sidebar";
const title = `${baseTitle} ${
/Mac|iPhone/.test(navigator.platform)
? /Firefox/.test(navigator.userAgent)
? "⌥" // option symbol for mac firefox
Expand Down
52 changes: 39 additions & 13 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {DUCKDB_CORE_ALIASES, DUCKDB_CORE_EXTENSIONS} from "./duckdb.js";
import {visitFiles} from "./files.js";
import {formatIsoDate, formatLocaleDate} from "./format.js";
import type {FrontMatter} from "./frontMatter.js";
import {getFrameworkLanguage, getFrameworkLocale, getFrameworkMessages} from "./i18n.js";
import {findModule} from "./javascript/module.js";
import {LoaderResolver} from "./loader.js";
import {createMarkdownIt, parseMarkdownMetadata} from "./markdown.js";
Expand Down Expand Up @@ -100,6 +101,9 @@ export interface Config {
base: string; // defaults to "/"
home: string; // defaults to the (escaped) title, or "Home"
title?: string;
lang?: string;
dir?: "ltr" | "rtl";
locale?: string;
sidebar: boolean; // defaults to true if pages isn’t empty
pages: (Page | Section<Page>)[];
pager: boolean; // defaults to true
Expand Down Expand Up @@ -135,6 +139,9 @@ export interface ConfigSpec {
interpreters?: unknown;
home?: unknown;
title?: unknown;
lang?: unknown;
dir?: unknown;
locale?: unknown;
pages?: unknown;
pager?: unknown;
dynamicPaths?: unknown;
Expand Down Expand Up @@ -206,7 +213,8 @@ async function resolveDefaultConfig(root?: string): Promise<string | undefined>

let cachedPages: {key: string; pages: Page[]} | null = null;

function readPages(root: string, md: MarkdownIt): Page[] {
function readPages(root: string, md: MarkdownIt, locale?: string, lang?: string): Page[] {
const messages = getFrameworkMessages(locale, lang);
const files: {file: string; source: string}[] = [];
const hash = createHash("sha256");
for (const file of visitFiles(root, (name) => !isParameterized(name))) {
Expand All @@ -225,7 +233,7 @@ function readPages(root: string, md: MarkdownIt): Page[] {
if (data.draft) continue;
const name = basename(file, ".md");
const {pager = "main"} = data;
const page = {path: join("/", dirname(file), name), name: title ?? "Untitled", pager};
const page = {path: join("/", dirname(file), name), name: title ?? messages.untitled, pager};
if (name === "index") pages.unshift(page);
else pages.push(page);
}
Expand Down Expand Up @@ -269,16 +277,20 @@ export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot?: string, wat
markdownIt: spec.markdownIt as any
});
const title = spec.title === undefined ? undefined : String(spec.title);
const home = spec.home === undefined ? he.escape(title ?? "Home") : String(spec.home); // eslint-disable-line import/no-named-as-default-member
const locale = spec.locale === undefined ? undefined : String(spec.locale);
const lang = spec.lang === undefined ? getFrameworkLanguage(locale) : String(spec.lang);
const dir = spec.dir === undefined ? undefined : normalizeDir(spec.dir);
const messages = getFrameworkMessages(locale, lang);
const home = spec.home === undefined ? he.escape(title ?? messages.home) : String(spec.home); // eslint-disable-line import/no-named-as-default-member
const pages = spec.pages === undefined ? undefined : normalizePages(spec.pages);
const pager = spec.pager === undefined ? true : Boolean(spec.pager);
const dynamicPaths = normalizeDynamicPaths(spec.dynamicPaths);
const toc = normalizeToc(spec.toc as any);
const toc = normalizeToc(spec.toc as any, messages.contents);
const sidebar = spec.sidebar === undefined ? undefined : Boolean(spec.sidebar);
const scripts = spec.scripts === undefined ? [] : normalizeScripts(spec.scripts);
const head = pageFragment(spec.head === undefined ? "" : spec.head);
const header = pageFragment(spec.header === undefined ? "" : spec.header);
const footer = pageFragment(spec.footer === undefined ? defaultFooter() : spec.footer);
const footer = pageFragment(spec.footer === undefined ? defaultFooter(locale, lang) : spec.footer);
const search = spec.search == null || spec.search === false ? null : normalizeSearch(spec.search as any);
const interpreters = normalizeInterpreters(spec.interpreters as any);
const normalizePath = getPathNormalizer(spec);
Expand All @@ -301,6 +313,9 @@ export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot?: string, wat
base,
home,
title,
lang,
dir,
locale,
sidebar: sidebar!, // see below
pages: pages!, // see below
pager,
Expand Down Expand Up @@ -336,7 +351,7 @@ export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot?: string, wat
watchPath,
duckdb
};
if (pages === undefined) Object.defineProperty(config, "pages", {get: () => readPages(root, md)});
if (pages === undefined) Object.defineProperty(config, "pages", {get: () => readPages(root, md, locale, lang)});
if (sidebar === undefined) Object.defineProperty(config, "sidebar", {get: () => config.pages.length > 0});
configCache.set(spec, config);
return config;
Expand Down Expand Up @@ -378,11 +393,16 @@ function defaultGlobalStylesheets(): string[] {
];
}

function defaultFooter(): string {
const date = currentDate ?? new Date();
return `Built with <a href="https://observablehq.com/" target="_blank">Observable</a> on <a title="${formatIsoDate(
date
)}">${formatLocaleDate(date)}</a>.`;
function defaultFooter(projectLocale?: string, projectLang?: string): PageFragmentFunction {
return ({data}) => {
const locale = data.locale ?? projectLocale;
const lang = data.lang ?? getFrameworkLanguage(data.locale) ?? projectLang ?? getFrameworkLanguage(projectLocale);
const date = currentDate ?? new Date();
const messages = getFrameworkMessages(locale, lang);
return `${messages.footerPrefix} <a href="https://observablehq.com/" target="_blank">Observable</a> ${
messages.footerDatePreposition
} <a title="${formatIsoDate(date)}">${formatLocaleDate(date, getFrameworkLocale(locale, lang))}</a>.`;
};
}

function findDefaultRoot(defaultRoot?: string): string {
Expand Down Expand Up @@ -416,6 +436,12 @@ function normalizeBase(spec: unknown): string {
return base;
}

function normalizeDir(spec: unknown): "ltr" | "rtl" {
const dir = String(spec);
if (dir !== "ltr" && dir !== "rtl") throw new Error(`invalid dir: ${dir}`);
return dir;
}

function normalizeGlobalStylesheets(spec: unknown): string[] {
return normalizeArray(spec, String);
}
Expand Down Expand Up @@ -489,9 +515,9 @@ function normalizeInterpreters(spec: {[key: string]: unknown} = {}): {[key: stri
);
}

function normalizeToc(spec: TableOfContentsSpec | boolean = true): TableOfContents {
function normalizeToc(spec: TableOfContentsSpec | boolean = true, defaultLabel = "Contents"): TableOfContents {
const toc = typeof spec === "boolean" ? {show: spec} : (spec as TableOfContentsSpec);
const label = toc.label === undefined ? "Contents" : String(toc.label);
const label = toc.label === undefined ? defaultLabel : String(toc.label);
const show = toc.show === undefined ? true : Boolean(toc.show);
return {label, show};
}
Expand Down
13 changes: 13 additions & 0 deletions src/frontMatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {yellow} from "./tty.js";

export interface FrontMatter {
title?: string | null;
lang?: string | null;
dir?: "ltr" | "rtl" | null;
locale?: string | null;
toc?: {show?: boolean; label?: string};
style?: string | null;
theme?: string[];
Expand Down Expand Up @@ -36,6 +39,9 @@ export function normalizeFrontMatter(spec: any = {}): FrontMatter {
if (spec == null || typeof spec !== "object") return frontMatter;
const {title, sidebar, toc, index, keywords, draft, sql, head, header, footer, pager, style, theme} = spec;
if (title !== undefined) frontMatter.title = stringOrNull(title);
if (spec.lang !== undefined) frontMatter.lang = stringOrNull(spec.lang);
if (spec.dir !== undefined) frontMatter.dir = normalizeDir(spec.dir);
if (spec.locale !== undefined) frontMatter.locale = stringOrNull(spec.locale);
if (sidebar !== undefined) frontMatter.sidebar = Boolean(sidebar);
if (toc !== undefined) frontMatter.toc = normalizeToc(toc);
if (index !== undefined) frontMatter.index = Boolean(index);
Expand Down Expand Up @@ -71,3 +77,10 @@ function normalizeSql(spec: unknown): {[key: string]: string} {
for (const key in spec) sql[key] = String(spec[key]);
return sql;
}

function normalizeDir(spec: unknown): "ltr" | "rtl" | null {
if (spec == null || spec === false) return null;
const dir = String(spec);
if (dir !== "ltr" && dir !== "rtl") throw new Error(`invalid front matter dir: ${dir}`);
return dir;
}
86 changes: 86 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
const rtlLanguages = new Set([
"ar",
"fa",
"he",
"ks",
"ku",
"ps",
"sd",
"ug",
"ur",
"yi"
]);

export interface FrameworkMessages {
home: string;
contents: string;
untitled: string;
search: string;
toggleSidebar: string;
previousPage: string;
nextPage: string;
footerPrefix: string;
footerDatePreposition: string;
}

const frameworkMessages: Record<string, FrameworkMessages> = {
en: {
home: "Home",
contents: "Contents",
untitled: "Untitled",
search: "Search",
toggleSidebar: "Toggle sidebar",
previousPage: "Previous page",
nextPage: "Next page",
footerPrefix: "Built with",
footerDatePreposition: "on"
},
fr: {
home: "Accueil",
contents: "Sommaire",
untitled: "Sans titre",
search: "Rechercher",
toggleSidebar: "Basculer la barre latérale",
previousPage: "Page précédente",
nextPage: "Page suivante",
footerPrefix: "Créé avec",
footerDatePreposition: "le"
},
ar: {
home: "الرئيسية",
contents: "المحتويات",
untitled: "بدون عنوان",
search: "بحث",
toggleSidebar: "تبديل الشريط الجانبي",
previousPage: "الصفحة السابقة",
nextPage: "الصفحة التالية",
footerPrefix: "أُنشئ باستخدام",
footerDatePreposition: "في"
}
};

export function getFrameworkLanguage(locale?: string | null, lang?: string | null): string | undefined {
return languageSubtag(lang) ?? languageSubtag(locale);
}

export function getFrameworkDirection(
locale?: string | null,
lang?: string | null
): "ltr" | "rtl" | undefined {
const language = getFrameworkLanguage(locale, lang);
return language ? (rtlLanguages.has(language) ? "rtl" : "ltr") : undefined;
}

export function getFrameworkLocale(locale?: string | null, lang?: string | null): string {
return locale ?? lang ?? "en-US";
}

export function getFrameworkMessages(locale?: string | null, lang?: string | null): FrameworkMessages {
const language = getFrameworkLanguage(locale, lang);
return (language && frameworkMessages[language]) || frameworkMessages.en;
}

function languageSubtag(tag?: string | null): string | undefined {
const match = tag?.trim().match(/^([A-Za-z]{2,3})(?:[-_]|$)/);
return match?.[1].toLowerCase();
}
6 changes: 4 additions & 2 deletions src/pager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {Config, Page} from "./config.js";
import {getFrameworkMessages} from "./i18n.js";

export type PageLink =
| {prev: undefined; next: Page} // first page
Expand Down Expand Up @@ -45,7 +46,8 @@ export function findLink(path: string, config: Config): PageLink | undefined {
* adds a link at the beginning to the home page (/index).
*/
function walk(config: Config): Iterable<Iterable<Page>> {
const {pages, loaders, title = "Home"} = config;
const {pages, loaders, title} = config;
const {home} = getFrameworkMessages(config.locale, config.lang);
const pageGroups = new Map<string, Page[]>();
const visited = new Set<string>();

Expand All @@ -57,7 +59,7 @@ function walk(config: Config): Iterable<Iterable<Page>> {
pageGroup.push(page);
}

if (loaders.findPage("/index")) visit({name: title, path: "/index", pager: "main"});
if (loaders.findPage("/index")) visit({name: title ?? home, path: "/index", pager: "main"});

for (const page of pages) {
if (page.path !== null) visit(page as Page);
Expand Down
Loading