@roottale/cms-renderer-next 0.41.1 → 0.42.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 (60) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/before-after-client.d.ts +15 -0
  3. package/dist/before-after-client.js +8 -0
  4. package/dist/before-after-client.js.map +1 -0
  5. package/dist/chunk-4LQPZDXZ.js +75 -0
  6. package/dist/chunk-4LQPZDXZ.js.map +1 -0
  7. package/dist/chunk-6GQCFFHU.js +540 -0
  8. package/dist/chunk-6GQCFFHU.js.map +1 -0
  9. package/dist/chunk-F3G6RVIR.js +220 -0
  10. package/dist/chunk-F3G6RVIR.js.map +1 -0
  11. package/dist/chunk-H46JUIHZ.js +79 -0
  12. package/dist/chunk-H46JUIHZ.js.map +1 -0
  13. package/dist/chunk-K62RR4UQ.js +53 -0
  14. package/dist/chunk-K62RR4UQ.js.map +1 -0
  15. package/dist/chunk-NUUTTSGT.js +916 -0
  16. package/dist/chunk-NUUTTSGT.js.map +1 -0
  17. package/dist/chunk-RSNUZ4BF.js +3378 -0
  18. package/dist/chunk-RSNUZ4BF.js.map +1 -0
  19. package/dist/chunk-U5AKACPN.js +113 -0
  20. package/dist/chunk-U5AKACPN.js.map +1 -0
  21. package/dist/chunk-WWS43XCT.js +206 -0
  22. package/dist/chunk-WWS43XCT.js.map +1 -0
  23. package/dist/cms-public.css +164 -51
  24. package/dist/concerns-client.d.ts +12 -0
  25. package/dist/concerns-client.js +8 -0
  26. package/dist/concerns-client.js.map +1 -0
  27. package/dist/expertise-client.d.ts +10 -0
  28. package/dist/expertise-client.js +8 -0
  29. package/dist/expertise-client.js.map +1 -0
  30. package/dist/hero-carousel-client.d.ts +36 -0
  31. package/dist/hero-carousel-client.js +8 -0
  32. package/dist/hero-carousel-client.js.map +1 -0
  33. package/dist/routes.d.ts +51 -5
  34. package/dist/routes.js +109 -62
  35. package/dist/routes.js.map +1 -1
  36. package/dist/seo-checks.d.ts +87 -0
  37. package/dist/seo-checks.js +27 -0
  38. package/dist/seo-checks.js.map +1 -0
  39. package/dist/server.d.ts +51 -2
  40. package/dist/server.js +8 -2
  41. package/dist/site-config-DgYGGwu1.d.ts +3910 -0
  42. package/dist/spaces-client.d.ts +10 -0
  43. package/dist/spaces-client.js +8 -0
  44. package/dist/spaces-client.js.map +1 -0
  45. package/dist/templates.css +3246 -194
  46. package/dist/templates.d.ts +20 -1028
  47. package/dist/templates.js +70 -2
  48. package/dist/templates.js.map +1 -1
  49. package/dist/{theme-css-vars-BiyHBsJG.d.ts → theme-css-vars-CD3jcEkp.d.ts} +2 -2
  50. package/dist/ui-react-motion-client.d.ts +73 -0
  51. package/dist/ui-react-motion-client.js +14 -0
  52. package/dist/ui-react-motion-client.js.map +1 -0
  53. package/dist/validate.d.ts +15 -1
  54. package/dist/validate.js +806 -17
  55. package/dist/validate.js.map +1 -1
  56. package/package.json +18 -6
  57. package/dist/chunk-GOHP7RWD.js +0 -1647
  58. package/dist/chunk-GOHP7RWD.js.map +0 -1
  59. package/dist/chunk-IOYBEH6E.js +0 -271
  60. package/dist/chunk-IOYBEH6E.js.map +0 -1
package/dist/validate.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import {
2
2
  SectionSchema
3
- } from "./chunk-IOYBEH6E.js";
3
+ } from "./chunk-NUUTTSGT.js";
4
+ import {
5
+ runReadabilityChecks,
6
+ runSeoChecks
7
+ } from "./chunk-6GQCFFHU.js";
4
8
 
5
9
  // src/templates/section-locks.ts
6
10
  var CONTENT_PROP_PATHS = {
@@ -54,6 +58,46 @@ var CONTENT_PROP_PATHS = {
54
58
  ["hours", Number.NaN, "close"],
55
59
  ["hours", Number.NaN, "note"],
56
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"]
57
101
  ]
58
102
  };
59
103
  function readSectionLock(value) {
@@ -204,33 +248,703 @@ function formatSectionLockViolations(violations) {
204
248
  }).join(" / ");
205
249
  }
206
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
+
207
909
  // src/templates/publish-gate.ts
208
- import {
209
- MEDICAL_LINT_RULESET,
210
- lintMedicalPage
211
- } from "@roottale/audit-medical";
212
910
  var PUBLISH_GATE_RULESET = "publish-gate-2026-07-02";
213
- function isRecord2(value) {
911
+ function isRecord3(value) {
214
912
  return value !== null && typeof value === "object" && !Array.isArray(value);
215
913
  }
216
914
  function recordOrEmpty(value) {
217
- return isRecord2(value) ? value : {};
915
+ return isRecord3(value) ? value : {};
218
916
  }
219
917
  function stringOrEmpty(value) {
220
918
  return typeof value === "string" ? value.trim() : "";
221
919
  }
222
- function collectText(value, out) {
920
+ var NON_COPY_PROP_KEYS = /* @__PURE__ */ new Set(["href", "image", "url", "src", "id", "_id"]);
921
+ function collectPropsCopy(value, out) {
223
922
  if (typeof value === "string") {
224
923
  out.push(value);
225
924
  return;
226
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) {
227
937
  if (Array.isArray(value)) {
228
938
  for (const item of value) collectText(item, out);
229
939
  return;
230
940
  }
231
- if (!isRecord2(value)) return;
941
+ if (!isRecord3(value)) return;
232
942
  if (typeof value.text === "string") out.push(value.text);
233
- for (const child of Object.values(value)) collectText(child, out);
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);
234
948
  }
235
949
  function textFrom(value) {
236
950
  const out = [];
@@ -242,10 +956,25 @@ function seoDescription(doc) {
242
956
  const seo = recordOrEmpty(meta.seo);
243
957
  return stringOrEmpty(seo.description) || stringOrEmpty(doc.excerpt);
244
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
+ }
245
974
  function collectH1Labels(doc) {
246
975
  const labels = [];
247
976
  const walk = (node) => {
248
- if (!isRecord2(node)) return;
977
+ if (!isRecord3(node)) return;
249
978
  const type = stringOrEmpty(node.type);
250
979
  if (type === "heading") {
251
980
  const attrs = recordOrEmpty(node.attrs);
@@ -267,8 +996,19 @@ function collectH1Labels(doc) {
267
996
  walk(doc);
268
997
  return labels;
269
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
+ }
270
1009
  function assertPublishable(doc, options) {
271
1010
  const errors = [];
1011
+ const warnings = [];
272
1012
  const ruleset = { publish: PUBLISH_GATE_RULESET };
273
1013
  const h1Labels = collectH1Labels(doc.bodyJson);
274
1014
  if (options.documentType === "post") {
@@ -298,28 +1038,76 @@ function assertPublishable(doc, options) {
298
1038
  message: "\uBC1C\uD589\uD558\uB824\uBA74 SEO meta description \uB610\uB294 excerpt\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4."
299
1039
  });
300
1040
  }
301
- if (options.vertical === "medical" || options.complianceFlags?.includes("medical-review")) {
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")) {
302
1051
  ruleset.medical = MEDICAL_LINT_RULESET;
1052
+ const sectionText = textFrom(doc.bodyJson);
303
1053
  const lint = lintMedicalPage({
304
1054
  title: doc.title ?? "",
305
1055
  excerpt: doc.excerpt,
306
1056
  contentJson: {
307
1057
  type: "doc",
308
- content: [{ type: "text", text: textFrom(doc.bodyJson) }]
1058
+ content: [doc.bodyJson, { type: "text", text: sectionText }]
309
1059
  }
310
1060
  });
311
1061
  for (const finding of lint.findings) {
312
- if (finding.severity !== "critical") continue;
313
- errors.push({
1062
+ const entry = {
314
1063
  code: "MEDICAL_COMPLIANCE",
315
1064
  ruleId: finding.ruleId,
316
1065
  severity: finding.severity,
317
1066
  message: finding.message,
318
1067
  snippet: finding.snippet
319
- });
1068
+ };
1069
+ if (finding.severity === "critical") errors.push(entry);
1070
+ else warnings.push(entry);
320
1071
  }
321
1072
  }
322
- return { errors, ruleset };
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 };
323
1111
  }
324
1112
  function writePublishGateMeta(metaJson, result) {
325
1113
  const meta = { ...recordOrEmpty(metaJson) };
@@ -329,6 +1117,7 @@ function writePublishGateMeta(metaJson, result) {
329
1117
  blocked: result.errors.length > 0,
330
1118
  ruleset: result.ruleset,
331
1119
  errors: result.errors,
1120
+ warnings: result.warnings,
332
1121
  checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
333
1122
  ...result.errors.length === 0 ? { override: null } : {}
334
1123
  };