@webto-id/variant-check 0.1.3 → 0.1.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/dist/cli.js +79 -21
  2. package/dist/index.js +79 -21
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ var __name = (target, value) => __defProp(target, "name", { value, configurable:
6
6
  import { readFileSync, writeFileSync } from "fs";
7
7
 
8
8
  // ../variant-compiler/src/ir.ts
9
- var COMPONENT_NAMES = ["Container", "Button", "EditUrlPill", "ViewMoreLink"];
9
+ var COMPONENT_NAMES = ["Container", "Button", "EditUrlPill", "ViewMoreLink", "AddImageButton"];
10
10
 
11
11
  // ../variant-compiler/src/parse/parser.ts
12
12
  var ParseError = class extends Error {
@@ -603,9 +603,10 @@ var HELPER_BY_IMPORT = {
603
603
  Container: "Container",
604
604
  Button: "Button",
605
605
  EditUrlPill: "EditUrlPill",
606
- ViewMoreLink: "ViewMoreLink"
606
+ ViewMoreLink: "ViewMoreLink",
607
+ AddImageButton: "AddImageButton"
607
608
  };
608
- var ALLOWED_SOURCE = /^(webto\/variant|(\.{1,2}\/)+(lib\/(sanitize|image|section-i18n)|(components\/)?(v2\/)?(ui\/)?(Container|Button|ViewMoreLink)\.astro|(components\/)?EditUrlPill\.astro|ui\/(Container|Button)\.astro|ViewMoreLink\.astro))$/;
609
+ var ALLOWED_SOURCE = /^(webto\/variant|(\.{1,2}\/)+(lib\/(sanitize|image|section-i18n)|(components\/)?(v2\/)?(ui\/)?(Container|Button|ViewMoreLink|AddImageButton)\.astro|(components\/)?EditUrlPill\.astro|ui\/(Container|Button)\.astro|ViewMoreLink\.astro))$/;
609
610
  function stripComments(src) {
610
611
  return src.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/(^|[^:\\])\/\/[^\n]*/g, (m, pre) => pre + " ".repeat(m.length - pre.length));
611
612
  }
@@ -744,7 +745,7 @@ function parseFrontmatter(raw, lineOffset = 0) {
744
745
  const [, isType, def, named, source2] = m;
745
746
  i += m[0].length;
746
747
  if (isType) continue;
747
- if (!ALLOWED_SOURCE.test(source2)) throw new ParseError(`import from '${source2}' is not allowed \u2014 only "webto/variant" helpers (t, img, url, Container, Button, EditUrlPill, ViewMoreLink)`, line, "import");
748
+ if (!ALLOWED_SOURCE.test(source2)) throw new ParseError(`import from '${source2}' is not allowed \u2014 only "webto/variant" helpers (t, img, url, Container, Button, EditUrlPill, ViewMoreLink, AddImageButton)`, line, "import");
748
749
  const names = [];
749
750
  if (def) names.push({ local: def, imported: def });
750
751
  if (named) {
@@ -1153,6 +1154,13 @@ function lintNodes(nodes, ctx, ariaHidden = false) {
1153
1154
  const hidden = ariaHidden || staticValue(n.attrs["aria-hidden"]) === "true";
1154
1155
  for (const [k, v] of Object.entries(n.attrs)) lintAttr(k, v, tag, ctx);
1155
1156
  if (tag === "img" && !("alt" in n.attrs)) push(ctx, "warning", "img-alt", "<img> without alt");
1157
+ if (/^h[1-4]$/.test(tag)) {
1158
+ const cls = staticValue(n.attrs.class);
1159
+ const style = staticValue(n.attrs.style);
1160
+ const pinned = typeof style === "string" && /font-size\s*:/i.test(style);
1161
+ const inert = typeof cls === "string" && !pinned ? cls.split(/\s+/).filter((c) => HEADING_SIZE_CLASS.test(c)) : [];
1162
+ if (inert.length) push(ctx, "warning", "heading-size-inert", `${inert.join(" ")} on <${tag}> has no effect: h1-h4 sizes follow the site theme (Style tab). Keep it as intent, pin the size with !${inert[0]} (honored everywhere, never rescaled by theme or per-section settings) or an inline style, or use a non-heading tag to follow the body scale`);
1163
+ }
1156
1164
  if (tag === "a") {
1157
1165
  const target = staticValue(n.attrs.target);
1158
1166
  if (target === "_blank" && !("rel" in n.attrs)) n.attrs.rel = "noopener";
@@ -1207,6 +1215,7 @@ function checkUrl(name, v, tag, ctx) {
1207
1215
  if (/^https?:\/\//i.test(s) && (name === "src" || name === "poster")) push(ctx, "warning", "hardcoded-image", `<${tag} ${name}> hardcodes an external URL \u2014 images should come from a content field`);
1208
1216
  }
1209
1217
  __name(checkUrl, "checkUrl");
1218
+ var HEADING_SIZE_CLASS = /^(?:[\w-]+:)*text-(?:xs|sm|base|lg|xl|\dxl|\[[^\]]+\])$/;
1210
1219
  function lintHtml(root, sectionType2) {
1211
1220
  const ctx = { lint: [], sectionType: sectionType2, inButton: 0, inSvg: 0 };
1212
1221
  lintNodes(root, ctx);
@@ -1512,18 +1521,18 @@ __name(hasBreak, "hasBreak");
1512
1521
 
1513
1522
  // ../variant-compiler/src/css/scope.ts
1514
1523
  var ROOT_PLACEHOLDER = "__WV_ROOT__";
1515
- var RECURSE_AT = /^@(media|supports|container|layer|scope|document)\b/i;
1516
1524
  var VERBATIM_AT = /^@(keyframes|-webkit-keyframes|property|font-face|page|counter-style|font-feature-values|namespace|charset)\b/i;
1517
1525
  function scopeCss(css, root = ROOT_PLACEHOLDER) {
1518
- return scopeBlock(stripComments2(css), root);
1526
+ return scopeBlock(stripComments2(css), root, null);
1519
1527
  }
1520
1528
  __name(scopeCss, "scopeCss");
1521
1529
  function stripComments2(s) {
1522
1530
  return s.replace(/\/\*[\s\S]*?\*\//g, "");
1523
1531
  }
1524
1532
  __name(stripComments2, "stripComments");
1525
- function scopeBlock(src, root) {
1533
+ function scopeBlock(src, root, parents) {
1526
1534
  let out = "";
1535
+ let decls = "";
1527
1536
  let i = 0;
1528
1537
  while (i < src.length) {
1529
1538
  let j = i;
@@ -1540,7 +1549,10 @@ function scopeBlock(src, root) {
1540
1549
  }
1541
1550
  const prelude = src.slice(i, j).trim();
1542
1551
  if (j >= src.length) {
1543
- if (prelude) out += prelude;
1552
+ if (prelude) {
1553
+ if (parents) decls += `${prelude};`;
1554
+ else out += prelude;
1555
+ }
1544
1556
  break;
1545
1557
  }
1546
1558
  if (src[j] === "}") {
@@ -1548,7 +1560,10 @@ function scopeBlock(src, root) {
1548
1560
  continue;
1549
1561
  }
1550
1562
  if (src[j] === ";") {
1551
- if (prelude) out += `${prelude};`;
1563
+ if (prelude) {
1564
+ if (parents && !prelude.startsWith("@")) decls += `${prelude};`;
1565
+ else out += `${prelude};`;
1566
+ }
1552
1567
  i = j + 1;
1553
1568
  continue;
1554
1569
  }
@@ -1558,21 +1573,28 @@ function scopeBlock(src, root) {
1558
1573
  if (!prelude) continue;
1559
1574
  if (prelude.startsWith("@")) {
1560
1575
  if (VERBATIM_AT.test(prelude)) out += `${prelude}{${body}}`;
1561
- else if (RECURSE_AT.test(prelude)) out += `${prelude}{${scopeBlock(body, root)}}`;
1562
- else out += `${prelude}{${scopeBlock(body, root)}}`;
1576
+ else out += `${prelude}{${scopeBlock(body, root, parents)}}`;
1563
1577
  continue;
1564
1578
  }
1565
- const selectors = splitSelectors(prelude).map((s) => scopeSelector(s.trim(), root)).filter(Boolean);
1579
+ const raw = splitSelectors(prelude).map((s) => s.trim()).filter(Boolean);
1580
+ const selectors = parents ? resolveNested(raw, parents) : raw.map((s) => scopeSelector(s, root)).filter(Boolean);
1566
1581
  if (!selectors.length) continue;
1567
- if (body.includes("{")) {
1568
- out += `${selectors.join(",")}{${scopeBlock(body, root)}}`;
1569
- } else {
1570
- out += `${selectors.join(",")}{${body.trim()}}`;
1571
- }
1582
+ out += scopeBlock(body, root, selectors);
1572
1583
  }
1584
+ if (parents && decls) out = `${parents.join(",")}{${decls}}` + out;
1573
1585
  return out;
1574
1586
  }
1575
1587
  __name(scopeBlock, "scopeBlock");
1588
+ function resolveNested(selectors, parents) {
1589
+ const parentRef = parents.length === 1 ? parents[0] : `:is(${parents.join(", ")})`;
1590
+ const out = [];
1591
+ for (const sel of selectors) {
1592
+ if (sel.includes("&")) out.push(sel.replace(/&/g, parentRef));
1593
+ else for (const p of parents) out.push(`${p} ${sel}`);
1594
+ }
1595
+ return out;
1596
+ }
1597
+ __name(resolveNested, "resolveNested");
1576
1598
  function matchBrace2(src, open) {
1577
1599
  let depth = 0;
1578
1600
  let inStr = null;
@@ -1617,7 +1639,7 @@ function splitSelectors(s) {
1617
1639
  __name(splitSelectors, "splitSelectors");
1618
1640
  function scopeSelector(sel, root) {
1619
1641
  if (!sel) return "";
1620
- if (sel.startsWith("&")) return `${root} ${sel.slice(1).trim()}`.trim();
1642
+ if (sel.startsWith("&")) return `${root}${sel.slice(1)}`.trim();
1621
1643
  if (sel.includes(ROOT_PLACEHOLDER)) return sel;
1622
1644
  const m = /^(:root|html|body)(?![\w-])/i.exec(sel);
1623
1645
  if (m) {
@@ -1693,7 +1715,8 @@ var MACRO_CLASSES = [
1693
1715
  "text-foreground hover:bg-muted",
1694
1716
  "px-4 py-2 text-sm px-6 py-3 text-base px-8 py-4 text-lg",
1695
1717
  "gap-1.5 px-2.5 py-1 rounded-md border-dashed text-xs whitespace-nowrap font-mono break-all w-3.5 h-3.5 shrink-0",
1696
- "mt-10 flex justify-start justify-center justify-end hover:border-primary/30 text-primary hover:underline w-4 h-4 relative"
1718
+ "mt-10 flex justify-start justify-center justify-end hover:border-primary/30 text-primary hover:underline w-4 h-4 relative",
1719
+ "mt-8 absolute bottom-8 right-8 z-30"
1697
1720
  ];
1698
1721
  function pickCandidates(tokens) {
1699
1722
  const set = /* @__PURE__ */ new Set();
@@ -1758,7 +1781,7 @@ function deriveSchema(fields, props, base2) {
1758
1781
  __name(deriveSchema, "deriveSchema");
1759
1782
 
1760
1783
  // ../variant-compiler/src/index.ts
1761
- var COMPILER_VERSION = "0.1.3";
1784
+ var COMPILER_VERSION = "0.1.5";
1762
1785
  var IR_MAX_BYTES = 256 * 1024;
1763
1786
  var CSS_MAX_BYTES = 64 * 1024;
1764
1787
  var STRICT_CODES = /* @__PURE__ */ new Set(["dead-text", "hardcoded-color", "hardcoded-radius", "hardcoded-font", "hardcoded-image", "img-alt", "props-optional", "tailwind-skipped"]);
@@ -2313,6 +2336,12 @@ function editUrlPillHtml(field, value, anchor = "below", extraClass = "") {
2313
2336
  return `<div data-edit-reveal data-anchor="${escAttr(anchor)}" class="edit-url-pill${extraClass ? " " + escAttr(extraClass) : ""}" style="display:none;position:absolute;z-index:10;pointer-events:auto;${pos}"><div class="edit-url-pill__inner inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border border-dashed text-xs whitespace-nowrap shadow-sm" style="color:var(--color-background,#fff);border-color:color-mix(in srgb,var(--color-foreground,#000) 40%,transparent);background-color:var(--color-foreground,#000);text-transform:none;letter-spacing:normal;"><svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" /></svg><span data-edit-field="${escAttr(field)}" class="font-mono break-all">${escText(value)}</span></div></div>`;
2314
2337
  }
2315
2338
  __name(editUrlPillHtml, "editUrlPillHtml");
2339
+ function addImageButtonHtml(path, label = "Tambah Gambar", mode = "inline") {
2340
+ const btn = /* @__PURE__ */ __name((cls) => `<button type="button" class="${escAttr(cls)}" data-edit-add-image="${escAttr(path)}" aria-label="${escAttr(label)}"><span class="webto-inline-add-icon">+</span>${escText(label)}</button>`, "btn");
2341
+ if (mode === "floating") return btn("webto-inline-add-btn absolute bottom-8 right-8 z-30");
2342
+ return `<div class="webto-add-row mt-8">${btn("webto-inline-add-btn")}</div>`;
2343
+ }
2344
+ __name(addImageButtonHtml, "addImageButtonHtml");
2316
2345
  function renderComponent(n, scope, ctx, budget) {
2317
2346
  const p = {};
2318
2347
  for (const [k, v] of Object.entries(n.props)) p[k] = attrValue(v, scope, budget);
@@ -2327,6 +2356,13 @@ function renderComponent(n, scope, ctx, budget) {
2327
2356
  }
2328
2357
  case "EditUrlPill":
2329
2358
  return editUrlPillHtml(stringify(p.field), stringify(p.value), stringify(p.anchor) || "below", flattenClassList(p.class));
2359
+ case "AddImageButton": {
2360
+ if (ctx.marks) ctx.marks.addImageButton = true;
2361
+ if (!ctx.editChrome || p.visible === false) return "";
2362
+ const path = stringify(p.path);
2363
+ if (!path) return "";
2364
+ return addImageButtonHtml(path, stringify(p.label) || "Tambah Gambar", p.mode === "floating" ? "floating" : "inline");
2365
+ }
2330
2366
  case "ViewMoreLink": {
2331
2367
  const url = stringify(p.url);
2332
2368
  if (!url) return "";
@@ -2414,12 +2450,34 @@ function renderVariant(ir, content, ctx, opts = {}) {
2414
2450
  scope[p.name] = v === void 0 && p.def ? evalExpr(p.def, scope, budget) : v;
2415
2451
  }
2416
2452
  for (const c of ir.consts) scope[c.name] = evalExpr(c.e, scope, budget);
2417
- const html = renderNodes(ir.root, scope, ctx, budget);
2453
+ const rctx = { ...ctx, marks: { addImageButton: false } };
2454
+ const html = withAddImageFallback(renderNodes(ir.root, scope, rctx, budget), content, rctx);
2418
2455
  const css = ir.css ? layerize(ir.css.replace(/__WV_ROOT__/g, `[data-wv-inst="${uid}"]`)) : "";
2419
2456
  const script = ir.script ? wrapScript(ir.script, uid) : "";
2420
2457
  return { html, css, script, uid };
2421
2458
  }
2422
2459
  __name(renderVariant, "renderVariant");
2460
+ var EDIT_IMAGE_ITEM_RE = /data-edit-image="([A-Za-z_]\w*)\.(\d+)\.([A-Za-z_]\w*)"/g;
2461
+ function withAddImageFallback(html, content, ctx) {
2462
+ if (!ctx.editChrome || ctx.marks?.addImageButton || html.includes("data-edit-add-image=")) return html;
2463
+ const lists = /* @__PURE__ */ new Map();
2464
+ for (const m of html.matchAll(EDIT_IMAGE_ITEM_RE)) {
2465
+ const [, list, index, key] = m;
2466
+ const cur = lists.get(list) ?? { key, hits: 0, maxIndex: -1 };
2467
+ cur.hits += 1;
2468
+ cur.maxIndex = Math.max(cur.maxIndex, Number(index));
2469
+ lists.set(list, cur);
2470
+ }
2471
+ let best = null;
2472
+ for (const [list, v] of lists) if (!best || v.hits > best.hits) best = { list, ...v };
2473
+ if (!best) return html;
2474
+ const stored = content[best.list];
2475
+ const length = Array.isArray(stored) ? stored.length : best.maxIndex + 1;
2476
+ const pill = addImageButtonHtml(`${best.list}.${length}.${best.key}`);
2477
+ const tail = html.lastIndexOf("</section>");
2478
+ return tail >= 0 ? html.slice(0, tail) + pill + html.slice(tail) : html + pill;
2479
+ }
2480
+ __name(withAddImageFallback, "withAddImageFallback");
2423
2481
  var STATEMENT_AT = /^\s*@[a-zA-Z-]+[^;{}]*;/;
2424
2482
  function layerize(css) {
2425
2483
  let rest = css;
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ var __defProp = Object.defineProperty;
3
3
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
4
4
 
5
5
  // ../variant-compiler/src/ir.ts
6
- var COMPONENT_NAMES = ["Container", "Button", "EditUrlPill", "ViewMoreLink"];
6
+ var COMPONENT_NAMES = ["Container", "Button", "EditUrlPill", "ViewMoreLink", "AddImageButton"];
7
7
 
8
8
  // ../variant-compiler/src/parse/parser.ts
9
9
  var ParseError = class extends Error {
@@ -600,9 +600,10 @@ var HELPER_BY_IMPORT = {
600
600
  Container: "Container",
601
601
  Button: "Button",
602
602
  EditUrlPill: "EditUrlPill",
603
- ViewMoreLink: "ViewMoreLink"
603
+ ViewMoreLink: "ViewMoreLink",
604
+ AddImageButton: "AddImageButton"
604
605
  };
605
- var ALLOWED_SOURCE = /^(webto\/variant|(\.{1,2}\/)+(lib\/(sanitize|image|section-i18n)|(components\/)?(v2\/)?(ui\/)?(Container|Button|ViewMoreLink)\.astro|(components\/)?EditUrlPill\.astro|ui\/(Container|Button)\.astro|ViewMoreLink\.astro))$/;
606
+ var ALLOWED_SOURCE = /^(webto\/variant|(\.{1,2}\/)+(lib\/(sanitize|image|section-i18n)|(components\/)?(v2\/)?(ui\/)?(Container|Button|ViewMoreLink|AddImageButton)\.astro|(components\/)?EditUrlPill\.astro|ui\/(Container|Button)\.astro|ViewMoreLink\.astro))$/;
606
607
  function stripComments(src) {
607
608
  return src.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/(^|[^:\\])\/\/[^\n]*/g, (m, pre) => pre + " ".repeat(m.length - pre.length));
608
609
  }
@@ -741,7 +742,7 @@ function parseFrontmatter(raw, lineOffset = 0) {
741
742
  const [, isType, def, named, source] = m;
742
743
  i += m[0].length;
743
744
  if (isType) continue;
744
- if (!ALLOWED_SOURCE.test(source)) throw new ParseError(`import from '${source}' is not allowed \u2014 only "webto/variant" helpers (t, img, url, Container, Button, EditUrlPill, ViewMoreLink)`, line, "import");
745
+ if (!ALLOWED_SOURCE.test(source)) throw new ParseError(`import from '${source}' is not allowed \u2014 only "webto/variant" helpers (t, img, url, Container, Button, EditUrlPill, ViewMoreLink, AddImageButton)`, line, "import");
745
746
  const names = [];
746
747
  if (def) names.push({ local: def, imported: def });
747
748
  if (named) {
@@ -1150,6 +1151,13 @@ function lintNodes(nodes, ctx, ariaHidden = false) {
1150
1151
  const hidden = ariaHidden || staticValue(n.attrs["aria-hidden"]) === "true";
1151
1152
  for (const [k, v] of Object.entries(n.attrs)) lintAttr(k, v, tag, ctx);
1152
1153
  if (tag === "img" && !("alt" in n.attrs)) push(ctx, "warning", "img-alt", "<img> without alt");
1154
+ if (/^h[1-4]$/.test(tag)) {
1155
+ const cls = staticValue(n.attrs.class);
1156
+ const style = staticValue(n.attrs.style);
1157
+ const pinned = typeof style === "string" && /font-size\s*:/i.test(style);
1158
+ const inert = typeof cls === "string" && !pinned ? cls.split(/\s+/).filter((c) => HEADING_SIZE_CLASS.test(c)) : [];
1159
+ if (inert.length) push(ctx, "warning", "heading-size-inert", `${inert.join(" ")} on <${tag}> has no effect: h1-h4 sizes follow the site theme (Style tab). Keep it as intent, pin the size with !${inert[0]} (honored everywhere, never rescaled by theme or per-section settings) or an inline style, or use a non-heading tag to follow the body scale`);
1160
+ }
1153
1161
  if (tag === "a") {
1154
1162
  const target = staticValue(n.attrs.target);
1155
1163
  if (target === "_blank" && !("rel" in n.attrs)) n.attrs.rel = "noopener";
@@ -1204,6 +1212,7 @@ function checkUrl(name, v, tag, ctx) {
1204
1212
  if (/^https?:\/\//i.test(s) && (name === "src" || name === "poster")) push(ctx, "warning", "hardcoded-image", `<${tag} ${name}> hardcodes an external URL \u2014 images should come from a content field`);
1205
1213
  }
1206
1214
  __name(checkUrl, "checkUrl");
1215
+ var HEADING_SIZE_CLASS = /^(?:[\w-]+:)*text-(?:xs|sm|base|lg|xl|\dxl|\[[^\]]+\])$/;
1207
1216
  function lintHtml(root, sectionType) {
1208
1217
  const ctx = { lint: [], sectionType, inButton: 0, inSvg: 0 };
1209
1218
  lintNodes(root, ctx);
@@ -1509,18 +1518,18 @@ __name(hasBreak, "hasBreak");
1509
1518
 
1510
1519
  // ../variant-compiler/src/css/scope.ts
1511
1520
  var ROOT_PLACEHOLDER = "__WV_ROOT__";
1512
- var RECURSE_AT = /^@(media|supports|container|layer|scope|document)\b/i;
1513
1521
  var VERBATIM_AT = /^@(keyframes|-webkit-keyframes|property|font-face|page|counter-style|font-feature-values|namespace|charset)\b/i;
1514
1522
  function scopeCss(css, root = ROOT_PLACEHOLDER) {
1515
- return scopeBlock(stripComments2(css), root);
1523
+ return scopeBlock(stripComments2(css), root, null);
1516
1524
  }
1517
1525
  __name(scopeCss, "scopeCss");
1518
1526
  function stripComments2(s) {
1519
1527
  return s.replace(/\/\*[\s\S]*?\*\//g, "");
1520
1528
  }
1521
1529
  __name(stripComments2, "stripComments");
1522
- function scopeBlock(src, root) {
1530
+ function scopeBlock(src, root, parents) {
1523
1531
  let out = "";
1532
+ let decls = "";
1524
1533
  let i = 0;
1525
1534
  while (i < src.length) {
1526
1535
  let j = i;
@@ -1537,7 +1546,10 @@ function scopeBlock(src, root) {
1537
1546
  }
1538
1547
  const prelude = src.slice(i, j).trim();
1539
1548
  if (j >= src.length) {
1540
- if (prelude) out += prelude;
1549
+ if (prelude) {
1550
+ if (parents) decls += `${prelude};`;
1551
+ else out += prelude;
1552
+ }
1541
1553
  break;
1542
1554
  }
1543
1555
  if (src[j] === "}") {
@@ -1545,7 +1557,10 @@ function scopeBlock(src, root) {
1545
1557
  continue;
1546
1558
  }
1547
1559
  if (src[j] === ";") {
1548
- if (prelude) out += `${prelude};`;
1560
+ if (prelude) {
1561
+ if (parents && !prelude.startsWith("@")) decls += `${prelude};`;
1562
+ else out += `${prelude};`;
1563
+ }
1549
1564
  i = j + 1;
1550
1565
  continue;
1551
1566
  }
@@ -1555,21 +1570,28 @@ function scopeBlock(src, root) {
1555
1570
  if (!prelude) continue;
1556
1571
  if (prelude.startsWith("@")) {
1557
1572
  if (VERBATIM_AT.test(prelude)) out += `${prelude}{${body}}`;
1558
- else if (RECURSE_AT.test(prelude)) out += `${prelude}{${scopeBlock(body, root)}}`;
1559
- else out += `${prelude}{${scopeBlock(body, root)}}`;
1573
+ else out += `${prelude}{${scopeBlock(body, root, parents)}}`;
1560
1574
  continue;
1561
1575
  }
1562
- const selectors = splitSelectors(prelude).map((s) => scopeSelector(s.trim(), root)).filter(Boolean);
1576
+ const raw = splitSelectors(prelude).map((s) => s.trim()).filter(Boolean);
1577
+ const selectors = parents ? resolveNested(raw, parents) : raw.map((s) => scopeSelector(s, root)).filter(Boolean);
1563
1578
  if (!selectors.length) continue;
1564
- if (body.includes("{")) {
1565
- out += `${selectors.join(",")}{${scopeBlock(body, root)}}`;
1566
- } else {
1567
- out += `${selectors.join(",")}{${body.trim()}}`;
1568
- }
1579
+ out += scopeBlock(body, root, selectors);
1569
1580
  }
1581
+ if (parents && decls) out = `${parents.join(",")}{${decls}}` + out;
1570
1582
  return out;
1571
1583
  }
1572
1584
  __name(scopeBlock, "scopeBlock");
1585
+ function resolveNested(selectors, parents) {
1586
+ const parentRef = parents.length === 1 ? parents[0] : `:is(${parents.join(", ")})`;
1587
+ const out = [];
1588
+ for (const sel of selectors) {
1589
+ if (sel.includes("&")) out.push(sel.replace(/&/g, parentRef));
1590
+ else for (const p of parents) out.push(`${p} ${sel}`);
1591
+ }
1592
+ return out;
1593
+ }
1594
+ __name(resolveNested, "resolveNested");
1573
1595
  function matchBrace2(src, open) {
1574
1596
  let depth = 0;
1575
1597
  let inStr = null;
@@ -1614,7 +1636,7 @@ function splitSelectors(s) {
1614
1636
  __name(splitSelectors, "splitSelectors");
1615
1637
  function scopeSelector(sel, root) {
1616
1638
  if (!sel) return "";
1617
- if (sel.startsWith("&")) return `${root} ${sel.slice(1).trim()}`.trim();
1639
+ if (sel.startsWith("&")) return `${root}${sel.slice(1)}`.trim();
1618
1640
  if (sel.includes(ROOT_PLACEHOLDER)) return sel;
1619
1641
  const m = /^(:root|html|body)(?![\w-])/i.exec(sel);
1620
1642
  if (m) {
@@ -1690,7 +1712,8 @@ var MACRO_CLASSES = [
1690
1712
  "text-foreground hover:bg-muted",
1691
1713
  "px-4 py-2 text-sm px-6 py-3 text-base px-8 py-4 text-lg",
1692
1714
  "gap-1.5 px-2.5 py-1 rounded-md border-dashed text-xs whitespace-nowrap font-mono break-all w-3.5 h-3.5 shrink-0",
1693
- "mt-10 flex justify-start justify-center justify-end hover:border-primary/30 text-primary hover:underline w-4 h-4 relative"
1715
+ "mt-10 flex justify-start justify-center justify-end hover:border-primary/30 text-primary hover:underline w-4 h-4 relative",
1716
+ "mt-8 absolute bottom-8 right-8 z-30"
1694
1717
  ];
1695
1718
  function pickCandidates(tokens) {
1696
1719
  const set = /* @__PURE__ */ new Set();
@@ -1755,7 +1778,7 @@ function deriveSchema(fields, props, base) {
1755
1778
  __name(deriveSchema, "deriveSchema");
1756
1779
 
1757
1780
  // ../variant-compiler/src/index.ts
1758
- var COMPILER_VERSION = "0.1.3";
1781
+ var COMPILER_VERSION = "0.1.5";
1759
1782
  var IR_MAX_BYTES = 256 * 1024;
1760
1783
  var CSS_MAX_BYTES = 64 * 1024;
1761
1784
  var STRICT_CODES = /* @__PURE__ */ new Set(["dead-text", "hardcoded-color", "hardcoded-radius", "hardcoded-font", "hardcoded-image", "img-alt", "props-optional", "tailwind-skipped"]);
@@ -2310,6 +2333,12 @@ function editUrlPillHtml(field, value, anchor = "below", extraClass = "") {
2310
2333
  return `<div data-edit-reveal data-anchor="${escAttr(anchor)}" class="edit-url-pill${extraClass ? " " + escAttr(extraClass) : ""}" style="display:none;position:absolute;z-index:10;pointer-events:auto;${pos}"><div class="edit-url-pill__inner inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md border border-dashed text-xs whitespace-nowrap shadow-sm" style="color:var(--color-background,#fff);border-color:color-mix(in srgb,var(--color-foreground,#000) 40%,transparent);background-color:var(--color-foreground,#000);text-transform:none;letter-spacing:normal;"><svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" /></svg><span data-edit-field="${escAttr(field)}" class="font-mono break-all">${escText(value)}</span></div></div>`;
2311
2334
  }
2312
2335
  __name(editUrlPillHtml, "editUrlPillHtml");
2336
+ function addImageButtonHtml(path, label = "Tambah Gambar", mode = "inline") {
2337
+ const btn = /* @__PURE__ */ __name((cls) => `<button type="button" class="${escAttr(cls)}" data-edit-add-image="${escAttr(path)}" aria-label="${escAttr(label)}"><span class="webto-inline-add-icon">+</span>${escText(label)}</button>`, "btn");
2338
+ if (mode === "floating") return btn("webto-inline-add-btn absolute bottom-8 right-8 z-30");
2339
+ return `<div class="webto-add-row mt-8">${btn("webto-inline-add-btn")}</div>`;
2340
+ }
2341
+ __name(addImageButtonHtml, "addImageButtonHtml");
2313
2342
  function renderComponent(n, scope, ctx, budget) {
2314
2343
  const p = {};
2315
2344
  for (const [k, v] of Object.entries(n.props)) p[k] = attrValue(v, scope, budget);
@@ -2324,6 +2353,13 @@ function renderComponent(n, scope, ctx, budget) {
2324
2353
  }
2325
2354
  case "EditUrlPill":
2326
2355
  return editUrlPillHtml(stringify(p.field), stringify(p.value), stringify(p.anchor) || "below", flattenClassList(p.class));
2356
+ case "AddImageButton": {
2357
+ if (ctx.marks) ctx.marks.addImageButton = true;
2358
+ if (!ctx.editChrome || p.visible === false) return "";
2359
+ const path = stringify(p.path);
2360
+ if (!path) return "";
2361
+ return addImageButtonHtml(path, stringify(p.label) || "Tambah Gambar", p.mode === "floating" ? "floating" : "inline");
2362
+ }
2327
2363
  case "ViewMoreLink": {
2328
2364
  const url = stringify(p.url);
2329
2365
  if (!url) return "";
@@ -2411,12 +2447,34 @@ function renderVariant(ir, content, ctx, opts = {}) {
2411
2447
  scope[p.name] = v === void 0 && p.def ? evalExpr(p.def, scope, budget) : v;
2412
2448
  }
2413
2449
  for (const c of ir.consts) scope[c.name] = evalExpr(c.e, scope, budget);
2414
- const html = renderNodes(ir.root, scope, ctx, budget);
2450
+ const rctx = { ...ctx, marks: { addImageButton: false } };
2451
+ const html = withAddImageFallback(renderNodes(ir.root, scope, rctx, budget), content, rctx);
2415
2452
  const css = ir.css ? layerize(ir.css.replace(/__WV_ROOT__/g, `[data-wv-inst="${uid}"]`)) : "";
2416
2453
  const script = ir.script ? wrapScript(ir.script, uid) : "";
2417
2454
  return { html, css, script, uid };
2418
2455
  }
2419
2456
  __name(renderVariant, "renderVariant");
2457
+ var EDIT_IMAGE_ITEM_RE = /data-edit-image="([A-Za-z_]\w*)\.(\d+)\.([A-Za-z_]\w*)"/g;
2458
+ function withAddImageFallback(html, content, ctx) {
2459
+ if (!ctx.editChrome || ctx.marks?.addImageButton || html.includes("data-edit-add-image=")) return html;
2460
+ const lists = /* @__PURE__ */ new Map();
2461
+ for (const m of html.matchAll(EDIT_IMAGE_ITEM_RE)) {
2462
+ const [, list, index, key] = m;
2463
+ const cur = lists.get(list) ?? { key, hits: 0, maxIndex: -1 };
2464
+ cur.hits += 1;
2465
+ cur.maxIndex = Math.max(cur.maxIndex, Number(index));
2466
+ lists.set(list, cur);
2467
+ }
2468
+ let best = null;
2469
+ for (const [list, v] of lists) if (!best || v.hits > best.hits) best = { list, ...v };
2470
+ if (!best) return html;
2471
+ const stored = content[best.list];
2472
+ const length = Array.isArray(stored) ? stored.length : best.maxIndex + 1;
2473
+ const pill = addImageButtonHtml(`${best.list}.${length}.${best.key}`);
2474
+ const tail = html.lastIndexOf("</section>");
2475
+ return tail >= 0 ? html.slice(0, tail) + pill + html.slice(tail) : html + pill;
2476
+ }
2477
+ __name(withAddImageFallback, "withAddImageFallback");
2420
2478
  var STATEMENT_AT = /^\s*@[a-zA-Z-]+[^;{}]*;/;
2421
2479
  function layerize(css) {
2422
2480
  let rest = css;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webto-id/variant-check",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Lint, compile and preview a Webto Variant Format (.astro subset) section variant with the exact compiler the webto.id marketplace uses.",
5
5
  "license": "MIT",
6
6
  "type": "module",