1
0

validate-swiss-deck.mjs 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. #!/usr/bin/env node
  2. import { readFileSync } from 'node:fs';
  3. const file = process.argv[2];
  4. const allowExperimental = process.argv.includes('--allow-experimental');
  5. if (!file) {
  6. console.error('Usage: node scripts/validate-swiss-deck.mjs <index.html> [--allow-experimental]');
  7. process.exit(2);
  8. }
  9. const html = readFileSync(file, 'utf8');
  10. const htmlForSlides = html.replace(/<!--[\s\S]*?-->/g, '');
  11. const errors = [];
  12. const warnings = [];
  13. const allowedLayouts = new Set([
  14. 'SWISS-COVER-ASCII',
  15. 'SWISS-CLOSING-ASCII',
  16. ...Array.from({ length: 22 }, (_, i) => `S${String(i + 1).padStart(2, '0')}`),
  17. ]);
  18. const slideRe = /<section\b[^>]*class="[^"]*\bslide\b[^"]*"[^>]*>[\s\S]*?<\/section>/g;
  19. const slides = [...htmlForSlides.matchAll(slideRe)].map((m, idx) => ({ idx: idx + 1, html: m[0], tag: m[0].match(/<section\b[^>]*>/)?.[0] ?? '' }));
  20. if (!slides.length) {
  21. errors.push('No <section class="slide"> pages found.');
  22. }
  23. slides.forEach((slide) => {
  24. const layout = slide.tag.match(/\bdata-layout="([^"]+)"/)?.[1];
  25. if (!layout) {
  26. errors.push(`Slide ${slide.idx}: missing data-layout. Swiss locked mode requires S01-S22 or SWISS-COVER-ASCII/SWISS-CLOSING-ASCII.`);
  27. } else if (!allowedLayouts.has(layout)) {
  28. errors.push(`Slide ${slide.idx}: data-layout="${layout}" is not registered in swiss-layout-lock.md.`);
  29. }
  30. if (!allowExperimental && /\bdata-layout="P2[34]\b|Swiss Image Split|Swiss Evidence Grid|swiss-img-split|swiss-img-grid/.test(slide.html)) {
  31. errors.push(`Slide ${slide.idx}: uses experimental P23/P24 image structure. Use S22 or S15/S16 image-grid adaptations instead.`);
  32. }
  33. const isStatement = layout === 'S03' || layout === 'S09' || layout === 'S10' || layout === 'SWISS-COVER-ASCII' || layout === 'SWISS-CLOSING-ASCII';
  34. const topChunk = slide.html.slice(0, 1800);
  35. if (!isStatement && /text-align\s*:\s*center/i.test(topChunk)) {
  36. errors.push(`Slide ${slide.idx}: top title area contains text-align:center. Swiss body titles should stay left aligned.`);
  37. }
  38. if (!isStatement && /align-self\s*:\s*center/i.test(topChunk) && /<h[12]\b/i.test(topChunk)) {
  39. errors.push(`Slide ${slide.idx}: top heading appears vertically/centrally aligned. Use the original left-top title skeleton.`);
  40. }
  41. if (!isStatement && /grid-template-columns\s*:\s*[0-9.]+fr\s+[0-9.]+fr/i.test(topChunk) && /<h[12]\b/i.test(topChunk)) {
  42. warnings.push(`Slide ${slide.idx}: heading inside a custom fr/fr grid. Confirm this is copied from the original Sxx skeleton, not a centered title hack.`);
  43. }
  44. if (/<svg\b[\s\S]*?<text\b/i.test(slide.html)) {
  45. errors.push(`Slide ${slide.idx}: SVG contains visible <text>. Put labels in HTML grid/captions, keep SVG for geometry only.`);
  46. }
  47. const localImages = [...slide.html.matchAll(/<img\b[^>]*src="images\//g)];
  48. localImages.forEach((_, imageIndex) => {
  49. const imgTag = slide.html.slice(_.index, slide.html.indexOf('>', _.index) + 1);
  50. if (!/\bdata-image-slot="/.test(imgTag)) {
  51. errors.push(`Slide ${slide.idx}: local image ${imageIndex + 1} missing data-image-slot. Bind every image to a layout slot such as s22-hero-21x9 or s15-grid-21x9.`);
  52. }
  53. });
  54. const frameImageRe = /<div\b(?=[^>]*\bclass="([^"]*\bframe-img\b[^"]*)")[^>]*>\s*<img\b(?=[^>]*\bdata-image-slot="([^"]+)")[^>]*>/g;
  55. const frameImages = [...slide.html.matchAll(frameImageRe)];
  56. frameImages.forEach((match) => {
  57. const className = match[1];
  58. const slot = match[2];
  59. const frameTag = match[0].match(/^<div\b[^>]*>/)?.[0] ?? '';
  60. if (/^s1[56]-(?:grid|brief)-21x9$/.test(slot)) {
  61. if (/\bfit-contain\b/.test(className)) {
  62. errors.push(`Slide ${slide.idx}: ${slot} uses fit-contain. Regenerated S15/S16 21:9 images should fill the slot with .frame-img.r-21x9.`);
  63. }
  64. if (!/\br-21x9\b/.test(className)) {
  65. errors.push(`Slide ${slide.idx}: ${slot} must use .frame-img.r-21x9 so the image slot controls the visible size.`);
  66. }
  67. if (/height\s*:\s*\d+(?:\.\d+)?vh/i.test(frameTag)) {
  68. errors.push(`Slide ${slide.idx}: ${slot} frame has a fixed vh height. Use aspect-ratio .r-21x9 instead of shrinking long images into a short slot.`);
  69. }
  70. }
  71. });
  72. if (layout === 'S22') {
  73. if (!/data-image-slot="s22-hero-21x9"/.test(slide.html)) {
  74. errors.push(`Slide ${slide.idx}: S22 must use data-image-slot="s22-hero-21x9".`);
  75. }
  76. if (/object-position\s*:\s*top center/i.test(slide.html)) {
  77. errors.push(`Slide ${slide.idx}: S22 photo uses object-position:top center, which commonly crops faces. Use center 35% or center center.`);
  78. }
  79. }
  80. });
  81. if (warnings.length) {
  82. console.warn('Warnings:');
  83. for (const warning of warnings) console.warn(`- ${warning}`);
  84. }
  85. if (errors.length) {
  86. console.error('Swiss deck validation failed:');
  87. for (const error of errors) console.error(`- ${error}`);
  88. process.exit(1);
  89. }
  90. console.log(`Swiss deck validation passed: ${slides.length} slide(s).`);