@templatical/import-html 0.53.0 → 0.54.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.
package/dist/index.js CHANGED
@@ -155,10 +155,11 @@ function parseLegacyColor(value) {
155
155
  return /^(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(bare) ? parseColor(`#${bare}`) : "";
156
156
  }
157
157
  /**
158
- * Parses a CSS `padding` shorthand (1-4 values) into a SpacingValue.
158
+ * Parses a CSS `padding` shorthand (1-4 values) into a SpacingValue. One with
159
+ * a negative side is no padding, since CSS drops the declaration.
159
160
  */
160
161
  function parsePaddingShorthand(value) {
161
- if (!value) return {
162
+ if (!value || hasNegativeLength(value)) return {
162
163
  top: 0,
163
164
  right: 0,
164
165
  bottom: 0,
@@ -193,16 +194,37 @@ function parsePaddingShorthand(value) {
193
194
  }
194
195
  }
195
196
  /**
197
+ * A padding longhand's px length as written, `0` included, or `undefined` when
198
+ * it doesn't parse or is negative, which makes CSS drop the declaration.
199
+ * Trimmed first so the pattern has one way to match.
200
+ */
201
+ function explicitPx(value) {
202
+ const trimmed = (value ?? "").trim();
203
+ if (ZERO_LENGTH.test(trimmed)) return 0;
204
+ const match = /^(-?\d+(?:\.\d+)?)(?:px)?$/i.exec(trimmed);
205
+ if (!match) return void 0;
206
+ const px = parseFloat(match[1]);
207
+ return px < 0 ? void 0 : Math.round(px);
208
+ }
209
+ /**
210
+ * Whether a padding value holds a negative length, judged before rounding:
211
+ * `-0.4px` rounds to `-0`, which would pass as valid. A literal `-0` is 0.
212
+ */
213
+ function hasNegativeLength(value) {
214
+ return value.trim().split(/\s+/).some((part) => parseFloat(part) < 0);
215
+ }
216
+ /**
196
217
  * Reads CSS padding from a style record, preferring the longhand props
197
- * (padding-top/right/bottom/left) and falling back to the `padding` shorthand.
218
+ * (padding-top/right/bottom/left), `0` included, and falling back to the
219
+ * `padding` shorthand for a side whose longhand is absent or doesn't parse.
198
220
  */
199
221
  function readPaddingFromStyles(styles) {
200
222
  const shorthand = parsePaddingShorthand(styles.padding);
201
223
  return {
202
- top: parsePxValue(styles["padding-top"]) || shorthand.top,
203
- right: parsePxValue(styles["padding-right"]) || shorthand.right,
204
- bottom: parsePxValue(styles["padding-bottom"]) || shorthand.bottom,
205
- left: parsePxValue(styles["padding-left"]) || shorthand.left
224
+ top: explicitPx(styles["padding-top"]) ?? shorthand.top,
225
+ right: explicitPx(styles["padding-right"]) ?? shorthand.right,
226
+ bottom: explicitPx(styles["padding-bottom"]) ?? shorthand.bottom,
227
+ left: explicitPx(styles["padding-left"]) ?? shorthand.left
206
228
  };
207
229
  }
208
230
  /**
@@ -224,46 +246,133 @@ function parseAlignment(value, fallback = "left") {
224
246
  return fallback;
225
247
  }
226
248
  /**
227
- * Parses a CSS `border` shorthand (`"1px solid #ccc"`) into width/style/color.
228
- * Order-tolerant: each token is classified by content.
229
- */
230
- function parseBorderShorthand(value) {
231
- const fallback = {
232
- width: 0,
233
- style: "solid",
234
- color: "#000000"
235
- };
236
- if (!value) return fallback;
237
- const styleKeywords = /* @__PURE__ */ new Set([
238
- "none",
239
- "hidden",
240
- "dotted",
241
- "dashed",
242
- "solid",
243
- "double",
244
- "groove",
245
- "ridge",
246
- "inset",
247
- "outset"
248
- ]);
249
- let width = 0;
250
- let style = "solid";
251
- let color = "#000000";
252
- for (const token of value.trim().split(/\s+/)) {
249
+ * Whether a CSS colour paints nothing: `transparent`, or an alpha of 0 in a
250
+ * hex (`#rgba`, `#rrggbbaa`) or functional (`rgba()`, `hsl(… / 0%)`) colour.
251
+ * `parseColor` drops the alpha, so ask this before it.
252
+ */
253
+ function isTransparentColor(value) {
254
+ if (!value) return false;
255
+ const color = value.trim().toLowerCase();
256
+ if (color === "transparent") return true;
257
+ const hex = color.match(/^#(?:[0-9a-f]{3}([0-9a-f])|[0-9a-f]{6}([0-9a-f]{2}))$/);
258
+ if (hex) return /^0+$/.test(hex[1] ?? hex[2]);
259
+ const fn = color.match(/^(?:rgb|hsl)a?\((.*)\)$/);
260
+ if (!fn) return false;
261
+ const alpha = fn[1].includes("/") ? fn[1].split("/")[1] : fn[1].split(",")[3];
262
+ if (alpha === void 0) return false;
263
+ const opacity = parseFloat(alpha);
264
+ return Number.isFinite(opacity) && opacity <= 0;
265
+ }
266
+ const BORDER_STYLE_KEYWORDS = /* @__PURE__ */ new Set([
267
+ "none",
268
+ "hidden",
269
+ "dotted",
270
+ "dashed",
271
+ "solid",
272
+ "double",
273
+ "groove",
274
+ "ridge",
275
+ "inset",
276
+ "outset"
277
+ ]);
278
+ const BORDER_WIDTH_TOKEN = /^-?\d+(?:\.\d+)?(?:px)?$/i;
279
+ /**
280
+ * A zero length: `0`, or zero in a CSS length unit, with an optional sign. Run
281
+ * on a trimmed value; each run of zeros has one way to match, so a long run
282
+ * fails in linear time.
283
+ */
284
+ const ZERO_LENGTH = new RegExp(`^[+-]?(?:0+(?:\\.0+)?|\\.0+)(?:${[
285
+ "px",
286
+ "cm",
287
+ "mm",
288
+ "q",
289
+ "in",
290
+ "pt",
291
+ "pc",
292
+ "em",
293
+ "rem",
294
+ "ex",
295
+ "rex",
296
+ "cap",
297
+ "rcap",
298
+ "ch",
299
+ "rch",
300
+ "ic",
301
+ "ric",
302
+ "lh",
303
+ "rlh",
304
+ "vw",
305
+ "vh",
306
+ "vi",
307
+ "vb",
308
+ "vmin",
309
+ "vmax",
310
+ "svw",
311
+ "svh",
312
+ "svi",
313
+ "svb",
314
+ "svmin",
315
+ "svmax",
316
+ "lvw",
317
+ "lvh",
318
+ "lvi",
319
+ "lvb",
320
+ "lvmin",
321
+ "lvmax",
322
+ "dvw",
323
+ "dvh",
324
+ "dvi",
325
+ "dvb",
326
+ "dvmin",
327
+ "dvmax",
328
+ "cqw",
329
+ "cqh",
330
+ "cqi",
331
+ "cqb",
332
+ "cqmin",
333
+ "cqmax"
334
+ ].join("|")})?$`, "i");
335
+ /**
336
+ * One side of a border: a `border` / `border-top` shorthand (`"1px solid
337
+ * #ccc"`, its parts in any order) with that side's longhands applied over it.
338
+ * `width` is the room the border takes, so a `none` or `hidden` border has
339
+ * none. `drawn` is false when the border paints nothing: a stated 0 width, a
340
+ * `none` or `hidden` style, or a transparent colour. Nothing stated reads as
341
+ * drawn.
342
+ */
343
+ function parseBorderSide(shorthand, longhands = {}) {
344
+ let widthToken;
345
+ let styleToken;
346
+ let colorToken;
347
+ for (const token of cssTokens((shorthand ?? "").trim())) {
253
348
  const lower = token.toLowerCase();
254
- if (styleKeywords.has(lower)) style = lower;
255
- else if (/^-?\d+(?:\.\d+)?(?:px)?$/i.test(lower)) width = parsePxValue(lower);
256
- else {
257
- const c = parseColor(lower);
258
- if (c) color = c;
259
- }
349
+ if (BORDER_STYLE_KEYWORDS.has(lower)) styleToken = lower;
350
+ else if (BORDER_WIDTH_TOKEN.test(lower) || ZERO_LENGTH.test(lower)) widthToken = lower;
351
+ else if (isTransparentColor(lower) || parseColor(lower)) colorToken = lower;
260
352
  }
353
+ widthToken = longhands.width?.trim().toLowerCase() ?? widthToken;
354
+ styleToken = longhands.style?.trim().toLowerCase() ?? styleToken;
355
+ colorToken = longhands.color?.trim().toLowerCase() ?? colorToken;
356
+ const style = styleToken ?? "solid";
357
+ const hidden = style === "none" || style === "hidden";
358
+ const transparent = isTransparentColor(colorToken);
359
+ const zeroWidth = widthToken !== void 0 && ZERO_LENGTH.test(widthToken);
261
360
  return {
262
- width,
361
+ width: hidden ? 0 : parsePxValue(widthToken),
263
362
  style,
264
- color
363
+ color: !transparent && parseColor(colorToken) || "#000000",
364
+ drawn: !zeroWidth && !hidden && !transparent
265
365
  };
266
366
  }
367
+ /**
368
+ * The top or bottom value of a 1–4 value box property, such as
369
+ * `border-width: 0 0 1px` (bottom `1px`). `undefined` when it isn't set.
370
+ */
371
+ function boxSideValue(value, side) {
372
+ const parts = cssTokens((value ?? "").trim());
373
+ if (parts.length === 0) return void 0;
374
+ return side === "top" ? parts[0] : parts[2] ?? parts[0];
375
+ }
267
376
  const X_GRID = [
268
377
  "left",
269
378
  "center",
@@ -1532,15 +1641,102 @@ function dividerAlign($el) {
1532
1641
  return isAuto(left) ? "right" : "left";
1533
1642
  }
1534
1643
  /**
1644
+ * One side of an `<hr>`'s border: `border-<side>`, else the `border`
1645
+ * shorthand, with `border-<side>-width/-style/-color` and the
1646
+ * `border-width/-style/-color` box values applied over it. `declared` is false
1647
+ * when nothing sets that side.
1648
+ */
1649
+ function hrSide(styles, side) {
1650
+ const shorthand = styles[`border-${side}`] ?? styles.border;
1651
+ const longhands = {
1652
+ width: styles[`border-${side}-width`] ?? boxSideValue(styles["border-width"], side),
1653
+ style: styles[`border-${side}-style`] ?? boxSideValue(styles["border-style"], side),
1654
+ color: styles[`border-${side}-color`] ?? boxSideValue(styles["border-color"], side)
1655
+ };
1656
+ const declared = shorthand !== void 0 || Object.values(longhands).some((value) => value !== void 0);
1657
+ return {
1658
+ ...parseBorderSide(shorthand, longhands),
1659
+ declared
1660
+ };
1661
+ }
1662
+ /**
1663
+ * The border that draws an `<hr>`'s line: the top border when it draws, else
1664
+ * a drawn bottom border, the common `border: 0; border-bottom: 1px solid #ccc`
1665
+ * email `<hr>`. With neither declared, the top reads as the browser's default
1666
+ * line.
1667
+ */
1668
+ function hrBorder(styles) {
1669
+ const top = hrSide(styles, "top");
1670
+ if (top.declared && top.drawn) return top;
1671
+ const bottom = hrSide(styles, "bottom");
1672
+ return bottom.declared && bottom.drawn ? bottom : top;
1673
+ }
1674
+ /**
1675
+ * The line an `<hr>`'s background draws, the usual email `<hr>`
1676
+ * (`border: 0; height: 1px; background: #ccc`): its colour and `height`.
1677
+ * `undefined` when the background is unset or transparent, or the height is 0.
1678
+ */
1679
+ function hrBackgroundLine(styles) {
1680
+ const raw = styles["background-color"] ?? backgroundShorthandColor(styles.background);
1681
+ if (!raw || isTransparentColor(raw)) return void 0;
1682
+ const color = parseColor(raw);
1683
+ const thickness = parsePxValue(styles.height);
1684
+ return color && thickness > 0 ? {
1685
+ color,
1686
+ thickness
1687
+ } : void 0;
1688
+ }
1689
+ /** An element's top and bottom margins in px, from the shorthand and longhands. */
1690
+ function verticalMargins(styles) {
1691
+ const parts = (styles.margin ?? "").trim().split(/\s+/).filter(Boolean);
1692
+ const top = parts[0] ?? "";
1693
+ const bottom = parts.length >= 3 ? parts[2] : top;
1694
+ return {
1695
+ top: parsePxValue(styles["margin-top"] ?? top),
1696
+ bottom: parsePxValue(styles["margin-bottom"] ?? bottom)
1697
+ };
1698
+ }
1699
+ /**
1700
+ * The room an `<hr>` that draws nothing takes: its vertical margins and
1701
+ * padding, its `height` and the widths of transparent top and bottom borders,
1702
+ * never below 0 (negative margins pull content up; a spacer can't). CSS drops
1703
+ * a negative padding or height, so neither counts. `undefined` when a border
1704
+ * or its background draws a line. An `<hr>` with no border styles draws the
1705
+ * browser's default line.
1706
+ */
1707
+ function undrawnHrHeight($el) {
1708
+ const styles = getStyles$1($el);
1709
+ if (hrBorder(styles).drawn || hrBackgroundLine(styles)) return void 0;
1710
+ const margin = verticalMargins(styles);
1711
+ const padding = readPaddingFromStyles(styles);
1712
+ return Math.max(0, margin.top + padding.top + hrSide(styles, "top").width + Math.max(0, parsePxValue(styles.height)) + hrSide(styles, "bottom").width + padding.bottom + margin.bottom);
1713
+ }
1714
+ /**
1715
+ * The report entry for an element that imports no block, or `undefined` when
1716
+ * it converts: an `<hr>` that draws nothing and takes no space.
1717
+ */
1718
+ function skippedElementEntry($el, $) {
1719
+ const { $el: $target } = resolveWrappedBlock($el, $);
1720
+ if (tagOf($target[0]) !== "hr" || undrawnHrHeight($target) !== 0) return;
1721
+ return {
1722
+ sourceTag: "hr",
1723
+ templaticalBlockType: null,
1724
+ status: "skipped",
1725
+ note: "The divider draws no line and takes no space."
1726
+ };
1727
+ }
1728
+ /**
1535
1729
  * <hr> → Divider block, with a note for each thing the block does not keep.
1730
+ * The line is the top border, or the background when the border draws nothing.
1536
1731
  *
1537
1732
  * Templatical centres every divider, so a partial one the source places at
1538
1733
  * either side is reported. A full-width line has no placement to lose.
1539
1734
  */
1540
1735
  function convertDivider($el, room) {
1541
1736
  const styles = getStyles$1($el);
1542
- const border = parseBorderShorthand(styles["border-top"] ?? styles.border);
1543
- const lineStyle = border.style === "dashed" || border.style === "dotted" ? border.style : "solid";
1737
+ const border = hrBorder(styles);
1738
+ const background = border.drawn ? void 0 : hrBackgroundLine(styles);
1739
+ const lineStyle = !background && (border.style === "dashed" || border.style === "dotted") ? border.style : "solid";
1544
1740
  const notes = [];
1545
1741
  const width = readDividerWidth($el, room, notes);
1546
1742
  const align = dividerAlign($el);
@@ -1548,8 +1744,8 @@ function convertDivider($el, room) {
1548
1744
  return {
1549
1745
  block: createDividerBlock({
1550
1746
  lineStyle,
1551
- color: border.color || "#e5e7eb",
1552
- thickness: border.width || 1,
1747
+ color: background?.color ?? (border.color || "#e5e7eb"),
1748
+ thickness: background?.thickness ?? (border.width || 1),
1553
1749
  width,
1554
1750
  styles: { padding: readPaddingFromStyles(styles) }
1555
1751
  }),
@@ -1704,6 +1900,24 @@ function convertElement($el, $, room) {
1704
1900
  };
1705
1901
  }
1706
1902
  if (tag === "hr") {
1903
+ const height = undrawnHrHeight($target);
1904
+ if (height !== void 0) return {
1905
+ block: createSpacerBlock({
1906
+ height,
1907
+ styles: { padding: {
1908
+ top: 0,
1909
+ right: 0,
1910
+ bottom: 0,
1911
+ left: 0
1912
+ } }
1913
+ }),
1914
+ entry: {
1915
+ sourceTag: tag,
1916
+ templaticalBlockType: "spacer",
1917
+ status: "converted",
1918
+ note: `The divider draws no line; imported as a ${height}px spacer.`
1919
+ }
1920
+ };
1707
1921
  const { block, notes } = convertDivider($target, room);
1708
1922
  return {
1709
1923
  block,
@@ -2522,6 +2736,11 @@ function extractContentBlocks($host, $, entries, warnings, room) {
2522
2736
  blocks.push(...extractContentBlocks($child, $, entries, warnings, inner));
2523
2737
  return;
2524
2738
  }
2739
+ const skipped = skippedElementEntry($child, $);
2740
+ if (skipped) {
2741
+ entries.push(skipped);
2742
+ return;
2743
+ }
2525
2744
  const r = convertElement($child, $, inner);
2526
2745
  if (r) {
2527
2746
  entries.push(r.entry);
@@ -2789,6 +3008,11 @@ function processBody($, entries, warnings, bodyWidth) {
2789
3008
  walkContainer($inner);
2790
3009
  return;
2791
3010
  }
3011
+ const skipped = skippedElementEntry($inner, $);
3012
+ if (skipped) {
3013
+ entries.push(skipped);
3014
+ return;
3015
+ }
2792
3016
  const r = convertElement($inner, $, bodyWidth);
2793
3017
  if (r) {
2794
3018
  entries.push(r.entry);
@@ -2809,6 +3033,11 @@ function processBody($, entries, warnings, bodyWidth) {
2809
3033
  flushLoose();
2810
3034
  return;
2811
3035
  }
3036
+ const skipped = skippedElementEntry($child, $);
3037
+ if (skipped) {
3038
+ entries.push(skipped);
3039
+ return;
3040
+ }
2812
3041
  const r = convertElement($child, $, bodyWidth);
2813
3042
  if (r) {
2814
3043
  entries.push(r.entry);