@roottale/cms-renderer-next 0.51.1 → 0.53.0

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +54 -4
  3. package/dist/{chunk-U2IM6OCQ.js → chunk-HM5VP7SR.js} +1 -1
  4. package/dist/{chunk-U2IM6OCQ.js.map → chunk-HM5VP7SR.js.map} +1 -1
  5. package/dist/chunk-KSPPYX22.js +14 -0
  6. package/dist/chunk-KSPPYX22.js.map +1 -0
  7. package/dist/cms-public.css +46 -0
  8. package/dist/routes.d.ts +25 -1
  9. package/dist/routes.js +72 -14
  10. package/dist/routes.js.map +1 -1
  11. package/dist/section-runtime.d.ts +46 -0
  12. package/dist/section-runtime.js +9 -0
  13. package/dist/server.d.ts +5 -3
  14. package/dist/server.js +1679 -34
  15. package/dist/server.js.map +1 -1
  16. package/package.json +7 -23
  17. package/dist/before-after-client.d.ts +0 -15
  18. package/dist/before-after-client.js +0 -8
  19. package/dist/chunk-27RT6B3O.js +0 -93
  20. package/dist/chunk-27RT6B3O.js.map +0 -1
  21. package/dist/chunk-2QU4JU46.js +0 -3927
  22. package/dist/chunk-2QU4JU46.js.map +0 -1
  23. package/dist/chunk-4RQ3SEDL.js +0 -106
  24. package/dist/chunk-4RQ3SEDL.js.map +0 -1
  25. package/dist/chunk-6GQCFFHU.js +0 -540
  26. package/dist/chunk-6GQCFFHU.js.map +0 -1
  27. package/dist/chunk-H46JUIHZ.js +0 -79
  28. package/dist/chunk-H46JUIHZ.js.map +0 -1
  29. package/dist/chunk-K62RR4UQ.js +0 -53
  30. package/dist/chunk-K62RR4UQ.js.map +0 -1
  31. package/dist/chunk-LEHHSI6G.js +0 -64
  32. package/dist/chunk-LEHHSI6G.js.map +0 -1
  33. package/dist/chunk-QYNEOFL7.js +0 -418
  34. package/dist/chunk-QYNEOFL7.js.map +0 -1
  35. package/dist/chunk-U5AKACPN.js +0 -113
  36. package/dist/chunk-U5AKACPN.js.map +0 -1
  37. package/dist/chunk-UM37DOIK.js +0 -961
  38. package/dist/chunk-UM37DOIK.js.map +0 -1
  39. package/dist/chunk-YLSQAT6B.js +0 -59
  40. package/dist/chunk-YLSQAT6B.js.map +0 -1
  41. package/dist/chunk-ZL2NA3QT.js +0 -313
  42. package/dist/chunk-ZL2NA3QT.js.map +0 -1
  43. package/dist/concerns-client.d.ts +0 -12
  44. package/dist/concerns-client.js +0 -8
  45. package/dist/concerns-client.js.map +0 -1
  46. package/dist/contact-map-client.d.ts +0 -27
  47. package/dist/contact-map-client.js +0 -8
  48. package/dist/contact-map-client.js.map +0 -1
  49. package/dist/expertise-client.d.ts +0 -16
  50. package/dist/expertise-client.js +0 -9
  51. package/dist/expertise-client.js.map +0 -1
  52. package/dist/hero-carousel-client.d.ts +0 -52
  53. package/dist/hero-carousel-client.js +0 -9
  54. package/dist/hero-carousel-client.js.map +0 -1
  55. package/dist/seo-checks.d.ts +0 -87
  56. package/dist/seo-checks.js +0 -27
  57. package/dist/seo-checks.js.map +0 -1
  58. package/dist/site-config-BvUyDx_f.d.ts +0 -3961
  59. package/dist/spaces-client.d.ts +0 -10
  60. package/dist/spaces-client.js +0 -8
  61. package/dist/spaces-client.js.map +0 -1
  62. package/dist/team-expanding-stage-client.d.ts +0 -35
  63. package/dist/team-expanding-stage-client.js +0 -8
  64. package/dist/team-expanding-stage-client.js.map +0 -1
  65. package/dist/templates.css +0 -4145
  66. package/dist/templates.d.ts +0 -171
  67. package/dist/templates.js +0 -291
  68. package/dist/templates.js.map +0 -1
  69. package/dist/theme-css-vars-CD3jcEkp.d.ts +0 -7
  70. package/dist/ui-react-motion-client.d.ts +0 -76
  71. package/dist/ui-react-motion-client.js +0 -16
  72. package/dist/ui-react-motion-client.js.map +0 -1
  73. package/dist/validate.d.ts +0 -104
  74. package/dist/validate.js +0 -1204
  75. package/dist/validate.js.map +0 -1
  76. /package/dist/{before-after-client.js.map → section-runtime.js.map} +0 -0
package/dist/validate.js DELETED
@@ -1,1204 +0,0 @@
1
- import {
2
- SectionSchema
3
- } from "./chunk-UM37DOIK.js";
4
- import {
5
- runReadabilityChecks,
6
- runSeoChecks
7
- } from "./chunk-6GQCFFHU.js";
8
-
9
- // src/templates/section-locks.ts
10
- var CONTENT_PROP_PATHS = {
11
- hero: [
12
- ["eyebrow"],
13
- ["title"],
14
- ["description"],
15
- ["imageSrc"],
16
- ["imageAlt"],
17
- ["cta", "label"],
18
- ["cta", "href"]
19
- ],
20
- "rich-text": [["heading"], ["body"]],
21
- services: [
22
- ["heading"],
23
- ["description"],
24
- ["items", Number.NaN, "title"],
25
- ["items", Number.NaN, "description"],
26
- ["items", Number.NaN, "href"]
27
- ],
28
- faq: [
29
- ["heading"],
30
- ["items", Number.NaN, "question"],
31
- ["items", Number.NaN, "answer"]
32
- ],
33
- "cta-banner": [["title"], ["description"], ["cta", "label"], ["cta", "href"]],
34
- "blog-list": [["heading"], ["emptyMessage"]],
35
- "lead-form": [["heading"], ["description"], ["submitLabel"]],
36
- trust: [
37
- ["heading"],
38
- ["metrics", Number.NaN, "value"],
39
- ["metrics", Number.NaN, "unit"],
40
- ["metrics", Number.NaN, "label"],
41
- ["metrics", Number.NaN, "source"],
42
- ["credentials", Number.NaN, "name"],
43
- ["credentials", Number.NaN, "issuer"],
44
- ["credentials", Number.NaN, "kind"],
45
- ["credentials", Number.NaN, "image"],
46
- ["logos", Number.NaN, "src"],
47
- ["logos", Number.NaN, "alt"],
48
- ["logos", Number.NaN, "href"],
49
- ["disclaimer"]
50
- ],
51
- contact: [
52
- ["heading"],
53
- ["form", "enabled"],
54
- ["phone"],
55
- ["address"],
56
- ["hours", Number.NaN, "day"],
57
- ["hours", Number.NaN, "open"],
58
- ["hours", Number.NaN, "close"],
59
- ["hours", Number.NaN, "note"],
60
- ["mapEmbedUrl"]
61
- ],
62
- expertise: [
63
- ["heading"],
64
- ["description"],
65
- ["items", Number.NaN, "label"],
66
- ["items", Number.NaN, "kicker"],
67
- ["items", Number.NaN, "title"],
68
- ["items", Number.NaN, "body"],
69
- ["items", Number.NaN, "imageSrc"],
70
- ["items", Number.NaN, "imageAlt"],
71
- ["items", Number.NaN, "tags", Number.NaN],
72
- ["items", Number.NaN, "href"],
73
- ["items", Number.NaN, "ctaLabel"]
74
- ],
75
- spaces: [
76
- ["heading"],
77
- ["description"],
78
- ["spaces", Number.NaN, "label"],
79
- ["spaces", Number.NaN, "kicker"],
80
- ["spaces", Number.NaN, "title"],
81
- ["spaces", Number.NaN, "description"],
82
- ["spaces", Number.NaN, "imageSrc"],
83
- ["spaces", Number.NaN, "imageAlt"],
84
- ["spaces", Number.NaN, "tags", Number.NaN],
85
- ["disclaimer"]
86
- ],
87
- concerns: [
88
- ["heading"],
89
- ["description"],
90
- ["figureImage"],
91
- ["figureAlt"],
92
- ["regions", Number.NaN, "label"],
93
- ["regions", Number.NaN, "x"],
94
- ["regions", Number.NaN, "y"],
95
- ["regions", Number.NaN, "title"],
96
- ["regions", Number.NaN, "description"],
97
- ["regions", Number.NaN, "items", Number.NaN],
98
- ["regions", Number.NaN, "href"],
99
- ["regions", Number.NaN, "ctaLabel"],
100
- ["disclaimer"]
101
- ]
102
- };
103
- function readSectionLock(value) {
104
- if (!value || typeof value !== "object") return {};
105
- const input = value;
106
- return {
107
- ...typeof input.move === "boolean" ? { move: input.move } : {},
108
- ...typeof input.remove === "boolean" ? { remove: input.remove } : {},
109
- ...typeof input.structure === "boolean" ? { structure: input.structure } : {}
110
- };
111
- }
112
- function isSectionLocked(lock) {
113
- return lock.move === true || lock.remove === true || lock.structure === true;
114
- }
115
- function valueEquals(left, right) {
116
- return JSON.stringify(left) === JSON.stringify(right);
117
- }
118
- function isRecord(value) {
119
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
120
- }
121
- function collectChangedPaths(before, after, prefix = []) {
122
- if (valueEquals(before, after)) return [];
123
- if (Array.isArray(before) && Array.isArray(after)) {
124
- if (before.length !== after.length) return [prefix];
125
- return before.flatMap(
126
- (item, index) => collectChangedPaths(item, after[index], [...prefix, index])
127
- );
128
- }
129
- if (isRecord(before) && isRecord(after)) {
130
- const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
131
- return [...keys].flatMap(
132
- (key) => collectChangedPaths(before[key], after[key], [...prefix, key])
133
- );
134
- }
135
- return [prefix];
136
- }
137
- function contentPathMatches(pattern, path) {
138
- if (pattern.length !== path.length) return false;
139
- return pattern.every((segment, index) => {
140
- const actual = path[index];
141
- return Number.isNaN(segment) ? typeof actual === "number" : segment === actual;
142
- });
143
- }
144
- function isSectionContentPropPath(sectionType, path) {
145
- const patterns = CONTENT_PROP_PATHS[sectionType] ?? [];
146
- return patterns.some((pattern) => contentPathMatches(pattern, path));
147
- }
148
- function collectSectionSnapshots(doc) {
149
- const content = doc && typeof doc === "object" ? doc.content : void 0;
150
- if (!Array.isArray(content)) return [];
151
- return content.flatMap((node, index) => {
152
- if (!node || typeof node !== "object") return [];
153
- const tiptapNode = node;
154
- if (tiptapNode.type !== "section") return [];
155
- const attrs = tiptapNode.attrs ?? {};
156
- const id = typeof attrs._id === "string" ? attrs._id : "";
157
- if (!id) return [];
158
- return [
159
- {
160
- id,
161
- index,
162
- sectionType: typeof attrs.sectionType === "string" ? attrs.sectionType : "",
163
- attrs,
164
- props: attrs.props ?? {},
165
- lock: readSectionLock(attrs.lock)
166
- }
167
- ];
168
- });
169
- }
170
- function findSectionLockViolations(beforeDoc, afterDoc) {
171
- const before = collectSectionSnapshots(beforeDoc);
172
- const after = collectSectionSnapshots(afterDoc);
173
- const afterById = new Map(after.map((section) => [section.id, section]));
174
- const beforeById = new Map(before.map((section) => [section.id, section]));
175
- const violations = [];
176
- for (const section of before) {
177
- const next = afterById.get(section.id);
178
- if (!next) {
179
- if (section.lock.remove === true) {
180
- violations.push({
181
- code: "locked_section_removed",
182
- sectionId: section.id,
183
- sectionType: section.sectionType,
184
- field: "(node)"
185
- });
186
- }
187
- continue;
188
- }
189
- if (section.lock.move === true && section.index !== next.index) {
190
- violations.push({
191
- code: "locked_section_moved",
192
- sectionId: section.id,
193
- sectionType: section.sectionType,
194
- field: "(index)"
195
- });
196
- }
197
- if (!valueEquals(section.lock, next.lock) && (isSectionLocked(section.lock) || isSectionLocked(next.lock))) {
198
- violations.push({
199
- code: "locked_section_lock_changed",
200
- sectionId: section.id,
201
- sectionType: section.sectionType,
202
- field: "lock"
203
- });
204
- }
205
- if (section.lock.structure !== true) continue;
206
- for (const field of ["_id", "sectionType", "variant", "theme"]) {
207
- if (!valueEquals(section.attrs[field], next.attrs[field])) {
208
- violations.push({
209
- code: "locked_section_structure_changed",
210
- sectionId: section.id,
211
- sectionType: section.sectionType,
212
- field
213
- });
214
- }
215
- }
216
- for (const path of collectChangedPaths(section.props, next.props)) {
217
- if (!isSectionContentPropPath(section.sectionType, path)) {
218
- violations.push({
219
- code: "locked_section_structure_changed",
220
- sectionId: section.id,
221
- sectionType: section.sectionType,
222
- field: `props.${path.join(".") || "(root)"}`
223
- });
224
- }
225
- }
226
- }
227
- for (const section of after) {
228
- if (beforeById.has(section.id) || !isSectionLocked(section.lock)) continue;
229
- violations.push({
230
- code: "locked_section_inserted",
231
- sectionId: section.id,
232
- sectionType: section.sectionType,
233
- field: "(node)"
234
- });
235
- }
236
- return violations;
237
- }
238
- function formatSectionLockViolations(violations) {
239
- return violations.map((violation) => {
240
- const label = `${violation.sectionType || "section"} \uC139\uC158(${violation.sectionId})`;
241
- if (violation.code === "locked_section_inserted") return `${label} \uCD94\uAC00`;
242
- if (violation.code === "locked_section_removed") return `${label} \uC0AD\uC81C`;
243
- if (violation.code === "locked_section_moved") return `${label} \uC774\uB3D9`;
244
- if (violation.code === "locked_section_lock_changed") {
245
- return `${label} \uC7A0\uAE08 \uBCC0\uACBD`;
246
- }
247
- return `${label} \uAD6C\uC870 \uBCC0\uACBD(${violation.field})`;
248
- }).join(" / ");
249
- }
250
-
251
- // ../audit-medical/src/index.ts
252
- function snippetFor(text, idx, term) {
253
- const start = Math.max(0, idx - 20);
254
- const end = Math.min(text.length, idx + term.length + 20);
255
- return `\u2026${text.slice(start, end)}\u2026`;
256
- }
257
- function findAll(text, terms) {
258
- const out = [];
259
- for (const term of terms) {
260
- let from = 0;
261
- while (from <= text.length) {
262
- const idx = text.indexOf(term, from);
263
- if (idx === -1) break;
264
- out.push({ term, idx });
265
- from = idx + term.length;
266
- }
267
- }
268
- return out;
269
- }
270
- function ruleFromTerms(id, severity, description, terms, message) {
271
- return {
272
- id,
273
- severity,
274
- description,
275
- check(text) {
276
- return findAll(text, terms).map(({ term, idx }) => ({
277
- ruleId: id,
278
- severity,
279
- message: message(term),
280
- snippet: snippetFor(text, idx, term)
281
- }));
282
- }
283
- };
284
- }
285
- var ABSOLUTE_SUPERLATIVES = [
286
- "\uCD5C\uACE0",
287
- "\uCD5C\uC0C1",
288
- "\uCD5C\uB300",
289
- "\uC720\uC77C\uD55C",
290
- "\uC720\uC77C\uD558\uAC8C",
291
- "\uB2E8 \uD558\uB098",
292
- "\uCD5C\uCD08\uC758",
293
- "\uB3C5\uBCF4\uC801",
294
- "\uB118\uBC84\uC6D0",
295
- "1\uC704",
296
- "\uAD6D\uB0B4 1\uC704",
297
- "\uC138\uACC4 1\uC704"
298
- ];
299
- var GUARANTEE_TERMS = [
300
- "100%",
301
- "100 %",
302
- "\uC644\uC804\uD55C",
303
- "\uC644\uBCBD\uD55C",
304
- "\uD655\uC2E4\uD55C",
305
- "\uC808\uB300",
306
- "\uBB34\uC870\uAC74",
307
- "\uBC18\uB4DC\uC2DC",
308
- "\uBCF4\uC7A5",
309
- "guarantee"
310
- ];
311
- var COMPARISON_TERMS = [
312
- "\uD0C0 \uBCD1\uC6D0\uBCF4\uB2E4",
313
- "\uB2E4\uB978 \uBCD1\uC6D0\uBCF4\uB2E4",
314
- "\uD0C0 \uC758\uC6D0\uBCF4\uB2E4",
315
- "\uD0C0 \uC758\uC6D0\uACFC \uBE44\uAD50",
316
- "\uB2E4\uB978 \uC758\uC0AC\uBCF4\uB2E4",
317
- "\uBE44\uAD50 \uC2DC"
318
- ];
319
- var RISK_OMISSION_TERMS = [
320
- "\uBD80\uC791\uC6A9 \uC5C6\uB294",
321
- "\uBD80\uC791\uC6A9 \uC5C6\uC74C",
322
- "\uC548\uC804\uD55C",
323
- "\uC548\uC2EC",
324
- "\uD1B5\uC99D \uC5C6\uB294",
325
- "\uD1B5\uC99D 0",
326
- "\uC704\uD5D8 \uC5C6\uB294"
327
- ];
328
- var CURE_PROMISE_TERMS = [
329
- "\uC644\uCE58",
330
- "\uCE58\uB8CC \uD6A8\uACFC \uBCF4\uC7A5",
331
- "\uC7AC\uBC1C \uC5C6\uB294",
332
- "\uC7AC\uBC1C 0",
333
- "100% \uCE58\uC720",
334
- "\uAE30\uC801\uC758"
335
- ];
336
- var PRICE_DISCOUNT_PROMOTION_TERMS = [
337
- "\uC774\uBCA4\uD2B8\uAC00",
338
- "\uD2B9\uBCC4 \uD560\uC778",
339
- "\uD560\uC778 \uD589\uC0AC",
340
- "\uC6D0 \u2192 ",
341
- "\uC6D0\uC73C\uB85C \uD560\uC778",
342
- "\uD30C\uACA9\uAC00",
343
- "\uCD5C\uC800\uAC00"
344
- ];
345
- var TESTIMONIAL_TERMS = ["\uD658\uC790 \uD6C4\uAE30", "\uD658\uC790 \uC0AC\uB840", "\uCE58\uB8CC \uC0AC\uB840", "\uC804\uD6C4 \uC0AC\uC9C4"];
346
- var UNAUTHORIZED_TITLE_TERMS = ["\uC804\uBB38\uBCD1\uC6D0", "\uC9C0\uC815\uBCD1\uC6D0", "\uCD5C\uACE0\uC758 \uC758\uC0AC"];
347
- var FOREIGN_PROMOTION_TERMS = ["\uC758\uB8CC\uAD00\uAD11 \uC804\uBB38", "\uC678\uAD6D\uC778 \uD658\uC790 1\uC704"];
348
- function isRecord2(value) {
349
- return value !== null && typeof value === "object" && !Array.isArray(value);
350
- }
351
- function hasBeforeAfterSection(value) {
352
- if (Array.isArray(value)) return value.some(hasBeforeAfterSection);
353
- if (!isRecord2(value)) return false;
354
- if (value.type === "before-after") return true;
355
- if (value.type === "section") {
356
- const attrs = value.attrs;
357
- if (isRecord2(attrs) && attrs.sectionType === "before-after") return true;
358
- }
359
- return Object.values(value).some(hasBeforeAfterSection);
360
- }
361
- var MEDICAL_RULES = [
362
- // MED-001~005: 절대표현 / 보장
363
- ruleFromTerms(
364
- "MED-001",
365
- "critical",
366
- "\uC808\uB300 \uC6B0\uC704 \uD45C\uD604 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u24618 \uACFC\uC7A5)",
367
- ABSOLUTE_SUPERLATIVES,
368
- (term) => `"${term}" \uAC19\uC740 \uC808\uB300 \uC6B0\uC704 \uD45C\uD604\uC740 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`
369
- ),
370
- ruleFromTerms(
371
- "MED-002",
372
- "critical",
373
- "\uCE58\uB8CC \uD6A8\uACFC \uBCF4\uC7A5 \uD45C\uD604 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u24612)",
374
- GUARANTEE_TERMS,
375
- (term) => `"${term}" \uAC19\uC740 \uBCF4\uC7A5\uC131 \uD45C\uD604\uC740 \uC758\uB8CC\uAD11\uACE0\uC5D0 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`
376
- ),
377
- ruleFromTerms(
378
- "MED-003",
379
- "critical",
380
- "\uB2E4\uB978 \uC758\uB8CC\uAE30\uAD00 \uBE44\uAD50 \uAD11\uACE0 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u24616)",
381
- COMPARISON_TERMS,
382
- (term) => `\uB2E4\uB978 \uC758\uB8CC\uAE30\uAD00\uACFC \uBE44\uAD50\uD558\uB294 \uD45C\uD604 "${term}" \uC740 \uAE08\uC9C0\uB429\uB2C8\uB2E4.`
383
- ),
384
- ruleFromTerms(
385
- "MED-004",
386
- "critical",
387
- "\uBD80\uC791\uC6A9\xB7\uC704\uD5D8 \uB204\uB77D \uD45C\uD604 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u24611)",
388
- RISK_OMISSION_TERMS,
389
- (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.`
390
- ),
391
- ruleFromTerms(
392
- "MED-005",
393
- "critical",
394
- "\uC644\uCE58\xB7\uCE58\uC720 \uBCF4\uC7A5 \uD45C\uD604 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u24617)",
395
- CURE_PROMISE_TERMS,
396
- (term) => `"${term}" \uAC19\uC740 \uC644\uCE58\xB7\uCE58\uC720 \uBCF4\uC7A5 \uD45C\uD604\uC740 \uAE08\uC9C0\uB429\uB2C8\uB2E4.`
397
- ),
398
- // MED-006~010: 가격·이벤트 광고
399
- ruleFromTerms(
400
- "MED-006",
401
- "warning",
402
- "\uD560\uC778\xB7\uC774\uBCA4\uD2B8 \uAD11\uACE0 \uD45C\uC2DC (\uC758\uB8CC\uBC95 \xA756 \u24618 \uD658\uC790 \uC720\uC778)",
403
- PRICE_DISCOUNT_PROMOTION_TERMS,
404
- (term) => `"${term}" \u2014 \uD658\uC790 \uC720\uC778 \uAD11\uACE0\uB85C \uBD84\uB958\uB420 \uC218 \uC788\uC5B4 \uC0AC\uC804\uC2EC\uC758 \uD544\uC694.`
405
- ),
406
- ruleFromTerms(
407
- "MED-007",
408
- "warning",
409
- "\uD6C4\uAE30\xB7\uC0AC\uB840 \uAD11\uACE0 (\uC758\uB8CC\uBC95 \xA756 \u24614 \uCE58\uB8CC \uACBD\uD5D8\uB2F4)",
410
- TESTIMONIAL_TERMS,
411
- (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.`
412
- ),
413
- ruleFromTerms(
414
- "MED-008",
415
- "critical",
416
- "\uBBF8\uC778\uC99D \uBA85\uCE6D \uC0AC\uC6A9 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA756 \u246110)",
417
- UNAUTHORIZED_TITLE_TERMS,
418
- (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.`
419
- ),
420
- ruleFromTerms(
421
- "MED-009",
422
- "warning",
423
- "\uC758\uB8CC\uAD00\uAD11 \uAD11\uACE0 (\uC758\uB8CC\uBC95 \xA756 \u2461 \uC678\uAD6D\uC778 \uD658\uC790 \uC720\uCE58)",
424
- FOREIGN_PROMOTION_TERMS,
425
- (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.`
426
- ),
427
- {
428
- id: "MED-010",
429
- severity: "warning",
430
- description: "\uC804\uBB38\uC758 \uC790\uACA9 \uD45C\uC2DC \uC2DC \uC9C4\uB8CC\uACFC\uBAA9 \uBA85\uC2DC \uD544\uC694",
431
- check(text) {
432
- if (/전문의/.test(text) && !/[가-힣]+과 전문의/.test(text)) {
433
- return [
434
- {
435
- ruleId: "MED-010",
436
- severity: "warning",
437
- 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')."
438
- }
439
- ];
440
- }
441
- return [];
442
- }
443
- },
444
- // MED-011~015: 필수 표시 누락
445
- {
446
- id: "MED-011",
447
- severity: "info",
448
- description: "\uC758\uB8CC\uAE30\uAD00\uBA85 \uD45C\uC2DC \uAD8C\uC7A5",
449
- check(text) {
450
- if (text.length > 100 && !/(의원|병원|치과|한의원|클리닉)/.test(text)) {
451
- return [
452
- {
453
- ruleId: "MED-011",
454
- severity: "info",
455
- 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."
456
- }
457
- ];
458
- }
459
- return [];
460
- }
461
- },
462
- {
463
- id: "MED-012",
464
- severity: "info",
465
- description: "\uC758\uB8CC\uC778 \uC774\uB984\xB7\uC790\uACA9 \uD45C\uC2DC \uAD8C\uC7A5",
466
- check(text) {
467
- if (text.length > 200 && !/(원장|대표원장|의사|박사)/.test(text)) {
468
- return [
469
- {
470
- ruleId: "MED-012",
471
- severity: "info",
472
- message: "\uC758\uB8CC\uC778 \uC774\uB984\xB7\uC790\uACA9 (\uC6D0\uC7A5/\uB300\uD45C\uC6D0\uC7A5/\uC758\uC0AC \uB4F1) \uD45C\uC2DC \uAD8C\uC7A5."
473
- }
474
- ];
475
- }
476
- return [];
477
- }
478
- },
479
- {
480
- id: "MED-013",
481
- severity: "info",
482
- description: "\uC0AC\uC804\uC2EC\uC758 \uC758\uBB34 \uAD11\uACE0 \u2014 \uC2EC\uC758\uBC88\uD638 \uD45C\uC2DC",
483
- check(text) {
484
- const promotional = /(전후|개선|효과|시술)/.test(text);
485
- const hasReview = /심의[ ]?번호|심의필|사전심의/.test(text);
486
- if (promotional && !hasReview) {
487
- return [
488
- {
489
- ruleId: "MED-013",
490
- severity: "info",
491
- 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."
492
- }
493
- ];
494
- }
495
- return [];
496
- }
497
- },
498
- {
499
- id: "MED-014",
500
- severity: "warning",
501
- description: "\uC2E0\uC758\uB8CC\uAE30\uC220 \uD3C9\uAC00 \uD1B5\uACFC \uC5EC\uBD80 \uD45C\uC2DC",
502
- check(text) {
503
- if (/신의료기술|첨단의료/.test(text) && !/평가[ ]?통과|승인/.test(text)) {
504
- return [
505
- {
506
- ruleId: "MED-014",
507
- severity: "warning",
508
- 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."
509
- }
510
- ];
511
- }
512
- return [];
513
- }
514
- },
515
- {
516
- id: "MED-015",
517
- severity: "warning",
518
- description: "\uC784\uC0C1\uC2DC\uD5D8 \uB2E8\uACC4 \uBBF8\uD1B5\uACFC \uD45C\uD604 \uC8FC\uC758",
519
- check(text) {
520
- if (/임상시험|연구중/.test(text) && !/평가[ ]?완료|승인/.test(text)) {
521
- return [
522
- {
523
- ruleId: "MED-015",
524
- severity: "warning",
525
- message: "\uC784\uC0C1\uC2DC\uD5D8 \uB2E8\uACC4 \uD45C\uD604\uC740 \uBBF8\uC2B9\uC778 \uD6A8\uACFC \uAD11\uACE0\uB85C \uBD84\uB958\uB420 \uC218 \uC788\uC74C."
526
- }
527
- ];
528
- }
529
- return [];
530
- }
531
- },
532
- // MED-016~020: 광고 매체 별
533
- {
534
- id: "MED-016",
535
- severity: "info",
536
- description: "\uC678\uBD80 \uB9C1\uD06C \uC758\uB8CC \uC815\uBCF4 \uC778\uC6A9 \uC2DC \uCD9C\uCC98 \uD45C\uAE30",
537
- check(text) {
538
- if (/(논문|연구|study)/i.test(text) && !/(출처|reference|참고문헌)/i.test(text)) {
539
- return [
540
- {
541
- ruleId: "MED-016",
542
- severity: "info",
543
- 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)."
544
- }
545
- ];
546
- }
547
- return [];
548
- }
549
- },
550
- ruleFromTerms(
551
- "MED-017",
552
- "info",
553
- "\uC870\uD68C\uC218\xB7\uB9CC\uC871\uB3C4 \uC790\uB791 \uD45C\uD604 \uC8FC\uC758",
554
- ["\uB9CC\uC871\uB3C4 1\uC704", "\uBC29\uBB38\uC218 1\uC704", "\uC870\uD68C\uC218 1\uC704"],
555
- (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.`
556
- ),
557
- ruleFromTerms(
558
- "MED-018",
559
- "warning",
560
- "\uD2B9\uC218\uD6A8\uACFC \uC601\uC0C1 \uC0AC\uC6A9 \uC2DC \uD45C\uC2DC",
561
- ["before & after", "before and after"],
562
- () => "\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."
563
- ),
564
- ruleFromTerms(
565
- "MED-019",
566
- "warning",
567
- "\uBCF4\uD5D8\xB7\uC2E4\uC190\uBCF4\uC7A5 \uAD11\uACE0 (\uC758\uB8CC\uBC95 \xA756 \u24618 \uD658\uC790 \uC720\uC778)",
568
- ["\uC2E4\uC190 \uBCF4\uC7A5", "\uC804\uC561 \uD658\uAE09", "\uBCF4\uD5D8 \uC801\uC6A9"],
569
- (term) => `"${term}" \u2014 \uD658\uC790 \uC720\uC778 \uD45C\uD604\uC73C\uB85C \uBD84\uB958\uB420 \uC218 \uC788\uC74C. \uC0AC\uC804\uC2EC\uC758 \uD544\uC694.`
570
- ),
571
- ruleFromTerms(
572
- "MED-020",
573
- "warning",
574
- "\uC678\uAD6D \uC758\uB8CC\uC778 \uC790\uACA9 \uD45C\uC2DC",
575
- ["\uBBF8\uAD6D \uC758\uC0AC", "\uC77C\uBCF8 \uC758\uC0AC", "MD from"],
576
- (term) => `"${term}" \u2014 \uC678\uAD6D \uC790\uACA9\uC740 \uAD6D\uB0B4 \uBA74\uD5C8 \uBCF4\uC720 \uC5EC\uBD80 \uBA85\uC2DC \uC758\uBB34 (\uC758\uB8CC\uBC95 \xA727).`
577
- ),
578
- // MED-021~025: vertical 외 보강
579
- ruleFromTerms(
580
- "MED-021",
581
- "info",
582
- "\uC628\uB77C\uC778 \uC608\uC57D \uC548\uB0B4",
583
- ["\uC608\uC57D \uAC00\uB2A5", "\uC628\uB77C\uC778 \uC608\uC57D"],
584
- () => "\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."
585
- ),
586
- ruleFromTerms(
587
- "MED-022",
588
- "info",
589
- "\uAD50\uD1B5 \uD3B8\uC758 \uD45C\uD604",
590
- ["\uC8FC\uCC28 \uBB34\uB8CC", "\uBC1C\uB81B \uD30C\uD0B9", "\uC154\uD2C0 \uC6B4\uC601"],
591
- (term) => `"${term}" \u2014 \uC9C4\uB8CC \uC678 \uD61C\uD0DD \uAC15\uC870\uB294 \uD658\uC790 \uC720\uC778 \uAD11\uACE0\uB85C \uBD84\uB958\uB420 \uC218 \uC788\uC74C.`
592
- ),
593
- {
594
- id: "MED-023",
595
- severity: "info",
596
- description: "\uC678\uAD6D\uC5B4 \uD45C\uD604 \u2014 \uD55C\uAD6D\uC5B4 \uBCD1\uAE30 \uAD8C\uC7A5",
597
- check(text) {
598
- const ratio = (text.match(/[A-Za-z]/g)?.length ?? 0) / Math.max(text.length, 1);
599
- if (ratio > 0.3) {
600
- return [
601
- {
602
- ruleId: "MED-023",
603
- severity: "info",
604
- 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)."
605
- }
606
- ];
607
- }
608
- return [];
609
- }
610
- },
611
- ruleFromTerms(
612
- "MED-024",
613
- "critical",
614
- "\uBE44\uAE09\uC5EC \uC9C4\uB8CC\uBE44 \uBA85\uC2DC \uB204\uB77D (\uC758\uB8CC\uBC95 \xA745)",
615
- ["\uBE44\uAE09\uC5EC \uC9C4\uB8CC\uBE44 \uBCC4\uB3C4 \uBB38\uC758"],
616
- () => "\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)."
617
- ),
618
- ruleFromTerms(
619
- "MED-025",
620
- "info",
621
- "\uC6D0\uACA9\uC9C4\uB8CC\xB7\uBE44\uB300\uBA74 \uC9C4\uB8CC \uD45C\uD604",
622
- ["\uC6D0\uACA9\uC9C4\uB8CC", "\uBE44\uB300\uBA74 \uC9C4\uB8CC", "\uC628\uB77C\uC778 \uC9C4\uB8CC"],
623
- (term) => `"${term}" \u2014 \uD604\uD589 \uC758\uB8CC\uBC95 \uD55C\uC2DC\uC801 \uD5C8\uC6A9 \uBC94\uC704 \uD655\uC778 + \uCD9C\uCC98 \uBA85\uC2DC \uAD8C\uC7A5.`
624
- ),
625
- {
626
- id: "MED-026",
627
- severity: "critical",
628
- description: "\uC758\uB8CC \uC0AC\uC774\uD2B8 \uCE58\uB8CC \uC804\uD6C4 \uC0AC\uC9C4 \uC139\uC158 \uC0AC\uC6A9 \uAE08\uC9C0",
629
- check(_text, contentJson) {
630
- if (!hasBeforeAfterSection(contentJson)) return [];
631
- return [
632
- {
633
- ruleId: "MED-026",
634
- severity: "critical",
635
- message: "\uC758\uB8CC \uC0AC\uC774\uD2B8\uC5D0\uB294 before-after \uC139\uC158\uC744 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uCE58\uB8CC \uC804\uD6C4 \uC0AC\uC9C4 \uAD11\uACE0\uB294 \uC758\uB8CC\uAD11\uACE0\uBC95\uC0C1 \uC81C\uD55C\uB429\uB2C8\uB2E4."
636
- }
637
- ];
638
- }
639
- },
640
- {
641
- id: "MED-027",
642
- severity: "critical",
643
- description: "\uD658\uC790 \uC720\uC778 \u2014 \uC9C4\uB8CC\xB7\uC2DC\uC220 \uBB34\uB8CC \uC81C\uACF5 \uD45C\uD604 \uAE08\uC9C0 (\uC758\uB8CC\uBC95 \xA727 \u2462)",
644
- check(text) {
645
- const findings = [];
646
- const re = /(무료|공짜)\s*(시술|수술|검진|진료|치료)/g;
647
- let match;
648
- while ((match = re.exec(text)) !== null) {
649
- const before = text.slice(Math.max(0, match.index - 14), match.index);
650
- if (/(국가|국민건강보험|건강보험공단|공단|보건소)[^\n.]{0,8}$/.test(before)) {
651
- continue;
652
- }
653
- findings.push({
654
- ruleId: "MED-027",
655
- severity: "critical",
656
- message: `"${match[0]}" \u2014 \uBCF8\uC778\uBD80\uB2F4\uAE08 \uBA74\uC81C\xB7\uBB34\uB8CC \uC9C4\uB8CC \uC81C\uACF5 \uD45C\uD604\uC740 \uD658\uC790 \uC720\uC778 \uD589\uC704\uB85C \uAE08\uC9C0\uB429\uB2C8\uB2E4 (\uC758\uB8CC\uBC95 \xA727 \u2462).`,
657
- snippet: snippetFor(text, match.index, match[0])
658
- });
659
- }
660
- return findings;
661
- }
662
- }
663
- ];
664
- var MEDICAL_LINT_RULESET = "medical-2026-07-11";
665
- function extractText(doc) {
666
- const out = [];
667
- const walk = (node) => {
668
- if (!node || typeof node !== "object") return;
669
- const n = node;
670
- if (typeof n.text === "string") out.push(n.text);
671
- if (Array.isArray(n.content)) n.content.forEach(walk);
672
- };
673
- walk(doc);
674
- return out.join("\n");
675
- }
676
- function lintMedicalPage(input) {
677
- const text = [
678
- input.title,
679
- input.excerpt ?? "",
680
- extractText(input.contentJson)
681
- ].join("\n");
682
- const findings = [];
683
- for (const rule of MEDICAL_RULES) {
684
- findings.push(...rule.check(text, input.contentJson));
685
- }
686
- const counts = {
687
- critical: 0,
688
- warning: 0,
689
- info: 0
690
- };
691
- for (const f of findings) counts[f.severity] += 1;
692
- return { findings, counts };
693
- }
694
-
695
- // ../audit-tax/src/index.ts
696
- function snippetFor2(text, idx, term) {
697
- const start = Math.max(0, idx - 20);
698
- const end = Math.min(text.length, idx + term.length + 20);
699
- return `\u2026${text.slice(start, end)}\u2026`;
700
- }
701
- function findAll2(text, terms) {
702
- const out = [];
703
- for (const term of terms) {
704
- let from = 0;
705
- while (from <= text.length) {
706
- const idx = text.indexOf(term, from);
707
- if (idx === -1) break;
708
- out.push({ term, idx });
709
- from = idx + term.length;
710
- }
711
- }
712
- return out;
713
- }
714
- function ruleFromTerms2(id, severity, description, terms, message) {
715
- return {
716
- id,
717
- severity,
718
- description,
719
- check(text) {
720
- return findAll2(text, terms).map(({ term, idx }) => ({
721
- ruleId: id,
722
- severity,
723
- message: message(term),
724
- snippet: snippetFor2(text, idx, term)
725
- }));
726
- }
727
- };
728
- }
729
- var SUPERLATIVES = ["\uCD5C\uACE0\uC758 \uC138\uBB34\uC0AC", "\uCD5C\uB300 \uD658\uAE09", "\uC720\uC77C\uD55C", "1\uC704 \uC138\uBB34\uC0AC", "\uB118\uBC84\uC6D0"];
730
- var GUARANTEE_TERMS2 = [
731
- "\uD658\uAE09 \uBCF4\uC7A5",
732
- "100% \uD658\uAE09",
733
- "\uC808\uC138 \uBCF4\uC7A5",
734
- "\uC808\uB300 \uD658\uAE09",
735
- "\uC808\uC138 100%"
736
- ];
737
- var COMPARISON_TERMS2 = ["\uB2E4\uB978 \uC138\uBB34\uC0AC\uBCF4\uB2E4", "\uD0C0 \uC138\uBB34\uBC95\uC778\uBCF4\uB2E4"];
738
- var REFUND_PROMISE_TERMS = ["\uD658\uAE09\uB960 1\uC704", "\uCD5C\uB300 \uD658\uAE09\uC561 \uBCF4\uC7A5", "\uC218\uC2ED\uC5B5 \uD658\uAE09"];
739
- var PROMOTION_TERMS = [
740
- "\uBB34\uB8CC \uC0C1\uB2F4 + \uD560\uC778",
741
- "\uC218\uC218\uB8CC 50% \uD560\uC778",
742
- "\uC218\uC784\uB8CC \uBA74\uC81C",
743
- // 시행령 6호 — 객관적 근거 없는 무료·최저가 표시 (2026-07-11)
744
- "\uC218\uC784\uB8CC \uBB34\uB8CC",
745
- "\uCD5C\uC800 \uC218\uC218\uB8CC",
746
- "\uCD5C\uC800\uAC00"
747
- ];
748
- var TAX_RULES = [
749
- ruleFromTerms2(
750
- "TAX-001",
751
- "critical",
752
- "\uC808\uB300 \uC6B0\uC704 \uD45C\uD604 \uAE08\uC9C0 (\uC138\uBB34\uC0AC\uBC95 \uC81C12\uC870\uC7587 \u24613 \uACFC\uC7A5\xB7\uC624\uB3C4)",
753
- SUPERLATIVES,
754
- (term) => `"${term}" \uAC19\uC740 \uC808\uB300 \uC6B0\uC704 \uD45C\uD604\uC740 \uAD11\uACE0\uC5D0 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`
755
- ),
756
- ruleFromTerms2(
757
- "TAX-002",
758
- "critical",
759
- "\uACB0\uACFC \uBCF4\uC7A5 \uD45C\uD604 \uAE08\uC9C0 (\uC138\uBB34\uC0AC\uBC95 \uC81C12\uC870\uC7587 \u24614 \uBD80\uB2F9\uD55C \uAE30\uB300)",
760
- GUARANTEE_TERMS2,
761
- (term) => `"${term}" \u2014 \uD658\uAE09\xB7\uC808\uC138 \uACB0\uACFC \uBCF4\uC7A5 \uAD11\uACE0\uB294 \uAE08\uC9C0\uB429\uB2C8\uB2E4.`
762
- ),
763
- ruleFromTerms2(
764
- "TAX-003",
765
- "critical",
766
- "\uB2E4\uB978 \uC138\uBB34\uC0AC \uBE44\uAD50 \uAD11\uACE0 \uAE08\uC9C0 (\uC138\uBB34\uC0AC\uBC95 \uC81C12\uC870\uC7587 \u24615 \uBE44\uBC29\xB7\uBE44\uAD50)",
767
- COMPARISON_TERMS2,
768
- (term) => `\uB2E4\uB978 \uC138\uBB34\uC0AC\xB7\uC138\uBB34\uBC95\uC778 \uBE44\uAD50 \uD45C\uD604 "${term}" \uC740 \uAE08\uC9C0\uB429\uB2C8\uB2E4.`
769
- ),
770
- ruleFromTerms2(
771
- "TAX-004",
772
- "warning",
773
- "\uD658\uAE09\uC561 \uAC15\uC870 (\uC2DC\uD589\uB839 \xA733\u24612 \u2014 \uC0B0\uC815\uAE30\uC900\xB7\uC801\uC6A9\uB300\uC0C1\xB7\uC870\uAC74 \uD45C\uC2DC \uC758\uBB34)",
774
- REFUND_PROMISE_TERMS,
775
- (term) => `"${term}" \u2014 \uD658\uAE09\uC561 \uAC15\uC870\uB294 \uD3C9\uADE0\xB7\uC911\uAC04\uAC12 + \uCD9C\uCC98 \uBA85\uC2DC \uD544\uC694 (\uC724\uB9AC\uADDC\uC815).`
776
- ),
777
- ruleFromTerms2(
778
- "TAX-005",
779
- "warning",
780
- "\uD560\uC778\xB7\uBB34\uB8CC\xB7\uCD5C\uC800\uAC00 \uAD11\uACE0 (\uC2DC\uD589\uB839 \xA733\u24616 \u2014 \uADFC\uAC70 \uC5C6\uB294 \uBB34\uB8CC\xB7\uCD5C\uC800\uAC00)",
781
- PROMOTION_TERMS,
782
- (term) => `"${term}" \u2014 \uC218\uC784\uB8CC \uBA74\uC81C\xB7\uACFC\uB3C4\uD55C \uD560\uC778\uC740 \uC720\uC778\uC131 \uAD11\uACE0\uB85C \uBD84\uB958 \uAC00\uB2A5.`
783
- ),
784
- {
785
- id: "TAX-006",
786
- severity: "info",
787
- description: "\uC138\uBB34\uC0AC \uC790\uACA9 \uBA85\uC2DC \uAD8C\uC7A5",
788
- check(text) {
789
- if (text.length > 100 && !/세무사|세무법인|회계사|CPA/.test(text)) {
790
- return [
791
- {
792
- ruleId: "TAX-006",
793
- severity: "info",
794
- message: "\uC138\uBB34\uC0AC \uC790\uACA9 (\uC138\uBB34\uC0AC/\uC138\uBB34\uBC95\uC778/\uD68C\uACC4\uC0AC \uB4F1) \uBA85\uC2DC \uAD8C\uC7A5."
795
- }
796
- ];
797
- }
798
- return [];
799
- }
800
- },
801
- {
802
- id: "TAX-007",
803
- severity: "warning",
804
- description: "\uC0AC\uBB34\uC18C\uBA85\xB7\uB4F1\uB85D \uC131\uBA85 \uD45C\uC2DC \uC758\uBB34 (\uC2DC\uD589\uB839 \xA733\u2460)",
805
- check(text) {
806
- if (text.length > 200 && !/사무소|법인|등록번호/.test(text)) {
807
- return [
808
- {
809
- ruleId: "TAX-007",
810
- severity: "warning",
811
- message: "\uAD11\uACE0\uC5D0\uB294 \uC0AC\uBB34\uC18C\uBA85\xB7\uB4F1\uB85D\uB41C \uC131\uBA85 \uD45C\uC2DC\uAC00 \uC758\uBB34\uC785\uB2C8\uB2E4 (\uC138\uBB34\uC0AC\uBC95 \uC2DC\uD589\uB839, 2026-06-24 \uC2DC\uD589)."
812
- }
813
- ];
814
- }
815
- return [];
816
- }
817
- },
818
- ruleFromTerms2(
819
- "TAX-008",
820
- "warning",
821
- "\uD6C4\uAE30\xB7\uC0AC\uB840 \uAD11\uACE0 \u2014 \uD3C9\uADE0/\uD55C\uACC4 \uBA85\uC2DC \uD544\uC694",
822
- ["\uD658\uAE09 \uC0AC\uB840", "\uACE0\uAC1D \uD6C4\uAE30", "\uC131\uACF5 \uC0AC\uB840"],
823
- (term) => `"${term}" \u2014 \uAC1C\uBCC4 \uC0AC\uB840\uB294 \uD3C9\uADE0\xB7\uD55C\uACC4 (\uAC1C\uC778\uCC28) \uBA85\uC2DC \uD544\uC694 (\uC724\uB9AC\uADDC\uC815).`
824
- ),
825
- ruleFromTerms2(
826
- "TAX-009",
827
- "info",
828
- "\uC678\uAD6D\uC5B4 \uC790\uACA9 \uD45C\uC2DC \u2014 \uAD6D\uB0B4 \uC790\uACA9 \uBCD1\uAE30",
829
- ["US CPA", "AICPA", "Big4"],
830
- (term) => `"${term}" \u2014 \uC678\uAD6D \uC790\uACA9 \uD45C\uC2DC \uC2DC \uAD6D\uB0B4 \uC138\uBB34\uC0AC \uC790\uACA9 \uBCF4\uC720 \uC5EC\uBD80 \uBA85\uC2DC.`
831
- ),
832
- {
833
- id: "TAX-010",
834
- severity: "critical",
835
- description: "\uC808\uB300 \uC6B0\uC704 \uD45C\uD604 \u2014 \uD6C4\uCE58\uD615 \uC5B4\uC21C ('\uC138\uBB34 \uC0C1\uB2F4 1\uC704', TAX-001 \uBCF4\uC644)",
836
- check(text) {
837
- const re = /(세무사?|세무법인|기장|상담)[^\n.]{0,10}?(1위|넘버원)/g;
838
- const findings = [];
839
- let match;
840
- while ((match = re.exec(text)) !== null) {
841
- findings.push({
842
- ruleId: "TAX-010",
843
- severity: "critical",
844
- message: `"${match[0]}" \u2014 \uD6C4\uCE58\uD615 \uC808\uB300 \uC6B0\uC704 \uD45C\uD604\uB3C4 \uAD11\uACE0\uC5D0 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 (\uC138\uBB34\uC0AC\uBC95 \uC81C12\uC870\uC7587 \u24613).`,
845
- snippet: snippetFor2(text, match.index, match[0])
846
- });
847
- }
848
- return findings;
849
- }
850
- },
851
- {
852
- id: "TAX-011",
853
- severity: "critical",
854
- description: "\uC138\uBB34\uACF5\uBB34\uC6D0 \uC5F0\uACE0 \uC120\uC804 \uAE08\uC9C0 (\uC138\uBB34\uC0AC\uBC95 \xA714\uC7582 + \uC2DC\uD589\uB839 \xA733\u24611)",
855
- check(text) {
856
- const re = /(국세청|세무서|세무공무원|국세공무원)[^\n.]{0,10}?(인맥|연줄|연고)/g;
857
- const findings = [];
858
- let match;
859
- while ((match = re.exec(text)) !== null) {
860
- findings.push({
861
- ruleId: "TAX-011",
862
- severity: "critical",
863
- message: `"${match[0]}" \u2014 \uC138\uBB34\uACF5\uBB34\uC6D0\uACFC\uC758 \uC5F0\uACE0 \uB4F1 \uC0AC\uC801 \uAD00\uACC4\uB85C \uC601\uD5A5\uB825\uC744 \uBBF8\uCE60 \uC218 \uC788\uB294 \uAC83\uCC98\uB7FC \uC120\uC804\uD558\uB294 \uAD11\uACE0\uB294 \uAE08\uC9C0\uB429\uB2C8\uB2E4 (\uC138\uBB34\uC0AC\uBC95 \xA714\uC7582).`,
864
- snippet: snippetFor2(text, match.index, match[0])
865
- });
866
- }
867
- return findings;
868
- }
869
- },
870
- ruleFromTerms2(
871
- "TAX-012",
872
- "critical",
873
- "\uD589\uC815\uCC98\uBD84\xB7\uD310\uACB0 \uB4F1 \uACB0\uACFC \uC608\uCE21 \uAD11\uACE0 \uAE08\uC9C0 (\uC2DC\uD589\uB839 \xA733\u24615)",
874
- ["\uC2B9\uC18C \uBCF4\uC7A5", "\uC778\uC6A9 \uBCF4\uC7A5", "\uCDE8\uC18C \uBCF4\uC7A5", "\uC138\uBB34\uC870\uC0AC \uBB34\uB9C8", "\uD658\uAE09 \uD655\uC815"],
875
- (term) => `"${term}" \u2014 \uD589\uC815\uAE30\uAD00 \uCC98\uBD84\xB7\uBC95\uC6D0 \uD310\uACB0 \uB4F1\uC758 \uACB0\uACFC\uB97C \uC608\uCE21\xB7\uD655\uC815\uD558\uB294 \uAD11\uACE0\uB294 \uAE08\uC9C0\uB429\uB2C8\uB2E4 (\uC138\uBB34\uC0AC\uBC95 \uC2DC\uD589\uB839, \uC0BC\uCA5C\uC0BC \uACFC\uC9D5\uAE08 \uC120\uB840).`
876
- )
877
- ];
878
- var TAX_LINT_RULESET = "tax-2026-07-11b";
879
- function extractText2(doc) {
880
- const out = [];
881
- const walk = (node) => {
882
- if (!node || typeof node !== "object") return;
883
- const n = node;
884
- if (typeof n.text === "string") out.push(n.text);
885
- if (Array.isArray(n.content)) n.content.forEach(walk);
886
- };
887
- walk(doc);
888
- return out.join("\n");
889
- }
890
- function lintTaxPage(input) {
891
- const text = [
892
- input.title,
893
- input.excerpt ?? "",
894
- extractText2(input.contentJson)
895
- ].join("\n");
896
- const findings = [];
897
- for (const rule of TAX_RULES) {
898
- findings.push(...rule.check(text));
899
- }
900
- const counts = {
901
- critical: 0,
902
- warning: 0,
903
- info: 0
904
- };
905
- for (const f of findings) counts[f.severity] += 1;
906
- return { findings, counts };
907
- }
908
-
909
- // src/templates/publish-gate.ts
910
- var PUBLISH_GATE_RULESET = "publish-gate-2026-07-02";
911
- function isRecord3(value) {
912
- return value !== null && typeof value === "object" && !Array.isArray(value);
913
- }
914
- function recordOrEmpty(value) {
915
- return isRecord3(value) ? value : {};
916
- }
917
- function stringOrEmpty(value) {
918
- return typeof value === "string" ? value.trim() : "";
919
- }
920
- var NON_COPY_PROP_KEYS = /* @__PURE__ */ new Set(["href", "image", "url", "src", "id", "_id"]);
921
- function collectPropsCopy(value, out) {
922
- if (typeof value === "string") {
923
- out.push(value);
924
- return;
925
- }
926
- if (Array.isArray(value)) {
927
- for (const item of value) collectPropsCopy(item, out);
928
- return;
929
- }
930
- if (!isRecord3(value)) return;
931
- for (const [key, child] of Object.entries(value)) {
932
- if (NON_COPY_PROP_KEYS.has(key)) continue;
933
- collectPropsCopy(child, out);
934
- }
935
- }
936
- function collectText(value, out) {
937
- if (Array.isArray(value)) {
938
- for (const item of value) collectText(item, out);
939
- return;
940
- }
941
- if (!isRecord3(value)) return;
942
- if (typeof value.text === "string") out.push(value.text);
943
- if (value.type === "section") {
944
- const attrs = recordOrEmpty(value.attrs);
945
- collectPropsCopy(attrs.props, out);
946
- }
947
- if (Array.isArray(value.content)) collectText(value.content, out);
948
- }
949
- function textFrom(value) {
950
- const out = [];
951
- collectText(value, out);
952
- return out.join("\n");
953
- }
954
- function seoDescription(doc) {
955
- const meta = recordOrEmpty(doc.metaJson);
956
- const seo = recordOrEmpty(meta.seo);
957
- return stringOrEmpty(seo.description) || stringOrEmpty(doc.excerpt);
958
- }
959
- function toSeoCheckInput(doc) {
960
- const meta = recordOrEmpty(doc.metaJson);
961
- const seo = recordOrEmpty(meta.seo);
962
- return {
963
- title: stringOrEmpty(doc.title),
964
- seoTitle: stringOrEmpty(seo.title),
965
- seoDescription: stringOrEmpty(seo.description),
966
- excerpt: stringOrEmpty(doc.excerpt),
967
- slug: doc.slug?.trim() ?? "",
968
- bodyJson: doc.bodyJson,
969
- hasFeaturedImage: doc.hasFeaturedImage === true,
970
- canonical: typeof seo.canonical === "string" ? seo.canonical : void 0,
971
- noindex: seo.noindex === true
972
- };
973
- }
974
- function collectH1Labels(doc) {
975
- const labels = [];
976
- const walk = (node) => {
977
- if (!isRecord3(node)) return;
978
- const type = stringOrEmpty(node.type);
979
- if (type === "heading") {
980
- const attrs = recordOrEmpty(node.attrs);
981
- if (Number(attrs.level ?? 2) === 1) labels.push(textFrom(node));
982
- }
983
- if (type === "section") {
984
- const attrs = recordOrEmpty(node.attrs);
985
- if (attrs.sectionType === "hero") {
986
- const props = recordOrEmpty(attrs.props);
987
- const title = stringOrEmpty(props.title);
988
- if (title) labels.push(title);
989
- }
990
- }
991
- const content = node.content;
992
- if (Array.isArray(content)) {
993
- for (const child of content) walk(child);
994
- }
995
- };
996
- walk(doc);
997
- return labels;
998
- }
999
- function hasBeforeAfterSection2(value) {
1000
- if (Array.isArray(value)) return value.some(hasBeforeAfterSection2);
1001
- if (!isRecord3(value)) return false;
1002
- if (value.type === "before-after") return true;
1003
- if (value.type === "section") {
1004
- const attrs = recordOrEmpty(value.attrs);
1005
- return attrs.sectionType === "before-after";
1006
- }
1007
- return Object.values(value).some(hasBeforeAfterSection2);
1008
- }
1009
- function assertPublishable(doc, options) {
1010
- const errors = [];
1011
- const warnings = [];
1012
- const ruleset = { publish: PUBLISH_GATE_RULESET };
1013
- const h1Labels = collectH1Labels(doc.bodyJson);
1014
- if (options.documentType === "post") {
1015
- if (h1Labels.length !== 0) {
1016
- errors.push({
1017
- code: "SEO_H1_COUNT",
1018
- message: `post \uB294 \uC81C\uBAA9\uC774 h1 \uC774\uBA70 \uBCF8\uBB38\uC5D0 h1 \uAE08\uC9C0, \uD604\uC7AC ${h1Labels.length}\uAC1C\uC785\uB2C8\uB2E4.`
1019
- });
1020
- }
1021
- } else {
1022
- if (h1Labels.length !== 1) {
1023
- errors.push({
1024
- code: "SEO_H1_COUNT",
1025
- message: `h1\uC740 \uC815\uD655\uD788 1\uAC1C\uC5EC\uC57C \uD569\uB2C8\uB2E4. \uD604\uC7AC ${h1Labels.length}\uAC1C\uC785\uB2C8\uB2E4.`
1026
- });
1027
- }
1028
- }
1029
- if (!stringOrEmpty(doc.title)) {
1030
- errors.push({
1031
- code: "SEO_TITLE_REQUIRED",
1032
- message: "\uBC1C\uD589\uD558\uB824\uBA74 \uAE00 \uC81C\uBAA9\uC774 \uD544\uC694\uD569\uB2C8\uB2E4."
1033
- });
1034
- }
1035
- if (!seoDescription(doc)) {
1036
- errors.push({
1037
- code: "SEO_META_DESCRIPTION_REQUIRED",
1038
- message: "\uBC1C\uD589\uD558\uB824\uBA74 SEO meta description \uB610\uB294 excerpt\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4."
1039
- });
1040
- }
1041
- const vertical = stringOrEmpty(options.vertical);
1042
- const includesBeforeAfter = hasBeforeAfterSection2(doc.bodyJson);
1043
- if (!vertical && includesBeforeAfter) {
1044
- errors.push({
1045
- code: "VERTICAL_UNKNOWN",
1046
- severity: "warning",
1047
- message: "before-after \uC139\uC158\uC774 \uC788\uC9C0\uB9CC \uC0AC\uC774\uD2B8 vertical \uC744 \uD655\uC778\uD560 \uC218 \uC5C6\uC5B4 \uBC1C\uD589\uC744 \uCC28\uB2E8\uD569\uB2C8\uB2E4."
1048
- });
1049
- }
1050
- if (vertical === "medical" || options.complianceFlags?.includes("medical-review")) {
1051
- ruleset.medical = MEDICAL_LINT_RULESET;
1052
- const sectionText = textFrom(doc.bodyJson);
1053
- const lint = lintMedicalPage({
1054
- title: doc.title ?? "",
1055
- excerpt: doc.excerpt,
1056
- contentJson: {
1057
- type: "doc",
1058
- content: [doc.bodyJson, { type: "text", text: sectionText }]
1059
- }
1060
- });
1061
- for (const finding of lint.findings) {
1062
- const entry = {
1063
- code: "MEDICAL_COMPLIANCE",
1064
- ruleId: finding.ruleId,
1065
- severity: finding.severity,
1066
- message: finding.message,
1067
- snippet: finding.snippet
1068
- };
1069
- if (finding.severity === "critical") errors.push(entry);
1070
- else warnings.push(entry);
1071
- }
1072
- }
1073
- if (vertical === "tax" || options.complianceFlags?.includes("tax-review")) {
1074
- ruleset.tax = TAX_LINT_RULESET;
1075
- const sectionText = textFrom(doc.bodyJson);
1076
- const lint = lintTaxPage({
1077
- title: doc.title ?? "",
1078
- excerpt: doc.excerpt,
1079
- contentJson: {
1080
- type: "doc",
1081
- content: [doc.bodyJson, { type: "text", text: sectionText }]
1082
- }
1083
- });
1084
- for (const finding of lint.findings) {
1085
- const entry = {
1086
- code: "TAX_COMPLIANCE",
1087
- ruleId: finding.ruleId,
1088
- severity: finding.severity,
1089
- message: finding.message,
1090
- snippet: finding.snippet
1091
- };
1092
- if (finding.severity === "critical") errors.push(entry);
1093
- else warnings.push(entry);
1094
- }
1095
- }
1096
- const seoCheckInput = toSeoCheckInput(doc);
1097
- const seoFindings = [
1098
- ...runSeoChecks(seoCheckInput),
1099
- ...runReadabilityChecks(seoCheckInput.bodyJson)
1100
- ];
1101
- for (const finding of seoFindings) {
1102
- if (finding.level !== "bad") continue;
1103
- warnings.push({
1104
- code: "SEO_CHECK",
1105
- ruleId: finding.id,
1106
- severity: "warning",
1107
- message: finding.hint
1108
- });
1109
- }
1110
- return { errors, warnings, ruleset };
1111
- }
1112
- function writePublishGateMeta(metaJson, result) {
1113
- const meta = { ...recordOrEmpty(metaJson) };
1114
- const current = recordOrEmpty(meta.compliance);
1115
- meta.compliance = {
1116
- ...current,
1117
- blocked: result.errors.length > 0,
1118
- ruleset: result.ruleset,
1119
- errors: result.errors,
1120
- warnings: result.warnings,
1121
- checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
1122
- ...result.errors.length === 0 ? { override: null } : {}
1123
- };
1124
- return meta;
1125
- }
1126
- function writePublishOverrideMeta(metaJson, override) {
1127
- const meta = { ...recordOrEmpty(metaJson) };
1128
- const compliance = recordOrEmpty(meta.compliance);
1129
- meta.compliance = {
1130
- ...compliance,
1131
- blocked: false,
1132
- override
1133
- };
1134
- return meta;
1135
- }
1136
- function isComplianceBlocked(metaJson) {
1137
- const compliance = recordOrEmpty(recordOrEmpty(metaJson).compliance);
1138
- return compliance.blocked === true && !hasPublishOverride(metaJson);
1139
- }
1140
- function hasPublishOverride(metaJson) {
1141
- const compliance = recordOrEmpty(recordOrEmpty(metaJson).compliance);
1142
- const override = recordOrEmpty(compliance.override);
1143
- return stringOrEmpty(override.reason).length > 0 && stringOrEmpty(override.userId).length > 0;
1144
- }
1145
-
1146
- // src/templates/validate-sections.ts
1147
- function collectSectionNodes(doc) {
1148
- const content = doc && typeof doc === "object" ? doc.content : void 0;
1149
- if (!Array.isArray(content)) return [];
1150
- const out = [];
1151
- content.forEach((node, index) => {
1152
- if (node && typeof node === "object" && node.type === "section") {
1153
- out.push({ index, node });
1154
- }
1155
- });
1156
- return out;
1157
- }
1158
- function sectionInputFromAttrs(attrs) {
1159
- const a = attrs ?? {};
1160
- return {
1161
- type: typeof a.sectionType === "string" ? a.sectionType : "",
1162
- variant: a.variant ?? void 0,
1163
- theme: a.theme ?? void 0,
1164
- props: a.props ?? {}
1165
- };
1166
- }
1167
- function validateSectionNodes(doc) {
1168
- const sections = collectSectionNodes(doc);
1169
- const issues = [];
1170
- for (const { index, node } of sections) {
1171
- const input = sectionInputFromAttrs(node.attrs);
1172
- const result = SectionSchema.safeParse(input);
1173
- if (!result.success) {
1174
- for (const issue of result.error.issues) {
1175
- issues.push({
1176
- index,
1177
- sectionType: input.type || "(\uBBF8\uC9C0\uC815)",
1178
- field: issue.path.join(".") || "(root)",
1179
- message: issue.message
1180
- });
1181
- }
1182
- }
1183
- }
1184
- return { valid: issues.length === 0, issues };
1185
- }
1186
- function formatSectionIssues(issues) {
1187
- return issues.map(
1188
- (i) => `${i.sectionType} \uC139\uC158(${i.index + 1}\uBC88\uC9F8)\uC758 ${i.field}: ${i.message}`
1189
- ).join(" / ");
1190
- }
1191
- export {
1192
- PUBLISH_GATE_RULESET,
1193
- assertPublishable,
1194
- findSectionLockViolations,
1195
- formatSectionIssues,
1196
- formatSectionLockViolations,
1197
- hasPublishOverride,
1198
- isComplianceBlocked,
1199
- isSectionContentPropPath,
1200
- validateSectionNodes,
1201
- writePublishGateMeta,
1202
- writePublishOverrideMeta
1203
- };
1204
- //# sourceMappingURL=validate.js.map