@jant/core 0.5.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/bin/commands/telegram/register-webhooks.js +93 -0
  2. package/dist/app-CMSW_AYG.js +6 -0
  3. package/dist/{app-BtNdUAqz.js → app-DYQdDMs8.js} +2249 -387
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-BRTh1ii1.js +274 -0
  6. package/dist/client/_assets/client-CO4b-RKd.css +2 -0
  7. package/dist/client/_assets/{client-auth-DJ_5wx9N.js → client-auth-CSNcTJwP.js} +81 -81
  8. package/dist/{env-CgaH9Mut.js → env-C7e2Nlnt.js} +30 -1
  9. package/dist/{export-CR9Megtb.js → export-Bbn86HmS.js} +1 -1
  10. package/dist/{github-sync-DYZq9rQp.js → github-sync-CBQPRZ8H.js} +1 -1
  11. package/dist/{github-sync-8Vv06aCr.js → github-sync-dXsiZa_e.js} +2 -2
  12. package/dist/index.js +4 -4
  13. package/dist/node.js +61 -5
  14. package/package.json +2 -1
  15. package/src/__tests__/helpers/app.ts +15 -2
  16. package/src/app.tsx +3 -0
  17. package/src/client/thread-context.ts +146 -2
  18. package/src/client/tiptap/__tests__/link-toolbar.test.ts +1 -1
  19. package/src/client/tiptap/bubble-menu.ts +1 -16
  20. package/src/client/tiptap/extensions.ts +2 -6
  21. package/src/client/tiptap/link-toolbar.ts +0 -21
  22. package/src/client/tiptap/toolbar-mode.ts +0 -43
  23. package/src/db/migrations/0022_old_gressill.sql +24 -0
  24. package/src/db/migrations/0023_broad_terror.sql +20 -0
  25. package/src/db/migrations/0024_red_the_twelve.sql +3 -0
  26. package/src/db/migrations/0025_exotic_wendell_rand.sql +1 -0
  27. package/src/db/migrations/meta/0022_snapshot.json +2267 -0
  28. package/src/db/migrations/meta/0023_snapshot.json +2396 -0
  29. package/src/db/migrations/meta/0024_snapshot.json +2417 -0
  30. package/src/db/migrations/meta/0025_snapshot.json +2424 -0
  31. package/src/db/migrations/meta/_journal.json +28 -0
  32. package/src/db/migrations/pg/0020_bizarre_smasher.sql +24 -0
  33. package/src/db/migrations/pg/0021_sharp_puppet_master.sql +20 -0
  34. package/src/db/migrations/pg/0022_blushing_blue_shield.sql +3 -0
  35. package/src/db/migrations/pg/0023_organic_zemo.sql +1 -0
  36. package/src/db/migrations/pg/meta/0020_snapshot.json +2904 -0
  37. package/src/db/migrations/pg/meta/0021_snapshot.json +3060 -0
  38. package/src/db/migrations/pg/meta/0022_snapshot.json +3078 -0
  39. package/src/db/migrations/pg/meta/0023_snapshot.json +3084 -0
  40. package/src/db/migrations/pg/meta/_journal.json +28 -0
  41. package/src/db/pg/schema.ts +82 -0
  42. package/src/db/schema.ts +90 -0
  43. package/src/i18n/coverage.generated.ts +2 -2
  44. package/src/i18n/locales/public/en.po +8 -0
  45. package/src/i18n/locales/public/zh-Hans.po +8 -0
  46. package/src/i18n/locales/public/zh-Hant.po +8 -0
  47. package/src/i18n/locales/settings/en.po +135 -0
  48. package/src/i18n/locales/settings/en.ts +1 -1
  49. package/src/i18n/locales/settings/zh-Hans.po +136 -1
  50. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  51. package/src/i18n/locales/settings/zh-Hant.po +136 -1
  52. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  53. package/src/lib/__tests__/image-dimensions.test.ts +314 -0
  54. package/src/lib/__tests__/telegram-entities.test.ts +180 -0
  55. package/src/lib/__tests__/telegram-pool-webhooks.test.ts +127 -0
  56. package/src/lib/env.ts +45 -0
  57. package/src/lib/ids.ts +3 -0
  58. package/src/lib/image-dimensions.ts +258 -0
  59. package/src/lib/telegram-entities.ts +240 -0
  60. package/src/lib/telegram-pool-webhooks.ts +86 -0
  61. package/src/lib/telegram-settings-status.tsx +109 -0
  62. package/src/lib/telegram.ts +363 -0
  63. package/src/node/runtime.ts +6 -0
  64. package/src/routes/api/__tests__/telegram.test.ts +612 -0
  65. package/src/routes/api/telegram.ts +782 -0
  66. package/src/routes/api/upload-multipart.ts +34 -12
  67. package/src/routes/api/upload.ts +23 -2
  68. package/src/routes/dash/settings.tsx +131 -1
  69. package/src/routes/pages/__tests__/post-page-title.test.ts +70 -0
  70. package/src/routes/pages/page.tsx +3 -2
  71. package/src/runtime/cloudflare.ts +20 -9
  72. package/src/runtime/node.ts +20 -9
  73. package/src/runtime/site.ts +2 -1
  74. package/src/services/__tests__/telegram.test.ts +148 -0
  75. package/src/services/index.ts +9 -0
  76. package/src/services/telegram.ts +613 -0
  77. package/src/services/upload-session.ts +39 -12
  78. package/src/styles/tokens.css +1 -0
  79. package/src/styles/ui.css +117 -38
  80. package/src/types/app-context.ts +6 -0
  81. package/src/types/bindings.ts +3 -0
  82. package/src/types/config.ts +40 -0
  83. package/src/ui/dash/settings/SettingsRootContent.tsx +48 -17
  84. package/src/ui/dash/settings/TelegramContent.tsx +549 -0
  85. package/src/ui/feed/ThreadPreview.tsx +90 -38
  86. package/src/ui/feed/__tests__/thread-preview.test.ts +66 -5
  87. package/src/ui/pages/PostPage.tsx +77 -15
  88. package/dist/app-DLINgGBd.js +0 -6
  89. package/dist/client/_assets/client-BErXNT6k.css +0 -2
  90. package/dist/client/_assets/client-CtAgWT8i.js +0 -274
@@ -69,6 +69,8 @@ var env_exports = /* @__PURE__ */ __exportAll({
69
69
  getSiteResolutionMode: () => getSiteResolutionMode,
70
70
  getSiteUrl: () => getSiteUrl,
71
71
  getStorageDriverEnv: () => getStorageDriverEnv,
72
+ getTelegramBotPool: () => getTelegramBotPool,
73
+ getTelegramWebhookSecret: () => getTelegramWebhookSecret,
72
74
  parsePortValue: () => parsePortValue,
73
75
  shouldTrustProxy: () => shouldTrustProxy,
74
76
  shouldUseSecureCookies: () => shouldUseSecureCookies
@@ -243,10 +245,37 @@ function getGitHubAppConfig(env) {
243
245
  function shouldTrustProxy(env) {
244
246
  return getEnvString(env, "TRUST_PROXY") === "true";
245
247
  }
248
+ /**
249
+ * Parses the platform-managed Telegram bot pool from `TELEGRAM_BOT_TOKENS`.
250
+ *
251
+ * The env value is a comma-separated list of `<bot_id>:<secret>` tokens. The
252
+ * first entry is the public-facing bot (`bot1`); the rest are surfaced only
253
+ * contextually when a binding code reaches an already-bound bot slot.
254
+ *
255
+ * @param env - Runtime environment bindings
256
+ * @returns Parsed pool bots in declared order; empty when unset/invalid
257
+ * @example
258
+ * getTelegramBotPool({ TELEGRAM_BOT_TOKENS: "111:aaa,222:bbb" });
259
+ */ function getTelegramBotPool(env) {
260
+ const raw = getEnvString(env, "TELEGRAM_BOT_TOKENS");
261
+ if (!raw) return [];
262
+ return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0).map((token) => {
263
+ return {
264
+ botId: token.split(":")[0]?.trim() ?? "",
265
+ token
266
+ };
267
+ }).filter((bot) => bot.botId.length > 0);
268
+ }
269
+ /**
270
+ * Returns the shared `secret_token` used when registering every pool bot's
271
+ * webhook. Only meaningful alongside `TELEGRAM_BOT_TOKENS`.
272
+ */ function getTelegramWebhookSecret(env) {
273
+ return getEnvString(env, "TELEGRAM_WEBHOOK_SECRET");
274
+ }
246
275
  function shouldUseSecureCookies(env, publicRequestUrl) {
247
276
  const siteOrigin = getConfiguredSingleSiteOrigin(env);
248
277
  if (siteOrigin) return new URL(siteOrigin).protocol === "https:";
249
278
  return new URL(publicRequestUrl).protocol === "https:";
250
279
  }
251
280
  //#endregion
252
- export { coalesceDisplayText as C, shouldUseSecureCookies as S, getInternalAdminToken as _, getConfiguredSingleSiteUrl as a, getSiteResolutionMode as b, getDevApiToken as c, getHostedControlPlaneBaseUrl as d, getHostedControlPlaneDomainCheckSecret as f, getHostedControlPlaneSsoSecret as g, getHostedControlPlaneProviderLabel as h, getConfiguredSingleSitePathPrefix as i, getEnvString as l, getHostedControlPlaneInternalToken as m, getAuthSecret as n, getConfiguredStorageDriver as o, getHostedControlPlaneInternalBaseUrl as p, getConfiguredSingleSiteOrigin as r, getCorsOrigins as s, env_exports as t, getGitHubAppConfig as u, getLocalStoragePath as v, shouldTrustProxy as x, getPort as y };
281
+ export { shouldTrustProxy as C, getTelegramWebhookSecret as S, coalesceDisplayText as T, getInternalAdminToken as _, getConfiguredSingleSiteUrl as a, getSiteResolutionMode as b, getDevApiToken as c, getHostedControlPlaneBaseUrl as d, getHostedControlPlaneDomainCheckSecret as f, getHostedControlPlaneSsoSecret as g, getHostedControlPlaneProviderLabel as h, getConfiguredSingleSitePathPrefix as i, getEnvString as l, getHostedControlPlaneInternalToken as m, getAuthSecret as n, getConfiguredStorageDriver as o, getHostedControlPlaneInternalBaseUrl as p, getConfiguredSingleSiteOrigin as r, getCorsOrigins as s, env_exports as t, getGitHubAppConfig as u, getLocalStoragePath as v, shouldUseSecureCookies as w, getTelegramBotPool as x, getPort as y };
@@ -3270,7 +3270,7 @@ function serializeMarkdownDocument(doc) {
3270
3270
  }
3271
3271
  //#endregion
3272
3272
  //#region src/styles/tokens.css?raw
3273
- var tokens_default = "/**\n * Design Tokens\n *\n * CSS custom properties for all visual aspects of the UI.\n * These are the stable customization API — override in custom CSS\n * to change typography, layout, surfaces, and element sizing.\n */\n\n:root {\n /* Typography — Font families */\n --font-cjk-serif-fallback:\n \"Songti SC\", STSong, SimSun, \"Songti TC\", PMingLiU, MingLiU,\n \"Noto Serif SC\", \"Noto Serif CJK SC\", \"Noto Serif TC\", \"Noto Serif CJK TC\";\n --font-body:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, \"PingFang TC\", \"PingFang SC\",\n \"Hiragino Sans CNS\", \"Hiragino Sans GB\", \"Microsoft JhengHei\",\n \"Microsoft YaHei\", \"Noto Sans CJK TC\", \"Noto Sans CJK SC\", sans-serif;\n --font-heading:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-site-title:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-serif:\n var(--font-cjk-serif-fallback), ui-serif, \"New York Small\", \"New York\",\n \"Iowan Old Style\", Charter, Georgia, \"Times New Roman\", Times, serif;\n --font-ui:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, \"PingFang TC\", \"PingFang SC\",\n \"Hiragino Sans CNS\", \"Hiragino Sans GB\", \"Microsoft JhengHei\",\n \"Microsoft YaHei\", \"Noto Sans CJK TC\", \"Noto Sans CJK SC\", sans-serif;\n --font-mono:\n ui-monospace, Menlo, Monaco, Consolas, \"Cascadia Code\", \"Courier New\",\n monospace;\n /*\n * Blockquote font family.\n *\n * Defaults to `inherit` so blockquotes follow the body font in sans-only\n * themes (Clean, Friendly, Bold) and serif-only themes (Tufte, Bookish).\n * Mixed themes that want a distinct blockquote voice — e.g. Classic, which\n * pairs a sans body with serif headings — override this token in their\n * `cssVariables` to point at a serif stack. This also restores a type-level\n * distinction for CJK, where italic is disabled.\n */\n --font-blockquote: inherit;\n\n /* Typography — Font weights */\n --fw-light: 300;\n --fw-regular: 400;\n --fw-medium: 500;\n --fw-semibold: 600;\n --fw-bold: 700;\n --fw-extrabold: 800;\n /*\n * Unified type scale.\n *\n * Every font-size in the system — reading content AND UI controls —\n * must reference one of these tokens. No raw rem/px values elsewhere.\n *\n * --type-display 2.7rem (40.5px) — page titles, h1\n * --type-title 2.1rem (31.5px) — h2, feed card titles\n * --type-subtitle 1.8rem (27px) — h3\n * --type-body 1.4rem (21px) — running text, form inputs\n * --type-secondary 1.1rem (16.5px) — meta, nav, sidenotes, UI labels\n * --type-base 1.0rem (15px) — code, UI buttons, controls\n * --type-sm 0.9rem (13.5px) — code blocks, small UI text\n * --type-xs 0.8rem (12px) — captions, descriptions, chips\n * --type-2xs 0.65rem (9.75px) — tiny labels, file sizes, badges\n */\n --type-display: 2.7rem;\n --type-title: 2.1rem;\n --type-subtitle: 1.8rem;\n --type-body: 1.4rem;\n --type-secondary: 1.1rem;\n --type-base: 1rem;\n --type-sm: 0.9rem;\n --type-xs: 0.8rem;\n --type-2xs: 0.65rem;\n\n /* Content reading tokens — desktop maps to core scale,\n mobile overrides below cap sizes for small screens.\n --type-content-scale uniformly scales all content sizes\n without affecting UI controls. */\n --type-content-scale: 0.81;\n --type-content-display: calc(var(--type-display) * var(--type-content-scale));\n --type-content-title: calc(var(--type-title) * var(--type-content-scale));\n --type-content-subtitle: calc(\n var(--type-subtitle) * var(--type-content-scale)\n );\n --type-content-body: calc(var(--type-body) * var(--type-content-scale));\n\n /* Semantic aliases */\n --type-code: var(--type-base);\n --type-code-block: var(--type-sm);\n --type-body-size: var(--type-content-body);\n --type-body-tracking: 0;\n --type-ui-title: var(--type-secondary);\n --type-ui-control: var(--type-base);\n --type-ui-meta: var(--type-base);\n --type-ui-hint: var(--type-sm);\n --type-ui-caption: var(--type-xs);\n --type-ui-micro: var(--type-2xs);\n --type-ui-input: var(--type-secondary);\n --type-thread-context: var(--type-base);\n --type-thread-context-title: var(--type-secondary);\n --type-thread-context-meta: var(--type-sm);\n --feed-note-title-size: var(--type-content-title);\n --feed-note-title-leading: var(--type-heading-leading);\n --type-body-leading: 1.5;\n --type-display-leading: 1.15;\n --type-heading-leading: 1.15;\n --type-heading-weight: var(--fw-regular);\n --type-heading-tracking: 0;\n --type-display-weight: var(--fw-regular);\n --type-display-tracking: 0;\n --type-label-weight: var(--fw-medium);\n --type-label-tracking: 0.08em;\n\n /* Layout — Tufte proportional model */\n --content-max-width: 42rem;\n --form-max-width: 42rem;\n /* compose dialogs + feed content cap */\n --layout-body-max-width: 1088px;\n --layout-content-width: 55%;\n --layout-sidenote-width: 50%;\n --layout-sidenote-margin: -60%;\n --site-padding: 1.5rem;\n --content-gap: 1rem;\n --space-xl: 2rem;\n --space-2xl: 4rem;\n /*\n * Home timeline vertical rhythm — the single source of truth for the gaps\n * between stacked sections on the home page:\n *\n * site description → separator → first post → divider → post → ...\n *\n * where \"separator\" is the description hairline (logged out) or the compose\n * prompt (logged in). Every one of those gaps derives from this token, and\n * the individual pieces (compose prompt, description hairline, post cards)\n * carry no rhythm margin of their own — the structural wrappers\n * (.site-home-header, hr.feed-divider) own the spacing. Retune the whole\n * feed cadence by changing this one value.\n *\n * Note: per-format card padding (.feed-quote-post, thread previews) is the\n * card's own internal spacing and is intentionally NOT part of this rhythm.\n *\n * Kept as a free-standing value (not pinned to the --space-* scale) so the\n * feed cadence can be tuned independently.\n */\n --site-feed-rhythm: 4.4rem;\n /*\n * The post footer's action row (reply / menu buttons) is a taller tap\n * target than its visible content, leaving a few px of dead space below\n * the last visible element of every post (~5.9px logged out from the\n * footer min-height, ~6.8px logged in from the menu button). hr.feed-divider\n * subtracts this from its top margin so the VISIBLE gap between posts equals\n * --site-feed-rhythm — the same as the header spacing. Re-measure if the\n * footer button size or meta font changes.\n */\n --post-footer-overshoot: 6px;\n /*\n * The site intro (description) block is deliberately tighter than\n * --site-feed-rhythm so it reads as a compact header unit rather than a\n * full feed section. -top: gap from the header nav down to the intro.\n * -bottom: gap from the intro down to its separator — the hairline (logged\n * out) or the compose prompt text (logged in). Same values in both states.\n */\n --site-intro-gap-top: 24px;\n --site-intro-gap-bottom: 36px;\n\n /* Sidebar layout (admin + site sidebar pages) */\n --sidebar-width: 12rem;\n --sidebar-gap: 2rem;\n\n /* Surfaces */\n --card-bg: var(--card);\n --card-radius: 0;\n --card-padding: 1rem;\n --card-border-width: 0;\n --card-shadow: none;\n\n /* Elements */\n --avatar-size: 28px;\n --avatar-radius: 50%;\n --media-radius: 0.5rem;\n\n /* Icons */\n --icon-stroke: 2;\n --icon-stroke-fine: 1.5;\n\n /* Derived color tokens (from BaseCoat variables) */\n --site-accent: var(--primary);\n --site-accent-text: var(--primary-foreground);\n --site-column-outline: var(--border);\n --site-border-light: color-mix(\n in srgb,\n var(--site-column-outline) 52%,\n transparent\n );\n --site-threadline: var(--border);\n --site-page-bg: var(--background);\n --site-elevated-bg: var(--background);\n --site-nav-hover-bg: var(--accent);\n --site-text-primary: var(--foreground);\n --site-text-secondary: var(--muted-foreground);\n --site-reading-title: color-mix(\n in oklch,\n var(--site-text-primary) 81%,\n black\n );\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 86%,\n black\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 90%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 95%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 72%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 88%,\n var(--site-text-primary)\n );\n --site-content-link: inherit;\n --site-content-link-hover: var(--site-text-primary);\n --site-content-link-underline: color-mix(\n in srgb,\n var(--site-text-secondary) 58%,\n transparent\n );\n --site-reading-link: var(--site-reading-body);\n --site-reading-link-hover: var(--site-reading-heading);\n --site-reading-link-underline: color-mix(\n in srgb,\n var(--site-reading-meta) 58%,\n transparent\n );\n --site-text-placeholder: oklch(from var(--muted-foreground) l c h / 0.5);\n --site-media-outline: var(--border);\n --site-divider: var(--border);\n --site-feed-card-bg: color-mix(\n in srgb,\n var(--site-elevated-bg) 88%,\n var(--site-nav-hover-bg)\n );\n --site-feed-card-border: color-mix(\n in srgb,\n var(--site-divider) 78%,\n transparent\n );\n --site-feed-card-shadow: color-mix(\n in srgb,\n var(--site-text-primary) 12%,\n transparent\n );\n --site-feed-divider-color: color-mix(\n in srgb,\n var(--site-text-secondary) 30%,\n transparent\n );\n --site-feed-link-tint: color-mix(in srgb, var(--site-accent) 7%, transparent);\n --site-feed-quote-tint: color-mix(\n in srgb,\n var(--site-accent) 10%,\n transparent\n );\n --site-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 22%,\n var(--site-divider)\n );\n --site-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 62%,\n var(--site-page-bg)\n );\n --site-blockquote-text: color-mix(\n in oklch,\n var(--site-text-primary) 92%,\n black\n );\n --site-summary-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 42%,\n var(--site-page-bg)\n );\n --site-reading-blockquote-rail: color-mix(\n in srgb,\n var(--site-reading-link) 18%,\n var(--site-reading-meta)\n );\n --site-reading-blockquote-bg: color-mix(\n in srgb,\n var(--site-accent) 6%,\n var(--site-page-bg)\n );\n --site-thread-context-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 58%,\n transparent\n );\n --site-thread-context-border: color-mix(\n in srgb,\n var(--site-divider) 74%,\n transparent\n );\n --site-thread-gap-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 42%,\n transparent\n );\n --site-thread-item-spacing: 32px;\n --site-thread-dot-ring: color-mix(\n in srgb,\n var(--site-accent) 16%,\n transparent\n );\n --compose-paper-bg: var(--site-page-bg);\n --compose-control-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 72%,\n var(--compose-paper-bg)\n );\n --compose-control-bg-strong: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 88%,\n var(--compose-paper-bg)\n );\n --compose-control-border: color-mix(\n in srgb,\n var(--site-column-outline) 68%,\n transparent\n );\n --compose-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 24%,\n var(--compose-control-border)\n );\n --compose-blockquote-bg: color-mix(\n in srgb,\n var(--compose-control-bg) 56%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-bg-focus: color-mix(\n in srgb,\n var(--compose-control-bg-strong) 70%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-text: color-mix(\n in srgb,\n var(--site-text-primary) 88%,\n var(--site-text-secondary)\n );\n --compose-floating-bg: color-mix(\n in srgb,\n var(--compose-paper-bg) 94%,\n var(--site-nav-hover-bg) 6%\n );\n\n /* Search highlight */\n --search-mark-bg: oklch(0.92 0.14 90 / 0.55);\n --search-mark-color: oklch(0.35 0.09 70);\n\n /* Admin */\n --dash-bg: oklch(0.97 0.005 80);\n --dash-card-radius: 10px;\n}\n\n@media (max-width: 760px) {\n :root {\n --site-padding: 1.875rem;\n --layout-content-width: 100%;\n }\n}\n\n/*\n * Dark-mode reading color overrides.\n *\n * These rules must beat the active color theme's light-mode block, which\n * uses `:root:root { ... }` (specificity 0,0,2,0) with no media query and\n * therefore applies in both light and dark modes. Most themes do not\n * redefine `--site-reading-*` in their dark block, so without higher\n * specificity here the light reading-body color would leak into dark mode\n * (resulting in near-invisible body text on a dark background).\n *\n * We repeat `:root:root` to reach specificity 0,0,3,0, which outranks the\n * theme's light `:root:root` (0,0,2,0). The theme's dark blocks use\n * `:root:root[data-theme-mode=\"dark\"]` or `:root:root:not([data-theme-mode=\"light\"])`\n * (also 0,0,3,0), so a theme that explicitly defines dark reading colors\n * still wins via source order.\n */\n@media (prefers-color-scheme: dark) {\n :root:root:not([data-theme-mode=\"light\"]) {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(\n in oklch,\n var(--site-text-primary) 96%,\n black\n );\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n }\n}\n\n:root:root[data-theme-mode=\"dark\"] {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 96%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n}\n\n@media (max-width: 760px), (hover: none) and (pointer: coarse) {\n :root {\n /* Content layer — tighter scale for mobile reading\n Ratio ≈ 1.9 : 1.4 : 1.15 : 1 (display:title:subtitle:body)\n 13px base → 31.9 / 24 / 19.5 / 16.9 */\n --type-content-display: calc(2.45rem * var(--type-content-scale));\n --type-content-title: calc(1.85rem * var(--type-content-scale));\n --type-content-subtitle: calc(1.5rem * var(--type-content-scale));\n --type-content-body: calc(1.3rem * var(--type-content-scale));\n\n /* UI layer — pixel floors for touch targets */\n --type-ui-title: max(16px, var(--type-secondary));\n --type-ui-control: max(15px, var(--type-base));\n --type-ui-meta: max(15px, var(--type-secondary));\n --type-ui-hint: max(13px, var(--type-sm));\n --type-ui-caption: max(13px, var(--type-xs));\n --type-ui-micro: max(12px, var(--type-2xs));\n --type-ui-input: max(16px, var(--type-secondary));\n --type-thread-context: max(15px, var(--type-base));\n --type-thread-context-title: max(16px, var(--type-secondary));\n --type-thread-context-meta: max(14px, var(--type-sm));\n }\n}\n";
3273
+ var tokens_default = "/**\n * Design Tokens\n *\n * CSS custom properties for all visual aspects of the UI.\n * These are the stable customization API — override in custom CSS\n * to change typography, layout, surfaces, and element sizing.\n */\n\n:root {\n /* Typography — Font families */\n --font-cjk-serif-fallback:\n \"Songti SC\", STSong, SimSun, \"Songti TC\", PMingLiU, MingLiU,\n \"Noto Serif SC\", \"Noto Serif CJK SC\", \"Noto Serif TC\", \"Noto Serif CJK TC\";\n --font-body:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, \"PingFang TC\", \"PingFang SC\",\n \"Hiragino Sans CNS\", \"Hiragino Sans GB\", \"Microsoft JhengHei\",\n \"Microsoft YaHei\", \"Noto Sans CJK TC\", \"Noto Sans CJK SC\", sans-serif;\n --font-heading:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-site-title:\n \"New York Small\", \"New York\", \"Iowan Old Style\", Charter,\n \"Bitstream Charter\", \"Source Serif 4\", Cambria, \"Sitka Text\", Georgia,\n var(--font-cjk-serif-fallback), ui-serif, serif;\n --font-serif:\n var(--font-cjk-serif-fallback), ui-serif, \"New York Small\", \"New York\",\n \"Iowan Old Style\", Charter, Georgia, \"Times New Roman\", Times, serif;\n --font-ui:\n system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Helvetica, Arial, \"PingFang TC\", \"PingFang SC\",\n \"Hiragino Sans CNS\", \"Hiragino Sans GB\", \"Microsoft JhengHei\",\n \"Microsoft YaHei\", \"Noto Sans CJK TC\", \"Noto Sans CJK SC\", sans-serif;\n --font-mono:\n ui-monospace, Menlo, Monaco, Consolas, \"Cascadia Code\", \"Courier New\",\n monospace;\n /*\n * Blockquote font family.\n *\n * Defaults to `inherit` so blockquotes follow the body font in sans-only\n * themes (Clean, Friendly, Bold) and serif-only themes (Tufte, Bookish).\n * Mixed themes that want a distinct blockquote voice — e.g. Classic, which\n * pairs a sans body with serif headings — override this token in their\n * `cssVariables` to point at a serif stack. This also restores a type-level\n * distinction for CJK, where italic is disabled.\n */\n --font-blockquote: inherit;\n\n /* Typography — Font weights */\n --fw-light: 300;\n --fw-regular: 400;\n --fw-medium: 500;\n --fw-semibold: 600;\n --fw-bold: 700;\n --fw-extrabold: 800;\n /*\n * Unified type scale.\n *\n * Every font-size in the system — reading content AND UI controls —\n * must reference one of these tokens. No raw rem/px values elsewhere.\n *\n * --type-display 2.7rem (40.5px) — page titles, h1\n * --type-title 2.1rem (31.5px) — h2, feed card titles\n * --type-subtitle 1.8rem (27px) — h3\n * --type-body 1.4rem (21px) — running text, form inputs\n * --type-secondary 1.1rem (16.5px) — meta, nav, sidenotes, UI labels\n * --type-base 1.0rem (15px) — code, UI buttons, controls\n * --type-sm 0.9rem (13.5px) — code blocks, small UI text\n * --type-xs 0.8rem (12px) — captions, descriptions, chips\n * --type-2xs 0.65rem (9.75px) — tiny labels, file sizes, badges\n */\n --type-display: 2.7rem;\n --type-title: 2.1rem;\n --type-subtitle: 1.8rem;\n --type-body: 1.4rem;\n --type-secondary: 1.1rem;\n --type-base: 1rem;\n --type-sm: 0.9rem;\n --type-xs: 0.8rem;\n --type-2xs: 0.65rem;\n\n /* Content reading tokens — desktop maps to core scale,\n mobile overrides below cap sizes for small screens.\n --type-content-scale uniformly scales all content sizes\n without affecting UI controls. */\n --type-content-scale: 0.81;\n --type-content-display: calc(var(--type-display) * var(--type-content-scale));\n --type-content-title: calc(var(--type-title) * var(--type-content-scale));\n --type-content-subtitle: calc(\n var(--type-subtitle) * var(--type-content-scale)\n );\n --type-content-body: calc(var(--type-body) * var(--type-content-scale));\n\n /* Semantic aliases */\n --type-code: var(--type-base);\n --type-code-block: var(--type-sm);\n --type-body-size: var(--type-content-body);\n --type-body-tracking: 0;\n --type-ui-title: var(--type-secondary);\n --type-ui-control: var(--type-base);\n --type-ui-meta: var(--type-base);\n --type-ui-hint: var(--type-sm);\n --type-ui-caption: var(--type-xs);\n --type-ui-micro: var(--type-2xs);\n --type-ui-input: var(--type-secondary);\n --type-thread-context: var(--type-base);\n --type-thread-context-title: var(--type-secondary);\n --type-thread-context-meta: var(--type-sm);\n --feed-note-title-size: var(--type-content-title);\n --feed-note-title-leading: var(--type-heading-leading);\n --type-body-leading: 1.5;\n --type-display-leading: 1.15;\n --type-heading-leading: 1.15;\n --type-heading-weight: var(--fw-regular);\n --type-heading-tracking: 0;\n --type-display-weight: var(--fw-regular);\n --type-display-tracking: 0;\n --type-label-weight: var(--fw-medium);\n --type-label-tracking: 0.08em;\n\n /* Layout — Tufte proportional model */\n --content-max-width: 42rem;\n --form-max-width: 42rem;\n /* compose dialogs + feed content cap */\n --layout-body-max-width: 1088px;\n --layout-content-width: 55%;\n --layout-sidenote-width: 50%;\n --layout-sidenote-margin: -60%;\n --site-padding: 1.5rem;\n --content-gap: 1rem;\n --space-xl: 2rem;\n --space-2xl: 4rem;\n /*\n * Home timeline vertical rhythm — the single source of truth for the gaps\n * between stacked sections on the home page:\n *\n * site description → separator → first post → divider → post → ...\n *\n * where \"separator\" is the description hairline (logged out) or the compose\n * prompt (logged in). Every one of those gaps derives from this token, and\n * the individual pieces (compose prompt, description hairline, post cards)\n * carry no rhythm margin of their own — the structural wrappers\n * (.site-home-header, hr.feed-divider) own the spacing. Retune the whole\n * feed cadence by changing this one value.\n *\n * Note: per-format card padding (.feed-quote-post, thread previews) is the\n * card's own internal spacing and is intentionally NOT part of this rhythm.\n *\n * Kept as a free-standing value (not pinned to the --space-* scale) so the\n * feed cadence can be tuned independently.\n */\n --site-feed-rhythm: 4.4rem;\n /*\n * The post footer's action row (reply / menu buttons) is a taller tap\n * target than its visible content, leaving a few px of dead space below\n * the last visible element of every post (~5.9px logged out from the\n * footer min-height, ~6.8px logged in from the menu button). hr.feed-divider\n * subtracts this from its top margin so the VISIBLE gap between posts equals\n * --site-feed-rhythm — the same as the header spacing. Re-measure if the\n * footer button size or meta font changes.\n */\n --post-footer-overshoot: 6px;\n /*\n * The site intro (description) block is deliberately tighter than\n * --site-feed-rhythm so it reads as a compact header unit rather than a\n * full feed section. -top: gap from the header nav down to the intro.\n * -bottom: gap from the intro down to its separator — the hairline (logged\n * out) or the compose prompt text (logged in). Same values in both states.\n */\n --site-intro-gap-top: 24px;\n --site-intro-gap-bottom: 36px;\n\n /* Sidebar layout (admin + site sidebar pages) */\n --sidebar-width: 12rem;\n --sidebar-gap: 2rem;\n\n /* Surfaces */\n --card-bg: var(--card);\n --card-radius: 0;\n --card-padding: 1rem;\n --card-border-width: 0;\n --card-shadow: none;\n\n /* Elements */\n --avatar-size: 28px;\n --avatar-radius: 50%;\n --media-radius: 0.5rem;\n\n /* Icons */\n --icon-stroke: 2;\n --icon-stroke-fine: 1.5;\n\n /* Derived color tokens (from BaseCoat variables) */\n --site-accent: var(--primary);\n --site-accent-text: var(--primary-foreground);\n --site-column-outline: var(--border);\n --site-border-light: color-mix(\n in srgb,\n var(--site-column-outline) 52%,\n transparent\n );\n --site-threadline: var(--border);\n --site-page-bg: var(--background);\n --site-elevated-bg: var(--background);\n --site-nav-hover-bg: var(--accent);\n --site-text-primary: var(--foreground);\n --site-text-secondary: var(--muted-foreground);\n --site-reading-title: color-mix(\n in oklch,\n var(--site-text-primary) 81%,\n black\n );\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 86%,\n black\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 90%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 95%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 72%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 88%,\n var(--site-text-primary)\n );\n --site-content-link: inherit;\n --site-content-link-hover: var(--site-text-primary);\n --site-content-link-underline: color-mix(\n in srgb,\n var(--site-text-secondary) 58%,\n transparent\n );\n --site-reading-link: var(--site-reading-body);\n --site-reading-link-hover: var(--site-reading-heading);\n --site-reading-link-underline: color-mix(\n in srgb,\n var(--site-reading-meta) 58%,\n transparent\n );\n --site-text-placeholder: oklch(from var(--muted-foreground) l c h / 0.5);\n --site-media-outline: var(--border);\n --site-divider: var(--border);\n --site-feed-card-bg: color-mix(\n in srgb,\n var(--site-elevated-bg) 88%,\n var(--site-nav-hover-bg)\n );\n --site-feed-card-border: color-mix(\n in srgb,\n var(--site-divider) 78%,\n transparent\n );\n --site-feed-card-shadow: color-mix(\n in srgb,\n var(--site-text-primary) 12%,\n transparent\n );\n --site-feed-divider-color: color-mix(\n in srgb,\n var(--site-text-secondary) 30%,\n transparent\n );\n --site-feed-link-tint: color-mix(in srgb, var(--site-accent) 7%, transparent);\n --site-feed-quote-tint: color-mix(\n in srgb,\n var(--site-accent) 10%,\n transparent\n );\n --site-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 22%,\n var(--site-divider)\n );\n --site-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 62%,\n var(--site-page-bg)\n );\n --site-blockquote-text: color-mix(\n in oklch,\n var(--site-text-primary) 92%,\n black\n );\n --site-summary-blockquote-bg: color-mix(\n in srgb,\n var(--site-feed-quote-tint) 42%,\n var(--site-page-bg)\n );\n --site-reading-blockquote-rail: color-mix(\n in srgb,\n var(--site-reading-link) 18%,\n var(--site-reading-meta)\n );\n --site-reading-blockquote-bg: color-mix(\n in srgb,\n var(--site-accent) 6%,\n var(--site-page-bg)\n );\n --site-thread-context-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 58%,\n transparent\n );\n --site-thread-context-border: color-mix(\n in srgb,\n var(--site-divider) 74%,\n transparent\n );\n --site-thread-gap-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 42%,\n transparent\n );\n --site-thread-item-spacing: 32px;\n --site-thread-context-max-height: 160px;\n --site-thread-dot-ring: color-mix(\n in srgb,\n var(--site-accent) 16%,\n transparent\n );\n --compose-paper-bg: var(--site-page-bg);\n --compose-control-bg: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 72%,\n var(--compose-paper-bg)\n );\n --compose-control-bg-strong: color-mix(\n in srgb,\n var(--site-nav-hover-bg) 88%,\n var(--compose-paper-bg)\n );\n --compose-control-border: color-mix(\n in srgb,\n var(--site-column-outline) 68%,\n transparent\n );\n --compose-blockquote-rail: color-mix(\n in srgb,\n var(--site-accent) 24%,\n var(--compose-control-border)\n );\n --compose-blockquote-bg: color-mix(\n in srgb,\n var(--compose-control-bg) 56%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-bg-focus: color-mix(\n in srgb,\n var(--compose-control-bg-strong) 70%,\n var(--compose-paper-bg)\n );\n --compose-blockquote-text: color-mix(\n in srgb,\n var(--site-text-primary) 88%,\n var(--site-text-secondary)\n );\n --compose-floating-bg: color-mix(\n in srgb,\n var(--compose-paper-bg) 94%,\n var(--site-nav-hover-bg) 6%\n );\n\n /* Search highlight */\n --search-mark-bg: oklch(0.92 0.14 90 / 0.55);\n --search-mark-color: oklch(0.35 0.09 70);\n\n /* Admin */\n --dash-bg: oklch(0.97 0.005 80);\n --dash-card-radius: 10px;\n}\n\n@media (max-width: 760px) {\n :root {\n --site-padding: 1.875rem;\n --layout-content-width: 100%;\n }\n}\n\n/*\n * Dark-mode reading color overrides.\n *\n * These rules must beat the active color theme's light-mode block, which\n * uses `:root:root { ... }` (specificity 0,0,2,0) with no media query and\n * therefore applies in both light and dark modes. Most themes do not\n * redefine `--site-reading-*` in their dark block, so without higher\n * specificity here the light reading-body color would leak into dark mode\n * (resulting in near-invisible body text on a dark background).\n *\n * We repeat `:root:root` to reach specificity 0,0,3,0, which outranks the\n * theme's light `:root:root` (0,0,2,0). The theme's dark blocks use\n * `:root:root[data-theme-mode=\"dark\"]` or `:root:root:not([data-theme-mode=\"light\"])`\n * (also 0,0,3,0), so a theme that explicitly defines dark reading colors\n * still wins via source order.\n */\n@media (prefers-color-scheme: dark) {\n :root:root:not([data-theme-mode=\"light\"]) {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(\n in oklch,\n var(--site-text-primary) 96%,\n black\n );\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n }\n}\n\n:root:root[data-theme-mode=\"dark\"] {\n --site-reading-title: var(--site-text-primary);\n --site-reading-heading: color-mix(\n in oklch,\n var(--site-text-primary) 94%,\n var(--site-text-secondary)\n );\n --site-reading-body: color-mix(in oklch, var(--site-text-primary) 96%, black);\n --site-reading-quote: color-mix(\n in oklch,\n var(--site-text-primary) 98%,\n black\n );\n --site-reading-meta: color-mix(\n in srgb,\n var(--site-text-secondary) 92%,\n var(--site-text-primary)\n );\n --site-reading-caption: color-mix(\n in srgb,\n var(--site-text-secondary) 96%,\n var(--site-text-primary)\n );\n --site-reading-link: var(--site-reading-body);\n --search-mark-bg: oklch(0.45 0.1 85 / 0.5);\n --search-mark-color: oklch(0.92 0.08 90);\n --dash-bg: oklch(0.2 0.005 80);\n}\n\n@media (max-width: 760px), (hover: none) and (pointer: coarse) {\n :root {\n /* Content layer — tighter scale for mobile reading\n Ratio ≈ 1.9 : 1.4 : 1.15 : 1 (display:title:subtitle:body)\n 13px base → 31.9 / 24 / 19.5 / 16.9 */\n --type-content-display: calc(2.45rem * var(--type-content-scale));\n --type-content-title: calc(1.85rem * var(--type-content-scale));\n --type-content-subtitle: calc(1.5rem * var(--type-content-scale));\n --type-content-body: calc(1.3rem * var(--type-content-scale));\n\n /* UI layer — pixel floors for touch targets */\n --type-ui-title: max(16px, var(--type-secondary));\n --type-ui-control: max(15px, var(--type-base));\n --type-ui-meta: max(15px, var(--type-secondary));\n --type-ui-hint: max(13px, var(--type-sm));\n --type-ui-caption: max(13px, var(--type-xs));\n --type-ui-micro: max(12px, var(--type-2xs));\n --type-ui-input: max(16px, var(--type-secondary));\n --type-thread-context: max(15px, var(--type-base));\n --type-thread-context-title: max(16px, var(--type-secondary));\n --type-thread-context-meta: max(14px, var(--type-sm));\n }\n}\n";
3274
3274
  //#endregion
3275
3275
  //#region src/services/export-theme/theme.toml?raw
3276
3276
  var theme_default = "name = \"jant\"\nlicense = \"MIT\"\nlicenselink = \"https://github.com/jant-me/jant/blob/main/LICENSE\"\ndescription = \"Default theme packaged with Jant exports.\"\nhomepage = \"https://jant.so\"\ntags = [\"blog\", \"microblog\", \"minimal\"]\nfeatures = [\"pagination\", \"aliases\"]\nmin_version = \"0.160.1\"\n\n[author]\n name = \"Jant\"\n homepage = \"https://jant.so\"\n";
@@ -1,4 +1,4 @@
1
- import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-CR9Megtb.js";
1
+ import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-Bbn86HmS.js";
2
2
  import { r as getInstallationToken } from "./github-app-D0GvNnqp.js";
3
3
  import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-Bh0PH3zr.js";
4
4
  //#region src/lib/markdown-to-tiptap.ts
@@ -1,4 +1,4 @@
1
1
  import "./url-umUptr5z.js";
2
- import "./export-CR9Megtb.js";
3
- import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-DYZq9rQp.js";
2
+ import "./export-Bbn86HmS.js";
3
+ import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-CBQPRZ8H.js";
4
4
  export { classifyRepoForSync, createGitHubSyncService };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { _ as url_exports } from "./url-umUptr5z.js";
2
- import { A as NAV_ITEM_TYPES, C as toPostView, D as MAX_MEDIA_ATTACHMENTS, E as FORMATS, M as STATUSES, N as TEXT_ATTACHMENT_CONTENT_FORMATS, O as MAX_PINNED_POSTS, S as toNavItemViews, T as toSearchResultView, _ as createMediaContext, b as toMediaView, f as defaultFeedRenderer, j as SORT_ORDERS, k as MEDIA_KINDS, t as createApp, v as toArchiveGroups, w as toPostViews, x as toNavItemView, y as toArchiveGroupsWithMedia } from "./app-BtNdUAqz.js";
3
- import { T as time_exports, a as markdown_exports } from "./export-CR9Megtb.js";
4
- import "./env-CgaH9Mut.js";
5
- import "./github-sync-DYZq9rQp.js";
2
+ import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, h as defaultFeedRenderer, j as MAX_PINNED_POSTS, k as FORMATS, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-DYQdDMs8.js";
3
+ import { T as time_exports, a as markdown_exports } from "./export-Bbn86HmS.js";
4
+ import "./env-C7e2Nlnt.js";
5
+ import "./github-sync-CBQPRZ8H.js";
6
6
  export { FORMATS, MAX_MEDIA_ATTACHMENTS, MAX_PINNED_POSTS, MEDIA_KINDS, NAV_ITEM_TYPES, SORT_ORDERS, STATUSES, TEXT_ATTACHMENT_CONTENT_FORMATS, createApp, createMediaContext, defaultFeedRenderer, markdown_exports as markdown, time_exports as time, toArchiveGroups, toArchiveGroupsWithMedia, toMediaView, toNavItemView, toNavItemViews, toPostView, toPostViews, toSearchResultView, url_exports as url };
package/dist/node.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import "./url-umUptr5z.js";
2
- import { F as BUILTIN_COLOR_THEMES, I as getPublicAssetBasePath, L as isAssetPath, P as buildThemeStyle, a as resolveDatabaseDialect, c as resolveConfig, d as getFontThemeCssVariables, g as schema_exports, h as createNodeDatabase, i as createSiteService, l as BUILTIN_FONT_THEMES, m as sqliteSchemaBundle, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as pgSchemaBundle, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as getCjkSerifCssVariables } from "./app-BtNdUAqz.js";
3
- import { t as createExportService } from "./export-CR9Megtb.js";
4
- import { b as getSiteResolutionMode, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as shouldTrustProxy, y as getPort } from "./env-CgaH9Mut.js";
5
- import "./github-sync-DYZq9rQp.js";
2
+ import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-DYQdDMs8.js";
3
+ import { t as createExportService } from "./export-Bbn86HmS.js";
4
+ import { C as shouldTrustProxy, S as getTelegramWebhookSecret, b as getSiteResolutionMode, d as getHostedControlPlaneBaseUrl, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as getTelegramBotPool, y as getPort } from "./env-C7e2Nlnt.js";
5
+ import "./github-sync-CBQPRZ8H.js";
6
6
  import { drizzle } from "drizzle-orm/better-sqlite3";
7
7
  import { serve } from "@hono/node-server";
8
8
  import Database from "better-sqlite3";
@@ -470,11 +470,66 @@ async function createNodeRequestHandler(options) {
470
470
  }
471
471
  }
472
472
  //#endregion
473
+ //#region src/lib/telegram-pool-webhooks.ts
474
+ /**
475
+ * Telegram managed-pool webhook registration.
476
+ *
477
+ * In hosted mode the bot pool is platform-owned (`TELEGRAM_BOT_TOKENS`) and
478
+ * there is no settings-page action that would register its webhooks. Rather
479
+ * than make the operator run a CLI step, the Node server self-registers on
480
+ * startup: it derives the webhook URL from `HOSTED_CONTROL_PLANE_BASE_URL`
481
+ * (the public control-plane host that forwards to core) and points each pool
482
+ * bot at `<base>/api/telegram/webhook/<botId>`.
483
+ *
484
+ * This is gated on `HOSTED_CONTROL_PLANE_BASE_URL` being set, so a local dev
485
+ * box with `TELEGRAM_BOT_TOKENS` in its env never touches Telegram. It is
486
+ * idempotent — a `getWebhookInfo` check skips bots already pointed at the
487
+ * right URL, so a steady-state restart issues only cheap reads. Callers run
488
+ * it fire-and-forget; it must never block or fail startup.
489
+ */
490
+ /**
491
+ * Registers webhooks for every managed-pool bot, skipping those already
492
+ * pointed at the correct URL. No-ops when the pool is unset, the deployment
493
+ * is not hosted, or no shared webhook secret is configured.
494
+ *
495
+ * @param env - Runtime environment bindings
496
+ */ async function registerTelegramPoolWebhooks(env) {
497
+ const pool = getTelegramBotPool(env);
498
+ if (pool.length === 0) return;
499
+ const baseUrl = getHostedControlPlaneBaseUrl(env);
500
+ if (!baseUrl) return;
501
+ const secret = getTelegramWebhookSecret(env);
502
+ if (!secret) {
503
+ console.error("[Jant] TELEGRAM_BOT_TOKENS is set but TELEGRAM_WEBHOOK_SECRET is missing — skipping webhook registration.");
504
+ return;
505
+ }
506
+ const origin = baseUrl.replace(/\/+$/, "");
507
+ for (const bot of pool) {
508
+ const webhookUrl = `${origin}/api/telegram/webhook/${bot.botId}`;
509
+ try {
510
+ if (await getWebhookUrl(bot.token) !== webhookUrl) {
511
+ await setWebhook(bot.token, webhookUrl, secret);
512
+ console.log(`[Jant] Telegram webhook registered: bot=${bot.botId}`);
513
+ }
514
+ } catch (err) {
515
+ const message = err instanceof Error ? err.message : String(err);
516
+ console.error(`[Jant] Telegram webhook registration failed: bot=${bot.botId} error=${message}`);
517
+ continue;
518
+ }
519
+ try {
520
+ await setMyCommands(bot.token);
521
+ } catch (err) {
522
+ const message = err instanceof Error ? err.message : String(err);
523
+ console.error(`[Jant] Telegram setMyCommands failed: bot=${bot.botId} error=${message}`);
524
+ }
525
+ }
526
+ }
527
+ //#endregion
473
528
  //#region src/node/runtime.ts
474
529
  async function start(env = process.env, app) {
475
530
  const handler = await createNodeRequestHandler({
476
531
  env,
477
- app: async () => app ?? (await import("./app-DLINgGBd.js")).createApp()
532
+ app: async () => app ?? (await import("./app-CMSW_AYG.js")).createApp()
478
533
  });
479
534
  const hostname = resolveHost(env);
480
535
  const port = resolvePort(env);
@@ -487,6 +542,7 @@ async function start(env = process.env, app) {
487
542
  }, (info) => {
488
543
  didResolve = true;
489
544
  server.off("error", onError);
545
+ registerTelegramPoolWebhooks(env);
490
546
  let closed = false;
491
547
  resolvePromise({
492
548
  server,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -70,6 +70,7 @@
70
70
  "sortablejs": "^1.15.7",
71
71
  "typeid-js": "^1.2.0",
72
72
  "unist-util-visit": "^5.1.0",
73
+ "uqr": "^0.1.3",
73
74
  "yaml": "^2.8.2",
74
75
  "zod": "^4.3.6"
75
76
  },
@@ -39,6 +39,10 @@ interface TestAppOptions {
39
39
  hostedControlPlaneSsoSecret?: string;
40
40
  /** Optional hosted handoff service override */
41
41
  hostedHandoff?: HostedHandoffService;
42
+ /** Optional `TELEGRAM_BOT_TOKENS` env binding for Telegram webhook tests */
43
+ telegramBotTokens?: string;
44
+ /** Optional `TELEGRAM_WEBHOOK_SECRET` env binding for Telegram webhook tests */
45
+ telegramWebhookSecret?: string;
42
46
  }
43
47
 
44
48
  /**
@@ -53,10 +57,13 @@ export function createTestApp(options: TestAppOptions = {}) {
53
57
  // Create a mock D1 for search service
54
58
  const mockD1 = createMockD1(sqlite);
55
59
 
56
- const services = createServices(db, mockD1, DEFAULT_TEST_SITE_ID, {
60
+ const servicesConfig = {
57
61
  slugIdLength: 5,
58
62
  siteResolutionMode: options.siteResolutionMode ?? "single-site",
59
- });
63
+ };
64
+ const servicesForSite = (siteId: string) =>
65
+ createServices(db, mockD1, siteId, servicesConfig);
66
+ const services = servicesForSite(DEFAULT_TEST_SITE_ID);
60
67
 
61
68
  // Fresh limiter per test app so counters don't leak between tests.
62
69
  const rateLimiter = createMemoryRateLimiter();
@@ -75,6 +82,8 @@ export function createTestApp(options: TestAppOptions = {}) {
75
82
  DEMO_MODE: options.demoMode ? "true" : "false",
76
83
  INTERNAL_ADMIN_TOKEN: options.internalAdminToken,
77
84
  HOSTED_CONTROL_PLANE_SSO_SECRET: options.hostedControlPlaneSsoSecret,
85
+ TELEGRAM_BOT_TOKENS: options.telegramBotTokens,
86
+ TELEGRAM_WEBHOOK_SECRET: options.telegramWebhookSecret,
78
87
  NODE_DATABASE: {
79
88
  db,
80
89
  dialect: "sqlite",
@@ -85,6 +94,10 @@ export function createTestApp(options: TestAppOptions = {}) {
85
94
  } as AppVariables["services"] extends never ? never : Bindings;
86
95
 
87
96
  c.set("services", services as AppVariables["services"]);
97
+ c.set(
98
+ "servicesForSite",
99
+ servicesForSite as AppVariables["servicesForSite"],
100
+ );
88
101
  c.set(
89
102
  "hostedHandoff",
90
103
  options.hostedHandoff ??
package/src/app.tsx CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  githubSyncWebhookRoutes,
53
53
  githubSyncAdminRoutes,
54
54
  } from "./routes/api/github-sync.js";
55
+ import { telegramWebhookRoutes } from "./routes/api/telegram.js";
55
56
  import { internalTextAttachmentsRoutes } from "./routes/api/internal/text-attachments.js";
56
57
  import { internalSearchReindexRoutes } from "./routes/api/internal/search-reindex.js";
57
58
  import { internalUploadsRoutes } from "./routes/api/internal/uploads.js";
@@ -304,6 +305,7 @@ export function createApp(): App {
304
305
  }
305
306
  const runtime = await createRequestRuntime(c.env, publicRequestUrl);
306
307
  c.set("services", runtime.services);
308
+ c.set("servicesForSite", runtime.servicesForSite);
307
309
  c.set("hostedHandoff", runtime.hostedHandoff);
308
310
  c.set("storage", runtime.storage);
309
311
  c.set("auth", runtime.auth);
@@ -348,6 +350,7 @@ export function createApp(): App {
348
350
  app.route("/api/internal/search/reindex", internalSearchReindexRoutes);
349
351
  app.route("/api/internal/uploads", internalUploadsRoutes);
350
352
  app.route("/api/github-sync", githubSyncWebhookRoutes);
353
+ app.route("/api/telegram", telegramWebhookRoutes);
351
354
 
352
355
  // Fetch text media content by ID (same-origin proxy to avoid CORS with CDN URLs)
353
356
  app.get("/api/media/:id/content", async (c) => {
@@ -1,9 +1,153 @@
1
1
  /**
2
2
  * Thread Context Interactions
3
3
  *
4
- * Auto-scroll to current post on thread detail pages.
4
+ * 1. The ancestor-context shell is server-rendered in the "collapsed" state
5
+ * (cap + fade + visible Show more button). For the common case where the
6
+ * content actually overflows that cap, the server state is already correct
7
+ * and JS does nothing visual at all — no first-paint reflow. For the rarer
8
+ * case where the content fits inside the cap (a couple of tiny posts), JS
9
+ * post-load removes the cap and hides the toggle so the user never sees
10
+ * a no-op "Show more" button.
11
+ * 2. Handle expand/collapse toggle clicks with a max-height transition.
12
+ * 3. Auto-scroll to current post on thread detail pages.
5
13
  */
6
14
 
15
+ const OVERFLOW_THRESHOLD_PX = 8;
16
+
17
+ function parsePixelValue(value: string, fallback: number): number {
18
+ const parsed = Number.parseFloat(value);
19
+ return Number.isFinite(parsed) ? parsed : fallback;
20
+ }
21
+
22
+ function getCapPx(shell: HTMLElement): number {
23
+ const value = getComputedStyle(shell)
24
+ .getPropertyValue("--site-thread-context-max-height")
25
+ .trim();
26
+ return parsePixelValue(value, 240);
27
+ }
28
+
29
+ function setupShell(toggle: HTMLElement): void {
30
+ if (toggle.dataset.threadContextToggleBound === "1") return;
31
+ toggle.dataset.threadContextToggleBound = "1";
32
+
33
+ const shell = toggle.previousElementSibling;
34
+ if (
35
+ !(shell instanceof HTMLElement) ||
36
+ shell.dataset.threadContext === undefined
37
+ ) {
38
+ return;
39
+ }
40
+
41
+ const label = toggle.querySelector<HTMLElement>(
42
+ ".thread-context-toggle-label",
43
+ );
44
+ const labelMore = toggle.dataset.labelMore ?? "Show more";
45
+ const labelLess = toggle.dataset.labelLess ?? "Show less";
46
+
47
+ let userInteracted = false;
48
+
49
+ const setExpandedLabel = (expanded: boolean): void => {
50
+ toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
51
+ if (label) label.textContent = expanded ? labelLess : labelMore;
52
+ };
53
+
54
+ const allImagesSettled = (): boolean =>
55
+ Array.from(shell.querySelectorAll("img")).every((img) => img.complete);
56
+
57
+ const evaluate = (): void => {
58
+ // Skip once the user has taken control; their state is intentional.
59
+ if (userInteracted) return;
60
+ const cap = getCapPx(shell);
61
+ // scrollHeight gives the natural content height regardless of whether
62
+ // overflow is currently clipped — valid in collapsed and expanded states.
63
+ const overflows = shell.scrollHeight > cap + OVERFLOW_THRESHOLD_PX;
64
+
65
+ // We always keep `data-collapsed` set so the fade overlay stays visible
66
+ // as a "this is context" hint, regardless of whether the cap actually
67
+ // clips anything. Only the toggle button depends on real overflow —
68
+ // showing it for content that already fits would let the user click a
69
+ // no-op control.
70
+ if (shell.dataset.collapsed === undefined) shell.dataset.collapsed = "";
71
+ if (overflows) {
72
+ toggle.hidden = false;
73
+ setExpandedLabel(false);
74
+ } else if (allImagesSettled()) {
75
+ // Only hide once we're sure nothing pending will grow the shell.
76
+ // Hiding while an image is mid-download would briefly remove the
77
+ // button, and the user could click the spot where it just was.
78
+ toggle.hidden = true;
79
+ }
80
+ };
81
+
82
+ evaluate();
83
+
84
+ shell.querySelectorAll("img").forEach((img) => {
85
+ if (img.complete) return;
86
+ img.addEventListener("load", evaluate, { once: true });
87
+ img.addEventListener("error", evaluate, { once: true });
88
+ });
89
+
90
+ if ("ResizeObserver" in globalThis) {
91
+ let raf = 0;
92
+ const observer = new globalThis.ResizeObserver(() => {
93
+ cancelAnimationFrame(raf);
94
+ raf = requestAnimationFrame(evaluate);
95
+ });
96
+ observer.observe(shell);
97
+ }
98
+
99
+ // Per-click cleanup that fires when max-height settles. The shell's
100
+ // descendants (the fade overlay) also transition, and their transitionend
101
+ // events bubble to the shell — we have to filter by propertyName or
102
+ // cleanup runs early and the animation snaps. Multiple cleanups can stack
103
+ // safely because each is self-removing and idempotent.
104
+ const scheduleCleanup = (): void => {
105
+ let done = false;
106
+ const cleanup = (e?: globalThis.TransitionEvent): void => {
107
+ if (done) return;
108
+ if (e && e.propertyName !== "max-height") return;
109
+ done = true;
110
+ shell.removeEventListener("transitionend", cleanup);
111
+ if (shell.dataset.collapsed === undefined) shell.style.maxHeight = "";
112
+ };
113
+ shell.addEventListener("transitionend", cleanup);
114
+ window.setTimeout(cleanup, 600);
115
+ };
116
+
117
+ toggle.addEventListener("click", () => {
118
+ userInteracted = true;
119
+ const collapsed = shell.dataset.collapsed !== undefined;
120
+
121
+ if (collapsed) {
122
+ // Expand: pin to the natural content height so the transition has a
123
+ // concrete target, then drop the cap.
124
+ shell.style.maxHeight = `${shell.scrollHeight}px`;
125
+ void shell.offsetHeight;
126
+ delete shell.dataset.collapsed;
127
+ setExpandedLabel(true);
128
+ } else {
129
+ // Collapse: pin to the current height, then on the next frame restore
130
+ // the cap so the transition animates back down.
131
+ shell.style.maxHeight = `${shell.scrollHeight}px`;
132
+ void shell.offsetHeight;
133
+ requestAnimationFrame(() => {
134
+ shell.dataset.collapsed = "";
135
+ shell.style.maxHeight = "";
136
+ });
137
+ setExpandedLabel(false);
138
+ }
139
+ scheduleCleanup();
140
+ });
141
+ }
142
+
143
+ export function setupThreadContexts(
144
+ root: globalThis.Document | globalThis.Element = document,
145
+ ): void {
146
+ root
147
+ .querySelectorAll<HTMLElement>("[data-thread-context-toggle]")
148
+ .forEach(setupShell);
149
+ }
150
+
7
151
  function isFirstThreadDetailItem(current: HTMLElement): boolean {
8
152
  const group = current.closest<HTMLElement>(".thread-group-detail");
9
153
  if (!group) return false;
@@ -39,8 +183,8 @@ function scrollCurrentDetailPostIntoView(
39
183
  });
40
184
  }
41
185
 
42
- // Auto-scroll to current post on detail pages
43
186
  document.addEventListener("DOMContentLoaded", () => {
187
+ setupThreadContexts(document);
44
188
  scrollCurrentDetailPostIntoView(document);
45
189
  });
46
190
 
@@ -30,7 +30,7 @@ function createEditor() {
30
30
  link: { openOnClick: false, autolink: false },
31
31
  }),
32
32
  BubbleMenu.configure({ toolbarMode: "compose" }),
33
- LinkToolbar.configure({ toolbarMode: "compose" }),
33
+ LinkToolbar,
34
34
  ],
35
35
  content: "<p>Hello world</p>",
36
36
  });
@@ -10,11 +10,7 @@ import { Extension, type Editor } from "@tiptap/core";
10
10
  import { Plugin, PluginKey, Selection } from "@tiptap/pm/state";
11
11
  import type { EditorView } from "@tiptap/pm/view";
12
12
  import { isLinkToolbarInputActive } from "./link-toolbar.js";
13
- import {
14
- applyDockedToolbarOffset,
15
- isComposeDockedToolbar,
16
- type FormattingToolbarMode,
17
- } from "./toolbar-mode.js";
13
+ import type { FormattingToolbarMode } from "./toolbar-mode.js";
18
14
  import {
19
15
  getFixedFloatingContainerRect,
20
16
  getFloatingPosition,
@@ -206,19 +202,8 @@ export const BubbleMenu = Extension.create({
206
202
 
207
203
  function show(view: EditorView) {
208
204
  if (!el) return;
209
- const docked = isComposeDockedToolbar(toolbarMode);
210
-
211
- el.classList.toggle("tiptap-bubble-menu-docked", docked);
212
205
  el.style.display = "flex";
213
206
 
214
- if (docked) {
215
- applyDockedToolbarOffset(el, view);
216
- el.style.removeProperty("left");
217
- el.style.removeProperty("top");
218
- syncActive();
219
- return;
220
- }
221
-
222
207
  // Position above selection center
223
208
  const { from, to } = view.state.selection;
224
209
  const start = view.coordsAtPos(from);
@@ -85,9 +85,7 @@ export function createSettingsEditorExtensions(
85
85
  BubbleMenu.configure({
86
86
  toolbarMode: "compose",
87
87
  }),
88
- LinkToolbar.configure({
89
- toolbarMode: "compose",
90
- }),
88
+ LinkToolbar,
91
89
  ];
92
90
  }
93
91
 
@@ -118,9 +116,7 @@ export function createEditorExtensions(
118
116
  BubbleMenu.configure({
119
117
  toolbarMode: options.toolbarMode ?? "default",
120
118
  }),
121
- LinkToolbar.configure({
122
- toolbarMode: options.toolbarMode ?? "default",
123
- }),
119
+ LinkToolbar,
124
120
  ExitableMarks,
125
121
  InsertParagraphAround,
126
122
  TabIndent,
@@ -13,11 +13,6 @@ import { Extension } from "@tiptap/core";
13
13
  import { Plugin, PluginKey } from "@tiptap/pm/state";
14
14
  import type { EditorState } from "@tiptap/pm/state";
15
15
  import type { EditorView } from "@tiptap/pm/view";
16
- import {
17
- applyDockedToolbarOffset,
18
- isComposeDockedToolbar,
19
- type FormattingToolbarMode,
20
- } from "./toolbar-mode.js";
21
16
  import {
22
17
  getFixedFloatingContainerRect,
23
18
  getFloatingPosition,
@@ -96,15 +91,8 @@ function getLinkRange(state: EditorState): LinkRange | null {
96
91
  export const LinkToolbar = Extension.create({
97
92
  name: "linkToolbar",
98
93
 
99
- addOptions() {
100
- return {
101
- toolbarMode: "default" as FormattingToolbarMode,
102
- };
103
- },
104
-
105
94
  addProseMirrorPlugins() {
106
95
  const editor = this.editor;
107
- const toolbarMode = this.options.toolbarMode as FormattingToolbarMode;
108
96
 
109
97
  // DOM elements
110
98
  let inputEl: HTMLElement | null = null;
@@ -204,17 +192,8 @@ export const LinkToolbar = Extension.create({
204
192
  from: number,
205
193
  to: number,
206
194
  ) {
207
- const docked = isComposeDockedToolbar(toolbarMode);
208
- el.classList.toggle("tiptap-link-input-docked", docked);
209
195
  el.style.display = "flex";
210
196
 
211
- if (docked) {
212
- applyDockedToolbarOffset(el, view);
213
- el.style.removeProperty("left");
214
- el.style.removeProperty("top");
215
- return;
216
- }
217
-
218
197
  const dialog = view.dom.closest("dialog");
219
198
  const start = view.coordsAtPos(from);
220
199
  const end = view.coordsAtPos(to);