@jant/core 0.3.46 → 0.3.47

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 (109) hide show
  1. package/bin/commands/db/execute-file.js +12 -4
  2. package/bin/commands/db/rehearse.js +2 -2
  3. package/bin/commands/export.js +12 -4
  4. package/bin/commands/import-site.js +60 -267
  5. package/bin/commands/migrate.js +36 -69
  6. package/bin/commands/reset-password.js +10 -4
  7. package/bin/commands/site/export.js +59 -248
  8. package/bin/commands/site/snapshot/export.js +58 -45
  9. package/bin/commands/site/snapshot/import.js +104 -52
  10. package/bin/lib/node-env.js +100 -0
  11. package/bin/lib/runtime-target.js +64 -0
  12. package/bin/lib/site-snapshot.js +185 -54
  13. package/bin/lib/sql-export.js +19 -2
  14. package/dist/{app-DB-P66E5.js → app-3REcR-3U.js} +331 -189
  15. package/dist/app-B67XOEyo.js +6 -0
  16. package/dist/client/.vite/manifest.json +2 -2
  17. package/dist/client/_assets/{client-auth-BLCUje4M.js → client-auth-Ce5WEAVS.js} +102 -49
  18. package/dist/client/_assets/client-s71Js1Cu.css +2 -0
  19. package/dist/{github-sync-CQ1x271f.js → export-ZBlfKSKm.js} +12 -439
  20. package/dist/github-sync-C593r22F.js +4 -0
  21. package/dist/github-sync-bL1hnx3Q.js +428 -0
  22. package/dist/index.js +3 -2
  23. package/dist/node.js +5 -4
  24. package/package.json +3 -2
  25. package/src/__tests__/helpers/export-fixtures.ts +0 -1
  26. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -0
  27. package/src/client/components/__tests__/jant-settings-general.test.ts +70 -0
  28. package/src/client/components/jant-settings-general.ts +164 -22
  29. package/src/client/components/settings-types.ts +4 -6
  30. package/src/client-auth.ts +1 -1
  31. package/src/db/__tests__/demo-canonical-snapshot.test.ts +1 -1
  32. package/src/db/__tests__/migration-rehearsal.test.ts +2 -5
  33. package/src/db/backfills/0004_register_apple_touch_media_rows.sql +65 -0
  34. package/src/db/migrations/0021_thankful_phalanx.sql +16 -0
  35. package/src/db/migrations/meta/0021_snapshot.json +2121 -0
  36. package/src/db/migrations/meta/_journal.json +7 -0
  37. package/src/db/migrations/pg/0019_gray_natasha_romanoff.sql +20 -0
  38. package/src/db/migrations/pg/meta/0019_snapshot.json +2718 -0
  39. package/src/db/migrations/pg/meta/_journal.json +7 -0
  40. package/src/db/pg/schema.ts +21 -26
  41. package/src/db/rehearsal-fixtures/demo-current.json +1 -1
  42. package/src/db/schema.ts +16 -20
  43. package/src/i18n/__tests__/middleware.test.ts +43 -1
  44. package/src/i18n/coverage.generated.ts +17 -0
  45. package/src/i18n/i18n.ts +18 -2
  46. package/src/i18n/index.ts +3 -0
  47. package/src/i18n/locales/settings/en.po +16 -11
  48. package/src/i18n/locales/settings/en.ts +1 -1
  49. package/src/i18n/locales/settings/zh-Hans.po +17 -12
  50. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  51. package/src/i18n/locales/settings/zh-Hant.po +16 -11
  52. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  53. package/src/i18n/locales.ts +84 -2
  54. package/src/i18n/middleware.ts +25 -16
  55. package/src/i18n/supported-locales.ts +153 -0
  56. package/src/lib/__tests__/csp-builder.test.ts +19 -2
  57. package/src/lib/__tests__/feed.test.ts +242 -1
  58. package/src/lib/__tests__/post-meta.test.ts +0 -1
  59. package/src/lib/__tests__/view.test.ts +0 -1
  60. package/src/lib/csp-builder.ts +28 -10
  61. package/src/lib/feed.ts +153 -3
  62. package/src/middleware/__tests__/secure-headers.test.ts +89 -0
  63. package/src/middleware/auth.ts +1 -1
  64. package/src/middleware/secure-headers.ts +47 -1
  65. package/src/node/__tests__/cli-runtime-target.test.ts +110 -2
  66. package/src/node/__tests__/cli-site-snapshot.test.ts +308 -13
  67. package/src/node/__tests__/cli-site-token-env.test.ts +2 -7
  68. package/src/node/__tests__/cli-snapshot-meta.test.ts +85 -0
  69. package/src/node/__tests__/cli-sql-export.test.ts +49 -0
  70. package/src/node/index.ts +1 -0
  71. package/src/preset.css +8 -2
  72. package/src/routes/api/__tests__/settings.test.ts +3 -2
  73. package/src/routes/api/github-sync.tsx +1 -1
  74. package/src/routes/api/settings.ts +4 -1
  75. package/src/routes/auth/signin.tsx +6 -0
  76. package/src/routes/pages/archive.tsx +4 -2
  77. package/src/services/__tests__/post.test.ts +19 -19
  78. package/src/services/__tests__/search.test.ts +0 -1
  79. package/src/services/__tests__/settings.test.ts +22 -3
  80. package/src/services/bootstrap.ts +7 -3
  81. package/src/services/collection.ts +3 -3
  82. package/src/services/export.ts +0 -3
  83. package/src/services/navigation.ts +0 -2
  84. package/src/services/path.ts +1 -38
  85. package/src/services/post.ts +32 -66
  86. package/src/services/search.ts +0 -6
  87. package/src/services/settings.ts +47 -6
  88. package/src/services/site-admin.ts +6 -1
  89. package/src/styles/ui.css +12 -23
  90. package/src/types/entities.ts +0 -1
  91. package/src/ui/color-themes.ts +1 -1
  92. package/src/ui/dash/settings/GeneralContent.tsx +17 -19
  93. package/src/ui/dash/settings/SettingsRootContent.tsx +17 -28
  94. package/src/ui/feed/NoteCard.tsx +1 -11
  95. package/src/ui/feed/__tests__/timeline-cards.test.ts +1 -1
  96. package/src/ui/pages/PostPage.tsx +2 -0
  97. package/bin/commands/collections.js +0 -268
  98. package/bin/commands/media.js +0 -302
  99. package/bin/commands/posts.js +0 -262
  100. package/bin/commands/search.js +0 -53
  101. package/bin/commands/settings.js +0 -93
  102. package/bin/lib/http-api.js +0 -223
  103. package/bin/lib/media-upload.js +0 -206
  104. package/dist/app-CM7sb3xO.js +0 -5
  105. package/dist/client/_assets/client-DDs6NzB3.css +0 -2
  106. package/src/__tests__/bin/content-cli.test.ts +0 -179
  107. package/src/__tests__/bin/media-cli.test.ts +0 -192
  108. /package/dist/{github-api-BkRWnqMx.js → github-api-Bh0PH3zr.js} +0 -0
  109. /package/dist/{github-app-WeadXMb8.js → github-app-D0GvNnqp.js} +0 -0
@@ -99,7 +99,6 @@ export interface PostFilters {
99
99
  excludeLatestHidden?: boolean;
100
100
  /** Exclude private posts from results */
101
101
  excludePrivate?: boolean;
102
- includeDeleted?: boolean;
103
102
  threadId?: string;
104
103
  /** Unix timestamp (inclusive) — only posts published at or after this time */
105
104
  publishedAfter?: number;
@@ -572,13 +571,7 @@ export function createPostService(
572
571
  ),
573
572
  })
574
573
  .from(posts)
575
- .where(
576
- and(
577
- eq(posts.siteId, siteId),
578
- eq(posts.threadId, rootId),
579
- isNull(posts.deletedAt),
580
- ),
581
- );
574
+ .where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId)));
582
575
 
583
576
  const latestPublishedAt = rootRows[0]?.latestPublishedAt ?? null;
584
577
  const root = await db
@@ -761,9 +754,6 @@ export function createPostService(
761
754
  if (filters.excludeReplies) {
762
755
  conditions.push(isNull(posts.replyToId));
763
756
  }
764
- if (!filters.includeDeleted) {
765
- conditions.push(isNull(posts.deletedAt));
766
- }
767
757
  if (filters.publishedAfter !== undefined) {
768
758
  conditions.push(sql`${posts.publishedAt} >= ${filters.publishedAfter}`);
769
759
  }
@@ -820,13 +810,7 @@ export function createPostService(
820
810
  const rows = await db
821
811
  .select({ id: posts.id })
822
812
  .from(posts)
823
- .where(
824
- and(
825
- eq(posts.siteId, siteId),
826
- eq(posts.threadId, threadId),
827
- isNull(posts.deletedAt),
828
- ),
829
- )
813
+ .where(and(eq(posts.siteId, siteId), eq(posts.threadId, threadId)))
830
814
  .orderBy(desc(posts.createdAt), desc(posts.id))
831
815
  .limit(1);
832
816
 
@@ -997,7 +981,6 @@ export function createPostService(
997
981
  previewProvider: row.previewProvider,
998
982
  replyToId: row.replyToId,
999
983
  threadId: row.threadId,
1000
- deletedAt: row.deletedAt,
1001
984
  publishedAt: row.publishedAt,
1002
985
  lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
1003
986
  createdAt: row.createdAt,
@@ -1054,7 +1037,6 @@ export function createPostService(
1054
1037
  eq(posts.siteId, siteId),
1055
1038
  inArray(posts.id, chunk),
1056
1039
  eq(posts.status, "published"),
1057
- isNull(posts.deletedAt),
1058
1040
  ),
1059
1041
  ),
1060
1042
  );
@@ -1090,7 +1072,7 @@ export function createPostService(
1090
1072
  }
1091
1073
 
1092
1074
  function buildThreadRootPageConditions(options?: ThreadRootPageOptions) {
1093
- const conditions = [isNull(posts.deletedAt)];
1075
+ const conditions: SQL[] = [];
1094
1076
  const status = options?.status;
1095
1077
 
1096
1078
  if (status) {
@@ -1230,13 +1212,7 @@ export function createPostService(
1230
1212
  const result = await db
1231
1213
  .select()
1232
1214
  .from(posts)
1233
- .where(
1234
- and(
1235
- eq(posts.siteId, siteId),
1236
- eq(posts.id, id),
1237
- isNull(posts.deletedAt),
1238
- ),
1239
- )
1215
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, id)))
1240
1216
  .limit(1);
1241
1217
  return hydratePost(result[0]);
1242
1218
  },
@@ -2442,17 +2418,35 @@ export function createPostService(
2442
2418
  }
2443
2419
  }
2444
2420
 
2445
- const timestamp = now();
2446
-
2447
2421
  if (isRoot) {
2448
- await db
2449
- .update(posts)
2450
- .set({ deletedAt: timestamp, updatedAt: timestamp })
2451
- .where(and(eq(posts.siteId, siteId), eq(posts.threadId, id)));
2422
+ // Delete the entire thread atomically. SQLite/D1's self-referential
2423
+ // FK on thread_id triggers a violation when the root is removed (its
2424
+ // own thread_id points to itself), so wrap the cascade in a
2425
+ // transaction with PRAGMA defer_foreign_keys to push the FK check to
2426
+ // commit time, by which point every referencing row is gone too.
2427
+ if (databaseDialect === "pg") {
2428
+ await db.transaction(async (tx) => {
2429
+ await tx
2430
+ .delete(posts)
2431
+ .where(and(eq(posts.siteId, siteId), eq(posts.threadId, id)));
2432
+ });
2433
+ } else {
2434
+ await db.batch([
2435
+ db.run(sql`PRAGMA defer_foreign_keys = ON`),
2436
+ db
2437
+ .delete(posts)
2438
+ .where(and(eq(posts.siteId, siteId), eq(posts.threadId, id))),
2439
+ ]);
2440
+ }
2452
2441
  } else {
2442
+ // Re-parent any direct children of this reply onto its own parent so
2443
+ // the thread chain stays connected after the reply is removed.
2453
2444
  await db
2454
2445
  .update(posts)
2455
- .set({ deletedAt: timestamp, updatedAt: timestamp })
2446
+ .set({ replyToId: existing.replyToId })
2447
+ .where(and(eq(posts.siteId, siteId), eq(posts.replyToId, id)));
2448
+ await db
2449
+ .delete(posts)
2456
2450
  .where(and(eq(posts.siteId, siteId), eq(posts.id, id)));
2457
2451
  await recalculateThreadLastActivity(existing.threadId);
2458
2452
  }
@@ -2461,33 +2455,14 @@ export function createPostService(
2461
2455
  },
2462
2456
 
2463
2457
  async deleteThreadDraft(id, deps) {
2464
- const deleted = await this.delete(id, deps);
2465
- if (!deleted) return false;
2466
-
2467
- // Release path_registry entries for all posts in the thread so slugs
2468
- // can be reused by the replacement thread.
2469
- const threadRows = await db
2470
- .select({ id: posts.id })
2471
- .from(posts)
2472
- .where(and(eq(posts.siteId, siteId), eq(posts.threadId, id)));
2473
- for (const row of threadRows) {
2474
- await resolvedPaths.deleteByPostId(row.id);
2475
- }
2476
-
2477
- return true;
2458
+ return this.delete(id, deps);
2478
2459
  },
2479
2460
 
2480
2461
  async getThread(rootId) {
2481
2462
  const rows = await db
2482
2463
  .select()
2483
2464
  .from(posts)
2484
- .where(
2485
- and(
2486
- eq(posts.siteId, siteId),
2487
- eq(posts.threadId, rootId),
2488
- isNull(posts.deletedAt),
2489
- ),
2490
- )
2465
+ .where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId)))
2491
2466
  .orderBy(posts.createdAt);
2492
2467
 
2493
2468
  return hydratePosts(rows);
@@ -2575,7 +2550,6 @@ export function createPostService(
2575
2550
  inArray(posts.threadId, postIds),
2576
2551
  eq(posts.status, "published"),
2577
2552
  isNotNull(posts.replyToId),
2578
- isNull(posts.deletedAt),
2579
2553
  ),
2580
2554
  )
2581
2555
  .groupBy(posts.threadId);
@@ -2607,7 +2581,6 @@ export function createPostService(
2607
2581
  inArray(posts.threadId, rootIds),
2608
2582
  eq(posts.status, "published"),
2609
2583
  isNotNull(posts.replyToId),
2610
- isNull(posts.deletedAt),
2611
2584
  ),
2612
2585
  )
2613
2586
  .as("ranked_replies");
@@ -2671,7 +2644,6 @@ export function createPostService(
2671
2644
  inArray(posts.threadId, rootIds),
2672
2645
  eq(posts.status, "published"),
2673
2646
  isNotNull(posts.replyToId),
2674
- isNull(posts.deletedAt),
2675
2647
  ),
2676
2648
  )
2677
2649
  .as("ranked_replies");
@@ -2994,7 +2966,6 @@ export function createPostService(
2994
2966
  eq(posts.siteId, siteId),
2995
2967
  inArray(posts.threadId, unique),
2996
2968
  eq(posts.status, "published"),
2997
- isNull(posts.deletedAt),
2998
2969
  ),
2999
2970
  )
3000
2971
  .orderBy(posts.threadId, posts.createdAt, posts.id);
@@ -3043,7 +3014,6 @@ export function createPostService(
3043
3014
  buildCollectionMembershipCondition(collectionIds),
3044
3015
  inArray(posts.threadId, chunk),
3045
3016
  eq(posts.status, "published"),
3046
- isNull(posts.deletedAt),
3047
3017
  ),
3048
3018
  )
3049
3019
  .groupBy(posts.threadId, posts.id)
@@ -3078,7 +3048,6 @@ export function createPostService(
3078
3048
  eq(posts.siteId, siteId),
3079
3049
  inArray(posts.threadId, unique),
3080
3050
  eq(posts.status, "published"),
3081
- isNull(posts.deletedAt),
3082
3051
  ),
3083
3052
  )
3084
3053
  .orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
@@ -3115,10 +3084,7 @@ export function createPostService(
3115
3084
  const limit = Math.min(Math.max(Math.trunc(requested), 1), 500);
3116
3085
  const cursor = options.cursor;
3117
3086
 
3118
- const whereConditions = [
3119
- eq(posts.siteId, siteId),
3120
- isNull(posts.deletedAt),
3121
- ];
3087
+ const whereConditions = [eq(posts.siteId, siteId)];
3122
3088
  if (cursor) whereConditions.push(gt(posts.id, cursor));
3123
3089
 
3124
3090
  // Fetch one extra row to detect end-of-data without a separate COUNT.
@@ -53,7 +53,6 @@ interface RawSearchRow {
53
53
  collection_id: string | null;
54
54
  reply_to_id: string | null;
55
55
  thread_id: string;
56
- deleted_at: number | null;
57
56
  published_at: number | null;
58
57
  last_activity_at: number | null;
59
58
  created_at: number;
@@ -87,7 +86,6 @@ function mapRow(row: RawSearchRow): SearchResult {
87
86
  previewProvider: null,
88
87
  replyToId: row.reply_to_id,
89
88
  threadId: row.thread_id,
90
- deletedAt: row.deleted_at,
91
89
  publishedAt: row.published_at,
92
90
  lastActivityAt:
93
91
  row.last_activity_at ?? row.published_at ?? row.updated_at,
@@ -189,7 +187,6 @@ export function createSearchService(
189
187
  AND path_registry.kind = 'slug'
190
188
  WHERE post_fts MATCH ?
191
189
  AND post.site_id = ?
192
- AND post.deleted_at IS NULL
193
190
  AND post.status IN (${statusPlaceholders})
194
191
  ${formatFilter}
195
192
  ORDER BY post_fts.rank
@@ -264,7 +261,6 @@ export function createSearchService(
264
261
  AND path_registry.kind = 'slug'
265
262
  WHERE post.search_document @@ search_query.tsq
266
263
  AND post.site_id = ?
267
- AND post.deleted_at IS NULL
268
264
  AND post.status IN (${statusPlaceholders})
269
265
  ${formatFilter}
270
266
  ORDER BY rank DESC, post.published_at DESC NULLS LAST, post.id DESC
@@ -311,7 +307,6 @@ export function createSearchService(
311
307
  AND path_registry.kind = 'slug'
312
308
  WHERE post.search_text ILIKE ?
313
309
  AND post.site_id = ?
314
- AND post.deleted_at IS NULL
315
310
  AND post.status IN (${statusPlaceholders})
316
311
  ${formatFilter}
317
312
  ORDER BY rank DESC, post.published_at DESC NULLS LAST, post.id DESC
@@ -359,7 +354,6 @@ export function createSearchService(
359
354
  post.url ${likeOperator} ?
360
355
  )
361
356
  AND post.site_id = ?
362
- AND post.deleted_at IS NULL
363
357
  AND post.status IN (${statusPlaceholders})
364
358
  ${formatFilter}
365
359
  ${likeOrderBy}
@@ -17,7 +17,11 @@ import {
17
17
  ONBOARDING_STATUS,
18
18
  type SettingsKey,
19
19
  } from "../lib/constants.js";
20
- import { baseLocale, isLocale } from "../i18n/locales.js";
20
+ import {
21
+ baseLocale,
22
+ isValidContentLanguage,
23
+ normalizeContentLanguage,
24
+ } from "../i18n/locales.js";
21
25
  import { isCjkSerifFont } from "../i18n/detect.js";
22
26
  import type { StorageDriver } from "../lib/storage.js";
23
27
  import type { MediaService } from "./media.js";
@@ -132,7 +136,10 @@ export interface SettingsService {
132
136
  *
133
137
  * @param storage - Optional storage driver for deleting the apple-touch-icon file
134
138
  */
135
- removeAvatar(storage?: StorageDriver | null): Promise<void>;
139
+ removeAvatar(
140
+ storage?: StorageDriver | null,
141
+ deps?: { media?: MediaService; storageProvider?: string },
142
+ ): Promise<void>;
136
143
  }
137
144
 
138
145
  export function createSettingsService(
@@ -284,10 +291,15 @@ export function createSettingsService(
284
291
 
285
292
  async updateLocaleSettings(data, opts) {
286
293
  const trimmedLanguage = data.siteLanguage.trim() || baseLocale;
287
- if (!isLocale(trimmedLanguage)) {
288
- throw new ValidationError("Choose a supported language.");
294
+ if (!isValidContentLanguage(trimmedLanguage)) {
295
+ throw new ValidationError(
296
+ "Enter a valid BCP 47 language tag (e.g. en, zh-Hans, fi, ja, fr-CA).",
297
+ );
289
298
  }
290
- await this.set("SITE_LANGUAGE", trimmedLanguage);
299
+ await this.set(
300
+ "SITE_LANGUAGE",
301
+ normalizeContentLanguage(trimmedLanguage),
302
+ );
291
303
 
292
304
  // CJK serif font setting
293
305
  const cjkFont = data.cjkSerifFont?.trim() ?? "";
@@ -419,11 +431,30 @@ export function createSettingsService(
419
431
  "favicon",
420
432
  "apple-touch-icon.png",
421
433
  );
434
+
435
+ // The storage key is fixed across uploads, so an existing media
436
+ // row would violate the (provider, storage_key) unique index.
437
+ const existing = await deps.media.getByStorageKey(
438
+ appleTouchKey,
439
+ deps.storageProvider,
440
+ );
441
+ if (existing) {
442
+ await deps.media.delete(existing.id);
443
+ }
444
+
422
445
  await deps.storage.put(
423
446
  appleTouchKey,
424
447
  new Uint8Array(data.appleTouchIcon),
425
448
  { contentType: "image/png" },
426
449
  );
450
+ await deps.media.create({
451
+ filename: "apple-touch-icon.png",
452
+ originalName: "apple-touch-icon.png",
453
+ mimeType: "image/png",
454
+ size: data.appleTouchIcon.byteLength,
455
+ storageKey: appleTouchKey,
456
+ provider: deps.storageProvider,
457
+ });
427
458
  await this.set("SITE_FAVICON_APPLE_TOUCH", appleTouchKey);
428
459
  }
429
460
 
@@ -438,12 +469,22 @@ export function createSettingsService(
438
469
  await this.set("SITE_FAVICON_VERSION", version);
439
470
  },
440
471
 
441
- async removeAvatar(storage) {
472
+ async removeAvatar(storage, deps) {
442
473
  const appleTouchKey = await this.get("SITE_FAVICON_APPLE_TOUCH");
443
474
  if (storage && appleTouchKey) {
444
475
  await storage.delete(appleTouchKey);
445
476
  }
446
477
 
478
+ if (deps?.media && deps.storageProvider && appleTouchKey) {
479
+ const existing = await deps.media.getByStorageKey(
480
+ appleTouchKey,
481
+ deps.storageProvider,
482
+ );
483
+ if (existing) {
484
+ await deps.media.delete(existing.id);
485
+ }
486
+ }
487
+
447
488
  await this.remove("SITE_AVATAR");
448
489
  await this.remove("SITE_FAVICON_ICO");
449
490
  await this.remove("SITE_FAVICON_APPLE_TOUCH");
@@ -29,7 +29,11 @@ import {
29
29
  } from "../ui/font-themes.js";
30
30
  import type { Site, SiteDomain } from "../types.js";
31
31
  import { createCollectionService } from "./collection.js";
32
- import { createExportService } from "./export.js";
32
+ // Note: `./export.js` is loaded lazily inside `exportManagedSite` because it
33
+ // pulls in Vite-specific `?raw` asset imports (CSS/HTML/JS templates) at
34
+ // module-load time. Importing it eagerly here would force every consumer of
35
+ // `services/index.ts` — including Node-only dev scripts run under tsx — to
36
+ // resolve those Vite-only imports just to construct the services bundle.
33
37
  import { createMediaService } from "./media.js";
34
38
  import { createNavItemService } from "./navigation.js";
35
39
  import { createPathService } from "./path.js";
@@ -675,6 +679,7 @@ export function createSiteAdminService(
675
679
  );
676
680
  const navItemList = await navItems.list();
677
681
  const appleTouchKey = allSettings[SETTINGS_KEYS.SITE_FAVICON_APPLE_TOUCH];
682
+ const { createExportService } = await import("./export.js");
678
683
  const exportService = createExportService(
679
684
  {
680
685
  collections,
package/src/styles/ui.css CHANGED
@@ -2285,17 +2285,6 @@
2285
2285
  text-decoration: underline;
2286
2286
  }
2287
2287
 
2288
- .post-header-actions {
2289
- display: flex;
2290
- align-items: center;
2291
- flex-shrink: 0;
2292
- margin-left: auto;
2293
- }
2294
-
2295
- .post-header-menu-trigger {
2296
- margin-right: -0.1rem;
2297
- }
2298
-
2299
2288
  /*
2300
2289
  * Detail pages get a denser reading ladder than the surrounding UI chrome.
2301
2290
  * This keeps long-form text feeling grounded without making global UI copy
@@ -2349,18 +2338,6 @@
2349
2338
  color: var(--site-reading-body);
2350
2339
  }
2351
2340
 
2352
- [data-page="post"] .post-header-actions .post-menu-trigger {
2353
- color: var(--site-reading-meta);
2354
- }
2355
-
2356
- [data-page="post"] .post-header-actions .post-menu-trigger:hover,
2357
- [data-page="post"] .post-header-actions .post-menu-trigger:focus-visible,
2358
- [data-page="post"]
2359
- .post-header-actions
2360
- .post-menu-trigger[aria-expanded="true"] {
2361
- color: var(--site-reading-body);
2362
- }
2363
-
2364
2341
  [data-page="post"] .feed-quote {
2365
2342
  border-left-color: color-mix(
2366
2343
  in srgb,
@@ -4415,6 +4392,7 @@
4415
4392
  .compose-dialog jant-compose-dialog {
4416
4393
  display: block;
4417
4394
  position: relative;
4395
+ border-radius: inherit;
4418
4396
  }
4419
4397
 
4420
4398
  .compose-dialog:has(.compose-dialog-inner-suspended) {
@@ -4523,6 +4501,8 @@
4523
4501
  min-height: 3.6rem;
4524
4502
  padding: 12px 14px;
4525
4503
  background-color: var(--compose-paper-bg);
4504
+ border-top-left-radius: inherit;
4505
+ border-top-right-radius: inherit;
4526
4506
  position: relative;
4527
4507
  z-index: 4;
4528
4508
  }
@@ -5378,6 +5358,13 @@
5378
5358
  position: relative;
5379
5359
  z-index: 3;
5380
5360
  background-color: var(--compose-paper-bg);
5361
+ border-bottom-left-radius: inherit;
5362
+ border-bottom-right-radius: inherit;
5363
+ }
5364
+
5365
+ .compose-action-row:has(+ .compose-quick-actions-row) {
5366
+ border-bottom-left-radius: 0;
5367
+ border-bottom-right-radius: 0;
5381
5368
  }
5382
5369
 
5383
5370
  .compose-action-row-overlay-open {
@@ -5801,6 +5788,8 @@
5801
5788
  justify-content: flex-end;
5802
5789
  padding: 2px 12px 12px;
5803
5790
  background-color: var(--compose-paper-bg);
5791
+ border-bottom-left-radius: inherit;
5792
+ border-bottom-right-radius: inherit;
5804
5793
  }
5805
5794
 
5806
5795
  .compose-action-row:has(+ .compose-quick-actions-row) {
@@ -66,7 +66,6 @@ export interface Post {
66
66
  previewProvider: string | null;
67
67
  replyToId: string | null;
68
68
  threadId: string;
69
- deletedAt: number | null;
70
69
  publishedAt: number | null;
71
70
  lastActivityAt: number;
72
71
  createdAt: number;
@@ -37,7 +37,7 @@ interface ThemeModeColors {
37
37
  searchMarkBg?: string;
38
38
  /** Search highlight text color */
39
39
  searchMarkColor?: string;
40
- /** Admin dashboard background */
40
+ /** Settings page background */
41
41
  dashBg?: string;
42
42
  /** Detail-page title color for long-form reading */
43
43
  readingTitle?: string;
@@ -102,14 +102,28 @@ export function GeneralContent({
102
102
  siteLanguage: i18n._(
103
103
  msg({
104
104
  message: "Language",
105
- comment: "@context: Settings form field for site/dashboard language",
105
+ comment: "@context: Settings form field for site/admin language",
106
106
  }),
107
107
  ),
108
108
  siteLanguageHelp: i18n._(
109
109
  msg({
110
110
  message:
111
- "Controls the language of the dashboard and settings. Public pages stay in English.",
112
- comment: "@context: Help text under the site language select",
111
+ "Sets the content language announced to readers (HTML lang, RSS) and the dashboard language. Any BCP 47 tag is accepted; tags without a dashboard translation fall back to English.",
112
+ comment: "@context: Help text under the site language input",
113
+ }),
114
+ ),
115
+ siteLanguageSearchPlaceholder: i18n._(
116
+ msg({
117
+ message: "Search…",
118
+ comment:
119
+ "@context: Placeholder inside the language combobox search field",
120
+ }),
121
+ ),
122
+ siteLanguageNoMatches: i18n._(
123
+ msg({
124
+ message: "No matches.",
125
+ comment:
126
+ "@context: Empty state shown when the language search filters out every entry",
113
127
  }),
114
128
  ),
115
129
  cjkFont: i18n._(
@@ -294,21 +308,6 @@ export function GeneralContent({
294
308
  timezones.map((tz) => ({ value: tz.value, label: tz.label })),
295
309
  ).replace(/</g, "\\u003c");
296
310
 
297
- // Language options — `value` must stay in sync with `src/i18n/locales.ts`.
298
- // Labels are native names (untranslated) so they stay recognizable to any
299
- // reader regardless of the currently active dashboard locale.
300
- const languagesJson = JSON.stringify([
301
- { value: "en", label: "English" },
302
- {
303
- value: "zh-Hans",
304
- label: "\u7B80\u4F53\u4E2D\u6587 (Simplified Chinese)",
305
- },
306
- {
307
- value: "zh-Hant",
308
- label: "\u7E41\u9AD4\u4E2D\u6587 (Traditional Chinese)",
309
- },
310
- ]).replace(/</g, "\\u003c");
311
-
312
311
  const cjkFontsJson = JSON.stringify([
313
312
  { value: "off", label: "None" },
314
313
  {
@@ -342,7 +341,6 @@ export function GeneralContent({
342
341
  labels={labels}
343
342
  timezones={timezonesJson}
344
343
  cjk-fonts={cjkFontsJson}
345
- languages={languagesJson}
346
344
  sitename-fallback={siteNameFallback}
347
345
  sitedescription-fallback={siteDescriptionFallback}
348
346
  main-feed-url={mainFeedUrl}
@@ -114,6 +114,23 @@ export function SettingsRootContent({
114
114
  }),
115
115
  )}
116
116
  />
117
+ <SettingsDirectoryLink
118
+ href={toPublicPath("/settings/github-sync", sitePathPrefix)}
119
+ icon={ICONS.gitBranch}
120
+ tone="subtle"
121
+ name={i18n._(
122
+ msg({
123
+ message: "GitHub Sync",
124
+ comment: "@context: Settings item — GitHub sync settings",
125
+ }),
126
+ )}
127
+ description={i18n._(
128
+ msg({
129
+ message: "Back up and sync content with a GitHub repository",
130
+ comment: "@context: Settings item description for GitHub sync",
131
+ }),
132
+ )}
133
+ />
117
134
  </SettingsDirectorySection>
118
135
 
119
136
  <SettingsDirectorySection
@@ -208,34 +225,6 @@ export function SettingsRootContent({
208
225
  />
209
226
  </SettingsDirectorySection>
210
227
 
211
- <SettingsDirectorySection
212
- title={i18n._(
213
- msg({
214
- message: "Integrations",
215
- comment:
216
- "@context: Settings group label for third-party integrations",
217
- }),
218
- )}
219
- >
220
- <SettingsDirectoryLink
221
- href={toPublicPath("/settings/github-sync", sitePathPrefix)}
222
- icon={ICONS.gitBranch}
223
- tone="subtle"
224
- name={i18n._(
225
- msg({
226
- message: "GitHub Sync",
227
- comment: "@context: Settings item — GitHub sync settings",
228
- }),
229
- )}
230
- description={i18n._(
231
- msg({
232
- message: "Back up and sync content with a GitHub repository",
233
- comment: "@context: Settings item description for GitHub sync",
234
- }),
235
- )}
236
- />
237
- </SettingsDirectorySection>
238
-
239
228
  <SettingsDirectorySection
240
229
  title={i18n._(
241
230
  msg({
@@ -9,11 +9,7 @@ import type { FC } from "hono/jsx";
9
9
  import type { TimelineCardProps } from "../../types.js";
10
10
  import { MediaGallery } from "../shared/MediaGallery.js";
11
11
  import { StarRating } from "../shared/StarRating.js";
12
- import {
13
- PostFooter,
14
- PostMenuTriggerButton,
15
- PostPublishedLink,
16
- } from "../shared/PostFooter.js";
12
+ import { PostFooter, PostPublishedLink } from "../shared/PostFooter.js";
17
13
  import { PostStatusBadges } from "./PostStatusBadges.js";
18
14
 
19
15
  function stripContinueAnchor(html?: string): string | undefined {
@@ -44,7 +40,6 @@ export const NoteCard: FC<TimelineCardProps> = ({
44
40
  const hasVisibleRating =
45
41
  !!post.rating && post.rating > 0 && !display?.hideRating;
46
42
  const showHeaderRating = isDetail && isArticle && hasVisibleRating;
47
- const showHeaderActions = !display?.footer?.hideActions;
48
43
  const footerDisplay =
49
44
  isDetail && isArticle && display?.footer?.hideTimestamp === undefined
50
45
  ? { ...display?.footer, hideTimestamp: true }
@@ -77,11 +72,6 @@ export const NoteCard: FC<TimelineCardProps> = ({
77
72
  post={post}
78
73
  className="u-url post-header-meta-link"
79
74
  />
80
- {showHeaderActions && (
81
- <div class="post-header-actions">
82
- <PostMenuTriggerButton className="post-menu-trigger post-header-menu-trigger" />
83
- </div>
84
- )}
85
75
  </div>
86
76
  {showHeaderRating && <StarRating rating={post.rating} />}
87
77
  </div>
@@ -260,7 +260,7 @@ describe("timeline cards", () => {
260
260
  expect(detailHtml).toContain('class="u-url post-header-meta-link"');
261
261
  expect(detailHtml.match(/class="dt-published"/g)).toHaveLength(1);
262
262
  expect(detailHtml).toContain("data-reply-trigger");
263
- expect(detailHtml.match(/data-post-menu-trigger/g)).toHaveLength(2);
263
+ expect(detailHtml.match(/data-post-menu-trigger/g)).toHaveLength(1);
264
264
  expect(detailHtml.indexOf('class="post-header-meta-row"')).toBeLessThan(
265
265
  detailHtml.indexOf("data-post-body"),
266
266
  );
@@ -49,6 +49,8 @@ export const PostPage: FC<PostPageProps> = ({ post, threadPosts }) => {
49
49
  ) : (
50
50
  <TimelineItemFromPost post={post} mode="detail" />
51
51
  )}
52
+ {/* Public integration slot — code injection (giscus, Webmentions, etc.) appends here. */}
53
+ <div data-post-end />
52
54
  </div>
53
55
  );
54
56
  };