@vdaluz/astro-blog 1.0.1 → 1.1.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 +18 -2
- package/package.json +1 -1
- package/src/components/BlogPostMeta.astro +4 -4
- package/src/components/HeroImageCredit.astro +2 -2
- package/src/components/Pagination.astro +8 -3
- package/src/components/PostCard.astro +14 -5
- package/src/components/RelatedPosts.astro +16 -8
- package/src/components/Subheading.astro +8 -5
- package/src/components/TableOfContents.astro +2 -2
- package/src/components/TagFilterNav.astro +1 -1
- package/src/index.ts +11 -11
- package/src/lib/filterPosts.ts +1 -1
- package/src/lib/post-href.ts +9 -0
- package/src/lib/relatedPosts.ts +1 -1
- package/src/lib/rss.ts +7 -3
- package/src/lib/schema.ts +17 -2
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
|
-
`
|
|
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
|
|
3
|
+
"version": "1.1.0",
|
|
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={
|
|
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) =>
|
|
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 {
|
|
16
|
-
|
|
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');
|
|
@@ -27,7 +36,7 @@ const heroImageWebp = post.data.heroImage?.replace(/\.(jpe?g|png|gif)$/i, '.webp
|
|
|
27
36
|
<a href={href}>
|
|
28
37
|
<img
|
|
29
38
|
src={heroImageWebp}
|
|
30
|
-
alt=
|
|
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 {
|
|
16
|
-
|
|
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={
|
|
37
|
+
<a href={postHref(base, post.id, trailingSlash)}>
|
|
30
38
|
<img
|
|
31
39
|
src={heroImageWebp(post.data.heroImage)}
|
|
32
|
-
alt=
|
|
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={
|
|
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={
|
|
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
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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 ${
|
|
27
|
+
class={`inline-block text-sm uppercase tracking-wider font-mono font-semibold ${toneClasses[resolvedTone]}`}
|
|
25
28
|
>
|
|
26
29
|
{text}
|
|
27
30
|
</span>
|
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';
|
package/src/lib/filterPosts.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/relatedPosts.ts
CHANGED
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(
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|