@pmeweb/content 2.2.0 → 2.3.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 +35 -0
- package/assets/embed/embed.css +14 -0
- package/assets/embed/player.js +21 -0
- package/astro/EmbedPrivacy.astro +20 -0
- package/dist/embeds/astro.d.ts +3 -0
- package/dist/embeds/astro.js +5 -0
- package/dist/embeds/detect.d.ts +28 -0
- package/dist/embeds/detect.js +60 -0
- package/dist/embeds/facade.d.ts +12 -0
- package/dist/embeds/facade.js +26 -0
- package/dist/embeds/index.d.ts +2 -0
- package/dist/embeds/index.js +4 -0
- package/dist/embeds/integration.d.ts +7 -0
- package/dist/embeds/integration.js +53 -0
- package/dist/embeds/posters.d.ts +12 -0
- package/dist/embeds/posters.js +63 -0
- package/dist/embeds/providers.d.ts +94 -0
- package/dist/embeds/providers.js +95 -0
- package/dist/embeds/rehype.d.ts +25 -0
- package/dist/embeds/rehype.js +49 -0
- package/dist/embeds/scan.d.ts +21 -0
- package/dist/embeds/scan.js +54 -0
- package/package.json +20 -3
package/README.md
CHANGED
|
@@ -54,6 +54,41 @@ reads both once, `const APERCU = portalPreview(process.env)`, and filters with
|
|
|
54
54
|
drafts in the preview, removed ones only in a preview that asked for them. Only the exact
|
|
55
55
|
string `'1'` counts, and the past flag means nothing without the preview flag.
|
|
56
56
|
|
|
57
|
+
## Embeds (2.3.0)
|
|
58
|
+
|
|
59
|
+
A YouTube or SoundCloud link alone on its paragraph (`[words](url)`, no title attribute, words
|
|
60
|
+
that are not the URL itself, not in a list or a quote) becomes a click-to-load player: the
|
|
61
|
+
original link around a poster served by the site, swapped for the provider's iframe on click.
|
|
62
|
+
Nothing loads from the provider before that click.
|
|
63
|
+
|
|
64
|
+
Two subpaths:
|
|
65
|
+
|
|
66
|
+
- `@pmeweb/content/embeds`: pure and browser-safe (the portal's editor imports it). The provider
|
|
67
|
+
table `PROVIDERS`, `embedFromUrl`, `embedFromLink`, `embedProviders(bodies)`, `embedFrameHosts`,
|
|
68
|
+
`supportedLabels()`.
|
|
69
|
+
- `@pmeweb/content/embeds/astro`: Node only. `rehypeEmbeds(options)` (a satteri hast plugin),
|
|
70
|
+
`embeds(options)` (the Astro integration), `scanPages` / `selfCheck`.
|
|
71
|
+
|
|
72
|
+
A site with a `rich` body wires FOUR pieces:
|
|
73
|
+
|
|
74
|
+
1. `rehypeEmbeds({ lang, privacyHref })` through `satteri({ hastPlugins: [...] })` in
|
|
75
|
+
`astro.config.mjs`, AND through every `createSatteriMarkdownProcessor` the site calls itself.
|
|
76
|
+
2. `embeds({ lang, privacyHref })` in `integrations`. At build it writes `dist/_embed/player.js`,
|
|
77
|
+
`embed.css` and the posters (a YouTube thumbnail fetched at build and re-encoded to WebP, or a
|
|
78
|
+
drawn poster when the fetch fails), and FAILS the build when a player line was not converted
|
|
79
|
+
or the privacy page does not mention a provider in use.
|
|
80
|
+
3. `<EmbedPrivacy lang providers={embedProviders(bodies)} />` (`@pmeweb/content/EmbedPrivacy.astro`)
|
|
81
|
+
on every privacy page, `bodies` being the published posts' raw Markdown.
|
|
82
|
+
4. `embedFrameHosts` in the worker CSP's `frame-src`.
|
|
83
|
+
|
|
84
|
+
`astro dev` serves the script and the stylesheet but not the posters (they are build output):
|
|
85
|
+
the facade shows its dark background behind the title.
|
|
86
|
+
|
|
87
|
+
The look is the site's, through `--pmw-embed-radius` and `--pmw-embed-note`.
|
|
88
|
+
|
|
89
|
+
Adding a provider = one entry in `src/embeds/providers.ts` + its privacy text, approved by the
|
|
90
|
+
operator + a minor release. `prepublishOnly` refuses a privacy text still carrying `[À VALIDER`.
|
|
91
|
+
|
|
57
92
|
## 2.2.0
|
|
58
93
|
|
|
59
94
|
Additive: every 2.1.0 declaration is valid and behaves identically.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* @pmeweb/content/embeds -- the STRUCTURE of a player facade. The look is per site, through the
|
|
2
|
+
* two variables. Written to dist/_embed/embed.css by the integration, linked only by pages that
|
|
3
|
+
* hold a player. */
|
|
4
|
+
.pmw-embed { margin: 1.5em 0; }
|
|
5
|
+
.pmw-embed__link { position: relative; display: block; overflow: hidden; border-radius: var(--pmw-embed-radius, 8px); background: #1f2328; color: #fff; text-decoration: none; }
|
|
6
|
+
.pmw-embed--video .pmw-embed__link, .pmw-embed--video .pmw-embed__frame { width: 100%; aspect-ratio: 16 / 9; }
|
|
7
|
+
.pmw-embed--audio .pmw-embed__link, .pmw-embed--audio .pmw-embed__frame { width: 100%; height: 166px; }
|
|
8
|
+
.pmw-embed__link img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
9
|
+
.pmw-embed__play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
|
|
10
|
+
.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; }
|
|
11
|
+
.pmw-embed__link:focus-visible { outline: 3px solid currentColor; outline-offset: 3px; }
|
|
12
|
+
.pmw-embed__link:hover .pmw-embed__play path:first-child { fill: #c00; }
|
|
13
|
+
.pmw-embed__frame { display: block; border: 0; border-radius: var(--pmw-embed-radius, 8px); }
|
|
14
|
+
.pmw-embed__note { margin-top: 0.5em; font-size: 0.875rem; color: var(--pmw-embed-note, inherit); }
|
|
@@ -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,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,12 @@
|
|
|
1
|
+
import { type Lang, type ProviderKey } from './providers.js';
|
|
2
|
+
import type { Embed } from './detect.js';
|
|
3
|
+
export interface FacadeOptions {
|
|
4
|
+
lang: Lang;
|
|
5
|
+
privacyHref: string;
|
|
6
|
+
}
|
|
7
|
+
/** The two files a document with a player loads, once per document. */
|
|
8
|
+
export declare const ASSET_TAGS = "<link rel=\"stylesheet\" href=\"/_embed/embed.css\"><script type=\"module\" src=\"/_embed/player.js\"></script>";
|
|
9
|
+
export declare const POSTER_WIDTHS: readonly [640, 960];
|
|
10
|
+
/** A provider whose poster is drawn shares one file per width; a thumbnail is per video. */
|
|
11
|
+
export declare function posterPath(provider: ProviderKey, id: string, width: number): string;
|
|
12
|
+
export declare function facadeHtml(e: Embed, o: FacadeOptions): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The HTML a player line becomes on the site: the ORIGINAL link around a poster served by the site,
|
|
2
|
+
// so without JavaScript it is exactly today's link and nothing third-party loads before a click.
|
|
3
|
+
// `player.js` (assets/embed/) swaps the link for the iframe named in `data-embed` on click.
|
|
4
|
+
import { EMBED_PRIVACY_ID, PROVIDERS } from './providers.js';
|
|
5
|
+
/** The two files a document with a player loads, once per document. */
|
|
6
|
+
export const ASSET_TAGS = '<link rel="stylesheet" href="/_embed/embed.css"><script type="module" src="/_embed/player.js"></script>';
|
|
7
|
+
export const POSTER_WIDTHS = [640, 960];
|
|
8
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
9
|
+
/** A provider whose poster is drawn shares one file per width; a thumbnail is per video. */
|
|
10
|
+
export function posterPath(provider, id, width) {
|
|
11
|
+
return PROVIDERS[provider].poster.kind === 'drawn'
|
|
12
|
+
? `/_embed/${provider}-drawn-${width}.webp`
|
|
13
|
+
: `/_embed/${provider}-${id}-${width}.webp`;
|
|
14
|
+
}
|
|
15
|
+
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>';
|
|
16
|
+
export function facadeHtml(e, o) {
|
|
17
|
+
const p = PROVIDERS[e.provider];
|
|
18
|
+
const copy = p.copy[o.lang];
|
|
19
|
+
const [w1, w2] = POSTER_WIDTHS;
|
|
20
|
+
const height = p.shape === 'video' ? Math.round((w2 * 9) / 16) : 166;
|
|
21
|
+
const img = `<img 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">`;
|
|
22
|
+
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)}">`
|
|
23
|
+
+ `<a class="pmw-embed__link" href="${esc(e.url)}" target="_blank" rel="noopener">${img}<span class="pmw-embed__play" aria-hidden="true">${PLAY}</span><span class="pmw-embed__title">${esc(e.title)}</span></a>`
|
|
24
|
+
+ `<figcaption class="pmw-embed__note">${esc(copy.note)} <a href="${esc(o.privacyHref)}#${EMBED_PRIVACY_ID}">${esc(copy.noteLink)}</a></figcaption>`
|
|
25
|
+
+ '</figure>';
|
|
26
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AstroIntegration } from 'astro';
|
|
2
|
+
import type { FacadeOptions } from './facade.js';
|
|
3
|
+
export interface EmbedsOptions extends FacadeOptions {
|
|
4
|
+
/** For tests; the build uses the global fetch. */
|
|
5
|
+
fetch?: (url: string) => Promise<Response>;
|
|
6
|
+
}
|
|
7
|
+
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, drawn: scan.drawn,
|
|
48
|
+
fetch: o.fetch ?? ((url) => fetch(url)), warn: (m) => logger.warn(m),
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ProviderKey } from './providers.js';
|
|
2
|
+
export interface PosterJob {
|
|
3
|
+
dir: string;
|
|
4
|
+
thumbnails: {
|
|
5
|
+
provider: ProviderKey;
|
|
6
|
+
id: string;
|
|
7
|
+
}[];
|
|
8
|
+
drawn: ProviderKey[];
|
|
9
|
+
fetch: (url: string) => Promise<Response>;
|
|
10
|
+
warn: (message: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function writePosters(job: PosterJob): Promise<void>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The posters, written into dist/_embed/ at build. A thumbnail is fetched from the provider HERE,
|
|
2
|
+
// never by the visitor's browser, re-encoded to WebP and cropped to 16:9 (hqdefault is 4:3 with
|
|
3
|
+
// black bars). Any failure writes the drawn poster under the same names: a poster never fails a
|
|
4
|
+
// build. The preview builder may have no outbound network [UNVERIFIED]; it then shows the drawn
|
|
5
|
+
// poster where production shows the thumbnail.
|
|
6
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { POSTER_WIDTHS, posterPath } from './facade.js';
|
|
9
|
+
import { PROVIDERS } from './providers.js';
|
|
10
|
+
const FETCH_MS = 8000;
|
|
11
|
+
/** A dark panel with a waveform: no text, so no font is needed where the build runs. */
|
|
12
|
+
function drawnSvg(shape) {
|
|
13
|
+
const [w, h] = shape === 'video' ? [960, 540] : [960, 166];
|
|
14
|
+
const mid = h / 2;
|
|
15
|
+
const bars = Array.from({ length: 48 }, (_, i) => {
|
|
16
|
+
const bh = Math.round((0.25 + 0.75 * Math.abs(Math.sin(i * 0.7))) * h * 0.35);
|
|
17
|
+
return `<rect x="${120 + i * 15}" y="${mid - bh / 2}" width="8" height="${bh}" rx="4" fill="#ffffff" fill-opacity="0.18"/>`;
|
|
18
|
+
}).join('');
|
|
19
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="${w}" height="${h}" fill="#1f2328"/>${bars}</svg>`;
|
|
20
|
+
}
|
|
21
|
+
async function encode(input, shape, width) {
|
|
22
|
+
const { default: sharp } = await import('sharp');
|
|
23
|
+
const height = shape === 'video' ? Math.round((width * 9) / 16) : Math.round((width * 166) / 960);
|
|
24
|
+
return sharp(input).resize(width, height, { fit: 'cover' }).webp({ quality: 78 }).toBuffer();
|
|
25
|
+
}
|
|
26
|
+
async function fetchFirst(urls, fetcher) {
|
|
27
|
+
for (const url of urls) {
|
|
28
|
+
let timer;
|
|
29
|
+
try {
|
|
30
|
+
const res = await Promise.race([
|
|
31
|
+
fetcher(url),
|
|
32
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), FETCH_MS); }),
|
|
33
|
+
]);
|
|
34
|
+
if (res.ok)
|
|
35
|
+
return Buffer.from(await res.arrayBuffer());
|
|
36
|
+
}
|
|
37
|
+
catch { /* next url, then the drawn poster */ }
|
|
38
|
+
finally {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
async function write(dir, provider, id, source) {
|
|
45
|
+
const shape = PROVIDERS[provider].shape;
|
|
46
|
+
for (const width of POSTER_WIDTHS) {
|
|
47
|
+
const file = join(dir, posterPath(provider, id, width).replace('/_embed/', ''));
|
|
48
|
+
writeFileSync(file, await encode(source, shape, width));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function writePosters(job) {
|
|
52
|
+
mkdirSync(job.dir, { recursive: true });
|
|
53
|
+
for (const provider of job.drawn) {
|
|
54
|
+
await write(job.dir, provider, 'drawn', Buffer.from(drawnSvg(PROVIDERS[provider].shape)));
|
|
55
|
+
}
|
|
56
|
+
for (const { provider, id } of job.thumbnails) {
|
|
57
|
+
const poster = PROVIDERS[provider].poster;
|
|
58
|
+
const got = poster.kind === 'thumbnail' ? await fetchFirst(poster.urls(id), job.fetch) : null;
|
|
59
|
+
if (!got)
|
|
60
|
+
job.warn(`embeds : aperçu ${PROVIDERS[provider].label} ${id} introuvable, image dessinée à la place`);
|
|
61
|
+
await write(job.dir, provider, id, got ?? Buffer.from(drawnSvg(PROVIDERS[provider].shape)));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export type Lang = 'fr' | 'en';
|
|
2
|
+
export interface ProviderCopy {
|
|
3
|
+
/** The line under the poster: `note` then a link whose words are `noteLink`. */
|
|
4
|
+
note: string;
|
|
5
|
+
noteLink: string;
|
|
6
|
+
/** The privacy page's paragraph, then a link to the provider's own policy. */
|
|
7
|
+
privacy: string;
|
|
8
|
+
policyLabel: string;
|
|
9
|
+
policyHref: string;
|
|
10
|
+
}
|
|
11
|
+
export type Poster = {
|
|
12
|
+
kind: 'thumbnail';
|
|
13
|
+
urls(id: string): string[];
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'drawn';
|
|
16
|
+
};
|
|
17
|
+
export interface Provider {
|
|
18
|
+
label: string;
|
|
19
|
+
/** The id the embed and the poster need, or null when this URL is not one of this provider's players. */
|
|
20
|
+
id(url: URL): string | null;
|
|
21
|
+
embed(id: string): string;
|
|
22
|
+
frameHost: string;
|
|
23
|
+
poster: Poster;
|
|
24
|
+
shape: 'video' | 'audio';
|
|
25
|
+
oembed: string;
|
|
26
|
+
copy: Record<Lang, ProviderCopy>;
|
|
27
|
+
}
|
|
28
|
+
declare function youtubeId(u: URL): string | null;
|
|
29
|
+
declare function soundcloudId(u: URL): string | null;
|
|
30
|
+
export declare const PROVIDERS: {
|
|
31
|
+
readonly youtube: {
|
|
32
|
+
readonly label: "YouTube";
|
|
33
|
+
readonly id: typeof youtubeId;
|
|
34
|
+
readonly embed: (id: string) => string;
|
|
35
|
+
readonly frameHost: "https://www.youtube-nocookie.com";
|
|
36
|
+
readonly poster: {
|
|
37
|
+
readonly kind: "thumbnail";
|
|
38
|
+
readonly urls: (id: string) => string[];
|
|
39
|
+
};
|
|
40
|
+
readonly shape: "video";
|
|
41
|
+
readonly oembed: "https://www.youtube.com/oembed";
|
|
42
|
+
readonly copy: {
|
|
43
|
+
readonly fr: {
|
|
44
|
+
readonly note: "Lecture par YouTube.";
|
|
45
|
+
readonly noteLink: "Politique de confidentialité";
|
|
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 note: "Plays from YouTube.";
|
|
52
|
+
readonly noteLink: "Privacy policy";
|
|
53
|
+
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.";
|
|
54
|
+
readonly policyLabel: "Google’s privacy policy";
|
|
55
|
+
readonly policyHref: "https://policies.google.com/privacy?hl=en";
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
readonly soundcloud: {
|
|
60
|
+
readonly label: "SoundCloud";
|
|
61
|
+
readonly id: typeof soundcloudId;
|
|
62
|
+
readonly embed: (id: string) => string;
|
|
63
|
+
readonly frameHost: "https://w.soundcloud.com";
|
|
64
|
+
readonly poster: {
|
|
65
|
+
readonly kind: "drawn";
|
|
66
|
+
};
|
|
67
|
+
readonly shape: "audio";
|
|
68
|
+
readonly oembed: "https://soundcloud.com/oembed";
|
|
69
|
+
readonly copy: {
|
|
70
|
+
readonly fr: {
|
|
71
|
+
readonly note: "Lecture par SoundCloud.";
|
|
72
|
+
readonly noteLink: "Politique de confidentialité";
|
|
73
|
+
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.";
|
|
74
|
+
readonly policyLabel: "Politique de confidentialité de SoundCloud";
|
|
75
|
+
readonly policyHref: "https://soundcloud.com/pages/privacy";
|
|
76
|
+
};
|
|
77
|
+
readonly en: {
|
|
78
|
+
readonly note: "Plays from SoundCloud.";
|
|
79
|
+
readonly noteLink: "Privacy policy";
|
|
80
|
+
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.";
|
|
81
|
+
readonly policyLabel: "SoundCloud’s privacy policy";
|
|
82
|
+
readonly policyHref: "https://soundcloud.com/pages/privacy";
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
export type ProviderKey = keyof typeof PROVIDERS;
|
|
88
|
+
/** Table order, which is the order of the privacy paragraphs and of `embedFrameHosts`. */
|
|
89
|
+
export declare const PROVIDER_KEYS: ProviderKey[];
|
|
90
|
+
/** The privacy section's heading, one per language, shared by every provider. */
|
|
91
|
+
export declare const EMBED_PRIVACY_HEADING: Record<Lang, string>;
|
|
92
|
+
/** The privacy section's anchor, which every facade's note links to. */
|
|
93
|
+
export declare const EMBED_PRIVACY_ID = "lecteurs-integres";
|
|
94
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
note: 'Lecture par YouTube.',
|
|
47
|
+
noteLink: 'Politique de confidentialité',
|
|
48
|
+
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.',
|
|
49
|
+
policyLabel: 'Politique de confidentialité de Google',
|
|
50
|
+
policyHref: 'https://policies.google.com/privacy?hl=fr',
|
|
51
|
+
},
|
|
52
|
+
en: {
|
|
53
|
+
note: 'Plays from YouTube.',
|
|
54
|
+
noteLink: 'Privacy policy',
|
|
55
|
+
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.',
|
|
56
|
+
policyLabel: 'Google’s privacy policy',
|
|
57
|
+
policyHref: 'https://policies.google.com/privacy?hl=en',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
soundcloud: {
|
|
62
|
+
label: 'SoundCloud',
|
|
63
|
+
id: soundcloudId,
|
|
64
|
+
embed: (id) => `https://w.soundcloud.com/player/?url=${encodeURIComponent(`https://soundcloud.com/${id}`)}&auto_play=true`,
|
|
65
|
+
frameHost: 'https://w.soundcloud.com',
|
|
66
|
+
poster: { kind: 'drawn' },
|
|
67
|
+
shape: 'audio',
|
|
68
|
+
oembed: 'https://soundcloud.com/oembed',
|
|
69
|
+
copy: {
|
|
70
|
+
fr: {
|
|
71
|
+
note: 'Lecture par SoundCloud.',
|
|
72
|
+
noteLink: 'Politique de confidentialité',
|
|
73
|
+
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.',
|
|
74
|
+
policyLabel: 'Politique de confidentialité de SoundCloud',
|
|
75
|
+
policyHref: 'https://soundcloud.com/pages/privacy',
|
|
76
|
+
},
|
|
77
|
+
en: {
|
|
78
|
+
note: 'Plays from SoundCloud.',
|
|
79
|
+
noteLink: 'Privacy policy',
|
|
80
|
+
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.',
|
|
81
|
+
policyLabel: 'SoundCloud’s privacy policy',
|
|
82
|
+
policyHref: 'https://soundcloud.com/pages/privacy',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
/** Table order, which is the order of the privacy paragraphs and of `embedFrameHosts`. */
|
|
88
|
+
export const PROVIDER_KEYS = Object.keys(PROVIDERS);
|
|
89
|
+
/** The privacy section's heading, one per language, shared by every provider. */
|
|
90
|
+
export const EMBED_PRIVACY_HEADING = {
|
|
91
|
+
fr: 'Lecteurs vidéo et audio intégrés',
|
|
92
|
+
en: 'Embedded video and audio players',
|
|
93
|
+
};
|
|
94
|
+
/** The privacy section's anchor, which every facade's note links to. */
|
|
95
|
+
export const EMBED_PRIVACY_ID = 'lecteurs-integres';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type FacadeOptions } from './facade.js';
|
|
2
|
+
interface El {
|
|
3
|
+
type: string;
|
|
4
|
+
tagName?: string;
|
|
5
|
+
properties?: Record<string, unknown>;
|
|
6
|
+
children?: El[];
|
|
7
|
+
value?: string;
|
|
8
|
+
}
|
|
9
|
+
interface Ctx {
|
|
10
|
+
data: Record<string, unknown>;
|
|
11
|
+
parent(node: never): {
|
|
12
|
+
type: string;
|
|
13
|
+
} | undefined;
|
|
14
|
+
replaceNode(node: never, next: unknown): void;
|
|
15
|
+
appendChild(node: never, child: unknown): void;
|
|
16
|
+
}
|
|
17
|
+
export declare function rehypeEmbeds(o: FacadeOptions): {
|
|
18
|
+
name: string;
|
|
19
|
+
element: {
|
|
20
|
+
filter: string[];
|
|
21
|
+
visit: (node: El, ctx: Ctx) => void;
|
|
22
|
+
};
|
|
23
|
+
after(root: unknown, ctx: Ctx): void;
|
|
24
|
+
};
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
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(o) {
|
|
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, o) });
|
|
37
|
+
ctx.data[FLAG] = true;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function rehypeEmbeds(o) {
|
|
41
|
+
return {
|
|
42
|
+
name: 'pmeweb-embeds',
|
|
43
|
+
element: { filter: ['p'], visit: visitParagraph(o) },
|
|
44
|
+
after(root, ctx) {
|
|
45
|
+
if (ctx.data[FLAG] === true)
|
|
46
|
+
ctx.appendChild(root, { type: 'raw', value: ASSET_TAGS });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
drawn: ProviderKey[];
|
|
12
|
+
used: ProviderKey[];
|
|
13
|
+
privacy: ProviderKey[];
|
|
14
|
+
unconverted: {
|
|
15
|
+
path: string;
|
|
16
|
+
href: string;
|
|
17
|
+
}[];
|
|
18
|
+
}
|
|
19
|
+
export declare function scanPages(pages: Iterable<Page>): Scan;
|
|
20
|
+
/** The build's two refusals, as messages; empty when the site is wired. */
|
|
21
|
+
export declare function selfCheck(s: Scan): string[];
|
|
@@ -0,0 +1,54 @@
|
|
|
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(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
15
|
+
export function scanPages(pages) {
|
|
16
|
+
const thumbs = new Map();
|
|
17
|
+
const drawn = new Set();
|
|
18
|
+
const used = new Set();
|
|
19
|
+
const privacy = new Set();
|
|
20
|
+
const unconverted = [];
|
|
21
|
+
for (const { path, html } of pages) {
|
|
22
|
+
for (const m of html.matchAll(POSTER)) {
|
|
23
|
+
const [, provider, id] = m;
|
|
24
|
+
if (!isKey(provider))
|
|
25
|
+
continue;
|
|
26
|
+
if (id === 'drawn' && PROVIDERS[provider].poster.kind === 'drawn')
|
|
27
|
+
drawn.add(provider);
|
|
28
|
+
else
|
|
29
|
+
thumbs.set(`${provider}:${id}`, { provider, id });
|
|
30
|
+
}
|
|
31
|
+
for (const m of html.matchAll(FIGURE))
|
|
32
|
+
if (isKey(m[1]))
|
|
33
|
+
used.add(m[1]);
|
|
34
|
+
for (const m of html.matchAll(PRIVACY))
|
|
35
|
+
for (const k of m[1].split(' '))
|
|
36
|
+
if (isKey(k))
|
|
37
|
+
privacy.add(k);
|
|
38
|
+
for (const m of html.matchAll(LONE_LINK)) {
|
|
39
|
+
const href = unescapeHtml(m[1]);
|
|
40
|
+
if (embedFromLink(href, unescapeHtml(m[2])))
|
|
41
|
+
unconverted.push({ path, href });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { thumbnails: [...thumbs.values()], drawn: ordered(drawn), used: ordered(used), privacy: ordered(privacy), unconverted };
|
|
45
|
+
}
|
|
46
|
+
/** The build's two refusals, as messages; empty when the site is wired. */
|
|
47
|
+
export function selfCheck(s) {
|
|
48
|
+
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`);
|
|
49
|
+
for (const k of s.used) {
|
|
50
|
+
if (!s.privacy.includes(k))
|
|
51
|
+
errors.push(`embeds : la page de confidentialité ne mentionne pas ${PROVIDERS[k].label} -- ajoutez <EmbedPrivacy> avec embedProviders()`);
|
|
52
|
+
}
|
|
53
|
+
return errors;
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmeweb/content",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
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
|
}
|