@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.
- package/bin/commands/import-site.js +1 -1
- package/bin/commands/search-reindex.js +175 -0
- package/bin/lib/hugo-markdown.js +102 -0
- package/bin/lib/site-pull-media.js +1 -4
- package/dist/app-Ctl0T0zO.js +5 -0
- package/dist/{app-Cu3lveYI.js → app-GbfwoeDJ.js} +630 -123
- package/dist/client/.vite/manifest.json +1 -1
- package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
- package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
- package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
- package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
- package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
- package/dist/index.js +5 -5
- package/dist/node.js +5 -5
- package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
- package/package.json +1 -1
- package/src/__tests__/helpers/app.ts +15 -4
- package/src/app.tsx +8 -0
- package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
- package/src/client/tiptap/extensions.ts +3 -0
- package/src/client/tiptap/insert-paragraph-around.ts +79 -0
- package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
- package/src/db/migrations/meta/0018_snapshot.json +2225 -0
- package/src/db/migrations/meta/_journal.json +8 -1
- package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
- package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
- package/src/db/migrations/pg/meta/_journal.json +8 -1
- package/src/db/pg/schema.ts +18 -0
- package/src/db/schema.ts +23 -0
- package/src/index.ts +1 -2
- package/src/lib/__tests__/hosted-signin.test.ts +30 -0
- package/src/lib/__tests__/navigation.test.ts +4 -20
- package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
- package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
- package/src/lib/__tests__/summary.test.ts +140 -0
- package/src/lib/__tests__/view.test.ts +66 -0
- package/src/lib/feed.ts +70 -34
- package/src/lib/hosted-signin.ts +9 -3
- package/src/lib/navigation.ts +11 -12
- package/src/lib/post-meta.ts +20 -2
- package/src/lib/rate-limit-d1.ts +99 -0
- package/src/lib/rate-limit-memory.ts +105 -0
- package/src/lib/rate-limit.ts +63 -0
- package/src/lib/render.tsx +9 -0
- package/src/lib/resolve-config.ts +9 -0
- package/src/lib/summary.ts +42 -7
- package/src/lib/url.ts +34 -0
- package/src/lib/view.ts +42 -8
- package/src/middleware/__tests__/auth.test.ts +44 -4
- package/src/middleware/__tests__/rate-limit.test.ts +113 -0
- package/src/middleware/__tests__/session.test.ts +85 -0
- package/src/middleware/auth.ts +62 -25
- package/src/middleware/rate-limit.ts +54 -0
- package/src/middleware/session.ts +36 -0
- package/src/routes/__tests__/compose.test.ts +1 -1
- package/src/routes/api/__tests__/search.test.ts +48 -0
- package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
- package/src/routes/api/internal/search-reindex.ts +40 -0
- package/src/routes/api/search.ts +13 -0
- package/src/routes/auth/dev.ts +1 -1
- package/src/routes/auth/signin.tsx +23 -5
- package/src/routes/dash/settings.tsx +3 -5
- package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
- package/src/routes/feed/sitemap.ts +208 -33
- package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
- package/src/routes/pages/home.tsx +24 -15
- package/src/routes/pages/page.tsx +34 -0
- package/src/routes/pages/partials.tsx +4 -15
- package/src/runtime/cloudflare.ts +4 -0
- package/src/runtime/node.ts +16 -0
- package/src/services/__tests__/post.test.ts +205 -0
- package/src/services/__tests__/search.test.ts +44 -0
- package/src/services/export.ts +9 -2
- package/src/services/post.ts +200 -2
- package/src/types/app-context.ts +20 -0
- package/src/types/config.ts +8 -0
- package/src/types/props.ts +0 -7
- package/src/ui/layouts/BaseLayout.tsx +9 -0
- package/dist/app-DzCB4yOp.js +0 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { r as getInstallationToken } from "./github-app-
|
|
3
|
-
import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-
|
|
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")
|
|
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
|
|
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
|
-
|
|
2421
|
-
|
|
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
|
-
|
|
3917
|
+
cleanBodyText,
|
|
3891
3918
|
post.quoteText
|
|
3892
|
-
] : [post.summary,
|
|
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 {
|
|
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 {
|
|
2
|
-
import { A as
|
|
3
|
-
import {
|
|
4
|
-
import { u as getGitHubAppConfig } from "./env-
|
|
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,
|
|
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-
|
|
2
|
-
import { F as
|
|
3
|
-
import { i as createExportService } from "./github-sync-
|
|
4
|
-
import { b as getSiteResolutionMode, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as shouldTrustProxy, y as getPort } from "./env-
|
|
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-
|
|
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 {
|
|
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
|
@@ -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
|
}
|