@backmatter/core 0.0.27 → 0.0.28
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 +1 -1
- package/src/api-loader.ts +1 -5
- package/src/config.ts +1 -15
- package/src/content-committed.ts +4 -8
- package/src/doc-ref.ts +2 -8
- package/src/doc.ts +0 -2
- package/src/fields.ts +0 -3
- package/src/index.ts +1 -5
- package/src/model.ts +12 -7
- package/src/node/cli.ts +2 -4
- package/src/node/create-backmatter.ts +5 -12
- package/src/node/fs-loader.ts +3 -6
- package/src/node/generate.ts +8 -8
- package/src/page-nav.ts +3 -7
- package/src/path.ts +17 -47
- package/src/preview-channel.ts +1 -4
- package/src/preview.ts +3 -8
- package/src/query.ts +22 -47
- package/src/routes.ts +18 -29
- package/src/source.ts +4 -4
- package/src/sync-schema.ts +5 -1
package/package.json
CHANGED
package/src/api-loader.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
// packages/core/src/api-loader.ts
|
|
2
2
|
import type { BackmatterDoc, BackmatterDocSummary } from "./doc";
|
|
3
|
-
import { encodeDocLocale } from "./path";
|
|
4
3
|
|
|
5
4
|
export type PublicDocsQuery = {
|
|
6
5
|
branch?: string;
|
|
7
6
|
type?: string;
|
|
8
|
-
locale?: string;
|
|
9
7
|
/** When set, Next Data Cache uses these tags instead of `cache: "no-store"`. */
|
|
10
8
|
tags?: string[];
|
|
11
9
|
};
|
|
@@ -31,7 +29,6 @@ function publicDocsUrl(
|
|
|
31
29
|
);
|
|
32
30
|
if (query.branch) url.searchParams.set("branch", query.branch);
|
|
33
31
|
if (query.type) url.searchParams.set("type", query.type);
|
|
34
|
-
if (query.locale) url.searchParams.set("locale", query.locale);
|
|
35
32
|
return url.toString();
|
|
36
33
|
}
|
|
37
34
|
|
|
@@ -55,13 +52,12 @@ export async function getDocFromApi(
|
|
|
55
52
|
apiUrl: string,
|
|
56
53
|
workspaceId: string,
|
|
57
54
|
contentToken: string | undefined,
|
|
58
|
-
locale: string,
|
|
59
55
|
type: string,
|
|
60
56
|
slug: string,
|
|
61
57
|
query: Pick<PublicDocsQuery, "branch" | "tags"> = {},
|
|
62
58
|
): Promise<BackmatterDoc | null> {
|
|
63
59
|
const res = await fetch(
|
|
64
|
-
publicDocsUrl(apiUrl, workspaceId, `/${
|
|
60
|
+
publicDocsUrl(apiUrl, workspaceId, `/${type}/${slug}`, query),
|
|
65
61
|
fetchInit(contentToken, query.tags),
|
|
66
62
|
);
|
|
67
63
|
if (res.status === 404) return null;
|
package/src/config.ts
CHANGED
|
@@ -48,16 +48,11 @@ export type BackmatterConfig = {
|
|
|
48
48
|
siteUrl?: string;
|
|
49
49
|
/** Site root for filesystem mode. Default: process.cwd(). */
|
|
50
50
|
siteRoot?: string;
|
|
51
|
-
/** Locales the catch-all route accepts in `{locale}`. Unset → any segment. */
|
|
52
|
-
locales?: readonly string[];
|
|
53
|
-
/** Locale used by routes without a `{locale}` placeholder. Default: "en". */
|
|
54
|
-
defaultLocale?: string;
|
|
55
51
|
dirs?: BackmatterDirs;
|
|
56
52
|
devBar?: DevBarHostConfig;
|
|
57
53
|
};
|
|
58
54
|
|
|
59
|
-
export type ResolvedConfig = Omit<BackmatterConfig, "dirs"
|
|
60
|
-
defaultLocale: string;
|
|
55
|
+
export type ResolvedConfig = Omit<BackmatterConfig, "dirs"> & {
|
|
61
56
|
dirs: Required<BackmatterDirs>;
|
|
62
57
|
};
|
|
63
58
|
|
|
@@ -70,7 +65,6 @@ export const DEFAULT_DIRS: Required<BackmatterDirs> = {
|
|
|
70
65
|
objects: "objects",
|
|
71
66
|
sources: "sources",
|
|
72
67
|
};
|
|
73
|
-
export const DEFAULT_LOCALE = "en";
|
|
74
68
|
export const CLOUD_API_URL = "https://api.backmatter.app";
|
|
75
69
|
export const CLOUD_DASHBOARD_URL = "https://dash.backmatter.app";
|
|
76
70
|
export const LOCAL_DASHBOARD_URL = "http://localhost:5173";
|
|
@@ -189,12 +183,6 @@ export function detectSiteUrl(
|
|
|
189
183
|
|
|
190
184
|
/** Identity helper that gives `backmatter.config.ts` a typed default export. */
|
|
191
185
|
export function defineConfig(config: BackmatterConfig): BackmatterConfig {
|
|
192
|
-
if (config.defaultLocale !== undefined && !config.defaultLocale) {
|
|
193
|
-
throw new Error("config.defaultLocale must be a non-empty string");
|
|
194
|
-
}
|
|
195
|
-
if (config.locales && config.defaultLocale && !config.locales.includes(config.defaultLocale)) {
|
|
196
|
-
throw new Error(`config.defaultLocale "${config.defaultLocale}" is not in config.locales`);
|
|
197
|
-
}
|
|
198
186
|
return config;
|
|
199
187
|
}
|
|
200
188
|
|
|
@@ -218,8 +206,6 @@ export function resolveConfig(
|
|
|
218
206
|
branch: config.branch?.trim() || detectGitBranch(env) || undefined,
|
|
219
207
|
siteUrl: detectSiteUrl(config, env),
|
|
220
208
|
siteRoot: config.siteRoot,
|
|
221
|
-
locales: config.locales,
|
|
222
|
-
defaultLocale: config.defaultLocale ?? DEFAULT_LOCALE,
|
|
223
209
|
devBar: config.devBar,
|
|
224
210
|
dirs: {
|
|
225
211
|
models: config.dirs?.models ?? DEFAULT_DIRS.models,
|
package/src/content-committed.ts
CHANGED
|
@@ -10,7 +10,6 @@ export const BACKMATTER_REVALIDATE_PATH = "/api/backmatter/revalidate";
|
|
|
10
10
|
export const BACKMATTER_CACHE_TAG = "backmatter";
|
|
11
11
|
|
|
12
12
|
export type ContentCommittedDoc = {
|
|
13
|
-
locale: string;
|
|
14
13
|
type: string;
|
|
15
14
|
slug: string;
|
|
16
15
|
};
|
|
@@ -29,11 +28,8 @@ export function revalidateUrl(siteUrl: string): string {
|
|
|
29
28
|
return `${siteUrl.replace(/\/$/, "")}${BACKMATTER_REVALIDATE_PATH}`;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
export function contentCacheTags(
|
|
33
|
-
|
|
34
|
-
? `backmatter:${locale}:${type}:${slug}`
|
|
35
|
-
: `backmatter:${type}:${slug}`;
|
|
36
|
-
return [BACKMATTER_CACHE_TAG, `backmatter:${type}`, identity];
|
|
31
|
+
export function contentCacheTags(type: string, slug: string): string[] {
|
|
32
|
+
return [BACKMATTER_CACHE_TAG, `backmatter:${type}`, `backmatter:${type}:${slug}`];
|
|
37
33
|
}
|
|
38
34
|
|
|
39
35
|
export function listCacheTags(type?: string): string[] {
|
|
@@ -49,7 +45,7 @@ export function revalidateTagsForCommit(event: Pick<ContentCommittedEvent, "docs
|
|
|
49
45
|
if (event.full) return [BACKMATTER_CACHE_TAG];
|
|
50
46
|
const tags = new Set<string>();
|
|
51
47
|
for (const doc of event.docs ?? []) {
|
|
52
|
-
for (const tag of contentCacheTags(doc.
|
|
48
|
+
for (const tag of contentCacheTags(doc.type, doc.slug)) {
|
|
53
49
|
if (tag !== BACKMATTER_CACHE_TAG) tags.add(tag);
|
|
54
50
|
}
|
|
55
51
|
}
|
|
@@ -67,7 +63,7 @@ export function pathsForCommittedDocs(
|
|
|
67
63
|
for (const doc of docs) {
|
|
68
64
|
const model = byType.get(doc.type);
|
|
69
65
|
if (!model) continue;
|
|
70
|
-
const path = routeFor(model, doc.
|
|
66
|
+
const path = routeFor(model, doc.slug);
|
|
71
67
|
if (!path) continue;
|
|
72
68
|
if (seen.has(path)) continue;
|
|
73
69
|
seen.add(path);
|
package/src/doc-ref.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
// packages/core/src/doc-ref.ts
|
|
1
2
|
export type DocRef = {
|
|
2
3
|
workspaceId: string;
|
|
3
|
-
locale: string;
|
|
4
4
|
type: string;
|
|
5
5
|
slug: string;
|
|
6
6
|
};
|
|
@@ -15,12 +15,8 @@ export function parseDocRef(input: unknown): DocRef {
|
|
|
15
15
|
throw new Error(`docRef.${key} is required`);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
-
if (typeof value.locale !== "string") {
|
|
19
|
-
throw new Error("docRef.locale is required");
|
|
20
|
-
}
|
|
21
18
|
return {
|
|
22
19
|
workspaceId: value.workspaceId as string,
|
|
23
|
-
locale: value.locale as string,
|
|
24
20
|
type: value.type as string,
|
|
25
21
|
slug: value.slug as string,
|
|
26
22
|
};
|
|
@@ -28,7 +24,5 @@ export function parseDocRef(input: unknown): DocRef {
|
|
|
28
24
|
|
|
29
25
|
/** Relative content path (no workspace prefix) used under head/ and trees/{userId}/. */
|
|
30
26
|
export function docRefPath(ref: DocRef): string {
|
|
31
|
-
return ref.
|
|
32
|
-
? `${ref.locale}/${ref.type}/${ref.slug}`
|
|
33
|
-
: `${ref.type}/${ref.slug}`;
|
|
27
|
+
return `${ref.type}/${ref.slug}`;
|
|
34
28
|
}
|
package/src/doc.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// packages/core/src/doc.ts
|
|
2
2
|
|
|
3
3
|
export type BackmatterDocSummary = {
|
|
4
|
-
locale: string;
|
|
5
4
|
type: string;
|
|
6
5
|
slug: string;
|
|
7
6
|
/** Card-level fields when the loader already parsed the file. */
|
|
@@ -9,7 +8,6 @@ export type BackmatterDocSummary = {
|
|
|
9
8
|
};
|
|
10
9
|
|
|
11
10
|
export type BackmatterDoc = {
|
|
12
|
-
locale: string;
|
|
13
11
|
type: string;
|
|
14
12
|
slug: string;
|
|
15
13
|
/** `head` = committed on the branch; `tree` = the caller's uncommitted working copy. */
|
package/src/fields.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
export type FieldKind =
|
|
4
4
|
| "text"
|
|
5
|
-
| "locale"
|
|
6
5
|
| "image"
|
|
7
6
|
| "media"
|
|
8
7
|
| "relation"
|
|
@@ -77,7 +76,6 @@ function composite<K extends "object" | "array", R extends boolean>(
|
|
|
77
76
|
|
|
78
77
|
export const field = {
|
|
79
78
|
text: <const R extends boolean = false>(opts?: Opts<R>) => base("text", opts),
|
|
80
|
-
locale: <const R extends boolean = false>(opts?: Opts<R>) => base("locale", opts),
|
|
81
79
|
image: <const R extends boolean = false>(opts?: Opts<R>) => base("image", opts),
|
|
82
80
|
media: <const R extends boolean = false>(opts?: Opts<R>) => base("media", opts),
|
|
83
81
|
number: <const R extends boolean = false>(opts?: Opts<R>) => base("number", opts),
|
|
@@ -117,7 +115,6 @@ export function parseFieldSnapshot(input: unknown): FieldSnapshot {
|
|
|
117
115
|
const kind = value.kind;
|
|
118
116
|
const allowed: FieldKind[] = [
|
|
119
117
|
"text",
|
|
120
|
-
"locale",
|
|
121
118
|
"image",
|
|
122
119
|
"media",
|
|
123
120
|
"relation",
|
package/src/index.ts
CHANGED
|
@@ -75,13 +75,12 @@ export {
|
|
|
75
75
|
resolveDevBarApiUrl,
|
|
76
76
|
resolveDashboardUrl,
|
|
77
77
|
DEFAULT_DIRS,
|
|
78
|
-
DEFAULT_LOCALE,
|
|
79
78
|
CLOUD_API_URL,
|
|
80
79
|
CLOUD_DASHBOARD_URL,
|
|
81
80
|
LOCAL_DASHBOARD_URL,
|
|
82
81
|
} from "./config";
|
|
83
82
|
|
|
84
|
-
export type { RouteMatch, RouteModel
|
|
83
|
+
export type { RouteMatch, RouteModel } from "./routes";
|
|
85
84
|
export {
|
|
86
85
|
DEFAULT_ROUTE,
|
|
87
86
|
compileRoutePattern,
|
|
@@ -110,9 +109,6 @@ export {
|
|
|
110
109
|
repoPathFromModel,
|
|
111
110
|
contentExtFromPath,
|
|
112
111
|
contentExtForType,
|
|
113
|
-
EMPTY_LOCALE_SEGMENT,
|
|
114
|
-
encodeDocLocale,
|
|
115
|
-
decodeDocLocale,
|
|
116
112
|
encodeGitBranch,
|
|
117
113
|
decodeGitBranch,
|
|
118
114
|
s3HeadKey,
|
package/src/model.ts
CHANGED
|
@@ -13,17 +13,16 @@ import type { DocFormat } from "./doc-format";
|
|
|
13
13
|
|
|
14
14
|
export type ModelDef = {
|
|
15
15
|
type: string;
|
|
16
|
-
/** Repo-relative path template, e.g. content/
|
|
16
|
+
/** Repo-relative path template, e.g. content/blog/{slug}.mdx */
|
|
17
17
|
path: string;
|
|
18
18
|
/**
|
|
19
19
|
* URL template for the host catch-all route, e.g. "/blog/{slug}".
|
|
20
|
-
* Defaults to "/{
|
|
20
|
+
* Defaults to "/{type}/{slug}" when omitted (see routes.ts).
|
|
21
21
|
* `false` excludes the model from the catch-all. Singletons default to `false`.
|
|
22
22
|
*/
|
|
23
23
|
route?: string | false;
|
|
24
24
|
/**
|
|
25
|
-
* At most one document for each identity the path allows
|
|
26
|
-
* file per `{locale}` when that placeholder is present).
|
|
25
|
+
* At most one document for each identity the path allows.
|
|
27
26
|
*/
|
|
28
27
|
singleton?: boolean;
|
|
29
28
|
/** File format. Default `"mdx"`. `"page"` stores JSON with a blocks array. */
|
|
@@ -79,8 +78,8 @@ export function resolveTemplateSlug(
|
|
|
79
78
|
}
|
|
80
79
|
|
|
81
80
|
/** Default repo path when a model does not declare one. */
|
|
82
|
-
export const DEFAULT_MODEL_PATH = "content/{
|
|
83
|
-
export const DEFAULT_PAGE_PATH = "content/{
|
|
81
|
+
export const DEFAULT_MODEL_PATH = "content/{type}/{slug}.mdx";
|
|
82
|
+
export const DEFAULT_PAGE_PATH = "content/{type}/{slug}.json";
|
|
84
83
|
|
|
85
84
|
export function defaultModelPath(type: string, format: DocFormat = "mdx"): string {
|
|
86
85
|
const template = format === "page" ? DEFAULT_PAGE_PATH : DEFAULT_MODEL_PATH;
|
|
@@ -103,6 +102,9 @@ export type ModelSnapshot = {
|
|
|
103
102
|
export const IMPLICIT_SINGLETON_SLUG = "index";
|
|
104
103
|
|
|
105
104
|
export function assertModelPath(path: string, singleton: boolean, label = "model.path"): void {
|
|
105
|
+
if (path.includes("{locale}")) {
|
|
106
|
+
throw new Error(`${label} must not contain {locale}`);
|
|
107
|
+
}
|
|
106
108
|
if (!singleton && !path.includes("{slug}")) {
|
|
107
109
|
throw new Error(`${label} must include {slug}`);
|
|
108
110
|
}
|
|
@@ -117,6 +119,9 @@ export function assertModelRoute(
|
|
|
117
119
|
if (typeof route !== "string" || !route.startsWith("/")) {
|
|
118
120
|
throw new Error(`${label} must start with "/"`);
|
|
119
121
|
}
|
|
122
|
+
if (route.includes("{locale}")) {
|
|
123
|
+
throw new Error(`${label} must not contain {locale}`);
|
|
124
|
+
}
|
|
120
125
|
if (!singleton && !route.includes("{slug}")) {
|
|
121
126
|
throw new Error(`${label} must include {slug}`);
|
|
122
127
|
}
|
|
@@ -140,7 +145,7 @@ type WithTemplate<D extends ModelInput> = D extends { external: true }
|
|
|
140
145
|
|
|
141
146
|
/**
|
|
142
147
|
* Validates the definition at runtime and preserves its literal types.
|
|
143
|
-
* `path` defaults to `content/{
|
|
148
|
+
* `path` defaults to `content/{type}/{slug}.mdx`.
|
|
144
149
|
*/
|
|
145
150
|
export function defineModel<const D extends ModelInput>(def: D): WithTemplate<WithPath<D>> {
|
|
146
151
|
if (!def.type || typeof def.type !== "string") {
|
package/src/node/cli.ts
CHANGED
|
@@ -138,7 +138,7 @@ export async function loadModels(root: string, modelsPath?: string): Promise<Mod
|
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
140
|
* Static directory roots the models' path templates live under, e.g.
|
|
141
|
-
* `content/
|
|
141
|
+
* `content/en/blog/{slug}.mdx` → `content/en`. Roots that are sub-paths of
|
|
142
142
|
* another root are dropped; a template with no static leading segment yields ".".
|
|
143
143
|
*/
|
|
144
144
|
export function contentRoots(models: ModelSnapshot[]): string[] {
|
|
@@ -257,13 +257,11 @@ import { defineConfig } from "${from}";
|
|
|
257
257
|
|
|
258
258
|
// General settings only. Models live in models/*.ts, composer blocks in
|
|
259
259
|
// blocks/*.tsx, in-content blocks in richtext/*.tsx, MDX tags in mdx/*.tsx,
|
|
260
|
-
// and content
|
|
261
|
-
// automatically.
|
|
260
|
+
// and content under each model's path template — all discovered automatically.
|
|
262
261
|
// Set workspaceId from General settings (not an env var).
|
|
263
262
|
// API URL defaults to https://api.backmatter.app in production; tokens stay env.
|
|
264
263
|
export default defineConfig({
|
|
265
264
|
// workspaceId: "ws_…",
|
|
266
|
-
defaultLocale: "en",
|
|
267
265
|
});
|
|
268
266
|
`;
|
|
269
267
|
}
|
|
@@ -36,13 +36,8 @@ export type CreateBackmatterOptions<Models extends readonly ModelDef[] = readonl
|
|
|
36
36
|
export type BackmatterClient<Models extends readonly ModelDef[] = readonly ModelDef[]> = {
|
|
37
37
|
listDocs: (filter?: {
|
|
38
38
|
type?: string;
|
|
39
|
-
locale?: string;
|
|
40
39
|
}) => Promise<BackmatterDocSummary[]>;
|
|
41
|
-
getDoc: (
|
|
42
|
-
locale: string,
|
|
43
|
-
type: string,
|
|
44
|
-
slug: string,
|
|
45
|
-
) => Promise<BackmatterDoc | null>;
|
|
40
|
+
getDoc: (type: string, slug: string) => Promise<BackmatterDoc | null>;
|
|
46
41
|
/** Typed access keyed by model.type: cms.query.blog.get / findMany */
|
|
47
42
|
query: Query<Models>;
|
|
48
43
|
/** The registered models, e.g. for route matching. */
|
|
@@ -109,26 +104,24 @@ export function createBackmatter<const Models extends readonly ModelDef[]>(
|
|
|
109
104
|
? await listDocsFromApi(apiUrl!, workspaceId!, contentToken, {
|
|
110
105
|
branch,
|
|
111
106
|
type: filter?.type,
|
|
112
|
-
locale: filter?.locale,
|
|
113
107
|
tags: taggedApiFetch ? listCacheTags(filter?.type) : undefined,
|
|
114
108
|
})
|
|
115
109
|
: await listDocsFromFs(siteRoot, models);
|
|
116
110
|
|
|
117
111
|
return docs.filter((d) => {
|
|
118
112
|
if (filter?.type && d.type !== filter.type) return false;
|
|
119
|
-
if (filter?.locale && d.locale !== filter.locale) return false;
|
|
120
113
|
return true;
|
|
121
114
|
});
|
|
122
115
|
},
|
|
123
116
|
|
|
124
|
-
async getDoc(
|
|
117
|
+
async getDoc(type, slug) {
|
|
125
118
|
if (useApi) {
|
|
126
|
-
return getDocFromApi(apiUrl!, workspaceId!, contentToken,
|
|
119
|
+
return getDocFromApi(apiUrl!, workspaceId!, contentToken, type, slug, {
|
|
127
120
|
branch,
|
|
128
|
-
tags: taggedApiFetch ? contentCacheTags(
|
|
121
|
+
tags: taggedApiFetch ? contentCacheTags(type, slug) : undefined,
|
|
129
122
|
});
|
|
130
123
|
}
|
|
131
|
-
return getDocFromFs(siteRoot, models,
|
|
124
|
+
return getDocFromFs(siteRoot, models, type, slug);
|
|
132
125
|
},
|
|
133
126
|
};
|
|
134
127
|
|
package/src/node/fs-loader.ts
CHANGED
|
@@ -63,15 +63,14 @@ export async function listDocsFromFs(
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
docs.push({
|
|
66
|
-
locale: hit.locale ?? "",
|
|
67
66
|
type: hit.type,
|
|
68
67
|
slug: hit.slug,
|
|
69
68
|
frontmatter,
|
|
70
69
|
});
|
|
71
70
|
}
|
|
72
71
|
return docs.sort((a, b) => {
|
|
73
|
-
const ak = `${a.
|
|
74
|
-
const bk = `${b.
|
|
72
|
+
const ak = `${a.type}/${a.slug}`;
|
|
73
|
+
const bk = `${b.type}/${b.slug}`;
|
|
75
74
|
return ak.localeCompare(bk);
|
|
76
75
|
});
|
|
77
76
|
}
|
|
@@ -79,13 +78,12 @@ export async function listDocsFromFs(
|
|
|
79
78
|
export async function getDocFromFs(
|
|
80
79
|
siteRoot: string,
|
|
81
80
|
models: ModelSnapshot[],
|
|
82
|
-
locale: string,
|
|
83
81
|
type: string,
|
|
84
82
|
slug: string,
|
|
85
83
|
): Promise<BackmatterDoc | null> {
|
|
86
84
|
const model = models.find((m) => m.type === type);
|
|
87
85
|
if (!model) return null;
|
|
88
|
-
const rel = repoPathFromModel(model,
|
|
86
|
+
const rel = repoPathFromModel(model, slug);
|
|
89
87
|
const full = path.join(siteRoot, rel);
|
|
90
88
|
let raw: string;
|
|
91
89
|
try {
|
|
@@ -95,7 +93,6 @@ export async function getDocFromFs(
|
|
|
95
93
|
}
|
|
96
94
|
const parsed = parseDoc(raw, modelFormat(model));
|
|
97
95
|
return {
|
|
98
|
-
locale,
|
|
99
96
|
type,
|
|
100
97
|
slug,
|
|
101
98
|
source: "head",
|
package/src/node/generate.ts
CHANGED
|
@@ -105,27 +105,27 @@ function renderCachedCmsClient(): string[] {
|
|
|
105
105
|
return [
|
|
106
106
|
"const raw = createBackmatter({ ...config, models, sync: false });",
|
|
107
107
|
"",
|
|
108
|
-
"async function cachedGetDoc(
|
|
108
|
+
"async function cachedGetDoc(type: string, slug: string) {",
|
|
109
109
|
' "use cache";',
|
|
110
110
|
' cacheLife("hours");',
|
|
111
|
-
" cacheTag(...contentCacheTags(
|
|
112
|
-
" return raw.getDoc(
|
|
111
|
+
" cacheTag(...contentCacheTags(type, slug));",
|
|
112
|
+
" return raw.getDoc(type, slug);",
|
|
113
113
|
"}",
|
|
114
114
|
"",
|
|
115
|
-
"async function cachedListDocs(filter?: { type?: string
|
|
115
|
+
"async function cachedListDocs(filter?: { type?: string }) {",
|
|
116
116
|
' "use cache";',
|
|
117
117
|
' cacheLife("hours");',
|
|
118
118
|
" cacheTag(...listCacheTags(filter?.type));",
|
|
119
119
|
" return raw.listDocs(filter);",
|
|
120
120
|
"}",
|
|
121
121
|
"",
|
|
122
|
-
"async function getDoc(
|
|
122
|
+
"async function getDoc(type: string, slug: string) {",
|
|
123
123
|
" const preview = currentPreviewContent();",
|
|
124
|
-
" if (preview) return preview.getDoc(
|
|
125
|
-
" return cachedGetDoc(
|
|
124
|
+
" if (preview) return preview.getDoc(type, slug);",
|
|
125
|
+
" return cachedGetDoc(type, slug);",
|
|
126
126
|
"}",
|
|
127
127
|
"",
|
|
128
|
-
"async function listDocs(filter?: { type?: string
|
|
128
|
+
"async function listDocs(filter?: { type?: string }) {",
|
|
129
129
|
" const preview = currentPreviewContent();",
|
|
130
130
|
" if (preview) return preview.listDocs(filter);",
|
|
131
131
|
" return cachedListDocs(filter);",
|
package/src/page-nav.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { isRoutable, routeFor, type RouteModel } from "./routes";
|
|
3
3
|
|
|
4
4
|
export type PageNavDoc = {
|
|
5
|
-
locale: string;
|
|
6
5
|
type: string;
|
|
7
6
|
slug: string;
|
|
8
7
|
};
|
|
@@ -21,22 +20,19 @@ export function pageNavItems(input: {
|
|
|
21
20
|
current: PageNavDoc | null;
|
|
22
21
|
}): PageNavItem[] {
|
|
23
22
|
const byType = new Map(input.models.map((model) => [model.type, model]));
|
|
24
|
-
const locales = new Set(input.docs.map((doc) => doc.locale));
|
|
25
|
-
const showLocale = locales.size > 1;
|
|
26
23
|
const items: PageNavItem[] = [];
|
|
27
24
|
for (const doc of input.docs) {
|
|
28
25
|
const model = byType.get(doc.type);
|
|
29
26
|
if (!model || !isRoutable(model)) continue;
|
|
30
|
-
const href = routeFor(model, doc.
|
|
27
|
+
const href = routeFor(model, doc.slug);
|
|
31
28
|
if (!href) continue;
|
|
32
29
|
const name = `${doc.type}/${doc.slug}`;
|
|
33
30
|
items.push({
|
|
34
|
-
key: `${doc.
|
|
35
|
-
label:
|
|
31
|
+
key: `${doc.type}/${doc.slug}`,
|
|
32
|
+
label: name,
|
|
36
33
|
href,
|
|
37
34
|
current: Boolean(
|
|
38
35
|
input.current &&
|
|
39
|
-
input.current.locale === doc.locale &&
|
|
40
36
|
input.current.type === doc.type &&
|
|
41
37
|
input.current.slug === doc.slug,
|
|
42
38
|
),
|
package/src/path.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { IMPLICIT_SINGLETON_SLUG, type ModelSnapshot } from "./model";
|
|
3
3
|
|
|
4
4
|
export type PathMatch = {
|
|
5
|
-
locale?: string;
|
|
6
5
|
slug: string;
|
|
7
6
|
type: string;
|
|
8
7
|
};
|
|
@@ -11,13 +10,15 @@ function escapeRegex(s: string): string {
|
|
|
11
10
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
/** Compile a model path template into a regex with named
|
|
13
|
+
/** Compile a model path template into a regex with a named slug capture. */
|
|
15
14
|
export function compilePathPattern(template: string): RegExp {
|
|
16
|
-
|
|
15
|
+
if (template.includes("{locale}")) {
|
|
16
|
+
throw new Error("model.path must not contain {locale}");
|
|
17
|
+
}
|
|
18
|
+
const parts = template.split(/(\{slug\})/g);
|
|
17
19
|
let pattern = "^";
|
|
18
20
|
for (const part of parts) {
|
|
19
|
-
if (part === "{
|
|
20
|
-
else if (part === "{slug}") pattern += "(?<slug>[^/]+?)";
|
|
21
|
+
if (part === "{slug}") pattern += "(?<slug>[^/]+?)";
|
|
21
22
|
else pattern += escapeRegex(part);
|
|
22
23
|
}
|
|
23
24
|
pattern += "$";
|
|
@@ -32,11 +33,8 @@ export function matchModelPath(
|
|
|
32
33
|
const re = compilePathPattern(model.path);
|
|
33
34
|
const m = re.exec(normalized);
|
|
34
35
|
if (!m) return null;
|
|
35
|
-
const hasLocale = model.path.includes("{locale}");
|
|
36
36
|
const hasSlug = model.path.includes("{slug}");
|
|
37
|
-
if (hasLocale && !m.groups?.locale) return null;
|
|
38
37
|
if (hasSlug && !m.groups?.slug) return null;
|
|
39
|
-
// Strip trailing extension from slug if the template baked it into the capture.
|
|
40
38
|
let slug = hasSlug ? m.groups!.slug! : IMPLICIT_SINGLETON_SLUG;
|
|
41
39
|
const ext = contentExtFromPath(model.path);
|
|
42
40
|
const suffix = `.${ext}`;
|
|
@@ -44,9 +42,7 @@ export function matchModelPath(
|
|
|
44
42
|
slug = slug.slice(0, -suffix.length);
|
|
45
43
|
}
|
|
46
44
|
if (!slug) return null;
|
|
47
|
-
|
|
48
|
-
if (hasLocale) match.locale = m.groups!.locale;
|
|
49
|
-
return match;
|
|
45
|
+
return { slug, type: model.type };
|
|
50
46
|
}
|
|
51
47
|
|
|
52
48
|
export function matchAnyModelPath(
|
|
@@ -75,23 +71,17 @@ export function contentExtForType(
|
|
|
75
71
|
|
|
76
72
|
export function repoPathFromModel(
|
|
77
73
|
model: Pick<ModelSnapshot, "path">,
|
|
78
|
-
locale: string | undefined,
|
|
79
74
|
slug: string,
|
|
80
75
|
): string {
|
|
81
|
-
|
|
82
|
-
if (locale !== undefined) out = out.replaceAll("{locale}", locale);
|
|
83
|
-
return out.replaceAll("{slug}", slug);
|
|
76
|
+
return model.path.replaceAll("{slug}", slug);
|
|
84
77
|
}
|
|
85
78
|
|
|
86
79
|
// ---------------------------------------------------------------------------
|
|
87
80
|
// S3 layout (git working tree model)
|
|
88
81
|
//
|
|
89
|
-
// workspaces/{ws}/branches/{branch}/head/{
|
|
90
|
-
//
|
|
91
|
-
// workspaces/{ws}/branches/{branch}/
|
|
92
|
-
// one user's uncommitted file on that branch
|
|
93
|
-
// workspaces/{ws}/branches/{branch}/meta/shas/{locale}/{type}/{slug}.json
|
|
94
|
-
// HEAD blob sha per file (conflict base)
|
|
82
|
+
// workspaces/{ws}/branches/{branch}/head/{type}/{slug}.mdx
|
|
83
|
+
// workspaces/{ws}/branches/{branch}/trees/{userId}/{type}/{slug}.mdx
|
|
84
|
+
// workspaces/{ws}/branches/{branch}/meta/shas/{type}/{slug}.json
|
|
95
85
|
// ---------------------------------------------------------------------------
|
|
96
86
|
|
|
97
87
|
/** Where a doc's bytes came from: the branch HEAD mirror or the caller's working tree. */
|
|
@@ -121,55 +111,39 @@ export function s3TreePrefix(
|
|
|
121
111
|
return `${branchRoot(workspaceId, branch)}/trees/${encodeURIComponent(userId)}/`;
|
|
122
112
|
}
|
|
123
113
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
export function encodeDocLocale(locale: string | undefined): string {
|
|
128
|
-
return locale ? locale : EMPTY_LOCALE_SEGMENT;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function decodeDocLocale(segment: string): string {
|
|
132
|
-
return segment === EMPTY_LOCALE_SEGMENT ? "" : segment;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function contentLeaf(type: string, slug: string, ext: string, locale?: string): string {
|
|
136
|
-
const leaf = `${type}/${slug}.${ext}`;
|
|
137
|
-
return locale ? `${locale}/${leaf}` : leaf;
|
|
114
|
+
function contentLeaf(type: string, slug: string, ext: string): string {
|
|
115
|
+
return `${type}/${slug}.${ext}`;
|
|
138
116
|
}
|
|
139
117
|
|
|
140
118
|
export function s3HeadKey(
|
|
141
119
|
workspaceId: string,
|
|
142
120
|
branch: string,
|
|
143
|
-
locale: string | undefined,
|
|
144
121
|
type: string,
|
|
145
122
|
slug: string,
|
|
146
123
|
ext = "mdx",
|
|
147
124
|
): string {
|
|
148
|
-
return `${s3HeadPrefix(workspaceId, branch)}${contentLeaf(type, slug, ext
|
|
125
|
+
return `${s3HeadPrefix(workspaceId, branch)}${contentLeaf(type, slug, ext)}`;
|
|
149
126
|
}
|
|
150
127
|
|
|
151
128
|
export function s3TreeKey(
|
|
152
129
|
workspaceId: string,
|
|
153
130
|
branch: string,
|
|
154
131
|
userId: string,
|
|
155
|
-
locale: string | undefined,
|
|
156
132
|
type: string,
|
|
157
133
|
slug: string,
|
|
158
134
|
ext = "mdx",
|
|
159
135
|
): string {
|
|
160
|
-
return `${s3TreePrefix(workspaceId, branch, userId)}${contentLeaf(type, slug, ext
|
|
136
|
+
return `${s3TreePrefix(workspaceId, branch, userId)}${contentLeaf(type, slug, ext)}`;
|
|
161
137
|
}
|
|
162
138
|
|
|
163
139
|
/** HEAD blob sha for conflict checks. */
|
|
164
140
|
export function s3ShaMetaKey(
|
|
165
141
|
workspaceId: string,
|
|
166
142
|
branch: string,
|
|
167
|
-
locale: string | undefined,
|
|
168
143
|
type: string,
|
|
169
144
|
slug: string,
|
|
170
145
|
): string {
|
|
171
|
-
|
|
172
|
-
return `${branchRoot(workspaceId, branch)}/meta/shas/${leaf}`;
|
|
146
|
+
return `${branchRoot(workspaceId, branch)}/meta/shas/${type}/${slug}.json`;
|
|
173
147
|
}
|
|
174
148
|
|
|
175
149
|
export type ParsedContentKey = {
|
|
@@ -177,7 +151,6 @@ export type ParsedContentKey = {
|
|
|
177
151
|
source: ContentSource;
|
|
178
152
|
/** Owner of the working tree; null for head keys. */
|
|
179
153
|
userId: string | null;
|
|
180
|
-
locale?: string;
|
|
181
154
|
type: string;
|
|
182
155
|
slug: string;
|
|
183
156
|
};
|
|
@@ -219,11 +192,8 @@ export function parseS3ContentKey(
|
|
|
219
192
|
return null;
|
|
220
193
|
}
|
|
221
194
|
|
|
222
|
-
function parseContentLeaf(leaf: string): {
|
|
195
|
+
function parseContentLeaf(leaf: string): { type: string; slug: string } | null {
|
|
223
196
|
const parts = leaf.split("/");
|
|
224
|
-
if (parts.length === 3) {
|
|
225
|
-
return { locale: parts[0], type: parts[1]!, slug: parts[2]! };
|
|
226
|
-
}
|
|
227
197
|
if (parts.length === 2) {
|
|
228
198
|
return { type: parts[0]!, slug: parts[1]! };
|
|
229
199
|
}
|
package/src/preview-channel.ts
CHANGED
|
@@ -9,7 +9,6 @@ import { parsePreviewHotkey, type PreviewHotkeyMessage } from "./preview-hotkeys
|
|
|
9
9
|
export const PREVIEW_CHANNEL_VERSION = 1 as const;
|
|
10
10
|
|
|
11
11
|
export type PreviewChannelRef = {
|
|
12
|
-
locale: string;
|
|
13
12
|
type: string;
|
|
14
13
|
slug: string;
|
|
15
14
|
branch?: string;
|
|
@@ -90,7 +89,7 @@ export type PreviewTransport = {
|
|
|
90
89
|
};
|
|
91
90
|
|
|
92
91
|
export function samePreviewRef(a: PreviewChannelRef, b: PreviewChannelRef): boolean {
|
|
93
|
-
if (a.
|
|
92
|
+
if (a.type !== b.type || a.slug !== b.slug) return false;
|
|
94
93
|
if (a.branch && b.branch && a.branch !== b.branch) return false;
|
|
95
94
|
if (a.tree && b.tree && a.tree !== b.tree) return false;
|
|
96
95
|
return true;
|
|
@@ -102,11 +101,9 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
102
101
|
|
|
103
102
|
function readRef(value: unknown): PreviewChannelRef | null {
|
|
104
103
|
if (!isRecord(value)) return null;
|
|
105
|
-
if (typeof value.locale !== "string" || !value.locale) return null;
|
|
106
104
|
if (typeof value.type !== "string" || !value.type) return null;
|
|
107
105
|
if (typeof value.slug !== "string" || !value.slug) return null;
|
|
108
106
|
const ref: PreviewChannelRef = {
|
|
109
|
-
locale: value.locale,
|
|
110
107
|
type: value.type,
|
|
111
108
|
slug: value.slug,
|
|
112
109
|
};
|
package/src/preview.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// packages/core/src/preview.ts
|
|
2
2
|
import type { BackmatterDoc, BackmatterDocSummary } from "./doc";
|
|
3
|
-
import { encodeDocLocale } from "./path";
|
|
4
3
|
|
|
5
4
|
export type PreviewDocRef = {
|
|
6
|
-
locale: string;
|
|
7
5
|
type: string;
|
|
8
6
|
slug: string;
|
|
9
7
|
/** Editor checkout branch. Omit to read the workspace default branch. */
|
|
@@ -198,10 +196,9 @@ function previewParams(options: PreviewClientOptions, ref: PreviewDocRef) {
|
|
|
198
196
|
}
|
|
199
197
|
|
|
200
198
|
function docPath(options: PreviewClientOptions, ref: PreviewDocRef) {
|
|
201
|
-
const locale = encodeURIComponent(encodeDocLocale(ref.locale));
|
|
202
199
|
const type = encodeURIComponent(ref.type);
|
|
203
200
|
const slug = encodeURIComponent(ref.slug);
|
|
204
|
-
return `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/docs/${
|
|
201
|
+
return `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/docs/${type}/${slug}`;
|
|
205
202
|
}
|
|
206
203
|
|
|
207
204
|
export function previewDocRequestUrl(
|
|
@@ -223,7 +220,7 @@ export function previewTreeStreamUrl(
|
|
|
223
220
|
options: PreviewClientOptions,
|
|
224
221
|
checkout: { branch?: string; tree?: string },
|
|
225
222
|
): string {
|
|
226
|
-
return `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/tree/stream?${previewParams(options, {
|
|
223
|
+
return `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/tree/stream?${previewParams(options, { type: "", slug: "", ...checkout })}`;
|
|
227
224
|
}
|
|
228
225
|
|
|
229
226
|
export async function fetchPreviewDoc(
|
|
@@ -244,20 +241,18 @@ export async function fetchPreviewDoc(
|
|
|
244
241
|
*/
|
|
245
242
|
export async function fetchPreviewDocs(
|
|
246
243
|
options: PreviewClientOptions,
|
|
247
|
-
query: { branch?: string; tree?: string; type?: string
|
|
244
|
+
query: { branch?: string; tree?: string; type?: string },
|
|
248
245
|
): Promise<BackmatterDocSummary[]> {
|
|
249
246
|
const docs: BackmatterDocSummary[] = [];
|
|
250
247
|
let cursor: string | undefined;
|
|
251
248
|
for (let page = 0; page < 100; page++) {
|
|
252
249
|
const params = previewParams(options, {
|
|
253
|
-
locale: query.locale ?? "",
|
|
254
250
|
type: "",
|
|
255
251
|
slug: "",
|
|
256
252
|
branch: query.branch,
|
|
257
253
|
tree: query.tree,
|
|
258
254
|
});
|
|
259
255
|
if (query.type) params.set("type", query.type);
|
|
260
|
-
if (query.locale) params.set("locale", query.locale);
|
|
261
256
|
if (cursor) params.set("cursor", cursor);
|
|
262
257
|
const url = `${baseUrl(options.apiUrl)}/preview/workspaces/${options.workspaceId}/docs?${params}`;
|
|
263
258
|
const res = await fetch(url, { cache: "no-store" });
|
package/src/query.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { normalizeFrontmatter } from "./zod";
|
|
|
6
6
|
|
|
7
7
|
/** Loader the query layer sits on: the FS or API loaders behind createBackmatter. */
|
|
8
8
|
export type DocLoader = {
|
|
9
|
-
listDocs(filter?: { type?: string
|
|
10
|
-
getDoc(
|
|
9
|
+
listDocs(filter?: { type?: string }): Promise<BackmatterDocSummary[]>;
|
|
10
|
+
getDoc(type: string, slug: string): Promise<BackmatterDoc | null>;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
export type TypedDoc<M extends ModelDef> = Omit<BackmatterDoc, "type" | "frontmatter"> & {
|
|
@@ -19,7 +19,7 @@ type KeysOfKind<M extends ModelDef, K extends FieldKind> = {
|
|
|
19
19
|
[N in keyof M["fields"]]: M["fields"][N]["kind"] extends K ? N : never;
|
|
20
20
|
}[keyof M["fields"]];
|
|
21
21
|
|
|
22
|
-
type ScalarKind = "text" | "
|
|
22
|
+
type ScalarKind = "text" | "number" | "boolean";
|
|
23
23
|
|
|
24
24
|
/** Equality filters on slug and scalar frontmatter fields. */
|
|
25
25
|
export type Where<M extends ModelDef> = Partial<
|
|
@@ -29,7 +29,7 @@ export type Where<M extends ModelDef> = Partial<
|
|
|
29
29
|
>
|
|
30
30
|
>;
|
|
31
31
|
|
|
32
|
-
/** Relation fields that may be resolved (depth 1
|
|
32
|
+
/** Relation fields that may be resolved (depth 1). */
|
|
33
33
|
export type Include<M extends ModelDef> = Partial<Record<KeysOfKind<M, "relation">, true>>;
|
|
34
34
|
|
|
35
35
|
type RelationTarget<
|
|
@@ -50,22 +50,13 @@ export type Resolved<
|
|
|
50
50
|
: never;
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
-
type PathHasLocale<M extends ModelDef> = M["path"] extends `${string}{locale}${string}`
|
|
54
|
-
? true
|
|
55
|
-
: false;
|
|
56
|
-
|
|
57
53
|
type ModelGet<M extends ModelDef> = M["singleton"] extends true
|
|
58
|
-
?
|
|
59
|
-
|
|
60
|
-
: () => Promise<TypedDoc<M> | null>
|
|
61
|
-
: PathHasLocale<M> extends true
|
|
62
|
-
? (locale: string, slug: string) => Promise<TypedDoc<M> | null>
|
|
63
|
-
: (slug: string) => Promise<TypedDoc<M> | null>;
|
|
54
|
+
? () => Promise<TypedDoc<M> | null>
|
|
55
|
+
: (slug: string) => Promise<TypedDoc<M> | null>;
|
|
64
56
|
|
|
65
57
|
export type ModelQuery<M extends ModelDef, Models extends readonly ModelDef[]> = {
|
|
66
58
|
get: ModelGet<M>;
|
|
67
59
|
findMany<I extends Include<M> = {}>(opts?: {
|
|
68
|
-
locale?: string;
|
|
69
60
|
where?: Where<M>;
|
|
70
61
|
include?: I;
|
|
71
62
|
}): Promise<Array<TypedDoc<M> & Resolved<M, Models, I>>>;
|
|
@@ -76,7 +67,6 @@ export type Query<Models extends readonly ModelDef[]> = {
|
|
|
76
67
|
};
|
|
77
68
|
|
|
78
69
|
type RawOpts = {
|
|
79
|
-
locale?: string;
|
|
80
70
|
where?: Record<string, unknown>;
|
|
81
71
|
include?: Record<string, boolean | undefined>;
|
|
82
72
|
};
|
|
@@ -87,24 +77,17 @@ export function createQuery<const Models extends readonly ModelDef[]>(
|
|
|
87
77
|
): Query<Models> {
|
|
88
78
|
const byType = new Map<string, ModelDef>(models.map((m) => [m.type, m]));
|
|
89
79
|
|
|
90
|
-
function
|
|
80
|
+
function slugForGet(type: string, arg?: string): string {
|
|
91
81
|
const model = byType.get(type);
|
|
92
|
-
const localized = model?.path.includes("{locale}") ?? true;
|
|
93
82
|
const singleton = model?.singleton === true;
|
|
94
83
|
if (singleton) {
|
|
95
|
-
return {
|
|
96
|
-
locale: localized ? (a ?? "") : "",
|
|
97
|
-
slug: model?.path.includes("{slug}") ? (localized ? (b ?? IMPLICIT_SINGLETON_SLUG) : (a ?? IMPLICIT_SINGLETON_SLUG)) : IMPLICIT_SINGLETON_SLUG,
|
|
98
|
-
};
|
|
84
|
+
return model?.path.includes("{slug}") ? (arg ?? IMPLICIT_SINGLETON_SLUG) : IMPLICIT_SINGLETON_SLUG;
|
|
99
85
|
}
|
|
100
|
-
return
|
|
101
|
-
locale: localized ? (a ?? "") : "",
|
|
102
|
-
slug: localized ? (b ?? "") : (a ?? ""),
|
|
103
|
-
};
|
|
86
|
+
return arg ?? "";
|
|
104
87
|
}
|
|
105
88
|
|
|
106
|
-
async function get(type: string,
|
|
107
|
-
const doc = await loader.getDoc(
|
|
89
|
+
async function get(type: string, slug: string): Promise<BackmatterDoc | null> {
|
|
90
|
+
const doc = await loader.getDoc(type, slug);
|
|
108
91
|
if (!doc) return null;
|
|
109
92
|
return { ...doc, frontmatter: normalizeFrontmatter(doc.frontmatter) };
|
|
110
93
|
}
|
|
@@ -123,20 +106,16 @@ export function createQuery<const Models extends readonly ModelDef[]>(
|
|
|
123
106
|
const whereSlug = opts?.where?.slug;
|
|
124
107
|
|
|
125
108
|
let docs: BackmatterDoc[];
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (typeof whereSlug === "string" && opts?.locale) {
|
|
129
|
-
// Slug fast-path: identity lookup instead of list + load fan-out.
|
|
130
|
-
const doc = await get(type, opts.locale, whereSlug);
|
|
109
|
+
if (typeof whereSlug === "string") {
|
|
110
|
+
const doc = await get(type, whereSlug);
|
|
131
111
|
docs = doc ? [doc] : [];
|
|
132
112
|
} else {
|
|
133
|
-
const summaries = await loader.listDocs({ type
|
|
113
|
+
const summaries = await loader.listDocs({ type });
|
|
134
114
|
const fromList = summaries.filter((s) => s.frontmatter != null);
|
|
135
115
|
const missing = summaries.filter((s) => s.frontmatter == null);
|
|
136
|
-
const loaded = await Promise.all(missing.map((s) => get(type, s.
|
|
116
|
+
const loaded = await Promise.all(missing.map((s) => get(type, s.slug)));
|
|
137
117
|
docs = [
|
|
138
118
|
...fromList.map((s) => ({
|
|
139
|
-
locale: s.locale,
|
|
140
119
|
type: s.type,
|
|
141
120
|
slug: s.slug,
|
|
142
121
|
source: "head" as const,
|
|
@@ -152,13 +131,12 @@ export function createQuery<const Models extends readonly ModelDef[]>(
|
|
|
152
131
|
const include = opts?.include;
|
|
153
132
|
if (!include || !model) return filtered;
|
|
154
133
|
|
|
155
|
-
// Memoize relation loads per call: N posts sharing one author load it once.
|
|
156
134
|
const relationCache = new Map<string, Promise<BackmatterDoc | null>>();
|
|
157
|
-
const getCached = (target: string,
|
|
158
|
-
const key = `${target}:${
|
|
135
|
+
const getCached = (target: string, slug: string) => {
|
|
136
|
+
const key = `${target}:${slug}`;
|
|
159
137
|
let promise = relationCache.get(key);
|
|
160
138
|
if (!promise) {
|
|
161
|
-
promise = get(target,
|
|
139
|
+
promise = get(target, slug);
|
|
162
140
|
relationCache.set(key, promise);
|
|
163
141
|
}
|
|
164
142
|
return promise;
|
|
@@ -171,10 +149,10 @@ export function createQuery<const Models extends readonly ModelDef[]>(
|
|
|
171
149
|
if (!on) continue;
|
|
172
150
|
const field = model.fields[name];
|
|
173
151
|
const target = field?.kind === "relation" ? field.relationTo : undefined;
|
|
174
|
-
const
|
|
152
|
+
const relatedSlug = doc.frontmatter[name];
|
|
175
153
|
extra[name] =
|
|
176
|
-
target && typeof
|
|
177
|
-
? await getCached(target,
|
|
154
|
+
target && typeof relatedSlug === "string" && relatedSlug.length > 0
|
|
155
|
+
? await getCached(target, relatedSlug)
|
|
178
156
|
: null;
|
|
179
157
|
}
|
|
180
158
|
return { ...doc, ...extra };
|
|
@@ -185,10 +163,7 @@ export function createQuery<const Models extends readonly ModelDef[]>(
|
|
|
185
163
|
const query: Record<string, unknown> = {};
|
|
186
164
|
for (const m of models) {
|
|
187
165
|
query[m.type] = {
|
|
188
|
-
get: (
|
|
189
|
-
const id = identityFor(m.type, a, b);
|
|
190
|
-
return get(m.type, id.locale, id.slug);
|
|
191
|
-
},
|
|
166
|
+
get: (arg?: string) => get(m.type, slugForGet(m.type, arg)),
|
|
192
167
|
findMany: (opts?: RawOpts) => findMany(m.type, opts),
|
|
193
168
|
};
|
|
194
169
|
}
|
package/src/routes.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Pure URL ↔ document mapping for the host catch-all route. No framework code.
|
|
3
3
|
import type { ModelSnapshot } from "./model";
|
|
4
4
|
|
|
5
|
-
export const DEFAULT_ROUTE = "/{
|
|
5
|
+
export const DEFAULT_ROUTE = "/{type}/{slug}";
|
|
6
6
|
|
|
7
7
|
export type RouteModel = Pick<ModelSnapshot, "type" | "route" | "indexSlug">;
|
|
8
8
|
|
|
@@ -12,35 +12,30 @@ export function isRoutable(model: Pick<RouteModel, "route">): boolean {
|
|
|
12
12
|
|
|
13
13
|
export type RouteMatch = {
|
|
14
14
|
type: string;
|
|
15
|
-
locale: string;
|
|
16
15
|
slug: string;
|
|
17
16
|
};
|
|
18
17
|
|
|
19
|
-
export type MatchRouteOptions = {
|
|
20
|
-
/** Locale for routes without a `{locale}` placeholder. */
|
|
21
|
-
defaultLocale: string;
|
|
22
|
-
/** When set, `{locale}` only matches one of these values. */
|
|
23
|
-
locales?: readonly string[];
|
|
24
|
-
};
|
|
25
|
-
|
|
26
18
|
/** The model's route template with `{type}` already substituted. */
|
|
27
19
|
export function routeTemplate(model: RouteModel): string {
|
|
28
20
|
if (model.route === false) return "";
|
|
29
|
-
|
|
21
|
+
const template = model.route ?? DEFAULT_ROUTE;
|
|
22
|
+
if (template.includes("{locale}")) {
|
|
23
|
+
throw new Error("model.route must not contain {locale}");
|
|
24
|
+
}
|
|
25
|
+
return template.replaceAll("{type}", model.type);
|
|
30
26
|
}
|
|
31
27
|
|
|
32
|
-
/** Build the public URL path for a document, e.g. "/
|
|
33
|
-
export function routeFor(model: RouteModel,
|
|
28
|
+
/** Build the public URL path for a document, e.g. "/blog/hello". */
|
|
29
|
+
export function routeFor(model: RouteModel, slug: string): string {
|
|
34
30
|
if (model.route === false) return "";
|
|
35
31
|
if (model.indexSlug && slug === model.indexSlug) return "/";
|
|
36
32
|
return routeTemplate(model)
|
|
37
|
-
.replaceAll("{locale}", encodeURIComponent(locale))
|
|
38
33
|
.replaceAll("{slug}", slug.split("/").map(encodeURIComponent).join("/"));
|
|
39
34
|
}
|
|
40
35
|
|
|
41
|
-
/** URL segments for Next `generateStaticParams` (["
|
|
42
|
-
export function routeSegments(model: RouteModel,
|
|
43
|
-
return routeFor(model,
|
|
36
|
+
/** URL segments for Next `generateStaticParams` (["blog", "hello"]). */
|
|
37
|
+
export function routeSegments(model: RouteModel, slug: string): string[] {
|
|
38
|
+
return routeFor(model, slug)
|
|
44
39
|
.split("/")
|
|
45
40
|
.filter(Boolean)
|
|
46
41
|
.map(decodeURIComponent);
|
|
@@ -51,11 +46,13 @@ function escapeRegex(s: string): string {
|
|
|
51
46
|
}
|
|
52
47
|
|
|
53
48
|
export function compileRoutePattern(template: string): RegExp {
|
|
54
|
-
|
|
49
|
+
if (template.includes("{locale}")) {
|
|
50
|
+
throw new Error("model.route must not contain {locale}");
|
|
51
|
+
}
|
|
52
|
+
const parts = template.split(/(\{slug\})/g);
|
|
55
53
|
let pattern = "^";
|
|
56
54
|
for (const part of parts) {
|
|
57
|
-
if (part === "{
|
|
58
|
-
else if (part === "{slug}") pattern += "(?<slug>[^/]+)";
|
|
55
|
+
if (part === "{slug}") pattern += "(?<slug>[^/]+)";
|
|
59
56
|
else pattern += escapeRegex(part);
|
|
60
57
|
}
|
|
61
58
|
return new RegExp(`${pattern}/?$`);
|
|
@@ -89,13 +86,12 @@ export function normalizePathname(pathname: string): string {
|
|
|
89
86
|
export function matchRoute(
|
|
90
87
|
models: readonly RouteModel[],
|
|
91
88
|
pathname: string,
|
|
92
|
-
options: MatchRouteOptions,
|
|
93
89
|
): RouteMatch | null {
|
|
94
90
|
const target = normalizePathname(pathname);
|
|
95
91
|
if (target === "/") {
|
|
96
92
|
const index = models.find((model) => model.indexSlug && isRoutable(model));
|
|
97
93
|
if (index?.indexSlug) {
|
|
98
|
-
return { type: index.type,
|
|
94
|
+
return { type: index.type, slug: index.indexSlug };
|
|
99
95
|
}
|
|
100
96
|
return null;
|
|
101
97
|
}
|
|
@@ -103,14 +99,7 @@ export function matchRoute(
|
|
|
103
99
|
const template = routeTemplate(model);
|
|
104
100
|
const m = compileRoutePattern(template).exec(target);
|
|
105
101
|
if (!m?.groups?.slug) continue;
|
|
106
|
-
|
|
107
|
-
if (template.includes("{locale}")) {
|
|
108
|
-
const raw = m.groups.locale;
|
|
109
|
-
if (!raw) continue;
|
|
110
|
-
locale = decodeURIComponent(raw);
|
|
111
|
-
if (options.locales && !options.locales.includes(locale)) continue;
|
|
112
|
-
}
|
|
113
|
-
return { type: model.type, locale, slug: decodeURIComponent(m.groups.slug) };
|
|
102
|
+
return { type: model.type, slug: decodeURIComponent(m.groups.slug) };
|
|
114
103
|
}
|
|
115
104
|
return null;
|
|
116
105
|
}
|
package/src/source.ts
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
// A source resolves the outside record behind an `external` model's route.
|
|
3
3
|
// Host code only (`sources/{type}.ts`); never serialized into the schema.
|
|
4
4
|
|
|
5
|
-
export type SourceRef = {
|
|
5
|
+
export type SourceRef = { slug: string };
|
|
6
6
|
|
|
7
7
|
export type SourceDef<T = unknown> = {
|
|
8
8
|
/** The outside record for a route hit. `null` means the record does not exist (404). */
|
|
9
9
|
load: (ref: SourceRef) => Promise<T | null>;
|
|
10
|
-
/** Slugs to prerender
|
|
11
|
-
list?: (
|
|
10
|
+
/** Slugs to prerender. Omit to prerender only slugs that have a CMS file. */
|
|
11
|
+
list?: () => Promise<readonly string[]>;
|
|
12
12
|
/** One representative record for template preview. Omit to preview the template with `data: null`. */
|
|
13
|
-
sample?: (
|
|
13
|
+
sample?: () => Promise<T | null>;
|
|
14
14
|
/** Metadata when the document has no `title`. */
|
|
15
15
|
metadata?: (record: T) => { title?: string };
|
|
16
16
|
};
|
package/src/sync-schema.ts
CHANGED
|
@@ -38,7 +38,11 @@ export async function syncSchema(options: SyncSchemaOptions): Promise<void> {
|
|
|
38
38
|
options.objects,
|
|
39
39
|
options.richtext,
|
|
40
40
|
);
|
|
41
|
-
const body: {
|
|
41
|
+
const body: {
|
|
42
|
+
payload: ReturnType<typeof serializeSchema>;
|
|
43
|
+
branch: string;
|
|
44
|
+
siteUrl?: string;
|
|
45
|
+
} = {
|
|
42
46
|
payload,
|
|
43
47
|
branch,
|
|
44
48
|
};
|