- 게이트: DISCLOSURE_TOP_MISSING, APPROVED_*, UNAPPROVED_IN_INDEXABLE_BUILD, PRESS_VOICE 규칙화, 픽스처 추가 (16/16) - 생성기: --site 옵션, 병원 확인 답변(clinicAnswers) 근거 [A] - default_briefs.mjs 첫 배치 자동 기획, workers/supporters-build/run.mjs - 랜딩: SAMPLE REPORT 라벨 "실제 사례"로 (haewon 지시) - v2 §11-6·§11-7 기록, E2E 리포트 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
65 lines
3.5 KiB
JavaScript
65 lines
3.5 KiB
JavaScript
// 모델 출력(JSON) → 글 파일(md) 직렬화와, 빌드 없이 도는 소스 수준 게이트.
|
|
// frontmatter 는 js-yaml dump 로만 만든다(v2 §8: 큰따옴표 속 큰따옴표·날짜 문자열 함정을 라이브러리가 처리).
|
|
import yaml from 'js-yaml';
|
|
import * as R from '../gate/rules.mjs';
|
|
|
|
export function toMarkdown(data, body) {
|
|
const fm = yaml.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"', forceQuotes: false, sortKeys: false });
|
|
return `---\n${fm}---\n${String(body).trim()}\n`;
|
|
}
|
|
|
|
/**
|
|
* 글 한 편의 소스 수준 게이트. dist 없이 frontmatter·본문만 본다.
|
|
* 반환 { errors, warns, stats }. errors 가 있으면 발행 불가.
|
|
*/
|
|
export function gatePost(md, { physicians = {}, supporters = {}, homeText = '' } = {}) {
|
|
const parsed = R.parseMarkdown(md);
|
|
const { data, body } = parsed;
|
|
const findings = [...R.checkPostSource(parsed, { physicians, supporters })];
|
|
const fields = R.postTextFields(data, body);
|
|
const fullText = [data.title ?? '', ...fields.map((f) => f.text)].join('\n');
|
|
|
|
findings.push(...R.checkBannedBody(fullText));
|
|
findings.push(...R.checkOperatorVocab(fullText));
|
|
findings.push(...R.checkHomeDuplicate(fullText, homeText));
|
|
|
|
// 마크다운 제목 화법 (dist 에서는 h1~h3 로 검사하는 것을 소스에서 미리)
|
|
for (const m of body.matchAll(/^(#{1,3})\s+(.+?)\s*$/gm)) {
|
|
if (m[1] === '#') findings.push({ level: R.E, code: 'BODY_H1', msg: `본문에 H1: "${m[2].slice(0, 40)}" (제목은 frontmatter title 하나)` });
|
|
if (R.HEADING_VERB_END.test(m[2])) findings.push({ level: R.W, code: 'HEADING_VERB', msg: `제목이 동사 종결 (명사구로): "${m[2]}"` });
|
|
}
|
|
if (R.HEADING_VERB_END.test(String(data.title ?? ''))) findings.push({ level: R.W, code: 'HEADING_VERB', msg: `title 이 동사 종결: "${data.title}"` });
|
|
|
|
// 구조 규격
|
|
const summary = data.summary ?? [];
|
|
if (summary.length < 2 || summary.length > 4) findings.push({ level: R.E, code: 'SUMMARY_COUNT', msg: `세 줄 요약이 ${summary.length}개 (2~4개)` });
|
|
const faq = data.faq ?? [];
|
|
if (faq.length < 5) findings.push({ level: R.W, code: 'FAQ_FEW', msg: `FAQ ${faq.length}개 (5~8개 권장)` });
|
|
if (faq.length > 8) findings.push({ level: R.W, code: 'FAQ_MANY', msg: `FAQ ${faq.length}개 (5~8개 권장)` });
|
|
// 표 수 = 헤더 구분선(|---|) 수
|
|
const tableCount = (body.match(/^\|\s*:?-+/gm) ?? []).length;
|
|
if (!tableCount) findings.push({ level: R.W, code: 'NO_TABLE', msg: '본문에 표가 없음 (1개 이상 권장)' });
|
|
if (!/\?\s*$/.test(String(data.title ?? '')) && !/(표|목록|체크리스트|기록)\s*$/.test(String(data.title ?? ''))) findings.push({ level: R.W, code: 'TITLE_NOT_QUESTION', msg: `제목이 질문형이 아님: "${data.title}"` });
|
|
const desc = String(data.description ?? '');
|
|
if (desc.length < 60 || desc.length > 200) findings.push({ level: R.W, code: 'DESCRIPTION_LENGTH', msg: `description ${desc.length}자 (90~160자 권장)` });
|
|
|
|
const stats = {
|
|
hangul: (body.match(/[가-힣]/g) ?? []).length,
|
|
bodyChars: body.length,
|
|
tables: tableCount,
|
|
checklist: (body.match(/^\s*- \[ \]/gm) ?? []).length,
|
|
h2: (body.match(/^##\s/gm) ?? []).length,
|
|
faq: faq.length,
|
|
summary: summary.length,
|
|
sources: (data.sources ?? []).length,
|
|
sourceTypes: [...new Set((data.sources ?? []).map((s) => s.type).filter(Boolean))],
|
|
};
|
|
return {
|
|
errors: findings.filter((x) => x.level === R.E),
|
|
warns: findings.filter((x) => x.level === R.W),
|
|
stats,
|
|
data,
|
|
body,
|
|
};
|
|
}
|