diff --git a/package.json b/package.json index 971cc82c..652d52bc 100644 --- a/package.json +++ b/package.json @@ -86,5 +86,8 @@ "typescript": "^5.9.2", "vitest": "^3.1.4" }, + "optionalDependencies": { + "@seonbi/node": "0.2.2" + }, "packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 269dd9df..56d7ce47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,10 @@ importers: zod: specifier: ^4.3.5 version: 4.3.5 + optionalDependencies: + '@seonbi/node': + specifier: 0.2.2 + version: 0.2.2 devDependencies: '@biomejs/biome': specifier: ^2.4.4 @@ -2191,6 +2195,39 @@ packages: '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 '@opentelemetry/semantic-conventions': ^1.39.0 + '@seonbi/node-darwin-arm64@0.2.2': + resolution: {integrity: sha512-seMwCWrP57tRU+K4q0UrpulUKW6Iydu6CZqOTqul+p/QhI9iQ3RR4yL374QXi3WI56KcwAFQ+gIfl1PWTVX4NA==} + cpu: [arm64] + os: [darwin] + + '@seonbi/node-darwin-x64@0.2.2': + resolution: {integrity: sha512-ujSO6hJC8sJHQdUsn99sVFhlKhwnSr1QFOMWP+ZRy5knLvgX8vGMTTh3oTF8gajOD0BhhdMK5DEIQg31vSancA==} + cpu: [x64] + os: [darwin] + + '@seonbi/node-linux-arm64-gnu@0.2.2': + resolution: {integrity: sha512-cPR+NPTXCr3nK/oOsm9vAA8cYZy7qwe/++qYgJ6L/5BvfyipOSesBaRIVhbBVuXFm6Y2IjRezhX+krIoMrwuxQ==} + cpu: [arm64] + os: [linux] + + '@seonbi/node-linux-x64-gnu@0.2.2': + resolution: {integrity: sha512-LyLe7Jy7ltr0D5C7CKkP7mpUeBsWD1ffAnQhgz1IfQXVTxYYEgYRjKOVaCVGCXVE1SKJCis1cG6mEYCehfYpQw==} + cpu: [x64] + os: [linux] + + '@seonbi/node-linux-x64-musl@0.2.2': + resolution: {integrity: sha512-gBIYQAB9g0SHvVJ1y9YAvZPGRYKSM6Ll0JG0cYz9VYY4WjjxUpWuqJac/K0fcEvSP46VLXU/WjUjBX74bBLK3A==} + cpu: [x64] + os: [linux] + + '@seonbi/node-win32-x64-msvc@0.2.2': + resolution: {integrity: sha512-SHxLaMj/FUAJMmBY4a2oPeCD4uKQ2vKNSNdCCwY+/Q8nRVcPPqtui7owf+7/mJhu6SxXVSBqujOxsrlWvfVgWw==} + cpu: [x64] + os: [win32] + + '@seonbi/node@0.2.2': + resolution: {integrity: sha512-FpsjHw71LUGrss7DwJXcePQPdN8UU4qZi5On67K/LFNYn8tRbdjbh4tlK1rKdx3BwS1m0GGNUMxwpdZ5Dwsi/Q==} + '@shikijs/core@3.22.0': resolution: {integrity: sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA==} @@ -7671,6 +7708,34 @@ snapshots: '@opentelemetry/semantic-conventions': 1.39.0 '@sentry/core': 10.39.0 + '@seonbi/node-darwin-arm64@0.2.2': + optional: true + + '@seonbi/node-darwin-x64@0.2.2': + optional: true + + '@seonbi/node-linux-arm64-gnu@0.2.2': + optional: true + + '@seonbi/node-linux-x64-gnu@0.2.2': + optional: true + + '@seonbi/node-linux-x64-musl@0.2.2': + optional: true + + '@seonbi/node-win32-x64-msvc@0.2.2': + optional: true + + '@seonbi/node@0.2.2': + optionalDependencies: + '@seonbi/node-darwin-arm64': 0.2.2 + '@seonbi/node-darwin-x64': 0.2.2 + '@seonbi/node-linux-arm64-gnu': 0.2.2 + '@seonbi/node-linux-x64-gnu': 0.2.2 + '@seonbi/node-linux-x64-musl': 0.2.2 + '@seonbi/node-win32-x64-msvc': 0.2.2 + optional: true + '@shikijs/core@3.22.0': dependencies: '@shikijs/types': 3.22.0 diff --git a/src/text.ts b/src/text.ts index da784624..2b82c43f 100644 --- a/src/text.ts +++ b/src/text.ts @@ -262,8 +262,106 @@ export function extractText(html: string | null): string | null { return $(":root").text(); } -// biome-ignore lint/complexity/useLiteralKeys: tsc claims about this -const SEONBI_URL = process.env["SEONBI_URL"]; +export type PostContentTransformer = (html: string) => Promise; + +const koPostContentTransformer = await determineKoPostContentTransformer(); + +async function determineKoPostContentTransformer(): Promise { + const SEONBI_NATIVE = + process.env.SEONBI_NATIVE?.trim()?.toLowerCase() === "true"; + + // biome-ignore lint/complexity/useLiteralKeys: tsc claims about this + const SEONBI_URL = process.env["SEONBI_URL"]; + + const SEONBI_CONFIGURATION = { + contentType: "text/html", + quote: "HorizontalCornerBrackets", + cite: "AngleQuotes", + arrow: { + bidirArrow: true, + doubleArrow: true, + }, + ellipsis: true, + emDash: true, + stop: "Horizontal", + hanja: { + rendering: "HanjaInRuby", + reading: { + initialSoundLaw: true, + useDictionaries: ["kr-stdict"], + dictionary: {}, + }, + }, + } satisfies import("@seonbi/node").Configuration; + + if (SEONBI_NATIVE) { + try { + const { transform } = await import("@seonbi/node"); + return async (html: string) => { + try { + return transform(SEONBI_CONFIGURATION, html); + } catch (error) { + logger.error( + "Failed to format post content with Seonbi native: {error}", + { + error, + }, + ); + return html; + } + }; + } catch { + logger.error( + "SEONBI_NATIVE is enabled but @seonbi/node is not installed", + ); + } + } + + if (SEONBI_URL != null) { + return async (html: string): Promise => { + const response = await fetch(SEONBI_URL, { + method: "POST", + body: JSON.stringify({ + ...SEONBI_CONFIGURATION, + content: html, + }), + }); + try { + const seonbiResult = await response.json(); + if (seonbiResult.success) { + if ( + Array.isArray(seonbiResult.warnings) && + seonbiResult.warnings.length > 0 + ) { + logger.warn("Seonbi warnings: {warnings}", { + warnings: seonbiResult.warnings, + }); + } + return seonbiResult.content; + } + logger.error("Seonbi failed to format post content: {message}", { + message: seonbiResult.message, + }); + } catch (error) { + logger.error("Failed to format post content with Seonbi: {error}", { + error, + }); + } + return html; + }; + } + + return async (html: string) => html; +} + +function getPostContentTransformer( + language: string | null | undefined, +): PostContentTransformer | null { + if (language === "ko" || language?.startsWith("ko-")) { + return koPostContentTransformer; + } + return null; +} export async function formatPostContent( db: PgDatabase< @@ -280,56 +378,9 @@ export async function formatPostContent( }, ): Promise { const result = await formatText(db, text, options); - if ( - SEONBI_URL != null && - (language === "ko" || language?.startsWith("ko-")) - ) { - const response = await fetch(SEONBI_URL, { - method: "POST", - body: JSON.stringify({ - content: result.html, - contentType: "text/html", - quote: "HorizontalCornerBrackets", - cite: "AngleQuotes", - arrow: { - bidirArrow: true, - doubleArrow: true, - }, - ellipsis: true, - emDash: true, - stop: "Horizontal", - hanja: { - rendering: "HanjaInRuby", - reading: { - initialSoundLaw: true, - useDictionaries: ["kr-stdict"], - dictionary: {}, - }, - }, - }), - }); - try { - const seonbiResult = await response.json(); - if (seonbiResult.success) { - result.html = seonbiResult.content; - if ( - Array.isArray(seonbiResult.warnings) && - seonbiResult.warnings.length > 0 - ) { - logger.warn("Seonbi warnings: {warnings}", { - warnings: seonbiResult.warnings, - }); - } - } else { - logger.error("Seonbi failed to format post content: {message}", { - message: seonbiResult.message, - }); - } - } catch (error) { - logger.error("Failed to format post content with Seonbi: {error}", { - error, - }); - } + const transformer = getPostContentTransformer(language); + if (transformer != null) { + result.html = await transformer(result.html); } return result; }