@jant/core 0.3.42 → 0.3.43

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 (79) 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-Ctl0T0zO.js +5 -0
  6. package/dist/{app-Cu3lveYI.js → app-GbfwoeDJ.js} +630 -123
  7. package/dist/client/.vite/manifest.json +1 -1
  8. package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
  9. package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
  10. package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
  11. package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
  12. package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
  13. package/dist/index.js +5 -5
  14. package/dist/node.js +5 -5
  15. package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
  16. package/package.json +1 -1
  17. package/src/__tests__/helpers/app.ts +15 -4
  18. package/src/app.tsx +8 -0
  19. package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
  20. package/src/client/tiptap/extensions.ts +3 -0
  21. package/src/client/tiptap/insert-paragraph-around.ts +79 -0
  22. package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
  23. package/src/db/migrations/meta/0018_snapshot.json +2225 -0
  24. package/src/db/migrations/meta/_journal.json +8 -1
  25. package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
  26. package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
  27. package/src/db/migrations/pg/meta/_journal.json +8 -1
  28. package/src/db/pg/schema.ts +18 -0
  29. package/src/db/schema.ts +23 -0
  30. package/src/index.ts +1 -2
  31. package/src/lib/__tests__/hosted-signin.test.ts +30 -0
  32. package/src/lib/__tests__/navigation.test.ts +4 -20
  33. package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
  34. package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
  35. package/src/lib/__tests__/summary.test.ts +140 -0
  36. package/src/lib/__tests__/view.test.ts +66 -0
  37. package/src/lib/feed.ts +70 -34
  38. package/src/lib/hosted-signin.ts +9 -3
  39. package/src/lib/navigation.ts +11 -12
  40. package/src/lib/post-meta.ts +20 -2
  41. package/src/lib/rate-limit-d1.ts +99 -0
  42. package/src/lib/rate-limit-memory.ts +105 -0
  43. package/src/lib/rate-limit.ts +63 -0
  44. package/src/lib/render.tsx +9 -0
  45. package/src/lib/resolve-config.ts +9 -0
  46. package/src/lib/summary.ts +42 -7
  47. package/src/lib/url.ts +34 -0
  48. package/src/lib/view.ts +42 -8
  49. package/src/middleware/__tests__/auth.test.ts +44 -4
  50. package/src/middleware/__tests__/rate-limit.test.ts +113 -0
  51. package/src/middleware/__tests__/session.test.ts +85 -0
  52. package/src/middleware/auth.ts +62 -25
  53. package/src/middleware/rate-limit.ts +54 -0
  54. package/src/middleware/session.ts +36 -0
  55. package/src/routes/__tests__/compose.test.ts +1 -1
  56. package/src/routes/api/__tests__/search.test.ts +48 -0
  57. package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
  58. package/src/routes/api/internal/search-reindex.ts +40 -0
  59. package/src/routes/api/search.ts +13 -0
  60. package/src/routes/auth/dev.ts +1 -1
  61. package/src/routes/auth/signin.tsx +23 -5
  62. package/src/routes/dash/settings.tsx +3 -5
  63. package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
  64. package/src/routes/feed/sitemap.ts +208 -33
  65. package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
  66. package/src/routes/pages/home.tsx +24 -15
  67. package/src/routes/pages/page.tsx +34 -0
  68. package/src/routes/pages/partials.tsx +4 -15
  69. package/src/runtime/cloudflare.ts +4 -0
  70. package/src/runtime/node.ts +16 -0
  71. package/src/services/__tests__/post.test.ts +205 -0
  72. package/src/services/__tests__/search.test.ts +44 -0
  73. package/src/services/export.ts +9 -2
  74. package/src/services/post.ts +200 -2
  75. package/src/types/app-context.ts +20 -0
  76. package/src/types/config.ts +8 -0
  77. package/src/types/props.ts +0 -7
  78. package/src/ui/layouts/BaseLayout.tsx +9 -0
  79. package/dist/app-DzCB4yOp.js +0 -5
@@ -1,4 +1,4 @@
1
- import { _ as __exportAll, c as normalizeSitePathPrefix, t as buildSiteUrl } from "./url-FvvgARU9.js";
1
+ import { l as normalizeSitePathPrefix, t as buildSiteUrl, v as __exportAll } from "./url-umUptr5z.js";
2
2
  //#region src/lib/display-text.ts
3
3
  /**
4
4
  * Normalize a user-visible text value.
@@ -1,4 +1,4 @@
1
- import { _ as __exportAll } from "./url-FvvgARU9.js";
1
+ import { v as __exportAll } from "./url-umUptr5z.js";
2
2
  //#region src/lib/github-api.ts
3
3
  var github_api_exports = /* @__PURE__ */ __exportAll({
4
4
  GitHubApiError: () => GitHubApiError,
@@ -1,4 +1,4 @@
1
- import { _ as __exportAll } from "./url-FvvgARU9.js";
1
+ import { v as __exportAll } from "./url-umUptr5z.js";
2
2
  //#region src/lib/github-app.ts
3
3
  var github_app_exports = /* @__PURE__ */ __exportAll({
4
4
  buildInstallUrl: () => buildInstallUrl,
@@ -1,6 +1,6 @@
1
- import { _ as __exportAll, h as toPublicPath, u as sanitizeUrl } from "./url-FvvgARU9.js";
2
- import { r as getInstallationToken } from "./github-app-F4qZ05xk.js";
3
- import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-CficQztC.js";
1
+ import { d as sanitizeUrl, g as toPublicPath, v as __exportAll } from "./url-umUptr5z.js";
2
+ import { r as getInstallationToken } from "./github-app-WeadXMb8.js";
3
+ import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BkRWnqMx.js";
4
4
  import { strToU8, zipSync } from "fflate";
5
5
  import { Extension, Node } from "@tiptap/core";
6
6
  import { MarkdownManager } from "@tiptap/markdown";
@@ -2315,14 +2315,21 @@ var RENDER_CONTEXT = {
2315
2315
  * matching.
2316
2316
  *
2317
2317
  * @param bodyJson - TipTap JSON string (the `body` column)
2318
+ * @param options.includeLinkHrefs
2319
+ * When `true`, URLs from inline link marks are appended after the link text
2320
+ * so they get indexed for search. Default `false` keeps the output clean for
2321
+ * plain-text consumers like `toPlainText`/`extractTitle`.
2318
2322
  * @returns Plain text for FTS indexing, or null if parsing fails or doc is empty
2319
2323
  *
2320
2324
  * @example
2321
2325
  * ```ts
2322
2326
  * const text = extractBodyText(body);
2323
2327
  * // "Hello world Some code here"
2328
+ *
2329
+ * const indexed = extractBodyText(body, { includeLinkHrefs: true });
2330
+ * // "See this page https://example.com"
2324
2331
  * ```
2325
- */ function extractBodyText(bodyJson) {
2332
+ */ function extractBodyText(bodyJson, options = {}) {
2326
2333
  let doc;
2327
2334
  try {
2328
2335
  doc = JSON.parse(bodyJson);
@@ -2330,9 +2337,20 @@ var RENDER_CONTEXT = {
2330
2337
  return null;
2331
2338
  }
2332
2339
  if (doc.type !== "doc" || !doc.content) return null;
2340
+ const includeLinkHrefs = options.includeLinkHrefs === true;
2333
2341
  function collectText(node) {
2334
2342
  if (!SEARCHABLE_TYPES.has(node.type)) return "";
2335
- if (node.type === "text") return node.text ?? "";
2343
+ if (node.type === "text") {
2344
+ const text = node.text ?? "";
2345
+ if (!includeLinkHrefs || !node.marks || node.marks.length === 0) return text;
2346
+ const hrefs = [];
2347
+ for (const mark of node.marks) {
2348
+ if (mark.type !== "link") continue;
2349
+ const href = mark.attrs?.href;
2350
+ if (typeof href === "string" && href.trim()) hrefs.push(href);
2351
+ }
2352
+ return hrefs.length > 0 ? `${text} ${hrefs.join(" ")}` : text;
2353
+ }
2336
2354
  if (node.type === "hardBreak") return " ";
2337
2355
  if (!node.content) return "";
2338
2356
  return node.content.map(collectText).join(" ");
@@ -2386,12 +2404,15 @@ function extractSummary(bodyJson, maxBlocks, maxChars) {
2386
2404
  * @param bodyJson - Tiptap JSON string
2387
2405
  * @param maxBlocks - Maximum number of top-level blocks to include
2388
2406
  * @param maxChars - Maximum total plain-text character count
2389
- * @returns HTML summary and whether content was truncated, or null
2407
+ * @returns HTML summary, whether content was truncated, and the index in
2408
+ * `doc.content` where the content after the summary boundary begins, or null.
2409
+ * `breakAtIndex` lets callers align the summary with the full-body rendering
2410
+ * when splitting at the "read more" boundary (e.g. to insert an anchor).
2390
2411
  *
2391
2412
  * @example
2392
2413
  * ```ts
2393
2414
  * const result = extractSummaryHtml(body, 5, 500);
2394
- * // { html: "<ul><li><p>Item</p></li></ul>", hasMore: true }
2415
+ * // { html: "<ul><li><p>Item</p></li></ul>", hasMore: true, breakAtIndex: 1 }
2395
2416
  * ```
2396
2417
  */ function extractSummaryHtml(bodyJson, maxBlocks = 5, maxChars = 500) {
2397
2418
  let doc;
@@ -2412,17 +2433,21 @@ function extractSummary(bodyJson, maxBlocks, maxChars) {
2412
2433
  type: "doc",
2413
2434
  content: selected
2414
2435
  }),
2415
- hasMore: true
2436
+ hasMore: true,
2437
+ breakAtIndex: moreBreakIdx
2416
2438
  };
2417
2439
  }
2418
2440
  const selected = [];
2419
2441
  let totalChars = 0;
2420
- for (const node of nodes) {
2421
- if (!SUMMARY_BLOCK_TYPES.has(node.type)) continue;
2442
+ let lastSelectedIdx = -1;
2443
+ for (let i = 0; i < nodes.length; i++) {
2444
+ const node = nodes[i];
2445
+ if (!node || !SUMMARY_BLOCK_TYPES.has(node.type)) continue;
2422
2446
  const text = extractPlainText(node).trim();
2423
2447
  if ((selected.length >= maxBlocks || totalChars + text.length > maxChars) && selected.length > 0) break;
2424
2448
  selected.push(node);
2425
2449
  totalChars += text.length;
2450
+ lastSelectedIdx = i;
2426
2451
  }
2427
2452
  if (selected.length === 0) return null;
2428
2453
  return {
@@ -2430,7 +2455,8 @@ function extractSummary(bodyJson, maxBlocks, maxChars) {
2430
2455
  type: "doc",
2431
2456
  content: selected
2432
2457
  }),
2433
- hasMore: selected.length < totalContentNodes
2458
+ hasMore: selected.length < totalContentNodes,
2459
+ breakAtIndex: lastSelectedIdx + 1
2434
2460
  };
2435
2461
  }
2436
2462
  //#endregion
@@ -3885,11 +3911,12 @@ function normalizeArchiveText(text) {
3885
3911
  return (text ?? "").replace(/\s+/g, " ").trim();
3886
3912
  }
3887
3913
  function getArchiveSummaryText(post) {
3914
+ const cleanBodyText = post.body ? extractBodyText(post.body) : null;
3888
3915
  const candidates = post.format === "quote" ? [
3889
3916
  post.summary,
3890
- post.bodyText,
3917
+ cleanBodyText,
3891
3918
  post.quoteText
3892
- ] : [post.summary, post.bodyText];
3919
+ ] : [post.summary, cleanBodyText];
3893
3920
  for (const candidate of candidates) {
3894
3921
  const normalized = normalizeArchiveText(candidate);
3895
3922
  if (normalized) return normalized;
@@ -4693,4 +4720,4 @@ function uint8ArrayToBase64(bytes) {
4693
4720
  return btoa(binary);
4694
4721
  }
4695
4722
  //#endregion
4696
- export { JANT_POSITIVE_LOGO_PNG_FILENAME as A, getJantLogoHref as B, formatYearMonth as C, HOME_BRANDING_LINK_LABEL as D, toISOString as E, getJantBundledAsset as F, base64ToUint8Array as G, JANT_LOGO_PATH_DATA as H, getJantIconFilename as I, getJantIconHref as L, getDefaultJantAppleTouchIconBytes as M, getDefaultJantFaviconIcoBytes as N, HOME_BRANDING_PREFIX as O, getJantBrandPackHref as P, getJantLogoFilename as R, formatTime as S, time_exports as T, JANT_LOGO_VIEW_BOX as U, getJantPositiveLogoPngHref as V, arrayBufferToBase64 as W, getMediaUrl as _, tiptapJsonToMarkdown as a, formatRelativeAge as b, render as c, extractSummary as d, extractSummaryHtml as f, getImageUrl as g, escapeHtml as h, createExportService as i, JANT_REPO_URL as j, JANT_BRAND_PACK_FILENAME as k, toPlainText as l, trimTiptapBody as m, createGitHubSyncService as n, markdownToTiptapJson as o, renderTiptapJson as p, github_sync_exports as r, markdown_exports as s, SYNC_COMMIT_MARKER as t, extractBodyText as u, getPublicUrlForProvider as v, now as w, formatRelativeTime as x, formatDate as y, getJantLogoFills as z };
4723
+ export { JANT_BRAND_PACK_FILENAME as A, getJantLogoFills as B, formatTime as C, toISOString as D, time_exports as E, getJantBrandPackHref as F, arrayBufferToBase64 as G, getJantPositiveLogoPngHref as H, getJantBundledAsset as I, base64ToUint8Array as K, getJantIconFilename as L, JANT_REPO_URL as M, getDefaultJantAppleTouchIconBytes as N, HOME_BRANDING_LINK_LABEL as O, getDefaultJantFaviconIcoBytes as P, getJantIconHref as R, formatRelativeTime as S, now as T, JANT_LOGO_PATH_DATA as U, getJantLogoHref as V, JANT_LOGO_VIEW_BOX as W, getImageUrl as _, tiptapJsonToMarkdown as a, formatDate as b, render as c, extractSummary as d, extractSummaryHtml as f, escapeHtml as g, trimTiptapBody as h, createExportService as i, JANT_POSITIVE_LOGO_PNG_FILENAME as j, HOME_BRANDING_PREFIX as k, toPlainText as l, renderTiptapJson as m, createGitHubSyncService as n, markdownToTiptapJson as o, renderTiptapDocument as p, github_sync_exports as r, markdown_exports as s, SYNC_COMMIT_MARKER as t, extractBodyText as u, getMediaUrl as v, formatYearMonth as w, formatRelativeAge as x, getPublicUrlForProvider as y, getJantLogoFilename as z };
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { g as url_exports } from "./url-FvvgARU9.js";
2
- import { A as MEDIA_KINDS, C as toNavItemViews, D as FORMATS, E as toSearchResultView, M as SORT_ORDERS, N as STATUSES, O as MAX_MEDIA_ATTACHMENTS, P as TEXT_ATTACHMENT_CONTENT_FORMATS, S as toNavItemView, T as toPostViews, b as toArchiveGroupsWithMedia, f as defaultFeedRenderer, j as NAV_ITEM_TYPES, k as MAX_PINNED_POSTS, n as createRequestRuntime, p as defaultSitemapRenderer, t as createApp, v as createMediaContext, w as toPostView, x as toMediaView, y as toArchiveGroups } from "./app-Cu3lveYI.js";
3
- import { T as time_exports, n as createGitHubSyncService, s as markdown_exports } from "./github-sync-zohnA9qv.js";
4
- import { u as getGitHubAppConfig } from "./env-wCpMcNXs.js";
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, n as createRequestRuntime, t as createApp, v as toArchiveGroups, w as toPostViews, x as toNavItemView, y as toArchiveGroupsWithMedia } from "./app-GbfwoeDJ.js";
3
+ import { E as time_exports, n as createGitHubSyncService, s as markdown_exports } from "./github-sync-7y_nTXx1.js";
4
+ import { u as getGitHubAppConfig } from "./env-CgaH9Mut.js";
5
5
  //#region src/lib/github-sync-worker.ts
6
6
  /**
7
7
  * GitHub Sync Worker
@@ -85,4 +85,4 @@ import { u as getGitHubAppConfig } from "./env-wCpMcNXs.js";
85
85
  }
86
86
  }
87
87
  //#endregion
88
- export { FORMATS, MAX_MEDIA_ATTACHMENTS, MAX_PINNED_POSTS, MEDIA_KINDS, NAV_ITEM_TYPES, SORT_ORDERS, STATUSES, TEXT_ATTACHMENT_CONTENT_FORMATS, createApp, createMediaContext, defaultFeedRenderer, defaultSitemapRenderer, handleQueueBatch as handleGitHubSyncQueueBatch, markdown_exports as markdown, time_exports as time, toArchiveGroups, toArchiveGroupsWithMedia, toMediaView, toNavItemView, toNavItemViews, toPostView, toPostViews, toSearchResultView, url_exports as url };
88
+ export { FORMATS, MAX_MEDIA_ATTACHMENTS, MAX_PINNED_POSTS, MEDIA_KINDS, NAV_ITEM_TYPES, SORT_ORDERS, STATUSES, TEXT_ATTACHMENT_CONTENT_FORMATS, createApp, createMediaContext, defaultFeedRenderer, handleQueueBatch as handleGitHubSyncQueueBatch, 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,7 +1,7 @@
1
- import "./url-FvvgARU9.js";
2
- import { F as buildThemeStyle, I as BUILTIN_COLOR_THEMES, L as getPublicAssetBasePath, R as isAssetPath, _ as schema_exports, a as createSiteService, c as resolveConfig, d as getFontThemeCssVariables, g as createNodeDatabase, h as sqliteSchemaBundle, i as createNodeRequestRuntime, l as BUILTIN_FONT_THEMES, m as pgSchemaBundle, o as resolveDatabaseDialect, r as createNodeCliRuntime, s as getHostBasedStartupConfigurationIssues, t as createApp, u as getCjkSerifCssVariables } from "./app-Cu3lveYI.js";
3
- import { i as createExportService } from "./github-sync-zohnA9qv.js";
4
- import { b as getSiteResolutionMode, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as shouldTrustProxy, y as getPort } from "./env-wCpMcNXs.js";
1
+ import "./url-umUptr5z.js";
2
+ import { F as BUILTIN_COLOR_THEMES, I as getPublicAssetBasePath, L as isAssetPath, P as buildThemeStyle, a as createSiteService, c as resolveConfig, d as getFontThemeCssVariables, g as schema_exports, h as createNodeDatabase, i as createNodeRequestRuntime, l as BUILTIN_FONT_THEMES, m as sqliteSchemaBundle, o as resolveDatabaseDialect, p as pgSchemaBundle, r as createNodeCliRuntime, s as getHostBasedStartupConfigurationIssues, t as createApp, u as getCjkSerifCssVariables } from "./app-GbfwoeDJ.js";
3
+ import { i as createExportService } from "./github-sync-7y_nTXx1.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
5
  import { drizzle } from "drizzle-orm/better-sqlite3";
6
6
  import { serve } from "@hono/node-server";
7
7
  import Database from "better-sqlite3";
@@ -473,7 +473,7 @@ async function createNodeRequestHandler(options) {
473
473
  async function start(env = process.env, app) {
474
474
  const handler = await createNodeRequestHandler({
475
475
  env,
476
- app: async () => app ?? (await import("./app-DzCB4yOp.js")).createApp()
476
+ app: async () => app ?? (await import("./app-Ctl0T0zO.js")).createApp()
477
477
  });
478
478
  const hostname = resolveHost(env);
479
479
  const port = resolvePort(env);
@@ -22,6 +22,7 @@ var __exportAll = (all, no_symbols) => {
22
22
  getSitePathPrefix: () => getSitePathPrefix,
23
23
  isFullUrl: () => isFullUrl,
24
24
  isSafeAbsoluteUrl: () => isSafeAbsoluteUrl,
25
+ isSafeInternalRedirect: () => isSafeInternalRedirect,
25
26
  normalizePath: () => normalizePath,
26
27
  normalizeSitePathPrefix: () => normalizeSitePathPrefix,
27
28
  normalizeSiteUrl: () => normalizeSiteUrl,
@@ -279,6 +280,34 @@ var SAFE_URL_PROTOCOLS = new Set([
279
280
  return toPublicPath(href, sitePathPrefix);
280
281
  }
281
282
  /**
283
+ * Check whether a path is a safe same-origin redirect target.
284
+ *
285
+ * Accepts only paths that start with a single `/` (no protocol-relative
286
+ * `//host`, no scheme, no control characters). Callers should use this to
287
+ * validate user-supplied `redirect` query parameters before issuing a
288
+ * `Location` header.
289
+ *
290
+ * @param path - Candidate redirect path
291
+ * @returns `true` when the path is safe to use as an internal redirect
292
+ *
293
+ * @example
294
+ * ```ts
295
+ * isSafeInternalRedirect("/settings") // true
296
+ * isSafeInternalRedirect("//evil.example") // false
297
+ * isSafeInternalRedirect("https://evil.example") // false
298
+ * ```
299
+ */ function isSafeInternalRedirect(path) {
300
+ if (typeof path !== "string") return false;
301
+ if (!path.startsWith("/")) return false;
302
+ if (path.startsWith("//")) return false;
303
+ if (path.startsWith("/\\")) return false;
304
+ for (let i = 0; i < path.length; i += 1) {
305
+ const code = path.charCodeAt(i);
306
+ if (code < 32 || code === 127) return false;
307
+ }
308
+ return true;
309
+ }
310
+ /**
282
311
  * Remove the site path prefix from a public request pathname.
283
312
  *
284
313
  * @param pathname - Public request pathname
@@ -302,4 +331,4 @@ var SAFE_URL_PROTOCOLS = new Set([
302
331
  return new URL(toPublicPath(path, sitePathPrefix), siteUrl).toString();
303
332
  }
304
333
  //#endregion
305
- export { __exportAll as _, getSitePathPrefix as a, normalizeSitePathPrefix as c, slugify as d, stripSitePathPrefix as f, url_exports as g, toPublicPath as h, getSiteOrigin as i, normalizeSiteUrl as l, toPublicHref as m, extractDisplayDomain as n, isFullUrl as o, toAbsoluteSiteUrl as p, extractDomain as r, normalizePath as s, buildSiteUrl as t, sanitizeUrl as u };
334
+ export { url_exports as _, getSitePathPrefix as a, normalizePath as c, sanitizeUrl as d, slugify as f, toPublicPath as g, toPublicHref as h, getSiteOrigin as i, normalizeSitePathPrefix as l, toAbsoluteSiteUrl as m, extractDisplayDomain as n, isFullUrl as o, stripSitePathPrefix as p, extractDomain as r, isSafeInternalRedirect as s, buildSiteUrl as t, normalizeSiteUrl as u, __exportAll as v };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.3.42",
3
+ "version": "0.3.43",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,7 @@ import type BetterSqlite3 from "better-sqlite3";
13
13
  import { errorHandler } from "../../middleware/error-handler.js";
14
14
  import { createI18n } from "../../i18n/i18n.js";
15
15
  import { DEFAULT_APP_PORT } from "../../lib/env.js";
16
+ import { createMemoryRateLimiter } from "../../lib/rate-limit-memory.js";
16
17
  import { resolveConfig } from "../../lib/resolve-config.js";
17
18
  import type { StorageDriver } from "../../lib/storage.js";
18
19
  import type { HostedHandoffService } from "../../services/hosted-handoff.js";
@@ -57,6 +58,9 @@ export function createTestApp(options: TestAppOptions = {}) {
57
58
  siteResolutionMode: options.siteResolutionMode ?? "single-site",
58
59
  });
59
60
 
61
+ // Fresh limiter per test app so counters don't leak between tests.
62
+ const rateLimiter = createMemoryRateLimiter();
63
+
60
64
  const app = new Hono<Env>();
61
65
 
62
66
  // Global error handler: maps DomainError → HTTP responses
@@ -105,6 +109,7 @@ export function createTestApp(options: TestAppOptions = {}) {
105
109
  c.set("allSettings", allSettings);
106
110
  c.set("appConfig", resolveConfig(c.env, allSettings));
107
111
  c.set("storage", options.storage ?? null);
112
+ c.set("rateLimiter", rateLimiter);
108
113
 
109
114
  // i18n (English default for tests)
110
115
  const i18n = createI18n("en");
@@ -117,21 +122,27 @@ export function createTestApp(options: TestAppOptions = {}) {
117
122
  "test-user",
118
123
  "owner",
119
124
  );
125
+ const session = {
126
+ user: { id: "test-user", email: "test@test.com", name: "Test" },
127
+ session: { id: "test-session" },
128
+ } as unknown as AppVariables["session"];
120
129
  // Mock auth that always returns a session
121
130
  c.set("auth", {
122
131
  api: {
123
- getSession: async () => ({
124
- user: { id: "test-user", email: "test@test.com", name: "Test" },
125
- session: { id: "test-session" },
126
- }),
132
+ getSession: async () => session,
127
133
  },
128
134
  } as AppVariables["auth"]);
135
+ // Mirror what `attachSession` middleware would produce in production.
136
+ c.set("session", session);
137
+ c.set("isAuthenticated", true);
129
138
  } else {
130
139
  c.set("auth", {
131
140
  api: {
132
141
  getSession: async () => null,
133
142
  },
134
143
  } as AppVariables["auth"]);
144
+ c.set("session", null);
145
+ c.set("isAuthenticated", false);
135
146
  }
136
147
 
137
148
  await next();
package/src/app.tsx CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  githubSyncAdminRoutes,
54
54
  } from "./routes/api/github-sync.js";
55
55
  import { internalTextAttachmentsRoutes } from "./routes/api/internal/text-attachments.js";
56
+ import { internalSearchReindexRoutes } from "./routes/api/internal/search-reindex.js";
56
57
  import { internalUploadsRoutes } from "./routes/api/internal/uploads.js";
57
58
  import { publicPostsApiRoutes } from "./routes/api/public/posts.js";
58
59
  // Routes - Compose
@@ -65,6 +66,7 @@ import { manifestRoutes } from "./routes/feed/manifest.js";
65
66
 
66
67
  // Middleware
67
68
  import { requireAuth } from "./middleware/auth.js";
69
+ import { attachSession } from "./middleware/session.js";
68
70
  import { requireOnboarding } from "./middleware/onboarding.js";
69
71
  import { errorHandler } from "./middleware/error-handler.js";
70
72
  import { withConfig } from "./middleware/config.js";
@@ -306,10 +308,15 @@ export function createApp(): App {
306
308
  c.set("auth", runtime.auth);
307
309
  c.set("currentSite", runtime.currentSite);
308
310
  c.set("currentSiteDomain", runtime.currentSiteDomain);
311
+ c.set("rateLimiter", runtime.rateLimiter);
309
312
 
310
313
  await next();
311
314
  });
312
315
 
316
+ // Populate c.var.session / c.var.isAuthenticated once per request so
317
+ // downstream handlers don't each call auth.api.getSession themselves.
318
+ app.use("*", attachSession());
319
+
313
320
  app.use("*", async (c, next) => {
314
321
  const redirectUrl = await getHostedCanonicalRedirect({
315
322
  currentSite: c.var.currentSite,
@@ -337,6 +344,7 @@ export function createApp(): App {
337
344
  app.route("/api/internal/api-tokens", internalApiTokensRoutes);
338
345
  app.route("/api/internal/sites", internalSitesRoutes);
339
346
  app.route("/api/internal/text-attachments", internalTextAttachmentsRoutes);
347
+ app.route("/api/internal/search/reindex", internalSearchReindexRoutes);
340
348
  app.route("/api/internal/uploads", internalUploadsRoutes);
341
349
  app.route("/api/github-sync", githubSyncWebhookRoutes);
342
350
 
@@ -0,0 +1,228 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { Editor } from "@tiptap/core";
5
+ import StarterKit from "@tiptap/starter-kit";
6
+ import { TextSelection } from "@tiptap/pm/state";
7
+ import { InsertParagraphAround } from "../insert-paragraph-around.js";
8
+
9
+ const editors: Editor[] = [];
10
+
11
+ function createEditor(content: string) {
12
+ const element = document.createElement("div");
13
+ document.body.appendChild(element);
14
+
15
+ const editor = new Editor({
16
+ element,
17
+ extensions: [
18
+ StarterKit.configure({
19
+ heading: { levels: [1, 2, 3] },
20
+ link: { openOnClick: false, autolink: false },
21
+ }),
22
+ InsertParagraphAround,
23
+ ],
24
+ content,
25
+ });
26
+
27
+ // Any first transaction flushes happy-dom's synthetic DOM-change, which in
28
+ // this test environment appends an auto trailing paragraph. Running it
29
+ // once up front means later pos-based assertions count deltas instead of
30
+ // racing with that artifact.
31
+ editor.view.dispatch(editor.state.tr);
32
+
33
+ editors.push(editor);
34
+ return editor;
35
+ }
36
+
37
+ function setCursor(editor: Editor, pos: number) {
38
+ editor.view.dispatch(
39
+ editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos)),
40
+ );
41
+ }
42
+
43
+ function pressKey(editor: Editor, key: string) {
44
+ return editor.view.someProp("handleKeyDown", (fn) =>
45
+ fn(
46
+ editor.view,
47
+ new KeyboardEvent("keydown", {
48
+ key,
49
+ code: key,
50
+ }),
51
+ ),
52
+ );
53
+ }
54
+
55
+ function pressModShiftEnter(editor: Editor) {
56
+ return editor.view.someProp("handleKeyDown", (fn) =>
57
+ fn(
58
+ editor.view,
59
+ new KeyboardEvent("keydown", {
60
+ key: "Enter",
61
+ code: "Enter",
62
+ shiftKey: true,
63
+ ctrlKey: true,
64
+ }),
65
+ ),
66
+ );
67
+ }
68
+
69
+ function pressModAltEnter(editor: Editor) {
70
+ return editor.view.someProp("handleKeyDown", (fn) =>
71
+ fn(
72
+ editor.view,
73
+ new KeyboardEvent("keydown", {
74
+ key: "Enter",
75
+ code: "Enter",
76
+ altKey: true,
77
+ ctrlKey: true,
78
+ }),
79
+ ),
80
+ );
81
+ }
82
+
83
+ afterEach(() => {
84
+ while (editors.length > 0) {
85
+ editors.pop()?.destroy();
86
+ }
87
+ document.body.innerHTML = "";
88
+ });
89
+
90
+ describe("InsertParagraphAround: ArrowUp / ArrowLeft at doc start", () => {
91
+ it("inserts an empty paragraph before a leading blockquote on ArrowUp", () => {
92
+ const editor = createEditor("<blockquote><p>hello</p></blockquote>");
93
+ setCursor(editor, 2); // inside blockquote's <p>, at the very start
94
+
95
+ const before = editor.state.doc.childCount;
96
+ const handled = pressKey(editor, "ArrowUp");
97
+
98
+ expect(handled).toBe(true);
99
+ expect(editor.state.doc.childCount).toBe(before + 1);
100
+ expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
101
+ expect(editor.state.doc.firstChild?.textContent).toBe("");
102
+ expect(editor.state.doc.child(1).type.name).toBe("blockquote");
103
+ // Cursor lands in the new leading paragraph.
104
+ expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
105
+ expect(editor.state.selection.$from.pos).toBe(1);
106
+ });
107
+
108
+ it("also fires on ArrowLeft", () => {
109
+ const editor = createEditor("<blockquote><p>hello</p></blockquote>");
110
+ setCursor(editor, 2);
111
+
112
+ const before = editor.state.doc.childCount;
113
+ const handled = pressKey(editor, "ArrowLeft");
114
+
115
+ expect(handled).toBe(true);
116
+ expect(editor.state.doc.childCount).toBe(before + 1);
117
+ expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
118
+ });
119
+
120
+ it("fires for a leading heading too", () => {
121
+ const editor = createEditor("<h1>Title</h1><p>body</p>");
122
+ setCursor(editor, 1); // very start of the <h1>
123
+
124
+ const handled = pressKey(editor, "ArrowUp");
125
+
126
+ expect(handled).toBe(true);
127
+ expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
128
+ expect(editor.state.doc.firstChild?.textContent).toBe("");
129
+ expect(editor.state.doc.child(1).type.name).toBe("heading");
130
+ });
131
+
132
+ it("does nothing when the first block is already a paragraph", () => {
133
+ const editor = createEditor("<p>hi</p><blockquote><p>q</p></blockquote>");
134
+ setCursor(editor, 1); // start of leading paragraph
135
+
136
+ const before = editor.state.doc.childCount;
137
+ const handled = pressKey(editor, "ArrowUp");
138
+
139
+ expect(handled).toBeFalsy();
140
+ expect(editor.state.doc.childCount).toBe(before);
141
+ expect(editor.state.doc.firstChild?.textContent).toBe("hi");
142
+ });
143
+
144
+ it("does nothing when the cursor is not at the very first position", () => {
145
+ const editor = createEditor("<blockquote><p>hello</p></blockquote>");
146
+ setCursor(editor, 4); // middle of "hello"
147
+
148
+ const before = editor.state.doc.childCount;
149
+ const handled = pressKey(editor, "ArrowUp");
150
+
151
+ expect(handled).toBeFalsy();
152
+ expect(editor.state.doc.childCount).toBe(before);
153
+ });
154
+
155
+ it("does nothing when inside a later blockquote, not the first", () => {
156
+ const editor = createEditor(
157
+ "<p>first</p><blockquote><p>second</p></blockquote>",
158
+ );
159
+ // doc: p("first") + bq(p("second"))
160
+ // Positions: 0=before p, 1=start of "first", 7=after p, 8=inside bq,
161
+ // 9=start of "second" — cursor not at doc start.
162
+ setCursor(editor, 9);
163
+
164
+ const before = editor.state.doc.childCount;
165
+ const handled = pressKey(editor, "ArrowUp");
166
+
167
+ expect(handled).toBeFalsy();
168
+ expect(editor.state.doc.childCount).toBe(before);
169
+ });
170
+ });
171
+
172
+ describe("InsertParagraphAround: Mod-Shift-Enter / Mod-Alt-Enter", () => {
173
+ it("Mod-Shift-Enter inserts a paragraph before the current top-level block", () => {
174
+ const editor = createEditor("<blockquote><p>hello</p></blockquote>");
175
+ setCursor(editor, 4); // middle of "hello"
176
+
177
+ const before = editor.state.doc.childCount;
178
+ const handled = pressModShiftEnter(editor);
179
+
180
+ expect(handled).toBe(true);
181
+ expect(editor.state.doc.childCount).toBe(before + 1);
182
+ expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
183
+ expect(editor.state.doc.firstChild?.textContent).toBe("");
184
+ expect(editor.state.doc.child(1).type.name).toBe("blockquote");
185
+ expect(editor.state.doc.child(1).textContent).toBe("hello");
186
+ expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
187
+ expect(editor.state.selection.$from.pos).toBe(1);
188
+ });
189
+
190
+ it("Mod-Alt-Enter inserts a paragraph after the current top-level block", () => {
191
+ const editor = createEditor("<blockquote><p>hello</p></blockquote>");
192
+ setCursor(editor, 4);
193
+
194
+ const before = editor.state.doc.childCount;
195
+ const blockquoteSize = editor.state.doc.firstChild!.nodeSize;
196
+ const handled = pressModAltEnter(editor);
197
+
198
+ expect(handled).toBe(true);
199
+ expect(editor.state.doc.childCount).toBe(before + 1);
200
+ expect(editor.state.doc.firstChild?.type.name).toBe("blockquote");
201
+ expect(editor.state.doc.child(1).type.name).toBe("paragraph");
202
+ expect(editor.state.doc.child(1).textContent).toBe("");
203
+ // Cursor sits just inside the inserted trailing paragraph.
204
+ expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
205
+ expect(editor.state.selection.$from.pos).toBe(blockquoteSize + 1);
206
+ });
207
+
208
+ it("Mod-Shift-Enter escapes the top-level block from nested content", () => {
209
+ const editor = createEditor(
210
+ "<ul><li><p>one</p></li><li><p>two</p></li></ul>",
211
+ );
212
+ // Cursor inside the paragraph of the 2nd list item.
213
+ // Walk to a safe position within the second item's text.
214
+ const listNode = editor.state.doc.firstChild!;
215
+ const firstItem = listNode.child(0);
216
+ const pos = 1 /* into <ul> */ + firstItem.nodeSize + 2; /* into <li><p> */
217
+ setCursor(editor, pos);
218
+
219
+ const before = editor.state.doc.childCount;
220
+ const handled = pressModShiftEnter(editor);
221
+
222
+ expect(handled).toBe(true);
223
+ expect(editor.state.doc.childCount).toBe(before + 1);
224
+ expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
225
+ expect(editor.state.doc.firstChild?.textContent).toBe("");
226
+ expect(editor.state.doc.child(1).type.name).toBe("bulletList");
227
+ });
228
+ });
@@ -16,6 +16,7 @@ import { ExitableMarks } from "./exitable-marks.js";
16
16
  import { TabIndent } from "./tab-indent.js";
17
17
  import { LinkInputRules } from "./link-input-rules.js";
18
18
  import { WrappingInputRules } from "./wrapping-input-rules.js";
19
+ import { InsertParagraphAround } from "./insert-paragraph-around.js";
19
20
  import { Footnotes } from "./footnotes.js";
20
21
  import type { FormattingToolbarMode } from "./toolbar-mode.js";
21
22
  import { ImageNode } from "./image-node.js";
@@ -80,6 +81,7 @@ export function createSettingsEditorExtensions(
80
81
  WrappingInputRules,
81
82
  MarkdownClipboard,
82
83
  ExitableMarks,
84
+ InsertParagraphAround,
83
85
  BubbleMenu.configure({
84
86
  toolbarMode: "compose",
85
87
  }),
@@ -120,6 +122,7 @@ export function createEditorExtensions(
120
122
  toolbarMode: options.toolbarMode ?? "default",
121
123
  }),
122
124
  ExitableMarks,
125
+ InsertParagraphAround,
123
126
  TabIndent,
124
127
  ];
125
128
  }