@notionx/core 0.1.1 → 0.1.3

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.
Files changed (88) hide show
  1. package/dist/auth/index.d.ts +1 -1
  2. package/dist/auth/index.js.map +1 -1
  3. package/dist/auth/rate-limit.js.map +1 -1
  4. package/dist/auth/routes/google-callback.js.map +1 -1
  5. package/dist/auth/routes/google.js.map +1 -1
  6. package/dist/auth/routes/index.js.map +1 -1
  7. package/dist/auth/routes/verify-email.js.map +1 -1
  8. package/dist/auth/routes/viewer.js.map +1 -1
  9. package/dist/auth/turnstile.js.map +1 -1
  10. package/dist/auth/user-session.d.ts +1 -1
  11. package/dist/auth/user-session.js.map +1 -1
  12. package/dist/auth/users.js.map +1 -1
  13. package/dist/content/index.d.ts +3 -2
  14. package/dist/content/index.js +176 -60
  15. package/dist/content/index.js.map +1 -1
  16. package/dist/content/localized.d.ts +67 -0
  17. package/dist/content/localized.js +170 -0
  18. package/dist/content/localized.js.map +1 -0
  19. package/dist/content/revalidate.d.ts +2 -1
  20. package/dist/content/revalidate.js +5 -28
  21. package/dist/content/revalidate.js.map +1 -1
  22. package/dist/content/search-index.d.ts +1 -1
  23. package/dist/content/search-index.js.map +1 -1
  24. package/dist/content/search.d.ts +2 -5
  25. package/dist/content/search.js +3 -32
  26. package/dist/content/search.js.map +1 -1
  27. package/dist/email/index.js.map +1 -1
  28. package/dist/{env-C5qu-0R-.d.ts → env-hoez1e-n.d.ts} +0 -4
  29. package/dist/i18n/index.d.ts +18 -24
  30. package/dist/i18n/index.js +29 -54
  31. package/dist/i18n/index.js.map +1 -1
  32. package/dist/index.js +0 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/internal/admin/index.js.map +1 -1
  35. package/dist/media/index.js +3 -2
  36. package/dist/media/index.js.map +1 -1
  37. package/dist/media/routes/index.js +0 -1
  38. package/dist/media/routes/index.js.map +1 -1
  39. package/dist/media/routes/notion-media.js +0 -1
  40. package/dist/media/routes/notion-media.js.map +1 -1
  41. package/dist/notion/config.d.ts +1 -4
  42. package/dist/notion/config.js +1 -23
  43. package/dist/notion/config.js.map +1 -1
  44. package/dist/notion/content-cache.d.ts +1 -1
  45. package/dist/notion/generic-source.js +0 -1
  46. package/dist/notion/generic-source.js.map +1 -1
  47. package/dist/notion/index.d.ts +4 -4
  48. package/dist/notion/index.js +8 -23
  49. package/dist/notion/index.js.map +1 -1
  50. package/dist/notion/mappers.js.map +1 -1
  51. package/dist/notion/media.d.ts +1 -1
  52. package/dist/notion/media.js +1 -1
  53. package/dist/notion/media.js.map +1 -1
  54. package/dist/notion/property-mappers.d.ts +2 -1
  55. package/dist/notion/property-mappers.js +7 -0
  56. package/dist/notion/property-mappers.js.map +1 -1
  57. package/dist/notion/routes/index.d.ts +1 -1
  58. package/dist/notion/routes/index.js +0 -1
  59. package/dist/notion/routes/index.js.map +1 -1
  60. package/dist/notion/routes/webhook.d.ts +1 -1
  61. package/dist/notion/routes/webhook.js +0 -1
  62. package/dist/notion/routes/webhook.js.map +1 -1
  63. package/dist/notion/types.d.ts +1 -73
  64. package/dist/notion/webhook.d.ts +1 -1
  65. package/dist/notion/webhook.js +0 -1
  66. package/dist/notion/webhook.js.map +1 -1
  67. package/dist/pages/index.d.ts +117 -0
  68. package/dist/pages/index.js +487 -0
  69. package/dist/pages/index.js.map +1 -0
  70. package/dist/platform/current.d.ts +1 -1
  71. package/dist/platform/current.js.map +1 -1
  72. package/dist/platform/index.d.ts +1 -1
  73. package/dist/platform/index.js.map +1 -1
  74. package/dist/platform/runtime.d.ts +1 -1
  75. package/dist/storage/index.js.map +1 -1
  76. package/dist/storage/routes/cdn.js.map +1 -1
  77. package/dist/storage/routes/files.js.map +1 -1
  78. package/dist/storage/routes/index.js.map +1 -1
  79. package/dist/util/index.d.ts +1 -1
  80. package/dist/util/index.js +1 -2
  81. package/dist/util/index.js.map +1 -1
  82. package/dist/worker/index.js +0 -1
  83. package/dist/worker/index.js.map +1 -1
  84. package/dist/worker/routes/content-revalidate.d.ts +1 -1
  85. package/dist/worker/routes/health.js.map +1 -1
  86. package/dist/worker/routes/index.d.ts +1 -1
  87. package/dist/worker/routes/index.js.map +1 -1
  88. package/package.json +14 -1
@@ -0,0 +1,170 @@
1
+ // src/notion/property-mappers.ts
2
+ function isRecord(value) {
3
+ return Boolean(value && typeof value === "object");
4
+ }
5
+ function getPlainText(parts) {
6
+ if (!Array.isArray(parts)) return "";
7
+ return parts.map((part) => part.plain_text ?? "").join("").trim();
8
+ }
9
+ function getProperty(properties, key) {
10
+ return properties[key];
11
+ }
12
+ function getRichTextProperty(properties, key) {
13
+ const property = getProperty(properties, key);
14
+ if (!property) return "";
15
+ if (property.type === "title") return getPlainText(property.title);
16
+ if (property.type === "rich_text") return getPlainText(property.rich_text);
17
+ if (property.type === "url") return String(property.url ?? "").trim();
18
+ if (property.type === "email") return String(property.email ?? "").trim();
19
+ if (property.type === "phone_number") {
20
+ return String(property.phone_number ?? "").trim();
21
+ }
22
+ return "";
23
+ }
24
+ function getSelectProperty(properties, key) {
25
+ const property = getProperty(properties, key);
26
+ if (property?.type !== "select") return "";
27
+ const select = property.select;
28
+ return String(select?.name ?? "").trim();
29
+ }
30
+ function getCheckboxProperty(properties, key) {
31
+ const property = getProperty(properties, key);
32
+ if (property?.type !== "checkbox") return false;
33
+ return Boolean(property.checkbox);
34
+ }
35
+ function getRelationPageIds(properties, key) {
36
+ const property = getProperty(properties, key);
37
+ if (property?.type !== "relation" || !Array.isArray(property.relation)) {
38
+ return [];
39
+ }
40
+ return property.relation.map((item) => String(item.id ?? "").trim()).filter(Boolean);
41
+ }
42
+ function getTagsProperty(properties, key) {
43
+ const property = getProperty(properties, key);
44
+ if (property?.type === "multi_select" && Array.isArray(property.multi_select)) {
45
+ return property.multi_select.map((item) => String(item.name ?? "").trim()).filter(Boolean);
46
+ }
47
+ if (property?.type === "select") {
48
+ const select = property.select;
49
+ const name = String(select?.name ?? "").trim();
50
+ return name ? [name] : [];
51
+ }
52
+ return [];
53
+ }
54
+ function isValidPublicSlug(slug) {
55
+ return /^[a-z0-9][a-z0-9-]{0,79}$/.test(slug);
56
+ }
57
+ function notionPageEditUrl(pageId, editBaseUrl) {
58
+ const compactPageId = pageId.replaceAll("-", "");
59
+ if (editBaseUrl?.includes("{pageId}")) {
60
+ return editBaseUrl.replaceAll("{pageId}", compactPageId);
61
+ }
62
+ return `https://www.notion.so/${compactPageId}`;
63
+ }
64
+ function compactNotionId(id) {
65
+ return id.replaceAll("-", "").toLowerCase();
66
+ }
67
+
68
+ // src/content/localized.ts
69
+ function readPublishedFlag(properties, field) {
70
+ const property = properties[field];
71
+ if (property?.type === "checkbox") return getCheckboxProperty(properties, field);
72
+ if (property?.type === "status") {
73
+ const status = property.status;
74
+ return String(status?.name ?? "").trim().toLowerCase() === "published";
75
+ }
76
+ if (property?.type === "select") {
77
+ const select = property.select;
78
+ return String(select?.name ?? "").trim().toLowerCase() === "published";
79
+ }
80
+ return false;
81
+ }
82
+ function readLocale(properties, field) {
83
+ return getSelectProperty(properties, field) || getRichTextProperty(properties, field);
84
+ }
85
+ function normalizeExtraField(definition) {
86
+ if (typeof definition === "string") return { field: definition, kind: "text" };
87
+ return { field: definition.field, kind: definition.kind ?? "text" };
88
+ }
89
+ function readExtraFields(properties, fields) {
90
+ const result = {};
91
+ for (const [key, definition] of Object.entries(fields ?? {})) {
92
+ const { field, kind } = normalizeExtraField(definition);
93
+ if (kind === "tags") result[key] = getTagsProperty(properties, field);
94
+ else if (kind === "select") result[key] = getSelectProperty(properties, field);
95
+ else if (kind === "checkbox") result[key] = getCheckboxProperty(properties, field);
96
+ else result[key] = getRichTextProperty(properties, field);
97
+ }
98
+ return result;
99
+ }
100
+ function mapNotionPageToLocalizedContentTranslation(page, input) {
101
+ const properties = isRecord(page.properties) ? page.properties : {};
102
+ const sourcePageIds = getRelationPageIds(properties, input.fields.source);
103
+ const locale = readLocale(properties, input.fields.locale);
104
+ const configuredSlug = getRichTextProperty(
105
+ properties,
106
+ input.fields.slug
107
+ ).toLowerCase();
108
+ const slug = isValidPublicSlug(configuredSlug) ? configuredSlug : "";
109
+ const title = getRichTextProperty(properties, input.fields.title);
110
+ const published = readPublishedFlag(properties, input.fields.published);
111
+ const sourceUrl = typeof page.public_url === "string" && page.public_url ? page.public_url : typeof page.url === "string" ? page.url : null;
112
+ if (!sourcePageIds[0] || !locale || !slug || !title || !published || input.isValidLocale && !input.isValidLocale(locale)) {
113
+ return null;
114
+ }
115
+ return {
116
+ pageId: page.id,
117
+ ...page.last_edited_time ? { updatedAt: page.last_edited_time } : {},
118
+ sourcePageId: sourcePageIds[0],
119
+ locale,
120
+ slug,
121
+ title,
122
+ seoTitle: input.fields.seoTitle ? getRichTextProperty(properties, input.fields.seoTitle) : "",
123
+ seoDescription: input.fields.seoDescription ? getRichTextProperty(properties, input.fields.seoDescription) : "",
124
+ published,
125
+ editUrl: notionPageEditUrl(page.id, input.editBaseUrl),
126
+ sourceUrl,
127
+ ...readExtraFields(properties, input.extraFields)
128
+ };
129
+ }
130
+ function localizeContentList(input) {
131
+ const translationsForLocale = input.translations.filter(
132
+ (translation) => input.getTranslationLocale(translation) === input.locale
133
+ );
134
+ if (translationsForLocale.length === 0 && input.locale === input.defaultLocale) {
135
+ const fallback = input.baseItems.map(input.fallback);
136
+ return input.sort ? fallback.sort(input.sort) : fallback;
137
+ }
138
+ const baseByPageId = new Map(
139
+ input.baseItems.map((item) => [compactNotionId(input.getBasePageId(item)), item])
140
+ );
141
+ const localized = translationsForLocale.map((translation) => {
142
+ const base = baseByPageId.get(
143
+ compactNotionId(input.getTranslationSourcePageId(translation))
144
+ );
145
+ return base ? input.applyTranslation(base, translation) : null;
146
+ }).filter((item) => Boolean(item));
147
+ return input.sort ? localized.sort(input.sort) : localized;
148
+ }
149
+ function getAlternateLocalizedContentLinks(input) {
150
+ const normalizedSourcePageId = compactNotionId(input.sourcePageId);
151
+ return input.translations.filter((translation) => {
152
+ const locale = input.getTranslationLocale(translation);
153
+ return compactNotionId(input.getTranslationSourcePageId(translation)) === normalizedSourcePageId && locale !== input.currentLocale && (!input.isValidLocale || input.isValidLocale(locale));
154
+ }).map((translation) => {
155
+ const locale = input.getTranslationLocale(translation);
156
+ const slug = input.getTranslationSlug(translation);
157
+ return {
158
+ locale,
159
+ slug,
160
+ href: input.hrefForTranslation(locale, slug),
161
+ label: input.labelForLocale?.(locale) ?? locale
162
+ };
163
+ });
164
+ }
165
+ export {
166
+ getAlternateLocalizedContentLinks,
167
+ localizeContentList,
168
+ mapNotionPageToLocalizedContentTranslation
169
+ };
170
+ //# sourceMappingURL=localized.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/notion/property-mappers.ts","../../src/content/localized.ts"],"sourcesContent":["type PropertyMap = Record<string, unknown>;\n\ntype TextPart = {\n plain_text?: string;\n};\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return Boolean(value && typeof value === \"object\");\n}\n\nfunction getPlainText(parts: unknown): string {\n if (!Array.isArray(parts)) return \"\";\n return parts\n .map((part: TextPart) => part.plain_text ?? \"\")\n .join(\"\")\n .trim();\n}\n\nfunction getProperty(properties: PropertyMap, key: string) {\n return properties[key] as Record<string, unknown> | undefined;\n}\n\nfunction firstPropertyOfType(properties: PropertyMap, type: string) {\n return Object.values(properties).find(\n (property): property is Record<string, unknown> =>\n isRecord(property) && property.type === type\n );\n}\n\nexport function getFirstTitleProperty(properties: PropertyMap): string {\n const property = firstPropertyOfType(properties, \"title\");\n return property ? getPlainText(property.title) : \"\";\n}\n\nexport function getRichTextProperty(properties: PropertyMap, key: string): string {\n const property = getProperty(properties, key);\n if (!property) return \"\";\n\n if (property.type === \"title\") return getPlainText(property.title);\n if (property.type === \"rich_text\") return getPlainText(property.rich_text);\n if (property.type === \"url\") return String(property.url ?? \"\").trim();\n if (property.type === \"email\") return String(property.email ?? \"\").trim();\n if (property.type === \"phone_number\") {\n return String(property.phone_number ?? \"\").trim();\n }\n\n return \"\";\n}\n\nexport function getDateProperty(properties: PropertyMap, key: string): string {\n const property = getProperty(properties, key);\n if (property?.type !== \"date\") return \"\";\n const date = property.date as { start?: string } | null | undefined;\n return String(date?.start ?? \"\").trim();\n}\n\nexport function getFirstDateProperty(properties: PropertyMap): string {\n const property = firstPropertyOfType(properties, \"date\");\n if (!property) return \"\";\n const date = property.date as { start?: string } | null | undefined;\n return String(date?.start ?? \"\").trim();\n}\n\nexport function getSelectProperty(properties: PropertyMap, key: string): string {\n const property = getProperty(properties, key);\n if (property?.type !== \"select\") return \"\";\n const select = property.select as { name?: string } | null | undefined;\n return String(select?.name ?? \"\").trim();\n}\n\nexport function getCheckboxProperty(properties: PropertyMap, key: string): boolean {\n const property = getProperty(properties, key);\n if (property?.type !== \"checkbox\") return false;\n return Boolean(property.checkbox);\n}\n\nexport function getNumberProperty(\n properties: PropertyMap,\n key: string,\n fallback = 0\n): number {\n const property = getProperty(properties, key);\n if (property?.type !== \"number\") return fallback;\n const value = Number(property.number);\n return Number.isFinite(value) ? value : fallback;\n}\n\nexport function getRelationPageIds(properties: PropertyMap, key: string): string[] {\n const property = getProperty(properties, key);\n if (property?.type !== \"relation\" || !Array.isArray(property.relation)) {\n return [];\n }\n\n return property.relation\n .map((item: { id?: string }) => String(item.id ?? \"\").trim())\n .filter(Boolean);\n}\n\nexport function getTagsProperty(properties: PropertyMap, key: string): string[] {\n const property = getProperty(properties, key);\n if (property?.type === \"multi_select\" && Array.isArray(property.multi_select)) {\n return property.multi_select\n .map((item: { name?: string }) => String(item.name ?? \"\").trim())\n .filter(Boolean);\n }\n\n if (property?.type === \"select\") {\n const select = property.select as { name?: string } | null | undefined;\n const name = String(select?.name ?? \"\").trim();\n return name ? [name] : [];\n }\n\n return [];\n}\n\nexport function getFirstTagsProperty(properties: PropertyMap): string[] {\n const multiSelect = firstPropertyOfType(properties, \"multi_select\");\n if (multiSelect && Array.isArray(multiSelect.multi_select)) {\n return multiSelect.multi_select\n .map((item: { name?: string }) => String(item.name ?? \"\").trim())\n .filter(Boolean);\n }\n\n const select = firstPropertyOfType(properties, \"select\");\n const name = String((select?.select as { name?: string } | null)?.name ?? \"\").trim();\n return name ? [name] : [];\n}\n\nexport function getAuthorProperty(properties: PropertyMap, key: string): string {\n const property = getProperty(properties, key);\n if (!property) return \"\";\n\n if (property.type === \"people\" && Array.isArray(property.people)) {\n return property.people\n .map((person: { name?: string; person?: { email?: string } }) =>\n String(person.name ?? person.person?.email ?? \"\").trim()\n )\n .filter(Boolean)\n .join(\", \");\n }\n\n return getRichTextProperty(properties, key);\n}\n\nexport function getFirstPeopleProperty(properties: PropertyMap): string {\n const property = firstPropertyOfType(properties, \"people\");\n if (!property || !Array.isArray(property.people)) return \"\";\n\n return property.people\n .map((person: { name?: string; person?: { email?: string } }) =>\n String(person.name ?? person.person?.email ?? \"\").trim()\n )\n .filter(Boolean)\n .join(\", \");\n}\n\nexport function pickPublishedFlag(properties: PropertyMap): boolean {\n const published = getProperty(properties, \"Published\");\n if (published?.type === \"checkbox\") {\n return Boolean(published.checkbox);\n }\n\n const status = getProperty(properties, \"Status\");\n if (status?.type === \"status\") {\n const statusValue = status.status as { name?: string } | null | undefined;\n return String(statusValue?.name ?? \"\").trim().toLowerCase() === \"published\";\n }\n\n if (status?.type === \"select\") {\n const statusValue = status.select as { name?: string } | null | undefined;\n return String(statusValue?.name ?? \"\").trim().toLowerCase() === \"published\";\n }\n\n return false;\n}\n\nexport function pickDescriptionFallback(description: string, title: string): string {\n return description.trim() || title.trim();\n}\n\nexport function isValidPublicSlug(slug: string): boolean {\n return /^[a-z0-9][a-z0-9-]{0,79}$/.test(slug);\n}\n\nexport function notionPageEditUrl(pageId: string, editBaseUrl?: string): string {\n const compactPageId = pageId.replaceAll(\"-\", \"\");\n if (editBaseUrl?.includes(\"{pageId}\")) {\n return editBaseUrl.replaceAll(\"{pageId}\", compactPageId);\n }\n return `https://www.notion.so/${compactPageId}`;\n}\n\n/**\n * Normalize a Notion page id (with or without dashes) to a compact lowercase\n * string. Used as a stable identifier in URLs and cache keys.\n */\nexport function compactNotionId(id: string): string {\n return id.replaceAll(\"-\", \"\").toLowerCase();\n}\n","import {\n compactNotionId,\n getCheckboxProperty,\n getRelationPageIds,\n getRichTextProperty,\n getSelectProperty,\n getTagsProperty,\n isRecord,\n isValidPublicSlug,\n notionPageEditUrl,\n} from \"../notion/property-mappers\";\nimport type { NotionPageLike } from \"../notion/types\";\n\nexport type LocalizedContentFields = {\n title: string;\n source: string;\n locale: string;\n slug: string;\n published: string;\n seoTitle?: string;\n seoDescription?: string;\n};\n\nexport type LocalizedContentExtraFieldKind =\n | \"text\"\n | \"tags\"\n | \"select\"\n | \"checkbox\";\n\nexport type LocalizedContentExtraFields = Record<\n string,\n string | { field: string; kind?: LocalizedContentExtraFieldKind }\n>;\n\nexport type LocalizedContentExtraValue = string | string[] | boolean;\n\nexport type LocalizedContentTranslationBase = {\n pageId: string;\n updatedAt?: string;\n sourcePageId: string;\n locale: string;\n slug: string;\n title: string;\n seoTitle: string;\n seoDescription: string;\n published: boolean;\n editUrl: string | null;\n sourceUrl: string | null;\n};\n\nexport type LocalizedContentTranslation<TExtra extends object = object> =\n LocalizedContentTranslationBase & TExtra;\n\nfunction readPublishedFlag(properties: Record<string, unknown>, field: string) {\n const property = properties[field] as Record<string, unknown> | undefined;\n if (property?.type === \"checkbox\") return getCheckboxProperty(properties, field);\n\n if (property?.type === \"status\") {\n const status = property.status as { name?: string } | null | undefined;\n return String(status?.name ?? \"\").trim().toLowerCase() === \"published\";\n }\n\n if (property?.type === \"select\") {\n const select = property.select as { name?: string } | null | undefined;\n return String(select?.name ?? \"\").trim().toLowerCase() === \"published\";\n }\n\n return false;\n}\n\nfunction readLocale(properties: Record<string, unknown>, field: string) {\n return (\n getSelectProperty(properties, field) || getRichTextProperty(properties, field)\n );\n}\n\nfunction normalizeExtraField(\n definition: string | { field: string; kind?: LocalizedContentExtraFieldKind }\n) {\n if (typeof definition === \"string\") return { field: definition, kind: \"text\" };\n return { field: definition.field, kind: definition.kind ?? \"text\" };\n}\n\nfunction readExtraFields<TExtra extends object>(\n properties: Record<string, unknown>,\n fields?: LocalizedContentExtraFields\n): TExtra {\n const result: Record<string, LocalizedContentExtraValue> = {};\n\n for (const [key, definition] of Object.entries(fields ?? {})) {\n const { field, kind } = normalizeExtraField(definition);\n if (kind === \"tags\") result[key] = getTagsProperty(properties, field);\n else if (kind === \"select\") result[key] = getSelectProperty(properties, field);\n else if (kind === \"checkbox\") result[key] = getCheckboxProperty(properties, field);\n else result[key] = getRichTextProperty(properties, field);\n }\n\n return result as TExtra;\n}\n\nexport function mapNotionPageToLocalizedContentTranslation<\n TExtra extends object = object,\n>(\n page: NotionPageLike,\n input: {\n fields: LocalizedContentFields;\n extraFields?: LocalizedContentExtraFields;\n editBaseUrl?: string;\n isValidLocale?: (locale: string) => boolean;\n }\n): LocalizedContentTranslation<TExtra> | null {\n const properties = isRecord(page.properties) ? page.properties : {};\n const sourcePageIds = getRelationPageIds(properties, input.fields.source);\n const locale = readLocale(properties, input.fields.locale);\n const configuredSlug = getRichTextProperty(\n properties,\n input.fields.slug\n ).toLowerCase();\n const slug = isValidPublicSlug(configuredSlug) ? configuredSlug : \"\";\n const title = getRichTextProperty(properties, input.fields.title);\n const published = readPublishedFlag(properties, input.fields.published);\n const sourceUrl =\n typeof page.public_url === \"string\" && page.public_url\n ? page.public_url\n : typeof page.url === \"string\"\n ? page.url\n : null;\n\n if (\n !sourcePageIds[0] ||\n !locale ||\n !slug ||\n !title ||\n !published ||\n (input.isValidLocale && !input.isValidLocale(locale))\n ) {\n return null;\n }\n\n return {\n pageId: page.id,\n ...(page.last_edited_time ? { updatedAt: page.last_edited_time } : {}),\n sourcePageId: sourcePageIds[0],\n locale,\n slug,\n title,\n seoTitle: input.fields.seoTitle\n ? getRichTextProperty(properties, input.fields.seoTitle)\n : \"\",\n seoDescription: input.fields.seoDescription\n ? getRichTextProperty(properties, input.fields.seoDescription)\n : \"\",\n published,\n editUrl: notionPageEditUrl(page.id, input.editBaseUrl),\n sourceUrl,\n ...readExtraFields<TExtra>(properties, input.extraFields),\n };\n}\n\nexport function localizeContentList<TBase, TTranslation, TResult>(input: {\n baseItems: readonly TBase[];\n translations: readonly TTranslation[];\n locale: string;\n defaultLocale: string;\n getBasePageId: (item: TBase) => string;\n getTranslationLocale: (translation: TTranslation) => string;\n getTranslationSourcePageId: (translation: TTranslation) => string;\n applyTranslation: (base: TBase, translation: TTranslation) => TResult | null;\n fallback: (base: TBase) => TResult;\n sort?: (left: TResult, right: TResult) => number;\n}) {\n const translationsForLocale = input.translations.filter(\n (translation) => input.getTranslationLocale(translation) === input.locale\n );\n\n if (translationsForLocale.length === 0 && input.locale === input.defaultLocale) {\n const fallback = input.baseItems.map(input.fallback);\n return input.sort ? fallback.sort(input.sort) : fallback;\n }\n\n const baseByPageId = new Map(\n input.baseItems.map((item) => [compactNotionId(input.getBasePageId(item)), item])\n );\n const localized = translationsForLocale\n .map((translation) => {\n const base = baseByPageId.get(\n compactNotionId(input.getTranslationSourcePageId(translation))\n );\n return base ? input.applyTranslation(base, translation) : null;\n })\n .filter((item): item is TResult => Boolean(item));\n\n return input.sort ? localized.sort(input.sort) : localized;\n}\n\nexport function getAlternateLocalizedContentLinks<TTranslation>(input: {\n translations: readonly TTranslation[];\n sourcePageId: string;\n currentLocale: string;\n getTranslationLocale: (translation: TTranslation) => string;\n getTranslationSlug: (translation: TTranslation) => string;\n getTranslationSourcePageId: (translation: TTranslation) => string;\n isValidLocale?: (locale: string) => boolean;\n hrefForTranslation: (locale: string, slug: string) => string;\n labelForLocale?: (locale: string) => string;\n}) {\n const normalizedSourcePageId = compactNotionId(input.sourcePageId);\n\n return input.translations\n .filter((translation) => {\n const locale = input.getTranslationLocale(translation);\n return (\n compactNotionId(input.getTranslationSourcePageId(translation)) ===\n normalizedSourcePageId &&\n locale !== input.currentLocale &&\n (!input.isValidLocale || input.isValidLocale(locale))\n );\n })\n .map((translation) => {\n const locale = input.getTranslationLocale(translation);\n const slug = input.getTranslationSlug(translation);\n return {\n locale,\n slug,\n href: input.hrefForTranslation(locale, slug),\n label: input.labelForLocale?.(locale) ?? locale,\n };\n });\n}\n"],"mappings":"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","names":[]}
@@ -19,7 +19,8 @@ declare function buildContentRevalidationPaths(input: {
19
19
  previousRouteId?: string;
20
20
  locale?: string;
21
21
  includeApi?: boolean;
22
- localizedMovieDetailPaths?: readonly string[];
22
+ extraPagePaths?: readonly string[];
23
+ expandPagePaths?: (paths: readonly string[], locale?: string) => readonly string[];
23
24
  }): {
24
25
  pagePaths: string[];
25
26
  routePaths: string[];
@@ -1,23 +1,3 @@
1
- // src/i18n/config.ts
2
- var supportedLocales = ["zh-CN", "en-US"];
3
- function isAppLocale(value) {
4
- return supportedLocales.includes(value);
5
- }
6
- function expandLocalizedMoviePaths(paths, locale) {
7
- const locales = locale && isAppLocale(locale) ? [locale] : [...supportedLocales];
8
- const expanded = [];
9
- for (const path of paths) {
10
- if (path === "/movies" || path.startsWith("/movies/")) {
11
- for (const currentLocale of locales) {
12
- expanded.push(`/${currentLocale}${path}`);
13
- }
14
- continue;
15
- }
16
- expanded.push(path);
17
- }
18
- return Array.from(new Set(expanded));
19
- }
20
-
21
1
  // src/content/models.ts
22
2
  var registry = [];
23
3
  function getRegisteredSource(id) {
@@ -61,9 +41,6 @@ function timingSafeEqualString(a, b) {
61
41
  }
62
42
  return diff === 0;
63
43
  }
64
- function shouldLocalizeMoviePaths(modelId) {
65
- return modelId === "movies" || modelId === "movie-translations";
66
- }
67
44
  function buildContentRevalidationPaths(input) {
68
45
  const pagePaths = [input.model.routes.listPath];
69
46
  const routePaths = [];
@@ -80,8 +57,8 @@ function buildContentRevalidationPaths(input) {
80
57
  )
81
58
  );
82
59
  }
83
- if (input.localizedMovieDetailPaths?.length) {
84
- pagePaths.push(...input.localizedMovieDetailPaths);
60
+ if (input.extraPagePaths?.length) {
61
+ pagePaths.push(...input.extraPagePaths);
85
62
  }
86
63
  if (input.includeApi !== false && input.model.routes.publicApiPath) {
87
64
  routePaths.push(input.model.routes.publicApiPath);
@@ -102,11 +79,11 @@ function buildContentRevalidationPaths(input) {
102
79
  );
103
80
  }
104
81
  }
105
- const localizedPagePaths = shouldLocalizeMoviePaths(input.model.id) ? expandLocalizedMoviePaths(pagePaths, input.locale) : pagePaths;
82
+ const expandedPagePaths = input.expandPagePaths ? input.expandPagePaths(pagePaths, input.locale) : pagePaths;
106
83
  return {
107
- pagePaths: Array.from(new Set(localizedPagePaths)),
84
+ pagePaths: Array.from(new Set(expandedPagePaths)),
108
85
  routePaths: Array.from(new Set(routePaths)),
109
- all: Array.from(/* @__PURE__ */ new Set([...localizedPagePaths, ...routePaths]))
86
+ all: Array.from(/* @__PURE__ */ new Set([...expandedPagePaths, ...routePaths]))
110
87
  };
111
88
  }
112
89
  function authorizeContentRevalidate(request, token) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/i18n/config.ts","../../src/content/models.ts","../../src/content/revalidate.ts"],"sourcesContent":["export const supportedLocales = [\"zh-CN\", \"en-US\"] as const;\n\nexport type AppLocale = (typeof supportedLocales)[number];\n\nexport const defaultLocale: AppLocale = \"zh-CN\";\n\nexport function isAppLocale(value: string): value is AppLocale {\n return (supportedLocales as readonly string[]).includes(value);\n}\n\nexport function localizedMovieListPath(locale: AppLocale) {\n return `/${locale}/movies`;\n}\n\nexport function localizedMovieDetailPath(locale: AppLocale, slug: string) {\n return `/${locale}/movies/${slug}`;\n}\n\nexport function expandLocalizedMoviePaths(\n paths: readonly string[],\n locale?: string\n) {\n const locales =\n locale && isAppLocale(locale) ? [locale] : [...supportedLocales];\n const expanded: string[] = [];\n\n for (const path of paths) {\n if (path === \"/movies\" || path.startsWith(\"/movies/\")) {\n for (const currentLocale of locales) {\n expanded.push(`/${currentLocale}${path}`);\n }\n continue;\n }\n expanded.push(path);\n }\n\n return Array.from(new Set(expanded));\n}\n","// packages/nextion/src/content/models.ts\n//\n// Canonical content-source shape and a module-level registry.\n//\n// The starter's `defineContentModel` returns the same value passed in.\n// The foundation's `defineContentSource` adds a side effect: it stores\n// the value in a process-wide registry so other packages (admin pages,\n// search index, revalidation) can discover content sources without\n// reaching back into the starter.\n//\n// Existing values from the starter pass through unchanged: `ContentSource`\n// is a structural alias of the prior `ContentModelDefinition<TFields>` so\n// source field-maps stay narrowly typed through the boundary.\n\nimport type {\n NotionFieldMap,\n NotionSort,\n NotionSortDirection,\n} from \"../notion/types\";\n\nexport type { NotionFieldMap, NotionSort, NotionSortDirection };\n\n/**\n * Canonical content-source shape. The shape mirrors the starter's\n * prior `ContentModelDefinition` exactly so that registered sources\n * remain type-compatible across the package boundary.\n */\nexport type ContentModelDefinition<\n TFields extends NotionFieldMap = NotionFieldMap,\n> = {\n id: string;\n kind: \"article\" | \"catalog\" | \"directory\";\n visibility: {\n public: boolean;\n admin: boolean;\n };\n source: {\n type: \"notion\";\n tokenEnv: \"NOTION_TOKEN\";\n dataSourceEnv: string;\n defaultDataSourceId?: string;\n fields: TFields;\n query: {\n pageSize: number;\n sorts?: readonly NotionSort[];\n filterProperties?: readonly string[];\n };\n };\n routes: {\n listPath: string;\n detailPath: string;\n detailParam: string;\n publicApiPath?: string;\n };\n ui: {\n name: string;\n pluralName: string;\n navLabel: string;\n listTitle: string;\n listDescription: string;\n emptyState: string;\n };\n capabilities: {\n richBlocks: boolean;\n coverImages: boolean;\n gatedAssets: boolean;\n };\n};\n\n/**\n * Public alias for `ContentModelDefinition`. External consumers import\n * this name from `@notionx/core/content`; the internal\n * `ContentModelDefinition` name remains available for the starter's\n * `model.ts`.\n */\nexport type ContentSource<\n TFields extends NotionFieldMap = NotionFieldMap,\n> = ContentModelDefinition<TFields>;\n\nconst registry: ContentSource[] = [];\n\n/**\n * Register a content source. Returns the value unchanged. Re-registering\n * the same `id` replaces the prior value (idempotent on the id, useful\n * for HMR + tests).\n */\nexport function defineContentSource<const TFields extends NotionFieldMap>(\n model: ContentModelDefinition<TFields>\n): ContentModelDefinition<TFields> {\n const existing = registry.findIndex((s) => s.id === model.id);\n if (existing >= 0) registry[existing] = model;\n else registry.push(model);\n return model;\n}\n\nexport function getRegisteredSources(): readonly ContentSource[] {\n return registry;\n}\n\nexport function getRegisteredSource(id: string): ContentSource | undefined {\n return registry.find((s) => s.id === id);\n}\n\n/**\n * Test-only escape hatch: empties the registry so vitest cases do not\n * leak state between files. Not for production use.\n */\nexport function clearRegistryForTests(): void {\n registry.length = 0;\n}\n","// packages/nextion/src/content/revalidate.ts\n//\n// Generic content revalidation helpers. The starter's\n// `revalidateContentModel` is project-specific (it knows how to\n// resolve localized movie paths and which content models exist) and\n// lives in the starter. The helpers below are model-agnostic and are\n// re-exported by the starter's `lib/content/revalidate.ts`.\n\nimport { expandLocalizedMoviePaths } from \"../i18n/config\";\nimport type { ContentModelDefinition } from \"./models\";\nimport { getRegisteredSource } from \"./models\";\n\ntype RevalidatePathFn = (\n path: string,\n type?: \"page\" | \"layout\"\n) => void | Promise<void>;\n\nexport type { RevalidatePathFn };\n\nexport type InvalidationKind = \"publish\" | \"update\" | \"delete\";\n\nexport type ContentRevalidateRequest = {\n modelId: string;\n pageId?: string;\n routeId?: string;\n previousRouteId?: string;\n locale?: string;\n kind?: InvalidationKind;\n includeApi?: boolean;\n};\n\nfunction asObject(input: unknown): Record<string, unknown> | null {\n return input && typeof input === \"object\" && !Array.isArray(input)\n ? (input as Record<string, unknown>)\n : null;\n}\n\nfunction readString(input: Record<string, unknown>, key: string) {\n const value = input[key];\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\nfunction readKind(input: Record<string, unknown>): InvalidationKind {\n const value = readString(input, \"kind\");\n if (value === \"publish\" || value === \"delete\" || value === \"update\") {\n return value;\n }\n return \"update\";\n}\n\nfunction detailPathForRouteId(detailPath: string, routeId: string) {\n return detailPath.replace(/\\[[^\\]]+\\]/g, routeId);\n}\n\nfunction publicApiDetailPathForRouteId(publicApiPath: string, routeId: string) {\n return `${publicApiPath.replace(/\\/+$/, \"\")}/${routeId.replace(/^\\/+/, \"\")}`;\n}\n\nfunction bearerToken(request: Request) {\n const authorization = request.headers.get(\"authorization\") ?? \"\";\n const match = authorization.match(/^Bearer\\s+(.+)$/i);\n return match?.[1]?.trim() ?? \"\";\n}\n\nfunction timingSafeEqualString(a: string, b: string) {\n const encoder = new TextEncoder();\n const left = encoder.encode(a);\n const right = encoder.encode(b);\n if (left.byteLength !== right.byteLength) return false;\n\n let diff = 0;\n for (let index = 0; index < left.byteLength; index += 1) {\n diff |= (left[index] ?? 0) ^ (right[index] ?? 0);\n }\n return diff === 0;\n}\n\nfunction shouldLocalizeMoviePaths(modelId: string) {\n return modelId === \"movies\" || modelId === \"movie-translations\";\n}\n\nexport function buildContentRevalidationPaths(input: {\n model: Pick<ContentModelDefinition, \"id\" | \"routes\">;\n routeId?: string;\n previousRouteId?: string;\n locale?: string;\n includeApi?: boolean;\n localizedMovieDetailPaths?: readonly string[];\n}) {\n const pagePaths = [input.model.routes.listPath];\n const routePaths: string[] = [];\n\n if (input.routeId) {\n pagePaths.push(\n detailPathForRouteId(input.model.routes.detailPath, input.routeId)\n );\n }\n if (input.previousRouteId) {\n pagePaths.push(\n detailPathForRouteId(\n input.model.routes.detailPath,\n input.previousRouteId\n )\n );\n }\n if (input.localizedMovieDetailPaths?.length) {\n pagePaths.push(...input.localizedMovieDetailPaths);\n }\n if (input.includeApi !== false && input.model.routes.publicApiPath) {\n routePaths.push(input.model.routes.publicApiPath);\n if (input.routeId) {\n routePaths.push(\n publicApiDetailPathForRouteId(\n input.model.routes.publicApiPath,\n input.routeId\n )\n );\n }\n if (input.previousRouteId) {\n routePaths.push(\n publicApiDetailPathForRouteId(\n input.model.routes.publicApiPath,\n input.previousRouteId\n )\n );\n }\n }\n\n const localizedPagePaths = shouldLocalizeMoviePaths(input.model.id)\n ? expandLocalizedMoviePaths(pagePaths, input.locale)\n : pagePaths;\n\n return {\n pagePaths: Array.from(new Set(localizedPagePaths)),\n routePaths: Array.from(new Set(routePaths)),\n all: Array.from(new Set([...localizedPagePaths, ...routePaths])),\n };\n}\n\nexport function authorizeContentRevalidate(\n request: Request,\n token?: string | null\n) {\n const expected = String(token ?? \"\").trim();\n if (!expected) return false;\n const actual = bearerToken(request);\n return Boolean(actual && timingSafeEqualString(actual, expected));\n}\n\nexport async function readContentRevalidateRequest(\n request: Request\n): Promise<ContentRevalidateRequest | null> {\n const body = asObject(await request.json().catch(() => null));\n if (!body) return null;\n\n const modelId = readString(body, \"modelId\");\n const pageId = readString(body, \"pageId\");\n const routeId = readString(body, \"routeId\");\n const previousRouteId = readString(body, \"previousRouteId\");\n const locale = readString(body, \"locale\");\n\n return {\n modelId,\n pageId: pageId || undefined,\n routeId: routeId || undefined,\n previousRouteId: previousRouteId || undefined,\n locale: locale || undefined,\n kind: readKind(body),\n includeApi: body.includeApi !== false,\n };\n}\n\nexport function readContentRevalidateRequestFromUrl(\n url: URL\n): ContentRevalidateRequest | null {\n const modelId = url.searchParams.get(\"modelId\")?.trim() ?? \"\";\n if (!modelId) return null;\n\n const kind = url.searchParams.get(\"kind\")?.trim() ?? \"\";\n const includeApi = url.searchParams.get(\"includeApi\");\n return {\n modelId,\n pageId: url.searchParams.get(\"pageId\")?.trim() || undefined,\n routeId: url.searchParams.get(\"routeId\")?.trim() || undefined,\n previousRouteId:\n url.searchParams.get(\"previousRouteId\")?.trim() || undefined,\n locale: url.searchParams.get(\"locale\")?.trim() || undefined,\n kind:\n kind === \"publish\" || kind === \"delete\" || kind === \"update\"\n ? kind\n : \"update\",\n includeApi: includeApi !== \"false\",\n };\n}\n\nexport function previewContentModelInvalidation(input: ContentRevalidateRequest) {\n const model = getRegisteredSource(input.modelId);\n if (!model) {\n throw new Error(`Unknown content model: ${input.modelId}`);\n }\n\n return buildContentRevalidationPaths({\n model,\n routeId: input.routeId,\n previousRouteId: input.previousRouteId,\n includeApi: input.includeApi,\n });\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/content/models.ts","../../src/content/revalidate.ts"],"sourcesContent":["// packages/nextion/src/content/models.ts\n//\n// Canonical content-source shape and a module-level registry.\n//\n// The starter's `defineContentModel` returns the same value passed in.\n// The foundation's `defineContentSource` adds a side effect: it stores\n// the value in a process-wide registry so other packages (admin pages,\n// search index, revalidation) can discover content sources without\n// reaching back into the starter.\n//\n// Existing values from the starter pass through unchanged: `ContentSource`\n// is a structural alias of the prior `ContentModelDefinition<TFields>` so\n// source field-maps stay narrowly typed through the boundary.\n\nimport type {\n NotionFieldMap,\n NotionSort,\n NotionSortDirection,\n} from \"../notion/types\";\n\nexport type { NotionFieldMap, NotionSort, NotionSortDirection };\n\n/**\n * Canonical content-source shape. The shape mirrors the starter's\n * prior `ContentModelDefinition` exactly so that registered sources\n * remain type-compatible across the package boundary.\n */\nexport type ContentModelDefinition<\n TFields extends NotionFieldMap = NotionFieldMap,\n> = {\n id: string;\n kind: \"article\" | \"catalog\" | \"directory\";\n visibility: {\n public: boolean;\n admin: boolean;\n };\n source: {\n type: \"notion\";\n tokenEnv: \"NOTION_TOKEN\";\n dataSourceEnv: string;\n defaultDataSourceId?: string;\n fields: TFields;\n query: {\n pageSize: number;\n sorts?: readonly NotionSort[];\n filterProperties?: readonly string[];\n };\n };\n routes: {\n listPath: string;\n detailPath: string;\n detailParam: string;\n publicApiPath?: string;\n };\n ui: {\n name: string;\n pluralName: string;\n navLabel: string;\n listTitle: string;\n listDescription: string;\n emptyState: string;\n };\n capabilities: {\n richBlocks: boolean;\n coverImages: boolean;\n gatedAssets: boolean;\n };\n};\n\n/**\n * Public alias for `ContentModelDefinition`. External consumers import\n * this name from `@notionx/core/content`; the internal\n * `ContentModelDefinition` name remains available for the starter's\n * `model.ts`.\n */\nexport type ContentSource<\n TFields extends NotionFieldMap = NotionFieldMap,\n> = ContentModelDefinition<TFields>;\n\nconst registry: ContentSource[] = [];\n\n/**\n * Register a content source. Returns the value unchanged. Re-registering\n * the same `id` replaces the prior value (idempotent on the id, useful\n * for HMR + tests).\n */\nexport function defineContentSource<const TFields extends NotionFieldMap>(\n model: ContentModelDefinition<TFields>\n): ContentModelDefinition<TFields> {\n const existing = registry.findIndex((s) => s.id === model.id);\n if (existing >= 0) registry[existing] = model;\n else registry.push(model);\n return model;\n}\n\nexport function getRegisteredSources(): readonly ContentSource[] {\n return registry;\n}\n\nexport function getRegisteredSource(id: string): ContentSource | undefined {\n return registry.find((s) => s.id === id);\n}\n\n/**\n * Test-only escape hatch: empties the registry so vitest cases do not\n * leak state between files. Not for production use.\n */\nexport function clearRegistryForTests(): void {\n registry.length = 0;\n}\n","// packages/nextion/src/content/revalidate.ts\n//\n// Generic content revalidation helpers. Projects can layer domain-specific\n// path expansion (for example localized routes) through `expandPagePaths`.\n\nimport type { ContentModelDefinition } from \"./models\";\nimport { getRegisteredSource } from \"./models\";\n\ntype RevalidatePathFn = (\n path: string,\n type?: \"page\" | \"layout\"\n) => void | Promise<void>;\n\nexport type { RevalidatePathFn };\n\nexport type InvalidationKind = \"publish\" | \"update\" | \"delete\";\n\nexport type ContentRevalidateRequest = {\n modelId: string;\n pageId?: string;\n routeId?: string;\n previousRouteId?: string;\n locale?: string;\n kind?: InvalidationKind;\n includeApi?: boolean;\n};\n\nfunction asObject(input: unknown): Record<string, unknown> | null {\n return input && typeof input === \"object\" && !Array.isArray(input)\n ? (input as Record<string, unknown>)\n : null;\n}\n\nfunction readString(input: Record<string, unknown>, key: string) {\n const value = input[key];\n return typeof value === \"string\" ? value.trim() : \"\";\n}\n\nfunction readKind(input: Record<string, unknown>): InvalidationKind {\n const value = readString(input, \"kind\");\n if (value === \"publish\" || value === \"delete\" || value === \"update\") {\n return value;\n }\n return \"update\";\n}\n\nfunction detailPathForRouteId(detailPath: string, routeId: string) {\n return detailPath.replace(/\\[[^\\]]+\\]/g, routeId);\n}\n\nfunction publicApiDetailPathForRouteId(publicApiPath: string, routeId: string) {\n return `${publicApiPath.replace(/\\/+$/, \"\")}/${routeId.replace(/^\\/+/, \"\")}`;\n}\n\nfunction bearerToken(request: Request) {\n const authorization = request.headers.get(\"authorization\") ?? \"\";\n const match = authorization.match(/^Bearer\\s+(.+)$/i);\n return match?.[1]?.trim() ?? \"\";\n}\n\nfunction timingSafeEqualString(a: string, b: string) {\n const encoder = new TextEncoder();\n const left = encoder.encode(a);\n const right = encoder.encode(b);\n if (left.byteLength !== right.byteLength) return false;\n\n let diff = 0;\n for (let index = 0; index < left.byteLength; index += 1) {\n diff |= (left[index] ?? 0) ^ (right[index] ?? 0);\n }\n return diff === 0;\n}\n\nexport function buildContentRevalidationPaths(input: {\n model: Pick<ContentModelDefinition, \"id\" | \"routes\">;\n routeId?: string;\n previousRouteId?: string;\n locale?: string;\n includeApi?: boolean;\n extraPagePaths?: readonly string[];\n expandPagePaths?: (paths: readonly string[], locale?: string) => readonly string[];\n}) {\n const pagePaths = [input.model.routes.listPath];\n const routePaths: string[] = [];\n\n if (input.routeId) {\n pagePaths.push(\n detailPathForRouteId(input.model.routes.detailPath, input.routeId)\n );\n }\n if (input.previousRouteId) {\n pagePaths.push(\n detailPathForRouteId(\n input.model.routes.detailPath,\n input.previousRouteId\n )\n );\n }\n if (input.extraPagePaths?.length) {\n pagePaths.push(...input.extraPagePaths);\n }\n if (input.includeApi !== false && input.model.routes.publicApiPath) {\n routePaths.push(input.model.routes.publicApiPath);\n if (input.routeId) {\n routePaths.push(\n publicApiDetailPathForRouteId(\n input.model.routes.publicApiPath,\n input.routeId\n )\n );\n }\n if (input.previousRouteId) {\n routePaths.push(\n publicApiDetailPathForRouteId(\n input.model.routes.publicApiPath,\n input.previousRouteId\n )\n );\n }\n }\n\n const expandedPagePaths = input.expandPagePaths\n ? input.expandPagePaths(pagePaths, input.locale)\n : pagePaths;\n\n return {\n pagePaths: Array.from(new Set(expandedPagePaths)),\n routePaths: Array.from(new Set(routePaths)),\n all: Array.from(new Set([...expandedPagePaths, ...routePaths])),\n };\n}\n\nexport function authorizeContentRevalidate(\n request: Request,\n token?: string | null\n) {\n const expected = String(token ?? \"\").trim();\n if (!expected) return false;\n const actual = bearerToken(request);\n return Boolean(actual && timingSafeEqualString(actual, expected));\n}\n\nexport async function readContentRevalidateRequest(\n request: Request\n): Promise<ContentRevalidateRequest | null> {\n const body = asObject(await request.json().catch(() => null));\n if (!body) return null;\n\n const modelId = readString(body, \"modelId\");\n const pageId = readString(body, \"pageId\");\n const routeId = readString(body, \"routeId\");\n const previousRouteId = readString(body, \"previousRouteId\");\n const locale = readString(body, \"locale\");\n\n return {\n modelId,\n pageId: pageId || undefined,\n routeId: routeId || undefined,\n previousRouteId: previousRouteId || undefined,\n locale: locale || undefined,\n kind: readKind(body),\n includeApi: body.includeApi !== false,\n };\n}\n\nexport function readContentRevalidateRequestFromUrl(\n url: URL\n): ContentRevalidateRequest | null {\n const modelId = url.searchParams.get(\"modelId\")?.trim() ?? \"\";\n if (!modelId) return null;\n\n const kind = url.searchParams.get(\"kind\")?.trim() ?? \"\";\n const includeApi = url.searchParams.get(\"includeApi\");\n return {\n modelId,\n pageId: url.searchParams.get(\"pageId\")?.trim() || undefined,\n routeId: url.searchParams.get(\"routeId\")?.trim() || undefined,\n previousRouteId:\n url.searchParams.get(\"previousRouteId\")?.trim() || undefined,\n locale: url.searchParams.get(\"locale\")?.trim() || undefined,\n kind:\n kind === \"publish\" || kind === \"delete\" || kind === \"update\"\n ? kind\n : \"update\",\n includeApi: includeApi !== \"false\",\n };\n}\n\nexport function previewContentModelInvalidation(input: ContentRevalidateRequest) {\n const model = getRegisteredSource(input.modelId);\n if (!model) {\n throw new Error(`Unknown content model: ${input.modelId}`);\n }\n\n return buildContentRevalidationPaths({\n model,\n routeId: input.routeId,\n previousRouteId: input.previousRouteId,\n includeApi: input.includeApi,\n });\n}\n"],"mappings":";AA+EA,IAAM,WAA4B,CAAC;AAoB5B,SAAS,oBAAoB,IAAuC;AACzE,SAAO,SAAS,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE;AACzC;;;AC1EA,SAAS,SAAS,OAAgD;AAChE,SAAO,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,IAC5D,QACD;AACN;AAEA,SAAS,WAAW,OAAgC,KAAa;AAC/D,QAAM,QAAQ,MAAM,GAAG;AACvB,SAAO,OAAO,UAAU,WAAW,MAAM,KAAK,IAAI;AACpD;AAEA,SAAS,SAAS,OAAkD;AAClE,QAAM,QAAQ,WAAW,OAAO,MAAM;AACtC,MAAI,UAAU,aAAa,UAAU,YAAY,UAAU,UAAU;AACnE,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEA,SAAS,qBAAqB,YAAoB,SAAiB;AACjE,SAAO,WAAW,QAAQ,eAAe,OAAO;AAClD;AAEA,SAAS,8BAA8B,eAAuB,SAAiB;AAC7E,SAAO,GAAG,cAAc,QAAQ,QAAQ,EAAE,CAAC,IAAI,QAAQ,QAAQ,QAAQ,EAAE,CAAC;AAC5E;AAEA,SAAS,YAAY,SAAkB;AACrC,QAAM,gBAAgB,QAAQ,QAAQ,IAAI,eAAe,KAAK;AAC9D,QAAM,QAAQ,cAAc,MAAM,kBAAkB;AACpD,SAAO,QAAQ,CAAC,GAAG,KAAK,KAAK;AAC/B;AAEA,SAAS,sBAAsB,GAAW,GAAW;AACnD,QAAM,UAAU,IAAI,YAAY;AAChC,QAAM,OAAO,QAAQ,OAAO,CAAC;AAC7B,QAAM,QAAQ,QAAQ,OAAO,CAAC;AAC9B,MAAI,KAAK,eAAe,MAAM,WAAY,QAAO;AAEjD,MAAI,OAAO;AACX,WAAS,QAAQ,GAAG,QAAQ,KAAK,YAAY,SAAS,GAAG;AACvD,aAAS,KAAK,KAAK,KAAK,MAAM,MAAM,KAAK,KAAK;AAAA,EAChD;AACA,SAAO,SAAS;AAClB;AAEO,SAAS,8BAA8B,OAQ3C;AACD,QAAM,YAAY,CAAC,MAAM,MAAM,OAAO,QAAQ;AAC9C,QAAM,aAAuB,CAAC;AAE9B,MAAI,MAAM,SAAS;AACjB,cAAU;AAAA,MACR,qBAAqB,MAAM,MAAM,OAAO,YAAY,MAAM,OAAO;AAAA,IACnE;AAAA,EACF;AACA,MAAI,MAAM,iBAAiB;AACzB,cAAU;AAAA,MACR;AAAA,QACE,MAAM,MAAM,OAAO;AAAA,QACnB,MAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,MAAI,MAAM,gBAAgB,QAAQ;AAChC,cAAU,KAAK,GAAG,MAAM,cAAc;AAAA,EACxC;AACA,MAAI,MAAM,eAAe,SAAS,MAAM,MAAM,OAAO,eAAe;AAClE,eAAW,KAAK,MAAM,MAAM,OAAO,aAAa;AAChD,QAAI,MAAM,SAAS;AACjB,iBAAW;AAAA,QACT;AAAA,UACE,MAAM,MAAM,OAAO;AAAA,UACnB,MAAM;AAAA,QACR;AAAA,MACF;AAAA,IACF;AACA,QAAI,MAAM,iBAAiB;AACzB,iBAAW;AAAA,QACT;AAAA,UACE,MAAM,MAAM,OAAO;AAAA,UACnB,MAAM;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,QAAM,oBAAoB,MAAM,kBAC5B,MAAM,gBAAgB,WAAW,MAAM,MAAM,IAC7C;AAEJ,SAAO;AAAA,IACL,WAAW,MAAM,KAAK,IAAI,IAAI,iBAAiB,CAAC;AAAA,IAChD,YAAY,MAAM,KAAK,IAAI,IAAI,UAAU,CAAC;AAAA,IAC1C,KAAK,MAAM,KAAK,oBAAI,IAAI,CAAC,GAAG,mBAAmB,GAAG,UAAU,CAAC,CAAC;AAAA,EAChE;AACF;AAEO,SAAS,2BACd,SACA,OACA;AACA,QAAM,WAAW,OAAO,SAAS,EAAE,EAAE,KAAK;AAC1C,MAAI,CAAC,SAAU,QAAO;AACtB,QAAM,SAAS,YAAY,OAAO;AAClC,SAAO,QAAQ,UAAU,sBAAsB,QAAQ,QAAQ,CAAC;AAClE;AAEA,eAAsB,6BACpB,SAC0C;AAC1C,QAAM,OAAO,SAAS,MAAM,QAAQ,KAAK,EAAE,MAAM,MAAM,IAAI,CAAC;AAC5D,MAAI,CAAC,KAAM,QAAO;AAElB,QAAM,UAAU,WAAW,MAAM,SAAS;AAC1C,QAAM,SAAS,WAAW,MAAM,QAAQ;AACxC,QAAM,UAAU,WAAW,MAAM,SAAS;AAC1C,QAAM,kBAAkB,WAAW,MAAM,iBAAiB;AAC1D,QAAM,SAAS,WAAW,MAAM,QAAQ;AAExC,SAAO;AAAA,IACL;AAAA,IACA,QAAQ,UAAU;AAAA,IAClB,SAAS,WAAW;AAAA,IACpB,iBAAiB,mBAAmB;AAAA,IACpC,QAAQ,UAAU;AAAA,IAClB,MAAM,SAAS,IAAI;AAAA,IACnB,YAAY,KAAK,eAAe;AAAA,EAClC;AACF;AAEO,SAAS,oCACd,KACiC;AACjC,QAAM,UAAU,IAAI,aAAa,IAAI,SAAS,GAAG,KAAK,KAAK;AAC3D,MAAI,CAAC,QAAS,QAAO;AAErB,QAAM,OAAO,IAAI,aAAa,IAAI,MAAM,GAAG,KAAK,KAAK;AACrD,QAAM,aAAa,IAAI,aAAa,IAAI,YAAY;AACpD,SAAO;AAAA,IACL;AAAA,IACA,QAAQ,IAAI,aAAa,IAAI,QAAQ,GAAG,KAAK,KAAK;AAAA,IAClD,SAAS,IAAI,aAAa,IAAI,SAAS,GAAG,KAAK,KAAK;AAAA,IACpD,iBACE,IAAI,aAAa,IAAI,iBAAiB,GAAG,KAAK,KAAK;AAAA,IACrD,QAAQ,IAAI,aAAa,IAAI,QAAQ,GAAG,KAAK,KAAK;AAAA,IAClD,MACE,SAAS,aAAa,SAAS,YAAY,SAAS,WAChD,OACA;AAAA,IACN,YAAY,eAAe;AAAA,EAC7B;AACF;AAEO,SAAS,gCAAgC,OAAiC;AAC/E,QAAM,QAAQ,oBAAoB,MAAM,OAAO;AAC/C,MAAI,CAAC,OAAO;AACV,UAAM,IAAI,MAAM,0BAA0B,MAAM,OAAO,EAAE;AAAA,EAC3D;AAEA,SAAO,8BAA8B;AAAA,IACnC;AAAA,IACA,SAAS,MAAM;AAAA,IACf,iBAAiB,MAAM;AAAA,IACvB,YAAY,MAAM;AAAA,EACpB,CAAC;AACH;","names":[]}
@@ -1,5 +1,5 @@
1
1
  import { SqlDatabaseAdapter } from '../platform/runtime.js';
2
- import '../env-C5qu-0R-.js';
2
+ import '../env-hoez1e-n.js';
3
3
 
4
4
  type SearchIndexedItem = {
5
5
  pageId?: string;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/content/search.ts","../../src/content/search-index.ts"],"sourcesContent":["// packages/nextion/src/content/search.ts\n//\n// Generic text-search helpers for content sources.\n\nimport type {\n NotionMovieListItem,\n NotionPostListItem,\n} from \"../notion/types\";\n\nexport function normalizeSearchQuery(query: string | null | undefined) {\n return String(query ?? \"\")\n .normalize(\"NFKC\")\n .trim()\n .replace(/\\s+/g, \" \")\n .toLowerCase();\n}\n\nfunction searchTerms(query: string | null | undefined) {\n const normalized = normalizeSearchQuery(query);\n return normalized ? normalized.split(\" \") : [];\n}\n\nfunction searchableText(values: readonly unknown[]) {\n return values\n .flatMap((value) => (Array.isArray(value) ? value : [value]))\n .filter((value): value is string | number | boolean =>\n [\"string\", \"number\", \"boolean\"].includes(typeof value)\n )\n .map((value) => String(value))\n .join(\" \")\n .normalize(\"NFKC\")\n .toLowerCase();\n}\n\nexport function matchesSearchQuery(\n values: readonly unknown[],\n query: string | null | undefined\n) {\n const terms = searchTerms(query);\n if (terms.length === 0) return true;\n\n const haystack = searchableText(values);\n return terms.every((term) => haystack.includes(term));\n}\n\nexport function filterPostsBySearch<TPost extends NotionPostListItem>(\n posts: readonly TPost[],\n query: string | null | undefined\n) {\n return posts.filter((post) =>\n matchesSearchQuery(\n [\n post.title,\n post.description,\n post.author,\n post.tags,\n post.slug,\n post.date,\n ],\n query\n )\n );\n}\n\nexport function filterMoviesBySearch<TMovie extends NotionMovieListItem>(\n movies: readonly TMovie[],\n query: string | null | undefined\n) {\n return movies.filter((movie) =>\n matchesSearchQuery(\n [\n movie.title,\n movie.summary,\n movie.director,\n movie.actors,\n movie.genres,\n movie.releaseDate,\n movie.routeId,\n ],\n query\n )\n );\n}\n","// packages/nextion/src/content/search-index.ts\n//\n// Generic D1-backed content search index helpers.\n\nimport type { SqlDatabaseAdapter } from \"../platform/runtime\";\nimport { matchesSearchQuery, normalizeSearchQuery } from \"./search\";\n\nexport type SearchIndexedItem = {\n pageId?: string;\n slug?: string;\n routeId?: string;\n title: string;\n description?: string;\n date?: string;\n author?: string;\n tags?: readonly string[];\n releaseDate?: string;\n director?: string;\n actors?: string;\n summary?: string;\n genres?: readonly string[];\n};\n\nexport type SearchIndexDocument = {\n modelId: string;\n pageId: string;\n routeId: string;\n title: string;\n summary: string;\n bodyText: string;\n facets: readonly string[];\n sourceUpdatedAt?: string | null;\n};\n\ntype SearchIndexRow = {\n route_id: string;\n};\n\ntype MaybePromise<T> = T | Promise<T>;\n\nfunction indexValuesForItem(item: SearchIndexedItem) {\n return [\n item.title,\n item.description,\n item.author,\n item.tags,\n item.slug,\n item.date,\n item.summary,\n item.director,\n item.actors,\n item.genres,\n item.routeId,\n item.releaseDate,\n ];\n}\n\nfunction routeIdForItem(item: SearchIndexedItem) {\n return item.routeId ?? item.slug ?? \"\";\n}\n\nfunction routeOrder<T extends SearchIndexedItem>(items: readonly T[]) {\n const order = new Map<string, number>();\n items.forEach((item, index) => {\n const routeId = routeIdForItem(item);\n if (routeId) order.set(routeId, index);\n });\n return order;\n}\n\nfunction uniqueValues(values: readonly string[]) {\n return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));\n}\n\nexport async function upsertSearchIndexDocument(\n db: SqlDatabaseAdapter,\n document: SearchIndexDocument\n) {\n const normalizedText = [\n document.title,\n document.summary,\n document.bodyText,\n ...document.facets,\n ]\n .join(\" \")\n .normalize(\"NFKC\")\n .replace(/\\s+/g, \" \")\n .toLowerCase();\n\n await db\n .prepare(\n `INSERT INTO content_search_index (\n model_id,\n page_id,\n route_id,\n title,\n summary,\n body_text,\n facets,\n normalized_text,\n source_updated_at,\n indexed_at\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))\n ON CONFLICT(model_id, route_id) DO UPDATE SET\n page_id = excluded.page_id,\n title = excluded.title,\n summary = excluded.summary,\n body_text = excluded.body_text,\n facets = excluded.facets,\n normalized_text = excluded.normalized_text,\n source_updated_at = excluded.source_updated_at,\n indexed_at = excluded.indexed_at`\n )\n .bind(\n document.modelId,\n document.pageId,\n document.routeId,\n document.title,\n document.summary,\n document.bodyText,\n JSON.stringify(uniqueValues([...document.facets])),\n normalizedText,\n document.sourceUpdatedAt ?? null\n )\n .run();\n}\n\nexport async function deleteSearchIndexDocument(\n db: SqlDatabaseAdapter,\n input: { modelId: string; routeId: string }\n) {\n await db\n .prepare(\n \"DELETE FROM content_search_index WHERE model_id = ? AND route_id = ?\"\n )\n .bind(input.modelId, input.routeId)\n .run();\n}\n\nexport async function deleteSearchIndexForModel(\n db: SqlDatabaseAdapter,\n input: { modelId: string }\n) {\n await db\n .prepare(\"DELETE FROM content_search_index WHERE model_id = ?\")\n .bind(input.modelId)\n .run();\n}\n\nexport async function getMissingSearchIndexRouteIds(\n db: SqlDatabaseAdapter,\n input: { modelId: string; routeIds: readonly string[] }\n) {\n const routeIds = uniqueValues([...input.routeIds]);\n if (routeIds.length === 0) return [];\n\n const placeholders = routeIds.map(() => \"?\").join(\", \");\n const result = await db\n .prepare(\n `SELECT route_id\n FROM content_search_index\n WHERE model_id = ? AND route_id IN (${placeholders})`\n )\n .bind(input.modelId, ...routeIds)\n .all<SearchIndexRow>();\n\n const present = new Set((result.results ?? []).map((row) => row.route_id));\n return routeIds.filter((routeId) => !present.has(routeId));\n}\n\nexport async function querySearchIndexRouteIds(\n db: SqlDatabaseAdapter,\n input: { modelId: string; query: string; limit?: number }\n) {\n const query = normalizeSearchQuery(input.query);\n if (!query) return [];\n\n const terms = query\n .split(\" \")\n .map((term) => `%${term}%`);\n const clauses = terms\n .map(\n () =>\n \"(normalized_text LIKE ? OR title LIKE ? OR summary LIKE ? OR facets LIKE ?)\"\n )\n .join(\" AND \");\n const values = terms.flatMap((term) => [term, term, term, term]);\n\n const result = await db\n .prepare(\n `SELECT route_id\n FROM content_search_index\n WHERE model_id = ? AND ${clauses}\n ORDER BY indexed_at DESC\n LIMIT ?`\n )\n .bind(input.modelId, ...values, input.limit ?? 200)\n .all<SearchIndexRow>();\n\n return (result.results ?? [])\n .map((row) => row.route_id)\n .filter((routeId): routeId is string => typeof routeId === \"string\");\n}\n\nexport async function filterItemsBySearchIndex<T extends SearchIndexedItem>(\n items: readonly T[],\n query: string | null | undefined,\n input: {\n modelId: string;\n filterFallback: (items: readonly T[], query: string | null | undefined) => T[];\n getDatabase?: () => MaybePromise<SqlDatabaseAdapter | null>;\n }\n) {\n const normalized = normalizeSearchQuery(query);\n if (!normalized) return [...items];\n if (!input.getDatabase) return input.filterFallback(items, normalized);\n\n try {\n const db = await input.getDatabase();\n if (!db) return input.filterFallback(items, normalized);\n const routeIds = await querySearchIndexRouteIds(db, {\n modelId: input.modelId,\n query: normalized,\n limit: Math.max(items.length, 200),\n });\n if (routeIds.length === 0) return input.filterFallback(items, normalized);\n\n const order = routeOrder(items);\n const matched = new Set(routeIds);\n return items\n .filter((item) => matched.has(routeIdForItem(item)))\n .sort(\n (a, b) =>\n (order.get(routeIdForItem(a)) ?? 0) -\n (order.get(routeIdForItem(b)) ?? 0)\n );\n } catch (error) {\n console.warn(\n JSON.stringify({\n tag: \"content_search_index_error\",\n modelId: input.modelId,\n message: error instanceof Error ? error.message : String(error),\n })\n );\n return input.filterFallback(items, normalized);\n }\n}\n\nexport function matchesIndexedItem(\n item: SearchIndexedItem,\n query: string | null | undefined\n) {\n return matchesSearchQuery(indexValuesForItem(item), query);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/content/search.ts","../../src/content/search-index.ts"],"sourcesContent":["// packages/nextion/src/content/search.ts\n//\n// Generic text-search helpers for content sources.\n\nexport function normalizeSearchQuery(query: string | null | undefined) {\n return String(query ?? \"\")\n .normalize(\"NFKC\")\n .trim()\n .replace(/\\s+/g, \" \")\n .toLowerCase();\n}\n\nfunction searchTerms(query: string | null | undefined) {\n const normalized = normalizeSearchQuery(query);\n return normalized ? normalized.split(\" \") : [];\n}\n\nfunction searchableText(values: readonly unknown[]) {\n return values\n .flatMap((value) => (Array.isArray(value) ? value : [value]))\n .filter((value): value is string | number | boolean =>\n [\"string\", \"number\", \"boolean\"].includes(typeof value)\n )\n .map((value) => String(value))\n .join(\" \")\n .normalize(\"NFKC\")\n .toLowerCase();\n}\n\nexport function matchesSearchQuery(\n values: readonly unknown[],\n query: string | null | undefined\n) {\n const terms = searchTerms(query);\n if (terms.length === 0) return true;\n\n const haystack = searchableText(values);\n return terms.every((term) => haystack.includes(term));\n}\n\nexport function filterItemsBySearch<TItem>(\n items: readonly TItem[],\n valuesForItem: (item: TItem) => readonly unknown[],\n query: string | null | undefined\n) {\n return items.filter((item) => matchesSearchQuery(valuesForItem(item), query));\n}\n","// packages/nextion/src/content/search-index.ts\n//\n// Generic D1-backed content search index helpers.\n\nimport type { SqlDatabaseAdapter } from \"../platform/runtime\";\nimport { matchesSearchQuery, normalizeSearchQuery } from \"./search\";\n\nexport type SearchIndexedItem = {\n pageId?: string;\n slug?: string;\n routeId?: string;\n title: string;\n description?: string;\n date?: string;\n author?: string;\n tags?: readonly string[];\n releaseDate?: string;\n director?: string;\n actors?: string;\n summary?: string;\n genres?: readonly string[];\n};\n\nexport type SearchIndexDocument = {\n modelId: string;\n pageId: string;\n routeId: string;\n title: string;\n summary: string;\n bodyText: string;\n facets: readonly string[];\n sourceUpdatedAt?: string | null;\n};\n\ntype SearchIndexRow = {\n route_id: string;\n};\n\ntype MaybePromise<T> = T | Promise<T>;\n\nfunction indexValuesForItem(item: SearchIndexedItem) {\n return [\n item.title,\n item.description,\n item.author,\n item.tags,\n item.slug,\n item.date,\n item.summary,\n item.director,\n item.actors,\n item.genres,\n item.routeId,\n item.releaseDate,\n ];\n}\n\nfunction routeIdForItem(item: SearchIndexedItem) {\n return item.routeId ?? item.slug ?? \"\";\n}\n\nfunction routeOrder<T extends SearchIndexedItem>(items: readonly T[]) {\n const order = new Map<string, number>();\n items.forEach((item, index) => {\n const routeId = routeIdForItem(item);\n if (routeId) order.set(routeId, index);\n });\n return order;\n}\n\nfunction uniqueValues(values: readonly string[]) {\n return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));\n}\n\nexport async function upsertSearchIndexDocument(\n db: SqlDatabaseAdapter,\n document: SearchIndexDocument\n) {\n const normalizedText = [\n document.title,\n document.summary,\n document.bodyText,\n ...document.facets,\n ]\n .join(\" \")\n .normalize(\"NFKC\")\n .replace(/\\s+/g, \" \")\n .toLowerCase();\n\n await db\n .prepare(\n `INSERT INTO content_search_index (\n model_id,\n page_id,\n route_id,\n title,\n summary,\n body_text,\n facets,\n normalized_text,\n source_updated_at,\n indexed_at\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))\n ON CONFLICT(model_id, route_id) DO UPDATE SET\n page_id = excluded.page_id,\n title = excluded.title,\n summary = excluded.summary,\n body_text = excluded.body_text,\n facets = excluded.facets,\n normalized_text = excluded.normalized_text,\n source_updated_at = excluded.source_updated_at,\n indexed_at = excluded.indexed_at`\n )\n .bind(\n document.modelId,\n document.pageId,\n document.routeId,\n document.title,\n document.summary,\n document.bodyText,\n JSON.stringify(uniqueValues([...document.facets])),\n normalizedText,\n document.sourceUpdatedAt ?? null\n )\n .run();\n}\n\nexport async function deleteSearchIndexDocument(\n db: SqlDatabaseAdapter,\n input: { modelId: string; routeId: string }\n) {\n await db\n .prepare(\n \"DELETE FROM content_search_index WHERE model_id = ? AND route_id = ?\"\n )\n .bind(input.modelId, input.routeId)\n .run();\n}\n\nexport async function deleteSearchIndexForModel(\n db: SqlDatabaseAdapter,\n input: { modelId: string }\n) {\n await db\n .prepare(\"DELETE FROM content_search_index WHERE model_id = ?\")\n .bind(input.modelId)\n .run();\n}\n\nexport async function getMissingSearchIndexRouteIds(\n db: SqlDatabaseAdapter,\n input: { modelId: string; routeIds: readonly string[] }\n) {\n const routeIds = uniqueValues([...input.routeIds]);\n if (routeIds.length === 0) return [];\n\n const placeholders = routeIds.map(() => \"?\").join(\", \");\n const result = await db\n .prepare(\n `SELECT route_id\n FROM content_search_index\n WHERE model_id = ? AND route_id IN (${placeholders})`\n )\n .bind(input.modelId, ...routeIds)\n .all<SearchIndexRow>();\n\n const present = new Set((result.results ?? []).map((row) => row.route_id));\n return routeIds.filter((routeId) => !present.has(routeId));\n}\n\nexport async function querySearchIndexRouteIds(\n db: SqlDatabaseAdapter,\n input: { modelId: string; query: string; limit?: number }\n) {\n const query = normalizeSearchQuery(input.query);\n if (!query) return [];\n\n const terms = query\n .split(\" \")\n .map((term) => `%${term}%`);\n const clauses = terms\n .map(\n () =>\n \"(normalized_text LIKE ? OR title LIKE ? OR summary LIKE ? OR facets LIKE ?)\"\n )\n .join(\" AND \");\n const values = terms.flatMap((term) => [term, term, term, term]);\n\n const result = await db\n .prepare(\n `SELECT route_id\n FROM content_search_index\n WHERE model_id = ? AND ${clauses}\n ORDER BY indexed_at DESC\n LIMIT ?`\n )\n .bind(input.modelId, ...values, input.limit ?? 200)\n .all<SearchIndexRow>();\n\n return (result.results ?? [])\n .map((row) => row.route_id)\n .filter((routeId): routeId is string => typeof routeId === \"string\");\n}\n\nexport async function filterItemsBySearchIndex<T extends SearchIndexedItem>(\n items: readonly T[],\n query: string | null | undefined,\n input: {\n modelId: string;\n filterFallback: (items: readonly T[], query: string | null | undefined) => T[];\n getDatabase?: () => MaybePromise<SqlDatabaseAdapter | null>;\n }\n) {\n const normalized = normalizeSearchQuery(query);\n if (!normalized) return [...items];\n if (!input.getDatabase) return input.filterFallback(items, normalized);\n\n try {\n const db = await input.getDatabase();\n if (!db) return input.filterFallback(items, normalized);\n const routeIds = await querySearchIndexRouteIds(db, {\n modelId: input.modelId,\n query: normalized,\n limit: Math.max(items.length, 200),\n });\n if (routeIds.length === 0) return input.filterFallback(items, normalized);\n\n const order = routeOrder(items);\n const matched = new Set(routeIds);\n return items\n .filter((item) => matched.has(routeIdForItem(item)))\n .sort(\n (a, b) =>\n (order.get(routeIdForItem(a)) ?? 0) -\n (order.get(routeIdForItem(b)) ?? 0)\n );\n } catch (error) {\n console.warn(\n JSON.stringify({\n tag: \"content_search_index_error\",\n modelId: input.modelId,\n message: error instanceof Error ? error.message : String(error),\n })\n );\n return input.filterFallback(items, normalized);\n }\n}\n\nexport function matchesIndexedItem(\n item: SearchIndexedItem,\n query: string | null | undefined\n) {\n return matchesSearchQuery(indexValuesForItem(item), query);\n}\n"],"mappings":"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","names":[]}
@@ -1,8 +1,5 @@
1
- import { NotionMovieListItem, NotionPostListItem } from '../notion/types.js';
2
-
3
1
  declare function normalizeSearchQuery(query: string | null | undefined): string;
4
2
  declare function matchesSearchQuery(values: readonly unknown[], query: string | null | undefined): boolean;
5
- declare function filterPostsBySearch<TPost extends NotionPostListItem>(posts: readonly TPost[], query: string | null | undefined): TPost[];
6
- declare function filterMoviesBySearch<TMovie extends NotionMovieListItem>(movies: readonly TMovie[], query: string | null | undefined): TMovie[];
3
+ declare function filterItemsBySearch<TItem>(items: readonly TItem[], valuesForItem: (item: TItem) => readonly unknown[], query: string | null | undefined): TItem[];
7
4
 
8
- export { filterMoviesBySearch, filterPostsBySearch, matchesSearchQuery, normalizeSearchQuery };
5
+ export { filterItemsBySearch, matchesSearchQuery, normalizeSearchQuery };
@@ -17,40 +17,11 @@ function matchesSearchQuery(values, query) {
17
17
  const haystack = searchableText(values);
18
18
  return terms.every((term) => haystack.includes(term));
19
19
  }
20
- function filterPostsBySearch(posts, query) {
21
- return posts.filter(
22
- (post) => matchesSearchQuery(
23
- [
24
- post.title,
25
- post.description,
26
- post.author,
27
- post.tags,
28
- post.slug,
29
- post.date
30
- ],
31
- query
32
- )
33
- );
34
- }
35
- function filterMoviesBySearch(movies, query) {
36
- return movies.filter(
37
- (movie) => matchesSearchQuery(
38
- [
39
- movie.title,
40
- movie.summary,
41
- movie.director,
42
- movie.actors,
43
- movie.genres,
44
- movie.releaseDate,
45
- movie.routeId
46
- ],
47
- query
48
- )
49
- );
20
+ function filterItemsBySearch(items, valuesForItem, query) {
21
+ return items.filter((item) => matchesSearchQuery(valuesForItem(item), query));
50
22
  }
51
23
  export {
52
- filterMoviesBySearch,
53
- filterPostsBySearch,
24
+ filterItemsBySearch,
54
25
  matchesSearchQuery,
55
26
  normalizeSearchQuery
56
27
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/content/search.ts"],"sourcesContent":["// packages/nextion/src/content/search.ts\n//\n// Generic text-search helpers for content sources.\n\nimport type {\n NotionMovieListItem,\n NotionPostListItem,\n} from \"../notion/types\";\n\nexport function normalizeSearchQuery(query: string | null | undefined) {\n return String(query ?? \"\")\n .normalize(\"NFKC\")\n .trim()\n .replace(/\\s+/g, \" \")\n .toLowerCase();\n}\n\nfunction searchTerms(query: string | null | undefined) {\n const normalized = normalizeSearchQuery(query);\n return normalized ? normalized.split(\" \") : [];\n}\n\nfunction searchableText(values: readonly unknown[]) {\n return values\n .flatMap((value) => (Array.isArray(value) ? value : [value]))\n .filter((value): value is string | number | boolean =>\n [\"string\", \"number\", \"boolean\"].includes(typeof value)\n )\n .map((value) => String(value))\n .join(\" \")\n .normalize(\"NFKC\")\n .toLowerCase();\n}\n\nexport function matchesSearchQuery(\n values: readonly unknown[],\n query: string | null | undefined\n) {\n const terms = searchTerms(query);\n if (terms.length === 0) return true;\n\n const haystack = searchableText(values);\n return terms.every((term) => haystack.includes(term));\n}\n\nexport function filterPostsBySearch<TPost extends NotionPostListItem>(\n posts: readonly TPost[],\n query: string | null | undefined\n) {\n return posts.filter((post) =>\n matchesSearchQuery(\n [\n post.title,\n post.description,\n post.author,\n post.tags,\n post.slug,\n post.date,\n ],\n query\n )\n );\n}\n\nexport function filterMoviesBySearch<TMovie extends NotionMovieListItem>(\n movies: readonly TMovie[],\n query: string | null | undefined\n) {\n return movies.filter((movie) =>\n matchesSearchQuery(\n [\n movie.title,\n movie.summary,\n movie.director,\n movie.actors,\n movie.genres,\n movie.releaseDate,\n movie.routeId,\n ],\n query\n )\n );\n}\n"],"mappings":";AASO,SAAS,qBAAqB,OAAkC;AACrE,SAAO,OAAO,SAAS,EAAE,EACtB,UAAU,MAAM,EAChB,KAAK,EACL,QAAQ,QAAQ,GAAG,EACnB,YAAY;AACjB;AAEA,SAAS,YAAY,OAAkC;AACrD,QAAM,aAAa,qBAAqB,KAAK;AAC7C,SAAO,aAAa,WAAW,MAAM,GAAG,IAAI,CAAC;AAC/C;AAEA,SAAS,eAAe,QAA4B;AAClD,SAAO,OACJ,QAAQ,CAAC,UAAW,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,KAAK,CAAE,EAC3D;AAAA,IAAO,CAAC,UACP,CAAC,UAAU,UAAU,SAAS,EAAE,SAAS,OAAO,KAAK;AAAA,EACvD,EACC,IAAI,CAAC,UAAU,OAAO,KAAK,CAAC,EAC5B,KAAK,GAAG,EACR,UAAU,MAAM,EAChB,YAAY;AACjB;AAEO,SAAS,mBACd,QACA,OACA;AACA,QAAM,QAAQ,YAAY,KAAK;AAC/B,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,QAAM,WAAW,eAAe,MAAM;AACtC,SAAO,MAAM,MAAM,CAAC,SAAS,SAAS,SAAS,IAAI,CAAC;AACtD;AAEO,SAAS,oBACd,OACA,OACA;AACA,SAAO,MAAM;AAAA,IAAO,CAAC,SACnB;AAAA,MACE;AAAA,QACE,KAAK;AAAA,QACL,KAAK;AAAA,QACL,KAAK;AAAA,QACL,KAAK;AAAA,QACL,KAAK;AAAA,QACL,KAAK;AAAA,MACP;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;AAEO,SAAS,qBACd,QACA,OACA;AACA,SAAO,OAAO;AAAA,IAAO,CAAC,UACpB;AAAA,MACE;AAAA,QACE,MAAM;AAAA,QACN,MAAM;AAAA,QACN,MAAM;AAAA,QACN,MAAM;AAAA,QACN,MAAM;AAAA,QACN,MAAM;AAAA,QACN,MAAM;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../src/content/search.ts"],"sourcesContent":["// packages/nextion/src/content/search.ts\n//\n// Generic text-search helpers for content sources.\n\nexport function normalizeSearchQuery(query: string | null | undefined) {\n return String(query ?? \"\")\n .normalize(\"NFKC\")\n .trim()\n .replace(/\\s+/g, \" \")\n .toLowerCase();\n}\n\nfunction searchTerms(query: string | null | undefined) {\n const normalized = normalizeSearchQuery(query);\n return normalized ? normalized.split(\" \") : [];\n}\n\nfunction searchableText(values: readonly unknown[]) {\n return values\n .flatMap((value) => (Array.isArray(value) ? value : [value]))\n .filter((value): value is string | number | boolean =>\n [\"string\", \"number\", \"boolean\"].includes(typeof value)\n )\n .map((value) => String(value))\n .join(\" \")\n .normalize(\"NFKC\")\n .toLowerCase();\n}\n\nexport function matchesSearchQuery(\n values: readonly unknown[],\n query: string | null | undefined\n) {\n const terms = searchTerms(query);\n if (terms.length === 0) return true;\n\n const haystack = searchableText(values);\n return terms.every((term) => haystack.includes(term));\n}\n\nexport function filterItemsBySearch<TItem>(\n items: readonly TItem[],\n valuesForItem: (item: TItem) => readonly unknown[],\n query: string | null | undefined\n) {\n return items.filter((item) => matchesSearchQuery(valuesForItem(item), query));\n}\n"],"mappings":";AAIO,SAAS,qBAAqB,OAAkC;AACrE,SAAO,OAAO,SAAS,EAAE,EACtB,UAAU,MAAM,EAChB,KAAK,EACL,QAAQ,QAAQ,GAAG,EACnB,YAAY;AACjB;AAEA,SAAS,YAAY,OAAkC;AACrD,QAAM,aAAa,qBAAqB,KAAK;AAC7C,SAAO,aAAa,WAAW,MAAM,GAAG,IAAI,CAAC;AAC/C;AAEA,SAAS,eAAe,QAA4B;AAClD,SAAO,OACJ,QAAQ,CAAC,UAAW,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,KAAK,CAAE,EAC3D;AAAA,IAAO,CAAC,UACP,CAAC,UAAU,UAAU,SAAS,EAAE,SAAS,OAAO,KAAK;AAAA,EACvD,EACC,IAAI,CAAC,UAAU,OAAO,KAAK,CAAC,EAC5B,KAAK,GAAG,EACR,UAAU,MAAM,EAChB,YAAY;AACjB;AAEO,SAAS,mBACd,QACA,OACA;AACA,QAAM,QAAQ,YAAY,KAAK;AAC/B,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,QAAM,WAAW,eAAe,MAAM;AACtC,SAAO,MAAM,MAAM,CAAC,SAAS,SAAS,SAAS,IAAI,CAAC;AACtD;AAEO,SAAS,oBACd,OACA,eACA,OACA;AACA,SAAO,MAAM,OAAO,CAAC,SAAS,mBAAmB,cAAc,IAAI,GAAG,KAAK,CAAC;AAC9E;","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/email/resend.ts","../../src/util/env.ts"],"sourcesContent":["// Email delivery via the Resend HTTP API.\n//\n// Cloudflare Workers cannot use SMTP (no long-lived connections), so\n// email is sent through the Resend HTTP API. The free Resend tier\n// covers 3 000 messages per month.\n\nimport { Resend } from \"resend\";\nimport { workerEnv } from \"../util/env\";\n\nlet resendClient: Resend | null = null;\n\nfunction getResend(): Resend | null {\n if (resendClient) return resendClient;\n const env = workerEnv;\n if (!env.RESEND_API_KEY) return null;\n resendClient = new Resend(env.RESEND_API_KEY);\n return resendClient;\n}\n\ntype SendArgs = {\n to: string | string[];\n subject: string;\n html: string;\n text?: string;\n replyTo?: string;\n};\n\n/**\n * Send an email. When RESEND_API_KEY is not set (the dev default), the\n * call is silently skipped with a log line instead of failing. Returns\n * the Resend message id, or null when no-op. Throws on a Resend error.\n */\nexport async function sendEmail(args: SendArgs): Promise<string | null> {\n const env = workerEnv;\n const resend = getResend();\n\n // Dev mode without a key: skip silently.\n if (!resend) {\n console.log(\"[email:noop] to=%s subject=%s\", args.to, args.subject);\n return null;\n }\n\n const from = env.RESEND_FROM || \"Blog <noreply@resend.dev>\";\n\n const { data, error } = await resend.emails.send({\n from,\n to: args.to,\n subject: args.subject,\n html: args.html,\n text: args.text,\n replyTo: args.replyTo,\n });\n\n if (error) {\n throw new Error(`Resend error: ${error.message}`);\n }\n return data?.id ?? null;\n}\n\n/** Minimal HTML escape (avoids depending on a runtime sanitizer). */\nfunction esc(s: string): string {\n return s\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#39;\");\n}\n\n/** Welcome email for new newsletter subscribers. */\nexport function welcomeEmailHtml(opts: {\n email: string;\n unsubscribeUrl: string;\n siteUrl: string;\n}): string {\n return `<!DOCTYPE html>\n<html>\n<head><meta charset=\"utf-8\"><title>Welcome</title></head>\n<body style=\"margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#171717;\">\n <div style=\"max-width:560px;margin:0 auto;padding:40px 20px;\">\n <h1 style=\"font-size:24px;font-weight:600;margin:0 0 16px;\">Welcome aboard!</h1>\n <p style=\"font-size:16px;line-height:24px;color:#404040;margin:0 0 24px;\">\n Hi <strong>${esc(opts.email)}</strong>,感谢订阅 <a href=\"${esc(opts.siteUrl)}\">vinext Blog</a>。\n 新文章发布时会第一时间通知你。\n </p>\n <hr style=\"border:0;border-top:1px solid #e5e5e5;margin:32px 0;\" />\n <p style=\"font-size:12px;color:#737373;margin:0;\">\n 不想再收?<a href=\"${esc(opts.unsubscribeUrl)}\" style=\"color:#737373;\">退订</a>\n </p>\n </div>\n</body>\n</html>`;\n}\n\nexport function resetPasswordHtml(opts: {\n resetUrl: string;\n siteUrl: string;\n email: string;\n}): string {\n return `<!DOCTYPE html>\n<html>\n<body style=\"margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,sans-serif;color:#171717;\">\n <div style=\"max-width:560px;margin:0 auto;padding:40px 20px;\">\n <p style=\"font-size:13px;color:#737373;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em;\">Reset password</p>\n <h1 style=\"font-size:24px;font-weight:600;margin:0 0 12px;\">Reset your password</h1>\n <p style=\"font-size:16px;line-height:24px;color:#404040;margin:0 0 24px;\">\n Hi <strong>${esc(opts.email)}</strong>,我们收到了重置 <a href=\"${esc(opts.siteUrl)}\">vinext Blog</a> 账户密码的请求。\n 点击下方按钮设置新密码。链接 1 小时内有效。\n </p>\n <a href=\"${esc(opts.resetUrl)}\" style=\"display:inline-block;background:#171717;color:#fafafa;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:14px;\">Reset password</a>\n <p style=\"font-size:12px;color:#737373;margin:24px 0 0;\">\n 如果这不是你的操作,请忽略此邮件。如果按钮无法打开,请复制链接到浏览器:<br />\n <a href=\"${esc(opts.resetUrl)}\" style=\"color:#737373;word-break:break-all;\">${esc(opts.resetUrl)}</a>\n </p>\n </div>\n</body>\n</html>`;\n}\n\nexport function verifyEmailHtml(opts: {\n verifyUrl: string;\n siteUrl: string;\n email: string;\n}): string {\n return `<!DOCTYPE html>\n<html>\n<body style=\"margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,sans-serif;color:#171717;\">\n <div style=\"max-width:560px;margin:0 auto;padding:40px 20px;\">\n <p style=\"font-size:13px;color:#737373;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em;\">Verify email</p>\n <h1 style=\"font-size:24px;font-weight:600;margin:0 0 12px;\">Confirm your email</h1>\n <p style=\"font-size:16px;line-height:24px;color:#404040;margin:0 0 24px;\">\n Hi <strong>${esc(opts.email)}</strong>,欢迎注册 <a href=\"${esc(opts.siteUrl)}\">vinext Blog</a>。\n 请点击下面的按钮完成邮箱验证,验证后即可登录后台。\n </p>\n <a href=\"${esc(opts.verifyUrl)}\" style=\"display:inline-block;background:#171717;color:#fafafa;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:14px;\">Verify email</a>\n <p style=\"font-size:12px;color:#737373;margin:24px 0 0;\">\n 如果按钮无法打开,请复制这个链接到浏览器:<br />\n <a href=\"${esc(opts.verifyUrl)}\" style=\"color:#737373;word-break:break-all;\">${esc(opts.verifyUrl)}</a>\n </p>\n </div>\n</body>\n</html>`;\n}\n\n/** New post notification email. */\nexport function newPostEmailHtml(opts: {\n title: string;\n description: string;\n url: string;\n unsubscribeUrl: string;\n}): string {\n return `<!DOCTYPE html>\n<html>\n<body style=\"margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,sans-serif;color:#171717;\">\n <div style=\"max-width:560px;margin:0 auto;padding:40px 20px;\">\n <p style=\"font-size:13px;color:#737373;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em;\">New post</p>\n <h1 style=\"font-size:24px;font-weight:600;margin:0 0 16px;\">\n <a href=\"${esc(opts.url)}\" style=\"color:#171717;text-decoration:none;\">${esc(opts.title)}</a>\n </h1>\n <p style=\"font-size:16px;line-height:24px;color:#404040;margin:0 0 24px;\">${esc(opts.description)}</p>\n <a href=\"${esc(opts.url)}\" style=\"display:inline-block;background:#171717;color:#fafafa;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:14px;\">Read the post →</a>\n <hr style=\"border:0;border-top:1px solid #e5e5e5;margin:40px 0 16px;\" />\n <p style=\"font-size:12px;color:#737373;margin:0;\">\n 不想再收新文章通知?<a href=\"${esc(opts.unsubscribeUrl)}\" style=\"color:#737373;\">退订</a>\n </p>\n </div>\n</body>\n</html>`;\n}\n","// lib/env.ts - 集中获取 Cloudflare bindings\n// 用 cloudflare:workers 模块(workerd 内置),作为平台 adapter 的绑定入口\n\n/// <reference types=\"@cloudflare/workers-types\" />\nimport { env } from \"cloudflare:workers\";\n\nexport type AppEnv = {\n DB: D1Database;\n ASSETS: Fetcher;\n IMAGES: ImagesBinding;\n ASSETS_BUCKET?: R2Bucket;\n CONTENT_CACHE?: KVNamespace;\n ADMIN_PASSWORD: string;\n ADMIN_EMAIL?: string;\n SITE_URL?: string;\n RESEND_API_KEY?: string;\n RESEND_FROM?: string;\n // Google OAuth 仍然兼容 Cloudflare Secret 作为兜底。\n // 实际生效值以 app_settings.google_client_id / google_client_secret 为准。\n GOOGLE_CLIENT_ID?: string;\n GOOGLE_CLIENT_SECRET?: string;\n /** Turnstile site key fallback when not stored in app_settings */\n TURNSTILE_SITE_KEY?: string;\n /** Turnstile secret — set via `wrangler secret put TURNSTILE_SECRET_KEY` */\n TURNSTILE_SECRET_KEY?: string;\n /** Notion integration token for the blog data source */\n NOTION_TOKEN?: string;\n /** Notion data source ID used by dataSources.query */\n NOTION_DATA_SOURCE_ID?: string;\n /** Notion data source ID for the public movie catalog */\n NOTION_MOVIES_DATA_SOURCE_ID?: string;\n /** Notion data source ID for localized movie copy */\n NOTION_MOVIE_TRANSLATIONS_DATA_SOURCE_ID?: string;\n /** Optional Notion API base URL for tests or proxies */\n NOTION_API_BASE_URL?: string;\n /** Optional Notion edit URL for admin handoff screens */\n NOTION_EDIT_BASE_URL?: string;\n /** Optional webhook verification token for Notion invalidation */\n NOTION_WEBHOOK_VERIFICATION_TOKEN?: string;\n};\n\n// 强制类型:vinext 把 env 类型放在 env.d.ts(interface VinextEnv extends Env),\n// 但 TS server 经常解析不到。运行时一定有 DB,类型断言保证编译通过。\nexport const workerEnv = env as unknown as AppEnv;\n"],"mappings":";AAMA,SAAS,cAAc;;;ACFvB,SAAS,WAAW;AAuCb,IAAM,YAAY;;;ADlCzB,IAAI,eAA8B;AAElC,SAAS,YAA2B;AAClC,MAAI,aAAc,QAAO;AACzB,QAAMA,OAAM;AACZ,MAAI,CAACA,KAAI,eAAgB,QAAO;AAChC,iBAAe,IAAI,OAAOA,KAAI,cAAc;AAC5C,SAAO;AACT;AAeA,eAAsB,UAAU,MAAwC;AACtE,QAAMA,OAAM;AACZ,QAAM,SAAS,UAAU;AAGzB,MAAI,CAAC,QAAQ;AACX,YAAQ,IAAI,iCAAiC,KAAK,IAAI,KAAK,OAAO;AAClE,WAAO;AAAA,EACT;AAEA,QAAM,OAAOA,KAAI,eAAe;AAEhC,QAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,OAAO,KAAK;AAAA,IAC/C;AAAA,IACA,IAAI,KAAK;AAAA,IACT,SAAS,KAAK;AAAA,IACd,MAAM,KAAK;AAAA,IACX,MAAM,KAAK;AAAA,IACX,SAAS,KAAK;AAAA,EAChB,CAAC;AAED,MAAI,OAAO;AACT,UAAM,IAAI,MAAM,iBAAiB,MAAM,OAAO,EAAE;AAAA,EAClD;AACA,SAAO,MAAM,MAAM;AACrB;AAGA,SAAS,IAAI,GAAmB;AAC9B,SAAO,EACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,OAAO;AAC1B;AAGO,SAAS,iBAAiB,MAItB;AACT,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAOU,IAAI,KAAK,KAAK,CAAC,oDAA2B,IAAI,KAAK,OAAO,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA,+CAKxD,IAAI,KAAK,cAAc,CAAC;AAAA;AAAA;AAAA;AAAA;AAK9C;AAEO,SAAS,kBAAkB,MAIvB;AACT,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAOU,IAAI,KAAK,KAAK,CAAC,sEAA8B,IAAI,KAAK,OAAO,CAAC;AAAA;AAAA;AAAA,eAGlE,IAAI,KAAK,QAAQ,CAAC;AAAA;AAAA;AAAA,iBAGhB,IAAI,KAAK,QAAQ,CAAC,iDAAiD,IAAI,KAAK,QAAQ,CAAC;AAAA;AAAA;AAAA;AAAA;AAKtG;AAEO,SAAS,gBAAgB,MAIrB;AACT,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAOU,IAAI,KAAK,KAAK,CAAC,oDAA2B,IAAI,KAAK,OAAO,CAAC;AAAA;AAAA;AAAA,eAG/D,IAAI,KAAK,SAAS,CAAC;AAAA;AAAA;AAAA,iBAGjB,IAAI,KAAK,SAAS,CAAC,iDAAiD,IAAI,KAAK,SAAS,CAAC;AAAA;AAAA;AAAA;AAAA;AAKxG;AAGO,SAAS,iBAAiB,MAKtB;AACT,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,iBAMQ,IAAI,KAAK,GAAG,CAAC,iDAAiD,IAAI,KAAK,KAAK,CAAC;AAAA;AAAA,gFAEd,IAAI,KAAK,WAAW,CAAC;AAAA,eACtF,IAAI,KAAK,GAAG,CAAC;AAAA;AAAA;AAAA,6EAGD,IAAI,KAAK,cAAc,CAAC;AAAA;AAAA;AAAA;AAAA;AAKnD;","names":["env"]}
1
+ {"version":3,"sources":["../../src/email/resend.ts","../../src/util/env.ts"],"sourcesContent":["// Email delivery via the Resend HTTP API.\n//\n// Cloudflare Workers cannot use SMTP (no long-lived connections), so\n// email is sent through the Resend HTTP API. The free Resend tier\n// covers 3 000 messages per month.\n\nimport { Resend } from \"resend\";\nimport { workerEnv } from \"../util/env\";\n\nlet resendClient: Resend | null = null;\n\nfunction getResend(): Resend | null {\n if (resendClient) return resendClient;\n const env = workerEnv;\n if (!env.RESEND_API_KEY) return null;\n resendClient = new Resend(env.RESEND_API_KEY);\n return resendClient;\n}\n\ntype SendArgs = {\n to: string | string[];\n subject: string;\n html: string;\n text?: string;\n replyTo?: string;\n};\n\n/**\n * Send an email. When RESEND_API_KEY is not set (the dev default), the\n * call is silently skipped with a log line instead of failing. Returns\n * the Resend message id, or null when no-op. Throws on a Resend error.\n */\nexport async function sendEmail(args: SendArgs): Promise<string | null> {\n const env = workerEnv;\n const resend = getResend();\n\n // Dev mode without a key: skip silently.\n if (!resend) {\n console.log(\"[email:noop] to=%s subject=%s\", args.to, args.subject);\n return null;\n }\n\n const from = env.RESEND_FROM || \"Blog <noreply@resend.dev>\";\n\n const { data, error } = await resend.emails.send({\n from,\n to: args.to,\n subject: args.subject,\n html: args.html,\n text: args.text,\n replyTo: args.replyTo,\n });\n\n if (error) {\n throw new Error(`Resend error: ${error.message}`);\n }\n return data?.id ?? null;\n}\n\n/** Minimal HTML escape (avoids depending on a runtime sanitizer). */\nfunction esc(s: string): string {\n return s\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#39;\");\n}\n\n/** Welcome email for new newsletter subscribers. */\nexport function welcomeEmailHtml(opts: {\n email: string;\n unsubscribeUrl: string;\n siteUrl: string;\n}): string {\n return `<!DOCTYPE html>\n<html>\n<head><meta charset=\"utf-8\"><title>Welcome</title></head>\n<body style=\"margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#171717;\">\n <div style=\"max-width:560px;margin:0 auto;padding:40px 20px;\">\n <h1 style=\"font-size:24px;font-weight:600;margin:0 0 16px;\">Welcome aboard!</h1>\n <p style=\"font-size:16px;line-height:24px;color:#404040;margin:0 0 24px;\">\n Hi <strong>${esc(opts.email)}</strong>,感谢订阅 <a href=\"${esc(opts.siteUrl)}\">vinext Blog</a>。\n 新文章发布时会第一时间通知你。\n </p>\n <hr style=\"border:0;border-top:1px solid #e5e5e5;margin:32px 0;\" />\n <p style=\"font-size:12px;color:#737373;margin:0;\">\n 不想再收?<a href=\"${esc(opts.unsubscribeUrl)}\" style=\"color:#737373;\">退订</a>\n </p>\n </div>\n</body>\n</html>`;\n}\n\nexport function resetPasswordHtml(opts: {\n resetUrl: string;\n siteUrl: string;\n email: string;\n}): string {\n return `<!DOCTYPE html>\n<html>\n<body style=\"margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,sans-serif;color:#171717;\">\n <div style=\"max-width:560px;margin:0 auto;padding:40px 20px;\">\n <p style=\"font-size:13px;color:#737373;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em;\">Reset password</p>\n <h1 style=\"font-size:24px;font-weight:600;margin:0 0 12px;\">Reset your password</h1>\n <p style=\"font-size:16px;line-height:24px;color:#404040;margin:0 0 24px;\">\n Hi <strong>${esc(opts.email)}</strong>,我们收到了重置 <a href=\"${esc(opts.siteUrl)}\">vinext Blog</a> 账户密码的请求。\n 点击下方按钮设置新密码。链接 1 小时内有效。\n </p>\n <a href=\"${esc(opts.resetUrl)}\" style=\"display:inline-block;background:#171717;color:#fafafa;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:14px;\">Reset password</a>\n <p style=\"font-size:12px;color:#737373;margin:24px 0 0;\">\n 如果这不是你的操作,请忽略此邮件。如果按钮无法打开,请复制链接到浏览器:<br />\n <a href=\"${esc(opts.resetUrl)}\" style=\"color:#737373;word-break:break-all;\">${esc(opts.resetUrl)}</a>\n </p>\n </div>\n</body>\n</html>`;\n}\n\nexport function verifyEmailHtml(opts: {\n verifyUrl: string;\n siteUrl: string;\n email: string;\n}): string {\n return `<!DOCTYPE html>\n<html>\n<body style=\"margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,sans-serif;color:#171717;\">\n <div style=\"max-width:560px;margin:0 auto;padding:40px 20px;\">\n <p style=\"font-size:13px;color:#737373;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em;\">Verify email</p>\n <h1 style=\"font-size:24px;font-weight:600;margin:0 0 12px;\">Confirm your email</h1>\n <p style=\"font-size:16px;line-height:24px;color:#404040;margin:0 0 24px;\">\n Hi <strong>${esc(opts.email)}</strong>,欢迎注册 <a href=\"${esc(opts.siteUrl)}\">vinext Blog</a>。\n 请点击下面的按钮完成邮箱验证,验证后即可登录后台。\n </p>\n <a href=\"${esc(opts.verifyUrl)}\" style=\"display:inline-block;background:#171717;color:#fafafa;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:14px;\">Verify email</a>\n <p style=\"font-size:12px;color:#737373;margin:24px 0 0;\">\n 如果按钮无法打开,请复制这个链接到浏览器:<br />\n <a href=\"${esc(opts.verifyUrl)}\" style=\"color:#737373;word-break:break-all;\">${esc(opts.verifyUrl)}</a>\n </p>\n </div>\n</body>\n</html>`;\n}\n\n/** New post notification email. */\nexport function newPostEmailHtml(opts: {\n title: string;\n description: string;\n url: string;\n unsubscribeUrl: string;\n}): string {\n return `<!DOCTYPE html>\n<html>\n<body style=\"margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,sans-serif;color:#171717;\">\n <div style=\"max-width:560px;margin:0 auto;padding:40px 20px;\">\n <p style=\"font-size:13px;color:#737373;margin:0 0 8px;text-transform:uppercase;letter-spacing:0.05em;\">New post</p>\n <h1 style=\"font-size:24px;font-weight:600;margin:0 0 16px;\">\n <a href=\"${esc(opts.url)}\" style=\"color:#171717;text-decoration:none;\">${esc(opts.title)}</a>\n </h1>\n <p style=\"font-size:16px;line-height:24px;color:#404040;margin:0 0 24px;\">${esc(opts.description)}</p>\n <a href=\"${esc(opts.url)}\" style=\"display:inline-block;background:#171717;color:#fafafa;padding:10px 20px;border-radius:6px;text-decoration:none;font-size:14px;\">Read the post →</a>\n <hr style=\"border:0;border-top:1px solid #e5e5e5;margin:40px 0 16px;\" />\n <p style=\"font-size:12px;color:#737373;margin:0;\">\n 不想再收新文章通知?<a href=\"${esc(opts.unsubscribeUrl)}\" style=\"color:#737373;\">退订</a>\n </p>\n </div>\n</body>\n</html>`;\n}\n","// lib/env.ts - 集中获取 Cloudflare bindings\n// 用 cloudflare:workers 模块(workerd 内置),作为平台 adapter 的绑定入口\n\n/// <reference types=\"@cloudflare/workers-types\" />\nimport { env } from \"cloudflare:workers\";\n\nexport type AppEnv = {\n DB: D1Database;\n ASSETS: Fetcher;\n IMAGES: ImagesBinding;\n ASSETS_BUCKET?: R2Bucket;\n CONTENT_CACHE?: KVNamespace;\n ADMIN_PASSWORD: string;\n ADMIN_EMAIL?: string;\n SITE_URL?: string;\n RESEND_API_KEY?: string;\n RESEND_FROM?: string;\n // Google OAuth 仍然兼容 Cloudflare Secret 作为兜底。\n // 实际生效值以 app_settings.google_client_id / google_client_secret 为准。\n GOOGLE_CLIENT_ID?: string;\n GOOGLE_CLIENT_SECRET?: string;\n /** Turnstile site key fallback when not stored in app_settings */\n TURNSTILE_SITE_KEY?: string;\n /** Turnstile secret — set via `wrangler secret put TURNSTILE_SECRET_KEY` */\n TURNSTILE_SECRET_KEY?: string;\n /** Notion integration token for the blog data source */\n NOTION_TOKEN?: string;\n /** Notion data source ID used by dataSources.query */\n NOTION_DATA_SOURCE_ID?: string;\n /** Optional Notion API base URL for tests or proxies */\n NOTION_API_BASE_URL?: string;\n /** Optional Notion edit URL for admin handoff screens */\n NOTION_EDIT_BASE_URL?: string;\n /** Optional webhook verification token for Notion invalidation */\n NOTION_WEBHOOK_VERIFICATION_TOKEN?: string;\n};\n\n// 强制类型:vinext 把 env 类型放在 env.d.ts(interface VinextEnv extends Env),\n// 但 TS server 经常解析不到。运行时一定有 DB,类型断言保证编译通过。\nexport const workerEnv = env as unknown as AppEnv;\n"],"mappings":";AAMA,SAAS,cAAc;;;ACFvB,SAAS,WAAW;AAmCb,IAAM,YAAY;;;AD9BzB,IAAI,eAA8B;AAElC,SAAS,YAA2B;AAClC,MAAI,aAAc,QAAO;AACzB,QAAMA,OAAM;AACZ,MAAI,CAACA,KAAI,eAAgB,QAAO;AAChC,iBAAe,IAAI,OAAOA,KAAI,cAAc;AAC5C,SAAO;AACT;AAeA,eAAsB,UAAU,MAAwC;AACtE,QAAMA,OAAM;AACZ,QAAM,SAAS,UAAU;AAGzB,MAAI,CAAC,QAAQ;AACX,YAAQ,IAAI,iCAAiC,KAAK,IAAI,KAAK,OAAO;AAClE,WAAO;AAAA,EACT;AAEA,QAAM,OAAOA,KAAI,eAAe;AAEhC,QAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,OAAO,KAAK;AAAA,IAC/C;AAAA,IACA,IAAI,KAAK;AAAA,IACT,SAAS,KAAK;AAAA,IACd,MAAM,KAAK;AAAA,IACX,MAAM,KAAK;AAAA,IACX,SAAS,KAAK;AAAA,EAChB,CAAC;AAED,MAAI,OAAO;AACT,UAAM,IAAI,MAAM,iBAAiB,MAAM,OAAO,EAAE;AAAA,EAClD;AACA,SAAO,MAAM,MAAM;AACrB;AAGA,SAAS,IAAI,GAAmB;AAC9B,SAAO,EACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,OAAO;AAC1B;AAGO,SAAS,iBAAiB,MAItB;AACT,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAOU,IAAI,KAAK,KAAK,CAAC,oDAA2B,IAAI,KAAK,OAAO,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA,+CAKxD,IAAI,KAAK,cAAc,CAAC;AAAA;AAAA;AAAA;AAAA;AAK9C;AAEO,SAAS,kBAAkB,MAIvB;AACT,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAOU,IAAI,KAAK,KAAK,CAAC,sEAA8B,IAAI,KAAK,OAAO,CAAC;AAAA;AAAA;AAAA,eAGlE,IAAI,KAAK,QAAQ,CAAC;AAAA;AAAA;AAAA,iBAGhB,IAAI,KAAK,QAAQ,CAAC,iDAAiD,IAAI,KAAK,QAAQ,CAAC;AAAA;AAAA;AAAA;AAAA;AAKtG;AAEO,SAAS,gBAAgB,MAIrB;AACT,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAOU,IAAI,KAAK,KAAK,CAAC,oDAA2B,IAAI,KAAK,OAAO,CAAC;AAAA;AAAA;AAAA,eAG/D,IAAI,KAAK,SAAS,CAAC;AAAA;AAAA;AAAA,iBAGjB,IAAI,KAAK,SAAS,CAAC,iDAAiD,IAAI,KAAK,SAAS,CAAC;AAAA;AAAA;AAAA;AAAA;AAKxG;AAGO,SAAS,iBAAiB,MAKtB;AACT,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,iBAMQ,IAAI,KAAK,GAAG,CAAC,iDAAiD,IAAI,KAAK,KAAK,CAAC;AAAA;AAAA,gFAEd,IAAI,KAAK,WAAW,CAAC;AAAA,eACtF,IAAI,KAAK,GAAG,CAAC;AAAA;AAAA;AAAA,6EAGD,IAAI,KAAK,cAAc,CAAC;AAAA;AAAA;AAAA;AAAA;AAKnD;","names":["env"]}
@@ -19,10 +19,6 @@ type AppEnv = {
19
19
  NOTION_TOKEN?: string;
20
20
  /** Notion data source ID used by dataSources.query */
21
21
  NOTION_DATA_SOURCE_ID?: string;
22
- /** Notion data source ID for the public movie catalog */
23
- NOTION_MOVIES_DATA_SOURCE_ID?: string;
24
- /** Notion data source ID for localized movie copy */
25
- NOTION_MOVIE_TRANSLATIONS_DATA_SOURCE_ID?: string;
26
22
  /** Optional Notion API base URL for tests or proxies */
27
23
  NOTION_API_BASE_URL?: string;
28
24
  /** Optional Notion edit URL for admin handoff screens */
@@ -1,26 +1,20 @@
1
- declare const supportedLocales: readonly ["zh-CN", "en-US"];
2
- type AppLocale = (typeof supportedLocales)[number];
3
- declare const defaultLocale: AppLocale;
4
- declare function isAppLocale(value: string): value is AppLocale;
5
- declare function localizedMovieListPath(locale: AppLocale): string;
6
- declare function localizedMovieDetailPath(locale: AppLocale, slug: string): string;
7
- declare function expandLocalizedMoviePaths(paths: readonly string[], locale?: string): string[];
8
-
9
- type MovieUiMessages = {
10
- backToList: string;
11
- releaseDate: string;
12
- director: string;
13
- actors: string;
14
- noSummary: string;
15
- unknownYear: string;
16
- unknownReleaseDate: string;
17
- searchPlaceholder: string;
18
- noSearchResults: string;
19
- itemLabel: string;
20
- notionLink: string;
21
- admin: string;
22
- languageLabel: string;
1
+ type I18nConfig<TLocale extends string = string> = {
2
+ supportedLocales: readonly TLocale[];
3
+ defaultLocale: TLocale;
23
4
  };
24
- declare function getMovieUiMessages(locale: AppLocale): MovieUiMessages;
5
+ declare function defineI18nConfig<const TLocale extends string>(config: I18nConfig<TLocale>): I18nConfig<TLocale>;
6
+ declare function isSupportedLocale<TLocale extends string>(config: I18nConfig<TLocale>, value: string): value is TLocale;
7
+ declare function localesForExpansion<TLocale extends string>(config: I18nConfig<TLocale>, locale?: string): TLocale[];
8
+ declare function localizedPath(locale: string, path: string): string;
9
+ declare function localizedDetailPath(locale: string, listPath: string, slug: string): string;
10
+ declare function expandLocalizedPaths<TLocale extends string>(input: {
11
+ paths: readonly string[];
12
+ config: I18nConfig<TLocale>;
13
+ locale?: string;
14
+ shouldLocalize?: (path: string) => boolean;
15
+ }): string[];
16
+
17
+ type LocaleMessages<TLocale extends string = string, TMessages extends object = Record<string, string>> = Record<TLocale, TMessages>;
18
+ declare function getLocaleMessages<TLocale extends string, TMessages extends object>(messages: LocaleMessages<TLocale, TMessages>, locale: TLocale): TMessages;
25
19
 
26
- export { type AppLocale, type MovieUiMessages, defaultLocale, expandLocalizedMoviePaths, getMovieUiMessages, isAppLocale, localizedMovieDetailPath, localizedMovieListPath, supportedLocales };
20
+ export { type I18nConfig, type LocaleMessages, defineI18nConfig, expandLocalizedPaths, getLocaleMessages, isSupportedLocale, localesForExpansion, localizedDetailPath, localizedPath };