@roottale/cms-renderer-next 0.41.0 → 0.41.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +6 -3
- package/dist/{chunk-6AFM2UR5.js → chunk-5R6APJZ2.js} +62 -2
- package/dist/chunk-5R6APJZ2.js.map +1 -0
- package/dist/{chunk-IOYBEH6E.js → chunk-QKZ4HRBB.js} +32 -3
- package/dist/chunk-QKZ4HRBB.js.map +1 -0
- package/dist/chunk-YUT4BRJY.js +167 -0
- package/dist/chunk-YUT4BRJY.js.map +1 -0
- package/dist/hero-carousel-client.d.ts +15 -0
- package/dist/hero-carousel-client.js +8 -0
- package/dist/hero-carousel-client.js.map +1 -0
- package/dist/routes.js +1 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +3 -2
- package/dist/templates.css +196 -0
- package/dist/templates.d.ts +105 -5
- package/dist/templates.js +3 -2
- package/dist/templates.js.map +1 -1
- package/dist/validate.js +412 -7
- package/dist/validate.js.map +1 -1
- package/package.json +7 -5
- package/dist/chunk-6AFM2UR5.js.map +0 -1
- package/dist/chunk-IOYBEH6E.js.map +0 -1
package/dist/templates.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/templates/SiteRenderer.tsx"],"sourcesContent":["/**\n * `SiteRenderer` — `SiteConfig.pages[path]` 섹션 배열을 registry 로 렌더.\n *\n * ADR-0040 P2 + P0.5 RFC §①:\n * - 빌트인 registry + 클라 주입 `customSections` 머지. **빌트인이 우선** —\n * 클라가 빌트인 type 을 가로채 계약이 갈라지는 것 방지 (고유 섹션은 고유\n * type 으로).\n * - **render-prop 금지**: 임의 JSX escape hatch 는 customSections registry 로만.\n * - **admin preview parity**: admin draft/preview 도 동일 `SiteRenderer` + 동일\n * 빌트인 registry 를 탄다. customSections 는 클라 repo 자산 — preview 에선\n * 미주입 → `unknownSection: \"placeholder\"` 로 skeleton 표시.\n * - cms-bound 섹션(blog-list/lead-form)은 `RootTaleBlogList`/`RootTaleLeadForm`\n * 위임 (단방향: templates → renderer 코어).\n */\nimport {\n Fragment,\n type CSSProperties,\n type ReactElement,\n type ReactNode,\n} from \"react\";\n\nimport { RootTaleLeadForm } from \"../LeadForm.js\";\nimport { RootTaleBlogList } from \"../server.js\";\nimport {\n TemplateContact,\n TemplateCtaBanner,\n TemplateFaq,\n TemplateHero,\n TemplateRichText,\n TemplateServices,\n TemplateTrust,\n sectionThemeStyle,\n} from \"./sections.js\";\nimport {\n type BlogListSection,\n type ContactSection,\n type CtaBannerSection,\n type FaqSection,\n type HeroSection,\n type LeadFormSection,\n type RichTextSection,\n type Section,\n type ServicesSection,\n type SiteConfig,\n type SiteConfigInput,\n type TrustSection,\n parseSiteConfig,\n} from \"./site-config.js\";\n\n/** customSections registry 컴포넌트 계약 (RFC §①). */\nexport type SectionComponent = (p: {\n id?: string;\n props: Record<string, unknown>;\n variant?: string;\n theme?: Record<string, string>;\n}) => ReactNode;\n\nexport interface SiteRendererProps {\n /** `defineSiteConfig()` 결과 또는 raw config (내부에서 검증). */\n config: SiteConfig | SiteConfigInput;\n /** 현재 페이지 경로 — `config.pages` 의 key (\"/\", \"/services\", …). */\n path: string;\n /** cms-bound 섹션(blog-list/lead-form 의 theme fetch) 위임용. 서버 전용. */\n apiKey: string;\n /** self-host 등 API base override (`ROOTTALE_API_BASE`). */\n baseUrl?: string;\n /** 클라 repo 고유 섹션 registry (escape hatch). 빌트인 type 은 가로챌 수 없음. */\n customSections?: Record<string, SectionComponent>;\n /**\n * registry 에 없는 type 처리. 기본 \"warn-dev\" = dev 에서 경고 박스, prod 에서\n * 생략. admin preview 는 \"placeholder\" (customSections 미주입 환경).\n */\n unknownSection?: \"warn-dev\" | \"placeholder\" | \"ignore\";\n}\n\nfunction isDev(): boolean {\n // dts 빌드는 node types 없이 돈다 — process 는 globalThis 경유로 안전 접근.\n const env =\n (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env ?? {};\n return env.NODE_ENV !== \"production\";\n}\n\nfunction UnknownSection({\n type,\n mode,\n}: {\n type: string;\n mode: \"warn-dev\" | \"placeholder\" | \"ignore\";\n}): ReactElement | null {\n if (mode === \"ignore\") return null;\n if (mode === \"placeholder\") {\n return (\n <section\n data-roottale-tpl=\"placeholder\"\n className=\"rt-tpl-section rt-tpl-placeholder\"\n aria-hidden\n >\n <div className=\"rt-tpl-container\">\n <div className=\"rt-tpl-placeholder-box\">{type}</div>\n </div>\n </section>\n );\n }\n if (!isDev()) return null;\n const style: CSSProperties = {\n padding: \"1rem\",\n border: \"1px dashed #c00\",\n color: \"#c00\",\n };\n return (\n <div style={style}>\n 알 수 없는 섹션 type: <code>{type}</code> — SiteRenderer 의 customSections\n 에 등록하세요.\n </div>\n );\n}\n\nfunction renderBuiltin(\n section: Section,\n ctx: { apiKey: string; baseUrl?: string },\n): ReactNode | undefined {\n switch (section.type) {\n case \"hero\":\n return <TemplateHero {...(section as HeroSection)} />;\n case \"rich-text\":\n return <TemplateRichText {...(section as RichTextSection)} />;\n case \"services\":\n return <TemplateServices {...(section as ServicesSection)} />;\n case \"faq\":\n return <TemplateFaq {...(section as FaqSection)} />;\n case \"cta-banner\":\n return <TemplateCtaBanner {...(section as CtaBannerSection)} />;\n case \"blog-list\": {\n const s = section as BlogListSection;\n const base = s.props.blogBasePath ?? \"/blog\";\n return (\n <section\n data-roottale-tpl=\"blog-list\"\n data-roottale-cms=\"list\"\n id={s.id}\n className=\"rt-tpl-section rt-tpl-blog-list\"\n style={sectionThemeStyle(s.theme)}\n >\n <div className=\"rt-tpl-container\">\n {s.props.heading ? (\n <h2 className=\"rt-tpl-heading\">{s.props.heading}</h2>\n ) : null}\n <RootTaleBlogList\n apiKey={ctx.apiKey}\n baseUrl={ctx.baseUrl}\n limit={s.props.limit}\n emptyMessage={s.props.emptyMessage}\n postHref={(post) => `${base}/${post.slug}`}\n />\n </div>\n </section>\n );\n }\n case \"lead-form\": {\n const s = section as LeadFormSection;\n return (\n <section\n data-roottale-tpl=\"lead-form\"\n data-roottale-cms=\"lead-form\"\n id={s.id}\n className=\"rt-tpl-section rt-tpl-lead-form\"\n style={sectionThemeStyle(s.theme)}\n >\n <div className=\"rt-tpl-container\">\n <RootTaleLeadForm\n action={s.props.action}\n vertical={s.props.vertical}\n heading={s.props.heading}\n description={s.props.description}\n submitLabel={s.props.submitLabel}\n />\n </div>\n </section>\n );\n }\n case \"trust\":\n return <TemplateTrust {...(section as TrustSection)} />;\n case \"contact\":\n return <TemplateContact {...(section as ContactSection)} />;\n default:\n return undefined;\n }\n}\n\n/**\n * 한 페이지의 섹션 배열을 순서대로 렌더.\n *\n * ```tsx\n * // app/services/page.tsx\n * import config from \"@/site.config\";\n * import { SiteRenderer } from \"@roottale/cms-renderer-next/templates\";\n *\n * export default function ServicesPage() {\n * return (\n * <SiteRenderer config={config} path=\"/services\"\n * apiKey={process.env.ROOTTALE_API_KEY!} />\n * );\n * }\n * ```\n */\nexport function SiteRenderer(props: SiteRendererProps): ReactElement {\n const {\n path,\n apiKey,\n baseUrl,\n customSections = {},\n unknownSection = \"warn-dev\",\n } = props;\n const config = parseSiteConfig(props.config);\n const sections = config.pages[path] ?? [];\n return (\n <>\n {sections.length === 0 ? (\n <UnknownSection\n type={`pages[\"${path}\"] (섹션 0개)`}\n mode={unknownSection}\n />\n ) : null}\n {sections.map((section, i) => {\n const key = section.id ?? `${section.type}-${i}`;\n const builtin = renderBuiltin(section, { apiKey, baseUrl });\n if (builtin !== undefined) {\n return <Fragment key={key}>{builtin}</Fragment>;\n }\n const Custom = customSections[section.type];\n if (Custom) {\n return (\n <Fragment key={key}>\n {Custom({\n id: section.id,\n props: section.props as Record<string, unknown>,\n variant: section.variant,\n theme: section.theme,\n })}\n </Fragment>\n );\n }\n return (\n <UnknownSection key={key} type={section.type} mode={unknownSection} />\n );\n })}\n </>\n );\n}\n"],"mappings":"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","names":["Fragment"]}
|
|
1
|
+
{"version":3,"sources":["../src/templates/SiteRenderer.tsx"],"sourcesContent":["/**\n * `SiteRenderer` — `SiteConfig.pages[path]` 섹션 배열을 registry 로 렌더.\n *\n * ADR-0040 P2 + P0.5 RFC §①:\n * - 빌트인 registry + 클라 주입 `customSections` 머지. **빌트인이 우선** —\n * 클라가 빌트인 type 을 가로채 계약이 갈라지는 것 방지 (고유 섹션은 고유\n * type 으로).\n * - **render-prop 금지**: 임의 JSX escape hatch 는 customSections registry 로만.\n * - **admin preview parity**: admin draft/preview 도 동일 `SiteRenderer` + 동일\n * 빌트인 registry 를 탄다. customSections 는 클라 repo 자산 — preview 에선\n * 미주입 → `unknownSection: \"placeholder\"` 로 skeleton 표시.\n * - cms-bound 섹션(blog-list/lead-form)은 `RootTaleBlogList`/`RootTaleLeadForm`\n * 위임 (단방향: templates → renderer 코어).\n */\nimport {\n Fragment,\n type CSSProperties,\n type ReactElement,\n type ReactNode,\n} from \"react\";\n\nimport { RootTaleLeadForm } from \"../LeadForm.js\";\nimport { RootTaleBlogList } from \"../server.js\";\nimport {\n TemplateContact,\n TemplateCtaBanner,\n TemplateFaq,\n TemplateHero,\n TemplateRichText,\n TemplateServices,\n TemplateTrust,\n sectionThemeStyle,\n} from \"./sections.js\";\nimport {\n type BlogListSection,\n type ContactSection,\n type CtaBannerSection,\n type FaqSection,\n type HeroSection,\n type LeadFormSection,\n type RichTextSection,\n type Section,\n type ServicesSection,\n type SiteConfig,\n type SiteConfigInput,\n type TrustSection,\n parseSiteConfig,\n} from \"./site-config.js\";\n\n/** customSections registry 컴포넌트 계약 (RFC §①). */\nexport type SectionComponent = (p: {\n id?: string;\n props: Record<string, unknown>;\n variant?: string;\n theme?: Record<string, string>;\n}) => ReactNode;\n\nexport interface SiteRendererProps {\n /** `defineSiteConfig()` 결과 또는 raw config (내부에서 검증). */\n config: SiteConfig | SiteConfigInput;\n /** 현재 페이지 경로 — `config.pages` 의 key (\"/\", \"/services\", …). */\n path: string;\n /** cms-bound 섹션(blog-list/lead-form 의 theme fetch) 위임용. 서버 전용. */\n apiKey: string;\n /** self-host 등 API base override (`ROOTTALE_API_BASE`). */\n baseUrl?: string;\n /** 클라 repo 고유 섹션 registry (escape hatch). 빌트인 type 은 가로챌 수 없음. */\n customSections?: Record<string, SectionComponent>;\n /**\n * registry 에 없는 type 처리. 기본 \"warn-dev\" = dev 에서 경고 박스, prod 에서\n * 생략. admin preview 는 \"placeholder\" (customSections 미주입 환경).\n */\n unknownSection?: \"warn-dev\" | \"placeholder\" | \"ignore\";\n}\n\nfunction isDev(): boolean {\n // dts 빌드는 node types 없이 돈다 — process 는 globalThis 경유로 안전 접근.\n const env =\n (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env ?? {};\n return env.NODE_ENV !== \"production\";\n}\n\nfunction UnknownSection({\n type,\n mode,\n}: {\n type: string;\n mode: \"warn-dev\" | \"placeholder\" | \"ignore\";\n}): ReactElement | null {\n if (mode === \"ignore\") return null;\n if (mode === \"placeholder\") {\n return (\n <section\n data-roottale-tpl=\"placeholder\"\n className=\"rt-tpl-section rt-tpl-placeholder\"\n aria-hidden\n >\n <div className=\"rt-tpl-container\">\n <div className=\"rt-tpl-placeholder-box\">{type}</div>\n </div>\n </section>\n );\n }\n if (!isDev()) return null;\n const style: CSSProperties = {\n padding: \"1rem\",\n border: \"1px dashed #c00\",\n color: \"#c00\",\n };\n return (\n <div style={style}>\n 알 수 없는 섹션 type: <code>{type}</code> — SiteRenderer 의 customSections\n 에 등록하세요.\n </div>\n );\n}\n\nfunction renderBuiltin(\n section: Section,\n ctx: { apiKey: string; baseUrl?: string },\n): ReactNode | undefined {\n switch (section.type) {\n case \"hero\":\n return <TemplateHero {...(section as HeroSection)} />;\n case \"rich-text\":\n return <TemplateRichText {...(section as RichTextSection)} />;\n case \"services\":\n return <TemplateServices {...(section as ServicesSection)} />;\n case \"faq\":\n return <TemplateFaq {...(section as FaqSection)} />;\n case \"cta-banner\":\n return <TemplateCtaBanner {...(section as CtaBannerSection)} />;\n case \"blog-list\": {\n const s = section as BlogListSection;\n const base = s.props.blogBasePath ?? \"/blog\";\n return (\n <section\n data-roottale-tpl=\"blog-list\"\n data-roottale-cms=\"list\"\n id={s.id}\n className=\"rt-tpl-section rt-tpl-blog-list\"\n style={sectionThemeStyle(s.theme)}\n >\n <div className=\"rt-tpl-container\">\n {s.props.heading ? (\n <h2 className=\"rt-tpl-heading\">{s.props.heading}</h2>\n ) : null}\n <RootTaleBlogList\n apiKey={ctx.apiKey}\n baseUrl={ctx.baseUrl}\n limit={s.props.limit}\n emptyMessage={s.props.emptyMessage}\n postHref={(post) => `${base}/${post.slug}`}\n />\n </div>\n </section>\n );\n }\n case \"lead-form\": {\n const s = section as LeadFormSection;\n return (\n <section\n data-roottale-tpl=\"lead-form\"\n data-roottale-cms=\"lead-form\"\n id={s.id}\n className=\"rt-tpl-section rt-tpl-lead-form\"\n style={sectionThemeStyle(s.theme)}\n >\n <div className=\"rt-tpl-container\">\n <RootTaleLeadForm\n action={s.props.action}\n vertical={s.props.vertical}\n heading={s.props.heading}\n description={s.props.description}\n submitLabel={s.props.submitLabel}\n />\n </div>\n </section>\n );\n }\n case \"trust\":\n return <TemplateTrust {...(section as TrustSection)} />;\n case \"contact\":\n return <TemplateContact {...(section as ContactSection)} />;\n default:\n return undefined;\n }\n}\n\n/**\n * 한 페이지의 섹션 배열을 순서대로 렌더.\n *\n * ```tsx\n * // app/services/page.tsx\n * import config from \"@/site.config\";\n * import { SiteRenderer } from \"@roottale/cms-renderer-next/templates\";\n *\n * export default function ServicesPage() {\n * return (\n * <SiteRenderer config={config} path=\"/services\"\n * apiKey={process.env.ROOTTALE_API_KEY!} />\n * );\n * }\n * ```\n */\nexport function SiteRenderer(props: SiteRendererProps): ReactElement {\n const {\n path,\n apiKey,\n baseUrl,\n customSections = {},\n unknownSection = \"warn-dev\",\n } = props;\n const config = parseSiteConfig(props.config);\n const sections = config.pages[path] ?? [];\n return (\n <>\n {sections.length === 0 ? (\n <UnknownSection\n type={`pages[\"${path}\"] (섹션 0개)`}\n mode={unknownSection}\n />\n ) : null}\n {sections.map((section, i) => {\n const key = section.id ?? `${section.type}-${i}`;\n const builtin = renderBuiltin(section, { apiKey, baseUrl });\n if (builtin !== undefined) {\n return <Fragment key={key}>{builtin}</Fragment>;\n }\n const Custom = customSections[section.type];\n if (Custom) {\n return (\n <Fragment key={key}>\n {Custom({\n id: section.id,\n props: section.props as Record<string, unknown>,\n variant: section.variant,\n theme: section.theme,\n })}\n </Fragment>\n );\n }\n return (\n <UnknownSection key={key} type={section.type} mode={unknownSection} />\n );\n })}\n </>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAcA;AAAA,EACE;AAAA,OAIK;AAgFG,SAsHN,YAAAA,WAtHM,KAYN,YAZM;AAxBV,SAAS,QAAiB;AAExB,QAAM,MACH,WACE,SAAS,OAAO,CAAC;AACtB,SAAO,IAAI,aAAa;AAC1B;AAEA,SAAS,eAAe;AAAA,EACtB;AAAA,EACA;AACF,GAGwB;AACtB,MAAI,SAAS,SAAU,QAAO;AAC9B,MAAI,SAAS,eAAe;AAC1B,WACE;AAAA,MAAC;AAAA;AAAA,QACC,qBAAkB;AAAA,QAClB,WAAU;AAAA,QACV,eAAW;AAAA,QAEX,8BAAC,SAAI,WAAU,oBACb,8BAAC,SAAI,WAAU,0BAA0B,gBAAK,GAChD;AAAA;AAAA,IACF;AAAA,EAEJ;AACA,MAAI,CAAC,MAAM,EAAG,QAAO;AACrB,QAAM,QAAuB;AAAA,IAC3B,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,OAAO;AAAA,EACT;AACA,SACE,qBAAC,SAAI,OAAc;AAAA;AAAA,IACD,oBAAC,UAAM,gBAAK;AAAA,IAAO;AAAA,KAErC;AAEJ;AAEA,SAAS,cACP,SACA,KACuB;AACvB,UAAQ,QAAQ,MAAM;AAAA,IACpB,KAAK;AACH,aAAO,oBAAC,gBAAc,GAAI,SAAyB;AAAA,IACrD,KAAK;AACH,aAAO,oBAAC,oBAAkB,GAAI,SAA6B;AAAA,IAC7D,KAAK;AACH,aAAO,oBAAC,oBAAkB,GAAI,SAA6B;AAAA,IAC7D,KAAK;AACH,aAAO,oBAAC,eAAa,GAAI,SAAwB;AAAA,IACnD,KAAK;AACH,aAAO,oBAAC,qBAAmB,GAAI,SAA8B;AAAA,IAC/D,KAAK,aAAa;AAChB,YAAM,IAAI;AACV,YAAM,OAAO,EAAE,MAAM,gBAAgB;AACrC,aACE;AAAA,QAAC;AAAA;AAAA,UACC,qBAAkB;AAAA,UAClB,qBAAkB;AAAA,UAClB,IAAI,EAAE;AAAA,UACN,WAAU;AAAA,UACV,OAAO,kBAAkB,EAAE,KAAK;AAAA,UAEhC,+BAAC,SAAI,WAAU,oBACZ;AAAA,cAAE,MAAM,UACP,oBAAC,QAAG,WAAU,kBAAkB,YAAE,MAAM,SAAQ,IAC9C;AAAA,YACJ;AAAA,cAAC;AAAA;AAAA,gBACC,QAAQ,IAAI;AAAA,gBACZ,SAAS,IAAI;AAAA,gBACb,OAAO,EAAE,MAAM;AAAA,gBACf,cAAc,EAAE,MAAM;AAAA,gBACtB,UAAU,CAAC,SAAS,GAAG,IAAI,IAAI,KAAK,IAAI;AAAA;AAAA,YAC1C;AAAA,aACF;AAAA;AAAA,MACF;AAAA,IAEJ;AAAA,IACA,KAAK,aAAa;AAChB,YAAM,IAAI;AACV,aACE;AAAA,QAAC;AAAA;AAAA,UACC,qBAAkB;AAAA,UAClB,qBAAkB;AAAA,UAClB,IAAI,EAAE;AAAA,UACN,WAAU;AAAA,UACV,OAAO,kBAAkB,EAAE,KAAK;AAAA,UAEhC,8BAAC,SAAI,WAAU,oBACb;AAAA,YAAC;AAAA;AAAA,cACC,QAAQ,EAAE,MAAM;AAAA,cAChB,UAAU,EAAE,MAAM;AAAA,cAClB,SAAS,EAAE,MAAM;AAAA,cACjB,aAAa,EAAE,MAAM;AAAA,cACrB,aAAa,EAAE,MAAM;AAAA;AAAA,UACvB,GACF;AAAA;AAAA,MACF;AAAA,IAEJ;AAAA,IACA,KAAK;AACH,aAAO,oBAAC,iBAAe,GAAI,SAA0B;AAAA,IACvD,KAAK;AACH,aAAO,oBAAC,mBAAiB,GAAI,SAA4B;AAAA,IAC3D;AACE,aAAO;AAAA,EACX;AACF;AAkBO,SAAS,aAAa,OAAwC;AACnE,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA,iBAAiB,CAAC;AAAA,IAClB,iBAAiB;AAAA,EACnB,IAAI;AACJ,QAAM,SAAS,gBAAgB,MAAM,MAAM;AAC3C,QAAM,WAAW,OAAO,MAAM,IAAI,KAAK,CAAC;AACxC,SACE,qBAAAA,WAAA,EACG;AAAA,aAAS,WAAW,IACnB;AAAA,MAAC;AAAA;AAAA,QACC,MAAM,UAAU,IAAI;AAAA,QACpB,MAAM;AAAA;AAAA,IACR,IACE;AAAA,IACH,SAAS,IAAI,CAAC,SAAS,MAAM;AAC5B,YAAM,MAAM,QAAQ,MAAM,GAAG,QAAQ,IAAI,IAAI,CAAC;AAC9C,YAAM,UAAU,cAAc,SAAS,EAAE,QAAQ,QAAQ,CAAC;AAC1D,UAAI,YAAY,QAAW;AACzB,eAAO,oBAAC,YAAoB,qBAAN,GAAc;AAAA,MACtC;AACA,YAAM,SAAS,eAAe,QAAQ,IAAI;AAC1C,UAAI,QAAQ;AACV,eACE,oBAAC,YACE,iBAAO;AAAA,UACN,IAAI,QAAQ;AAAA,UACZ,OAAO,QAAQ;AAAA,UACf,SAAS,QAAQ;AAAA,UACjB,OAAO,QAAQ;AAAA,QACjB,CAAC,KANY,GAOf;AAAA,MAEJ;AACA,aACE,oBAAC,kBAAyB,MAAM,QAAQ,MAAM,MAAM,kBAA/B,GAA+C;AAAA,IAExE,CAAC;AAAA,KACH;AAEJ;","names":["Fragment"]}
|
package/dist/validate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SectionSchema
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-QKZ4HRBB.js";
|
|
4
4
|
|
|
5
5
|
// src/templates/section-locks.ts
|
|
6
6
|
var CONTENT_PROP_PATHS = {
|
|
@@ -204,11 +204,400 @@ function formatSectionLockViolations(violations) {
|
|
|
204
204
|
}).join(" / ");
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
// ../audit-medical/src/index.ts
|
|
208
|
+
function snippetFor(text, idx, term) {
|
|
209
|
+
const start = Math.max(0, idx - 20);
|
|
210
|
+
const end = Math.min(text.length, idx + term.length + 20);
|
|
211
|
+
return `\u2026${text.slice(start, end)}\u2026`;
|
|
212
|
+
}
|
|
213
|
+
function findAll(text, terms) {
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const term of terms) {
|
|
216
|
+
let from = 0;
|
|
217
|
+
while (from <= text.length) {
|
|
218
|
+
const idx = text.indexOf(term, from);
|
|
219
|
+
if (idx === -1) break;
|
|
220
|
+
out.push({ term, idx });
|
|
221
|
+
from = idx + term.length;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
function ruleFromTerms(id, severity, description, terms, message) {
|
|
227
|
+
return {
|
|
228
|
+
id,
|
|
229
|
+
severity,
|
|
230
|
+
description,
|
|
231
|
+
check(text) {
|
|
232
|
+
return findAll(text, terms).map(({ term, idx }) => ({
|
|
233
|
+
ruleId: id,
|
|
234
|
+
severity,
|
|
235
|
+
message: message(term),
|
|
236
|
+
snippet: snippetFor(text, idx, term)
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
var ABSOLUTE_SUPERLATIVES = [
|
|
242
|
+
"\uCD5C\uACE0",
|
|
243
|
+
"\uCD5C\uC0C1",
|
|
244
|
+
"\uCD5C\uB300",
|
|
245
|
+
"\uC720\uC77C\uD55C",
|
|
246
|
+
"\uC720\uC77C\uD558\uAC8C",
|
|
247
|
+
"\uB2E8 \uD558\uB098",
|
|
248
|
+
"\uCD5C\uCD08\uC758",
|
|
249
|
+
"\uB3C5\uBCF4\uC801",
|
|
250
|
+
"\uB118\uBC84\uC6D0",
|
|
251
|
+
"1\uC704",
|
|
252
|
+
"\uAD6D\uB0B4 1\uC704",
|
|
253
|
+
"\uC138\uACC4 1\uC704"
|
|
254
|
+
];
|
|
255
|
+
var GUARANTEE_TERMS = [
|
|
256
|
+
"100%",
|
|
257
|
+
"100 %",
|
|
258
|
+
"\uC644\uC804\uD55C",
|
|
259
|
+
"\uC644\uBCBD\uD55C",
|
|
260
|
+
"\uD655\uC2E4\uD55C",
|
|
261
|
+
"\uC808\uB300",
|
|
262
|
+
"\uBB34\uC870\uAC74",
|
|
263
|
+
"\uBC18\uB4DC\uC2DC",
|
|
264
|
+
"\uBCF4\uC7A5",
|
|
265
|
+
"guarantee"
|
|
266
|
+
];
|
|
267
|
+
var COMPARISON_TERMS = [
|
|
268
|
+
"\uD0C0 \uBCD1\uC6D0\uBCF4\uB2E4",
|
|
269
|
+
"\uB2E4\uB978 \uBCD1\uC6D0\uBCF4\uB2E4",
|
|
270
|
+
"\uD0C0 \uC758\uC6D0\uBCF4\uB2E4",
|
|
271
|
+
"\uD0C0 \uC758\uC6D0\uACFC \uBE44\uAD50",
|
|
272
|
+
"\uB2E4\uB978 \uC758\uC0AC\uBCF4\uB2E4",
|
|
273
|
+
"\uBE44\uAD50 \uC2DC"
|
|
274
|
+
];
|
|
275
|
+
var RISK_OMISSION_TERMS = [
|
|
276
|
+
"\uBD80\uC791\uC6A9 \uC5C6\uB294",
|
|
277
|
+
"\uBD80\uC791\uC6A9 \uC5C6\uC74C",
|
|
278
|
+
"\uC548\uC804\uD55C",
|
|
279
|
+
"\uC548\uC2EC",
|
|
280
|
+
"\uD1B5\uC99D \uC5C6\uB294",
|
|
281
|
+
"\uD1B5\uC99D 0",
|
|
282
|
+
"\uC704\uD5D8 \uC5C6\uB294"
|
|
283
|
+
];
|
|
284
|
+
var CURE_PROMISE_TERMS = [
|
|
285
|
+
"\uC644\uCE58",
|
|
286
|
+
"\uCE58\uB8CC \uD6A8\uACFC \uBCF4\uC7A5",
|
|
287
|
+
"\uC7AC\uBC1C \uC5C6\uB294",
|
|
288
|
+
"\uC7AC\uBC1C 0",
|
|
289
|
+
"100% \uCE58\uC720",
|
|
290
|
+
"\uAE30\uC801\uC758"
|
|
291
|
+
];
|
|
292
|
+
var PRICE_DISCOUNT_PROMOTION_TERMS = [
|
|
293
|
+
"\uC774\uBCA4\uD2B8\uAC00",
|
|
294
|
+
"\uD2B9\uBCC4 \uD560\uC778",
|
|
295
|
+
"\uD560\uC778 \uD589\uC0AC",
|
|
296
|
+
"\uC6D0 \u2192 ",
|
|
297
|
+
"\uC6D0\uC73C\uB85C \uD560\uC778",
|
|
298
|
+
"\uD30C\uACA9\uAC00",
|
|
299
|
+
"\uCD5C\uC800\uAC00"
|
|
300
|
+
];
|
|
301
|
+
var TESTIMONIAL_TERMS = ["\uD658\uC790 \uD6C4\uAE30", "\uD658\uC790 \uC0AC\uB840", "\uCE58\uB8CC \uC0AC\uB840", "\uC804\uD6C4 \uC0AC\uC9C4"];
|
|
302
|
+
var UNAUTHORIZED_TITLE_TERMS = ["\uC804\uBB38\uBCD1\uC6D0", "\uC9C0\uC815\uBCD1\uC6D0", "\uCD5C\uACE0\uC758 \uC758\uC0AC"];
|
|
303
|
+
var FOREIGN_PROMOTION_TERMS = ["\uC758\uB8CC\uAD00\uAD11 \uC804\uBB38", "\uC678\uAD6D\uC778 \uD658\uC790 1\uC704"];
|
|
304
|
+
var MEDICAL_RULES = [
|
|
305
|
+
// MED-001~005: 절대표현 / 보장
|
|
306
|
+
ruleFromTerms(
|
|
307
|
+
"MED-001",
|
|
308
|
+
"critical",
|
|
309
|
+
"\uC808\uB300 \uC6B0\uC704 \uD45C\uD604 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u24613)",
|
|
310
|
+
ABSOLUTE_SUPERLATIVES,
|
|
311
|
+
(term) => `"${term}" \uAC19\uC740 \uC808\uB300 \uC6B0\uC704 \uD45C\uD604\uC740 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`
|
|
312
|
+
),
|
|
313
|
+
ruleFromTerms(
|
|
314
|
+
"MED-002",
|
|
315
|
+
"critical",
|
|
316
|
+
"\uCE58\uB8CC \uD6A8\uACFC \uBCF4\uC7A5 \uD45C\uD604 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u24612)",
|
|
317
|
+
GUARANTEE_TERMS,
|
|
318
|
+
(term) => `"${term}" \uAC19\uC740 \uBCF4\uC7A5\uC131 \uD45C\uD604\uC740 \uC758\uB8CC\uAD11\uACE0\uC5D0 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`
|
|
319
|
+
),
|
|
320
|
+
ruleFromTerms(
|
|
321
|
+
"MED-003",
|
|
322
|
+
"critical",
|
|
323
|
+
"\uB2E4\uB978 \uC758\uB8CC\uAE30\uAD00 \uBE44\uAD50 \uAD11\uACE0 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u24616)",
|
|
324
|
+
COMPARISON_TERMS,
|
|
325
|
+
(term) => `\uB2E4\uB978 \uC758\uB8CC\uAE30\uAD00\uACFC \uBE44\uAD50\uD558\uB294 \uD45C\uD604 "${term}" \uC740 \uAE08\uC9C0\uB429\uB2C8\uB2E4.`
|
|
326
|
+
),
|
|
327
|
+
ruleFromTerms(
|
|
328
|
+
"MED-004",
|
|
329
|
+
"critical",
|
|
330
|
+
"\uBD80\uC791\uC6A9\xB7\uC704\uD5D8 \uB204\uB77D \uD45C\uD604 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u24611)",
|
|
331
|
+
RISK_OMISSION_TERMS,
|
|
332
|
+
(term) => `"${term}" \uC740 \uBD80\uC791\uC6A9\xB7\uC704\uD5D8\uC744 \uB204\uB77D\uD55C \uD45C\uD604\uC73C\uB85C \uBD84\uB958\uB420 \uC218 \uC788\uC2B5\uB2C8\uB2E4.`
|
|
333
|
+
),
|
|
334
|
+
ruleFromTerms(
|
|
335
|
+
"MED-005",
|
|
336
|
+
"critical",
|
|
337
|
+
"\uC644\uCE58\xB7\uCE58\uC720 \uBCF4\uC7A5 \uD45C\uD604 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u24617)",
|
|
338
|
+
CURE_PROMISE_TERMS,
|
|
339
|
+
(term) => `"${term}" \uAC19\uC740 \uC644\uCE58\xB7\uCE58\uC720 \uBCF4\uC7A5 \uD45C\uD604\uC740 \uAE08\uC9C0\uB429\uB2C8\uB2E4.`
|
|
340
|
+
),
|
|
341
|
+
// MED-006~010: 가격·이벤트 광고
|
|
342
|
+
ruleFromTerms(
|
|
343
|
+
"MED-006",
|
|
344
|
+
"warning",
|
|
345
|
+
"\uD560\uC778\xB7\uC774\uBCA4\uD2B8 \uAD11\uACE0 \uD45C\uC2DC (\uC758\uB8CC\uBC95 \xA756 \u24618 \uD658\uC790 \uC720\uC778)",
|
|
346
|
+
PRICE_DISCOUNT_PROMOTION_TERMS,
|
|
347
|
+
(term) => `"${term}" \u2014 \uD658\uC790 \uC720\uC778 \uAD11\uACE0\uB85C \uBD84\uB958\uB420 \uC218 \uC788\uC5B4 \uC0AC\uC804\uC2EC\uC758 \uD544\uC694.`
|
|
348
|
+
),
|
|
349
|
+
ruleFromTerms(
|
|
350
|
+
"MED-007",
|
|
351
|
+
"warning",
|
|
352
|
+
"\uD6C4\uAE30\xB7\uC0AC\uB840 \uAD11\uACE0 (\uC758\uB8CC\uBC95 \xA756 \u24614 \uCE58\uB8CC \uACBD\uD5D8\uB2F4)",
|
|
353
|
+
TESTIMONIAL_TERMS,
|
|
354
|
+
(term) => `"${term}" \u2014 \uCE58\uB8CC \uACBD\uD5D8\uB2F4\uC740 \uC790\uC728\uC2EC\uC758\uAE30\uAD6C \uC0AC\uC804\uC2EC\uC758 \uD6C4\uB9CC \uAC8C\uC7AC \uAC00\uB2A5.`
|
|
355
|
+
),
|
|
356
|
+
ruleFromTerms(
|
|
357
|
+
"MED-008",
|
|
358
|
+
"critical",
|
|
359
|
+
"\uBBF8\uC778\uC99D \uBA85\uCE6D \uC0AC\uC6A9 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u246110)",
|
|
360
|
+
UNAUTHORIZED_TITLE_TERMS,
|
|
361
|
+
(term) => `"${term}" \u2014 \uBCF4\uAC74\uBCF5\uC9C0\uBD80 \uC9C0\uC815\xB7\uD3C9\uAC00 \uC5C6\uC774 \uC0AC\uC6A9 \uAE08\uC9C0. \uC778\uC99D \uC2DC \uC778\uC99D\uBC88\uD638 \uBA85\uC2DC \uC758\uBB34.`
|
|
362
|
+
),
|
|
363
|
+
ruleFromTerms(
|
|
364
|
+
"MED-009",
|
|
365
|
+
"warning",
|
|
366
|
+
"\uC758\uB8CC\uAD00\uAD11 \uAD11\uACE0 (\uC758\uB8CC\uBC95 \xA756 \u2461 \uC678\uAD6D\uC778 \uD658\uC790 \uC720\uCE58)",
|
|
367
|
+
FOREIGN_PROMOTION_TERMS,
|
|
368
|
+
(term) => `"${term}" \u2014 \uC678\uAD6D\uC778 \uD658\uC790 \uC720\uCE58 \uAD11\uACE0\uB294 \uC758\uB8CC\uD574\uC678\uC9C4\uCD9C\uBC95 \uCD94\uAC00 \uAC80\uD1A0 \uD544\uC694.`
|
|
369
|
+
),
|
|
370
|
+
{
|
|
371
|
+
id: "MED-010",
|
|
372
|
+
severity: "warning",
|
|
373
|
+
description: "\uC804\uBB38\uC758 \uC790\uACA9 \uD45C\uC2DC \uC2DC \uC9C4\uB8CC\uACFC\uBAA9 \uBA85\uC2DC \uD544\uC694",
|
|
374
|
+
check(text) {
|
|
375
|
+
if (/전문의/.test(text) && !/[가-힣]+과 전문의/.test(text)) {
|
|
376
|
+
return [
|
|
377
|
+
{
|
|
378
|
+
ruleId: "MED-010",
|
|
379
|
+
severity: "warning",
|
|
380
|
+
message: "\uC804\uBB38\uC758 \uD45C\uC2DC \uC2DC \uC9C4\uB8CC\uACFC\uBAA9 \uBA85\uC2DC \uAD8C\uC7A5 (\uC608: '\uB0B4\uACFC \uC804\uBB38\uC758', '\uD53C\uBD80\uACFC \uC804\uBB38\uC758')."
|
|
381
|
+
}
|
|
382
|
+
];
|
|
383
|
+
}
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
// MED-011~015: 필수 표시 누락
|
|
388
|
+
{
|
|
389
|
+
id: "MED-011",
|
|
390
|
+
severity: "info",
|
|
391
|
+
description: "\uC758\uB8CC\uAE30\uAD00\uBA85 \uD45C\uC2DC \uAD8C\uC7A5",
|
|
392
|
+
check(text) {
|
|
393
|
+
if (text.length > 100 && !/(의원|병원|치과|한의원|클리닉)/.test(text)) {
|
|
394
|
+
return [
|
|
395
|
+
{
|
|
396
|
+
ruleId: "MED-011",
|
|
397
|
+
severity: "info",
|
|
398
|
+
message: "\uBCF8\uBB38\uC5D0 \uC758\uB8CC\uAE30\uAD00\uBA85 (\uC758\uC6D0/\uBCD1\uC6D0/\uCE58\uACFC/\uD55C\uC758\uC6D0/\uD074\uB9AC\uB2C9) \uD45C\uAE30 \uAD8C\uC7A5."
|
|
399
|
+
}
|
|
400
|
+
];
|
|
401
|
+
}
|
|
402
|
+
return [];
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
id: "MED-012",
|
|
407
|
+
severity: "info",
|
|
408
|
+
description: "\uC758\uB8CC\uC778 \uC774\uB984\xB7\uC790\uACA9 \uD45C\uC2DC \uAD8C\uC7A5",
|
|
409
|
+
check(text) {
|
|
410
|
+
if (text.length > 200 && !/(원장|대표원장|의사|박사)/.test(text)) {
|
|
411
|
+
return [
|
|
412
|
+
{
|
|
413
|
+
ruleId: "MED-012",
|
|
414
|
+
severity: "info",
|
|
415
|
+
message: "\uC758\uB8CC\uC778 \uC774\uB984\xB7\uC790\uACA9 (\uC6D0\uC7A5/\uB300\uD45C\uC6D0\uC7A5/\uC758\uC0AC \uB4F1) \uD45C\uC2DC \uAD8C\uC7A5."
|
|
416
|
+
}
|
|
417
|
+
];
|
|
418
|
+
}
|
|
419
|
+
return [];
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
id: "MED-013",
|
|
424
|
+
severity: "info",
|
|
425
|
+
description: "\uC0AC\uC804\uC2EC\uC758 \uC758\uBB34 \uAD11\uACE0 \u2014 \uC2EC\uC758\uBC88\uD638 \uD45C\uC2DC",
|
|
426
|
+
check(text) {
|
|
427
|
+
const promotional = /(전후|개선|효과|시술)/.test(text);
|
|
428
|
+
const hasReview = /심의[ ]?번호|심의필|사전심의/.test(text);
|
|
429
|
+
if (promotional && !hasReview) {
|
|
430
|
+
return [
|
|
431
|
+
{
|
|
432
|
+
ruleId: "MED-013",
|
|
433
|
+
severity: "info",
|
|
434
|
+
message: "\uC2DC\uC220 \uD6A8\uACFC\xB7\uC804\uD6C4 \uC0AC\uC9C4 \uAD11\uACE0\uB294 \uC790\uC728\uC2EC\uC758 \uD6C4 \uC2EC\uC758\uBC88\uD638 \uD45C\uC2DC \uC758\uBB34 \uAC80\uD1A0."
|
|
435
|
+
}
|
|
436
|
+
];
|
|
437
|
+
}
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
id: "MED-014",
|
|
443
|
+
severity: "warning",
|
|
444
|
+
description: "\uC2E0\uC758\uB8CC\uAE30\uC220 \uD3C9\uAC00 \uD1B5\uACFC \uC5EC\uBD80 \uD45C\uC2DC",
|
|
445
|
+
check(text) {
|
|
446
|
+
if (/신의료기술|첨단의료/.test(text) && !/평가[ ]?통과|승인/.test(text)) {
|
|
447
|
+
return [
|
|
448
|
+
{
|
|
449
|
+
ruleId: "MED-014",
|
|
450
|
+
severity: "warning",
|
|
451
|
+
message: "\uC2E0\uC758\uB8CC\uAE30\uC220\xB7\uCCA8\uB2E8\uC758\uB8CC \uD45C\uD604\uC740 \uBCF4\uAC74\uBCF5\uC9C0\uBD80 \uD3C9\uAC00 \uD1B5\uACFC \uC5EC\uBD80 \uBA85\uC2DC \uD544\uC694."
|
|
452
|
+
}
|
|
453
|
+
];
|
|
454
|
+
}
|
|
455
|
+
return [];
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
id: "MED-015",
|
|
460
|
+
severity: "warning",
|
|
461
|
+
description: "\uC784\uC0C1\uC2DC\uD5D8 \uB2E8\uACC4 \uBBF8\uD1B5\uACFC \uD45C\uD604 \uC8FC\uC758",
|
|
462
|
+
check(text) {
|
|
463
|
+
if (/임상시험|연구중/.test(text) && !/평가[ ]?완료|승인/.test(text)) {
|
|
464
|
+
return [
|
|
465
|
+
{
|
|
466
|
+
ruleId: "MED-015",
|
|
467
|
+
severity: "warning",
|
|
468
|
+
message: "\uC784\uC0C1\uC2DC\uD5D8 \uB2E8\uACC4 \uD45C\uD604\uC740 \uBBF8\uC2B9\uC778 \uD6A8\uACFC \uAD11\uACE0\uB85C \uBD84\uB958\uB420 \uC218 \uC788\uC74C."
|
|
469
|
+
}
|
|
470
|
+
];
|
|
471
|
+
}
|
|
472
|
+
return [];
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
// MED-016~020: 광고 매체 별
|
|
476
|
+
{
|
|
477
|
+
id: "MED-016",
|
|
478
|
+
severity: "info",
|
|
479
|
+
description: "\uC678\uBD80 \uB9C1\uD06C \uC758\uB8CC \uC815\uBCF4 \uC778\uC6A9 \uC2DC \uCD9C\uCC98 \uD45C\uAE30",
|
|
480
|
+
check(text) {
|
|
481
|
+
if (/(논문|연구|study)/i.test(text) && !/(출처|reference|참고문헌)/i.test(text)) {
|
|
482
|
+
return [
|
|
483
|
+
{
|
|
484
|
+
ruleId: "MED-016",
|
|
485
|
+
severity: "info",
|
|
486
|
+
message: "\uB17C\uBB38\xB7\uC5F0\uAD6C \uC778\uC6A9 \uC2DC \uCD9C\uCC98 \uBA85\uC2DC \uAD8C\uC7A5 (\uC758\uB8CC\uBC95 \xA756 \u24614 \uAC1D\uAD00\uC801 \uADFC\uAC70)."
|
|
487
|
+
}
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
ruleFromTerms(
|
|
494
|
+
"MED-017",
|
|
495
|
+
"info",
|
|
496
|
+
"\uC870\uD68C\uC218\xB7\uB9CC\uC871\uB3C4 \uC790\uB791 \uD45C\uD604 \uC8FC\uC758",
|
|
497
|
+
["\uB9CC\uC871\uB3C4 1\uC704", "\uBC29\uBB38\uC218 1\uC704", "\uC870\uD68C\uC218 1\uC704"],
|
|
498
|
+
(term) => `"${term}" \u2014 \uC790\uC728\uC2EC\uC758\uAE30\uAD6C \uC0AC\uC804\uC2EC\uC758 + \uAC1D\uAD00\uC801 \uADFC\uAC70 (\uC870\uC0AC \uCD9C\uCC98) \uBA85\uC2DC \uD544\uC694.`
|
|
499
|
+
),
|
|
500
|
+
ruleFromTerms(
|
|
501
|
+
"MED-018",
|
|
502
|
+
"warning",
|
|
503
|
+
"\uD2B9\uC218\uD6A8\uACFC \uC601\uC0C1 \uC0AC\uC6A9 \uC2DC \uD45C\uC2DC",
|
|
504
|
+
["before & after", "before and after"],
|
|
505
|
+
() => "\uC804\uD6C4 \uC0AC\uC9C4/\uC601\uC0C1 \uC0AC\uC6A9 \uC2DC \uC2DC\uC220 \uC870\uAC74 (\uCD2C\uC601 \uC870\uAC74\xB7\uAC1C\uC778\uCC28) \uBA85\uC2DC \uC758\uBB34 \uAC80\uD1A0."
|
|
506
|
+
),
|
|
507
|
+
ruleFromTerms(
|
|
508
|
+
"MED-019",
|
|
509
|
+
"warning",
|
|
510
|
+
"\uBCF4\uD5D8\xB7\uC2E4\uC190\uBCF4\uC7A5 \uAD11\uACE0 (\uC758\uB8CC\uBC95 \xA756 \u24618 \uD658\uC790 \uC720\uC778)",
|
|
511
|
+
["\uC2E4\uC190 \uBCF4\uC7A5", "\uC804\uC561 \uD658\uAE09", "\uBCF4\uD5D8 \uC801\uC6A9"],
|
|
512
|
+
(term) => `"${term}" \u2014 \uD658\uC790 \uC720\uC778 \uD45C\uD604\uC73C\uB85C \uBD84\uB958\uB420 \uC218 \uC788\uC74C. \uC0AC\uC804\uC2EC\uC758 \uD544\uC694.`
|
|
513
|
+
),
|
|
514
|
+
ruleFromTerms(
|
|
515
|
+
"MED-020",
|
|
516
|
+
"warning",
|
|
517
|
+
"\uC678\uAD6D \uC758\uB8CC\uC778 \uC790\uACA9 \uD45C\uC2DC",
|
|
518
|
+
["\uBBF8\uAD6D \uC758\uC0AC", "\uC77C\uBCF8 \uC758\uC0AC", "MD from"],
|
|
519
|
+
(term) => `"${term}" \u2014 \uC678\uAD6D \uC790\uACA9\uC740 \uAD6D\uB0B4 \uBA74\uD5C8 \uBCF4\uC720 \uC5EC\uBD80 \uBA85\uC2DC \uC758\uBB34 (\uC758\uB8CC\uBC95 \xA727).`
|
|
520
|
+
),
|
|
521
|
+
// MED-021~025: vertical 외 보강
|
|
522
|
+
ruleFromTerms(
|
|
523
|
+
"MED-021",
|
|
524
|
+
"info",
|
|
525
|
+
"\uC628\uB77C\uC778 \uC608\uC57D \uC548\uB0B4",
|
|
526
|
+
["\uC608\uC57D \uAC00\uB2A5", "\uC628\uB77C\uC778 \uC608\uC57D"],
|
|
527
|
+
() => "\uC628\uB77C\uC778 \uC608\uC57D\uC740 \uC758\uB8CC\uBC95 \xA756 \uD658\uC790 \uC720\uC778 \uAD11\uACE0 \uACBD\uACC4 \u2014 \uC0AC\uC804\uC2EC\uC758 \uAC80\uD1A0."
|
|
528
|
+
),
|
|
529
|
+
ruleFromTerms(
|
|
530
|
+
"MED-022",
|
|
531
|
+
"info",
|
|
532
|
+
"\uAD50\uD1B5 \uD3B8\uC758 \uD45C\uD604",
|
|
533
|
+
["\uC8FC\uCC28 \uBB34\uB8CC", "\uBC1C\uB81B \uD30C\uD0B9", "\uC154\uD2C0 \uC6B4\uC601"],
|
|
534
|
+
(term) => `"${term}" \u2014 \uC9C4\uB8CC \uC678 \uD61C\uD0DD \uAC15\uC870\uB294 \uD658\uC790 \uC720\uC778 \uAD11\uACE0\uB85C \uBD84\uB958\uB420 \uC218 \uC788\uC74C.`
|
|
535
|
+
),
|
|
536
|
+
{
|
|
537
|
+
id: "MED-023",
|
|
538
|
+
severity: "info",
|
|
539
|
+
description: "\uC678\uAD6D\uC5B4 \uD45C\uD604 \u2014 \uD55C\uAD6D\uC5B4 \uBCD1\uAE30 \uAD8C\uC7A5",
|
|
540
|
+
check(text) {
|
|
541
|
+
const ratio = (text.match(/[A-Za-z]/g)?.length ?? 0) / Math.max(text.length, 1);
|
|
542
|
+
if (ratio > 0.3) {
|
|
543
|
+
return [
|
|
544
|
+
{
|
|
545
|
+
ruleId: "MED-023",
|
|
546
|
+
severity: "info",
|
|
547
|
+
message: "\uC601\uBB38 \uBE44\uC728 30% \uCD08\uACFC \u2014 \uD55C\uAD6D\uC5B4 \uBCD1\uAE30 \uAD8C\uC7A5 (\uAC00\uB3C5\uC131 + \uC758\uB8CC\uAD11\uACE0 \uC2EC\uC758 \uAC00\uC774\uB4DC)."
|
|
548
|
+
}
|
|
549
|
+
];
|
|
550
|
+
}
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
ruleFromTerms(
|
|
555
|
+
"MED-024",
|
|
556
|
+
"critical",
|
|
557
|
+
"\uBE44\uAE09\uC5EC \uC9C4\uB8CC\uBE44 \uBA85\uC2DC \uB204\uB77D (\uC758\uB8CC\uBC95 \xA745)",
|
|
558
|
+
["\uBE44\uAE09\uC5EC \uC9C4\uB8CC\uBE44 \uBCC4\uB3C4 \uBB38\uC758"],
|
|
559
|
+
() => "\uBE44\uAE09\uC5EC \uC9C4\uB8CC\uBE44\uB294 \uD56D\uBAA9\xB7\uAC00\uACA9 \uBA85\uC2DC \uC758\uBB34 (\uC758\uB8CC\uBC95 \xA745, \uBE44\uAE09\uC5EC \uC9C4\uB8CC\uBE44 \uACE0\uC9C0)."
|
|
560
|
+
),
|
|
561
|
+
ruleFromTerms(
|
|
562
|
+
"MED-025",
|
|
563
|
+
"info",
|
|
564
|
+
"\uC6D0\uACA9\uC9C4\uB8CC\xB7\uBE44\uB300\uBA74 \uC9C4\uB8CC \uD45C\uD604",
|
|
565
|
+
["\uC6D0\uACA9\uC9C4\uB8CC", "\uBE44\uB300\uBA74 \uC9C4\uB8CC", "\uC628\uB77C\uC778 \uC9C4\uB8CC"],
|
|
566
|
+
(term) => `"${term}" \u2014 \uD604\uD589 \uC758\uB8CC\uBC95 \uD55C\uC2DC\uC801 \uD5C8\uC6A9 \uBC94\uC704 \uD655\uC778 + \uCD9C\uCC98 \uBA85\uC2DC \uAD8C\uC7A5.`
|
|
567
|
+
)
|
|
568
|
+
];
|
|
569
|
+
var MEDICAL_LINT_RULESET = "medical-2026-06-13";
|
|
570
|
+
function extractText(doc) {
|
|
571
|
+
const out = [];
|
|
572
|
+
const walk = (node) => {
|
|
573
|
+
if (!node || typeof node !== "object") return;
|
|
574
|
+
const n = node;
|
|
575
|
+
if (typeof n.text === "string") out.push(n.text);
|
|
576
|
+
if (Array.isArray(n.content)) n.content.forEach(walk);
|
|
577
|
+
};
|
|
578
|
+
walk(doc);
|
|
579
|
+
return out.join("\n");
|
|
580
|
+
}
|
|
581
|
+
function lintMedicalPage(input) {
|
|
582
|
+
const text = [
|
|
583
|
+
input.title,
|
|
584
|
+
input.excerpt ?? "",
|
|
585
|
+
extractText(input.contentJson)
|
|
586
|
+
].join("\n");
|
|
587
|
+
const findings = [];
|
|
588
|
+
for (const rule of MEDICAL_RULES) {
|
|
589
|
+
findings.push(...rule.check(text));
|
|
590
|
+
}
|
|
591
|
+
const counts = {
|
|
592
|
+
critical: 0,
|
|
593
|
+
warning: 0,
|
|
594
|
+
info: 0
|
|
595
|
+
};
|
|
596
|
+
for (const f of findings) counts[f.severity] += 1;
|
|
597
|
+
return { findings, counts };
|
|
598
|
+
}
|
|
599
|
+
|
|
207
600
|
// src/templates/publish-gate.ts
|
|
208
|
-
import {
|
|
209
|
-
MEDICAL_LINT_RULESET,
|
|
210
|
-
lintMedicalPage
|
|
211
|
-
} from "@roottale/audit-medical";
|
|
212
601
|
var PUBLISH_GATE_RULESET = "publish-gate-2026-07-02";
|
|
213
602
|
function isRecord2(value) {
|
|
214
603
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -219,18 +608,34 @@ function recordOrEmpty(value) {
|
|
|
219
608
|
function stringOrEmpty(value) {
|
|
220
609
|
return typeof value === "string" ? value.trim() : "";
|
|
221
610
|
}
|
|
222
|
-
|
|
611
|
+
var NON_COPY_PROP_KEYS = /* @__PURE__ */ new Set(["href", "image", "url", "src", "id", "_id"]);
|
|
612
|
+
function collectPropsCopy(value, out) {
|
|
223
613
|
if (typeof value === "string") {
|
|
224
614
|
out.push(value);
|
|
225
615
|
return;
|
|
226
616
|
}
|
|
617
|
+
if (Array.isArray(value)) {
|
|
618
|
+
for (const item of value) collectPropsCopy(item, out);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (!isRecord2(value)) return;
|
|
622
|
+
for (const [key, child] of Object.entries(value)) {
|
|
623
|
+
if (NON_COPY_PROP_KEYS.has(key)) continue;
|
|
624
|
+
collectPropsCopy(child, out);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function collectText(value, out) {
|
|
227
628
|
if (Array.isArray(value)) {
|
|
228
629
|
for (const item of value) collectText(item, out);
|
|
229
630
|
return;
|
|
230
631
|
}
|
|
231
632
|
if (!isRecord2(value)) return;
|
|
232
633
|
if (typeof value.text === "string") out.push(value.text);
|
|
233
|
-
|
|
634
|
+
if (value.type === "section") {
|
|
635
|
+
const attrs = recordOrEmpty(value.attrs);
|
|
636
|
+
collectPropsCopy(attrs.props, out);
|
|
637
|
+
}
|
|
638
|
+
if (Array.isArray(value.content)) collectText(value.content, out);
|
|
234
639
|
}
|
|
235
640
|
function textFrom(value) {
|
|
236
641
|
const out = [];
|