ferst-core 0.7.10 → 0.7.12

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.
@@ -16,9 +16,10 @@ interface Props {
16
16
  shadow?: boolean;
17
17
  border?: boolean;
18
18
  ratio?: 'auto' | 'square' | '4/3' | '3/2' | '16/9' | '21/9';
19
+ focus?: string;
19
20
  reveal?: boolean;
20
21
  }
21
- const { src, alt = '', caption, radius = 'soft', shadow = false, border = false, ratio = 'auto', reveal = false } = Astro.props;
22
+ const { src, alt = '', caption, radius = 'soft', shadow = false, border = false, ratio = 'auto', reveal = false, focus } = Astro.props;
22
23
  const ratioValue = ratio === 'auto' ? null : ratio === 'square' ? '1 / 1' : ratio.replace('/', ' / ');
23
24
  ---
24
25
 
@@ -34,7 +35,7 @@ const ratioValue = ratio === 'auto' ? null : ratio === 'square' ? '1 / 1' : rati
34
35
  alt={alt}
35
36
  loading="lazy"
36
37
  decoding="async"
37
- style={ratioValue ? `aspect-ratio: ${ratioValue}` : undefined}
38
+ style={ratioValue ? `aspect-ratio: ${ratioValue}${focus ? `; object-position: ${focus}` : ''}` : undefined}
38
39
  />
39
40
  {caption && <figcaption>{caption}</figcaption>}
40
41
  </figure>
@@ -15,7 +15,7 @@ interface Item {
15
15
  title: string;
16
16
  text?: string;
17
17
  href?: string;
18
- image?: { src: string; alt?: string };
18
+ image?: { src: string; alt?: string; focus?: string };
19
19
  }
20
20
  interface Props {
21
21
  title?: string;
@@ -39,7 +39,7 @@ const headAlign = headingAlign ?? blockAlign ?? 'center';
39
39
  <div class="b-bento__grid">
40
40
  {items.map((item) => {
41
41
  const Tag = item.href ? 'a' : 'div';
42
- const style = item.variant === 'image' && item.image ? `--bento-image: url('${item.image.src}')` : undefined;
42
+ const style = item.variant === 'image' && item.image ? `--bento-image: url('${item.image.src}')${item.image.focus ? `; --bento-focus: ${item.image.focus}` : ''}` : undefined;
43
43
  return (
44
44
  <Tag class="b-bento__item" data-size={item.size ?? 'sm'} data-variant={item.variant ?? 'surface'} href={item.href} style={style}>
45
45
  {item.icon && isIconKey(item.icon) && (
@@ -115,7 +115,7 @@ const headAlign = headingAlign ?? blockAlign ?? 'center';
115
115
  z-index: -1;
116
116
  background-image: linear-gradient(to top, rgba(8,10,18,0.82), rgba(8,10,18,0.15)), var(--bento-image);
117
117
  background-size: cover;
118
- background-position: center;
118
+ background-position: center, var(--bento-focus, center);
119
119
  }
120
120
  a.b-bento__item:hover { transform: translateY(-3px); }
121
121
  a.b-bento__item[data-variant='surface']:hover { box-shadow: var(--shadow-lg); }
@@ -46,7 +46,10 @@ const provider = bgLink ? videoEmbed(bgLink)?.provider : undefined;
46
46
  const watchLabel = media?.linkLabel || (provider ? `Watch the full video on ${provider}` : 'Watch the full video');
47
47
  // The still: the image (no motion), or an embed's poster until the player may load.
48
48
  const stillSrc = embed ? media?.poster ?? still : !media?.video && !slides.length ? still : undefined;
49
- const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
49
+ // Drawn as a real <img>, never a CSS background: a picture set through a CSS variable is
50
+ // found late (never by the preload scanner), so the hero sat grey until it arrived, and
51
+ // some browsers kept it grey. A real image, loaded first, is found early and painted.
52
+ const heroImg = isMedia && stillSrc ? stillSrc : undefined;
50
53
  ---
51
54
 
52
55
  <section
@@ -58,10 +61,11 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
58
61
  data-linked={bgLink ? 'true' : undefined}
59
62
  data-parallax={isMedia && parallax ? 'true' : undefined}
60
63
  data-full-bleed={isMedia && fullBleed ? 'true' : undefined}
61
- style={isMedia ? `--hero-overlay: ${overlay}; ${bgImage}${focus ? `; --hero-focus: ${focus}` : ''}` : undefined}
64
+ style={isMedia ? `--hero-overlay: ${overlay}${focus ? `; --hero-focus: ${focus}` : ''}` : undefined}
62
65
  >
63
66
  {isMedia && (
64
67
  <div class="b-hero__bg" aria-hidden="true">
68
+ {heroImg && <img class="b-hero__img" src={heroImg} alt="" loading="eager" fetchpriority="high" decoding="async" />}
65
69
  {embed && <div class="b-hero__embed" data-embed-url={embed.embedUrl} data-provider={embed.provider}></div>}
66
70
  {media?.video && !embed ? (
67
71
  <video autoplay muted loop playsinline poster={media.poster} preload="metadata">
@@ -69,7 +73,7 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
69
73
  </video>
70
74
  ) : null}
71
75
  {slides.map((src, i) => (
72
- <span class:list={['b-hero__slide', { 'is-on': i === 0 }]} style={`background-image: url('${src}')`}></span>
76
+ <img class:list={['b-hero__slide', { 'is-on': i === 0 }]} src={src} alt="" loading={i === 0 ? 'eager' : 'lazy'} fetchpriority={i === 0 ? 'high' : undefined} decoding="async" />
73
77
  ))}
74
78
  <span class="b-hero__scrim"></span>
75
79
  </div>
@@ -346,11 +350,20 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
346
350
  position: absolute;
347
351
  inset: 0;
348
352
  z-index: -1;
349
- background-image: var(--hero-image, none);
350
- background-size: cover;
351
- background-position: var(--hero-focus, center);
352
353
  background-color: var(--bg-section);
353
354
  }
355
+ /* The picture (and each rotating slide) covers the band, framed by `focus`. */
356
+ .b-hero__img,
357
+ .b-hero__slide {
358
+ position: absolute;
359
+ inset: 0;
360
+ width: 100%;
361
+ height: 100%;
362
+ object-fit: cover;
363
+ object-position: var(--hero-focus, center);
364
+ display: block;
365
+ max-width: none;
366
+ }
354
367
  /* Parallax: give the background room to drift without uncovering an edge. */
355
368
  .b-hero.is-parallax .b-hero__bg {
356
369
  inset: 0 0 -35% 0;
@@ -390,10 +403,6 @@ const bgImage = isMedia && stillSrc ? `--hero-image: url('${stillSrc}')` : '';
390
403
  display: none;
391
404
  }
392
405
  .b-hero__slide {
393
- position: absolute;
394
- inset: 0;
395
- background-size: cover;
396
- background-position: var(--hero-focus, center);
397
406
  opacity: 0;
398
407
  transition: opacity 1.6s ease-in-out;
399
408
  }
@@ -14,7 +14,7 @@ import Heading from '../blocks/Heading.astro';
14
14
 
15
15
  import { inlineMarkdown, inlineMarkdownNoLinks } from '../../lib/inline-markdown';
16
16
  interface Card {
17
- image?: { src: string; alt?: string };
17
+ image?: { src: string; alt?: string; focus?: string };
18
18
  icon?: string;
19
19
  title: string;
20
20
  meta?: string;
@@ -26,9 +26,10 @@ interface Props {
26
26
  title?: string;
27
27
  intro?: string;
28
28
  columns?: 2 | 3;
29
+ ratio?: '16/9' | '4/3' | '3/2' | '1/1' | '3/4';
29
30
  cards?: Card[];
30
31
  }
31
- const { title, intro, columns = 3, cards = [] } = Astro.props;
32
+ const { title, intro, columns = 3, ratio = '16/9', cards = [] } = Astro.props;
32
33
  // Never reserve more columns than there are cards; `data-single` centres a lone one.
33
34
  const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3;
34
35
  ---
@@ -49,8 +50,8 @@ const cols = Math.min(columns, Math.max(cards.length, 1)) as 1 | 2 | 3;
49
50
  return (
50
51
  <article class:list={['b-media-cards__card', 'ferst-tile', 'ferst-tile--sm', card.href && 'is-link']}>
51
52
  {card.image && (
52
- <div class="b-media-cards__media">
53
- <img src={card.image.src} alt={card.image.alt ?? ''} loading="lazy" />
53
+ <div class="b-media-cards__media" data-ratio={ratio} style={`aspect-ratio: ${ratio.replace('/', ' / ')}`}>
54
+ <img src={card.image.src} alt={card.image.alt ?? ''} loading="lazy" style={card.image.focus ? `object-position: ${card.image.focus}` : undefined} />
54
55
  </div>
55
56
  )}
56
57
  <div class="b-media-cards__body">
@@ -1,88 +1,88 @@
1
- ---
2
- /**
3
- * MediaText — an image beside its text, on the start or end side; stacks (text, then image)
4
- * on a phone. The text takes prose formatting (paragraphs, lists, links); the image keeps
5
- * its own shape unless `ratio` sets one (cropped to fill). An optional button follows the
6
- * text.
7
- */
8
- import Heading from '../blocks/Heading.astro';
9
- import Button from '../Button.astro';
10
- import ProseParts from '../ProseParts.astro';
11
-
12
- interface Props {
13
- title?: string;
14
- text: string;
15
- image: { src: string; alt?: string; caption?: string };
16
- imageSide?: 'start' | 'end';
17
- ratio?: 'auto' | '1/1' | '4/3' | '3/2' | '16/9';
18
- cta?: { label: string; href: string };
19
- }
20
- const { title, text, image, imageSide = 'end', ratio = 'auto', cta } = Astro.props;
21
- ---
22
-
23
- <section class="b-mediatext" data-side={imageSide}>
24
- <div class="b-mediatext__text">
25
- {title && <Heading text={title} level={2} />}
26
- <ProseParts text={text} />
27
- {cta && <div class="b-mediatext__cta"><Button href={cta.href} variant="primary">{cta.label}</Button></div>}
28
- </div>
29
- <figure class="b-mediatext__media">
30
- <img src={image.src} alt={image.alt ?? ''} loading="lazy" decoding="async" style={ratio !== 'auto' ? `aspect-ratio: ${ratio}; object-fit: cover` : undefined} />
31
- {image.caption && <figcaption>{image.caption}</figcaption>}
32
- </figure>
33
- </section>
34
-
35
- <style>
36
- .b-mediatext {
37
- display: grid;
38
- gap: clamp(1.5rem, 4vw, 3rem);
39
- align-items: center;
40
- width: 100%;
41
- max-width: 72rem;
42
- }
43
- @media (min-width: 48em) {
44
- .b-mediatext {
45
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
46
- }
47
- .b-mediatext[data-side='start'] .b-mediatext__media {
48
- order: -1;
49
- }
50
- }
51
- .b-mediatext__text {
52
- display: flex;
53
- flex-direction: column;
54
- gap: 0.9rem;
55
- }
56
- .b-mediatext__text :global(p),
57
- .b-mediatext__text :global(ul),
58
- .b-mediatext__text :global(ol) {
59
- margin: 0;
60
- line-height: 1.7;
61
- }
62
- .b-mediatext__text :global(ul),
63
- .b-mediatext__text :global(ol) {
64
- padding-inline-start: 1.4em;
65
- }
66
- .b-mediatext__text :global(a:not(.btn)) {
67
- color: var(--accent);
68
- text-decoration: underline;
69
- text-underline-offset: 0.15em;
70
- }
71
- .b-mediatext__cta {
72
- margin-top: 0.4rem;
73
- }
74
- .b-mediatext__media {
75
- margin: 0;
76
- }
77
- .b-mediatext__media img {
78
- display: block;
79
- width: 100%;
80
- height: auto;
81
- border-radius: var(--radius);
82
- }
83
- .b-mediatext__media figcaption {
84
- margin-top: 0.5rem;
85
- font-size: 0.875rem;
86
- color: var(--muted);
87
- }
88
- </style>
1
+ ---
2
+ /**
3
+ * MediaText — an image beside its text, on the start or end side; stacks (text, then image)
4
+ * on a phone. The text takes prose formatting (paragraphs, lists, links); the image keeps
5
+ * its own shape unless `ratio` sets one (cropped to fill). An optional button follows the
6
+ * text.
7
+ */
8
+ import Heading from '../blocks/Heading.astro';
9
+ import Button from '../Button.astro';
10
+ import ProseParts from '../ProseParts.astro';
11
+
12
+ interface Props {
13
+ title?: string;
14
+ text: string;
15
+ image: { src: string; alt?: string; caption?: string; focus?: string };
16
+ imageSide?: 'start' | 'end';
17
+ ratio?: 'auto' | '1/1' | '4/3' | '3/2' | '16/9';
18
+ cta?: { label: string; href: string };
19
+ }
20
+ const { title, text, image, imageSide = 'end', ratio = 'auto', cta } = Astro.props;
21
+ ---
22
+
23
+ <section class="b-mediatext" data-side={imageSide}>
24
+ <div class="b-mediatext__text">
25
+ {title && <Heading text={title} level={2} />}
26
+ <ProseParts text={text} />
27
+ {cta && <div class="b-mediatext__cta"><Button href={cta.href} variant="primary">{cta.label}</Button></div>}
28
+ </div>
29
+ <figure class="b-mediatext__media">
30
+ <img src={image.src} alt={image.alt ?? ''} loading="lazy" decoding="async" style={ratio !== 'auto' ? `aspect-ratio: ${ratio}; object-fit: cover${image.focus ? `; object-position: ${image.focus}` : ''}` : undefined} />
31
+ {image.caption && <figcaption>{image.caption}</figcaption>}
32
+ </figure>
33
+ </section>
34
+
35
+ <style>
36
+ .b-mediatext {
37
+ display: grid;
38
+ gap: clamp(1.5rem, 4vw, 3rem);
39
+ align-items: center;
40
+ width: 100%;
41
+ max-width: 72rem;
42
+ }
43
+ @media (min-width: 48em) {
44
+ .b-mediatext {
45
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
46
+ }
47
+ .b-mediatext[data-side='start'] .b-mediatext__media {
48
+ order: -1;
49
+ }
50
+ }
51
+ .b-mediatext__text {
52
+ display: flex;
53
+ flex-direction: column;
54
+ gap: 0.9rem;
55
+ }
56
+ .b-mediatext__text :global(p),
57
+ .b-mediatext__text :global(ul),
58
+ .b-mediatext__text :global(ol) {
59
+ margin: 0;
60
+ line-height: 1.7;
61
+ }
62
+ .b-mediatext__text :global(ul),
63
+ .b-mediatext__text :global(ol) {
64
+ padding-inline-start: 1.4em;
65
+ }
66
+ .b-mediatext__text :global(a:not(.btn)) {
67
+ color: var(--accent);
68
+ text-decoration: underline;
69
+ text-underline-offset: 0.15em;
70
+ }
71
+ .b-mediatext__cta {
72
+ margin-top: 0.4rem;
73
+ }
74
+ .b-mediatext__media {
75
+ margin: 0;
76
+ }
77
+ .b-mediatext__media img {
78
+ display: block;
79
+ width: 100%;
80
+ height: auto;
81
+ border-radius: var(--radius);
82
+ }
83
+ .b-mediatext__media figcaption {
84
+ margin-top: 0.5rem;
85
+ font-size: 0.875rem;
86
+ color: var(--muted);
87
+ }
88
+ </style>
@@ -25,7 +25,7 @@ interface Tile {
25
25
  text?: string;
26
26
  href?: string;
27
27
  linkLabel?: string;
28
- image?: { src: string; alt?: string };
28
+ image?: { src: string; alt?: string; focus?: string };
29
29
  }
30
30
  interface Props {
31
31
  title?: string;
@@ -59,7 +59,7 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
59
59
  const plain = variant === 'image' && !!tile.image && !tile.title && !tile.text && !tile.eyebrow;
60
60
  const bg =
61
61
  variant === 'image' && tile.image && !plain
62
- ? `--tile-image: url('${tile.image.src}')`
62
+ ? `--tile-image: url('${tile.image.src}')${tile.image.focus ? `; --tile-focus: ${tile.image.focus}` : ''}`
63
63
  : undefined;
64
64
  return (
65
65
  <Tag
@@ -166,7 +166,7 @@ const cols = Math.min(columns, Math.max(tiles.length, 1)) as 1 | 2 | 3 | 4;
166
166
  z-index: -1;
167
167
  background-image: linear-gradient(to top, rgba(10, 12, 20, 0.85), rgba(10, 12, 20, 0.15)), var(--tile-image);
168
168
  background-size: cover;
169
- background-position: center;
169
+ background-position: center, var(--tile-focus, center);
170
170
  }
171
171
  /* A picture tile shows the whole image, whatever its shape: no scrim, no crop. */
172
172
  .b-tiles__grid[data-variant='image'] .b-tile[data-plain] {
package/content/blocks.ts CHANGED
@@ -45,6 +45,15 @@ export const proseBlock = z.object({
45
45
  variant: z.enum(['body', 'lead']).default('body'),
46
46
  });
47
47
 
48
+ /** Which part of an image stays in view when it's cropped to a shape: one or two positions,
49
+ * keywords or percentages (`center 25%` keeps a face in a portrait, `top`, `left 20%`).
50
+ * Positions only, so no other CSS can get in. Default `center`. */
51
+ export const focusPosition = z
52
+ .string()
53
+ .regex(/^(?:left|center|right|top|bottom|\d{1,3}%)(?: (?:left|center|right|top|bottom|\d{1,3}%))?$/, 'focus is one or two of left / center / right / top / bottom / a percentage, e.g. "center 25%"');
54
+ /** An image a block may crop to a shape, with the part to keep in view. */
55
+ const croppedImage = z.object({ src: z.string(), alt: z.string().default(''), focus: focusPosition.optional() });
56
+
48
57
  export const imageBlock = z.object({
49
58
  type: z.literal('image'),
50
59
  enabled: z.boolean().default(true),
@@ -57,6 +66,8 @@ export const imageBlock = z.object({
57
66
  shadow: z.boolean().default(false),
58
67
  border: z.boolean().default(false),
59
68
  ratio: z.enum(['auto', 'square', '4/3', '3/2', '16/9', '21/9']).default('auto'),
69
+ /** Which part stays in view when `ratio` crops it. */
70
+ focus: focusPosition.optional(),
60
71
  /** Opt in to a soft clip-path reveal as the image scrolls into view
61
72
  * (progressive: no motion under reduced-motion or unsupported browsers). */
62
73
  reveal: z.boolean().default(false),
@@ -155,10 +166,7 @@ export const heroBlock = z.object({
155
166
  fullBleed: z.boolean().default(false),
156
167
  /** Which part of the background image shows when it's cropped: one or two positions,
157
168
  * keywords or percentages (e.g. `center 30%`, `top`, `left 20%`). Default `center`. */
158
- focus: z
159
- .string()
160
- .regex(/^(?:left|center|right|top|bottom|\d{1,3}%)(?: (?:left|center|right|top|bottom|\d{1,3}%))?$/, 'focus is one or two of left / center / right / top / bottom / a percentage, e.g. "center 30%"')
161
- .optional(),
169
+ focus: focusPosition.optional(),
162
170
  /** A `media` hero's background drifts more slowly than the page as it scrolls (any
163
171
  * background: image, slides or video). Off on touch devices and under reduced motion. */
164
172
  parallax: z.boolean().default(false),
@@ -302,7 +310,7 @@ export const tilesBlock = z.object({
302
310
  href: z.string().optional(),
303
311
  /** The link cue on a linking tile ("Learn more" by default). */
304
312
  linkLabel: z.string().optional(),
305
- image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
313
+ image: croppedImage.optional(),
306
314
  })
307
315
  .refine((t) => !!t.title || !!t.image, { message: 'A tile needs a title or an image', path: ['title'] })
308
316
  )
@@ -425,7 +433,7 @@ export const bentoBlock = z.object({
425
433
  title: z.string(),
426
434
  text: z.string().optional(),
427
435
  href: z.string().optional(),
428
- image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
436
+ image: croppedImage.optional(),
429
437
  })
430
438
  )
431
439
  .default([]),
@@ -520,10 +528,12 @@ export const mediaCardsBlock = z.object({
520
528
  title: z.string().optional(),
521
529
  intro: z.string().optional(),
522
530
  columns: z.union([z.literal(2), z.literal(3)]).default(3),
531
+ /** The image shape: landscape 16:9 (default), 4:3, 3:2, square, or portrait 3:4 (people). */
532
+ ratio: z.enum(['16/9', '4/3', '3/2', '1/1', '3/4']).default('16/9'),
523
533
  cards: z
524
534
  .array(
525
535
  z.object({
526
- image: z.object({ src: z.string(), alt: z.string().default('') }).optional(),
536
+ image: croppedImage.optional(),
527
537
  icon: iconName.optional(),
528
538
  title: z.string(),
529
539
  /** A short meta line shown as a pill (e.g. "Sundays 10:45 · Main hall"). */
@@ -702,7 +712,7 @@ export const mediaTextBlock = z.object({
702
712
  enabled: z.boolean().default(true),
703
713
  title: z.string().optional(),
704
714
  text: z.string(),
705
- image: z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional() }),
715
+ image: z.object({ src: z.string(), alt: z.string().default(''), caption: z.string().optional(), focus: focusPosition.optional() }),
706
716
  imageSide: z.enum(['start', 'end']).default('end'),
707
717
  ratio: z.enum(['auto', '1/1', '4/3', '3/2', '16/9']).default('auto'),
708
718
  cta: z.object({ label: z.string(), href: z.string() }).optional(),
@@ -160,7 +160,9 @@ const metaDescription = description ?? identity.description;
160
160
  return;
161
161
  }
162
162
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return; // skip mailto/tel/etc.
163
- if (url.host === host) return; // same-site
163
+ // Same-site pages stay in the tab; a same-site document (a PDF leaflet, a form)
164
+ // opens in a new one, like an external link.
165
+ if (url.host === host && !/\.(?:pdf|docx?|xlsx?|pptx?)$/i.test(url.pathname)) return;
164
166
  if (!a.target) a.target = '_blank';
165
167
  const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
166
168
  rel.add('noopener');
@@ -1,74 +1,79 @@
1
- /**
2
- * inlineMarkdown — render a *tiny*, safe subset of markdown from trusted editor/CMS
3
- * text: links `[label](href)`, bold `**text**`, italic `*text*` / `_text_`, and single
4
- * newlines → `<br>`.
5
- * Everything is HTML-escaped first, and only safe href schemes (http(s), root-
6
- * relative, mailto, tel, in-page #anchors) become links — an unsafe href renders
7
- * as literal text. The result is safe to drop into `set:html` for trusted content.
8
- *
9
- * This exists so recipes that show a line or two of editor copy (FAQ answers,
10
- * notices) can carry a link without a full markdown pipeline — and without the
11
- * `[text](url)` showing through as literal characters.
12
- */
13
- const ESCAPE: Record<string, string> = {
14
- '&': '&amp;',
15
- '<': '&lt;',
16
- '>': '&gt;',
17
- '"': '&quot;',
18
- "'": '&#39;',
19
- };
20
- function escapeHtml(s: string): string {
21
- return s.replace(/[&<>"']/g, (c) => ESCAPE[c]);
22
- }
23
-
24
- /** Only these href schemes are allowed to become real links. */
25
- const SAFE_HREF = /^(?:https?:\/\/|\/|mailto:|tel:|#)/i;
26
-
27
- const LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g;
28
-
29
- /** Bold, italic + single-newline breaks, applied to already-escaped text. Italic
30
- * `*x*` / `_x_` must hug its text, and `_` only counts at word edges, so
31
- * snake_case and a lone asterisk stay literal. */
32
- function inlineFormat(escaped: string): string {
33
- return escaped
34
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
35
- .replace(/(^|[^*\w])\*(?![\s*])([^*]*?[^\s*])\*(?!\*)/g, '$1<em>$2</em>')
36
- .replace(/(^|[^\w])_(?![\s_])([^_]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>')
37
- .replace(/\n/g, '<br>');
38
- }
39
-
40
- /** Escaped text with single newlines → `<br>`, and no links: for text that already sits
41
- * inside a link (a nested <a> is invalid). */
42
- export function textWithBreaks(text: string | undefined | null): string {
43
- return text ? escapeHtml(text).replace(/\n/g, '<br>') : '';
44
- }
45
-
46
- /** Inline formatting (bold, italic, line breaks) WITHOUT links: `[label](href)` becomes
47
- * its label. For text inside something that is already a link (a linked tile or card). */
48
- export function inlineMarkdownNoLinks(text: string | undefined | null): string {
49
- return text ? inlineFormat(escapeHtml(text.replace(LINK_RE, '$1'))) : '';
50
- }
51
-
52
- export function inlineMarkdown(text: string | undefined | null): string {
53
- if (!text) return '';
54
- let out = '';
55
- let last = 0;
56
- let m: RegExpExecArray | null;
57
- LINK_RE.lastIndex = 0;
58
- while ((m = LINK_RE.exec(text))) {
59
- out += inlineFormat(escapeHtml(text.slice(last, m.index)));
60
- const label = inlineFormat(escapeHtml(m[1]));
61
- const href = m[2];
62
- if (SAFE_HREF.test(href)) {
63
- out += `<a href="${escapeHtml(href)}">${label}</a>`;
64
- } else {
65
- out += inlineFormat(escapeHtml(m[0])); // unsafe scheme → keep literal
66
- }
67
- last = m.index + m[0].length;
68
- }
69
- out += inlineFormat(escapeHtml(text.slice(last)));
70
- return out;
71
- }
1
+ /**
2
+ * inlineMarkdown — render a *tiny*, safe subset of markdown from trusted editor/CMS
3
+ * text: links `[label](href)`, bold `**text**`, italic `*text*` / `_text_`, and single
4
+ * newlines → `<br>`.
5
+ * Everything is HTML-escaped first, and only safe href schemes (http(s), root-
6
+ * relative, mailto, tel, in-page #anchors) become links — an unsafe href renders
7
+ * as literal text. The result is safe to drop into `set:html` for trusted content.
8
+ *
9
+ * This exists so recipes that show a line or two of editor copy (FAQ answers,
10
+ * notices) can carry a link without a full markdown pipeline — and without the
11
+ * `[text](url)` showing through as literal characters.
12
+ */
13
+ const ESCAPE: Record<string, string> = {
14
+ '&': '&amp;',
15
+ '<': '&lt;',
16
+ '>': '&gt;',
17
+ '"': '&quot;',
18
+ "'": '&#39;',
19
+ };
20
+ function escapeHtml(s: string): string {
21
+ return s.replace(/[&<>"']/g, (c) => ESCAPE[c]);
22
+ }
23
+
24
+ /** Only these href schemes are allowed to become real links. */
25
+ const SAFE_HREF = /^(?:https?:\/\/|\/|mailto:|tel:|#)/i;
26
+
27
+ const LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g;
28
+
29
+ /** A link to a document (PDF, Word, Excel, PowerPoint) opens in a new tab, so a visitor
30
+ * doesn't lose the site — as external links already do. */
31
+ export const DOCUMENT_HREF = /\.(?:pdf|docx?|xlsx?|pptx?)(?:[?#]|$)/i;
32
+
33
+ /** Bold, italic + single-newline breaks, applied to already-escaped text. Italic
34
+ * `*x*` / `_x_` must hug its text, and `_` only counts at word edges, so
35
+ * snake_case and a lone asterisk stay literal. */
36
+ function inlineFormat(escaped: string): string {
37
+ return escaped
38
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
39
+ .replace(/(^|[^*\w])\*(?![\s*])([^*]*?[^\s*])\*(?!\*)/g, '$1<em>$2</em>')
40
+ .replace(/(^|[^\w])_(?![\s_])([^_]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>')
41
+ .replace(/\n/g, '<br>');
42
+ }
43
+
44
+ /** Escaped text with single newlines → `<br>`, and no links: for text that already sits
45
+ * inside a link (a nested <a> is invalid). */
46
+ export function textWithBreaks(text: string | undefined | null): string {
47
+ return text ? escapeHtml(text).replace(/\n/g, '<br>') : '';
48
+ }
49
+
50
+ /** Inline formatting (bold, italic, line breaks) WITHOUT links: `[label](href)` becomes
51
+ * its label. For text inside something that is already a link (a linked tile or card). */
52
+ export function inlineMarkdownNoLinks(text: string | undefined | null): string {
53
+ return text ? inlineFormat(escapeHtml(text.replace(LINK_RE, '$1'))) : '';
54
+ }
55
+
56
+ export function inlineMarkdown(text: string | undefined | null): string {
57
+ if (!text) return '';
58
+ let out = '';
59
+ let last = 0;
60
+ let m: RegExpExecArray | null;
61
+ LINK_RE.lastIndex = 0;
62
+ while ((m = LINK_RE.exec(text))) {
63
+ out += inlineFormat(escapeHtml(text.slice(last, m.index)));
64
+ const label = inlineFormat(escapeHtml(m[1]));
65
+ const href = m[2];
66
+ if (SAFE_HREF.test(href)) {
67
+ const newTab = DOCUMENT_HREF.test(href) ? ' target="_blank" rel="noopener"' : '';
68
+ out += `<a href="${escapeHtml(href)}"${newTab}>${label}</a>`;
69
+ } else {
70
+ out += inlineFormat(escapeHtml(m[0])); // unsafe scheme → keep literal
71
+ }
72
+ last = m.index + m[0].length;
73
+ }
74
+ out += inlineFormat(escapeHtml(text.slice(last)));
75
+ return out;
76
+ }
72
77
 
73
78
  /** The words of formatted copy with the formatting gone (a link becomes its label): for
74
79
  * places that must be plain text, like a page's meta description. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
4
4
  "type": "module",
5
5
  "description": "Ferst Core \u2014 the shared, brand-agnostic client-site system: Astro components, layouts, content schemas, the layered config resolver, and a neutral styling architecture that every client overrides.",
6
6
  "license": "BUSL-1.1",