@notionx/create-notionx-app 1.0.0 → 2.0.1
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/dist/cli-notionx.js +25 -1
- package/dist/cli-notionx.js.map +1 -1
- package/dist/locale-add/persist.js +113 -0
- package/dist/locale-add/persist.js.map +1 -0
- package/dist/locale-add/plan.js +202 -21
- package/dist/locale-add/plan.js.map +1 -1
- package/dist/metadata.js.map +1 -1
- package/dist/notion-translation-sources/apply.js +11 -26
- package/dist/notion-translation-sources/apply.js.map +1 -1
- package/dist/notion-translation-sources/plan.js +25 -0
- package/dist/notion-translation-sources/plan.js.map +1 -1
- package/dist/provision/__tests__/translation-properties.test.js +86 -0
- package/dist/provision/__tests__/translation-properties.test.js.map +1 -0
- package/dist/provision/credentials.js +67 -0
- package/dist/provision/credentials.js.map +1 -0
- package/dist/provision/index.js +188 -11
- package/dist/provision/index.js.map +1 -1
- package/dist/provision/notion.js +422 -269
- package/dist/provision/notion.js.map +1 -1
- package/dist/provision/notion.test.js +143 -116
- package/dist/provision/notion.test.js.map +1 -1
- package/dist/provision/wire.js +16 -0
- package/dist/provision/wire.js.map +1 -1
- package/dist/registry/install.test.js +2 -0
- package/dist/registry/install.test.js.map +1 -1
- package/dist/registry/project-meta.js +4 -2
- package/dist/registry/project-meta.js.map +1 -1
- package/dist/registry/registry-types.js.map +1 -1
- package/dist/registry/render-content-source-files.js +1 -0
- package/dist/registry/render-content-source-files.js.map +1 -1
- package/dist/registry/render-multi-source.js +72 -28
- package/dist/registry/render-multi-source.js.map +1 -1
- package/dist/registry/update.test.js +2 -0
- package/dist/registry/update.test.js.map +1 -1
- package/dist/render.js +2 -0
- package/dist/render.js.map +1 -1
- package/dist/render.test.js +18 -12
- package/dist/render.test.js.map +1 -1
- package/dist/templates/.dev.vars.example.tmpl +4 -0
- package/dist/templates/__tests__/middleware-integration.test.ts +58 -0
- package/dist/templates/app/{{contentSourceListPath}}/[slug]/page.tsx.tmpl +8 -4
- package/dist/templates/app/{{contentSourceListPath}}/page.tsx.tmpl +8 -4
- package/dist/templates/components/page-blocks/feature-grid-block.tsx.tmpl +4 -56
- package/dist/templates/components/page-blocks/hero-block.tsx.tmpl +6 -67
- package/dist/templates/components/page-blocks/latest-posts-block.tsx.tmpl +11 -19
- package/dist/templates/components/page-blocks/story-block.tsx.tmpl +4 -62
- package/dist/templates/components/page-blocks.tsx.tmpl +5 -5
- package/dist/templates/components/site/site-header.tsx.tmpl +5 -3
- package/dist/templates/env.d.ts.tmpl +8 -0
- package/dist/templates/lib/blocks/translations.ts.tmpl +22 -1
- package/dist/templates/lib/blog/translations.ts.tmpl +19 -3
- package/dist/templates/lib/content/models.ts.tmpl +6 -0
- package/dist/templates/lib/locale-contract/index.ts.tmpl +1 -1
- package/dist/templates/lib/pages/source.ts.tmpl +140 -335
- package/dist/templates/lib/pages/translations.ts.tmpl +23 -1
- package/dist/templates/lib/search/config.ts.tmpl +17 -4
- package/dist/templates/lib/site/request-env.ts.tmpl +21 -3
- package/dist/templates/lib/site/settings.ts.tmpl +99 -179
- package/dist/templates/lib/site/translations.ts.tmpl +34 -11
- package/dist/templates/middleware.ts.tmpl +56 -0
- package/dist/templates/worker/index.ts.tmpl +14 -5
- package/dist/templates/wrangler.jsonc.tmpl +5 -1
- package/package.json +1 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Notion-backed site settings loader.
|
|
2
2
|
//
|
|
3
|
-
// Reads
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// Reads multiple rows from the `site-settings` Notion data source
|
|
4
|
+
// (declared in `lib/content/models.ts`) and aggregates them into a
|
|
5
|
+
// single `SiteConfig`. Each row is one setting item keyed by the
|
|
6
|
+
// `Key` field; the runtime loader builds a `Record<string, string>`
|
|
7
|
+
// and maps known keys to the `SiteConfig` structure. The result is
|
|
8
|
+
// merged with the static fallback in `./config.ts` so any missing
|
|
9
|
+
// row falls back gracefully.
|
|
8
10
|
//
|
|
9
11
|
// Caching strategy:
|
|
10
12
|
// - One KV read per request. On miss, fetch from Notion, cache
|
|
@@ -20,7 +22,7 @@
|
|
|
20
22
|
// the KV entry. The endpoint re-uses
|
|
21
23
|
// `@notionx/core/auth/routes/viewer` to authorize the caller.
|
|
22
24
|
//
|
|
23
|
-
// If the Notion data source is empty or the
|
|
25
|
+
// If the Notion data source is empty or the rows can't be read,
|
|
24
26
|
// `fallbackSiteConfig` from `./config.ts` is returned unchanged.
|
|
25
27
|
// The fallback is also what `getStaticSiteSettings()` returns
|
|
26
28
|
// synchronously — useful for places that can't `await` (e.g.
|
|
@@ -31,6 +33,7 @@ import {
|
|
|
31
33
|
hasNotionModelConfig,
|
|
32
34
|
listGenericNotionContent,
|
|
33
35
|
} from "@notionx/core/notion";
|
|
36
|
+
import { listGenericNotionContentForLocale } from "@notionx/core";
|
|
34
37
|
import { siteSettingsSource } from "../content/models";
|
|
35
38
|
import { fallbackSiteConfig, type SiteConfig } from "./config";
|
|
36
39
|
|
|
@@ -47,70 +50,22 @@ function readKv(): KVNamespace | null {
|
|
|
47
50
|
return getRequestEnv()?.CONTENT_CACHE ?? null;
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
// Mirrors `SiteConfig["navigation"]["main"]`: the JSON stored in
|
|
54
|
+
// Notion's "Nav" field should include `modelId` for each item.
|
|
50
55
|
type RawNavItem = {
|
|
51
56
|
label: string;
|
|
52
57
|
href: string;
|
|
58
|
+
modelId: string;
|
|
53
59
|
children?: RawNavItem[];
|
|
54
60
|
};
|
|
55
61
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
defaultLocale?: string;
|
|
61
|
-
socialImageUrl?: string;
|
|
62
|
-
ogImageUrl?: string;
|
|
63
|
-
seo?: { title?: string; description?: string };
|
|
64
|
-
navigation?: {
|
|
65
|
-
main?: RawNavItem[];
|
|
66
|
-
cta?: { label: string; href: string } | null;
|
|
67
|
-
};
|
|
68
|
-
theme?: { primary?: string; accent?: string; font?: string };
|
|
69
|
-
footer?: {
|
|
70
|
-
columns?: Array<{ label: string; items: Array<{ label: string; href: string }> }>;
|
|
71
|
-
social?: Array<{ label: string; href: string }>;
|
|
72
|
-
tagline?: string;
|
|
73
|
-
copyright?: string;
|
|
74
|
-
};
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
function readRichText(
|
|
78
|
-
properties: Record<string, unknown>,
|
|
79
|
-
field: string
|
|
80
|
-
): string {
|
|
81
|
-
const prop = properties[field] as
|
|
82
|
-
| { rich_text?: Array<{ plain_text?: string }> }
|
|
83
|
-
| undefined;
|
|
84
|
-
if (!prop?.rich_text?.length) return "";
|
|
85
|
-
return prop.rich_text.map((t) => t.plain_text ?? "").join("").trim();
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function readUrl(
|
|
89
|
-
properties: Record<string, unknown>,
|
|
90
|
-
field: string
|
|
91
|
-
): string | null {
|
|
92
|
-
const prop = properties[field] as
|
|
93
|
-
| { url?: string | null }
|
|
94
|
-
| undefined;
|
|
95
|
-
return prop?.url ?? null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function readSelect(
|
|
99
|
-
properties: Record<string, unknown>,
|
|
100
|
-
field: string
|
|
101
|
-
): string {
|
|
102
|
-
const prop = properties[field] as
|
|
103
|
-
| { select?: { name?: string } | null }
|
|
104
|
-
| undefined;
|
|
105
|
-
return prop?.select?.name?.trim() ?? "";
|
|
62
|
+
function readString(value: unknown): string {
|
|
63
|
+
if (typeof value === "string") return value;
|
|
64
|
+
if (Array.isArray(value)) return value.join("");
|
|
65
|
+
return "";
|
|
106
66
|
}
|
|
107
67
|
|
|
108
|
-
function
|
|
109
|
-
properties: Record<string, unknown>,
|
|
110
|
-
field: string,
|
|
111
|
-
fallback: T
|
|
112
|
-
): T {
|
|
113
|
-
const raw = readRichText(properties, field);
|
|
68
|
+
function parseJson<T>(raw: string, fallback: T): T {
|
|
114
69
|
if (!raw) return fallback;
|
|
115
70
|
try {
|
|
116
71
|
return JSON.parse(raw) as T;
|
|
@@ -132,8 +87,8 @@ export function getStaticSiteSettings(): SiteConfig {
|
|
|
132
87
|
* Resolve the current site settings.
|
|
133
88
|
*
|
|
134
89
|
* Order of precedence for each field:
|
|
135
|
-
* 1. Notion
|
|
136
|
-
* contains
|
|
90
|
+
* 1. Notion rows (if `site-settings` data source is reachable and
|
|
91
|
+
* contains published rows)
|
|
137
92
|
* 2. `fallbackSiteConfig` (so a Notion outage never breaks the
|
|
138
93
|
* home page or SEO metadata)
|
|
139
94
|
*
|
|
@@ -141,14 +96,14 @@ export function getStaticSiteSettings(): SiteConfig {
|
|
|
141
96
|
* `site-settings:v1` for 5 minutes per process. Bump the cache key
|
|
142
97
|
* suffix to invalidate globally after a breaking schema change.
|
|
143
98
|
*/
|
|
144
|
-
export async function getSiteSettings(): Promise<SiteConfig> {
|
|
99
|
+
export async function getSiteSettings(locale?: string): Promise<SiteConfig> {
|
|
145
100
|
const kv = readKv();
|
|
146
101
|
if (kv) {
|
|
147
102
|
const cached = await kv.get<SiteConfig>(CACHE_KEY, "json");
|
|
148
103
|
if (cached) return cached;
|
|
149
104
|
}
|
|
150
105
|
|
|
151
|
-
const merged = await loadFromNotion();
|
|
106
|
+
const merged = await loadFromNotion(locale);
|
|
152
107
|
|
|
153
108
|
if (kv) {
|
|
154
109
|
// Best-effort write. KV failure shouldn't break the page.
|
|
@@ -160,7 +115,7 @@ export async function getSiteSettings(): Promise<SiteConfig> {
|
|
|
160
115
|
return merged;
|
|
161
116
|
}
|
|
162
117
|
|
|
163
|
-
async function loadFromNotion(): Promise<SiteConfig> {
|
|
118
|
+
async function loadFromNotion(locale?: string): Promise<SiteConfig> {
|
|
164
119
|
// `hasNotionModelConfig` reads the env from the active request
|
|
165
120
|
// context. If Notion isn't configured we return the fallback
|
|
166
121
|
// untouched — never throw, so the home page can't 500 because of
|
|
@@ -179,7 +134,9 @@ async function loadFromNotion(): Promise<SiteConfig> {
|
|
|
179
134
|
ReturnType<typeof listGenericNotionContent<typeof siteSettingsSource.source.fields>>
|
|
180
135
|
>;
|
|
181
136
|
try {
|
|
182
|
-
items =
|
|
137
|
+
items = locale
|
|
138
|
+
? await listGenericNotionContentForLocale(siteSettingsSource, locale)
|
|
139
|
+
: await listGenericNotionContent(siteSettingsSource);
|
|
183
140
|
} catch {
|
|
184
141
|
return fallbackSiteConfig;
|
|
185
142
|
}
|
|
@@ -187,124 +144,87 @@ async function loadFromNotion(): Promise<SiteConfig> {
|
|
|
187
144
|
return fallbackSiteConfig;
|
|
188
145
|
}
|
|
189
146
|
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
tagline: row.properties.tagline
|
|
200
|
-
? Array.isArray(row.properties.tagline)
|
|
201
|
-
? row.properties.tagline[0]
|
|
202
|
-
: (row.properties.tagline as string)
|
|
203
|
-
: undefined,
|
|
204
|
-
description: row.description || undefined,
|
|
205
|
-
socialImageUrl: row.coverImage || undefined,
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
// The Notion mapper doesn't know about every field we expose
|
|
209
|
-
// (e.g. `defaultLocale` lives in a `Select` column). Read it
|
|
210
|
-
// straight off the raw Notion page object that
|
|
211
|
-
// `listGenericNotionContent` returns — its `properties` map
|
|
212
|
-
// includes everything Notion sent us, not just the mapped ones.
|
|
213
|
-
const extra = (row as unknown as { properties?: Record<string, unknown> })
|
|
214
|
-
.properties;
|
|
215
|
-
if (extra && typeof extra === "object") {
|
|
216
|
-
const defaultLocale = readSelect(extra, "Default Locale");
|
|
217
|
-
if (defaultLocale) raw.defaultLocale = defaultLocale;
|
|
218
|
-
const tagline = readRichText(extra, "Tagline");
|
|
219
|
-
if (tagline) raw.tagline = tagline;
|
|
220
|
-
const socialImage = readUrl(extra, "Social Image");
|
|
221
|
-
if (socialImage) raw.socialImageUrl = socialImage;
|
|
222
|
-
|
|
223
|
-
// SEO
|
|
224
|
-
const metaTitle = readRichText(extra, "Meta Title");
|
|
225
|
-
const metaDescription = readRichText(extra, "Meta Description");
|
|
226
|
-
if (metaTitle || metaDescription) {
|
|
227
|
-
raw.seo = {
|
|
228
|
-
title: metaTitle || raw.name,
|
|
229
|
-
description: metaDescription || raw.description,
|
|
230
|
-
};
|
|
147
|
+
// Aggregate rows into a key-value map keyed by the `Key` field.
|
|
148
|
+
// Each row carries one setting item; the runtime loader merges
|
|
149
|
+
// them into a single `SiteConfig`.
|
|
150
|
+
const settingsMap: Record<string, string> = {};
|
|
151
|
+
for (const item of items) {
|
|
152
|
+
const key = readString(item.properties.key);
|
|
153
|
+
const value = readString(item.properties.value);
|
|
154
|
+
if (key) {
|
|
155
|
+
settingsMap[key] = value;
|
|
231
156
|
}
|
|
232
|
-
|
|
233
|
-
if (ogImage) raw.ogImageUrl = ogImage;
|
|
157
|
+
}
|
|
234
158
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
159
|
+
// Map known keys to the SiteConfig structure. Missing keys fall
|
|
160
|
+
// back to `fallbackSiteConfig`.
|
|
161
|
+
const name = settingsMap.name ?? fallbackSiteConfig.name;
|
|
162
|
+
const tagline = settingsMap.tagline ?? fallbackSiteConfig.tagline;
|
|
163
|
+
const description = settingsMap.description ?? fallbackSiteConfig.description;
|
|
164
|
+
const socialImageUrl = settingsMap.socialImage ?? fallbackSiteConfig.socialImageUrl;
|
|
165
|
+
const ogImageUrl = settingsMap.ogImage ?? socialImageUrl ?? fallbackSiteConfig.ogImageUrl;
|
|
166
|
+
|
|
167
|
+
const metaTitle = settingsMap.metaTitle ?? "";
|
|
168
|
+
const metaDescription = settingsMap.metaDescription ?? "";
|
|
169
|
+
const seo = {
|
|
170
|
+
title: metaTitle.trim() || fallbackSiteConfig.seo.title,
|
|
171
|
+
description: metaDescription.trim() || fallbackSiteConfig.seo.description,
|
|
172
|
+
};
|
|
243
173
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
174
|
+
const navItems = parseJson<RawNavItem[]>(
|
|
175
|
+
settingsMap.items ?? "[]",
|
|
176
|
+
fallbackSiteConfig.navigation.main as RawNavItem[]
|
|
177
|
+
);
|
|
178
|
+
const navCta = parseJson<{ label: string; href: string } | null>(
|
|
179
|
+
settingsMap.cta ?? "null",
|
|
180
|
+
fallbackSiteConfig.navigation.cta
|
|
181
|
+
);
|
|
182
|
+
const navigation = {
|
|
183
|
+
...fallbackSiteConfig.navigation,
|
|
184
|
+
main: navItems.length ? navItems : fallbackSiteConfig.navigation.main,
|
|
185
|
+
cta: navCta ?? fallbackSiteConfig.navigation.cta,
|
|
186
|
+
};
|
|
251
187
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
188
|
+
const primary = (settingsMap.primaryColor as SiteConfig["theme"]["primary"]) ||
|
|
189
|
+
fallbackSiteConfig.theme.primary;
|
|
190
|
+
const accent = (settingsMap.accentColor as SiteConfig["theme"]["accent"]) ||
|
|
191
|
+
fallbackSiteConfig.theme.accent;
|
|
192
|
+
const font = (settingsMap.fontFamily as SiteConfig["theme"]["font"]) ||
|
|
193
|
+
fallbackSiteConfig.theme.font;
|
|
194
|
+
const theme = { primary, accent, font };
|
|
195
|
+
|
|
196
|
+
type FooterColumn = {
|
|
197
|
+
label: string;
|
|
198
|
+
items: Array<{ label: string; href: string }>;
|
|
199
|
+
};
|
|
200
|
+
const footerColumns = parseJson<FooterColumn[]>(
|
|
201
|
+
settingsMap.columns ?? "[]",
|
|
202
|
+
fallbackSiteConfig.footer.columns as FooterColumn[]
|
|
203
|
+
);
|
|
204
|
+
const footerSocial = parseJson<Array<{ label: string; href: string }>>(
|
|
205
|
+
settingsMap.socialLinks ?? "[]",
|
|
206
|
+
fallbackSiteConfig.footer.social
|
|
207
|
+
);
|
|
208
|
+
const footerTagline = settingsMap.footerTagline ?? fallbackSiteConfig.footer.tagline;
|
|
209
|
+
const footerCopyright = settingsMap.copyright ?? fallbackSiteConfig.footer.copyright;
|
|
210
|
+
const footer = {
|
|
211
|
+
columns: footerColumns,
|
|
212
|
+
social: footerSocial,
|
|
213
|
+
tagline: footerTagline,
|
|
214
|
+
copyright: footerCopyright,
|
|
215
|
+
};
|
|
267
216
|
|
|
268
217
|
return {
|
|
269
218
|
...fallbackSiteConfig,
|
|
270
|
-
name:
|
|
271
|
-
tagline:
|
|
272
|
-
description:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
title: raw.seo?.title?.trim() || fallbackSiteConfig.seo.title,
|
|
280
|
-
description:
|
|
281
|
-
raw.seo?.description?.trim() || fallbackSiteConfig.seo.description,
|
|
282
|
-
},
|
|
283
|
-
navigation: {
|
|
284
|
-
...fallbackSiteConfig.navigation,
|
|
285
|
-
main: raw.navigation?.main?.length
|
|
286
|
-
? raw.navigation.main
|
|
287
|
-
: fallbackSiteConfig.navigation.main,
|
|
288
|
-
cta: raw.navigation?.cta ?? fallbackSiteConfig.navigation.cta,
|
|
289
|
-
},
|
|
290
|
-
theme: {
|
|
291
|
-
primary:
|
|
292
|
-
(raw.theme?.primary as SiteConfig["theme"]["primary"]) ??
|
|
293
|
-
fallbackSiteConfig.theme.primary,
|
|
294
|
-
accent:
|
|
295
|
-
(raw.theme?.accent as SiteConfig["theme"]["accent"]) ??
|
|
296
|
-
fallbackSiteConfig.theme.accent,
|
|
297
|
-
font:
|
|
298
|
-
(raw.theme?.font as SiteConfig["theme"]["font"]) ??
|
|
299
|
-
fallbackSiteConfig.theme.font,
|
|
300
|
-
},
|
|
301
|
-
footer: {
|
|
302
|
-
columns: raw.footer?.columns ?? fallbackSiteConfig.footer.columns,
|
|
303
|
-
social: raw.footer?.social ?? fallbackSiteConfig.footer.social,
|
|
304
|
-
tagline: raw.footer?.tagline ?? fallbackSiteConfig.footer.tagline,
|
|
305
|
-
copyright:
|
|
306
|
-
raw.footer?.copyright ?? fallbackSiteConfig.footer.copyright,
|
|
307
|
-
},
|
|
219
|
+
name: name.trim() || fallbackSiteConfig.name,
|
|
220
|
+
tagline: tagline.trim() || name.trim() || fallbackSiteConfig.tagline,
|
|
221
|
+
description: description.trim() || fallbackSiteConfig.description,
|
|
222
|
+
socialImageUrl: socialImageUrl ?? fallbackSiteConfig.socialImageUrl,
|
|
223
|
+
ogImageUrl: ogImageUrl ?? fallbackSiteConfig.ogImageUrl,
|
|
224
|
+
seo,
|
|
225
|
+
navigation,
|
|
226
|
+
theme,
|
|
227
|
+
footer,
|
|
308
228
|
};
|
|
309
229
|
}
|
|
310
230
|
|
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
// Locale-aware site settings merge. The `site-settings` Notion
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Locale-aware site settings merge. The `site-settings` Notion rows
|
|
2
|
+
// hold the global config (key-value pairs grouped by section); the
|
|
3
|
+
// `site-settings-translations` rows hold locale-specific `Value`
|
|
4
|
+
// overrides. `Section`, `Key`, and `Type` are the same across
|
|
5
|
+
// locales — only `Value` is translated. Missing locale copy falls
|
|
6
|
+
// back to the default-locale translation.
|
|
5
7
|
|
|
8
|
+
import {
|
|
9
|
+
mapNotionPageToLocalizedContentTranslation,
|
|
10
|
+
listGenericNotionContentForLocale,
|
|
11
|
+
} from "@notionx/core";
|
|
6
12
|
import { i18n } from "@/lib/i18n";
|
|
7
13
|
import { siteSettingsContract } from "@/lib/locale-contract";
|
|
14
|
+
import { siteSettingsTranslationsSource } from "@/lib/content/models";
|
|
15
|
+
import type { NotionPageLike } from "@notionx/core";
|
|
8
16
|
|
|
9
17
|
export type SiteSettingsTranslation = {
|
|
10
18
|
pageId: string;
|
|
11
19
|
sourcePageId: string;
|
|
12
20
|
locale: string;
|
|
13
|
-
|
|
14
|
-
description: string;
|
|
15
|
-
seoTitle: string;
|
|
16
|
-
seoDescription: string;
|
|
17
|
-
navLabels: Record<string, string>;
|
|
18
|
-
footerLabels: Record<string, string>;
|
|
19
|
-
globalFallbackCopy: string;
|
|
21
|
+
value: string;
|
|
20
22
|
published: boolean;
|
|
21
23
|
};
|
|
22
24
|
|
|
@@ -28,3 +30,24 @@ export function pickSiteSettingsTranslation(
|
|
|
28
30
|
if (direct) return direct;
|
|
29
31
|
return rows.find((row) => row.locale === i18n.defaultLocale) ?? null;
|
|
30
32
|
}
|
|
33
|
+
|
|
34
|
+
export async function listSiteSettingsTranslations(
|
|
35
|
+
locale: string
|
|
36
|
+
): Promise<SiteSettingsTranslation[]> {
|
|
37
|
+
if (!siteSettingsTranslationsSource) return [];
|
|
38
|
+
const pages = await listGenericNotionContentForLocale(siteSettingsTranslationsSource, locale);
|
|
39
|
+
return pages
|
|
40
|
+
.map((p) => mapNotionPageToLocalizedContentTranslation<SiteSettingsTranslation>(p as unknown as NotionPageLike, {
|
|
41
|
+
fields: {
|
|
42
|
+
title: "Title",
|
|
43
|
+
source: "Source",
|
|
44
|
+
locale: "Locale",
|
|
45
|
+
slug: "Title",
|
|
46
|
+
published: "Published",
|
|
47
|
+
},
|
|
48
|
+
extraFields: {
|
|
49
|
+
value: "Value",
|
|
50
|
+
},
|
|
51
|
+
}))
|
|
52
|
+
.filter((row): row is SiteSettingsTranslation => row !== null);
|
|
53
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Locale prefix middleware. Parses the `/{locale}` path prefix for
|
|
2
|
+
// non-default locales, sets `x-notionx-locale` on the request so
|
|
3
|
+
// server components and data loaders can read it, and rewrites the
|
|
4
|
+
// URL to strip the prefix so the App Router matches the same route
|
|
5
|
+
// handlers for every locale.
|
|
6
|
+
//
|
|
7
|
+
// Default locale (`{{defaultLocale}}`) stays unprefixed — `/blog`
|
|
8
|
+
// serves the default locale, `/{{defaultLocale}}/blog` redirects to
|
|
9
|
+
// `/blog` to avoid duplicate-content URLs.
|
|
10
|
+
|
|
11
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
12
|
+
|
|
13
|
+
const SUPPORTED_LOCALES = {{supportedLocalesJson}} as readonly string[];
|
|
14
|
+
const DEFAULT_LOCALE = "{{defaultLocale}}";
|
|
15
|
+
|
|
16
|
+
export function middleware(request: NextRequest) {
|
|
17
|
+
const { pathname } = request.nextUrl;
|
|
18
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
19
|
+
const firstSegment = segments[0];
|
|
20
|
+
|
|
21
|
+
// No locale prefix — default locale.
|
|
22
|
+
if (!firstSegment || !SUPPORTED_LOCALES.includes(firstSegment)) {
|
|
23
|
+
const requestHeaders = new Headers(request.headers);
|
|
24
|
+
requestHeaders.set("x-notionx-locale", DEFAULT_LOCALE);
|
|
25
|
+
return NextResponse.next({
|
|
26
|
+
request: { headers: requestHeaders },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Default locale with explicit prefix — redirect to unprefixed URL
|
|
31
|
+
// to keep canonical URLs clean.
|
|
32
|
+
if (firstSegment === DEFAULT_LOCALE) {
|
|
33
|
+
const url = request.nextUrl.clone();
|
|
34
|
+
url.pathname = pathname.replace(`/${DEFAULT_LOCALE}`, "") || "/";
|
|
35
|
+
return NextResponse.redirect(url, 308);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Non-default locale — strip the prefix and pass the locale via header.
|
|
39
|
+
const locale = firstSegment;
|
|
40
|
+
const stripped = pathname.replace(`/${locale}`, "") || "/";
|
|
41
|
+
const url = request.nextUrl.clone();
|
|
42
|
+
url.pathname = stripped;
|
|
43
|
+
const requestHeaders = new Headers(request.headers);
|
|
44
|
+
requestHeaders.set("x-notionx-locale", locale);
|
|
45
|
+
return NextResponse.rewrite(url, {
|
|
46
|
+
request: { headers: requestHeaders },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const config = {
|
|
51
|
+
// Match all paths except static assets, API routes, Next.js
|
|
52
|
+
// internals, and admin (admin is single-locale by design).
|
|
53
|
+
matcher: [
|
|
54
|
+
"/((?!api|_next/static|_next/image|favicon.ico|admin|.*\\.).*)",
|
|
55
|
+
],
|
|
56
|
+
};
|
|
@@ -43,10 +43,19 @@ export default {
|
|
|
43
43
|
// `getRequestEnv()`. See `lib/site/request-env.ts` for the
|
|
44
44
|
// rationale — we cannot use `cloudflare:workers.getRequestContext`
|
|
45
45
|
// because that module does not export it.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
//
|
|
47
|
+
// The locale prefix middleware (Next.js middleware) sets the
|
|
48
|
+
// `x-notionx-locale` header on every request. Thread it into
|
|
49
|
+
// the request env so data loaders can read it via
|
|
50
|
+
// `getRequestLocale()` without reaching into headers themselves.
|
|
51
|
+
const locale = request.headers.get("x-notionx-locale") ?? undefined;
|
|
52
|
+
return runWithRequestEnv(
|
|
53
|
+
{ ...env, ...(locale ? { NOTIONX_LOCALE: locale } : {}) },
|
|
54
|
+
async () => {
|
|
55
|
+
const notionxResponse = await notionx.fetch(request, env, ctx);
|
|
56
|
+
if (notionxResponse) return notionxResponse;
|
|
57
|
+
return handler.fetch(request, env, ctx);
|
|
58
|
+
}
|
|
59
|
+
);
|
|
51
60
|
},
|
|
52
61
|
};
|
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
"vars": {
|
|
40
40
|
"SITE_URL": "https://{{projectNameLower}}.example.com",
|
|
41
41
|
"NOTION_BLOCKS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_BLOCKS_DATA_SOURCE_ID",
|
|
42
|
-
"NOTION_SITE_SETTINGS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_SITE_SETTINGS_DATA_SOURCE_ID"
|
|
42
|
+
"NOTION_SITE_SETTINGS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_SITE_SETTINGS_DATA_SOURCE_ID",
|
|
43
|
+
"NOTION_BLOG_TRANSLATIONS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_BLOG_TRANSLATIONS_DATA_SOURCE_ID",
|
|
44
|
+
"NOTION_PAGES_TRANSLATIONS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_PAGES_TRANSLATIONS_DATA_SOURCE_ID",
|
|
45
|
+
"NOTION_BLOCKS_TRANSLATIONS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_BLOCKS_TRANSLATIONS_DATA_SOURCE_ID",
|
|
46
|
+
"NOTION_SITE_SETTINGS_TRANSLATIONS_DATA_SOURCE_ID": "REPLACE_WITH_NOTION_SITE_SETTINGS_TRANSLATIONS_DATA_SOURCE_ID"
|
|
43
47
|
}
|
|
44
48
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@notionx/create-notionx-app",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Scaffold a new vinext (Next.js on Cloudflare Workers + D1 + R2) project backed by @notionx/core, with one-step provisioning of D1 / KV / R2 / Turnstile / Notion / Resend / Google OAuth.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|