@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
|
@@ -2073,4 +2073,209 @@ describe("PostService", () => {
|
|
|
2073
2073
|
expect(updatedRoot?.lastActivityAt).toBe(1000);
|
|
2074
2074
|
});
|
|
2075
2075
|
});
|
|
2076
|
+
|
|
2077
|
+
describe("reindexBodyText", () => {
|
|
2078
|
+
function bodyWithLink(text: string, href: string): string {
|
|
2079
|
+
return JSON.stringify({
|
|
2080
|
+
type: "doc",
|
|
2081
|
+
content: [
|
|
2082
|
+
{
|
|
2083
|
+
type: "paragraph",
|
|
2084
|
+
content: [
|
|
2085
|
+
{
|
|
2086
|
+
type: "text",
|
|
2087
|
+
text,
|
|
2088
|
+
marks: [{ type: "link", attrs: { href } }],
|
|
2089
|
+
},
|
|
2090
|
+
],
|
|
2091
|
+
},
|
|
2092
|
+
],
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
it("recomputes body_text and updates only rows that differ", async () => {
|
|
2097
|
+
const post = await postService.create({
|
|
2098
|
+
format: "note",
|
|
2099
|
+
body: bodyWithLink("docs", "https://rebuild.example/page"),
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
// Simulate the pre-fix state by stripping URLs from body_text directly.
|
|
2103
|
+
await db
|
|
2104
|
+
.update(posts)
|
|
2105
|
+
.set({ bodyText: "docs" })
|
|
2106
|
+
.where(eq(posts.id, post.id));
|
|
2107
|
+
|
|
2108
|
+
const firstPass = await postService.reindexBodyText();
|
|
2109
|
+
expect(firstPass.processed).toBe(1);
|
|
2110
|
+
expect(firstPass.updated).toBe(1);
|
|
2111
|
+
expect(firstPass.skipped).toBe(0);
|
|
2112
|
+
expect(firstPass.done).toBe(true);
|
|
2113
|
+
expect(firstPass.nextCursor).toBeNull();
|
|
2114
|
+
|
|
2115
|
+
const reindexed = await postService.getById(post.id);
|
|
2116
|
+
expect(reindexed?.bodyText).toContain("rebuild.example");
|
|
2117
|
+
|
|
2118
|
+
// Idempotent: re-running immediately should be a no-op.
|
|
2119
|
+
const secondPass = await postService.reindexBodyText();
|
|
2120
|
+
expect(secondPass.updated).toBe(0);
|
|
2121
|
+
expect(secondPass.skipped).toBe(1);
|
|
2122
|
+
expect(secondPass.done).toBe(true);
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
it("skips soft-deleted posts", async () => {
|
|
2126
|
+
const live = await postService.create({
|
|
2127
|
+
format: "note",
|
|
2128
|
+
body: bodyWithLink("a", "https://live.example"),
|
|
2129
|
+
});
|
|
2130
|
+
const gone = await postService.create({
|
|
2131
|
+
format: "note",
|
|
2132
|
+
body: bodyWithLink("b", "https://gone.example"),
|
|
2133
|
+
});
|
|
2134
|
+
|
|
2135
|
+
// Strip body_text on both to force an update on the next pass.
|
|
2136
|
+
await db
|
|
2137
|
+
.update(posts)
|
|
2138
|
+
.set({ bodyText: "a" })
|
|
2139
|
+
.where(eq(posts.id, live.id));
|
|
2140
|
+
await db
|
|
2141
|
+
.update(posts)
|
|
2142
|
+
.set({ bodyText: "b" })
|
|
2143
|
+
.where(eq(posts.id, gone.id));
|
|
2144
|
+
await postService.delete(gone.id);
|
|
2145
|
+
|
|
2146
|
+
const result = await postService.reindexBodyText();
|
|
2147
|
+
expect(result.processed).toBe(1);
|
|
2148
|
+
expect(result.updated).toBe(1);
|
|
2149
|
+
expect(result.done).toBe(true);
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
it("paginates with cursor when more posts remain", async () => {
|
|
2153
|
+
for (let i = 0; i < 3; i++) {
|
|
2154
|
+
await postService.create({
|
|
2155
|
+
format: "note",
|
|
2156
|
+
body: bodyWithLink(`p${i}`, `https://p${i}.example`),
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
const first = await postService.reindexBodyText({ limit: 2 });
|
|
2161
|
+
expect(first.processed).toBe(2);
|
|
2162
|
+
expect(first.done).toBe(false);
|
|
2163
|
+
expect(first.nextCursor).not.toBeNull();
|
|
2164
|
+
|
|
2165
|
+
const second = await postService.reindexBodyText({
|
|
2166
|
+
limit: 2,
|
|
2167
|
+
cursor: first.nextCursor ?? undefined,
|
|
2168
|
+
});
|
|
2169
|
+
expect(second.processed).toBe(1);
|
|
2170
|
+
expect(second.done).toBe(true);
|
|
2171
|
+
expect(second.nextCursor).toBeNull();
|
|
2172
|
+
});
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
describe("listForSitemap", () => {
|
|
2176
|
+
it("returns published non-reply non-private non-deleted posts", async () => {
|
|
2177
|
+
const root = await postService.create({
|
|
2178
|
+
format: "note",
|
|
2179
|
+
bodyMarkdown: "root",
|
|
2180
|
+
status: "published",
|
|
2181
|
+
});
|
|
2182
|
+
await postService.create({
|
|
2183
|
+
format: "note",
|
|
2184
|
+
bodyMarkdown: "reply",
|
|
2185
|
+
replyToId: root.id,
|
|
2186
|
+
status: "published",
|
|
2187
|
+
});
|
|
2188
|
+
await postService.create({
|
|
2189
|
+
format: "note",
|
|
2190
|
+
bodyMarkdown: "private",
|
|
2191
|
+
visibility: "private",
|
|
2192
|
+
status: "published",
|
|
2193
|
+
});
|
|
2194
|
+
await postService.create({
|
|
2195
|
+
format: "note",
|
|
2196
|
+
bodyMarkdown: "draft",
|
|
2197
|
+
status: "draft",
|
|
2198
|
+
});
|
|
2199
|
+
await postService.create({
|
|
2200
|
+
format: "note",
|
|
2201
|
+
bodyMarkdown: "latest hidden",
|
|
2202
|
+
visibility: "latest_hidden",
|
|
2203
|
+
status: "published",
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
const entries = await postService.listForSitemap({ limit: 100 });
|
|
2207
|
+
const ids = entries.map((e) => e.id);
|
|
2208
|
+
// Root post and latest_hidden post should be included; reply/private/draft excluded.
|
|
2209
|
+
expect(ids).toHaveLength(2);
|
|
2210
|
+
expect(ids).toContain(root.id);
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
it("returns entries in ascending id order", async () => {
|
|
2214
|
+
const created: string[] = [];
|
|
2215
|
+
for (let i = 0; i < 5; i++) {
|
|
2216
|
+
const post = await postService.create({
|
|
2217
|
+
format: "note",
|
|
2218
|
+
bodyMarkdown: `post ${i}`,
|
|
2219
|
+
status: "published",
|
|
2220
|
+
});
|
|
2221
|
+
created.push(post.id);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
const entries = await postService.listForSitemap({ limit: 100 });
|
|
2225
|
+
const ids = entries.map((e) => e.id);
|
|
2226
|
+
// TypeIDs embed a UUIDv7 timestamp, so creation order == ascending id.
|
|
2227
|
+
expect(ids).toEqual([...ids].sort());
|
|
2228
|
+
expect(ids).toEqual(created);
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
it("respects afterId as an exclusive cursor", async () => {
|
|
2232
|
+
const posts = [];
|
|
2233
|
+
for (let i = 0; i < 5; i++) {
|
|
2234
|
+
posts.push(
|
|
2235
|
+
await postService.create({
|
|
2236
|
+
format: "note",
|
|
2237
|
+
bodyMarkdown: `post ${i}`,
|
|
2238
|
+
status: "published",
|
|
2239
|
+
}),
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
const firstPage = await postService.listForSitemap({ limit: 2 });
|
|
2244
|
+
expect(firstPage).toHaveLength(2);
|
|
2245
|
+
|
|
2246
|
+
const cursor = firstPage[firstPage.length - 1]?.id;
|
|
2247
|
+
const secondPage = await postService.listForSitemap({
|
|
2248
|
+
afterId: cursor,
|
|
2249
|
+
limit: 2,
|
|
2250
|
+
});
|
|
2251
|
+
expect(secondPage.map((e) => e.id)).toEqual([posts[2]?.id, posts[3]?.id]);
|
|
2252
|
+
|
|
2253
|
+
const thirdPage = await postService.listForSitemap({
|
|
2254
|
+
afterId: secondPage[secondPage.length - 1]?.id,
|
|
2255
|
+
limit: 2,
|
|
2256
|
+
});
|
|
2257
|
+
expect(thirdPage.map((e) => e.id)).toEqual([posts[4]?.id]);
|
|
2258
|
+
});
|
|
2259
|
+
|
|
2260
|
+
it("countForSitemap matches listForSitemap without a cursor", async () => {
|
|
2261
|
+
for (let i = 0; i < 3; i++) {
|
|
2262
|
+
await postService.create({
|
|
2263
|
+
format: "note",
|
|
2264
|
+
bodyMarkdown: `p${i}`,
|
|
2265
|
+
status: "published",
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
await postService.create({
|
|
2269
|
+
format: "note",
|
|
2270
|
+
bodyMarkdown: "private",
|
|
2271
|
+
visibility: "private",
|
|
2272
|
+
status: "published",
|
|
2273
|
+
});
|
|
2274
|
+
|
|
2275
|
+
const count = await postService.countForSitemap();
|
|
2276
|
+
const entries = await postService.listForSitemap({ limit: 100 });
|
|
2277
|
+
expect(count).toBe(entries.length);
|
|
2278
|
+
expect(count).toBe(3);
|
|
2279
|
+
});
|
|
2280
|
+
});
|
|
2076
2281
|
});
|
|
@@ -204,6 +204,50 @@ describe("SearchService", () => {
|
|
|
204
204
|
expect(results[0]?.post.url).toContain("example.com");
|
|
205
205
|
});
|
|
206
206
|
|
|
207
|
+
it("finds posts by URL embedded in inline markdown links", async () => {
|
|
208
|
+
// TipTap stores markdown links as marks on text nodes. Their href
|
|
209
|
+
// must reach body_text so users can search for the URL, not just
|
|
210
|
+
// the visible link text.
|
|
211
|
+
const bodyWithLink = JSON.stringify({
|
|
212
|
+
type: "doc",
|
|
213
|
+
content: [
|
|
214
|
+
{
|
|
215
|
+
type: "paragraph",
|
|
216
|
+
content: [
|
|
217
|
+
{ type: "text", text: "See " },
|
|
218
|
+
{
|
|
219
|
+
type: "text",
|
|
220
|
+
text: "this page",
|
|
221
|
+
marks: [
|
|
222
|
+
{
|
|
223
|
+
type: "link",
|
|
224
|
+
attrs: { href: "https://inline-link.example/article" },
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
{ type: "text", text: " for details." },
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await postService.create({
|
|
235
|
+
format: "note",
|
|
236
|
+
body: bodyWithLink,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const d1 = createMockD1(sqlite);
|
|
240
|
+
const searchService = createSearchService(d1, DEFAULT_TEST_SITE_ID);
|
|
241
|
+
|
|
242
|
+
// Searching by the link's URL host should match.
|
|
243
|
+
const byUrl = await searchService.search("inline-link.example");
|
|
244
|
+
expect(byUrl.length).toBeGreaterThanOrEqual(1);
|
|
245
|
+
|
|
246
|
+
// Regression guard: the visible link text still matches too.
|
|
247
|
+
const byText = await searchService.search("this page");
|
|
248
|
+
expect(byText.length).toBeGreaterThanOrEqual(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
207
251
|
it("finds posts with short queries (< 3 chars) via LIKE fallback", async () => {
|
|
208
252
|
await postService.create({
|
|
209
253
|
format: "note",
|
package/src/services/export.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
getDefaultJantFaviconIcoBytes,
|
|
29
29
|
} from "../lib/jant-branding.js";
|
|
30
30
|
import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
|
|
31
|
+
import { extractBodyText } from "../lib/summary.js";
|
|
31
32
|
import { getMediaUrl, getPublicUrlForProvider } from "../lib/image.js";
|
|
32
33
|
import { render as renderMarkdown } from "../lib/markdown.js";
|
|
33
34
|
import { formatRelativeAge, toISOString } from "../lib/time.js";
|
|
@@ -1074,10 +1075,16 @@ function getArchiveSummaryText(post: Post): string | null {
|
|
|
1074
1075
|
// serialized as `link_url` and rendered as a domain badge, so using
|
|
1075
1076
|
// it as `summary_text` would duplicate that information.
|
|
1076
1077
|
// - Note: body only. If the body is empty, there's nothing to describe.
|
|
1078
|
+
//
|
|
1079
|
+
// Note: we re-derive the body text from `post.body` (TipTap JSON) rather
|
|
1080
|
+
// than reusing `post.bodyText`, because `bodyText` is written with
|
|
1081
|
+
// `includeLinkHrefs: true` for FTS search indexing — that pollutes the
|
|
1082
|
+
// stored text with trailing link URLs. Here we need clean prose.
|
|
1083
|
+
const cleanBodyText = post.body ? extractBodyText(post.body) : null;
|
|
1077
1084
|
const candidates =
|
|
1078
1085
|
post.format === "quote"
|
|
1079
|
-
? [post.summary,
|
|
1080
|
-
: [post.summary,
|
|
1086
|
+
? [post.summary, cleanBodyText, post.quoteText]
|
|
1087
|
+
: [post.summary, cleanBodyText];
|
|
1081
1088
|
|
|
1082
1089
|
for (const candidate of candidates) {
|
|
1083
1090
|
const normalized = normalizeArchiveText(candidate);
|
package/src/services/post.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
isNotNull,
|
|
20
20
|
asc,
|
|
21
21
|
lte,
|
|
22
|
+
gt,
|
|
22
23
|
} from "drizzle-orm";
|
|
23
24
|
import {
|
|
24
25
|
type Database,
|
|
@@ -156,6 +157,17 @@ export interface PostBodyContent {
|
|
|
156
157
|
chars: number;
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
/** Minimal projection used by the sitemap renderer. */
|
|
161
|
+
export interface SitemapPostEntry {
|
|
162
|
+
id: string;
|
|
163
|
+
/** Canonical slug from `path_registry` */
|
|
164
|
+
slug: string;
|
|
165
|
+
/** Primary alias, if the post has one; used in preference to `slug` for URLs */
|
|
166
|
+
alias: string | null;
|
|
167
|
+
updatedAt: number;
|
|
168
|
+
featuredAt: number | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
159
171
|
export interface PostService {
|
|
160
172
|
getById(id: string): Promise<Post | null>;
|
|
161
173
|
getBodyContent(id: string): Promise<PostBodyContent | null>;
|
|
@@ -167,6 +179,35 @@ export interface PostService {
|
|
|
167
179
|
}): Promise<string>;
|
|
168
180
|
checkSlugAvailability(slug: string, excludePostId?: string): Promise<boolean>;
|
|
169
181
|
list(filters?: PostFilters): Promise<Post[]>;
|
|
182
|
+
/**
|
|
183
|
+
* List minimal fields needed to render sitemap entries, paginated by `id`
|
|
184
|
+
* (ascending). Excludes replies, private posts, deleted posts, and drafts.
|
|
185
|
+
*
|
|
186
|
+
* Uses keyset pagination on the primary key so old sitemap shards are cheap
|
|
187
|
+
* to serve and stable across shard boundaries: a newly created post always
|
|
188
|
+
* gets a larger TypeID than any previously-committed post, so it lands in
|
|
189
|
+
* the last shard and never rewrites older ones.
|
|
190
|
+
*
|
|
191
|
+
* @param options.afterId Exclusive lower bound on `id`. Omit for the first
|
|
192
|
+
* shard.
|
|
193
|
+
* @param options.limit Maximum rows to return.
|
|
194
|
+
*/
|
|
195
|
+
listForSitemap(options: {
|
|
196
|
+
afterId?: string;
|
|
197
|
+
limit: number;
|
|
198
|
+
}): Promise<SitemapPostEntry[]>;
|
|
199
|
+
/** Count posts that qualify for the sitemap (same filters as `listForSitemap`) */
|
|
200
|
+
countForSitemap(): Promise<number>;
|
|
201
|
+
/**
|
|
202
|
+
* Return the id at the given 0-based offset in the sitemap ordering.
|
|
203
|
+
* Used to compute keyset cursors for sharded sitemap endpoints.
|
|
204
|
+
*
|
|
205
|
+
* Returns `null` when the offset is beyond the available rows.
|
|
206
|
+
*
|
|
207
|
+
* Walks the primary-key index with `ORDER BY id ASC LIMIT 1 OFFSET ?` —
|
|
208
|
+
* SQLite/D1 scan only the index for this, not the row data.
|
|
209
|
+
*/
|
|
210
|
+
getSitemapIdAt(offset: number): Promise<string | null>;
|
|
170
211
|
/** Count posts matching filters (ignores cursor, offset, limit) */
|
|
171
212
|
count(filters?: PostFilters): Promise<number>;
|
|
172
213
|
/** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
|
|
@@ -292,6 +333,28 @@ export interface PostService {
|
|
|
292
333
|
getDistinctYears(filters?: PostFilters): Promise<number[]>;
|
|
293
334
|
/** For each thread ID, return the ID of the last published, non-deleted post */
|
|
294
335
|
getLastPostIdsByThread(threadIds: string[]): Promise<Map<string, string>>;
|
|
336
|
+
/**
|
|
337
|
+
* Rebuild `post.body_text` for a batch of non-deleted posts, cursor-paginated
|
|
338
|
+
* by post id. For each row, recomputes the plain-text extraction via
|
|
339
|
+
* `extractBodyText(body)` and writes it back only when it differs from the
|
|
340
|
+
* stored value. FTS indexes (SQLite trigger / Postgres generated column)
|
|
341
|
+
* refresh automatically on the UPDATE.
|
|
342
|
+
*
|
|
343
|
+
* Idempotent: re-running after a no-op pass returns `updated: 0`.
|
|
344
|
+
*
|
|
345
|
+
* @param options.limit Batch size (1..500, default 50)
|
|
346
|
+
* @param options.cursor Exclusive lower bound on post id; pass the previous
|
|
347
|
+
* response's `nextCursor` to continue
|
|
348
|
+
* @returns processed/updated/skipped counts, the next cursor, and a `done`
|
|
349
|
+
* flag the caller uses to terminate the loop
|
|
350
|
+
*/
|
|
351
|
+
reindexBodyText(options?: { limit?: number; cursor?: string }): Promise<{
|
|
352
|
+
processed: number;
|
|
353
|
+
updated: number;
|
|
354
|
+
skipped: number;
|
|
355
|
+
nextCursor: string | null;
|
|
356
|
+
done: boolean;
|
|
357
|
+
}>;
|
|
295
358
|
}
|
|
296
359
|
|
|
297
360
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
@@ -1300,6 +1363,83 @@ export function createPostService(
|
|
|
1300
1363
|
return hydratePosts(rows);
|
|
1301
1364
|
},
|
|
1302
1365
|
|
|
1366
|
+
async listForSitemap({ afterId, limit }) {
|
|
1367
|
+
// Share the filter conditions with `list()` so visibility/reply/deleted
|
|
1368
|
+
// semantics stay consistent if they ever change.
|
|
1369
|
+
const conditions = buildFilterConditions({
|
|
1370
|
+
status: "published",
|
|
1371
|
+
excludePrivate: true,
|
|
1372
|
+
excludeReplies: true,
|
|
1373
|
+
});
|
|
1374
|
+
if (afterId !== undefined) {
|
|
1375
|
+
conditions.push(sql`${posts.id} > ${afterId}`);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const rows = await db
|
|
1379
|
+
.select({
|
|
1380
|
+
id: posts.id,
|
|
1381
|
+
updatedAt: posts.updatedAt,
|
|
1382
|
+
featuredAt: posts.featuredAt,
|
|
1383
|
+
})
|
|
1384
|
+
.from(posts)
|
|
1385
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
1386
|
+
.orderBy(asc(posts.id))
|
|
1387
|
+
.limit(limit);
|
|
1388
|
+
|
|
1389
|
+
if (rows.length === 0) return [];
|
|
1390
|
+
|
|
1391
|
+
const ids = rows.map((row) => row.id);
|
|
1392
|
+
const [slugMap, aliasesMap] = await Promise.all([
|
|
1393
|
+
resolvedPaths.getPostSlugMap(ids),
|
|
1394
|
+
resolvedPaths.getPostAliases(ids),
|
|
1395
|
+
]);
|
|
1396
|
+
|
|
1397
|
+
return rows
|
|
1398
|
+
.map((row): SitemapPostEntry | null => {
|
|
1399
|
+
const slug = slugMap.get(row.id);
|
|
1400
|
+
if (!slug) return null;
|
|
1401
|
+
const alias = aliasesMap.get(row.id)?.[0] ?? null;
|
|
1402
|
+
return {
|
|
1403
|
+
id: row.id,
|
|
1404
|
+
slug,
|
|
1405
|
+
alias,
|
|
1406
|
+
updatedAt: row.updatedAt,
|
|
1407
|
+
featuredAt: row.featuredAt,
|
|
1408
|
+
};
|
|
1409
|
+
})
|
|
1410
|
+
.filter((entry): entry is SitemapPostEntry => entry !== null);
|
|
1411
|
+
},
|
|
1412
|
+
|
|
1413
|
+
async countForSitemap() {
|
|
1414
|
+
const conditions = buildFilterConditions({
|
|
1415
|
+
status: "published",
|
|
1416
|
+
excludePrivate: true,
|
|
1417
|
+
excludeReplies: true,
|
|
1418
|
+
});
|
|
1419
|
+
const result = await db
|
|
1420
|
+
.select({ count: sql<number>`CAST(count(*) AS INTEGER)`.as("count") })
|
|
1421
|
+
.from(posts)
|
|
1422
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
|
1423
|
+
return result[0]?.count ?? 0;
|
|
1424
|
+
},
|
|
1425
|
+
|
|
1426
|
+
async getSitemapIdAt(offset) {
|
|
1427
|
+
if (offset < 0) return null;
|
|
1428
|
+
const conditions = buildFilterConditions({
|
|
1429
|
+
status: "published",
|
|
1430
|
+
excludePrivate: true,
|
|
1431
|
+
excludeReplies: true,
|
|
1432
|
+
});
|
|
1433
|
+
const rows = await db
|
|
1434
|
+
.select({ id: posts.id })
|
|
1435
|
+
.from(posts)
|
|
1436
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
1437
|
+
.orderBy(asc(posts.id))
|
|
1438
|
+
.limit(1)
|
|
1439
|
+
.offset(offset);
|
|
1440
|
+
return rows[0]?.id ?? null;
|
|
1441
|
+
},
|
|
1442
|
+
|
|
1303
1443
|
async count(filters = {}) {
|
|
1304
1444
|
const conditions = buildFilterConditions(filters);
|
|
1305
1445
|
|
|
@@ -1377,7 +1517,9 @@ export function createPostService(
|
|
|
1377
1517
|
});
|
|
1378
1518
|
|
|
1379
1519
|
const bodyHtml = body ? renderTiptapJson(body) : null;
|
|
1380
|
-
const bodyText = body
|
|
1520
|
+
const bodyText = body
|
|
1521
|
+
? extractBodyText(body, { includeLinkHrefs: true })
|
|
1522
|
+
: null;
|
|
1381
1523
|
|
|
1382
1524
|
// Generate summary for titled notes with body content
|
|
1383
1525
|
let summary: string | null = null;
|
|
@@ -1879,7 +2021,7 @@ export function createPostService(
|
|
|
1879
2021
|
? renderTiptapJson(normalizedBody)
|
|
1880
2022
|
: null;
|
|
1881
2023
|
updates.bodyText = normalizedBody
|
|
1882
|
-
? extractBodyText(normalizedBody)
|
|
2024
|
+
? extractBodyText(normalizedBody, { includeLinkHrefs: true })
|
|
1883
2025
|
: null;
|
|
1884
2026
|
}
|
|
1885
2027
|
|
|
@@ -2967,5 +3109,61 @@ export function createPostService(
|
|
|
2967
3109
|
|
|
2968
3110
|
return rows.map((r) => parseInt(r.year, 10));
|
|
2969
3111
|
},
|
|
3112
|
+
|
|
3113
|
+
async reindexBodyText(options = {}) {
|
|
3114
|
+
const requested = options.limit ?? 50;
|
|
3115
|
+
const limit = Math.min(Math.max(Math.trunc(requested), 1), 500);
|
|
3116
|
+
const cursor = options.cursor;
|
|
3117
|
+
|
|
3118
|
+
const whereConditions = [
|
|
3119
|
+
eq(posts.siteId, siteId),
|
|
3120
|
+
isNull(posts.deletedAt),
|
|
3121
|
+
];
|
|
3122
|
+
if (cursor) whereConditions.push(gt(posts.id, cursor));
|
|
3123
|
+
|
|
3124
|
+
// Fetch one extra row to detect end-of-data without a separate COUNT.
|
|
3125
|
+
const rows = await db
|
|
3126
|
+
.select({
|
|
3127
|
+
id: posts.id,
|
|
3128
|
+
body: posts.body,
|
|
3129
|
+
bodyText: posts.bodyText,
|
|
3130
|
+
})
|
|
3131
|
+
.from(posts)
|
|
3132
|
+
.where(and(...whereConditions))
|
|
3133
|
+
.orderBy(asc(posts.id))
|
|
3134
|
+
.limit(limit + 1);
|
|
3135
|
+
|
|
3136
|
+
const hasMore = rows.length > limit;
|
|
3137
|
+
const batch = hasMore ? rows.slice(0, limit) : rows;
|
|
3138
|
+
|
|
3139
|
+
let updated = 0;
|
|
3140
|
+
let skipped = 0;
|
|
3141
|
+
|
|
3142
|
+
for (const row of batch) {
|
|
3143
|
+
const nextBodyText = row.body
|
|
3144
|
+
? extractBodyText(row.body, { includeLinkHrefs: true })
|
|
3145
|
+
: null;
|
|
3146
|
+
if (nextBodyText === row.bodyText) {
|
|
3147
|
+
skipped++;
|
|
3148
|
+
continue;
|
|
3149
|
+
}
|
|
3150
|
+
await db
|
|
3151
|
+
.update(posts)
|
|
3152
|
+
.set({ bodyText: nextBodyText })
|
|
3153
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, row.id)));
|
|
3154
|
+
updated++;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
const lastRow = batch.at(-1);
|
|
3158
|
+
const lastId = lastRow ? lastRow.id : null;
|
|
3159
|
+
|
|
3160
|
+
return {
|
|
3161
|
+
processed: batch.length,
|
|
3162
|
+
updated,
|
|
3163
|
+
skipped,
|
|
3164
|
+
nextCursor: hasMore ? lastId : null,
|
|
3165
|
+
done: !hasMore,
|
|
3166
|
+
};
|
|
3167
|
+
},
|
|
2970
3168
|
};
|
|
2971
3169
|
}
|
package/src/types/app-context.ts
CHANGED
|
@@ -11,10 +11,17 @@ import type { Services } from "../services/index.js";
|
|
|
11
11
|
import type { HostedHandoffService } from "../services/hosted-handoff.js";
|
|
12
12
|
import type { Auth } from "../auth.js";
|
|
13
13
|
import type { AppConfig } from "./config.js";
|
|
14
|
+
import type { RateLimiter } from "../lib/rate-limit.js";
|
|
14
15
|
import type { StorageDriver } from "../lib/storage.js";
|
|
15
16
|
import type { Bindings } from "./bindings.js";
|
|
16
17
|
import type { Site, SiteDomain } from "./entities.js";
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Session payload as returned by better-auth's `getSession`.
|
|
21
|
+
* Populated once per request by the `attachSession` middleware.
|
|
22
|
+
*/
|
|
23
|
+
export type AppSession = Awaited<ReturnType<Auth["api"]["getSession"]>>;
|
|
24
|
+
|
|
18
25
|
export interface AppVariables {
|
|
19
26
|
services: Services;
|
|
20
27
|
hostedHandoff: HostedHandoffService;
|
|
@@ -27,6 +34,19 @@ export interface AppVariables {
|
|
|
27
34
|
storage: StorageDriver | null;
|
|
28
35
|
publicRequestUrl: string;
|
|
29
36
|
publicPath: string;
|
|
37
|
+
/**
|
|
38
|
+
* Cached session for the current request. `null` when unauthenticated or
|
|
39
|
+
* when the session lookup errored. Populated by `attachSession` middleware.
|
|
40
|
+
*/
|
|
41
|
+
session: AppSession;
|
|
42
|
+
/** True when `session?.user` is set. Shortcut for the common read. */
|
|
43
|
+
isAuthenticated: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Runtime-appropriate rate limiter. Populated per-request from the
|
|
46
|
+
* runtime (D1 on Workers, in-memory on Node). Middleware calls
|
|
47
|
+
* `c.var.rateLimiter.check(...)` instead of caring which impl is used.
|
|
48
|
+
*/
|
|
49
|
+
rateLimiter: RateLimiter;
|
|
30
50
|
}
|
|
31
51
|
|
|
32
52
|
export type App = Hono<{ Bindings: Bindings; Variables: AppVariables }>;
|
package/src/types/config.ts
CHANGED
|
@@ -476,6 +476,14 @@ export interface AppConfig {
|
|
|
476
476
|
siteAvatarUrl: string;
|
|
477
477
|
faviconVersion: string;
|
|
478
478
|
|
|
479
|
+
// Rate limiting (ENV only)
|
|
480
|
+
rateLimit: {
|
|
481
|
+
/** When true, all rate-limit middleware becomes a no-op. */
|
|
482
|
+
disabled: boolean;
|
|
483
|
+
/** Per-IP cap for `/api/search` requests per 60-second window. */
|
|
484
|
+
searchPerMinute: number;
|
|
485
|
+
};
|
|
486
|
+
|
|
479
487
|
// Settings form placeholders (ENV > Default, without DB)
|
|
480
488
|
fallbacks: {
|
|
481
489
|
siteName: string;
|
package/src/types/props.ts
CHANGED
|
@@ -138,13 +138,6 @@ export interface FeedData {
|
|
|
138
138
|
posts: FeedPostView[];
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/** Data passed to sitemap renderers */
|
|
142
|
-
export interface SitemapData {
|
|
143
|
-
siteUrl: string;
|
|
144
|
-
sitemapUrl: string;
|
|
145
|
-
posts: PostView[];
|
|
146
|
-
}
|
|
147
|
-
|
|
148
141
|
// =============================================================================
|
|
149
142
|
// Timeline Types
|
|
150
143
|
// =============================================================================
|
|
@@ -49,6 +49,13 @@ export interface BaseLayoutProps {
|
|
|
49
49
|
faviconUrl?: string;
|
|
50
50
|
faviconVersion?: string;
|
|
51
51
|
socialImageUrl?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Absolute canonical URL for the current page. Rendered as
|
|
54
|
+
* `<link rel="canonical">` when set. Use on pages whose primary content is
|
|
55
|
+
* also reachable via another URL (e.g. reply posts, which render the full
|
|
56
|
+
* thread at both the reply URL and the thread-root URL).
|
|
57
|
+
*/
|
|
58
|
+
canonicalHref?: string;
|
|
52
59
|
noindex?: boolean;
|
|
53
60
|
isAuthenticated?: boolean;
|
|
54
61
|
clientBundle?: "public" | "full";
|
|
@@ -65,6 +72,7 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
65
72
|
faviconUrl,
|
|
66
73
|
faviconVersion,
|
|
67
74
|
socialImageUrl,
|
|
75
|
+
canonicalHref,
|
|
68
76
|
noindex,
|
|
69
77
|
isAuthenticated = false,
|
|
70
78
|
clientBundle,
|
|
@@ -265,6 +273,7 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
265
273
|
{resolvedNoindex && (
|
|
266
274
|
<meta name="robots" content="noindex, nofollow" />
|
|
267
275
|
)}
|
|
276
|
+
{canonicalHref && <link rel="canonical" href={canonicalHref} />}
|
|
268
277
|
<link rel="icon" href={resolvedFaviconHref} sizes="16x16 32x32" />
|
|
269
278
|
<link rel="apple-touch-icon" href={resolvedAppleTouchHref} />
|
|
270
279
|
<link
|