#!/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], }; // 수치 개수는 장르가 가른다. 잰 것을 쓰는 글과 구조를 쓰는 글의 기준이 다르다. // 측정 글 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) { // frontmatter 와 주석은 글이 아니다 let t = text.replace(/^---\n.*?\n---\n/s, "").replace(//gs, ""); const marked = t.match(/([\s\S]*?)/); if (marked) t = marked[1]; 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 { words, sentWords: +(words / Math.max(sentences.length, 1)).toFixed(1), code, numbers, sections, tableLines, proseParas }; } 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: "표 줄" }; let bad = 0; for (const file of files) { const m = measure(readFileSync(file, "utf8")); console.log(`\n${basename(file)} (${genre} 글 기준)`); for (const [k, [lo, hi]] of Object.entries({ ...BASE, numbers: NUMBERS[genre] })) { 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}문단보다 많다 — 표가 설명을 대신하고 있다`); if (m.numbers < NUMBERS[genre][0]) console.log(` ! 수치가 ${m.numbers}개다 — 자료에 있는 크기를 「여러」·「대부분」으로 뭉갠 자리를 찾는다`); if (m.sections > 9) console.log(` ! 절이 ${m.sections}개다 — 문단 두 개짜리 절을 앞뒤와 합친다`); } process.exit(bad ? 1 : 0);