@casomoltd/tooling 0.32.0 → 0.33.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/README.md CHANGED
@@ -15,7 +15,7 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
15
15
  | `@casomoltd/tooling/knip` | Knip config for unused exports/dependencies |
16
16
  | `@casomoltd/tooling/jscpd` | Copy-paste detection config |
17
17
  | `@casomoltd/tooling/readability` | `scoreText()` — readability scoring for page content |
18
- | `@casomoltd/tooling/buttondown` | For a repo using the `buttondown` command: `tokenReader()` for its email templates, `loadSettings()` and `buildPayload()` for its tests, `EMAIL_CHROME_HIDES` for its config, and the `Check` type for its own gates |
18
+ | `@casomoltd/tooling/buttondown` | For a repo using the `buttondown` command: the shared email blocks and skins (`createEmailBlocks`, `emailBaseCss`, `composeWebSkin`), `tokenReader()`, `loadSettings()` and `buildPayload()`, and `EMAIL_CHROME_HIDES` ([docs/buttondown.md](docs/buttondown.md)) |
19
19
 
20
20
  ### Bin commands
21
21
 
@@ -33,7 +33,7 @@ Shared linting, formatting, commit config, and CLI tools for Casomo Ltd's repos.
33
33
  | `spec-check` | Check a design spec's anchors, contents, figures and numbering |
34
34
  | `check-private-refs` | In a public repo, refuse a private tracker link or a name listed under `privateRefs` in `casomo.config.mjs` |
35
35
  | `check-personal-data` | Refuse an email, National Insurance number, UK mobile number, date of birth or locally denylisted name; per-repo allowances under `personalData` in `casomo.config.mjs` |
36
- | `buttondown` | Keep a Buttondown newsletter as code: `diff` and `apply` its fields (settings, skins and email templates) and tags, `check` the skins, sync their tokens, and create drafts. Settings under `buttondown` in `casomo.config.mjs`; the API key from the file `BUTTONDOWN_KEY_FILE` names |
36
+ | `buttondown` | Keep a Buttondown newsletter as code: `diff` and `apply` its fields (settings, skins and email templates) and tags, `check` the skins, sync their tokens, and create drafts ([docs/buttondown.md](docs/buttondown.md)) |
37
37
 
38
38
  ## Install
39
39
 
@@ -0,0 +1,135 @@
1
+ /* The rules every site's blog skin shares, inserted ahead of the site's own
2
+ rules by composeWebSkin. They restyle Buttondown's markup by role and set
3
+ no value of their own: each comes from a variable the site's TOKENS block
4
+ defines, Buttondown's theme variables where the theme has the role and
5
+ --bd-* where it has not:
6
+
7
+ --bd-font-heading headings and the archive entry's title
8
+ --bd-heading their colour
9
+ --bd-action-bg the one action's ground
10
+ --bd-action-ink its label
11
+ --bd-action-weight its label's weight
12
+ --bd-dialog-bg a dialog's ground
13
+ --bd-focus the focus ring
14
+ --bd-leading an archive entry's line height */
15
+
16
+ body,
17
+ .subscribe,
18
+ .manage-subscription {
19
+ background: var(--color-background);
20
+ color: var(--color-foreground);
21
+ font-family: var(--font-family-sans);
22
+ }
23
+
24
+ /* Square and flat everywhere, whatever class Buttondown gives an element,
25
+ its dialogs and their buttons included. A tag switch keeps its round
26
+ track. Removing every shadow removes any focus ring drawn as one, so
27
+ focus is an outline below. */
28
+ *:not(.checkbox-field),
29
+ *::before,
30
+ *::after {
31
+ border-radius: var(--radius) !important;
32
+ box-shadow: none !important;
33
+ }
34
+
35
+ /* Important, so a theme utility that removes outlines cannot leave a
36
+ control with no focus indicator. */
37
+ :focus-visible {
38
+ outline: 2px solid var(--bd-focus) !important;
39
+ outline-offset: 2px;
40
+ }
41
+
42
+ h1, h2, h3,
43
+ .newsletter-name,
44
+ .section-title,
45
+ .modal-title,
46
+ .login-modal-title {
47
+ font-family: var(--bd-font-heading);
48
+ color: var(--bd-heading);
49
+ letter-spacing: -0.01em;
50
+ }
51
+
52
+ /* The one action. `.archive__button` is every action button on the
53
+ archive, the subscribe form's and a signed-in reader's "Manage
54
+ subscription" alike; the theme sets it pill-shaped, white and upper
55
+ case with `!important`, so these name the element too to outrank it. */
56
+ a.archive__button,
57
+ button.archive__button,
58
+ button.button.tinted,
59
+ .portal-btn--primary,
60
+ .subscription-success-button {
61
+ background-color: var(--bd-action-bg) !important;
62
+ color: var(--bd-action-ink) !important;
63
+ font-family: var(--font-family-sans) !important;
64
+ font-weight: var(--bd-action-weight) !important;
65
+ text-transform: none !important;
66
+ border-radius: var(--radius) !important;
67
+ }
68
+
69
+ /* 16px, because iOS zooms a focused field below it and the page jumps.
70
+ Buttondown's own rule is `input[type=email].input-field`, so the
71
+ portal's field is named with its type to outrank it. */
72
+ .archive__subscribe-form-input,
73
+ #subscribe-form input[type="email"],
74
+ .subscribe-modal-content input[type="email"],
75
+ .login-modal-content .magic-link-input,
76
+ .manage-subscription input[type="email"].input-field,
77
+ .manage-subscription input[type="text"].input-field {
78
+ font-family: var(--font-family-sans);
79
+ font-size: 16px;
80
+ }
81
+
82
+ .modal-content,
83
+ .buttondown-dialog-root .bg-background {
84
+ background: var(--bd-dialog-bg) !important;
85
+ }
86
+
87
+ /* The log-in dialog renders outside the page body, so it does not
88
+ inherit the page's type. */
89
+ .login-modal-content,
90
+ .login-modal-content * {
91
+ font-family: var(--font-family-sans);
92
+ }
93
+
94
+ /* The line after a log-in request: body text, not Buttondown's green. */
95
+ .magic-link-success-message {
96
+ color: var(--color-foreground) !important;
97
+ }
98
+
99
+ /* The theme centres the description, and lines of centred body text are
100
+ harder to read, since each starts in a different place. */
101
+ .newsletter-description,
102
+ .newsletter-description p {
103
+ text-align: left;
104
+ }
105
+
106
+ /* An archive entry, set as an article is: a large left-aligned title and
107
+ justified, hyphenated prose. */
108
+ .email-body .subject {
109
+ font-family: var(--bd-font-heading);
110
+ font-size: 36px;
111
+ font-weight: 700;
112
+ line-height: 1.1;
113
+ color: var(--bd-heading);
114
+ text-align: left;
115
+ }
116
+
117
+ .email-body .description {
118
+ text-align: left;
119
+ }
120
+
121
+ .email-body-content {
122
+ margin-top: 1.5rem;
123
+ }
124
+
125
+ .email-body-content p,
126
+ .email-body-content li {
127
+ font-size: 16px;
128
+ line-height: var(--bd-leading);
129
+ }
130
+
131
+ .email-body-content p {
132
+ text-align: justify;
133
+ hyphens: auto;
134
+ -webkit-hyphens: auto;
135
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The bands an email is built from, for a repo whose Buttondown emails
3
+ * draw their own header and footer: the title on a ground, white air,
4
+ * paragraphs, one action, a sign-off and the small print. The site
5
+ * supplies its tokens, its mark band and its colophon; this supplies the
6
+ * layout they share.
7
+ *
8
+ * Every rule is an inline `style` attribute, because inline is the only
9
+ * thing every mail client honours, and vertical rhythm is carried on
10
+ * table cell padding, because Outlook drops a margin on a table but
11
+ * always honours padding on a cell. Every block ends with its own
12
+ * trailing newlines, so a template is a plain concatenation.
13
+ */
14
+
15
+ /** Buttondown re-emits these attributes in its own order when it inlines
16
+ * an email, so the opening tag is written once. */
17
+ export const TABLE =
18
+ '<table role="presentation"\n'
19
+ + ' cellpadding="0"\n'
20
+ + ' cellspacing="0"\n'
21
+ + ' border="0"\n'
22
+ + ' width="100%"';
23
+
24
+ /** The body text size and a paragraph's spacing, the same in every band
25
+ * and in the shared email stylesheet. */
26
+ export const BODY_SIZE = "17px";
27
+ export const PARAGRAPH_MARGIN = "0 0 16px 0";
28
+
29
+ /** The template variables Buttondown fills with a subscriber's links. */
30
+ const UNSUBSCRIBE = '{{ unsubscribe_url }}';
31
+ const MANAGE = '{{ manage_subscription_url }}';
32
+
33
+ /**
34
+ * The names an email theme must give, which `emailBaseCss` and
35
+ * `createEmailBlocks` both read: one value per site, so a font or colour
36
+ * has one producer.
37
+ */
38
+ const THEME_PATHS = [
39
+ "fonts.body", "fonts.heading", "colours.page", "colours.ink",
40
+ "colours.heading", "leading.body", "leading.prose",
41
+ ];
42
+
43
+ /** The paths in `paths` that `value` leaves undefined. */
44
+ export function missingPaths(value, paths) {
45
+ return paths.filter((p) =>
46
+ p.split(".").reduce((v, key) => v?.[key], value) === undefined);
47
+ }
48
+
49
+ /** Throws, naming each path missing, so a site states its whole look
50
+ * rather than rendering `undefined` into an email. */
51
+ export function requirePaths(what, value, paths) {
52
+ const missing = missingPaths(value, paths);
53
+ if (missing.length) throw new Error(`${what} needs ${missing.join(", ")}`);
54
+ }
55
+
56
+ /** Checks an email theme: {fonts: {body, heading}, colours: {page, ink,
57
+ * heading}, leading: {body, prose}}. */
58
+ export function requireTheme(what, theme) {
59
+ requirePaths(what, {theme}, THEME_PATHS.map((p) => `theme.${p}`));
60
+ }
61
+
62
+ /**
63
+ * The blocks for one site. Every option is required:
64
+ *
65
+ * theme: the site's email theme (see requireTheme): the body and
66
+ * heading fonts, the page, ink and heading colours, and the
67
+ * prose line height
68
+ * colours: {titleGround, action, actionInk, footerGround, footerRule}
69
+ * rhythm: {cardPadding, buttonCell, buttonSize, buttonPadding,
70
+ * footerRuleWidth, footerCells: [top, bottom]}
71
+ * small: the small print's inline style
72
+ * buttonClass: the class the email skin keys the button's rule on
73
+ * smallLink(href, text): a link in the small print
74
+ * colophon: the small print's HTML, before the subscription sentence
75
+ * copyright: the last line's HTML
76
+ * sentenceBreak: what joins the colophon to the subscription sentence
77
+ * signoff: the name a message is signed with
78
+ */
79
+ export function createEmailBlocks(options = {}) {
80
+ requireTheme("createEmailBlocks", options.theme);
81
+ requirePaths("createEmailBlocks", options, [
82
+ "colours.titleGround", "colours.action", "colours.actionInk",
83
+ "colours.footerGround", "colours.footerRule", "rhythm.cardPadding",
84
+ "rhythm.buttonCell", "rhythm.buttonSize", "rhythm.buttonPadding",
85
+ "rhythm.footerRuleWidth", "rhythm.footerCells.0", "rhythm.footerCells.1",
86
+ "small", "buttonClass", "smallLink", "colophon", "copyright",
87
+ "sentenceBreak", "signoff",
88
+ ]);
89
+ const {
90
+ theme, colours, rhythm, small, buttonClass, smallLink, colophon,
91
+ copyright, sentenceBreak, signoff: name,
92
+ } = options;
93
+
94
+ /** A link inside the message: body-coloured and underlined, which
95
+ * leaves the action colour meaning "action". */
96
+ function bodyLink(href, text) {
97
+ return (
98
+ `<a href="${href}" style="color:${theme.colours.ink};`
99
+ + `text-decoration:underline;">${text}</a>`
100
+ );
101
+ }
102
+
103
+ /** What the message is, on its own ground. */
104
+ function card(title) {
105
+ return (
106
+ `${TABLE}\n`
107
+ + ` style="background-color:${colours.titleGround};"><tr><td`
108
+ + ` style="padding:${rhythm.cardPadding};"><div style="font-family:`
109
+ + `${theme.fonts.heading};color:${theme.colours.heading};font-size:22px;`
110
+ + "font-weight:700;line-height:28px;letter-spacing:-0.01em;\">"
111
+ + `${title}</div></td></tr></table>\n\n`
112
+ );
113
+ }
114
+
115
+ /** White air between bands: a cell with a height, because a margin on
116
+ * the table above would be dropped by several clients. */
117
+ function spacer(height) {
118
+ return (
119
+ `${TABLE}\n`
120
+ + ` style="background-color:${theme.colours.page};"><tr><td`
121
+ + ` style="height:${height}px;line-height:${height}px;`
122
+ + "font-size:0;\">&nbsp;</td></tr></table>\n\n"
123
+ );
124
+ }
125
+
126
+ /** A paragraph of the message itself. */
127
+ function p(html, margin = PARAGRAPH_MARGIN) {
128
+ return (
129
+ `<p style="font-family:${theme.fonts.body};color:${theme.colours.ink};`
130
+ + `font-size:${BODY_SIZE};line-height:${theme.leading.prose};`
131
+ + "font-weight:400;"
132
+ + `margin:${margin};">${html}</p>\n\n`
133
+ );
134
+ }
135
+
136
+ /** The one action, in its own cell so the space around it does not
137
+ * depend on the paragraph above collapsing its margin. */
138
+ function button(href, label) {
139
+ return (
140
+ `${TABLE}><tr><td style="padding:${rhythm.buttonCell};">`
141
+ + `<a class="${buttonClass}"\n`
142
+ + ` href="${href}"\n`
143
+ + ` style="font-family:${theme.fonts.body};display:inline-block;`
144
+ + `background-color:${colours.action};`
145
+ + `color:${colours.actionInk} !important;`
146
+ + `font-size:${rhythm.buttonSize};font-weight:600;line-height:1.2;`
147
+ + `padding:${rhythm.buttonPadding};text-decoration:none;">`
148
+ + `<span style="color:${colours.actionInk} !important;">`
149
+ + `${label}</span></a></td></tr></table>\n\n`
150
+ );
151
+ }
152
+
153
+ /** Signed off, with room above so it reads as the end of the message
154
+ * rather than another paragraph of it. */
155
+ function signoff() {
156
+ return p(name, "24px 0 16px 0");
157
+ }
158
+
159
+ /**
160
+ * The small print: the colophon, how to leave, and the copyright line.
161
+ *
162
+ * `subscription` is false on the confirmation and its reminder, where
163
+ * there is no subscription yet to manage or leave. It has no default:
164
+ * a repo hiding Buttondown's own unsubscribe line relies on this one,
165
+ * so leaving it out by mistake would send an email with no way to
166
+ * leave.
167
+ */
168
+ function footer({subscription} = {}) {
169
+ if (typeof subscription !== "boolean") {
170
+ throw new Error("footer() needs subscription: true or false");
171
+ }
172
+ const [top, bottom] = rhythm.footerCells;
173
+ return (
174
+ `${TABLE}\n`
175
+ + " style=\"margin-top:40px;"
176
+ + `background-color:${colours.footerGround};\n`
177
+ + ` border-top:${rhythm.footerRuleWidth} solid `
178
+ + `${colours.footerRule};"><tr><td style="padding:${top};">`
179
+ + `<div style="${small}">${colophon}`
180
+ + (subscription
181
+ ? `${sentenceBreak}You can ${smallLink(UNSUBSCRIBE, "unsubscribe")}`
182
+ + ` or ${smallLink(MANAGE, "manage your subscription")}.`
183
+ : "")
184
+ + `</div></td></tr><tr><td style="padding:${bottom};">`
185
+ + `<div style="${small}">${copyright}</div>`
186
+ + "</td></tr></table>\n"
187
+ );
188
+ }
189
+
190
+ return {bodyLink, button, card, footer, p, signoff, spacer};
191
+ }
@@ -64,8 +64,8 @@ const DRAFT = "draft";
64
64
 
65
65
  const FRONT_MATTER = /^---\n([\s\S]*?)\n---\n/;
66
66
 
67
- /** The two skins, by the Buttondown field each file fills. */
68
- const SKIN = {web: "web_css.css", email: "css.css"};
67
+ /** The Buttondown fields holding the two skins. */
68
+ const SKIN = {web: "web_css", email: "css"};
69
69
 
70
70
  /** Fields Buttondown stores behind its image proxy. */
71
71
  const IMAGE_FIELDS = new Set(["icon", "image"]);
@@ -101,10 +101,11 @@ export async function loadSettings(root = process.cwd()) {
101
101
  + "stylesheet the site's design tokens are declared in");
102
102
  }
103
103
  const at = (p) => path.resolve(root, p);
104
+ const dir = at(config.dir ?? "buttondown");
104
105
  return {
105
106
  root,
106
107
  newsletter: config.newsletter,
107
- dir: at(config.dir ?? "buttondown"),
108
+ dir,
108
109
  globals: at(config.globals),
109
110
  tokens: config.tokens ?? [],
110
111
  extra: config.extra ?? [],
@@ -112,6 +113,10 @@ export async function loadSettings(root = process.cwd()) {
112
113
  allowedHides: config.allowedHides ?? {},
113
114
  linkClasses: Object.values(config.linkClasses ?? {}),
114
115
  ownLinks: [...BUTTONDOWN_LINKS, ...(config.ownLinks ?? [])],
116
+ // The file holding the web skin's TOKENS block, which `tokens`
117
+ // rewrites: the field file itself unless the field is composed.
118
+ webSkin: config.webSkin
119
+ ? at(config.webSkin) : path.join(dir, "fields", "web_css.css"),
115
120
  paletteFiles: (config.paletteFiles ?? []).map(at),
116
121
  checks: (config.checks ?? []).map(at),
117
122
  };
@@ -206,7 +211,7 @@ export function tokensBlock(settings, css) {
206
211
  /** Rewrites the web skin's TOKENS block from the site's tokens. */
207
212
  export async function writeTokens(settings) {
208
213
  const css = await readFile(settings.globals, "utf8");
209
- const file = skinPath(settings, "web");
214
+ const file = settings.webSkin;
210
215
  const web = await readFile(file, "utf8");
211
216
  const {s, e} = markers(web);
212
217
  await writeFile(
@@ -217,11 +222,42 @@ export async function writeTokens(settings) {
217
222
  /** Every problem with the repo's skins and settings; empty when clean. */
218
223
  export async function runCheck(settings) {
219
224
  const css = await readFile(settings.globals, "utf8");
220
- const web = await readIf(skinPath(settings, "web"));
221
- const email = await readIf(skinPath(settings, "email"));
222
225
  const payload = await buildPayload(settings);
226
+ // The skins as sent, base rules and site rules together, so what is
227
+ // checked is what Buttondown receives. Absent, there is nothing to
228
+ // check, which is refused rather than passed.
229
+ for (const field of Object.values(SKIN)) {
230
+ if (typeof payload[field] !== "string") {
231
+ throw new Error(`the payload has no ${field} field to check`);
232
+ }
233
+ }
234
+ const web = payload[SKIN.web];
235
+ const email = payload[SKIN.email];
223
236
  const problems = [];
224
237
 
238
+ // An @import must be a whole statement before any rule: a browser
239
+ // ignores one after a rule, and one whose URL was cut short swallows
240
+ // what follows it. Read past the leading statements as the composer
241
+ // does; any @import left over, outside a string, is one of those.
242
+ const rest = stripComments(web.slice(preludeEnd(web)))
243
+ .replace(/"[^"]*"|'[^']*'/g, '""');
244
+ if (/@import/i.test(rest)) {
245
+ problems.push("the web skin has an @import that is not a complete "
246
+ + "statement before its first rule, which a browser ignores");
247
+ }
248
+
249
+ // Every --bd-* role the shared rules read must be declared, or the
250
+ // declaration reading it is dropped without an error.
251
+ const declared = new Set([...stripComments(web)
252
+ .matchAll(/(--bd-[\w-]+)\s*:/g)].map((m) => m[1]));
253
+ for (const role of new Set([...stripComments(web)
254
+ .matchAll(/var\((--bd-[\w-]+)/g)].map((m) => m[1]))) {
255
+ if (!declared.has(role)) {
256
+ problems.push(`the web skin reads ${role}, which nothing declares: `
257
+ + "map it in the TOKENS block");
258
+ }
259
+ }
260
+
225
261
  if (settings.tokens.length || settings.extra.length) {
226
262
  const {s, e} = markers(web);
227
263
  if (web.slice(s, e) !== tokensBlock(settings, css)) {
@@ -574,6 +610,29 @@ export async function emit(settings, dir) {
574
610
  return written;
575
611
  }
576
612
 
613
+ /**
614
+ * Where a stylesheet's leading statements end: its opening comments and its
615
+ * `@charset`, `@import` and `@layer` statements, which must come before any
616
+ * rule. An @import's URL may hold semicolons (a font request's weights), so
617
+ * it is read whole before its closing `;`; one that isn't a whole statement
618
+ * ends the leading statements there.
619
+ */
620
+ export function preludeEnd(css) {
621
+ let at = 0;
622
+ for (;;) {
623
+ // A URL never holds a newline or a brace, so one that does is an
624
+ // import cut short, and ends the leading statements there.
625
+ const url = "(?:\"[^\"\\n{}]*\"|'[^'\\n{}]*')";
626
+ const lead = css.slice(at).match(new RegExp(
627
+ "^\\s*(\\/\\*[\\s\\S]*?\\*\\/|@charset\\s+\"[^\"]*\";"
628
+ + `|@import\\s+(?:url\\((?:${url}|[^)"'\\n{}]*)\\)|${url})[^;{]*;`
629
+ + "|@layer\\s+[^;{]*;)", "i",
630
+ ));
631
+ if (!lead) return at;
632
+ at += lead[0].length;
633
+ }
634
+ }
635
+
577
636
  /** Comments removed by index rather than a lazy regex, which backtracks
578
637
  * between its two literals. */
579
638
  export function stripComments(text) {
@@ -591,16 +650,13 @@ export function stripComments(text) {
591
650
 
592
651
  /** Why a call failed: Buttondown's `detail`, else its whole body, which
593
652
  * names the fields it refused, else the status. */
594
- const detail = (res) =>
595
- JSON.stringify(res.data?.detail ?? res.data ?? res.status);
596
-
597
- const newsletterPath = (newsletter) => `/newsletters/${newsletter.id}`;
598
-
599
- const skinPath = (settings, kind) =>
600
- path.join(settings.dir, "fields", SKIN[kind]);
653
+ function detail(res) {
654
+ return JSON.stringify(res.data?.detail ?? res.data ?? res.status);
655
+ }
601
656
 
602
- const readIf = async (file) =>
603
- existsSync(file) ? readFile(file, "utf8") : "";
657
+ function newsletterPath(newsletter) {
658
+ return `/newsletters/${newsletter.id}`;
659
+ }
604
660
 
605
661
  /** Whether `index` sits directly in a top-level `:root` or `@theme` block:
606
662
  * `:root.dark`, `:root[data-theme]` and a `:root` nested in an at-rule are
@@ -613,7 +669,8 @@ function isBaseBlock(css, index) {
613
669
  return depth === 1 && (head === ":root" || /^@theme(\s|$)/.test(head));
614
670
  }
615
671
 
616
- /** The head of the block enclosing `index`, or "" at the top level. */
672
+ /** The head of the block enclosing `index`, or "" at the top level. A
673
+ * comment before the head is not part of it, even one holding a `;`. */
617
674
  function enclosing(css, index) {
618
675
  let depth = 0;
619
676
  for (let i = index; i >= 0; i -= 1) {
@@ -621,8 +678,9 @@ function enclosing(css, index) {
621
678
  if (css[i] === "{") {
622
679
  if (depth === 0) {
623
680
  const head = css.slice(0, i);
624
- return head.slice(Math.max(head.lastIndexOf("}"),
625
- head.lastIndexOf(";"), head.lastIndexOf("{")) + 1).trim();
681
+ const tail = head.slice(Math.max(head.lastIndexOf("}"),
682
+ head.lastIndexOf(";"), head.lastIndexOf("{")) + 1);
683
+ return stripComments(tail).replace(/^[\s\S]*\*\//, "").trim();
626
684
  }
627
685
  depth -= 1;
628
686
  }
@@ -673,12 +731,14 @@ function declarations(text) {
673
731
 
674
732
  /** Declaration values only, so a selector such as `a:not(#cafe)` is not
675
733
  * read as a colour. */
676
- const cssValues = (text) =>
677
- declarations(text).map(([, value]) => value).join(" ");
734
+ function cssValues(text) {
735
+ return declarations(text).map(([, value]) => value).join(" ");
736
+ }
678
737
 
679
738
  /** An SVG's attribute values. */
680
- const attributeValues = (text) =>
681
- [...text.matchAll(/="([^"]*)"/g)].map((m) => m[1]).join(" ");
739
+ function attributeValues(text) {
740
+ return [...text.matchAll(/="([^"]*)"/g)].map((m) => m[1]).join(" ");
741
+ }
682
742
 
683
743
  function offPalette(values, palette) {
684
744
  return [...new Set(
@@ -26,6 +26,8 @@ export interface Settings {
26
26
  newsletter: Newsletter;
27
27
  dir: string;
28
28
  globals: string;
29
+ /** The file holding the web skin's TOKENS block. */
30
+ webSkin: string;
29
31
  tokens: [target: string, source: string][];
30
32
  extra: [target: string, value: string][];
31
33
  asserts: Record<string, string>;
@@ -44,7 +46,8 @@ export interface CheckContext {
44
46
  payload: Payload;
45
47
  /** The site's tokens stylesheet. */
46
48
  css: string;
47
- /** The web skin and the email skin, as written. */
49
+ /** The web skin and the email skin as sent: composed, base rules and
50
+ * site rules together. */
48
51
  web: string;
49
52
  email: string;
50
53
  readToken(css: string, name: string): string;
@@ -64,3 +67,59 @@ export function buildPayload(settings: Settings): Promise<Payload>;
64
67
  export function tokenReader(
65
68
  file: string | URL,
66
69
  ): Promise<(name: string) => string>;
70
+
71
+ /** The opening tag every band's table shares. */
72
+ export const TABLE: string;
73
+
74
+ /** A site's email theme, read by both `emailBaseCss` and
75
+ * `createEmailBlocks`, so each font and colour has one producer. */
76
+ export interface EmailTheme {
77
+ fonts: {body: string; heading: string};
78
+ colours: {page: string; ink: string; heading: string};
79
+ /** Line heights: of the page, and of prose. */
80
+ leading: {body: string; prose: string};
81
+ }
82
+
83
+ export interface EmailBlockOptions {
84
+ theme: EmailTheme;
85
+ colours: {
86
+ titleGround: string; action: string; actionInk: string;
87
+ footerGround: string; footerRule: string;
88
+ };
89
+ rhythm: {
90
+ cardPadding: string; buttonCell: string; buttonSize: string;
91
+ buttonPadding: string; footerRuleWidth: string;
92
+ footerCells: [top: string, bottom: string];
93
+ };
94
+ /** The small print's inline style. */
95
+ small: string;
96
+ /** The class the email skin keys the button's rule on. */
97
+ buttonClass: string;
98
+ smallLink(href: string, text: string): string;
99
+ colophon: string;
100
+ copyright: string;
101
+ /** What joins the colophon to the subscription sentence. */
102
+ sentenceBreak: string;
103
+ signoff: string;
104
+ }
105
+
106
+ export interface EmailBlocks {
107
+ bodyLink(href: string, text: string): string;
108
+ button(href: string, label: string): string;
109
+ card(title: string): string;
110
+ footer(options: {subscription: boolean}): string;
111
+ p(html: string, margin?: string): string;
112
+ signoff(): string;
113
+ spacer(height: number): string;
114
+ }
115
+
116
+ /** The bands an email is built from, in one site's theme. */
117
+ export function createEmailBlocks(options: EmailBlockOptions): EmailBlocks;
118
+
119
+ /** The web skin: the shared rules inserted after the site stylesheet's
120
+ * leading comments and @charset, @import and @layer statements, ahead of
121
+ * its own rules. */
122
+ export function composeWebSkin(site: string): string;
123
+
124
+ /** The shared email rules, in the site's theme, with literal values. */
125
+ export function emailBaseCss(theme: EmailTheme): string;
@@ -5,3 +5,5 @@
5
5
  export {
6
6
  buildPayload, EMAIL_CHROME_HIDES, loadSettings, tokenReader,
7
7
  } from "./engine.mjs";
8
+ export {createEmailBlocks, TABLE} from "./email-blocks.mjs";
9
+ export {composeWebSkin, emailBaseCss} from "./skins.mjs";
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The rules every site's skins share, for a repo whose `fields/web_css.mjs`
3
+ * and `fields/css.mjs` compose a field from them and the site's own rules.
4
+ */
5
+ import {readFileSync} from "node:fs";
6
+ import {
7
+ BODY_SIZE, PARAGRAPH_MARGIN, requireTheme,
8
+ } from "./email-blocks.mjs";
9
+ import {EMAIL_CHROME_HIDES, preludeEnd} from "./engine.mjs";
10
+
11
+ /** The shared web rules: base-web.css, which reads every value from the
12
+ * variables the site's TOKENS block defines. */
13
+ const WEB_BASE = readFileSync(
14
+ new URL("./base-web.css", import.meta.url), "utf8",
15
+ );
16
+
17
+ /**
18
+ * The web skin: the site's stylesheet with the shared rules inserted after
19
+ * its opening comments and `@import`s, which must come before any rule,
20
+ * and ahead of its own rules, so the site's win a tie.
21
+ */
22
+ export function composeWebSkin(site) {
23
+ const at = preludeEnd(site);
24
+ const head = site.slice(0, at);
25
+ return `${head}${head ? "\n\n" : ""}${WEB_BASE}\n${site.slice(at)}`;
26
+ }
27
+
28
+ /**
29
+ * The shared email rules, in the site's email theme (see requireTheme), with
30
+ * literal values: a mail client cannot resolve a CSS custom property, and
31
+ * Buttondown inlines the stylesheet before sending. The site's own
32
+ * stylesheet follows it: fonts it loads, its link colour, its button, its
33
+ * quotations and its small print.
34
+ */
35
+ export function emailBaseCss(theme) {
36
+ requireTheme("emailBaseCss", theme);
37
+ const {fonts, colours, leading} = theme;
38
+ const hides = Object.keys(EMAIL_CHROME_HIDES)
39
+ .map((selector) => `${selector} { display: none !important; }`);
40
+ return [
41
+ `body, td, div { font-family: ${fonts.body}; color: ${colours.ink}; `
42
+ + `font-size: ${BODY_SIZE}; line-height: ${leading.body}; }`,
43
+ // Buttondown inlines its own grey; important, so this is the last.
44
+ `body { background-color: ${colours.page} !important; }`,
45
+ // Unscoped, because the template rounds its card, its quotation and
46
+ // whatever it adds next under class names of its own.
47
+ "* { border-radius: 0 !important; }",
48
+ // Forced, because the template styles the paragraph it wraps around
49
+ // the content and wins on specificity.
50
+ `p, li { font-family: ${fonts.body} !important; `
51
+ + `color: ${colours.ink} !important; font-size: ${BODY_SIZE} !important; `
52
+ + `line-height: ${leading.prose} !important; `
53
+ + "font-weight: 400 !important; text-align: justify !important; "
54
+ + "hyphens: auto; -webkit-hyphens: auto; -ms-hyphens: auto; }",
55
+ `p { margin: ${PARAGRAPH_MARGIN}; }`,
56
+ `ul, ol { margin: ${PARAGRAPH_MARGIN}; padding-left: 24px; `
57
+ + `color: ${colours.ink}; font-size: ${BODY_SIZE} !important; }`,
58
+ "li { margin: 0 0 6px 0; }",
59
+ `h1, h2, h3, h4 { font-family: ${fonts.heading}; `
60
+ + `color: ${colours.heading}; line-height: 1.2; `
61
+ + "letter-spacing: -0.01em; margin: 32px 0 12px 0; }",
62
+ // Smaller than a page's: read in a narrow pane under a subject the
63
+ // reader has already seen.
64
+ "h1 { font-size: 26px; font-weight: 700; line-height: 32px; }",
65
+ "h2 { font-size: 20px; font-weight: 700; line-height: 28px; }",
66
+ "h3 { font-size: 18px; line-height: 26px; }",
67
+ // The template inlines a size and a heavier weight on the subject.
68
+ "h1.subject, .newsletter-masthead h1 { font-size: 26px !important; "
69
+ + "line-height: 32px !important; font-weight: 700 !important; }",
70
+ `strong, b { font-weight: 600; color: ${colours.ink}; }`,
71
+ "a { text-decoration: underline; text-decoration-thickness: 1px; "
72
+ + "text-underline-offset: 0.15em; }",
73
+ "hr { border: 0; margin: 32px 0; }",
74
+ "blockquote { background: transparent !important; margin: 24px 0; }",
75
+ // Longhands, because Buttondown emits its own border properties after
76
+ // these and would override the shorthand.
77
+ ".email-container { border-width: 0 !important; "
78
+ + "border-style: none !important; "
79
+ + `background-color: ${colours.page} !important; }`,
80
+ ".newsletter-footer { border: 0 !important; "
81
+ + "background: transparent !important; padding: 0 !important; "
82
+ + "margin: 0 !important; }",
83
+ ...hides,
84
+ ].join("\n") + "\n\n";
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots, design specs)",
5
5
  "license": "MIT",
6
6
  "repository": {