@jant/core 0.6.12 → 0.6.14

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 (120) hide show
  1. package/bin/commands/import-site.js +2 -10
  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-D-zAhayc.js → app-2i7-WQHg.js} +426 -450
  7. package/dist/app-DZDlmh7C.js +6 -0
  8. package/dist/client/.vite/manifest.json +3 -3
  9. package/dist/client/_assets/{client-S1cdvulk.js → client-BvID5ucz.js} +1 -1
  10. package/dist/client/_assets/client-Cevfpm8H.css +2 -0
  11. package/dist/client/_assets/{client-auth-YW92ZH7u.js → client-auth-v3yvhbgZ.js} +596 -590
  12. package/dist/{export-Ckwh4BiE.js → export-DWn149b7.js} +24 -19
  13. package/dist/{github-sync-BMmK3JZ0.js → github-sync-C0zyF6XS.js} +2 -2
  14. package/dist/{github-sync-Dl9wQMar.js → github-sync-Ckk0sJxK.js} +1 -1
  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 +1 -2
  22. package/src/client/__tests__/site-header-nav.test.ts +36 -2
  23. package/src/client/components/__tests__/compose-title-from-heading.test.ts +57 -0
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +137 -10
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3 -0
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +94 -2
  27. package/src/client/components/__tests__/jant-nav-manager.test.ts +107 -22
  28. package/src/client/components/__tests__/jant-settings-general.test.ts +1 -0
  29. package/src/client/components/compose-title-from-heading.ts +66 -0
  30. package/src/client/components/compose-types.ts +8 -0
  31. package/src/client/components/inline-image-issues.ts +17 -0
  32. package/src/client/components/jant-command-palette.ts +100 -70
  33. package/src/client/components/jant-compose-dialog.ts +3 -1
  34. package/src/client/components/jant-compose-editor.ts +78 -1
  35. package/src/client/components/jant-compose-fullscreen.ts +2 -0
  36. package/src/client/components/jant-nav-manager.ts +245 -155
  37. package/src/client/components/nav-manager-types.ts +0 -10
  38. package/src/client/site-header-nav.d.ts +1 -0
  39. package/src/client/site-header-nav.js +105 -47
  40. package/src/client/tiptap/__tests__/block-insertion.test.ts +35 -0
  41. package/src/client/tiptap/__tests__/footnotes.test.ts +284 -0
  42. package/src/client/tiptap/__tests__/image-input-rules.test.ts +117 -0
  43. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +148 -1
  44. package/src/client/tiptap/bubble-menu.ts +3 -3
  45. package/src/client/tiptap/create-editor.ts +7 -0
  46. package/src/client/tiptap/extensions.ts +7 -2
  47. package/src/client/tiptap/footnotes.ts +156 -0
  48. package/src/client/tiptap/image-input-rules.ts +48 -0
  49. package/src/client/tiptap/image-node.ts +189 -7
  50. package/src/client/tiptap/markdown-clipboard.ts +7 -2
  51. package/src/client-auth.ts +0 -1
  52. package/src/i18n/locales/public/en.po +20 -4
  53. package/src/i18n/locales/public/en.ts +1 -1
  54. package/src/i18n/locales/public/zh-Hans.po +20 -4
  55. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hant.po +20 -4
  57. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  58. package/src/i18n/locales/settings/en.po +23 -20
  59. package/src/i18n/locales/settings/en.ts +1 -1
  60. package/src/i18n/locales/settings/zh-Hans.po +23 -20
  61. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  62. package/src/i18n/locales/settings/zh-Hant.po +23 -20
  63. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  64. package/src/lib/__tests__/markdown-to-tiptap.test.ts +4 -0
  65. package/src/lib/__tests__/navigation.test.ts +4 -37
  66. package/src/lib/__tests__/resolve-config.test.ts +6 -1
  67. package/src/lib/__tests__/view.test.ts +6 -30
  68. package/src/lib/github-sync-site-config.ts +0 -2
  69. package/src/lib/hugo-markdown.ts +1 -1
  70. package/src/lib/markdown-manager.ts +14 -5
  71. package/src/lib/navigation.ts +5 -22
  72. package/src/lib/resolve-config.ts +9 -2
  73. package/src/lib/site-header-fragment.tsx +37 -0
  74. package/src/lib/view.ts +2 -7
  75. package/src/node/__tests__/cli-site-snapshot.test.ts +45 -45
  76. package/src/routes/api/__tests__/nav-items.test.ts +26 -0
  77. package/src/routes/api/export.ts +0 -2
  78. package/src/routes/api/internal/__tests__/uploads.test.ts +9 -13
  79. package/src/routes/api/internal/sites.ts +5 -5
  80. package/src/routes/api/nav-items.ts +25 -4
  81. package/src/routes/dash/settings.tsx +8 -2
  82. package/src/routes/feed/__tests__/feed.test.ts +42 -0
  83. package/src/routes/feed/__tests__/sitemap.test.ts +2 -13
  84. package/src/routes/feed/feed.ts +1 -0
  85. package/src/routes/feed/sitemap.ts +2 -6
  86. package/src/routes/pages/archive.tsx +1 -0
  87. package/src/routes/pages/collection.tsx +1 -0
  88. package/src/routes/pages/featured.tsx +0 -4
  89. package/src/routes/pages/home.tsx +4 -51
  90. package/src/routes/pages/latest.tsx +2 -51
  91. package/src/services/__tests__/github-app-installations.test.ts +10 -10
  92. package/src/services/__tests__/navigation.test.ts +41 -0
  93. package/src/services/__tests__/post-timeline.test.ts +56 -0
  94. package/src/services/__tests__/post.test.ts +33 -0
  95. package/src/services/__tests__/settings.test.ts +5 -20
  96. package/src/services/export-theme/assets/client-site.js +10 -10
  97. package/src/services/export-theme/layouts/_default/rss.xml +5 -15
  98. package/src/services/export-theme/layouts/index.html +26 -39
  99. package/src/services/export.ts +5 -10
  100. package/src/services/github-app-installations.ts +1 -3
  101. package/src/services/navigation.ts +44 -13
  102. package/src/services/post.ts +80 -57
  103. package/src/services/settings.ts +0 -10
  104. package/src/services/site-admin.ts +0 -2
  105. package/src/services/upload-session.ts +4 -18
  106. package/src/styles/tokens.css +2 -2
  107. package/src/styles/ui.css +316 -34
  108. package/src/types/bindings.ts +0 -1
  109. package/src/types/config.ts +3 -7
  110. package/src/ui/compose/ComposeDialog.tsx +28 -0
  111. package/src/ui/dash/appearance/NavigationContent.tsx +1 -1
  112. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  113. package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
  114. package/src/ui/feed/__tests__/timeline-cards.test.ts +8 -3
  115. package/src/ui/layouts/BaseLayout.tsx +1 -1
  116. package/src/ui/layouts/SiteLayout.tsx +266 -227
  117. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +48 -0
  118. package/dist/app-B5P1b4TF.js +0 -6
  119. package/dist/client/_assets/client-BUxR-E8O.css +0 -2
  120. package/src/client/nav-manager-bridge.ts +0 -54
@@ -104,6 +104,46 @@ const suggestedLinks: NavManagerSuggestedLink[] = [
104
104
  },
105
105
  ];
106
106
 
107
+ function renderHeaderFragment(label: string): string {
108
+ return `
109
+ <header class="site-header" data-site-header-fragment="header">
110
+ <div class="site-header-inner">
111
+ <div class="site-header-top">
112
+ <a href="/" class="site-logo">Test Site</a>
113
+ <nav class="site-header-nav" aria-label="Primary">
114
+ <a href="/now" class="site-header-link">${label}</a>
115
+ </nav>
116
+ <button
117
+ type="button"
118
+ class="site-header-hamburger"
119
+ aria-controls="site-nav-drawer"
120
+ aria-expanded="false"
121
+ ></button>
122
+ </div>
123
+ </div>
124
+ </header>
125
+ <div
126
+ class="site-nav-drawer-backdrop"
127
+ data-site-header-fragment="drawer-backdrop"
128
+ aria-hidden="true"
129
+ ></div>
130
+ <div
131
+ id="site-nav-drawer"
132
+ class="site-nav-drawer"
133
+ data-site-header-fragment="drawer"
134
+ aria-hidden="true"
135
+ inert
136
+ >
137
+ <button class="site-nav-drawer-close" type="button"></button>
138
+ <a href="/now" class="site-nav-drawer-link">${label}</a>
139
+ </div>
140
+ `;
141
+ }
142
+
143
+ function installCurrentHeaderFragment(label = "Old"): void {
144
+ document.body.insertAdjacentHTML("afterbegin", renderHeaderFragment(label));
145
+ }
146
+
107
147
  function requireElement<T>(value: T | null, message: string): T {
108
148
  if (!value) {
109
149
  throw new Error(message);
@@ -148,6 +188,12 @@ function getSortableOptions(
148
188
  return options as Record<string, ((event: unknown) => void) | undefined>;
149
189
  }
150
190
 
191
+ async function flushAsyncWork(): Promise<void> {
192
+ await Promise.resolve();
193
+ await Promise.resolve();
194
+ await Promise.resolve();
195
+ }
196
+
151
197
  async function createElement(): Promise<JantNavManager> {
152
198
  const el = document.createElement("jant-nav-manager") as JantNavManager;
153
199
  el.labels = labels;
@@ -170,6 +216,7 @@ describe("JantNavManager", () => {
170
216
  vi.fn().mockResolvedValue({
171
217
  ok: true,
172
218
  status: 200,
219
+ json: async () => ({ ...items[1] }),
173
220
  }),
174
221
  );
175
222
  });
@@ -209,8 +256,7 @@ describe("JantNavManager", () => {
209
256
  });
210
257
 
211
258
  await el.updateComplete;
212
- await Promise.resolve();
213
- await Promise.resolve();
259
+ await flushAsyncWork();
214
260
 
215
261
  expect(getListIds(headerList)).toEqual(["nav-1"]);
216
262
  expect(getListIds(moreList)).toEqual(["nav-2", "nav-3"]);
@@ -234,6 +280,9 @@ describe("JantNavManager", () => {
234
280
  "/api/nav-items/nav-2/move",
235
281
  expect.objectContaining({
236
282
  method: "PUT",
283
+ headers: expect.objectContaining({
284
+ "X-Jant-Site-Header": "include",
285
+ }),
237
286
  body: JSON.stringify({
238
287
  after: null,
239
288
  before: "nav-3",
@@ -293,22 +342,28 @@ describe("JantNavManager", () => {
293
342
  url: "/now",
294
343
  placement: "header",
295
344
  };
345
+ installCurrentHeaderFragment("Old");
296
346
  const fetchMock = vi.fn().mockResolvedValue({
297
347
  ok: true,
298
348
  status: 201,
299
- json: async () => created,
349
+ json: async () => ({
350
+ ...created,
351
+ headerHtml: renderHeaderFragment("Now"),
352
+ }),
300
353
  });
301
354
  vi.stubGlobal("fetch", fetchMock);
302
355
 
303
356
  addButton.click();
304
- await Promise.resolve();
305
- await Promise.resolve();
357
+ await flushAsyncWork();
306
358
  await el.updateComplete;
307
359
 
308
360
  expect(fetchMock).toHaveBeenCalledWith(
309
361
  "/api/nav-items",
310
362
  expect.objectContaining({
311
363
  method: "POST",
364
+ headers: expect.objectContaining({
365
+ "X-Jant-Site-Header": "include",
366
+ }),
312
367
  body: JSON.stringify({
313
368
  type: "collection",
314
369
  collectionId: "col_now",
@@ -325,12 +380,25 @@ describe("JantNavManager", () => {
325
380
  ),
326
381
  ).toContain("nav-now");
327
382
  expect(el.querySelector(".nav-suggestion-item")).toBeNull();
383
+ expect(
384
+ document
385
+ .querySelector<HTMLElement>('[data-site-header-fragment="header"]')
386
+ ?.textContent?.trim(),
387
+ ).toContain("Now");
328
388
  });
329
389
 
330
- it("confirms before dispatching nav deletion", async () => {
390
+ it("confirms before deleting a navigation item", async () => {
331
391
  const el = await createElement();
332
- const deleteHandler = vi.fn();
333
- el.addEventListener("jant:nav-delete", deleteHandler);
392
+ const fetchMock = vi.fn().mockResolvedValue({
393
+ ok: true,
394
+ status: 200,
395
+ json: async () => ({
396
+ success: true,
397
+ headerHtml: renderHeaderFragment("Links"),
398
+ }),
399
+ });
400
+ vi.stubGlobal("fetch", fetchMock);
401
+ installCurrentHeaderFragment("About");
334
402
 
335
403
  (
336
404
  el as unknown as { _editingId: string | null; requestUpdate: () => void }
@@ -343,7 +411,7 @@ describe("JantNavManager", () => {
343
411
  "expected nav delete button",
344
412
  );
345
413
  deleteButton.click();
346
- await Promise.resolve();
414
+ await flushAsyncWork();
347
415
 
348
416
  const host = requireElement(
349
417
  document.querySelector<HTMLElement>("jant-confirm-dialog"),
@@ -356,19 +424,37 @@ describe("JantNavManager", () => {
356
424
  "expected confirm button",
357
425
  );
358
426
  confirmButton.click();
359
- await Promise.resolve();
360
- await Promise.resolve();
427
+ await flushAsyncWork();
428
+ await el.updateComplete;
361
429
 
362
- expect(deleteHandler).toHaveBeenCalledTimes(1);
363
- expect(deleteHandler.mock.calls[0]?.[0]).toMatchObject({
364
- detail: { id: "nav-1" },
365
- });
430
+ expect(fetchMock).toHaveBeenCalledWith(
431
+ "/api/nav-items/nav-1",
432
+ expect.objectContaining({
433
+ method: "DELETE",
434
+ headers: expect.objectContaining({
435
+ "X-Jant-Site-Header": "include",
436
+ }),
437
+ }),
438
+ );
439
+ expect(
440
+ getListIds(
441
+ requireElement(
442
+ el.querySelector<HTMLElement>("#nav-items-header"),
443
+ "expected header nav list",
444
+ ),
445
+ ),
446
+ ).toEqual(["nav-2"]);
447
+ expect(
448
+ document
449
+ .querySelector<HTMLElement>('[data-site-header-fragment="header"]')
450
+ ?.textContent?.trim(),
451
+ ).toContain("Links");
366
452
  });
367
453
 
368
- it("does not dispatch nav deletion when confirmation is canceled", async () => {
454
+ it("does not delete when confirmation is canceled", async () => {
369
455
  const el = await createElement();
370
- const deleteHandler = vi.fn();
371
- el.addEventListener("jant:nav-delete", deleteHandler);
456
+ const fetchMock = vi.fn();
457
+ vi.stubGlobal("fetch", fetchMock);
372
458
 
373
459
  (
374
460
  el as unknown as { _editingId: string | null; requestUpdate: () => void }
@@ -381,7 +467,7 @@ describe("JantNavManager", () => {
381
467
  "expected nav delete button",
382
468
  );
383
469
  deleteButton.click();
384
- await Promise.resolve();
470
+ await flushAsyncWork();
385
471
 
386
472
  const host = requireElement(
387
473
  document.querySelector<HTMLElement>("jant-confirm-dialog"),
@@ -394,9 +480,8 @@ describe("JantNavManager", () => {
394
480
  "expected cancel button",
395
481
  );
396
482
  cancelButton.click();
397
- await Promise.resolve();
398
- await Promise.resolve();
483
+ await flushAsyncWork();
399
484
 
400
- expect(deleteHandler).not.toHaveBeenCalled();
485
+ expect(fetchMock).not.toHaveBeenCalled();
401
486
  });
402
487
  });
@@ -657,6 +657,7 @@ describe("JantSettingsGeneral", () => {
657
657
  const d = detail as unknown as SettingsSaveDetail;
658
658
  expect(d.endpoint).toBe("/settings/general/home");
659
659
  expect(d.section).toBe("home");
660
+ expect(d.data).not.toHaveProperty("homeDefaultView");
660
661
  expect(d.data.showJantBrandingOnHome).toBe(true);
661
662
  expect(siteSaveBtn?.disabled).toBe(true);
662
663
  });
@@ -0,0 +1,66 @@
1
+ import type { JSONContent } from "@tiptap/core";
2
+
3
+ export interface LeadingH1TitlePromotion {
4
+ title: string;
5
+ bodyJson: JSONContent | null;
6
+ headingIndex: number;
7
+ }
8
+
9
+ function textFromNode(node: JSONContent): string {
10
+ if (typeof node.text === "string") return node.text;
11
+ if (!node.content) return node.type === "hardBreak" ? " " : "";
12
+ return node.content.map(textFromNode).join("");
13
+ }
14
+
15
+ function hasOnlyEmptyInlineContent(node: JSONContent): boolean {
16
+ if (!node.content || node.content.length === 0) return true;
17
+ return node.content.every((child) => {
18
+ if (child.type === "text") return !child.text?.trim();
19
+ if (child.type === "hardBreak") return true;
20
+ return false;
21
+ });
22
+ }
23
+
24
+ function isIgnorableLeadingBlock(node: JSONContent): boolean {
25
+ return (
26
+ (node.type === "paragraph" || node.type === "heading") &&
27
+ hasOnlyEmptyInlineContent(node)
28
+ );
29
+ }
30
+
31
+ function normalizeTitleText(text: string): string {
32
+ return text.replace(/\s+/g, " ").trim();
33
+ }
34
+
35
+ export function promoteLeadingH1Title(
36
+ bodyJson: JSONContent | null,
37
+ ): LeadingH1TitlePromotion | null {
38
+ const content = bodyJson?.content;
39
+ if (!bodyJson || !content?.length) return null;
40
+
41
+ const headingIndex = content.findIndex((node) => {
42
+ return !isIgnorableLeadingBlock(node);
43
+ });
44
+
45
+ if (headingIndex < 0) return null;
46
+
47
+ const heading = content[headingIndex];
48
+ if (heading?.type !== "heading" || heading.attrs?.level !== 1) return null;
49
+
50
+ const title = normalizeTitleText(textFromNode(heading));
51
+ if (!title) return null;
52
+
53
+ const nextContent = content.slice(headingIndex + 1);
54
+ if (nextContent.length === 0) {
55
+ return { title, bodyJson: null, headingIndex };
56
+ }
57
+
58
+ return {
59
+ title,
60
+ bodyJson: {
61
+ ...bodyJson,
62
+ content: nextContent,
63
+ },
64
+ headingIndex,
65
+ };
66
+ }
@@ -149,6 +149,14 @@ export interface ComposeLabels {
149
149
  imageNotRehosted?: string;
150
150
  /** Toast when several pasted remote images couldn't be rehosted (uses a {count} placeholder). */
151
151
  imagesNotRehosted?: string;
152
+ /** Inline editor label shown when an image node cannot load. */
153
+ brokenImageUnavailable?: string;
154
+ /** Button label for removing a broken inline image. */
155
+ brokenImageDelete?: string;
156
+ /** Button label for replacing a broken inline image. */
157
+ brokenImageReplace?: string;
158
+ /** Button label for opening the original broken image URL. */
159
+ brokenImageOpen?: string;
152
160
  loadingPost: string;
153
161
  loadPostFailed: string;
154
162
  published: string;
@@ -0,0 +1,17 @@
1
+ import type { ImageNodeLabels } from "../tiptap/image-node.js";
2
+ import type { ComposeLabels } from "./compose-types.js";
3
+
4
+ export function getInlineImageNodeLabels(
5
+ labels: ComposeLabels,
6
+ ): Partial<ImageNodeLabels> {
7
+ const imageLabels: Partial<ImageNodeLabels> = {};
8
+ if (labels.brokenImageUnavailable) {
9
+ imageLabels.unavailable = labels.brokenImageUnavailable;
10
+ }
11
+ if (labels.brokenImageDelete) imageLabels.delete = labels.brokenImageDelete;
12
+ if (labels.brokenImageReplace) {
13
+ imageLabels.replace = labels.brokenImageReplace;
14
+ }
15
+ if (labels.brokenImageOpen) imageLabels.open = labels.brokenImageOpen;
16
+ return imageLabels;
17
+ }
@@ -30,6 +30,19 @@ interface CommandItem {
30
30
  action: () => void;
31
31
  }
32
32
 
33
+ interface DisplayItem {
34
+ label: string;
35
+ secondary?: string;
36
+ icon: string;
37
+ /** For search-mode items: the query to execute */
38
+ searchQuery?: string;
39
+ /** For path-mode items: the local path to open */
40
+ pathTarget?: string;
41
+ /** Keep cross-mode actions visible below the scrollable result list. */
42
+ placement?: "results" | "footer";
43
+ shortcut?: string;
44
+ }
45
+
33
46
  // ---------------------------------------------------------------------------
34
47
  // SVG icon paths (reuse existing Lucide-style icons from the codebase)
35
48
  // ---------------------------------------------------------------------------
@@ -280,15 +293,7 @@ export class JantCommandPalette extends LitElement {
280
293
  return COMMANDS.filter((c) => !q || c.label.toLowerCase().includes(q));
281
294
  }
282
295
 
283
- get #displayItems(): Array<{
284
- label: string;
285
- secondary?: string;
286
- icon: string;
287
- /** For search-mode items: the query to execute */
288
- searchQuery?: string;
289
- /** For path-mode items: the local path to open */
290
- pathTarget?: string;
291
- }> {
296
+ get #displayItems(): DisplayItem[] {
292
297
  const mode = this.#mode;
293
298
 
294
299
  if (mode === "command") {
@@ -320,13 +325,7 @@ export class JantCommandPalette extends LitElement {
320
325
  if (mode === "path") {
321
326
  const target = getPathCommandTarget(this._query);
322
327
  const searchQuery = getPathCommandSearchQuery(this._query);
323
- const items: Array<{
324
- label: string;
325
- secondary?: string;
326
- icon: string;
327
- searchQuery?: string;
328
- pathTarget?: string;
329
- }> = [
328
+ const items: DisplayItem[] = [
330
329
  {
331
330
  label: `Go to ${target}`,
332
331
  icon: ICONS.path,
@@ -346,25 +345,25 @@ export class JantCommandPalette extends LitElement {
346
345
  }
347
346
 
348
347
  // Navigate mode — show all items when no query (autocomplete)
349
- const navItems: Array<{
350
- label: string;
351
- secondary?: string;
352
- icon: string;
353
- searchQuery?: string;
354
- pathTarget?: string;
355
- }> = this.#navigateItems.map((item) => ({
348
+ // Rank direct destinations and let the scrollable result list handle
349
+ // overflow without hiding valid matches.
350
+ const q = normalizeSearch(this._query);
351
+ const navItems: DisplayItem[] = this.#navigateItems.map((item) => ({
356
352
  label: item.title,
357
- secondary: item.type === "system" ? item.path : item.path,
353
+ secondary: item.path,
358
354
  icon: ICONS[item.type],
359
355
  }));
360
356
 
361
- // When navigate mode has no matches, offer a full-text search fallback
362
- const q = normalizeSearch(this._query);
363
- if (navItems.length === 0 && q) {
357
+ // Full-text search is a persistent action, not another navigation result.
358
+ // Keep it outside the scrollable list while retaining it as the final
359
+ // keyboard option.
360
+ if (q) {
364
361
  navItems.push({
365
- label: `Search for "${this._query.trim()}"`,
362
+ label: `Search all content for "${this._query.trim()}"`,
366
363
  icon: ICONS.search,
367
364
  searchQuery: this._query.trim(),
365
+ placement: "footer",
366
+ ...(navItems.length > 0 ? { shortcut: "⌘/Ctrl ↵" } : {}),
368
367
  });
369
368
  }
370
369
 
@@ -375,6 +374,12 @@ export class JantCommandPalette extends LitElement {
375
374
  // Actions
376
375
  // -----------------------------------------------------------------------
377
376
 
377
+ #executeSearch(query: string) {
378
+ saveHistory(SEARCH_HISTORY_KEY, query);
379
+ this.close();
380
+ window.location.href = `/search?q=${encodeURIComponent(query)}`;
381
+ }
382
+
378
383
  #executeItem(index: number) {
379
384
  const mode = this.#mode;
380
385
 
@@ -391,9 +396,7 @@ export class JantCommandPalette extends LitElement {
391
396
  const displayItem = this.#displayItems[index];
392
397
  const q = displayItem?.searchQuery;
393
398
  if (q) {
394
- saveHistory(SEARCH_HISTORY_KEY, q);
395
- this.close();
396
- window.location.href = `/search?q=${encodeURIComponent(q)}`;
399
+ this.#executeSearch(q);
397
400
  }
398
401
  return;
399
402
  }
@@ -408,10 +411,7 @@ export class JantCommandPalette extends LitElement {
408
411
  }
409
412
 
410
413
  if (displayItem?.searchQuery) {
411
- const q = displayItem.searchQuery;
412
- saveHistory(SEARCH_HISTORY_KEY, q);
413
- this.close();
414
- window.location.href = `/search?q=${encodeURIComponent(q)}`;
414
+ this.#executeSearch(displayItem.searchQuery);
415
415
  }
416
416
  return;
417
417
  }
@@ -419,10 +419,7 @@ export class JantCommandPalette extends LitElement {
419
419
  // Navigate mode — check if the selected item is a search fallback
420
420
  const displayItem = this.#displayItems[index];
421
421
  if (displayItem?.searchQuery) {
422
- const q = displayItem.searchQuery;
423
- saveHistory(SEARCH_HISTORY_KEY, q);
424
- this.close();
425
- window.location.href = `/search?q=${encodeURIComponent(q)}`;
422
+ this.#executeSearch(displayItem.searchQuery);
426
423
  return;
427
424
  }
428
425
 
@@ -475,6 +472,19 @@ export class JantCommandPalette extends LitElement {
475
472
  return;
476
473
  }
477
474
 
475
+ if (
476
+ event.key === "Enter" &&
477
+ (event.metaKey || event.ctrlKey) &&
478
+ this.#mode === "navigate"
479
+ ) {
480
+ const query = this._query.trim();
481
+ if (query) {
482
+ event.preventDefault();
483
+ this.#executeSearch(query);
484
+ return;
485
+ }
486
+ }
487
+
478
488
  if (event.key === "Enter") {
479
489
  event.preventDefault();
480
490
  this.#executeItem(this._selectedIndex);
@@ -521,10 +531,49 @@ export class JantCommandPalette extends LitElement {
521
531
  // Render
522
532
  // -----------------------------------------------------------------------
523
533
 
534
+ #renderItem(item: DisplayItem, index: number, footer = false) {
535
+ return html`
536
+ <div
537
+ id="command-palette-item-${index}"
538
+ class=${classMap({
539
+ "command-palette-result": true,
540
+ "command-palette-result-selected": index === this._selectedIndex,
541
+ "command-palette-search-action": footer,
542
+ })}
543
+ role="option"
544
+ aria-selected=${index === this._selectedIndex}
545
+ @click=${this.#handleItemClick(index)}
546
+ >
547
+ <span class="command-palette-result-icon">${unsafeSVG(item.icon)}</span>
548
+ <span class="command-palette-result-body">
549
+ <span class="command-palette-result-title">${item.label}</span>
550
+ ${item.secondary
551
+ ? html`<span class="command-palette-result-path"
552
+ >${item.secondary}</span
553
+ >`
554
+ : nothing}
555
+ </span>
556
+ ${item.shortcut
557
+ ? html`<kbd
558
+ class="command-palette-result-shortcut"
559
+ aria-label="Command or Control plus Enter"
560
+ >${item.shortcut}</kbd
561
+ >`
562
+ : nothing}
563
+ </div>
564
+ `;
565
+ }
566
+
524
567
  render() {
525
568
  if (!this._open) return nothing;
526
569
 
527
570
  const items = this.#displayItems;
571
+ const resultItems = items
572
+ .map((item, index) => ({ item, index }))
573
+ .filter(({ item }) => item.placement !== "footer");
574
+ const footerItems = items
575
+ .map((item, index) => ({ item, index }))
576
+ .filter(({ item }) => item.placement === "footer");
528
577
 
529
578
  return html`
530
579
  <dialog
@@ -563,41 +612,22 @@ export class JantCommandPalette extends LitElement {
563
612
 
564
613
  ${items.length > 0
565
614
  ? html`
566
- <ul
615
+ <div
567
616
  id="command-palette-results"
568
- class="command-palette-results"
617
+ class="command-palette-results-container"
569
618
  role="listbox"
570
619
  >
571
- ${items.map(
572
- (item, i) => html`
573
- <li
574
- id="command-palette-item-${i}"
575
- class=${classMap({
576
- "command-palette-result": true,
577
- "command-palette-result-selected":
578
- i === this._selectedIndex,
579
- })}
580
- role="option"
581
- aria-selected=${i === this._selectedIndex}
582
- @click=${this.#handleItemClick(i)}
583
- >
584
- <span class="command-palette-result-icon"
585
- >${unsafeSVG(item.icon)}</span
586
- >
587
- <span class="command-palette-result-body">
588
- <span class="command-palette-result-title"
589
- >${item.label}</span
590
- >
591
- ${item.secondary
592
- ? html`<span class="command-palette-result-path"
593
- >${item.secondary}</span
594
- >`
595
- : nothing}
596
- </span>
597
- </li>
598
- `,
620
+ ${resultItems.length > 0
621
+ ? html`<div class="command-palette-results">
622
+ ${resultItems.map(({ item, index }) =>
623
+ this.#renderItem(item, index),
624
+ )}
625
+ </div>`
626
+ : nothing}
627
+ ${footerItems.map(({ item, index }) =>
628
+ this.#renderItem(item, index, true),
599
629
  )}
600
- </ul>
630
+ </div>
601
631
  `
602
632
  : this._query.trim() && !this._loading
603
633
  ? html`<div class="command-palette-empty">No results</div>`
@@ -1321,6 +1321,7 @@ export class JantComposeDialog extends LitElement {
1321
1321
  index: number,
1322
1322
  status: "published" | "draft",
1323
1323
  ): ComposeSubmitDetail {
1324
+ editor.promoteLeadingH1Title({ force: true });
1324
1325
  const editorData = editor.getData();
1325
1326
  const mediaAttachments = new Map(
1326
1327
  (editorData.attachments ?? []).map((a) => [a.clientId, a]),
@@ -1389,6 +1390,7 @@ export class JantComposeDialog extends LitElement {
1389
1390
  const editor = this._editor;
1390
1391
  if (!editor) return null;
1391
1392
 
1393
+ editor.promoteLeadingH1Title({ force: true });
1392
1394
  const editorData = editor.getData();
1393
1395
  const mediaAttachments = new Map(
1394
1396
  (editorData.attachments ?? []).map((attachment) => [
@@ -4031,7 +4033,7 @@ export class JantComposeDialog extends LitElement {
4031
4033
  <span class="compose-alt-title">${this.labels.attachedText}</span>
4032
4034
  <button
4033
4035
  type="button"
4034
- class="compose-post-btn ml-auto"
4036
+ class="compose-attached-done"
4035
4037
  @click=${() => this._doneAttachedPanel()}
4036
4038
  >
4037
4039
  ${this.labels.done}