@jant/core 0.6.11 → 0.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/bin/commands/import-site.js +45 -20
  2. package/dist/app-B5P1b4TF.js +6 -0
  3. package/dist/{app-CpmficmQ.js → app-D-zAhayc.js} +331 -55
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-BUxR-E8O.css +2 -0
  6. package/dist/client/_assets/{client-Dd9U383b.js → client-S1cdvulk.js} +1 -1
  7. package/dist/client/_assets/{client-auth-DkpSdIDz.js → client-auth-YW92ZH7u.js} +116 -55
  8. package/dist/{export-Ba7NJImL.js → export-Ckwh4BiE.js} +4 -4
  9. package/dist/{github-sync-BD4w2m8-.js → github-sync-BMmK3JZ0.js} +2 -2
  10. package/dist/{github-sync-Cb4_6_i7.js → github-sync-Dl9wQMar.js} +1 -1
  11. package/dist/index.js +3 -3
  12. package/dist/node.js +4 -4
  13. package/package.json +1 -1
  14. package/src/__tests__/import-site-command.test.ts +75 -0
  15. package/src/__tests__/vite-dev-plugins.test.ts +34 -0
  16. package/src/client/__tests__/compose-launch.test.ts +50 -1
  17. package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
  18. package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
  19. package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
  20. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +20 -1
  21. package/src/client/components/__tests__/jant-nav-manager.test.ts +77 -1
  22. package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
  23. package/src/client/components/__tests__/jant-settings-general.test.ts +71 -0
  24. package/src/client/components/jant-command-palette.ts +67 -2
  25. package/src/client/components/jant-compose-dialog.ts +8 -2
  26. package/src/client/components/jant-nav-manager.ts +142 -2
  27. package/src/client/components/jant-settings-general.ts +56 -0
  28. package/src/client/components/nav-manager-types.ts +14 -0
  29. package/src/client/components/settings-types.ts +30 -0
  30. package/src/client/compose-launch.ts +30 -0
  31. package/src/client/compose-shortcuts.ts +46 -3
  32. package/src/client/tiptap/__tests__/rehost-images.test.ts +66 -13
  33. package/src/client/tiptap/rehost-images.ts +5 -1
  34. package/src/i18n/__tests__/fallback.test.ts +24 -0
  35. package/src/i18n/locales/public/zh-Hans.po +2 -2
  36. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  37. package/src/i18n/locales/public/zh-Hant.po +2 -2
  38. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  39. package/src/i18n/locales/settings/en.po +54 -0
  40. package/src/i18n/locales/settings/en.ts +1 -1
  41. package/src/i18n/locales/settings/zh-Hans.po +64 -10
  42. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  43. package/src/i18n/locales/settings/zh-Hant.po +56 -2
  44. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  45. package/src/lib/__tests__/feed.test.ts +96 -10
  46. package/src/lib/feed.ts +63 -3
  47. package/src/lib/post-display.ts +14 -8
  48. package/src/preset.css +60 -18
  49. package/src/routes/dash/settings.tsx +41 -2
  50. package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
  51. package/src/routes/pages/page.tsx +44 -12
  52. package/src/services/__tests__/about-page.test.ts +116 -0
  53. package/src/services/__tests__/navigation.test.ts +190 -1
  54. package/src/services/__tests__/post-timeline.test.ts +119 -2
  55. package/src/services/about-page.ts +143 -0
  56. package/src/services/export-theme/layouts/_default/rss.xml +2 -1
  57. package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
  58. package/src/services/export-theme/styles/main.css +46 -14
  59. package/src/services/index.ts +27 -17
  60. package/src/services/navigation.ts +155 -1
  61. package/src/services/post.ts +24 -7
  62. package/src/styles/components.css +20 -0
  63. package/src/styles/tokens.css +26 -8
  64. package/src/styles/ui.css +149 -7
  65. package/src/types/views.ts +13 -0
  66. package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
  67. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
  68. package/src/ui/dash/settings/GeneralContent.tsx +44 -0
  69. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
  70. package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
  71. package/dist/app-DqKkZenB.js +0 -6
  72. package/dist/client/_assets/client-BhHHVvSY.css +0 -2
package/src/preset.css CHANGED
@@ -182,9 +182,9 @@ html {
182
182
  --tw-prose-quote-borders: var(--color-border);
183
183
  --tw-prose-captions: var(--color-muted-foreground);
184
184
  --tw-prose-kbd: var(--color-foreground);
185
- --tw-prose-code: var(--color-foreground);
186
- --tw-prose-pre-code: var(--color-muted-foreground);
187
- --tw-prose-pre-bg: var(--color-muted);
185
+ --tw-prose-code: var(--site-code-text);
186
+ --tw-prose-pre-code: var(--site-code-text);
187
+ --tw-prose-pre-bg: var(--site-code-block-bg);
188
188
  --tw-prose-th-borders: var(--color-border);
189
189
  --tw-prose-td-borders: var(--color-border);
190
190
  font-family: var(--font-body);
@@ -213,14 +213,12 @@ html {
213
213
 
214
214
  :where(h2) {
215
215
  font-size: var(--type-content-title);
216
- font-style: italic;
217
216
  margin-top: 2.1rem;
218
217
  margin-bottom: 1.4rem;
219
218
  }
220
219
 
221
220
  :where(h3) {
222
221
  font-size: var(--type-content-subtitle);
223
- font-style: italic;
224
222
  margin-top: 2rem;
225
223
  margin-bottom: 1.4rem;
226
224
  }
@@ -236,12 +234,12 @@ html {
236
234
 
237
235
  :where(ul, ol) {
238
236
  margin: 1.25em 0;
239
- padding-left: 1.625em;
237
+ padding-left: 1.4em;
240
238
  }
241
239
 
242
240
  :where(li, dt) {
243
- margin-top: 1.2em;
244
- margin-bottom: 1.2em;
241
+ margin-top: 0.65em;
242
+ margin-bottom: 0.65em;
245
243
  }
246
244
 
247
245
  /* Normalize li > p so list spacing is controlled by li alone,
@@ -257,18 +255,35 @@ html {
257
255
  :where(code, pre code) {
258
256
  font-size: var(--type-code);
259
257
  line-height: 1.42;
258
+ color: var(--site-code-text);
260
259
  overflow-wrap: break-word;
261
260
  }
262
261
 
263
262
  :where(:not(pre) > code) {
264
- padding: 0.1em 0.35em;
265
- background-color: var(--site-feed-card-bg);
266
- border: 1px solid var(--site-feed-card-border);
267
- border-radius: 0.25rem;
263
+ padding: 0.08em 0.28em;
264
+ background-color: var(--site-code-bg);
265
+ border-radius: 0.22em;
266
+ box-decoration-break: clone;
267
+ -webkit-box-decoration-break: clone;
268
+ }
269
+
270
+ :where(:not(pre) > code)::before,
271
+ :where(:not(pre) > code)::after {
272
+ content: none;
273
+ }
274
+
275
+ :where(pre) {
276
+ background-color: var(--site-code-block-bg);
277
+ border: 1px solid var(--site-code-block-border);
278
+ border-radius: 6px;
268
279
  }
269
280
 
270
281
  :where(pre code) {
271
282
  font-size: var(--type-code-block);
283
+ color: inherit;
284
+ background: transparent;
285
+ border: 0;
286
+ padding: 0;
272
287
  }
273
288
 
274
289
  :where(h1, h2, h3) :where(code) {
@@ -425,6 +440,37 @@ html {
425
440
  margin-bottom: 0;
426
441
  }
427
442
 
443
+ /*
444
+ * Feed body headings
445
+ *
446
+ * In list contexts, the post title owns the card heading level. Authored
447
+ * headings inside the body should remain visible, but they should read as
448
+ * sections within the card rather than as peer post titles.
449
+ */
450
+ [data-post]:not([data-page="post"]) [data-post-body].prose {
451
+ :where(h1, h2) {
452
+ font-size: calc(var(--type-content-body) * 1.12);
453
+ font-weight: var(--fw-medium);
454
+ margin-top: 1.45rem;
455
+ margin-bottom: 0.55rem;
456
+ }
457
+
458
+ :where(h3, h4) {
459
+ font-size: var(--type-content-body);
460
+ font-weight: var(--fw-medium);
461
+ margin-top: 1.2rem;
462
+ margin-bottom: 0.45rem;
463
+ }
464
+
465
+ :where(h5, h6) {
466
+ font-size: var(--type-secondary);
467
+ font-weight: var(--fw-medium);
468
+ color: var(--site-text-secondary);
469
+ margin-top: 1rem;
470
+ margin-bottom: 0.35rem;
471
+ }
472
+ }
473
+
428
474
  /*
429
475
  * Detail-page reading prose
430
476
  *
@@ -455,12 +501,8 @@ html {
455
501
  );
456
502
  --tw-prose-captions: var(--site-reading-caption);
457
503
  --tw-prose-kbd: var(--site-reading-heading);
458
- --tw-prose-code: var(--site-reading-heading);
459
- --tw-prose-pre-code: color-mix(
460
- in srgb,
461
- var(--site-reading-caption) 86%,
462
- var(--site-reading-body)
463
- );
504
+ --tw-prose-code: var(--site-code-text);
505
+ --tw-prose-pre-code: var(--site-code-text);
464
506
  --tw-prose-th-borders: color-mix(
465
507
  in srgb,
466
508
  var(--site-divider) 88%,
@@ -75,6 +75,7 @@ import {
75
75
  isSyncPending,
76
76
  markSyncPending,
77
77
  runBackgroundSync,
78
+ triggerGitHubSyncInline,
78
79
  } from "../../lib/github-sync-trigger.js";
79
80
  import { buildSyncSiteConfig } from "../../lib/github-sync-site-config.js";
80
81
  import {
@@ -117,6 +118,10 @@ function publicPath(c: Context<Env>, path: string): string {
117
118
  return toPublicPath(path, c.var.appConfig.sitePathPrefix);
118
119
  }
119
120
 
121
+ function aboutEditPath(c: Context<Env>): string {
122
+ return `${publicPath(c, "/about")}?edit=1`;
123
+ }
124
+
120
125
  /**
121
126
  * Breadcrumb labels for admin settings pages.
122
127
  *
@@ -302,7 +307,10 @@ settingsRoutes.get("/general", async (c) => {
302
307
  const dbSiteDescription = allSettings["SITE_DESCRIPTION"] ?? "";
303
308
 
304
309
  const saved = c.req.query("saved") !== undefined;
305
- const navData = await getNavigationData(c);
310
+ const [navData, aboutPage] = await Promise.all([
311
+ getNavigationData(c),
312
+ c.var.services.aboutPage.getStatus(),
313
+ ]);
306
314
  const siteUrlForDisplay =
307
315
  appConfig.siteUrl || new URL(publicPath(c, "/"), c.req.url).toString();
308
316
 
@@ -356,6 +364,9 @@ settingsRoutes.get("/general", async (c) => {
356
364
  noindex={appConfig.noindex}
357
365
  demoMode={appConfig.demoMode}
358
366
  timezones={TIMEZONES}
367
+ aboutPage={aboutPage}
368
+ aboutEditUrl={aboutEditPath(c)}
369
+ aboutCreateUrl={publicPath(c, "/settings/general/about-page")}
359
370
  />
360
371
  </>
361
372
  ),
@@ -534,6 +545,32 @@ settingsRoutes.post("/general/search", async (c) => {
534
545
  );
535
546
  });
536
547
 
548
+ settingsRoutes.post("/general/about-page", async (c) => {
549
+ try {
550
+ await c.var.services.aboutPage.ensurePage();
551
+ await triggerGitHubSyncInline(c);
552
+ } catch (error) {
553
+ if (error instanceof ValidationError) {
554
+ const wantsJson = c.req.header("accept")?.includes("application/json");
555
+ if (wantsJson) {
556
+ return c.json({ error: error.message, code: error.code }, 400);
557
+ }
558
+ return c.text(error.message, 400);
559
+ }
560
+ throw error;
561
+ }
562
+
563
+ const wantsJson = c.req.header("accept")?.includes("application/json");
564
+ if (wantsJson) {
565
+ return c.json({
566
+ status: "redirect" as const,
567
+ url: aboutEditPath(c),
568
+ });
569
+ }
570
+
571
+ return c.redirect(aboutEditPath(c));
572
+ });
573
+
537
574
  // ===========================================================================
538
575
  // Avatar
539
576
  // ===========================================================================
@@ -743,9 +780,10 @@ settingsRoutes.post("/avatar/display", async (c) => {
743
780
  // ===========================================================================
744
781
 
745
782
  settingsRoutes.get("/navigation", async (c) => {
746
- const [navItems, directoryData] = await Promise.all([
783
+ const [navItems, directoryData, suggestedLinks] = await Promise.all([
747
784
  c.var.services.navItems.list(),
748
785
  c.var.services.collections.listDirectoryData(),
786
+ c.var.services.navItems.listSuggestedLinks(),
749
787
  ]);
750
788
  const navData = await getNavigationData(c);
751
789
 
@@ -762,6 +800,7 @@ settingsRoutes.get("/navigation", async (c) => {
762
800
  <NavigationContent
763
801
  navItems={navItems}
764
802
  directoryData={directoryData}
803
+ suggestedLinks={suggestedLinks}
765
804
  mainRssFeed={c.var.appConfig.mainRssFeed}
766
805
  siteName={navData.siteName}
767
806
  sitePathPrefix={c.var.appConfig.sitePathPrefix}
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { pageRoutes } from "../page.js";
4
+
5
+ function createPageTestApp(options: { authenticated?: boolean } = {}) {
6
+ const testApp = createTestApp(options);
7
+ const { app } = testApp;
8
+
9
+ app.use("*", async (c, next) => {
10
+ c.set("publicPath", c.req.path);
11
+ c.set("publicRequestUrl", c.req.url);
12
+ await next();
13
+ });
14
+
15
+ app.route("/", pageRoutes);
16
+
17
+ return testApp;
18
+ }
19
+
20
+ async function createDraftAbout(
21
+ services: ReturnType<typeof createTestApp>["services"],
22
+ ) {
23
+ return services.posts.create({
24
+ format: "note",
25
+ title: "About",
26
+ slug: "about",
27
+ bodyMarkdown: "Draft About body",
28
+ status: "draft",
29
+ visibility: "latest_hidden",
30
+ });
31
+ }
32
+
33
+ describe("draft About edit route", () => {
34
+ it("keeps draft About hidden from anonymous edit requests", async () => {
35
+ const { app, services } = createPageTestApp();
36
+ await createDraftAbout(services);
37
+
38
+ const res = await app.request("/about?edit=1");
39
+
40
+ expect(res.status).toBe(404);
41
+ });
42
+
43
+ it("keeps draft About hidden from normal authenticated page requests", async () => {
44
+ const { app, services } = createPageTestApp({ authenticated: true });
45
+ await createDraftAbout(services);
46
+
47
+ const res = await app.request("/about");
48
+
49
+ expect(res.status).toBe(404);
50
+ });
51
+
52
+ it("renders draft About for authenticated edit requests", async () => {
53
+ const { app, services } = createPageTestApp({ authenticated: true });
54
+ const post = await createDraftAbout(services);
55
+
56
+ const res = await app.request("/about?edit=1");
57
+
58
+ expect(res.status).toBe(200);
59
+ const html = await res.text();
60
+ expect(html).toContain(`data-post-id="${post.id}"`);
61
+ expect(html).toContain("data-authenticated");
62
+ expect(html).not.toContain('rel="canonical"');
63
+ expect(html).not.toContain("application/ld+json");
64
+ });
65
+ });
@@ -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
- async function renderPost(c: Context<Env>, post: Post) {
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
- canonicalHref,
212
- socialImageUrl: display.socialImage?.url,
213
- socialImageAlt: display.socialImage?.alt,
214
- socialImageWidth: display.socialImage?.width,
215
- socialImageHeight: display.socialImage?.height,
216
- ogType: "article",
217
- articlePublishedTime: display.articlePublishedTime,
218
- articleModifiedTime: display.articleModifiedTime,
219
- jsonLd: buildPostJsonLd(c, display, meta, canonicalHref, navData.siteName),
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 || post.status === "draft") return c.notFound();
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
+ });