@samva/markup 0.7.0 → 0.8.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.
Files changed (73) hide show
  1. package/AGENTS.md +14 -0
  2. package/dist/brand-DcoVkh_S.d.ts +88 -0
  3. package/dist/{catalog-60eaYC7b.d.ts → catalog-CKuOooB8.d.ts} +13 -15
  4. package/dist/{checks-DBweSIPN.js → checks-D73Xizbw.js} +414 -2
  5. package/dist/checks.js +1 -1
  6. package/dist/classes.d.ts +2 -3
  7. package/dist/compiler.d.ts +62 -45
  8. package/dist/compiler.js +179 -857
  9. package/dist/{corpus-BjYMqOoY.d.ts → corpus-CPPfgAHN.d.ts} +14 -14
  10. package/dist/{css-COcFOT1y.d.ts → css-CS2jLID6.d.ts} +28 -2
  11. package/dist/email/assets.d.ts +40 -0
  12. package/dist/email/assets.js +63 -0
  13. package/dist/email/brand-footer.d.ts +34 -0
  14. package/dist/email/brand-footer.js +62 -0
  15. package/dist/email/brand.d.ts +2 -0
  16. package/dist/email/brand.js +148 -0
  17. package/dist/email/html.d.ts +2 -42
  18. package/dist/email/html.js +11 -0
  19. package/dist/email/render.d.ts +12 -5
  20. package/dist/email/render.js +2 -5
  21. package/dist/email/selection.d.ts +2 -91
  22. package/dist/email/stylesheets.d.ts +1 -1
  23. package/dist/email/stylesheets.js +3 -0
  24. package/dist/html-Bl9BAdqc.d.ts +42 -0
  25. package/dist/{ops-2aiiYO7m.js → ops-BvwqHl7V.js} +2 -2
  26. package/dist/ops.js +1 -1
  27. package/dist/registry-Dcp6QEK_.d.ts +73 -0
  28. package/dist/registry.d.ts +2 -42
  29. package/dist/{render-aaJBnsXK.js → render-vAv_4r9v.js} +174 -17
  30. package/dist/selection-CCArx9lU.d.ts +91 -0
  31. package/dist/{showcase-DGZAWn4Z.d.ts → showcase-egkusQBf.d.ts} +15 -56
  32. package/dist/{presets → starters}/category-mapping.d.ts +5 -5
  33. package/dist/{presets → starters}/category-mapping.js +5 -5
  34. package/dist/starters/corpus.d.ts +2 -0
  35. package/dist/{presets → starters}/corpus.js +15 -15
  36. package/dist/starters/library.d.ts +18 -0
  37. package/dist/starters/library.js +692 -0
  38. package/dist/{presets → starters}/public-gallery.d.ts +47 -47
  39. package/dist/{presets → starters}/public-gallery.js +92 -92
  40. package/dist/{presets → starters}/public-handoff.d.ts +14 -14
  41. package/dist/{presets → starters}/public-handoff.js +40 -40
  42. package/dist/{presets → starters}/public-preview-artifacts.d.ts +4 -4
  43. package/dist/{presets → starters}/public-preview-artifacts.js +15 -15
  44. package/dist/{presets → starters}/public-preview.d.ts +1 -1
  45. package/dist/{presets → starters}/public-preview.js +3 -3
  46. package/dist/starters/showcase.d.ts +2 -0
  47. package/dist/starters/showcase.js +335 -0
  48. package/dist/starters/sources.d.ts +45 -0
  49. package/dist/{presets → starters}/sources.js +1951 -272
  50. package/dist/starters/theme.d.ts +45 -0
  51. package/dist/starters/theme.js +77 -0
  52. package/dist/starters.d.ts +2 -0
  53. package/dist/{presets.js → starters.js} +4 -11
  54. package/dist/tailwind-BYq7Lo6u.js +727 -0
  55. package/dist/theme-css-hFqcPSar.js +587 -0
  56. package/dist/theme-css.d.ts +56 -15
  57. package/dist/theme-css.js +2 -509
  58. package/dist/tsx-tracer.js +1 -1
  59. package/dist/unsubscribe-link-CoesnQDU.js +26 -0
  60. package/package.json +53 -41
  61. package/samva-brand.d.ts +14 -0
  62. package/dist/footer.d.ts +0 -66
  63. package/dist/footer.js +0 -93
  64. package/dist/presets/corpus.d.ts +0 -2
  65. package/dist/presets/showcase.d.ts +0 -2
  66. package/dist/presets/showcase.js +0 -627
  67. package/dist/presets/sources.d.ts +0 -38
  68. package/dist/presets.d.ts +0 -2
  69. package/dist/theme-6ApjjiBo.d.ts +0 -116
  70. package/dist/theme.d.ts +0 -2
  71. package/dist/theme.js +0 -118
  72. package/dist/tokens.d.ts +0 -6
  73. package/dist/tokens.js +0 -417
@@ -1,6 +1,6 @@
1
- import { n as OwnedShowcaseId, t as BrandRecreationShowcaseId } from "./showcase-DGZAWn4Z.js";
1
+ import { n as OwnedShowcaseId, t as BrandRecreationShowcaseId } from "./showcase-egkusQBf.js";
2
2
  import { Result } from "better-result";
3
- //#region presets/corpus.d.ts
3
+ //#region starters/corpus.d.ts
4
4
  type TemplateCorpusKind = "owned-editable-sml" | "brand-recreation-sml" | "inspiration-reference";
5
5
  declare const templateFacetOptions: {
6
6
  readonly jobType: readonly ["account-access", "commerce", "product-communication", "lifecycle", "publishing-campaigns"];
@@ -32,8 +32,8 @@ interface CorpusTakedown {
32
32
  readonly contact: string;
33
33
  readonly procedureUrl: string;
34
34
  }
35
- interface OwnedEditablePreset {
36
- readonly type: "OwnedEditablePreset";
35
+ interface OwnedEditableStarter {
36
+ readonly type: "OwnedEditableStarter";
37
37
  readonly corpusKind: "owned-editable-sml";
38
38
  readonly provenance: {
39
39
  readonly owner: "Samva";
@@ -45,8 +45,8 @@ interface OwnedEditablePreset {
45
45
  readonly retention: CorpusRetention;
46
46
  readonly takedown: CorpusTakedown;
47
47
  }
48
- interface BrandRecreationPreset {
49
- readonly type: "BrandRecreationPreset";
48
+ interface BrandRecreationStarter {
49
+ readonly type: "BrandRecreationStarter";
50
50
  readonly corpusKind: "brand-recreation-sml";
51
51
  readonly provenance: {
52
52
  readonly owner: "Samva";
@@ -64,13 +64,13 @@ interface BrandRecreationPreset {
64
64
  readonly takedown: CorpusTakedown;
65
65
  }
66
66
  /** Clean-room recreations of a named brand's own email, authored as editable Samva projects. */
67
- declare const brandRecreationProvenance: Record<BrandRecreationShowcaseId, BrandRecreationPreset["provenance"]>;
67
+ declare const brandRecreationProvenance: Record<BrandRecreationShowcaseId, BrandRecreationStarter["provenance"]>;
68
68
  /**
69
- * Owned presets authored as complete showcase projects with their own stylesheet and assets.
70
- * Every other owned preset is the single module `packages/markup/presets/<id>.tsx`.
69
+ * Owned starters authored as complete showcase projects with their own stylesheet and assets.
70
+ * Every other owned starter is the single module `packages/markup/starters/<id>.tsx`.
71
71
  */
72
- declare const ownedShowcasePresetSources: Record<OwnedShowcaseId, string>;
73
- declare const ownedPresetSourcePath: (id: string) => string;
72
+ declare const ownedShowcaseStarterSources: Record<OwnedShowcaseId, string>;
73
+ declare const ownedStarterSourcePath: (id: string) => string;
74
74
  interface InspirationReference {
75
75
  readonly type: "InspirationReference";
76
76
  readonly corpusKind: "inspiration-reference";
@@ -85,7 +85,7 @@ interface InspirationReference {
85
85
  readonly retention: CorpusRetention;
86
86
  readonly takedown: CorpusTakedown;
87
87
  }
88
- type TemplateCorpusRecord = OwnedEditablePreset | BrandRecreationPreset | InspirationReference;
88
+ type TemplateCorpusRecord = OwnedEditableStarter | BrandRecreationStarter | InspirationReference;
89
89
  interface PublicCorpusRegistration {
90
90
  readonly id: string;
91
91
  readonly corpus: TemplateCorpusRecord;
@@ -102,5 +102,5 @@ type TemplateFacetQuery = Partial<TemplateFacetSet>;
102
102
  /** Strict intersection: every selected value in every selected dimension must match. */
103
103
  declare const matchesTemplateFacets: (facets: TemplateFacetSet, query: TemplateFacetQuery) => boolean;
104
104
  //#endregion
105
- export { PublicCorpusRegistrationIssue as a, TemplateFacetQuery as c, matchesTemplateFacets as d, ownedPresetSourcePath as f, templateFacetOptions as h, PublicCorpusRegistration as i, TemplateFacetSet as l, registerPublicCorpusEntry as m, InspirationReference as n, TemplateCorpusKind as o, ownedShowcasePresetSources as p, OwnedEditablePreset as r, TemplateCorpusRecord as s, BrandRecreationPreset as t, brandRecreationProvenance as u };
106
- //# sourceMappingURL=corpus-BjYMqOoY.d.ts.map
105
+ export { PublicCorpusRegistrationIssue as a, TemplateFacetQuery as c, matchesTemplateFacets as d, ownedShowcaseStarterSources as f, templateFacetOptions as h, PublicCorpusRegistration as i, TemplateFacetSet as l, registerPublicCorpusEntry as m, InspirationReference as n, TemplateCorpusKind as o, ownedStarterSourcePath as p, OwnedEditableStarter as r, TemplateCorpusRecord as s, BrandRecreationStarter as t, brandRecreationProvenance as u };
106
+ //# sourceMappingURL=corpus-CPPfgAHN.d.ts.map
@@ -1,6 +1,23 @@
1
1
  import { r as EmailDiagnostic } from "./diagnostics-BMYtBCQk.js";
2
2
  import { m as EmailNode } from "./elements-BzjjnoCS.js";
3
3
  import "css-tree";
4
+ //#region src/email/font-stacks.d.ts
5
+ /** One `@font-face` rule, its `src` already resolved to served URLs. */
6
+ interface FontFaceRule {
7
+ /** The family the rule declares, unquoted. */
8
+ readonly family: string;
9
+ /** The rule as it goes into the email's `<head>`. */
10
+ readonly css: string;
11
+ }
12
+ //#endregion
13
+ //#region src/email/fonts.d.ts
14
+ /**
15
+ * Maps a relative `src` URL, as authored, to the URL its project file is served
16
+ * from, or `undefined` when the project has no such file. The stylesheet's host
17
+ * owns this: it knows which file the stylesheet came from and the asset base.
18
+ */
19
+ type FontFileResolver = (reference: string) => string | undefined;
20
+ //#endregion
4
21
  //#region src/email/selectors.d.ts
5
22
  type EmailElementNode = Extract<EmailNode, {
6
23
  type: "Element";
@@ -65,8 +82,10 @@ interface CssRule {
65
82
  }
66
83
  interface Stylesheet {
67
84
  readonly rules: readonly CssRule[];
68
- /** `@font-face` and other at-rules kept verbatim in `<head>`. */
85
+ /** `@page`, `@keyframes` and other at-rules kept verbatim in `<head>`. */
69
86
  readonly headAtRules: readonly string[];
87
+ /** Validated `@font-face` rules, `src` resolved; the render hides them from classic Outlook. */
88
+ readonly fontFaces: readonly FontFaceRule[];
70
89
  /** Unconditional custom properties, for resolving `var()` before inlining. */
71
90
  readonly variables: Readonly<Record<string, string>>;
72
91
  readonly diagnostics: readonly EmailDiagnostic[];
@@ -77,7 +96,14 @@ declare const compileSelector: (selector: string) => CompiledSelector | undefine
77
96
  declare const parseStylesheet: (source: string, options?: {
78
97
  readonly origin?: string;
79
98
  readonly startOrder?: number;
99
+ /** Resolves a relative `@font-face` src to the URL its project file is served from. */
100
+ readonly resolveFontFile?: FontFileResolver | undefined;
101
+ /**
102
+ * The sheet belongs to the project theme, whose `@import`s the Tailwind
103
+ * compile resolves; they are layering, not something to report here.
104
+ */
105
+ readonly themeImports?: boolean | undefined;
80
106
  }) => Stylesheet;
81
107
  //#endregion
82
108
  export { compileSelector as a, CompiledSelector as c, matchesSelector as d, Stylesheet as i, Compound as l, CssRule as n, parseStylesheet as o, Specificity as r, Ancestry as s, CssDeclaration as t, classListOf as u };
83
- //# sourceMappingURL=css-COcFOT1y.d.ts.map
109
+ //# sourceMappingURL=css-CS2jLID6.d.ts.map
@@ -0,0 +1,40 @@
1
+ import { r as EmailDiagnostic } from "../diagnostics-BMYtBCQk.js";
2
+ //#region src/email/assets.d.ts
3
+ /** Files a template may import, and what they are served as. */
4
+ declare const ASSET_CONTENT_TYPES: Readonly<Record<string, string>>;
5
+ interface AssetEntry {
6
+ /** Project-relative path the template imported. */
7
+ readonly path: string;
8
+ /** SHA-256 of the exact bytes, lowercase hex. */
9
+ readonly digest: string;
10
+ readonly bytes: number;
11
+ readonly contentType: string;
12
+ /** `<digest><ext>` — the name the asset has under any base. */
13
+ readonly fileName: string;
14
+ }
15
+ interface AssetManifest {
16
+ /** Where these assets are served from in this build. */
17
+ readonly base: string;
18
+ readonly assets: readonly AssetEntry[];
19
+ }
20
+ /** True when the compiler turns this import into a content-addressed asset. */
21
+ declare const isAssetPath: (path: string) => boolean;
22
+ /** Address one file by its content. */
23
+ declare const assetEntry: (path: string, bytes: Uint8Array) => Promise<AssetEntry>;
24
+ /**
25
+ * The URL an asset has under a base. The base is a build input: publication
26
+ * passes its hosted origin, and a local export passes whatever it will serve
27
+ * the exported files from.
28
+ */
29
+ declare const assetUrl: (entry: AssetEntry, base: string) => string;
30
+ /** Record, but never fetch, a URL the template points at. */
31
+ declare const isExternalAsset: (reference: string) => boolean;
32
+ /**
33
+ * Reject an asset base that would produce URLs a client will not load. The
34
+ * relative forms are the ones a page on disk resolves against its own
35
+ * directory, which is what a local export writes.
36
+ */
37
+ declare const checkAssetBase: (base: string) => readonly EmailDiagnostic[];
38
+ //#endregion
39
+ export { ASSET_CONTENT_TYPES, AssetEntry, AssetManifest, assetEntry, assetUrl, checkAssetBase, isAssetPath, isExternalAsset };
40
+ //# sourceMappingURL=assets.d.ts.map
@@ -0,0 +1,63 @@
1
+ //#region src/email/assets.ts
2
+ /** Files a template may import, and what they are served as. */
3
+ const ASSET_CONTENT_TYPES = {
4
+ ".png": "image/png",
5
+ ".jpg": "image/jpeg",
6
+ ".jpeg": "image/jpeg",
7
+ ".gif": "image/gif",
8
+ ".webp": "image/webp",
9
+ ".avif": "image/avif",
10
+ ".svg": "image/svg+xml",
11
+ ".ico": "image/x-icon",
12
+ ".woff": "font/woff",
13
+ ".woff2": "font/woff2",
14
+ ".ttf": "font/ttf",
15
+ ".otf": "font/otf",
16
+ ".pdf": "application/pdf",
17
+ ".txt": "text/plain"
18
+ };
19
+ const extensionOf = (path) => {
20
+ const dot = path.lastIndexOf(".");
21
+ return dot < 0 ? "" : path.slice(dot).toLowerCase();
22
+ };
23
+ /** True when the compiler turns this import into a content-addressed asset. */
24
+ const isAssetPath = (path) => Object.hasOwn(ASSET_CONTENT_TYPES, extensionOf(path));
25
+ const hex = (buffer) => Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
26
+ /** Address one file by its content. */
27
+ const assetEntry = async (path, bytes) => {
28
+ const extension = extensionOf(path);
29
+ const view = new Uint8Array(bytes.byteLength);
30
+ view.set(bytes);
31
+ const digest = hex(await crypto.subtle.digest("SHA-256", view));
32
+ return {
33
+ path,
34
+ digest,
35
+ bytes: bytes.byteLength,
36
+ contentType: ASSET_CONTENT_TYPES[extension] ?? "application/octet-stream",
37
+ fileName: `${digest}${extension}`
38
+ };
39
+ };
40
+ /**
41
+ * The URL an asset has under a base. The base is a build input: publication
42
+ * passes its hosted origin, and a local export passes whatever it will serve
43
+ * the exported files from.
44
+ */
45
+ const assetUrl = (entry, base) => `${base.replace(/\/+$/, "")}/${entry.fileName}`;
46
+ const EXTERNAL_IMAGE = /^https:\/\//i;
47
+ /** Record, but never fetch, a URL the template points at. */
48
+ const isExternalAsset = (reference) => EXTERNAL_IMAGE.test(reference);
49
+ /**
50
+ * Reject an asset base that would produce URLs a client will not load. The
51
+ * relative forms are the ones a page on disk resolves against its own
52
+ * directory, which is what a local export writes.
53
+ */
54
+ const checkAssetBase = (base) => /^(https:\/\/|\.{1,2}\/|\/)/i.test(base) ? [] : [{
55
+ code: "invalid-asset-base",
56
+ severity: "error",
57
+ message: `Asset base ${JSON.stringify(base)} must be an https URL, an absolute path, or a relative path for a local export.`,
58
+ origins: []
59
+ }];
60
+ //#endregion
61
+ export { ASSET_CONTENT_TYPES, assetEntry, assetUrl, checkAssetBase, isAssetPath, isExternalAsset };
62
+
63
+ //# sourceMappingURL=assets.js.map
@@ -0,0 +1,34 @@
1
+ import { t as EmailContent } from "../template-DWftqh4x.js";
2
+ import { RenderedEmail } from "./render.js";
3
+ import { a as EmailBrand, n as BrandComponents } from "../brand-DcoVkh_S.js";
4
+ //#region src/email/brand-footer.d.ts
5
+ /** A footer block for the send path to append, and its plain-text tail. */
6
+ interface RenderedBrandFooter {
7
+ readonly html: string;
8
+ readonly text: string;
9
+ }
10
+ /**
11
+ * Render `BrandFooter` against a brand on its own, for the send path to
12
+ * append to a message whose template renders no footer of its own. It
13
+ * compiles the same component, styled by the same brand theme, a template
14
+ * would get from `samva:brand`. Without `unsubscribe` the unsubscribe row is
15
+ * left out.
16
+ */
17
+ declare const renderBrandFooter: (brand: EmailBrand, options: {
18
+ readonly unsubscribe: boolean;
19
+ }) => Promise<RenderedBrandFooter>;
20
+ /**
21
+ * Render content against a brand as a project whose `theme.css` is
22
+ * `@import "samva:brand";` renders it: Tailwind compiles the brand theme for
23
+ * the classes the content names, `BrandLogo` and `BrandFooter` are bound to the
24
+ * brand, and the render inlines the result. A brand preview renders through
25
+ * this, so it shows what a template importing the brand compiles to.
26
+ */
27
+ declare const renderBrandSample: (brand: EmailBrand, sample: {
28
+ /** Every class the content names; nothing is discovered from source here. */
29
+ readonly classes: readonly string[];
30
+ readonly render: (components: BrandComponents) => EmailContent;
31
+ }) => Promise<RenderedEmail>;
32
+ //#endregion
33
+ export { RenderedBrandFooter, renderBrandFooter, renderBrandSample };
34
+ //# sourceMappingURL=brand-footer.d.ts.map
@@ -0,0 +1,62 @@
1
+ import { a as renderEmailContent, o as applyStylesheet, r as compileEmail } from "../render-vAv_4r9v.js";
2
+ import { EmailCompileError } from "./diagnostics.js";
3
+ import { BRAND_COMPONENT_CLASSES, brandComponents, withoutUnsubscribeRow } from "./brand.js";
4
+ import { c as parseBrandCss, i as brandTailwindCss } from "../theme-css-hFqcPSar.js";
5
+ import { t as compileTailwind, u as parseStylesheet } from "../tailwind-BYq7Lo6u.js";
6
+ //#region src/email/brand-footer.ts
7
+ /** The brand theme as Tailwind compiles it; a theme outside the grammar is a compile refusal. */
8
+ const brandTheme = (brand) => {
9
+ const parsed = parseBrandCss(brand.css);
10
+ if (parsed.isErr()) throw new EmailCompileError({ diagnostics: parsed.error.map((finding) => ({
11
+ code: finding.code,
12
+ severity: "error",
13
+ message: `samva:brand: ${finding.message}`,
14
+ origins: []
15
+ })) });
16
+ return brandTailwindCss(parsed.value);
17
+ };
18
+ /**
19
+ * Render `BrandFooter` against a brand on its own, for the send path to
20
+ * append to a message whose template renders no footer of its own. It
21
+ * compiles the same component, styled by the same brand theme, a template
22
+ * would get from `samva:brand`. Without `unsubscribe` the unsubscribe row is
23
+ * left out.
24
+ */
25
+ const renderBrandFooter = async (brand, options) => {
26
+ const tailwind = await compileTailwind(BRAND_COMPONENT_CLASSES, { css: brandTheme(brand) });
27
+ const node = brandComponents(brand).BrandFooter({});
28
+ if (node === null) return {
29
+ html: "",
30
+ text: ""
31
+ };
32
+ const styled = applyStylesheet(node, parseStylesheet(tailwind.css, { origin: "samva:brand" }));
33
+ const compiled = compileEmail(styled.tree);
34
+ const rendered = options.unsubscribe ? compiled : withoutUnsubscribeRow({
35
+ html: compiled.html,
36
+ text: compiled.text
37
+ });
38
+ const text = (rendered.text ?? "").trim();
39
+ return {
40
+ html: rendered.html ?? "",
41
+ text: text === "" ? "" : `\n\n---\n${text}`
42
+ };
43
+ };
44
+ /**
45
+ * Render content against a brand as a project whose `theme.css` is
46
+ * `@import "samva:brand";` renders it: Tailwind compiles the brand theme for
47
+ * the classes the content names, `BrandLogo` and `BrandFooter` are bound to the
48
+ * brand, and the render inlines the result. A brand preview renders through
49
+ * this, so it shows what a template importing the brand compiles to.
50
+ */
51
+ const renderBrandSample = async (brand, sample) => {
52
+ const tailwind = await compileTailwind([...BRAND_COMPONENT_CLASSES, ...sample.classes], { css: "@import \"samva:brand\";" }, {
53
+ stylesheets: /* @__PURE__ */ new Map(),
54
+ brandCss: brandTheme(brand)
55
+ });
56
+ const components = brandComponents(brand);
57
+ return renderEmailContent(parseStylesheet(tailwind.css, { origin: "samva:brand" }), () => sample.render(components));
58
+ };
59
+ //#endregion
60
+ export { renderBrandFooter, renderBrandSample };
61
+
62
+ //# sourceMappingURL=brand-footer.js.map
@@ -0,0 +1,2 @@
1
+ import { a as EmailBrand, c as EmailBrandFooter, d as RenderedFooterMarkers, f as UNSUBSCRIBE_ROW_ATTRIBUTE, g as UNSUBSCRIBE_URL_PLACEHOLDER, h as withoutUnsubscribeRow, i as BrandLogoProps, l as EmailBrandSocialLink, m as renderedFooterMarkers, n as BrandComponents, o as EmailBrandAddress, p as brandComponents, r as BrandFooterProps, s as EmailBrandAssets, t as BRAND_COMPONENT_CLASSES, u as FOOTER_ATTRIBUTE } from "../brand-DcoVkh_S.js";
2
+ export { BRAND_COMPONENT_CLASSES, BrandComponents, BrandFooterProps, BrandLogoProps, EmailBrand, EmailBrandAddress, EmailBrandAssets, EmailBrandFooter, EmailBrandSocialLink, FOOTER_ATTRIBUTE, RenderedFooterMarkers, UNSUBSCRIBE_ROW_ATTRIBUTE, UNSUBSCRIBE_URL_PLACEHOLDER, brandComponents, renderedFooterMarkers, withoutUnsubscribeRow };
@@ -0,0 +1,148 @@
1
+ import { Fragment, jsx } from "./jsx-runtime.js";
2
+ import { i as UNSUBSCRIBE_URL_PLACEHOLDER, r as UNSUBSCRIBE_LINK, t as BRAND_FOOTER_MARKER } from "../unsubscribe-link-CoesnQDU.js";
3
+ //#region src/email/brand.ts
4
+ /** Marks the block the send path recognizes as the template's own footer. */
5
+ const FOOTER_ATTRIBUTE = "data-samva-footer";
6
+ /** Marks the footer row that carries the unsubscribe link. */
7
+ const UNSUBSCRIBE_ROW_ATTRIBUTE = "data-samva-unsubscribe";
8
+ /** Every class the brand components use, compiled for any project that imports them. */
9
+ const BRAND_COMPONENT_CLASSES = [
10
+ "dark:hidden!",
11
+ "hidden",
12
+ "dark:block!",
13
+ "text-xs",
14
+ "text-gray-500",
15
+ "text-center",
16
+ "font-semibold",
17
+ "underline"
18
+ ];
19
+ const addressLines = (address) => [
20
+ address.street1,
21
+ address.street2,
22
+ [
23
+ address.city,
24
+ address.region,
25
+ address.postalCode
26
+ ].filter((part) => part.trim() !== "").join(", "),
27
+ address.country
28
+ ].filter((line) => line !== void 0 && line.trim() !== "");
29
+ const classes = (...names) => {
30
+ const joined = names.filter((name) => name !== void 0 && name !== "").join(" ");
31
+ return joined === "" ? void 0 : joined;
32
+ };
33
+ const row = (children, props = {}) => jsx("tr", {
34
+ ...props,
35
+ children: jsx("td", {
36
+ style: { padding: "0 0 8px 0" },
37
+ children
38
+ })
39
+ });
40
+ /** Bind `BrandLogo` and `BrandFooter` to one brand. */
41
+ const brandComponents = (brand) => {
42
+ const assets = brand.assets ?? {};
43
+ const footer = brand.footer ?? {};
44
+ const BrandLogo = ({ width = 120, alt, align, className }) => {
45
+ if (assets.logoUrl === void 0) return null;
46
+ const label = alt ?? footer.companyName ?? "";
47
+ const image = (src, imageClass, imageAlt) => jsx("img", {
48
+ src,
49
+ alt: imageAlt,
50
+ width,
51
+ border: 0,
52
+ ...align === void 0 ? {} : { align },
53
+ className: classes(imageClass, className),
54
+ style: {
55
+ display: "block",
56
+ outline: "none",
57
+ border: "none",
58
+ height: "auto"
59
+ }
60
+ });
61
+ if (assets.logoDarkUrl === void 0) return image(assets.logoUrl, void 0, label);
62
+ return jsx(Fragment, { children: [image(assets.logoUrl, "dark:hidden!", label), jsx("div", {
63
+ className: "hidden dark:block!",
64
+ style: {
65
+ msoHide: "all",
66
+ maxHeight: 0,
67
+ overflow: "hidden"
68
+ },
69
+ children: image(assets.logoDarkUrl, void 0, "")
70
+ })] });
71
+ };
72
+ const BrandFooter = ({ unsubscribeLabel = "Unsubscribe", className }) => {
73
+ const rows = [];
74
+ if (footer.companyName !== void 0 && footer.companyName.trim() !== "") rows.push(row(jsx("span", {
75
+ className: "font-semibold",
76
+ children: footer.companyName
77
+ })));
78
+ if (footer.address !== void 0) {
79
+ const lines = addressLines(footer.address);
80
+ if (lines.length > 0) rows.push(row(lines.flatMap((line, index) => index === 0 ? [line] : [jsx("br", {}), line])));
81
+ }
82
+ const links = footer.socialLinks ?? [];
83
+ if (links.length > 0) rows.push(row(links.flatMap((link, index) => [...index === 0 ? [] : [" · "], jsx("a", {
84
+ href: link.url,
85
+ className: "text-gray-500 underline",
86
+ children: link.label
87
+ })])));
88
+ rows.push(row(unsubscribeLink(unsubscribeLabel), {
89
+ [UNSUBSCRIBE_ROW_ATTRIBUTE]: "",
90
+ [BRAND_FOOTER_MARKER]: true
91
+ }));
92
+ return jsx("table", {
93
+ role: "presentation",
94
+ width: "100%",
95
+ border: 0,
96
+ cellpadding: 0,
97
+ cellspacing: 0,
98
+ [FOOTER_ATTRIBUTE]: "",
99
+ [BRAND_FOOTER_MARKER]: true,
100
+ children: jsx("tbody", { children: jsx("tr", { children: jsx("td", {
101
+ align: "center",
102
+ className: classes("text-xs text-gray-500 text-center", className),
103
+ style: { padding: "32px 16px" },
104
+ children: jsx("table", {
105
+ role: "presentation",
106
+ border: 0,
107
+ cellpadding: 0,
108
+ cellspacing: 0,
109
+ children: jsx("tbody", { children: rows })
110
+ })
111
+ }) }) })
112
+ });
113
+ };
114
+ return {
115
+ BrandLogo,
116
+ BrandFooter
117
+ };
118
+ };
119
+ /**
120
+ * The unsubscribe link. Its href is the placeholder, which no author-written
121
+ * link may carry: the serializer accepts it only on this node, and marks it so
122
+ * click tracking never rewrites it.
123
+ */
124
+ const unsubscribeLink = (label) => jsx("a", {
125
+ href: UNSUBSCRIBE_URL_PLACEHOLDER,
126
+ className: "text-gray-500 underline",
127
+ [UNSUBSCRIBE_LINK]: true,
128
+ children: label
129
+ });
130
+ const UNSUBSCRIBE_ROW = new RegExp(`<tr ${UNSUBSCRIBE_ROW_ATTRIBUTE}=""[^>]*>[\\s\\S]*?</tr>`, "g");
131
+ const UNSUBSCRIBE_HREF = `href="${UNSUBSCRIBE_URL_PLACEHOLDER}"`;
132
+ /** Read the footer markers from rendered HTML. */
133
+ const renderedFooterMarkers = (html) => ({
134
+ footer: html.includes(`${FOOTER_ATTRIBUTE}=""`),
135
+ unsubscribeLink: html.includes(UNSUBSCRIBE_HREF)
136
+ });
137
+ /**
138
+ * Drop the unsubscribe row from a rendered message, for a send that resolved
139
+ * no unsubscribe group: there is no link to put there, so the row goes.
140
+ */
141
+ const withoutUnsubscribeRow = (rendered) => ({
142
+ html: rendered.html?.replace(UNSUBSCRIBE_ROW, ""),
143
+ text: rendered.text?.split("\n").filter((line) => !line.includes(UNSUBSCRIBE_URL_PLACEHOLDER)).join("\n")
144
+ });
145
+ //#endregion
146
+ export { BRAND_COMPONENT_CLASSES, FOOTER_ATTRIBUTE, UNSUBSCRIBE_ROW_ATTRIBUTE, UNSUBSCRIBE_URL_PLACEHOLDER, brandComponents, renderedFooterMarkers, withoutUnsubscribeRow };
147
+
148
+ //# sourceMappingURL=brand.js.map
@@ -1,42 +1,2 @@
1
- import { r as EmailDiagnostic } from "../diagnostics-BMYtBCQk.js";
2
- import { m as EmailNode } from "../elements-BzjjnoCS.js";
3
- import { EmittedPosition } from "./selection.js";
4
- //#region src/email/html.d.ts
5
- /** Escape a value for element content. */
6
- declare const escapeText: (value: string) => string;
7
- /** Escape a value for a double-quoted attribute. */
8
- declare const escapeAttribute: (value: string) => string;
9
- /** camelCase to kebab-case; already-kebab names pass through unchanged. */
10
- declare const kebab: (property: string) => string;
11
- /** A bare number is pixels for every property that takes a length. */
12
- declare const declarationValue: (property: string, value: string | number) => string;
13
- interface EmailHtmlOutput {
14
- readonly html: string;
15
- readonly diagnostics: readonly EmailDiagnostic[];
16
- /**
17
- * Each emitted element's offset range and origins. A compatibility finding
18
- * carries a position in the emitted document, and this is what turns that
19
- * back into the authoring element the author can edit.
20
- */
21
- readonly positions: readonly EmittedPosition[];
22
- }
23
- /** How this document will be consumed. Delivery is the default; export is opt-in. */
24
- interface SerializeEmailOptions {
25
- /**
26
- * Accept relative references. A local export serves assets from a relative
27
- * base beside the file; a delivered message has no directory to resolve one
28
- * against, so this stays off for anything that will be sent.
29
- */
30
- readonly relativeUrls?: boolean | undefined;
31
- /**
32
- * Stamp every element with its instance path, so an editor preview can
33
- * resolve a click in the rendered document to exactly one rendered element.
34
- * A delivered message never carries it.
35
- */
36
- readonly instancePaths?: boolean | undefined;
37
- }
38
- /** Lower the derived tree to email HTML, reporting every unsupported construct with its origin. */
39
- declare const serializeEmailHtml: (node: EmailNode, options?: SerializeEmailOptions) => EmailHtmlOutput;
40
- //#endregion
41
- export { EmailHtmlOutput, SerializeEmailOptions, declarationValue, escapeAttribute, escapeText, kebab, serializeEmailHtml };
42
- //# sourceMappingURL=html.d.ts.map
1
+ import { a as escapeText, i as escapeAttribute, n as SerializeEmailOptions, o as kebab, r as declarationValue, s as serializeEmailHtml, t as EmailHtmlOutput } from "../html-Bl9BAdqc.js";
2
+ export { EmailHtmlOutput, SerializeEmailOptions, declarationValue, escapeAttribute, escapeText, kebab, serializeEmailHtml };
@@ -1,4 +1,5 @@
1
1
  import { n as isObject } from "../guards-eV1qLbf2.js";
2
+ import { i as UNSUBSCRIBE_URL_PLACEHOLDER, n as RESERVED_FOOTER_ATTRIBUTES, r as UNSUBSCRIBE_LINK, t as BRAND_FOOTER_MARKER } from "../unsubscribe-link-CoesnQDU.js";
2
3
  import { EMAIL_ELEMENTS, EMAIL_GLOBAL_ATTRIBUTES, emailElement } from "./elements.js";
3
4
  import { INSTANCE_PATH_ATTRIBUTE } from "./selection.js";
4
5
  //#region src/email/html.ts
@@ -117,9 +118,15 @@ const attributes = (sink, tag, spec, props, origins) => {
117
118
  const emitted = [];
118
119
  const present = /* @__PURE__ */ new Set();
119
120
  let classes;
121
+ const unsubscribeLink = tag === "a" && props[UNSUBSCRIBE_LINK] === true;
120
122
  for (const [key, value] of Object.entries(props)) {
121
123
  if (key === "key") continue;
122
124
  const name = key === "className" ? "class" : key;
125
+ if (unsubscribeLink && name === "href" && value === "%samva:unsubscribe-url%") {
126
+ present.add(name);
127
+ emitted.push(` href="${UNSUBSCRIBE_URL_PLACEHOLDER}" ses:no-track`);
128
+ continue;
129
+ }
123
130
  if (name === "class") {
124
131
  if (value === void 0 || value === null) continue;
125
132
  if (typeof value !== "string") {
@@ -148,6 +155,10 @@ const attributes = (sink, tag, spec, props, origins) => {
148
155
  report(sink, "event-handler", `<${tag} ${name}> is an event handler; email clients strip scripting and Samva refuses it.`, origins);
149
156
  continue;
150
157
  }
158
+ if (RESERVED_FOOTER_ATTRIBUTES.has(name) && props[BRAND_FOOTER_MARKER] !== true) {
159
+ report(sink, "reserved-attribute", `<${tag} ${name}> is written only by BrandFooter from samva:brand; render BrandFooter instead of marking an element as the footer.`, origins);
160
+ continue;
161
+ }
151
162
  if (/^(data|aria)-[a-z0-9-]+$/.test(name)) {
152
163
  const text = attributeValue(sink, tag, name, { type: "string" }, value, origins);
153
164
  if (text !== void 0) emitted.push(` ${name}="${escapeAttribute(text)}"`);
@@ -1,9 +1,9 @@
1
1
  import { r as EmailDiagnostic } from "../diagnostics-BMYtBCQk.js";
2
2
  import { m as EmailNode } from "../elements-BzjjnoCS.js";
3
- import { EmittedPosition } from "./selection.js";
4
- import { SerializeEmailOptions } from "./html.js";
5
- import { r as TemplateDefinition } from "../template-DWftqh4x.js";
6
- import { i as Stylesheet } from "../css-COcFOT1y.js";
3
+ import { n as EmittedPosition } from "../selection-CCArx9lU.js";
4
+ import { n as SerializeEmailOptions } from "../html-Bl9BAdqc.js";
5
+ import { r as TemplateDefinition, t as EmailContent } from "../template-DWftqh4x.js";
6
+ import { i as Stylesheet } from "../css-CS2jLID6.js";
7
7
  //#region src/email/render.d.ts
8
8
  /** Classic Outlook lays a document out correctly only under a transitional doctype. */
9
9
  declare const EMAIL_DOCTYPE = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">";
@@ -38,6 +38,13 @@ declare const renderEmail: <Input>(template: TemplateDefinition<Input>, data: un
38
38
  * loaded into the same process cannot restyle an entry that is rendered again.
39
39
  */
40
40
  declare const bindStylesheets: (stylesheet?: Stylesheet | undefined) => typeof renderEmail;
41
+ /**
42
+ * Render authored content against a stylesheet: inline what a client honors,
43
+ * keep the rest for `<head>`, and lower to HTML and text. `renderEmail` runs a
44
+ * validated template through it; a host that composes content without a
45
+ * template definition calls it directly.
46
+ */
47
+ declare const renderEmailContent: (stylesheet: Stylesheet | undefined, render: () => EmailContent, options?: SerializeEmailOptions) => RenderedEmail;
41
48
  //#endregion
42
- export { EMAIL_DOCTYPE, EmailCompileOutput, RenderedEmail, bindStylesheets, compileEmail, renderEmail };
49
+ export { EMAIL_DOCTYPE, EmailCompileOutput, RenderedEmail, bindStylesheets, compileEmail, renderEmail, renderEmailContent };
43
50
  //# sourceMappingURL=render.d.ts.map
@@ -1,6 +1,3 @@
1
- import { i as renderEmail, n as bindStylesheets, r as compileEmail, t as EMAIL_DOCTYPE } from "../render-aaJBnsXK.js";
2
- import "./html.js";
1
+ import { a as renderEmailContent, i as renderEmail, n as bindStylesheets, r as compileEmail, t as EMAIL_DOCTYPE } from "../render-vAv_4r9v.js";
3
2
  import "./diagnostics.js";
4
- import "./stylesheets.js";
5
- import "./text.js";
6
- export { EMAIL_DOCTYPE, bindStylesheets, compileEmail, renderEmail };
3
+ export { EMAIL_DOCTYPE, bindStylesheets, compileEmail, renderEmail, renderEmailContent };