@jant/core 0.6.6 → 0.6.8

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 (112) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-BJkOcMbZ.js → app-9P4rVCe2.js} +396 -117
  3. package/dist/app-DaxS_Cz-.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-C6peCkkD.css +2 -0
  6. package/dist/client/_assets/{client-mBvc8KAT.js → client-CXnEhyyv.js} +2 -2
  7. package/dist/client/_assets/{client-auth-BlfwVtHz.js → client-auth-CSItbyU8.js} +360 -358
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
  13. package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/json.test.ts +94 -0
  22. package/src/client/__tests__/note-expand.test.ts +130 -0
  23. package/src/client/archive-nav.js +2 -1
  24. package/src/client/audio-player.ts +7 -3
  25. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  26. package/src/client/components/__tests__/jant-compose-dialog.test.ts +357 -0
  27. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  29. package/src/client/components/compose-format-convert.ts +255 -0
  30. package/src/client/components/compose-types.ts +2 -0
  31. package/src/client/components/jant-collection-directory.ts +1 -0
  32. package/src/client/components/jant-collection-form.ts +1 -0
  33. package/src/client/components/jant-command-palette.ts +4 -0
  34. package/src/client/components/jant-compose-dialog.ts +106 -44
  35. package/src/client/components/jant-compose-editor.ts +65 -11
  36. package/src/client/components/jant-compose-fullscreen.ts +3 -0
  37. package/src/client/components/jant-nav-manager.ts +4 -0
  38. package/src/client/components/jant-post-menu.ts +3 -0
  39. package/src/client/components/jant-repo-picker.ts +3 -0
  40. package/src/client/components/jant-settings-general.ts +3 -0
  41. package/src/client/compose-bridge.ts +17 -0
  42. package/src/client/feed-video-player.ts +1 -1
  43. package/src/client/hydrate-partial.ts +25 -0
  44. package/src/client/json.ts +56 -2
  45. package/src/client/multipart-upload.ts +17 -7
  46. package/src/client/note-expand.ts +63 -0
  47. package/src/client/upload-session.ts +17 -9
  48. package/src/client.ts +1 -0
  49. package/src/i18n/locales/public/en.po +41 -0
  50. package/src/i18n/locales/public/en.ts +1 -1
  51. package/src/i18n/locales/public/zh-Hans.po +41 -0
  52. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  53. package/src/i18n/locales/public/zh-Hant.po +41 -0
  54. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  55. package/src/i18n/locales/settings/en.po +12 -12
  56. package/src/i18n/locales/settings/en.ts +1 -1
  57. package/src/i18n/locales/settings/zh-Hans.po +12 -12
  58. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  59. package/src/i18n/locales/settings/zh-Hant.po +12 -12
  60. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  61. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  62. package/src/lib/__tests__/markdown.test.ts +1 -1
  63. package/src/lib/__tests__/summary.test.ts +87 -0
  64. package/src/lib/__tests__/timeline.test.ts +48 -1
  65. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  66. package/src/lib/__tests__/url.test.ts +44 -0
  67. package/src/lib/__tests__/view.test.ts +168 -1
  68. package/src/lib/navigation.ts +1 -0
  69. package/src/lib/resolve-config.ts +2 -2
  70. package/src/lib/summary.ts +42 -3
  71. package/src/lib/tiptap-render.ts +6 -2
  72. package/src/lib/upload.ts +2 -2
  73. package/src/lib/url.ts +41 -0
  74. package/src/lib/view.ts +102 -40
  75. package/src/preset.css +7 -1
  76. package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
  77. package/src/routes/api/internal/sites.ts +77 -1
  78. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  79. package/src/routes/api/public/archive.ts +22 -6
  80. package/src/routes/api/telegram.ts +2 -1
  81. package/src/routes/dash/custom-urls.tsx +1 -1
  82. package/src/routes/dash/settings.tsx +8 -5
  83. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  84. package/src/routes/pages/archive.tsx +116 -20
  85. package/src/routes/pages/collections.tsx +1 -0
  86. package/src/services/__tests__/media.test.ts +83 -0
  87. package/src/services/__tests__/post.test.ts +81 -0
  88. package/src/services/export-theme/assets/client-site.js +1 -1
  89. package/src/services/export-theme/styles/main.css +49 -15
  90. package/src/services/media.ts +31 -1
  91. package/src/services/post.ts +22 -2
  92. package/src/services/search.ts +4 -4
  93. package/src/services/site-admin.ts +121 -0
  94. package/src/services/upload-session.ts +18 -0
  95. package/src/styles/tokens.css +1 -1
  96. package/src/styles/ui.css +163 -34
  97. package/src/types/config.ts +1 -1
  98. package/src/types/props.ts +3 -0
  99. package/src/ui/compose/ComposeDialog.tsx +13 -0
  100. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  101. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  102. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  103. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  104. package/src/ui/feed/NoteCard.tsx +54 -5
  105. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  106. package/src/ui/pages/ArchivePage.tsx +89 -6
  107. package/src/ui/pages/CollectionsPage.tsx +7 -1
  108. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  109. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  110. package/src/ui/shared/CollectionsManager.tsx +3 -0
  111. package/dist/app-CL2PC1Fl.js +0 -6
  112. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
@@ -1,4 +1,4 @@
1
- import { y as __exportAll } from "./url-XF0GbKGO.js";
1
+ import { b as __exportAll } from "./url-BMYO-Zlt.js";
2
2
  //#region src/lib/github-api.ts
3
3
  var github_api_exports = /* @__PURE__ */ __exportAll({
4
4
  GitHubApiError: () => GitHubApiError,
@@ -1,4 +1,4 @@
1
- import { y as __exportAll } from "./url-XF0GbKGO.js";
1
+ import { b as __exportAll } from "./url-BMYO-Zlt.js";
2
2
  //#region src/lib/github-app.ts
3
3
  var github_app_exports = /* @__PURE__ */ __exportAll({
4
4
  buildInstallUrl: () => buildInstallUrl,
@@ -1,6 +1,6 @@
1
- import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-DLukCOO3.js";
2
- import { r as getInstallationToken } from "./github-app-DeX6Td1O.js";
3
- import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-UD4u_7fa.js";
1
+ import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-Be082J0n.js";
2
+ import { r as getInstallationToken } from "./github-app-BbklkFmU.js";
3
+ import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-BgSiE71w.js";
4
4
  //#region src/lib/markdown-to-tiptap.ts
5
5
  /**
6
6
  * Markdown → TipTap JSON Conversion
@@ -1,4 +1,4 @@
1
- import "./url-XF0GbKGO.js";
2
- import "./export-DLukCOO3.js";
3
- import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-BtHY2AST.js";
1
+ import "./url-BMYO-Zlt.js";
2
+ import "./export-Be082J0n.js";
3
+ import { i as classifyRepoForSync, o as createGitHubSyncService } from "./github-sync-D1Cw8mOY.js";
4
4
  export { classifyRepoForSync, createGitHubSyncService };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { v as url_exports } from "./url-XF0GbKGO.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, h as defaultFeedRenderer, j as MAX_PINNED_POSTS, k as FORMATS, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-BJkOcMbZ.js";
3
- import { T as time_exports, a as markdown_exports } from "./export-DLukCOO3.js";
4
- import "./env-CoSe-1y4.js";
5
- import "./github-sync-BtHY2AST.js";
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, h as defaultFeedRenderer, j as MAX_PINNED_POSTS, k as FORMATS, t as createApp, w as toNavItemView, x as toArchiveGroups } from "./app-9P4rVCe2.js";
3
+ import { T as time_exports, a as markdown_exports } from "./export-Be082J0n.js";
4
+ import "./env-OHRKGcMj.js";
5
+ import "./github-sync-D1Cw8mOY.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
- import "./url-XF0GbKGO.js";
2
- import { B as isAssetPath, L as buildThemeStyle, R as BUILTIN_COLOR_THEMES, _ as sqliteSchemaBundle, a as resolveDatabaseDialect, c as resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-BJkOcMbZ.js";
3
- import { t as createExportService } from "./export-DLukCOO3.js";
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-CoSe-1y4.js";
5
- import "./github-sync-BtHY2AST.js";
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 resolveConfig, d as setWebhook, f as BUILTIN_FONT_THEMES, g as pgSchemaBundle, i as createSiteService, l as getWebhookUrl, m as getFontThemeCssVariables, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as getCjkSerifCssVariables, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as setMyCommands, v as createNodeDatabase, y as schema_exports, z as getPublicAssetBasePath } from "./app-9P4rVCe2.js";
3
+ import { t as createExportService } from "./export-Be082J0n.js";
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-D1Cw8mOY.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-CL2PC1Fl.js")).createApp()
532
+ app: async () => app ?? (await import("./app-DaxS_Cz-.js")).createApp()
533
533
  });
534
534
  const hostname = resolveHost(env);
535
535
  const port = resolvePort(env);
@@ -32,7 +32,8 @@ var __exportAll = (all, no_symbols) => {
32
32
  toAbsoluteAssetUrl: () => toAbsoluteAssetUrl,
33
33
  toAbsoluteSiteUrl: () => toAbsoluteSiteUrl,
34
34
  toPublicHref: () => toPublicHref,
35
- toPublicPath: () => toPublicPath
35
+ toPublicPath: () => toPublicPath,
36
+ toSameSitePath: () => toSameSitePath
36
37
  });
37
38
  function normalizeSitePathname(pathname) {
38
39
  if (pathname === "/" || pathname === "") return "";
@@ -109,6 +110,45 @@ function normalizeSitePathname(pathname) {
109
110
  return str.startsWith("http://") || str.startsWith("https://");
110
111
  }
111
112
  /**
113
+ * If a full URL points at the site's own host, return its same-site path
114
+ * (`pathname` + `search` + `hash`). Returns `null` when the input is not a full
115
+ * URL, is unparseable, or points at a different host.
116
+ *
117
+ * Self-referential absolute links — e.g. a nav item set to
118
+ * `https://example.com/about` on `example.com` — should behave like the
119
+ * internal path `/about`: no external-link icon, no `target="_blank"`.
120
+ *
121
+ * Matching is by **hostname**, not full origin: scheme and port differences are
122
+ * treated as same-site. This keeps the check intuitive ("same domain") and
123
+ * robust in dev, where the site is often served over `http://host:<port>` while
124
+ * a nav link stores the canonical `https://host` URL. In production (canonical
125
+ * https + default port) hostname match is equivalent to origin match.
126
+ *
127
+ * @param url - Candidate URL (full URL or relative path)
128
+ * @param siteOrigin - The site's own origin, e.g. `https://example.com`
129
+ * @returns The same-site path, or `null` when the URL is external/non-absolute
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * toSameSitePath("https://example.com/about", "https://example.com"); // "/about"
134
+ * toSameSitePath("https://example.com/about", "http://example.com:8787"); // "/about"
135
+ * toSameSitePath("https://other.com/about", "https://example.com"); // null
136
+ * toSameSitePath("/about", "https://example.com"); // null
137
+ * ```
138
+ */ function toSameSitePath(url, siteOrigin) {
139
+ if (!siteOrigin || !isFullUrl(url)) return null;
140
+ let parsed;
141
+ let reference;
142
+ try {
143
+ parsed = new URL(url);
144
+ reference = new URL(siteOrigin);
145
+ } catch {
146
+ return null;
147
+ }
148
+ if (parsed.hostname !== reference.hostname) return null;
149
+ return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
150
+ }
151
+ /**
112
152
  * Converts text to a URL-friendly slug.
113
153
  *
114
154
  * Transforms text into a lowercase, hyphen-separated slug using limax for
@@ -352,4 +392,4 @@ var SAFE_URL_PROTOCOLS = new Set([
352
392
  return toAbsoluteSiteUrl(url, siteUrl, sitePathPrefix);
353
393
  }
354
394
  //#endregion
355
- export { toPublicPath as _, getSitePathPrefix as a, normalizePath as c, sanitizeUrl as d, slugify as f, toPublicHref as g, toAbsoluteSiteUrl as h, getSiteOrigin as i, normalizeSitePathPrefix as l, toAbsoluteAssetUrl as m, extractDisplayDomain as n, isFullUrl as o, stripSitePathPrefix as p, extractDomain as r, isSafeInternalRedirect as s, buildSiteUrl as t, normalizeSiteUrl as u, url_exports as v, __exportAll as y };
395
+ export { toPublicPath as _, getSitePathPrefix as a, __exportAll as b, normalizePath as c, sanitizeUrl as d, slugify as f, toPublicHref as g, toAbsoluteSiteUrl as h, getSiteOrigin as i, normalizeSitePathPrefix as l, toAbsoluteAssetUrl as m, extractDisplayDomain as n, isFullUrl as o, stripSitePathPrefix as p, extractDomain as r, isSafeInternalRedirect as s, buildSiteUrl as t, normalizeSiteUrl as u, toSameSitePath as v, url_exports as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -41,6 +41,7 @@ describe("jant uploads cleanup", () => {
41
41
  JSON.stringify({
42
42
  abortedMultipartUploads: 1,
43
43
  deletedSessions: 3,
44
+ deletedOrphanMedia: 2,
44
45
  }),
45
46
  {
46
47
  status: 200,
@@ -73,5 +74,6 @@ describe("jant uploads cleanup", () => {
73
74
  );
74
75
  expect(logSpy).toHaveBeenNthCalledWith(2, "Deleted sessions: 3");
75
76
  expect(logSpy).toHaveBeenNthCalledWith(3, "Aborted multipart uploads: 1");
77
+ expect(logSpy).toHaveBeenNthCalledWith(4, "Orphaned media deleted: 2");
76
78
  });
77
79
  });
@@ -459,6 +459,111 @@ describe("compose bridge", () => {
459
459
  expect(composeEl.clearLocalDraftFromStorage).not.toHaveBeenCalled();
460
460
  });
461
461
 
462
+ it("re-binds the thread-context Show more toggle after a reply swaps in a thread preview", async () => {
463
+ document.body.innerHTML = `
464
+ <div data-timeline-item data-thread-root-id="pst_root">
465
+ <div data-timeline-item-content>
466
+ <article data-post data-post-id="pst_root">Root</article>
467
+ </div>
468
+ </div>
469
+ `;
470
+
471
+ const composeEl = document.createElement(
472
+ "jant-compose-dialog",
473
+ ) as ComposeHarness;
474
+ composeEl.refreshCollections = vi.fn(async () => true);
475
+ composeEl.pageMode = false;
476
+ document.body.appendChild(composeEl);
477
+
478
+ // The freshly rendered thread preview carries the collapsed ancestor shell
479
+ // plus its "Show more" toggle — the markup the bug left inert until reload.
480
+ const threadPreviewHtml = `
481
+ <div class="thread-group thread-group-preview">
482
+ <div class="thread-context-shell" data-thread-context data-collapsed="">
483
+ <div class="thread-item"><article data-post>Root</article></div>
484
+ </div>
485
+ <button
486
+ type="button"
487
+ class="thread-context-toggle"
488
+ data-thread-context-toggle
489
+ data-label-more="Show more"
490
+ data-label-less="Show less"
491
+ aria-expanded="false"
492
+ >
493
+ <span class="thread-context-toggle-label">Show more</span>
494
+ </button>
495
+ <div class="thread-item thread-item-hero"><article data-post>Reply</article></div>
496
+ </div>
497
+ `;
498
+
499
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
500
+ const raw =
501
+ typeof input === "string"
502
+ ? input
503
+ : input instanceof URL
504
+ ? input.toString()
505
+ : input.url;
506
+ const url = new URL(raw, "http://localhost");
507
+
508
+ if (url.pathname === "/compose") {
509
+ return new Response(
510
+ JSON.stringify({ status: "published", permalink: "/the-reply" }),
511
+ { status: 200, headers: { "Content-Type": "application/json" } },
512
+ );
513
+ }
514
+
515
+ if (url.pathname === "/_/timeline-item/pst_root") {
516
+ return new Response(threadPreviewHtml, {
517
+ status: 200,
518
+ headers: { "Content-Type": "text/html" },
519
+ });
520
+ }
521
+
522
+ throw new Error(`Unexpected fetch: ${url.pathname}`);
523
+ });
524
+
525
+ composeEl.dispatchEvent(
526
+ new CustomEvent("jant:compose-submit-deferred", {
527
+ bubbles: true,
528
+ detail: {
529
+ format: "note",
530
+ title: "",
531
+ body: "A reply",
532
+ url: "",
533
+ quoteText: "",
534
+ quoteAuthor: "",
535
+ slug: "",
536
+ status: "published",
537
+ visibility: "public",
538
+ rating: 0,
539
+ collectionIds: [],
540
+ attachments: [],
541
+ pendingAttachments: [],
542
+ replyToId: "pst_parent",
543
+ replyThreadRootId: "pst_root",
544
+ replyRefreshKind: "timeline-item",
545
+ replyRefreshId: "pst_root",
546
+ },
547
+ }),
548
+ );
549
+
550
+ await flushBridgeWork();
551
+
552
+ const toggle = document.querySelector<HTMLElement>(
553
+ "[data-thread-context-toggle]",
554
+ );
555
+ const shell = document.querySelector<HTMLElement>("[data-thread-context]");
556
+ expect(toggle).not.toBeNull();
557
+ expect(shell).not.toBeNull();
558
+ // setupThreadContexts ran on the swapped-in markup.
559
+ expect(toggle?.dataset.threadContextToggleBound).toBe("1");
560
+
561
+ // And the bound listener actually toggles the collapsed state.
562
+ toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
563
+ expect(shell?.dataset.collapsed).toBeUndefined();
564
+ expect(toggle?.getAttribute("aria-expanded")).toBe("true");
565
+ });
566
+
462
567
  it("sends nulls for cleared quote attribution fields when editing", async () => {
463
568
  const composeEl = document.createElement(
464
569
  "jant-compose-dialog",
@@ -0,0 +1,27 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ // Stub the initializer modules so importing hydrate-partial doesn't run their
6
+ // module-load side effects (DOMContentLoaded registration, observers), and so we
7
+ // can assert hydratePartial forwards the root to each one.
8
+ vi.mock("../thread-context.js", () => ({ setupThreadContexts: vi.fn() }));
9
+ vi.mock("../feed-video-player.js", () => ({ initFeedVideoPlayer: vi.fn() }));
10
+ vi.mock("../audio-player.js", () => ({ initPrecomputedWaveforms: vi.fn() }));
11
+
12
+ import { hydratePartial } from "../hydrate-partial.js";
13
+ import { setupThreadContexts } from "../thread-context.js";
14
+ import { initFeedVideoPlayer } from "../feed-video-player.js";
15
+ import { initPrecomputedWaveforms } from "../audio-player.js";
16
+
17
+ describe("hydratePartial", () => {
18
+ it("re-initializes per-element behaviors scoped to the swapped root", () => {
19
+ const root = document.createElement("div");
20
+
21
+ hydratePartial(root);
22
+
23
+ expect(setupThreadContexts).toHaveBeenCalledWith(root);
24
+ expect(initFeedVideoPlayer).toHaveBeenCalledWith(root);
25
+ expect(initPrecomputedWaveforms).toHaveBeenCalledWith(root);
26
+ });
27
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ readErrorMessage,
5
+ readErrorMessageFromText,
6
+ readJsonObject,
7
+ } from "../json.js";
8
+
9
+ function jsonResponse(body: unknown, status = 200): Response {
10
+ return new Response(JSON.stringify(body), {
11
+ status,
12
+ headers: { "Content-Type": "application/json" },
13
+ });
14
+ }
15
+
16
+ function textResponse(body: string, status: number): Response {
17
+ return new Response(body, {
18
+ status,
19
+ headers: { "Content-Type": "text/plain" },
20
+ });
21
+ }
22
+
23
+ describe("readJsonObject", () => {
24
+ it("parses a JSON object body", async () => {
25
+ const res = jsonResponse({ id: "abc", count: 2 });
26
+ expect(await readJsonObject(res)).toEqual({ id: "abc", count: 2 });
27
+ });
28
+
29
+ it("returns empty object for empty body", async () => {
30
+ const res = new Response("", { status: 200 });
31
+ expect(await readJsonObject(res)).toEqual({});
32
+ });
33
+
34
+ it("returns empty object for a JSON primitive", async () => {
35
+ expect(await readJsonObject(jsonResponse("hi"))).toEqual({});
36
+ });
37
+
38
+ it("throws an informative error when body is not JSON", async () => {
39
+ const res = textResponse("Not Found", 404);
40
+ await expect(readJsonObject(res)).rejects.toThrow(
41
+ /Expected JSON \(HTTP 404\) but got: Not Found/,
42
+ );
43
+ });
44
+ });
45
+
46
+ describe("readErrorMessage", () => {
47
+ it("returns the JSON `error` field when present", async () => {
48
+ const res = jsonResponse({ error: "Quota exceeded" }, 400);
49
+ expect(await readErrorMessage(res, "Default")).toBe("Quota exceeded");
50
+ });
51
+
52
+ it("surfaces plain-text body when not JSON (the Not Found case)", async () => {
53
+ const res = textResponse("Not Found", 404);
54
+ expect(await readErrorMessage(res, "Failed to start upload")).toBe(
55
+ "Not Found",
56
+ );
57
+ });
58
+
59
+ it("falls back when body is empty", async () => {
60
+ const res = new Response("", { status: 500 });
61
+ expect(await readErrorMessage(res, "Default")).toBe("Default");
62
+ });
63
+
64
+ it("truncates very long bodies (e.g. error HTML pages)", async () => {
65
+ const long = "x".repeat(500);
66
+ const res = textResponse(long, 502);
67
+ const result = await readErrorMessage(res, "Default");
68
+ expect(result.length).toBeLessThanOrEqual(201);
69
+ expect(result.endsWith("…")).toBe(true);
70
+ });
71
+
72
+ it("falls back when JSON has no `error` field", async () => {
73
+ const res = jsonResponse({ status: "bad" }, 400);
74
+ expect(await readErrorMessage(res, "Default")).toBe('{"status":"bad"}');
75
+ });
76
+ });
77
+
78
+ describe("readErrorMessageFromText", () => {
79
+ it("extracts error from JSON text", () => {
80
+ expect(
81
+ readErrorMessageFromText('{"error":"part too small"}', "Default"),
82
+ ).toBe("part too small");
83
+ });
84
+
85
+ it("returns plain text when not JSON", () => {
86
+ expect(readErrorMessageFromText("Internal Error", "Default")).toBe(
87
+ "Internal Error",
88
+ );
89
+ });
90
+
91
+ it("falls back when text is blank", () => {
92
+ expect(readErrorMessageFromText(" ", "Default")).toBe("Default");
93
+ });
94
+ });
@@ -0,0 +1,130 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import "../note-expand.js";
5
+
6
+ interface CardOptions {
7
+ clamped?: boolean;
8
+ withMarker?: boolean;
9
+ }
10
+
11
+ function buildCard(options: CardOptions = {}) {
12
+ const { clamped = true, withMarker = true } = options;
13
+
14
+ const article = document.createElement("article");
15
+ article.setAttribute("data-post", "");
16
+
17
+ const body = document.createElement("div");
18
+ body.setAttribute("data-post-body", "");
19
+ if (clamped) body.setAttribute("data-note-clamp", "");
20
+ body.innerHTML = withMarker
21
+ ? "<p>Summary</p><span data-note-break></span><p>Rest</p>"
22
+ : "<p>Summary</p>";
23
+
24
+ const control = document.createElement("a");
25
+ control.setAttribute("data-note-expand", "");
26
+ control.setAttribute("aria-expanded", "false");
27
+ control.setAttribute("href", "/post-1");
28
+ control.dataset.labelMore = "Read more";
29
+ control.dataset.labelLess = "Read less";
30
+ control.textContent = "Read more";
31
+
32
+ article.appendChild(body);
33
+ article.appendChild(control);
34
+ document.body.appendChild(article);
35
+ return { article, body, control };
36
+ }
37
+
38
+ /** Dispatch a click; returns false when a handler called preventDefault. */
39
+ function click(
40
+ el: HTMLElement,
41
+ init: { metaKey?: boolean; button?: number } = {},
42
+ ): boolean {
43
+ return el.dispatchEvent(
44
+ new MouseEvent("click", { bubbles: true, cancelable: true, ...init }),
45
+ );
46
+ }
47
+
48
+ describe("note expand", () => {
49
+ beforeEach(() => {
50
+ document.body.innerHTML = "";
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ it("reveals the tail by removing the clamp on expand", () => {
55
+ const { body, control } = buildCard();
56
+
57
+ const notCancelled = click(control);
58
+
59
+ expect(notCancelled).toBe(false);
60
+ expect(body.hasAttribute("data-note-clamp")).toBe(false);
61
+ expect(control.getAttribute("aria-expanded")).toBe("true");
62
+ expect(control.textContent).toBe("Read less");
63
+ });
64
+
65
+ it("re-clamps the tail on collapse", () => {
66
+ const { body, control } = buildCard();
67
+
68
+ click(control);
69
+ click(control);
70
+
71
+ expect(body.hasAttribute("data-note-clamp")).toBe(true);
72
+ expect(control.getAttribute("aria-expanded")).toBe("false");
73
+ expect(control.textContent).toBe("Read more");
74
+ });
75
+
76
+ it("ignores modified clicks so the link opens normally", () => {
77
+ const { body, control } = buildCard();
78
+
79
+ const notCancelled = click(control, { metaKey: true });
80
+
81
+ expect(notCancelled).toBe(true);
82
+ expect(body.hasAttribute("data-note-clamp")).toBe(true);
83
+ });
84
+
85
+ it("leaves the link alone when the body has no break marker", () => {
86
+ const { body, control } = buildCard({ clamped: false, withMarker: false });
87
+
88
+ const notCancelled = click(control);
89
+
90
+ expect(notCancelled).toBe(true);
91
+ expect(body.hasAttribute("data-note-clamp")).toBe(false);
92
+ });
93
+
94
+ it("only toggles the clicked card", () => {
95
+ const a = buildCard();
96
+ const b = buildCard();
97
+
98
+ click(a.control);
99
+
100
+ expect(a.body.hasAttribute("data-note-clamp")).toBe(false);
101
+ expect(b.body.hasAttribute("data-note-clamp")).toBe(true);
102
+ });
103
+
104
+ it("scrolls the note top into view on collapse when scrolled past it", () => {
105
+ const { article, control } = buildCard();
106
+ click(control); // expand
107
+
108
+ const scrollIntoView = vi.fn();
109
+ article.scrollIntoView = scrollIntoView;
110
+ vi.spyOn(article, "getBoundingClientRect").mockReturnValue({
111
+ top: -50,
112
+ } as unknown as ReturnType<HTMLElement["getBoundingClientRect"]>);
113
+
114
+ click(control); // collapse
115
+
116
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: "start" });
117
+ });
118
+
119
+ it("keeps working after the card DOM is replaced", () => {
120
+ buildCard();
121
+
122
+ // Simulate compose-bridge replacing the card with a fresh collapsed render.
123
+ document.body.innerHTML = "";
124
+ const { body, control } = buildCard();
125
+
126
+ click(control);
127
+
128
+ expect(body.hasAttribute("data-note-clamp")).toBe(false);
129
+ });
130
+ });
@@ -65,7 +65,8 @@ document.querySelectorAll(".archive-chip-dropdown").forEach((chip) => {
65
65
  } else {
66
66
  url.searchParams.delete(filterKey);
67
67
  }
68
- // Clear hasMedia when toggling kinds
68
+ // Kind toggles overwrite media=any/none via the shared param; also
69
+ // drop the legacy hasMedia param when acting on an old URL.
69
70
  url.searchParams.delete("hasMedia");
70
71
  url.searchParams.delete("page");
71
72
  window.location.href = url.pathname + (url.search || "");
@@ -99,9 +99,11 @@ async function extractPeaks(url: string, count: number): Promise<number[]> {
99
99
  * time to lay out the canvas (which starts as display:none and only
100
100
  * becomes visible when the `has-waveform` class is added).
101
101
  */
102
- function initPrecomputedWaveforms() {
102
+ export function initPrecomputedWaveforms(
103
+ root: globalThis.Document | globalThis.Element = document,
104
+ ) {
103
105
  const canvases =
104
- document.querySelectorAll<HTMLCanvasElement>("[data-audio-peaks]");
106
+ root.querySelectorAll<HTMLCanvasElement>("[data-audio-peaks]");
105
107
  const cardsToRender: HTMLElement[] = [];
106
108
 
107
109
  for (const canvas of canvases) {
@@ -472,7 +474,9 @@ document.addEventListener(
472
474
  // --- Initialize pre-computed waveforms on page load ---
473
475
 
474
476
  if (document.readyState === "loading") {
475
- document.addEventListener("DOMContentLoaded", initPrecomputedWaveforms);
477
+ document.addEventListener("DOMContentLoaded", () =>
478
+ initPrecomputedWaveforms(),
479
+ );
476
480
  } else {
477
481
  initPrecomputedWaveforms();
478
482
  }