Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 121 additions & 3 deletions content/posts/ko/odin-ax-transformation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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시간 내 답글이 관측되지 않은 요청은 중앙값에서 제외했으며, 분석 완료나 문제 해결까지 걸린 시간을 뜻하지 않는다.*

Expand Down
41 changes: 41 additions & 0 deletions docs/markdown-charts.md
Original file line number Diff line number Diff line change
@@ -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 파일은 기존 공유 링크의 호환성을 위해 보존하지만 오딘 본문에서는 사용하지 않는다.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions scripts/loading.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<figure\b[^>]*data-markdown-chart[^>]*>([\s\S]*?)<\/figure>/g)]
assert.equal(figures.length, 2)
for (const figure of figures) {
assert.match(figure[1], /<table/)
assert.ok(!figure[1].includes("<pre"))
assert.ok(!figure[1].includes("<svg"))
}
assert.match(figures[0][1], /22:01 KST/)
assert.match(figures[1][1], /35\.4/)
assert.match(figures[1][1], /29\/34/)
assert.match(figures[1][1], /70\/75/)
})
72 changes: 72 additions & 0 deletions scripts/markdown-chart.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import assert from "node:assert/strict"
import fs from "node:fs"
import test from "node:test"
import { createElement } from "react"
import { parseMarkdownChart, formatChartValue } from "../src/lib/markdown-chart.ts"
import { readMarkdownCode } from "../src/lib/markdown-code.ts"

const fixture = () => ({
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<br/>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"))
})
27 changes: 6 additions & 21 deletions src/components/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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 "<br/>"
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 <MermaidBlock code={code} />
}

if (language === "benchmark") {
return <BenchmarkChart data={code} />
return <Suspense fallback={<pre {...props}>{children}</pre>}><BenchmarkChart data={code} /></Suspense>
}

return <ShikiBlock code={code} language={language} preProps={props}>{children}</ShikiBlock>
Expand Down
Loading
Loading