@saeroon/cli 0.2.3 → 0.2.5

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 (3) hide show
  1. package/README.md +21 -3
  2. package/dist/index.js +397 -8
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -54,6 +54,7 @@ npx @saeroon/cli deploy --target production
54
54
  | `add` | schema.json에 블록 추가 (기본 props + 부모 children 자동 연결) |
55
55
  | `upload` | 이미지를 Saeroon CDN에 업로드. `--replace-in`으로 경로 자동 교체 |
56
56
  | `generate` | AI로 사이트 스키마 생성 (`--ref` URL 분석 / `--prompt` 텍스트) |
57
+ | `analyze` | 레퍼런스 URL 분석 (스크린샷 + DOM/CSS + 비디오 감지 + Pexels 스톡 매칭) |
57
58
  | `compare` | 레퍼런스 ↔ 프리뷰 시각 비교 (Playwright 스크린샷 diff) |
58
59
 
59
60
  ### Deployment
@@ -89,9 +90,9 @@ init ──→ preview ──→ validate ──→ deploy --target staging
89
90
  ### AI-Assisted Creation
90
91
 
91
92
  ```
92
- generate --ref <url> ──→ validate --local ──→ add <블록> ──→ preview
93
- or
94
- generate --prompt <text> upload ./assets/
93
+ analyze <url> ──→ generate --ref <url> ──→ validate --local ──→ preview
94
+ (스크린샷 + or
95
+ 비디오 매칭) generate --prompt <text> upload ./assets/
95
96
  --replace-in schema.json
96
97
 
97
98
  compare --ref ──→ deploy
@@ -197,6 +198,10 @@ npx @saeroon/cli deploy --dry-run
197
198
  ~/.saeroon/config.json ← API 키, 기본 설정 (login으로 생성)
198
199
  ./saeroon.config.json ← 프로젝트별 siteId, templateId (init으로 생성)
199
200
  ./schema.json ← 사이트 스키마 정의
201
+
202
+ # 환경변수
203
+ SAEROON_API_KEY ← Saeroon API Key
204
+ PEXELS_API_KEY ← Pexels API Key (비디오 스톡 검색, 무료 — pexels.com/api)
200
205
  ```
201
206
 
202
207
  ## Block Scaffolding
@@ -214,6 +219,19 @@ npx @saeroon/cli add image-block --parent root --after hero-1
214
219
  npx @saeroon/cli add content-showcase --id insights --parent main --page about
215
220
  ```
216
221
 
222
+ ## Reference Analysis
223
+
224
+ ```bash
225
+ # 레퍼런스 URL 분석 (스크린샷 4장 + DOM/CSS + 비디오 감지)
226
+ npx @saeroon/cli analyze https://example.com
227
+
228
+ # 업종 지정 (비디오 검색 키워드에 사용)
229
+ npx @saeroon/cli analyze https://example.com --industry cafe
230
+
231
+ # Pexels API Key 설정 시 비디오 자동 매칭 (video-stock-map.json 생성)
232
+ # 설정: PEXELS_API_KEY 환경변수 또는 .saeroon/config.json의 pexelsApiKey
233
+ ```
234
+
217
235
  ## AI Generation
218
236
 
219
237
  ```bash
package/dist/index.js CHANGED
@@ -98,6 +98,12 @@ async function getApiBaseUrl() {
98
98
  const url = config.apiBaseUrl || DEFAULT_API_BASE_URL;
99
99
  return validateApiBaseUrl(url);
100
100
  }
101
+ async function resolvePexelsApiKey() {
102
+ const envKey = process.env.PEXELS_API_KEY;
103
+ if (envKey) return envKey;
104
+ const config = await loadConfig();
105
+ return config.pexelsApiKey ?? null;
106
+ }
101
107
  var PRIVATE_IP_PATTERNS = [
102
108
  /^10\./,
103
109
  /^172\.(1[6-9]|2\d|3[01])\./,
@@ -2468,6 +2474,51 @@ async function commandPreview(schemaPath, options) {
2468
2474
  import chalk8 from "chalk";
2469
2475
 
2470
2476
  // src/lib/local-validator.ts
2477
+ var ELEMENT_TYPE_PROPS = {
2478
+ "heading-block.level": {
2479
+ validValues: /* @__PURE__ */ new Set(["h1", "h2", "h3", "h4", "h5", "h6"]),
2480
+ coerce: (v) => typeof v === "number" && v >= 1 && v <= 6 ? `h${v}` : null
2481
+ },
2482
+ "container.semanticTag": {
2483
+ validValues: /* @__PURE__ */ new Set([
2484
+ "section",
2485
+ "article",
2486
+ "aside",
2487
+ "figure",
2488
+ "figcaption",
2489
+ "ul",
2490
+ "ol",
2491
+ "li",
2492
+ "dl",
2493
+ "dt",
2494
+ "dd",
2495
+ "table",
2496
+ "thead",
2497
+ "tbody",
2498
+ "tfoot",
2499
+ "tr",
2500
+ "td",
2501
+ "th",
2502
+ "details",
2503
+ "summary",
2504
+ "dialog",
2505
+ "form",
2506
+ "fieldset",
2507
+ "legend",
2508
+ "nav",
2509
+ "main",
2510
+ "header",
2511
+ "footer",
2512
+ "address",
2513
+ "blockquote",
2514
+ "pre",
2515
+ "code",
2516
+ "progress",
2517
+ "meter"
2518
+ ]),
2519
+ coerce: () => null
2520
+ }
2521
+ };
2471
2522
  var VALID_BLOCK_TYPES = /* @__PURE__ */ new Set([
2472
2523
  // Primitives (10)
2473
2524
  "container",
@@ -2593,6 +2644,42 @@ function validateSchemaLocal(schema) {
2593
2644
  step: 2
2594
2645
  });
2595
2646
  }
2647
+ const props = block.props;
2648
+ if (props) {
2649
+ for (const [key, rule] of Object.entries(ELEMENT_TYPE_PROPS)) {
2650
+ const [targetType, propName] = key.split(".");
2651
+ if (blockType !== targetType) continue;
2652
+ const value = props[propName];
2653
+ if (value == null) continue;
2654
+ if (typeof value === "string") {
2655
+ if (!rule.validValues.has(value)) {
2656
+ errors.push({
2657
+ severity: "error",
2658
+ message: `\uBE14\uB85D "${blockId}": ${propName} \uAC12 "${value}"\uC774(\uAC00) \uC720\uD6A8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uD5C8\uC6A9: ${[...rule.validValues].join(", ")}`,
2659
+ path: `${blockPath}.props.${propName}`,
2660
+ step: 2
2661
+ });
2662
+ }
2663
+ } else {
2664
+ const coerced = rule.coerce?.(value);
2665
+ if (coerced && rule.validValues.has(coerced)) {
2666
+ errors.push({
2667
+ severity: "warning",
2668
+ message: `\uBE14\uB85D "${blockId}": ${propName} \uAC12 ${JSON.stringify(value)}\uC740(\uB294) \uBB38\uC790\uC5F4\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4. "${coerced}"\uB85C \uBCC0\uD658\uD558\uC138\uC694. (\uB80C\uB354\uB9C1 \uD06C\uB798\uC2DC \uC704\uD5D8)`,
2669
+ path: `${blockPath}.props.${propName}`,
2670
+ step: 2
2671
+ });
2672
+ } else {
2673
+ errors.push({
2674
+ severity: "error",
2675
+ message: `\uBE14\uB85D "${blockId}": ${propName} \uAC12 ${JSON.stringify(value)}\uC740(\uB294) \uC720\uD6A8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uBB38\uC790\uC5F4\uC774\uC5B4\uC57C \uD558\uBA70 \uD5C8\uC6A9: ${[...rule.validValues].join(", ")}`,
2676
+ path: `${blockPath}.props.${propName}`,
2677
+ step: 2
2678
+ });
2679
+ }
2680
+ }
2681
+ }
2682
+ }
2596
2683
  if (block.children && Array.isArray(block.children)) {
2597
2684
  for (const childId of block.children) {
2598
2685
  if (typeof childId === "string" && !blocks[childId]) {
@@ -3827,7 +3914,7 @@ async function commandGenerate(options) {
3827
3914
  // src/commands/analyze.ts
3828
3915
  import chalk15 from "chalk";
3829
3916
  import { resolve as resolve13, join as join2 } from "path";
3830
- import { mkdir as mkdir4 } from "fs/promises";
3917
+ import { mkdir as mkdir4, writeFile as writeFile8 } from "fs/promises";
3831
3918
 
3832
3919
  // src/scripts/analyze-reference.ts
3833
3920
  import { spawnSync } from "child_process";
@@ -4153,12 +4240,94 @@ var EXTRACTION_SCRIPT = `
4153
4240
  });
4154
4241
  });
4155
4242
 
4156
- // \u2500\u2500 5. Gaps (V2 \uBE14\uB85D\uC73C\uB85C \uB9E4\uD551 \uBD88\uAC00\uB2A5\uD55C \uAE30\uB2A5) \u2500\u2500
4243
+ // \u2500\u2500 5. Videos \u2500\u2500
4244
+ const videoEls = document.querySelectorAll('video, iframe[src*="youtube"], iframe[src*="youtu.be"], iframe[src*="vimeo"], iframe[data-src*="youtube"], iframe[data-src*="vimeo"]');
4245
+ const videos = [];
4246
+
4247
+ videoEls.forEach(el => {
4248
+ const tag = el.tagName.toLowerCase();
4249
+ let src = '';
4250
+ let type = 'inline';
4251
+ let platform = 'unknown';
4252
+ let autoplay = false;
4253
+ let loop = false;
4254
+ let muted = false;
4255
+ let posterSrc = '';
4256
+
4257
+ if (tag === 'video') {
4258
+ src = el.src || el.querySelector('source')?.src || el.dataset.src || '';
4259
+ posterSrc = el.poster || '';
4260
+ autoplay = el.hasAttribute('autoplay');
4261
+ loop = el.hasAttribute('loop');
4262
+ muted = el.hasAttribute('muted');
4263
+ platform = 'direct';
4264
+
4265
+ // background \uBE44\uB514\uC624 \uD310\uC815: autoplay+muted+loop \uB610\uB294 CSS\uB85C \uBC30\uACBD \uCC98\uB9AC\uB41C \uACBD\uC6B0
4266
+ if ((autoplay && muted) || el.closest('[class*=hero], [class*=banner], [class*=bg-video], [class*=video-bg], [class*=background]')) {
4267
+ type = 'background';
4268
+ }
4269
+ } else if (tag === 'iframe') {
4270
+ src = el.src || el.dataset.src || '';
4271
+ if (/youtube.com|youtu.be/.test(src)) {
4272
+ platform = 'youtube';
4273
+ type = 'embed';
4274
+ // YouTube autoplay \uD30C\uB77C\uBBF8\uD130 \uAC10\uC9C0
4275
+ autoplay = /autoplay=1/.test(src);
4276
+ muted = /mute=1/.test(src);
4277
+ loop = /loop=1/.test(src);
4278
+ // YouTube thumbnail \uCD94\uCD9C
4279
+ const ytMatch = src.match(/(?:embed\\/|v=|youtu\\.be\\/)([a-zA-Z0-9_-]{11})/);
4280
+ if (ytMatch) posterSrc = 'https://img.youtube.com/vi/' + ytMatch[1] + '/hqdefault.jpg';
4281
+ } else if (/vimeo.com/.test(src)) {
4282
+ platform = 'vimeo';
4283
+ type = 'embed';
4284
+ autoplay = /autoplay=1/.test(src);
4285
+ muted = /muted=1/.test(src);
4286
+ loop = /loop=1/.test(src);
4287
+ }
4288
+ }
4289
+
4290
+ if (!src) return;
4291
+
4292
+ const rect = el.getBoundingClientRect();
4293
+ const width = Math.round(rect.width) || 0;
4294
+ const height = Math.round(rect.height) || 0;
4295
+
4296
+ // \uBD80\uBAA8 \uC139\uC158 \uC5ED\uD560 \uCD94\uB860
4297
+ const parentSection = el.closest('section, header, footer, main, [class*=hero], [class*=banner]');
4298
+ const position = parentSection ? inferSectionRole(parentSection) : 'unknown';
4299
+
4300
+ // \uC8FC\uBCC0 \uD14D\uC2A4\uD2B8\uC5D0\uC11C \uB9E5\uB77D \uCD94\uCD9C
4301
+ const nearby = (el.parentElement?.textContent || '').trim().slice(0, 150);
4302
+ const title = el.getAttribute('title') || el.getAttribute('aria-label') || '';
4303
+ const context = (title || nearby).slice(0, 150);
4304
+
4305
+ // background \uBE44\uB514\uC624 \uC911 full-width\uC778 \uACBD\uC6B0 \uBCF4\uAC15
4306
+ if (type === 'inline' && width > window.innerWidth * 0.9 && height > 300) {
4307
+ type = 'background';
4308
+ }
4309
+
4310
+ videos.push({
4311
+ src: src.slice(0, 500),
4312
+ type,
4313
+ platform,
4314
+ aspectRatio: guessAspectRatio(width, height),
4315
+ posterSrc: posterSrc.slice(0, 500),
4316
+ position,
4317
+ context: context.slice(0, 150),
4318
+ autoplay,
4319
+ loop,
4320
+ muted,
4321
+ });
4322
+ });
4323
+
4324
+ // \u2500\u2500 6. Gaps (V2 \uBE14\uB85D\uC73C\uB85C \uB9E4\uD551 \uBD88\uAC00\uB2A5\uD55C \uAE30\uB2A5) \u2500\u2500
4157
4325
  const gaps = [];
4158
4326
 
4159
- // video background
4160
- if (document.querySelector('video[autoplay], video[muted]')) {
4161
- gaps.push('video-background-autoplay: \uC790\uB3D9 \uC7AC\uC0DD \uBE44\uB514\uC624 \uBC30\uACBD');
4327
+ // video background (\uBE44\uB514\uC624 \uAC10\uC9C0\uB294 \uC139\uC158 5\uC5D0\uC11C \uCC98\uB9AC, gap\uC5D0\uB294 \uBBF8\uC9C0\uC6D0 \uD328\uD134\uB9CC \uAE30\uB85D)
4328
+ const hasScrollScrubVideo = document.querySelector('video[data-scroll-scrub], [class*=scroll-video]');
4329
+ if (hasScrollScrubVideo) {
4330
+ gaps.push('video-scroll-scrub: \uC2A4\uD06C\uB864 \uC5F0\uB3D9 \uBE44\uB514\uC624 \uC7AC\uC0DD (scroll scrub)');
4162
4331
  }
4163
4332
  // canvas / WebGL
4164
4333
  if (document.querySelector('canvas')) {
@@ -4240,6 +4409,7 @@ var EXTRACTION_SCRIPT = `
4240
4409
  detectedTransitions: [...transitionProps].slice(0, 20),
4241
4410
  },
4242
4411
  images,
4412
+ videos,
4243
4413
  gaps,
4244
4414
  };
4245
4415
  })()
@@ -4371,10 +4541,144 @@ function createFallbackData() {
4371
4541
  detectedTransitions: []
4372
4542
  },
4373
4543
  images: [],
4544
+ videos: [],
4374
4545
  gaps: []
4375
4546
  };
4376
4547
  }
4377
4548
 
4549
+ // src/lib/pexels-client.ts
4550
+ var PexelsClient = class {
4551
+ apiKey;
4552
+ baseUrl = "https://api.pexels.com/videos";
4553
+ constructor(apiKey) {
4554
+ this.apiKey = apiKey;
4555
+ }
4556
+ /**
4557
+ * 키워드로 비디오를 검색합니다.
4558
+ */
4559
+ async searchVideos(options) {
4560
+ const params = new URLSearchParams({
4561
+ query: options.query,
4562
+ per_page: String(options.perPage ?? 1)
4563
+ });
4564
+ if (options.orientation) params.set("orientation", options.orientation);
4565
+ if (options.minDuration) params.set("min_duration", String(options.minDuration));
4566
+ if (options.maxDuration) params.set("max_duration", String(options.maxDuration));
4567
+ const url = `${this.baseUrl}/search?${params}`;
4568
+ const res = await fetch(url, {
4569
+ headers: { Authorization: this.apiKey }
4570
+ });
4571
+ if (!res.ok) {
4572
+ if (res.status === 429) {
4573
+ throw new Error("Pexels API rate limit \uCD08\uACFC. \uC7A0\uC2DC \uD6C4 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694.");
4574
+ }
4575
+ throw new Error(`Pexels API \uC624\uB958: ${res.status} ${res.statusText}`);
4576
+ }
4577
+ return res.json();
4578
+ }
4579
+ /**
4580
+ * 키워드로 비디오를 검색하여 최적의 파일 URL을 반환합니다.
4581
+ *
4582
+ * 선택 기준:
4583
+ * 1. HD 품질 우선 (sd < hd < uhd)
4584
+ * 2. mp4 파일 타입 우선
4585
+ * 3. 가로형(landscape) 기본 선호
4586
+ */
4587
+ async findVideo(options) {
4588
+ const response = await this.searchVideos(options);
4589
+ if (response.videos.length === 0) return null;
4590
+ const video = response.videos[0];
4591
+ const file = pickBestFile(video.video_files);
4592
+ if (!file) return null;
4593
+ return {
4594
+ src: file.link,
4595
+ poster: video.image,
4596
+ pexelsUrl: video.url,
4597
+ width: file.width,
4598
+ height: file.height,
4599
+ duration: video.duration
4600
+ };
4601
+ }
4602
+ /**
4603
+ * 여러 키워드를 순차적으로 시도하여 첫 번째 매칭되는 비디오를 반환합니다.
4604
+ * 업종 키워드 → 일반 키워드 순으로 fallback.
4605
+ */
4606
+ async findVideoWithFallback(queries, options) {
4607
+ for (const query of queries) {
4608
+ const result = await this.findVideo({ ...options, query });
4609
+ if (result) return result;
4610
+ }
4611
+ return null;
4612
+ }
4613
+ };
4614
+ var QUALITY_RANK = { sd: 0, hd: 1, uhd: 2 };
4615
+ function pickBestFile(files) {
4616
+ const mp4Files = files.filter((f) => f.file_type === "video/mp4");
4617
+ const pool = mp4Files.length > 0 ? mp4Files : files;
4618
+ return pool.sort((a, b) => {
4619
+ const qualityDiff = (QUALITY_RANK[b.quality] ?? 0) - (QUALITY_RANK[a.quality] ?? 0);
4620
+ if (qualityDiff !== 0) return qualityDiff;
4621
+ if (a.quality === "uhd" && b.quality !== "uhd") return 1;
4622
+ if (b.quality === "uhd" && a.quality !== "uhd") return -1;
4623
+ return b.width * b.height - a.width * a.height;
4624
+ })[0];
4625
+ }
4626
+ function buildVideoSearchQueries(industry, sectionRole, videoType) {
4627
+ const queries = [];
4628
+ const industryKeywords = {
4629
+ // 외식
4630
+ cafe: ["cafe interior", "coffee shop", "barista coffee"],
4631
+ restaurant: ["restaurant kitchen", "dining food", "chef cooking"],
4632
+ bakery: ["bakery bread", "pastry baking", "fresh bread"],
4633
+ bar: ["cocktail bar", "bartender mixing", "bar nightlife"],
4634
+ // 뷰티/건강
4635
+ salon: ["hair salon", "beauty salon", "hairdresser styling"],
4636
+ spa: ["spa massage", "wellness relaxation", "spa treatment"],
4637
+ gym: ["fitness gym", "workout training", "gym exercise"],
4638
+ clinic: ["medical clinic", "healthcare professional", "doctor clinic"],
4639
+ dental: ["dental clinic", "dentist office", "dental care"],
4640
+ dermatology: ["skincare treatment", "dermatology clinic", "skin care"],
4641
+ // 전문 서비스
4642
+ law: ["law office", "legal professional", "law firm"],
4643
+ accounting: ["accounting office", "financial planning", "business meeting"],
4644
+ consulting: ["business consulting", "corporate meeting", "professional office"],
4645
+ realestate: ["real estate", "property tour", "house interior"],
4646
+ // 교육
4647
+ academy: ["classroom learning", "education study", "student learning"],
4648
+ kindergarten: ["children playing", "kids education", "nursery school"],
4649
+ // 소매/서비스
4650
+ shop: ["retail store", "shopping", "product display"],
4651
+ flower: ["flower arrangement", "florist shop", "floral bouquet"],
4652
+ pet: ["pet grooming", "pet care", "cute animals"],
4653
+ cleaning: ["cleaning service", "professional cleaning", "clean home"],
4654
+ moving: ["moving service", "packing boxes", "new home"],
4655
+ // 테크
4656
+ tech: ["technology innovation", "digital workspace", "coding computer"],
4657
+ saas: ["software dashboard", "digital technology", "cloud computing"],
4658
+ startup: ["startup team", "innovation workspace", "creative office"]
4659
+ };
4660
+ const keywords = industryKeywords[industry] ?? [`${industry} professional`, `${industry} business`];
4661
+ if (videoType === "background") {
4662
+ queries.push(...keywords);
4663
+ if (sectionRole === "hero") {
4664
+ queries.push(`${industry} aerial`, `${industry} cinematic`);
4665
+ }
4666
+ queries.push("abstract background loop", "minimal background");
4667
+ } else {
4668
+ if (sectionRole === "hero") {
4669
+ queries.push(...keywords);
4670
+ } else if (sectionRole === "features" || sectionRole === "gallery") {
4671
+ queries.push(...keywords.slice(0, 2));
4672
+ } else if (sectionRole === "testimonials") {
4673
+ queries.push("people talking", "customer interview");
4674
+ } else {
4675
+ queries.push(...keywords.slice(0, 1));
4676
+ }
4677
+ queries.push(`${industry} video`);
4678
+ }
4679
+ return queries;
4680
+ }
4681
+
4378
4682
  // src/commands/analyze.ts
4379
4683
  async function commandAnalyze(url, options) {
4380
4684
  console.log(chalk15.bold("\n\uB808\uD37C\uB7F0\uC2A4 \uBD84\uC11D (Reference Analysis)\n"));
@@ -4447,6 +4751,42 @@ async function commandAnalyze(url, options) {
4447
4751
  Object.entries(roleCount).forEach(([role, count]) => {
4448
4752
  console.log(chalk15.dim(` ${role}: ${count}\uAC1C`));
4449
4753
  });
4754
+ console.log("");
4755
+ console.log(chalk15.bold("\uBE44\uB514\uC624"));
4756
+ if (analysis.videos.length > 0) {
4757
+ const typeCount = {};
4758
+ analysis.videos.forEach((v) => {
4759
+ typeCount[v.type] = (typeCount[v.type] || 0) + 1;
4760
+ });
4761
+ console.log(chalk15.dim(` \uCD1D ${analysis.videos.length}\uAC1C \uAC10\uC9C0`));
4762
+ Object.entries(typeCount).forEach(([type, count]) => {
4763
+ console.log(chalk15.dim(` ${type}: ${count}\uAC1C`));
4764
+ });
4765
+ const pexelsApiKey = await resolvePexelsApiKey();
4766
+ if (pexelsApiKey) {
4767
+ const videoSpinner = spinner("Pexels\uC5D0\uC11C \uC2A4\uD1A1 \uBE44\uB514\uC624 \uAC80\uC0C9 \uC911...");
4768
+ try {
4769
+ const pexels = new PexelsClient(pexelsApiKey);
4770
+ const industry = options.industry ?? inferIndustry(analysis);
4771
+ const resolved = await resolveVideos(pexels, analysis.videos, industry);
4772
+ videoSpinner.stop(chalk15.green(` ${resolved.length}/${analysis.videos.length}\uAC1C \uC2A4\uD1A1 \uBE44\uB514\uC624 \uB9E4\uCE6D \uC644\uB8CC`));
4773
+ resolved.forEach(({ video, stock }) => {
4774
+ console.log(chalk15.dim(` [${video.type}] ${video.position} \u2192 ${stock.src.slice(0, 80)}...`));
4775
+ });
4776
+ const videoMapPath = join2(outputDir, "video-stock-map.json");
4777
+ await writeFile8(videoMapPath, JSON.stringify(resolved, null, 2), "utf-8");
4778
+ console.log(chalk15.dim(` \uB9E4\uCE6D \uACB0\uACFC: ${videoMapPath}`));
4779
+ } catch (error) {
4780
+ videoSpinner.stop(chalk15.yellow(" Pexels \uAC80\uC0C9 \uC2E4\uD328 (\uBE44\uB514\uC624\uB294 \uC218\uB3D9 \uAD50\uCCB4 \uD544\uC694)"));
4781
+ console.log(chalk15.dim(` ${error instanceof Error ? error.message : String(error)}`));
4782
+ }
4783
+ } else {
4784
+ console.log(chalk15.dim(" PEXELS_API_KEY \uBBF8\uC124\uC815 \u2192 \uC2A4\uD1A1 \uBE44\uB514\uC624 \uC790\uB3D9 \uB9E4\uCE6D \uAC74\uB108\uB700"));
4785
+ console.log(chalk15.dim(" \uC124\uC815: PEXELS_API_KEY \uD658\uACBD\uBCC0\uC218 \uB610\uB294 .saeroon/config.json\uC758 pexelsApiKey"));
4786
+ }
4787
+ } else {
4788
+ console.log(chalk15.dim(" \uBE44\uB514\uC624 \uC5C6\uC74C"));
4789
+ }
4450
4790
  if (analysis.gaps.length > 0) {
4451
4791
  console.log("");
4452
4792
  console.log(chalk15.bold(chalk15.yellow("Gap \uAC10\uC9C0")));
@@ -4474,10 +4814,59 @@ ${error instanceof Error ? error.message : String(error)}`));
4474
4814
  process.exit(1);
4475
4815
  }
4476
4816
  }
4817
+ async function resolveVideos(pexels, videos, industry) {
4818
+ const results = [];
4819
+ for (const video of videos) {
4820
+ const queries = buildVideoSearchQueries(industry, video.position, video.type);
4821
+ const orientation = video.type === "background" ? "landscape" : void 0;
4822
+ const maxDuration = video.type === "background" ? 30 : 60;
4823
+ const stock = await pexels.findVideoWithFallback(queries, {
4824
+ orientation,
4825
+ maxDuration
4826
+ });
4827
+ if (stock) {
4828
+ results.push({ video, stock });
4829
+ }
4830
+ }
4831
+ return results;
4832
+ }
4833
+ function inferIndustry(analysis) {
4834
+ const text = analysis.structure.headingHierarchy.join(" ").toLowerCase();
4835
+ const roles = analysis.structure.sections.map((s) => s.role).join(" ");
4836
+ const combined = text + " " + roles;
4837
+ const industryPatterns = [
4838
+ [/카페|coffee|cafe|커피/, "cafe"],
4839
+ [/레스토랑|restaurant|맛집|음식점|식당/, "restaurant"],
4840
+ [/베이커리|bakery|빵|제과/, "bakery"],
4841
+ [/바|bar|칵테일|cocktail|pub/, "bar"],
4842
+ [/미용|salon|헤어|hair|뷰티|beauty/, "salon"],
4843
+ [/스파|spa|마사지|massage|웰니스/, "spa"],
4844
+ [/피트니스|gym|헬스|fitness|운동/, "gym"],
4845
+ [/병원|clinic|의원|진료|medical/, "clinic"],
4846
+ [/치과|dental|dentist/, "dental"],
4847
+ [/피부|derma|skin|에스테틱/, "dermatology"],
4848
+ [/법률|law|변호사|attorney|법무/, "law"],
4849
+ [/회계|accounting|세무|tax/, "accounting"],
4850
+ [/컨설팅|consulting|자문/, "consulting"],
4851
+ [/부동산|real\s*estate|property|공인중개/, "realestate"],
4852
+ [/학원|academy|교육|education|학습/, "academy"],
4853
+ [/어린이집|유치원|kindergarten|nursery/, "kindergarten"],
4854
+ [/꽃|flower|florist|플라워/, "flower"],
4855
+ [/반려|pet|동물|animal/, "pet"],
4856
+ [/청소|cleaning|클리닝/, "cleaning"],
4857
+ [/이사|moving|포장이사/, "moving"],
4858
+ [/saas|software|플랫폼|platform/, "saas"],
4859
+ [/startup|스타트업/, "startup"]
4860
+ ];
4861
+ for (const [pattern, industry] of industryPatterns) {
4862
+ if (pattern.test(combined)) return industry;
4863
+ }
4864
+ return "business";
4865
+ }
4477
4866
 
4478
4867
  // src/commands/compare.ts
4479
4868
  import chalk16 from "chalk";
4480
- import { writeFile as writeFile8, mkdir as mkdir5 } from "fs/promises";
4869
+ import { writeFile as writeFile9, mkdir as mkdir5 } from "fs/promises";
4481
4870
  import { resolve as resolve14, join as join3 } from "path";
4482
4871
  import { execSync as execSync2, spawnSync as spawnSync2 } from "child_process";
4483
4872
  async function commandCompare(options) {
@@ -4565,7 +4954,7 @@ async function runMultiViewportCompare(options) {
4565
4954
  overallDiffPercentage: Math.round(overallDiff * 10) / 10
4566
4955
  };
4567
4956
  const reportPath = join3(outputDir, "comparison-report.json");
4568
- await writeFile8(reportPath, JSON.stringify(report, null, 2), "utf-8");
4957
+ await writeFile9(reportPath, JSON.stringify(report, null, 2), "utf-8");
4569
4958
  console.log("");
4570
4959
  console.log(chalk16.bold("\uBE44\uAD50 \uC694\uC57D"));
4571
4960
  for (const r of results) {
@@ -5460,7 +5849,7 @@ program.command("add-pattern").description("\uC2A4\uD0A4\uB9C8\uC5D0 \uD328\uD13
5460
5849
  program.command("fork-pattern").description("\uACF5\uAC1C \uD328\uD134\uC744 \uB0B4 \uC0AC\uC774\uD2B8\uB85C \uD3EC\uD06C").argument("<pattern-id>", "\uD3EC\uD06C\uD560 \uACF5\uAC1C \uD328\uD134 ID").requiredOption("--site-id <id>", "\uB300\uC0C1 \uC0AC\uC774\uD2B8 ID").action(commandForkPattern);
5461
5850
  program.command("add").description("schema.json\uC5D0 \uBE14\uB85D \uCD94\uAC00 (\uAE30\uBCF8 props \uD3EC\uD568)").argument("<block-type>", "\uCD94\uAC00\uD560 \uBE14\uB85D \uD0C0\uC785 (e.g., heading-block, image-block)").option("--id <id>", "\uBE14\uB85D ID (\uBBF8\uC9C0\uC815 \uC2DC \uC790\uB3D9 \uC0DD\uC131)").option("--parent <parentId>", "\uBD80\uBAA8 \uBE14\uB85D ID").option("--after <siblingId>", "\uC0BD\uC785 \uC704\uCE58 (\uD615\uC81C \uBE14\uB85D \uB4A4)").option("--page <pageId>", "\uB300\uC0C1 \uD398\uC774\uC9C0 ID").action(commandAdd);
5462
5851
  program.command("generate").description("AI\uB85C \uC0AC\uC774\uD2B8 \uC2A4\uD0A4\uB9C8 \uC0DD\uC131").option("--ref <url>", "\uB808\uD37C\uB7F0\uC2A4 URL (\uC0AC\uC774\uD2B8\uB97C \uBD84\uC11D\uD558\uC5EC \uC720\uC0AC\uD55C \uC2A4\uD0A4\uB9C8 \uC0DD\uC131)").option("--prompt <text>", "\uD504\uB86C\uD504\uD2B8 \uD14D\uC2A4\uD2B8 (\uC124\uBA85 \uAE30\uBC18 \uC0DD\uC131)").option("--output <file>", "\uCD9C\uB825 \uD30C\uC77C \uACBD\uB85C", "schema.json").option("--api-key <key>", "API Key").action(commandGenerate);
5463
- program.command("analyze").description("\uB808\uD37C\uB7F0\uC2A4 URL \uBD84\uC11D (\uC2A4\uD06C\uB9B0\uC0F7 4\uC7A5 + DOM/CSS \uCD94\uCD9C + \uC778\uD130\uB799\uC158 \uAC10\uC9C0)").argument("<url>", "\uBD84\uC11D\uD560 \uB808\uD37C\uB7F0\uC2A4 URL").option("--output-dir <dir>", "\uBD84\uC11D \uACB0\uACFC \uCD9C\uB825 \uB514\uB809\uD1A0\uB9AC").option("--timeout <ms>", "\uD398\uC774\uC9C0 \uB85C\uB4DC \uD0C0\uC784\uC544\uC6C3 (ms)", "30000").action(commandAnalyze);
5852
+ program.command("analyze").description("\uB808\uD37C\uB7F0\uC2A4 URL \uBD84\uC11D (\uC2A4\uD06C\uB9B0\uC0F7 4\uC7A5 + DOM/CSS \uCD94\uCD9C + \uBE44\uB514\uC624 \uAC10\uC9C0 + \uC778\uD130\uB799\uC158 \uAC10\uC9C0)").argument("<url>", "\uBD84\uC11D\uD560 \uB808\uD37C\uB7F0\uC2A4 URL").option("--output-dir <dir>", "\uBD84\uC11D \uACB0\uACFC \uCD9C\uB825 \uB514\uB809\uD1A0\uB9AC").option("--timeout <ms>", "\uD398\uC774\uC9C0 \uB85C\uB4DC \uD0C0\uC784\uC544\uC6C3 (ms)", "30000").option("--industry <type>", "\uC5C5\uC885 (cafe, restaurant, salon, law \uB4F1 \u2014 \uBE44\uB514\uC624 \uAC80\uC0C9 \uD0A4\uC6CC\uB4DC\uC5D0 \uC0AC\uC6A9)").action(commandAnalyze);
5464
5853
  program.command("compare").description("\uB808\uD37C\uB7F0\uC2A4 \u2194 \uD504\uB9AC\uBDF0 \uC2DC\uAC01 \uBE44\uAD50 (Playwright \uC2A4\uD06C\uB9B0\uC0F7)").option("--ref <url>", "\uB808\uD37C\uB7F0\uC2A4 URL").option("--preview <url>", "\uD504\uB9AC\uBDF0 URL").option("--output <file>", "\uCD9C\uB825 \uD30C\uC77C \uACBD\uB85C (\uB2E8\uC77C \uBDF0\uD3EC\uD2B8)", "compare-result.png").option("--output-dir <dir>", "\uCD9C\uB825 \uB514\uB809\uD1A0\uB9AC (\uBA40\uD2F0 \uBDF0\uD3EC\uD2B8)").option("--width <px>", "\uBDF0\uD3EC\uD2B8 \uB108\uBE44 (\uB2E8\uC77C \uBAA8\uB4DC)", "1280").option("--height <px>", "\uBDF0\uD3EC\uD2B8 \uB192\uC774 (\uB2E8\uC77C \uBAA8\uB4DC)", "800").option("--viewports <list>", "\uBE44\uAD50 \uBDF0\uD3EC\uD2B8: all | mobile,tablet,laptop,desktop").action(commandCompare);
5465
5854
  program.command("upload").description("\uC774\uBBF8\uC9C0\uB97C Saeroon CDN\uC5D0 \uC5C5\uB85C\uB4DC").argument("<path>", "\uC5C5\uB85C\uB4DC\uD560 \uD30C\uC77C \uB610\uB294 \uB514\uB809\uD1A0\uB9AC \uACBD\uB85C").option("--replace-in <file>", "\uC5C5\uB85C\uB4DC \uD6C4 \uD30C\uC77C \uB0B4 \uB85C\uCEEC \uACBD\uB85C\uB97C CDN URL\uB85C \uAD50\uCCB4").option("--site-id <id>", "\uC0AC\uC774\uD2B8 ID").option("--api-key <key>", "API Key").action(commandUpload);
5466
5855
  program.command("deploy").description("\uC0AC\uC774\uD2B8\uC5D0 \uC2A4\uD0A4\uB9C8 \uBC30\uD3EC").option("--api-key <key>", "API Key").option("--target <target>", "\uBC30\uD3EC \uB300\uC0C1: staging|production", "staging").option("--dry-run", "\uC2E4\uC81C \uC5C5\uB85C\uB4DC/\uBC30\uD3EC \uC5C6\uC774 \uC5D0\uC14B \uB9AC\uD3EC\uD2B8\uB9CC \uCD9C\uB825").option("--sync-template", "Production \uBC30\uD3EC \uD6C4 \uB9C8\uCF13\uD50C\uB808\uC774\uC2A4 \uD15C\uD50C\uB9BF \uBC84\uC804 \uC790\uB3D9 \uB3D9\uAE30\uD654").action(commandDeploy);
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@saeroon/cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Saeroon Hosting developer CLI — preview, validate, and deploy sites & templates",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
9
9
  "bin": {
10
- "saeroon": "dist/index.js"
10
+ "saeroon": "dist/index.js",
11
+ "saeroon-cli": "dist/index.js"
11
12
  },
12
13
  "license": "MIT",
13
14
  "publishConfig": {