@half-built/astro 0.4.2 → 0.6.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 (60) hide show
  1. package/README.md +50 -2
  2. package/package.json +11 -4
  3. package/src/components/CategoryCard.astro +9 -1
  4. package/src/components/CornerBadges.astro +14 -21
  5. package/src/components/Footer.astro +57 -29
  6. package/src/components/Icon.astro +16 -0
  7. package/src/components/LightboxLink.astro +19 -10
  8. package/src/components/LinkListWidget.astro +11 -9
  9. package/src/components/Pagination.astro +51 -28
  10. package/src/components/PostCard.astro +54 -22
  11. package/src/components/PostNavigation.astro +40 -29
  12. package/src/components/Shell.astro +26 -12
  13. package/src/components/SiteHeader.astro +139 -80
  14. package/src/components/SmartImage.astro +48 -17
  15. package/src/components/Subscribe.astro +60 -35
  16. package/src/components/ThemeToggle.astro +28 -15
  17. package/src/components/TwoColumn.astro +5 -5
  18. package/src/components/Widget.astro +10 -7
  19. package/src/components/content/BlogImage.astro +48 -13
  20. package/src/components/content/Button.astro +13 -13
  21. package/src/components/content/Callout.astro +17 -8
  22. package/src/components/content/CodeBlock.astro +6 -1
  23. package/src/components/content/EditorNote.astro +19 -21
  24. package/src/components/content/Gallery.astro +18 -5
  25. package/src/components/content/GalleryImage.astro +26 -7
  26. package/src/components/content/Group.astro +5 -6
  27. package/src/components/content/MediaText.astro +43 -9
  28. package/src/components/content/Palette.astro +37 -21
  29. package/src/components/content/PostLink.astro +15 -0
  30. package/src/components/content/Quote.astro +10 -9
  31. package/src/components/content/Spacer.astro +11 -2
  32. package/src/components/content/Step.astro +34 -15
  33. package/src/components/content/Walkthrough.astro +10 -6
  34. package/src/components/content/WhenPublished.astro +14 -0
  35. package/src/components/models.ts +2 -2
  36. package/src/lib/archive.ts +18 -4
  37. package/src/lib/drafts.ts +16 -5
  38. package/src/lib/format-date.ts +4 -1
  39. package/src/lib/ordering.ts +3 -1
  40. package/src/lib/paginate.ts +5 -1
  41. package/src/lib/slug.ts +30 -7
  42. package/src/scripts/code-island.ts +27 -4
  43. package/src/scripts/core/badges.ts +28 -0
  44. package/src/scripts/core/dom.ts +6 -1
  45. package/src/scripts/core/frame-loop.ts +4 -1
  46. package/src/scripts/core/icons.ts +92 -15
  47. package/src/scripts/ecosystem.ts +72 -12
  48. package/src/scripts/focus-mode.ts +14 -3
  49. package/src/scripts/lightbox.ts +227 -41
  50. package/src/scripts/link-tip.ts +55 -11
  51. package/src/scripts/path-player-math.ts +11 -2
  52. package/src/scripts/path-player-paint.ts +54 -8
  53. package/src/scripts/path-player.ts +131 -29
  54. package/src/scripts/plate-modal.ts +45 -9
  55. package/src/scripts/scroll-top.ts +6 -1
  56. package/src/scripts/site-header.ts +34 -6
  57. package/src/scripts/subscribe.ts +50 -9
  58. package/src/scripts/theme-toggle.ts +32 -5
  59. package/src/shiki/code-theme.mjs +25 -5
  60. package/src/shiki/code-vars.mjs +5 -1
@@ -48,30 +48,46 @@ interface Popout {
48
48
  closeOnFocusLeave?: boolean;
49
49
  }
50
50
 
51
- function wirePopout(doc: Document, { btn, panel, openClass, onOpen, closeOnFocusLeave = false }: Popout): () => void {
51
+ function wirePopout(
52
+ doc: Document,
53
+ { btn, panel, openClass, onOpen, closeOnFocusLeave = false }: Popout,
54
+ ): () => void {
52
55
  const isOpen = (): boolean => panel.classList.contains(openClass);
53
- const inside = (t: EventTarget | null): boolean => t instanceof Node && (panel.contains(t) || btn.contains(t));
56
+
57
+ const inside = (t: EventTarget | null): boolean =>
58
+ t instanceof Node && (panel.contains(t) || btn.contains(t));
59
+
54
60
  const setOpen = (open: boolean): void => {
55
61
  panel.classList.toggle(openClass, open);
56
62
  btn.setAttribute("aria-expanded", String(open));
57
63
  if (open) onOpen?.();
58
64
  };
59
- const onClick = (): void => { setOpen(!isOpen()); };
65
+
66
+ const onClick = (): void => {
67
+ setOpen(!isOpen());
68
+ };
69
+
60
70
  const onPointerdown = (ev: PointerEvent): void => {
61
71
  if (isOpen() && !inside(ev.target)) setOpen(false);
62
72
  };
73
+
63
74
  const onKeydown = (ev: KeyboardEvent): void => {
64
75
  if (ev.key !== "Escape" || !isOpen()) return;
65
76
  setOpen(false);
66
77
  btn.focus();
67
78
  };
79
+
68
80
  const onFocusout = (ev: FocusEvent): void => {
69
- if (isOpen() && ev.relatedTarget !== null && !inside(ev.relatedTarget)) setOpen(false);
81
+ if (isOpen() && ev.relatedTarget !== null && !inside(ev.relatedTarget)) {
82
+ setOpen(false);
83
+ }
70
84
  };
85
+
71
86
  btn.addEventListener("click", onClick);
72
87
  doc.addEventListener("pointerdown", onPointerdown);
73
88
  doc.addEventListener("keydown", onKeydown);
74
89
  if (closeOnFocusLeave) panel.addEventListener("focusout", onFocusout);
90
+
75
91
  return () => {
76
92
  btn.removeEventListener("click", onClick);
77
93
  doc.removeEventListener("pointerdown", onPointerdown);
@@ -80,7 +96,10 @@ function wirePopout(doc: Document, { btn, panel, openClass, onOpen, closeOnFocus
80
96
  };
81
97
  }
82
98
 
83
- export const mountSiteHeader: Island<SiteHeaderOptions> = (root, options = {}): IslandHandle => {
99
+ export const mountSiteHeader: Island<SiteHeaderOptions> = (
100
+ root,
101
+ options = {},
102
+ ): IslandHandle => {
84
103
  const {
85
104
  dateId = "header-date",
86
105
  menuButton = ".menu-toggle",
@@ -90,6 +109,7 @@ export const mountSiteHeader: Island<SiteHeaderOptions> = (root, options = {}):
90
109
  searchField = ".search-field",
91
110
  formatDate = formatHeaderDate,
92
111
  } = options;
112
+
93
113
  const doc = docOf(root);
94
114
 
95
115
  // Header date: live like the WordPress original (build-time text is the
@@ -101,16 +121,23 @@ export const mountSiteHeader: Island<SiteHeaderOptions> = (root, options = {}):
101
121
 
102
122
  const btn = root.querySelector<HTMLButtonElement>(menuButton);
103
123
  const menu = doc.getElementById(menuId);
124
+
104
125
  if (btn && menu && claim(btn, "site-header")) {
105
126
  const off = wirePopout(doc, { btn, panel: menu, openClass: "open" });
106
- unwire.push(() => { off(); release(btn, "site-header"); });
127
+
128
+ unwire.push(() => {
129
+ off();
130
+ release(btn, "site-header");
131
+ });
107
132
  }
108
133
 
109
134
  const searchBtn = root.querySelector<HTMLButtonElement>(searchButton);
110
135
  const wrap = searchBtn?.closest<HTMLElement>(searchWrap) ?? null;
136
+
111
137
  if (searchBtn && wrap && claim(searchBtn, "site-header")) {
112
138
  const field = wrap.querySelector<HTMLInputElement>(searchField);
113
139
  wrap.setAttribute("data-search-js", "");
140
+
114
141
  const off = wirePopout(doc, {
115
142
  btn: searchBtn,
116
143
  panel: wrap,
@@ -118,6 +145,7 @@ export const mountSiteHeader: Island<SiteHeaderOptions> = (root, options = {}):
118
145
  onOpen: () => field?.focus(),
119
146
  closeOnFocusLeave: true,
120
147
  });
148
+
121
149
  unwire.push(() => {
122
150
  off();
123
151
  wrap.removeAttribute("data-search-js");
@@ -35,13 +35,17 @@ export const MSG = {
35
35
  A function member so an overridden register controls the whole
36
36
  sentence, joiner included (step 9.5; the generalization review found
37
37
  the joiner was the one baked phrase left in this script). */
38
- failedAt: (failed: string, url: string): string => `${failed.replace(/\.$/, "")}, or sign up at ${url}.`,
38
+ failedAt: (failed: string, url: string): string =>
39
+ `${failed.replace(/\.$/, "")}, or sign up at ${url}.`,
39
40
  };
40
41
 
41
42
  /* The provider's public sign-up page rides the form's data-public-url
42
43
  (config NEWSLETTER via the component's publicUrl prop), so this script names no provider; without the
43
44
  attribute the message stays generic. */
44
- export function failedMessage(form: HTMLFormElement, messages: typeof MSG): string {
45
+ export function failedMessage(
46
+ form: HTMLFormElement,
47
+ messages: typeof MSG,
48
+ ): string {
45
49
  const url = form.dataset.publicUrl;
46
50
  return url ? messages.failedAt(messages.failed, url) : messages.failed;
47
51
  }
@@ -51,11 +55,23 @@ function setStatus(status: HTMLElement, text: string, isError: boolean): void {
51
55
  status.classList.toggle("subscribe-status-err", isError);
52
56
  }
53
57
 
54
- async function send(form: HTMLFormElement, input: HTMLInputElement, button: HTMLButtonElement | null, status: HTMLElement, messages: typeof MSG): Promise<void> {
58
+ async function send(
59
+ form: HTMLFormElement,
60
+ input: HTMLInputElement,
61
+ button: HTMLButtonElement | null,
62
+ status: HTMLElement,
63
+ messages: typeof MSG,
64
+ ): Promise<void> {
55
65
  if (button) button.disabled = true;
56
66
  setStatus(status, messages.pending, false);
67
+
57
68
  try {
58
- const res = await fetch(form.action, { method: "POST", body: new FormData(form), redirect: "manual" });
69
+ const res = await fetch(form.action, {
70
+ method: "POST",
71
+ body: new FormData(form),
72
+ redirect: "manual",
73
+ });
74
+
59
75
  if (res.ok || res.type === "opaqueredirect") {
60
76
  setStatus(status, messages.sent, false);
61
77
  input.value = "";
@@ -80,27 +96,52 @@ export interface SubscribeOptions {
80
96
  messages?: Partial<typeof MSG>;
81
97
  }
82
98
 
83
- export const mountSubscribe: Island<SubscribeOptions> = (root, options = {}): IslandHandle => {
84
- const { selector = ".subscribe-form", messages: messagesOverride = {} } = options;
99
+ export const mountSubscribe: Island<SubscribeOptions> = (
100
+ root,
101
+ options = {},
102
+ ): IslandHandle => {
103
+ const { selector = ".subscribe-form", messages: messagesOverride = {} } =
104
+ options;
105
+
85
106
  const messages: typeof MSG = { ...MSG, ...messagesOverride };
86
- const forms = [...root.querySelectorAll<HTMLFormElement>(selector)].filter((form) => claim(form, "subscribe"));
107
+
108
+ const forms = [...root.querySelectorAll<HTMLFormElement>(selector)].filter(
109
+ (form) => claim(form, "subscribe"),
110
+ );
87
111
 
88
112
  const handlers: [HTMLFormElement, (e: Event) => void][] = [];
113
+
89
114
  for (const form of forms) {
90
115
  const onSubmit = (e: Event): void => {
91
116
  e.preventDefault();
92
117
  const input = form.querySelector(".subscribe-email");
93
118
  const button = form.querySelector(".subscribe-submit");
94
119
  const status = form.parentElement?.querySelector(".subscribe-status");
95
- if (!(input instanceof HTMLInputElement) || !(status instanceof HTMLElement)) return;
120
+
121
+ if (
122
+ !(input instanceof HTMLInputElement) ||
123
+ !(status instanceof HTMLElement)
124
+ ) {
125
+ return;
126
+ }
127
+
96
128
  const value = input.value.trim();
97
129
  input.value = value;
130
+
98
131
  if (!EMAIL.test(value)) {
99
132
  setStatus(status, messages.invalid, true);
100
133
  return;
101
134
  }
102
- void send(form, input, button instanceof HTMLButtonElement ? button : null, status, messages);
135
+
136
+ void send(
137
+ form,
138
+ input,
139
+ button instanceof HTMLButtonElement ? button : null,
140
+ status,
141
+ messages,
142
+ );
103
143
  };
144
+
104
145
  form.addEventListener("submit", onSubmit);
105
146
  handlers.push([form, onSubmit]);
106
147
  }
@@ -22,8 +22,12 @@ const LABEL: Record<Theme, string> = {
22
22
  every access is guarded and a bad value reads as no choice. With no
23
23
  storageKey there is nowhere to read from, so this reads as no choice
24
24
  too rather than guessing at a key. */
25
- export function readStored(storage: Storage | null, storageKey?: string): Theme | null {
25
+ export function readStored(
26
+ storage: Storage | null,
27
+ storageKey?: string,
28
+ ): Theme | null {
26
29
  if (!storageKey) return null;
30
+
27
31
  try {
28
32
  const v = storage?.getItem(storageKey);
29
33
  return v === "light" || v === "dark" ? v : null;
@@ -36,8 +40,13 @@ export function readStored(storage: Storage | null, storageKey?: string): Theme
36
40
  the page, it just does not persist the choice. That is a safe
37
41
  degradation for an adopter who forgot to pass one, not a broken
38
42
  toggle. */
39
- function writeStored(storage: Storage | null, storageKey: string | undefined, theme: Theme): void {
43
+ function writeStored(
44
+ storage: Storage | null,
45
+ storageKey: string | undefined,
46
+ theme: Theme,
47
+ ): void {
40
48
  if (!storageKey) return;
49
+
41
50
  try {
42
51
  storage?.setItem(storageKey, theme);
43
52
  } catch {
@@ -56,7 +65,11 @@ export function apply(doc: Document, theme: Theme): void {
56
65
  else delete doc.documentElement.dataset.theme;
57
66
  }
58
67
 
59
- function reflect(btn: HTMLButtonElement, theme: Theme, labels: Record<Theme, string>): void {
68
+ function reflect(
69
+ btn: HTMLButtonElement,
70
+ theme: Theme,
71
+ labels: Record<Theme, string>,
72
+ ): void {
60
73
  btn.setAttribute("aria-pressed", theme === "dark" ? "true" : "false");
61
74
  btn.setAttribute("aria-label", labels[theme]);
62
75
  btn.title = labels[theme];
@@ -79,23 +92,37 @@ export interface ThemeToggleOptions {
79
92
  labels?: Record<Theme, string>;
80
93
  }
81
94
 
82
- export const mountThemeToggle: Island<ThemeToggleOptions> = (root, options = {}): IslandHandle => {
95
+ export const mountThemeToggle: Island<ThemeToggleOptions> = (
96
+ root,
97
+ options = {},
98
+ ): IslandHandle => {
83
99
  const doc = options.doc ?? docOf(root);
84
- const { selector = ".theme-toggle", storage = safeStorage(doc), storageKey, labels = LABEL } = options;
100
+
101
+ const {
102
+ selector = ".theme-toggle",
103
+ storage = safeStorage(doc),
104
+ storageKey,
105
+ labels = LABEL,
106
+ } = options;
107
+
85
108
  const handlers: [HTMLButtonElement, () => void][] = [];
109
+
86
110
  for (const btn of root.querySelectorAll<HTMLButtonElement>(selector)) {
87
111
  if (mounted.has(btn)) continue;
88
112
  mounted.add(btn);
89
113
  reflect(btn, current(doc), labels);
114
+
90
115
  const onClick = () => {
91
116
  const next: Theme = current(doc) === "dark" ? "light" : "dark";
92
117
  apply(doc, next);
93
118
  writeStored(storage, storageKey, next);
94
119
  for (const b of mounted) reflect(b, next, labels);
95
120
  };
121
+
96
122
  btn.addEventListener("click", onClick);
97
123
  handlers.push([btn, onClick]);
98
124
  }
125
+
99
126
  return {
100
127
  destroy(): void {
101
128
  for (const [btn, onClick] of handlers) {
@@ -7,10 +7,30 @@ export default {
7
7
  "editor.foreground": "#e8d9c3",
8
8
  },
9
9
  tokenColors: [
10
- { scope: ["keyword", "storage", "keyword.control"], settings: { foreground: "#ffaa3c" } },
11
- { scope: ["entity.name.function", "support.function", "entity.name.tag"], settings: { foreground: "#ffd18a" } },
12
- { scope: ["string", "constant.numeric", "constant.language", "constant.character"], settings: { foreground: "#e07c14" } },
13
- { scope: ["comment", "punctuation.definition.comment"], settings: { foreground: "#8a7a63", fontStyle: "italic" } },
14
- { scope: ["variable", "support.variable"], settings: { foreground: "#e8d9c3" } },
10
+ {
11
+ scope: ["keyword", "storage", "keyword.control"],
12
+ settings: { foreground: "#ffaa3c" },
13
+ },
14
+ {
15
+ scope: ["entity.name.function", "support.function", "entity.name.tag"],
16
+ settings: { foreground: "#ffd18a" },
17
+ },
18
+ {
19
+ scope: [
20
+ "string",
21
+ "constant.numeric",
22
+ "constant.language",
23
+ "constant.character",
24
+ ],
25
+ settings: { foreground: "#e07c14" },
26
+ },
27
+ {
28
+ scope: ["comment", "punctuation.definition.comment"],
29
+ settings: { foreground: "#8a7a63", fontStyle: "italic" },
30
+ },
31
+ {
32
+ scope: ["variable", "support.variable"],
33
+ settings: { foreground: "#e8d9c3" },
34
+ },
15
35
  ],
16
36
  };
@@ -19,7 +19,11 @@ const HEX_TO_VAR = {
19
19
  function swap(node) {
20
20
  const style = node.properties?.style;
21
21
  if (typeof style !== "string") return;
22
- node.properties.style = style.replace(/#[0-9a-fA-F]{6}/g, (hex) => HEX_TO_VAR[hex.toLowerCase()] ?? hex);
22
+
23
+ node.properties.style = style.replace(
24
+ /#[0-9a-fA-F]{6}/g,
25
+ (hex) => HEX_TO_VAR[hex.toLowerCase()] ?? hex,
26
+ );
23
27
  }
24
28
 
25
29
  export default {