#!/usr/bin/env node // 글이 얼마나 채워져 있는지 잰다. 문체가 아니라 밀도를 본다. // // node density.mjs 초안.md [...] 기준값과 나란히 보여 준다 // node density.mjs --baseline dir/*.md 기준값을 다시 잰다 // // 기준값은 ../examples 의 여섯 편에서 잰 것이다. 맞히려고 문단을 넣지 않는다 — // 낮게 나오면 설명이 빠진 자리를 찾으라는 뜻이지 분량을 늘리라는 뜻이 아니다. import { readFileSync } from "node:fs"; import { basename } from "node:path"; // examples 의 여섯 편에서 잰 값. 23625 는 본문 일부만 받아서 낱말 수 기준에서 뺐다. const BASE = { words: [1277, 2871], sentWords: [14.2, 18.3], code: [1, 13], sections: [4, 9], tableLines: [0, 15], }; // Tech Log 기록의 본문은 글 한 편이 아니라 그 가운데 토막이다. 문제·결론·검증 환경·재현 조건은 // Studio 칸이 따로 들고 있고, 본문의 `##` 는 「본문」 칸 아래라 층이 한 단 낮다. 그래서 절 수와 // 낱말 수를 절대값으로 재면 잘 쓴 본문이 「절이 모자람」으로 잡힌다. // 대신 밀도로 본다 — 절 하나가 몇 낱말인가, 1,000 낱말에 수치가 몇 개인가. // 여섯 편: 절당 160 ~ 718 낱말 · 측정 글은 1,000 낱말에 10.2 ~ 13.8 개 const BODY = { sentWords: [14.2, 18.3], code: [1, 13], tableLines: [0, 15], wordsPerSection: [160, 718], }; const BODY_NUM_RATE = { 측정: [9, 45], 구조: [0.8, 8] }; // 수치 개수는 장르가 가른다. 잰 것을 쓰는 글과 구조를 쓰는 글의 기준이 다르다. // 측정 글 13569(13) · 23625(18) · 20161(24) · 22396(36) // 구조 글 17386(2) · 7835(5) const NUMBERS = { 측정: [13, 36], 구조: [2, 12] }; const NUM = /\d[\d,.]*\s*(?:ms|초|분|시간|일|년|개월|주|배|%|건|개|줄|번|회|명|자|KB|MB|GB|TB|Gbps|TPS|QPS|만|천|억)/g; function measure(text) { // 본문 마커부터 찾는다. 주석을 먼저 지우면 마커도 같이 지워진다 — 그러면 Studio 칸까지 // 세게 되고, 기록 하나가 절 아홉 개인 것처럼 보인다. const marked = text.match(/([\s\S]*?)/); let t = marked ? marked[1] : text.replace(/^---\n.*?\n---\n/s, ""); t = t.replace(//gs, ""); const code = (t.match(/^```/gm) || []).length / 2; const tableLines = (t.match(/^\s*\|/gm) || []).length; const sections = (t.match(/^## /gm) || []).length; const prose = t.replace(/```[\s\S]*?```/g, "").replace(/^\s*\|.*$/gm, ""); const words = prose.split(/\s+/).filter(Boolean).length; const sentences = prose.split(/(?<=[.!?다])\s+/).filter((s) => s.trim().length > 10); const numbers = (t.match(NUM) || []).length; const proseParas = prose.split(/\n\s*\n/).filter((p) => p.trim().length > 40).length; return { isBody: Boolean(marked), words, sentWords: +(words / Math.max(sentences.length, 1)).toFixed(1), code, numbers, sections, tableLines, proseParas, wordsPerSection: Math.round(words / Math.max(sections, 1)), numberRate: +((numbers / Math.max(words, 1)) * 1000).toFixed(1) }; } const args = process.argv.slice(2); // 잰 것을 쓴 글인가 구조를 쓴 글인가. Case 는 측정, Concept 은 `--구조` 로 부른다. const genre = args.includes("--구조") ? "구조" : "측정"; const files = args.filter((a) => !a.startsWith("--")); if (args.includes("--baseline")) { const all = files.map((f) => measure(readFileSync(f, "utf8"))); const range = (k) => [Math.min(...all.map((m) => m[k])), Math.max(...all.map((m) => m[k]))]; for (const k of [...Object.keys(BASE), "numbers"]) console.log(` ${k}: [${range(k)}]`); process.exit(0); } if (!files.length) { console.error("쓰는 법: node density.mjs [--구조] 초안.md"); process.exit(2); } const LABEL = { words: "낱말", sentWords: "문장 평균 낱말", code: "코드블록", numbers: "수치", sections: "## 절", tableLines: "표 줄", wordsPerSection: "절당 낱말", numberRate: "1,000 낱말당 수치" }; let bad = 0; for (const file of files) { const m = measure(readFileSync(file, "utf8")); const where = m.isBody ? "기록 본문" : "글 한 편"; console.log(`\n${basename(file)} (${where} · ${genre} 글 기준)`); const bands = m.isBody ? { ...BODY, numberRate: BODY_NUM_RATE[genre] } : { ...BASE, numbers: NUMBERS[genre] }; for (const [k, [lo, hi]] of Object.entries(bands)) { const v = m[k]; const ok = v >= lo && v <= hi; const note = ok ? "" : v < lo ? " ← 모자람" : " ← 넘침"; if (!ok && k !== "words" && k !== "code") bad++; console.log(` ${LABEL[k].padEnd(16)} ${String(v).padStart(6)} 기준 ${lo}~${hi}${note}`); } if (m.tableLines > m.proseParas) console.log(` ! 표 ${m.tableLines}줄이 산문 ${m.proseParas}문단보다 많다 — 표가 설명을 대신하고 있다`); const rateLo = m.isBody ? BODY_NUM_RATE[genre][0] : null; if (rateLo !== null && m.numberRate < rateLo) console.log(` ! 1,000 낱말에 수치가 ${m.numberRate}개다 — 자료에 있는 크기를 「여러」·「대부분」으로 뭉갠 자리를 찾는다`); if (!m.isBody && m.numbers < NUMBERS[genre][0]) console.log(` ! 수치가 ${m.numbers}개다 — 자료에 있는 크기를 「여러」·「대부분」으로 뭉갠 자리를 찾는다`); if (m.wordsPerSection < 160) console.log(` ! 절 하나가 ${m.wordsPerSection} 낱말이다 — 문단 두세 개짜리 절을 앞뒤와 합친다`); } process.exit(bad ? 1 : 0);