@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,60 +1,235 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Sitemap Routes
|
|
3
|
+
*
|
|
4
|
+
* Sitemap is sharded to keep each shard small, cache-friendly, and stable:
|
|
5
|
+
*
|
|
6
|
+
* /sitemap.xml → sitemap index listing all shards
|
|
7
|
+
* /sitemap-posts-N.xml → one shard of published non-reply posts
|
|
8
|
+
* /sitemap-collections.xml → public collection pages
|
|
9
|
+
* /sitemap-pages.xml → homepage + static aggregate pages
|
|
10
|
+
*
|
|
11
|
+
* Post shards are keyset-paginated by post `id` (TypeIDs embed a
|
|
12
|
+
* creation-ordered UUIDv7 timestamp), so once a shard fills up its membership
|
|
13
|
+
* never changes: new posts always land in the last shard, never rewriting an
|
|
14
|
+
* older one. This lets old shards be cached at the edge for a long time.
|
|
3
15
|
*/
|
|
4
16
|
|
|
5
17
|
import { Hono } from "hono";
|
|
6
18
|
import type { Bindings } from "../../types.js";
|
|
7
19
|
import type { AppVariables } from "../../types/app-context.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
20
|
+
import {
|
|
21
|
+
renderSitemapIndex,
|
|
22
|
+
renderSitemapUrlSet,
|
|
23
|
+
SITEMAP_SHARD_SIZE,
|
|
24
|
+
type SitemapIndexEntry,
|
|
25
|
+
type SitemapUrlEntry,
|
|
26
|
+
} from "../../lib/feed.js";
|
|
10
27
|
import { toAbsoluteSiteUrl } from "../../lib/url.js";
|
|
11
28
|
|
|
12
29
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
13
30
|
|
|
14
31
|
export const sitemapRoutes = new Hono<Env>();
|
|
15
32
|
|
|
16
|
-
|
|
33
|
+
const CACHE_SHORT = "public, max-age=180";
|
|
34
|
+
const CACHE_FULL_SHARD = "public, max-age=86400, s-maxage=86400";
|
|
35
|
+
|
|
36
|
+
function xmlResponse(xml: string, cacheControl: string): Response {
|
|
37
|
+
return new Response(xml, {
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
40
|
+
"Cache-Control": cacheControl,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a public URL entry (absolute URL) from an internal path.
|
|
47
|
+
*/
|
|
48
|
+
function absoluteUrl(
|
|
49
|
+
internalPath: string,
|
|
50
|
+
siteUrl: string,
|
|
51
|
+
sitePathPrefix: string,
|
|
52
|
+
): string {
|
|
53
|
+
return toAbsoluteSiteUrl(internalPath, siteUrl, sitePathPrefix);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Convert a unix-seconds timestamp into a `YYYY-MM-DD` string. */
|
|
57
|
+
function toIsoDate(unixSeconds: number): string {
|
|
58
|
+
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// =============================================================================
|
|
62
|
+
// Sitemap Index
|
|
63
|
+
// =============================================================================
|
|
64
|
+
|
|
17
65
|
sitemapRoutes.get("/sitemap.xml", async (c) => {
|
|
18
66
|
const { appConfig } = c.var;
|
|
19
|
-
const siteUrl = appConfig
|
|
67
|
+
const { siteUrl, sitePathPrefix } = appConfig;
|
|
20
68
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
excludeReplies: true,
|
|
24
|
-
excludePrivate: true,
|
|
25
|
-
limit: 1000,
|
|
26
|
-
});
|
|
69
|
+
const postCount = await c.var.services.posts.countForSitemap();
|
|
70
|
+
const postShardCount = Math.max(1, Math.ceil(postCount / SITEMAP_SHARD_SIZE));
|
|
27
71
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
72
|
+
const entries: SitemapIndexEntry[] = [
|
|
73
|
+
{ loc: absoluteUrl("/sitemap-pages.xml", siteUrl, sitePathPrefix) },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Only include post shards if there's at least one post. When postCount is
|
|
77
|
+
// 0 we still list `sitemap-posts-1.xml` so the site always has a posts
|
|
78
|
+
// shard — the renderer emits an empty <urlset>, which is valid.
|
|
79
|
+
for (let page = 1; page <= postShardCount; page++) {
|
|
80
|
+
entries.push({
|
|
81
|
+
loc: absoluteUrl(`/sitemap-posts-${page}.xml`, siteUrl, sitePathPrefix),
|
|
82
|
+
});
|
|
36
83
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
84
|
+
|
|
85
|
+
const collections = await c.var.services.collections.list();
|
|
86
|
+
if (collections.length > 0) {
|
|
87
|
+
entries.push({
|
|
88
|
+
loc: absoluteUrl("/sitemap-collections.xml", siteUrl, sitePathPrefix),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return xmlResponse(renderSitemapIndex(entries), CACHE_SHORT);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// =============================================================================
|
|
96
|
+
// Post Shards
|
|
97
|
+
// =============================================================================
|
|
98
|
+
|
|
99
|
+
// Hono's path parser does not allow a param alongside a literal prefix in
|
|
100
|
+
// the same segment (e.g. `/sitemap-posts-:page.xml` does not match). The
|
|
101
|
+
// param must own the whole segment, so we match the full filename with a
|
|
102
|
+
// regex and parse the page number out inside the handler.
|
|
103
|
+
sitemapRoutes.get("/:file{sitemap-posts-[0-9]+\\.xml}", async (c) => {
|
|
104
|
+
const { appConfig } = c.var;
|
|
105
|
+
const { siteUrl, sitePathPrefix } = appConfig;
|
|
106
|
+
const file = c.req.param("file");
|
|
107
|
+
const match = /^sitemap-posts-([0-9]+)\.xml$/.exec(file);
|
|
108
|
+
if (!match) return c.notFound();
|
|
109
|
+
const page = Number(match[1]);
|
|
110
|
+
if (!Number.isFinite(page) || page < 1) return c.notFound();
|
|
111
|
+
|
|
112
|
+
// Keyset cursor: for page N (>1) we want the id just before the shard's
|
|
113
|
+
// first row, so `listForSitemap({ afterId })` returns the shard. For page
|
|
114
|
+
// 1 there is no cursor.
|
|
115
|
+
let afterId: string | undefined;
|
|
116
|
+
if (page > 1) {
|
|
117
|
+
const cursorOffset = (page - 1) * SITEMAP_SHARD_SIZE - 1;
|
|
118
|
+
const cursor = await c.var.services.posts.getSitemapIdAt(cursorOffset);
|
|
119
|
+
if (cursor === null) return c.notFound();
|
|
120
|
+
afterId = cursor;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const shardEntries = await c.var.services.posts.listForSitemap({
|
|
124
|
+
afterId,
|
|
125
|
+
limit: SITEMAP_SHARD_SIZE,
|
|
47
126
|
});
|
|
48
127
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
128
|
+
const urls: SitemapUrlEntry[] = shardEntries.map((entry) => {
|
|
129
|
+
// `entry.alias` already includes a leading "/" (see
|
|
130
|
+
// `paths.getPostAliases`); slugs are stored raw. Prepending "/" to an
|
|
131
|
+
// alias would create "//path" which `new URL()` interprets as
|
|
132
|
+
// protocol-relative and hijacks the hostname.
|
|
133
|
+
const path = entry.alias ?? `/${entry.slug}`;
|
|
134
|
+
return {
|
|
135
|
+
loc: absoluteUrl(path, siteUrl, sitePathPrefix),
|
|
136
|
+
lastmod: toIsoDate(entry.updatedAt),
|
|
137
|
+
priority: entry.featuredAt ? "0.8" : "0.6",
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// The last (not-yet-filled) shard needs short caching because new posts
|
|
142
|
+
// will append to it. Full shards are immutable in membership and can be
|
|
143
|
+
// cached aggressively — only a post edit inside them moves `<lastmod>`,
|
|
144
|
+
// which is acceptable sitemap staleness.
|
|
145
|
+
const isFullShard = shardEntries.length === SITEMAP_SHARD_SIZE;
|
|
146
|
+
const cacheControl = isFullShard ? CACHE_FULL_SHARD : CACHE_SHORT;
|
|
147
|
+
|
|
148
|
+
return xmlResponse(renderSitemapUrlSet(urls), cacheControl);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// =============================================================================
|
|
152
|
+
// Collections Shard
|
|
153
|
+
// =============================================================================
|
|
154
|
+
|
|
155
|
+
sitemapRoutes.get("/sitemap-collections.xml", async (c) => {
|
|
156
|
+
const { appConfig } = c.var;
|
|
157
|
+
const { siteUrl, sitePathPrefix } = appConfig;
|
|
158
|
+
|
|
159
|
+
const collections = await c.var.services.collections.list();
|
|
160
|
+
|
|
161
|
+
// Resolve each collection's canonical URL (alias if one exists, else slug).
|
|
162
|
+
// The `/collections` directory itself lives in `/sitemap-pages.xml`, since
|
|
163
|
+
// it's a static aggregate page rather than per-collection content.
|
|
164
|
+
const urls: SitemapUrlEntry[] = await Promise.all(
|
|
165
|
+
collections.map(async (collection) => {
|
|
166
|
+
const alias = await c.var.services.customUrls.getByTarget(
|
|
167
|
+
"collection",
|
|
168
|
+
collection.id,
|
|
169
|
+
);
|
|
170
|
+
const path = alias ? `/${alias.path}` : `/${collection.slug}`;
|
|
171
|
+
return {
|
|
172
|
+
loc: absoluteUrl(path, siteUrl, sitePathPrefix),
|
|
173
|
+
lastmod: toIsoDate(collection.updatedAt),
|
|
174
|
+
priority: "0.7",
|
|
175
|
+
};
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return xmlResponse(renderSitemapUrlSet(urls), CACHE_SHORT);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// =============================================================================
|
|
183
|
+
// Static Pages Shard (homepage)
|
|
184
|
+
// =============================================================================
|
|
185
|
+
|
|
186
|
+
sitemapRoutes.get("/sitemap-pages.xml", async (c) => {
|
|
187
|
+
const { appConfig } = c.var;
|
|
188
|
+
const { siteUrl, sitePathPrefix, homeDefaultView } = appConfig;
|
|
189
|
+
|
|
190
|
+
const urls: SitemapUrlEntry[] = [
|
|
191
|
+
{
|
|
192
|
+
loc: absoluteUrl("/", siteUrl, sitePathPrefix),
|
|
193
|
+
priority: "1.0",
|
|
194
|
+
changefreq: "daily",
|
|
53
195
|
},
|
|
196
|
+
{
|
|
197
|
+
loc: absoluteUrl("/archive", siteUrl, sitePathPrefix),
|
|
198
|
+
priority: "0.5",
|
|
199
|
+
changefreq: "weekly",
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
// Whichever of /latest and /featured is NOT the homepage default is a
|
|
204
|
+
// standalone URL worth indexing; the other 302-redirects to `/`.
|
|
205
|
+
const secondaryAggregate =
|
|
206
|
+
homeDefaultView === "featured" ? "/latest" : "/featured";
|
|
207
|
+
urls.push({
|
|
208
|
+
loc: absoluteUrl(secondaryAggregate, siteUrl, sitePathPrefix),
|
|
209
|
+
priority: "0.6",
|
|
210
|
+
changefreq: "daily",
|
|
54
211
|
});
|
|
212
|
+
|
|
213
|
+
// Include the collections directory landing page when at least one
|
|
214
|
+
// collection exists. When there are no collections, `/collections` still
|
|
215
|
+
// renders (as an empty directory), but indexing an empty aggregate page
|
|
216
|
+
// adds no value.
|
|
217
|
+
const collections = await c.var.services.collections.list();
|
|
218
|
+
if (collections.length > 0) {
|
|
219
|
+
urls.push({
|
|
220
|
+
loc: absoluteUrl("/collections", siteUrl, sitePathPrefix),
|
|
221
|
+
priority: "0.5",
|
|
222
|
+
changefreq: "weekly",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return xmlResponse(renderSitemapUrlSet(urls), CACHE_SHORT);
|
|
55
227
|
});
|
|
56
228
|
|
|
229
|
+
// =============================================================================
|
|
57
230
|
// robots.txt
|
|
231
|
+
// =============================================================================
|
|
232
|
+
|
|
58
233
|
sitemapRoutes.get("/robots.txt", async (c) => {
|
|
59
234
|
const { appConfig } = c.var;
|
|
60
235
|
const siteUrl = appConfig.siteUrl;
|
|
@@ -74,7 +249,7 @@ sitemapRoutes.get("/robots.txt", async (c) => {
|
|
|
74
249
|
return new Response(robots, {
|
|
75
250
|
headers: {
|
|
76
251
|
"Content-Type": "text/plain; charset=utf-8",
|
|
77
|
-
"Cache-Control":
|
|
252
|
+
"Cache-Control": CACHE_SHORT,
|
|
78
253
|
},
|
|
79
254
|
});
|
|
80
255
|
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `<link rel="canonical">` tag on post pages.
|
|
3
|
+
*
|
|
4
|
+
* Reply URLs render the full thread, so each reply URL would otherwise look
|
|
5
|
+
* like duplicate content to crawlers. The canonical tag points every page in
|
|
6
|
+
* the thread back to the thread root.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
11
|
+
import { pageRoutes } from "../page.js";
|
|
12
|
+
|
|
13
|
+
function createPageTestApp() {
|
|
14
|
+
const testApp = createTestApp();
|
|
15
|
+
const { app } = testApp;
|
|
16
|
+
|
|
17
|
+
app.use("*", async (c, next) => {
|
|
18
|
+
c.set("publicPath", c.req.path);
|
|
19
|
+
c.set("publicRequestUrl", c.req.url);
|
|
20
|
+
await next();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.route("/", pageRoutes);
|
|
24
|
+
|
|
25
|
+
return testApp;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractCanonicalHref(html: string): string | null {
|
|
29
|
+
const match = html.match(/<link\s+rel="canonical"\s+href="([^"]+)"\s*\/?>/i);
|
|
30
|
+
return match?.[1] ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("Post page canonical link", () => {
|
|
34
|
+
it("root post canonical points at its own permalink", async () => {
|
|
35
|
+
const { app, services } = createPageTestApp();
|
|
36
|
+
|
|
37
|
+
const root = await services.posts.create({
|
|
38
|
+
format: "note",
|
|
39
|
+
title: "Root post",
|
|
40
|
+
bodyMarkdown: "Root body",
|
|
41
|
+
status: "published",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const res = await app.request(`/${root.slug}`);
|
|
45
|
+
expect(res.status).toBe(200);
|
|
46
|
+
|
|
47
|
+
const html = await res.text();
|
|
48
|
+
const canonical = extractCanonicalHref(html);
|
|
49
|
+
expect(canonical).not.toBeNull();
|
|
50
|
+
expect(canonical).toMatch(new RegExp(`/${root.slug}$`));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("reply canonical points back to the thread root", async () => {
|
|
54
|
+
const { app, services } = createPageTestApp();
|
|
55
|
+
|
|
56
|
+
const root = await services.posts.create({
|
|
57
|
+
format: "note",
|
|
58
|
+
title: "Thread root",
|
|
59
|
+
bodyMarkdown: "Root body",
|
|
60
|
+
status: "published",
|
|
61
|
+
});
|
|
62
|
+
const reply = await services.posts.create({
|
|
63
|
+
format: "note",
|
|
64
|
+
bodyMarkdown: "Reply body",
|
|
65
|
+
replyToId: root.id,
|
|
66
|
+
status: "published",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Visiting the reply URL should canonicalize to the root URL.
|
|
70
|
+
const replyRes = await app.request(`/${reply.slug}`);
|
|
71
|
+
expect(replyRes.status).toBe(200);
|
|
72
|
+
|
|
73
|
+
const replyHtml = await replyRes.text();
|
|
74
|
+
const replyCanonical = extractCanonicalHref(replyHtml);
|
|
75
|
+
expect(replyCanonical).not.toBeNull();
|
|
76
|
+
expect(replyCanonical).toMatch(new RegExp(`/${root.slug}$`));
|
|
77
|
+
expect(replyCanonical).not.toMatch(new RegExp(`/${reply.slug}$`));
|
|
78
|
+
|
|
79
|
+
// And the root URL should canonicalize to itself.
|
|
80
|
+
const rootRes = await app.request(`/${root.slug}`);
|
|
81
|
+
const rootCanonical = extractCanonicalHref(await rootRes.text());
|
|
82
|
+
expect(rootCanonical).toMatch(new RegExp(`/${root.slug}$`));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("canonical is absolute when siteUrl is configured", async () => {
|
|
86
|
+
const { app, services } = createPageTestApp();
|
|
87
|
+
|
|
88
|
+
const post = await services.posts.create({
|
|
89
|
+
format: "note",
|
|
90
|
+
title: "Absolute test",
|
|
91
|
+
bodyMarkdown: "Body",
|
|
92
|
+
status: "published",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const res = await app.request(`/${post.slug}`);
|
|
96
|
+
const canonical = extractCanonicalHref(await res.text());
|
|
97
|
+
// SITE_ORIGIN in the test harness is http://localhost:<port>
|
|
98
|
+
expect(canonical).toMatch(/^https?:\/\//);
|
|
99
|
+
expect(canonical).toContain(`/${post.slug}`);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -12,7 +12,10 @@ import { Hono } from "hono";
|
|
|
12
12
|
import { msg } from "@lingui/core/macro";
|
|
13
13
|
import type { Bindings } from "../../types.js";
|
|
14
14
|
import type { AppVariables } from "../../types/app-context.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
getHomeDefaultViewFromNavItems,
|
|
17
|
+
getNavigationData,
|
|
18
|
+
} from "../../lib/navigation.js";
|
|
16
19
|
import { getI18n } from "../../i18n/index.js";
|
|
17
20
|
import { formatPageLabel, parsePageNumber } from "../../lib/pagination.js";
|
|
18
21
|
import { buildPageTitle } from "../../lib/page-title.js";
|
|
@@ -30,25 +33,36 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
|
30
33
|
export const homeRoutes = new Hono<Env>();
|
|
31
34
|
|
|
32
35
|
homeRoutes.get("/", async (c) => {
|
|
33
|
-
const navData = await getNavigationData(c);
|
|
34
36
|
const i18n = getI18n(c);
|
|
35
37
|
const page = parsePageNumber(c.req.query("page"));
|
|
36
38
|
const paginatedPageTitle = formatPageLabel(page);
|
|
39
|
+
const isAuthenticated = c.var.isAuthenticated;
|
|
40
|
+
|
|
41
|
+
// Fetch nav items once — we need `homeDefaultView` to decide which timeline
|
|
42
|
+
// to assemble, but `getNavigationData` also consumes them. Passing them
|
|
43
|
+
// through avoids a duplicate DB query and unlocks the Promise.all below.
|
|
44
|
+
const navItems = await c.var.services.navItems.list();
|
|
45
|
+
const homeDefaultView = getHomeDefaultViewFromNavItems(navItems);
|
|
46
|
+
|
|
47
|
+
const timelinePromise =
|
|
48
|
+
homeDefaultView === "featured"
|
|
49
|
+
? assembleFeaturedTimeline(c, { page, isAuthenticated })
|
|
50
|
+
: assembleTimeline(c, { page, isAuthenticated });
|
|
51
|
+
|
|
52
|
+
const [navData, timeline] = await Promise.all([
|
|
53
|
+
getNavigationData(c, { preloadedItems: navItems }),
|
|
54
|
+
timelinePromise,
|
|
55
|
+
]);
|
|
37
56
|
|
|
38
|
-
|
|
57
|
+
const { items, currentPage, totalPages } = timeline;
|
|
58
|
+
|
|
59
|
+
if (homeDefaultView === "featured") {
|
|
39
60
|
const featuredTitle = i18n._(
|
|
40
61
|
msg({
|
|
41
62
|
message: "Featured",
|
|
42
63
|
comment: "@context: Browser page title for the featured feed",
|
|
43
64
|
}),
|
|
44
65
|
);
|
|
45
|
-
const { items, currentPage, totalPages } = await assembleFeaturedTimeline(
|
|
46
|
-
c,
|
|
47
|
-
{
|
|
48
|
-
page,
|
|
49
|
-
isAuthenticated: navData.isAuthenticated,
|
|
50
|
-
},
|
|
51
|
-
);
|
|
52
66
|
|
|
53
67
|
return renderPublicPage(c, {
|
|
54
68
|
title:
|
|
@@ -76,11 +90,6 @@ homeRoutes.get("/", async (c) => {
|
|
|
76
90
|
}),
|
|
77
91
|
);
|
|
78
92
|
|
|
79
|
-
const { items, currentPage, totalPages } = await assembleTimeline(c, {
|
|
80
|
-
page,
|
|
81
|
-
isAuthenticated: navData.isAuthenticated,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
93
|
return renderPublicPage(c, {
|
|
85
94
|
title:
|
|
86
95
|
page > 1
|
|
@@ -33,6 +33,28 @@ interface TextPreviewAutoOpen {
|
|
|
33
33
|
mediaId: string;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Build the canonical absolute URL for a post page.
|
|
38
|
+
*
|
|
39
|
+
* Reply URLs render the full thread, so search engines see overlapping
|
|
40
|
+
* content at every reply URL. Point the canonical to the thread root so
|
|
41
|
+
* crawlers consolidate ranking on one URL.
|
|
42
|
+
*
|
|
43
|
+
* The root post is always at index 0 of `threadPostViews` (getThread orders
|
|
44
|
+
* by createdAt ASC, and the DB check constraint guarantees root has the
|
|
45
|
+
* smallest createdAt in its thread). When `threadPostViews` is undefined the
|
|
46
|
+
* post is not part of a multi-post thread, so the post itself is the root.
|
|
47
|
+
*/
|
|
48
|
+
function buildPostCanonicalHref(
|
|
49
|
+
postView: { permalink: string },
|
|
50
|
+
threadPostViews: Array<{ permalink: string }> | undefined,
|
|
51
|
+
siteUrl: string,
|
|
52
|
+
): string {
|
|
53
|
+
const rootPermalink = threadPostViews?.[0]?.permalink ?? postView.permalink;
|
|
54
|
+
if (!siteUrl) return rootPermalink;
|
|
55
|
+
return new URL(rootPermalink, siteUrl).toString();
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
async function renderPostWithTextPreview(
|
|
37
59
|
c: Context<Env>,
|
|
38
60
|
post: Post,
|
|
@@ -48,6 +70,11 @@ async function renderPostWithTextPreview(
|
|
|
48
70
|
|
|
49
71
|
const navData = await navDataPromise;
|
|
50
72
|
const meta = buildPostMeta(post, navData.siteName);
|
|
73
|
+
const canonicalHref = buildPostCanonicalHref(
|
|
74
|
+
display.postView,
|
|
75
|
+
display.threadPostViews,
|
|
76
|
+
c.var.appConfig.siteUrl,
|
|
77
|
+
);
|
|
51
78
|
|
|
52
79
|
// Use the attachment summary as the page title (for OG/link previews),
|
|
53
80
|
// and pass the post title in the payload so the client can restore it
|
|
@@ -68,6 +95,7 @@ async function renderPostWithTextPreview(
|
|
|
68
95
|
return renderPublicPage(c, {
|
|
69
96
|
title: pageTitle,
|
|
70
97
|
description: meta.description,
|
|
98
|
+
canonicalHref,
|
|
71
99
|
navData,
|
|
72
100
|
content: (
|
|
73
101
|
<>
|
|
@@ -127,10 +155,16 @@ async function renderPost(c: Context<Env>, post: Post) {
|
|
|
127
155
|
|
|
128
156
|
const navData = await navDataPromise;
|
|
129
157
|
const meta = buildPostMeta(post, navData.siteName);
|
|
158
|
+
const canonicalHref = buildPostCanonicalHref(
|
|
159
|
+
display.postView,
|
|
160
|
+
display.threadPostViews,
|
|
161
|
+
c.var.appConfig.siteUrl,
|
|
162
|
+
);
|
|
130
163
|
|
|
131
164
|
return renderPublicPage(c, {
|
|
132
165
|
title: meta.title,
|
|
133
166
|
description: meta.description,
|
|
167
|
+
canonicalHref,
|
|
134
168
|
navData,
|
|
135
169
|
content: (
|
|
136
170
|
<PostPage post={display.postView} threadPosts={display.threadPostViews} />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Hono
|
|
1
|
+
import { Hono } from "hono";
|
|
2
2
|
import { I18nProvider } from "../../i18n/index.js";
|
|
3
3
|
import { parseIdParam } from "../../lib/errors.js";
|
|
4
4
|
import { ID_PREFIX } from "../../lib/ids.js";
|
|
@@ -18,17 +18,6 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
|
18
18
|
|
|
19
19
|
export const partialPageRoutes = new Hono<Env>();
|
|
20
20
|
|
|
21
|
-
async function getIsAuthenticated(c: Context<Env>): Promise<boolean> {
|
|
22
|
-
try {
|
|
23
|
-
const session = await c.var.auth.api.getSession({
|
|
24
|
-
headers: c.req.raw.headers,
|
|
25
|
-
});
|
|
26
|
-
return !!session?.user;
|
|
27
|
-
} catch {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
21
|
partialPageRoutes.get("/_/version", (c) => {
|
|
33
22
|
return c.json({ version: CORE_VERSION });
|
|
34
23
|
});
|
|
@@ -39,7 +28,7 @@ partialPageRoutes.get("/_/timeline-item/:threadRootId", async (c) => {
|
|
|
39
28
|
ID_PREFIX.post,
|
|
40
29
|
);
|
|
41
30
|
const item = await assembleTimelineItem(c, threadRootId, {
|
|
42
|
-
isAuthenticated:
|
|
31
|
+
isAuthenticated: c.var.isAuthenticated,
|
|
43
32
|
});
|
|
44
33
|
|
|
45
34
|
if (!item) {
|
|
@@ -56,7 +45,7 @@ partialPageRoutes.get("/_/timeline-item/:threadRootId", async (c) => {
|
|
|
56
45
|
partialPageRoutes.get("/_/post-card/:postId", async (c) => {
|
|
57
46
|
const postId = parseIdParam(c.req.param("postId"), ID_PREFIX.post);
|
|
58
47
|
const postView = await assemblePostCardView(c, postId, {
|
|
59
|
-
isAuthenticated:
|
|
48
|
+
isAuthenticated: c.var.isAuthenticated,
|
|
60
49
|
});
|
|
61
50
|
|
|
62
51
|
if (!postView) {
|
|
@@ -73,7 +62,7 @@ partialPageRoutes.get("/_/post-card/:postId", async (c) => {
|
|
|
73
62
|
partialPageRoutes.get("/_/post-view/:postId", async (c) => {
|
|
74
63
|
const postId = parseIdParam(c.req.param("postId"), ID_PREFIX.post);
|
|
75
64
|
const display = await assemblePostPageDisplay(c, postId, {
|
|
76
|
-
isAuthenticated:
|
|
65
|
+
isAuthenticated: c.var.isAuthenticated,
|
|
77
66
|
});
|
|
78
67
|
|
|
79
68
|
if (!display) {
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
shouldUseSecureCookies,
|
|
11
11
|
} from "../lib/env.js";
|
|
12
12
|
import { createHostedControlPlaneClient } from "../lib/hosted-control-plane.js";
|
|
13
|
+
import { createD1RateLimiter } from "../lib/rate-limit-d1.js";
|
|
14
|
+
import type { RateLimiter } from "../lib/rate-limit.js";
|
|
13
15
|
import { createStorageDriver, type StorageDriver } from "../lib/storage.js";
|
|
14
16
|
import {
|
|
15
17
|
createHostedHandoffService,
|
|
@@ -30,6 +32,7 @@ export interface CloudflareRequestRuntime {
|
|
|
30
32
|
currentSiteDomain: SiteDomain | null;
|
|
31
33
|
db: Database;
|
|
32
34
|
hostedHandoff: HostedHandoffService;
|
|
35
|
+
rateLimiter: RateLimiter;
|
|
33
36
|
services: Services;
|
|
34
37
|
storage: StorageDriver | null;
|
|
35
38
|
}
|
|
@@ -88,6 +91,7 @@ export async function createCloudflareRequestRuntime(
|
|
|
88
91
|
schema: sqliteSchemaBundle,
|
|
89
92
|
secret: hostedControlPlaneSsoSecret,
|
|
90
93
|
}),
|
|
94
|
+
rateLimiter: createD1RateLimiter(db, sqliteSchemaBundle),
|
|
91
95
|
services: createServices(db, session, siteLookup.site.id, {
|
|
92
96
|
databaseDialect: "sqlite",
|
|
93
97
|
bootstrapSite: getSingleSiteBootstrapOptions(env),
|
package/src/runtime/node.ts
CHANGED
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
shouldUseSecureCookies,
|
|
13
13
|
} from "../lib/env.js";
|
|
14
14
|
import { createHostedControlPlaneClient } from "../lib/hosted-control-plane.js";
|
|
15
|
+
import { createMemoryRateLimiter } from "../lib/rate-limit-memory.js";
|
|
16
|
+
import type { RateLimiter } from "../lib/rate-limit.js";
|
|
15
17
|
import { createStorageDriver, type StorageDriver } from "../lib/storage.js";
|
|
16
18
|
import {
|
|
17
19
|
createHostedHandoffService,
|
|
@@ -33,10 +35,23 @@ export interface NodeRequestRuntime {
|
|
|
33
35
|
currentSiteDomain: SiteDomain | null;
|
|
34
36
|
db: Database;
|
|
35
37
|
hostedHandoff: HostedHandoffService;
|
|
38
|
+
rateLimiter: RateLimiter;
|
|
36
39
|
services: Services;
|
|
37
40
|
storage: StorageDriver | null;
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Single process-wide rate limiter for the Node runtime. Node serves all
|
|
45
|
+
* requests out of one persistent process, so in-memory counters are
|
|
46
|
+
* reliable and avoid per-request D1 round-trips. Constructed lazily on
|
|
47
|
+
* first use so tests that never build a request runtime don't pay for it.
|
|
48
|
+
*/
|
|
49
|
+
let sharedNodeRateLimiter: RateLimiter | null = null;
|
|
50
|
+
function getNodeRateLimiter(): RateLimiter {
|
|
51
|
+
sharedNodeRateLimiter ??= createMemoryRateLimiter();
|
|
52
|
+
return sharedNodeRateLimiter;
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
export interface NodeCliRuntime {
|
|
41
56
|
currentSite: Site;
|
|
42
57
|
currentSiteDomain: SiteDomain | null;
|
|
@@ -131,6 +146,7 @@ export async function createNodeRequestRuntime(
|
|
|
131
146
|
schema: databaseSchema,
|
|
132
147
|
secret: hostedControlPlaneSsoSecret,
|
|
133
148
|
}),
|
|
149
|
+
rateLimiter: getNodeRateLimiter(),
|
|
134
150
|
services: createServices(db, rawQuery, siteLookup.site.id, {
|
|
135
151
|
databaseDialect,
|
|
136
152
|
bootstrapSite: getSingleSiteBootstrapOptions(env),
|