@jant/core 0.6.8 → 0.6.9

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 (62) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-9P4rVCe2.js → app-C-jxWmAV.js} +12324 -12157
  3. package/dist/app-DqHzOwL5.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/{client-C6peCkkD.css → client-CGf2m3qp.css} +1 -1
  6. package/dist/client/_assets/{client-CXnEhyyv.js → client-DWy1LEEk.js} +1 -1
  7. package/dist/client/_assets/{client-auth-CSItbyU8.js → client-auth-Blg-a5Ep.js} +180 -162
  8. package/dist/{export-Be082J0n.js → export-C2DIB7mm.js} +2 -2
  9. package/dist/{github-sync-_kPWM4m9.js → github-sync-7XQ5ZM6z.js} +2 -2
  10. package/dist/{github-sync-D1Cw8mOY.js → github-sync-BEFCfLKK.js} +1 -1
  11. package/dist/index.js +3 -3
  12. package/dist/node.js +4 -4
  13. package/package.json +1 -1
  14. package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
  15. package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
  16. package/src/client/components/jant-compose-dialog.ts +12 -0
  17. package/src/client/components/jant-settings-general.ts +56 -18
  18. package/src/client/components/settings-types.ts +11 -0
  19. package/src/client/settings-bridge.ts +3 -0
  20. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  21. package/src/client/tiptap/bubble-menu.ts +37 -4
  22. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  23. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  24. package/src/db/migrations/meta/_journal.json +7 -0
  25. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  26. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  27. package/src/db/migrations/pg/meta/_journal.json +7 -0
  28. package/src/db/pg/schema.ts +36 -0
  29. package/src/db/schema.ts +36 -0
  30. package/src/i18n/__tests__/middleware.test.ts +46 -0
  31. package/src/i18n/locales/settings/en.po +25 -10
  32. package/src/i18n/locales/settings/en.ts +1 -1
  33. package/src/i18n/locales/settings/zh-Hans.po +25 -10
  34. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  35. package/src/i18n/locales/settings/zh-Hant.po +25 -10
  36. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  37. package/src/i18n/middleware.ts +17 -8
  38. package/src/i18n/supported-locales.ts +5 -4
  39. package/src/lib/ids.ts +1 -0
  40. package/src/lib/resolve-config.ts +1 -0
  41. package/src/lib/upload.ts +14 -0
  42. package/src/routes/api/__tests__/settings.test.ts +1 -4
  43. package/src/routes/api/__tests__/upload.test.ts +2 -0
  44. package/src/routes/api/internal/__tests__/uploads.test.ts +19 -1
  45. package/src/routes/api/settings.ts +2 -1
  46. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  47. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  48. package/src/routes/dash/settings.tsx +15 -2
  49. package/src/services/__tests__/media.test.ts +191 -30
  50. package/src/services/__tests__/settings.test.ts +55 -0
  51. package/src/services/bootstrap.ts +7 -0
  52. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  53. package/src/services/media.ts +169 -42
  54. package/src/services/settings.ts +49 -15
  55. package/src/services/upload-session.ts +13 -3
  56. package/src/styles/tokens.css +6 -4
  57. package/src/types/bindings.ts +1 -0
  58. package/src/types/config.ts +13 -0
  59. package/src/ui/dash/settings/GeneralContent.tsx +38 -4
  60. package/src/ui/layouts/BaseLayout.tsx +1 -0
  61. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
  62. package/dist/app-DaxS_Cz-.js +0 -6
@@ -19,6 +19,7 @@ import {
19
19
  } from "../lib/constants.js";
20
20
  import {
21
21
  baseLocale,
22
+ isLocale,
22
23
  isValidContentLanguage,
23
24
  normalizeContentLanguage,
24
25
  } from "../i18n/locales.js";
@@ -41,6 +42,8 @@ export interface GeneralSettingsData {
41
42
  siteDescription: string;
42
43
  siteFooter: string;
43
44
  siteLanguage: string;
45
+ /** Admin UI locale; empty string follows the content language. */
46
+ dashboardLanguage?: string;
44
47
  cjkSerifFont: string;
45
48
  showJantBrandingOnHome: boolean;
46
49
  homeDefaultView?: FeedKind;
@@ -61,6 +64,11 @@ export interface SiteSettingsResult {
61
64
 
62
65
  export interface LocaleSettingsData {
63
66
  siteLanguage: string;
67
+ /**
68
+ * Admin dashboard UI locale. Empty string clears the explicit setting so the
69
+ * dashboard follows the content language. When set, must be a catalog locale.
70
+ */
71
+ dashboardLanguage?: string;
64
72
  cjkSerifFont: string;
65
73
  timeZone: string;
66
74
  }
@@ -97,7 +105,11 @@ export interface SettingsService {
97
105
  ): Promise<SiteSettingsResult>;
98
106
  updateLocaleSettings(
99
107
  data: LocaleSettingsData,
100
- opts: { oldLanguage: string; oldCjkSerifFont?: string },
108
+ opts: {
109
+ oldLanguage: string;
110
+ oldCjkSerifFont?: string;
111
+ oldDashboardLanguage?: string;
112
+ },
101
113
  ): Promise<{ languageChanged: boolean }>;
102
114
  updateFeedSettings(data: { mainRssFeed?: FeedKind }): Promise<void>;
103
115
  updateHomeBranding(showJantBrandingOnHome: boolean): Promise<void>;
@@ -131,15 +143,16 @@ export interface SettingsService {
131
143
  */
132
144
  uploadAvatar(data: AvatarUploadData, deps: AvatarUploadDeps): Promise<void>;
133
145
  /**
134
- * Remove avatar and all favicon-related settings.
135
- * Deletes the apple-touch-icon from storage if it exists.
136
- *
137
- * @param storage - Optional storage driver for deleting the apple-touch-icon file
146
+ * Remove avatar and all favicon-related settings. The apple-touch-icon's
147
+ * media row is deleted and its storage object is retired (moved to the
148
+ * recycle bin, or deleted) via the media service when supplied — so removal
149
+ * is recoverable rather than erasing the bytes with no trace.
138
150
  */
139
- removeAvatar(
140
- storage?: StorageDriver | null,
141
- deps?: { media?: MediaService; storageProvider?: string },
142
- ): Promise<void>;
151
+ removeAvatar(deps?: {
152
+ storage?: StorageDriver | null;
153
+ media?: MediaService;
154
+ storageProvider?: string;
155
+ }): Promise<void>;
143
156
  }
144
157
 
145
158
  export function createSettingsService(
@@ -301,6 +314,26 @@ export function createSettingsService(
301
314
  normalizeContentLanguage(trimmedLanguage),
302
315
  );
303
316
 
317
+ // Dashboard UI locale. undefined = leave untouched; "" = clear so the
318
+ // dashboard follows the content language; otherwise it must be one of the
319
+ // translated catalog locales.
320
+ let dashboardChanged = false;
321
+ if (data.dashboardLanguage !== undefined) {
322
+ const dashboardLanguage = data.dashboardLanguage.trim();
323
+ if (dashboardLanguage && !isLocale(dashboardLanguage)) {
324
+ throw new ValidationError(
325
+ "Choose a dashboard language Jant is translated into.",
326
+ );
327
+ }
328
+ if (dashboardLanguage) {
329
+ await this.set("DASHBOARD_LANGUAGE", dashboardLanguage);
330
+ } else {
331
+ await this.remove("DASHBOARD_LANGUAGE");
332
+ }
333
+ dashboardChanged =
334
+ (opts.oldDashboardLanguage ?? "") !== dashboardLanguage;
335
+ }
336
+
304
337
  // CJK serif font setting
305
338
  const cjkFont = data.cjkSerifFont?.trim() ?? "";
306
339
  if (cjkFont && isCjkSerifFont(cjkFont) && cjkFont !== "off") {
@@ -329,7 +362,8 @@ export function createSettingsService(
329
362
  return {
330
363
  languageChanged:
331
364
  opts.oldLanguage !== trimmedLanguage ||
332
- (opts.oldCjkSerifFont ?? "off") !== effectiveCjkFont,
365
+ (opts.oldCjkSerifFont ?? "off") !== effectiveCjkFont ||
366
+ dashboardChanged,
333
367
  };
334
368
  },
335
369
 
@@ -469,19 +503,19 @@ export function createSettingsService(
469
503
  await this.set("SITE_FAVICON_VERSION", version);
470
504
  },
471
505
 
472
- async removeAvatar(storage, deps) {
506
+ async removeAvatar(deps) {
473
507
  const appleTouchKey = await this.get("SITE_FAVICON_APPLE_TOUCH");
474
- if (storage && appleTouchKey) {
475
- await storage.delete(appleTouchKey);
476
- }
477
508
 
509
+ // Retire the apple-touch-icon through the media service so its object goes
510
+ // to the recycle bin (recoverable) rather than being erased now. Also
511
+ // removes its media row.
478
512
  if (deps?.media && deps.storageProvider && appleTouchKey) {
479
513
  const existing = await deps.media.getByStorageKey(
480
514
  appleTouchKey,
481
515
  deps.storageProvider,
482
516
  );
483
517
  if (existing) {
484
- await deps.media.delete(existing.id);
518
+ await deps.media.delete(existing.id, deps.storage);
485
519
  }
486
520
  }
487
521
 
@@ -148,6 +148,7 @@ export interface UploadSessionService {
148
148
  abortedMultipartUploads: number;
149
149
  deletedSessions: number;
150
150
  deletedOrphanMedia: number;
151
+ purgedStorageObjects: number;
151
152
  }>;
152
153
  abort(id: string, deps: { storage: StorageDriver }): Promise<void>;
153
154
  }
@@ -766,9 +767,10 @@ export function createUploadSessionService(
766
767
  }
767
768
 
768
769
  // Reap finalized media that was uploaded during compose but never
769
- // attached to a post (`postId IS NULL`) past the grace window. Deletes
770
- // the S3/R2 objects and DB rows (best-effort storage delete). Bounded by
771
- // the same per-run `limit`; any backlog drains over subsequent runs.
770
+ // attached to a post (`postId IS NULL`) past the grace window. This now
771
+ // soft-deletes via the media service: the DB row is removed and the
772
+ // storage object is enqueued for deferred deletion. Bounded by the same
773
+ // per-run `limit`; any backlog drains over subsequent runs.
772
774
  const orphanIds = await media.listOrphanedMediaIds({
773
775
  before: now() - ORPHAN_MEDIA_GRACE_SECONDS,
774
776
  limit,
@@ -777,10 +779,18 @@ export function createUploadSessionService(
777
779
  await media.deleteByIds(orphanIds, deps.storage);
778
780
  }
779
781
 
782
+ // Physically delete storage objects whose recycle window has elapsed.
783
+ // Skips any object a live media row still references (re-uploads).
784
+ const purgedStorageObjects = await media.purgeDueStorageObjects(
785
+ { before: now(), limit, provider: deps.storageDriver },
786
+ deps.storage,
787
+ );
788
+
780
789
  return {
781
790
  abortedMultipartUploads,
782
791
  deletedSessions,
783
792
  deletedOrphanMedia: orphanIds.length,
793
+ purgedStorageObjects,
784
794
  };
785
795
  },
786
796
  };
@@ -467,13 +467,15 @@
467
467
 
468
468
  @media (max-width: 760px), (hover: none) and (pointer: coarse) {
469
469
  :root {
470
- /* Content layer — tighter scale for mobile reading
471
- Ratio ≈ 1.9 : 1.4 : 1.15 : 1 (display:title:subtitle:body)
472
- 13px base 31.9 / 24 / 19.5 / 16.9 */
470
+ /* Content layer — tighter scale for mobile reading.
471
+ Ratio ≈ 1.88 : 1.42 : 1.15 : 1 (display:title:subtitle:body).
472
+ At 15px root × 0.78 content-scale: 28.7 / 21.6 / 17.6 / 15.2px.
473
+ Body is floored to a 16px minimum below (max()) for readability;
474
+ the floor still yields to calc() when the user zooms the root up. */
473
475
  --type-content-display: calc(2.45rem * var(--type-content-scale));
474
476
  --type-content-title: calc(1.85rem * var(--type-content-scale));
475
477
  --type-content-subtitle: calc(1.5rem * var(--type-content-scale));
476
- --type-content-body: calc(1.3rem * var(--type-content-scale));
478
+ --type-content-body: max(16px, calc(1.3rem * var(--type-content-scale)));
477
479
 
478
480
  /* UI layer — pixel floors for touch targets */
479
481
  --type-ui-title: max(16px, var(--type-secondary));
@@ -35,6 +35,7 @@ export interface Bindings {
35
35
  SITE_NAME?: EnvBindingValue;
36
36
  SITE_DESCRIPTION?: EnvBindingValue;
37
37
  SITE_LANGUAGE?: EnvBindingValue;
38
+ DASHBOARD_LANGUAGE?: EnvBindingValue;
38
39
  HOME_DEFAULT_VIEW?: EnvBindingValue;
39
40
  MAIN_RSS_FEED?: EnvBindingValue;
40
41
  TIME_ZONE?: EnvBindingValue;
@@ -41,6 +41,13 @@ export const CONFIG_FIELDS = {
41
41
  envOnly: false,
42
42
  envKeys: ["SITE_LANGUAGE"],
43
43
  },
44
+ // Admin dashboard UI locale. Empty means "follow the content language"
45
+ // (resolved through the catalog fallback chain, i.e. today's behaviour).
46
+ DASHBOARD_LANGUAGE: {
47
+ defaultValue: "",
48
+ envOnly: false,
49
+ envKeys: ["DASHBOARD_LANGUAGE"],
50
+ },
44
51
  CJK_SERIF_FONT: {
45
52
  defaultValue: "off",
46
53
  envOnly: false,
@@ -455,6 +462,12 @@ export interface AppConfig {
455
462
  /** true only when description is set in DB or ENV (not just the default) */
456
463
  siteDescriptionExplicit: boolean;
457
464
  siteLanguage: string;
465
+ /**
466
+ * Admin dashboard UI locale. Empty string means "follow the content
467
+ * language" (derived via the catalog fallback chain). When set, it is one of
468
+ * the translated catalog locales ("en", "zh-Hans", "zh-Hant").
469
+ */
470
+ dashboardLanguage: string;
458
471
  /** CJK serif font locale: "off", "zh-Hans", "zh-Hant", "ja", or "ko" */
459
472
  cjkSerifFont: string;
460
473
  homeDefaultView: string;
@@ -15,6 +15,7 @@ export function GeneralContent({
15
15
  siteName,
16
16
  siteDescription,
17
17
  siteLanguage,
18
+ dashboardLanguage,
18
19
  cjkSerifFont,
19
20
  siteNameFallback,
20
21
  siteDescriptionFallback,
@@ -32,6 +33,7 @@ export function GeneralContent({
32
33
  siteName: string;
33
34
  siteDescription: string;
34
35
  siteLanguage: string;
36
+ dashboardLanguage: string;
35
37
  cjkSerifFont: string;
36
38
  siteNameFallback: string;
37
39
  siteDescriptionFallback: string;
@@ -101,15 +103,37 @@ export function GeneralContent({
101
103
  ),
102
104
  siteLanguage: i18n._(
103
105
  msg({
104
- message: "Language",
105
- comment: "@context: Settings form field for site/admin language",
106
+ message: "Content language",
107
+ comment:
108
+ "@context: Settings form field for the public content language",
106
109
  }),
107
110
  ),
108
111
  siteLanguageHelp: i18n._(
109
112
  msg({
110
113
  message:
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",
114
+ "The language your posts are written in. Announced to readers and search engines through HTML lang and your RSS feed. Any BCP 47 tag works.",
115
+ comment: "@context: Help text under the content language picker",
116
+ }),
117
+ ),
118
+ contentLanguagePreview: i18n._(
119
+ msg({
120
+ message: "Readers and search engines see",
121
+ comment:
122
+ "@context: Lead text before a live <html lang> preview of the content language",
123
+ }),
124
+ ),
125
+ dashboardLanguage: i18n._(
126
+ msg({
127
+ message: "Dashboard language",
128
+ comment:
129
+ "@context: Settings form field for the admin interface language",
130
+ }),
131
+ ),
132
+ dashboardLanguageHelp: i18n._(
133
+ msg({
134
+ message:
135
+ "The language this admin dashboard shows in. Available in English, 简体中文, and 繁體中文.",
136
+ comment: "@context: Help text under the dashboard language picker",
113
137
  }),
114
138
  ),
115
139
  siteLanguageSearchPlaceholder: i18n._(
@@ -308,6 +332,14 @@ export function GeneralContent({
308
332
  timezones.map((tz) => ({ value: tz.value, label: tz.label })),
309
333
  ).replace(/</g, "\\u003c");
310
334
 
335
+ // The 3 catalog locales Jant's dashboard is translated into. Native-script
336
+ // labels so each reads in its own language, like the CJK font options.
337
+ const dashboardLanguagesJson = JSON.stringify([
338
+ { value: "en", label: "English" },
339
+ { value: "zh-Hans", label: "简体中文" },
340
+ { value: "zh-Hant", label: "繁體中文" },
341
+ ]).replace(/</g, "\\u003c");
342
+
311
343
  const cjkFontsJson = JSON.stringify([
312
344
  { value: "off", label: "None" },
313
345
  {
@@ -326,6 +358,7 @@ export function GeneralContent({
326
358
  siteName,
327
359
  siteDescription,
328
360
  siteLanguage,
361
+ dashboardLanguage,
329
362
  cjkSerifFont,
330
363
  mainRssFeed,
331
364
  timeZone,
@@ -341,6 +374,7 @@ export function GeneralContent({
341
374
  labels={labels}
342
375
  timezones={timezonesJson}
343
376
  cjk-fonts={cjkFontsJson}
377
+ dashboard-languages={dashboardLanguagesJson}
344
378
  sitename-fallback={siteNameFallback}
345
379
  sitedescription-fallback={siteDescriptionFallback}
346
380
  main-feed-url={mainFeedUrl}
@@ -277,6 +277,7 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
277
277
  {raw("<!DOCTYPE html>")}
278
278
  <html
279
279
  lang={resolvedLang}
280
+ data-theme={appConfig?.themeId}
280
281
  data-theme-mode={themeMode}
281
282
  data-site-path-prefix={sitePathPrefix}
282
283
  data-asset-base-path={assetBasePath}
@@ -320,6 +320,19 @@ describe("BaseLayout", () => {
320
320
  expect(html).toContain('data-asset-base-path="/blog/_assets"');
321
321
  });
322
322
 
323
+ it("exposes the active theme id on the root html element", async () => {
324
+ const { BaseLayout } = await loadBaseLayout();
325
+ const html = renderToString(
326
+ BaseLayout({
327
+ title: "Jant",
328
+ c: createContext("featured", { themeId: "frost" }),
329
+ children: "Test",
330
+ }),
331
+ );
332
+
333
+ expect(html).toContain('data-theme="frost"');
334
+ });
335
+
323
336
  it("renders theme-color tags that follow the active theme in auto mode", async () => {
324
337
  const { BaseLayout } = await loadBaseLayout();
325
338
  const html = renderToString(
@@ -1,6 +0,0 @@
1
- import "./url-BMYO-Zlt.js";
2
- import { t as createApp } from "./app-9P4rVCe2.js";
3
- import "./export-Be082J0n.js";
4
- import "./env-OHRKGcMj.js";
5
- import "./github-sync-D1Cw8mOY.js";
6
- export { createApp };