@casomoltd/tooling 0.34.1 → 0.35.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 +1 -1
- package/buttondown/engine.mjs +66 -13
- package/buttondown/index.d.mts +4 -0
- package/buttondown/index.mjs +1 -0
- package/buttondown/skins.mjs +9 -2
- package/package.json +1 -1
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: the shared email blocks and skins (`createEmailBlocks`, `emailBaseCss`, `composeWebSkin`), `tokenReader()`, `loadSettings()` and `buildPayload()`, and `EMAIL_CHROME_HIDES` ([docs/buttondown.md](docs/buttondown.md)) |
|
|
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` and `WEB_CHROME_HIDES` ([docs/buttondown.md](docs/buttondown.md)) |
|
|
19
19
|
|
|
20
20
|
### Bin commands
|
|
21
21
|
|
package/buttondown/engine.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* A repo configures it under `buttondown` in its `casomo.config.mjs`:
|
|
8
8
|
*
|
|
9
|
-
* import {EMAIL_CHROME_HIDES}
|
|
9
|
+
* import {EMAIL_CHROME_HIDES, WEB_CHROME_HIDES}
|
|
10
|
+
* from "@casomoltd/tooling/buttondown";
|
|
10
11
|
*
|
|
11
12
|
* export default {
|
|
12
13
|
* buttondown: {
|
|
@@ -16,7 +17,9 @@
|
|
|
16
17
|
* tokens: [["--color-accent", "--color-brand-accent"]], // target, source
|
|
17
18
|
* extra: [["--radius", "0"]], // uncommented lines after them
|
|
18
19
|
* asserts: {"--radius-lg": "0"}, // tokens the skins depend on
|
|
19
|
-
* allowedHides: {...EMAIL_CHROME_HIDES,
|
|
20
|
+
* allowedHides: {...EMAIL_CHROME_HIDES, ...WEB_CHROME_HIDES, // the
|
|
21
|
+
* second for a repo that composes its web skin
|
|
22
|
+
* ".selector": "why"},
|
|
20
23
|
* linkClasses: {onDark: "on-dark"}, // each needs an `a.<class>` rule
|
|
21
24
|
* ownLinks: ["/rss"], // hrefs that are not socials
|
|
22
25
|
* paletteFiles: ["buttondown/assets/icon.svg"],
|
|
@@ -24,7 +27,8 @@
|
|
|
24
27
|
* },
|
|
25
28
|
* };
|
|
26
29
|
*
|
|
27
|
-
* `newsletter.json` holds the newsletter's short fields
|
|
30
|
+
* `newsletter.json` holds the newsletter's short fields (its `image` is
|
|
31
|
+
* also every draft's default share image), `tags.json` its
|
|
28
32
|
* tags, and each file in `fields/` is the field it is named after: a
|
|
29
33
|
* stylesheet is read verbatim, and an `.mjs` file's default export is the
|
|
30
34
|
* field's value.
|
|
@@ -56,6 +60,25 @@ export const EMAIL_CHROME_HIDES = {
|
|
|
56
60
|
".powered-by-icon": "the logo tile above the kept attribution line",
|
|
57
61
|
};
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Buttondown's controls on the hosted pages that send a reader somewhere
|
|
65
|
+
* worse than the site does. `composeWebSkin` hides them, so a repo that
|
|
66
|
+
* composes its web skin spreads this into `allowedHides`, as it does
|
|
67
|
+
* EMAIL_CHROME_HIDES. The manage-link hide assumes the archive is on a
|
|
68
|
+
* custom domain, where that link's buttondown.com address asks a signed-in
|
|
69
|
+
* reader to sign in again; on buttondown.com it is their only manage link.
|
|
70
|
+
*/
|
|
71
|
+
export const WEB_CHROME_HIDES = {
|
|
72
|
+
"a.sniper-link-button":
|
|
73
|
+
"a button to the reader's mail provider, in the form's place and in the"
|
|
74
|
+
+ " sign-up dialog; their inbox is theirs to go to",
|
|
75
|
+
[".subscribe:has(span.confirmation-email-message)"
|
|
76
|
+
+ " #subscribe-form a.archive__button"]:
|
|
77
|
+
"a signed-in reader's manage link, which goes to buttondown.com and asks"
|
|
78
|
+
+ " them to sign in again; the header's Account link opens the same"
|
|
79
|
+
+ " settings on the site's domain",
|
|
80
|
+
};
|
|
81
|
+
|
|
59
82
|
const API = "https://api.buttondown.com/v1";
|
|
60
83
|
|
|
61
84
|
/** The API's own path prefix, which every request path sits under. */
|
|
@@ -158,15 +181,7 @@ export async function tokenReader(file) {
|
|
|
158
181
|
|
|
159
182
|
/** Every field the newsletter is set to: newsletter.json, then fields/. */
|
|
160
183
|
export async function buildPayload(settings) {
|
|
161
|
-
const payload =
|
|
162
|
-
await readFile(path.join(settings.dir, "newsletter.json"), "utf8"),
|
|
163
|
-
);
|
|
164
|
-
for (const field of IMAGE_FIELDS) {
|
|
165
|
-
if (payload[field] && !URL.canParse(payload[field])) {
|
|
166
|
-
throw new Error(`${field} must be an absolute URL, got `
|
|
167
|
-
+ JSON.stringify(payload[field]));
|
|
168
|
-
}
|
|
169
|
-
}
|
|
184
|
+
const payload = await readNewsletter(settings);
|
|
170
185
|
const identity = IDENTITY.filter((key) => key in payload);
|
|
171
186
|
if (identity.length) {
|
|
172
187
|
throw new Error(
|
|
@@ -548,7 +563,8 @@ export async function apply(settings, api, log = console.log) {
|
|
|
548
563
|
* A draft from a markdown file whose first line, after any front matter,
|
|
549
564
|
* is `# Subject`. Front matter carries the email's id once it exists, so a
|
|
550
565
|
* re-run replaces that draft rather than creating a second; `slug` and
|
|
551
|
-
* `description` are passed through
|
|
566
|
+
* `description` are passed through, and `image` (the share image) defaults
|
|
567
|
+
* to the newsletter's own. An email already sent is refused:
|
|
552
568
|
* replacing it would rewrite the published archive.
|
|
553
569
|
*/
|
|
554
570
|
export async function draft(settings, api, file, log = console.log) {
|
|
@@ -569,6 +585,10 @@ export async function draft(settings, api, file, log = console.log) {
|
|
|
569
585
|
throw new Error("front matter has an empty `id:`; delete the line to "
|
|
570
586
|
+ "create a draft");
|
|
571
587
|
}
|
|
588
|
+
if ("image" in fm && !fm.image) {
|
|
589
|
+
throw new Error("front matter has an empty `image:`; delete the line "
|
|
590
|
+
+ "to use the newsletter's share image");
|
|
591
|
+
}
|
|
572
592
|
// The marker says the body is markdown (Buttondown's `plaintext` mode).
|
|
573
593
|
// Without it Buttondown guesses, and a draft once stored as rich text
|
|
574
594
|
// takes the next PATCH as rich text too.
|
|
@@ -579,6 +599,15 @@ export async function draft(settings, api, file, log = console.log) {
|
|
|
579
599
|
...Object.fromEntries(["slug", "description"]
|
|
580
600
|
.filter((k) => k in fm).map((k) => [k, fm[k]])),
|
|
581
601
|
};
|
|
602
|
+
// Left unset, Buttondown takes the post's first picture as its share
|
|
603
|
+
// image, so every post would preview differently; the newsletter's own
|
|
604
|
+
// share image is the default, and `image:` in the front matter wins.
|
|
605
|
+
const image = "image" in fm
|
|
606
|
+
? fm.image : (await readNewsletter(settings)).image;
|
|
607
|
+
if (image) {
|
|
608
|
+
assertAbsoluteUrl("image", image);
|
|
609
|
+
body.image = image;
|
|
610
|
+
}
|
|
582
611
|
if (fm.id) {
|
|
583
612
|
const current = await api.call("GET", `/emails/${fm.id}`);
|
|
584
613
|
if (!current.ok) throw new Error(`GET email: ${detail(current)}`);
|
|
@@ -664,6 +693,30 @@ export function stripComments(text) {
|
|
|
664
693
|
}
|
|
665
694
|
}
|
|
666
695
|
|
|
696
|
+
/** Throws unless an image field holds an absolute URL, since Buttondown
|
|
697
|
+
* stores a relative one as given and the image then never loads. */
|
|
698
|
+
function assertAbsoluteUrl(field, value) {
|
|
699
|
+
if (!URL.canParse(value)) {
|
|
700
|
+
throw new Error(`${field} must be an absolute URL, got `
|
|
701
|
+
+ JSON.stringify(value));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** newsletter.json, parsed and with its image fields checked. */
|
|
706
|
+
async function readNewsletter(settings) {
|
|
707
|
+
const file = path.join(settings.dir, "newsletter.json");
|
|
708
|
+
let payload;
|
|
709
|
+
try {
|
|
710
|
+
payload = JSON.parse(await readFile(file, "utf8"));
|
|
711
|
+
} catch (error) {
|
|
712
|
+
throw new Error(`${file}: ${error.message}`, {cause: error});
|
|
713
|
+
}
|
|
714
|
+
for (const field of IMAGE_FIELDS) {
|
|
715
|
+
if (payload[field]) assertAbsoluteUrl(field, payload[field]);
|
|
716
|
+
}
|
|
717
|
+
return payload;
|
|
718
|
+
}
|
|
719
|
+
|
|
667
720
|
/** Why a call failed: Buttondown's `detail`, else its whole body, which
|
|
668
721
|
* names the fields it refused, else the status. */
|
|
669
722
|
function detail(res) {
|
package/buttondown/index.d.mts
CHANGED
|
@@ -59,6 +59,10 @@ export type Check = (context: CheckContext) => Promise<string[]>;
|
|
|
59
59
|
* bands: spread into `allowedHides` to allow hiding it. */
|
|
60
60
|
export const EMAIL_CHROME_HIDES: Readonly<Record<string, string>>;
|
|
61
61
|
|
|
62
|
+
/** Hosted-page controls the composed web skin hides; spread into
|
|
63
|
+
* `allowedHides` by a repo that uses `composeWebSkin`. */
|
|
64
|
+
export const WEB_CHROME_HIDES: Readonly<Record<string, string>>;
|
|
65
|
+
|
|
62
66
|
export function loadSettings(root?: string): Promise<Settings>;
|
|
63
67
|
|
|
64
68
|
export function buildPayload(settings: Settings): Promise<Payload>;
|
package/buttondown/index.mjs
CHANGED
package/buttondown/skins.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import {readFileSync} from "node:fs";
|
|
|
6
6
|
import {
|
|
7
7
|
BODY_SIZE, PARAGRAPH_MARGIN, requireTheme,
|
|
8
8
|
} from "./email-blocks.mjs";
|
|
9
|
-
import {EMAIL_CHROME_HIDES, preludeEnd} from "./engine.mjs";
|
|
9
|
+
import {EMAIL_CHROME_HIDES, preludeEnd, WEB_CHROME_HIDES} from "./engine.mjs";
|
|
10
10
|
|
|
11
11
|
/** The shared web rules: base-web.css, which reads every value from the
|
|
12
12
|
* variables the site's TOKENS block defines. */
|
|
@@ -14,6 +14,12 @@ const WEB_BASE = readFileSync(
|
|
|
14
14
|
new URL("./base-web.css", import.meta.url), "utf8",
|
|
15
15
|
);
|
|
16
16
|
|
|
17
|
+
/** WEB_CHROME_HIDES as rules. Important, so a site's own rules for the
|
|
18
|
+
* same elements can't show them unless those are important too. */
|
|
19
|
+
const WEB_HIDES = Object.keys(WEB_CHROME_HIDES)
|
|
20
|
+
.map((selector) => `${selector} { display: none !important; }`)
|
|
21
|
+
.join("\n");
|
|
22
|
+
|
|
17
23
|
/**
|
|
18
24
|
* The web skin: the site's stylesheet with the shared rules inserted after
|
|
19
25
|
* its opening comments and `@import`s, which must come before any rule,
|
|
@@ -22,7 +28,8 @@ const WEB_BASE = readFileSync(
|
|
|
22
28
|
export function composeWebSkin(site) {
|
|
23
29
|
const at = preludeEnd(site);
|
|
24
30
|
const head = site.slice(0, at);
|
|
25
|
-
return `${head}${head ? "\n\n" : ""}${WEB_BASE}\n${
|
|
31
|
+
return `${head}${head ? "\n\n" : ""}${WEB_BASE}\n${WEB_HIDES}\n`
|
|
32
|
+
+ site.slice(at);
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casomoltd/tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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": {
|