Commit 8da2a826 by PLN (Algolia)

markdown: ::audio / ::youtube / ::soundcloud directives — native player tag +…

markdown: ::audio / ::youtube / ::soundcloud directives — native player tag + click-to-render embed facades

Directive target lives in the label (micromark's extension has no
::name[label](url) destination form); titles with parens/dashes must be
quoted in attributes. Facades ship as static HTML — YouTube touches
i.ytimg.com for the still only, SoundCloud touches nothing — and
ArticleEnhance swaps in the nocookie/w.soundcloud player on click.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 37637356
...@@ -26,8 +26,14 @@ import { visit } from 'unist-util-visit'; ...@@ -26,8 +26,14 @@ import { visit } from 'unist-util-visit';
* to light a directive up (e.g. `::note[Heads up]` renders the note tag below); * to light a directive up (e.g. `::note[Heads up]` renders the note tag below);
* an island swap later means pointing the tag at a component, nothing more. * an island swap later means pointing the tag at a component, nothing more.
*/ */
export const directiveComponents: Record<string, { tag: string; className: string }> = { export const directiveComponents: Record<
string,
{ tag: string; className: string; embed?: boolean }
> = {
note: { tag: 'div', className: 'directive-note' }, note: { tag: 'div', className: 'directive-note' },
audio: { tag: 'audio', className: 'directive-audio' },
youtube: { tag: 'div', className: 'embed-youtube', embed: true },
soundcloud: { tag: 'div', className: 'embed-soundcloud', embed: true },
}; };
/** remark plugin: register mapped directives as hast tags via data.hName. */ /** remark plugin: register mapped directives as hast tags via data.hName. */
...@@ -45,12 +51,52 @@ export function remarkDirectiveTags() { ...@@ -45,12 +51,52 @@ export function remarkDirectiveTags() {
if (!mapping) return; if (!mapping) return;
node.data = node.data || {}; node.data = node.data || {};
node.data.hName = mapping.tag; node.data.hName = mapping.tag;
node.data.hProperties = { className: [mapping.className] }; // Leaf directives carry their target in the label — micromark's
// directive extension does NOT parse a `::name[label](url)`
// destination form, so the agreed shapes are:
// ::audio[/path/file.mp3]{title=Accord}
// ::youtube[https://youtu.be/ID]{title=Demo}
// ::soundcloud[https://soundcloud.com/x/y]{title=Set}
// Plain media (`audio`) becomes a self-contained native tag; embeds
// (`youtube`, `soundcloud`) defer to rehypeEmbeds, which needs the
// raw target plus a title — passed through as data attributes.
const base: Record<string, unknown> = { className: [mapping.className] };
if (node.type === 'leafDirective') {
const label = nodeTextLike(node);
const target = node.url || label;
if (mapping.embed) {
if (target) base['dataUrl'] = target;
const title = node.attributes?.title || label;
if (title && title !== target) base['dataTitle'] = title;
} else if (target) {
// hast boolean attributes need true, not '' (empty string drops)
Object.assign(base, { src: target, controls: true, preload: 'none' });
// the label is the target — don't let it become tag content
node.children = [];
}
}
node.data.hProperties = base;
}); });
}; };
} }
/** /**
* Concatenated content of a directive label ("the [target] in ::name[target]").
* A bare URL in a label parses as a link child — take its href.
*/
function nodeTextLike(node: any): string {
if (!node.children) return '';
return node.children
.map((child: any) => {
if (child.type === 'text') return child.value;
if (child.type === 'link') return child.url;
return '';
})
.join('')
.trim();
}
/**
* rehype plugin: wrap a lone image paragraph in <figure>, with the alt text as * rehype plugin: wrap a lone image paragraph in <figure>, with the alt text as
* figcaption when present. Images sitting next to prose stay inline — only * figcaption when present. Images sitting next to prose stay inline — only
* paragraphs that are just an image get figured. * paragraphs that are just an image get figured.
...@@ -277,6 +323,104 @@ export function rehypeMarginNotes() { ...@@ -277,6 +323,104 @@ export function rehypeMarginNotes() {
}; };
} }
/* ------------------------------------------------------------------ *
* Embed facades — click-to-render, privacy-first
* ------------------------------------------------------------------ */
/** YouTube video id from the common URL shapes (watch, youtu.be, embed, shorts). */
export function youtubeId(url: string): string | null {
const m =
url.match(/youtu\.be\/([\w-]{6,})/) ||
url.match(/[?&]v=([\w-]{6,})/) ||
url.match(/youtube\.com\/embed\/([\w-]{6,})/) ||
url.match(/youtube\.com\/shorts\/([\w-]{6,})/);
return m ? m[1] : null;
}
function embedFacadeDiv(node: any): any {
const url = String(node.properties?.dataUrl || '');
const title = String(node.properties?.dataTitle || '');
if (node.tagName === 'div' && node.properties?.className?.includes('embed-youtube')) {
const id = youtubeId(url);
if (!id) return null;
// Privacy: the facade touches i.ytimg.com for the still only; the player
// iframe (youtube-nocookie) loads on click, wired by ArticleEnhance. The
// fallback link keeps no-JS readers a way in.
return {
type: 'element',
tagName: 'div',
properties: { className: ['embed-facade', 'embed-facade-youtube'], dataVideo: id },
children: [
{
type: 'element',
tagName: 'img',
properties: {
src: `https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
alt: '',
loading: 'lazy',
},
},
{
type: 'element',
tagName: 'button',
properties: { className: ['embed-play'], type: 'button', ariaLabel: title || 'Play video' },
children: [{ type: 'text', value: '▶' }],
},
{
type: 'element',
tagName: 'a',
properties: {
className: ['embed-fallback'],
href: `https://www.youtube.com/watch?v=${id}`,
},
children: [{ type: 'text', value: title || 'Watch on YouTube' }],
},
],
};
}
if (node.tagName === 'div' && node.properties?.className?.includes('embed-soundcloud')) {
// No remote requests at all: SoundCloud's widget accepts the raw track
// URL, so the facade is a styled placeholder until ArticleEnhance swaps
// in the w.soundcloud.com iframe on click.
return {
type: 'element',
tagName: 'div',
properties: { className: ['embed-facade', 'embed-facade-soundcloud'], dataEmbedUrl: url },
children: [
{
type: 'element',
tagName: 'button',
properties: { className: ['embed-play'], type: 'button', ariaLabel: title || 'Play audio' },
children: [{ type: 'text', value: '▶' }],
},
{
type: 'element',
tagName: 'a',
properties: { className: ['embed-fallback'], href: url },
children: [{ type: 'text', value: title || 'Listen on SoundCloud' }],
},
],
};
}
return null;
}
/**
* rehype plugin: turn the `::youtube` / `::soundcloud` directive divs into
* click-to-render facades. Nothing remote loads until the reader plays —
* except the YouTube still (one i.ytimg.com request, no cookies).
*/
export function rehypeEmbeds() {
return (tree: any) => {
visit(tree, 'element', (node: any, index: number, parent: any) => {
if (node.tagName !== 'div' || index === null || !parent) return;
const facade = embedFacadeDiv(node);
if (!facade) return;
parent.children[index] = facade;
});
};
}
const processor = unified() const processor = unified()
.use(remarkParse) .use(remarkParse)
.use(remarkGfm) .use(remarkGfm)
...@@ -284,6 +428,7 @@ const processor = unified() ...@@ -284,6 +428,7 @@ const processor = unified()
.use(remarkDirectiveTags) .use(remarkDirectiveTags)
.use(remarkRehype) .use(remarkRehype)
.use(rehypeFigure) .use(rehypeFigure)
.use(rehypeEmbeds)
.use(rehypeHeadingIds) .use(rehypeHeadingIds)
.use(rehypeMarginNotes) .use(rehypeMarginNotes)
// Dark theme on a dark pre (background comes from the blog CSS module — // Dark theme on a dark pre (background comes from the blog CSS module —
......
import { renderMarkdown } from '@/lib/markdown';
describe('markdown directives', () => {
test('::note maps to a div.directive-note', async () => {
const html = await renderMarkdown('::note[Heads up]\nBody text.');
expect(html).toContain('directive-note');
});
test('::audio with the target in the label becomes a native audio tag', async () => {
const html = await renderMarkdown('::audio[/audio/labo/test-chord.mp3]{title=accord}');
expect(html).toContain('<audio');
expect(html).toContain('src="/audio/labo/test-chord.mp3"');
expect(html).toContain('controls');
});
test('::youtube builds a click-to-render facade', async () => {
const html = await renderMarkdown(
'::youtube[https://www.youtube.com/watch?v=dQw4w9WgXcQ]{title="Vidéo de test"}',
);
expect(html).toContain('embed-facade-youtube');
expect(html).toContain('data-video="dQw4w9WgXcQ"');
expect(html).toContain('aria-label="Vidéo de test"');
expect(html).toContain('i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg');
expect(html).not.toContain('<iframe');
});
test('titles with parens or dashes must be quoted in attributes', async () => {
const quoted = await renderMarkdown(
'::youtube[https://youtu.be/dQw4w9WgXcQ]{title="Vidéo (remplacer)"}',
);
expect(quoted).toContain('aria-label="Vidéo (remplacer)"');
});
test('::soundcloud builds a placeholder facade, no remote assets', async () => {
const html = await renderMarkdown(
'::soundcloud[https://soundcloud.com/parvagues/live-39c3]{title=Set}',
);
expect(html).toContain('embed-facade-soundcloud');
expect(html).toContain('data-embed-url');
expect(html).not.toContain('w.soundcloud.com'); // iframe only on click
});
test('unknown directives are dropped silently', async () => {
const html = await renderMarkdown('::mystery[foo]');
expect(html).not.toContain('mystery');
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment