From cae283ab684750c472a0670c80ec6c5d78e2aaa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Tue, 22 Sep 2026 22:49:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(blog):=20Markdown=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B0=A8=ED=8A=B8=EB=A5=BC=20shadcn=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 1 + content/posts/ko/odin-ax-transformation.md | 124 ++++++++++++++++++++- docs/markdown-charts.md | 41 +++++++ package.json | 1 + scripts/loading.test.mjs | 20 ++++ scripts/markdown-chart.test.mjs | 72 ++++++++++++ src/components/CodeBlock.tsx | 27 +---- src/components/MarkdownChart.tsx | 108 ++++++++++++++++++ src/components/MarkdownChartPlot.tsx | 79 +++++++++++++ src/i18n/translations.ts | 24 ++++ src/index.css | 10 ++ src/lib/markdown-chart.ts | 89 +++++++++++++++ src/lib/markdown-code.ts | 20 ++++ src/pages/PostPage.tsx | 8 ++ 14 files changed, 600 insertions(+), 24 deletions(-) create mode 100644 docs/markdown-charts.md create mode 100644 scripts/markdown-chart.test.mjs create mode 100644 src/components/MarkdownChart.tsx create mode 100644 src/components/MarkdownChartPlot.tsx create mode 100644 src/lib/markdown-chart.ts create mode 100644 src/lib/markdown-code.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f24d9b..3320fbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - run: pnpm lint - run: pnpm type-check - run: pnpm test:post-dates + - run: pnpm test:charts - run: pnpm build - run: pnpm test:seo - run: pnpm test:loading diff --git a/content/posts/ko/odin-ax-transformation.md b/content/posts/ko/odin-ax-transformation.md index 3c9b802..7043e1f 100644 --- a/content/posts/ko/odin-ax-transformation.md +++ b/content/posts/ko/odin-ax-transformation.md @@ -251,15 +251,133 @@ App Server 클라이언트는 사용한 Codex CLI 버전의 계약에 맞춰 구 먼저 달라진 것은 요청 게시글의 호출 대상이었다. 도입 전에는 34개 중 32개가 개발자를 태그했다. 도입 후에는 75개 중 50개가 오딘만 태그했고, 개발자 태그가 포함된 게시글은 동시 호출을 합쳐 22개였다. **이 두 채널의 도입 후 요청 중 66.7%가 오딘 단독 호출이었다.** -[![주차별 요청 게시글에서 개발자와 오딘을 호출한 스레드 수](/images/odin-ax/06-odin-request-routing-metrics.png)](https://dev.devy.dev/images/odin-ax/06-odin-request-routing-metrics.png) +```chart +{ + "type": "stacked-bar", + "title": "요청 게시글의 호출 대상이 달라졌다", + "description": "두 업무 채널 · 주차별 요청 스레드 수 · 게시글 본문의 멘션 기준", + "categoryLabel": "주 시작일", + "unit": "개", + "series": [ + { + "key": "developer", + "label": "개발자", + "color": "chart-1" + }, + { + "key": "odin", + "label": "오딘", + "color": "chart-2" + }, + { + "key": "both", + "label": "동시 호출", + "color": "chart-3" + }, + { + "key": "none", + "label": "명시 호출 없음", + "color": "chart-4" + } + ], + "data": [ + { + "label": "8/10", + "note": "도입 전", + "developer": 23, + "odin": 0, + "both": 0, + "none": 2 + }, + { + "label": "8/17", + "note": "도입 전", + "developer": 9, + "odin": 0, + "both": 0, + "none": 0 + }, + { + "label": "8/24", + "note": "전환기", + "developer": 41, + "odin": 0, + "both": 0, + "none": 4 + }, + { + "label": "8/31", + "note": "전환기", + "developer": 43, + "odin": 0, + "both": 0, + "none": 2 + }, + { + "label": "9/7", + "note": "도입 후", + "developer": 12, + "odin": 16, + "both": 1, + "none": 3 + }, + { + "label": "9/14", + "note": "도입 후", + "developer": 7, + "odin": 34, + "both": 2, + "none": 0 + }, + { + "label": "9/21", + "partial": true, + "note": "9/22 22:01 KST까지 · 진행 중인 주", + "developer": 6, + "odin": 11, + "both": 1, + "none": 0 + } + ] +} +``` -*조회 당시 게시글 본문의 멘션을 기준으로 분류했다. 같은 스레드에서 여러 번 부른 것은 합산하지 않았다. 사선으로 표시한 마지막 주는 9월 22일 22:01까지의 진행 중 집계다. 그래프를 누르면 크게 볼 수 있다.* +*조회 당시 게시글 본문의 멘션을 기준으로 분류했다. 같은 스레드에서 여러 번 부른 것은 합산하지 않았다. 사선으로 표시한 마지막 주는 9월 22일 22:01까지의 진행 중 집계다. 표 보기에서 항목별 수치를 확인할 수 있다.* 최근 기록에서도 9월 21–22일의 요청 18개 중 11개는 오딘만 태그했다. 이 구간은 아직 진행 중이어서 완료된 2주 단위의 전후 비교에는 섞지 않았다. 다음으로 요청을 남긴 뒤 첫 답글이 달리기까지의 시간을 봤다. 24시간 안에 개발자나 오딘의 첫 텍스트 답글이 관측된 요청의 대기 중앙값은 **35.4분에서 3.4분**으로 짧아졌다. 관측된 요청은 도입 전 34개 중 29개, 도입 후 75개 중 70개였다. -[![도입 전후 첫 담당자 텍스트 답글까지의 대기 중앙값 비교](/images/odin-ax/07-odin-first-reply-metrics.png)](https://dev.devy.dev/images/odin-ax/07-odin-first-reply-metrics.png) +```chart +{ + "type": "bar", + "orientation": "horizontal", + "title": "첫 담당자 답글까지의 대기", + "description": "24시간 내 첫 텍스트 답글이 관측된 요청의 중앙값", + "categoryLabel": "비교 기간", + "unit": "분", + "decimals": 1, + "series": [ + { + "key": "minutes", + "label": "대기 중앙값", + "color": "chart-1" + } + ], + "data": [ + { + "label": "도입 전", + "minutes": 35.404, + "note": "8/10–23 · 24시간 내 관측 29/34건" + }, + { + "label": "도입 후", + "minutes": 3.4438, + "note": "9/7–20 · 24시간 내 관측 70/75건" + } + ] +} +``` *요청자 본인의 답글을 제외하고, 단순 접수 답변도 포함했다. 편집된 메시지도 최초 생성 시각을 사용한다. 24시간 내 답글이 관측되지 않은 요청은 중앙값에서 제외했으며, 분석 완료나 문제 해결까지 걸린 시간을 뜻하지 않는다.* diff --git a/docs/markdown-charts.md b/docs/markdown-charts.md new file mode 100644 index 0000000..8f26350 --- /dev/null +++ b/docs/markdown-charts.md @@ -0,0 +1,41 @@ +# Markdown 차트 + +글의 `chart` 코드 블록에 JSON을 작성한다. shadcn의 보기 전환·차트 컨테이너·툴팁과 Recharts로 표시한다. + +```chart +{ + "type": "bar", + "orientation": "horizontal", + "title": "첫 담당자 답글까지의 대기", + "description": "24시간 내 첫 텍스트 답글이 관측된 요청의 중앙값", + "categoryLabel": "비교 기간", + "unit": "분", + "decimals": 1, + "series": [{ "key": "minutes", "label": "대기 중앙값", "color": "chart-1" }], + "data": [ + { "label": "도입 전", "minutes": 35.4, "note": "8/10–23 · 관측 29/34건" }, + { "label": "도입 후", "minutes": 3.4, "note": "9/7–20 · 관측 70/75건" } + ] +} +``` + +## 작성 규칙 + +- `type`: `bar` 또는 `stacked-bar`. `orientation`: `vertical`(기본) 또는 `horizontal`. +- 제목, 범주 축 이름(`categoryLabel`), 단위, 계열과 데이터를 필수로 작성한다. `description`은 범위·기간·정의 설명이다. +- 계열은 1–5개, 행은 1–60개다. 계열 key는 영문 소문자로 시작하는 영숫자 32자 이하이며 중복·예약어를 허용하지 않는다. 계열 key마다 모든 행에 유한한 0 이상의 숫자가 있어야 한다. 없는 값은 0으로 대체하지 않는다. +- 행 `label`은 중복될 수 없다. `note`는 표와 툴팁에 표시된다. `partial: true`는 이름의 별표와 사선 무늬로 진행 중인 기간을 구분한다. 기간 차이와 제외 조건도 본문에 설명한다. +- 색상은 테마 토큰 `chart-1`~`chart-5`만 허용한다. 생략하면 계열 순서대로 지정된다. 임의 CSS·HTML·JavaScript는 실행하지 않는다. +- `decimals`는 0–3(기본 0)으로 표시 반올림만 정한다. JSON에는 검증한 원래 수치를 보관한다. +- 단순 접수 답글 시간과 문제 해결 시간처럼 의미가 다른 지표를 혼용하지 않는다. 표본·분모·관측 기준은 `note`와 본문에 보존한다. + +## 렌더링과 확인 기준 + +- 서버 HTML과 최초 hydration에는 제목·단위·수치 표가 동일하게 렌더된다. 검색엔진과 스크립트를 사용할 수 없는 환경도 값을 읽을 수 있다. +- 기본 선택은 차트다. 화면 200px 근처에 도달했을 때 차트 모듈을 가져온다. 차트 로딩 중·실패 시 표를 유지하고 실패 시 재시도 버튼을 제공한다. +- `표`·`차트` 전환은 shadcn ToggleGroup을 사용한다. 키보드로 조작 가능하며 툴팁을 사용하지 않아도 표에서 모든 수치와 조건을 확인할 수 있다. +- 막대 축은 0부터 시작한다. 계열 색은 라이트·다크 모드에 맞춰 바뀐다. 불완전 주차의 별표·사선 표시는 표와 차트 모두 유지한다. +- 초기 차트 애니메이션은 사용하지 않는다. 차트와 표는 좁은 본문 너비에 맞춰 표시하며 넓은 표만 자체 가로 스크롤을 허용한다. +- Markdown/데이터 파서 검사, SSR 수치 보존, 빌드의 지연 로딩 경계, 실제 Chrome의 데스크톱·390px 화면·라이트/다크·표 전환·툴팁·기존 benchmark/Mermaid를 검증한다. + +기존 `benchmark` 블록의 형식과 표 우선 동작은 유지한다. 차트 PNG 파일은 기존 공유 링크의 호환성을 위해 보존하지만 오딘 본문에서는 사용하지 않는다. diff --git a/package.json b/package.json index b1dc81f..489c01b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "type-check": "tsc --noEmit", "check:internal-links": "node scripts/check-internal-post-links.mjs", "test:post-dates": "node --test scripts/post-dates.test.mjs", + "test:charts": "node --test scripts/markdown-chart.test.mjs", "test:loading": "node --test scripts/async-loader.test.mjs scripts/loading.test.mjs", "test:seo": "node --test scripts/seo.test.mjs", "test:sitemap-lastmod": "node --test scripts/sitemap-lastmod.test.mjs" diff --git a/scripts/loading.test.mjs b/scripts/loading.test.mjs index 969ba3c..f6759bb 100644 --- a/scripts/loading.test.mjs +++ b/scripts/loading.test.mjs @@ -55,3 +55,23 @@ test("prerendered article keeps its body while analytics has stable chart placeh assert.match(analytics, /차트를 불러오는 중/) assert.ok(!analytics.includes('id="S:')) }) + +test("Markdown charts are server-rendered as data tables and their renderer stays out of the initial import graph", () => { + for (const entry of ["index.html", "src/pages/PostPage.tsx", "src/components/CodeBlock.tsx"]) { + assert.deepEqual([...staticImports(entry)].filter((key) => /MarkdownChartPlot|BenchmarkChart|_chart-/.test(key)), []) + } + assert.ok(manifest["src/components/MarkdownChartPlot.tsx"]?.isDynamicEntry) + assert.ok(manifest["src/components/BenchmarkChart.tsx"]?.isDynamicEntry) + const article = fs.readFileSync(new URL("../dist/posts/odin-ax-transformation/index.html", import.meta.url), "utf8") + const figures = [...article.matchAll(/]*data-markdown-chart[^>]*>([\s\S]*?)<\/figure>/g)] + assert.equal(figures.length, 2) + for (const figure of figures) { + assert.match(figure[1], / ({ + type: "bar", title: "Response time", categoryLabel: "Period", unit: "min", decimals: 1, + series: [{ key: "minutes", label: "Median" }], + data: [{ label: "Before", minutes: 35.404, note: "29/34 observed" }, { label: "After", minutes: 0, partial: true }], +}) +const parse = (value) => parseMarkdownChart(JSON.stringify(value)) + +test("preserves values, zero, sample notes and partial-period markers", () => { + const result = parse(fixture()) + assert.equal(result.rows[0].values.minutes, 35.404) + assert.equal(result.rows[0].note, "29/34 observed") + assert.equal(result.rows[1].values.minutes, 0) + assert.equal(result.rows[1].partial, true) + assert.equal(formatChartValue(result.rows[0].values.minutes, result.decimals), "35.4") + assert.equal(formatChartValue(0, 1), "0") +}) + +test("rejects missing, nonnumeric, negative and nonfinite observations instead of replacing them with zero", () => { + for (const value of [undefined, null, "3.4", -1, [], {}]) { + const spec = fixture(); spec.data[0].minutes = value + assert.equal(parse(spec), null) + } + assert.equal(parseMarkdownChart(JSON.stringify(fixture()).replace("35.404", "1e400")), null) +}) + +test("rejects duplicate categories, series, reserved keys and CSS injection", () => { + const duplicate = fixture(); duplicate.data[1].label = "Before" + assert.equal(parse(duplicate), null) + const duplicateSeries = fixture(); duplicateSeries.series.push(duplicateSeries.series[0]) + assert.equal(parse(duplicateSeries), null) + for (const key of ["__proto__", "constructor", "label", "total", "x;body{color:red}"]) { + const spec = fixture(); spec.series[0].key = key + assert.equal(parse(spec), null) + } + const style = fixture(); style.series[0].color = "red;}body{display:none}" + assert.equal(parse(style), null) +}) + +test("handles invalid configuration without throwing or accepting excessive payloads", () => { + for (const value of [null, [], {}, { ...fixture(), type: "pie" }, { ...fixture(), decimals: 1.5 }, { ...fixture(), data: [] }]) assert.equal(parse(value), null) + assert.equal(parseMarkdownChart("not JSON"), null) + assert.equal(parseMarkdownChart(" ".repeat(60_001)), null) +}) + +test("Markdown code extraction retains Mermaid line breaks and distinguishes chart blocks", () => { + const code = createElement("code", { className: "language-mermaid" }, ["A[one", createElement("br", { key: "br" }), "two]\n"]) + assert.deepEqual(readMarkdownCode(code), { language: "mermaid", code: "A[one
two]" }) + assert.deepEqual(readMarkdownCode(createElement("code", { className: "language-chart" }, "{}\n")), { language: "chart", code: "{}" }) +}) + +test("Odin's interactive charts retain the published cohort counts and observation conditions", () => { + const post = fs.readFileSync(new URL("../content/posts/ko/odin-ax-transformation.md", import.meta.url), "utf8") + const specs = [...post.matchAll(/```chart\n([\s\S]*?)\n```/g)].map((m) => parseMarkdownChart(m[1])) + assert.equal(specs.length, 2) + const [routing, reply] = specs + assert.ok(routing && reply) + assert.deepEqual(routing.rows.map((r) => Object.values(r.values).reduce((a, b) => a + b, 0)), [25, 9, 45, 45, 32, 43, 18]) + assert.equal(routing.rows[4].values.odin + routing.rows[5].values.odin, 50) + assert.equal(routing.rows.filter((r) => r.partial).length, 1) + assert.match(routing.rows[6].note, /9\/22 22:01 KST/) + assert.deepEqual(reply.rows.map((r) => formatChartValue(r.values.minutes, reply.decimals)), ["35.4", "3.4"]) + assert.match(reply.rows[0].note, /29\/34/) + assert.match(reply.rows[1].note, /70\/75/) + assert.ok(!post.includes("-metrics.png")) +}) diff --git a/src/components/CodeBlock.tsx b/src/components/CodeBlock.tsx index 0a0fd4f..952ac1f 100644 --- a/src/components/CodeBlock.tsx +++ b/src/components/CodeBlock.tsx @@ -1,10 +1,12 @@ -import { type ComponentPropsWithoutRef, useCallback, useEffect, useId, useRef, useState } from "react" +import { lazy, Suspense, type ComponentPropsWithoutRef, useCallback, useEffect, useId, useRef, useState } from "react" import { Copy, Check } from "lucide-react" import { Button } from "@/components/ui/button" import { useT } from "@/i18n" import { useTheme } from "@/hooks/useTheme" import mermaid from "mermaid" -import { BenchmarkChart } from "@/components/BenchmarkChart" +import { readMarkdownCode } from "@/lib/markdown-code" + +const BenchmarkChart = lazy(() => import("@/components/BenchmarkChart").then((module) => ({ default: module.BenchmarkChart }))) function getCssHex(varName: string): string { const temp = document.createElement("div") @@ -301,32 +303,15 @@ function ShikiBlock({ code, language, children, preProps }: { code: string; lang ) } -function extractCode(node: React.ReactNode): string { - if (typeof node === "string") return node - if (typeof node === "number") return String(node) - if (!node) return "" - if (Array.isArray(node)) return node.map(extractCode).join("") - if (typeof node === "object" && "type" in node) { - const el = node as React.ReactElement<{ children?: React.ReactNode }> - if ((el.type as string) === "br" || el.type === "br") return "
" - return extractCode(el.props?.children) - } - return "" -} - export function CodeBlock({ children, ...props }: ComponentPropsWithoutRef<"pre">) { - const codeEl = children as React.ReactElement<{ className?: string; children?: React.ReactNode }> - const className = codeEl?.props?.className || "" - const match = className.match(/language-(\w+)/) - const language = match?.[1] ?? "" - const code = extractCode(codeEl?.props?.children).replace(/\n$/, "") + const { language, code } = readMarkdownCode(children) if (language === "mermaid") { return } if (language === "benchmark") { - return + return {children}}> } return {children} diff --git a/src/components/MarkdownChart.tsx b/src/components/MarkdownChart.tsx new file mode 100644 index 0000000..02e75df --- /dev/null +++ b/src/components/MarkdownChart.tsx @@ -0,0 +1,108 @@ +import { useEffect, useId, useMemo, useRef, useState } from "react" +import { Button } from "@/components/ui/button" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { useLanguage, useT } from "@/i18n" +import { useDeferredModule } from "@/hooks/useDeferredModule" +import { createRetryableLoader } from "@/lib/async-loader" +import { formatChartValue, parseMarkdownChart, type MarkdownChartSpec } from "@/lib/markdown-chart" + +const loadChart = createRetryableLoader(() => import("@/components/MarkdownChartPlot")) + +function DataTable({ spec }: { spec: MarkdownChartSpec }) { + const { language, t } = useLanguage() + const hasNotes = spec.rows.some((row) => row.note) + return ( +
+
+ + + + + {spec.series.map((series) => )} + {hasNotes && } + + + + {spec.rows.map((row) => ( + + + {spec.series.map((series) => )} + {hasNotes && } + + ))} + +
{spec.title} · {spec.unit}
{spec.categoryLabel}{series.label}{t.components.chartNote}
+ {row.label}{row.partial ? "*" : ""} + {row.partial && · {t.components.chartPartial}} + {formatChartValue(row.values[series.key]!, spec.decimals, language)}{row.note ?? "—"}
+ + ) +} + +function DeferredPlot({ spec }: { spec: MarkdownChartSpec }) { + const { value: module, error, retry } = useDeferredModule(loadChart) + const t = useT() + if (module) return + return ( + <> +
+

{error ? t.common.chartLoadError : t.common.chartLoading}

+ {error && } +
+ + + ) +} + +/** The server and first hydration both render an ordinary data table. */ +export function MarkdownChart({ source }: { source: string }) { + const spec = useMemo(() => parseMarkdownChart(source), [source]) + const [view, setView] = useState("chart") + const [nearby, setNearby] = useState(false) + const container = useRef(null) + const id = useId() + const t = useT() + + useEffect(() => { + const element = container.current + if (!element) return + let active = true + if (typeof IntersectionObserver === "undefined") { + void Promise.resolve().then(() => { if (active) setNearby(true) }) + return () => { active = false } + } + const observer = new IntersectionObserver(([entry]) => { + if (entry?.isIntersecting) { + setNearby(true) + observer.disconnect() + } + }, { rootMargin: "200px" }) + observer.observe(element) + return () => { active = false; observer.disconnect() } + }, []) + + if (!spec) return

{t.components.chartDataError}

+ + return ( +
+
+
+

{spec.title}

+ { + if (!value) return + setView(value) + if (value === "chart") setNearby(true) + }}> + {t.components.chartView} + {t.components.chartTable} + +
+ {spec.description &&

{spec.description}

} +

{t.components.chartUnit}: {spec.unit}

+
+
+ {view === "chart" && nearby ? : } +
+
+ ) +} diff --git a/src/components/MarkdownChartPlot.tsx b/src/components/MarkdownChartPlot.tsx new file mode 100644 index 0000000..0097d29 --- /dev/null +++ b/src/components/MarkdownChartPlot.tsx @@ -0,0 +1,79 @@ +import { useId } from "react" +import { Bar, BarChart, CartesianGrid, Cell, LabelList, XAxis, YAxis } from "recharts" +import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart" +import { useLanguage } from "@/i18n" +import { formatChartValue, type MarkdownChartSpec } from "@/lib/markdown-chart" + +export function MarkdownChartPlot({ spec }: { spec: MarkdownChartSpec }) { + const id = useId().replace(/[^a-zA-Z0-9_-]/g, "") + const { language, t } = useLanguage() + const horizontal = spec.orientation === "horizontal" + const stacked = spec.type === "stacked-bar" + const data = spec.rows.map((row) => ({ + label: row.label, + displayLabel: `${row.label}${row.partial ? "*" : ""}`, + note: row.note, partial: row.partial, + ...row.values, + total: Object.values(row.values).reduce((sum, value) => sum + value, 0), + })) + const config: ChartConfig = Object.fromEntries(spec.series.map((series) => [series.key, { label: series.label, color: `var(--${series.color})` }])) + const format = (value: number) => formatChartValue(value, spec.decimals, language) + const height = horizontal ? Math.max(240, data.length * 64 + 60) : 300 + + return ( +
+ + + + {spec.series.map((series) => ( + + + + + ))} + + + + 0} + tickLine={false} axisLine={false} tickMargin={8} + /> + { + const row = payload?.[0]?.payload as (typeof data)[number] | undefined + return row ?

{row.displayLabel}{row.partial ? ` · ${t.components.chartPartial}` : ""}

{row.note &&

{row.note}

}
: null + }} formatter={(value, name, item) => ( +
+ {config[String(name)]?.label ?? String(name)} + {format(Number(value))}{spec.unit} +
+ )} /> + } /> + {spec.series.map((series, index) => ( + + {data.map((row) => )} + {(!stacked || index === spec.series.length - 1) && ( + `${format(Number(value))}${horizontal ? spec.unit : ""}`} /> + )} + + ))} +
+
+ {spec.series.length > 1 &&
    + {spec.series.map((series) =>
  • )} +
} +
+ ) +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 2d73284..57416f8 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -208,6 +208,14 @@ export interface Translations { last30Days: string tableOfContents: string copyCode: string + chartView: string + chartTable: string + chartViewLabel: string + chartUnit: string + chartNote: string + chartPartial: string + chartLegend: string + chartDataError: string selectDate: string scrollToTop: string toggleLanguage: string @@ -436,6 +444,14 @@ export const ko: Translations = { last30Days: "최근 30일", tableOfContents: "목차", copyCode: "코드 복사", + chartView: "차트", + chartTable: "표", + chartViewLabel: "보기 방식", + chartUnit: "단위", + chartNote: "집계 조건", + chartPartial: "진행 중", + chartLegend: "범례", + chartDataError: "차트 데이터를 표시할 수 없습니다.", selectDate: "날짜 선택", scrollToTop: "맨 위로", toggleLanguage: "언어 변경", @@ -664,6 +680,14 @@ export const en: Translations = { last30Days: "Last 30 Days", tableOfContents: "Table of Contents", copyCode: "Copy code", + chartView: "Chart", + chartTable: "Table", + chartViewLabel: "View", + chartUnit: "Unit", + chartNote: "Measurement notes", + chartPartial: "Partial period", + chartLegend: "Legend", + chartDataError: "The chart data could not be displayed.", selectDate: "Select date", scrollToTop: "Scroll to top", toggleLanguage: "Toggle language", diff --git a/src/index.css b/src/index.css index 0df6170..cb727cb 100644 --- a/src/index.css +++ b/src/index.css @@ -25,6 +25,11 @@ --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); --radius: 0.625rem; + --chart-1: #4263a6; + --chart-2: #bb750c; + --chart-3: #8558ae; + --chart-4: #858e9c; + --chart-5: #598049; --sidebar: hsl(0 0% 98%); --sidebar-foreground: hsl(240 5.3% 26.1%); --sidebar-primary: hsl(240 5.9% 10%); @@ -36,6 +41,11 @@ } .dark { + --chart-1: #8aa9ed; + --chart-2: #efb253; + --chart-3: #bc98e2; + --chart-4: #a5aebc; + --chart-5: #9cbf85; --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.145 0 0); diff --git a/src/lib/markdown-chart.ts b/src/lib/markdown-chart.ts new file mode 100644 index 0000000..f39d3da --- /dev/null +++ b/src/lib/markdown-chart.ts @@ -0,0 +1,89 @@ +export interface ChartSeries { + key: string + label: string + color: "chart-1" | "chart-2" | "chart-3" | "chart-4" | "chart-5" +} + +export interface ChartRow { + label: string + note?: string + partial: boolean + values: Record +} + +export interface MarkdownChartSpec { + type: "bar" | "stacked-bar" + orientation: "vertical" | "horizontal" + title: string + description?: string + categoryLabel: string + unit: string + decimals: number + series: ChartSeries[] + rows: ChartRow[] +} + +function object(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function text(value: unknown, max = 160): value is string { + return typeof value === "string" && value.trim().length > 0 && value.length <= max +} + +/** Only data is accepted: no executable code, HTML or arbitrary CSS configuration. */ +export function parseMarkdownChart(source: string): MarkdownChartSpec | null { + if (source.length > 60_000) return null + try { + const input: unknown = JSON.parse(source) + if (!object(input) || !["bar", "stacked-bar"].includes(String(input.type))) return null + if (!text(input.title) || !text(input.categoryLabel, 80) || !text(input.unit, 24)) return null + if (input.description !== undefined && !text(input.description, 500)) return null + const orientation = input.orientation ?? "vertical" + if (orientation !== "vertical" && orientation !== "horizontal") return null + const decimals = input.decimals ?? 0 + if (typeof decimals !== "number" || !Number.isInteger(decimals) || decimals < 0 || decimals > 3) return null + if (!Array.isArray(input.series) || input.series.length < 1 || input.series.length > 5) return null + if (!Array.isArray(input.data) || input.data.length < 1 || input.data.length > 60) return null + + const keys = new Set() + const reserved = new Set(["label", "note", "partial", "total", "displayLabel", "constructor", "prototype"]) + const series: ChartSeries[] = [] + for (const [index, item] of input.series.entries()) { + if (!object(item) || !text(item.key, 32) || !/^[a-z][a-zA-Z0-9]*$/.test(item.key)) return null + if (keys.has(item.key) || reserved.has(item.key) || !text(item.label, 80)) return null + const color = item.color ?? `chart-${index + 1}` + if (typeof color !== "string" || !/^chart-[1-5]$/.test(color)) return null + keys.add(item.key) + series.push({ key: item.key, label: item.label, color: color as ChartSeries["color"] }) + } + + const labels = new Set() + const rows: ChartRow[] = [] + for (const item of input.data) { + if (!object(item) || !text(item.label, 80) || labels.has(item.label)) return null + if (item.note !== undefined && !text(item.note, 300)) return null + if (item.partial !== undefined && typeof item.partial !== "boolean") return null + const values: Record = {} + for (const { key } of series) { + const value = item[key] + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) return null + values[key] = value + } + if (!Number.isFinite(Object.values(values).reduce((sum, value) => sum + value, 0))) return null + labels.add(item.label) + rows.push({ label: item.label, note: item.note as string | undefined, partial: item.partial === true, values }) + } + return { + type: input.type as MarkdownChartSpec["type"], orientation, + title: input.title, description: input.description as string | undefined, + categoryLabel: input.categoryLabel, unit: input.unit, decimals, series, rows, + } + } catch { + return null + } +} + +export function formatChartValue(value: number, decimals: number, locale = "ko") { + return value.toLocaleString(locale, { maximumFractionDigits: decimals }) +} diff --git a/src/lib/markdown-code.ts b/src/lib/markdown-code.ts new file mode 100644 index 0000000..001ea49 --- /dev/null +++ b/src/lib/markdown-code.ts @@ -0,0 +1,20 @@ +import type { ReactElement, ReactNode } from "react" + +function extractCode(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") return String(node) + if (!node) return "" + if (Array.isArray(node)) return node.map(extractCode).join("") + if (typeof node === "object" && "type" in node) { + const element = node as ReactElement<{ children?: ReactNode }> + return element.type === "br" ? "
" : extractCode(element.props.children) + } + return "" +} + +export function readMarkdownCode(children: ReactNode) { + const element = children as ReactElement<{ className?: string; children?: ReactNode }> + return { + language: element?.props?.className?.match(/language-([\w-]+)/)?.[1] ?? "", + code: extractCode(element?.props?.children).replace(/\n$/, ""), + } +} diff --git a/src/pages/PostPage.tsx b/src/pages/PostPage.tsx index 47fefaf..1b39b75 100644 --- a/src/pages/PostPage.tsx +++ b/src/pages/PostPage.tsx @@ -48,6 +48,8 @@ import { localizePath, postPath } from "@/lib/i18n-routing" import type { Language } from "@/i18n" import { StructuredData } from "@/components/StructuredData" import { postStructuredData } from "@/lib/structured-data" +import { MarkdownChart } from "@/components/MarkdownChart" +import { readMarkdownCode } from "@/lib/markdown-code" const LazyCodeBlock = lazy(() => import("@/components/CodeBlock").then((module) => ({ default: module.CodeBlock })) @@ -85,6 +87,12 @@ function CodeBlockFallback({ children, ...props }: ComponentPropsWithoutRef<"pre } function MarkdownCodeBlock(props: ComponentPropsWithoutRef<"pre">) { + const { language, code } = readMarkdownCode(props.children) + if (language === "chart") return + return +} + +function HydratedCodeBlock(props: ComponentPropsWithoutRef<"pre">) { const isClient = useSyncExternalStore(subscribeHydration, getClientSnapshot, getServerSnapshot) if (!isClient) {