ferst-core 0.6.2 → 0.7.2

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.
@@ -1,52 +1,65 @@
1
- /**
2
- * inlineMarkdown — render a *tiny*, safe subset of markdown from trusted editor/CMS
3
- * text: links `[label](href)`, bold `**text**`, and single newlines → `<br>`.
4
- * Everything is HTML-escaped first, and only safe href schemes (http(s), root-
5
- * relative, mailto, tel, in-page #anchors) become links — an unsafe href renders
6
- * as literal text. The result is safe to drop into `set:html` for trusted content.
7
- *
8
- * This exists so recipes that show a line or two of editor copy (FAQ answers,
9
- * notices) can carry a link without a full markdown pipeline — and without the
10
- * `[text](url)` showing through as literal characters.
11
- */
12
- const ESCAPE: Record<string, string> = {
13
- '&': '&amp;',
14
- '<': '&lt;',
15
- '>': '&gt;',
16
- '"': '&quot;',
17
- "'": '&#39;',
18
- };
19
- function escapeHtml(s: string): string {
20
- return s.replace(/[&<>"']/g, (c) => ESCAPE[c]);
21
- }
22
-
23
- /** Only these href schemes are allowed to become real links. */
24
- const SAFE_HREF = /^(?:https?:\/\/|\/|mailto:|tel:|#)/i;
25
-
26
- const LINK_RE = /\[([^\]]+)\]\(([^)\s]+)\)/g;
27
-
28
- /** Bold + single-newline breaks, applied to already-escaped text. */
29
- function inlineFormat(escaped: string): string {
30
- return escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>');
31
- }
32
-
33
- export function inlineMarkdown(text: string | undefined | null): string {
34
- if (!text) return '';
35
- let out = '';
36
- let last = 0;
37
- let m: RegExpExecArray | null;
38
- LINK_RE.lastIndex = 0;
39
- while ((m = LINK_RE.exec(text))) {
40
- out += inlineFormat(escapeHtml(text.slice(last, m.index)));
41
- const label = inlineFormat(escapeHtml(m[1]));
42
- const href = m[2];
43
- if (SAFE_HREF.test(href)) {
44
- out += `<a href="${escapeHtml(href)}">${label}</a>`;
45
- } else {
46
- out += inlineFormat(escapeHtml(m[0])); // unsafe scheme → keep literal
47
- }
48
- last = m.index + m[0].length;
49
- }
50
- out += inlineFormat(escapeHtml(text.slice(last)));
51
- return out;
52
- }
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
+ export function inlineMarkdown(text: string | undefined | null): string {
47
+ if (!text) return '';
48
+ let out = '';
49
+ let last = 0;
50
+ let m: RegExpExecArray | null;
51
+ LINK_RE.lastIndex = 0;
52
+ while ((m = LINK_RE.exec(text))) {
53
+ out += inlineFormat(escapeHtml(text.slice(last, m.index)));
54
+ const label = inlineFormat(escapeHtml(m[1]));
55
+ const href = m[2];
56
+ if (SAFE_HREF.test(href)) {
57
+ out += `<a href="${escapeHtml(href)}">${label}</a>`;
58
+ } else {
59
+ out += inlineFormat(escapeHtml(m[0])); // unsafe scheme → keep literal
60
+ }
61
+ last = m.index + m[0].length;
62
+ }
63
+ out += inlineFormat(escapeHtml(text.slice(last)));
64
+ return out;
65
+ }
package/lib/prose.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Prose → paragraphs and simple lists, for the `prose` block. Blank lines separate
3
+ * paragraphs; a paragraph whose every line starts `- ` / `* ` becomes a bulleted list,
4
+ * and one whose every line starts `1. ` / `1) ` a numbered list. Inline formatting
5
+ * (links incl. tel:/mailto:, bold, italic) comes from inlineMarkdown, which escapes
6
+ * everything first — so the output is safe for `set:html`.
7
+ */
8
+ import { inlineMarkdown } from './inline-markdown';
9
+
10
+ export type ProsePart =
11
+ | { kind: 'p'; html: string }
12
+ | { kind: 'ul' | 'ol'; items: string[] };
13
+
14
+ const BULLET = /^\s*[-*•]\s+/;
15
+ const NUMBER = /^\s*\d+[.)]\s+/;
16
+
17
+ export function proseParts(text: string | undefined | null): ProsePart[] {
18
+ if (!text) return [];
19
+ return text
20
+ .split(/\n\s*\n/)
21
+ .map((p) => p.trim())
22
+ .filter(Boolean)
23
+ .map((p): ProsePart => {
24
+ const lines = p.split('\n').map((l) => l.trim()).filter(Boolean);
25
+ if (lines.every((l) => BULLET.test(l))) return { kind: 'ul', items: lines.map((l) => inlineMarkdown(l.replace(BULLET, ''))) };
26
+ if (lines.length > 1 && lines.every((l) => NUMBER.test(l))) return { kind: 'ol', items: lines.map((l) => inlineMarkdown(l.replace(NUMBER, ''))) };
27
+ return { kind: 'p', html: inlineMarkdown(p) };
28
+ });
29
+ }
package/lib/video.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Video links → a privacy-friendly embed. Accepts what an editor would paste — a
3
+ * YouTube watch / share / shorts / live link, a channel's live page, or a Vimeo link —
4
+ * and returns the embed URL (youtube-nocookie / Vimeo `dnt=1`) plus a link to watch it
5
+ * on the provider. Anything else → null (the block shows a plain link instead).
6
+ */
7
+ export interface VideoEmbed {
8
+ provider: 'YouTube' | 'Vimeo';
9
+ embedUrl: string;
10
+ watchUrl: string;
11
+ }
12
+
13
+ const YT_ID = /^[A-Za-z0-9_-]{11}$/;
14
+ const YT_CHANNEL = /^UC[A-Za-z0-9_-]{22}$/;
15
+
16
+ export function videoEmbed(input: string | undefined | null): VideoEmbed | null {
17
+ if (!input) return null;
18
+ let u: URL;
19
+ try {
20
+ u = new URL(input.trim());
21
+ } catch {
22
+ return null;
23
+ }
24
+ if (u.protocol !== 'https:') return null;
25
+ const host = u.hostname.replace(/^(www\.|m\.)/, '');
26
+ const parts = u.pathname.split('/').filter(Boolean);
27
+ const watchUrl = u.toString();
28
+
29
+ if (host === 'youtu.be' && YT_ID.test(parts[0] ?? '')) return yt(parts[0], watchUrl);
30
+ if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
31
+ const v = u.searchParams.get('v');
32
+ if (parts[0] === 'watch' && v && YT_ID.test(v)) return yt(v, watchUrl);
33
+ if (['embed', 'shorts', 'live', 'v'].includes(parts[0]) && YT_ID.test(parts[1] ?? '')) return yt(parts[1], watchUrl);
34
+ // A channel's live stream: /channel/<UC…>/live, or an embed of live_stream?channel=…
35
+ const channel = parts[0] === 'channel' && parts[2] === 'live' ? parts[1] : u.searchParams.get('channel');
36
+ if (channel && YT_CHANNEL.test(channel)) {
37
+ return {
38
+ provider: 'YouTube',
39
+ embedUrl: `https://www.youtube.com/embed/live_stream?channel=${channel}`,
40
+ watchUrl: `https://www.youtube.com/channel/${channel}/live`,
41
+ };
42
+ }
43
+ return null;
44
+ }
45
+ if (host === 'vimeo.com' && /^\d+$/.test(parts[0] ?? '')) {
46
+ return { provider: 'Vimeo', embedUrl: `https://player.vimeo.com/video/${parts[0]}?dnt=1`, watchUrl };
47
+ }
48
+ if (host === 'player.vimeo.com' && parts[0] === 'video' && /^\d+$/.test(parts[1] ?? '')) {
49
+ return { provider: 'Vimeo', embedUrl: `https://player.vimeo.com/video/${parts[1]}?dnt=1`, watchUrl: `https://vimeo.com/${parts[1]}` };
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * The same links as a header BACKGROUND: muted, autoplaying, looping and chromeless.
56
+ * YouTube loops a single video only with `playlist=<id>`; `enablejsapi=1` lets the
57
+ * hero's pause button send play/pause commands (postMessage, no API script). Vimeo
58
+ * has a dedicated `background=1` mode. Live streams and hosted files → null (a file
59
+ * plays in a native <video>).
60
+ */
61
+ export function backgroundEmbed(input: string | undefined | null): VideoEmbed | null {
62
+ const v = videoEmbed(input);
63
+ if (!v || v.embedUrl.includes('live_stream')) return null;
64
+ if (v.provider === 'Vimeo') return { ...v, embedUrl: v.embedUrl.replace('?dnt=1', '?background=1&dnt=1') };
65
+ const id = new URL(v.embedUrl).pathname.split('/').pop()!;
66
+ const q = new URLSearchParams({
67
+ autoplay: '1', mute: '1', loop: '1', playlist: id, controls: '0', playsinline: '1',
68
+ rel: '0', disablekb: '1', iv_load_policy: '3', enablejsapi: '1',
69
+ });
70
+ return { ...v, embedUrl: `https://www.youtube-nocookie.com/embed/${id}?${q}` };
71
+ }
72
+
73
+ function yt(id: string, watchUrl: string): VideoEmbed {
74
+ return { provider: 'YouTube', embedUrl: `https://www.youtube-nocookie.com/embed/${id}?rel=0`, watchUrl };
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferst-core",
3
- "version": "0.6.2",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "description": "Ferst Core — 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",