@jant/core 0.6.11 → 0.6.13
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 +47 -30
- package/bin/commands/migrate-text-attachments.js +1 -3
- package/bin/commands/site/export.js +1 -3
- package/bin/commands/telegram/register-webhooks.js +9 -3
- package/bin/lib/site-snapshot.js +1 -5
- package/dist/app-CWJFPjuR.js +6 -0
- package/dist/{app-CpmficmQ.js → app-HnzmsaqU.js} +721 -473
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/{client-Dd9U383b.js → client-3_OaxhTs.js} +1 -1
- package/dist/client/_assets/client-DCrcHVJl.css +2 -0
- package/dist/client/_assets/{client-auth-DkpSdIDz.js → client-auth-TsYq90xm.js} +625 -561
- package/dist/{export-Ba7NJImL.js → export-Dfbq9aot.js} +12 -15
- package/dist/{github-sync-Cb4_6_i7.js → github-sync--PSoE-Ne.js} +1 -1
- package/dist/{github-sync-BD4w2m8-.js → github-sync-BGTLx8Mf.js} +2 -2
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/__tests__/export-service.test.ts +1 -2
- package/src/__tests__/helpers/app.ts +3 -0
- package/src/__tests__/helpers/export-fixtures.ts +0 -1
- package/src/__tests__/import-site-command.test.ts +76 -2
- package/src/__tests__/vite-dev-plugins.test.ts +34 -0
- package/src/client/__tests__/compose-launch.test.ts +50 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
- package/src/client/__tests__/site-header-nav.test.ts +36 -2
- package/src/client/components/__tests__/compose-title-from-heading.test.ts +57 -0
- package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +20 -1
- package/src/client/components/__tests__/jant-compose-editor.test.ts +94 -2
- package/src/client/components/__tests__/jant-nav-manager.test.ts +181 -20
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +72 -0
- package/src/client/components/compose-title-from-heading.ts +66 -0
- package/src/client/components/compose-types.ts +8 -0
- package/src/client/components/inline-image-issues.ts +17 -0
- package/src/client/components/jant-command-palette.ts +67 -2
- package/src/client/components/jant-compose-dialog.ts +10 -2
- package/src/client/components/jant-compose-editor.ts +78 -1
- package/src/client/components/jant-compose-fullscreen.ts +2 -0
- package/src/client/components/jant-nav-manager.ts +382 -152
- package/src/client/components/jant-settings-general.ts +56 -0
- package/src/client/components/nav-manager-types.ts +14 -10
- package/src/client/components/settings-types.ts +30 -0
- package/src/client/compose-launch.ts +30 -0
- package/src/client/compose-shortcuts.ts +46 -3
- package/src/client/site-header-nav.d.ts +1 -0
- package/src/client/site-header-nav.js +105 -47
- package/src/client/tiptap/__tests__/block-insertion.test.ts +35 -0
- package/src/client/tiptap/__tests__/image-input-rules.test.ts +117 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +66 -13
- package/src/client/tiptap/create-editor.ts +7 -0
- package/src/client/tiptap/extensions.ts +7 -2
- package/src/client/tiptap/image-input-rules.ts +48 -0
- package/src/client/tiptap/image-node.ts +189 -7
- package/src/client/tiptap/rehost-images.ts +5 -1
- package/src/client-auth.ts +0 -1
- package/src/i18n/__tests__/fallback.test.ts +24 -0
- package/src/i18n/locales/public/en.po +20 -4
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +22 -6
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +22 -6
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +77 -20
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +85 -28
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +79 -22
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +96 -10
- package/src/lib/__tests__/navigation.test.ts +4 -37
- package/src/lib/__tests__/resolve-config.test.ts +6 -1
- package/src/lib/__tests__/view.test.ts +6 -30
- package/src/lib/feed.ts +63 -3
- package/src/lib/github-sync-site-config.ts +0 -2
- package/src/lib/hugo-markdown.ts +1 -1
- package/src/lib/navigation.ts +5 -22
- package/src/lib/post-display.ts +14 -8
- package/src/lib/resolve-config.ts +9 -2
- package/src/lib/site-header-fragment.tsx +37 -0
- package/src/lib/view.ts +2 -7
- package/src/node/__tests__/cli-site-snapshot.test.ts +45 -45
- package/src/preset.css +60 -18
- package/src/routes/api/__tests__/nav-items.test.ts +26 -0
- package/src/routes/api/export.ts +0 -2
- package/src/routes/api/internal/__tests__/uploads.test.ts +9 -13
- package/src/routes/api/internal/sites.ts +5 -5
- package/src/routes/api/nav-items.ts +25 -4
- package/src/routes/dash/settings.tsx +48 -3
- package/src/routes/feed/__tests__/feed.test.ts +42 -0
- package/src/routes/feed/__tests__/sitemap.test.ts +2 -13
- package/src/routes/feed/feed.ts +1 -0
- package/src/routes/feed/sitemap.ts +2 -6
- package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
- package/src/routes/pages/archive.tsx +1 -0
- package/src/routes/pages/collection.tsx +1 -0
- package/src/routes/pages/featured.tsx +0 -4
- package/src/routes/pages/home.tsx +4 -51
- package/src/routes/pages/latest.tsx +2 -51
- package/src/routes/pages/page.tsx +44 -12
- package/src/services/__tests__/about-page.test.ts +116 -0
- package/src/services/__tests__/github-app-installations.test.ts +10 -10
- package/src/services/__tests__/navigation.test.ts +231 -1
- package/src/services/__tests__/post-timeline.test.ts +175 -2
- package/src/services/__tests__/post.test.ts +33 -0
- package/src/services/__tests__/settings.test.ts +5 -20
- package/src/services/about-page.ts +143 -0
- package/src/services/export-theme/assets/client-site.js +10 -10
- package/src/services/export-theme/layouts/_default/rss.xml +7 -16
- package/src/services/export-theme/layouts/index.html +26 -39
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
- package/src/services/export-theme/styles/main.css +46 -14
- package/src/services/export.ts +5 -10
- package/src/services/github-app-installations.ts +1 -3
- package/src/services/index.ts +27 -17
- package/src/services/navigation.ts +186 -1
- package/src/services/post.ts +103 -63
- package/src/services/settings.ts +0 -10
- package/src/services/site-admin.ts +0 -2
- package/src/services/upload-session.ts +4 -18
- package/src/styles/components.css +20 -0
- package/src/styles/tokens.css +28 -10
- package/src/styles/ui.css +349 -13
- package/src/types/bindings.ts +0 -1
- package/src/types/config.ts +3 -7
- package/src/types/views.ts +13 -0
- package/src/ui/compose/ComposeDialog.tsx +28 -0
- package/src/ui/dash/appearance/NavigationContent.tsx +58 -1
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/GeneralContent.tsx +44 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
- package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
- package/src/ui/layouts/SiteLayout.tsx +249 -216
- package/dist/app-DqKkZenB.js +0 -6
- package/dist/client/_assets/client-BhHHVvSY.css +0 -2
- package/src/client/nav-manager-bridge.ts +0 -54
|
@@ -3,31 +3,21 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Timeline feed with per-type card components and thread previews.
|
|
5
5
|
* Uses page-based pagination.
|
|
6
|
-
*
|
|
7
|
-
* The homepage shows whichever built-in feed comes first between
|
|
8
|
-
* Latest and Featured in navigation. The explicit feed routes still work.
|
|
9
6
|
*/
|
|
10
7
|
|
|
11
8
|
import { Hono } from "hono";
|
|
12
9
|
import { msg } from "@lingui/core/macro";
|
|
13
10
|
import type { Bindings } from "../../types.js";
|
|
14
11
|
import type { AppVariables } from "../../types/app-context.js";
|
|
15
|
-
import {
|
|
16
|
-
getHomeDefaultViewFromNavItems,
|
|
17
|
-
getNavigationData,
|
|
18
|
-
} from "../../lib/navigation.js";
|
|
12
|
+
import { getNavigationData } from "../../lib/navigation.js";
|
|
19
13
|
import { getI18n } from "../../i18n/index.js";
|
|
20
14
|
import { formatPageLabel, parsePageNumber } from "../../lib/pagination.js";
|
|
21
15
|
import { buildPageTitle } from "../../lib/page-title.js";
|
|
22
16
|
import { renderPublicPage } from "../../lib/render.js";
|
|
23
|
-
import {
|
|
24
|
-
assembleFeaturedTimeline,
|
|
25
|
-
assembleTimeline,
|
|
26
|
-
} from "../../lib/timeline.js";
|
|
17
|
+
import { assembleTimeline } from "../../lib/timeline.js";
|
|
27
18
|
import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
|
|
28
19
|
import { buildWebSiteJsonLd } from "../../lib/structured-data.js";
|
|
29
20
|
import { HomePage } from "../../ui/pages/HomePage.js";
|
|
30
|
-
import { FeaturedPage } from "../../ui/pages/FeaturedPage.js";
|
|
31
21
|
|
|
32
22
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
33
23
|
|
|
@@ -39,19 +29,10 @@ homeRoutes.get("/", async (c) => {
|
|
|
39
29
|
const paginatedPageTitle = formatPageLabel(page);
|
|
40
30
|
const isAuthenticated = c.var.isAuthenticated;
|
|
41
31
|
|
|
42
|
-
|
|
43
|
-
// to assemble, but `getNavigationData` also consumes them. Passing them
|
|
44
|
-
// through avoids a duplicate DB query and unlocks the Promise.all below.
|
|
45
|
-
const navItems = await c.var.services.navItems.list();
|
|
46
|
-
const homeDefaultView = getHomeDefaultViewFromNavItems(navItems);
|
|
47
|
-
|
|
48
|
-
const timelinePromise =
|
|
49
|
-
homeDefaultView === "featured"
|
|
50
|
-
? assembleFeaturedTimeline(c, { page, isAuthenticated })
|
|
51
|
-
: assembleTimeline(c, { page, isAuthenticated });
|
|
32
|
+
const timelinePromise = assembleTimeline(c, { page, isAuthenticated });
|
|
52
33
|
|
|
53
34
|
const [navData, timeline] = await Promise.all([
|
|
54
|
-
getNavigationData(c
|
|
35
|
+
getNavigationData(c),
|
|
55
36
|
timelinePromise,
|
|
56
37
|
]);
|
|
57
38
|
|
|
@@ -74,34 +55,6 @@ homeRoutes.get("/", async (c) => {
|
|
|
74
55
|
})
|
|
75
56
|
: undefined;
|
|
76
57
|
|
|
77
|
-
if (homeDefaultView === "featured") {
|
|
78
|
-
const featuredTitle = i18n._(
|
|
79
|
-
msg({
|
|
80
|
-
message: "Featured",
|
|
81
|
-
comment: "@context: Browser page title for the featured feed",
|
|
82
|
-
}),
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
return renderPublicPage(c, {
|
|
86
|
-
title:
|
|
87
|
-
page > 1
|
|
88
|
-
? buildPageTitle(featuredTitle, paginatedPageTitle, navData.siteName)
|
|
89
|
-
: navData.siteName,
|
|
90
|
-
jsonLd: websiteJsonLd,
|
|
91
|
-
navData,
|
|
92
|
-
showHomeBranding:
|
|
93
|
-
c.var.appConfig.showJantBrandingOnHome && currentPage === 1,
|
|
94
|
-
content: (
|
|
95
|
-
<FeaturedPage
|
|
96
|
-
items={items}
|
|
97
|
-
currentPage={currentPage}
|
|
98
|
-
totalPages={totalPages}
|
|
99
|
-
baseUrl={toPublicPath("/", navData.sitePathPrefix)}
|
|
100
|
-
/>
|
|
101
|
-
),
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
58
|
const latestTitle = i18n._(
|
|
106
59
|
msg({
|
|
107
60
|
message: "Latest",
|
|
@@ -1,71 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Latest Page Route
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* When Latest is the homepage default, this redirects to / to avoid
|
|
6
|
-
* duplicate content. When Featured comes first, this serves as the
|
|
7
|
-
* explicit latest view.
|
|
4
|
+
* The homepage is the canonical latest timeline, so /latest redirects to /.
|
|
8
5
|
*/
|
|
9
6
|
|
|
10
7
|
import { Hono } from "hono";
|
|
11
|
-
import { msg } from "@lingui/core/macro";
|
|
12
8
|
import type { Bindings } from "../../types.js";
|
|
13
9
|
import type { AppVariables } from "../../types/app-context.js";
|
|
14
|
-
import { getI18n } from "../../i18n/index.js";
|
|
15
|
-
import { getNavigationData } from "../../lib/navigation.js";
|
|
16
|
-
import { formatPageLabel, parsePageNumber } from "../../lib/pagination.js";
|
|
17
|
-
import { buildPageTitle } from "../../lib/page-title.js";
|
|
18
|
-
import { renderPublicPage } from "../../lib/render.js";
|
|
19
|
-
import { assembleTimeline } from "../../lib/timeline.js";
|
|
20
10
|
import { toPublicPath } from "../../lib/url.js";
|
|
21
11
|
import { defaultFeedRenderer } from "../../lib/feed.js";
|
|
22
12
|
import { buildFeedData, parseFormatQuery, renderFeed } from "../feed/feed.js";
|
|
23
|
-
import { HomePage } from "../../ui/pages/HomePage.js";
|
|
24
13
|
|
|
25
14
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
26
15
|
|
|
27
16
|
export const latestRoutes = new Hono<Env>();
|
|
28
17
|
|
|
29
18
|
latestRoutes.get("/", async (c) => {
|
|
30
|
-
|
|
31
|
-
const i18n = getI18n(c);
|
|
32
|
-
|
|
33
|
-
// When homepage already shows latest, redirect to avoid duplicate content
|
|
34
|
-
if (navData.homeDefaultView !== "featured") {
|
|
35
|
-
return c.redirect(toPublicPath("/", navData.sitePathPrefix), 302);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const page = parsePageNumber(c.req.query("page"));
|
|
39
|
-
const latestTitle = i18n._(
|
|
40
|
-
msg({
|
|
41
|
-
message: "Latest",
|
|
42
|
-
comment: "@context: Browser page title for the latest feed",
|
|
43
|
-
}),
|
|
44
|
-
);
|
|
45
|
-
const paginatedPageTitle = formatPageLabel(page);
|
|
46
|
-
|
|
47
|
-
const { items, currentPage, totalPages } = await assembleTimeline(c, {
|
|
48
|
-
page,
|
|
49
|
-
isAuthenticated: navData.isAuthenticated,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
return renderPublicPage(c, {
|
|
53
|
-
title:
|
|
54
|
-
page > 1
|
|
55
|
-
? buildPageTitle(latestTitle, paginatedPageTitle, navData.siteName)
|
|
56
|
-
: buildPageTitle(latestTitle, navData.siteName),
|
|
57
|
-
navData,
|
|
58
|
-
content: (
|
|
59
|
-
<HomePage
|
|
60
|
-
items={items}
|
|
61
|
-
baseUrl={toPublicPath("/latest", navData.sitePathPrefix)}
|
|
62
|
-
currentPage={currentPage}
|
|
63
|
-
totalPages={totalPages}
|
|
64
|
-
isAuthenticated={navData.isAuthenticated}
|
|
65
|
-
signinUrl={`${toPublicPath("/signin", navData.sitePathPrefix)}?redirect=${encodeURIComponent(toPublicPath("/latest", navData.sitePathPrefix))}`}
|
|
66
|
-
/>
|
|
67
|
-
),
|
|
68
|
-
});
|
|
19
|
+
return c.redirect(toPublicPath("/", c.var.appConfig.sitePathPrefix), 302);
|
|
69
20
|
});
|
|
70
21
|
|
|
71
22
|
// Atom — /latest/feed (canonical latest feed; accepts ?format=note|link|quote)
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
toPublicHref,
|
|
24
24
|
toPublicPath,
|
|
25
25
|
} from "../../lib/url.js";
|
|
26
|
+
import { ABOUT_PAGE_SLUG } from "../../services/about-page.js";
|
|
26
27
|
import { isTextAttachment } from "../../services/media.js";
|
|
27
28
|
import type { Post } from "../../types.js";
|
|
28
29
|
import { renderArchivePage } from "./archive.js";
|
|
@@ -186,12 +187,30 @@ async function renderPostWithTextPreview(
|
|
|
186
187
|
});
|
|
187
188
|
}
|
|
188
189
|
|
|
189
|
-
|
|
190
|
+
function canRenderDraftAboutEditor(
|
|
191
|
+
c: Context<Env>,
|
|
192
|
+
fullPath: string,
|
|
193
|
+
post: Post,
|
|
194
|
+
): boolean {
|
|
195
|
+
return (
|
|
196
|
+
post.status === "draft" &&
|
|
197
|
+
fullPath === ABOUT_PAGE_SLUG &&
|
|
198
|
+
c.req.query("edit") === "1" &&
|
|
199
|
+
c.var.isAuthenticated
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function renderPost(
|
|
204
|
+
c: Context<Env>,
|
|
205
|
+
post: Post,
|
|
206
|
+
options: { allowDraft?: boolean } = {},
|
|
207
|
+
) {
|
|
190
208
|
// Start navData fetch immediately — it's independent of thread/media queries
|
|
191
209
|
const navDataPromise = getNavigationData(c);
|
|
192
210
|
const display = await assemblePostPageDisplay(c, post, {
|
|
193
211
|
// Private-post access is validated before renderPost() is called.
|
|
194
212
|
isAuthenticated: true,
|
|
213
|
+
allowDraft: options.allowDraft,
|
|
195
214
|
});
|
|
196
215
|
if (!display) {
|
|
197
216
|
return c.notFound();
|
|
@@ -208,15 +227,25 @@ async function renderPost(c: Context<Env>, post: Post) {
|
|
|
208
227
|
return renderPublicPage(c, {
|
|
209
228
|
title: buildPageTitle(meta.title, navData.siteName),
|
|
210
229
|
description: meta.description,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
230
|
+
...(post.status === "published"
|
|
231
|
+
? {
|
|
232
|
+
canonicalHref,
|
|
233
|
+
socialImageUrl: display.socialImage?.url,
|
|
234
|
+
socialImageAlt: display.socialImage?.alt,
|
|
235
|
+
socialImageWidth: display.socialImage?.width,
|
|
236
|
+
socialImageHeight: display.socialImage?.height,
|
|
237
|
+
ogType: "article" as const,
|
|
238
|
+
articlePublishedTime: display.articlePublishedTime,
|
|
239
|
+
articleModifiedTime: display.articleModifiedTime,
|
|
240
|
+
jsonLd: buildPostJsonLd(
|
|
241
|
+
c,
|
|
242
|
+
display,
|
|
243
|
+
meta,
|
|
244
|
+
canonicalHref,
|
|
245
|
+
navData.siteName,
|
|
246
|
+
),
|
|
247
|
+
}
|
|
248
|
+
: {}),
|
|
220
249
|
navData,
|
|
221
250
|
content: (
|
|
222
251
|
<PostPage post={display.postView} threadPosts={display.threadPostViews} />
|
|
@@ -350,7 +379,10 @@ pageRoutes.get("/*", async (c) => {
|
|
|
350
379
|
|
|
351
380
|
if (resolved.postId) {
|
|
352
381
|
const post = await c.var.services.posts.getById(resolved.postId);
|
|
353
|
-
if (!post
|
|
382
|
+
if (!post) return c.notFound();
|
|
383
|
+
|
|
384
|
+
const allowDraft = canRenderDraftAboutEditor(c, fullPath, post);
|
|
385
|
+
if (post.status === "draft" && !allowDraft) return c.notFound();
|
|
354
386
|
|
|
355
387
|
if (post.visibility === "private") {
|
|
356
388
|
const navData = await getNavigationData(c);
|
|
@@ -368,7 +400,7 @@ pageRoutes.get("/*", async (c) => {
|
|
|
368
400
|
}
|
|
369
401
|
}
|
|
370
402
|
|
|
371
|
-
return renderPost(c, post);
|
|
403
|
+
return renderPost(c, post, { allowDraft });
|
|
372
404
|
}
|
|
373
405
|
|
|
374
406
|
if (resolved.collectionId) {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createTestDatabase,
|
|
4
|
+
DEFAULT_TEST_SITE_ID,
|
|
5
|
+
} from "../../__tests__/helpers/db.js";
|
|
6
|
+
import type { Database } from "../../db/index.js";
|
|
7
|
+
import { createAboutPageService } from "../about-page.js";
|
|
8
|
+
import { createCollectionService } from "../collection.js";
|
|
9
|
+
import { createPathService } from "../path.js";
|
|
10
|
+
import { createPostService } from "../post.js";
|
|
11
|
+
|
|
12
|
+
const ABOUT_BODY = JSON.stringify({
|
|
13
|
+
type: "doc",
|
|
14
|
+
content: [
|
|
15
|
+
{
|
|
16
|
+
type: "paragraph",
|
|
17
|
+
content: [{ type: "text", text: "About this site." }],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("AboutPageService", () => {
|
|
23
|
+
let aboutPage: ReturnType<typeof createAboutPageService>;
|
|
24
|
+
let posts: ReturnType<typeof createPostService>;
|
|
25
|
+
let collections: ReturnType<typeof createCollectionService>;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
const testDb = createTestDatabase();
|
|
29
|
+
const db = testDb.db as unknown as Database;
|
|
30
|
+
const paths = createPathService(db, DEFAULT_TEST_SITE_ID);
|
|
31
|
+
posts = createPostService(
|
|
32
|
+
db,
|
|
33
|
+
{ slugIdLength: 5 },
|
|
34
|
+
DEFAULT_TEST_SITE_ID,
|
|
35
|
+
paths,
|
|
36
|
+
);
|
|
37
|
+
collections = createCollectionService(db, DEFAULT_TEST_SITE_ID, paths);
|
|
38
|
+
aboutPage = createAboutPageService({
|
|
39
|
+
paths,
|
|
40
|
+
posts,
|
|
41
|
+
collections,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reports a missing About page when /about is unused", async () => {
|
|
46
|
+
await expect(aboutPage.getStatus()).resolves.toMatchObject({
|
|
47
|
+
state: "missing",
|
|
48
|
+
path: "/about",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("recognizes a post at /about as the About page", async () => {
|
|
53
|
+
const post = await posts.create({
|
|
54
|
+
format: "note",
|
|
55
|
+
title: "About",
|
|
56
|
+
slug: "about",
|
|
57
|
+
visibility: "latest_hidden",
|
|
58
|
+
body: ABOUT_BODY,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await expect(aboutPage.getStatus()).resolves.toMatchObject({
|
|
62
|
+
state: "ready",
|
|
63
|
+
post: {
|
|
64
|
+
id: post.id,
|
|
65
|
+
title: "About",
|
|
66
|
+
visibility: "latest_hidden",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("creates a hidden About page when /about is unused", async () => {
|
|
72
|
+
const post = await aboutPage.ensurePage();
|
|
73
|
+
expect(post).toMatchObject({
|
|
74
|
+
title: "About",
|
|
75
|
+
status: "published",
|
|
76
|
+
visibility: "latest_hidden",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await expect(aboutPage.getStatus()).resolves.toMatchObject({
|
|
80
|
+
state: "ready",
|
|
81
|
+
post: {
|
|
82
|
+
id: post.id,
|
|
83
|
+
title: "About",
|
|
84
|
+
status: "published",
|
|
85
|
+
visibility: "latest_hidden",
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reports a conflict when a collection owns /about", async () => {
|
|
91
|
+
const collection = await collections.create({
|
|
92
|
+
title: "About",
|
|
93
|
+
slug: "about",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await expect(aboutPage.getStatus()).resolves.toMatchObject({
|
|
97
|
+
state: "conflict",
|
|
98
|
+
conflict: {
|
|
99
|
+
targetType: "collection",
|
|
100
|
+
id: collection.id,
|
|
101
|
+
title: "About",
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects About creation when another item owns /about", async () => {
|
|
107
|
+
await collections.create({
|
|
108
|
+
title: "About",
|
|
109
|
+
slug: "about",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await expect(aboutPage.ensurePage()).rejects.toThrow(
|
|
113
|
+
"/about is already used.",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
const SECOND_SITE_ID = "sit_second00000000000000000000000";
|
|
15
15
|
const SECOND_SITE_KEY = "second";
|
|
16
16
|
|
|
17
|
-
function insertSecondSite(
|
|
17
|
+
function insertSecondSite(
|
|
18
|
+
sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
|
|
19
|
+
) {
|
|
18
20
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
19
21
|
sqlite
|
|
20
22
|
.prepare(
|
|
@@ -48,9 +50,9 @@ describe("GitHubAppInstallationsService", () => {
|
|
|
48
50
|
});
|
|
49
51
|
|
|
50
52
|
it("returns empty list for an unknown site", async () => {
|
|
51
|
-
expect(
|
|
52
|
-
|
|
53
|
-
);
|
|
53
|
+
expect(
|
|
54
|
+
await service.listInstallationsForSite(DEFAULT_TEST_SITE_ID),
|
|
55
|
+
).toEqual([]);
|
|
54
56
|
});
|
|
55
57
|
|
|
56
58
|
it("returns empty list of sites for an unknown installation", async () => {
|
|
@@ -78,9 +80,8 @@ describe("GitHubAppInstallationsService", () => {
|
|
|
78
80
|
DEFAULT_TEST_SITE_ID,
|
|
79
81
|
makeAccount({ login: "old-name" }),
|
|
80
82
|
);
|
|
81
|
-
const initial =
|
|
82
|
-
DEFAULT_TEST_SITE_ID
|
|
83
|
-
);
|
|
83
|
+
const initial =
|
|
84
|
+
await service.listInstallationsForSite(DEFAULT_TEST_SITE_ID);
|
|
84
85
|
const addedAt = initial[0]!.addedAt;
|
|
85
86
|
|
|
86
87
|
await service.upsertInstallation(
|
|
@@ -88,9 +89,8 @@ describe("GitHubAppInstallationsService", () => {
|
|
|
88
89
|
DEFAULT_TEST_SITE_ID,
|
|
89
90
|
makeAccount({ login: "renamed-org", avatarUrl: "https://x.png" }),
|
|
90
91
|
);
|
|
91
|
-
const updated =
|
|
92
|
-
DEFAULT_TEST_SITE_ID
|
|
93
|
-
);
|
|
92
|
+
const updated =
|
|
93
|
+
await service.listInstallationsForSite(DEFAULT_TEST_SITE_ID);
|
|
94
94
|
expect(updated).toHaveLength(1);
|
|
95
95
|
expect(updated[0]!.addedAt).toBe(addedAt);
|
|
96
96
|
expect(updated[0]!.account.login).toBe("renamed-org");
|
|
@@ -9,11 +9,50 @@ import { now } from "../../lib/time.js";
|
|
|
9
9
|
|
|
10
10
|
const TEST_COLLECTION_ID = "col_test00000000000000000000001";
|
|
11
11
|
const TEST_COLLECTION_ID_2 = "col_test00000000000000000000002";
|
|
12
|
+
const TEST_POST_ID = "pst_test00000000000000000000001";
|
|
13
|
+
const TEST_POST_ID_2 = "pst_test00000000000000000000002";
|
|
14
|
+
|
|
15
|
+
function insertTestPath(
|
|
16
|
+
sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
|
|
17
|
+
input: {
|
|
18
|
+
id: string;
|
|
19
|
+
path: string;
|
|
20
|
+
kind: "slug" | "alias" | "redirect" | "archive";
|
|
21
|
+
postId?: string | null;
|
|
22
|
+
collectionId?: string | null;
|
|
23
|
+
redirectToPath?: string | null;
|
|
24
|
+
redirectType?: 301 | 302 | null;
|
|
25
|
+
archiveQuery?: string | null;
|
|
26
|
+
},
|
|
27
|
+
) {
|
|
28
|
+
const ts = now();
|
|
29
|
+
sqlite
|
|
30
|
+
.prepare(
|
|
31
|
+
`INSERT INTO path_registry (
|
|
32
|
+
id, site_id, path, kind, post_id, collection_id, redirect_to_path,
|
|
33
|
+
redirect_type, archive_query, created_at, updated_at
|
|
34
|
+
)
|
|
35
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
36
|
+
)
|
|
37
|
+
.run(
|
|
38
|
+
input.id,
|
|
39
|
+
DEFAULT_TEST_SITE_ID,
|
|
40
|
+
input.path,
|
|
41
|
+
input.kind,
|
|
42
|
+
input.postId ?? null,
|
|
43
|
+
input.collectionId ?? null,
|
|
44
|
+
input.redirectToPath ?? null,
|
|
45
|
+
input.redirectType ?? null,
|
|
46
|
+
input.archiveQuery ?? null,
|
|
47
|
+
ts,
|
|
48
|
+
ts,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
12
51
|
|
|
13
52
|
function insertTestCollection(
|
|
14
53
|
sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
|
|
15
54
|
id: string,
|
|
16
|
-
|
|
55
|
+
slug: string,
|
|
17
56
|
title: string,
|
|
18
57
|
) {
|
|
19
58
|
const ts = now();
|
|
@@ -23,6 +62,51 @@ function insertTestCollection(
|
|
|
23
62
|
VALUES (?, ?, ?, 'newest', ?, ?)`,
|
|
24
63
|
)
|
|
25
64
|
.run(id, DEFAULT_TEST_SITE_ID, title, ts, ts);
|
|
65
|
+
insertTestPath(sqlite, {
|
|
66
|
+
id: `pth_col_${id.slice(4)}`,
|
|
67
|
+
path: slug,
|
|
68
|
+
kind: "slug",
|
|
69
|
+
collectionId: id,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function insertTestPost(
|
|
74
|
+
sqlite: ReturnType<typeof createTestDatabase>["sqlite"],
|
|
75
|
+
input: {
|
|
76
|
+
id: string;
|
|
77
|
+
slug: string;
|
|
78
|
+
title: string | null;
|
|
79
|
+
status?: "draft" | "published";
|
|
80
|
+
visibility?: "public" | "latest_hidden" | "private";
|
|
81
|
+
},
|
|
82
|
+
) {
|
|
83
|
+
const ts = now();
|
|
84
|
+
sqlite
|
|
85
|
+
.prepare(
|
|
86
|
+
`INSERT INTO post (
|
|
87
|
+
id, site_id, format, status, visibility, title, thread_id,
|
|
88
|
+
published_at, last_activity_at, created_at, updated_at
|
|
89
|
+
)
|
|
90
|
+
VALUES (?, ?, 'note', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
91
|
+
)
|
|
92
|
+
.run(
|
|
93
|
+
input.id,
|
|
94
|
+
DEFAULT_TEST_SITE_ID,
|
|
95
|
+
input.status ?? "published",
|
|
96
|
+
input.visibility ?? "latest_hidden",
|
|
97
|
+
input.title,
|
|
98
|
+
input.id,
|
|
99
|
+
ts,
|
|
100
|
+
ts,
|
|
101
|
+
ts,
|
|
102
|
+
ts,
|
|
103
|
+
);
|
|
104
|
+
insertTestPath(sqlite, {
|
|
105
|
+
id: `pth_pst_${input.id.slice(4)}`,
|
|
106
|
+
path: input.slug,
|
|
107
|
+
kind: "slug",
|
|
108
|
+
postId: input.id,
|
|
109
|
+
});
|
|
26
110
|
}
|
|
27
111
|
|
|
28
112
|
describe("NavItemService", () => {
|
|
@@ -277,6 +361,152 @@ describe("NavItemService", () => {
|
|
|
277
361
|
});
|
|
278
362
|
});
|
|
279
363
|
|
|
364
|
+
describe("listSuggestedLinks", () => {
|
|
365
|
+
it("suggests a published /about page as a normal link", async () => {
|
|
366
|
+
insertTestPost(sqlite, {
|
|
367
|
+
id: TEST_POST_ID,
|
|
368
|
+
slug: "about",
|
|
369
|
+
title: "About me",
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const suggestions = await navItemService.listSuggestedLinks();
|
|
373
|
+
|
|
374
|
+
expect(suggestions).toEqual([
|
|
375
|
+
{
|
|
376
|
+
key: "about",
|
|
377
|
+
label: "About me",
|
|
378
|
+
url: "/about",
|
|
379
|
+
targetType: "page",
|
|
380
|
+
navItemType: "link",
|
|
381
|
+
},
|
|
382
|
+
]);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("suggests a canonical /now collection as a collection nav item", async () => {
|
|
386
|
+
insertTestCollection(sqlite, TEST_COLLECTION_ID, "now", "Now");
|
|
387
|
+
|
|
388
|
+
const suggestions = await navItemService.listSuggestedLinks();
|
|
389
|
+
|
|
390
|
+
expect(suggestions).toEqual([
|
|
391
|
+
{
|
|
392
|
+
key: "now",
|
|
393
|
+
label: "Now",
|
|
394
|
+
url: "/now",
|
|
395
|
+
targetType: "collection",
|
|
396
|
+
navItemType: "collection",
|
|
397
|
+
collectionId: TEST_COLLECTION_ID,
|
|
398
|
+
},
|
|
399
|
+
]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("suggests a /now collection alias as a normal link to preserve the path", async () => {
|
|
403
|
+
insertTestCollection(sqlite, TEST_COLLECTION_ID, "updates", "Updates");
|
|
404
|
+
insertTestPath(sqlite, {
|
|
405
|
+
id: "pth_now_alias",
|
|
406
|
+
path: "now",
|
|
407
|
+
kind: "alias",
|
|
408
|
+
collectionId: TEST_COLLECTION_ID,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const suggestions = await navItemService.listSuggestedLinks();
|
|
412
|
+
|
|
413
|
+
expect(suggestions).toEqual([
|
|
414
|
+
{
|
|
415
|
+
key: "now",
|
|
416
|
+
label: "Updates",
|
|
417
|
+
url: "/now",
|
|
418
|
+
targetType: "collection",
|
|
419
|
+
navItemType: "link",
|
|
420
|
+
},
|
|
421
|
+
]);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("hides suggestions already represented by path or collection id", async () => {
|
|
425
|
+
insertTestPost(sqlite, {
|
|
426
|
+
id: TEST_POST_ID,
|
|
427
|
+
slug: "about",
|
|
428
|
+
title: "About",
|
|
429
|
+
});
|
|
430
|
+
insertTestCollection(sqlite, TEST_COLLECTION_ID, "now", "Now");
|
|
431
|
+
|
|
432
|
+
await navItemService.create({
|
|
433
|
+
type: "link",
|
|
434
|
+
label: "About",
|
|
435
|
+
url: "/about/",
|
|
436
|
+
});
|
|
437
|
+
await navItemService.create({
|
|
438
|
+
type: "collection",
|
|
439
|
+
collectionId: TEST_COLLECTION_ID,
|
|
440
|
+
label: "Now",
|
|
441
|
+
url: "/now",
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const suggestions = await navItemService.listSuggestedLinks();
|
|
445
|
+
|
|
446
|
+
expect(suggestions).toEqual([]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("hides suggestions already represented by a same-site absolute URL", async () => {
|
|
450
|
+
insertTestPost(sqlite, {
|
|
451
|
+
id: TEST_POST_ID,
|
|
452
|
+
slug: "about",
|
|
453
|
+
title: "About",
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
await navItemService.create({
|
|
457
|
+
type: "link",
|
|
458
|
+
label: "About",
|
|
459
|
+
url: "https://preview-test.owenyoung.com/about",
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const suggestions = await navItemService.listSuggestedLinks({
|
|
463
|
+
siteOrigin: "https://preview-test.owenyoung.com",
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
expect(suggestions).toEqual([]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("hides suggestions already represented by a public path prefix", async () => {
|
|
470
|
+
insertTestPost(sqlite, {
|
|
471
|
+
id: TEST_POST_ID,
|
|
472
|
+
slug: "about",
|
|
473
|
+
title: "About",
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
await navItemService.create({
|
|
477
|
+
type: "link",
|
|
478
|
+
label: "About",
|
|
479
|
+
url: "https://example.com/blog/about",
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const suggestions = await navItemService.listSuggestedLinks({
|
|
483
|
+
siteOrigin: "https://example.com",
|
|
484
|
+
sitePathPrefix: "/blog",
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
expect(suggestions).toEqual([]);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("does not suggest draft or private posts", async () => {
|
|
491
|
+
insertTestPost(sqlite, {
|
|
492
|
+
id: TEST_POST_ID,
|
|
493
|
+
slug: "about",
|
|
494
|
+
title: "About",
|
|
495
|
+
status: "draft",
|
|
496
|
+
});
|
|
497
|
+
insertTestPost(sqlite, {
|
|
498
|
+
id: TEST_POST_ID_2,
|
|
499
|
+
slug: "now",
|
|
500
|
+
title: "Now",
|
|
501
|
+
visibility: "private",
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const suggestions = await navItemService.listSuggestedLinks();
|
|
505
|
+
|
|
506
|
+
expect(suggestions).toEqual([]);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
280
510
|
describe("update", () => {
|
|
281
511
|
it("updates a nav item's label", async () => {
|
|
282
512
|
const created = await navItemService.create({
|