@jant/core 0.6.15 → 0.6.16

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/import-site.js +7 -0
  2. package/dist/app-C75Zc8dD.js +6 -0
  3. package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
  4. package/dist/client/.vite/manifest.json +8 -8
  5. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  6. package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
  7. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  8. package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
  9. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  10. package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
  11. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  12. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  13. package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
  14. package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
  15. package/dist/index.js +5 -5
  16. package/dist/node.js +6 -6
  17. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  18. package/package.json +1 -1
  19. package/src/__tests__/mise-config.test.ts +22 -0
  20. package/src/client/__tests__/compose-bridge.test.ts +252 -2
  21. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  22. package/src/client/__tests__/toast.test.ts +65 -1
  23. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
  28. package/src/client/components/compose-format-convert.ts +5 -1
  29. package/src/client/components/compose-types.ts +47 -3
  30. package/src/client/components/jant-command-palette.ts +12 -6
  31. package/src/client/components/jant-compose-dialog.ts +1464 -680
  32. package/src/client/components/jant-compose-editor.ts +225 -93
  33. package/src/client/components/jant-compose-fullscreen.ts +3 -5
  34. package/src/client/components/jant-post-menu.ts +91 -21
  35. package/src/client/compose-bridge.ts +143 -122
  36. package/src/client/compose-launch.ts +13 -0
  37. package/src/client/compose-shortcuts.ts +5 -1
  38. package/src/client/post-refresh.ts +135 -0
  39. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  40. package/src/client/tiptap/link-input-rules.ts +4 -4
  41. package/src/client/toast.ts +130 -38
  42. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  43. package/src/db/__tests__/thread-activity.test.ts +113 -0
  44. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  45. package/src/db/migrations/0031_many_ego.sql +3 -0
  46. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  47. package/src/db/migrations/meta/_journal.json +7 -0
  48. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  49. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  50. package/src/db/migrations/pg/meta/_journal.json +7 -0
  51. package/src/db/pg/schema.ts +18 -0
  52. package/src/db/schema.ts +20 -0
  53. package/src/db/thread-activity.ts +101 -0
  54. package/src/i18n/locales/public/en.po +89 -21
  55. package/src/i18n/locales/public/en.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hans.po +88 -20
  57. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/public/zh-Hant.po +88 -20
  59. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/feed.test.ts +168 -0
  61. package/src/lib/__tests__/markdown.test.ts +16 -0
  62. package/src/lib/__tests__/slug.test.ts +18 -0
  63. package/src/lib/__tests__/translit.test.ts +87 -0
  64. package/src/lib/__tests__/url.test.ts +53 -0
  65. package/src/lib/feed.ts +113 -11
  66. package/src/lib/hugo-markdown.ts +5 -0
  67. package/src/lib/link-preview.ts +25 -0
  68. package/src/lib/markdown-manager.ts +6 -1
  69. package/src/lib/post-display.ts +42 -9
  70. package/src/lib/timeline.ts +39 -9
  71. package/src/lib/tiptap-render.ts +4 -2
  72. package/src/lib/translit.ts +288 -0
  73. package/src/lib/url.ts +123 -12
  74. package/src/lib/view.ts +28 -0
  75. package/src/routes/api/__tests__/palette.test.ts +44 -0
  76. package/src/routes/api/posts.ts +13 -9
  77. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  78. package/src/routes/api/public/posts.ts +7 -0
  79. package/src/routes/compose.tsx +6 -2
  80. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  81. package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
  82. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  83. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  84. package/src/routes/pages/archive.tsx +65 -17
  85. package/src/routes/pages/page.tsx +5 -2
  86. package/src/services/__tests__/collection.test.ts +32 -1
  87. package/src/services/__tests__/post-timeline.test.ts +142 -11
  88. package/src/services/__tests__/post.test.ts +277 -0
  89. package/src/services/collection.ts +8 -10
  90. package/src/services/export-theme/assets/client-site.css +1 -1
  91. package/src/services/export.ts +3 -5
  92. package/src/services/path.ts +4 -1
  93. package/src/services/post.ts +297 -100
  94. package/src/services/search.ts +9 -0
  95. package/src/styles/components.css +0 -14
  96. package/src/styles/site-media.css +4 -4
  97. package/src/styles/tokens.css +62 -1
  98. package/src/styles/ui.css +1363 -1283
  99. package/src/types/entities.ts +5 -0
  100. package/src/types/props.ts +12 -0
  101. package/src/types/views.ts +6 -0
  102. package/src/ui/compose/ComposeDialog.tsx +17 -17
  103. package/src/ui/feed/LinkPreview.tsx +2 -7
  104. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  105. package/src/ui/pages/ArchivePage.tsx +131 -24
  106. package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
  107. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  108. package/src/ui/shared/PostFooter.tsx +35 -12
  109. package/src/ui/shared/custom-icons.ts +5 -0
  110. package/src/ui/shared/post-article-attributes.ts +6 -0
  111. package/dist/app-BxCOR3Uc.js +0 -6
  112. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Executes the real 0005 backfill SQL against legacy-shaped rows.
3
+ *
4
+ * The backfill runs on user data through `jant migrate`, and the same file is
5
+ * executed on both SQLite and Postgres, so it has to be portable and safe to
6
+ * rerun. These tests cover the SQLite side; the SQL avoids dialect-specific
7
+ * syntax (no `UPDATE ... FROM`, only correlated subqueries and TRUE/FALSE
8
+ * literals, which both engines accept).
9
+ */
10
+
11
+ import { readFileSync } from "fs";
12
+ import { resolve } from "path";
13
+ import type BetterSqlite3 from "better-sqlite3";
14
+ import { beforeEach, describe, expect, it } from "vitest";
15
+ import {
16
+ createTestDatabase,
17
+ DEFAULT_TEST_SITE_ID,
18
+ } from "../../__tests__/helpers/db.js";
19
+
20
+ const BACKFILL_SQL = readFileSync(
21
+ resolve(
22
+ import.meta.dirname,
23
+ "../backfills/0005_split_thread_activity_from_quiet_replies.sql",
24
+ ),
25
+ "utf-8",
26
+ );
27
+
28
+ interface RootRow {
29
+ quiet_reply: number;
30
+ last_activity_at: number | null;
31
+ thread_updated_at: number | null;
32
+ }
33
+
34
+ describe("0005 backfill — split thread activity from quiet replies", () => {
35
+ let sqlite: BetterSqlite3.Database;
36
+
37
+ function runBackfill() {
38
+ sqlite.exec(BACKFILL_SQL);
39
+ }
40
+
41
+ /** Insert a post row the way the pre-0031 schema would have left it. */
42
+ function insertLegacyPost(row: {
43
+ id: string;
44
+ threadId: string;
45
+ replyToId?: string | null;
46
+ publishedAt: number | null;
47
+ lastActivityAt: number | null;
48
+ status?: string;
49
+ }) {
50
+ sqlite
51
+ .prepare(
52
+ `INSERT INTO post (
53
+ id, site_id, format, status, visibility, thread_id, reply_to_id,
54
+ published_at, last_activity_at, created_at, updated_at
55
+ ) VALUES (?, ?, 'note', ?, 'public', ?, ?, ?, ?, ?, ?)`,
56
+ )
57
+ .run(
58
+ row.id,
59
+ DEFAULT_TEST_SITE_ID,
60
+ row.status ?? "published",
61
+ row.threadId,
62
+ row.replyToId ?? null,
63
+ row.publishedAt,
64
+ row.lastActivityAt,
65
+ row.publishedAt ?? 0,
66
+ row.publishedAt ?? 0,
67
+ );
68
+ }
69
+
70
+ function readPost(id: string): RootRow {
71
+ return sqlite
72
+ .prepare(
73
+ `SELECT quiet_reply, last_activity_at, thread_updated_at
74
+ FROM post WHERE id = ?`,
75
+ )
76
+ .get(id) as RootRow;
77
+ }
78
+
79
+ beforeEach(() => {
80
+ sqlite = createTestDatabase().sqlite;
81
+ });
82
+
83
+ it("recovers a quiet reply from the gap it left behind", () => {
84
+ // A reply sitting later than the root's recorded activity can only have
85
+ // gotten there by skipping the bump — the signature of a quiet reply.
86
+ insertLegacyPost({
87
+ id: "pst_root",
88
+ threadId: "pst_root",
89
+ publishedAt: 1000,
90
+ lastActivityAt: 1000,
91
+ });
92
+ insertLegacyPost({
93
+ id: "pst_quiet",
94
+ threadId: "pst_root",
95
+ replyToId: "pst_root",
96
+ publishedAt: 3000,
97
+ lastActivityAt: 3000,
98
+ });
99
+
100
+ runBackfill();
101
+
102
+ expect(readPost("pst_quiet").quiet_reply).toBe(1);
103
+ const root = readPost("pst_root");
104
+ expect(root.last_activity_at).toBe(1000);
105
+ expect(root.thread_updated_at).toBe(3000);
106
+ });
107
+
108
+ it("leaves announced replies alone", () => {
109
+ insertLegacyPost({
110
+ id: "pst_root2",
111
+ threadId: "pst_root2",
112
+ publishedAt: 1000,
113
+ lastActivityAt: 2000,
114
+ });
115
+ insertLegacyPost({
116
+ id: "pst_reply2",
117
+ threadId: "pst_root2",
118
+ replyToId: "pst_root2",
119
+ publishedAt: 2000,
120
+ lastActivityAt: 2000,
121
+ });
122
+
123
+ runBackfill();
124
+
125
+ expect(readPost("pst_reply2").quiet_reply).toBe(0);
126
+ const root = readPost("pst_root2");
127
+ expect(root.last_activity_at).toBe(2000);
128
+ expect(root.thread_updated_at).toBe(2000);
129
+ });
130
+
131
+ it("ignores drafts when computing both timestamps", () => {
132
+ insertLegacyPost({
133
+ id: "pst_root3",
134
+ threadId: "pst_root3",
135
+ publishedAt: 1000,
136
+ lastActivityAt: 1000,
137
+ });
138
+ insertLegacyPost({
139
+ id: "pst_draft3",
140
+ threadId: "pst_root3",
141
+ replyToId: "pst_root3",
142
+ publishedAt: null,
143
+ lastActivityAt: null,
144
+ status: "draft",
145
+ });
146
+
147
+ runBackfill();
148
+
149
+ expect(readPost("pst_draft3").quiet_reply).toBe(0);
150
+ const root = readPost("pst_root3");
151
+ expect(root.last_activity_at).toBe(1000);
152
+ expect(root.thread_updated_at).toBe(1000);
153
+ });
154
+
155
+ it("keeps a fully unpublished thread's existing last_activity_at", () => {
156
+ insertLegacyPost({
157
+ id: "pst_root4",
158
+ threadId: "pst_root4",
159
+ publishedAt: null,
160
+ lastActivityAt: 500,
161
+ status: "draft",
162
+ });
163
+
164
+ runBackfill();
165
+
166
+ const root = readPost("pst_root4");
167
+ expect(root.last_activity_at).toBe(500);
168
+ expect(root.thread_updated_at).toBeNull();
169
+ });
170
+
171
+ it("is idempotent", () => {
172
+ insertLegacyPost({
173
+ id: "pst_root5",
174
+ threadId: "pst_root5",
175
+ publishedAt: 1000,
176
+ lastActivityAt: 1000,
177
+ });
178
+ insertLegacyPost({
179
+ id: "pst_quiet5",
180
+ threadId: "pst_root5",
181
+ replyToId: "pst_root5",
182
+ publishedAt: 3000,
183
+ lastActivityAt: 3000,
184
+ });
185
+ insertLegacyPost({
186
+ id: "pst_loud5",
187
+ threadId: "pst_root5",
188
+ replyToId: "pst_root5",
189
+ publishedAt: 900,
190
+ lastActivityAt: 900,
191
+ });
192
+
193
+ runBackfill();
194
+ const first = readPost("pst_root5");
195
+ runBackfill();
196
+ runBackfill();
197
+ const third = readPost("pst_root5");
198
+
199
+ expect(third).toEqual(first);
200
+ expect(first.last_activity_at).toBe(1000);
201
+ expect(first.thread_updated_at).toBe(3000);
202
+ expect(readPost("pst_quiet5").quiet_reply).toBe(1);
203
+ expect(readPost("pst_loud5").quiet_reply).toBe(0);
204
+ });
205
+
206
+ it("scopes recovery to the reply's own thread", () => {
207
+ insertLegacyPost({
208
+ id: "pst_a",
209
+ threadId: "pst_a",
210
+ publishedAt: 5000,
211
+ lastActivityAt: 5000,
212
+ });
213
+ insertLegacyPost({
214
+ id: "pst_b",
215
+ threadId: "pst_b",
216
+ publishedAt: 1000,
217
+ lastActivityAt: 1000,
218
+ });
219
+ // Later than thread A's activity, but it belongs to thread B.
220
+ insertLegacyPost({
221
+ id: "pst_b_reply",
222
+ threadId: "pst_b",
223
+ replyToId: "pst_b",
224
+ publishedAt: 2000,
225
+ lastActivityAt: 2000,
226
+ });
227
+
228
+ runBackfill();
229
+
230
+ expect(readPost("pst_b_reply").quiet_reply).toBe(1);
231
+ expect(readPost("pst_b").last_activity_at).toBe(1000);
232
+ expect(readPost("pst_a").last_activity_at).toBe(5000);
233
+ });
234
+ });
@@ -0,0 +1,113 @@
1
+ import { and, eq, sql } from "drizzle-orm";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ createTestDatabase,
5
+ DEFAULT_TEST_SITE_ID,
6
+ } from "../../__tests__/helpers/db.js";
7
+ import { posts, threadCollections } from "../schema.js";
8
+ import {
9
+ buildRootActivityExpr,
10
+ getRootActivityAt,
11
+ rootActivityColumns,
12
+ } from "../thread-activity.js";
13
+
14
+ /**
15
+ * The SQL assertions here look pedantic, but they guard a failure mode that
16
+ * produces no error and no obviously wrong output: Drizzle omits the table
17
+ * qualifier on interpolated columns in a single-table projection, so a
18
+ * correlated subquery silently binds to its own alias instead of the outer
19
+ * row. That is why the subquery flavor takes raw fragments.
20
+ */
21
+ describe("thread activity definition", () => {
22
+ const normalize = (value: string) => value.replace(/\s+/g, " ");
23
+
24
+ it("prefers last activity, then publication, then the row's own write", () => {
25
+ expect(
26
+ getRootActivityAt({
27
+ lastActivityAt: 300,
28
+ publishedAt: 200,
29
+ updatedAt: 100,
30
+ }),
31
+ ).toBe(300);
32
+ expect(
33
+ getRootActivityAt({
34
+ lastActivityAt: null,
35
+ publishedAt: 200,
36
+ updatedAt: 100,
37
+ }),
38
+ ).toBe(200);
39
+ expect(
40
+ getRootActivityAt({
41
+ lastActivityAt: null,
42
+ publishedAt: null,
43
+ updatedAt: 100,
44
+ }),
45
+ ).toBe(100);
46
+ });
47
+
48
+ it("never falls back to a plain edit when real activity exists", () => {
49
+ // An edited root keeps its activity timestamp — the whole point.
50
+ expect(
51
+ getRootActivityAt({
52
+ lastActivityAt: 1000,
53
+ publishedAt: 1000,
54
+ updatedAt: 9999,
55
+ }),
56
+ ).toBe(1000);
57
+ });
58
+
59
+ it("qualifies the subquery flavor against the root alias", () => {
60
+ const { db } = createTestDatabase();
61
+ const expr = sql<number>`(
62
+ SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
63
+ FROM post AS root
64
+ WHERE root.site_id = ${DEFAULT_TEST_SITE_ID}
65
+ AND root.id = ${posts.threadId}
66
+ )`.as("activity_at");
67
+
68
+ const generated = normalize(
69
+ (db as never as { select: (fields: unknown) => never })
70
+ .select({ threadId: posts.threadId, activityAt: expr })
71
+ // The join is what makes Drizzle qualify `posts.threadId` below.
72
+ .from(posts)
73
+ .innerJoin(
74
+ threadCollections,
75
+ and(
76
+ eq(threadCollections.siteId, posts.siteId),
77
+ eq(threadCollections.threadId, posts.threadId),
78
+ ),
79
+ )
80
+ .groupBy(posts.threadId)
81
+ .toSQL().sql,
82
+ );
83
+
84
+ // Inner columns resolve against the subquery's own alias...
85
+ expect(generated).toContain(
86
+ "COALESCE( root.last_activity_at, root.published_at, root.updated_at )",
87
+ );
88
+ // ...and the correlation still points at the outer row, not at `root`.
89
+ expect(generated).toContain('root.id = "post"."thread_id"');
90
+ });
91
+
92
+ it("uses the root's own columns when rows are already roots", () => {
93
+ const { db } = createTestDatabase();
94
+ const generated = normalize(
95
+ (db as never as { select: (fields: unknown) => never })
96
+ .select({
97
+ id: posts.id,
98
+ activityAt: buildRootActivityExpr({
99
+ lastActivityAt: posts.lastActivityAt,
100
+ publishedAt: posts.publishedAt,
101
+ updatedAt: posts.updatedAt,
102
+ }),
103
+ })
104
+ .from(posts)
105
+ .innerJoin(threadCollections, eq(threadCollections.threadId, posts.id))
106
+ .toSQL().sql,
107
+ );
108
+
109
+ expect(generated).toContain(
110
+ 'COALESCE( "post"."last_activity_at", "post"."published_at", "post"."updated_at" )',
111
+ );
112
+ });
113
+ });
@@ -0,0 +1,68 @@
1
+ -- Split "last announced" from "last changed" for Threads.
2
+ --
3
+ -- Until now a quiet reply was implemented by *skipping* the recalculation of
4
+ -- post.last_activity_at, and the quietness itself was never stored. That made
5
+ -- the flag non-durable: the next recalculation (deleting any post in the
6
+ -- Thread, changing a published_at, republishing) recomputed
7
+ -- MAX(published_at) over the whole Thread, absorbed the quiet reply, and
8
+ -- bumped the Thread on Latest anyway.
9
+ --
10
+ -- post.quiet_reply now persists the author's choice, so both timestamps are
11
+ -- pure functions of the rows:
12
+ -- last_activity_at = MAX(published_at) excluding quiet replies (Latest)
13
+ -- thread_updated_at = MAX(published_at) including quiet replies (archive)
14
+ --
15
+ -- Step 1 recovers the quiet decisions that are still visible in the data.
16
+ -- A published reply can only sit later than its root's recorded
17
+ -- last_activity_at if the recalculation was skipped for it — that is the
18
+ -- signature of a quiet reply. Decisions that were already absorbed by a later
19
+ -- recalculation are unrecoverable and stay false, which matches the ordering
20
+ -- those sites are already seeing.
21
+ --
22
+ -- Steps 2 and 3 then recompute both timestamps from scratch.
23
+ --
24
+ -- Idempotent: after step 3 a quiet reply is still later than the recomputed
25
+ -- last_activity_at (it is excluded from that MAX), so a rerun re-selects
26
+ -- exactly the same rows, and every non-quiet published reply is covered by
27
+ -- the MAX and can never be selected.
28
+
29
+ -- Step 1: recover still-detectable quiet replies.
30
+ UPDATE "post"
31
+ SET "quiet_reply" = TRUE
32
+ WHERE "post"."reply_to_id" IS NOT NULL
33
+ AND "post"."published_at" IS NOT NULL
34
+ AND EXISTS (
35
+ SELECT 1
36
+ FROM "post" AS root
37
+ WHERE root."site_id" = "post"."site_id"
38
+ AND root."id" = "post"."thread_id"
39
+ AND root."last_activity_at" IS NOT NULL
40
+ AND "post"."published_at" > root."last_activity_at"
41
+ );
42
+
43
+ -- Step 2: thread_updated_at — newest published post in the Thread, quiet included.
44
+ UPDATE "post"
45
+ SET "thread_updated_at" = (
46
+ SELECT MAX(member."published_at")
47
+ FROM "post" AS member
48
+ WHERE member."site_id" = "post"."site_id"
49
+ AND member."thread_id" = "post"."id"
50
+ AND member."status" = 'published'
51
+ )
52
+ WHERE "post"."reply_to_id" IS NULL;
53
+
54
+ -- Step 3: last_activity_at — newest announced post in the Thread.
55
+ -- Threads with nothing published (drafts) keep whatever they had.
56
+ UPDATE "post"
57
+ SET "last_activity_at" = COALESCE(
58
+ (
59
+ SELECT MAX(member."published_at")
60
+ FROM "post" AS member
61
+ WHERE member."site_id" = "post"."site_id"
62
+ AND member."thread_id" = "post"."id"
63
+ AND member."status" = 'published'
64
+ AND member."quiet_reply" = FALSE
65
+ ),
66
+ "post"."last_activity_at"
67
+ )
68
+ WHERE "post"."reply_to_id" IS NULL;
@@ -0,0 +1,3 @@
1
+ ALTER TABLE `post` ADD `quiet_reply` integer DEFAULT false NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE `post` ADD `thread_updated_at` integer;--> statement-breakpoint
3
+ CREATE INDEX `idx_post_site_root_thread_updated` ON `post` (`site_id`,`thread_updated_at`,`id`) WHERE "post"."reply_to_id" IS NULL AND "post"."status" = 'published';