@nexpress/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/dist/audit-54XLVCWD.js +14 -0
  4. package/dist/audit-54XLVCWD.js.map +1 -0
  5. package/dist/auth.d.ts +640 -0
  6. package/dist/auth.js +94 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/can-YLUHRJAB.js +19 -0
  9. package/dist/can-YLUHRJAB.js.map +1 -0
  10. package/dist/chunk-2G264RCD.js +68 -0
  11. package/dist/chunk-2G264RCD.js.map +1 -0
  12. package/dist/chunk-2YDGE7YX.js +92 -0
  13. package/dist/chunk-2YDGE7YX.js.map +1 -0
  14. package/dist/chunk-473S4TER.js +538 -0
  15. package/dist/chunk-473S4TER.js.map +1 -0
  16. package/dist/chunk-4ZLMEKFX.js +18 -0
  17. package/dist/chunk-4ZLMEKFX.js.map +1 -0
  18. package/dist/chunk-55FU6WED.js +179 -0
  19. package/dist/chunk-55FU6WED.js.map +1 -0
  20. package/dist/chunk-6YI5K2TI.js +1959 -0
  21. package/dist/chunk-6YI5K2TI.js.map +1 -0
  22. package/dist/chunk-BHK3AD3Q.js +41 -0
  23. package/dist/chunk-BHK3AD3Q.js.map +1 -0
  24. package/dist/chunk-CRUQBZUF.js +39 -0
  25. package/dist/chunk-CRUQBZUF.js.map +1 -0
  26. package/dist/chunk-CTSQ7BRI.js +175 -0
  27. package/dist/chunk-CTSQ7BRI.js.map +1 -0
  28. package/dist/chunk-DK2JBJH7.js +81 -0
  29. package/dist/chunk-DK2JBJH7.js.map +1 -0
  30. package/dist/chunk-DP2PREDU.js +597 -0
  31. package/dist/chunk-DP2PREDU.js.map +1 -0
  32. package/dist/chunk-EQ2Z3KMD.js +24 -0
  33. package/dist/chunk-EQ2Z3KMD.js.map +1 -0
  34. package/dist/chunk-FZ7O6DWI.js +305 -0
  35. package/dist/chunk-FZ7O6DWI.js.map +1 -0
  36. package/dist/chunk-ISLYFQWL.js +1270 -0
  37. package/dist/chunk-ISLYFQWL.js.map +1 -0
  38. package/dist/chunk-JJL74ZPK.js +68 -0
  39. package/dist/chunk-JJL74ZPK.js.map +1 -0
  40. package/dist/chunk-JKXAPSU4.js +24 -0
  41. package/dist/chunk-JKXAPSU4.js.map +1 -0
  42. package/dist/chunk-KU5M27ZC.js +24 -0
  43. package/dist/chunk-KU5M27ZC.js.map +1 -0
  44. package/dist/chunk-LSHHRDVR.js +34 -0
  45. package/dist/chunk-LSHHRDVR.js.map +1 -0
  46. package/dist/chunk-M43PGOQY.js +715 -0
  47. package/dist/chunk-M43PGOQY.js.map +1 -0
  48. package/dist/chunk-MEJAHXIO.js +150 -0
  49. package/dist/chunk-MEJAHXIO.js.map +1 -0
  50. package/dist/chunk-NUCGHWCF.js +101 -0
  51. package/dist/chunk-NUCGHWCF.js.map +1 -0
  52. package/dist/chunk-OK5HOCQI.js +845 -0
  53. package/dist/chunk-OK5HOCQI.js.map +1 -0
  54. package/dist/chunk-OROPGO65.js +13 -0
  55. package/dist/chunk-OROPGO65.js.map +1 -0
  56. package/dist/chunk-PPAS4SZR.js +176 -0
  57. package/dist/chunk-PPAS4SZR.js.map +1 -0
  58. package/dist/chunk-PPBWRKO2.js +171 -0
  59. package/dist/chunk-PPBWRKO2.js.map +1 -0
  60. package/dist/chunk-PZ5AY32C.js +10 -0
  61. package/dist/chunk-PZ5AY32C.js.map +1 -0
  62. package/dist/chunk-QO7LAQZH.js +321 -0
  63. package/dist/chunk-QO7LAQZH.js.map +1 -0
  64. package/dist/chunk-QVJ2HCAX.js +225 -0
  65. package/dist/chunk-QVJ2HCAX.js.map +1 -0
  66. package/dist/chunk-RIPHIRPP.js +68 -0
  67. package/dist/chunk-RIPHIRPP.js.map +1 -0
  68. package/dist/chunk-S27S42QY.js +134 -0
  69. package/dist/chunk-S27S42QY.js.map +1 -0
  70. package/dist/chunk-SBCVAC2Z.js +40 -0
  71. package/dist/chunk-SBCVAC2Z.js.map +1 -0
  72. package/dist/chunk-TFJ4MKPH.js +694 -0
  73. package/dist/chunk-TFJ4MKPH.js.map +1 -0
  74. package/dist/chunk-THX3SHYA.js +75 -0
  75. package/dist/chunk-THX3SHYA.js.map +1 -0
  76. package/dist/chunk-UGQSQO5B.js +222 -0
  77. package/dist/chunk-UGQSQO5B.js.map +1 -0
  78. package/dist/chunk-V2UNHGAP.js +26 -0
  79. package/dist/chunk-V2UNHGAP.js.map +1 -0
  80. package/dist/chunk-VGTPQXNQ.js +2790 -0
  81. package/dist/chunk-VGTPQXNQ.js.map +1 -0
  82. package/dist/chunk-VNIHXQ7W.js +194 -0
  83. package/dist/chunk-VNIHXQ7W.js.map +1 -0
  84. package/dist/chunk-WV272MPW.js +31 -0
  85. package/dist/chunk-WV272MPW.js.map +1 -0
  86. package/dist/chunk-X5KKBOUS.js +26 -0
  87. package/dist/chunk-X5KKBOUS.js.map +1 -0
  88. package/dist/chunk-XANPEOJC.js +17 -0
  89. package/dist/chunk-XANPEOJC.js.map +1 -0
  90. package/dist/chunk-XPVQIHAQ.js +83 -0
  91. package/dist/chunk-XPVQIHAQ.js.map +1 -0
  92. package/dist/chunk-ZCINJSS4.js +75 -0
  93. package/dist/chunk-ZCINJSS4.js.map +1 -0
  94. package/dist/community.d.ts +1425 -0
  95. package/dist/community.js +206 -0
  96. package/dist/community.js.map +1 -0
  97. package/dist/config-2GDU7PCK.js +32 -0
  98. package/dist/config-2GDU7PCK.js.map +1 -0
  99. package/dist/context-MNZ4QXPC.js +16 -0
  100. package/dist/context-MNZ4QXPC.js.map +1 -0
  101. package/dist/db-schema.d.ts +4 -0
  102. package/dist/db-schema.js +102 -0
  103. package/dist/db-schema.js.map +1 -0
  104. package/dist/db.d.ts +7 -0
  105. package/dist/db.js +117 -0
  106. package/dist/db.js.map +1 -0
  107. package/dist/digest-SY42GQSU.js +17 -0
  108. package/dist/digest-SY42GQSU.js.map +1 -0
  109. package/dist/errors-5OS3S2J3.js +22 -0
  110. package/dist/errors-5OS3S2J3.js.map +1 -0
  111. package/dist/host-OBOI4MJK.js +51 -0
  112. package/dist/host-OBOI4MJK.js.map +1 -0
  113. package/dist/i18n.d.ts +301 -0
  114. package/dist/i18n.js +68 -0
  115. package/dist/i18n.js.map +1 -0
  116. package/dist/index-B6-_vr_m.d.ts +590 -0
  117. package/dist/index-CY55LC0u.d.ts +4722 -0
  118. package/dist/index-CeiTvwbp.d.ts +168 -0
  119. package/dist/index-XwP1ET8b.d.ts +61 -0
  120. package/dist/index.d.ts +2037 -0
  121. package/dist/index.js +2205 -0
  122. package/dist/index.js.map +1 -0
  123. package/dist/job-log-VZXWQUDK.js +24 -0
  124. package/dist/job-log-VZXWQUDK.js.map +1 -0
  125. package/dist/jobs.d.ts +4 -0
  126. package/dist/jobs.js +76 -0
  127. package/dist/jobs.js.map +1 -0
  128. package/dist/logger-DqGaOU_j.d.ts +29 -0
  129. package/dist/logger-S7REWDNE.js +16 -0
  130. package/dist/logger-S7REWDNE.js.map +1 -0
  131. package/dist/media.d.ts +5 -0
  132. package/dist/media.js +41 -0
  133. package/dist/media.js.map +1 -0
  134. package/dist/mentions-2IHFVSHW.js +23 -0
  135. package/dist/mentions-2IHFVSHW.js.map +1 -0
  136. package/dist/mutes-EWAE5FZR.js +21 -0
  137. package/dist/mutes-EWAE5FZR.js.map +1 -0
  138. package/dist/notification-prefs-VPJDU7I6.js +21 -0
  139. package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
  140. package/dist/observability.d.ts +156 -0
  141. package/dist/observability.js +32 -0
  142. package/dist/observability.js.map +1 -0
  143. package/dist/profanity-adapter-NU2JQSLX.js +12 -0
  144. package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
  145. package/dist/queue-XE5BC75T.js +14 -0
  146. package/dist/queue-XE5BC75T.js.map +1 -0
  147. package/dist/rate-limit.d.ts +99 -0
  148. package/dist/rate-limit.js +14 -0
  149. package/dist/rate-limit.js.map +1 -0
  150. package/dist/registry-XIXDEPVI.js +31 -0
  151. package/dist/registry-XIXDEPVI.js.map +1 -0
  152. package/dist/reputation-JRL2YQHM.js +11 -0
  153. package/dist/reputation-JRL2YQHM.js.map +1 -0
  154. package/dist/routes.d.ts +43 -0
  155. package/dist/routes.js +12 -0
  156. package/dist/routes.js.map +1 -0
  157. package/dist/scheduled-CIQM57HT.js +20 -0
  158. package/dist/scheduled-CIQM57HT.js.map +1 -0
  159. package/dist/seo.d.ts +410 -0
  160. package/dist/seo.js +44 -0
  161. package/dist/seo.js.map +1 -0
  162. package/dist/settings-FOBIESPB.js +17 -0
  163. package/dist/settings-FOBIESPB.js.map +1 -0
  164. package/dist/spam-adapter-XX3G737Z.js +12 -0
  165. package/dist/spam-adapter-XX3G737Z.js.map +1 -0
  166. package/dist/strings-VAE47B2C.js +29 -0
  167. package/dist/strings-VAE47B2C.js.map +1 -0
  168. package/dist/templates-IFVJMCJ6.js +12 -0
  169. package/dist/templates-IFVJMCJ6.js.map +1 -0
  170. package/dist/types-TlsbXS0T.d.ts +871 -0
  171. package/package.json +129 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/seo/sitemap.ts","../src/seo/page-metadata.ts","../src/seo/feed.ts","../src/seo/json-ld.ts"],"sourcesContent":["import { getAllCollectionSlugs, getCollectionConfig } from \"../collections/registry.js\";\nimport { findDocuments } from \"../collections/pipeline.js\";\nimport { getI18nConfig } from \"../i18n/registry.js\";\n\n/**\n * Phase 10.1 — sitemap entry shape. Mirrors the sitemap.org spec\n * fields the framework cares about. Apps format these into XML\n * (the reference app does that in `apps/web/src/app/sitemap.xml/\n * route.ts`); the core helper stays format-agnostic so a future\n * news-sitemap or video-sitemap variant can reuse the same\n * collection walk.\n */\nexport interface NpSitemapEntry {\n /** Path-only — host is prepended by the consumer. Always starts with `/`. */\n loc: string;\n /** ISO timestamp for `<lastmod>`. Falls back to `updatedAt` then `createdAt`. */\n lastmod?: string;\n changefreq?:\n | \"always\"\n | \"hourly\"\n | \"daily\"\n | \"weekly\"\n | \"monthly\"\n | \"yearly\"\n | \"never\";\n priority?: number;\n /**\n * Phase 12.2 — hreflang alternates for translated content.\n * The renderer emits an `<xhtml:link rel=\"alternate\" hreflang=\"...\" href=\"...\"/>`\n * entry per alternate, and the urlset gets the xhtml namespace\n * declaration when any entry uses alternates.\n */\n alternates?: Array<{ hreflang: string; href: string }>;\n}\n\nexport interface BuildSitemapOptions {\n /**\n * Cap per-collection at this many rows so a 100K-document blog\n * doesn't bring the sitemap.xml endpoint to its knees. Default\n * 5000 is the sitemaps.org recommended max per file. Sites with\n * more rows than that per locale should pair this with the\n * sitemap-index split (see `locale` below) so each child file\n * stays under the cap.\n */\n perCollectionLimit?: number;\n /** Restrict to specific collection slugs (default: all). */\n collections?: string[];\n /**\n * Restrict to a single locale. When set:\n * - i18n collections filter rows to `locale = $locale` (so\n * each per-locale sitemap only enumerates its own URLs).\n * - non-i18n collections are emitted only for the configured\n * `defaultLocale`; other locales' sitemaps skip them so\n * a row never appears in two sibling sitemaps.\n * Leaving this `undefined` keeps the unfiltered single-file\n * behavior used when i18n is not configured.\n */\n locale?: string;\n}\n\n/**\n * Sitemap-index entry — a pointer to a child `<urlset>` document\n * (typically a per-locale sitemap). The `loc` is path-only; the\n * renderer prepends the absolute origin.\n */\nexport interface NpSitemapIndexEntry {\n loc: string;\n /** Optional ISO timestamp for `<lastmod>` on the child sitemap. */\n lastmod?: string;\n}\n\nconst DEFAULT_LIMIT_PER_COLLECTION = 5_000;\n\n/**\n * Walks every registered collection that opts into the sitemap\n * via `seo.urlPath`, queries published documents, and emits a\n * flat list of `NpSitemapEntry` rows. Anonymous read access is\n * required — `findDocuments(slug, opts, undefined)` runs the\n * collection's `access.read` callback with no user. Collections\n * that gate reads (admin-only, member-only) won't surface in the\n * sitemap, which is the right default.\n *\n * The function intentionally doesn't include the site root `/`\n * by itself — sites add a fixed entry for the home page (and any\n * other static routes like /search, /discussions) on top of the\n * collection walk.\n */\nexport async function buildSitemap(\n options: BuildSitemapOptions = {},\n): Promise<NpSitemapEntry[]> {\n const limit = options.perCollectionLimit ?? DEFAULT_LIMIT_PER_COLLECTION;\n const slugs = options.collections ?? getAllCollectionSlugs();\n const entries: NpSitemapEntry[] = [];\n const i18n = getI18nConfig();\n const localeFilter = options.locale;\n\n for (const slug of slugs) {\n let config;\n try {\n config = getCollectionConfig(slug);\n } catch {\n continue;\n }\n const seo = config.seo;\n if (!seo?.urlPath) continue;\n\n // Phase 12.9 — per-locale sitemap split. When the caller\n // requests a specific locale, non-i18n collections only\n // surface in the default-locale sitemap so a row never\n // appears in two sibling sitemaps.\n if (localeFilter && !config.i18n) {\n if (!i18n || localeFilter !== i18n.defaultLocale) continue;\n }\n\n let result;\n try {\n result = await findDocuments(\n slug,\n {\n limit,\n page: 1,\n where: { status: \"published\" },\n // For i18n collections we deliberately fetch *every*\n // locale's rows even when a localeFilter is set so the\n // grouping pass below can still build a complete\n // hreflang-alternates list. The emission step further\n // down filters siblings to the requested locale before\n // pushing entries. Non-i18n collections take the\n // localeFilter path through the early `continue` above.\n },\n // Anonymous — `access.read` must allow it for the row to\n // appear. Collections gated to authenticated users won't\n // throw here because the access check runs on the\n // collection level (not per-row); they'll throw and we\n // skip below.\n undefined,\n );\n } catch {\n continue;\n }\n\n // Phase 12.2 — for i18n collections, group rows by\n // translationGroupId so each emitted entry can advertise\n // its hreflang alternates. Rows missing the group id\n // (shouldn't happen post-12.1) fall back to standalone\n // entries with no alternates.\n const docs = result.docs;\n if (config.i18n) {\n const groups = new Map<string, Array<Record<string, unknown>>>();\n const orphans: Array<Record<string, unknown>> = [];\n for (const doc of docs) {\n const groupId =\n typeof doc.translationGroupId === \"string\"\n ? doc.translationGroupId\n : null;\n if (!groupId) {\n orphans.push(doc);\n continue;\n }\n const list = groups.get(groupId);\n if (list) list.push(doc);\n else groups.set(groupId, [doc]);\n }\n for (const siblings of groups.values()) {\n const alternates: Array<{ hreflang: string; href: string }> = [];\n for (const sibling of siblings) {\n const siblingPath = seo.urlPath(sibling);\n const locale =\n typeof sibling.locale === \"string\" ? sibling.locale : null;\n if (siblingPath && locale) {\n alternates.push({ hreflang: locale, href: siblingPath });\n }\n }\n for (const sibling of siblings) {\n // Phase 12.9 — when emitting a per-locale sitemap, only\n // push the sibling whose locale matches the filter; the\n // alternates list still references every translation\n // (built above) so crawlers discover the others through\n // hreflang.\n if (localeFilter) {\n const siblingLocale =\n typeof sibling.locale === \"string\" ? sibling.locale : null;\n if (siblingLocale !== localeFilter) continue;\n }\n const path = seo.urlPath(sibling);\n if (!path || !path.startsWith(\"/\")) continue;\n entries.push({\n loc: path,\n lastmod: pickLastmod(sibling),\n changefreq: seo.changefreq,\n priority: seo.priority,\n alternates: alternates.length > 1 ? alternates : undefined,\n });\n }\n }\n for (const doc of orphans) {\n if (localeFilter) {\n const docLocale = typeof doc.locale === \"string\" ? doc.locale : null;\n if (docLocale !== localeFilter) continue;\n }\n const path = seo.urlPath(doc);\n if (!path || !path.startsWith(\"/\")) continue;\n entries.push({\n loc: path,\n lastmod: pickLastmod(doc),\n changefreq: seo.changefreq,\n priority: seo.priority,\n });\n }\n continue;\n }\n\n for (const doc of docs) {\n const path = seo.urlPath(doc);\n if (!path) continue;\n if (!path.startsWith(\"/\")) continue;\n entries.push({\n loc: path,\n lastmod: pickLastmod(doc),\n changefreq: seo.changefreq,\n priority: seo.priority,\n });\n }\n }\n\n return entries;\n}\n\nfunction pickLastmod(doc: Record<string, unknown>): string | undefined {\n const candidate = doc.updatedAt ?? doc.createdAt;\n if (candidate instanceof Date) return candidate.toISOString();\n if (typeof candidate === \"string\") {\n const parsed = new Date(candidate);\n if (!Number.isNaN(parsed.getTime())) return parsed.toISOString();\n }\n return undefined;\n}\n\n/**\n * Renders an `NpSitemapEntry[]` plus the absolute host into an\n * XML body suitable for `Content-Type: application/xml`. The\n * loc path is URL-joined with the host without double-slashes;\n * the host should NOT have a trailing slash. The output is\n * sitemap.org 0.9 compliant.\n */\nexport function renderSitemapXml(\n origin: string,\n entries: NpSitemapEntry[],\n): string {\n const trimmed = origin.replace(/\\/+$/, \"\");\n const usesAlternates = entries.some(\n (e) => e.alternates && e.alternates.length > 0,\n );\n const lines: string[] = [\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>',\n usesAlternates\n ? '<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">'\n : '<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">',\n ];\n for (const entry of entries) {\n lines.push(\" <url>\");\n lines.push(` <loc>${escapeXml(`${trimmed}${entry.loc}`)}</loc>`);\n if (entry.lastmod) {\n lines.push(` <lastmod>${entry.lastmod}</lastmod>`);\n }\n if (entry.changefreq) {\n lines.push(` <changefreq>${entry.changefreq}</changefreq>`);\n }\n if (typeof entry.priority === \"number\") {\n lines.push(` <priority>${entry.priority.toFixed(1)}</priority>`);\n }\n if (entry.alternates) {\n for (const alt of entry.alternates) {\n lines.push(\n ` <xhtml:link rel=\"alternate\" hreflang=\"${escapeXml(alt.hreflang)}\" href=\"${escapeXml(`${trimmed}${alt.href}`)}\"/>`,\n );\n }\n }\n lines.push(\" </url>\");\n }\n lines.push(\"</urlset>\");\n return lines.join(\"\\n\");\n}\n\n/**\n * Phase 12.9 — render a sitemap-index document. Sites with i18n\n * configured emit one of these at `/sitemap.xml` instead of a\n * single `<urlset>`; each child sitemap holds the URLs for one\n * locale so the per-file 50K-entry sitemaps.org cap is per-locale\n * rather than shared across the whole site.\n *\n * The index itself is small (one `<sitemap>` per locale) so it\n * doesn't need the `xhtml` namespace or alternates — those live\n * inside the child `<urlset>` documents.\n */\nexport function renderSitemapIndexXml(\n origin: string,\n entries: NpSitemapIndexEntry[],\n): string {\n const trimmed = origin.replace(/\\/+$/, \"\");\n const lines: string[] = [\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>',\n '<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">',\n ];\n for (const entry of entries) {\n lines.push(\" <sitemap>\");\n lines.push(` <loc>${escapeXml(`${trimmed}${entry.loc}`)}</loc>`);\n if (entry.lastmod) {\n lines.push(` <lastmod>${entry.lastmod}</lastmod>`);\n }\n lines.push(\" </sitemap>\");\n }\n lines.push(\"</sitemapindex>\");\n return lines.join(\"\\n\");\n}\n\nfunction escapeXml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&apos;\");\n}\n","import { getDb } from \"../db/runtime.js\";\nimport { npSettings } from \"../db/schema/system.js\";\n\n/**\n * Phase 10.3 — site-wide SEO defaults read from `np_settings`.\n * Reads three existing keys + a new `seo` key:\n *\n * - `site` → { name, url } (existing General settings)\n * - `description` → string (existing General settings)\n * - `seo` → { defaultOgImage, twitterHandle, defaultLocale }\n *\n * The shape is a flat merge so callers don't have to hop across\n * keys to pre-fill metadata.\n */\nexport interface NpSiteSeoSettings {\n /** Site name shown in the title bar suffix and `og:site_name`. */\n siteName: string;\n /** Absolute origin used as the base for canonical URLs. */\n siteUrl: string;\n /** Default `<meta name=\"description\">` when a page doesn't set its own. */\n defaultDescription: string;\n /**\n * Default Open Graph image. Either an absolute URL or a path\n * starting with `/` (which gets joined to `siteUrl`). `null` =\n * no default; pages that don't set an image won't render an\n * `og:image` tag.\n */\n defaultOgImage: string | null;\n /** Twitter `@handle` (no leading `@`) for `twitter:site`. `null` = omit. */\n twitterHandle: string | null;\n /** BCP 47 locale tag for `og:locale`. Default `\"en_US\"`. */\n defaultLocale: string;\n}\n\nexport const DEFAULT_SITE_SEO_SETTINGS: NpSiteSeoSettings = {\n siteName: \"NexPress\",\n siteUrl: \"http://localhost:3000\",\n defaultDescription: \"\",\n defaultOgImage: null,\n twitterHandle: null,\n defaultLocale: \"en_US\",\n};\n\n/**\n * Reads the three settings keys that contribute to site-wide\n * SEO and merges into a single flat object. Missing fields fall\n * back to `DEFAULT_SITE_SEO_SETTINGS`. Read-only access — no\n * permission gate; the values are surfaced in public HTML\n * `<head>` tags.\n */\nexport async function getSiteSeoSettings(): Promise<NpSiteSeoSettings> {\n const db = getDb();\n const rows = (await db\n .select()\n .from(npSettings)) as Array<{ key: string; value: unknown }>;\n\n const map = new Map<string, unknown>();\n for (const row of rows) map.set(row.key, row.value);\n\n const site = readObject(map.get(\"site\"));\n const seo = readObject(map.get(\"seo\"));\n const description = map.get(\"description\");\n\n return {\n siteName:\n readString(site?.name) ?? DEFAULT_SITE_SEO_SETTINGS.siteName,\n siteUrl:\n readString(site?.url) ?? DEFAULT_SITE_SEO_SETTINGS.siteUrl,\n defaultDescription:\n (typeof description === \"string\" ? description : null) ??\n DEFAULT_SITE_SEO_SETTINGS.defaultDescription,\n defaultOgImage:\n readString(seo?.defaultOgImage) ??\n DEFAULT_SITE_SEO_SETTINGS.defaultOgImage,\n twitterHandle:\n readString(seo?.twitterHandle) ??\n DEFAULT_SITE_SEO_SETTINGS.twitterHandle,\n defaultLocale:\n readString(seo?.defaultLocale) ??\n DEFAULT_SITE_SEO_SETTINGS.defaultLocale,\n };\n}\n\nfunction readObject(v: unknown): Record<string, unknown> | null {\n if (v && typeof v === \"object\" && !Array.isArray(v)) {\n return v as Record<string, unknown>;\n }\n return null;\n}\n\nfunction readString(v: unknown): string | null {\n if (typeof v === \"string\" && v.trim().length > 0) return v.trim();\n return null;\n}\n\n/**\n * Caller-provided metadata for a single page. All fields are\n * optional — anything missing falls back to the site-wide\n * defaults from `getSiteSeoSettings()`.\n */\nexport interface NpPageMetadataInput {\n /** Page title (without site-name suffix; that's appended below). */\n title?: string | null;\n /** Page-specific description. Falls back to site default. */\n description?: string | null;\n /**\n * Image URL for og:image / twitter:image. Either absolute or\n * a path starting with `/` (joined to siteUrl). Falls back to\n * `defaultOgImage`.\n */\n ogImage?: string | null;\n /**\n * Path of the current page (no origin). Used to build the\n * canonical URL. Pass without query / hash; trailing `/` is\n * normalized off.\n */\n path?: string;\n /** OpenGraph type. Default `\"website\"`; posts use `\"article\"`. */\n ogType?: \"website\" | \"article\" | \"profile\";\n /**\n * Article-specific dates (only honored when `ogType === \"article\"`).\n * Pass `Date` instances — the helper formats to ISO 8601.\n */\n publishedTime?: Date | null;\n modifiedTime?: Date | null;\n /**\n * Phase 12.2 — locale for the rendered page. Surfaces into\n * `og:locale` so social cards label the language correctly.\n * Optional; sites without i18n leave it unset and the helper\n * omits `og:locale` from the output.\n */\n locale?: string;\n}\n\n/**\n * Next.js Metadata shape — kept structurally typed here to avoid\n * a hard dependency on the framework from core. The reference app\n * casts the return to Next's `Metadata` (the field names match).\n */\nexport interface NpPageMetadata {\n title: string;\n description: string;\n alternates?: { canonical: string };\n openGraph?: {\n title: string;\n description: string;\n siteName: string;\n url: string;\n type: \"website\" | \"article\" | \"profile\";\n images?: Array<{ url: string }>;\n locale?: string;\n publishedTime?: string;\n modifiedTime?: string;\n };\n twitter?: {\n card: \"summary\" | \"summary_large_image\";\n title: string;\n description: string;\n site?: string;\n images?: string[];\n };\n}\n\n/**\n * Combines the page-level input with site-wide SEO defaults to\n * produce a fully-resolved metadata object suitable for\n * Next.js' `generateMetadata`. Title, description, and image\n * fall back through to defaults; the OG and Twitter blocks are\n * mirrored so both crawler families see consistent values.\n */\nexport async function buildPageMetadata(\n input: NpPageMetadataInput = {},\n): Promise<NpPageMetadata> {\n const settings = await getSiteSeoSettings();\n const path = normalizePath(input.path);\n\n const titleText = input.title?.trim()\n ? `${input.title.trim()} · ${settings.siteName}`\n : settings.siteName;\n const descriptionText =\n input.description?.trim() ?? settings.defaultDescription;\n const canonicalUrl = `${settings.siteUrl.replace(/\\/+$/, \"\")}${path}`;\n const ogImage = resolveOgImage(input.ogImage, settings);\n const ogType = input.ogType ?? \"website\";\n\n const metadata: NpPageMetadata = {\n title: titleText,\n description: descriptionText,\n alternates: { canonical: canonicalUrl },\n openGraph: {\n title: titleText,\n description: descriptionText,\n siteName: settings.siteName,\n url: canonicalUrl,\n type: ogType,\n // Page-supplied locale wins over the site default so\n // translated copies surface their actual language to\n // social previews. Falls back to the site setting when\n // the caller doesn't pass one (non-i18n routes).\n locale: input.locale ?? settings.defaultLocale,\n ...(ogImage ? { images: [{ url: ogImage }] } : {}),\n ...(ogType === \"article\" && input.publishedTime\n ? { publishedTime: input.publishedTime.toISOString() }\n : {}),\n ...(ogType === \"article\" && input.modifiedTime\n ? { modifiedTime: input.modifiedTime.toISOString() }\n : {}),\n },\n twitter: {\n card: ogImage ? \"summary_large_image\" : \"summary\",\n title: titleText,\n description: descriptionText,\n ...(settings.twitterHandle ? { site: `@${settings.twitterHandle}` } : {}),\n ...(ogImage ? { images: [ogImage] } : {}),\n },\n };\n\n return metadata;\n}\n\nfunction normalizePath(raw: string | undefined): string {\n if (!raw || !raw.startsWith(\"/\")) return \"/\";\n if (raw === \"/\") return \"/\";\n return raw.replace(/\\/+$/, \"\");\n}\n\nfunction resolveOgImage(\n pageImage: string | null | undefined,\n settings: NpSiteSeoSettings,\n): string | null {\n const candidate = pageImage?.trim() || settings.defaultOgImage;\n if (!candidate) return null;\n if (/^https?:\\/\\//i.test(candidate)) return candidate;\n if (candidate.startsWith(\"/\")) {\n return `${settings.siteUrl.replace(/\\/+$/, \"\")}${candidate}`;\n }\n return candidate;\n}\n\n/**\n * Validates a partial patch against the `seo` settings shape.\n * Throws when fields are mistyped; returns the merged settings\n * the admin endpoint should persist. Mirrors\n * `validateCommunitySettingsPatch` in the community module.\n */\nexport interface NpSeoSettingsPatch {\n defaultOgImage?: string | null;\n twitterHandle?: string | null;\n defaultLocale?: string | null;\n}\n\nexport function validateSeoSettingsPatch(\n patch: unknown,\n): NpSeoSettingsPatch {\n if (!patch || typeof patch !== \"object\" || Array.isArray(patch)) {\n throw new Error(\"Body must be a JSON object\");\n }\n const raw = patch as Record<string, unknown>;\n const out: NpSeoSettingsPatch = {};\n\n if (\"defaultOgImage\" in raw) {\n const v = raw.defaultOgImage;\n if (v === null || v === \"\") {\n out.defaultOgImage = null;\n } else if (typeof v === \"string\") {\n // Accept absolute URLs or `/`-rooted paths. Reject anything\n // else — a stray `javascript:` URL would be a content-injection\n // hazard since the value lands in `<meta>` tags and `<img>`\n // src on social cards.\n const trimmed = v.trim();\n if (\n !/^https?:\\/\\//i.test(trimmed) &&\n !trimmed.startsWith(\"/\")\n ) {\n throw new Error(\n \"defaultOgImage must be an absolute URL or a /-rooted path\",\n );\n }\n out.defaultOgImage = trimmed;\n } else {\n throw new Error(\"defaultOgImage must be a string or null\");\n }\n }\n\n if (\"twitterHandle\" in raw) {\n const v = raw.twitterHandle;\n if (v === null || v === \"\") {\n out.twitterHandle = null;\n } else if (typeof v === \"string\") {\n // Strip a leading @ — we'll re-add it when emitting tags.\n const trimmed = v.trim().replace(/^@/, \"\");\n if (!/^[A-Za-z0-9_]{1,15}$/.test(trimmed)) {\n throw new Error(\n \"twitterHandle must be 1–15 alphanumeric/underscore characters\",\n );\n }\n out.twitterHandle = trimmed;\n } else {\n throw new Error(\"twitterHandle must be a string or null\");\n }\n }\n\n if (\"defaultLocale\" in raw) {\n const v = raw.defaultLocale;\n if (v === null || v === \"\") {\n out.defaultLocale = null;\n } else if (typeof v === \"string\") {\n const trimmed = v.trim();\n // BCP 47 language tag — loose check (full validation is\n // overkill; ICU does the real work downstream).\n if (!/^[a-z]{2,3}(?:[_-][A-Za-z0-9]{2,8})?$/.test(trimmed)) {\n throw new Error(\"defaultLocale must look like 'en' or 'en_US'\");\n }\n out.defaultLocale = trimmed.replace(\"-\", \"_\");\n } else {\n throw new Error(\"defaultLocale must be a string or null\");\n }\n }\n\n return out;\n}\n","import { findDocuments } from \"../collections/pipeline.js\";\nimport { getCollectionConfig } from \"../collections/registry.js\";\n\nimport { getSiteSeoSettings } from \"./page-metadata.js\";\n\n/**\n * Phase 10.4 — Atom feed builder. Atom (RFC 4287) over RSS 2.0\n * because Atom has tighter spec compliance, mandatory unique\n * IDs, and timezone-correct timestamps that RSS 2.0 leaves\n * underspecified. Most modern readers consume both, but new\n * surfaces should write Atom.\n *\n * Sites declare which collections expose a feed by giving them\n * `seo.urlPath` (already required for the sitemap, 10.1). The\n * feed reuses the same anonymous-read query path so non-public\n * rows never leak — same trust model as `/sitemap.xml`.\n */\n\nexport interface NpFeedEntry {\n /** Stable id (we use the absolute canonical URL). */\n id: string;\n title: string;\n /** Short summary; HTML escaped on the way out. */\n summary: string | null;\n link: string;\n /** Author display name; null when unavailable (e.g. anonymous post). */\n author: string | null;\n /** ISO 8601. The Atom `<updated>` element. */\n updated: string;\n /** ISO 8601. Optional — emitted as `<published>`. */\n published: string | null;\n}\n\nexport interface BuildAtomFeedOptions {\n collection?: string;\n /** Cap entries per feed. Default 50 — most readers ignore beyond that. */\n limit?: number;\n /**\n * Phase 12.4 — restrict an i18n collection's feed to one\n * locale. Ignored on non-i18n collections. The Atom feed's\n * top-level `<title>` / `<subtitle>` aren't translated by\n * the framework — sites that want fully translated feeds\n * pass their own language-specific siteName / description\n * via custom site settings hooks.\n */\n locale?: string;\n /**\n * Phase F.7 — extra entries to merge into the feed alongside\n * the collection walk. Supplied by the active theme's\n * `impl.seo.feedEntries` hook through the route handler.\n * Deduplicated against the collection-walk output by `id`\n * (collection-walk wins on collision); the final list is\n * sorted by `updated` desc and capped by `limit`.\n */\n extraEntries?: NpFeedEntry[];\n}\n\nconst DEFAULT_FEED_LIMIT = 50;\nconst DEFAULT_FEED_COLLECTION = \"posts\";\n\n/**\n * Walks a single collection's published documents and returns a\n * flat list of feed entries. Skips collections that don't\n * declare `seo.urlPath` (the same opt-in the sitemap uses).\n * Anonymous read access required — `findDocuments` runs the\n * collection's `access.read` callback with no user.\n */\nexport async function buildAtomFeed(\n options: BuildAtomFeedOptions = {},\n): Promise<{ entries: NpFeedEntry[]; collection: string } | null> {\n const collection = options.collection ?? DEFAULT_FEED_COLLECTION;\n const limit = options.limit ?? DEFAULT_FEED_LIMIT;\n\n let config;\n try {\n config = getCollectionConfig(collection);\n } catch {\n return null;\n }\n const urlPath = config.seo?.urlPath;\n if (!urlPath) return null;\n\n const settings = await getSiteSeoSettings();\n const origin = settings.siteUrl.replace(/\\/+$/, \"\");\n\n let result;\n try {\n result = await findDocuments(\n collection,\n {\n where: { status: \"published\" },\n limit,\n page: 1,\n sort: \"-updatedAt\",\n // Phase 12.4 — when the caller passed a locale AND\n // this collection is i18n-enabled, scope the feed to\n // that locale. findDocuments() ignores `locale` for\n // non-i18n collections so passing it unconditionally\n // is safe; we still gate on config.i18n to keep the\n // intent obvious to readers.\n ...(options.locale && config.i18n ? { locale: options.locale } : {}),\n },\n undefined,\n );\n } catch {\n return null;\n }\n\n const entries: NpFeedEntry[] = [];\n for (const doc of result.docs) {\n const path = urlPath(doc);\n if (!path) continue;\n const link = `${origin}${path}`;\n const updated = pickIso(\n (doc as { updatedAt?: unknown }).updatedAt ??\n (doc as { createdAt?: unknown }).createdAt,\n );\n if (!updated) continue;\n entries.push({\n id: link,\n title: pickTitle(doc),\n summary: pickSummary(doc),\n link,\n author: pickAuthor(doc),\n updated,\n published: pickIso(\n (doc as { publishedAt?: unknown }).publishedAt ??\n (doc as { createdAt?: unknown }).createdAt,\n ),\n });\n }\n\n // Phase F.7 — merge in theme-supplied extra entries, dedup by\n // id (collection-walk wins on collision), sort newest-first,\n // cap at the same limit.\n const extras = options.extraEntries ?? [];\n if (extras.length > 0) {\n const seenIds = new Set(entries.map((e) => e.id));\n for (const extra of extras) {\n if (seenIds.has(extra.id)) continue;\n seenIds.add(extra.id);\n entries.push(extra);\n }\n entries.sort((a, b) => (a.updated < b.updated ? 1 : -1));\n if (entries.length > limit) entries.length = limit;\n }\n\n return { entries, collection };\n}\n\nfunction pickTitle(doc: Record<string, unknown>): string {\n if (typeof doc.title === \"string\" && doc.title.length > 0) return doc.title;\n if (typeof doc.name === \"string\" && doc.name.length > 0) return doc.name;\n if (typeof doc.slug === \"string\" && doc.slug.length > 0) return doc.slug;\n return \"Untitled\";\n}\n\nfunction pickSummary(doc: Record<string, unknown>): string | null {\n for (const key of [\"excerpt\", \"summary\", \"description\", \"seoDescription\"]) {\n const value = doc[key];\n if (typeof value === \"string\" && value.trim().length > 0) {\n const trimmed = value.trim();\n return trimmed.length > 500 ? `${trimmed.slice(0, 497)}…` : trimmed;\n }\n }\n return null;\n}\n\nfunction pickAuthor(doc: Record<string, unknown>): string | null {\n // We don't follow relationship FKs here — the lookup would\n // be N+1 and the feed doesn't need a perfect display name.\n // Sites that want author names in their feed should denormalize\n // a `authorName` field onto the row, or add a feed plugin that\n // does the resolution.\n if (typeof doc.authorName === \"string\" && doc.authorName.length > 0) {\n return doc.authorName;\n }\n return null;\n}\n\nfunction pickIso(value: unknown): string | null {\n if (value instanceof Date) {\n const time = value.getTime();\n if (Number.isNaN(time)) return null;\n return value.toISOString();\n }\n if (typeof value === \"string\") {\n const parsed = new Date(value);\n if (!Number.isNaN(parsed.getTime())) return parsed.toISOString();\n }\n return null;\n}\n\n/**\n * Renders the Atom 1.0 XML body. Doesn't depend on any specific\n * runtime so it's safe to call from a route handler, a static\n * builder, or a CLI exporter.\n */\nexport async function renderAtomFeed(\n options: BuildAtomFeedOptions = {},\n): Promise<string | null> {\n const result = await buildAtomFeed(options);\n if (!result) return null;\n const settings = await getSiteSeoSettings();\n const origin = settings.siteUrl.replace(/\\/+$/, \"\");\n const queryParts: string[] = [];\n if (result.collection !== DEFAULT_FEED_COLLECTION) {\n queryParts.push(`collection=${encodeURIComponent(result.collection)}`);\n }\n if (options.locale) {\n queryParts.push(`locale=${encodeURIComponent(options.locale)}`);\n }\n const feedSelfUrl = `${origin}/feed.xml${queryParts.length ? `?${queryParts.join(\"&\")}` : \"\"}`;\n const updated = result.entries[0]?.updated ?? new Date().toISOString();\n\n const lines: string[] = [\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>',\n options.locale\n ? `<feed xmlns=\"http://www.w3.org/2005/Atom\" xml:lang=\"${escapeXml(options.locale)}\">`\n : '<feed xmlns=\"http://www.w3.org/2005/Atom\">',\n ` <title>${escapeXml(settings.siteName)}</title>`,\n settings.defaultDescription\n ? ` <subtitle>${escapeXml(settings.defaultDescription)}</subtitle>`\n : \"\",\n ` <id>${escapeXml(feedSelfUrl)}</id>`,\n ` <link rel=\"self\" href=\"${escapeXml(feedSelfUrl)}\"/>`,\n ` <link rel=\"alternate\" type=\"text/html\" href=\"${escapeXml(origin)}/\"/>`,\n ` <updated>${updated}</updated>`,\n ];\n for (const entry of result.entries) {\n lines.push(\" <entry>\");\n lines.push(` <id>${escapeXml(entry.id)}</id>`);\n lines.push(` <title>${escapeXml(entry.title)}</title>`);\n lines.push(\n ` <link rel=\"alternate\" type=\"text/html\" href=\"${escapeXml(entry.link)}\"/>`,\n );\n lines.push(` <updated>${entry.updated}</updated>`);\n if (entry.published) {\n lines.push(` <published>${entry.published}</published>`);\n }\n if (entry.author) {\n lines.push(\" <author>\");\n lines.push(` <name>${escapeXml(entry.author)}</name>`);\n lines.push(\" </author>\");\n }\n if (entry.summary) {\n lines.push(\n ` <summary type=\"text\">${escapeXml(entry.summary)}</summary>`,\n );\n }\n lines.push(\" </entry>\");\n }\n lines.push(\"</feed>\");\n return lines.filter(Boolean).join(\"\\n\");\n}\n\nfunction escapeXml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&apos;\");\n}\n","import { getSiteSeoSettings } from \"./page-metadata.js\";\n\n/**\n * Phase 10.5 — JSON-LD structured data builders. Schema.org\n * vocabulary, embedded into pages as\n * `<script type=\"application/ld+json\">{ ... }</script>`. The\n * builders here produce plain objects; pages render them via\n * the helper component the reference app declares\n * (`@/components/json-ld`). Keeping the builders structural\n * (no React dependency) lets non-Next consumers — static\n * exporters, mobile bridges, plugin tests — emit the same\n * shapes.\n *\n * Why JSON-LD over Microdata / RDFa: Google explicitly\n * recommends JSON-LD as the preferred format for structured\n * data, and it composes cleanly because it doesn't require\n * splicing schema attributes into the page markup.\n */\n\nconst SCHEMA = \"https://schema.org\";\n\nexport interface BuildJsonLdContext {\n /** Origin without trailing slash. Falls back to settings if omitted. */\n origin?: string;\n}\n\nasync function resolveOrigin(ctx: BuildJsonLdContext = {}): Promise<string> {\n if (ctx.origin) return ctx.origin.replace(/\\/+$/, \"\");\n const settings = await getSiteSeoSettings();\n return settings.siteUrl.replace(/\\/+$/, \"\");\n}\n\nfunction absoluteUrl(origin: string, path: string): string {\n if (/^https?:\\/\\//i.test(path)) return path;\n return `${origin}${path.startsWith(\"/\") ? \"\" : \"/\"}${path}`;\n}\n\n/**\n * `WebSite` + `SearchAction` for the site root. Tells search\n * engines the canonical site name and lets Google render a\n * sitelinks searchbox in result pages — when the user types\n * into it, the engine routes them straight to /search?q=… on\n * the site instead of returning more SERP results.\n */\nexport interface WebSiteJsonLd {\n \"@context\": typeof SCHEMA;\n \"@type\": \"WebSite\";\n name: string;\n url: string;\n potentialAction?: {\n \"@type\": \"SearchAction\";\n target: { \"@type\": \"EntryPoint\"; urlTemplate: string };\n \"query-input\": string;\n };\n}\n\nexport async function buildWebSiteJsonLd(\n ctx: BuildJsonLdContext = {},\n): Promise<WebSiteJsonLd> {\n const settings = await getSiteSeoSettings();\n const origin = await resolveOrigin(ctx);\n return {\n \"@context\": SCHEMA,\n \"@type\": \"WebSite\",\n name: settings.siteName,\n url: `${origin}/`,\n potentialAction: {\n \"@type\": \"SearchAction\",\n target: {\n \"@type\": \"EntryPoint\",\n urlTemplate: `${origin}/search?q={search_term_string}`,\n },\n \"query-input\": \"required name=search_term_string\",\n },\n };\n}\n\n/**\n * `BlogPosting` (a subtype of Article) for blog posts. Keeps\n * the structural fields a search engine cares about — headline,\n * dates, author, image, description — without trying to encode\n * the body content.\n */\nexport interface ArticleJsonLdInput {\n /** Canonical URL of the article. */\n url: string;\n headline: string;\n description?: string | null;\n /** Absolute URL or `/`-rooted path. Resolved against `origin`. */\n image?: string | null;\n datePublished?: Date | string | null;\n dateModified?: Date | string | null;\n authorName?: string | null;\n /** Schema.org type. Defaults to `BlogPosting`; forum threads use\n * `DiscussionForumPosting` via `buildDiscussionForumPostingJsonLd`. */\n type?: \"BlogPosting\" | \"Article\";\n}\n\nexport interface ArticleJsonLd {\n \"@context\": typeof SCHEMA;\n \"@type\": \"BlogPosting\" | \"Article\";\n headline: string;\n url: string;\n description?: string;\n image?: string;\n datePublished?: string;\n dateModified?: string;\n author?: { \"@type\": \"Person\"; name: string };\n publisher: {\n \"@type\": \"Organization\";\n name: string;\n url: string;\n };\n}\n\nexport async function buildArticleJsonLd(\n input: ArticleJsonLdInput,\n ctx: BuildJsonLdContext = {},\n): Promise<ArticleJsonLd> {\n const settings = await getSiteSeoSettings();\n const origin = await resolveOrigin(ctx);\n\n const out: ArticleJsonLd = {\n \"@context\": SCHEMA,\n \"@type\": input.type ?? \"BlogPosting\",\n headline: input.headline,\n url: input.url,\n publisher: {\n \"@type\": \"Organization\",\n name: settings.siteName,\n url: `${origin}/`,\n },\n };\n if (input.description) out.description = input.description;\n if (input.image) out.image = absoluteUrl(origin, input.image);\n const published = toIso(input.datePublished);\n if (published) out.datePublished = published;\n const modified = toIso(input.dateModified);\n if (modified) out.dateModified = modified;\n if (input.authorName) {\n out.author = { \"@type\": \"Person\", name: input.authorName };\n }\n return out;\n}\n\n/**\n * `DiscussionForumPosting` for member-authored forum threads.\n * Same skeleton as `Article` but the type tells search engines\n * (and surfaces like Google's \"Forums\" filter) that this is\n * community discussion, not editorial content.\n */\nexport interface DiscussionForumPostingJsonLd\n extends Omit<ArticleJsonLd, \"@type\"> {\n \"@type\": \"DiscussionForumPosting\";\n}\n\nexport async function buildDiscussionForumPostingJsonLd(\n input: ArticleJsonLdInput,\n ctx: BuildJsonLdContext = {},\n): Promise<DiscussionForumPostingJsonLd> {\n const article = await buildArticleJsonLd(input, ctx);\n return { ...article, \"@type\": \"DiscussionForumPosting\" };\n}\n\n/**\n * `Person` for member / user profile pages. Keeps the public\n * fields a search engine could legitimately surface — handle,\n * display name, profile URL, avatar.\n */\nexport interface PersonJsonLdInput {\n url: string;\n name: string;\n alternateName?: string | null;\n image?: string | null;\n description?: string | null;\n}\n\nexport interface PersonJsonLd {\n \"@context\": typeof SCHEMA;\n \"@type\": \"Person\";\n name: string;\n url: string;\n alternateName?: string;\n image?: string;\n description?: string;\n}\n\nexport async function buildPersonJsonLd(\n input: PersonJsonLdInput,\n ctx: BuildJsonLdContext = {},\n): Promise<PersonJsonLd> {\n const origin = await resolveOrigin(ctx);\n const out: PersonJsonLd = {\n \"@context\": SCHEMA,\n \"@type\": \"Person\",\n name: input.name,\n url: input.url,\n };\n if (input.alternateName) out.alternateName = input.alternateName;\n if (input.image) out.image = absoluteUrl(origin, input.image);\n if (input.description) out.description = input.description;\n return out;\n}\n\nfunction toIso(value: Date | string | null | undefined): string | null {\n if (!value) return null;\n if (value instanceof Date) {\n if (Number.isNaN(value.getTime())) return null;\n return value.toISOString();\n }\n if (typeof value === \"string\") {\n const parsed = new Date(value);\n if (!Number.isNaN(parsed.getTime())) return parsed.toISOString();\n }\n return null;\n}\n"],"mappings":"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","names":["escapeXml"]}
@@ -0,0 +1,24 @@
1
+ // src/auth/capabilities.ts
2
+ function can(user, capability) {
3
+ if (!user) return false;
4
+ switch (capability) {
5
+ case "content.publish":
6
+ return user.role === "admin" || user.role === "editor";
7
+ case "content.author":
8
+ return user.role === "admin" || user.role === "editor" || user.role === "author" || user.role === "moderator";
9
+ case "community.moderate":
10
+ return user.role === "admin" || user.role === "editor" || user.role === "moderator";
11
+ case "admin.manage":
12
+ return user.role === "admin";
13
+ default: {
14
+ const _exhaustive = capability;
15
+ void _exhaustive;
16
+ return false;
17
+ }
18
+ }
19
+ }
20
+
21
+ export {
22
+ can
23
+ };
24
+ //# sourceMappingURL=chunk-EQ2Z3KMD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/auth/capabilities.ts"],"sourcesContent":["import type { NpAuthUser } from \"../config/types.js\";\n\n/**\n * Capability-based authorization (#273).\n *\n * Replaced the previous parallel `hasRole(user, minRole)` /\n * `isStaffMod(user)` model. Naming the *behavior* instead of a role\n * hierarchy means a reviewer spots `can(user, \"community.moderate\")`\n * on a comment-mod path regardless of how the role table evolves\n * later — and the previous trap where a `hasRole(user, \"editor\")`\n * check silently dropped moderators is gone by construction.\n *\n * Capability vocabulary:\n * - `content.publish` — change publication state on staff-owned\n * content. Editor or admin.\n * - `content.author` — create / edit content. Author, moderator,\n * editor, or admin (moderators get author-\n * level write access in this model so they\n * can leave moderation notes / pinned\n * replies on the content surface).\n * - `community.moderate` — comment hide/restore, report triage, ban\n * operations. Admin, editor, or moderator.\n * - `admin.manage` — admin-only surfaces (site CRUD,\n * super-admin-adjacent settings).\n *\n * Add new capabilities by extending the union AND the exhaustive\n * switch below — TypeScript will surface the missing branch.\n */\nexport type NpCapability =\n | \"content.publish\"\n | \"content.author\"\n | \"community.moderate\"\n | \"admin.manage\";\n\nexport function can(\n user: NpAuthUser | null | undefined,\n capability: NpCapability,\n): boolean {\n if (!user) return false;\n switch (capability) {\n case \"content.publish\":\n return user.role === \"admin\" || user.role === \"editor\";\n case \"content.author\":\n return (\n user.role === \"admin\" ||\n user.role === \"editor\" ||\n user.role === \"author\" ||\n user.role === \"moderator\"\n );\n case \"community.moderate\":\n return (\n user.role === \"admin\" ||\n user.role === \"editor\" ||\n user.role === \"moderator\"\n );\n case \"admin.manage\":\n return user.role === \"admin\";\n default: {\n // Exhaustiveness check — adding a capability without handling\n // it here is a compile error.\n const _exhaustive: never = capability;\n void _exhaustive;\n return false;\n }\n }\n}\n"],"mappings":";AAkCO,SAAS,IACd,MACA,YACS;AACT,MAAI,CAAC,KAAM,QAAO;AAClB,UAAQ,YAAY;AAAA,IAClB,KAAK;AACH,aAAO,KAAK,SAAS,WAAW,KAAK,SAAS;AAAA,IAChD,KAAK;AACH,aACE,KAAK,SAAS,WACd,KAAK,SAAS,YACd,KAAK,SAAS,YACd,KAAK,SAAS;AAAA,IAElB,KAAK;AACH,aACE,KAAK,SAAS,WACd,KAAK,SAAS,YACd,KAAK,SAAS;AAAA,IAElB,KAAK;AACH,aAAO,KAAK,SAAS;AAAA,IACvB,SAAS;AAGP,YAAM,cAAqB;AAC3B,WAAK;AACL,aAAO;AAAA,IACT;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,305 @@
1
+ import {
2
+ NpNotFoundError,
3
+ NpValidationError
4
+ } from "./chunk-ZCINJSS4.js";
5
+ import {
6
+ getDb
7
+ } from "./chunk-XANPEOJC.js";
8
+ import {
9
+ npAuditEvents,
10
+ npBans,
11
+ npComments,
12
+ npFollows,
13
+ npMemberMutes,
14
+ npMemberRoles,
15
+ npNavigation,
16
+ npNotifications,
17
+ npPluginStorage,
18
+ npReactions,
19
+ npReports,
20
+ npSettings,
21
+ npSiteMemberships,
22
+ npSites,
23
+ npStringOverrides
24
+ } from "./chunk-M43PGOQY.js";
25
+
26
+ // src/sites/registry.ts
27
+ import { eq, asc, sql } from "drizzle-orm";
28
+
29
+ // src/collections/registry.ts
30
+ var registry = /* @__PURE__ */ new Map();
31
+ function registerCollection(slug, table, config, opts) {
32
+ registry.set(slug, {
33
+ config,
34
+ table,
35
+ childTables: opts?.childTables,
36
+ joinTables: opts?.joinTables
37
+ });
38
+ }
39
+ function getCollectionConfig(slug) {
40
+ return getCollectionRegistration(slug).config;
41
+ }
42
+ function getCollectionTable(slug) {
43
+ return getCollectionRegistration(slug).table;
44
+ }
45
+ function getCollectionRegistration(slug) {
46
+ const registration = registry.get(slug);
47
+ if (!registration) {
48
+ throw new NpNotFoundError("collection", slug);
49
+ }
50
+ return registration;
51
+ }
52
+ function getAllCollectionSlugs() {
53
+ return [...registry.keys()];
54
+ }
55
+
56
+ // src/sites/registry.ts
57
+ var DEFAULT_SITE_ID = "default";
58
+ function rowToSite(row) {
59
+ return {
60
+ id: row.id,
61
+ name: row.name,
62
+ hostname: row.hostname,
63
+ description: row.description,
64
+ settings: row.settings ?? {},
65
+ isDefault: row.isDefault,
66
+ createdAt: row.createdAt,
67
+ updatedAt: row.updatedAt
68
+ };
69
+ }
70
+ async function ensureDefaultSite() {
71
+ const db = getDb();
72
+ const existingDefault = await db.select().from(npSites).where(eq(npSites.id, DEFAULT_SITE_ID)).limit(1);
73
+ if (existingDefault[0]) return rowToSite(existingDefault[0]);
74
+ const now = /* @__PURE__ */ new Date();
75
+ const [created] = await db.insert(npSites).values({
76
+ id: DEFAULT_SITE_ID,
77
+ name: "Default site",
78
+ hostname: null,
79
+ isDefault: true,
80
+ settings: {},
81
+ createdAt: now,
82
+ updatedAt: now
83
+ }).onConflictDoNothing().returning();
84
+ if (created) return rowToSite(created);
85
+ const [row] = await db.select().from(npSites).where(eq(npSites.id, DEFAULT_SITE_ID)).limit(1);
86
+ if (!row) {
87
+ throw new Error("Failed to create or read the default site");
88
+ }
89
+ return rowToSite(row);
90
+ }
91
+ async function listSites() {
92
+ const db = getDb();
93
+ const rows = await db.select().from(npSites).orderBy(asc(npSites.createdAt));
94
+ return rows.map(rowToSite);
95
+ }
96
+ async function getSiteById(id) {
97
+ const db = getDb();
98
+ const [row] = await db.select().from(npSites).where(eq(npSites.id, id)).limit(1);
99
+ return row ? rowToSite(row) : null;
100
+ }
101
+ async function getSiteByHostname(hostname) {
102
+ const db = getDb();
103
+ const lower = hostname.toLowerCase();
104
+ const [row] = await db.select().from(npSites).where(eq(npSites.hostname, lower)).limit(1);
105
+ return row ? rowToSite(row) : null;
106
+ }
107
+ async function getDefaultSite() {
108
+ const db = getDb();
109
+ const [row] = await db.select().from(npSites).where(eq(npSites.isDefault, true)).limit(1);
110
+ return row ? rowToSite(row) : null;
111
+ }
112
+ async function resolveSiteForHostname(hostname) {
113
+ if (hostname) {
114
+ const matched = await getSiteByHostname(hostname);
115
+ if (matched) return matched;
116
+ }
117
+ return getDefaultSite();
118
+ }
119
+ async function createSite(input) {
120
+ if (!/^[a-z][a-z0-9-]*$/.test(input.id)) {
121
+ throw new NpValidationError("Invalid input", [
122
+ {
123
+ field: "id",
124
+ message: "Site id must be lowercase alphanumeric + hyphens, starting with a letter"
125
+ }
126
+ ]);
127
+ }
128
+ const db = getDb();
129
+ const now = /* @__PURE__ */ new Date();
130
+ const [row] = await db.insert(npSites).values({
131
+ id: input.id,
132
+ name: input.name,
133
+ hostname: input.hostname?.toLowerCase() ?? null,
134
+ description: input.description ?? null,
135
+ settings: input.settings ?? {},
136
+ isDefault: false,
137
+ createdAt: now,
138
+ updatedAt: now
139
+ }).returning();
140
+ if (!row) {
141
+ throw new Error("Failed to create site");
142
+ }
143
+ return rowToSite(row);
144
+ }
145
+ async function updateSite(id, patch) {
146
+ const db = getDb();
147
+ const updates = { updatedAt: /* @__PURE__ */ new Date() };
148
+ if (patch.name !== void 0) updates.name = patch.name;
149
+ if (patch.hostname !== void 0) {
150
+ updates.hostname = patch.hostname ? patch.hostname.toLowerCase() : null;
151
+ }
152
+ if (patch.description !== void 0) updates.description = patch.description;
153
+ if (patch.settings !== void 0) updates.settings = patch.settings;
154
+ const [row] = await db.update(npSites).set(updates).where(eq(npSites.id, id)).returning();
155
+ if (!row) {
156
+ throw new NpValidationError("Invalid input", [
157
+ { field: "id", message: `Site "${id}" not found` }
158
+ ]);
159
+ }
160
+ return rowToSite(row);
161
+ }
162
+ async function getSiteUsageSummary(id) {
163
+ const db = getDb();
164
+ const collections = {};
165
+ for (const slug of getAllCollectionSlugs()) {
166
+ try {
167
+ const config = getCollectionConfig(slug);
168
+ void config;
169
+ const table = getCollectionTable(slug);
170
+ const idCol = table.siteId;
171
+ if (!idCol) continue;
172
+ const [row] = await db.select({ count: sql`count(*)::int` }).from(table).where(eq(idCol, id));
173
+ collections[slug] = row?.count ?? 0;
174
+ } catch {
175
+ }
176
+ }
177
+ const countWhere = async (table, where) => {
178
+ const [row] = await db.select({ count: sql`count(*)::int` }).from(table).where(where);
179
+ return row?.count ?? 0;
180
+ };
181
+ const settings = await countWhere(npSettings, eq(npSettings.siteId, id));
182
+ const navigation = await countWhere(npNavigation, eq(npNavigation.siteId, id));
183
+ const memberships = await countWhere(
184
+ npSiteMemberships,
185
+ eq(npSiteMemberships.siteId, id)
186
+ );
187
+ const stringOverrides = await countWhere(
188
+ npStringOverrides,
189
+ eq(npStringOverrides.siteId, id)
190
+ );
191
+ const pluginStorage = await countWhere(
192
+ npPluginStorage,
193
+ eq(npPluginStorage.siteId, id)
194
+ );
195
+ const comments = await countWhere(npComments, eq(npComments.siteId, id));
196
+ const reactions = await countWhere(npReactions, eq(npReactions.siteId, id));
197
+ const follows = await countWhere(npFollows, eq(npFollows.siteId, id));
198
+ const mutes = await countWhere(npMemberMutes, eq(npMemberMutes.siteId, id));
199
+ const notifications = await countWhere(
200
+ npNotifications,
201
+ eq(npNotifications.siteId, id)
202
+ );
203
+ const reports = await countWhere(npReports, eq(npReports.siteId, id));
204
+ const auditEvents = await countWhere(
205
+ npAuditEvents,
206
+ eq(npAuditEvents.siteId, id)
207
+ );
208
+ const bans = await countWhere(npBans, eq(npBans.siteId, id));
209
+ const memberRoles = await countWhere(npMemberRoles, eq(npMemberRoles.siteId, id));
210
+ const collectionsTotal = Object.values(collections).reduce(
211
+ (sum, n) => sum + n,
212
+ 0
213
+ );
214
+ return {
215
+ collections,
216
+ settings,
217
+ navigation,
218
+ memberships,
219
+ stringOverrides,
220
+ pluginStorage,
221
+ comments,
222
+ reactions,
223
+ follows,
224
+ mutes,
225
+ notifications,
226
+ reports,
227
+ auditEvents,
228
+ bans,
229
+ memberRoles,
230
+ total: collectionsTotal + settings + navigation + memberships + stringOverrides + pluginStorage + comments + reactions + follows + mutes + notifications + reports + auditEvents + bans + memberRoles
231
+ };
232
+ }
233
+ async function deleteSite(id, options) {
234
+ const db = getDb();
235
+ const [target] = await db.select().from(npSites).where(eq(npSites.id, id)).limit(1);
236
+ if (!target) {
237
+ throw new NpValidationError("Invalid input", [
238
+ { field: "id", message: `Site "${id}" not found` }
239
+ ]);
240
+ }
241
+ if (target.isDefault) {
242
+ throw new NpValidationError("Invalid input", [
243
+ {
244
+ field: "id",
245
+ message: "Cannot delete the default site. Promote another site to default first."
246
+ }
247
+ ]);
248
+ }
249
+ const usage = await getSiteUsageSummary(id);
250
+ if (usage.total > 0 && !options?.cascade) {
251
+ throw new NpValidationError("Invalid input", [
252
+ {
253
+ field: "cascade",
254
+ message: `Site "${id}" has ${usage.total} attached row(s). Pass cascade=true to delete them, or clear them manually first.`
255
+ }
256
+ ]);
257
+ }
258
+ if (options?.cascade) {
259
+ for (const slug of Object.keys(usage.collections)) {
260
+ try {
261
+ const table = getCollectionTable(slug);
262
+ const siteIdCol = table.siteId;
263
+ if (!siteIdCol) continue;
264
+ await db.delete(table).where(eq(siteIdCol, id));
265
+ } catch {
266
+ }
267
+ }
268
+ await db.delete(npReactions).where(eq(npReactions.siteId, id));
269
+ await db.delete(npFollows).where(eq(npFollows.siteId, id));
270
+ await db.delete(npMemberMutes).where(eq(npMemberMutes.siteId, id));
271
+ await db.delete(npNotifications).where(eq(npNotifications.siteId, id));
272
+ await db.delete(npReports).where(eq(npReports.siteId, id));
273
+ await db.delete(npAuditEvents).where(eq(npAuditEvents.siteId, id));
274
+ await db.delete(npBans).where(eq(npBans.siteId, id));
275
+ await db.delete(npMemberRoles).where(eq(npMemberRoles.siteId, id));
276
+ await db.delete(npComments).where(eq(npComments.siteId, id));
277
+ await db.delete(npStringOverrides).where(eq(npStringOverrides.siteId, id));
278
+ await db.delete(npNavigation).where(eq(npNavigation.siteId, id));
279
+ await db.delete(npSettings).where(eq(npSettings.siteId, id));
280
+ await db.delete(npPluginStorage).where(eq(npPluginStorage.siteId, id));
281
+ await db.delete(npSiteMemberships).where(eq(npSiteMemberships.siteId, id));
282
+ }
283
+ await db.delete(npSites).where(eq(npSites.id, id));
284
+ }
285
+ var NP_DEFAULT_SITE_ID = DEFAULT_SITE_ID;
286
+
287
+ export {
288
+ registerCollection,
289
+ getCollectionConfig,
290
+ getCollectionTable,
291
+ getCollectionRegistration,
292
+ getAllCollectionSlugs,
293
+ ensureDefaultSite,
294
+ listSites,
295
+ getSiteById,
296
+ getSiteByHostname,
297
+ getDefaultSite,
298
+ resolveSiteForHostname,
299
+ createSite,
300
+ updateSite,
301
+ getSiteUsageSummary,
302
+ deleteSite,
303
+ NP_DEFAULT_SITE_ID
304
+ };
305
+ //# sourceMappingURL=chunk-FZ7O6DWI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/sites/registry.ts","../src/collections/registry.ts"],"sourcesContent":["import { type and, eq, asc, sql } from \"drizzle-orm\";\nimport type { PgTable } from \"drizzle-orm/pg-core\";\n\nimport {\n getAllCollectionSlugs,\n getCollectionConfig,\n getCollectionTable,\n} from \"../collections/registry.js\";\nimport { getDb } from \"../db/runtime.js\";\nimport {\n npAuditEvents,\n npBans,\n npComments,\n npFollows,\n npMemberMutes,\n npMemberRoles,\n npNotifications,\n npReactions,\n npReports,\n} from \"../db/schema/community.js\";\nimport {\n npNavigation,\n npPluginStorage,\n npSettings,\n npSiteMemberships,\n npSites,\n npStringOverrides,\n} from \"../db/schema/system.js\";\nimport { NpValidationError } from \"../errors.js\";\n\n/**\n * Phase 15.1 — multi-site registry. The framework treats\n * sites as long-lived rows in `np_sites`; the bootstrap calls\n * `ensureDefaultSite()` at boot to guarantee at least one row\n * exists so single-tenant installs (the existing reference\n * app shape) keep working without operator intervention.\n *\n * 15.1 ships the model + lookup helpers; 15.2 wires\n * collection queries through `siteId`; 15.3 ships the\n * super-admin UI for creating / managing sites. Until 15.2\n * lands, nothing in the existing pipeline knows or cares\n * about which site a row belongs to — the columns just\n * exist and the default site backfills.\n */\n\nexport interface NpSite {\n id: string;\n name: string;\n hostname: string | null;\n description: string | null;\n settings: Record<string, unknown>;\n isDefault: boolean;\n createdAt: Date;\n updatedAt: Date;\n}\n\nconst DEFAULT_SITE_ID = \"default\";\n\nfunction rowToSite(row: typeof npSites.$inferSelect): NpSite {\n return {\n id: row.id,\n name: row.name,\n hostname: row.hostname,\n description: row.description,\n settings: row.settings ?? {},\n isDefault: row.isDefault,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\n/**\n * Idempotently create the default site if no sites exist.\n * Bootstrap calls this once during framework init; tests\n * that truncate `np_sites` between cases re-trigger it.\n */\nexport async function ensureDefaultSite(): Promise<NpSite> {\n const db = getDb();\n const existingDefault = await db\n .select()\n .from(npSites)\n .where(eq(npSites.id, DEFAULT_SITE_ID))\n .limit(1);\n if (existingDefault[0]) return rowToSite(existingDefault[0]);\n\n const now = new Date();\n const [created] = await db\n .insert(npSites)\n .values({\n id: DEFAULT_SITE_ID,\n name: \"Default site\",\n hostname: null,\n isDefault: true,\n settings: {},\n createdAt: now,\n updatedAt: now,\n })\n .onConflictDoNothing()\n .returning();\n if (created) return rowToSite(created);\n\n // Conflict path: another worker raced us. Re-read.\n const [row] = await db\n .select()\n .from(npSites)\n .where(eq(npSites.id, DEFAULT_SITE_ID))\n .limit(1);\n if (!row) {\n throw new Error(\"Failed to create or read the default site\");\n }\n return rowToSite(row);\n}\n\nexport async function listSites(): Promise<NpSite[]> {\n const db = getDb();\n const rows = await db.select().from(npSites).orderBy(asc(npSites.createdAt));\n return rows.map(rowToSite);\n}\n\nexport async function getSiteById(id: string): Promise<NpSite | null> {\n const db = getDb();\n const [row] = await db\n .select()\n .from(npSites)\n .where(eq(npSites.id, id))\n .limit(1);\n return row ? rowToSite(row) : null;\n}\n\n/**\n * Hostname-based lookup. Returns the matching site, or the\n * default site when no row matches (so a request hitting\n * an unconfigured host still gets served by the canonical\n * site rather than 404'ing). Case-insensitive on the host\n * string.\n */\nexport async function getSiteByHostname(\n hostname: string,\n): Promise<NpSite | null> {\n const db = getDb();\n const lower = hostname.toLowerCase();\n const [row] = await db\n .select()\n .from(npSites)\n .where(eq(npSites.hostname, lower))\n .limit(1);\n return row ? rowToSite(row) : null;\n}\n\nexport async function getDefaultSite(): Promise<NpSite | null> {\n const db = getDb();\n const [row] = await db\n .select()\n .from(npSites)\n .where(eq(npSites.isDefault, true))\n .limit(1);\n return row ? rowToSite(row) : null;\n}\n\n/**\n * Resolve which site a request belongs to. Tries hostname\n * lookup first; falls back to the default site. Returns\n * `null` only when the database has no sites at all (which\n * shouldn't happen post-bootstrap).\n */\nexport async function resolveSiteForHostname(\n hostname: string | null | undefined,\n): Promise<NpSite | null> {\n if (hostname) {\n const matched = await getSiteByHostname(hostname);\n if (matched) return matched;\n }\n return getDefaultSite();\n}\n\nexport interface CreateSiteInput {\n id: string;\n name: string;\n hostname?: string | null;\n description?: string | null;\n settings?: Record<string, unknown>;\n}\n\nexport async function createSite(input: CreateSiteInput): Promise<NpSite> {\n if (!/^[a-z][a-z0-9-]*$/.test(input.id)) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"id\",\n message:\n \"Site id must be lowercase alphanumeric + hyphens, starting with a letter\",\n },\n ]);\n }\n const db = getDb();\n const now = new Date();\n const [row] = await db\n .insert(npSites)\n .values({\n id: input.id,\n name: input.name,\n hostname: input.hostname?.toLowerCase() ?? null,\n description: input.description ?? null,\n settings: input.settings ?? {},\n isDefault: false,\n createdAt: now,\n updatedAt: now,\n })\n .returning();\n if (!row) {\n throw new Error(\"Failed to create site\");\n }\n return rowToSite(row);\n}\n\nexport async function updateSite(\n id: string,\n patch: Partial<Pick<NpSite, \"name\" | \"hostname\" | \"description\" | \"settings\">>,\n): Promise<NpSite> {\n const db = getDb();\n const updates: Record<string, unknown> = { updatedAt: new Date() };\n if (patch.name !== undefined) updates.name = patch.name;\n if (patch.hostname !== undefined) {\n updates.hostname = patch.hostname ? patch.hostname.toLowerCase() : null;\n }\n if (patch.description !== undefined) updates.description = patch.description;\n if (patch.settings !== undefined) updates.settings = patch.settings;\n const [row] = await db\n .update(npSites)\n .set(updates)\n .where(eq(npSites.id, id))\n .returning();\n if (!row) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"id\", message: `Site \"${id}\" not found` },\n ]);\n }\n return rowToSite(row);\n}\n\n/**\n * Phase 15.9 — count of every site-scoped row attached to a\n * given site. Surfaces in the admin delete-site dialog so\n * operators see what they're about to nuke (or leave behind\n * as orphans, in the cascade=false path).\n *\n * Includes:\n * - per-collection row counts (codegen'd `np_c_*` tables)\n * - system tables that carry `site_id`: settings,\n * navigation, memberships, string overrides, plugin\n * storage (Issue #220)\n * - community tables that carry `site_id`: comments,\n * reactions, follows, mutes, notifications, reports,\n * audit events, bans, member roles (Issue #220)\n *\n * Does NOT include things that aren't site-scoped:\n * - users (`np_users` is global)\n * - members (`np_members` is global; per-site enrollment\n * happens through the site-scoped `bans` / `member_roles`\n * tables which DO appear in usage)\n * - media (`np_media` is global)\n * - audit events with `site_id IS NULL` — those are\n * intentional super-admin / background-job events that\n * don't belong to any tenant.\n */\nexport interface NpSiteUsage {\n collections: Record<string, number>;\n settings: number;\n navigation: number;\n memberships: number;\n stringOverrides: number;\n /** Issue #220 — newly-included site-scoped tables. */\n pluginStorage: number;\n comments: number;\n reactions: number;\n follows: number;\n mutes: number;\n notifications: number;\n reports: number;\n auditEvents: number;\n bans: number;\n memberRoles: number;\n /** Sum of every count above. Convenience for \"is anything here?\" checks. */\n total: number;\n}\n\nexport async function getSiteUsageSummary(id: string): Promise<NpSiteUsage> {\n const db = getDb();\n const collections: Record<string, number> = {};\n for (const slug of getAllCollectionSlugs()) {\n try {\n const config = getCollectionConfig(slug);\n void config;\n const table = getCollectionTable(slug) as PgTable;\n const idCol = (table as unknown as Record<string, unknown>).siteId;\n if (!idCol) continue;\n const [row] = (await db\n .select({ count: sql<number>`count(*)::int` })\n .from(table)\n .where(eq(idCol as never, id))) as Array<{ count: number }>;\n collections[slug] = row?.count ?? 0;\n } catch {\n // Collection without a registered table — skip silently.\n }\n }\n\n const countWhere = async (\n table: PgTable,\n where: ReturnType<typeof eq> | ReturnType<typeof and>,\n ): Promise<number> => {\n const [row] = (await db\n .select({ count: sql<number>`count(*)::int` })\n .from(table)\n .where(where)) as Array<{ count: number }>;\n return row?.count ?? 0;\n };\n\n const settings = await countWhere(npSettings, eq(npSettings.siteId, id));\n const navigation = await countWhere(npNavigation, eq(npNavigation.siteId, id));\n const memberships = await countWhere(\n npSiteMemberships,\n eq(npSiteMemberships.siteId, id),\n );\n const stringOverrides = await countWhere(\n npStringOverrides,\n eq(npStringOverrides.siteId, id),\n );\n // Issue #220 — include the tables that landed after Phase 15.9\n // shipped. Without them a site looks \"empty\" in the admin\n // even though it owns thousands of community rows; deleting\n // it would silently leave them orphaned.\n const pluginStorage = await countWhere(\n npPluginStorage,\n eq(npPluginStorage.siteId, id),\n );\n const comments = await countWhere(npComments, eq(npComments.siteId, id));\n const reactions = await countWhere(npReactions, eq(npReactions.siteId, id));\n const follows = await countWhere(npFollows, eq(npFollows.siteId, id));\n const mutes = await countWhere(npMemberMutes, eq(npMemberMutes.siteId, id));\n const notifications = await countWhere(\n npNotifications,\n eq(npNotifications.siteId, id),\n );\n const reports = await countWhere(npReports, eq(npReports.siteId, id));\n // Audit events with `site_id IS NULL` are the cross-tenant /\n // background-job rows; we deliberately don't count them here.\n const auditEvents = await countWhere(\n npAuditEvents,\n eq(npAuditEvents.siteId, id),\n );\n const bans = await countWhere(npBans, eq(npBans.siteId, id));\n const memberRoles = await countWhere(npMemberRoles, eq(npMemberRoles.siteId, id));\n\n const collectionsTotal = Object.values(collections).reduce(\n (sum, n) => sum + n,\n 0,\n );\n\n return {\n collections,\n settings,\n navigation,\n memberships,\n stringOverrides,\n pluginStorage,\n comments,\n reactions,\n follows,\n mutes,\n notifications,\n reports,\n auditEvents,\n bans,\n memberRoles,\n total:\n collectionsTotal +\n settings +\n navigation +\n memberships +\n stringOverrides +\n pluginStorage +\n comments +\n reactions +\n follows +\n mutes +\n notifications +\n reports +\n auditEvents +\n bans +\n memberRoles,\n };\n}\n\nexport interface NpDeleteSiteOptions {\n /**\n * Phase 15.9 — when `true`, cascade-delete every site-scoped\n * row (collection content, settings, navigation, memberships,\n * string overrides) before dropping the `np_sites` row.\n *\n * When `false` (default, safe), the call refuses if any\n * site-scoped data still exists. The admin UI uses this to\n * force operators to confirm cascade explicitly so an\n * accidental delete can't quietly orphan thousands of rows.\n */\n cascade?: boolean;\n}\n\n/**\n * Delete a non-default site. The default site can't be\n * deleted (the framework's invariant is \"at least one site\n * always exists\"); operators who want to retire the default\n * promote a different site to default first.\n *\n * Phase 15.9 — `options.cascade` controls whether site-scoped\n * data is deleted alongside. Defaults to `false` for safety;\n * the admin UI surfaces a usage summary first so the operator\n * sees what cascade would touch.\n */\nexport async function deleteSite(\n id: string,\n options?: NpDeleteSiteOptions,\n): Promise<void> {\n const db = getDb();\n const [target] = await db\n .select()\n .from(npSites)\n .where(eq(npSites.id, id))\n .limit(1);\n if (!target) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"id\", message: `Site \"${id}\" not found` },\n ]);\n }\n if (target.isDefault) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"id\",\n message:\n \"Cannot delete the default site. Promote another site to default first.\",\n },\n ]);\n }\n\n const usage = await getSiteUsageSummary(id);\n if (usage.total > 0 && !options?.cascade) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"cascade\",\n message: `Site \"${id}\" has ${usage.total} attached row(s). Pass cascade=true to delete them, or clear them manually first.`,\n },\n ]);\n }\n\n if (options?.cascade) {\n // Order: collection content first, then community rows that\n // reference comments / members polymorphically (so we don't\n // leave orphan reactions pointing at deleted comments mid-\n // sweep), then community parent tables, then system tables.\n // Collection deletes go through the raw table (no hook\n // firing) — site teardown isn't a pipeline write and there's\n // no doc-level afterDelete hook expected here.\n for (const slug of Object.keys(usage.collections)) {\n try {\n const table = getCollectionTable(slug) as PgTable;\n const siteIdCol = (table as unknown as Record<string, unknown>).siteId;\n if (!siteIdCol) continue;\n await db.delete(table).where(eq(siteIdCol as never, id));\n } catch {\n // Ignore — the collection might have been\n // unregistered between the usage scan and the delete.\n }\n }\n // Issue #220 — community rows. Order:\n // reactions/follows/mutes/notifications/reports/audit/bans/\n // member_roles → comments → string_overrides/navigation/\n // settings/plugin_storage/memberships → np_sites.\n // Reactions reference comment ids polymorphically, so they\n // go before comments to keep the DB clean even though there's\n // no FK to enforce ordering.\n await db.delete(npReactions).where(eq(npReactions.siteId, id));\n await db.delete(npFollows).where(eq(npFollows.siteId, id));\n await db.delete(npMemberMutes).where(eq(npMemberMutes.siteId, id));\n await db.delete(npNotifications).where(eq(npNotifications.siteId, id));\n await db.delete(npReports).where(eq(npReports.siteId, id));\n await db.delete(npAuditEvents).where(eq(npAuditEvents.siteId, id));\n await db.delete(npBans).where(eq(npBans.siteId, id));\n await db.delete(npMemberRoles).where(eq(npMemberRoles.siteId, id));\n await db.delete(npComments).where(eq(npComments.siteId, id));\n\n await db.delete(npStringOverrides).where(eq(npStringOverrides.siteId, id));\n await db.delete(npNavigation).where(eq(npNavigation.siteId, id));\n await db.delete(npSettings).where(eq(npSettings.siteId, id));\n await db.delete(npPluginStorage).where(eq(npPluginStorage.siteId, id));\n await db.delete(npSiteMemberships).where(eq(npSiteMemberships.siteId, id));\n }\n\n await db.delete(npSites).where(eq(npSites.id, id));\n}\n\nexport const NP_DEFAULT_SITE_ID = DEFAULT_SITE_ID;\n","import type { NpCollectionConfig } from \"../config/types.js\";\n\nimport { NpNotFoundError } from \"../errors.js\";\n\nexport interface CollectionRegistration {\n config: NpCollectionConfig;\n table: unknown;\n childTables?: Record<string, unknown>;\n joinTables?: Record<string, unknown>;\n}\n\nconst registry = new Map<string, CollectionRegistration>();\n\nexport function registerCollection(\n slug: string,\n table: unknown,\n config: NpCollectionConfig,\n opts?: { childTables?: Record<string, unknown>; joinTables?: Record<string, unknown> },\n): void {\n registry.set(slug, {\n config,\n table,\n childTables: opts?.childTables,\n joinTables: opts?.joinTables,\n });\n}\n\nexport function getCollectionConfig(slug: string): NpCollectionConfig {\n return getCollectionRegistration(slug).config;\n}\n\nexport function getCollectionTable(slug: string): unknown {\n return getCollectionRegistration(slug).table;\n}\n\nexport function getCollectionRegistration(slug: string): CollectionRegistration {\n const registration = registry.get(slug);\n\n if (!registration) {\n throw new NpNotFoundError(\"collection\", slug);\n }\n\n return registration;\n}\n\nexport function getAllCollectionSlugs(): string[] {\n return [...registry.keys()];\n}\n"],"mappings":"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","names":[]}