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

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 (42) hide show
  1. package/dist/components/Body.vue +105 -36
  2. package/dist/components/Button.vue +4 -1
  3. package/dist/components/CodeBlock.vue +1 -1
  4. package/dist/components/CodeInline.vue +6 -1
  5. package/dist/components/Column.vue +30 -5
  6. package/dist/components/Container.vue +10 -2
  7. package/dist/components/Divider.vue +28 -0
  8. package/dist/components/Head.vue +22 -0
  9. package/dist/components/Heading.vue +28 -0
  10. package/dist/components/Html.vue +98 -47
  11. package/dist/components/Layout.vue +93 -0
  12. package/dist/components/Link.vue +26 -0
  13. package/dist/components/Markdown.vue +15 -2
  14. package/dist/components/Outlook.vue +36 -0
  15. package/dist/components/Overlap.vue +25 -5
  16. package/dist/components/Preheader.vue +1 -1
  17. package/dist/components/Row.vue +16 -5
  18. package/dist/components/Section.vue +83 -0
  19. package/dist/components/Text.vue +29 -0
  20. package/dist/components/Vml.vue +165 -13
  21. package/dist/render/createRenderer.d.mts.map +1 -1
  22. package/dist/render/createRenderer.mjs +13 -1
  23. package/dist/render/createRenderer.mjs.map +1 -1
  24. package/dist/serve.mjs +1 -1
  25. package/dist/serve.mjs.map +1 -1
  26. package/dist/server/compatibility.mjs +15 -1
  27. package/dist/server/compatibility.mjs.map +1 -1
  28. package/dist/server/email.mjs +2 -1
  29. package/dist/server/email.mjs.map +1 -1
  30. package/dist/server/linter.d.mts +1 -2
  31. package/dist/server/linter.d.mts.map +1 -1
  32. package/dist/server/linter.mjs +60 -71
  33. package/dist/server/linter.mjs.map +1 -1
  34. package/dist/server/ui/App.vue +9 -9
  35. package/dist/server/ui/pages/Preview.vue +215 -150
  36. package/dist/transformers/inlineCSS.d.mts +1 -14
  37. package/dist/transformers/inlineCSS.d.mts.map +1 -1
  38. package/dist/transformers/inlineCSS.mjs +16 -34
  39. package/dist/transformers/inlineCSS.mjs.map +1 -1
  40. package/dist/types/config.d.mts +11 -27
  41. package/dist/types/config.d.mts.map +1 -1
  42. package/package.json +1 -1
@@ -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