@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.
- package/bin/commands/import-site.js +7 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
- package/dist/client/.vite/manifest.json +8 -8
- package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
- package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
- package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
- package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
- package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +1 -1
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/client/__tests__/compose-bridge.test.ts +252 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/toast.test.ts +65 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
- package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +47 -3
- package/src/client/components/jant-command-palette.ts +12 -6
- package/src/client/components/jant-compose-dialog.ts +1464 -680
- package/src/client/components/jant-compose-editor.ts +225 -93
- package/src/client/components/jant-compose-fullscreen.ts +3 -5
- package/src/client/components/jant-post-menu.ts +91 -21
- package/src/client/compose-bridge.ts +143 -122
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/post-refresh.ts +135 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/toast.ts +130 -38
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +18 -0
- package/src/db/schema.ts +20 -0
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/locales/public/en.po +89 -21
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +88 -20
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +88 -20
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/hugo-markdown.ts +5 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/post-display.ts +42 -9
- package/src/lib/timeline.ts +39 -9
- package/src/lib/tiptap-render.ts +4 -2
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +28 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/posts.ts +13 -9
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/posts.ts +7 -0
- package/src/routes/compose.tsx +6 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/archive.tsx +65 -17
- package/src/routes/pages/page.tsx +5 -2
- package/src/services/__tests__/collection.test.ts +32 -1
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +277 -0
- package/src/services/collection.ts +8 -10
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export.ts +3 -5
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +297 -100
- package/src/services/search.ts +9 -0
- package/src/styles/components.css +0 -14
- package/src/styles/site-media.css +4 -4
- package/src/styles/tokens.css +62 -1
- package/src/styles/ui.css +1363 -1283
- package/src/types/entities.ts +5 -0
- package/src/types/props.ts +12 -0
- package/src/types/views.ts +6 -0
- package/src/ui/compose/ComposeDialog.tsx +17 -17
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/PostFooter.tsx +35 -12
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/post-article-attributes.ts +6 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- 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
|
-
*
|
|
140
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
});
|
package/src/routes/api/posts.ts
CHANGED
|
@@ -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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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) =>
|
package/src/routes/compose.tsx
CHANGED
|
@@ -211,7 +211,9 @@ composeRoutes.post("/thread", async (c) => {
|
|
|
211
211
|
postSchemas.map((data, index) => ({
|
|
212
212
|
data: {
|
|
213
213
|
format: data.format,
|
|
214
|
-
|
|
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
|
-
|
|
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
|
})),
|