@pmeweb/content 2.2.0 → 2.3.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
@@ -12,6 +12,9 @@ collections in `src/content.schema.json`; this package is what reads that declar
12
12
  | `@pmeweb/content/collection` | `titleFieldError`, `thumbnailError`, `richErrors`, `collectionErrors`, `schemaErrors`, `richBodyField`, `hasThumbnails` | portal, `zodFrom` |
13
13
  | `@pmeweb/content/published` | `getPublished`, the one filter every site's pages go through; `getPublishedItems` | sites |
14
14
  | `@pmeweb/content/prose.css` | the structure of a row of images in a rich body | sites' `Prose.astro` |
15
+ | `@pmeweb/content/embeds` | the players' provider table and detector (browser-safe) | sites, portal editor |
16
+ | `@pmeweb/content/embeds/astro` | `rehypeEmbeds`, `embeds()` integration (Node) | sites' `astro.config.mjs` |
17
+ | `@pmeweb/content/EmbedPrivacy.astro` | the privacy page's paragraph per player provider | sites' privacy pages |
15
18
 
16
19
  ## Dated kinds
17
20
 
@@ -54,6 +57,55 @@ reads both once, `const APERCU = portalPreview(process.env)`, and filters with
54
57
  drafts in the preview, removed ones only in a preview that asked for them. Only the exact
55
58
  string `'1'` counts, and the past flag means nothing without the preview flag.
56
59
 
60
+ ## Embeds (2.3.0, 2.3.1)
61
+
62
+ A YouTube or SoundCloud link alone on its paragraph (`[words](url)`, no title attribute, words
63
+ that are not the URL itself, not in a list or a quote) becomes a click-to-load player: the
64
+ original link around a poster (YouTube: the video's thumbnail, served by the site; SoundCloud: a
65
+ waveform drawn in the page), swapped for the provider's iframe on click.
66
+ Nothing loads from the provider before that click.
67
+
68
+ Two subpaths:
69
+
70
+ - `@pmeweb/content/embeds`: pure and browser-safe (the portal's editor imports it). The provider
71
+ table `PROVIDERS`, `embedFromUrl`, `embedFromLink`, `embedProviders(bodies)`, `embedFrameHosts`,
72
+ `supportedLabels()`.
73
+ - `@pmeweb/content/embeds/astro`: Node only. `rehypeEmbeds()` (a satteri hast plugin),
74
+ `embeds()` (the Astro integration), `scanPages` / `selfCheck`.
75
+
76
+ A site with a `rich` body wires FOUR pieces:
77
+
78
+ 1. `rehypeEmbeds()` through `satteri({ hastPlugins: [...] })` in
79
+ `astro.config.mjs`, AND through every `createSatteriMarkdownProcessor` the site calls itself.
80
+ 2. `embeds()` in `integrations`. At build it writes `dist/_embed/player.js`, `embed.css` and the
81
+ YouTube posters (the thumbnail fetched at build and re-encoded to WebP, or a drawn dark poster
82
+ when the fetch fails), and FAILS the build when a player line was not converted
83
+ or the privacy page does not mention a provider in use.
84
+ 3. `<EmbedPrivacy lang providers={embedProviders(bodies)} />` (`@pmeweb/content/EmbedPrivacy.astro`)
85
+ on every privacy page, `bodies` being the published posts' raw Markdown.
86
+ 4. `embedFrameHosts` in the worker CSP's `frame-src`.
87
+
88
+ `astro dev` serves the script and the stylesheet but not the posters (they are build output):
89
+ the facade shows its dark background behind the title.
90
+
91
+ The look is the site's, through `--pmw-embed-radius`, `--pmw-embed-focus`, and for audio
92
+ `--pmw-embed-audio-bg`, `-line`, `-wave`, `-fg` and `-accent` (light by default, like SoundCloud's own
93
+ player). `embed.css` names element and class in every selector, so a site's `.prose img` or
94
+ `.prose a` (Astro-scoped, two classes and an element) does not reach inside the facade.
95
+
96
+ ### 2.3.1
97
+
98
+ - The poster no longer takes the site's article image styles (margin, border, `height: auto`):
99
+ 2.3.0 showed a band above it and cut its bottom off.
100
+ - SoundCloud's facade is drawn in the page (inline SVG), light and themeable, laid out like its
101
+ player; no image file for audio.
102
+ - No line under the player any more (« Lecture par … Politique de confidentialité »). The privacy
103
+ page still lists each provider. `rehypeEmbeds` and `embeds` take no options; 2.3.0's
104
+ `{ lang, privacyHref }` are accepted and ignored.
105
+
106
+ Adding a provider = one entry in `src/embeds/providers.ts` + its privacy text, approved by the
107
+ operator + a minor release. `prepublishOnly` refuses a privacy text still carrying `[À VALIDER`.
108
+
57
109
  ## 2.2.0
58
110
 
59
111
  Additive: every 2.1.0 declaration is valid and behaves identically.
@@ -0,0 +1,40 @@
1
+ /* @pmeweb/content/embeds -- the STRUCTURE of a player facade. The look is per site, through the
2
+ * --pmw-embed-* variables. Written to dist/_embed/embed.css by the integration, linked only by pages that
3
+ * hold a player.
4
+ *
5
+ * EVERY SELECTOR NAMES THE ELEMENT AND ITS CLASS (figure.pmw-embed img.pmw-embed__poster: two
6
+ * classes, two elements). A site's article styles reach inside the facade -- Astro compiles
7
+ * `.prose :global(img)` to `.prose[data-astro-cid-…] img` (two classes, one element), which beat
8
+ * 2.3.0's `.pmw-embed__link img` and gave the poster the article's margin, border and
9
+ * `height: auto`: a band above the poster and its bottom cut off (test-contenu, 2026-11-05). So
10
+ * the poster and the link also reset every property an article style usually sets. */
11
+ figure.pmw-embed { margin: 1.5em 0; padding: 0; }
12
+ figure.pmw-embed a.pmw-embed__link { position: relative; display: block; overflow: hidden; margin: 0; padding: 0; border: 0; border-radius: var(--pmw-embed-radius, 8px); background: #1f2328; color: #fff; text-decoration: none; }
13
+ figure.pmw-embed--video a.pmw-embed__link, figure.pmw-embed--video iframe.pmw-embed__frame { width: 100%; aspect-ratio: 16 / 9; }
14
+ figure.pmw-embed--audio a.pmw-embed__link, figure.pmw-embed--audio iframe.pmw-embed__frame { width: 100%; height: 166px; }
15
+ figure.pmw-embed img.pmw-embed__poster { display: block; width: 100%; height: 100%; max-width: none; margin: 0; padding: 0; border: 0; border-radius: 0; box-shadow: none; object-fit: cover; }
16
+ figure.pmw-embed span.pmw-embed__play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
17
+ figure.pmw-embed span.pmw-embed__title { position: absolute; left: 0; right: 0; bottom: 0; padding: 0.6em 1em; background: rgba(0, 0, 0, 0.78); color: #fff; font-weight: 600; line-height: 1.35; text-decoration: none; }
18
+ /* White ring inside a dark one: visible on a light page and on a dark one, whatever the site's
19
+ * link color. A site may set its own with --pmw-embed-focus. */
20
+ figure.pmw-embed a.pmw-embed__link:focus-visible { outline: 3px solid var(--pmw-embed-focus, #fff); outline-offset: 2px; box-shadow: 0 0 0 7px #1f2328; }
21
+ figure.pmw-embed--video a.pmw-embed__link:hover .pmw-embed__play path:first-child { fill: #c00; }
22
+
23
+ /* AUDIO: drawn in the page (facade.ts WAVE), so every color is the site's. Light by default, like
24
+ * SoundCloud's own player, which is what the click turns it into:
25
+ * --pmw-embed-audio-bg panel (#f2f2f2)
26
+ * --pmw-embed-audio-line panel border (#dcdcdc)
27
+ * --pmw-embed-audio-wave waveform bars (#c4c4c4)
28
+ * --pmw-embed-audio-fg title text (#1f2328)
29
+ * --pmw-embed-audio-accent play button (#ff5500, SoundCloud's orange) */
30
+ /* Laid out like that player too, on a grid rather than over the poster: the button on the left,
31
+ * the title on top, the waveform under it -- so a title wrapping to two lines on a phone pushes the
32
+ * waveform down instead of running over it. */
33
+ figure.pmw-embed--audio a.pmw-embed__link { display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto 1fr; column-gap: 1em; row-gap: 0.5em; padding: 1em; box-sizing: border-box; background: var(--pmw-embed-audio-bg, #f2f2f2); color: var(--pmw-embed-audio-fg, #1f2328); border: 1px solid var(--pmw-embed-audio-line, #dcdcdc); }
34
+ figure.pmw-embed--audio span.pmw-embed__play { position: static; grid-column: 1; grid-row: 1 / 3; align-self: center; }
35
+ figure.pmw-embed--audio span.pmw-embed__title { position: static; grid-column: 2; grid-row: 1; padding: 0; background: none; color: inherit; }
36
+ figure.pmw-embed--audio svg.pmw-embed__wave { grid-column: 2; grid-row: 2; display: block; width: 100%; height: 100%; min-height: 2em; }
37
+ figure.pmw-embed svg.pmw-embed__wave rect { fill: var(--pmw-embed-audio-wave, #c4c4c4); }
38
+ figure.pmw-embed span.pmw-embed__play--round circle { fill: var(--pmw-embed-audio-accent, #ff5500); }
39
+ figure.pmw-embed a.pmw-embed__link:hover span.pmw-embed__play--round svg { filter: brightness(0.9); }
40
+ figure.pmw-embed iframe.pmw-embed__frame { display: block; margin: 0; border: 0; border-radius: var(--pmw-embed-radius, 8px); }
@@ -0,0 +1,21 @@
1
+ (function () {
2
+ if (document.documentElement.hasAttribute('data-pmw-embeds')) return;
3
+ document.documentElement.setAttribute('data-pmw-embeds', '');
4
+ document.addEventListener('click', function (e) {
5
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
6
+ var link = e.target instanceof Element ? e.target.closest('.pmw-embed__link') : null;
7
+ if (!link) return;
8
+ var fig = link.closest('.pmw-embed');
9
+ var src = fig && fig.getAttribute('data-embed');
10
+ if (!src) return;
11
+ e.preventDefault();
12
+ var frame = document.createElement('iframe');
13
+ frame.className = 'pmw-embed__frame';
14
+ frame.src = src;
15
+ frame.title = fig.getAttribute('data-title') || '';
16
+ frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
17
+ frame.setAttribute('allowfullscreen', '');
18
+ link.replaceWith(frame);
19
+ frame.focus();
20
+ });
21
+ })();
@@ -0,0 +1,20 @@
1
+ ---
2
+ // One paragraph per provider a site's published posts use, under one heading, at the anchor every
3
+ // facade's note links to. `providers` comes from `embedProviders(bodies)`
4
+ // (`@pmeweb/content/embeds`); empty, it renders nothing. The text is the package's, approved once
5
+ // by the operator, identical on every site: a client cannot edit it in « Contenu ».
6
+ // `data-pmeweb-embeds-privacy` is what the integration's self-check reads in dist/.
7
+ import { EMBED_PRIVACY_HEADING, EMBED_PRIVACY_ID, PROVIDERS, type Lang, type ProviderKey } from '@pmeweb/content/embeds';
8
+ interface Props { lang: Lang; providers: ProviderKey[]; headingLevel?: 2 | 3 }
9
+ const { lang, providers, headingLevel = 2 } = Astro.props;
10
+ const H = `h${headingLevel}`;
11
+ ---
12
+ {providers.length > 0 && (
13
+ <section id={EMBED_PRIVACY_ID} data-pmeweb-embeds-privacy={providers.join(' ')}>
14
+ <H>{EMBED_PRIVACY_HEADING[lang]}</H>
15
+ {providers.map((k) => {
16
+ const c = PROVIDERS[k].copy[lang];
17
+ return <p>{c.privacy} <a href={c.policyHref} rel="noopener" target="_blank">{c.policyLabel}</a></p>;
18
+ })}
19
+ </section>
20
+ )}
@@ -0,0 +1,3 @@
1
+ export { rehypeEmbeds } from './rehype.js';
2
+ export { embeds, type EmbedsOptions } from './integration.js';
3
+ export { scanPages, selfCheck } from './scan.js';
@@ -0,0 +1,5 @@
1
+ // `@pmeweb/content/embeds/astro`: the Node-only half (satteri plugin, Astro integration, dist
2
+ // scanner). Never imported by a browser bundle.
3
+ export { rehypeEmbeds } from './rehype.js';
4
+ export { embeds } from './integration.js';
5
+ export { scanPages, selfCheck } from './scan.js';
@@ -0,0 +1,28 @@
1
+ import { type ProviderKey } from './providers.js';
2
+ export interface Embed {
3
+ provider: ProviderKey;
4
+ id: string;
5
+ url: string;
6
+ title: string;
7
+ }
8
+ /** The provider and id of a player URL, or null. http(s) only. */
9
+ export declare function embedFromUrl(raw: string): {
10
+ provider: ProviderKey;
11
+ id: string;
12
+ } | null;
13
+ /**
14
+ * A link whose href is a player and whose text can title it. The text equal to the href is a bare
15
+ * URL (GFM autolink renders `https://…` as `[https://…](https://…)`), which stays a link: a player
16
+ * needs words a screen reader can say.
17
+ */
18
+ export declare function embedFromLink(href: string, text: string): Embed | null;
19
+ /**
20
+ * The providers used across these Markdown bodies, in table order, once each: what the privacy
21
+ * page lists. A paragraph is a run of lines between blank lines. It may over-include (a link
22
+ * alone inside a code fence); the integration's self-check guarantees it never under-includes.
23
+ */
24
+ export declare function embedProviders(bodies: Iterable<string>): ProviderKey[];
25
+ /** Every provider's iframe origin, for a site's CSP `frame-src`. */
26
+ export declare const embedFrameHosts: string[];
27
+ /** « YouTube, SoundCloud »: the editor's sentence for an unsupported URL. */
28
+ export declare function supportedLabels(): string;
@@ -0,0 +1,60 @@
1
+ // Is this link a player? ONE answer for the site's plugin, the site's privacy page and the
2
+ // portal's Markdown bridge, so the editor and the site cannot disagree. Pure, browser-safe.
3
+ import { PROVIDERS, PROVIDER_KEYS } from './providers.js';
4
+ /** The provider and id of a player URL, or null. http(s) only. */
5
+ export function embedFromUrl(raw) {
6
+ let u;
7
+ try {
8
+ u = new URL(raw.trim());
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ if (u.protocol !== 'https:' && u.protocol !== 'http:')
14
+ return null;
15
+ for (const provider of PROVIDER_KEYS) {
16
+ const id = PROVIDERS[provider].id(u);
17
+ if (id !== null)
18
+ return { provider, id };
19
+ }
20
+ return null;
21
+ }
22
+ /**
23
+ * A link whose href is a player and whose text can title it. The text equal to the href is a bare
24
+ * URL (GFM autolink renders `https://…` as `[https://…](https://…)`), which stays a link: a player
25
+ * needs words a screen reader can say.
26
+ */
27
+ export function embedFromLink(href, text) {
28
+ const title = text.trim();
29
+ if (title === '' || title === href.trim())
30
+ return null;
31
+ const hit = embedFromUrl(href);
32
+ return hit ? { ...hit, url: href.trim(), title } : null;
33
+ }
34
+ /** `[text](url)` and nothing else on the paragraph; no title attribute. */
35
+ const LINK_PARAGRAPH = /^\[((?:\\.|[^\]\\\n])+)\]\(<?([^\s<>()]+)>?\)$/;
36
+ /**
37
+ * The providers used across these Markdown bodies, in table order, once each: what the privacy
38
+ * page lists. A paragraph is a run of lines between blank lines. It may over-include (a link
39
+ * alone inside a code fence); the integration's self-check guarantees it never under-includes.
40
+ */
41
+ export function embedProviders(bodies) {
42
+ const found = new Set();
43
+ for (const body of bodies) {
44
+ for (const para of body.split(/\r?\n[ \t]*\r?\n/)) {
45
+ const m = LINK_PARAGRAPH.exec(para.trim());
46
+ if (!m)
47
+ continue;
48
+ const hit = embedFromLink(m[2], m[1].replace(/\\(.)/g, '$1'));
49
+ if (hit)
50
+ found.add(hit.provider);
51
+ }
52
+ }
53
+ return PROVIDER_KEYS.filter((k) => found.has(k));
54
+ }
55
+ /** Every provider's iframe origin, for a site's CSP `frame-src`. */
56
+ export const embedFrameHosts = PROVIDER_KEYS.map((k) => PROVIDERS[k].frameHost);
57
+ /** « YouTube, SoundCloud »: the editor's sentence for an unsupported URL. */
58
+ export function supportedLabels() {
59
+ return PROVIDER_KEYS.map((k) => PROVIDERS[k].label).join(', ');
60
+ }
@@ -0,0 +1,14 @@
1
+ import { type ProviderKey } from './providers.js';
2
+ import type { Embed } from './detect.js';
3
+ /** The two files a document with a player loads, once per document. */
4
+ export declare const ASSET_TAGS = "<link rel=\"stylesheet\" href=\"/_embed/embed.css\"><script type=\"module\" src=\"/_embed/player.js\"></script>";
5
+ export declare const POSTER_WIDTHS: readonly [640, 960];
6
+ /** A thumbnail's file, one per video and width (posters.ts writes it). */
7
+ export declare function posterPath(provider: ProviderKey, id: string, width: number): string;
8
+ /**
9
+ * No words of the package's own under the player (2.3.0 wrote « Lecture par YouTube. Politique de
10
+ * confidentialité »; the operator removed it, 2026-11-05): the facade carries the client's title
11
+ * and nothing else, so it needs no language. The privacy page still names each provider
12
+ * (EmbedPrivacy.astro), and the build still refuses a site where it does not.
13
+ */
14
+ export declare function facadeHtml(e: Embed): string;
@@ -0,0 +1,43 @@
1
+ // The HTML a player line becomes on the site: the ORIGINAL link around a poster -- an image served by
2
+ // the site, or a waveform drawn in the page -- so without JavaScript it is exactly today's link and
3
+ // nothing third-party loads before a click.
4
+ // `player.js` (assets/embed/) swaps the link for the iframe named in `data-embed` on click.
5
+ import { PROVIDERS } from './providers.js';
6
+ /** The two files a document with a player loads, once per document. */
7
+ export const ASSET_TAGS = '<link rel="stylesheet" href="/_embed/embed.css"><script type="module" src="/_embed/player.js"></script>';
8
+ export const POSTER_WIDTHS = [640, 960];
9
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
10
+ /** A thumbnail's file, one per video and width (posters.ts writes it). */
11
+ export function posterPath(provider, id, width) {
12
+ return `/_embed/${provider}-${id}-${width}.webp`;
13
+ }
14
+ const PLAY = '<svg viewBox="0 0 68 48" width="68" height="48" focusable="false"><path d="M66.5 7.7a8.5 8.5 0 0 0-6-6C55.2.3 34 .3 34 .3s-21.2 0-26.5 1.4a8.5 8.5 0 0 0-6 6C.1 13 .1 24 .1 24s0 11 1.4 16.3a8.5 8.5 0 0 0 6 6C12.8 47.7 34 47.7 34 47.7s21.2 0 26.5-1.4a8.5 8.5 0 0 0 6-6C67.9 35 67.9 24 67.9 24s0-11-1.4-16.3z" fill="rgba(0,0,0,.8)"/><path d="M27 14l18 10-18 10z" fill="#fff"/></svg>';
15
+ /** A round play button for audio: the rounded rectangle above is YouTube's own mark. */
16
+ const PLAY_ROUND = '<svg viewBox="0 0 56 56" width="56" height="56" focusable="false"><circle cx="28" cy="28" r="28"/><path d="M22 17l18 11-18 11z" fill="#fff"/></svg>';
17
+ /**
18
+ * An audio poster drawn IN THE PAGE (poster kind `inline`): a waveform whose colors are the site's
19
+ * (`--pmw-embed-audio-*`, embed.css), light by default like SoundCloud's own player, so the click
20
+ * does not flip a dark panel into a white one. Stretched to the box (preserveAspectRatio none).
21
+ */
22
+ const WAVE = `<svg class="pmw-embed__wave" viewBox="0 0 640 100" preserveAspectRatio="none" aria-hidden="true" focusable="false">${Array.from({ length: 64 }, (_, i) => {
23
+ const h = Math.round((0.25 + 0.75 * Math.abs(Math.sin(i * 0.7))) * 56);
24
+ return `<rect x="${i * 10 + 2}" y="${50 - h / 2}" width="6" height="${h}" rx="3"/>`;
25
+ }).join('')}</svg>`;
26
+ /**
27
+ * No words of the package's own under the player (2.3.0 wrote « Lecture par YouTube. Politique de
28
+ * confidentialité »; the operator removed it, 2026-11-05): the facade carries the client's title
29
+ * and nothing else, so it needs no language. The privacy page still names each provider
30
+ * (EmbedPrivacy.astro), and the build still refuses a site where it does not.
31
+ */
32
+ export function facadeHtml(e) {
33
+ const p = PROVIDERS[e.provider];
34
+ const [w1, w2] = POSTER_WIDTHS;
35
+ const height = p.shape === 'video' ? Math.round((w2 * 9) / 16) : 166;
36
+ const poster = p.poster.kind === 'inline' ? WAVE : `<img class="pmw-embed__poster" src="${posterPath(e.provider, e.id, w2)}" srcset="${posterPath(e.provider, e.id, w1)} ${w1}w, ${posterPath(e.provider, e.id, w2)} ${w2}w" sizes="(max-width: 720px) 100vw, 720px" width="${w2}" height="${height}" alt="" loading="lazy" decoding="async">`;
37
+ const play = p.shape === 'audio'
38
+ ? `<span class="pmw-embed__play pmw-embed__play--round" aria-hidden="true">${PLAY_ROUND}</span>`
39
+ : `<span class="pmw-embed__play" aria-hidden="true">${PLAY}</span>`;
40
+ return `<figure class="pmw-embed pmw-embed--${p.shape}" data-provider="${e.provider}" data-embed="${esc(p.embed(e.id))}" data-title="${esc(e.title)}">`
41
+ + `<a class="pmw-embed__link" href="${esc(e.url)}" target="_blank" rel="noopener">${poster}${play}<span class="pmw-embed__title">${esc(e.title)}</span></a>`
42
+ + '</figure>';
43
+ }
@@ -0,0 +1,2 @@
1
+ export * from './providers.js';
2
+ export * from './detect.js';
@@ -0,0 +1,4 @@
1
+ // `@pmeweb/content/embeds`: the browser-safe half (table, detector, hosts, copy). The Astro half is
2
+ // `@pmeweb/content/embeds/astro`.
3
+ export * from './providers.js';
4
+ export * from './detect.js';
@@ -0,0 +1,9 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ /** 2.3.0's `lang` and `privacyHref` are accepted and ignored (the line under the player is gone). */
3
+ export interface EmbedsOptions {
4
+ lang?: string;
5
+ privacyHref?: string;
6
+ /** For tests; the build uses the global fetch. */
7
+ fetch?: (url: string) => Promise<Response>;
8
+ }
9
+ export declare function embeds(o?: EmbedsOptions): AstroIntegration;
@@ -0,0 +1,53 @@
1
+ // The Astro half of the players: writes dist/_embed/{player.js, embed.css} and the posters, serves
2
+ // the same paths in `astro dev`, and REFUSES a half-wired site (scan.ts selfCheck): a player line
3
+ // the plugin never converted, or a provider the privacy page does not mention. A missing
4
+ // integration runs no check at all; that case is the workspace gate's
5
+ // (websites/scripts/check-rich-sites.mjs).
6
+ import { copyFileSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { join } from 'node:path';
9
+ import { writePosters } from './posters.js';
10
+ import { scanPages, selfCheck } from './scan.js';
11
+ const ASSETS = fileURLToPath(new URL('../../assets/embed/', import.meta.url));
12
+ function* htmlPages(root, rel = '') {
13
+ for (const d of readdirSync(join(root, rel), { withFileTypes: true })) {
14
+ const p = rel ? `${rel}/${d.name}` : d.name;
15
+ if (d.isDirectory())
16
+ yield* htmlPages(root, p);
17
+ else if (d.name.endsWith('.html'))
18
+ yield { path: p, html: readFileSync(join(root, p), 'utf8') };
19
+ }
20
+ }
21
+ export function embeds(o = {}) {
22
+ return {
23
+ name: '@pmeweb/content/embeds',
24
+ hooks: {
25
+ 'astro:server:setup': ({ server }) => {
26
+ server.middlewares.use('/_embed/', (req, res, next) => {
27
+ const name = (req.url ?? '').replace(/^\//, '').split('?')[0];
28
+ if (name !== 'player.js' && name !== 'embed.css')
29
+ return next();
30
+ res.setHeader('content-type', name.endsWith('.js') ? 'text/javascript' : 'text/css');
31
+ res.end(readFileSync(join(ASSETS, name)));
32
+ });
33
+ },
34
+ 'astro:build:done': async ({ dir, logger }) => {
35
+ const root = fileURLToPath(dir);
36
+ const scan = scanPages(htmlPages(root));
37
+ const errors = selfCheck(scan);
38
+ if (errors.length > 0)
39
+ throw new Error(errors.join('\n'));
40
+ if (scan.used.length === 0)
41
+ return;
42
+ const out = join(root, '_embed');
43
+ mkdirSync(out, { recursive: true });
44
+ copyFileSync(join(ASSETS, 'player.js'), join(out, 'player.js'));
45
+ copyFileSync(join(ASSETS, 'embed.css'), join(out, 'embed.css'));
46
+ await writePosters({
47
+ dir: out, thumbnails: scan.thumbnails,
48
+ fetch: o.fetch ?? ((url) => fetch(url)), warn: (m) => logger.warn(m),
49
+ });
50
+ },
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,11 @@
1
+ import { type ProviderKey } from './providers.js';
2
+ export interface PosterJob {
3
+ dir: string;
4
+ thumbnails: {
5
+ provider: ProviderKey;
6
+ id: string;
7
+ }[];
8
+ fetch: (url: string) => Promise<Response>;
9
+ warn: (message: string) => void;
10
+ }
11
+ export declare function writePosters(job: PosterJob): Promise<void>;
@@ -0,0 +1,61 @@
1
+ // The thumbnail posters, written into dist/_embed/ at build (an `inline` poster is drawn in the page,
2
+ // facade.ts, and has no file). A thumbnail is fetched from the provider HERE,
3
+ // never by the visitor's browser, re-encoded to WebP and cropped to 16:9 (hqdefault is 4:3 with
4
+ // black bars). Any failure writes the drawn poster under the same names: a poster never fails a
5
+ // build. The preview builder may have no outbound network [UNVERIFIED]; it then shows the drawn
6
+ // poster where production shows the thumbnail.
7
+ import { mkdirSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { POSTER_WIDTHS, posterPath } from './facade.js';
10
+ import { PROVIDERS } from './providers.js';
11
+ const FETCH_MS = 8000;
12
+ /** A dark panel with a waveform: no text, so no font is needed where the build runs. */
13
+ function drawnSvg(shape) {
14
+ const [w, h] = shape === 'video' ? [960, 540] : [960, 166];
15
+ const mid = h / 2;
16
+ const bars = Array.from({ length: 48 }, (_, i) => {
17
+ const bh = Math.round((0.25 + 0.75 * Math.abs(Math.sin(i * 0.7))) * h * 0.35);
18
+ return `<rect x="${120 + i * 15}" y="${mid - bh / 2}" width="8" height="${bh}" rx="4" fill="#ffffff" fill-opacity="0.18"/>`;
19
+ }).join('');
20
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="${w}" height="${h}" fill="#1f2328"/>${bars}</svg>`;
21
+ }
22
+ async function encode(input, shape, width) {
23
+ const { default: sharp } = await import('sharp');
24
+ const height = shape === 'video' ? Math.round((width * 9) / 16) : Math.round((width * 166) / 960);
25
+ return sharp(input).resize(width, height, { fit: 'cover' }).webp({ quality: 78 }).toBuffer();
26
+ }
27
+ async function fetchFirst(urls, fetcher) {
28
+ for (const url of urls) {
29
+ let timer;
30
+ try {
31
+ const res = await Promise.race([
32
+ fetcher(url),
33
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), FETCH_MS); }),
34
+ ]);
35
+ if (res.ok)
36
+ return Buffer.from(await res.arrayBuffer());
37
+ }
38
+ catch { /* next url, then the drawn poster */ }
39
+ finally {
40
+ clearTimeout(timer);
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ async function write(dir, provider, id, source) {
46
+ const shape = PROVIDERS[provider].shape;
47
+ for (const width of POSTER_WIDTHS) {
48
+ const file = join(dir, posterPath(provider, id, width).replace('/_embed/', ''));
49
+ writeFileSync(file, await encode(source, shape, width));
50
+ }
51
+ }
52
+ export async function writePosters(job) {
53
+ mkdirSync(job.dir, { recursive: true });
54
+ for (const { provider, id } of job.thumbnails) {
55
+ const poster = PROVIDERS[provider].poster;
56
+ const got = poster.kind === 'thumbnail' ? await fetchFirst(poster.urls(id), job.fetch) : null;
57
+ if (!got)
58
+ job.warn(`embeds : aperçu ${PROVIDERS[provider].label} ${id} introuvable, image dessinée à la place`);
59
+ await write(job.dir, provider, id, got ?? Buffer.from(drawnSvg(PROVIDERS[provider].shape)));
60
+ }
61
+ }
@@ -0,0 +1,88 @@
1
+ export type Lang = 'fr' | 'en';
2
+ export interface ProviderCopy {
3
+ /** The privacy page's paragraph, then a link to the provider's own policy. */
4
+ privacy: string;
5
+ policyLabel: string;
6
+ policyHref: string;
7
+ }
8
+ /**
9
+ * What the facade shows before the click. `thumbnail`: the provider's image, fetched at build and
10
+ * served by the site (posters.ts). `inline`: drawn in the page itself (facade.ts), so its colors
11
+ * are the site's CSS variables -- a provider with no thumbnail to fetch.
12
+ */
13
+ export type Poster = {
14
+ kind: 'thumbnail';
15
+ urls(id: string): string[];
16
+ } | {
17
+ kind: 'inline';
18
+ };
19
+ export interface Provider {
20
+ label: string;
21
+ /** The id the embed and the poster need, or null when this URL is not one of this provider's players. */
22
+ id(url: URL): string | null;
23
+ embed(id: string): string;
24
+ frameHost: string;
25
+ poster: Poster;
26
+ shape: 'video' | 'audio';
27
+ oembed: string;
28
+ copy: Record<Lang, ProviderCopy>;
29
+ }
30
+ declare function youtubeId(u: URL): string | null;
31
+ declare function soundcloudId(u: URL): string | null;
32
+ export declare const PROVIDERS: {
33
+ readonly youtube: {
34
+ readonly label: "YouTube";
35
+ readonly id: typeof youtubeId;
36
+ readonly embed: (id: string) => string;
37
+ readonly frameHost: "https://www.youtube-nocookie.com";
38
+ readonly poster: {
39
+ readonly kind: "thumbnail";
40
+ readonly urls: (id: string) => string[];
41
+ };
42
+ readonly shape: "video";
43
+ readonly oembed: "https://www.youtube.com/oembed";
44
+ readonly copy: {
45
+ readonly fr: {
46
+ readonly privacy: "Certains articles intègrent des vidéos YouTube (Google). Rien n’est chargé depuis YouTube tant que vous n’appuyez pas sur le bouton de lecture : l’image d’aperçu est servie par ce site. Dès que vous lancez une vidéo, elle est lue depuis youtube-nocookie.com, et YouTube peut alors recueillir des données et déposer des témoins selon sa propre politique.";
47
+ readonly policyLabel: "Politique de confidentialité de Google";
48
+ readonly policyHref: "https://policies.google.com/privacy?hl=fr";
49
+ };
50
+ readonly en: {
51
+ readonly privacy: "Some articles embed YouTube (Google) videos. Nothing loads from YouTube until you press play: the preview image is served by this site. Once you start a video, it plays from youtube-nocookie.com, and YouTube may then collect data and set cookies under its own policy.";
52
+ readonly policyLabel: "Google’s privacy policy";
53
+ readonly policyHref: "https://policies.google.com/privacy?hl=en";
54
+ };
55
+ };
56
+ };
57
+ readonly soundcloud: {
58
+ readonly label: "SoundCloud";
59
+ readonly id: typeof soundcloudId;
60
+ readonly embed: (id: string) => string;
61
+ readonly frameHost: "https://w.soundcloud.com";
62
+ readonly poster: {
63
+ readonly kind: "inline";
64
+ };
65
+ readonly shape: "audio";
66
+ readonly oembed: "https://soundcloud.com/oembed";
67
+ readonly copy: {
68
+ readonly fr: {
69
+ readonly privacy: "Certains articles intègrent des enregistrements SoundCloud. Rien n’est chargé depuis SoundCloud tant que vous n’appuyez pas sur le bouton de lecture. Dès que vous lancez un enregistrement, il est lu depuis w.soundcloud.com, et SoundCloud peut alors recueillir des données et déposer des témoins selon sa propre politique.";
70
+ readonly policyLabel: "Politique de confidentialité de SoundCloud";
71
+ readonly policyHref: "https://soundcloud.com/pages/privacy";
72
+ };
73
+ readonly en: {
74
+ readonly privacy: "Some articles embed SoundCloud recordings. Nothing loads from SoundCloud until you press play. Once you start a recording, it plays from w.soundcloud.com, and SoundCloud may then collect data and set cookies under its own policy.";
75
+ readonly policyLabel: "SoundCloud’s privacy policy";
76
+ readonly policyHref: "https://soundcloud.com/pages/privacy";
77
+ };
78
+ };
79
+ };
80
+ };
81
+ export type ProviderKey = keyof typeof PROVIDERS;
82
+ /** Table order, which is the order of the privacy paragraphs and of `embedFrameHosts`. */
83
+ export declare const PROVIDER_KEYS: ProviderKey[];
84
+ /** The privacy section's heading, one per language, shared by every provider. */
85
+ export declare const EMBED_PRIVACY_HEADING: Record<Lang, string>;
86
+ /** The privacy section's anchor (`EmbedPrivacy.astro`), for a site that links to it. */
87
+ export declare const EMBED_PRIVACY_ID = "lecteurs-integres";
88
+ export {};
@@ -0,0 +1,87 @@
1
+ // THE PROVIDER TABLE: every external player a client may put in a post, and everything the portal
2
+ // and the sites need to know about it. The detector, the editor, the portal's oEmbed allowlist,
3
+ // each site's CSP, the facade, the click script and the privacy page all read THIS, so adding a
4
+ // provider is one entry here (plus its privacy paragraph, approved by the operator) and a minor
5
+ // release -- no site or portal code. Spec: pmeweb-portail/docs/superpowers/specs/
6
+ // 2026-11-05-contenu-rework-7-media-players-design.md.
7
+ //
8
+ // PURE AND BROWSER-SAFE: the portal's editor bundle imports this. Nothing from node here.
9
+ const YT_ID = /^[A-Za-z0-9_-]{11}$/;
10
+ const YT_HOSTS = new Set(['www.youtube.com', 'youtube.com', 'm.youtube.com']);
11
+ const SC_HOSTS = new Set(['soundcloud.com', 'www.soundcloud.com', 'm.soundcloud.com']);
12
+ /** A SoundCloud profile's tabs: `/<artist>/<tab>` is a page of the profile, not a track. */
13
+ const SC_TABS = new Set(['tracks', 'albums', 'sets', 'reposts', 'likes', 'followers', 'following', 'popular-tracks', 'comments', 'spotlight']);
14
+ function youtubeId(u) {
15
+ let id = null;
16
+ if (u.hostname === 'youtu.be')
17
+ id = u.pathname.split('/')[1] ?? null;
18
+ else if (YT_HOSTS.has(u.hostname)) {
19
+ const parts = u.pathname.split('/').filter(Boolean);
20
+ if (parts.length === 1 && parts[0] === 'watch')
21
+ id = u.searchParams.get('v');
22
+ else if (parts.length === 2 && (parts[0] === 'live' || parts[0] === 'shorts'))
23
+ id = parts[1];
24
+ }
25
+ return id !== null && YT_ID.test(id) ? id : null;
26
+ }
27
+ function soundcloudId(u) {
28
+ if (!SC_HOSTS.has(u.hostname))
29
+ return null;
30
+ const parts = u.pathname.split('/').filter(Boolean);
31
+ if (parts.length !== 2 || SC_TABS.has(parts[1]))
32
+ return null;
33
+ return `${parts[0]}/${parts[1]}`;
34
+ }
35
+ export const PROVIDERS = {
36
+ youtube: {
37
+ label: 'YouTube',
38
+ id: youtubeId,
39
+ embed: (id) => `https://www.youtube-nocookie.com/embed/${id}?autoplay=1`,
40
+ frameHost: 'https://www.youtube-nocookie.com',
41
+ poster: { kind: 'thumbnail', urls: (id) => [`https://i.ytimg.com/vi/${id}/maxresdefault.jpg`, `https://i.ytimg.com/vi/${id}/hqdefault.jpg`] },
42
+ shape: 'video',
43
+ oembed: 'https://www.youtube.com/oembed',
44
+ copy: {
45
+ fr: {
46
+ privacy: 'Certains articles intègrent des vidéos YouTube (Google). Rien n’est chargé depuis YouTube tant que vous n’appuyez pas sur le bouton de lecture : l’image d’aperçu est servie par ce site. Dès que vous lancez une vidéo, elle est lue depuis youtube-nocookie.com, et YouTube peut alors recueillir des données et déposer des témoins selon sa propre politique.',
47
+ policyLabel: 'Politique de confidentialité de Google',
48
+ policyHref: 'https://policies.google.com/privacy?hl=fr',
49
+ },
50
+ en: {
51
+ privacy: 'Some articles embed YouTube (Google) videos. Nothing loads from YouTube until you press play: the preview image is served by this site. Once you start a video, it plays from youtube-nocookie.com, and YouTube may then collect data and set cookies under its own policy.',
52
+ policyLabel: 'Google’s privacy policy',
53
+ policyHref: 'https://policies.google.com/privacy?hl=en',
54
+ },
55
+ },
56
+ },
57
+ soundcloud: {
58
+ label: 'SoundCloud',
59
+ id: soundcloudId,
60
+ embed: (id) => `https://w.soundcloud.com/player/?url=${encodeURIComponent(`https://soundcloud.com/${id}`)}&auto_play=true`,
61
+ frameHost: 'https://w.soundcloud.com',
62
+ poster: { kind: 'inline' },
63
+ shape: 'audio',
64
+ oembed: 'https://soundcloud.com/oembed',
65
+ copy: {
66
+ fr: {
67
+ privacy: 'Certains articles intègrent des enregistrements SoundCloud. Rien n’est chargé depuis SoundCloud tant que vous n’appuyez pas sur le bouton de lecture. Dès que vous lancez un enregistrement, il est lu depuis w.soundcloud.com, et SoundCloud peut alors recueillir des données et déposer des témoins selon sa propre politique.',
68
+ policyLabel: 'Politique de confidentialité de SoundCloud',
69
+ policyHref: 'https://soundcloud.com/pages/privacy',
70
+ },
71
+ en: {
72
+ privacy: 'Some articles embed SoundCloud recordings. Nothing loads from SoundCloud until you press play. Once you start a recording, it plays from w.soundcloud.com, and SoundCloud may then collect data and set cookies under its own policy.',
73
+ policyLabel: 'SoundCloud’s privacy policy',
74
+ policyHref: 'https://soundcloud.com/pages/privacy',
75
+ },
76
+ },
77
+ },
78
+ };
79
+ /** Table order, which is the order of the privacy paragraphs and of `embedFrameHosts`. */
80
+ export const PROVIDER_KEYS = Object.keys(PROVIDERS);
81
+ /** The privacy section's heading, one per language, shared by every provider. */
82
+ export const EMBED_PRIVACY_HEADING = {
83
+ fr: 'Lecteurs vidéo et audio intégrés',
84
+ en: 'Embedded video and audio players',
85
+ };
86
+ /** The privacy section's anchor (`EmbedPrivacy.astro`), for a site that links to it. */
87
+ export const EMBED_PRIVACY_ID = 'lecteurs-integres';
@@ -0,0 +1,26 @@
1
+ interface El {
2
+ type: string;
3
+ tagName?: string;
4
+ properties?: Record<string, unknown>;
5
+ children?: El[];
6
+ value?: string;
7
+ }
8
+ interface Ctx {
9
+ data: Record<string, unknown>;
10
+ parent(node: never): {
11
+ type: string;
12
+ } | undefined;
13
+ replaceNode(node: never, next: unknown): void;
14
+ appendChild(node: never, child: unknown): void;
15
+ }
16
+ /** Takes no options. 2.3.0 took `{ lang, privacyHref }` for the line under the player, since
17
+ * removed; a site still passing them builds the same. */
18
+ export declare function rehypeEmbeds(_legacy?: unknown): {
19
+ name: string;
20
+ element: {
21
+ filter: string[];
22
+ visit: (node: El, ctx: Ctx) => void;
23
+ };
24
+ after(root: unknown, ctx: Ctx): void;
25
+ };
26
+ export {};
@@ -0,0 +1,51 @@
1
+ // A satteri hast plugin (NOT a unified plugin: satteri's are visitor objects, see
2
+ // satteri/dist/hast/hast-visitor.d.ts). It must be passed THROUGH satteri({ hastPlugins }) in
3
+ // astro.config.mjs, and through every createSatteriMarkdownProcessor a site calls itself
4
+ // (aheartful's src/lib/markdown.ts): `markdown.hastPlugins` is not an Astro config key.
5
+ //
6
+ // A paragraph that is exactly one link, whose only child is text, whose href is a player
7
+ // (detect.ts) and which carries no title attribute, becomes the facade (facade.ts). A paragraph
8
+ // inside a list or a quote is left alone: the portal's editor models only top-level players.
9
+ // The FIRST facade of a document flags it in ctx.data; the `after` hook then appends the
10
+ // stylesheet and the script once, so a page without a player ships neither.
11
+ import { embedFromLink } from './detect.js';
12
+ import { ASSET_TAGS, facadeHtml } from './facade.js';
13
+ const FLAG = 'pmwEmbeds';
14
+ const blank = (n) => n.type === 'text' && (n.value ?? '').trim() === '';
15
+ function visitParagraph() {
16
+ return (node, ctx) => {
17
+ if (ctx.parent(node)?.type !== 'root')
18
+ return;
19
+ const kids = (node.children ?? []).filter((c) => !blank(c));
20
+ if (kids.length !== 1)
21
+ return;
22
+ const a = kids[0];
23
+ if (a.type !== 'element' || a.tagName !== 'a')
24
+ return;
25
+ if (a.properties?.title !== undefined && a.properties?.title !== null)
26
+ return;
27
+ const text = a.children ?? [];
28
+ if (text.length !== 1 || text[0].type !== 'text')
29
+ return;
30
+ const href = a.properties?.href;
31
+ if (typeof href !== 'string')
32
+ return;
33
+ const hit = embedFromLink(href, text[0].value ?? '');
34
+ if (!hit)
35
+ return;
36
+ ctx.replaceNode(node, { type: 'raw', value: facadeHtml(hit) });
37
+ ctx.data[FLAG] = true;
38
+ };
39
+ }
40
+ /** Takes no options. 2.3.0 took `{ lang, privacyHref }` for the line under the player, since
41
+ * removed; a site still passing them builds the same. */
42
+ export function rehypeEmbeds(_legacy) {
43
+ return {
44
+ name: 'pmeweb-embeds',
45
+ element: { filter: ['p'], visit: visitParagraph() },
46
+ after(root, ctx) {
47
+ if (ctx.data[FLAG] === true)
48
+ ctx.appendChild(root, { type: 'raw', value: ASSET_TAGS });
49
+ },
50
+ };
51
+ }
@@ -0,0 +1,20 @@
1
+ import { type ProviderKey } from './providers.js';
2
+ export interface Page {
3
+ path: string;
4
+ html: string;
5
+ }
6
+ export interface Scan {
7
+ thumbnails: {
8
+ provider: ProviderKey;
9
+ id: string;
10
+ }[];
11
+ used: ProviderKey[];
12
+ privacy: ProviderKey[];
13
+ unconverted: {
14
+ path: string;
15
+ href: string;
16
+ }[];
17
+ }
18
+ export declare function scanPages(pages: Iterable<Page>): Scan;
19
+ /** The build's two refusals, as messages; empty when the site is wired. */
20
+ export declare function selfCheck(s: Scan): string[];
@@ -0,0 +1,49 @@
1
+ // What the built pages hold, read off `dist/` after the build: which posters to write, which
2
+ // providers are used, which ones the privacy page lists, and any player paragraph the plugin did
3
+ // not convert. Reading the OUTPUT, not the collections, is what lets the check run in an
4
+ // integration hook with no access to Astro's content layer, and a draft cannot false-positive.
5
+ import { embedFromLink } from './detect.js';
6
+ import { PROVIDERS, PROVIDER_KEYS } from './providers.js';
7
+ const POSTER = /\/_embed\/([a-z]+)-([A-Za-z0-9_-]+)-\d+\.webp/g;
8
+ const FIGURE = /class="pmw-embed [^"]*" data-provider="([a-z]+)"/g;
9
+ const PRIVACY = /data-pmeweb-embeds-privacy="([a-z ]*)"/g;
10
+ /** `<p><a …href="…"…>text</a></p>`, the shape satteri renders a lone link paragraph in. */
11
+ const LONE_LINK = /<p><a\s[^>]*?href="([^"]+)"[^>]*>([^<]+)<\/a><\/p>/g;
12
+ const isKey = (k) => PROVIDER_KEYS.includes(k);
13
+ const ordered = (s) => PROVIDER_KEYS.filter((k) => s.has(k));
14
+ const unescapeHtml = (s) => s.replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
15
+ export function scanPages(pages) {
16
+ const thumbs = new Map();
17
+ const used = new Set();
18
+ const privacy = new Set();
19
+ const unconverted = [];
20
+ for (const { path, html } of pages) {
21
+ for (const m of html.matchAll(POSTER)) {
22
+ const [, provider, id] = m;
23
+ if (isKey(provider) && PROVIDERS[provider].poster.kind === 'thumbnail')
24
+ thumbs.set(`${provider}:${id}`, { provider, id });
25
+ }
26
+ for (const m of html.matchAll(FIGURE))
27
+ if (isKey(m[1]))
28
+ used.add(m[1]);
29
+ for (const m of html.matchAll(PRIVACY))
30
+ for (const k of m[1].split(' '))
31
+ if (isKey(k))
32
+ privacy.add(k);
33
+ for (const m of html.matchAll(LONE_LINK)) {
34
+ const href = unescapeHtml(m[1]);
35
+ if (embedFromLink(href, unescapeHtml(m[2])))
36
+ unconverted.push({ path, href });
37
+ }
38
+ }
39
+ return { thumbnails: [...thumbs.values()], used: ordered(used), privacy: ordered(privacy), unconverted };
40
+ }
41
+ /** The build's two refusals, as messages; empty when the site is wired. */
42
+ export function selfCheck(s) {
43
+ const errors = s.unconverted.map((u) => `embeds : une ligne de lecteur n'a pas été convertie (${u.path} : ${u.href}) -- rehypeEmbeds n'est pas branché sur ce pipeline Markdown`);
44
+ for (const k of s.used) {
45
+ if (!s.privacy.includes(k))
46
+ errors.push(`embeds : la page de confidentialité ne mentionne pas ${PROVIDERS[k].label} -- ajoutez <EmbedPrivacy> avec embedProviders()`);
47
+ }
48
+ return errors;
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmeweb/content",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "PmeWeb content contract: content.schema.json types, zodFrom, field checks, scheduling. Read by every site's build and by the client portal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,7 +9,9 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
- "prose.css"
12
+ "prose.css",
13
+ "assets",
14
+ "astro"
13
15
  ],
14
16
  "exports": {
15
17
  "./types": {
@@ -36,6 +38,15 @@
36
38
  "types": "./dist/published.d.ts",
37
39
  "default": "./dist/published.js"
38
40
  },
41
+ "./embeds": {
42
+ "types": "./dist/embeds/index.d.ts",
43
+ "default": "./dist/embeds/index.js"
44
+ },
45
+ "./embeds/astro": {
46
+ "types": "./dist/embeds/astro.d.ts",
47
+ "default": "./dist/embeds/astro.js"
48
+ },
49
+ "./EmbedPrivacy.astro": "./astro/EmbedPrivacy.astro",
39
50
  "./prose.css": "./prose.css"
40
51
  },
41
52
  "scripts": {
@@ -46,6 +57,8 @@
46
57
  },
47
58
  "devDependencies": {
48
59
  "astro": "7.2.9",
60
+ "happy-dom": "^20.14.5",
61
+ "sharp": "0.35.5",
49
62
  "typescript": "6.0.3",
50
63
  "vite": "8.3.0",
51
64
  "vitest": "4.1.11"
@@ -54,11 +67,15 @@
54
67
  "node": ">=22.12"
55
68
  },
56
69
  "peerDependencies": {
57
- "astro": ">=7"
70
+ "astro": ">=7",
71
+ "sharp": ">=0.33"
58
72
  },
59
73
  "peerDependenciesMeta": {
60
74
  "astro": {
61
75
  "optional": true
76
+ },
77
+ "sharp": {
78
+ "optional": true
62
79
  }
63
80
  }
64
81
  }