Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/cardtile/card-render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,12 @@ function renderCell(cell, place, ctx) {
// Absent — no backdrop of a network, YouTube down, a channel with no uploads — `hit` is
// undefined and everything below falls through to the degraded state, by design.
const hit = chan && ctx.videos ? ctx.videos[chan] : null;
// A card may write the channel as `@handle` (what the share button gives a person). The Worker
// resolved it to the UC id alongside the latest video; when it could not (YouTube down, handle
// gone), the play button cannot build an uploads playlist from a handle, so it opens the channel
// page instead — a door that goes somewhere, not a player that plays nothing.
const chanId = hit && hit.channelId ? hit.channelId : chan;
const chanIsId = /^UC[\w-]{22}$/.test(chanId);
// Poster and playback name the SAME video. The alternative was to keep playing the uploads
// playlist (always truly newest) under a poster from our cache, which shows one video and
// plays another whenever the cache is a few minutes behind. Consistency wins; the cache TTL
Expand All @@ -520,7 +526,9 @@ function renderCell(cell, place, ctx) {
// exists to prevent, reintroduced through an <img>. The Worker proxies it (see /_yt/ there).
const poster = p.poster || (vid ? posterPath(vid) : '');
const label = b || p.title || (hit && hit.title) || 'YouTube';
const attrs = vid ? `data-yt="${esc(vid)}"` : `data-yt-channel="${esc(chan)}"`;
const attrs = vid ? `data-yt="${esc(vid)}"`
: chanIsId ? `data-yt-channel="${esc(chanId)}"`
: `data-yt-handle="${esc(chan)}"`;
return `<div class="st-cell st-embed-video" ${size} data-coral-type="video"><button type="button" class="st-embed-video-btn${poster ? '' : ' st-embed-video-noposter'}" ${attrs} aria-label="${esc(label)}">${poster ? `<img class="st-embed-video-poster" src="${esc(poster)}" alt="" loading="lazy" referrerpolicy="no-referrer">` : ''}<span class="st-embed-video-play" aria-hidden="true"><svg viewBox="0 0 68 48" width="54" height="38"><path d="M66.5 7.7a8.6 8.6 0 0 0-6-6C55.3 0 34 0 34 0S12.7 0 7.5 1.6a8.6 8.6 0 0 0-6 6.1A90 90 0 0 0 0 24a90 90 0 0 0 1.5 16.3 8.6 8.6 0 0 0 6 6C12.7 48 34 48 34 48s21.3 0 26.5-1.6a8.6 8.6 0 0 0 6-6.1A90 90 0 0 0 68 24a90 90 0 0 0-1.5-16.3z" fill="#f00"/><path d="M27 34l18-10-18-10z" fill="#fff"/></svg></span>${label ? `<span class="st-embed-video-label">${esc(label)}</span>` : ''}</button></div>`;
}
case 'social': {
Expand Down Expand Up @@ -1365,7 +1373,9 @@ export function renderPage(model, ctx = {}) {
// single byte or cookie from YouTube before the click.
const VIDEO_JS = `(function(){document.addEventListener('click',function(e){
var b=e.target.closest&&e.target.closest('.st-embed-video-btn');if(!b)return;
var id=b.getAttribute('data-yt');var ch=b.getAttribute('data-yt-channel');if(!id&&!ch)return;
var id=b.getAttribute('data-yt');var ch=b.getAttribute('data-yt-channel');var hd=b.getAttribute('data-yt-handle');
if(hd&&!id&&!ch){window.open('https://www.youtube.com/'+encodeURIComponent(hd)+'/videos','_blank','noopener');return;}
if(!id&&!ch)return;
var f=document.createElement('iframe');
f.src=id?('https://www.youtube-nocookie.com/embed/'+encodeURIComponent(id)+'?autoplay=1&modestbranding=1&rel=0')
:('https://www.youtube-nocookie.com/embed/videoseries?list=UU'+encodeURIComponent(ch.replace(/^UC/,''))+'&autoplay=1&modestbranding=1&rel=0');
Expand Down
13 changes: 9 additions & 4 deletions packages/cardtile/serve/card-worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ import { parseCard, serializeCard } from '../card-core.js';
import { renderPage } from '../card-render.mjs';
import { CSS, ICONS, QR_JS, QR_VERSION, DRAWER_JS, DRAWER_VERSION, ARROW } from './card-assets.mjs';
import { resolveChannels, VIDEO_RE } from '../yt.mjs';

// One fetch per channel, not per card or visitor (yt.mjs, condition 2). A feed is cached for 30
// minutes — that TTL is what bounds how old "latest" can be. A handle page (`youtube.com/@name`,
// resolved to the UC id) is cached for a day: it is a couple of MB, and a handle's id does not change.
const ytFetch = (u) => fetch(u, {
cf: { cacheTtl: /youtube\.com\/@/.test(u) ? 86400 : 1800, cacheEverything: true },
});
import { ICON_DOMAIN_RE, iconUpstream } from '../marks.mjs';
import { handleApi, previewKey, tryKey, TRY_TTL, TRY_MAX_BYTES } from './card-api.mjs';
import { EDIT2_BODY_HTML, EDIT2_CSS, EDIT2_JS, TABLE_FILES, TABLE_I18N, FONT_FILES } from './edit2-assets.mjs';
Expand Down Expand Up @@ -490,7 +497,7 @@ export default {
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow' },
});
}
const videos = await resolveChannels(draft, (u) => fetch(u, { cf: { cacheTtl: 1800, cacheEverything: true } }));
const videos = await resolveChannels(draft, ytFetch);
return new Response(renderCardHTML(draft, { handle: '', cardUrl: '', videos }), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
Expand Down Expand Up @@ -620,9 +627,7 @@ export default {
// Failure is not propagated. resolveChannels never throws and returns {} when YouTube is
// unreachable, which lands the block on its designed degraded state (condition 3). A creator's
// card must not go down because a video platform did.
const videos = await resolveChannels(cardMd, (u) => fetch(u, {
cf: { cacheTtl: 1800, cacheEverything: true },
}));
const videos = await resolveChannels(cardMd, ytFetch);

return html(renderCardHTML(cardMd, { ...resolved, videos }), 200, env, resolved.handle);
},
Expand Down
4 changes: 2 additions & 2 deletions packages/cardtile/serve/edit2-assets.mjs

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions packages/cardtile/w/cell-form-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,10 @@ export function formBodyHtml(def, cell, ctx) {
*/
const YT_ID = /^[\w-]{11}$/;
const YT_CH = /^UC[\w-]{22}$/;
const YT_HANDLE = /^@[\w.-]{3,30}$/; // same shape as yt.mjs HANDLE_RE; the Worker resolves it
export function youtubeRef(input) {
const s = String(input == null ? '' : input).trim();
if (!s || YT_ID.test(s) || YT_CH.test(s)) return s;
if (!s || YT_ID.test(s) || YT_CH.test(s) || YT_HANDLE.test(s)) return s;
let u;
try { u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`); } catch { return s; }
const host = u.hostname.toLowerCase().replace(/^(www|m|music)\./, '');
Expand All @@ -167,8 +168,9 @@ export function youtubeRef(input) {
if (seg[0] === 'watch') got = u.searchParams.get('v') || '';
else if (['shorts', 'embed', 'live', 'v'].includes(seg[0])) got = seg[1] || '';
else if (seg[0] === 'channel') got = seg[1] || '';
else if (seg[0] && seg[0].startsWith('@')) got = decodeURIComponent(seg[0]); // youtube.com/@name(/videos)
}
return YT_ID.test(got) || YT_CH.test(got) ? got : s;
return YT_ID.test(got) || YT_CH.test(got) || YT_HANDLE.test(got) ? got : s;
}

/**
Expand Down
18 changes: 9 additions & 9 deletions packages/cardtile/w/cell-i18n.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -315,15 +315,15 @@ export const CELL_STRINGS_BY_KEY = {
pt: 'Ou: mostrar sempre o vídeo mais recente deste canal',
},
'type.video.channel.hint': {
zh: '貼上頻道的網址,或只填 UC 開頭的那一串,例如 youtube.com/channel/UC…。',
ja: 'チャンネルのリンクを貼り付けるか、UC で始まる部分だけを入力します。例:youtube.com/channel/UC…',
en: 'Paste the channel’s link, or just the part starting with UC — like youtube.com/channel/UC….',
ko: '채널 링크를 붙여 넣거나 UC로 시작하는 부분만 적어요. 예: youtube.com/channel/UC…',
'zh-Hans': '粘贴频道的网址,或只填 UC 开头的那一串,例如 youtube.com/channel/UC…。',
de: 'Fügen Sie den Link des Kanals ein oder nur den Teil, der mit UC beginnt – etwa youtube.com/channel/UC….',
fr: 'Collez le lien de la chaîne, ou seulement la partie qui commence par UC — par exemple youtube.com/channel/UC….',
es: 'Pega el enlace del canal o solo la parte que empieza por UC, por ejemplo youtube.com/channel/UC….',
pt: 'Cole o link do canal, ou só a parte que começa com UC — por exemplo youtube.com/channel/UC….',
zh: '貼上頻道的網址,例如 youtube.com/@名稱 或 youtube.com/channel/UC…;也可以只填 @名稱。',
ja: 'チャンネルのリンクを貼り付けます。例:youtube.com/@name または youtube.com/channel/UC…。@name だけでも構いません。',
en: 'Paste the channel’s link — like youtube.com/@name or youtube.com/channel/UC…. Just @name works too.',
ko: '채널 링크를 붙여 넣어요. 예: youtube.com/@이름 또는 youtube.com/channel/UC…. @이름만 적어도 돼요.',
'zh-Hans': '粘贴频道的网址,例如 youtube.com/@名称 或 youtube.com/channel/UC…;也可以只填 @名称。',
de: 'Fügen Sie den Link des Kanals ein – etwa youtube.com/@name oder youtube.com/channel/UC…. Nur @name geht auch.',
fr: 'Collez le lien de la chaîne — par exemple youtube.com/@nom ou youtube.com/channel/UC…. Le simple @nom suffit aussi.',
es: 'Pega el enlace del canal, por ejemplo youtube.com/@nombre o youtube.com/channel/UC…. Solo @nombre también vale.',
pt: 'Cole o link do canal — por exemplo youtube.com/@nome ou youtube.com/channel/UC…. Só @nome também serve.',
},
'type.video.body.label': {
zh: '影片標題(選填)', ja: '動画のタイトル(任意)', en: 'Video title (optional)',
Expand Down
6 changes: 5 additions & 1 deletion packages/cardtile/w2/three-questions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,16 @@ test('youtubeRef: what the share button gives a person → the id or channel cod
[` ${ID} `, ID],
[`https://www.youtube.com/channel/${CH}`, CH],
[`youtube.com/channel/${CH}/videos`, CH],
['https://www.youtube.com/@Some.Body', '@Some.Body'], // the channel page's share button
['youtube.com/@some_body/videos?si=abc', '@some_body'],
['https://m.youtube.com/@somebody', '@somebody'],
['@somebody', '@somebody'],
[CH, CH],
['', ''],
];
for (const [input, want] of table) assert.equal(youtubeRef(input), want, input);
// controls: things that are not a video or channel are left exactly as typed
for (const other of ['https://video.example/123456789', 'https://example.com/watch?v=dQw4w9WgXcQ', 'youtube.com/@somebody', 'hello']) {
for (const other of ['https://video.example/123456789', 'https://example.com/watch?v=dQw4w9WgXcQ', 'youtube.com/@ab', 'hello']) {
assert.equal(youtubeRef(other), other, other);
}
});
Expand Down
69 changes: 64 additions & 5 deletions packages/cardtile/yt.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@

/** A channel id is `UC` + 22 url-safe chars. Anything else never reaches a URL. */
export const CHANNEL_RE = /^UC[\w-]{22}$/;
/**
* A handle is what a creator actually knows about their channel: `@name`, 3–30 of letters, digits,
* underscore, period, hyphen — the shape YouTube enforces. Nobody knows their UC id; everybody knows
* their handle, and the channel page's share button hands out `youtube.com/@name`. A handle in the
* card is resolved to the UC id here, server-side, on its own cache key (condition 2), and the
* visitor's browser still never talks to YouTube before pressing play (condition 4).
*/
export const HANDLE_RE = /^@[\w.-]{3,30}$/;
/** A channel reference as a card may write it: a UC id or an @handle. */
export const isChannelRef = (s) => CHANNEL_RE.test(s) || HANDLE_RE.test(s);
/** A video id is 11 url-safe chars. */
export const VIDEO_RE = /^[\w-]{11}$/;

Expand All @@ -42,8 +52,8 @@ export const posterPath = (videoId) => `/_yt/${videoId}.jpg`;
*/
export function channelsIn(cardMd) {
const out = new Set();
for (const m of String(cardMd || '').matchAll(/\bchannel\s*=\s*"?([A-Za-z0-9_-]+)"?/g)) {
if (CHANNEL_RE.test(m[1])) out.add(m[1]);
for (const m of String(cardMd || '').matchAll(/\bchannel\s*=\s*"?(@?[A-Za-z0-9_.-]+)"?/g)) {
if (isChannelRef(m[1])) out.add(m[1]);
}
return [...out];
}
Expand All @@ -67,20 +77,66 @@ export function latestFromFeed(xml) {
return { id: id[1], title: title ? unescape(title[1]) : '' };
}

/**
* The channel id a handle page names. 🔴 Read from `<meta itemprop="identifier">` (with the canonical
* link as the fallback), NOT from the first `"channelId":"UC…"` in the page's JSON — measured on a
* real page, that key appears three times before the channel's own id and every one of them is
* another channel (a recommended one). The identifier meta is the page saying who it is.
*/
export function channelIdFromPage(html) {
const s = String(html || '');
const meta = /<meta\s+itemprop="identifier"\s+content="(UC[\w-]{22})"/.exec(s)
|| /<link\s+rel="canonical"\s+href="https:\/\/www\.youtube\.com\/channel\/(UC[\w-]{22})"/.exec(s);
return meta && CHANNEL_RE.test(meta[1]) ? meta[1] : null;
}

/**
* `@handle` → `UC…`, or null. Same shape as resolveChannel: injected fetch, a deadline, never throws.
* The handle page is public and keyless (condition 1); it is a few hundred KB to a couple of MB, which
* is why the Worker caches it for a day — a handle's id does not change — and why this is one fetch
* per handle, not per card or per visitor (condition 2).
*/
export async function resolveHandle(handle, doFetch, timeoutMs = TIMEOUT_MS) {
if (!HANDLE_RE.test(handle)) return null;
let timer;
try {
const page = doFetch(`https://www.youtube.com/${handle}`)
.then(async (r) => (r && r.ok ? channelIdFromPage(await r.text()) : null));
return await Promise.race([
page,
new Promise((ok) => { timer = setTimeout(() => ok(null), timeoutMs); }),
]);
} catch {
return null;
} finally {
clearTimeout(timer);
}
}

/**
* Resolve one channel. `doFetch` is injected so this is testable without a network and so the Worker
* can hand in Cloudflare's caching fetch.
*
* Takes a UC id or an @handle. The answer carries `channelId` either way, so the renderer can play
* the uploads playlist for a card that wrote the handle — the card's markdown keeps the handle, which
* is the form a person or an agent can read and check.
*
* 🔴 Never throws. A live block that cannot resolve must land on the degraded state, which is already
* designed and already looks like a video you have not opened — not on a 500 that takes the whole
* card down with it. The creator's card is not allowed to depend on YouTube being up.
*/
export async function resolveChannel(channelId, doFetch, timeoutMs = TIMEOUT_MS) {
export async function resolveChannel(ref, doFetch, timeoutMs = TIMEOUT_MS) {
let channelId = ref;
if (HANDLE_RE.test(ref)) {
channelId = await resolveHandle(ref, doFetch, timeoutMs);
if (!channelId) return null;
}
if (!CHANNEL_RE.test(channelId)) return null;
let timer;
try {
const feed = doFetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`)
.then(async (r) => (r && r.ok ? latestFromFeed(await r.text()) : null));
.then(async (r) => (r && r.ok ? latestFromFeed(await r.text()) : null))
.then((hit) => (hit ? { ...hit, channelId } : null));
// 🔴 A DEADLINE, because this now sits on the render path. Without it a slow or hanging YouTube
// does not degrade the video block — it holds up the creator's entire card, and the four
// conditions are satisfied right up until the page never arrives. Losing the poster is the
Expand All @@ -97,7 +153,10 @@ export async function resolveChannel(channelId, doFetch, timeoutMs = TIMEOUT_MS)
}
}

/** Resolve every channel a card mentions, concurrently. Returns { [channelId]: {id, title} }. */
/**
* Resolve every channel a card mentions, concurrently. Returns { [ref]: {id, title, channelId} } —
* keyed by what the card WROTE (UC id or @handle), because that is what the renderer looks up.
*/
export async function resolveChannels(cardMd, doFetch) {
const ids = channelsIn(cardMd);
if (!ids.length) return {};
Expand Down
Loading
Loading