@vdaluz/astro-blog 1.0.1 → 1.1.1

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
@@ -59,7 +59,7 @@ Peer dependency: `astro` >= 6. For post body styling you'll also want `@tailwind
59
59
 
60
60
  The `shikiConfig` uses `defaultColor: false`, so the CSS handoff is what actually colors code blocks. They must ship together. **Dark-only sites:** keep `shikiConfig` and force `<html class="dark">` so the dark vars always apply.
61
61
 
62
- 4. **Generate a matching `.webp` sibling for every `heroImage`.** `PostCard` and `RelatedPosts` derive the thumbnail `src` by swapping the `heroImage` extension (`.jpg`/`.jpeg`/`.png`/`.gif`) for `.webp` - they never render the raw file. If a post sets `heroImage: /assets/images/foo.jpeg`, `assets/images/foo.webp` must exist at that same path or the thumbnail 404s. Any image pipeline that outputs a same-basename `.webp` next to the original works (e.g. a Sharp-based build step); nothing in this package generates it for you.
62
+ 4. **Generate a matching `.webp` sibling for every `heroImage`.** `PostCard` and `RelatedPosts` derive the thumbnail `src` by swapping the `heroImage` extension (`.jpg`/`.jpeg`/`.png`/`.gif`) for `.webp` - they never render the raw file. If a post sets `heroImage: /assets/images/foo.jpeg`, `assets/images/foo.webp` must exist at that same path or the thumbnail 404s. Any image pipeline that outputs a same-basename `.webp` next to the original works (e.g. a Sharp-based build step); nothing in this package generates it for you. The JSON-LD `image` field built by `buildBlogPostingSchema` is the one exception - it always points at the original `heroImage` file, not the `.webp`, since the original is the only file this package's contract guarantees exists.
63
63
 
64
64
  ## Example
65
65
 
@@ -88,6 +88,8 @@ Components that build post URLs (`PostCard`, `RelatedPosts`, `Pagination`) accep
88
88
 
89
89
  `PostCard` accepts an optional `categoryLabel` prop to override the category badge text (default `post.data.category`). `RelatedPosts` accepts the same override as a `(post) => string` function, since it renders a badge per post. Use these when `category` is a canonical/English taxonomy value that the consuming app translates for display - the package has no built-in category translation since the taxonomy itself is app-defined.
90
90
 
91
+ `Subheading` accepts an optional `tone?: 'accent' | 'muted'` prop (default `'accent'`) mapping to the same `--accent`/`--muted` token custom properties every other component uses. Its older `color?: 'blue' | 'gray'` prop is deprecated (`'blue'` behaves as `'accent'`, `'gray'` as `'muted'`) and will be removed in 2.0 - it named a literal color rather than a token, which is misleading on any site whose accent isn't actually blue.
92
+
91
93
  ### Hero image attribution
92
94
 
93
95
  `blogSchema()` validates an optional `heroImageCredit` field (`name`, `url`, `source: 'pexels' | 'unsplash' | 'openverse'`, optional `licenseName`/`licenseUrl`) for posts whose hero image needs attribution - required for CC/attribution-required sources like Openverse, not just polite. Render it with `HeroImageCredit`:
@@ -108,7 +110,7 @@ Set `updatedDate` in a post's frontmatter when you substantively edit it after p
108
110
 
109
111
  ### Trailing slash
110
112
 
111
- `buildBlogPostingSchema`/`BlogPostMeta` build the JSON-LD `url`/`mainEntityOfPage.@id` fields without a trailing slash by default. If your site's actual canonical post URL is slash-terminated, pass `trailingSlash={true}` - otherwise the JSON-LD `url` disagrees with your page's own `<link rel="canonical">`, which can cause search engines to pick the wrong canonical form. Check your real canonical output before setting this, not just your app's `trailingSlash` config: prerendered routes on some hosts are served slash-terminated regardless of that config (confirm with `curl -sI` on a bare post URL - a `307`/`308` to the slash form means you need `trailingSlash={true}`).
113
+ Every URL-building surface in this package defaults to no trailing slash and accepts a `trailingSlash` prop/option to opt in: `BlogPostMeta` (JSON-LD's `url`/`mainEntityOfPage.@id`), `PostCard`, `RelatedPosts`, `Pagination` (its numbered page links, including page 1's own href), and `buildRssItems` (each item's `link`). If your site's actual canonical post URL is slash-terminated, pass `trailingSlash={true}` (or `{ trailingSlash: true }` for `buildRssItems`) to **every one of these** - passing it to only one (e.g. just `BlogPostMeta`) leaves the rest emitting slash-less URLs that disagree with JSON-LD and your page's own `<link rel="canonical">`, which can cause search engines to pick the wrong canonical form. Check your real canonical output before setting this, not just your app's `trailingSlash` config: prerendered routes on some hosts are served slash-terminated regardless of that config (confirm with `curl -sI` on a bare post URL - a `307`/`308` to the slash form means you need `trailingSlash={true}`).
112
114
 
113
115
  ### Table of contents + reading time
114
116
 
@@ -223,6 +225,20 @@ export const GET: APIRoute = async (context) => {
223
225
 
224
226
  Issues welcome. PRs by discussion - open an issue first for anything beyond a typo or docs fix.
225
227
 
228
+ ### Releasing
229
+
230
+ Maintainer-only. Releases are tag-triggered and published to npm via GitHub Actions (Trusted
231
+ Publishing / OIDC, no token secret):
232
+
233
+ 1. Test before tagging: `npm pack`, install the tarball into a scratch Astro app (or a consumer
234
+ locally), `astro check && astro build`.
235
+ 2. Bump `version` in `package.json`, commit.
236
+ 3. Tag `vX.Y.Z` and push the tag. Pushing the tag runs `.github/workflows/publish.yml`, which
237
+ type-checks, tests, verifies the tag matches `package.json`'s version, and only then runs
238
+ `npm publish`.
239
+ 4. Confirm the version is live: `npm view @vdaluz/astro-blog version`. Consumers bump their own
240
+ semver pin once it's confirmed live - see this package's CHANGELOG.md for what changed.
241
+
226
242
  ## Consumers
227
243
 
228
244
  - [vdaluz.com](https://vdaluz.com)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vdaluz/astro-blog",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Token-driven Astro blog components, related-posts scoring, a schema factory, and Shiki config - proven in production on vdaluz.com and imperfectsystems.com.",
5
5
  "keywords": [
6
6
  "astro",
@@ -1,7 +1,7 @@
1
1
  ---
2
- import { buildBlogPostingSchema } from '../lib/schema';
3
- import type { BlogPostLike } from '../lib/types';
4
- import type { Locale } from '../lib/i18n';
2
+ import { buildBlogPostingSchema, serializeForScriptTag } from '../lib/schema.ts';
3
+ import type { BlogPostLike } from '../lib/types.ts';
4
+ import type { Locale } from '../lib/i18n.ts';
5
5
 
6
6
  interface Props {
7
7
  post: BlogPostLike;
@@ -29,4 +29,4 @@ const schema = buildBlogPostingSchema({
29
29
  ---
30
30
 
31
31
  {/* JSON-LD is valid in <body>; render this anywhere inside the post page. */}
32
- <script type="application/ld+json" set:html={JSON.stringify(schema)} />
32
+ <script type="application/ld+json" set:html={serializeForScriptTag(schema)} />
@@ -1,6 +1,6 @@
1
1
  ---
2
- import { t, type Locale } from '../lib/i18n';
3
- import type { HeroImageCredit as HeroImageCreditData } from '../lib/types';
2
+ import { t, type Locale } from '../lib/i18n.ts';
3
+ import type { HeroImageCredit as HeroImageCreditData } from '../lib/types.ts';
4
4
 
5
5
  interface Props {
6
6
  credit: HeroImageCreditData;
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { t, type Locale } from '../lib/i18n';
2
+ import { t, type Locale } from '../lib/i18n.ts';
3
3
 
4
4
  interface Props {
5
5
  page: {
@@ -16,9 +16,11 @@ interface Props {
16
16
  base?: string;
17
17
  /** Locale for UI strings. Defaults to "en". */
18
18
  locale?: Locale;
19
+ /** Whether this site's canonical post URL ends in a trailing slash. Defaults to false. */
20
+ trailingSlash?: boolean;
19
21
  }
20
22
 
21
- const { page, base = '/blog', locale = 'en' } = Astro.props;
23
+ const { page, base = '/blog', locale = 'en', trailingSlash = false } = Astro.props;
22
24
  const { currentPage, lastPage, url } = page;
23
25
  const strings = t(locale);
24
26
 
@@ -47,7 +49,10 @@ const getPageNumbers = () => {
47
49
  };
48
50
 
49
51
  const pageNumbers = getPageNumbers();
50
- const pageHref = (n: number) => (n === 1 ? base : `${base}/${n}`);
52
+ const pageHref = (n: number) => {
53
+ const href = n === 1 ? base : `${base}/${n}`;
54
+ return trailingSlash ? `${href}/` : href;
55
+ };
51
56
  ---
52
57
 
53
58
  <nav class="flex items-center justify-center space-x-2 mt-12" aria-label={strings.blogPagination}>
@@ -1,6 +1,7 @@
1
1
  ---
2
- import type { BlogPostLike } from '../lib/types';
3
- import { t, formatDate, type Locale } from '../lib/i18n';
2
+ import type { BlogPostLike } from '../lib/types.ts';
3
+ import { t, formatDate, type Locale } from '../lib/i18n.ts';
4
+ import { postHref } from '../lib/post-href.ts';
4
5
 
5
6
  interface Props {
6
7
  post: BlogPostLike;
@@ -10,10 +11,18 @@ interface Props {
10
11
  locale?: Locale;
11
12
  /** Override text for the category badge. Defaults to post.data.category. */
12
13
  categoryLabel?: string;
14
+ /** Whether this site's canonical post URL ends in a trailing slash. Defaults to false. */
15
+ trailingSlash?: boolean;
13
16
  }
14
17
 
15
- const { post, base = '/blog', locale = 'en', categoryLabel = post.data.category } = Astro.props;
16
- const href = `${base}/${post.id}`;
18
+ const {
19
+ post,
20
+ base = '/blog',
21
+ locale = 'en',
22
+ categoryLabel = post.data.category,
23
+ trailingSlash = false,
24
+ } = Astro.props;
25
+ const href = postHref(base, post.id, trailingSlash);
17
26
  const strings = t(locale);
18
27
  // Consumers must generate a matching .webp for every heroImage (see README).
19
28
  const heroImageWebp = post.data.heroImage?.replace(/\.(jpe?g|png|gif)$/i, '.webp');
@@ -24,10 +33,10 @@ const heroImageWebp = post.data.heroImage?.replace(/\.(jpe?g|png|gif)$/i, '.webp
24
33
  >
25
34
  {
26
35
  heroImageWebp && (
27
- <a href={href}>
36
+ <a href={href} aria-hidden="true" tabindex="-1">
28
37
  <img
29
38
  src={heroImageWebp}
30
- alt={post.data.title}
39
+ alt=""
31
40
  width="800"
32
41
  height="192"
33
42
  loading="lazy"
@@ -1,6 +1,7 @@
1
1
  ---
2
- import type { BlogPostLike } from '../lib/types';
3
- import { t, formatDate, type Locale } from '../lib/i18n';
2
+ import type { BlogPostLike } from '../lib/types.ts';
3
+ import { t, formatDate, type Locale } from '../lib/i18n.ts';
4
+ import { postHref } from '../lib/post-href.ts';
4
5
 
5
6
  interface Props {
6
7
  posts: BlogPostLike[];
@@ -10,10 +11,17 @@ interface Props {
10
11
  locale?: Locale;
11
12
  /** Override text for each post's category badge. Defaults to post.data.category. */
12
13
  categoryLabel?: (post: BlogPostLike) => string;
14
+ /** Whether this site's canonical post URL ends in a trailing slash. Defaults to false. */
15
+ trailingSlash?: boolean;
13
16
  }
14
17
 
15
- const { posts, base = '/blog', locale = 'en', categoryLabel = (post) => post.data.category } =
16
- Astro.props;
18
+ const {
19
+ posts,
20
+ base = '/blog',
21
+ locale = 'en',
22
+ categoryLabel = (post) => post.data.category,
23
+ trailingSlash = false,
24
+ } = Astro.props;
17
25
  const strings = t(locale);
18
26
  // Consumers must generate a matching .webp for every heroImage (see README).
19
27
  const heroImageWebp = (heroImage?: string) => heroImage?.replace(/\.(jpe?g|png|gif)$/i, '.webp');
@@ -26,10 +34,10 @@ const heroImageWebp = (heroImage?: string) => heroImage?.replace(/\.(jpe?g|png|g
26
34
  posts.map((post) => (
27
35
  <article class="bg-surface rounded-xl shadow-[0_1px_3px_0_rgb(0_0_0_/_0.1),0_1px_2px_-1px_rgb(0_0_0_/_0.1)] overflow-hidden hover:shadow-lg transition-all duration-300 flex flex-col">
28
36
  {heroImageWebp(post.data.heroImage) && (
29
- <a href={`${base}/${post.id}`}>
37
+ <a href={postHref(base, post.id, trailingSlash)} aria-hidden="true" tabindex="-1">
30
38
  <img
31
39
  src={heroImageWebp(post.data.heroImage)}
32
- alt={post.data.title}
40
+ alt=""
33
41
  width="640"
34
42
  height="160"
35
43
  loading="lazy"
@@ -43,7 +51,7 @@ const heroImageWebp = (heroImage?: string) => heroImage?.replace(/\.(jpe?g|png|g
43
51
  {categoryLabel(post)}
44
52
  </div>
45
53
  <h3 class="text-lg font-bold text-fg mb-3 hover:text-accent transition-colors leading-snug">
46
- <a href={`${base}/${post.id}`}>{post.data.title}</a>
54
+ <a href={postHref(base, post.id, trailingSlash)}>{post.data.title}</a>
47
55
  </h3>
48
56
  <p class="text-muted text-sm leading-relaxed mb-4 grow line-clamp-3">
49
57
  {post.data.description}
@@ -53,7 +61,7 @@ const heroImageWebp = (heroImage?: string) => heroImage?.replace(/\.(jpe?g|png|g
53
61
  {formatDate(post.data.pubDate, locale, { year: 'numeric', month: 'short', day: 'numeric' })}
54
62
  </time>
55
63
  <a
56
- href={`${base}/${post.id}`}
64
+ href={postHref(base, post.id, trailingSlash)}
57
65
  class="inline-flex items-center text-accent text-sm font-semibold hover:text-accent-strong transition-colors group"
58
66
  aria-label={`${strings.read} ${post.data.title}`}
59
67
  >
@@ -2,10 +2,13 @@
2
2
  interface Props {
3
3
  text: string;
4
4
  align?: 'left' | 'center' | 'right';
5
+ /** @deprecated Use `tone`. Removed in 2.0. `'blue'` maps to `accent`, `'gray'` to `muted`. */
5
6
  color?: 'blue' | 'gray';
7
+ tone?: 'accent' | 'muted';
6
8
  }
7
9
 
8
- const { text, align = 'left', color = 'blue' } = Astro.props;
10
+ const { text, align = 'left', color, tone } = Astro.props;
11
+ const resolvedTone: 'accent' | 'muted' = tone ?? (color === 'gray' ? 'muted' : 'accent');
9
12
 
10
13
  const alignClasses = {
11
14
  left: 'text-left',
@@ -13,15 +16,15 @@ const alignClasses = {
13
16
  right: 'text-right',
14
17
  };
15
18
 
16
- const colorClasses = {
17
- blue: 'text-accent',
18
- gray: 'text-muted',
19
+ const toneClasses = {
20
+ accent: 'text-accent',
21
+ muted: 'text-muted',
19
22
  };
20
23
  ---
21
24
 
22
25
  <div class={`mb-4 ${alignClasses[align]}`}>
23
26
  <span
24
- class={`inline-block text-sm uppercase tracking-wider font-semibold ${colorClasses[color]}`}
27
+ class={`inline-block text-sm uppercase tracking-wider font-mono font-semibold ${toneClasses[resolvedTone]}`}
25
28
  >
26
29
  {text}
27
30
  </span>
@@ -1,6 +1,6 @@
1
1
  ---
2
- import { t } from '../lib/i18n';
3
- import type { Locale } from '../lib/i18n';
2
+ import { t } from '../lib/i18n.ts';
3
+ import type { Locale } from '../lib/i18n.ts';
4
4
 
5
5
  export interface TocHeading {
6
6
  depth: number;
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { t, type Locale } from '../lib/i18n';
2
+ import { t, type Locale } from '../lib/i18n.ts';
3
3
 
4
4
  export interface FilterOption {
5
5
  label: string;
package/src/index.ts CHANGED
@@ -1,11 +1,11 @@
1
- export { blogSchema, buildBlogPostingSchema } from './lib/schema';
2
- export type { BlogPostingSchemaOptions } from './lib/schema';
3
- export { scoreRelated, normalizeTag } from './lib/relatedPosts';
4
- export type { ScoreRelatedOptions } from './lib/relatedPosts';
5
- export { filterPostsByTag } from './lib/filterPosts';
6
- export { shikiConfig } from './lib/shiki';
7
- export { buildRssItems } from './lib/rss';
8
- export type { RssItem } from './lib/rss';
9
- export type { BlogPostData, BlogPostLike, HeroImageCredit } from './lib/types';
10
- export { t, formatDate, BUILT_IN_LOCALES } from './lib/i18n';
11
- export type { Locale, Strings } from './lib/i18n';
1
+ export { blogSchema, buildBlogPostingSchema } from './lib/schema.ts';
2
+ export type { BlogPostingSchemaOptions } from './lib/schema.ts';
3
+ export { scoreRelated, normalizeTag } from './lib/relatedPosts.ts';
4
+ export type { ScoreRelatedOptions } from './lib/relatedPosts.ts';
5
+ export { filterPostsByTag } from './lib/filterPosts.ts';
6
+ export { shikiConfig } from './lib/shiki.ts';
7
+ export { buildRssItems } from './lib/rss.ts';
8
+ export type { RssItem } from './lib/rss.ts';
9
+ export type { BlogPostData, BlogPostLike, HeroImageCredit } from './lib/types.ts';
10
+ export { t, formatDate, BUILT_IN_LOCALES } from './lib/i18n.ts';
11
+ export type { Locale, Strings } from './lib/i18n.ts';
@@ -1,4 +1,4 @@
1
- import type { BlogPostLike } from './types';
1
+ import type { BlogPostLike } from './types.ts';
2
2
  import { normalizeTag } from './relatedPosts.ts';
3
3
 
4
4
  /**
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Builds a post's href. Matches the trailing-slash convention
3
+ * `buildBlogPostingSchema` already applies to JSON-LD's `url`/`@id`, so a
4
+ * site whose canonical post URL is slash-terminated doesn't get card/RSS
5
+ * links that disagree with JSON-LD.
6
+ */
7
+ export function postHref(base: string, id: string, trailingSlash = false): string {
8
+ return `${base}/${id}${trailingSlash ? '/' : ''}`;
9
+ }
@@ -1,4 +1,4 @@
1
- import type { BlogPostLike } from './types';
1
+ import type { BlogPostLike } from './types.ts';
2
2
 
3
3
  export interface ScoreRelatedOptions {
4
4
  /** Max number of related posts to return. Defaults to 3. */
package/src/lib/rss.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { BlogPostLike } from './types';
1
+ import type { BlogPostLike } from './types.ts';
2
+ import { postHref } from './post-href.ts';
2
3
 
3
4
  /**
4
5
  * Structurally assignable to @astrojs/rss's `RSSFeedItem` — this package doesn't
@@ -20,13 +21,16 @@ export interface RssItem {
20
21
  * `author` is intentionally not mapped: the RSS spec's `author` field expects an
21
22
  * email address, but `BlogPostData.author` is a display name.
22
23
  */
23
- export function buildRssItems(posts: BlogPostLike[], opts?: { basePath?: string }): RssItem[] {
24
+ export function buildRssItems(
25
+ posts: BlogPostLike[],
26
+ opts?: { basePath?: string; trailingSlash?: boolean },
27
+ ): RssItem[] {
24
28
  const prefix = (opts?.basePath ?? '/blog').replace(/\/$/, '');
25
29
  return posts.map((post) => ({
26
30
  title: post.data.title,
27
31
  description: post.data.description,
28
32
  pubDate: post.data.pubDate,
29
- link: `${prefix}/${post.id}`,
33
+ link: postHref(prefix, post.id, opts?.trailingSlash),
30
34
  categories: [post.data.category, ...(post.data.tags ?? [])].filter(
31
35
  (c, i, a) => a.indexOf(c) === i,
32
36
  ),
package/src/lib/schema.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'astro/zod';
2
- import type { BlogPostLike } from './types';
2
+ import type { BlogPostLike } from './types.ts';
3
3
 
4
4
  /**
5
5
  * Zod schema for a blog collection. Pass `defaultAuthor` to set the per-site
@@ -88,7 +88,22 @@ export function buildBlogPostingSchema({
88
88
  publisher: { '@type': 'Person', name: publisherName || authorName, url: origin },
89
89
  url: postUrl,
90
90
  mainEntityOfPage: { '@type': 'WebPage', '@id': postUrl },
91
- ...(post.data.heroImage ? { image: `${origin}${post.data.heroImage}` } : {}),
91
+ // Points at the original file, not the .webp thumbnail PostCard/RelatedPosts render -
92
+ // the original is the only file this package's own contract guarantees exists.
93
+ ...(post.data.heroImage ? { image: new URL(post.data.heroImage, `${origin}/`).href } : {}),
92
94
  ...(locale ? { inLanguage: locale } : {}),
93
95
  };
94
96
  }
97
+
98
+ const SCRIPT_TAG_ESCAPES: Record<string, string> = { '<': '\\u003c', '>': '\\u003e', '&': '\\u0026' };
99
+
100
+ /**
101
+ * Serializes a value for embedding in an inline `<script>` tag (e.g. via
102
+ * Astro's `set:html`). `JSON.stringify` does not escape `<`, so a `</script>`
103
+ * or `<!--` inside any string value would close the script element early and
104
+ * inject the remainder as markup - `<` is the load-bearing escape here; `>`
105
+ * and `&` are escaped only for symmetry.
106
+ */
107
+ export function serializeForScriptTag(value: unknown): string {
108
+ return JSON.stringify(value).replace(/[<>&]/g, (char) => SCRIPT_TAG_ESCAPES[char]);
109
+ }