@backmatter/core 0.0.16 → 0.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -2
- package/src/config.ts +26 -2
- package/src/node/cli.ts +23 -29
- package/src/node/index.ts +9 -0
- package/src/node/registry-import.ts +335 -0
package/package.json
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backmatter/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"description": "Experimental Backmatter SDK. APIs will break. Not ready for production.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MRued/backmatter.git",
|
|
12
|
+
"directory": "packages/core"
|
|
13
|
+
},
|
|
9
14
|
"exports": {
|
|
10
15
|
".": "./src/index.ts",
|
|
11
16
|
"./node": "./src/node/index.ts",
|
|
12
17
|
"./node/create-backmatter": "./src/node/create-backmatter.ts",
|
|
18
|
+
"./node/registry-import": "./src/node/registry-import.ts",
|
|
13
19
|
"./preview": "./src/preview.ts",
|
|
14
20
|
"./generate": "./src/node/generate.ts"
|
|
15
21
|
},
|
|
@@ -21,7 +27,7 @@
|
|
|
21
27
|
"!**/test-fixtures"
|
|
22
28
|
],
|
|
23
29
|
"bin": {
|
|
24
|
-
"backmatter": "
|
|
30
|
+
"backmatter": "bin/backmatter.js"
|
|
25
31
|
},
|
|
26
32
|
"scripts": {
|
|
27
33
|
"test": "bun test"
|
package/src/config.ts
CHANGED
|
@@ -98,14 +98,38 @@ export function resolveDevBarApiUrl(
|
|
|
98
98
|
return "";
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
function isLoopbackApiUrl(url: string): boolean {
|
|
102
|
+
try {
|
|
103
|
+
const { hostname } = new URL(url);
|
|
104
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Vercel, Netlify, Cloudflare Pages, or Railway — not a laptop `next build`. */
|
|
111
|
+
function onPlatformDeploy(env: Record<string, string | undefined>): boolean {
|
|
112
|
+
return Boolean(env.VERCEL || env.NETLIFY || env.CF_PAGES || env.RAILWAY_ENVIRONMENT);
|
|
113
|
+
}
|
|
114
|
+
|
|
101
115
|
function detectApiUrl(
|
|
102
116
|
config: BackmatterConfig,
|
|
103
117
|
env: Record<string, string | undefined>,
|
|
104
118
|
workspaceId: string | undefined,
|
|
105
119
|
): string | undefined {
|
|
106
|
-
const
|
|
120
|
+
const fromConfig = config.apiUrl !== undefined;
|
|
121
|
+
const explicit = fromConfig ? config.apiUrl : env.BACKMATTER_API_URL;
|
|
107
122
|
const trimmed = explicit?.trim();
|
|
108
|
-
|
|
123
|
+
// Next loads `.env` for every environment, so a dev loopback URL is present
|
|
124
|
+
// during `next build` and fails page-data collection when that API is down.
|
|
125
|
+
// A local production build falls through to the cloud API. An explicit
|
|
126
|
+
// config.apiUrl, `next dev`, and a real platform deploy keep the loopback URL.
|
|
127
|
+
const ignoreDevLoopback =
|
|
128
|
+
!fromConfig &&
|
|
129
|
+
Boolean(trimmed && isLoopbackApiUrl(trimmed)) &&
|
|
130
|
+
env.NEXT_PHASE === "phase-production-build" &&
|
|
131
|
+
!onPlatformDeploy(env);
|
|
132
|
+
if (trimmed && !ignoreDevLoopback) return stripTrailingSlash(trimmed);
|
|
109
133
|
if (workspaceId && isDeployedHost(env)) return CLOUD_API_URL;
|
|
110
134
|
return undefined;
|
|
111
135
|
}
|
package/src/node/cli.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { resolveConfig, type BackmatterConfig, type ResolvedConfig } from "../co
|
|
|
17
17
|
import { discoverModels, discoverBlocks } from "./discover";
|
|
18
18
|
import { walkContentFiles } from "./fs-loader";
|
|
19
19
|
import { detectSdk, findConfigFile, generate, type GenerateResult } from "./generate";
|
|
20
|
+
import { ensureRegistryTsconfig, REGISTRY_SPECIFIER } from "./registry-import";
|
|
20
21
|
|
|
21
22
|
export type CliArgs = {
|
|
22
23
|
command?: string;
|
|
@@ -261,16 +262,16 @@ export default defineConfig({
|
|
|
261
262
|
`;
|
|
262
263
|
}
|
|
263
264
|
|
|
264
|
-
function renderCatchAllPage(
|
|
265
|
+
function renderCatchAllPage(): string {
|
|
265
266
|
return `// ${CATCH_ALL_DIR}/page.tsx — mounts every model at its route, including / via indexSlug.
|
|
266
267
|
import { cacheLife } from "next/cache";
|
|
267
268
|
import { createCatchAll } from "@backmatter/next/routes";
|
|
268
|
-
import { cms } from "${
|
|
269
|
-
import { components as mdxComponents } from "${
|
|
270
|
-
import { richtextComponents } from "${
|
|
271
|
-
import { blockLoaders } from "${
|
|
272
|
-
import { blockFallbacks } from "${
|
|
273
|
-
import { layouts } from "${
|
|
269
|
+
import { cms } from "${REGISTRY_SPECIFIER}";
|
|
270
|
+
import { components as mdxComponents } from "${REGISTRY_SPECIFIER}/mdx";
|
|
271
|
+
import { richtextComponents } from "${REGISTRY_SPECIFIER}/richtext";
|
|
272
|
+
import { blockLoaders } from "${REGISTRY_SPECIFIER}/blocks";
|
|
273
|
+
import { blockFallbacks } from "${REGISTRY_SPECIFIER}/block-fallbacks";
|
|
274
|
+
import { layouts } from "${REGISTRY_SPECIFIER}/layouts";
|
|
274
275
|
|
|
275
276
|
const components = { ...richtextComponents, ...mdxComponents };
|
|
276
277
|
const route = createCatchAll(cms, { components, blocks: blockLoaders, fallbacks: blockFallbacks, layouts });
|
|
@@ -303,12 +304,12 @@ export default async function Page(props: {
|
|
|
303
304
|
`;
|
|
304
305
|
}
|
|
305
306
|
|
|
306
|
-
function renderInstrumentation(
|
|
307
|
+
function renderInstrumentation(): string {
|
|
307
308
|
return `// instrumentation.ts — schema sync on Node. Next also runs register() on Edge;
|
|
308
|
-
// do not import
|
|
309
|
+
// do not import ${REGISTRY_SPECIFIER}/sync there (it pulls Node APIs).
|
|
309
310
|
export async function register() {
|
|
310
311
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
311
|
-
await import("${
|
|
312
|
+
await import("${REGISTRY_SPECIFIER}/sync");
|
|
312
313
|
}
|
|
313
314
|
}
|
|
314
315
|
`;
|
|
@@ -320,15 +321,15 @@ export function warnMissingInstrumentation(root: string): void {
|
|
|
320
321
|
const hasSrc = existsSync(path.join(root, "src", "instrumentation.ts"));
|
|
321
322
|
if (!hasRoot && !hasSrc) {
|
|
322
323
|
console.warn(
|
|
323
|
-
|
|
324
|
+
`backmatter: add instrumentation.ts that imports ${REGISTRY_SPECIFIER}/sync — block schema sync will not run without it`,
|
|
324
325
|
);
|
|
325
326
|
}
|
|
326
327
|
}
|
|
327
328
|
|
|
328
|
-
function renderPreviewRoute(
|
|
329
|
+
function renderPreviewRoute(): string {
|
|
329
330
|
return `// api/backmatter/preview/route.ts — preview session enable / disable / stream / session.
|
|
330
331
|
import { createPreviewRoute } from "@backmatter/next/preview-route";
|
|
331
|
-
import { cms } from "${
|
|
332
|
+
import { cms } from "${REGISTRY_SPECIFIER}";
|
|
332
333
|
|
|
333
334
|
export const { GET, POST } = createPreviewRoute(cms);
|
|
334
335
|
`;
|
|
@@ -347,10 +348,10 @@ export const config = {
|
|
|
347
348
|
`;
|
|
348
349
|
}
|
|
349
350
|
|
|
350
|
-
function renderRevalidateRoute(
|
|
351
|
+
function renderRevalidateRoute(): string {
|
|
351
352
|
return `// api/backmatter/revalidate/route.ts — content.committed from the BACKMATTER server.
|
|
352
353
|
import { createRevalidateRoute } from "@backmatter/next/revalidate";
|
|
353
|
-
import { cms } from "${
|
|
354
|
+
import { cms } from "${REGISTRY_SPECIFIER}";
|
|
354
355
|
|
|
355
356
|
export const POST = createRevalidateRoute(cms);
|
|
356
357
|
`;
|
|
@@ -386,21 +387,16 @@ export async function runInit(args: CliArgs): Promise<string[]> {
|
|
|
386
387
|
} else if (existsSync(requiredCatchAll)) {
|
|
387
388
|
notes.push(`kept existing ${path.join(appDir, "[...backmatter]", "page.tsx")}`);
|
|
388
389
|
} else {
|
|
389
|
-
const registry = path.relative(pageDir, path.join(root, ".backmatter")).split(path.sep).join("/");
|
|
390
390
|
await mkdir(pageDir, { recursive: true });
|
|
391
|
-
await writeFile(page, renderCatchAllPage(
|
|
391
|
+
await writeFile(page, renderCatchAllPage());
|
|
392
392
|
notes.push(`wrote ${path.join(appDir, CATCH_ALL_DIR, "page.tsx")}`);
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
const revalidateDir = path.join(root, appDir, "api", "backmatter", "revalidate");
|
|
396
396
|
const revalidateFile = path.join(revalidateDir, "route.ts");
|
|
397
397
|
if (!existsSync(revalidateFile)) {
|
|
398
|
-
const registry = path.relative(revalidateDir, path.join(root, ".backmatter")).split(path.sep).join("/");
|
|
399
398
|
await mkdir(revalidateDir, { recursive: true });
|
|
400
|
-
await writeFile(
|
|
401
|
-
revalidateFile,
|
|
402
|
-
renderRevalidateRoute(registry.startsWith(".") ? registry : `./${registry}`),
|
|
403
|
-
);
|
|
399
|
+
await writeFile(revalidateFile, renderRevalidateRoute());
|
|
404
400
|
notes.push(`wrote ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
|
|
405
401
|
} else {
|
|
406
402
|
notes.push(`kept existing ${path.join(appDir, "api/backmatter/revalidate/route.ts")}`);
|
|
@@ -409,12 +405,8 @@ export async function runInit(args: CliArgs): Promise<string[]> {
|
|
|
409
405
|
const previewDir = path.join(root, appDir, "api", "backmatter", "preview");
|
|
410
406
|
const previewFile = path.join(previewDir, "route.ts");
|
|
411
407
|
if (!existsSync(previewFile)) {
|
|
412
|
-
const registry = path.relative(previewDir, path.join(root, ".backmatter")).split(path.sep).join("/");
|
|
413
408
|
await mkdir(previewDir, { recursive: true });
|
|
414
|
-
await writeFile(
|
|
415
|
-
previewFile,
|
|
416
|
-
renderPreviewRoute(registry.startsWith(".") ? registry : `./${registry}`),
|
|
417
|
-
);
|
|
409
|
+
await writeFile(previewFile, renderPreviewRoute());
|
|
418
410
|
notes.push(`wrote ${path.join(appDir, "api/backmatter/preview/route.ts")}`);
|
|
419
411
|
} else {
|
|
420
412
|
notes.push(`kept existing ${path.join(appDir, "api/backmatter/preview/route.ts")}`);
|
|
@@ -448,10 +440,12 @@ export async function runInit(args: CliArgs): Promise<string[]> {
|
|
|
448
440
|
notes.push("kept existing instrumentation.ts");
|
|
449
441
|
} else {
|
|
450
442
|
const dest = usesSrcApp ? srcInstrumentation : rootInstrumentation;
|
|
451
|
-
|
|
452
|
-
await writeFile(dest, renderInstrumentation(syncImport));
|
|
443
|
+
await writeFile(dest, renderInstrumentation());
|
|
453
444
|
notes.push(`wrote ${path.relative(root, dest)}`);
|
|
454
445
|
}
|
|
446
|
+
|
|
447
|
+
const tsconfigNote = ensureRegistryTsconfig(root);
|
|
448
|
+
if (tsconfigNote) notes.push(tsconfigNote);
|
|
455
449
|
} else {
|
|
456
450
|
notes.push("no @backmatter/next dependency: skipped the catch-all route");
|
|
457
451
|
}
|
package/src/node/index.ts
CHANGED
|
@@ -39,6 +39,15 @@ export {
|
|
|
39
39
|
REGISTRY_FILES,
|
|
40
40
|
} from "./generate";
|
|
41
41
|
export type { GenerateOptions, GenerateResult, GeneratedFile } from "./generate";
|
|
42
|
+
export {
|
|
43
|
+
REGISTRY_SPECIFIER,
|
|
44
|
+
addRegistryPaths,
|
|
45
|
+
ensureRegistryTsconfig,
|
|
46
|
+
registryImportPaths,
|
|
47
|
+
inferTurbopackRoot,
|
|
48
|
+
registryTurbopackAlias,
|
|
49
|
+
registryWebpackAlias,
|
|
50
|
+
} from "./registry-import";
|
|
42
51
|
export {
|
|
43
52
|
contentRoots,
|
|
44
53
|
formatResults,
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// packages/core/src/node/registry-import.ts
|
|
2
|
+
// The generated client lives in `.backmatter/`. Apps import it as `backmatter`
|
|
3
|
+
// from any file. These helpers are the alias (`withBackmatter`) and the
|
|
4
|
+
// matching `tsconfig.json` `paths` (`init` and `withBackmatter`).
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { DEFAULT_OUT_DIR, REGISTRY_FILES } from "./generate";
|
|
8
|
+
|
|
9
|
+
export const REGISTRY_SPECIFIER = "backmatter";
|
|
10
|
+
|
|
11
|
+
export type RegistryAlias = {
|
|
12
|
+
/** Bare import, e.g. `backmatter` or `backmatter/mdx`. */
|
|
13
|
+
specifier: string;
|
|
14
|
+
/** File name inside the out dir, e.g. `index.ts`. */
|
|
15
|
+
file: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function registryAliases(): RegistryAlias[] {
|
|
19
|
+
return REGISTRY_FILES.map((file) => {
|
|
20
|
+
const stem = file.slice(0, -".ts".length);
|
|
21
|
+
return {
|
|
22
|
+
specifier: stem === "index" ? REGISTRY_SPECIFIER : `${REGISTRY_SPECIFIER}/${stem}`,
|
|
23
|
+
file,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const LOCK_FILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb"];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Highest workspace or lockfile at or above `appRoot`, inside the git repo.
|
|
32
|
+
* Next uses the same files to choose `turbopack.root`. In a single-package app
|
|
33
|
+
* that is the app root; in this monorepo it is the repository root.
|
|
34
|
+
*/
|
|
35
|
+
export function inferTurbopackRoot(appRoot: string): string {
|
|
36
|
+
const start = path.resolve(appRoot);
|
|
37
|
+
const gitRoot = findGitRoot(start);
|
|
38
|
+
let dir = start;
|
|
39
|
+
let root = start;
|
|
40
|
+
while (true) {
|
|
41
|
+
if (hasWorkspace(dir) || LOCK_FILES.some((name) => existsSync(path.join(dir, name)))) root = dir;
|
|
42
|
+
if (gitRoot && dir === gitRoot) break;
|
|
43
|
+
const parent = path.dirname(dir);
|
|
44
|
+
if (parent === dir) break;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
return root;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Turbopack `resolveAlias` values, relative to the Next app, with a `./` prefix.
|
|
52
|
+
* Next resolves these from the app directory even when `turbopack.root` is a
|
|
53
|
+
* parent workspace. A monorepo prefix (`./packages/docs/.backmatter/...`) makes
|
|
54
|
+
* the lookup `packages/docs/packages/docs/.backmatter`.
|
|
55
|
+
*/
|
|
56
|
+
export function registryTurbopackAlias(
|
|
57
|
+
appRoot: string,
|
|
58
|
+
outDir = DEFAULT_OUT_DIR,
|
|
59
|
+
): Record<string, string> {
|
|
60
|
+
const alias: Record<string, string> = {};
|
|
61
|
+
const base = path.resolve(appRoot);
|
|
62
|
+
for (const { specifier, file } of registryAliases()) {
|
|
63
|
+
const abs = path.resolve(appRoot, outDir, file);
|
|
64
|
+
alias[specifier] = asRelative(path.relative(base, abs).split(path.sep).join("/"));
|
|
65
|
+
}
|
|
66
|
+
return alias;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Webpack `resolve.alias`. Keys end in `$` so `backmatter` matches only that
|
|
71
|
+
* specifier, and `backmatter/mdx` stays its own entry. Values are absolute.
|
|
72
|
+
*/
|
|
73
|
+
export function registryWebpackAlias(root: string, outDir = DEFAULT_OUT_DIR): Record<string, string> {
|
|
74
|
+
const alias: Record<string, string> = {};
|
|
75
|
+
for (const { specifier, file } of registryAliases()) {
|
|
76
|
+
alias[`${specifier}$`] = path.resolve(root, outDir, file);
|
|
77
|
+
}
|
|
78
|
+
return alias;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `tsconfig` `paths` for the registry. Values are relative to `baseUrl` when
|
|
83
|
+
* set, otherwise to the tsconfig directory (the project root).
|
|
84
|
+
*/
|
|
85
|
+
export function registryImportPaths(outDir = DEFAULT_OUT_DIR, baseUrl?: string): Record<string, string[]> {
|
|
86
|
+
const base = !baseUrl || baseUrl === "." ? "." : baseUrl;
|
|
87
|
+
const from = path.resolve("/registry-root", base);
|
|
88
|
+
const to = path.resolve("/registry-root", outDir);
|
|
89
|
+
const folder = asRelative(path.relative(from, to).split(path.sep).join("/"));
|
|
90
|
+
const paths: Record<string, string[]> = {};
|
|
91
|
+
for (const { specifier, file } of registryAliases()) {
|
|
92
|
+
paths[specifier] = [`${folder}/${file}`];
|
|
93
|
+
}
|
|
94
|
+
return paths;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Insert missing `backmatter` paths. Returns the source unchanged when they are already present. */
|
|
98
|
+
export function addRegistryPaths(source: string, outDir = DEFAULT_OUT_DIR): string {
|
|
99
|
+
let parsed: unknown;
|
|
100
|
+
try {
|
|
101
|
+
parsed = looseParse(source);
|
|
102
|
+
} catch {
|
|
103
|
+
return source;
|
|
104
|
+
}
|
|
105
|
+
if (!isRecord(parsed)) return source;
|
|
106
|
+
const options = parsed.compilerOptions;
|
|
107
|
+
if (!isRecord(options)) return source;
|
|
108
|
+
const baseUrl = typeof options.baseUrl === "string" ? options.baseUrl : undefined;
|
|
109
|
+
const mappings = registryImportPaths(outDir, baseUrl);
|
|
110
|
+
const existing = isRecord(options.paths) ? options.paths : undefined;
|
|
111
|
+
if (options.paths !== undefined && !existing) return source;
|
|
112
|
+
const missing = Object.fromEntries(
|
|
113
|
+
Object.entries(mappings).filter(([key]) => !existing || existing[key] === undefined),
|
|
114
|
+
);
|
|
115
|
+
if (Object.keys(missing).length === 0) return source;
|
|
116
|
+
|
|
117
|
+
const compiler = findKeyBrace(source, "compilerOptions", 0, source.length);
|
|
118
|
+
if (!compiler) return source;
|
|
119
|
+
const compilerEnd = matchBrace(source, compiler.brace);
|
|
120
|
+
if (compilerEnd < 0) return source;
|
|
121
|
+
|
|
122
|
+
const nl = source.includes("\r\n") ? "\r\n" : "\n";
|
|
123
|
+
if (existing) {
|
|
124
|
+
const pathsBrace = findKeyBrace(source, "paths", compiler.brace + 1, compilerEnd);
|
|
125
|
+
if (!pathsBrace || pathsBrace.brace > compilerEnd) return source;
|
|
126
|
+
const indent = innerIndent(source, pathsBrace.brace);
|
|
127
|
+
const entries = formatEntries(missing, indent, nl);
|
|
128
|
+
return insertEntries(source, pathsBrace.brace, entries, indent, nl);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const indent = innerIndent(source, compiler.brace);
|
|
132
|
+
const child = `${indent} `;
|
|
133
|
+
const entries = formatEntries(missing, child, nl);
|
|
134
|
+
const block = `${indent}"paths": {${nl}${entries},${nl}${indent}}`;
|
|
135
|
+
return `${source.slice(0, compiler.brace + 1)}${nl}${block},${source.slice(compiler.brace + 1)}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Write `paths` into tsconfig.json or jsconfig.json when the registry keys are missing. */
|
|
139
|
+
export function ensureRegistryTsconfig(root: string, outDir = DEFAULT_OUT_DIR): string | null {
|
|
140
|
+
const tsconfig = path.join(root, "tsconfig.json");
|
|
141
|
+
const jsconfig = path.join(root, "jsconfig.json");
|
|
142
|
+
const file = existsSync(tsconfig) ? tsconfig : existsSync(jsconfig) ? jsconfig : null;
|
|
143
|
+
if (!file) return null;
|
|
144
|
+
const before = readFileSync(file, "utf-8");
|
|
145
|
+
const after = addRegistryPaths(before, outDir);
|
|
146
|
+
if (after === before) return null;
|
|
147
|
+
writeFileSync(file, after);
|
|
148
|
+
return `updated ${path.basename(file)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hasWorkspace(dir: string): boolean {
|
|
152
|
+
const pkgPath = path.join(dir, "package.json");
|
|
153
|
+
if (!existsSync(pkgPath)) return false;
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(readFileSync(pkgPath, "utf-8")).workspaces != null;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function findGitRoot(start: string): string | null {
|
|
162
|
+
let dir = start;
|
|
163
|
+
while (true) {
|
|
164
|
+
if (existsSync(path.join(dir, ".git"))) return dir;
|
|
165
|
+
const parent = path.dirname(dir);
|
|
166
|
+
if (parent === dir) return null;
|
|
167
|
+
dir = parent;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `.backmatter/...` is relative, and still needs a `./` so bundlers do not treat it as a package. */
|
|
172
|
+
function asRelative(rel: string): string {
|
|
173
|
+
if (rel.startsWith("./") || rel.startsWith("../")) return rel;
|
|
174
|
+
return `./${rel}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function insertEntries(source: string, open: number, entries: string, indent: string, nl: string): string {
|
|
178
|
+
let rest = source.slice(open + 1);
|
|
179
|
+
if (!/^\r?\n/.test(rest)) rest = `${nl}${indent}${rest.trimStart()}`;
|
|
180
|
+
return `${source.slice(0, open + 1)}${nl}${entries},${rest}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function formatEntries(mappings: Record<string, string[]>, indent: string, nl: string): string {
|
|
184
|
+
return Object.entries(mappings)
|
|
185
|
+
.map(([key, value]) => `${indent}"${key}": [${value.map((entry) => JSON.stringify(entry)).join(", ")}]`)
|
|
186
|
+
.join(`,${nl}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function innerIndent(source: string, open: number): string {
|
|
190
|
+
const rest = source.slice(open + 1);
|
|
191
|
+
const nl = rest.search(/\r?\n/);
|
|
192
|
+
if (nl !== -1) {
|
|
193
|
+
const after = rest.slice(nl).replace(/^\r?\n/, "");
|
|
194
|
+
const indent = after.match(/^[ \t]*/)?.[0] ?? "";
|
|
195
|
+
const next = after[indent.length];
|
|
196
|
+
if (indent.length > 0 && next && next !== "}") return indent;
|
|
197
|
+
}
|
|
198
|
+
const lineStart = source.lastIndexOf("\n", open) + 1;
|
|
199
|
+
const base = source.slice(lineStart, open).match(/^[ \t]*/)?.[0] ?? "";
|
|
200
|
+
return `${base} `;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function looseParse(source: string): unknown {
|
|
204
|
+
const stripped = stripJsonComments(source).replace(/,\s*([}\]])/g, "$1");
|
|
205
|
+
return JSON.parse(stripped);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function stripJsonComments(input: string): string {
|
|
209
|
+
let out = "";
|
|
210
|
+
let i = 0;
|
|
211
|
+
let inString = false;
|
|
212
|
+
let escaped = false;
|
|
213
|
+
while (i < input.length) {
|
|
214
|
+
const c = input[i]!;
|
|
215
|
+
const next = input[i + 1];
|
|
216
|
+
if (inString) {
|
|
217
|
+
out += c;
|
|
218
|
+
if (escaped) escaped = false;
|
|
219
|
+
else if (c === "\\") escaped = true;
|
|
220
|
+
else if (c === '"') inString = false;
|
|
221
|
+
i++;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (c === '"') {
|
|
225
|
+
inString = true;
|
|
226
|
+
out += c;
|
|
227
|
+
i++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (c === "/" && next === "/") {
|
|
231
|
+
i += 2;
|
|
232
|
+
while (i < input.length && input[i] !== "\n") i++;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (c === "/" && next === "*") {
|
|
236
|
+
i += 2;
|
|
237
|
+
while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
|
|
238
|
+
i += 2;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
out += c;
|
|
242
|
+
i++;
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
248
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function findKeyBrace(source: string, key: string, start: number, end: number): { brace: number } | null {
|
|
252
|
+
const needle = `"${key}"`;
|
|
253
|
+
let i = start;
|
|
254
|
+
let inString = false;
|
|
255
|
+
let escaped = false;
|
|
256
|
+
while (i < end) {
|
|
257
|
+
const c = source[i]!;
|
|
258
|
+
const next = source[i + 1];
|
|
259
|
+
if (inString) {
|
|
260
|
+
if (escaped) escaped = false;
|
|
261
|
+
else if (c === "\\") escaped = true;
|
|
262
|
+
else if (c === '"') inString = false;
|
|
263
|
+
i++;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (c === '"') {
|
|
267
|
+
if (source.startsWith(needle, i)) {
|
|
268
|
+
let j = i + needle.length;
|
|
269
|
+
while (j < end && /\s/.test(source[j]!)) j++;
|
|
270
|
+
if (source[j] === ":") {
|
|
271
|
+
j++;
|
|
272
|
+
while (j < end && /\s/.test(source[j]!)) j++;
|
|
273
|
+
if (source[j] === "{") return { brace: j };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
inString = true;
|
|
277
|
+
i++;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (c === "/" && next === "/") {
|
|
281
|
+
i += 2;
|
|
282
|
+
while (i < end && source[i] !== "\n") i++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (c === "/" && next === "*") {
|
|
286
|
+
i += 2;
|
|
287
|
+
while (i < end && !(source[i] === "*" && source[i + 1] === "/")) i++;
|
|
288
|
+
i += 2;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
i++;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function matchBrace(source: string, open: number): number {
|
|
297
|
+
let depth = 0;
|
|
298
|
+
let i = open;
|
|
299
|
+
let inString = false;
|
|
300
|
+
let escaped = false;
|
|
301
|
+
while (i < source.length) {
|
|
302
|
+
const c = source[i]!;
|
|
303
|
+
const next = source[i + 1];
|
|
304
|
+
if (inString) {
|
|
305
|
+
if (escaped) escaped = false;
|
|
306
|
+
else if (c === "\\") escaped = true;
|
|
307
|
+
else if (c === '"') inString = false;
|
|
308
|
+
i++;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (c === '"') {
|
|
312
|
+
inString = true;
|
|
313
|
+
i++;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (c === "/" && next === "/") {
|
|
317
|
+
i += 2;
|
|
318
|
+
while (i < source.length && source[i] !== "\n") i++;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (c === "/" && next === "*") {
|
|
322
|
+
i += 2;
|
|
323
|
+
while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) i++;
|
|
324
|
+
i += 2;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (c === "{") depth++;
|
|
328
|
+
else if (c === "}") {
|
|
329
|
+
depth--;
|
|
330
|
+
if (depth === 0) return i;
|
|
331
|
+
}
|
|
332
|
+
i++;
|
|
333
|
+
}
|
|
334
|
+
return -1;
|
|
335
|
+
}
|