@notionx/create-notionx-app 1.0.0 → 2.0.0
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 +18 -2
- package/dist/templates/lib/content/models.ts.tmpl +6 -0
- package/dist/templates/lib/pages/source.ts.tmpl +136 -334
- package/dist/templates/lib/pages/translations.ts.tmpl +23 -1
- package/dist/templates/lib/site/request-env.ts.tmpl +16 -0
- package/dist/templates/lib/site/settings.ts.tmpl +96 -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
|
|
|
@@ -53,64 +56,13 @@ type RawNavItem = {
|
|
|
53
56
|
children?: RawNavItem[];
|
|
54
57
|
};
|
|
55
58
|
|
|
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() ?? "";
|
|
59
|
+
function readString(value: unknown): string {
|
|
60
|
+
if (typeof value === "string") return value;
|
|
61
|
+
if (Array.isArray(value)) return value.join("");
|
|
62
|
+
return "";
|
|
106
63
|
}
|
|
107
64
|
|
|
108
|
-
function
|
|
109
|
-
properties: Record<string, unknown>,
|
|
110
|
-
field: string,
|
|
111
|
-
fallback: T
|
|
112
|
-
): T {
|
|
113
|
-
const raw = readRichText(properties, field);
|
|
65
|
+
function parseJson<T>(raw: string, fallback: T): T {
|
|
114
66
|
if (!raw) return fallback;
|
|
115
67
|
try {
|
|
116
68
|
return JSON.parse(raw) as T;
|
|
@@ -132,8 +84,8 @@ export function getStaticSiteSettings(): SiteConfig {
|
|
|
132
84
|
* Resolve the current site settings.
|
|
133
85
|
*
|
|
134
86
|
* Order of precedence for each field:
|
|
135
|
-
* 1. Notion
|
|
136
|
-
* contains
|
|
87
|
+
* 1. Notion rows (if `site-settings` data source is reachable and
|
|
88
|
+
* contains published rows)
|
|
137
89
|
* 2. `fallbackSiteConfig` (so a Notion outage never breaks the
|
|
138
90
|
* home page or SEO metadata)
|
|
139
91
|
*
|
|
@@ -141,14 +93,14 @@ export function getStaticSiteSettings(): SiteConfig {
|
|
|
141
93
|
* `site-settings:v1` for 5 minutes per process. Bump the cache key
|
|
142
94
|
* suffix to invalidate globally after a breaking schema change.
|
|
143
95
|
*/
|
|
144
|
-
export async function getSiteSettings(): Promise<SiteConfig> {
|
|
96
|
+
export async function getSiteSettings(locale?: string): Promise<SiteConfig> {
|
|
145
97
|
const kv = readKv();
|
|
146
98
|
if (kv) {
|
|
147
99
|
const cached = await kv.get<SiteConfig>(CACHE_KEY, "json");
|
|
148
100
|
if (cached) return cached;
|
|
149
101
|
}
|
|
150
102
|
|
|
151
|
-
const merged = await loadFromNotion();
|
|
103
|
+
const merged = await loadFromNotion(locale);
|
|
152
104
|
|
|
153
105
|
if (kv) {
|
|
154
106
|
// Best-effort write. KV failure shouldn't break the page.
|
|
@@ -160,7 +112,7 @@ export async function getSiteSettings(): Promise<SiteConfig> {
|
|
|
160
112
|
return merged;
|
|
161
113
|
}
|
|
162
114
|
|
|
163
|
-
async function loadFromNotion(): Promise<SiteConfig> {
|
|
115
|
+
async function loadFromNotion(locale?: string): Promise<SiteConfig> {
|
|
164
116
|
// `hasNotionModelConfig` reads the env from the active request
|
|
165
117
|
// context. If Notion isn't configured we return the fallback
|
|
166
118
|
// untouched — never throw, so the home page can't 500 because of
|
|
@@ -179,7 +131,9 @@ async function loadFromNotion(): Promise<SiteConfig> {
|
|
|
179
131
|
ReturnType<typeof listGenericNotionContent<typeof siteSettingsSource.source.fields>>
|
|
180
132
|
>;
|
|
181
133
|
try {
|
|
182
|
-
items =
|
|
134
|
+
items = locale
|
|
135
|
+
? await listGenericNotionContentForLocale(siteSettingsSource, locale)
|
|
136
|
+
: await listGenericNotionContent(siteSettingsSource);
|
|
183
137
|
} catch {
|
|
184
138
|
return fallbackSiteConfig;
|
|
185
139
|
}
|
|
@@ -187,124 +141,87 @@ async function loadFromNotion(): Promise<SiteConfig> {
|
|
|
187
141
|
return fallbackSiteConfig;
|
|
188
142
|
}
|
|
189
143
|
|
|
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
|
-
};
|
|
144
|
+
// Aggregate rows into a key-value map keyed by the `Key` field.
|
|
145
|
+
// Each row carries one setting item; the runtime loader merges
|
|
146
|
+
// them into a single `SiteConfig`.
|
|
147
|
+
const settingsMap: Record<string, string> = {};
|
|
148
|
+
for (const item of items) {
|
|
149
|
+
const key = readString(item.properties.key);
|
|
150
|
+
const value = readString(item.properties.value);
|
|
151
|
+
if (key) {
|
|
152
|
+
settingsMap[key] = value;
|
|
231
153
|
}
|
|
232
|
-
|
|
233
|
-
if (ogImage) raw.ogImageUrl = ogImage;
|
|
154
|
+
}
|
|
234
155
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
156
|
+
// Map known keys to the SiteConfig structure. Missing keys fall
|
|
157
|
+
// back to `fallbackSiteConfig`.
|
|
158
|
+
const name = settingsMap.name ?? fallbackSiteConfig.name;
|
|
159
|
+
const tagline = settingsMap.tagline ?? fallbackSiteConfig.tagline;
|
|
160
|
+
const description = settingsMap.description ?? fallbackSiteConfig.description;
|
|
161
|
+
const socialImageUrl = settingsMap.socialImage ?? fallbackSiteConfig.socialImageUrl;
|
|
162
|
+
const ogImageUrl = settingsMap.ogImage ?? socialImageUrl ?? fallbackSiteConfig.ogImageUrl;
|
|
163
|
+
|
|
164
|
+
const metaTitle = settingsMap.metaTitle ?? "";
|
|
165
|
+
const metaDescription = settingsMap.metaDescription ?? "";
|
|
166
|
+
const seo = {
|
|
167
|
+
title: metaTitle.trim() || fallbackSiteConfig.seo.title,
|
|
168
|
+
description: metaDescription.trim() || fallbackSiteConfig.seo.description,
|
|
169
|
+
};
|
|
243
170
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
171
|
+
const navItems = parseJson<RawNavItem[]>(
|
|
172
|
+
settingsMap.items ?? "[]",
|
|
173
|
+
fallbackSiteConfig.navigation.main as RawNavItem[]
|
|
174
|
+
);
|
|
175
|
+
const navCta = parseJson<{ label: string; href: string } | null>(
|
|
176
|
+
settingsMap.cta ?? "null",
|
|
177
|
+
fallbackSiteConfig.navigation.cta
|
|
178
|
+
);
|
|
179
|
+
const navigation = {
|
|
180
|
+
...fallbackSiteConfig.navigation,
|
|
181
|
+
main: navItems.length ? navItems : fallbackSiteConfig.navigation.main,
|
|
182
|
+
cta: navCta ?? fallbackSiteConfig.navigation.cta,
|
|
183
|
+
};
|
|
251
184
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
185
|
+
const primary = (settingsMap.primaryColor as SiteConfig["theme"]["primary"]) ||
|
|
186
|
+
fallbackSiteConfig.theme.primary;
|
|
187
|
+
const accent = (settingsMap.accentColor as SiteConfig["theme"]["accent"]) ||
|
|
188
|
+
fallbackSiteConfig.theme.accent;
|
|
189
|
+
const font = (settingsMap.fontFamily as SiteConfig["theme"]["font"]) ||
|
|
190
|
+
fallbackSiteConfig.theme.font;
|
|
191
|
+
const theme = { primary, accent, font };
|
|
192
|
+
|
|
193
|
+
type FooterColumn = {
|
|
194
|
+
label: string;
|
|
195
|
+
items: Array<{ label: string; href: string }>;
|
|
196
|
+
};
|
|
197
|
+
const footerColumns = parseJson<FooterColumn[]>(
|
|
198
|
+
settingsMap.columns ?? "[]",
|
|
199
|
+
fallbackSiteConfig.footer.columns as FooterColumn[]
|
|
200
|
+
);
|
|
201
|
+
const footerSocial = parseJson<Array<{ label: string; href: string }>>(
|
|
202
|
+
settingsMap.socialLinks ?? "[]",
|
|
203
|
+
fallbackSiteConfig.footer.social
|
|
204
|
+
);
|
|
205
|
+
const footerTagline = settingsMap.footerTagline ?? fallbackSiteConfig.footer.tagline;
|
|
206
|
+
const footerCopyright = settingsMap.copyright ?? fallbackSiteConfig.footer.copyright;
|
|
207
|
+
const footer = {
|
|
208
|
+
columns: footerColumns,
|
|
209
|
+
social: footerSocial,
|
|
210
|
+
tagline: footerTagline,
|
|
211
|
+
copyright: footerCopyright,
|
|
212
|
+
};
|
|
267
213
|
|
|
268
214
|
return {
|
|
269
215
|
...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
|
-
},
|
|
216
|
+
name: name.trim() || fallbackSiteConfig.name,
|
|
217
|
+
tagline: tagline.trim() || name.trim() || fallbackSiteConfig.tagline,
|
|
218
|
+
description: description.trim() || fallbackSiteConfig.description,
|
|
219
|
+
socialImageUrl: socialImageUrl ?? fallbackSiteConfig.socialImageUrl,
|
|
220
|
+
ogImageUrl: ogImageUrl ?? fallbackSiteConfig.ogImageUrl,
|
|
221
|
+
seo,
|
|
222
|
+
navigation,
|
|
223
|
+
theme,
|
|
224
|
+
footer,
|
|
308
225
|
};
|
|
309
226
|
}
|
|
310
227
|
|
|
@@ -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.0",
|
|
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": {
|