@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,288 @@
1
+ /**
2
+ * Dictionary-free transliteration helpers for slug generation.
3
+ *
4
+ * These converters cover the scripts limax handles poorly:
5
+ *
6
+ * - Korean hangul → Revised Romanization (limax strips hangul entirely)
7
+ * - Japanese kana → Hepburn romaji (limax mangles long vowels and digraphs)
8
+ *
9
+ * Both are pure algorithms over Unicode code points — no dictionaries — so
10
+ * they stay small enough for the Workers bundle and the lazy client chunk.
11
+ * Japanese kanji is intentionally out of scope: readings are context-dependent
12
+ * and need a morphological dictionary, which is why `slugify` falls back to a
13
+ * random ID for kanji-heavy Japanese titles instead of guessing.
14
+ */
15
+
16
+ /** Matches hiragana, katakana, and the chōonpu (ー) long-vowel mark. */
17
+ export const KANA_RE = /[ぁ-ゖァ-ヺー]/u;
18
+
19
+ /** Matches precomposed hangul syllables and compatibility jamo. */
20
+ export const HANGUL_RE = /[가-힣ㄱ-ㆎ]/u;
21
+
22
+ /** Matches Han (CJK ideograph) characters. */
23
+ export const HAN_RE = /\p{Script=Han}/u;
24
+
25
+ // --- Korean: Revised Romanization ---------------------------------------
26
+
27
+ const HANGUL_SYLLABLE_BASE = 0xac00;
28
+ const HANGUL_SYLLABLE_END = 0xd7a3;
29
+ const VOWEL_COUNT = 21;
30
+ const FINAL_COUNT = 28;
31
+
32
+ // prettier-ignore
33
+ const RR_INITIALS = [
34
+ "g", "kk", "n", "d", "tt", "r", "m", "b", "pp", "s",
35
+ "ss", "", "j", "jj", "ch", "k", "t", "p", "h",
36
+ ];
37
+
38
+ // prettier-ignore
39
+ const RR_VOWELS = [
40
+ "a", "ae", "ya", "yae", "eo", "e", "yeo", "ye", "o", "wa",
41
+ "wae", "oe", "yo", "u", "wo", "we", "wi", "yu", "eu", "ui", "i",
42
+ ];
43
+
44
+ // Finals use their phonetic (syllable-final) value, e.g. ㅅ → "t".
45
+ // prettier-ignore
46
+ const RR_FINALS = [
47
+ "", "k", "k", "k", "n", "n", "n", "t", "l", "k", "m", "l", "l", "l",
48
+ "p", "l", "m", "p", "p", "t", "t", "ng", "t", "t", "k", "t", "p", "t",
49
+ ];
50
+
51
+ // Liaison: when the next syllable starts with the silent initial ㅇ, the
52
+ // final consonant is pronounced as that syllable's initial instead
53
+ // (한국어 → han-gu-geo, not han-guk-eo). Each entry is
54
+ // [what remains as the final, what carries over as the next initial].
55
+ // ㅎ is dropped when carried (좋아 → joa); final ㅇ (ng) never carries.
56
+ // prettier-ignore
57
+ const RR_LIAISON: readonly (readonly [string, string])[] = [
58
+ ["", ""], ["", "g"], ["", "kk"], ["k", "s"], ["", "n"], ["n", "j"],
59
+ ["n", ""], ["", "d"], ["", "r"], ["l", "g"], ["l", "m"], ["l", "b"],
60
+ ["l", "s"], ["l", "t"], ["l", "p"], ["l", ""], ["", "m"], ["", "b"],
61
+ ["p", "s"], ["", "s"], ["", "ss"], ["ng", ""], ["", "j"], ["", "ch"],
62
+ ["", "k"], ["", "t"], ["", "p"], ["", ""],
63
+ ];
64
+
65
+ // Standalone compatibility jamo (ㅋㅋㅋ, ㅠㅠ) — common in informal titles.
66
+ const COMPAT_JAMO: Record<string, string> = {
67
+ ㄱ: "g",
68
+ ㄲ: "kk",
69
+ ㄴ: "n",
70
+ ㄷ: "d",
71
+ ㄸ: "tt",
72
+ ㄹ: "r",
73
+ ㅁ: "m",
74
+ ㅂ: "b",
75
+ ㅃ: "pp",
76
+ ㅅ: "s",
77
+ ㅆ: "ss",
78
+ ㅇ: "ng",
79
+ ㅈ: "j",
80
+ ㅉ: "jj",
81
+ ㅊ: "ch",
82
+ ㅋ: "k",
83
+ ㅌ: "t",
84
+ ㅍ: "p",
85
+ ㅎ: "h",
86
+ ㅏ: "a",
87
+ ㅐ: "ae",
88
+ ㅑ: "ya",
89
+ ㅒ: "yae",
90
+ ㅓ: "eo",
91
+ ㅔ: "e",
92
+ ㅕ: "yeo",
93
+ ㅖ: "ye",
94
+ ㅗ: "o",
95
+ ㅘ: "wa",
96
+ ㅙ: "wae",
97
+ ㅚ: "oe",
98
+ ㅛ: "yo",
99
+ ㅜ: "u",
100
+ ㅝ: "wo",
101
+ ㅞ: "we",
102
+ ㅟ: "wi",
103
+ ㅠ: "yu",
104
+ ㅡ: "eu",
105
+ ㅢ: "ui",
106
+ ㅣ: "i",
107
+ };
108
+
109
+ interface HangulSyllable {
110
+ initial: number;
111
+ vowel: number;
112
+ final: number;
113
+ }
114
+
115
+ function decomposeHangul(codePoint: number): HangulSyllable | null {
116
+ if (codePoint < HANGUL_SYLLABLE_BASE || codePoint > HANGUL_SYLLABLE_END) {
117
+ return null;
118
+ }
119
+ const index = codePoint - HANGUL_SYLLABLE_BASE;
120
+ return {
121
+ initial: Math.floor(index / (VOWEL_COUNT * FINAL_COUNT)),
122
+ vowel: Math.floor(index / FINAL_COUNT) % VOWEL_COUNT,
123
+ final: index % FINAL_COUNT,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Transliterates Korean hangul to Latin using Revised Romanization.
129
+ *
130
+ * Handles precomposed syllables with basic liaison (a final consonant before
131
+ * a silent-ㅇ syllable carries over as its initial) and standalone
132
+ * compatibility jamo. Assimilation sound rules (e.g. 종로 → "Jongno") are
133
+ * intentionally skipped — slugs need recognizable output, not phonetic
134
+ * perfection. Non-hangul characters pass through unchanged.
135
+ *
136
+ * @param text - Input text, possibly mixing hangul with other characters
137
+ * @returns Text with hangul replaced by its romanization
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * hangulToLatin("안녕하세요"); // "annyeonghaseyo"
142
+ * hangulToLatin("한국어"); // "hangugeo" (liaison)
143
+ * hangulToLatin("개발 일지"); // "gaebal ilji"
144
+ * ```
145
+ */
146
+ export function hangulToLatin(text: string): string {
147
+ const chars = [...text];
148
+ let out = "";
149
+ let carried: string | null = null;
150
+
151
+ for (const [i, char] of chars.entries()) {
152
+ const syllable = decomposeHangul(char.codePointAt(0) ?? 0);
153
+ if (!syllable) {
154
+ carried = null;
155
+ out += COMPAT_JAMO[char] ?? char;
156
+ continue;
157
+ }
158
+
159
+ const initial = carried ?? RR_INITIALS[syllable.initial] ?? "";
160
+ carried = null;
161
+ const next = decomposeHangul(chars[i + 1]?.codePointAt(0) ?? 0);
162
+ let final = RR_FINALS[syllable.final] ?? "";
163
+ if (syllable.final !== 0 && next && RR_INITIALS[next.initial] === "") {
164
+ const [remains, carry] = RR_LIAISON[syllable.final] ?? ["", ""];
165
+ final = remains;
166
+ carried = carry;
167
+ }
168
+ out += initial + (RR_VOWELS[syllable.vowel] ?? "") + final;
169
+ }
170
+ return out;
171
+ }
172
+
173
+ // --- Japanese: Hepburn romaji -------------------------------------------
174
+
175
+ // prettier-ignore
176
+ const KANA_BASE: Record<string, string> = {
177
+ あ: "a", い: "i", う: "u", え: "e", お: "o",
178
+ か: "ka", き: "ki", く: "ku", け: "ke", こ: "ko",
179
+ さ: "sa", し: "shi", す: "su", せ: "se", そ: "so",
180
+ た: "ta", ち: "chi", つ: "tsu", て: "te", と: "to",
181
+ な: "na", に: "ni", ぬ: "nu", ね: "ne", の: "no",
182
+ は: "ha", ひ: "hi", ふ: "fu", へ: "he", ほ: "ho",
183
+ ま: "ma", み: "mi", む: "mu", め: "me", も: "mo",
184
+ や: "ya", ゆ: "yu", よ: "yo",
185
+ ら: "ra", り: "ri", る: "ru", れ: "re", ろ: "ro",
186
+ わ: "wa", ゐ: "i", ゑ: "e", を: "o", ん: "n",
187
+ が: "ga", ぎ: "gi", ぐ: "gu", げ: "ge", ご: "go",
188
+ ざ: "za", じ: "ji", ず: "zu", ぜ: "ze", ぞ: "zo",
189
+ だ: "da", ぢ: "ji", づ: "zu", で: "de", ど: "do",
190
+ ば: "ba", び: "bi", ぶ: "bu", べ: "be", ぼ: "bo",
191
+ ぱ: "pa", ぴ: "pi", ぷ: "pu", ぺ: "pe", ぽ: "po",
192
+ ゔ: "vu",
193
+ ぁ: "a", ぃ: "i", ぅ: "u", ぇ: "e", ぉ: "o",
194
+ ゃ: "ya", ゅ: "yu", ょ: "yo", ゎ: "wa",
195
+ };
196
+
197
+ // Digraphs and extended katakana combos (ファ, ティ, チェ, …). Keys are the
198
+ // hiragana-normalized two-character sequences.
199
+ // prettier-ignore
200
+ const KANA_COMBO: Record<string, string> = {
201
+ きゃ: "kya", きゅ: "kyu", きょ: "kyo",
202
+ ぎゃ: "gya", ぎゅ: "gyu", ぎょ: "gyo",
203
+ しゃ: "sha", しゅ: "shu", しょ: "sho",
204
+ じゃ: "ja", じゅ: "ju", じょ: "jo",
205
+ ちゃ: "cha", ちゅ: "chu", ちょ: "cho",
206
+ ぢゃ: "ja", ぢゅ: "ju", ぢょ: "jo",
207
+ にゃ: "nya", にゅ: "nyu", にょ: "nyo",
208
+ ひゃ: "hya", ひゅ: "hyu", ひょ: "hyo",
209
+ びゃ: "bya", びゅ: "byu", びょ: "byo",
210
+ ぴゃ: "pya", ぴゅ: "pyu", ぴょ: "pyo",
211
+ みゃ: "mya", みゅ: "myu", みょ: "myo",
212
+ りゃ: "rya", りゅ: "ryu", りょ: "ryo",
213
+ ふぁ: "fa", ふぃ: "fi", ふぇ: "fe", ふぉ: "fo",
214
+ てぃ: "ti", でぃ: "di", とぅ: "tu", どぅ: "du",
215
+ うぃ: "wi", うぇ: "we", うぉ: "wo",
216
+ ゔぁ: "va", ゔぃ: "vi", ゔぇ: "ve", ゔぉ: "vo",
217
+ ちぇ: "che", しぇ: "she", じぇ: "je",
218
+ つぁ: "tsa", つぃ: "tsi", つぇ: "tse", つぉ: "tso",
219
+ いぇ: "ye",
220
+ };
221
+
222
+ const KATAKANA_START = 0x30a1;
223
+ const KATAKANA_END = 0x30f6;
224
+ const KATAKANA_TO_HIRAGANA_SHIFT = 0x60;
225
+ const SOKUON = "っ";
226
+ const CHOONPU = "ー";
227
+
228
+ function normalizeToHiragana(char: string): string {
229
+ const codePoint = char.codePointAt(0) ?? 0;
230
+ if (codePoint >= KATAKANA_START && codePoint <= KATAKANA_END) {
231
+ return String.fromCodePoint(codePoint - KATAKANA_TO_HIRAGANA_SHIFT);
232
+ }
233
+ return char;
234
+ }
235
+
236
+ /**
237
+ * Transliterates Japanese kana to macron-less Hepburn romaji.
238
+ *
239
+ * Handles hiragana and katakana, digraphs (きゃ → "kya"), extended katakana
240
+ * loan combos (ファ → "fa"), sokuon gemination (まっちゃ → "matcha"), and
241
+ * drops the chōonpu long-vowel mark (ハンバーガー → "hanbaga"), matching how
242
+ * macrons are conventionally stripped in URLs. Kanji and other non-kana
243
+ * characters pass through unchanged.
244
+ *
245
+ * @param text - Input text, possibly mixing kana with other characters
246
+ * @returns Text with kana replaced by romaji
247
+ *
248
+ * @example
249
+ * ```ts
250
+ * kanaToRomaji("カタカナタイトル"); // "katakanataitoru"
251
+ * kanaToRomaji("ハンバーガー"); // "hanbaga"
252
+ * kanaToRomaji("まっちゃ"); // "matcha"
253
+ * ```
254
+ */
255
+ export function kanaToRomaji(text: string): string {
256
+ const chars = [...text].map(normalizeToHiragana);
257
+ let out = "";
258
+ let geminate = false;
259
+
260
+ for (let i = 0; i < chars.length; i++) {
261
+ const char = chars[i] ?? "";
262
+ if (char === SOKUON) {
263
+ geminate = true;
264
+ continue;
265
+ }
266
+ if (char === CHOONPU) {
267
+ geminate = false;
268
+ continue;
269
+ }
270
+
271
+ const combo = KANA_COMBO[char + (chars[i + 1] ?? "")];
272
+ const romaji = combo ?? KANA_BASE[char];
273
+ if (combo) i++;
274
+ if (romaji === undefined) {
275
+ geminate = false;
276
+ out += char;
277
+ continue;
278
+ }
279
+ if (geminate) {
280
+ geminate = false;
281
+ if (/^[^aeiou]/.test(romaji)) {
282
+ out += romaji.startsWith("ch") ? "t" : (romaji[0] ?? "");
283
+ }
284
+ }
285
+ out += romaji;
286
+ }
287
+ return out;
288
+ }
package/src/lib/url.ts CHANGED
@@ -4,6 +4,14 @@
4
4
 
5
5
  import limax from "limax";
6
6
 
7
+ import {
8
+ HAN_RE,
9
+ HANGUL_RE,
10
+ KANA_RE,
11
+ hangulToLatin,
12
+ kanaToRomaji,
13
+ } from "./translit.js";
14
+
7
15
  function normalizeSitePathname(pathname: string): string {
8
16
  if (pathname === "/" || pathname === "") return "";
9
17
  const trimmed = pathname.replace(/\/+$/, "");
@@ -133,14 +141,66 @@ export function toSameSitePath(url: string, siteOrigin: string): string | null {
133
141
  return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
134
142
  }
135
143
 
144
+ const HAN_GLOBAL_RE = /\p{Script=Han}/gu;
145
+ const CONTENT_CHAR_RE = /[\p{L}\p{N}]/u;
146
+ const ASCII_ALNUM_RE = /[a-zA-Z0-9]/;
147
+
148
+ /**
149
+ * Minimum fraction of a title's letters/digits the transliteration pipeline
150
+ * must handle for the result to be usable. Below this, too much meaning is
151
+ * lost (e.g. mixed kanji+kana Japanese where the semantic core is kanji, or
152
+ * scripts limax drops entirely) and `slugify` returns "" so callers fall back
153
+ * to their random-ID path instead of shipping a garbled slug.
154
+ */
155
+ const SLUG_COVERAGE_THRESHOLD = 0.75;
156
+
157
+ /** Per-character limax probe cache: does this char transliterate to anything? */
158
+ const charCoverageCache = new Map<string, boolean>();
159
+
160
+ function isCharCovered(char: string): boolean {
161
+ if (ASCII_ALNUM_RE.test(char)) return true;
162
+ let covered = charCoverageCache.get(char);
163
+ if (covered === undefined) {
164
+ covered = limax(char, { tone: false }).length > 0;
165
+ charCoverageCache.set(char, covered);
166
+ }
167
+ return covered;
168
+ }
169
+
170
+ function slugCoverage(text: string, japanese: boolean): number {
171
+ let content = 0;
172
+ let covered = 0;
173
+ for (const char of text) {
174
+ if (!CONTENT_CHAR_RE.test(char)) continue;
175
+ content++;
176
+ if (KANA_RE.test(char) || HANGUL_RE.test(char)) {
177
+ covered++;
178
+ } else if (japanese && HAN_RE.test(char)) {
179
+ // Kanji in a kana-containing title is dropped, never pinyin-read.
180
+ } else if (isCharCovered(char)) {
181
+ covered++;
182
+ }
183
+ }
184
+ return content === 0 ? 0 : covered / content;
185
+ }
186
+
136
187
  /**
137
188
  * Converts text to a URL-friendly slug.
138
189
  *
139
- * Transforms text into a lowercase, hyphen-separated slug using limax for
140
- * i18n-aware transliteration (CJK → Pinyin, Japanese → Romaji, accented → ASCII).
190
+ * Transliterates i18n text to Latin before slugifying: Korean hangul via
191
+ * Revised Romanization, Japanese kana via Hepburn romaji (both in-house, see
192
+ * `translit.ts`), everything else (Han → pinyin, Cyrillic, accents, …) via
193
+ * limax. When the title contains kana it is treated as Japanese and Han
194
+ * characters are dropped rather than misread as Chinese pinyin; Han without
195
+ * kana is treated as Chinese.
196
+ *
197
+ * Returns `""` when transliteration would lose too much of the title (see
198
+ * `SLUG_COVERAGE_THRESHOLD`) — every caller treats an empty base as "no
199
+ * usable title" and falls back to a random ID, which beats publishing a
200
+ * garbled slug.
141
201
  *
142
202
  * @param text - The text to convert to a slug
143
- * @returns The slugified string
203
+ * @returns The slugified string, or `""` when no usable slug can be derived
144
204
  *
145
205
  * @example
146
206
  * ```ts
@@ -149,10 +209,29 @@ export function toSameSitePath(url: string, siteOrigin: string): string | null {
149
209
  *
150
210
  * slugify("书评");
151
211
  * // Returns: "shu-ping"
212
+ *
213
+ * slugify("안녕하세요 세계");
214
+ * // Returns: "annyeonghaseyo-segye"
215
+ *
216
+ * slugify("ハンバーガー");
217
+ * // Returns: "hanbaga"
218
+ *
219
+ * slugify("日本語のタイトルです");
220
+ * // Returns: "" (kanji carries the meaning and can't be transliterated)
152
221
  * ```
153
222
  */
154
223
  export function slugify(text: string): string {
155
- return limax(text, { tone: false }).replace(/_/g, "-");
224
+ const japanese = KANA_RE.test(text);
225
+ let pre = text;
226
+ if (japanese) {
227
+ pre = kanaToRomaji(pre.replace(HAN_GLOBAL_RE, " "));
228
+ }
229
+ if (HANGUL_RE.test(pre)) {
230
+ pre = hangulToLatin(pre);
231
+ }
232
+ const base = limax(pre, { tone: false }).replace(/_/g, "-");
233
+ if (!base) return "";
234
+ return slugCoverage(text, japanese) >= SLUG_COVERAGE_THRESHOLD ? base : "";
156
235
  }
157
236
 
158
237
  /**
@@ -183,6 +262,25 @@ export function extractDisplayDomain(url: string): string | null {
183
262
  }
184
263
 
185
264
  const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
265
+ const SAFE_RICH_TEXT_HREF_PROTOCOLS = new Set([
266
+ ...SAFE_URL_PROTOCOLS,
267
+ "tel:",
268
+ "sms:",
269
+ ]);
270
+
271
+ function sanitizeUrlWithProtocols(
272
+ url: string,
273
+ protocols: ReadonlySet<string>,
274
+ ): string {
275
+ try {
276
+ const parsed = new URL(url, "https://placeholder.invalid");
277
+ // Relative URLs resolve against the placeholder and get https: — allow them
278
+ if (protocols.has(parsed.protocol)) return url;
279
+ return "";
280
+ } catch {
281
+ return "";
282
+ }
283
+ }
186
284
 
187
285
  /**
188
286
  * Checks whether a string is a safe absolute URL.
@@ -231,14 +329,27 @@ export function isSafeAbsoluteUrl(url: string): boolean {
231
329
  * ```
232
330
  */
233
331
  export function sanitizeUrl(url: string): string {
234
- try {
235
- const parsed = new URL(url, "https://placeholder.invalid");
236
- // Relative URLs resolve against the placeholder and get https: — allow them
237
- if (SAFE_URL_PROTOCOLS.has(parsed.protocol)) return url;
238
- return "";
239
- } catch {
240
- return "";
241
- }
332
+ return sanitizeUrlWithProtocols(url, SAFE_URL_PROTOCOLS);
333
+ }
334
+
335
+ /**
336
+ * Sanitizes a rich-text link destination.
337
+ *
338
+ * In addition to safe web and email URLs, rich-text links may launch the
339
+ * device's phone or messaging app through `tel:` and `sms:`. Relative links
340
+ * remain valid, while executable and resource-only protocols are rejected.
341
+ *
342
+ * @param url - The link destination to sanitize
343
+ * @returns The original URL if safe for an anchor, or an empty string otherwise
344
+ *
345
+ * @example
346
+ * ```ts
347
+ * sanitizeRichTextHref("sms:+15551234567"); // "sms:+15551234567"
348
+ * sanitizeRichTextHref("javascript:alert(1)"); // ""
349
+ * ```
350
+ */
351
+ export function sanitizeRichTextHref(url: string): string {
352
+ return sanitizeUrlWithProtocols(url, SAFE_RICH_TEXT_HREF_PROTOCOLS);
242
353
  }
243
354
 
244
355
  /**
package/src/lib/view.ts CHANGED
@@ -406,6 +406,34 @@ export function toPostView(
406
406
  };
407
407
  }
408
408
 
409
+ /**
410
+ * Resolves the unpublished draft sitting at the end of a Thread, if any.
411
+ *
412
+ * Both maps come from the same ordering over the same rows, differing only in
413
+ * whether drafts are eligible — so when they disagree, the draft-inclusive
414
+ * answer is by definition unpublished. No status lookup needed.
415
+ *
416
+ * @param threadId - Thread root ID to look up
417
+ * @param publishedTails - Tails from `getThreadTailIds(ids)`
418
+ * @param draftInclusiveTails - Tails from `getThreadTailIds(ids, { includeDrafts: true })`
419
+ * @returns The trailing draft's Post ID, or undefined when the Thread ends
420
+ * on a published Post
421
+ * @example
422
+ * ```ts
423
+ * const draftTailId = resolveDraftTailId(post.threadId, tails, draftTails);
424
+ * if (draftTailId) view.draftTailId = draftTailId;
425
+ * ```
426
+ */
427
+ export function resolveDraftTailId(
428
+ threadId: string,
429
+ publishedTails: Map<string, string>,
430
+ draftInclusiveTails: Map<string, string>,
431
+ ): string | undefined {
432
+ const withDrafts = draftInclusiveTails.get(threadId);
433
+ if (!withDrafts) return undefined;
434
+ return withDrafts === publishedTails.get(threadId) ? undefined : withDrafts;
435
+ }
436
+
409
437
  /**
410
438
  * Batch converts PostWithMedia[] to PostView[].
411
439
  *
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { paletteApiRoutes } from "../palette.js";
4
+
5
+ describe("Command palette API", () => {
6
+ it("includes post status so clients can route drafts to preview", async () => {
7
+ const { app, services } = createTestApp({ authenticated: true });
8
+ app.route("/api/palette", paletteApiRoutes);
9
+
10
+ const draft = await services.posts.create({
11
+ format: "note",
12
+ title: "Draft destination",
13
+ bodyMarkdown: "Not published yet.",
14
+ status: "draft",
15
+ });
16
+ const published = await services.posts.create({
17
+ format: "note",
18
+ title: "Published destination",
19
+ bodyMarkdown: "Already published.",
20
+ status: "published",
21
+ });
22
+
23
+ const response = await app.request("/api/palette");
24
+ const body = (await response.json()) as {
25
+ items: Array<Record<string, unknown>>;
26
+ };
27
+
28
+ expect(response.status).toBe(200);
29
+ expect(body.items).toContainEqual({
30
+ title: "Draft destination",
31
+ path: draft.slug,
32
+ type: "post",
33
+ format: "note",
34
+ status: "draft",
35
+ });
36
+ expect(body.items).toContainEqual({
37
+ title: "Published destination",
38
+ path: published.slug,
39
+ type: "post",
40
+ format: "note",
41
+ status: "published",
42
+ });
43
+ });
44
+ });
@@ -131,12 +131,15 @@ postsApiRoutes.get("/:id/content", requireAuthApi(), async (c) => {
131
131
  postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
132
132
  const id = parseIdParam(c.req.param("id"), ID_PREFIX.post);
133
133
 
134
- // Fetch post, media, and collections in parallel (all keyed by the same id)
135
- const [post, mediaList, threadCollections] = await Promise.all([
136
- c.var.services.posts.getById(id),
137
- c.var.services.media.getByPostId(id),
138
- c.var.services.collections.getCollectionsByPostId(id),
139
- ]);
134
+ // Fetch post, media, collections and thread position in parallel (all keyed
135
+ // by the same id)
136
+ const [post, mediaList, threadCollections, threadPosition] =
137
+ await Promise.all([
138
+ c.var.services.posts.getById(id),
139
+ c.var.services.media.getByPostId(id),
140
+ c.var.services.collections.getCollectionsByPostId(id),
141
+ c.var.services.posts.getThreadPosition(id),
142
+ ]);
140
143
  const foundPost = assertFound(post, "Post");
141
144
  const {
142
145
  r2PublicUrl,
@@ -147,8 +150,8 @@ postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
147
150
  } = c.var.appConfig;
148
151
  const collectionIds = threadCollections.map((col) => col.id);
149
152
 
150
- return c.json(
151
- toApiPost(foundPost, {
153
+ return c.json({
154
+ ...toApiPost(foundPost, {
152
155
  collectionIds,
153
156
  attachments: mediaList.map((m) =>
154
157
  toApiAttachment(
@@ -161,7 +164,8 @@ postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
161
164
  ),
162
165
  ),
163
166
  }),
164
- );
167
+ threadPosition,
168
+ });
165
169
  });
166
170
 
167
171
  // Create post (requires auth)
@@ -53,6 +53,44 @@ describe("Public Posts API Routes", () => {
53
53
  expect(body.posts[0]).not.toHaveProperty("body");
54
54
  });
55
55
 
56
+ // Consumers need both axes: `lastActivityAt` answers "when was this
57
+ // announced", `threadUpdatedAt` answers "when did it actually change".
58
+ // Exposing only the first left no way to tell a quiet addition happened.
59
+ it("reports both thread activity timestamps", async () => {
60
+ const { app, services } = createTestApp({ authenticated: false });
61
+ app.route("/api/public/posts", publicPostsApiRoutes);
62
+
63
+ const root = await services.posts.create({
64
+ format: "note",
65
+ title: "Quietly extended",
66
+ bodyMarkdown: "root",
67
+ publishedAt: 1000,
68
+ });
69
+ await services.posts.create({
70
+ format: "note",
71
+ bodyMarkdown: "quiet addendum",
72
+ replyToId: root.id,
73
+ publishedAt: 3000,
74
+ quietReply: true,
75
+ });
76
+
77
+ const res = await app.request("/api/public/posts");
78
+ expect(res.status).toBe(200);
79
+ const body = (await res.json()) as {
80
+ posts: {
81
+ title: string | null;
82
+ quietReply: boolean;
83
+ lastActivityAt: number;
84
+ threadUpdatedAt: number;
85
+ }[];
86
+ };
87
+
88
+ const entry = body.posts.find((p) => p.title === "Quietly extended");
89
+ expect(entry?.lastActivityAt).toBe(1000);
90
+ expect(entry?.threadUpdatedAt).toBe(3000);
91
+ expect(entry?.quietReply).toBe(false);
92
+ });
93
+
56
94
  it("supports format and limit filters", async () => {
57
95
  const { app, services } = createTestApp({ authenticated: false });
58
96
  app.route("/api/public/posts", publicPostsApiRoutes);
@@ -61,10 +61,15 @@ export type PublicPostBaseResponse = {
61
61
  previewImageUrl: string | null;
62
62
  replyToId: string | null;
63
63
  threadId: string;
64
+ /** Reply published without announcing its Thread. Always false on roots. */
65
+ quietReply: boolean;
64
66
  pinnedAt: number | null;
65
67
  featuredAt: number | null;
66
68
  publishedAt: number | null;
69
+ /** Root only: newest post in the Thread, quiet replies excluded. */
67
70
  lastActivityAt: number;
71
+ /** Root only: newest post in the Thread, quiet replies included. */
72
+ threadUpdatedAt: number;
68
73
  createdAt: number;
69
74
  updatedAt: number;
70
75
  attachments: ReturnType<typeof toApiAttachment>[];
@@ -146,10 +151,12 @@ export function toPublicPost(
146
151
  previewImageUrl,
147
152
  replyToId: post.replyToId,
148
153
  threadId: post.threadId,
154
+ quietReply: post.quietReply,
149
155
  pinnedAt: post.pinnedAt,
150
156
  featuredAt: post.featuredAt,
151
157
  publishedAt: post.publishedAt,
152
158
  lastActivityAt: post.lastActivityAt,
159
+ threadUpdatedAt: post.threadUpdatedAt,
153
160
  createdAt: post.createdAt,
154
161
  updatedAt: post.updatedAt,
155
162
  attachments: mediaList.map((media) =>
@@ -211,7 +211,9 @@ composeRoutes.post("/thread", async (c) => {
211
211
  postSchemas.map((data, index) => ({
212
212
  data: {
213
213
  format: data.format,
214
- slug: index === 0 ? data.slug || undefined : undefined,
214
+ // Every post may carry its own permalink; blank means the service
215
+ // generates one (title-derived for the root, random id for replies).
216
+ slug: data.slug || undefined,
215
217
  title:
216
218
  data.format === "quote"
217
219
  ? data.sourceName || undefined
@@ -229,7 +231,9 @@ composeRoutes.post("/thread", async (c) => {
229
231
  collectionIds: index === 0 ? data.collectionIds : undefined,
230
232
  replyToId: index === 0 ? data.replyToId : undefined,
231
233
  quietReply: data.quietReply,
232
- publishedAt: index === 0 ? data.publishedAt : undefined,
234
+ // Replies may carry their own date; when they don't, the service
235
+ // inherits the root's rather than stamping "now".
236
+ publishedAt: data.publishedAt,
233
237
  },
234
238
  attachments: data.attachments,
235
239
  })),