@jant/core 0.3.42 → 0.3.44

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 (99) hide show
  1. package/bin/commands/import-site.js +1 -1
  2. package/bin/commands/search-reindex.js +175 -0
  3. package/bin/lib/hugo-markdown.js +102 -0
  4. package/bin/lib/site-pull-media.js +1 -4
  5. package/dist/app-BI9bnCkO.js +5 -0
  6. package/dist/{app-Cu3lveYI.js → app-CtJDxZBb.js} +969 -373
  7. package/dist/client/.vite/manifest.json +2 -2
  8. package/dist/client/_assets/client-BQH7AQ24.css +2 -0
  9. package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
  10. package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
  11. package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
  12. package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
  13. package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +5 -5
  16. package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
  17. package/package.json +1 -1
  18. package/src/__tests__/helpers/app.ts +15 -4
  19. package/src/app.tsx +8 -0
  20. package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
  21. package/src/client/tiptap/extensions.ts +3 -0
  22. package/src/client/tiptap/insert-paragraph-around.ts +79 -0
  23. package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
  24. package/src/db/migrations/0019_bored_magus.sql +2 -0
  25. package/src/db/migrations/meta/0018_snapshot.json +2225 -0
  26. package/src/db/migrations/meta/0019_snapshot.json +2238 -0
  27. package/src/db/migrations/meta/_journal.json +15 -1
  28. package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
  29. package/src/db/migrations/pg/0017_bright_beyonder.sql +2 -0
  30. package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
  31. package/src/db/migrations/pg/meta/0017_snapshot.json +2862 -0
  32. package/src/db/migrations/pg/meta/_journal.json +15 -1
  33. package/src/db/pg/schema.ts +22 -0
  34. package/src/db/schema.ts +27 -0
  35. package/src/index.ts +1 -2
  36. package/src/lib/__tests__/hosted-signin.test.ts +30 -0
  37. package/src/lib/__tests__/navigation.test.ts +4 -20
  38. package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
  39. package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
  40. package/src/lib/__tests__/summary.test.ts +140 -0
  41. package/src/lib/__tests__/view.test.ts +66 -0
  42. package/src/lib/feed.ts +70 -34
  43. package/src/lib/hosted-signin.ts +9 -3
  44. package/src/lib/icons.ts +37 -0
  45. package/src/lib/navigation.ts +11 -12
  46. package/src/lib/post-meta.ts +20 -2
  47. package/src/lib/rate-limit-d1.ts +99 -0
  48. package/src/lib/rate-limit-memory.ts +105 -0
  49. package/src/lib/rate-limit.ts +63 -0
  50. package/src/lib/render.tsx +9 -0
  51. package/src/lib/resolve-config.ts +9 -0
  52. package/src/lib/summary.ts +42 -7
  53. package/src/lib/url.ts +34 -0
  54. package/src/lib/view.ts +42 -8
  55. package/src/middleware/__tests__/auth.test.ts +44 -4
  56. package/src/middleware/__tests__/rate-limit.test.ts +113 -0
  57. package/src/middleware/__tests__/session.test.ts +85 -0
  58. package/src/middleware/auth.ts +62 -25
  59. package/src/middleware/rate-limit.ts +54 -0
  60. package/src/middleware/session.ts +36 -0
  61. package/src/routes/__tests__/compose.test.ts +1 -1
  62. package/src/routes/api/__tests__/search.test.ts +48 -0
  63. package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
  64. package/src/routes/api/internal/search-reindex.ts +40 -0
  65. package/src/routes/api/internal/sites.ts +1 -0
  66. package/src/routes/api/search.ts +13 -0
  67. package/src/routes/auth/dev.ts +1 -1
  68. package/src/routes/auth/signin.tsx +23 -5
  69. package/src/routes/dash/settings.tsx +3 -5
  70. package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
  71. package/src/routes/feed/sitemap.ts +208 -33
  72. package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
  73. package/src/routes/pages/home.tsx +24 -15
  74. package/src/routes/pages/page.tsx +34 -0
  75. package/src/routes/pages/partials.tsx +4 -15
  76. package/src/runtime/cloudflare.ts +4 -0
  77. package/src/runtime/node.ts +16 -0
  78. package/src/services/__tests__/post.test.ts +205 -0
  79. package/src/services/__tests__/search.test.ts +44 -0
  80. package/src/services/__tests__/site-admin.test.ts +85 -0
  81. package/src/services/export.ts +9 -2
  82. package/src/services/post.ts +200 -2
  83. package/src/services/site-admin.ts +66 -1
  84. package/src/styles/ui.css +12 -0
  85. package/src/types/app-context.ts +20 -0
  86. package/src/types/config.ts +8 -0
  87. package/src/types/props.ts +0 -7
  88. package/src/ui/feed/LinkCard.tsx +3 -20
  89. package/src/ui/feed/LinkPreview.tsx +5 -19
  90. package/src/ui/feed/PostStatusBadges.tsx +4 -38
  91. package/src/ui/layouts/BaseLayout.tsx +23 -29
  92. package/src/ui/shared/DecorativeQuoteMark.tsx +2 -13
  93. package/src/ui/shared/Icon.tsx +60 -0
  94. package/src/ui/shared/IconSprite.tsx +57 -0
  95. package/src/ui/shared/PostFooter.tsx +6 -62
  96. package/src/ui/shared/custom-icons.ts +132 -0
  97. package/src/ui/shared/icon-collector.ts +37 -0
  98. package/dist/app-DzCB4yOp.js +0 -5
  99. package/dist/client/_assets/client-C_kImWZj.css +0 -2
@@ -1,19 +1,19 @@
1
- import { _ as __exportAll, a as getSitePathPrefix, d as slugify, f as stripSitePathPrefix, h as toPublicPath, i as getSiteOrigin, l as normalizeSiteUrl, m as toPublicHref, n as extractDisplayDomain, o as isFullUrl, p as toAbsoluteSiteUrl, r as extractDomain, s as normalizePath, t as buildSiteUrl, u as sanitizeUrl } from "./url-FvvgARU9.js";
2
- import { A as JANT_POSITIVE_LOGO_PNG_FILENAME, B as getJantLogoHref, C as formatYearMonth, D as HOME_BRANDING_LINK_LABEL, E as toISOString, F as getJantBundledAsset, G as base64ToUint8Array, H as JANT_LOGO_PATH_DATA, I as getJantIconFilename, L as getJantIconHref, M as getDefaultJantAppleTouchIconBytes, N as getDefaultJantFaviconIcoBytes, O as HOME_BRANDING_PREFIX, P as getJantBrandPackHref, R as getJantLogoFilename, S as formatTime, U as JANT_LOGO_VIEW_BOX, V as getJantPositiveLogoPngHref, W as arrayBufferToBase64, _ as getMediaUrl, a as tiptapJsonToMarkdown, b as formatRelativeAge, c as render, d as extractSummary, f as extractSummaryHtml, g as getImageUrl, h as escapeHtml, i as createExportService, j as JANT_REPO_URL, k as JANT_BRAND_PACK_FILENAME, l as toPlainText, m as trimTiptapBody, n as createGitHubSyncService, o as markdownToTiptapJson, p as renderTiptapJson, u as extractBodyText, v as getPublicUrlForProvider, w as now, x as formatRelativeTime, y as formatDate, z as getJantLogoFills } from "./github-sync-zohnA9qv.js";
3
- import { C as coalesceDisplayText, S as shouldUseSecureCookies, _ as getInternalAdminToken, a as getConfiguredSingleSiteUrl, b as getSiteResolutionMode, c as getDevApiToken, d as getHostedControlPlaneBaseUrl, f as getHostedControlPlaneDomainCheckSecret, g as getHostedControlPlaneSsoSecret, h as getHostedControlPlaneProviderLabel$1, i as getConfiguredSingleSitePathPrefix, l as getEnvString, m as getHostedControlPlaneInternalToken, n as getAuthSecret, o as getConfiguredStorageDriver, p as getHostedControlPlaneInternalBaseUrl, r as getConfiguredSingleSiteOrigin, s as getCorsOrigins, u as getGitHubAppConfig, v as getLocalStoragePath } from "./env-wCpMcNXs.js";
4
- import { a as listInstallationReposPage, n as getInstallation, o as searchInstallationRepos, t as buildInstallUrl } from "./github-app-F4qZ05xk.js";
5
- import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-CficQztC.js";
1
+ import { a as getSitePathPrefix, c as normalizePath, d as sanitizeUrl, f as slugify, g as toPublicPath, h as toPublicHref, i as getSiteOrigin, m as toAbsoluteSiteUrl, n as extractDisplayDomain, o as isFullUrl, p as stripSitePathPrefix, r as extractDomain, s as isSafeInternalRedirect, t as buildSiteUrl, u as normalizeSiteUrl, v as __exportAll } from "./url-umUptr5z.js";
2
+ import { A as JANT_BRAND_PACK_FILENAME, B as getJantLogoFills, C as formatTime, D as toISOString, F as getJantBrandPackHref, G as arrayBufferToBase64, H as getJantPositiveLogoPngHref, I as getJantBundledAsset, K as base64ToUint8Array, L as getJantIconFilename, M as JANT_REPO_URL, N as getDefaultJantAppleTouchIconBytes, O as HOME_BRANDING_LINK_LABEL, P as getDefaultJantFaviconIcoBytes, R as getJantIconHref, S as formatRelativeTime, T as now, U as JANT_LOGO_PATH_DATA, V as getJantLogoHref, W as JANT_LOGO_VIEW_BOX, _ as getImageUrl, a as tiptapJsonToMarkdown, b as formatDate, c as render, d as extractSummary, f as extractSummaryHtml, g as escapeHtml, h as trimTiptapBody, i as createExportService, j as JANT_POSITIVE_LOGO_PNG_FILENAME, k as HOME_BRANDING_PREFIX, l as toPlainText, m as renderTiptapJson, n as createGitHubSyncService, o as markdownToTiptapJson, p as renderTiptapDocument, u as extractBodyText, v as getMediaUrl, w as formatYearMonth, x as formatRelativeAge, y as getPublicUrlForProvider, z as getJantLogoFilename } from "./github-sync-7y_nTXx1.js";
3
+ import { C as coalesceDisplayText, S as shouldUseSecureCookies, _ as getInternalAdminToken, a as getConfiguredSingleSiteUrl, b as getSiteResolutionMode, c as getDevApiToken, d as getHostedControlPlaneBaseUrl, f as getHostedControlPlaneDomainCheckSecret, g as getHostedControlPlaneSsoSecret, h as getHostedControlPlaneProviderLabel$1, i as getConfiguredSingleSitePathPrefix, l as getEnvString, m as getHostedControlPlaneInternalToken, n as getAuthSecret, o as getConfiguredStorageDriver, p as getHostedControlPlaneInternalBaseUrl, r as getConfiguredSingleSiteOrigin, s as getCorsOrigins, u as getGitHubAppConfig, v as getLocalStoragePath } from "./env-CgaH9Mut.js";
4
+ import { a as listInstallationReposPage, n as getInstallation, o as searchInstallationRepos, t as buildInstallUrl } from "./github-app-WeadXMb8.js";
5
+ import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BkRWnqMx.js";
6
6
  import { I18n } from "@lingui/core";
7
+ import * as lucideIcons from "lucide-static";
7
8
  import { z } from "zod";
8
9
  import { fromString, typeidUnboxed } from "typeid-js";
9
10
  import { decode } from "blurhash";
10
- import { and, asc, desc, eq, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
11
+ import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lt, lte, ne, or, sql } from "drizzle-orm";
11
12
  import { generateKeyBetween } from "fractional-indexing";
12
13
  import { drizzle } from "drizzle-orm/better-sqlite3";
13
14
  import { drizzle as drizzle$1 } from "drizzle-orm/d1";
14
15
  import { check, foreignKey, index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
15
16
  import { boolean, check as check$1, customType, foreignKey as foreignKey$1, index as index$1, integer as integer$1, pgTable, primaryKey as primaryKey$1, text as text$1, timestamp, unique, uniqueIndex as uniqueIndex$1 } from "drizzle-orm/pg-core";
16
- import * as lucideIcons from "lucide-static";
17
17
  import { APIError, betterAuth } from "better-auth";
18
18
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
19
19
  import { verifyPassword } from "better-auth/crypto";
@@ -3352,15 +3352,289 @@ function normalizeThemeColorForMeta(color) {
3352
3352
  * internal paths (e.g. `/_assets/client-HASH.js`) embedded by the Worker build
3353
3353
  * from the Vite client manifest. Used only in production (IS_VITE_DEV=false).
3354
3354
  */ var IS_VITE_DEV = typeof __JANT_DEV__ !== "undefined" && __JANT_DEV__ === true;
3355
- var CORE_VERSION = "0.3.42-fc459b837c6eaa85";
3355
+ var CORE_VERSION = "0.3.44-c595e1fa6d741ba8";
3356
3356
  var CLIENT_JS_FILE = "/_assets/client-D95FNDg5.js";
3357
- var CLIENT_AUTH_JS_FILE = "/_assets/client-auth-BRFl5zQA.js";
3358
- var CLIENT_CSS_FILE = "/_assets/client-C_kImWZj.css";
3357
+ var CLIENT_AUTH_JS_FILE = "/_assets/client-auth-CXILhW1b.js";
3358
+ var CLIENT_CSS_FILE = "/_assets/client-BQH7AQ24.css";
3359
3359
  var CLIENT_CJK_CSS_FILE = "/_assets/client-cjk-B7Z0snDu.css";
3360
3360
  var CLIENT_CJK_TC_CSS_FILE = "/_assets/client-cjk-tc-BesJYrb2.css";
3361
3361
  var CLIENT_CJK_JP_CSS_FILE = "/_assets/client-cjk-jp-DZwrTzQC.css";
3362
3362
  var CLIENT_CJK_KR_CSS_FILE = "/_assets/client-cjk-kr-_3ZNI2ZP.css";
3363
3363
  //#endregion
3364
+ //#region src/ui/shared/icon-collector.ts
3365
+ /**
3366
+ * Request-scoped icon collector for SSR SVG sprite pattern.
3367
+ *
3368
+ * The <Icon> component registers each used icon name here during render.
3369
+ * At the end of <body>, <IconSprite> reads the collected set and emits a
3370
+ * single <svg><symbol>...</symbol></svg> block so every <use href="#icon-x">
3371
+ * reference in the page resolves to a definition.
3372
+ *
3373
+ * Mirrors the I18nProvider pattern in i18n/context.tsx: Hono JSX renders
3374
+ * synchronously per request, so a module-level singleton is safe.
3375
+ */ var currentCollector = null;
3376
+ /**
3377
+ * Start a new collection scope for the current render pass.
3378
+ * Call at the top of the root layout before children render.
3379
+ */ function resetIconCollector() {
3380
+ currentCollector = /* @__PURE__ */ new Set();
3381
+ }
3382
+ /**
3383
+ * Register an icon as used during this render.
3384
+ * Safe to call even when no collector is active (no-op).
3385
+ */ function collectIcon(name) {
3386
+ currentCollector?.add(name);
3387
+ }
3388
+ /**
3389
+ * Get the icon names collected so far during this render.
3390
+ * Returns an empty set if no collection scope is active.
3391
+ */ function getCollectedIcons() {
3392
+ return currentCollector ?? /* @__PURE__ */ new Set();
3393
+ }
3394
+ //#endregion
3395
+ //#region src/lib/featured-icons.ts
3396
+ /**
3397
+ * Shared icon definitions for featured post affordances.
3398
+ *
3399
+ * These paths are reused across Hono JSX, Lit, and exported static markup so
3400
+ * "Featured" keeps one visual language everywhere.
3401
+ */ var FEATURED_SPARKLE_PATH = "M12 3 10.1 10.1 3 12l7.1 1.9L12 21l1.9-7.1L21 12l-7.1-1.9Z";
3402
+ var FEATURED_SPARKLE_OFF_SLASH_PATH = "M4 4 20 20";
3403
+ /**
3404
+ * Build inline SVG markup for the shared featured sparkle icon.
3405
+ *
3406
+ * @param options - Render options for the sparkle icon.
3407
+ * @returns SVG markup string for inline insertion.
3408
+ * @example
3409
+ * getFeaturedIconSvg({ off: true, className: "icon-fine" });
3410
+ */ function getFeaturedIconSvg(options = {}) {
3411
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"${options.className ? ` class="${options.className}"` : ""} aria-hidden="true"><path d="${FEATURED_SPARKLE_PATH}" />${options.off ? `<path d="${FEATURED_SPARKLE_OFF_SLASH_PATH}" />` : ""}</svg>`;
3412
+ }
3413
+ //#endregion
3414
+ //#region src/lib/decorative-quote-mark.ts
3415
+ var DECORATIVE_QUOTE_MARK_VIEWBOX = "0 0 96 96";
3416
+ var DECORATIVE_QUOTE_MARK_PATHS = ["M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z", "M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z"];
3417
+ DECORATIVE_QUOTE_MARK_PATHS.map((path) => `<path fill="currentColor" d="${path}" />`).join("");
3418
+ //#endregion
3419
+ //#region src/ui/shared/custom-icons.ts
3420
+ /**
3421
+ * Custom (non-lucide) SVG symbol definitions used by the icon sprite.
3422
+ *
3423
+ * Icons here fall into three groups:
3424
+ * 1. Jant-specific paths (decorative quote mark, featured sparkle).
3425
+ * 2. Lucide-equivalent paths the UI uses with non-default stroke widths
3426
+ * or sizes that don't match the stock lucide symbol (we keep them as
3427
+ * custom symbols to preserve exact visual fidelity during refactor).
3428
+ * 3. Fixed-color SVGs (video play overlay) that don't use currentColor.
3429
+ *
3430
+ * Each entry provides everything needed to render a <symbol> element:
3431
+ * <symbol id="icon-${name}" viewBox={viewBox}>{inner}</symbol>
3432
+ * Consumers of <Icon name="..."> pass `size` / `className` on the outer
3433
+ * <svg><use/></svg>; `<symbol>` children inherit the outer attributes.
3434
+ */ var STROKE_THIN = "fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.35\" stroke-linecap=\"round\" stroke-linejoin=\"round\"";
3435
+ var STROKE_POST_BADGE = "fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\"";
3436
+ var CUSTOM_SYMBOLS = {
3437
+ "featured-sparkle": {
3438
+ viewBox: "0 0 24 24",
3439
+ inner: `<path ${STROKE_THIN} d="${FEATURED_SPARKLE_PATH}" />`
3440
+ },
3441
+ "featured-sparkle-off": {
3442
+ viewBox: "0 0 24 24",
3443
+ inner: `<path ${STROKE_THIN} d="${FEATURED_SPARKLE_PATH}" /><path ${STROKE_THIN} d="${FEATURED_SPARKLE_OFF_SLASH_PATH}" />`
3444
+ },
3445
+ "decorative-quote": {
3446
+ viewBox: DECORATIVE_QUOTE_MARK_VIEWBOX,
3447
+ inner: DECORATIVE_QUOTE_MARK_PATHS.map((path) => `<path fill="currentColor" d="${path}" />`).join("")
3448
+ },
3449
+ "post-collection-lock": {
3450
+ viewBox: "0 0 16 16",
3451
+ inner: `<rect ${STROKE_THIN} x="3" y="5.05" width="10" height="8.15" rx="2.2" /><path ${STROKE_THIN} d="M5.1 5.05V4.2a1.1 1.1 0 0 1 1.1-1.1h3.6a1.1 1.1 0 0 1 1.1 1.1v.85" />`
3452
+ },
3453
+ "post-menu-dots": {
3454
+ viewBox: "0 0 24 24",
3455
+ inner: `<circle cx="5" cy="12" r="1.75" fill="currentColor" /><circle cx="12" cy="12" r="1.75" fill="currentColor" /><circle cx="19" cy="12" r="1.75" fill="currentColor" />`
3456
+ },
3457
+ "post-external-link": {
3458
+ viewBox: "0 0 24 24",
3459
+ inner: `<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M7 17 17 7" /><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M9 7h8v8" />`
3460
+ },
3461
+ "post-reply": {
3462
+ viewBox: "0 0 24 24",
3463
+ inner: `<polyline fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="9 17 4 12 9 7" /><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M20 18v-2a4 4 0 0 0-4-4H4" />`
3464
+ },
3465
+ "link-domain": {
3466
+ viewBox: "0 0 24 24",
3467
+ inner: `<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />`
3468
+ },
3469
+ "link-preview-play": {
3470
+ viewBox: "0 0 68 48",
3471
+ inner: "<path class=\"link-preview-play-bg\" fill=\"rgba(0,0,0,.65)\" d=\"M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z\" /><path fill=\"#fff\" d=\"M45 24L27 14v20\" />"
3472
+ },
3473
+ "link-preview-badge-play": {
3474
+ viewBox: "0 0 16 16",
3475
+ inner: "<path fill=\"currentColor\" d=\"M5.5 3.5v9l7-4.5z\" />"
3476
+ },
3477
+ "toast-success": {
3478
+ viewBox: "0 0 24 24",
3479
+ inner: `<circle fill="none" stroke="currentColor" stroke-width="2" cx="12" cy="12" r="10" /><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="m9 12 2 2 4-4" />`
3480
+ },
3481
+ "toast-error": {
3482
+ viewBox: "0 0 24 24",
3483
+ inner: `<circle fill="none" stroke="currentColor" stroke-width="2" cx="12" cy="12" r="10" /><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="m15 9-6 6M9 9l6 6" />`
3484
+ },
3485
+ "toast-close": {
3486
+ viewBox: "0 0 24 24",
3487
+ inner: `<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M18 6 6 18M6 6l12 12" />`
3488
+ },
3489
+ "post-status-pin": {
3490
+ viewBox: "0 0 24 24",
3491
+ inner: `<line ${STROKE_POST_BADGE} x1="12" x2="12" y1="17" y2="22" /><path ${STROKE_POST_BADGE} d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" />`
3492
+ },
3493
+ "post-status-private": {
3494
+ viewBox: "0 0 24 24",
3495
+ inner: `<path ${STROKE_POST_BADGE} d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" /><path ${STROKE_POST_BADGE} d="M14.084 14.158a3 3 0 0 1-4.242-4.242" /><path ${STROKE_POST_BADGE} d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" /><path ${STROKE_POST_BADGE} d="m2 2 20 20" />`
3496
+ }
3497
+ };
3498
+ function getCustomSymbol(name) {
3499
+ return CUSTOM_SYMBOLS[name] ?? null;
3500
+ }
3501
+ /**
3502
+ * Return the viewBox for an icon's outer <svg> wrapper.
3503
+ *
3504
+ * This must match the <symbol>'s viewBox so the browser computes the correct
3505
+ * intrinsic aspect ratio. Without this, outer <svg> with `height: auto` in
3506
+ * CSS falls back to the 300×150 replaced-element default instead of the
3507
+ * icon's real aspect ratio.
3508
+ *
3509
+ * Falls back to lucide's "0 0 24 24" for lucide-sourced icons.
3510
+ */ function getIconViewBox(name) {
3511
+ return CUSTOM_SYMBOLS[name]?.viewBox ?? "0 0 24 24";
3512
+ }
3513
+ //#endregion
3514
+ //#region src/ui/shared/Icon.tsx
3515
+ /**
3516
+ * <Icon> — sprite-based SVG icon for SSR pages.
3517
+ *
3518
+ * Renders a lightweight <svg><use href="#icon-${name}"/></svg> stub and
3519
+ * registers the icon name with the request-scoped collector so the final
3520
+ * sprite (rendered by <IconSprite>) contains exactly the icons used on
3521
+ * this page.
3522
+ *
3523
+ * Name can refer to any lucide-static icon (kebab-case) or one of the
3524
+ * custom symbols defined in `custom-icons.ts`. Unknown names render an
3525
+ * empty <svg> — the same failure mode as the previous getIconSvg() path.
3526
+ *
3527
+ * Size: outer <svg> width/height in pixels. Defaults to 24 (lucide default).
3528
+ * Pass `class` to add CSS classes, e.g. for sizing via stylesheet instead
3529
+ * of inline width/height.
3530
+ */ var Icon$1 = ({ name, size, class: cls, "aria-label": ariaLabel, "aria-hidden": ariaHidden }) => {
3531
+ collectIcon(name);
3532
+ const hidden = ariaHidden ?? (ariaLabel ? void 0 : true);
3533
+ return /* @__PURE__ */ jsxDEV$1("svg", {
3534
+ viewBox: getIconViewBox(name),
3535
+ ...size !== void 0 ? {
3536
+ width: size,
3537
+ height: size
3538
+ } : {},
3539
+ ...cls ? { class: cls } : {},
3540
+ ...ariaLabel ? {
3541
+ "aria-label": ariaLabel,
3542
+ role: "img"
3543
+ } : {},
3544
+ ...hidden ? { "aria-hidden": "true" } : {},
3545
+ children: /* @__PURE__ */ jsxDEV$1("use", { href: `#icon-${name}` })
3546
+ });
3547
+ };
3548
+ //#endregion
3549
+ //#region src/lib/icons.ts
3550
+ /**
3551
+ * Shared icon utilities.
3552
+ *
3553
+ * Provides a small wrapper around lucide-static so server-rendered UI can fetch
3554
+ * SVG markup by kebab-case icon name.
3555
+ */
3556
+ /**
3557
+ * Convert a kebab-case icon name to PascalCase for lucide-static lookup.
3558
+ *
3559
+ * @param name - Kebab-case icon name such as "book-open"
3560
+ * @returns PascalCase name such as "BookOpen"
3561
+ */ function toPascalCase(name) {
3562
+ return name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
3563
+ }
3564
+ /**
3565
+ * Get SVG markup for a Lucide icon by kebab-case name.
3566
+ *
3567
+ * @param name - Kebab-case icon name
3568
+ * @returns SVG string or null when the icon is unknown
3569
+ *
3570
+ * @example
3571
+ * ```ts
3572
+ * getIconSvg("book-open");
3573
+ * ```
3574
+ */ function getIconSvg(name) {
3575
+ const svg = lucideIcons[toPascalCase(name)];
3576
+ return typeof svg === "string" ? svg : null;
3577
+ }
3578
+ /**
3579
+ * Get the inner SVG contents for a Lucide icon (the path children only,
3580
+ * without the outer <svg> wrapper). Used by the icon sprite to build
3581
+ * <symbol> definitions.
3582
+ *
3583
+ * @param name - Kebab-case icon name
3584
+ * @returns Inner SVG markup (e.g. "<path ... />"), or null when unknown
3585
+ *
3586
+ * @example
3587
+ * ```ts
3588
+ * getIconInnerSvg("book-open");
3589
+ * // -> "<path d=\"...\"/><path d=\"...\"/>"
3590
+ * ```
3591
+ */ function getIconInnerSvg(name) {
3592
+ const svg = getIconSvg(name);
3593
+ if (!svg) return null;
3594
+ const openTagEnd = svg.indexOf(">");
3595
+ const closeTagStart = svg.lastIndexOf("</svg>");
3596
+ if (openTagEnd < 0 || closeTagStart < 0) return null;
3597
+ return svg.slice(openTagEnd + 1, closeTagStart);
3598
+ }
3599
+ /**
3600
+ * Default stroke/fill attributes inherited by <symbol> children when a
3601
+ * lucide icon is referenced via <use>. These mirror the attributes lucide
3602
+ * normally sets on the outer <svg> so the currentColor-based theming keeps
3603
+ * working through <use>.
3604
+ */ var LUCIDE_SYMBOL_ATTRS = "fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"";
3605
+ var LUCIDE_VIEWBOX = "0 0 24 24";
3606
+ //#endregion
3607
+ //#region src/ui/shared/IconSprite.tsx
3608
+ /**
3609
+ * <IconSprite> — emits the SVG symbol definitions used by this render.
3610
+ *
3611
+ * Must be rendered AFTER all <Icon> usages in the document (e.g. at the
3612
+ * end of <body>) so the collector has the full set of icon names. Hono
3613
+ * JSX stringifies synchronously in document order, so children declared
3614
+ * earlier in the tree are evaluated before this component.
3615
+ *
3616
+ * <use href="#icon-x"> anywhere in the document resolves correctly even
3617
+ * when the <symbol> definition comes after the reference, since browsers
3618
+ * wire up the references after the full document is parsed.
3619
+ */ function buildSymbol(name) {
3620
+ const custom = getCustomSymbol(name);
3621
+ if (custom) return `<symbol id="icon-${name}" viewBox="${custom.viewBox}">${custom.inner}</symbol>`;
3622
+ const inner = getIconInnerSvg(name);
3623
+ if (inner === null) return null;
3624
+ return `<symbol id="icon-${name}" viewBox="${LUCIDE_VIEWBOX}" ${LUCIDE_SYMBOL_ATTRS}>${inner}</symbol>`;
3625
+ }
3626
+ var IconSprite = () => {
3627
+ const symbols = Array.from(getCollectedIcons()).sort().map(buildSymbol).filter((s) => s !== null).join("");
3628
+ if (!symbols) return null;
3629
+ return /* @__PURE__ */ jsxDEV$1("svg", {
3630
+ xmlns: "http://www.w3.org/2000/svg",
3631
+ style: "display:none",
3632
+ "aria-hidden": "true",
3633
+ "data-icon-sprite": true,
3634
+ children: raw(symbols)
3635
+ });
3636
+ };
3637
+ //#endregion
3364
3638
  //#region src/ui/layouts/BaseLayout.tsx
3365
3639
  /**
3366
3640
  * Base HTML Layout
@@ -3370,7 +3644,8 @@ var CLIENT_CJK_KR_CSS_FILE = "/_assets/client-cjk-kr-_3ZNI2ZP.css";
3370
3644
  *
3371
3645
  * In dev mode (Vite), serves assets via Vite's dev server.
3372
3646
  * In production, serves pre-built assets with content-hashed filenames.
3373
- */ var BaseLayout = ({ title, description, lang, c, toast, faviconHref, appleTouchHref, faviconUrl, faviconVersion, socialImageUrl, noindex, isAuthenticated = false, clientBundle, children }) => {
3647
+ */ var BaseLayout = ({ title, description, lang, c, toast, faviconHref, appleTouchHref, faviconUrl, faviconVersion, socialImageUrl, canonicalHref, noindex, isAuthenticated = false, clientBundle, children }) => {
3648
+ resetIconCollector();
3374
3649
  const resolvedLang = lang ?? (c ? c.get("lang") : "en");
3375
3650
  const appConfig = c ? c.get("appConfig") : void 0;
3376
3651
  const resolvedSocialImagePath = socialImageUrl ?? faviconUrl ?? appConfig?.siteAvatarUrl ?? getJantIconHref("socialImage", appConfig?.sitePathPrefix || "");
@@ -3398,7 +3673,7 @@ var CLIENT_CJK_KR_CSS_FILE = "/_assets/client-cjk-kr-_3ZNI2ZP.css";
3398
3673
  const cjkSerifFont = appConfig?.cjkSerifFont ?? "off";
3399
3674
  const cjkStylesheetPath = cjkSerifFont === "zh-Hans" ? IS_VITE_DEV ? assetPath("/src/style-cjk.css") : toPublicAssetPath(CLIENT_CJK_CSS_FILE, assetBasePath) : cjkSerifFont === "zh-Hant" ? IS_VITE_DEV ? assetPath("/src/style-cjk-tc.css") : toPublicAssetPath(CLIENT_CJK_TC_CSS_FILE, assetBasePath) : cjkSerifFont === "ja" ? IS_VITE_DEV ? assetPath("/src/style-cjk-jp.css") : toPublicAssetPath(CLIENT_CJK_JP_CSS_FILE, assetBasePath) : cjkSerifFont === "ko" ? IS_VITE_DEV ? assetPath("/src/style-cjk-kr.css") : toPublicAssetPath(CLIENT_CJK_KR_CSS_FILE, assetBasePath) : null;
3400
3675
  const clientScriptPath = IS_VITE_DEV ? resolvedClientBundle === "full" ? assetPath("/src/client-auth.ts") : assetPath("/src/client.ts") : toPublicAssetPath(resolvedClientBundle === "full" ? CLIENT_AUTH_JS_FILE : CLIENT_JS_FILE, assetBasePath);
3401
- const faviconAssetVersion = resolvedFaviconVersion || "0.3.42-fc459b837c6eaa85";
3676
+ const faviconAssetVersion = resolvedFaviconVersion || "0.3.44-c595e1fa6d741ba8";
3402
3677
  const resolvedFaviconHref = faviconHref ?? (faviconAssetVersion ? toPublicPath(`/favicon.ico?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/favicon.ico", sitePathPrefix));
3403
3678
  const resolvedAppleTouchHref = appleTouchHref ?? (faviconAssetVersion ? toPublicPath(`/apple-touch-icon.png?v=${faviconAssetVersion}`, sitePathPrefix) : toPublicPath("/apple-touch-icon.png", sitePathPrefix));
3404
3679
  const socialImageHref = resolvedSocialImagePath && (isFullUrl(resolvedSocialImagePath) || resolvedSocialImagePath.startsWith("//") ? resolvedSocialImagePath : toAbsoluteSiteUrl(resolvedSocialImagePath, appConfig?.siteUrl || "", sitePathPrefix));
@@ -3497,6 +3772,10 @@ var CLIENT_CJK_KR_CSS_FILE = "/_assets/client-cjk-kr-_3ZNI2ZP.css";
3497
3772
  name: "robots",
3498
3773
  content: "noindex, nofollow"
3499
3774
  }),
3775
+ canonicalHref && /* @__PURE__ */ jsxDEV$1("link", {
3776
+ rel: "canonical",
3777
+ href: canonicalHref
3778
+ }),
3500
3779
  /* @__PURE__ */ jsxDEV$1("link", {
3501
3780
  rel: "icon",
3502
3781
  href: resolvedFaviconHref,
@@ -3555,46 +3834,18 @@ var CLIENT_CJK_KR_CSS_FILE = "/_assets/client-cjk-kr-_3ZNI2ZP.css";
3555
3834
  class: `toast ${toast.type === "error" ? "toast-error" : "toast-success"}`,
3556
3835
  "data-init": "el.closest('[popover]').showPopover(); history.replaceState({}, '', location.pathname); setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)",
3557
3836
  children: [
3558
- toast.type === "error" ? /* @__PURE__ */ jsxDEV$1("svg", {
3559
- xmlns: "http://www.w3.org/2000/svg",
3560
- fill: "none",
3561
- viewBox: "0 0 24 24",
3562
- "stroke-width": "2",
3563
- stroke: "currentColor",
3564
- children: [/* @__PURE__ */ jsxDEV$1("circle", {
3565
- cx: "12",
3566
- cy: "12",
3567
- r: "10"
3568
- }), /* @__PURE__ */ jsxDEV$1("path", { d: "m15 9-6 6M9 9l6 6" })]
3569
- }) : /* @__PURE__ */ jsxDEV$1("svg", {
3570
- xmlns: "http://www.w3.org/2000/svg",
3571
- fill: "none",
3572
- viewBox: "0 0 24 24",
3573
- "stroke-width": "2",
3574
- stroke: "currentColor",
3575
- children: [/* @__PURE__ */ jsxDEV$1("circle", {
3576
- cx: "12",
3577
- cy: "12",
3578
- r: "10"
3579
- }), /* @__PURE__ */ jsxDEV$1("path", { d: "m9 12 2 2 4-4" })]
3580
- }),
3837
+ toast.type === "error" ? /* @__PURE__ */ jsxDEV$1(Icon$1, { name: "toast-error" }) : /* @__PURE__ */ jsxDEV$1(Icon$1, { name: "toast-success" }),
3581
3838
  /* @__PURE__ */ jsxDEV$1("span", { children: toast.message }),
3582
3839
  /* @__PURE__ */ jsxDEV$1("button", {
3583
3840
  class: "toast-close",
3584
3841
  "data-on:click": "el.closest('.toast').classList.add('toast-out'); el.closest('.toast').addEventListener('animationend', () => el.closest('.toast').remove())",
3585
- children: /* @__PURE__ */ jsxDEV$1("svg", {
3586
- xmlns: "http://www.w3.org/2000/svg",
3587
- fill: "none",
3588
- viewBox: "0 0 24 24",
3589
- "stroke-width": "2",
3590
- stroke: "currentColor",
3591
- children: /* @__PURE__ */ jsxDEV$1("path", { d: "M18 6 6 18M6 6l12 12" })
3592
- })
3842
+ children: /* @__PURE__ */ jsxDEV$1(Icon$1, { name: "toast-close" })
3593
3843
  })
3594
3844
  ]
3595
3845
  })
3596
3846
  }),
3597
- customBodyEndHtml && raw(customBodyEndHtml)
3847
+ customBodyEndHtml && raw(customBodyEndHtml),
3848
+ /* @__PURE__ */ jsxDEV$1(IconSprite, {})
3598
3849
  ]
3599
3850
  })]
3600
3851
  })] });
@@ -5381,9 +5632,10 @@ setupRoutes.post("/setup", async (c) => {
5381
5632
  });
5382
5633
  //#endregion
5383
5634
  //#region src/lib/hosted-signin.ts
5384
- function getHostedAdminContinuationPath(publicRequestUrl) {
5635
+ function getHostedAdminContinuationPath(publicRequestUrl, redirect) {
5385
5636
  const currentHost = new URL(publicRequestUrl).host;
5386
- return `/auth/handoff/start?host=${encodeURIComponent(currentHost)}&redirect=${encodeURIComponent("/")}`;
5637
+ const safeRedirect = isSafeInternalRedirect(redirect) ? redirect : "/";
5638
+ return `/auth/handoff/start?host=${encodeURIComponent(currentHost)}&redirect=${encodeURIComponent(safeRedirect)}`;
5387
5639
  }
5388
5640
  function buildHostedControlPlaneUrl(env, pathname, search) {
5389
5641
  const hostedControlPlaneBaseUrl = getHostedControlPlaneBaseUrl(env);
@@ -5393,8 +5645,8 @@ function buildHostedControlPlaneUrl(env, pathname, search) {
5393
5645
  location.search = search ?? "";
5394
5646
  return location.toString();
5395
5647
  }
5396
- function getHostedControlPlaneSigninUrl(env, publicRequestUrl) {
5397
- return buildHostedControlPlaneUrl(env, "/auth/handoff/start", getHostedAdminContinuationPath(publicRequestUrl).replace(/^\/auth\/handoff\/start/, ""));
5648
+ function getHostedControlPlaneSigninUrl(env, publicRequestUrl, redirect) {
5649
+ return buildHostedControlPlaneUrl(env, "/auth/handoff/start", getHostedAdminContinuationPath(publicRequestUrl, redirect).replace(/^\/auth\/handoff\/start/, ""));
5398
5650
  }
5399
5651
  function getHostedControlPlaneResetUrl(env, publicRequestUrl) {
5400
5652
  const search = new URLSearchParams();
@@ -5419,11 +5671,12 @@ function getHostedControlPlaneProviderLabel(env) {
5419
5671
  //#region src/routes/auth/signin.tsx
5420
5672
  /**
5421
5673
  * Sign-in / Sign-out Routes
5422
- */ var SigninContent = ({ demoEmail, demoPassword, sitePathPrefix = "" }) => {
5674
+ */ var SigninContent = ({ demoEmail, demoPassword, sitePathPrefix = "", redirect }) => {
5423
5675
  const { i18n } = useLingui();
5424
5676
  const signals = JSON.stringify({
5425
5677
  email: demoEmail || "",
5426
- password: demoPassword || ""
5678
+ password: demoPassword || "",
5679
+ ...redirect ? { redirect } : {}
5427
5680
  }).replace(/</g, "\\u003c");
5428
5681
  return /* @__PURE__ */ jsxDEV$1("div", {
5429
5682
  class: "min-h-screen flex items-center justify-center",
@@ -5488,7 +5741,9 @@ function getHostedControlPlaneProviderLabel(env) {
5488
5741
  };
5489
5742
  var signinRoutes = new Hono();
5490
5743
  signinRoutes.get("/signin", async (c) => {
5491
- const hostedSigninUrl = getHostedControlPlaneSigninUrl(c.env, c.var.publicRequestUrl);
5744
+ const rawRedirect = c.req.query("redirect");
5745
+ const redirect = isSafeInternalRedirect(rawRedirect) ? rawRedirect : void 0;
5746
+ const hostedSigninUrl = getHostedControlPlaneSigninUrl(c.env, c.var.publicRequestUrl, redirect);
5492
5747
  if (hostedSigninUrl) return c.redirect(hostedSigninUrl);
5493
5748
  const i18n = getI18n(c);
5494
5749
  const isSetup = c.req.query("setup") !== void 0;
@@ -5503,7 +5758,8 @@ signinRoutes.get("/signin", async (c) => {
5503
5758
  children: /* @__PURE__ */ jsxDEV$1(SigninContent, {
5504
5759
  demoEmail: c.var.appConfig.demoEmail,
5505
5760
  demoPassword: c.var.appConfig.demoPassword,
5506
- sitePathPrefix: c.var.appConfig.sitePathPrefix
5761
+ sitePathPrefix: c.var.appConfig.sitePathPrefix,
5762
+ redirect
5507
5763
  })
5508
5764
  }));
5509
5765
  });
@@ -5514,6 +5770,8 @@ signinRoutes.post("/signin", async (c) => {
5514
5770
  const parsed = SigninSchema.safeParse(body);
5515
5771
  if (!parsed.success) return dsToast(parsed.error.issues[0]?.message ?? i18n._({ id: "vXCC6J" }), "error");
5516
5772
  const { email, password } = parsed.data;
5773
+ const rawRedirect = body && typeof body === "object" && "redirect" in body ? body.redirect : void 0;
5774
+ const redirectTarget = typeof rawRedirect === "string" && isSafeInternalRedirect(rawRedirect) ? rawRedirect : "/";
5517
5775
  try {
5518
5776
  const { headers } = await c.var.auth.api.signInEmail({
5519
5777
  returnHeaders: true,
@@ -5523,7 +5781,7 @@ signinRoutes.post("/signin", async (c) => {
5523
5781
  },
5524
5782
  headers: c.req.raw.headers
5525
5783
  });
5526
- return dsRedirect(toPublicPath("/", c.var.appConfig.sitePathPrefix), { headers });
5784
+ return dsRedirect(toPublicPath(redirectTarget, c.var.appConfig.sitePathPrefix), { headers });
5527
5785
  } catch {
5528
5786
  return dsToast(i18n._({ id: "nFukaP" }), "error");
5529
5787
  }
@@ -5721,23 +5979,53 @@ function getRequestHostname(requestUrl, requestHost) {
5721
5979
  return hostname ? isLocalHostname(hostname) : false;
5722
5980
  }
5723
5981
  /**
5982
+ * Paths that should never be used as post-signin redirect targets (would
5983
+ * either loop back to signin or hit an unauthenticated endpoint).
5984
+ */ var POST_SIGNIN_REDIRECT_BLOCKLIST = new Set([
5985
+ "/signin",
5986
+ "/signout",
5987
+ "/setup",
5988
+ "/reset",
5989
+ "/__sso"
5990
+ ]);
5991
+ function getPostSigninRedirect(requestUrl) {
5992
+ let url;
5993
+ try {
5994
+ url = new URL(requestUrl);
5995
+ } catch {
5996
+ return null;
5997
+ }
5998
+ const pathname = url.pathname || "/";
5999
+ if (POST_SIGNIN_REDIRECT_BLOCKLIST.has(pathname)) return null;
6000
+ if (pathname.startsWith("/api/")) return null;
6001
+ const candidate = `${pathname}${url.search}`;
6002
+ return isSafeInternalRedirect(candidate) ? candidate : null;
6003
+ }
6004
+ /**
5724
6005
  * Middleware that requires authentication.
5725
6006
  * Redirects to signin page if not authenticated.
5726
6007
  * Session-only — Bearer tokens are not accepted for dashboard pages.
5727
6008
  */ function requireAuth(redirectTo = "/signin") {
5728
6009
  return async (c, next) => {
5729
- const redirectTarget = toPublicHref(redirectTo, getRuntimeSitePathPrefix({
6010
+ const sitePathPrefix = getRuntimeSitePathPrefix({
5730
6011
  env: c.env,
5731
6012
  appConfig: c.var.appConfig,
5732
6013
  currentSiteDomain: c.var.currentSiteDomain
5733
- }));
6014
+ });
6015
+ const buildRedirectTarget = () => {
6016
+ const publicHref = toPublicHref(redirectTo, sitePathPrefix);
6017
+ if (redirectTo !== "/signin") return publicHref;
6018
+ const postSignin = getPostSigninRedirect(c.req.url);
6019
+ if (!postSignin) return publicHref;
6020
+ return `${publicHref}${publicHref.includes("?") ? "&" : "?"}redirect=${encodeURIComponent(postSignin)}`;
6021
+ };
6022
+ const session = c.var.session;
6023
+ if (!session?.user) return c.redirect(buildRedirectTarget());
5734
6024
  try {
5735
- const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers });
5736
- if (!session?.user) return c.redirect(redirectTarget);
5737
- if (!await c.var.services.siteMembers.get(c.var.currentSite.id, session.user.id)) return c.redirect(redirectTarget);
6025
+ if (!await c.var.services.siteMembers.get(c.var.currentSite.id, session.user.id)) return c.redirect(buildRedirectTarget());
5738
6026
  await next();
5739
6027
  } catch {
5740
- return c.redirect(redirectTarget);
6028
+ return c.redirect(buildRedirectTarget());
5741
6029
  }
5742
6030
  };
5743
6031
  }
@@ -5747,10 +6035,9 @@ function getRequestHostname(requestUrl, requestHost) {
5747
6035
  * Returns 401 if neither method succeeds.
5748
6036
  */ function requireAuthApi() {
5749
6037
  return async (c, next) => {
5750
- try {
5751
- const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers });
5752
- if (session?.user) {
5753
- if (!await c.var.services.siteMembers.get(c.var.currentSite.id, session.user.id)) throw new UnauthorizedError();
6038
+ const session = c.var.session;
6039
+ if (session?.user) try {
6040
+ if (await c.var.services.siteMembers.get(c.var.currentSite.id, session.user.id)) {
5754
6041
  await next();
5755
6042
  return;
5756
6043
  }
@@ -5827,7 +6114,7 @@ devAuthRoutes.get("/__dev/login", async (c) => {
5827
6114
  headers: responseHeaders
5828
6115
  });
5829
6116
  } catch {
5830
- return c.text("Dev login failed. Finish /setup once or run `mise run db-local-rebuild-demo`, then retry.", 500);
6117
+ return c.text("Dev login failed. Finish /setup once or run `mise run db-wrangler-rebuild-demo`, then retry.", 500);
5831
6118
  }
5832
6119
  });
5833
6120
  //#endregion
@@ -6314,7 +6601,8 @@ function getLegacyBodyPreview(post) {
6314
6601
  }
6315
6602
  function getPlainSummary(post) {
6316
6603
  if (post.format === "quote") return normalizePreviewText(post.quoteText);
6317
- return normalizePreviewText(post.summary) || normalizePreviewText(post.bodyText) || getLegacyBodyPreview(post) || normalizePreviewText(post.url);
6604
+ const cleanBody = post.body ? extractBodyText(post.body) : null;
6605
+ return normalizePreviewText(post.summary) || normalizePreviewText(cleanBody) || normalizePreviewText(post.bodyText) || getLegacyBodyPreview(post) || normalizePreviewText(post.url);
6318
6606
  }
6319
6607
  function clipPreviewText(text, maxChars) {
6320
6608
  if (!text) return void 0;
@@ -6344,10 +6632,16 @@ function clipPreviewText(text, maxChars) {
6344
6632
  if (result) {
6345
6633
  summaryHtml = result.html;
6346
6634
  summaryHasMore = result.hasMore;
6347
- if (result.hasMore && post.bodyHtml) {
6348
- const pos = result.html.length;
6349
- bodyHtmlWithAnchor = post.bodyHtml.slice(0, pos) + "<span id=\"continue\"></span>" + post.bodyHtml.slice(pos);
6350
- }
6635
+ if (result.hasMore && post.bodyHtml) try {
6636
+ const doc = JSON.parse(post.body);
6637
+ if (doc.type === "doc" && Array.isArray(doc.content) && result.breakAtIndex > 0 && result.breakAtIndex <= doc.content.length) {
6638
+ const beforeHtml = renderTiptapDocument({
6639
+ type: "doc",
6640
+ content: doc.content.slice(0, result.breakAtIndex)
6641
+ });
6642
+ if (post.bodyHtml.startsWith(beforeHtml)) bodyHtmlWithAnchor = beforeHtml + "<span id=\"continue\"></span>" + post.bodyHtml.slice(beforeHtml.length);
6643
+ }
6644
+ } catch {}
6351
6645
  }
6352
6646
  }
6353
6647
  const collections = (postCollections ?? []).map((c) => ({
@@ -6589,8 +6883,8 @@ function clipPreviewText(text, maxChars) {
6589
6883
  * content: <MyContent />,
6590
6884
  * });
6591
6885
  * ```
6592
- */ async function getNavigationData(c) {
6593
- const items = await c.var.services.navItems.list();
6886
+ */ async function getNavigationData(c, options) {
6887
+ const items = options?.preloadedItems ?? await c.var.services.navItems.list();
6594
6888
  const currentPath = c.var.publicPath;
6595
6889
  const appConfig = c.var.appConfig;
6596
6890
  const siteName = appConfig.siteName;
@@ -6602,11 +6896,8 @@ function clipPreviewText(text, maxChars) {
6602
6896
  const siteAvatarUrl = appConfig.siteAvatarUrl || void 0;
6603
6897
  const showHeaderAvatar = appConfig.showHeaderAvatar;
6604
6898
  const siteFooterHtml = siteFooter ? render(siteFooter) : void 0;
6605
- let isAuthenticated = false;
6899
+ const isAuthenticated = c.var.isAuthenticated;
6606
6900
  let collections = [];
6607
- try {
6608
- isAuthenticated = !!(await c.var.auth.api.getSession({ headers: c.req.raw.headers }))?.user;
6609
- } catch {}
6610
6901
  const collectionNavIds = [];
6611
6902
  for (const item of items) if (item.type === "collection" && item.collectionId) collectionNavIds.push(item.collectionId);
6612
6903
  const collectionFreshness = collectionNavIds.length > 0 ? await c.var.services.navItems.getCollectionFreshness(collectionNavIds) : void 0;
@@ -7510,7 +7801,7 @@ var SiteLayout = ({ siteName, links, currentPath, sitePathPrefix = "", isAuthent
7510
7801
  * });
7511
7802
  * ```
7512
7803
  */ function renderPublicPage(c, options) {
7513
- const { title, description, faviconHref, appleTouchHref, socialImageUrl, navData, content, sidebar, toast, showComposeDialog, showHeader, showHomeBranding, composeCollectionId } = options;
7804
+ const { title, description, faviconHref, appleTouchHref, socialImageUrl, canonicalHref, navData, content, sidebar, toast, showComposeDialog, showHeader, showHomeBranding, composeCollectionId } = options;
7514
7805
  const metaDescription = description || navData.siteDescription || void 0;
7515
7806
  const appConfig = c.get("appConfig");
7516
7807
  const allSettings = c.get("allSettings");
@@ -7543,6 +7834,7 @@ var SiteLayout = ({ siteName, links, currentPath, sitePathPrefix = "", isAuthent
7543
7834
  faviconHref,
7544
7835
  appleTouchHref,
7545
7836
  socialImageUrl,
7837
+ canonicalHref,
7546
7838
  faviconUrl,
7547
7839
  faviconVersion,
7548
7840
  noindex,
@@ -9009,25 +9301,6 @@ var MediaGallery = ({ attachments, postPermalink }) => {
9009
9301
  });
9010
9302
  };
9011
9303
  //#endregion
9012
- //#region src/lib/featured-icons.ts
9013
- /**
9014
- * Shared icon definitions for featured post affordances.
9015
- *
9016
- * These paths are reused across Hono JSX, Lit, and exported static markup so
9017
- * "Featured" keeps one visual language everywhere.
9018
- */ var FEATURED_SPARKLE_PATH = "M12 3 10.1 10.1 3 12l7.1 1.9L12 21l1.9-7.1L21 12l-7.1-1.9Z";
9019
- var FEATURED_SPARKLE_OFF_SLASH_PATH = "M4 4 20 20";
9020
- /**
9021
- * Build inline SVG markup for the shared featured sparkle icon.
9022
- *
9023
- * @param options - Render options for the sparkle icon.
9024
- * @returns SVG markup string for inline insertion.
9025
- * @example
9026
- * getFeaturedIconSvg({ off: true, className: "icon-fine" });
9027
- */ function getFeaturedIconSvg(options = {}) {
9028
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"${options.className ? ` class="${options.className}"` : ""} aria-hidden="true"><path d="${FEATURED_SPARKLE_PATH}" />${options.off ? `<path d="${FEATURED_SPARKLE_OFF_SLASH_PATH}" />` : ""}</svg>`;
9029
- }
9030
- //#endregion
9031
9304
  //#region src/ui/shared/PostFooter.tsx
9032
9305
  /**
9033
9306
  * Post Footer
@@ -9054,22 +9327,7 @@ var FEATURED_SPARKLE_OFF_SLASH_PATH = "M4 4 20 20";
9054
9327
  children: [showIcon && /* @__PURE__ */ jsxDEV$1("span", {
9055
9328
  class: "post-collection-primary-icon",
9056
9329
  "aria-hidden": "true",
9057
- children: /* @__PURE__ */ jsxDEV$1("svg", {
9058
- xmlns: "http://www.w3.org/2000/svg",
9059
- viewBox: "0 0 16 16",
9060
- fill: "none",
9061
- stroke: "currentColor",
9062
- "stroke-width": "1.35",
9063
- "stroke-linecap": "round",
9064
- "stroke-linejoin": "round",
9065
- children: [/* @__PURE__ */ jsxDEV$1("rect", {
9066
- x: "3",
9067
- y: "5.05",
9068
- width: "10",
9069
- height: "8.15",
9070
- rx: "2.2"
9071
- }), /* @__PURE__ */ jsxDEV$1("path", { d: "M5.1 5.05V4.2a1.1 1.1 0 0 1 1.1-1.1h3.6a1.1 1.1 0 0 1 1.1 1.1v.85" })]
9072
- })
9330
+ children: /* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-collection-lock" })
9073
9331
  }), /* @__PURE__ */ jsxDEV$1("span", {
9074
9332
  class: "post-collection-tag-text",
9075
9333
  children: first.title
@@ -9140,29 +9398,9 @@ var PostMenuTriggerButton = ({ className = "post-menu-trigger" }) => {
9140
9398
  "aria-label": i18n._({ id: "JcD7qf" }),
9141
9399
  "aria-expanded": "false",
9142
9400
  "data-post-menu-trigger": true,
9143
- children: /* @__PURE__ */ jsxDEV$1("svg", {
9144
- xmlns: "http://www.w3.org/2000/svg",
9145
- width: "15",
9146
- height: "15",
9147
- viewBox: "0 0 24 24",
9148
- fill: "currentColor",
9149
- children: [
9150
- /* @__PURE__ */ jsxDEV$1("circle", {
9151
- cx: "5",
9152
- cy: "12",
9153
- r: "1.75"
9154
- }),
9155
- /* @__PURE__ */ jsxDEV$1("circle", {
9156
- cx: "12",
9157
- cy: "12",
9158
- r: "1.75"
9159
- }),
9160
- /* @__PURE__ */ jsxDEV$1("circle", {
9161
- cx: "19",
9162
- cy: "12",
9163
- r: "1.75"
9164
- })
9165
- ]
9401
+ children: /* @__PURE__ */ jsxDEV$1(Icon$1, {
9402
+ name: "post-menu-dots",
9403
+ size: 15
9166
9404
  })
9167
9405
  });
9168
9406
  };
@@ -9190,17 +9428,7 @@ var PostFooter = ({ post, detail, display }) => {
9190
9428
  "aria-label": featuredLabel,
9191
9429
  "data-tooltip": featuredLabel,
9192
9430
  "data-align": "center",
9193
- children: /* @__PURE__ */ jsxDEV$1("svg", {
9194
- xmlns: "http://www.w3.org/2000/svg",
9195
- viewBox: "0 0 24 24",
9196
- fill: "none",
9197
- stroke: "currentColor",
9198
- "stroke-width": "1.35",
9199
- "stroke-linecap": "round",
9200
- "stroke-linejoin": "round",
9201
- "aria-hidden": "true",
9202
- children: /* @__PURE__ */ jsxDEV$1("path", { d: FEATURED_SPARKLE_PATH })
9203
- })
9431
+ children: /* @__PURE__ */ jsxDEV$1(Icon$1, { name: "featured-sparkle" })
9204
9432
  }),
9205
9433
  showTimestamp && /* @__PURE__ */ jsxDEV$1(PostPublishedLink, {
9206
9434
  post,
@@ -9212,16 +9440,7 @@ var PostFooter = ({ post, detail, display }) => {
9212
9440
  target: "_blank",
9213
9441
  rel: "noopener noreferrer",
9214
9442
  "aria-label": i18n._({ id: "9dr9Nh" }),
9215
- children: /* @__PURE__ */ jsxDEV$1("svg", {
9216
- xmlns: "http://www.w3.org/2000/svg",
9217
- viewBox: "0 0 24 24",
9218
- fill: "none",
9219
- stroke: "currentColor",
9220
- "stroke-width": "2",
9221
- "stroke-linecap": "round",
9222
- "stroke-linejoin": "round",
9223
- children: [/* @__PURE__ */ jsxDEV$1("path", { d: "M7 17 17 7" }), /* @__PURE__ */ jsxDEV$1("path", { d: "M9 7h8v8" })]
9224
- })
9443
+ children: /* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-external-link" })
9225
9444
  }),
9226
9445
  /* @__PURE__ */ jsxDEV$1(CompactCollectionTags, {
9227
9446
  collections: post.collections,
@@ -9236,17 +9455,9 @@ var PostFooter = ({ post, detail, display }) => {
9236
9455
  class: "reply-trigger",
9237
9456
  "aria-label": i18n._({ id: "ImOQa9" }),
9238
9457
  "data-reply-trigger": true,
9239
- children: /* @__PURE__ */ jsxDEV$1("svg", {
9240
- xmlns: "http://www.w3.org/2000/svg",
9241
- width: "14",
9242
- height: "14",
9243
- viewBox: "0 0 24 24",
9244
- fill: "none",
9245
- stroke: "currentColor",
9246
- "stroke-width": "2",
9247
- "stroke-linecap": "round",
9248
- "stroke-linejoin": "round",
9249
- children: [/* @__PURE__ */ jsxDEV$1("polyline", { points: "9 17 4 12 9 7" }), /* @__PURE__ */ jsxDEV$1("path", { d: "M20 18v-2a4 4 0 0 0-4-4H4" })]
9458
+ children: /* @__PURE__ */ jsxDEV$1(Icon$1, {
9459
+ name: "post-reply",
9460
+ size: 14
9250
9461
  })
9251
9462
  }), /* @__PURE__ */ jsxDEV$1(PostMenuTriggerButton, {})]
9252
9463
  })]
@@ -9268,57 +9479,15 @@ var PostFooter = ({ post, detail, display }) => {
9268
9479
  children: [
9269
9480
  /* @__PURE__ */ jsxDEV$1("span", {
9270
9481
  class: "post-status-badge post-status-pinned",
9271
- children: [/* @__PURE__ */ jsxDEV$1("svg", {
9272
- xmlns: "http://www.w3.org/2000/svg",
9273
- viewBox: "0 0 24 24",
9274
- fill: "none",
9275
- stroke: "currentColor",
9276
- "stroke-width": "1.75",
9277
- "stroke-linecap": "round",
9278
- "stroke-linejoin": "round",
9279
- children: [/* @__PURE__ */ jsxDEV$1("line", {
9280
- x1: "12",
9281
- x2: "12",
9282
- y1: "17",
9283
- y2: "22"
9284
- }), /* @__PURE__ */ jsxDEV$1("path", { d: "M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" })]
9285
- }), "Pinned"]
9482
+ children: [/* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-status-pin" }), "Pinned"]
9286
9483
  }),
9287
9484
  /* @__PURE__ */ jsxDEV$1("span", {
9288
9485
  class: "post-status-badge post-status-pinned-in-collection",
9289
- children: [/* @__PURE__ */ jsxDEV$1("svg", {
9290
- xmlns: "http://www.w3.org/2000/svg",
9291
- viewBox: "0 0 24 24",
9292
- fill: "none",
9293
- stroke: "currentColor",
9294
- "stroke-width": "1.75",
9295
- "stroke-linecap": "round",
9296
- "stroke-linejoin": "round",
9297
- children: [/* @__PURE__ */ jsxDEV$1("line", {
9298
- x1: "12",
9299
- x2: "12",
9300
- y1: "17",
9301
- y2: "22"
9302
- }), /* @__PURE__ */ jsxDEV$1("path", { d: "M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" })]
9303
- }), "Pinned"]
9486
+ children: [/* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-status-pin" }), "Pinned"]
9304
9487
  }),
9305
9488
  /* @__PURE__ */ jsxDEV$1("span", {
9306
9489
  class: "post-status-badge post-status-private",
9307
- children: [/* @__PURE__ */ jsxDEV$1("svg", {
9308
- xmlns: "http://www.w3.org/2000/svg",
9309
- viewBox: "0 0 24 24",
9310
- fill: "none",
9311
- stroke: "currentColor",
9312
- "stroke-width": "1.75",
9313
- "stroke-linecap": "round",
9314
- "stroke-linejoin": "round",
9315
- children: [
9316
- /* @__PURE__ */ jsxDEV$1("path", { d: "M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" }),
9317
- /* @__PURE__ */ jsxDEV$1("path", { d: "M14.084 14.158a3 3 0 0 1-4.242-4.242" }),
9318
- /* @__PURE__ */ jsxDEV$1("path", { d: "M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" }),
9319
- /* @__PURE__ */ jsxDEV$1("path", { d: "m2 2 20 20" })
9320
- ]
9321
- }), "Private"]
9490
+ children: [/* @__PURE__ */ jsxDEV$1(Icon$1, { name: "post-status-private" }), "Private"]
9322
9491
  })
9323
9492
  ]
9324
9493
  });
@@ -9452,29 +9621,17 @@ var LinkPreview = ({ imageUrl, linkUrl, kind, provider }) => {
9452
9621
  isVideo && /* @__PURE__ */ jsxDEV$1("div", {
9453
9622
  class: "link-preview-play",
9454
9623
  "aria-hidden": "true",
9455
- children: /* @__PURE__ */ jsxDEV$1("svg", {
9456
- class: "link-preview-play-icon",
9457
- viewBox: "0 0 68 48",
9458
- xmlns: "http://www.w3.org/2000/svg",
9459
- children: [/* @__PURE__ */ jsxDEV$1("path", {
9460
- class: "link-preview-play-bg",
9461
- d: "M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55C3.97 2.33 2.27 4.81 1.48 7.74.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z",
9462
- fill: "rgba(0,0,0,.65)"
9463
- }), /* @__PURE__ */ jsxDEV$1("path", {
9464
- d: "M45 24L27 14v20",
9465
- fill: "#fff"
9466
- })]
9624
+ children: /* @__PURE__ */ jsxDEV$1(Icon$1, {
9625
+ name: "link-preview-play",
9626
+ class: "link-preview-play-icon"
9467
9627
  })
9468
9628
  }),
9469
9629
  providerLabel && /* @__PURE__ */ jsxDEV$1("span", {
9470
9630
  class: "link-preview-badge",
9471
9631
  "aria-hidden": "true",
9472
- children: [isVideo && /* @__PURE__ */ jsxDEV$1("svg", {
9473
- class: "link-preview-badge-icon",
9474
- viewBox: "0 0 16 16",
9475
- xmlns: "http://www.w3.org/2000/svg",
9476
- fill: "currentColor",
9477
- children: /* @__PURE__ */ jsxDEV$1("path", { d: "M5.5 3.5v9l7-4.5z" })
9632
+ children: [isVideo && /* @__PURE__ */ jsxDEV$1(Icon$1, {
9633
+ name: "link-preview-badge-play",
9634
+ class: "link-preview-badge-icon"
9478
9635
  }), providerLabel]
9479
9636
  })
9480
9637
  ]
@@ -9501,25 +9658,15 @@ var LinkPreview = ({ imageUrl, linkUrl, kind, provider }) => {
9501
9658
  class: "feed-link-domain",
9502
9659
  target: "_blank",
9503
9660
  rel: "noopener noreferrer",
9504
- children: [/* @__PURE__ */ jsxDEV$1("svg", {
9505
- class: "feed-link-domain-icon",
9506
- xmlns: "http://www.w3.org/2000/svg",
9507
- fill: "none",
9508
- viewBox: "0 0 24 24",
9509
- "stroke-width": "2",
9510
- stroke: "currentColor",
9511
- children: /* @__PURE__ */ jsxDEV$1("path", { d: "M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" })
9661
+ children: [/* @__PURE__ */ jsxDEV$1(Icon$1, {
9662
+ name: "link-domain",
9663
+ class: "feed-link-domain-icon"
9512
9664
  }), /* @__PURE__ */ jsxDEV$1("span", { children: domain })]
9513
9665
  }) : /* @__PURE__ */ jsxDEV$1("div", {
9514
9666
  class: "feed-link-domain",
9515
- children: [/* @__PURE__ */ jsxDEV$1("svg", {
9516
- class: "feed-link-domain-icon",
9517
- xmlns: "http://www.w3.org/2000/svg",
9518
- fill: "none",
9519
- viewBox: "0 0 24 24",
9520
- "stroke-width": "2",
9521
- stroke: "currentColor",
9522
- children: /* @__PURE__ */ jsxDEV$1("path", { d: "M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" })
9667
+ children: [/* @__PURE__ */ jsxDEV$1(Icon$1, {
9668
+ name: "link-domain",
9669
+ class: "feed-link-domain-icon"
9523
9670
  }), /* @__PURE__ */ jsxDEV$1("span", { children: domain })]
9524
9671
  }));
9525
9672
  const previewEl = !isCompact && post.previewImageUrl && /* @__PURE__ */ jsxDEV$1(LinkPreview, {
@@ -9600,11 +9747,6 @@ var LinkPreview = ({ imageUrl, linkUrl, kind, provider }) => {
9600
9747
  });
9601
9748
  };
9602
9749
  //#endregion
9603
- //#region src/lib/decorative-quote-mark.ts
9604
- var DECORATIVE_QUOTE_MARK_VIEWBOX = "0 0 96 96";
9605
- var DECORATIVE_QUOTE_MARK_PATHS = ["M24.4 10.5C16.9 17.7 11.5 26.8 8.2 37.7C4.9 48.7 4.8 58.9 7.8 68.2C10.3 75.7 15.4 79.5 22.9 79.5C28 79.5 32.2 77.8 35.4 74.2C38.6 70.7 40.2 66.5 40.2 61.4C40.2 56.5 38.8 52.6 36 49.6C33.3 46.6 29.7 45.1 25.2 45.1C23.4 45.1 21.8 45.3 20.2 45.8C22.2 37.3 26.7 29.2 33.6 21.4L24.4 10.5Z", "M60.8 10.5C53.3 17.7 47.9 26.8 44.6 37.7C41.3 48.7 41.2 58.9 44.2 68.2C46.7 75.7 51.8 79.5 59.3 79.5C64.4 79.5 68.6 77.8 71.8 74.2C75 70.7 76.6 66.5 76.6 61.4C76.6 56.5 75.2 52.6 72.4 49.6C69.7 46.6 66.1 45.1 61.6 45.1C59.8 45.1 58.2 45.3 56.6 45.8C58.6 37.3 63.1 29.2 70 21.4L60.8 10.5Z"];
9606
- DECORATIVE_QUOTE_MARK_PATHS.map((path) => `<path fill="currentColor" d="${path}" />`).join("");
9607
- //#endregion
9608
9750
  //#region src/ui/shared/DecorativeQuoteMark.tsx
9609
9751
  /**
9610
9752
  * Decorative double-quote mark rendered as SVG so the shape stays consistent
@@ -9613,15 +9755,7 @@ DECORATIVE_QUOTE_MARK_PATHS.map((path) => `<path fill="currentColor" d="${path}"
9613
9755
  class: `decorative-quote-mark${cls ? ` ${cls}` : ""}`,
9614
9756
  "data-direction": direction,
9615
9757
  "aria-hidden": "true",
9616
- children: /* @__PURE__ */ jsxDEV$1("svg", {
9617
- viewBox: DECORATIVE_QUOTE_MARK_VIEWBOX,
9618
- role: "presentation",
9619
- focusable: "false",
9620
- children: DECORATIVE_QUOTE_MARK_PATHS.map((path) => /* @__PURE__ */ jsxDEV$1("path", {
9621
- fill: "currentColor",
9622
- d: path
9623
- }))
9624
- })
9758
+ children: /* @__PURE__ */ jsxDEV$1(Icon$1, { name: "decorative-quote" })
9625
9759
  });
9626
9760
  //#endregion
9627
9761
  //#region src/ui/feed/QuoteCard.tsx
@@ -10086,16 +10220,23 @@ var PaginatedPageHeader = ({ title, currentPage = 1, totalPages, description, ic
10086
10220
  * Latest and Featured in navigation. The explicit feed routes still work.
10087
10221
  */ var homeRoutes = new Hono();
10088
10222
  homeRoutes.get("/", async (c) => {
10089
- const navData = await getNavigationData(c);
10090
10223
  const i18n = getI18n(c);
10091
10224
  const page = parsePageNumber(c.req.query("page"));
10092
10225
  const paginatedPageTitle = formatPageLabel(page);
10093
- if (navData.homeDefaultView === "featured") {
10226
+ const isAuthenticated = c.var.isAuthenticated;
10227
+ const navItems = await c.var.services.navItems.list();
10228
+ const homeDefaultView = getHomeDefaultViewFromNavItems(navItems);
10229
+ const timelinePromise = homeDefaultView === "featured" ? assembleFeaturedTimeline(c, {
10230
+ page,
10231
+ isAuthenticated
10232
+ }) : assembleTimeline(c, {
10233
+ page,
10234
+ isAuthenticated
10235
+ });
10236
+ const [navData, timeline] = await Promise.all([getNavigationData(c, { preloadedItems: navItems }), timelinePromise]);
10237
+ const { items, currentPage, totalPages } = timeline;
10238
+ if (homeDefaultView === "featured") {
10094
10239
  const featuredTitle = i18n._({ id: "FkMol5" });
10095
- const { items, currentPage, totalPages } = await assembleFeaturedTimeline(c, {
10096
- page,
10097
- isAuthenticated: navData.isAuthenticated
10098
- });
10099
10240
  return renderPublicPage(c, {
10100
10241
  title: page > 1 ? buildPageTitle(featuredTitle, paginatedPageTitle, navData.siteName) : navData.siteName,
10101
10242
  navData,
@@ -10109,10 +10250,6 @@ homeRoutes.get("/", async (c) => {
10109
10250
  });
10110
10251
  }
10111
10252
  const latestTitle = i18n._({ id: "wL3cK8" });
10112
- const { items, currentPage, totalPages } = await assembleTimeline(c, {
10113
- page,
10114
- isAuthenticated: navData.isAuthenticated
10115
- });
10116
10253
  return renderPublicPage(c, {
10117
10254
  title: page > 1 ? buildPageTitle(latestTitle, paginatedPageTitle, navData.siteName) : navData.siteName,
10118
10255
  navData,
@@ -10170,6 +10307,17 @@ var PostPage = ({ post, threadPosts }) => {
10170
10307
  //#region src/lib/post-meta.ts
10171
10308
  var TITLE_MAX_CHARS = 72;
10172
10309
  var DESCRIPTION_MAX_CHARS = 160;
10310
+ /**
10311
+ * Derive a clean plain-text projection of the body for human-facing meta.
10312
+ *
10313
+ * We cannot reuse `post.bodyText` here: that column is written with
10314
+ * `includeLinkHrefs: true` so inline link URLs land in the FTS index, which
10315
+ * pollutes the stored text with trailing URLs. Re-derive from the source
10316
+ * TipTap JSON (`post.body`) without that option.
10317
+ */ function getCleanBodyText(post) {
10318
+ if (post.body) return extractBodyText(post.body) ?? "";
10319
+ return post.bodyText ?? "";
10320
+ }
10173
10321
  function normalizeText(text) {
10174
10322
  return (text ?? "").replace(/\s+/g, " ").trim();
10175
10323
  }
@@ -10193,7 +10341,7 @@ function getTitleCandidate(post) {
10193
10341
  if (normalizeText(post.title)) return normalizeText(post.title);
10194
10342
  const summarySnippet = getFirstParagraph(post.summary);
10195
10343
  if (summarySnippet) return clipText(summarySnippet, TITLE_MAX_CHARS);
10196
- const bodySnippet = getFirstParagraph(post.bodyText);
10344
+ const bodySnippet = getFirstParagraph(getCleanBodyText(post));
10197
10345
  if (bodySnippet) return clipText(bodySnippet, TITLE_MAX_CHARS);
10198
10346
  if (post.format === "link" && post.url) return extractDisplayDomain(post.url) || post.url;
10199
10347
  return "";
@@ -10205,7 +10353,7 @@ function getDescriptionCandidate(post) {
10205
10353
  }
10206
10354
  const summaryText = normalizeText(post.summary);
10207
10355
  if (summaryText) return clipText(summaryText, DESCRIPTION_MAX_CHARS);
10208
- const bodyText = normalizeText(post.bodyText);
10356
+ const bodyText = normalizeText(getCleanBodyText(post));
10209
10357
  if (bodyText) return clipText(bodyText, DESCRIPTION_MAX_CHARS);
10210
10358
  const quoteText = normalizeText(post.quoteText);
10211
10359
  if (quoteText) return clipText(quoteText, DESCRIPTION_MAX_CHARS);
@@ -10305,6 +10453,7 @@ function buildPostMeta(post, siteName) {
10305
10453
  pathRegistry: () => pathRegistry$1,
10306
10454
  postCollections: () => postCollections$1,
10307
10455
  posts: () => posts$1,
10456
+ rateLimit: () => rateLimit$2,
10308
10457
  session: () => session$1,
10309
10458
  settings: () => settings$1,
10310
10459
  siteDomains: () => siteDomains$1,
@@ -10367,9 +10516,14 @@ var sites$1 = sqliteTable("site", {
10367
10516
  id: text("id").primaryKey(),
10368
10517
  key: text("key").notNull(),
10369
10518
  status: text("status", { enum: SITE_STATUSES$1 }).notNull().default("active"),
10519
+ provisioningIdempotencyKey: text("provisioning_idempotency_key"),
10370
10520
  createdAt: integer("created_at").notNull(),
10371
10521
  updatedAt: integer("updated_at").notNull()
10372
- }, (table) => [uniqueIndex("uq_site_key").on(table.key), check("chk_site_status", sql`${table.status} IN (${sqlTextEnum$1(SITE_STATUSES$1)})`)]);
10522
+ }, (table) => [
10523
+ uniqueIndex("uq_site_key").on(table.key),
10524
+ uniqueIndex("uq_site_provisioning_idempotency_key").on(table.provisioningIdempotencyKey).where(sql`${table.provisioningIdempotencyKey} IS NOT NULL`),
10525
+ check("chk_site_status", sql`${table.status} IN (${sqlTextEnum$1(SITE_STATUSES$1)})`)
10526
+ ]);
10373
10527
  var siteDomains$1 = sqliteTable("site_domain", {
10374
10528
  id: text("id").primaryKey(),
10375
10529
  siteId: text("site_id").notNull().references(() => sites$1.id, { onDelete: "cascade" }),
@@ -10752,6 +10906,19 @@ var githubAppInstallation$1 = sqliteTable("github_app_installation", {
10752
10906
  index("github_app_installation_by_site").on(table.siteId),
10753
10907
  check("chk_github_app_installation_account_type", sql`${table.accountType} IN (${sqlTextEnum$1(GITHUB_APP_ACCOUNT_TYPES$1)})`)
10754
10908
  ]);
10909
+ /**
10910
+ * Per-key sliding-window rate-limit counters. Used by the Cloudflare Workers
10911
+ * runtime; Node deployments keep the state in memory instead.
10912
+ *
10913
+ * `key` is typically a scope prefix plus client IP (e.g. "search:1.2.3.4").
10914
+ * `window_start` is the aligned start of the window in Unix seconds — we
10915
+ * store two adjacent windows per key to support the sliding-window-counter
10916
+ * algorithm.
10917
+ */ var rateLimit$2 = sqliteTable("rate_limit", {
10918
+ key: text("key").notNull(),
10919
+ windowStart: integer("window_start").notNull(),
10920
+ count: integer("count").notNull().default(0)
10921
+ }, (table) => [primaryKey({ columns: [table.key, table.windowStart] })]);
10755
10922
  //#endregion
10756
10923
  //#region src/db/index.ts
10757
10924
  /**
@@ -10856,6 +11023,7 @@ function isNodeSqliteDatabase(db) {
10856
11023
  pathRegistry: () => pathRegistry,
10857
11024
  postCollections: () => postCollections,
10858
11025
  posts: () => posts,
11026
+ rateLimit: () => rateLimit$1,
10859
11027
  session: () => session,
10860
11028
  settings: () => settings,
10861
11029
  siteDomains: () => siteDomains,
@@ -10921,9 +11089,14 @@ var sites = pgTable("site", {
10921
11089
  id: text$1("id").primaryKey(),
10922
11090
  key: text$1("key").notNull(),
10923
11091
  status: text$1("status", { enum: SITE_STATUSES }).notNull().default("active"),
11092
+ provisioningIdempotencyKey: text$1("provisioning_idempotency_key"),
10924
11093
  createdAt: integer$1("created_at").notNull(),
10925
11094
  updatedAt: integer$1("updated_at").notNull()
10926
- }, (table) => [uniqueIndex$1("uq_site_key").on(table.key), check$1("chk_site_status", sql`${table.status} IN (${sqlTextEnum(SITE_STATUSES)})`)]);
11095
+ }, (table) => [
11096
+ uniqueIndex$1("uq_site_key").on(table.key),
11097
+ uniqueIndex$1("uq_site_provisioning_idempotency_key").on(table.provisioningIdempotencyKey).where(sql`${table.provisioningIdempotencyKey} IS NOT NULL`),
11098
+ check$1("chk_site_status", sql`${table.status} IN (${sqlTextEnum(SITE_STATUSES)})`)
11099
+ ]);
10927
11100
  var siteDomains = pgTable("site_domain", {
10928
11101
  id: text$1("id").primaryKey(),
10929
11102
  siteId: text$1("site_id").notNull().references(() => sites.id, { onDelete: "cascade" }),
@@ -11344,6 +11517,14 @@ var githubAppInstallation = pgTable("github_app_installation", {
11344
11517
  index$1("github_app_installation_by_site").on(table.siteId),
11345
11518
  check$1("chk_github_app_installation_account_type", sql`${table.accountType} IN (${sqlTextEnum(GITHUB_APP_ACCOUNT_TYPES)})`)
11346
11519
  ]);
11520
+ /**
11521
+ * Per-key sliding-window rate-limit counters. Mirrors the SQLite `rate_limit`
11522
+ * table — kept in lockstep because both dialects are production targets.
11523
+ */ var rateLimit$1 = pgTable("rate_limit", {
11524
+ key: text$1("key").notNull(),
11525
+ windowStart: integer$1("window_start").notNull(),
11526
+ count: integer$1("count").notNull().default(0)
11527
+ }, (table) => [primaryKey$1({ columns: [table.key, table.windowStart] })]);
11347
11528
  //#endregion
11348
11529
  //#region src/db/schema-bundle.ts
11349
11530
  var sqliteSchemaBundle = schema_exports$1;
@@ -11766,36 +11947,6 @@ function createMediaService(db, siteId, databaseSchema = sqliteSchemaBundle, dat
11766
11947
  };
11767
11948
  }
11768
11949
  //#endregion
11769
- //#region src/lib/icons.ts
11770
- /**
11771
- * Shared icon utilities.
11772
- *
11773
- * Provides a small wrapper around lucide-static so server-rendered UI can fetch
11774
- * SVG markup by kebab-case icon name.
11775
- */
11776
- /**
11777
- * Convert a kebab-case icon name to PascalCase for lucide-static lookup.
11778
- *
11779
- * @param name - Kebab-case icon name such as "book-open"
11780
- * @returns PascalCase name such as "BookOpen"
11781
- */ function toPascalCase(name) {
11782
- return name.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
11783
- }
11784
- /**
11785
- * Get SVG markup for a Lucide icon by kebab-case name.
11786
- *
11787
- * @param name - Kebab-case icon name
11788
- * @returns SVG string or null when the icon is unknown
11789
- *
11790
- * @example
11791
- * ```ts
11792
- * getIconSvg("book-open");
11793
- * ```
11794
- */ function getIconSvg(name) {
11795
- const svg = lucideIcons[toPascalCase(name)];
11796
- return typeof svg === "string" ? svg : null;
11797
- }
11798
- //#endregion
11799
11950
  //#region src/ui/pages/ArchivePage.tsx
11800
11951
  /**
11801
11952
  * Archive Page
@@ -12706,33 +12857,33 @@ function getAtomTitle(post) {
12706
12857
  </feed>`;
12707
12858
  }
12708
12859
  /**
12709
- * Default Sitemap renderer.
12860
+ * Render a sitemap `<urlset>` XML document from a list of URL entries.
12710
12861
  *
12711
- * @param data - Sitemap data with PostView[]
12712
- * @returns Sitemap XML string
12713
- */ function defaultSitemapRenderer(data) {
12714
- const { siteUrl, sitemapUrl, posts } = data;
12715
- const postUrls = posts.map((post) => {
12716
- return `
12717
- <url>
12718
- <loc>${escapeXml(new URL(post.permalink, siteUrl).toString())}</loc>
12719
- <lastmod>${post.updatedAt.split("T")[0]}</lastmod>
12720
- <priority>${post.featured ? "0.8" : "0.6"}</priority>
12721
- </url>`;
12722
- }).join("");
12723
- const homepageUrl = `
12724
- <url>
12725
- <loc>${escapeXml(siteUrl)}</loc>
12726
- <priority>1.0</priority>
12727
- <changefreq>daily</changefreq>
12728
- </url>`;
12862
+ * Used by the sharded sitemap endpoints in `routes/feed/sitemap.ts`.
12863
+ */ function renderSitemapUrlSet(entries) {
12729
12864
  return `<?xml version="1.0" encoding="UTF-8"?>
12730
12865
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
12731
- <!-- Generated from ${escapeXml(sitemapUrl)} -->
12732
- ${homepageUrl}
12733
- ${postUrls}
12866
+ ${entries.map((entry) => {
12867
+ const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
12868
+ if (entry.lastmod) parts.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
12869
+ if (entry.changefreq) parts.push(` <changefreq>${escapeXml(entry.changefreq)}</changefreq>`);
12870
+ if (entry.priority) parts.push(` <priority>${escapeXml(entry.priority)}</priority>`);
12871
+ return ` <url>\n${parts.join("\n")}\n </url>`;
12872
+ }).join("\n")}
12734
12873
  </urlset>`;
12735
12874
  }
12875
+ /**
12876
+ * Render a `<sitemapindex>` XML document listing shard sitemap URLs.
12877
+ */ function renderSitemapIndex(entries) {
12878
+ return `<?xml version="1.0" encoding="UTF-8"?>
12879
+ <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
12880
+ ${entries.map((entry) => {
12881
+ const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
12882
+ if (entry.lastmod) parts.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
12883
+ return ` <sitemap>\n${parts.join("\n")}\n </sitemap>`;
12884
+ }).join("\n")}
12885
+ </sitemapindex>`;
12886
+ }
12736
12887
  //#endregion
12737
12888
  //#region src/routes/pages/archive.tsx
12738
12889
  /**
@@ -13760,12 +13911,29 @@ collectionRoutes.get("/:slug/feed", async (c) => {
13760
13911
  * Resolves post slugs, aliases, redirects, and collection aliases.
13761
13912
  * Must be registered last.
13762
13913
  */ var pageRoutes = new Hono();
13914
+ /**
13915
+ * Build the canonical absolute URL for a post page.
13916
+ *
13917
+ * Reply URLs render the full thread, so search engines see overlapping
13918
+ * content at every reply URL. Point the canonical to the thread root so
13919
+ * crawlers consolidate ranking on one URL.
13920
+ *
13921
+ * The root post is always at index 0 of `threadPostViews` (getThread orders
13922
+ * by createdAt ASC, and the DB check constraint guarantees root has the
13923
+ * smallest createdAt in its thread). When `threadPostViews` is undefined the
13924
+ * post is not part of a multi-post thread, so the post itself is the root.
13925
+ */ function buildPostCanonicalHref(postView, threadPostViews, siteUrl) {
13926
+ const rootPermalink = threadPostViews?.[0]?.permalink ?? postView.permalink;
13927
+ if (!siteUrl) return rootPermalink;
13928
+ return new URL(rootPermalink, siteUrl).toString();
13929
+ }
13763
13930
  async function renderPostWithTextPreview(c, post, autoOpen) {
13764
13931
  const navDataPromise = getNavigationData(c);
13765
13932
  const display = await assemblePostPageDisplay(c, post, { isAuthenticated: true });
13766
13933
  if (!display) return c.notFound();
13767
13934
  const navData = await navDataPromise;
13768
13935
  const meta = buildPostMeta(post, navData.siteName);
13936
+ const canonicalHref = buildPostCanonicalHref(display.postView, display.threadPostViews, c.var.appConfig.siteUrl);
13769
13937
  const pageTitle = autoOpen.attachmentTitle || meta.title;
13770
13938
  const autoOpenMeta = JSON.stringify({
13771
13939
  shareHref: autoOpen.shareHref,
@@ -13776,6 +13944,7 @@ async function renderPostWithTextPreview(c, post, autoOpen) {
13776
13944
  return renderPublicPage(c, {
13777
13945
  title: pageTitle,
13778
13946
  description: meta.description,
13947
+ canonicalHref,
13779
13948
  navData,
13780
13949
  content: /* @__PURE__ */ jsxDEV$1(Fragment$1, { children: [
13781
13950
  /* @__PURE__ */ jsxDEV$1(PostPage, {
@@ -13824,9 +13993,11 @@ async function renderPost(c, post) {
13824
13993
  if (!display) return c.notFound();
13825
13994
  const navData = await navDataPromise;
13826
13995
  const meta = buildPostMeta(post, navData.siteName);
13996
+ const canonicalHref = buildPostCanonicalHref(display.postView, display.threadPostViews, c.var.appConfig.siteUrl);
13827
13997
  return renderPublicPage(c, {
13828
13998
  title: meta.title,
13829
13999
  description: meta.description,
14000
+ canonicalHref,
13830
14001
  navData,
13831
14002
  content: /* @__PURE__ */ jsxDEV$1(PostPage, {
13832
14003
  post: display.postView,
@@ -14845,18 +15016,11 @@ newPostRoutes.get("/new", async (c) => {
14845
15016
  //#endregion
14846
15017
  //#region src/routes/pages/partials.tsx
14847
15018
  var partialPageRoutes = new Hono();
14848
- async function getIsAuthenticated(c) {
14849
- try {
14850
- return !!(await c.var.auth.api.getSession({ headers: c.req.raw.headers }))?.user;
14851
- } catch {
14852
- return false;
14853
- }
14854
- }
14855
15019
  partialPageRoutes.get("/_/version", (c) => {
14856
15020
  return c.json({ version: CORE_VERSION });
14857
15021
  });
14858
15022
  partialPageRoutes.get("/_/timeline-item/:threadRootId", async (c) => {
14859
- const item = await assembleTimelineItem(c, parseIdParam(c.req.param("threadRootId"), ID_PREFIX.post), { isAuthenticated: await getIsAuthenticated(c) });
15023
+ const item = await assembleTimelineItem(c, parseIdParam(c.req.param("threadRootId"), ID_PREFIX.post), { isAuthenticated: c.var.isAuthenticated });
14860
15024
  if (!item) return c.notFound();
14861
15025
  return c.html(/* @__PURE__ */ jsxDEV$1(I18nProvider, {
14862
15026
  c,
@@ -14864,7 +15028,7 @@ partialPageRoutes.get("/_/timeline-item/:threadRootId", async (c) => {
14864
15028
  }));
14865
15029
  });
14866
15030
  partialPageRoutes.get("/_/post-card/:postId", async (c) => {
14867
- const postView = await assemblePostCardView(c, parseIdParam(c.req.param("postId"), ID_PREFIX.post), { isAuthenticated: await getIsAuthenticated(c) });
15031
+ const postView = await assemblePostCardView(c, parseIdParam(c.req.param("postId"), ID_PREFIX.post), { isAuthenticated: c.var.isAuthenticated });
14868
15032
  if (!postView) return c.notFound();
14869
15033
  return c.html(/* @__PURE__ */ jsxDEV$1(I18nProvider, {
14870
15034
  c,
@@ -14872,7 +15036,7 @@ partialPageRoutes.get("/_/post-card/:postId", async (c) => {
14872
15036
  }));
14873
15037
  });
14874
15038
  partialPageRoutes.get("/_/post-view/:postId", async (c) => {
14875
- const display = await assemblePostPageDisplay(c, parseIdParam(c.req.param("postId"), ID_PREFIX.post), { isAuthenticated: await getIsAuthenticated(c) });
15039
+ const display = await assemblePostPageDisplay(c, parseIdParam(c.req.param("postId"), ID_PREFIX.post), { isAuthenticated: c.var.isAuthenticated });
14876
15040
  if (!display) return c.notFound();
14877
15041
  return c.html(/* @__PURE__ */ jsxDEV$1(I18nProvider, {
14878
15042
  c,
@@ -19829,6 +19993,10 @@ function parseConfigInt(value, fallback) {
19829
19993
  showHeaderAvatar: allSettings["SHOW_HEADER_AVATAR"] === "true",
19830
19994
  siteAvatarUrl,
19831
19995
  faviconVersion: allSettings["SITE_FAVICON_VERSION"] ?? "",
19996
+ rateLimit: {
19997
+ disabled: getEnvString(env, "RATE_LIMIT_DISABLED") === "true",
19998
+ searchPerMinute: parseInt(getEnvString(env, "RATE_LIMIT_SEARCH_PER_MIN") ?? "30", 10) || 30
19999
+ },
19832
20000
  fallbacks: {
19833
20001
  siteName: resolveFallback("SITE_NAME", env),
19834
20002
  siteDescription: resolveFallback("SITE_DESCRIPTION", env),
@@ -20019,8 +20187,8 @@ async function syncHostedControlPlaneSiteAvatar(input) {
20019
20187
  return;
20020
20188
  }
20021
20189
  await markSyncPending(settings);
20022
- const { createGitHubSyncService } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
20023
- const { getGitHubAppConfig } = await import("./env-wCpMcNXs.js").then((n) => n.t);
20190
+ const { createGitHubSyncService } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
20191
+ const { getGitHubAppConfig } = await import("./env-CgaH9Mut.js").then((n) => n.t);
20024
20192
  const run = runBackgroundSync(settings, createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
20025
20193
  storage: c.var.storage,
20026
20194
  githubApp: getGitHubAppConfig(c.env)
@@ -20634,7 +20802,7 @@ settingsRoutes.get("/account", async (c) => {
20634
20802
  settingsRoutes.get("/account/sessions", async (c) => {
20635
20803
  if (c.var.appConfig.demoMode) return c.redirect(publicPath(c, "/settings/account"));
20636
20804
  const navData = await getNavigationData(c);
20637
- const currentToken = (await c.var.auth.api.getSession({ headers: c.req.raw.headers }))?.session?.token ?? "";
20805
+ const currentToken = c.var.session?.session?.token ?? "";
20638
20806
  const sessions = (await c.var.auth.api.listSessions({ headers: c.req.raw.headers }) ?? []).map((s) => ({
20639
20807
  token: s.token,
20640
20808
  ipAddress: s.ipAddress ?? null,
@@ -20787,7 +20955,7 @@ settingsRoutes.post("/github-sync/connect", async (c) => {
20787
20955
  if (getGitHubAppConfig(c.env)) return dsToast("This deployment uses GitHub App authentication. Use Install GitHub App instead.", "error");
20788
20956
  const body = await c.req.json();
20789
20957
  if (!body.token?.trim() || !body.repo?.trim()) return dsToast("Token and repository are required.", "error");
20790
- const { parseRepoSlug, createGitHubClient } = await import("./github-api-CficQztC.js").then((n) => n.n);
20958
+ const { parseRepoSlug, createGitHubClient } = await import("./github-api-BkRWnqMx.js").then((n) => n.n);
20791
20959
  const parsed = parseRepoSlug(body.repo);
20792
20960
  if (!parsed) return dsToast("Invalid repository format. Use owner/repo.", "error");
20793
20961
  const client = createGitHubClient(body.token);
@@ -20806,7 +20974,7 @@ settingsRoutes.post("/github-sync/connect", async (c) => {
20806
20974
  await c.var.services.settings.set("GITHUB_SYNC_AUTH_MODE", "pat");
20807
20975
  await c.var.services.settings.set("GITHUB_SYNC_APP_INSTALLATION_ID", "");
20808
20976
  await c.var.services.settings.set("GITHUB_SYNC_ENABLED", "true");
20809
- const { createGitHubSyncService } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
20977
+ const { createGitHubSyncService } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
20810
20978
  const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
20811
20979
  storage: c.var.storage,
20812
20980
  githubApp: getGitHubAppConfig(c.env)
@@ -20825,7 +20993,7 @@ settingsRoutes.post("/github-sync/connect", async (c) => {
20825
20993
  return dsRedirect(publicPath(c, "/settings/github-sync"));
20826
20994
  });
20827
20995
  settingsRoutes.post("/github-sync/push", async (c) => {
20828
- const { createGitHubSyncService } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
20996
+ const { createGitHubSyncService } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
20829
20997
  const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
20830
20998
  storage: c.var.storage,
20831
20999
  githubApp: getGitHubAppConfig(c.env)
@@ -20845,7 +21013,7 @@ settingsRoutes.post("/github-sync/push", async (c) => {
20845
21013
  });
20846
21014
  });
20847
21015
  settingsRoutes.post("/github-sync/disconnect", async (c) => {
20848
- const { createGitHubSyncService } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
21016
+ const { createGitHubSyncService } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
20849
21017
  await createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), { githubApp: getGitHubAppConfig(c.env) }).teardownWebhook();
20850
21018
  return dsRedirect(publicPath(c, "/settings/github-sync"));
20851
21019
  });
@@ -21033,10 +21201,10 @@ function buildRepoPickerLabels(c) {
21033
21201
  const repo = String(body.repo ?? "").trim();
21034
21202
  const confirmForeign = body.confirmForeign === true || body.confirmForeign === "true";
21035
21203
  if (!installationId || !repo) return wantsJson ? c.json({ error: "Missing installationId or repo." }, 400) : c.text("Missing installationId or repo.", 400);
21036
- const { parseRepoSlug, createGitHubClient } = await import("./github-api-CficQztC.js").then((n) => n.n);
21204
+ const { parseRepoSlug, createGitHubClient } = await import("./github-api-BkRWnqMx.js").then((n) => n.n);
21037
21205
  const parsed = parseRepoSlug(repo);
21038
21206
  if (!parsed) return wantsJson ? c.json({ error: "Invalid repository format." }, 400) : c.text("Invalid repository format.", 400);
21039
- const { classifyRepoForSync } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
21207
+ const { classifyRepoForSync } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
21040
21208
  const ghClient = createGitHubClient(() => getInstallationTokenFromApp(app, installationId));
21041
21209
  let classification;
21042
21210
  try {
@@ -21066,7 +21234,7 @@ function buildRepoPickerLabels(c) {
21066
21234
  await c.var.services.settings.set("GITHUB_SYNC_REPO", repo);
21067
21235
  await c.var.services.settings.set("GITHUB_SYNC_TOKEN", "");
21068
21236
  await c.var.services.settings.set("GITHUB_SYNC_ENABLED", "true");
21069
- const { createGitHubSyncService } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
21237
+ const { createGitHubSyncService } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
21070
21238
  const syncService = createGitHubSyncService(c.var.services, c.var.currentSite.id, await buildSyncSiteConfig(c), {
21071
21239
  storage: c.var.storage,
21072
21240
  githubApp: app
@@ -21105,7 +21273,7 @@ function requireGitHubApp(c) {
21105
21273
  * build a client for classification without adding the helper to the
21106
21274
  * top-level imports (the module already lazy-imports github-api).
21107
21275
  */ async function getInstallationTokenFromApp(app, installationId) {
21108
- const { getInstallationToken } = await import("./github-app-F4qZ05xk.js").then((n) => n.i);
21276
+ const { getInstallationToken } = await import("./github-app-WeadXMb8.js").then((n) => n.i);
21109
21277
  return getInstallationToken(app, installationId);
21110
21278
  }
21111
21279
  /** List GitHub App installations authorized for this site. */ settingsRoutes.get("/github-sync/app/installations", async (c) => {
@@ -21179,10 +21347,10 @@ function requireGitHubApp(c) {
21179
21347
  const installationId = String(body.installationId ?? "").trim();
21180
21348
  const repo = String(body.repo ?? "").trim();
21181
21349
  if (!installationId || !repo) return c.json({ error: "Missing installationId or repo." }, 400);
21182
- const { parseRepoSlug, createGitHubClient } = await import("./github-api-CficQztC.js").then((n) => n.n);
21350
+ const { parseRepoSlug, createGitHubClient } = await import("./github-api-BkRWnqMx.js").then((n) => n.n);
21183
21351
  const parsed = parseRepoSlug(repo);
21184
21352
  if (!parsed) return c.json({ error: "Invalid repository format." }, 400);
21185
- const { classifyRepoForSync } = await import("./github-sync-zohnA9qv.js").then((n) => n.r);
21353
+ const { classifyRepoForSync } = await import("./github-sync-7y_nTXx1.js").then((n) => n.r);
21186
21354
  const client = createGitHubClient(() => getInstallationTokenFromApp(app, installationId));
21187
21355
  try {
21188
21356
  const classification = await classifyRepoForSync(client, parsed.owner, parsed.repo, c.var.currentSite.id);
@@ -23389,10 +23557,78 @@ function toSearchApiResult(post, snippet, sitePathPrefix) {
23389
23557
  };
23390
23558
  }
23391
23559
  //#endregion
23560
+ //#region src/lib/rate-limit.ts
23561
+ /**
23562
+ * Rate Limiting Abstraction
23563
+ *
23564
+ * Shared interface for per-key rate limiting. Runtimes provide their own
23565
+ * implementation: Cloudflare Workers uses a D1-backed sliding-window table
23566
+ * (ephemeral isolates can't hold memory state), while Node uses an
23567
+ * in-process Map (the process is persistent and avoids DB round-trips).
23568
+ *
23569
+ * Consumers depend only on this interface; they are runtime-agnostic.
23570
+ */ /**
23571
+ * Extracts the client IP from a Hono request context.
23572
+ *
23573
+ * On Cloudflare Workers, `cf-connecting-ip` is set by the edge and is
23574
+ * authoritative. On Node deployments we fall back to the leftmost
23575
+ * `x-forwarded-for` entry, which is the conventional client IP when the
23576
+ * app sits behind a single trusted proxy. When neither header is
23577
+ * available we return `"unknown"` so all such requests share a bucket —
23578
+ * preferable to skipping the rate limit entirely.
23579
+ *
23580
+ * Note: this helper does not verify proxy trust. It is used for DoS
23581
+ * protection, not authentication. If header-forgery resistance becomes
23582
+ * important, gate the `x-forwarded-for` branch on `shouldTrustProxy`.
23583
+ */ function getClientIp(c) {
23584
+ const cf = c.req.header("cf-connecting-ip");
23585
+ if (cf) return cf;
23586
+ const fwd = c.req.header("x-forwarded-for");
23587
+ if (fwd) {
23588
+ const first = fwd.split(",")[0]?.trim();
23589
+ if (first) return first;
23590
+ }
23591
+ return "unknown";
23592
+ }
23593
+ //#endregion
23594
+ //#region src/middleware/rate-limit.ts
23595
+ /**
23596
+ * Rate-Limit Middleware
23597
+ *
23598
+ * Applies a per-IP rate limit to the routes it wraps. Which storage
23599
+ * backs the limiter (D1 or in-memory) is decided upstream by the
23600
+ * runtime; this middleware only reads `c.var.rateLimiter` and doesn't
23601
+ * care.
23602
+ *
23603
+ * When the limit is exceeded, responds with HTTP 429 and a
23604
+ * `Retry-After` header. When `appConfig.rateLimit.disabled` is true the
23605
+ * middleware short-circuits to `next()` so test and dev environments
23606
+ * don't have to reason about bucket state.
23607
+ */ function rateLimit(opts) {
23608
+ return async (c, next) => {
23609
+ if (c.var.appConfig.rateLimit.disabled) return next();
23610
+ const ip = getClientIp(c);
23611
+ const result = await c.var.rateLimiter.check(`${opts.name}:${ip}`, {
23612
+ limit: opts.limit,
23613
+ windowSec: opts.windowSec
23614
+ });
23615
+ if (!result.ok) {
23616
+ c.header("Retry-After", String(result.retryAfterSec ?? opts.windowSec));
23617
+ return c.json({ error: "Too many requests. Please slow down." }, 429);
23618
+ }
23619
+ return next();
23620
+ };
23621
+ }
23622
+ //#endregion
23392
23623
  //#region src/routes/api/search.ts
23393
23624
  /**
23394
23625
  * Search API Routes
23395
23626
  */ var searchApiRoutes = new Hono();
23627
+ searchApiRoutes.use("*", async (c, next) => rateLimit({
23628
+ name: "search",
23629
+ limit: c.var.appConfig.rateLimit.searchPerMinute,
23630
+ windowSec: 60
23631
+ })(c, next));
23396
23632
  searchApiRoutes.get("/", async (c) => {
23397
23633
  const query = c.req.query("q");
23398
23634
  if (!query || query.trim().length === 0) throw new ValidationError("Query parameter 'q' is required");
@@ -24472,7 +24708,8 @@ var CreateManagedSiteSchema = z.object({
24472
24708
  primaryHost: z.string().trim().toLowerCase().min(1).max(255).regex(/^(?=.{1,255}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/, "Primary host must be a valid hostname."),
24473
24709
  siteName: z.string().trim().min(1).max(120),
24474
24710
  siteLanguage: z.string().trim().max(35).optional(),
24475
- timeZone: z.string().trim().max(100).optional()
24711
+ timeZone: z.string().trim().max(100).optional(),
24712
+ idempotencyKey: z.string().trim().min(1).max(128).optional()
24476
24713
  });
24477
24714
  var ManagedSiteDomainSchema = z.object({
24478
24715
  host: z.string().trim().toLowerCase().min(1).max(255).regex(/^(?=.{1,255}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/, "Domain host must be a valid hostname."),
@@ -24857,6 +25094,30 @@ var internalTextAttachmentsRoutes = new Hono();
24857
25094
  return c.json(result);
24858
25095
  });
24859
25096
  //#endregion
25097
+ //#region src/routes/api/internal/search-reindex.ts
25098
+ var ReindexSchema = z.object({
25099
+ limit: z.number().int().positive().max(500).optional(),
25100
+ cursor: z.string().min(1).optional()
25101
+ });
25102
+ var internalSearchReindexRoutes = new Hono();
25103
+ /**
25104
+ * Rebuild `post.body_text` for a batch of non-deleted posts. FTS indexes
25105
+ * (SQLite trigger / Postgres generated column) refresh automatically when
25106
+ * `body_text` changes.
25107
+ *
25108
+ * Idempotent. Callers loop with the returned `nextCursor` until `done: true`.
25109
+ * Used by the `jant search-reindex` CLI to backfill search indexes for
25110
+ * existing posts after changes to the text extraction logic (e.g. including
25111
+ * link mark hrefs so inline URLs become searchable).
25112
+ */ internalSearchReindexRoutes.post("/", requireInternalAdminApi(), async (c) => {
25113
+ const body = parseValidated(ReindexSchema, (c.req.header("Content-Type") || "").includes("application/json") ? await c.req.json().catch(() => ({})) : {});
25114
+ const result = await c.var.services.posts.reindexBodyText({
25115
+ limit: body.limit,
25116
+ cursor: body.cursor
25117
+ });
25118
+ return c.json(result);
25119
+ });
25120
+ //#endregion
24860
25121
  //#region src/routes/api/internal/uploads.ts
24861
25122
  var CleanupUploadsSchema = z.object({ limit: z.number().int().positive().max(500).optional() });
24862
25123
  var internalUploadsRoutes = new Hono();
@@ -25354,30 +25615,111 @@ feedRoutes.get("/all/atom.xml", (c) => {
25354
25615
  //#region src/routes/feed/sitemap.ts
25355
25616
  /**
25356
25617
  * Sitemap Routes
25618
+ *
25619
+ * Sitemap is sharded to keep each shard small, cache-friendly, and stable:
25620
+ *
25621
+ * /sitemap.xml → sitemap index listing all shards
25622
+ * /sitemap-posts-N.xml → one shard of published non-reply posts
25623
+ * /sitemap-collections.xml → public collection pages
25624
+ * /sitemap-pages.xml → homepage + static aggregate pages
25625
+ *
25626
+ * Post shards are keyset-paginated by post `id` (TypeIDs embed a
25627
+ * creation-ordered UUIDv7 timestamp), so once a shard fills up its membership
25628
+ * never changes: new posts always land in the last shard, never rewriting an
25629
+ * older one. This lets old shards be cached at the edge for a long time.
25357
25630
  */ var sitemapRoutes = new Hono();
25358
- sitemapRoutes.get("/sitemap.xml", async (c) => {
25359
- const { appConfig } = c.var;
25360
- const siteUrl = appConfig.siteUrl;
25361
- const posts = await c.var.services.posts.list({
25362
- status: "published",
25363
- excludeReplies: true,
25364
- excludePrivate: true,
25365
- limit: 1e3
25366
- });
25367
- const mediaCtx = createMediaContext(appConfig);
25368
- const aliasesMap = await c.var.services.paths.getPostAliases(posts.map((p) => p.id));
25369
- const aliasMap = /* @__PURE__ */ new Map();
25370
- for (const [id, aliases] of aliasesMap) if (aliases[0]) aliasMap.set(id, aliases[0]);
25371
- const postViews = toPostViewsFromPosts(posts, mediaCtx, void 0, aliasMap);
25372
- const xml = defaultSitemapRenderer({
25373
- siteUrl,
25374
- sitemapUrl: toAbsoluteSiteUrl("/sitemap.xml", siteUrl, appConfig.sitePathPrefix),
25375
- posts: postViews
25376
- });
25631
+ var CACHE_SHORT = "public, max-age=180";
25632
+ var CACHE_FULL_SHARD = "public, max-age=86400, s-maxage=86400";
25633
+ function xmlResponse(xml, cacheControl) {
25377
25634
  return new Response(xml, { headers: {
25378
25635
  "Content-Type": "application/xml; charset=utf-8",
25379
- "Cache-Control": "public, max-age=180"
25636
+ "Cache-Control": cacheControl
25380
25637
  } });
25638
+ }
25639
+ /**
25640
+ * Build a public URL entry (absolute URL) from an internal path.
25641
+ */ function absoluteUrl(internalPath, siteUrl, sitePathPrefix) {
25642
+ return toAbsoluteSiteUrl(internalPath, siteUrl, sitePathPrefix);
25643
+ }
25644
+ /** Convert a unix-seconds timestamp into a `YYYY-MM-DD` string. */ function toIsoDate(unixSeconds) {
25645
+ return (/* @__PURE__ */ new Date(unixSeconds * 1e3)).toISOString().slice(0, 10);
25646
+ }
25647
+ sitemapRoutes.get("/sitemap.xml", async (c) => {
25648
+ const { appConfig } = c.var;
25649
+ const { siteUrl, sitePathPrefix } = appConfig;
25650
+ const postCount = await c.var.services.posts.countForSitemap();
25651
+ const postShardCount = Math.max(1, Math.ceil(postCount / 500));
25652
+ const entries = [{ loc: absoluteUrl("/sitemap-pages.xml", siteUrl, sitePathPrefix) }];
25653
+ for (let page = 1; page <= postShardCount; page++) entries.push({ loc: absoluteUrl(`/sitemap-posts-${page}.xml`, siteUrl, sitePathPrefix) });
25654
+ if ((await c.var.services.collections.list()).length > 0) entries.push({ loc: absoluteUrl("/sitemap-collections.xml", siteUrl, sitePathPrefix) });
25655
+ return xmlResponse(renderSitemapIndex(entries), CACHE_SHORT);
25656
+ });
25657
+ sitemapRoutes.get("/:file{sitemap-posts-[0-9]+\\.xml}", async (c) => {
25658
+ const { appConfig } = c.var;
25659
+ const { siteUrl, sitePathPrefix } = appConfig;
25660
+ const file = c.req.param("file");
25661
+ const match = /^sitemap-posts-([0-9]+)\.xml$/.exec(file);
25662
+ if (!match) return c.notFound();
25663
+ const page = Number(match[1]);
25664
+ if (!Number.isFinite(page) || page < 1) return c.notFound();
25665
+ let afterId;
25666
+ if (page > 1) {
25667
+ const cursorOffset = (page - 1) * 500 - 1;
25668
+ const cursor = await c.var.services.posts.getSitemapIdAt(cursorOffset);
25669
+ if (cursor === null) return c.notFound();
25670
+ afterId = cursor;
25671
+ }
25672
+ const shardEntries = await c.var.services.posts.listForSitemap({
25673
+ afterId,
25674
+ limit: 500
25675
+ });
25676
+ const urls = shardEntries.map((entry) => {
25677
+ return {
25678
+ loc: absoluteUrl(entry.alias ?? `/${entry.slug}`, siteUrl, sitePathPrefix),
25679
+ lastmod: toIsoDate(entry.updatedAt),
25680
+ priority: entry.featuredAt ? "0.8" : "0.6"
25681
+ };
25682
+ });
25683
+ const cacheControl = shardEntries.length === 500 ? CACHE_FULL_SHARD : CACHE_SHORT;
25684
+ return xmlResponse(renderSitemapUrlSet(urls), cacheControl);
25685
+ });
25686
+ sitemapRoutes.get("/sitemap-collections.xml", async (c) => {
25687
+ const { appConfig } = c.var;
25688
+ const { siteUrl, sitePathPrefix } = appConfig;
25689
+ const collections = await c.var.services.collections.list();
25690
+ return xmlResponse(renderSitemapUrlSet(await Promise.all(collections.map(async (collection) => {
25691
+ const alias = await c.var.services.customUrls.getByTarget("collection", collection.id);
25692
+ return {
25693
+ loc: absoluteUrl(alias ? `/${alias.path}` : `/${collection.slug}`, siteUrl, sitePathPrefix),
25694
+ lastmod: toIsoDate(collection.updatedAt),
25695
+ priority: "0.7"
25696
+ };
25697
+ }))), CACHE_SHORT);
25698
+ });
25699
+ sitemapRoutes.get("/sitemap-pages.xml", async (c) => {
25700
+ const { appConfig } = c.var;
25701
+ const { siteUrl, sitePathPrefix, homeDefaultView } = appConfig;
25702
+ const urls = [{
25703
+ loc: absoluteUrl("/", siteUrl, sitePathPrefix),
25704
+ priority: "1.0",
25705
+ changefreq: "daily"
25706
+ }, {
25707
+ loc: absoluteUrl("/archive", siteUrl, sitePathPrefix),
25708
+ priority: "0.5",
25709
+ changefreq: "weekly"
25710
+ }];
25711
+ const secondaryAggregate = homeDefaultView === "featured" ? "/latest" : "/featured";
25712
+ urls.push({
25713
+ loc: absoluteUrl(secondaryAggregate, siteUrl, sitePathPrefix),
25714
+ priority: "0.6",
25715
+ changefreq: "daily"
25716
+ });
25717
+ if ((await c.var.services.collections.list()).length > 0) urls.push({
25718
+ loc: absoluteUrl("/collections", siteUrl, sitePathPrefix),
25719
+ priority: "0.5",
25720
+ changefreq: "weekly"
25721
+ });
25722
+ return xmlResponse(renderSitemapUrlSet(urls), CACHE_SHORT);
25381
25723
  });
25382
25724
  sitemapRoutes.get("/robots.txt", async (c) => {
25383
25725
  const { appConfig } = c.var;
@@ -25395,7 +25737,7 @@ sitemapRoutes.get("/robots.txt", async (c) => {
25395
25737
  ].join("\n");
25396
25738
  return new Response(robots, { headers: {
25397
25739
  "Content-Type": "text/plain; charset=utf-8",
25398
- "Cache-Control": "public, max-age=180"
25740
+ "Cache-Control": CACHE_SHORT
25399
25741
  } });
25400
25742
  });
25401
25743
  //#endregion
@@ -25438,6 +25780,34 @@ manifestRoutes.get("/manifest.webmanifest", (c) => {
25438
25780
  } });
25439
25781
  });
25440
25782
  //#endregion
25783
+ //#region src/middleware/session.ts
25784
+ /**
25785
+ * Session Middleware
25786
+ *
25787
+ * Runs once per request (after runtime init) to look up the better-auth
25788
+ * session and stash it on `c.var.session` / `c.var.isAuthenticated`.
25789
+ *
25790
+ * This replaces ad-hoc `auth.api.getSession()` calls scattered across
25791
+ * view helpers (e.g. `lib/navigation.ts`) so each request only parses
25792
+ * the session cookie once. better-auth's own cookieCache (5 min) still
25793
+ * keeps this cheap, but centralizing the call also unlocks `Promise.all`
25794
+ * patterns in routes that previously serialized on a hidden session fetch.
25795
+ *
25796
+ * Never throws — any lookup error is treated as "not authenticated".
25797
+ */ function attachSession() {
25798
+ return async (c, next) => {
25799
+ try {
25800
+ const session = await c.var.auth.api.getSession({ headers: c.req.raw.headers });
25801
+ c.set("session", session ?? null);
25802
+ c.set("isAuthenticated", !!session?.user);
25803
+ } catch {
25804
+ c.set("session", null);
25805
+ c.set("isAuthenticated", false);
25806
+ }
25807
+ await next();
25808
+ };
25809
+ }
25810
+ //#endregion
25441
25811
  //#region src/middleware/onboarding.ts
25442
25812
  /**
25443
25813
  * Onboarding Middleware
@@ -26361,6 +26731,60 @@ async function deriveScryptKey(password, saltHex, opts) {
26361
26731
  });
26362
26732
  }
26363
26733
  //#endregion
26734
+ //#region src/lib/rate-limit-d1.ts
26735
+ /**
26736
+ * D1 Sliding-Window Rate Limiter
26737
+ *
26738
+ * Used by the Cloudflare Workers runtime where isolates are ephemeral and
26739
+ * memory-based limiters would silently drop state between requests. Each
26740
+ * check performs one SELECT (over a two-row range) plus one UPSERT — both
26741
+ * hit a composite primary key so the round-trips are cheap.
26742
+ *
26743
+ * Algorithm: two-window weighted counter. The previous window's tally
26744
+ * decays linearly as the current window fills, giving a smoother limit
26745
+ * than a naive fixed window (which would allow a 2x burst at the
26746
+ * boundary) while remaining a single-key storage primitive.
26747
+ *
26748
+ * For Node deployments use `createMemoryRateLimiter` instead.
26749
+ */
26750
+ /**
26751
+ * Probability of running opportunistic cleanup on any given write.
26752
+ * 1% strikes a balance between bounded table growth and per-request cost.
26753
+ */ var CLEANUP_PROBABILITY = .01;
26754
+ function createD1RateLimiter(db, schema, now = () => Math.floor(Date.now() / 1e3)) {
26755
+ const { rateLimit } = schema;
26756
+ return { async check(key, opts) {
26757
+ const { limit, windowSec } = opts;
26758
+ const nowSec = now();
26759
+ const currentWindow = Math.floor(nowSec / windowSec) * windowSec;
26760
+ const previousWindow = currentWindow - windowSec;
26761
+ const rows = await db.select({
26762
+ windowStart: rateLimit.windowStart,
26763
+ count: rateLimit.count
26764
+ }).from(rateLimit).where(and(eq(rateLimit.key, key), inArray(rateLimit.windowStart, [currentWindow, previousWindow])));
26765
+ let currentCount = 0;
26766
+ let previousCount = 0;
26767
+ for (const row of rows) if (row.windowStart === currentWindow) currentCount = row.count;
26768
+ else if (row.windowStart === previousWindow) previousCount = row.count;
26769
+ const elapsed = nowSec - currentWindow;
26770
+ const prevWeight = 1 - elapsed / windowSec;
26771
+ if (previousCount * prevWeight + currentCount >= limit) return {
26772
+ ok: false,
26773
+ retryAfterSec: Math.max(1, windowSec - elapsed)
26774
+ };
26775
+ await db.insert(rateLimit).values({
26776
+ key,
26777
+ windowStart: currentWindow,
26778
+ count: 1
26779
+ }).onConflictDoUpdate({
26780
+ target: [rateLimit.key, rateLimit.windowStart],
26781
+ set: { count: sql`${rateLimit.count} + 1` }
26782
+ });
26783
+ if (Math.random() < CLEANUP_PROBABILITY) await db.delete(rateLimit).where(lt(rateLimit.windowStart, currentWindow - windowSec * 2));
26784
+ return { ok: true };
26785
+ } };
26786
+ }
26787
+ //#endregion
26364
26788
  //#region src/lib/hosted-sso.ts
26365
26789
  var textEncoder = new TextEncoder();
26366
26790
  var textDecoder = new TextDecoder();
@@ -27649,6 +28073,51 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
27649
28073
  if (filters.offset !== void 0) query = query.offset(filters.offset);
27650
28074
  return hydratePosts(await query);
27651
28075
  },
28076
+ async listForSitemap({ afterId, limit }) {
28077
+ const conditions = buildFilterConditions({
28078
+ status: "published",
28079
+ excludePrivate: true,
28080
+ excludeReplies: true
28081
+ });
28082
+ if (afterId !== void 0) conditions.push(sql`${posts.id} > ${afterId}`);
28083
+ const rows = await db.select({
28084
+ id: posts.id,
28085
+ updatedAt: posts.updatedAt,
28086
+ featuredAt: posts.featuredAt
28087
+ }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0).orderBy(asc(posts.id)).limit(limit);
28088
+ if (rows.length === 0) return [];
28089
+ const ids = rows.map((row) => row.id);
28090
+ const [slugMap, aliasesMap] = await Promise.all([resolvedPaths.getPostSlugMap(ids), resolvedPaths.getPostAliases(ids)]);
28091
+ return rows.map((row) => {
28092
+ const slug = slugMap.get(row.id);
28093
+ if (!slug) return null;
28094
+ const alias = aliasesMap.get(row.id)?.[0] ?? null;
28095
+ return {
28096
+ id: row.id,
28097
+ slug,
28098
+ alias,
28099
+ updatedAt: row.updatedAt,
28100
+ featuredAt: row.featuredAt
28101
+ };
28102
+ }).filter((entry) => entry !== null);
28103
+ },
28104
+ async countForSitemap() {
28105
+ const conditions = buildFilterConditions({
28106
+ status: "published",
28107
+ excludePrivate: true,
28108
+ excludeReplies: true
28109
+ });
28110
+ return (await db.select({ count: sql`CAST(count(*) AS INTEGER)`.as("count") }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0))[0]?.count ?? 0;
28111
+ },
28112
+ async getSitemapIdAt(offset) {
28113
+ if (offset < 0) return null;
28114
+ const conditions = buildFilterConditions({
28115
+ status: "published",
28116
+ excludePrivate: true,
28117
+ excludeReplies: true
28118
+ });
28119
+ return (await db.select({ id: posts.id }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0).orderBy(asc(posts.id)).limit(1).offset(offset))[0]?.id ?? null;
28120
+ },
27652
28121
  async count(filters = {}) {
27653
28122
  const conditions = buildFilterConditions(filters);
27654
28123
  return (await db.select({ count: sql`CAST(count(*) AS INTEGER)`.as("count") }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0))[0]?.count ?? 0;
@@ -27688,7 +28157,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
27688
28157
  hasAttachments: data.attachments ? data.attachments.length > 0 : void 0
27689
28158
  });
27690
28159
  const bodyHtml = body ? renderTiptapJson(body) : null;
27691
- const bodyText = body ? extractBodyText(body) : null;
28160
+ const bodyText = body ? extractBodyText(body, { includeLinkHrefs: true }) : null;
27692
28161
  let summary = null;
27693
28162
  if (format === "note" && title && body && summaryConfig) summary = extractSummary(body, summaryConfig.maxParagraphs, summaryConfig.maxChars);
27694
28163
  let threadId = id;
@@ -27968,7 +28437,7 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
27968
28437
  const normalizedBody = rawBody ? trimTiptapBody(rawBody) : null;
27969
28438
  updates.body = normalizedBody;
27970
28439
  updates.bodyHtml = normalizedBody ? renderTiptapJson(normalizedBody) : null;
27971
- updates.bodyText = normalizedBody ? extractBodyText(normalizedBody) : null;
28440
+ updates.bodyText = normalizedBody ? extractBodyText(normalizedBody, { includeLinkHrefs: true }) : null;
27972
28441
  }
27973
28442
  if (summaryConfig) {
27974
28443
  const format = nextFormat;
@@ -28406,6 +28875,40 @@ function createPostService(db, config, siteId, paths, databaseSchema = sqliteSch
28406
28875
  const conditions = [...buildFilterConditions(filters), isNotNull(posts.publishedAt)];
28407
28876
  const publishedYearExpr = buildPublishedYearExpr();
28408
28877
  return (await db.select({ year: publishedYearExpr.as("year") }).from(posts).where(conditions.length > 0 ? and(...conditions) : void 0).groupBy(publishedYearExpr).orderBy(desc(publishedYearExpr))).map((r) => parseInt(r.year, 10));
28878
+ },
28879
+ async reindexBodyText(options = {}) {
28880
+ const requested = options.limit ?? 50;
28881
+ const limit = Math.min(Math.max(Math.trunc(requested), 1), 500);
28882
+ const cursor = options.cursor;
28883
+ const whereConditions = [eq(posts.siteId, siteId), isNull(posts.deletedAt)];
28884
+ if (cursor) whereConditions.push(gt(posts.id, cursor));
28885
+ const rows = await db.select({
28886
+ id: posts.id,
28887
+ body: posts.body,
28888
+ bodyText: posts.bodyText
28889
+ }).from(posts).where(and(...whereConditions)).orderBy(asc(posts.id)).limit(limit + 1);
28890
+ const hasMore = rows.length > limit;
28891
+ const batch = hasMore ? rows.slice(0, limit) : rows;
28892
+ let updated = 0;
28893
+ let skipped = 0;
28894
+ for (const row of batch) {
28895
+ const nextBodyText = row.body ? extractBodyText(row.body, { includeLinkHrefs: true }) : null;
28896
+ if (nextBodyText === row.bodyText) {
28897
+ skipped++;
28898
+ continue;
28899
+ }
28900
+ await db.update(posts).set({ bodyText: nextBodyText }).where(and(eq(posts.siteId, siteId), eq(posts.id, row.id)));
28901
+ updated++;
28902
+ }
28903
+ const lastRow = batch.at(-1);
28904
+ const lastId = lastRow ? lastRow.id : null;
28905
+ return {
28906
+ processed: batch.length,
28907
+ updated,
28908
+ skipped,
28909
+ nextCursor: hasMore ? lastId : null,
28910
+ done: !hasMore
28911
+ };
28409
28912
  }
28410
28913
  };
28411
28914
  }
@@ -29933,10 +30436,28 @@ function createSiteAdminService(db, databaseSchema = sqliteSchemaBundle, databas
29933
30436
  const pathPrefix = domain.pathPrefix ?? "";
29934
30437
  return `${protocol}//${domain.host}${pathPrefix}`;
29935
30438
  }
30439
+ async function loadByIdempotencyKey(targetDb, idempotencyKey) {
30440
+ const siteRow = (await targetDb.select().from(sites).where(eq(sites.provisioningIdempotencyKey, idempotencyKey)).limit(1))[0];
30441
+ if (!siteRow) return null;
30442
+ const domainRow = (await targetDb.select().from(siteDomains).where(and(eq(siteDomains.siteId, siteRow.id), eq(siteDomains.kind, "primary"))).limit(1))[0];
30443
+ if (!domainRow) return null;
30444
+ return {
30445
+ site: toSite(siteRow),
30446
+ domain: toSiteDomain(domainRow)
30447
+ };
30448
+ }
29936
30449
  async function createWithDatabase(targetDb, input) {
29937
30450
  const siteKey = input.key.trim();
29938
30451
  const primaryHost = input.primaryHost.trim().toLowerCase();
29939
30452
  const siteName = input.siteName.trim();
30453
+ const idempotencyKey = input.idempotencyKey?.trim() || null;
30454
+ if (idempotencyKey) {
30455
+ const existing = await loadByIdempotencyKey(targetDb, idempotencyKey);
30456
+ if (existing) {
30457
+ if (existing.site.key !== siteKey || existing.domain.host !== primaryHost) throw new ConflictError("Idempotency key was reused with a different site key or primary host.");
30458
+ return existing;
30459
+ }
30460
+ }
29940
30461
  if ((await targetDb.select({ id: sites.id }).from(sites).where(eq(sites.key, siteKey)).limit(1))[0]) throw new ConflictError("Site key is already in use.");
29941
30462
  if ((await targetDb.select({ id: siteDomains.id }).from(siteDomains).where(eq(siteDomains.host, primaryHost)).limit(1))[0]) throw new ConflictError("Primary host is already in use.");
29942
30463
  const timestamp = now();
@@ -29946,6 +30467,7 @@ function createSiteAdminService(db, databaseSchema = sqliteSchemaBundle, databas
29946
30467
  id: siteId,
29947
30468
  key: siteKey,
29948
30469
  status: "active",
30470
+ provisioningIdempotencyKey: idempotencyKey,
29949
30471
  createdAt: timestamp,
29950
30472
  updatedAt: timestamp
29951
30473
  }).returning())[0];
@@ -30863,6 +31385,7 @@ function getResolvedSiteBaseUrl(env, publicRequestUrl, pathPrefix) {
30863
31385
  schema: sqliteSchemaBundle,
30864
31386
  secret: hostedControlPlaneSsoSecret
30865
31387
  }),
31388
+ rateLimiter: createD1RateLimiter(db, sqliteSchemaBundle),
30866
31389
  services: createServices(db, session, siteLookup.site.id, {
30867
31390
  databaseDialect: "sqlite",
30868
31391
  bootstrapSite: getSingleSiteBootstrapOptions(env),
@@ -30876,7 +31399,76 @@ function getResolvedSiteBaseUrl(env, publicRequestUrl, pathPrefix) {
30876
31399
  };
30877
31400
  }
30878
31401
  //#endregion
31402
+ //#region src/lib/rate-limit-memory.ts
31403
+ /**
31404
+ * In-Memory Rate Limiter
31405
+ *
31406
+ * Used by the Node runtime. The server process is long-lived and
31407
+ * single-instance, so a local `Map` is reliable and avoids unnecessary DB
31408
+ * round-trips. Uses the classic sliding-window-counter algorithm (two
31409
+ * aligned buckets with a weighted estimate) for smooth limiting without
31410
+ * the 2x boundary burst of a fixed window.
31411
+ *
31412
+ * On Cloudflare Workers use `createD1RateLimiter` instead — isolates are
31413
+ * ephemeral and cannot share memory across requests.
31414
+ */ /**
31415
+ * Number of live keys after which we prune old buckets on the next write.
31416
+ * Keeps the Map bounded under abuse without paying for eager sweeps.
31417
+ */ var SWEEP_THRESHOLD = 1e4;
31418
+ /**
31419
+ * Creates an isolated in-memory limiter. Tests construct one per test app;
31420
+ * the Node runtime holds a single module-level instance across requests.
31421
+ *
31422
+ * `now` is injectable so tests can assert window-rollover behavior without
31423
+ * relying on real time.
31424
+ */ function createMemoryRateLimiter(now = () => Math.floor(Date.now() / 1e3)) {
31425
+ const buckets = /* @__PURE__ */ new Map();
31426
+ function sweep(nowSec) {
31427
+ if (buckets.size < SWEEP_THRESHOLD) return;
31428
+ const cutoff = nowSec - 600;
31429
+ for (const [key, bucket] of buckets) if (bucket.windowStart < cutoff) buckets.delete(key);
31430
+ }
31431
+ return { async check(key, opts) {
31432
+ const { limit, windowSec } = opts;
31433
+ const nowSec = now();
31434
+ const currentWindow = Math.floor(nowSec / windowSec) * windowSec;
31435
+ let bucket = buckets.get(key);
31436
+ if (!bucket) {
31437
+ bucket = {
31438
+ windowStart: currentWindow,
31439
+ count: 0,
31440
+ prevCount: 0
31441
+ };
31442
+ buckets.set(key, bucket);
31443
+ } else if (bucket.windowStart !== currentWindow) {
31444
+ if (bucket.windowStart === currentWindow - windowSec) bucket.prevCount = bucket.count;
31445
+ else bucket.prevCount = 0;
31446
+ bucket.count = 0;
31447
+ bucket.windowStart = currentWindow;
31448
+ }
31449
+ const elapsed = nowSec - currentWindow;
31450
+ const prevWeight = 1 - elapsed / windowSec;
31451
+ if (bucket.prevCount * prevWeight + bucket.count >= limit) return {
31452
+ ok: false,
31453
+ retryAfterSec: Math.max(1, windowSec - elapsed)
31454
+ };
31455
+ bucket.count += 1;
31456
+ sweep(nowSec);
31457
+ return { ok: true };
31458
+ } };
31459
+ }
31460
+ //#endregion
30879
31461
  //#region src/runtime/node.ts
31462
+ /**
31463
+ * Single process-wide rate limiter for the Node runtime. Node serves all
31464
+ * requests out of one persistent process, so in-memory counters are
31465
+ * reliable and avoid per-request D1 round-trips. Constructed lazily on
31466
+ * first use so tests that never build a request runtime don't pay for it.
31467
+ */ var sharedNodeRateLimiter = null;
31468
+ function getNodeRateLimiter() {
31469
+ sharedNodeRateLimiter ??= createMemoryRateLimiter();
31470
+ return sharedNodeRateLimiter;
31471
+ }
30880
31472
  function createBetterSqliteRawQuery(sqlite) {
30881
31473
  return { prepare(query) {
30882
31474
  let params = [];
@@ -30928,6 +31520,7 @@ function createBetterSqliteRawQuery(sqlite) {
30928
31520
  schema: databaseSchema,
30929
31521
  secret: hostedControlPlaneSsoSecret
30930
31522
  }),
31523
+ rateLimiter: getNodeRateLimiter(),
30931
31524
  services: createServices(db, rawQuery, siteLookup.site.id, {
30932
31525
  databaseDialect,
30933
31526
  bootstrapSite: getSingleSiteBootstrapOptions(env),
@@ -31204,8 +31797,10 @@ async function servePublicStorage(c) {
31204
31797
  c.set("auth", runtime.auth);
31205
31798
  c.set("currentSite", runtime.currentSite);
31206
31799
  c.set("currentSiteDomain", runtime.currentSiteDomain);
31800
+ c.set("rateLimiter", runtime.rateLimiter);
31207
31801
  await next();
31208
31802
  });
31803
+ app.use("*", attachSession());
31209
31804
  app.use("*", async (c, next) => {
31210
31805
  const redirectUrl = await getHostedCanonicalRedirect({
31211
31806
  currentSite: c.var.currentSite,
@@ -31223,6 +31818,7 @@ async function servePublicStorage(c) {
31223
31818
  app.route("/api/internal/api-tokens", internalApiTokensRoutes);
31224
31819
  app.route("/api/internal/sites", internalSitesRoutes);
31225
31820
  app.route("/api/internal/text-attachments", internalTextAttachmentsRoutes);
31821
+ app.route("/api/internal/search/reindex", internalSearchReindexRoutes);
31226
31822
  app.route("/api/internal/uploads", internalUploadsRoutes);
31227
31823
  app.route("/api/github-sync", githubSyncWebhookRoutes);
31228
31824
  app.get("/api/media/:id/content", async (c) => {
@@ -31352,4 +31948,4 @@ async function servePublicStorage(c) {
31352
31948
  return app;
31353
31949
  }
31354
31950
  //#endregion
31355
- export { MEDIA_KINDS as A, toNavItemViews as C, FORMATS$2 as D, toSearchResultView as E, buildThemeStyle as F, BUILTIN_COLOR_THEMES as I, getPublicAssetBasePath as L, SORT_ORDERS as M, STATUSES$2 as N, MAX_MEDIA_ATTACHMENTS as O, TEXT_ATTACHMENT_CONTENT_FORMATS as P, isAssetPath as R, toNavItemView as S, toPostViews as T, schema_exports$1 as _, createSiteService as a, toArchiveGroupsWithMedia as b, resolveConfig as c, getFontThemeCssVariables as d, defaultFeedRenderer as f, createNodeDatabase as g, sqliteSchemaBundle as h, createNodeRequestRuntime as i, NAV_ITEM_TYPES$2 as j, MAX_PINNED_POSTS as k, BUILTIN_FONT_THEMES as l, pgSchemaBundle as m, createRequestRuntime as n, resolveDatabaseDialect as o, defaultSitemapRenderer as p, createNodeCliRuntime as r, getHostBasedStartupConfigurationIssues as s, createApp as t, getCjkSerifCssVariables as u, createMediaContext as v, toPostView as w, toMediaView as x, toArchiveGroups as y };
31951
+ export { NAV_ITEM_TYPES$2 as A, toPostView as C, MAX_MEDIA_ATTACHMENTS as D, FORMATS$2 as E, BUILTIN_COLOR_THEMES as F, getPublicAssetBasePath as I, isAssetPath as L, STATUSES$2 as M, TEXT_ATTACHMENT_CONTENT_FORMATS as N, MAX_PINNED_POSTS as O, buildThemeStyle as P, toNavItemViews as S, toSearchResultView as T, createMediaContext as _, createSiteService as a, toMediaView as b, resolveConfig as c, getFontThemeCssVariables as d, defaultFeedRenderer as f, schema_exports$1 as g, createNodeDatabase as h, createNodeRequestRuntime as i, SORT_ORDERS as j, MEDIA_KINDS as k, BUILTIN_FONT_THEMES as l, sqliteSchemaBundle as m, createRequestRuntime as n, resolveDatabaseDialect as o, pgSchemaBundle as p, createNodeCliRuntime as r, getHostBasedStartupConfigurationIssues as s, createApp as t, getCjkSerifCssVariables as u, toArchiveGroups as v, toPostViews as w, toNavItemView as x, toArchiveGroupsWithMedia as y };