@maizzle/framework 6.0.0-rc.7 → 6.0.0-rc.9

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 (76) hide show
  1. package/dist/_virtual/_rolldown/runtime.mjs +32 -0
  2. package/dist/build.mjs +2 -1
  3. package/dist/build.mjs.map +1 -1
  4. package/dist/components/Body.vue +105 -36
  5. package/dist/components/Button.vue +4 -1
  6. package/dist/components/CodeBlock.vue +1 -1
  7. package/dist/components/CodeInline.vue +6 -1
  8. package/dist/components/Column.vue +30 -5
  9. package/dist/components/Container.vue +10 -2
  10. package/dist/components/Divider.vue +28 -0
  11. package/dist/components/Head.vue +22 -0
  12. package/dist/components/Heading.vue +28 -0
  13. package/dist/components/Html.vue +98 -47
  14. package/dist/components/Layout.vue +93 -0
  15. package/dist/components/Link.vue +26 -0
  16. package/dist/components/Markdown.vue +22 -3
  17. package/dist/components/Outlook.vue +36 -0
  18. package/dist/components/Overlap.vue +25 -5
  19. package/dist/components/Preheader.vue +1 -1
  20. package/dist/components/Row.vue +16 -5
  21. package/dist/components/Section.vue +83 -0
  22. package/dist/components/Text.vue +29 -0
  23. package/dist/components/Vml.vue +165 -13
  24. package/dist/index.d.mts +2 -1
  25. package/dist/index.mjs +2 -1
  26. package/dist/node_modules/picomatch/index.mjs +13 -0
  27. package/dist/node_modules/picomatch/index.mjs.map +1 -0
  28. package/dist/node_modules/picomatch/lib/constants.mjs +174 -0
  29. package/dist/node_modules/picomatch/lib/constants.mjs.map +1 -0
  30. package/dist/node_modules/picomatch/lib/parse.mjs +1067 -0
  31. package/dist/node_modules/picomatch/lib/parse.mjs.map +1 -0
  32. package/dist/node_modules/picomatch/lib/picomatch.mjs +304 -0
  33. package/dist/node_modules/picomatch/lib/picomatch.mjs.map +1 -0
  34. package/dist/node_modules/picomatch/lib/scan.mjs +296 -0
  35. package/dist/node_modules/picomatch/lib/scan.mjs.map +1 -0
  36. package/dist/node_modules/picomatch/lib/utils.mjs +53 -0
  37. package/dist/node_modules/picomatch/lib/utils.mjs.map +1 -0
  38. package/dist/plugin.mjs +11 -7
  39. package/dist/plugin.mjs.map +1 -1
  40. package/dist/plugins/postcss/tailwindCleanup.d.mts.map +1 -1
  41. package/dist/plugins/postcss/tailwindCleanup.mjs +24 -2
  42. package/dist/plugins/postcss/tailwindCleanup.mjs.map +1 -1
  43. package/dist/render/createRenderer.d.mts +3 -0
  44. package/dist/render/createRenderer.d.mts.map +1 -1
  45. package/dist/render/createRenderer.mjs +26 -7
  46. package/dist/render/createRenderer.mjs.map +1 -1
  47. package/dist/render/index.mjs +2 -1
  48. package/dist/render/index.mjs.map +1 -1
  49. package/dist/serve.d.mts.map +1 -1
  50. package/dist/serve.mjs +13 -6
  51. package/dist/serve.mjs.map +1 -1
  52. package/dist/server/compatibility.mjs +15 -1
  53. package/dist/server/compatibility.mjs.map +1 -1
  54. package/dist/server/email.mjs +2 -1
  55. package/dist/server/email.mjs.map +1 -1
  56. package/dist/server/linter.d.mts +1 -2
  57. package/dist/server/linter.d.mts.map +1 -1
  58. package/dist/server/linter.mjs +60 -71
  59. package/dist/server/linter.mjs.map +1 -1
  60. package/dist/server/ui/App.vue +9 -9
  61. package/dist/server/ui/pages/Preview.vue +215 -150
  62. package/dist/transformers/index.d.mts +10 -9
  63. package/dist/transformers/index.d.mts.map +1 -1
  64. package/dist/transformers/index.mjs +12 -9
  65. package/dist/transformers/index.mjs.map +1 -1
  66. package/dist/transformers/inlineCSS.d.mts +1 -14
  67. package/dist/transformers/inlineCSS.d.mts.map +1 -1
  68. package/dist/transformers/inlineCSS.mjs +16 -34
  69. package/dist/transformers/inlineCSS.mjs.map +1 -1
  70. package/dist/transformers/sixHex.d.mts +16 -0
  71. package/dist/transformers/sixHex.d.mts.map +1 -0
  72. package/dist/transformers/sixHex.mjs +30 -0
  73. package/dist/transformers/sixHex.mjs.map +1 -0
  74. package/dist/types/config.d.mts +57 -28
  75. package/dist/types/config.d.mts.map +1 -1
  76. package/package.json +2 -1
@@ -1,18 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { glob } from "tinyglobby";
4
3
 
5
4
  //#region src/server/linter.ts
6
- async function serveLint(url, config, res) {
7
- const templateSlug = url.replace("/__maizzle/lint/", "").replace(/\?.*$/, "");
8
- const match = (await glob(config.content ?? ["emails/**/*.vue"])).find((t) => t.replace(/\.(vue|md)$/, "") === templateSlug);
9
- if (!match) {
10
- res.statusCode = 404;
11
- res.end(JSON.stringify({ error: "Template not found" }));
12
- return;
13
- }
5
+ function serveLint(url, res) {
6
+ const filePath = url.replace("/__maizzle/lint/", "").replace(/\?.*$/, "");
14
7
  try {
15
- const source = readFileSync(resolve(match), "utf-8");
8
+ const source = readFileSync(resolve(filePath), "utf-8");
16
9
  const templateMatch = source.match(/<template\b[^>]*>([\s\S]*)<\/template>/);
17
10
  const issues = lintHtml(templateMatch ? templateMatch[1] : source, templateMatch ? source.slice(0, source.indexOf(templateMatch[0]) + templateMatch[0].indexOf(templateMatch[1])).split("\n").length - 1 : 0);
18
11
  res.setHeader("Content-Type", "application/json");
@@ -22,98 +15,90 @@ async function serveLint(url, config, res) {
22
15
  res.end(JSON.stringify({ error: error.message }));
23
16
  }
24
17
  }
18
+ function lineAt(html, offset, lineOffset) {
19
+ return html.slice(0, offset).split("\n").length + lineOffset;
20
+ }
25
21
  function lintHtml(html, lineOffset = 0) {
26
22
  const issues = [];
27
- const lines = html.split("\n");
28
- for (let i = 0; i < lines.length; i++) {
29
- const line = lines[i];
30
- const lineNum = i + 1 + lineOffset;
31
- const imgMatches = [...line.matchAll(/<img\b[^>]*?>/gi)];
32
- for (const match of imgMatches) {
33
- const tag = match[0];
23
+ for (const m of Array.from(html.matchAll(/<([a-zA-Z][a-zA-Z0-9]*)\b([\s\S]*?)>/g))) {
24
+ const tag = m[0];
25
+ const tagName = m[1].toLowerCase();
26
+ const line = lineAt(html, m.index, lineOffset);
27
+ if (tagName === "img") {
34
28
  if (!/\balt\s*=/i.test(tag)) issues.push({
35
29
  type: "warning",
36
30
  title: "Missing alt text",
37
31
  message: "Image is missing the alt attribute",
38
- line: lineNum
32
+ line
39
33
  });
40
- }
41
- for (const match of imgMatches) {
42
- const srcMatch = match[0].match(/\bsrc\s*=\s*["']([^"']*)["']/i);
34
+ const srcMatch = tag.match(/\bsrc\s*=\s*["']([^"']*)["']/i);
43
35
  if (!srcMatch) issues.push({
44
36
  type: "error",
45
37
  title: "Missing image src",
46
38
  message: "Image tag has no src attribute",
47
- line: lineNum
39
+ line
48
40
  });
49
41
  else if (!srcMatch[1].trim()) issues.push({
50
42
  type: "error",
51
43
  title: "Empty image src",
52
44
  message: "Image src attribute is empty",
53
- line: lineNum
45
+ line
54
46
  });
55
47
  else if (srcMatch[1].trim().startsWith("http:")) issues.push({
56
48
  type: "warning",
57
49
  title: "Insecure image src",
58
50
  message: "Image loads over HTTP instead of HTTPS",
59
- line: lineNum
51
+ line
60
52
  });
61
53
  }
62
- const linkMatches = [...line.matchAll(/<a\b[^>]*?>/gi)];
63
- for (const match of linkMatches) {
64
- const hrefMatch = match[0].match(/\bhref\s*=\s*["']([^"']*)["']/i);
65
- if (!hrefMatch) issues.push({
66
- type: "error",
67
- title: "Missing link href",
68
- message: "Anchor tag has no href attribute",
69
- line: lineNum
54
+ const hrefMatch = tag.match(/\bhref\s*=\s*["']([^"']*)["']/i);
55
+ if (hrefMatch) {
56
+ const href = hrefMatch[1].trim();
57
+ if (!href) issues.push({
58
+ type: "warning",
59
+ title: "Empty link href",
60
+ message: "Link href attribute is empty",
61
+ line
62
+ });
63
+ else if (href === "#" || href === "/") issues.push({
64
+ type: "warning",
65
+ title: "Placeholder link",
66
+ message: `Link href is "${href}"`,
67
+ line
68
+ });
69
+ else if (href.startsWith("http:")) issues.push({
70
+ type: "warning",
71
+ title: "Insecure link",
72
+ message: "Link uses HTTP instead of HTTPS",
73
+ line
74
+ });
75
+ else if (href.startsWith("http") && !/^https?:\/\/.+\..+/i.test(href)) issues.push({
76
+ type: "warning",
77
+ title: "Invalid link",
78
+ message: `Link href "${href}" looks malformed`,
79
+ line
70
80
  });
71
- else {
72
- const href = hrefMatch[1].trim();
73
- if (!href) issues.push({
74
- type: "warning",
75
- title: "Empty link href",
76
- message: "Link href attribute is empty",
77
- line: lineNum
78
- });
79
- else if (href === "#" || href === "/") issues.push({
80
- type: "warning",
81
- title: "Placeholder link",
82
- message: `Link href is "${href}"`,
83
- line: lineNum
84
- });
85
- else if (href.startsWith("http:")) issues.push({
86
- type: "warning",
87
- title: "Insecure link",
88
- message: "Link uses HTTP instead of HTTPS",
89
- line: lineNum
90
- });
91
- else if (href.startsWith("http") && !/^https?:\/\/.+\..+/i.test(href)) issues.push({
92
- type: "warning",
93
- title: "Invalid link",
94
- message: `Link href "${href}" looks malformed`,
95
- line: lineNum
96
- });
97
- }
98
81
  }
99
- const resourceMatches = [...line.matchAll(/<(?:link|script|source)\b[^>]*?>/gi)];
100
- for (const match of resourceMatches) {
101
- const attrMatch = match[0].match(/\b(?:href|src)\s*=\s*["']([^"']*)["']/i);
82
+ if ([
83
+ "link",
84
+ "script",
85
+ "source"
86
+ ].includes(tagName)) {
87
+ const attrMatch = tag.match(/\b(?:href|src)\s*=\s*["']([^"']*)["']/i);
102
88
  if (attrMatch && attrMatch[1].trim().startsWith("http:")) issues.push({
103
89
  type: "warning",
104
90
  title: "Insecure resource",
105
91
  message: "Resource loads over HTTP instead of HTTPS",
106
- line: lineNum
92
+ line
107
93
  });
108
94
  }
109
- const urlMatches = [...line.matchAll(/url\s*\(\s*["']?(http:[^"')]+)["']?\s*\)/gi)];
110
- for (const _match of urlMatches) issues.push({
111
- type: "warning",
112
- title: "Insecure CSS url()",
113
- message: "CSS url() loads over HTTP instead of HTTPS",
114
- line: lineNum
115
- });
116
95
  }
96
+ for (const m of Array.from(html.matchAll(/url\s*\(\s*["']?(http:[^"')]+)["']?\s*\)/gi))) issues.push({
97
+ type: "warning",
98
+ title: "Insecure CSS url()",
99
+ message: "CSS url() loads over HTTP instead of HTTPS",
100
+ line: lineAt(html, m.index, lineOffset)
101
+ });
117
102
  const voidElements = new Set([
118
103
  "area",
119
104
  "base",
@@ -174,7 +159,11 @@ function lintHtml(html, lineOffset = 0) {
174
159
  if (!trackedTags.has(tagName) || voidElements.has(tagName)) continue;
175
160
  if (fullMatch.endsWith("/>")) continue;
176
161
  if (fullMatch.startsWith("</")) {
177
- const lastOpen = stack.findLastIndex((s) => s.tag === tagName);
162
+ let lastOpen = -1;
163
+ for (let j = stack.length - 1; j >= 0; j--) if (stack[j].tag === tagName) {
164
+ lastOpen = j;
165
+ break;
166
+ }
178
167
  if (lastOpen !== -1) stack.splice(lastOpen, 1);
179
168
  } else stack.push({
180
169
  tag: tagName,
@@ -1 +1 @@
1
- {"version":3,"file":"linter.mjs","names":[],"sources":["../../src/server/linter.ts"],"sourcesContent":["import { readFileSync } from 'node:fs'\nimport { resolve } from 'node:path'\nimport { glob } from 'tinyglobby'\nimport type { MaizzleConfig } from '../types/index.ts'\n\ninterface LintIssue {\n type: 'error' | 'warning'\n title: string\n message: string\n line?: number\n}\n\nexport async function serveLint(url: string, config: MaizzleConfig, res: any) {\n const templateSlug = url.replace('/__maizzle/lint/', '').replace(/\\?.*$/, '')\n\n const contentPatterns = config.content ?? ['emails/**/*.vue']\n const templates = await glob(contentPatterns)\n const match = templates.find(t => t.replace(/\\.(vue|md)$/, '') === templateSlug)\n\n if (!match) {\n res.statusCode = 404\n res.end(JSON.stringify({ error: 'Template not found' }))\n return\n }\n\n try {\n const source = readFileSync(resolve(match), 'utf-8')\n\n // Extract only the <template> block for linting\n const templateMatch = source.match(/<template\\b[^>]*>([\\s\\S]*)<\\/template>/)\n const html = templateMatch ? templateMatch[1] : source\n\n // Calculate the offset of the <template> content within the source file\n const templateOffset = templateMatch\n ? source.slice(0, source.indexOf(templateMatch[0]) + templateMatch[0].indexOf(templateMatch[1])).split('\\n').length - 1\n : 0\n\n const issues = lintHtml(html, templateOffset)\n\n res.setHeader('Content-Type', 'application/json')\n res.end(JSON.stringify(issues))\n } catch (error: any) {\n res.statusCode = 500\n res.end(JSON.stringify({ error: error.message }))\n }\n}\n\nfunction lintHtml(html: string, lineOffset = 0): LintIssue[] {\n const issues: LintIssue[] = []\n const lines = html.split('\\n')\n\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i]\n const lineNum = i + 1 + lineOffset\n\n // Images missing alt text\n const imgMatches = [...line.matchAll(/<img\\b[^>]*?>/gi)]\n for (const match of imgMatches) {\n const tag = match[0]\n if (!/\\balt\\s*=/i.test(tag)) {\n issues.push({\n type: 'warning',\n title: 'Missing alt text',\n message: 'Image is missing the alt attribute',\n line: lineNum,\n })\n }\n }\n\n // Images with empty or missing src\n for (const match of imgMatches) {\n const tag = match[0]\n const srcMatch = tag.match(/\\bsrc\\s*=\\s*[\"']([^\"']*)[\"']/i)\n if (!srcMatch) {\n issues.push({\n type: 'error',\n title: 'Missing image src',\n message: 'Image tag has no src attribute',\n line: lineNum,\n })\n } else if (!srcMatch[1].trim()) {\n issues.push({\n type: 'error',\n title: 'Empty image src',\n message: 'Image src attribute is empty',\n line: lineNum,\n })\n } else if (srcMatch[1].trim().startsWith('http:')) {\n issues.push({\n type: 'warning',\n title: 'Insecure image src',\n message: 'Image loads over HTTP instead of HTTPS',\n line: lineNum,\n })\n }\n }\n\n // Links: missing href, empty href, placeholder href\n const linkMatches = [...line.matchAll(/<a\\b[^>]*?>/gi)]\n for (const match of linkMatches) {\n const tag = match[0]\n const hrefMatch = tag.match(/\\bhref\\s*=\\s*[\"']([^\"']*)[\"']/i)\n\n if (!hrefMatch) {\n issues.push({\n type: 'error',\n title: 'Missing link href',\n message: 'Anchor tag has no href attribute',\n line: lineNum,\n })\n } else {\n const href = hrefMatch[1].trim()\n if (!href) {\n issues.push({\n type: 'warning',\n title: 'Empty link href',\n message: 'Link href attribute is empty',\n line: lineNum,\n })\n } else if (href === '#' || href === '/') {\n issues.push({\n type: 'warning',\n title: 'Placeholder link',\n message: `Link href is \"${href}\"`,\n line: lineNum,\n })\n } else if (href.startsWith('http:')) {\n issues.push({\n type: 'warning',\n title: 'Insecure link',\n message: 'Link uses HTTP instead of HTTPS',\n line: lineNum,\n })\n } else if (href.startsWith('http') && !/^https?:\\/\\/.+\\..+/i.test(href)) {\n issues.push({\n type: 'warning',\n title: 'Invalid link',\n message: `Link href \"${href}\" looks malformed`,\n line: lineNum,\n })\n }\n }\n }\n\n // Insecure resources (<link href>, <script src>, <source src>)\n const resourceMatches = [...line.matchAll(/<(?:link|script|source)\\b[^>]*?>/gi)]\n for (const match of resourceMatches) {\n const tag = match[0]\n const attrMatch = tag.match(/\\b(?:href|src)\\s*=\\s*[\"']([^\"']*)[\"']/i)\n if (attrMatch && attrMatch[1].trim().startsWith('http:')) {\n issues.push({\n type: 'warning',\n title: 'Insecure resource',\n message: 'Resource loads over HTTP instead of HTTPS',\n line: lineNum,\n })\n }\n }\n\n // Insecure CSS url() references\n const urlMatches = [...line.matchAll(/url\\s*\\(\\s*[\"']?(http:[^\"')]+)[\"']?\\s*\\)/gi)]\n for (const _match of urlMatches) {\n issues.push({\n type: 'warning',\n title: 'Insecure CSS url()',\n message: 'CSS url() loads over HTTP instead of HTTPS',\n line: lineNum,\n })\n }\n }\n\n // Check for unclosed tags (block-level and common inline elements)\n const voidElements = new Set([\n 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',\n 'link', 'meta', 'param', 'source', 'track', 'wbr',\n ])\n\n const trackedTags = new Set([\n 'a', 'b', 'body', 'div', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',\n 'head', 'html', 'i', 'li', 'ol', 'p', 'span', 'strong', 'style',\n 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'title', 'tr', 'u', 'ul',\n ])\n\n const stack: Array<{ tag: string, line: number }> = []\n\n // Strip comments and content inside <style>/<script> to avoid false matches\n const stripped = html\n .replace(/<!--[\\s\\S]*?-->/g, (m) => '\\n'.repeat((m.match(/\\n/g) || []).length))\n .replace(/<(style|script)\\b[^>]*>[\\s\\S]*?<\\/\\1>/gi, (m) => '\\n'.repeat((m.match(/\\n/g) || []).length))\n\n const strippedLines = stripped.split('\\n')\n\n for (let i = 0; i < strippedLines.length; i++) {\n const line = strippedLines[i]\n const tagRegex = /<\\/?([a-zA-Z][a-zA-Z0-9]*)\\b[^>]*\\/?>/g\n let m\n\n while ((m = tagRegex.exec(line)) !== null) {\n const fullMatch = m[0]\n const tagName = m[1].toLowerCase()\n\n if (!trackedTags.has(tagName) || voidElements.has(tagName)) continue\n if (fullMatch.endsWith('/>')) continue\n\n if (fullMatch.startsWith('</')) {\n // Closing tag\n const lastOpen = stack.findLastIndex(s => s.tag === tagName)\n if (lastOpen !== -1) {\n stack.splice(lastOpen, 1)\n }\n } else {\n // Opening tag\n stack.push({ tag: tagName, line: i + 1 + lineOffset })\n }\n }\n }\n\n for (const unclosed of stack) {\n issues.push({\n type: 'error',\n title: 'Unclosed tag',\n message: `<${unclosed.tag}> tag is not closed`,\n line: unclosed.line,\n })\n }\n\n // Sort: errors first, then warnings, then by line\n issues.sort((a, b) => {\n if (a.type !== b.type) return a.type === 'error' ? -1 : 1\n return (a.line ?? 0) - (b.line ?? 0)\n })\n\n return issues\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"linter.mjs","names":[],"sources":["../../src/server/linter.ts"],"sourcesContent":["import { readFileSync } from 'node:fs'\nimport { resolve } from 'node:path'\n\ninterface LintIssue {\n type: 'error' | 'warning'\n title: string\n message: string\n line?: number\n}\n\nexport function serveLint(url: string, res: any) {\n const filePath = url.replace('/__maizzle/lint/', '').replace(/\\?.*$/, '')\n\n try {\n const source = readFileSync(resolve(filePath), 'utf-8')\n\n // Extract only the <template> block for linting\n const templateMatch = source.match(/<template\\b[^>]*>([\\s\\S]*)<\\/template>/)\n const html = templateMatch ? templateMatch[1] : source\n\n // Calculate the offset of the <template> content within the source file\n const templateOffset = templateMatch\n ? source.slice(0, source.indexOf(templateMatch[0]) + templateMatch[0].indexOf(templateMatch[1])).split('\\n').length - 1\n : 0\n\n const issues = lintHtml(html, templateOffset)\n\n res.setHeader('Content-Type', 'application/json')\n res.end(JSON.stringify(issues))\n } catch (error: any) {\n res.statusCode = 500\n res.end(JSON.stringify({ error: error.message }))\n }\n}\n\nfunction lineAt(html: string, offset: number, lineOffset: number): number {\n return html.slice(0, offset).split('\\n').length + lineOffset\n}\n\nfunction lintHtml(html: string, lineOffset = 0): LintIssue[] {\n const issues: LintIssue[] = []\n\n // Match all tags (multiline) — [^>] doesn't cross > so use [\\s\\S] with lazy quantifier\n const tagRe = /<([a-zA-Z][a-zA-Z0-9]*)\\b([\\s\\S]*?)>/g\n\n for (const m of Array.from(html.matchAll(tagRe))) {\n const tag = m[0]\n const tagName = m[1].toLowerCase()\n const line = lineAt(html, m.index!, lineOffset)\n\n // Images\n if (tagName === 'img') {\n if (!/\\balt\\s*=/i.test(tag)) {\n issues.push({ type: 'warning', title: 'Missing alt text', message: 'Image is missing the alt attribute', line })\n }\n\n const srcMatch = tag.match(/\\bsrc\\s*=\\s*[\"']([^\"']*)[\"']/i)\n if (!srcMatch) {\n issues.push({ type: 'error', title: 'Missing image src', message: 'Image tag has no src attribute', line })\n } else if (!srcMatch[1].trim()) {\n issues.push({ type: 'error', title: 'Empty image src', message: 'Image src attribute is empty', line })\n } else if (srcMatch[1].trim().startsWith('http:')) {\n issues.push({ type: 'warning', title: 'Insecure image src', message: 'Image loads over HTTP instead of HTTPS', line })\n }\n }\n\n // Any tag with href (catches <a>, <Button>, etc.)\n const hrefMatch = tag.match(/\\bhref\\s*=\\s*[\"']([^\"']*)[\"']/i)\n if (hrefMatch) {\n const href = hrefMatch[1].trim()\n if (!href) {\n issues.push({ type: 'warning', title: 'Empty link href', message: 'Link href attribute is empty', line })\n } else if (href === '#' || href === '/') {\n issues.push({ type: 'warning', title: 'Placeholder link', message: `Link href is \"${href}\"`, line })\n } else if (href.startsWith('http:')) {\n issues.push({ type: 'warning', title: 'Insecure link', message: 'Link uses HTTP instead of HTTPS', line })\n } else if (href.startsWith('http') && !/^https?:\\/\\/.+\\..+/i.test(href)) {\n issues.push({ type: 'warning', title: 'Invalid link', message: `Link href \"${href}\" looks malformed`, line })\n }\n }\n\n // Insecure resources (<link>, <script>, <source>)\n if (['link', 'script', 'source'].includes(tagName)) {\n const attrMatch = tag.match(/\\b(?:href|src)\\s*=\\s*[\"']([^\"']*)[\"']/i)\n if (attrMatch && attrMatch[1].trim().startsWith('http:')) {\n issues.push({ type: 'warning', title: 'Insecure resource', message: 'Resource loads over HTTP instead of HTTPS', line })\n }\n }\n }\n\n // Insecure CSS url() references\n for (const m of Array.from(html.matchAll(/url\\s*\\(\\s*[\"']?(http:[^\"')]+)[\"']?\\s*\\)/gi))) {\n issues.push({ type: 'warning', title: 'Insecure CSS url()', message: 'CSS url() loads over HTTP instead of HTTPS', line: lineAt(html, m.index!, lineOffset) })\n }\n\n // Check for unclosed tags (block-level and common inline elements)\n const voidElements = new Set([\n 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',\n 'link', 'meta', 'param', 'source', 'track', 'wbr',\n ])\n\n const trackedTags = new Set([\n 'a', 'b', 'body', 'div', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',\n 'head', 'html', 'i', 'li', 'ol', 'p', 'span', 'strong', 'style',\n 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'title', 'tr', 'u', 'ul',\n ])\n\n const stack: Array<{ tag: string, line: number }> = []\n\n // Strip comments and content inside <style>/<script> to avoid false matches\n const stripped = html\n .replace(/<!--[\\s\\S]*?-->/g, (m) => '\\n'.repeat((m.match(/\\n/g) || []).length))\n .replace(/<(style|script)\\b[^>]*>[\\s\\S]*?<\\/\\1>/gi, (m) => '\\n'.repeat((m.match(/\\n/g) || []).length))\n\n const strippedLines = stripped.split('\\n')\n\n for (let i = 0; i < strippedLines.length; i++) {\n const line = strippedLines[i]\n const tagRegex = /<\\/?([a-zA-Z][a-zA-Z0-9]*)\\b[^>]*\\/?>/g\n let m\n\n while ((m = tagRegex.exec(line)) !== null) {\n const fullMatch = m[0]\n const tagName = m[1].toLowerCase()\n\n if (!trackedTags.has(tagName) || voidElements.has(tagName)) continue\n if (fullMatch.endsWith('/>')) continue\n\n if (fullMatch.startsWith('</')) {\n // Closing tag\n let lastOpen = -1\n for (let j = stack.length - 1; j >= 0; j--) {\n if (stack[j].tag === tagName) { lastOpen = j; break }\n }\n if (lastOpen !== -1) {\n stack.splice(lastOpen, 1)\n }\n } else {\n // Opening tag\n stack.push({ tag: tagName, line: i + 1 + lineOffset })\n }\n }\n }\n\n for (const unclosed of stack) {\n issues.push({\n type: 'error',\n title: 'Unclosed tag',\n message: `<${unclosed.tag}> tag is not closed`,\n line: unclosed.line,\n })\n }\n\n // Sort: errors first, then warnings, then by line\n issues.sort((a, b) => {\n if (a.type !== b.type) return a.type === 'error' ? -1 : 1\n return (a.line ?? 0) - (b.line ?? 0)\n })\n\n return issues\n}\n"],"mappings":"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"}
@@ -359,13 +359,13 @@ onUnmounted(() => {
359
359
  <div class="flex items-center justify-end gap-3">
360
360
  <span
361
361
  v-if="isPreviewRoute && (!isFullSize || selectedDevice) && panelWidth"
362
- class="text-xs font-medium tabular-nums text-gray-500 dark:text-gray-400 select-none"
362
+ class="hidden min-[430px]:inline text-xs font-medium tabular-nums text-gray-500 dark:text-gray-400 select-none"
363
363
  >
364
364
  {{ panelWidth }} &times; {{ panelHeight }}
365
365
  </span>
366
366
  <DropdownMenu v-if="isPreviewRoute" v-model:open="deviceMenuOpen" :modal="false">
367
367
  <DropdownMenuTrigger as-child>
368
- <Button variant="ghost" size="sm" class="gap-1.5 shadow-none border-none hover:bg-transparent">
368
+ <Button variant="ghost" size="sm" class="hidden min-[430px]:inline-flex gap-1.5 shadow-none border-none hover:bg-transparent">
369
369
  <Smartphone class="size-4 dark:text-gray-400" :stroke-width="1" />
370
370
  <span v-if="selectedDevice" class="text-xs">{{ selectedDevice.name }}</span>
371
371
  <ChevronDown class="size-3 opacity-50" :stroke-width="1" />
@@ -394,18 +394,18 @@ onUnmounted(() => {
394
394
  <!-- Main content -->
395
395
  <div class="flex-1 overflow-hidden">
396
396
  <RouterView v-slot="{ Component }">
397
- <component :is="Component" v-model:view-mode="viewMode" :device="selectedDevice" :reset-key="resetKey" v-model:panel-width="panelWidth" v-model:panel-height="panelHeight" v-model:is-dragging="isDragging" v-model:is-full-size="isFullSize" @clear-device="selectedDevice = null; isFullSize = false" />
397
+ <component :is="Component" v-model:view-mode="viewMode" :device="selectedDevice" :reset-key="resetKey" :templates="templates" v-model:panel-width="panelWidth" v-model:panel-height="panelHeight" v-model:is-dragging="isDragging" v-model:is-full-size="isFullSize" @clear-device="selectedDevice = null; isFullSize = false" />
398
398
  </RouterView>
399
399
  </div>
400
400
  </SidebarInset>
401
401
 
402
402
  <CommandDialog v-model:open="commandOpen" title="Command palette" description="Run commands or search emails">
403
- <CommandInput v-model="commandSearch" :placeholder="isPreviewRoute ? 'Type a command or search...' : 'Search emails...'" />
403
+ <CommandInput v-model="commandSearch" placeholder="Type a command or find an email..." />
404
404
  <CommandList>
405
405
  <CommandEmpty>No results found.</CommandEmpty>
406
406
 
407
- <!-- Copy to clipboard commands: shown when not searching -->
408
- <CommandGroup v-if="!commandSearch && isPreviewRoute" heading="Copy to clipboard">
407
+ <!-- Copy to clipboard commands -->
408
+ <CommandGroup v-if="isPreviewRoute" heading="Copy to clipboard">
409
409
  <CommandItem
410
410
  value="Screenshot"
411
411
  @select="copyScreenshot"
@@ -440,8 +440,8 @@ onUnmounted(() => {
440
440
  </CommandItem>
441
441
  </CommandGroup>
442
442
 
443
- <!-- Resources: always shown when not searching -->
444
- <CommandGroup v-if="!commandSearch" heading="Resources">
443
+ <!-- Resources -->
444
+ <CommandGroup heading="Resources">
445
445
  <CommandItem
446
446
  value="Documentation"
447
447
  @select="openExternal('https://maizzle.com')"
@@ -458,7 +458,7 @@ onUnmounted(() => {
458
458
  </CommandItem>
459
459
  </CommandGroup>
460
460
 
461
- <!-- Templates: shown when searching -->
461
+ <!-- Templates -->
462
462
  <template v-if="commandSearch">
463
463
  <CommandGroup v-for="(items, dir) in commandGrouped" :key="dir" :heading="String(dir)">
464
464
  <CommandItem