@half-built/astro 0.5.0 → 0.7.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 +33 -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 +68 -46
  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 +8 -11
  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 +7 -15
  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
package/README.md CHANGED
@@ -12,8 +12,39 @@ is the design record for this package.
12
12
 
13
13
  ## Icons
14
14
 
15
- Default icon glyphs are derived from Lucide (https://lucide.dev), ISC
16
- license. See `ICONS-LICENSE`.
15
+ Every glyph the package draws comes from one registry,
16
+ `scripts/core/icons.ts`, derived from Lucide (https://lucide.dev), ISC
17
+ license; see `ICONS-LICENSE`. Templates render one through
18
+ `components/Icon.astro`:
19
+
20
+ ```astro
21
+ <Icon name="search" size={14} />
22
+ ```
23
+
24
+ `name` is a registry key (`x`, `play`, `sparkles`, `pause`,
25
+ `rotate-ccw`, `chevron-left`, `chevron-right`, `chevron-up`,
26
+ `arrow-left`, `arrow-right`, `sun`, `moon`, `search`, `clock`, `user`,
27
+ `calendar`, `circle`), `size` a CSS
28
+ length or pixel count (default `1em`, tracking the parent's font
29
+ size), `strokeWidth` defaults to 2.5, and `class` lands on the svg.
30
+ The icon is decorative by contract (aria-hidden, pointer-events none),
31
+ so the accessible name belongs to the button or link around it. The
32
+ svg arrives through `set:html` and carries no scoped-style attribute;
33
+ style it from the parent with `:global(svg)`. Client scripts take the
34
+ `ICON_*` strings from the same file. Add a glyph to the registry,
35
+ never as inline `<svg>` in a component; a test enforces that.
36
+
37
+ ## Corner badges
38
+
39
+ `CornerBadges.astro` takes its chips as data, `badges: Badge[]` from
40
+ `scripts/core/badges.ts`: a `key` (rendered as the `badge-<key>` class,
41
+ the styling hook), a `label`, and an `icon` name from the registry.
42
+ The two house conventions ship as `GENAI_BADGE` and `DEMO_BADGE`; a
43
+ site's view-model lists the badges a post carries, in order, and can
44
+ add its own without a package change. `PostCardModel.badges` carries
45
+ them to the card. The content components (`BlogImage`, `GalleryImage`,
46
+ `MediaText`) keep a `genai` boolean as authoring sugar for MDX and take
47
+ `badges` for anything else.
17
48
 
18
49
  ## EditorNote
19
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@half-built/astro",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Astro components, islands, and pure helpers for the half-built design system.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,7 +9,10 @@
9
9
  "directory": "packages/astro"
10
10
  },
11
11
  "type": "module",
12
- "files": ["src", "ICONS-LICENSE"],
12
+ "files": [
13
+ "src",
14
+ "ICONS-LICENSE"
15
+ ],
13
16
  "exports": {
14
17
  "./components/Masthead.astro": "./src/components/SiteHeader.astro",
15
18
  "./components/*": "./src/components/*",
@@ -19,6 +22,10 @@
19
22
  "./shiki/code-theme": "./src/shiki/code-theme.mjs",
20
23
  "./shiki/code-vars": "./src/shiki/code-vars.mjs"
21
24
  },
22
- "peerDependencies": { "astro": "^5.0.0" },
23
- "publishConfig": { "access": "public" }
25
+ "peerDependencies": {
26
+ "astro": "^5.0.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ }
24
31
  }
@@ -1,10 +1,17 @@
1
1
  ---
2
2
  import type { ImageMetadata } from "astro";
3
3
 
4
- interface Props { name: string; href: string; image: ImageMetadata; class?: string }
4
+ interface Props {
5
+ name: string;
6
+ href: string;
7
+ image: ImageMetadata;
8
+ class?: string;
9
+ }
10
+
5
11
  const { name, href, image, class: className } = Astro.props;
6
12
  const bg = `background-image:url('${image.src.replace(/'/g, "%27").replace(/"/g, "%22")}')`;
7
13
  ---
14
+
8
15
  <a class:list={["category-single", className]} href={href}>
9
16
  <span class="category-img" style={bg}></span>
10
17
  <span class="category-label chip">{name}</span>
@@ -21,6 +28,7 @@ const bg = `background-image:url('${image.src.replace(/'/g, "%27").replace(/"/g,
21
28
  text-decoration: none;
22
29
  }
23
30
  .category-img { position: absolute; inset: 0; background-size: cover; background-position: center; }
31
+
24
32
  /* Chip look comes from the .chip pattern; card-specific metrics only. */
25
33
  .category-label {
26
34
  position: relative;
@@ -1,27 +1,20 @@
1
1
  ---
2
- /* Corner badges (owner request 2026-08-27, after launch): the chip
3
- family the archive cards' read-time chip belongs to, pinned to the
4
- lower-right corner of the image they sit on.
5
- - AI Art marks an AI-generated image: heroes through the heroGenAI
6
- frontmatter flag, content images through the genai prop on
7
- BlogImage, GalleryImage, and MediaText. Images that only went
8
- through ordinary editing carry nothing.
9
- - Demo marks a post that carries an interactive demo (the demo
10
- frontmatter flag), on the card and the post hero.
11
- Renders nothing when neither applies. The container is absolutely
12
- positioned, so the parent supplies position: relative. pointer-events
13
- off: a badge inside a lightbox link must not swallow the tap. */
14
- import { ICON_PLAY, ICON_SPARKLES } from "../scripts/core/icons";
2
+ import Icon from "./Icon.astro";
3
+ import type { Badge } from "../scripts/core/badges";
15
4
 
16
- interface Props { genai?: boolean; demo?: boolean; class?: string; genaiLabel?: string; demoLabel?: string }
17
- const { genai = false, demo = false, class: className, genaiLabel = "AI Art", demoLabel = "Demo" } = Astro.props;
5
+ interface Props {
6
+ badges?: Badge[];
7
+ class?: string;
8
+ }
9
+
10
+ const { badges = [], class: className } = Astro.props;
18
11
  ---
19
- {(genai || demo) && (
20
- <span class:list={["corner-badges", className]}>
21
- {genai && <span class="chip badge-genai"><Fragment set:html={ICON_SPARKLES} /> {genaiLabel}</span>}
22
- {demo && <span class="chip badge-demo"><Fragment set:html={ICON_PLAY} /> {demoLabel}</span>}
23
- </span>
24
- )}
12
+
13
+ {badges.length > 0 && <span class:list={["corner-badges", className]}>
14
+ {badges.map((badge) => <span class:list={["chip", `badge-${badge.key}`]}>
15
+ <Icon name={badge.icon} /> {badge.label}
16
+ </span>)}
17
+ </span>}
25
18
 
26
19
  <style>
27
20
  /* Chip look comes from the .chip pattern; the read-time chip sits
@@ -1,61 +1,69 @@
1
1
  ---
2
2
  import type { SitemapGroup, EcosystemEntry } from "./models";
3
3
  import { BP_PHONE_MAX } from "../scripts/core/breakpoints";
4
- /* The real half-built-robots.com footer is a single bottom bar with the
5
- copyright line, and the live site has no footer widget row. The sitemap
6
- below the copyright line is a deliberate divergence, owner-directed
7
- 2026-07-30 (docs/superpowers/specs/2026-07-30-footer-sitemap-design.md).
8
- The old theme credit stays dropped: this build is original code, not the
9
- Adore theme. Everything below renders from the sitemap/ecosystem props
10
- Base.astro passes in; SitemapGroup and EcosystemEntry live in models.ts. */
4
+
11
5
  interface Props {
12
6
  siteName: string;
13
7
  legalHolder: string;
14
8
  sitemap: SitemapGroup[];
15
9
  ecosystem: EcosystemEntry[];
16
- /* The entry rendered as this site itself (unlinked, bold). */
17
10
  ecosystemSelf: string;
18
11
  ecosystemTitle?: string;
19
12
  }
20
- const { siteName, legalHolder, sitemap, ecosystem, ecosystemSelf, ecosystemTitle = "Ecosystem" } = Astro.props;
13
+
14
+ const {
15
+ siteName,
16
+ legalHolder,
17
+ sitemap,
18
+ ecosystem,
19
+ ecosystemSelf,
20
+ ecosystemTitle = "Ecosystem",
21
+ } = Astro.props;
22
+
21
23
  const year = new Date().getFullYear();
22
24
  ---
25
+
23
26
  <footer id="colophon" class="site-footer">
24
27
  <div class="bottom-footer tint-overlay">
25
28
  <div class="shell">
26
29
  <div class="bottom-footer-info">
27
30
  <div class="site-info">
28
- {/* Segments are unbreakable so narrow screens wrap only at
29
- the sanctioned points: after the year and at the pipe,
30
- which the line break replaces below the mobile breakpoint
31
- (owner call 2026-08-20). Desktop and tablet render the
32
- one line exactly as before. */}
33
- <slot name="site-info"><span>
31
+ {/* Two lines at every width (owner call 2026-09-13): the
32
+ copyright over the site name, a real break rather than the
33
+ old pipe that retired on phones and left the wrap to do
34
+ the separating. The copyright's segments stay unbreakable
35
+ so a narrow screen wraps that line only after the year. */}
36
+ <slot name="site-info">
37
+ <span class="site-info-line">
34
38
  <span class="site-info-seg">Copyright © {year}</span>{" "}
35
- <span class="site-info-seg">{legalHolder}</span>{" "}
36
- <span class="site-info-sep" aria-hidden="true">|</span>{" "}
37
- <a class="site-info-seg" href="/">{siteName}</a>
38
- </span></slot>
39
+ <span class="site-info-seg">{legalHolder}</span>
40
+ </span>
41
+ <span class="site-info-line"><a href="/">{siteName}</a></span>
42
+ </slot>
39
43
  </div>
40
44
  </div>
41
45
  <nav class="footer-sitemap" aria-label="Sitemap">
42
46
  {sitemap.map((group) =>
43
47
  group.collapsible ? (
44
- <details class="footer-sitemap-group footer-sitemap-collapsible" open data-phone-open={group.phoneOpen ? "" : undefined}>
48
+ <details
49
+ class="footer-sitemap-group footer-sitemap-collapsible"
50
+ open
51
+ data-phone-open={group.phoneOpen ? "" : undefined}
52
+ >
45
53
  <summary><h2>{group.title}</h2></summary>
46
54
  <ul>
47
- {group.links.map((link) => (
48
- <li><a href={link.href}>{link.label}</a></li>
49
- ))}
55
+ {group.links.map((link) => <li>
56
+ <a href={link.href}>{link.label}</a>
57
+ </li>)}
50
58
  </ul>
51
59
  </details>
52
60
  ) : (
53
61
  <section class="footer-sitemap-group">
54
62
  <h2>{group.title}</h2>
55
63
  <ul>
56
- {group.links.map((link) => (
57
- <li><a href={link.href}>{link.label}</a></li>
58
- ))}
64
+ {group.links.map((link) => <li>
65
+ <a href={link.href}>{link.label}</a>
66
+ </li>)}
59
67
  </ul>
60
68
  </section>
61
69
  ),
@@ -63,17 +71,23 @@ const year = new Date().getFullYear();
63
71
  <details class="footer-sitemap-group footer-sitemap-collapsible" open>
64
72
  <summary><h2>{ecosystemTitle}</h2></summary>
65
73
  <ul data-ecosystem>
66
- {ecosystem.map((entry) => (
67
- <li>
68
- {entry.key === ecosystemSelf ? (
69
- <span class="footer-sitemap-self" aria-current="page">{entry.label}</span>
70
- ) : entry.href !== null ? (
71
- <a href={entry.href}>{entry.label}</a>
72
- ) : (
73
- <span class="footer-sitemap-pending">{entry.label}</span>
74
- )}
75
- </li>
76
- ))}
74
+ {ecosystem.map((entry) => {
75
+ /* Three states, one block each: this site, a live
76
+ property, a property with no href yet. */
77
+ const self = entry.key === ecosystemSelf;
78
+ const live = !self && entry.href !== null;
79
+ const pending = !self && !live;
80
+
81
+ return <li>
82
+ {self && <span class="footer-sitemap-self" aria-current="page"
83
+ >{entry.label}</span
84
+ >}{live && <a href={entry.href}
85
+ >{entry.label}</a
86
+ >}{pending && <span class="footer-sitemap-pending"
87
+ >{entry.label}</span
88
+ >}
89
+ </li>;
90
+ })}
77
91
  </ul>
78
92
  </details>
79
93
  </nav>
@@ -97,12 +111,14 @@ const year = new Date().getFullYear();
97
111
 
98
112
  <style>
99
113
  .site-footer { margin-top: 50px; }
114
+
100
115
  /* Tint comes from the .tint-overlay pattern (default 0.1 opacity).
101
116
  The bottom padding adds --dock-bottom (tokens/primitives.css): a
102
117
  site with a viewport-fixed bottom cluster sets it to the room
103
118
  that cluster occupies, and the tinted band runs on below the last
104
119
  link by that much, so the page scrolls far enough to clear it. */
105
120
  .bottom-footer { padding: 15px 0 calc(20px + var(--dock-bottom, 0px)); z-index: 1; }
121
+
106
122
  .bottom-footer-info {
107
123
  display: flex;
108
124
  flex-wrap: wrap;
@@ -111,20 +127,18 @@ const year = new Date().getFullYear();
111
127
  position: relative;
112
128
  font-size: var(--font-size-sm);
113
129
  }
114
- /* The wrapped phone lines center under each other like the sitemap
115
- below (owner report 2026-08-25); one line on desktop, as before. */
130
+
131
+ /* The two lines center under each other like the sitemap below
132
+ (owner report 2026-08-25). */
116
133
  .site-info { text-align: center; }
134
+ .site-info-line { display: block; }
135
+
117
136
  /* Underlined, unlike the sitemap links below: this one sits inside
118
- running text (the copyright line), where the same ink at 0.8
137
+ running text (the copyright block), where the same ink at 0.8
119
138
  opacity is not a visible link mark (WCAG 1.4.1, axe
120
- link-in-text-block; caught by test/a11y.test.ts at phone width,
121
- where the pipe retires and the line break is all that frames it). */
139
+ link-in-text-block; the line break is all that frames it). */
122
140
  .site-info a { text-decoration: underline; opacity: 0.8; transition: var(--transition); color: var(--ink); }
123
141
  .site-info-seg { white-space: nowrap; }
124
- /* The pipe's separating job is done by the line break when the
125
- segments wrap; below the mobile breakpoint the break is expected,
126
- so the pipe retires there. */
127
- @media (--bp-phone) { .site-info-sep { display: none; } }
128
142
  .site-info a:hover { opacity: 1; }
129
143
 
130
144
  /* Sitemap columns under the copyright line, centered like it (owner
@@ -141,25 +155,30 @@ const year = new Date().getFullYear();
141
155
  border-top: 1px solid var(--hairline);
142
156
  font-size: var(--font-size-sm);
143
157
  }
158
+
144
159
  .footer-sitemap-group h2 {
145
160
  margin: 0 0 8px;
146
161
  font-size: var(--font-size-xs);
147
162
  font-weight: 700;
148
163
  text-transform: uppercase;
149
164
  }
165
+
150
166
  .footer-sitemap-group ul {
151
167
  list-style: none;
152
168
  margin: 0;
153
169
  padding: 0;
154
170
  }
155
171
  .footer-sitemap-group li { margin: 4px 0; }
172
+
156
173
  .footer-sitemap a {
157
174
  text-decoration: none;
158
175
  opacity: 0.8;
159
176
  transition: var(--transition);
160
177
  }
178
+
161
179
  .footer-sitemap a:hover,
162
180
  .footer-sitemap a:focus { opacity: 1; text-decoration: underline; }
181
+
163
182
  /* The monochrome theme makes a color marker invisible (text already IS
164
183
  the primary color), so "you are here" is weight, not hue. */
165
184
  .footer-sitemap-self { font-weight: 700; }
@@ -186,12 +205,14 @@ const year = new Date().getFullYear();
186
205
  gap: 14px;
187
206
  }
188
207
  .footer-sitemap-group { text-align: center; width: 100%; }
208
+
189
209
  .footer-sitemap-collapsible summary {
190
210
  display: block;
191
211
  position: relative;
192
212
  cursor: pointer;
193
213
  pointer-events: auto;
194
214
  }
215
+
195
216
  .footer-sitemap-collapsible summary::before {
196
217
  content: "+";
197
218
  position: absolute;
@@ -208,6 +229,7 @@ const year = new Date().getFullYear();
208
229
  }
209
230
  .footer-sitemap-collapsible[open] summary::before { content: "\2212"; }
210
231
  .footer-sitemap-collapsible:not([open]) summary h2 { margin-bottom: 0; }
232
+
211
233
  /* Keyboard focus is the site-wide ring (base/reset.css). */
212
234
  }
213
235
  </style>
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { iconMarkup, type IconName } from "../scripts/core/icons";
3
+
4
+ interface Props {
5
+ name: IconName;
6
+ size?: number | string;
7
+ strokeWidth?: number;
8
+ class?: string;
9
+ }
10
+
11
+ const { name, size, strokeWidth, class: className } = Astro.props;
12
+ ---
13
+
14
+ <Fragment
15
+ set:html={iconMarkup(name, { size, strokeWidth, class: className })}
16
+ />
@@ -1,13 +1,22 @@
1
1
  ---
2
- /* The build-time lightbox anchor (step 5, 2026-08-30): owner of the
3
- data-lb-* contract that scripts/lightbox.ts itemFor() reads. The
4
- caption string feeds both data-lb-caption and the aria-label detail;
5
- every pre-step-5 site passed the same string to both. class:list
6
- stays FIRST in the tag: content-components and kitchen-sink pin
7
- `<a class="lightbox-link"` as the anchor's opening. Base styling
8
- (display, position, cursor) lives in styles/lightbox.css; no scoped
9
- CSS here so callers' :global-wrapped rules reach the anchor. */
10
- interface Props extends Record<string, unknown> { href: string; w: number; h: number; caption?: string; class?: string }
2
+ interface Props extends Record<string, unknown> {
3
+ href: string;
4
+ w: number;
5
+ h: number;
6
+ caption?: string;
7
+ class?: string;
8
+ }
9
+
11
10
  const { href, w, h, caption = "", class: className, ...rest } = Astro.props;
12
11
  ---
13
- <a class:list={["lightbox-link", className]} href={href} data-lb-w={w} data-lb-h={h} data-lb-caption={caption} aria-label={`View full-size image${caption ? `: ${caption}` : ""}`} {...rest}><slot /></a>
12
+
13
+ <a
14
+ class:list={["lightbox-link", className]}
15
+ href={href}
16
+ data-lb-w={w}
17
+ data-lb-h={h}
18
+ data-lb-caption={caption}
19
+ aria-label={`View full-size image${caption ? `: ${caption}` : ""}`}
20
+ {...rest}
21
+ ><slot
22
+ /></a>
@@ -1,19 +1,21 @@
1
1
  ---
2
- /* One list widget for the sidebar's three lists (step 6, 2026-08-30):
3
- a Widget holding a link list, counts rendered when present. Replaces
4
- RecentPosts, CategoriesWidget, and ArchivesWidget; the sidebar builds
5
- the items via src/lib/view-models.ts. The class prop rides down to
6
- Widget's root, so this file has no class:list of its own. */
7
2
  import Widget from "./Widget.astro";
8
3
  import type { LinkListItem } from "./models";
9
4
 
10
- interface Props { title: string; items: LinkListItem[]; headingLevel?: 2 | 3 | 4; class?: string }
5
+ interface Props {
6
+ title: string;
7
+ items: LinkListItem[];
8
+ headingLevel?: 2 | 3 | 4;
9
+ class?: string;
10
+ }
11
+
11
12
  const { title, items, headingLevel = 2, class: className } = Astro.props;
12
13
  ---
14
+
13
15
  <Widget title={title} headingLevel={headingLevel} class={className}>
14
16
  <ul>
15
- {items.map((i) => (
16
- <li><a href={i.href}>{i.label}</a>{i.count !== undefined && ` (${i.count})`}</li>
17
- ))}
17
+ {items.map((i) => <li>
18
+ <a href={i.href}>{i.label}</a>{i.count !== undefined && ` (${i.count})`}
19
+ </li>)}
18
20
  </ul>
19
21
  </Widget>
@@ -1,7 +1,7 @@
1
1
  ---
2
- /* Pagination matching the live WordPress markup: nav.navigation.pagination
3
- with .nav-links containing .page-numbers boxes and Prev/Next arrows. */
4
2
  import { archivePagePath } from "../lib/paginate";
3
+ import Icon from "./Icon.astro";
4
+
5
5
  interface Props {
6
6
  current: number;
7
7
  total: number;
@@ -9,34 +9,54 @@ interface Props {
9
9
  class?: string;
10
10
  labels?: { heading?: string; prev?: string; next?: string };
11
11
  }
12
- const { current, total, base = "", class: className, labels: labelsOverride = {} } = Astro.props;
13
- const labels = { heading: "Posts pagination", prev: "Previous", next: "Next", ...labelsOverride };
12
+
13
+ const {
14
+ current,
15
+ total,
16
+ base = "",
17
+ class: className,
18
+ labels: labelsOverride = {},
19
+ } = Astro.props;
20
+
21
+ const labels = {
22
+ heading: "Posts pagination",
23
+ prev: "Previous",
24
+ next: "Next",
25
+ ...labelsOverride,
26
+ };
27
+
14
28
  const pageHref = (n: number) => archivePagePath(n, base);
15
29
  ---
16
- {total > 1 && (
17
- <nav class:list={["navigation", "pagination", className]} aria-label={labels.heading}>
18
- <h2 class="screen-reader-text">{labels.heading}</h2>
19
- <div class="nav-links">
20
- {current > 1 && (
21
- <a class="prev page-numbers press-box" href={pageHref(current - 1)}>
22
- <svg viewBox="0 0 448 512" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M257.5 445.1l-22.2 22.2c-9.4 9.4-24.6 9.4-33.9 0L7 273c-9.4-9.4-9.4-24.6 0-33.9L201.4 44.7c9.4-9.4 24.6-9.4 33.9 0l22.2 22.2c9.5 9.5 9.3 25-.4 34.3L136.6 216H424c13.3 0 24 10.7 24 24v32c0 13.3-10.7 24-24 24H136.6l120.5 114.8c9.8 9.3 10 24.8.4 34.3z"/></svg>
23
- <span class="screen-reader-text">{labels.prev}</span>
24
- </a>
25
- )}
26
- {Array.from({ length: total }, (_, i) => i + 1).map((n) =>
27
- n === current
28
- ? <span aria-current="page" class="page-numbers current">{n}</span>
29
- : <a class="page-numbers press-box" href={pageHref(n)}>{n}</a>
30
- )}
31
- {current < total && (
32
- <a class="next page-numbers press-box" href={pageHref(current + 1)}>
33
- <svg viewBox="0 0 448 512" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M190.5 66.9l22.2-22.2c9.4-9.4 24.6-9.4 33.9 0L441 239c9.4 9.4 9.4 24.6 0 33.9L246.6 467.3c-9.4 9.4-24.6 9.4-33.9 0l-22.2-22.2c-9.5-9.5-9.3-25 .4-34.3L311.4 296H24c-13.3 0-24-10.7-24-24v-32c0-13.3 10.7-24 24-24h287.4L190.9 101.2c-9.8-9.3-10-24.8-.4-34.3z"/></svg>
34
- <span class="screen-reader-text">{labels.next}</span>
35
- </a>
36
- )}
37
- </div>
38
- </nav>
39
- )}
30
+
31
+ {total > 1 && <nav
32
+ class:list={["navigation", "pagination", className]}
33
+ aria-label={labels.heading}
34
+ >
35
+ <h2 class="screen-reader-text">{labels.heading}</h2>
36
+ <div class="nav-links">
37
+ {current > 1 && <a
38
+ class="prev page-numbers press-box"
39
+ href={pageHref(current - 1)}
40
+ >
41
+ <Icon name="arrow-left" size={14} />
42
+ <span class="screen-reader-text">{labels.prev}</span>
43
+ </a>}
44
+ {Array.from({ length: total }, (_, i) => i + 1).map((n) =>
45
+ n === current ? (
46
+ <span aria-current="page" class="page-numbers current">{n}</span>
47
+ ) : (
48
+ <a class="page-numbers press-box" href={pageHref(n)}>{n}</a>
49
+ ),
50
+ )}
51
+ {current < total && <a
52
+ class="next page-numbers press-box"
53
+ href={pageHref(current + 1)}
54
+ >
55
+ <Icon name="arrow-right" size={14} />
56
+ <span class="screen-reader-text">{labels.next}</span>
57
+ </a>}
58
+ </div>
59
+ </nav>}
40
60
 
41
61
  <style>
42
62
  .nav-links {
@@ -45,6 +65,7 @@ const pageHref = (n: number) => archivePagePath(n, base);
45
65
  margin: 10px 0 20px;
46
66
  justify-content: center;
47
67
  }
68
+
48
69
  .nav-links a,
49
70
  .nav-links span {
50
71
  text-decoration: none;
@@ -57,11 +78,13 @@ const pageHref = (n: number) => archivePagePath(n, base);
57
78
  color: var(--ink);
58
79
  border: 1px solid var(--ink);
59
80
  }
81
+
60
82
  .nav-links a:hover,
61
83
  .nav-links a:focus {
62
84
  color: var(--ink);
63
85
  border-color: var(--rule);
64
86
  }
87
+
65
88
  /* Press feedback comes from .press-box on the links; the shared rule
66
89
  above keeps this site's 1px cell border over the pattern's 2px. */
67
90
  .nav-links span.current {