@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.
Files changed (139) hide show
  1. package/bin/commands/import-site.js +47 -30
  2. package/bin/commands/migrate-text-attachments.js +1 -3
  3. package/bin/commands/site/export.js +1 -3
  4. package/bin/commands/telegram/register-webhooks.js +9 -3
  5. package/bin/lib/site-snapshot.js +1 -5
  6. package/dist/app-CWJFPjuR.js +6 -0
  7. package/dist/{app-CpmficmQ.js → app-HnzmsaqU.js} +721 -473
  8. package/dist/client/.vite/manifest.json +3 -3
  9. package/dist/client/_assets/{client-Dd9U383b.js → client-3_OaxhTs.js} +1 -1
  10. package/dist/client/_assets/client-DCrcHVJl.css +2 -0
  11. package/dist/client/_assets/{client-auth-DkpSdIDz.js → client-auth-TsYq90xm.js} +625 -561
  12. package/dist/{export-Ba7NJImL.js → export-Dfbq9aot.js} +12 -15
  13. package/dist/{github-sync-Cb4_6_i7.js → github-sync--PSoE-Ne.js} +1 -1
  14. package/dist/{github-sync-BD4w2m8-.js → github-sync-BGTLx8Mf.js} +2 -2
  15. package/dist/index.js +3 -3
  16. package/dist/node.js +4 -4
  17. package/package.json +1 -1
  18. package/src/__tests__/export-service.test.ts +1 -2
  19. package/src/__tests__/helpers/app.ts +3 -0
  20. package/src/__tests__/helpers/export-fixtures.ts +0 -1
  21. package/src/__tests__/import-site-command.test.ts +76 -2
  22. package/src/__tests__/vite-dev-plugins.test.ts +34 -0
  23. package/src/client/__tests__/compose-launch.test.ts +50 -1
  24. package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
  25. package/src/client/__tests__/site-header-nav.test.ts +36 -2
  26. package/src/client/components/__tests__/compose-title-from-heading.test.ts +57 -0
  27. package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
  28. package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
  29. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +20 -1
  30. package/src/client/components/__tests__/jant-compose-editor.test.ts +94 -2
  31. package/src/client/components/__tests__/jant-nav-manager.test.ts +181 -20
  32. package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
  33. package/src/client/components/__tests__/jant-settings-general.test.ts +72 -0
  34. package/src/client/components/compose-title-from-heading.ts +66 -0
  35. package/src/client/components/compose-types.ts +8 -0
  36. package/src/client/components/inline-image-issues.ts +17 -0
  37. package/src/client/components/jant-command-palette.ts +67 -2
  38. package/src/client/components/jant-compose-dialog.ts +10 -2
  39. package/src/client/components/jant-compose-editor.ts +78 -1
  40. package/src/client/components/jant-compose-fullscreen.ts +2 -0
  41. package/src/client/components/jant-nav-manager.ts +382 -152
  42. package/src/client/components/jant-settings-general.ts +56 -0
  43. package/src/client/components/nav-manager-types.ts +14 -10
  44. package/src/client/components/settings-types.ts +30 -0
  45. package/src/client/compose-launch.ts +30 -0
  46. package/src/client/compose-shortcuts.ts +46 -3
  47. package/src/client/site-header-nav.d.ts +1 -0
  48. package/src/client/site-header-nav.js +105 -47
  49. package/src/client/tiptap/__tests__/block-insertion.test.ts +35 -0
  50. package/src/client/tiptap/__tests__/image-input-rules.test.ts +117 -0
  51. package/src/client/tiptap/__tests__/rehost-images.test.ts +66 -13
  52. package/src/client/tiptap/create-editor.ts +7 -0
  53. package/src/client/tiptap/extensions.ts +7 -2
  54. package/src/client/tiptap/image-input-rules.ts +48 -0
  55. package/src/client/tiptap/image-node.ts +189 -7
  56. package/src/client/tiptap/rehost-images.ts +5 -1
  57. package/src/client-auth.ts +0 -1
  58. package/src/i18n/__tests__/fallback.test.ts +24 -0
  59. package/src/i18n/locales/public/en.po +20 -4
  60. package/src/i18n/locales/public/en.ts +1 -1
  61. package/src/i18n/locales/public/zh-Hans.po +22 -6
  62. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  63. package/src/i18n/locales/public/zh-Hant.po +22 -6
  64. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  65. package/src/i18n/locales/settings/en.po +77 -20
  66. package/src/i18n/locales/settings/en.ts +1 -1
  67. package/src/i18n/locales/settings/zh-Hans.po +85 -28
  68. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  69. package/src/i18n/locales/settings/zh-Hant.po +79 -22
  70. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  71. package/src/lib/__tests__/feed.test.ts +96 -10
  72. package/src/lib/__tests__/navigation.test.ts +4 -37
  73. package/src/lib/__tests__/resolve-config.test.ts +6 -1
  74. package/src/lib/__tests__/view.test.ts +6 -30
  75. package/src/lib/feed.ts +63 -3
  76. package/src/lib/github-sync-site-config.ts +0 -2
  77. package/src/lib/hugo-markdown.ts +1 -1
  78. package/src/lib/navigation.ts +5 -22
  79. package/src/lib/post-display.ts +14 -8
  80. package/src/lib/resolve-config.ts +9 -2
  81. package/src/lib/site-header-fragment.tsx +37 -0
  82. package/src/lib/view.ts +2 -7
  83. package/src/node/__tests__/cli-site-snapshot.test.ts +45 -45
  84. package/src/preset.css +60 -18
  85. package/src/routes/api/__tests__/nav-items.test.ts +26 -0
  86. package/src/routes/api/export.ts +0 -2
  87. package/src/routes/api/internal/__tests__/uploads.test.ts +9 -13
  88. package/src/routes/api/internal/sites.ts +5 -5
  89. package/src/routes/api/nav-items.ts +25 -4
  90. package/src/routes/dash/settings.tsx +48 -3
  91. package/src/routes/feed/__tests__/feed.test.ts +42 -0
  92. package/src/routes/feed/__tests__/sitemap.test.ts +2 -13
  93. package/src/routes/feed/feed.ts +1 -0
  94. package/src/routes/feed/sitemap.ts +2 -6
  95. package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
  96. package/src/routes/pages/archive.tsx +1 -0
  97. package/src/routes/pages/collection.tsx +1 -0
  98. package/src/routes/pages/featured.tsx +0 -4
  99. package/src/routes/pages/home.tsx +4 -51
  100. package/src/routes/pages/latest.tsx +2 -51
  101. package/src/routes/pages/page.tsx +44 -12
  102. package/src/services/__tests__/about-page.test.ts +116 -0
  103. package/src/services/__tests__/github-app-installations.test.ts +10 -10
  104. package/src/services/__tests__/navigation.test.ts +231 -1
  105. package/src/services/__tests__/post-timeline.test.ts +175 -2
  106. package/src/services/__tests__/post.test.ts +33 -0
  107. package/src/services/__tests__/settings.test.ts +5 -20
  108. package/src/services/about-page.ts +143 -0
  109. package/src/services/export-theme/assets/client-site.js +10 -10
  110. package/src/services/export-theme/layouts/_default/rss.xml +7 -16
  111. package/src/services/export-theme/layouts/index.html +26 -39
  112. package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
  113. package/src/services/export-theme/styles/main.css +46 -14
  114. package/src/services/export.ts +5 -10
  115. package/src/services/github-app-installations.ts +1 -3
  116. package/src/services/index.ts +27 -17
  117. package/src/services/navigation.ts +186 -1
  118. package/src/services/post.ts +103 -63
  119. package/src/services/settings.ts +0 -10
  120. package/src/services/site-admin.ts +0 -2
  121. package/src/services/upload-session.ts +4 -18
  122. package/src/styles/components.css +20 -0
  123. package/src/styles/tokens.css +28 -10
  124. package/src/styles/ui.css +349 -13
  125. package/src/types/bindings.ts +0 -1
  126. package/src/types/config.ts +3 -7
  127. package/src/types/views.ts +13 -0
  128. package/src/ui/compose/ComposeDialog.tsx +28 -0
  129. package/src/ui/dash/appearance/NavigationContent.tsx +58 -1
  130. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
  131. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  132. package/src/ui/dash/settings/GeneralContent.tsx +44 -0
  133. package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
  134. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
  135. package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
  136. package/src/ui/layouts/SiteLayout.tsx +249 -216
  137. package/dist/app-DqKkZenB.js +0 -6
  138. package/dist/client/_assets/client-BhHHVvSY.css +0 -2
  139. package/src/client/nav-manager-bridge.ts +0 -54
@@ -1,4 +1,4 @@
1
- import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-Ba7NJImL.js";
1
+ import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-Dfbq9aot.js";
2
2
  import { r as getInstallationToken } from "./github-app-BbklkFmU.js";
3
3
  import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BgSiE71w.js";
4
4
  //#region src/lib/markdown-to-tiptap.ts
@@ -1,4 +1,4 @@
1
1
  import "./url-BMYO-Zlt.js";
2
- import "./export-Ba7NJImL.js";
3
- import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-Cb4_6_i7.js";
2
+ import "./export-Dfbq9aot.js";
3
+ import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync--PSoE-Ne.js";
4
4
  export { classifyRepoForSync, createGitHubSyncService };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { y as url_exports } from "./url-BMYO-Zlt.js";
2
- import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, j as MAX_PINNED_POSTS, k as FORMATS, m as defaultFeedRenderer, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-CpmficmQ.js";
3
- import { S as time_exports, a as markdown_exports } from "./export-Ba7NJImL.js";
2
+ import { A as MAX_MEDIA_ATTACHMENTS, C as toMediaView, D as toPostViews, E as toPostView, F as STATUSES, I as TEXT_ATTACHMENT_CONTENT_FORMATS, M as MEDIA_KINDS, N as NAV_ITEM_TYPES, O as toSearchResultView, P as SORT_ORDERS, S as toArchiveGroupsWithMedia, T as toNavItemViews, b as createMediaContext, j as MAX_PINNED_POSTS, k as FORMATS, m as defaultFeedRenderer, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-HnzmsaqU.js";
3
+ import { S as time_exports, a as markdown_exports } from "./export-Dfbq9aot.js";
4
4
  import "./env-OHRKGcMj.js";
5
- import "./github-sync-Cb4_6_i7.js";
5
+ import "./github-sync--PSoE-Ne.js";
6
6
  export { FORMATS, MAX_MEDIA_ATTACHMENTS, MAX_PINNED_POSTS, MEDIA_KINDS, NAV_ITEM_TYPES, SORT_ORDERS, STATUSES, TEXT_ATTACHMENT_CONTENT_FORMATS, createApp, createMediaContext, defaultFeedRenderer, markdown_exports as markdown, time_exports as time, toArchiveGroups, toArchiveGroupsWithMedia, toMediaView, toNavItemView, toNavItemViews, toPostView, toPostViews, toSearchResultView, url_exports as url };
package/dist/node.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import "./url-BMYO-Zlt.js";
2
- import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as getWebhookUrl, d as BUILTIN_FONT_THEMES, f as getCjkSerifCssVariables, g as pgSchemaBundle, h as createStorageDriver, i as createSiteService, l as setMyCommands, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getFontThemeCssVariables, r as createNodeRequestRuntime, s as resolveConfig, t as createApp, u as setWebhook, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-CpmficmQ.js";
3
- import { t as createExportService } from "./export-Ba7NJImL.js";
2
+ import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as getWebhookUrl, d as BUILTIN_FONT_THEMES, f as getCjkSerifCssVariables, g as pgSchemaBundle, h as createStorageDriver, i as createSiteService, l as setMyCommands, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getFontThemeCssVariables, r as createNodeRequestRuntime, s as resolveConfig, t as createApp, u as setWebhook, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-HnzmsaqU.js";
3
+ import { t as createExportService } from "./export-Dfbq9aot.js";
4
4
  import { C as shouldTrustProxy, S as getTelegramWebhookSecret, b as getSiteResolutionMode, d as getHostedControlPlaneBaseUrl, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as getTelegramBotPool, y as getPort } from "./env-OHRKGcMj.js";
5
- import "./github-sync-Cb4_6_i7.js";
5
+ import "./github-sync--PSoE-Ne.js";
6
6
  import { drizzle } from "drizzle-orm/better-sqlite3";
7
7
  import { serve } from "@hono/node-server";
8
8
  import Database from "better-sqlite3";
@@ -529,7 +529,7 @@ async function createNodeRequestHandler(options) {
529
529
  async function start(env = process.env, app) {
530
530
  const handler = await createNodeRequestHandler({
531
531
  env,
532
- app: async () => app ?? (await import("./app-DqKkZenB.js")).createApp()
532
+ app: async () => app ?? (await import("./app-CWJFPjuR.js")).createApp()
533
533
  });
534
534
  const hostname = resolveHost(env);
535
535
  const port = resolvePort(env);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -281,7 +281,6 @@ describe("createExportService (Hugo)", () => {
281
281
  makeSiteConfig({
282
282
  siteName: "My Site",
283
283
  siteUrl: "https://my.example",
284
- homeDefaultView: "featured",
285
284
  }),
286
285
  );
287
286
  const files = filesToMap(await service.generateHugoFiles());
@@ -292,7 +291,7 @@ describe("createExportService (Hugo)", () => {
292
291
  expect(toml).toContain('theme = "jant"');
293
292
  expect(toml).toMatch(/\[permalinks\][\s\S]*post = "\/:slug\/"/);
294
293
  expect(toml).toContain("[params]");
295
- expect(toml).toContain('home_default_view = "featured"');
294
+ expect(toml).not.toContain("home_default_view");
296
295
  });
297
296
 
298
297
  it("configures hugo.toml for Atom RSS output with per-section opt-in", async () => {
@@ -123,6 +123,9 @@ export function createTestApp(options: TestAppOptions = {}) {
123
123
  c.set("appConfig", resolveConfig(c.env, allSettings));
124
124
  c.set("storage", options.storage ?? null);
125
125
  c.set("rateLimiter", rateLimiter);
126
+ const publicRequestUrl = c.req.url;
127
+ c.set("publicRequestUrl", publicRequestUrl);
128
+ c.set("publicPath", new URL(publicRequestUrl).pathname);
126
129
 
127
130
  // i18n (English default for tests)
128
131
  const i18n = createI18n("en");
@@ -88,7 +88,6 @@ export function makeSiteConfig(over: Partial<SiteConfig> = {}): SiteConfig {
88
88
  siteDescription: "Export fixture",
89
89
  siteLanguage: "en",
90
90
  showJantBrandingOnHome: true,
91
- homeDefaultView: "latest",
92
91
  mainRssFeed: "latest",
93
92
  siteFooter: "",
94
93
  showHeaderAvatar: false,
@@ -24,6 +24,7 @@ const {
24
24
  uploadMediaList,
25
25
  normalizeTextAttachmentSpec,
26
26
  isAbsoluteImportUrl,
27
+ shouldImportReplyQuietly,
27
28
  } = __test__;
28
29
 
29
30
  async function writeFileTree(
@@ -97,7 +98,6 @@ describe("Hugo import CLI helpers", () => {
97
98
  'site_name = "Example Site"',
98
99
  'site_description = "A description"',
99
100
  'site_language = "en"',
100
- 'home_default_view = "featured"',
101
101
  "show_jant_branding_on_home = true",
102
102
  "show_header_avatar = false",
103
103
  "noindex = false",
@@ -135,7 +135,7 @@ describe("Hugo import CLI helpers", () => {
135
135
  expect(siteConfig.title).toBe("Example Site");
136
136
  expect(siteConfig.base_url).toBe("https://example.com/");
137
137
  expect(siteConfig.extra.jant.theme_id).toBe("paper");
138
- expect(siteConfig.extra.jant.home_default_view).toBe("featured");
138
+ expect(siteConfig.extra.jant).not.toHaveProperty("home_default_view");
139
139
  expect(siteConfig.extra.jant.nav_exported).toBe(true);
140
140
  expect(siteConfig.extra.jant.nav).toHaveLength(1);
141
141
  expect(siteConfig.extra.jant.collections_directory_exported).toBe(true);
@@ -368,6 +368,35 @@ describe("Hugo import CLI helpers", () => {
368
368
  });
369
369
  });
370
370
 
371
+ it("buildPostPayloadFromBundle can mark imported replies as quiet", () => {
372
+ const bundle = {
373
+ slug: "reply-a",
374
+ frontMatter: {
375
+ id: "pst_reply",
376
+ title: "Reply A",
377
+ date: "2026-04-01T01:00:00Z",
378
+ slug: "reply-a",
379
+ type: "post",
380
+ format: "note",
381
+ status: "published",
382
+ visibility: "public",
383
+ },
384
+ body: "Reply body",
385
+ };
386
+ const data = buildPostPayloadFromBundle(bundle, {
387
+ bodyMarkdown: "Reply body",
388
+ attachments: [],
389
+ memberships: { entries: [], ids: [] },
390
+ replyToId: "pst_root",
391
+ quietReply: true,
392
+ });
393
+
394
+ expect(data).toMatchObject({
395
+ replyToId: "pst_root",
396
+ quietReply: true,
397
+ });
398
+ });
399
+
371
400
  it("buildPostPayloadFromBundle preserves every canonical visibility value", () => {
372
401
  const base = {
373
402
  slug: "hello",
@@ -406,6 +435,51 @@ describe("Hugo import CLI helpers", () => {
406
435
  expect(fallback.visibility).toBeUndefined();
407
436
  });
408
437
 
438
+ it("shouldImportReplyQuietly preserves exported thread activity cutoffs", () => {
439
+ const rootFrontMatter = {
440
+ date: "2026-04-01T00:00:00Z",
441
+ last_activity_at: "2026-04-01T01:00:00Z",
442
+ };
443
+
444
+ expect(
445
+ shouldImportReplyQuietly(rootFrontMatter, {
446
+ date: "2026-04-01T00:30:00Z",
447
+ status: "published",
448
+ }),
449
+ ).toBe(false);
450
+ expect(
451
+ shouldImportReplyQuietly(rootFrontMatter, {
452
+ date: "2026-04-01T01:00:00Z",
453
+ status: "published",
454
+ }),
455
+ ).toBe(false);
456
+ expect(
457
+ shouldImportReplyQuietly(rootFrontMatter, {
458
+ date: "2026-04-01T02:00:00Z",
459
+ status: "published",
460
+ }),
461
+ ).toBe(true);
462
+ });
463
+
464
+ it("shouldImportReplyQuietly falls back to root date for all-quiet threads", () => {
465
+ const rootFrontMatter = {
466
+ date: "2026-04-01T00:00:00Z",
467
+ };
468
+
469
+ expect(
470
+ shouldImportReplyQuietly(rootFrontMatter, {
471
+ date: "2026-04-01T01:00:00Z",
472
+ status: "published",
473
+ }),
474
+ ).toBe(true);
475
+ expect(
476
+ shouldImportReplyQuietly(rootFrontMatter, {
477
+ date: "2026-04-01T01:00:00Z",
478
+ status: "draft",
479
+ }),
480
+ ).toBe(false);
481
+ });
482
+
409
483
  it("getRootAliasPathsForImport prefers root_aliases and strips reply slugs", () => {
410
484
  // Reply slug paths are normalized (no trailing slash) to match the
411
485
  // return format of normalizeImportAliasPath.
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldRunLinguiBuildForFile } from "../../vite.dev-plugins.js";
3
+
4
+ describe("vite dev plugins", () => {
5
+ it("does not run Lingui build for generated i18n output", () => {
6
+ expect(
7
+ shouldRunLinguiBuildForFile(
8
+ "/repo/packages/core/src/i18n/coverage.generated.ts",
9
+ ),
10
+ ).toBe(false);
11
+ expect(
12
+ shouldRunLinguiBuildForFile(
13
+ "\\repo\\packages\\core\\src\\i18n\\coverage.generated.ts",
14
+ ),
15
+ ).toBe(false);
16
+ expect(
17
+ shouldRunLinguiBuildForFile(
18
+ "/repo/packages/core/src/i18n/locales/settings/en.ts",
19
+ ),
20
+ ).toBe(false);
21
+ });
22
+
23
+ it("runs Lingui build for source TypeScript files", () => {
24
+ expect(
25
+ shouldRunLinguiBuildForFile("/repo/packages/core/src/ui/pages/Home.tsx"),
26
+ ).toBe(true);
27
+ expect(
28
+ shouldRunLinguiBuildForFile("/repo/packages/core/src/routes/posts.ts"),
29
+ ).toBe(true);
30
+ expect(
31
+ shouldRunLinguiBuildForFile("/repo/packages/core/src/styles/ui.css"),
32
+ ).toBe(false);
33
+ });
34
+ });
@@ -1,7 +1,10 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { afterEach, describe, expect, it } from "vitest";
4
- import { getReplyRefreshTarget } from "../compose-launch.js";
4
+ import {
5
+ getReplyRefreshTarget,
6
+ getReplyTargetArticle,
7
+ } from "../compose-launch.js";
5
8
 
6
9
  function getArticle(): HTMLElement {
7
10
  const article = document.querySelector<HTMLElement>("article[data-post]");
@@ -9,6 +12,14 @@ function getArticle(): HTMLElement {
9
12
  return article;
10
13
  }
11
14
 
15
+ function getArticleByPostId(postId: string): HTMLElement {
16
+ const article = document.querySelector<HTMLElement>(
17
+ `article[data-post-id="${postId}"]`,
18
+ );
19
+ if (!article) throw new Error(`expected article for ${postId}`);
20
+ return article;
21
+ }
22
+
12
23
  describe("getReplyRefreshTarget", () => {
13
24
  afterEach(() => {
14
25
  document.body.innerHTML = "";
@@ -118,3 +129,41 @@ describe("getReplyRefreshTarget", () => {
118
129
  expect(getReplyRefreshTarget(getArticle())).toBeNull();
119
130
  });
120
131
  });
132
+
133
+ describe("getReplyTargetArticle", () => {
134
+ afterEach(() => {
135
+ document.body.innerHTML = "";
136
+ });
137
+
138
+ it("targets the latest thread detail post when the current page is the root", () => {
139
+ document.body.innerHTML = `
140
+ <div data-post-view data-post-view-id="pst_root">
141
+ <div class="thread-group thread-group-detail" data-page="post">
142
+ <div class="thread-item thread-detail-item" data-post-current>
143
+ <article data-post data-post-id="pst_root" data-thread-root-id="pst_root"></article>
144
+ </div>
145
+ <div class="thread-item thread-detail-item">
146
+ <article data-post data-post-id="pst_reply_1" data-thread-root-id="pst_root"></article>
147
+ </div>
148
+ <div class="thread-item thread-detail-item">
149
+ <article data-post data-post-id="pst_reply_2" data-thread-root-id="pst_root"></article>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ `;
154
+
155
+ expect(getReplyTargetArticle(document)?.dataset.postId).toBe("pst_reply_2");
156
+ });
157
+
158
+ it("keeps using the current article outside thread detail pages", () => {
159
+ document.body.innerHTML = `
160
+ <div data-post-view data-post-view-id="pst_single">
161
+ <article data-post data-post-id="pst_single"></article>
162
+ </div>
163
+ `;
164
+
165
+ expect(getReplyTargetArticle(document)).toBe(
166
+ getArticleByPostId("pst_single"),
167
+ );
168
+ });
169
+ });
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { __testOnly as composeDiscoveryTestOnly } from "../compose-discovery.js";
5
- import "../compose-shortcuts.js";
5
+ import { __testOnly as composeShortcutsTestOnly } from "../compose-shortcuts.js";
6
6
 
7
7
  type ComposeHarness = HTMLElement & {
8
8
  openNew: (options?: unknown) => Promise<void>;
@@ -44,6 +44,7 @@ function renderThreadDetailPage() {
44
44
  threadGroup.className = "thread-group thread-group-detail";
45
45
 
46
46
  const currentItem = document.createElement("div");
47
+ currentItem.className = "thread-item thread-detail-item";
47
48
  currentItem.dataset.postCurrent = "";
48
49
 
49
50
  const currentArticle = document.createElement("article");
@@ -58,6 +59,7 @@ function renderThreadDetailPage() {
58
59
  `;
59
60
 
60
61
  const hoveredItem = document.createElement("div");
62
+ hoveredItem.className = "thread-item thread-detail-item";
61
63
 
62
64
  const hoveredArticle = document.createElement("article");
63
65
  hoveredArticle.dataset.post = "";
@@ -70,14 +72,30 @@ function renderThreadDetailPage() {
70
72
  <div data-post-body>Hovered body</div>
71
73
  `;
72
74
 
75
+ const latestItem = document.createElement("div");
76
+ latestItem.className = "thread-item thread-detail-item";
77
+
78
+ const latestArticle = document.createElement("article");
79
+ latestArticle.dataset.post = "";
80
+ latestArticle.dataset.postId = "post-latest";
81
+ latestArticle.dataset.threadRootId = "thread-root";
82
+ latestArticle.dataset.format = "note";
83
+ latestArticle.innerHTML = `
84
+ <div data-post-meta>meta</div>
85
+ <time class="dt-published">Mar 21</time>
86
+ <div data-post-body>Latest body</div>
87
+ `;
88
+
73
89
  currentItem.appendChild(currentArticle);
74
90
  hoveredItem.appendChild(hoveredArticle);
91
+ latestItem.appendChild(latestArticle);
75
92
  threadGroup.appendChild(currentItem);
76
93
  threadGroup.appendChild(hoveredItem);
94
+ threadGroup.appendChild(latestItem);
77
95
  postView.appendChild(threadGroup);
78
96
  document.body.appendChild(postView);
79
97
 
80
- return { currentArticle, hoveredArticle };
98
+ return { currentArticle, hoveredArticle, latestArticle };
81
99
  }
82
100
 
83
101
  describe("compose shortcuts", () => {
@@ -130,7 +148,7 @@ describe("compose shortcuts", () => {
130
148
  expect(composeEl.openNew).not.toHaveBeenCalled();
131
149
  });
132
150
 
133
- it("opens a reply composer for the current post on detail pages with r", () => {
151
+ it("opens a reply composer for the latest thread post on detail pages with r", () => {
134
152
  const composeEl = createComposeHarness();
135
153
 
136
154
  const postView = document.createElement("div");
@@ -138,22 +156,45 @@ describe("compose shortcuts", () => {
138
156
  postView.dataset.postViewId = "post-current";
139
157
 
140
158
  const current = document.createElement("div");
159
+ current.className = "thread-item thread-detail-item";
141
160
  current.dataset.postCurrent = "";
142
161
 
143
- const article = document.createElement("article");
144
- article.dataset.post = "";
145
- article.dataset.postId = "post-current";
146
- article.dataset.threadRootId = "thread-root";
147
- article.dataset.format = "quote";
148
- article.innerHTML = `
162
+ const currentArticle = document.createElement("article");
163
+ currentArticle.dataset.post = "";
164
+ currentArticle.dataset.postId = "post-current";
165
+ currentArticle.dataset.threadRootId = "thread-root";
166
+ currentArticle.dataset.format = "quote";
167
+ currentArticle.innerHTML = `
149
168
  <div data-post-meta>meta</div>
150
169
  <div class="post-status-badges">badges</div>
151
170
  <time class="dt-published">Mar 19</time>
152
- <div data-post-body>Reply body</div>
171
+ <div data-post-body>Current body</div>
172
+ `;
173
+
174
+ const latest = document.createElement("div");
175
+ latest.className = "thread-item thread-detail-item";
176
+
177
+ const latestArticle = document.createElement("article");
178
+ latestArticle.dataset.post = "";
179
+ latestArticle.dataset.postId = "post-latest";
180
+ latestArticle.dataset.threadRootId = "thread-root";
181
+ latestArticle.dataset.format = "quote";
182
+ latestArticle.innerHTML = `
183
+ <div data-post-meta>meta</div>
184
+ <div class="post-status-badges">badges</div>
185
+ <time class="dt-published">Mar 21</time>
186
+ <div data-post-body>Latest reply body</div>
153
187
  `;
154
188
 
155
- current.appendChild(article);
156
- postView.appendChild(current);
189
+ const threadGroup = document.createElement("div");
190
+ threadGroup.dataset.page = "post";
191
+ threadGroup.className = "thread-group thread-group-detail";
192
+
193
+ current.appendChild(currentArticle);
194
+ latest.appendChild(latestArticle);
195
+ threadGroup.appendChild(current);
196
+ threadGroup.appendChild(latest);
197
+ postView.appendChild(threadGroup);
157
198
  document.body.appendChild(postView);
158
199
 
159
200
  const event = dispatchShortcut(document, "r");
@@ -165,13 +206,13 @@ describe("compose shortcuts", () => {
165
206
  composeEl.openReply,
166
207
  ).mock.calls[0] ?? [null, null, null, null];
167
208
 
168
- expect(postId).toBe("post-current");
209
+ expect(postId).toBe("post-latest");
169
210
  expect(threadRootId).toBe("thread-root");
170
211
  expect(refreshTarget).toEqual({ kind: "post-view", id: "post-current" });
171
212
  expect(vi.mocked(composeEl.openReply).mock.calls[0]?.[4]).toBeUndefined();
172
- expect(replyData).toMatchObject({ dateText: "Mar 19" });
213
+ expect(replyData).toMatchObject({ dateText: "Mar 21" });
173
214
  expect((replyData as { contentHtml: string }).contentHtml).toContain(
174
- "Reply body",
215
+ "Latest reply body",
175
216
  );
176
217
  expect((replyData as { contentHtml: string }).contentHtml).not.toContain(
177
218
  "meta",
@@ -181,7 +222,7 @@ describe("compose shortcuts", () => {
181
222
  );
182
223
  });
183
224
 
184
- it("prefers the hovered thread post for reply shortcuts on detail pages", () => {
225
+ it("targets the latest thread post for reply shortcuts even when an older post is hovered", () => {
185
226
  const composeEl = createComposeHarness();
186
227
  const { hoveredArticle } = renderThreadDetailPage();
187
228
  const originalQuerySelector = document.querySelector.bind(document);
@@ -199,8 +240,8 @@ describe("compose shortcuts", () => {
199
240
 
200
241
  expect(event.defaultPrevented).toBe(true);
201
242
  expect(composeEl.openReply).toHaveBeenCalledWith(
202
- "post-hovered",
203
- expect.objectContaining({ dateText: "Mar 20" }),
243
+ "post-latest",
244
+ expect.objectContaining({ dateText: "Mar 21" }),
204
245
  "thread-root",
205
246
  { kind: "post-view", id: "post-current" },
206
247
  );
@@ -228,6 +269,21 @@ describe("compose shortcuts", () => {
228
269
  expect(composeEl.openEdit).toHaveBeenCalledWith("post-hovered");
229
270
  });
230
271
 
272
+ it("opens the current post editor from the edit query parameter", async () => {
273
+ const composeEl = createComposeHarness();
274
+ composeEl.openEdit = vi.fn(async () => {});
275
+ document.body.setAttribute("data-authenticated", "true");
276
+ renderThreadDetailPage();
277
+ globalThis.history.pushState({}, "", "/about?edit=1");
278
+
279
+ composeShortcutsTestOnly.openEditFromQueryParam();
280
+ await Promise.resolve();
281
+
282
+ expect(composeEl.openEdit).toHaveBeenCalledWith("post-current");
283
+ expect(window.location.pathname).toBe("/about");
284
+ expect(window.location.search).toBe("");
285
+ });
286
+
231
287
  it("keeps using the hovered post for reply shortcuts in the timeline", () => {
232
288
  const composeEl = createComposeHarness();
233
289
  const article = document.createElement("article");
@@ -3,6 +3,8 @@
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
 
5
5
  type SiteHeaderInit = typeof import("../site-header-nav.js").initSiteHeaderNav;
6
+ type SiteHeaderDestroy =
7
+ typeof import("../site-header-nav.js").destroySiteHeaderNav;
6
8
 
7
9
  function createDrawerDOM(): HTMLElement {
8
10
  const root = document.createElement("div");
@@ -50,13 +52,15 @@ function createMoreDropdownDOM(): HTMLElement {
50
52
 
51
53
  describe("site header more dropdown", () => {
52
54
  let initSiteHeaderNav: SiteHeaderInit;
55
+ let destroySiteHeaderNav: SiteHeaderDestroy;
53
56
  let root: HTMLElement;
54
57
 
55
58
  beforeEach(async () => {
56
59
  document.body.innerHTML = "";
57
60
  vi.resetModules();
58
61
  root = createMoreDropdownDOM();
59
- ({ initSiteHeaderNav } = await import("../site-header-nav.js"));
62
+ ({ initSiteHeaderNav, destroySiteHeaderNav } =
63
+ await import("../site-header-nav.js"));
60
64
  initSiteHeaderNav(root);
61
65
  });
62
66
 
@@ -130,10 +134,26 @@ describe("site header more dropdown", () => {
130
134
  expect(trigger.getAttribute("aria-expanded")).toBe("false");
131
135
  expect(popover.getAttribute("aria-hidden")).toBe("true");
132
136
  });
137
+
138
+ it("removes dropdown listeners when destroyed", () => {
139
+ const trigger = root.querySelector(
140
+ ".site-header-more-btn",
141
+ ) as HTMLButtonElement;
142
+ const popover = root.querySelector(
143
+ ".site-header-more-popover",
144
+ ) as HTMLElement;
145
+
146
+ destroySiteHeaderNav(root);
147
+ trigger.click();
148
+
149
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
150
+ expect(popover.getAttribute("aria-hidden")).toBe("true");
151
+ });
133
152
  });
134
153
 
135
154
  describe("site header nav drawer", () => {
136
155
  let initSiteHeaderNav: SiteHeaderInit;
156
+ let destroySiteHeaderNav: SiteHeaderDestroy;
137
157
  let root: HTMLElement;
138
158
 
139
159
  beforeEach(async () => {
@@ -141,7 +161,8 @@ describe("site header nav drawer", () => {
141
161
  document.documentElement.classList.remove("drawer-open");
142
162
  vi.resetModules();
143
163
  root = createDrawerDOM();
144
- ({ initSiteHeaderNav } = await import("../site-header-nav.js"));
164
+ ({ initSiteHeaderNav, destroySiteHeaderNav } =
165
+ await import("../site-header-nav.js"));
145
166
  initSiteHeaderNav(root);
146
167
  });
147
168
 
@@ -247,4 +268,17 @@ describe("site header nav drawer", () => {
247
268
  expect(drawer.getAttribute("aria-hidden")).toBe("true");
248
269
  expect(hamburger.getAttribute("aria-expanded")).toBe("false");
249
270
  });
271
+
272
+ it("removes drawer listeners when destroyed", () => {
273
+ const hamburger = root.querySelector(
274
+ ".site-header-hamburger",
275
+ ) as HTMLButtonElement;
276
+ const drawer = root.querySelector("#site-nav-drawer") as HTMLElement;
277
+
278
+ destroySiteHeaderNav(root);
279
+ hamburger.click();
280
+
281
+ expect(drawer.getAttribute("aria-hidden")).toBe("true");
282
+ expect(hamburger.getAttribute("aria-expanded")).toBe("false");
283
+ });
250
284
  });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { JSONContent } from "@tiptap/core";
3
+
4
+ import { promoteLeadingH1Title } from "../compose-title-from-heading.js";
5
+
6
+ function doc(...content: JSONContent[]): JSONContent {
7
+ return { type: "doc", content };
8
+ }
9
+
10
+ function paragraph(text = ""): JSONContent {
11
+ return text
12
+ ? { type: "paragraph", content: [{ type: "text", text }] }
13
+ : { type: "paragraph" };
14
+ }
15
+
16
+ function heading(level: number, text = ""): JSONContent {
17
+ return text
18
+ ? {
19
+ type: "heading",
20
+ attrs: { level },
21
+ content: [{ type: "text", text }],
22
+ }
23
+ : { type: "heading", attrs: { level } };
24
+ }
25
+
26
+ describe("promoteLeadingH1Title", () => {
27
+ it("extracts the first non-empty H1 and removes it from the body", () => {
28
+ const result = promoteLeadingH1Title(
29
+ doc(paragraph(), heading(1, " My Title "), paragraph("Body")),
30
+ );
31
+
32
+ expect(result?.title).toBe("My Title");
33
+ expect(result?.headingIndex).toBe(1);
34
+ expect(result?.bodyJson).toEqual(doc(paragraph("Body")));
35
+ });
36
+
37
+ it("returns null when the first non-empty block is not an H1", () => {
38
+ const result = promoteLeadingH1Title(
39
+ doc(paragraph("Intro"), heading(1, "Title")),
40
+ );
41
+
42
+ expect(result).toBeNull();
43
+ });
44
+
45
+ it("returns null for empty H1 blocks", () => {
46
+ const result = promoteLeadingH1Title(doc(heading(1), paragraph("Body")));
47
+
48
+ expect(result).toBeNull();
49
+ });
50
+
51
+ it("returns a null body when the promoted H1 is the only content", () => {
52
+ const result = promoteLeadingH1Title(doc(heading(1, "Only title")));
53
+
54
+ expect(result?.title).toBe("Only title");
55
+ expect(result?.bodyJson).toBeNull();
56
+ });
57
+ });