Skip to content

Commit c3cb78b

Browse files
authored
more config type safety; drop config.deploy; more markdown-it options (#1263)
* more config type safety; drop config.deploy * quotes * more docs * typographer: false
1 parent 7e7d7b3 commit c3cb78b

File tree

5 files changed

+139
-112
lines changed

5 files changed

+139
-112
lines changed

docs/config.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,15 @@ export default {
262262
markdownIt: (md) => md.use(MarkdownItFootnote)
263263
};
264264
```
265+
266+
## typographer <a href="https://github.com/observablehq/framework/pull/1263" class="observablehq-version-badge" data-version="prerelease" title="Added in #1263"></a>
267+
268+
If true, enables simple typographic replacements in Markdown, such as replacing `(c)` with `©` and converting straight quotes to curly quotes. See also the [quotes](#quotes) option, which should be set for non-English languages if the **typographer** option is enabled. For the full list of replacements, see [markdown-it](https://github.com/markdown-it/markdown-it/blob/master/lib/rules_core/replacements.mjs). Defaults to false.
269+
270+
## quotes <a href="https://github.com/observablehq/framework/pull/1263" class="observablehq-version-badge" data-version="prerelease" title="Added in #1263"></a>
271+
272+
The set of replacements for straight double and single quotes used when the [**typographer** option](#typographer) is enabled. Defaults to `["“", "”", "‘", "’"]` which is suitable for English. For example, you can use `["«", "»", "„", "“"]` for Russian, `["„", "“", "‚", "‘"]` for German, and `["«\xa0", "\xa0»", "‹\xa0", "\xa0›"]` for French.
273+
274+
## linkify <a href="https://github.com/observablehq/framework/pull/1263" class="observablehq-version-badge" data-version="prerelease" title="Added in #1263"></a>
275+
276+
If true (the default), automatically convert URL-like text to links in Markdown.

src/config.ts

Lines changed: 117 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,59 @@ export interface Config {
5454
footer: string | null; // defaults to “Built with Observable on [date].”
5555
toc: TableOfContents;
5656
style: null | Style; // defaults to {theme: ["light", "dark"]}
57-
deploy: null | {workspace: string; project: string};
5857
search: boolean; // default to false
5958
md: MarkdownIt;
6059
loaders: LoaderResolver;
6160
watchPath?: string;
6261
}
6362

63+
interface ConfigSpec {
64+
root?: unknown;
65+
output?: unknown;
66+
base?: unknown;
67+
sidebar?: unknown;
68+
style?: unknown;
69+
theme?: unknown;
70+
search?: unknown;
71+
scripts?: unknown;
72+
head?: unknown;
73+
header?: unknown;
74+
footer?: unknown;
75+
interpreters?: unknown;
76+
title?: unknown;
77+
pages?: unknown;
78+
pager?: unknown;
79+
toc?: unknown;
80+
linkify?: unknown;
81+
typographer?: unknown;
82+
quotes?: unknown;
83+
cleanUrls?: unknown;
84+
markdownIt?: unknown;
85+
}
86+
87+
interface ScriptSpec {
88+
src?: unknown;
89+
async?: unknown;
90+
type?: unknown;
91+
}
92+
93+
interface SectionSpec {
94+
name?: unknown;
95+
open?: unknown;
96+
collapsible?: unknown;
97+
pages?: unknown;
98+
}
99+
100+
interface PageSpec {
101+
name?: unknown;
102+
path?: unknown;
103+
}
104+
105+
interface TableOfContentsSpec {
106+
label?: unknown;
107+
show?: unknown;
108+
}
109+
64110
/**
65111
* Returns the absolute path to the specified config file, which is specified as a
66112
* path relative to the given root (if any). If you want to import this, you should
@@ -72,7 +118,7 @@ function resolveConfig(configPath: string, root = "."): string {
72118

73119
// By using the modification time of the config, we ensure that we pick up any
74120
// changes to the config on reload.
75-
async function importConfig(path: string): Promise<any> {
121+
async function importConfig(path: string): Promise<ConfigSpec> {
76122
const {mtimeMs} = await stat(path);
77123
return (await import(`${pathToFileURL(path).href}?${mtimeMs}`)).default;
78124
}
@@ -116,73 +162,63 @@ function readPages(root: string, md: MarkdownIt): Page[] {
116162
return pages;
117163
}
118164

119-
let currentDate = new Date();
165+
let currentDate: Date | null = null;
120166

121-
export function setCurrentDate(date = new Date()): void {
167+
/** For testing only! */
168+
export function setCurrentDate(date: Date | null): void {
122169
currentDate = date;
123170
}
124171

125172
// The config is used as a cache key for other operations; for example the pages
126173
// are used as a cache key for search indexing and the previous & next links in
127174
// the footer. When given the same spec (because import returned the same
128175
// module), we want to return the same Config instance.
129-
const configCache = new WeakMap<any, Config>();
176+
const configCache = new WeakMap<ConfigSpec, Config>();
130177

131-
export function normalizeConfig(spec: any = {}, defaultRoot = "docs", watchPath?: string): Config {
178+
export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot = "docs", watchPath?: string): Config {
132179
const cachedConfig = configCache.get(spec);
133180
if (cachedConfig) return cachedConfig;
134-
let {
135-
root = defaultRoot,
136-
output = "dist",
137-
base = "/",
138-
sidebar,
139-
style,
140-
theme = "default",
141-
search,
142-
deploy,
143-
scripts = [],
144-
head = "",
145-
header = "",
146-
footer = `Built with <a href="https://observablehq.com/" target="_blank">Observable</a> on <a title="${formatIsoDate(
147-
currentDate
148-
)}">${formatLocaleDate(currentDate)}</a>.`,
149-
interpreters
150-
} = spec;
151-
root = String(root);
152-
output = String(output);
153-
base = normalizeBase(base);
154-
if (style === null) style = null;
155-
else if (style !== undefined) style = {path: String(style)};
156-
else style = {theme: (theme = normalizeTheme(theme))};
157-
const md = createMarkdownIt(spec);
158-
let {title, pages, pager = true, toc = true} = spec;
159-
if (title !== undefined) title = String(title);
160-
if (pages !== undefined) pages = normalizePages(pages);
161-
if (sidebar !== undefined) sidebar = Boolean(sidebar);
162-
pager = Boolean(pager);
163-
scripts = Array.from(scripts, normalizeScript);
164-
head = stringOrNull(head);
165-
header = stringOrNull(header);
166-
footer = stringOrNull(footer);
167-
toc = normalizeToc(toc);
168-
deploy = deploy ? {workspace: String(deploy.workspace).replace(/^@+/, ""), project: String(deploy.project)} : null;
169-
search = Boolean(search);
170-
interpreters = normalizeInterpreters(interpreters);
171-
const config = {
181+
const root = spec.root === undefined ? defaultRoot : String(spec.root);
182+
const output = spec.output === undefined ? "dist" : String(spec.output);
183+
const base = spec.base === undefined ? "/" : normalizeBase(spec.base);
184+
const style =
185+
spec.style === null
186+
? null
187+
: spec.style !== undefined
188+
? {path: String(spec.style)}
189+
: {theme: normalizeTheme(spec.theme === undefined ? "default" : spec.theme)};
190+
const md = createMarkdownIt({
191+
linkify: spec.linkify === undefined ? undefined : Boolean(spec.linkify),
192+
typographer: spec.typographer === undefined ? undefined : Boolean(spec.typographer),
193+
quotes: spec.quotes === undefined ? undefined : (spec.quotes as any),
194+
cleanUrls: spec.cleanUrls === undefined ? undefined : Boolean(spec.cleanUrls),
195+
markdownIt: spec.markdownIt as any
196+
});
197+
const title = spec.title === undefined ? undefined : String(spec.title);
198+
const pages = spec.pages === undefined ? undefined : normalizePages(spec.pages);
199+
const pager = spec.pager === undefined ? true : Boolean(spec.pager);
200+
const toc = normalizeToc(spec.toc as any);
201+
const sidebar = spec.sidebar === undefined ? undefined : Boolean(spec.sidebar);
202+
const scripts = spec.scripts === undefined ? [] : Array.from(spec.scripts as any, normalizeScript);
203+
const head = spec.head === undefined ? "" : stringOrNull(spec.head);
204+
const header = spec.header === undefined ? "" : stringOrNull(spec.header);
205+
const footer = spec.footer === undefined ? defaultFooter() : stringOrNull(spec.footer);
206+
const search = Boolean(spec.search);
207+
const interpreters = normalizeInterpreters(spec.interpreters as any);
208+
const config: Config = {
172209
root,
173210
output,
174211
base,
175212
title,
176-
sidebar,
177-
pages,
213+
sidebar: sidebar!, // see below
214+
pages: pages!, // see below
178215
pager,
179216
scripts,
180217
head,
181218
header,
182219
footer,
183220
toc,
184221
style,
185-
deploy,
186222
search,
187223
md,
188224
loaders: new LoaderResolver({root, interpreters}),
@@ -194,45 +230,49 @@ export function normalizeConfig(spec: any = {}, defaultRoot = "docs", watchPath?
194230
return config;
195231
}
196232

197-
function normalizeBase(base: any): string {
198-
base = String(base);
233+
function defaultFooter(): string {
234+
const date = currentDate ?? new Date();
235+
return `Built with <a href="https://observablehq.com/" target="_blank">Observable</a> on <a title="${formatIsoDate(
236+
date
237+
)}">${formatLocaleDate(date)}</a>.`;
238+
}
239+
240+
function normalizeBase(spec: unknown): string {
241+
let base = String(spec);
199242
if (!base.startsWith("/")) throw new Error(`base must start with slash: ${base}`);
200243
if (!base.endsWith("/")) base += "/";
201244
return base;
202245
}
203246

204-
export function normalizeTheme(spec: any): string[] {
205-
return resolveTheme(typeof spec === "string" ? [spec] : spec === null ? [] : Array.from(spec, String));
247+
export function normalizeTheme(spec: unknown): string[] {
248+
return resolveTheme(typeof spec === "string" ? [spec] : spec === null ? [] : Array.from(spec as any, String));
206249
}
207250

208-
function normalizeScript(spec: any): Script {
209-
if (typeof spec === "string") spec = {src: spec};
210-
let {src, async = false, type} = spec;
211-
src = String(src);
212-
async = Boolean(async);
213-
type = type == null ? null : String(type);
251+
function normalizeScript(spec: unknown): Script {
252+
const script = typeof spec === "string" ? {src: spec} : (spec as ScriptSpec);
253+
const src = String(script.src);
254+
const async = script.async === undefined ? false : Boolean(script.async);
255+
const type = script.type == null ? null : String(script.type);
214256
return {src, async, type};
215257
}
216258

217-
function normalizePages(spec: any): Config["pages"] {
218-
return Array.from(spec, (spec: any) =>
219-
"pages" in spec ? normalizeSection(spec, (spec: any) => normalizePage(spec)) : normalizePage(spec)
259+
function normalizePages(spec: unknown): Config["pages"] {
260+
return Array.from(spec as any, (spec: SectionSpec | PageSpec) =>
261+
"pages" in spec ? normalizeSection(spec, (spec: PageSpec) => normalizePage(spec)) : normalizePage(spec)
220262
);
221263
}
222264

223-
function normalizeSection<T>(spec: any, normalizePage: (spec: any) => T): Section<T> {
224-
let {name, open, collapsible = open === undefined ? false : true, pages} = spec;
225-
name = String(name);
226-
collapsible = Boolean(collapsible);
227-
open = collapsible ? Boolean(open) : true;
228-
pages = Array.from(pages, normalizePage);
265+
function normalizeSection<T>(spec: SectionSpec, normalizePage: (spec: PageSpec) => T): Section<T> {
266+
const name = String(spec.name);
267+
const collapsible = spec.collapsible === undefined ? spec.open !== undefined : Boolean(spec.collapsible);
268+
const open = collapsible ? Boolean(spec.open) : true;
269+
const pages = Array.from(spec.pages as any, normalizePage);
229270
return {name, collapsible, open, pages};
230271
}
231272

232-
function normalizePage(spec: any): Page {
233-
let {name, path} = spec;
234-
name = String(name);
235-
path = String(path);
273+
function normalizePage(spec: PageSpec): Page {
274+
const name = String(spec.name);
275+
let path = String(spec.path);
236276
if (isAssetPath(path)) {
237277
const u = parseRelativeUrl(join("/", path)); // add leading slash
238278
let {pathname} = u;
@@ -243,19 +283,18 @@ function normalizePage(spec: any): Page {
243283
return {name, path};
244284
}
245285

246-
function normalizeInterpreters(spec: any): Record<string, string[] | null> {
286+
function normalizeInterpreters(spec: {[key: string]: unknown} = {}): {[key: string]: string[] | null} {
247287
return Object.fromEntries(
248-
Object.entries<any>(spec ?? {}).map(([key, value]): [string, string[] | null] => {
249-
return [String(key), value == null ? null : Array.from(value, String)];
288+
Object.entries(spec).map(([key, value]): [string, string[] | null] => {
289+
return [String(key), value == null ? null : Array.from(value as any, String)];
250290
})
251291
);
252292
}
253293

254-
function normalizeToc(spec: any): TableOfContents {
255-
if (typeof spec === "boolean") spec = {show: spec};
256-
let {label = "Contents", show = true} = spec;
257-
label = String(label);
258-
show = Boolean(show);
294+
function normalizeToc(spec: TableOfContentsSpec | boolean = true): TableOfContents {
295+
const toc = typeof spec === "boolean" ? {show: spec} : (spec as TableOfContentsSpec);
296+
const label = toc.label === undefined ? "Contents" : String(toc.label);
297+
const show = toc.show === undefined ? true : Boolean(toc.show);
259298
return {label, show};
260299
}
261300

src/deploy.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,6 @@ export async function deploy(
9898
);
9999
}
100100

101-
const legacyConfig = config as unknown as {deploy: null | {project: string; workspace: string}};
102-
if (legacyConfig.deploy && deployConfig.projectId) {
103-
if (!deployConfig.projectSlug || !deployConfig.workspaceLogin) {
104-
clack.log.info("Copying deploy information from the config file to deploy.json.");
105-
deployConfig.projectSlug = legacyConfig.deploy.project;
106-
deployConfig.workspaceLogin = legacyConfig.deploy.workspace.replace(/^@/, "");
107-
effects.setDeployConfig(config.root, deployConfig);
108-
}
109-
clack.log.info("The `deploy` section of your config file is obsolete and can be deleted.");
110-
}
111-
112101
let currentUser: GetCurrentUserResponse | null = null;
113102
let authError: null | "unauthenticated" | "forbidden" = null;
114103
try {
@@ -351,7 +340,7 @@ export async function deploy(
351340
});
352341

353342
// Create the new deploy on the server
354-
let deployId;
343+
let deployId: string;
355344
try {
356345
deployId = await apiClient.postDeploy({projectId: deployTarget.project.id, message});
357346
} catch (error) {
@@ -375,7 +364,7 @@ export async function deploy(
375364
uploadSpinner.start("");
376365

377366
const rateLimiter = new RateLimiter(5);
378-
const waitForRateLimit = buildFilePaths.length <= 300 ? () => {} : () => rateLimiter.wait();
367+
const waitForRateLimit = buildFilePaths.length <= 300 ? async () => {} : () => rateLimiter.wait();
379368

380369
await runAllWithConcurrencyLimit(
381370
buildFilePaths,

src/markdown.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,13 +308,19 @@ export interface ParseOptions {
308308

309309
export function createMarkdownIt({
310310
markdownIt,
311+
linkify = true,
312+
quotes = "“”‘’",
313+
typographer = false,
311314
cleanUrls = true
312315
}: {
313316
markdownIt?: (md: MarkdownIt) => MarkdownIt;
317+
linkify?: boolean;
318+
quotes?: string | string[];
319+
typographer?: boolean;
314320
cleanUrls?: boolean;
315321
} = {}): MarkdownIt {
316-
const md = MarkdownIt({html: true, linkify: true});
317-
md.linkify.set({fuzzyLink: false, fuzzyEmail: false});
322+
const md = MarkdownIt({html: true, linkify, typographer, quotes});
323+
if (linkify) md.linkify.set({fuzzyLink: false, fuzzyEmail: false});
318324
md.use(MarkdownItAnchor, {permalink: MarkdownItAnchor.permalink.headerLink({class: "observablehq-header-anchor"})});
319325
md.inline.ruler.push("placeholder", transformPlaceholderInline);
320326
md.core.ruler.before("linkify", "placeholder", transformPlaceholderCore);

test/config-test.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,6 @@ describe("readConfig(undefined, root)", () => {
3535
header: "",
3636
footer:
3737
'Built with <a href="https://observablehq.com/" target="_blank">Observable</a> on <a title="2024-01-10T16:00:00">Jan 10, 2024</a>.',
38-
deploy: {
39-
workspace: "acme",
40-
project: "bi"
41-
},
4238
search: false,
4339
watchPath: resolve("test/input/build/config/observablehq.config.js")
4440
});
@@ -62,7 +58,6 @@ describe("readConfig(undefined, root)", () => {
6258
header: "",
6359
footer:
6460
'Built with <a href="https://observablehq.com/" target="_blank">Observable</a> on <a title="2024-01-10T16:00:00">Jan 10, 2024</a>.',
65-
deploy: null,
6661
search: false,
6762
watchPath: undefined
6863
});
@@ -164,20 +159,6 @@ describe("normalizeConfig(spec, root)", () => {
164159
it("populates default pager", () => {
165160
assert.strictEqual(config({pages: []}, root).pager, true);
166161
});
167-
describe("deploy", () => {
168-
it("considers deploy optional", () => {
169-
assert.strictEqual(config({pages: []}, root).deploy, null);
170-
});
171-
it("coerces workspace", () => {
172-
assert.strictEqual(config({pages: [], deploy: {workspace: 538, project: "bi"}}, root).deploy?.workspace, "538");
173-
});
174-
it("strips leading @ from workspace", () => {
175-
assert.strictEqual(config({pages: [], deploy: {workspace: "@acme"}}, root).deploy?.workspace, "acme");
176-
});
177-
it("coerces project", () => {
178-
assert.strictEqual(config({pages: [], deploy: {workspace: "adams", project: 42}}, root).deploy?.project, "42");
179-
});
180-
});
181162
});
182163

183164
describe("mergeToc(spec, toc)", () => {

0 commit comments

Comments
 (0)