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
188 changes: 47 additions & 141 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/markdown-remark": "^7.2.2",
"@astrojs/markdown-remark": "^7.2.3",
"@astrojs/starlight": "^0.41.7",
"@iconify-json/lucide": "^1.2.123",
"@iconify-json/lucide": "^1.2.124",
"@iconify-json/simple-icons": "^1.2.93",
"astro": "^7.2.1",
"astro-icon": "^1.1.5",
"astro": "^7.2.3",
"astro-icon": "^1.2.0",
"hast-util-from-html": "^2.0.3",
"hast-util-sanitize": "^5.0.2",
"hast-util-to-html": "^9.0.5",
Expand Down
7 changes: 7 additions & 0 deletions public/signal/theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@
});
}

function syncChangelogPictures(theme) {
document.querySelectorAll("picture source[data-changelog-theme]").forEach(function (source) {
source.media = source.getAttribute("data-changelog-theme") === theme ? "all" : "not all";
});
}

function syncIcons(preference) {
document.querySelectorAll("[data-theme-icon]").forEach(function (icon) {
fillIcon(icon, preference);
Expand Down Expand Up @@ -123,6 +129,7 @@
document.documentElement.dataset.theme = theme;
storePreference(preference);
syncLogos(theme);
syncChangelogPictures(theme);
syncControls(preference);
}

Expand Down
2 changes: 1 addition & 1 deletion src/lib/changelog-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const changelogSanitizeSchema = {
span: [...(defaultSchema.attributes?.span ?? []), 'className', 'style'],
div: [...(defaultSchema.attributes?.div ?? []), 'className'],
video: ['src', 'controls', 'muted', 'playsInline', 'poster', 'preload', 'width', 'height'],
source: ['src', 'type'],
source: ['src', 'srcSet', 'type', 'media', 'dataChangelogTheme'],
},
};

Expand Down
54 changes: 40 additions & 14 deletions src/plugins/remark-cache-changelog-images.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ const ALLOWED_HOSTS = new Set([
const ALLOWED_DOWNLOAD_HOSTS = new Set([
...ALLOWED_HOSTS,
'github-production-user-asset-6210df.s3.amazonaws.com',
'release-assets.githubusercontent.com',
]);

// Raster formats only — remote SVGs stay on their origin so active content
// never becomes a same-origin navigable asset on the site.
const SUPPORTED_TYPES = new Set(['image/gif', 'image/jpeg', 'image/png', 'image/webp']);
const SUPPORTED_FORMATS = new Set(['gif', 'jpeg', 'png', 'webp']);
const OPTIMIZED_SUFFIX = '.optimized.webp';
const VIDEO_TYPES = new Map([
['video/mp4', '.mp4'],
Expand Down Expand Up @@ -139,6 +141,7 @@ async function fetchAsset(url, accept) {
return {
temporaryPath,
contentType: response.headers.get('content-type')?.split(';')[0]?.trim(),
downloadHost: new URL(response.url).hostname,
};
} catch (error) {
await rm(temporaryPath, { force: true });
Expand Down Expand Up @@ -206,23 +209,30 @@ async function downloadImage(url) {

const asset = await fetchAsset(url, 'image/*');
try {
if (!asset.contentType || !SUPPORTED_TYPES.has(asset.contentType)) {
const isGithubReleaseAsset =
asset.downloadHost === 'release-assets.githubusercontent.com' &&
asset.contentType === 'application/octet-stream';
if (!asset.contentType || (!SUPPORTED_TYPES.has(asset.contentType) && !isGithubReleaseAsset)) {
throw new Error(`Unsupported image type: ${asset.contentType ?? 'unknown'}`);
}

const filename = `${id}${OPTIMIZED_SUFFIX}`;
const cachePath = resolve(cacheDirectory, filename);
const publicPath = resolve(publicDirectory, filename);
const temporaryPath = `${cachePath}.tmp`;
const info = asset.contentType === 'image/webp'
? await sharp(asset.temporaryPath, {
animated: true,
limitInputPixels: false,
}).metadata()
const metadata = await sharp(asset.temporaryPath, {
animated: true,
limitInputPixels: false,
}).metadata();
if (!metadata.format || !SUPPORTED_FORMATS.has(metadata.format)) {
throw new Error(`Unsupported image format: ${metadata.format ?? 'unknown'}`);
}
const info = metadata.format === 'webp'
? metadata
: await sharp(asset.temporaryPath, { animated: true, autoOrient: true })
.webp()
.toFile(temporaryPath);
if (asset.contentType === 'image/webp') {
if (metadata.format === 'webp') {
await copyFile(asset.temporaryPath, temporaryPath);
}
await rename(temporaryPath, cachePath);
Expand Down Expand Up @@ -367,22 +377,37 @@ export function rehypeCacheChangelogImages() {
return async (tree) => {
const images = [];
visit(tree, 'element', (node) => {
if (node.tagName !== 'img') return;
const src = node.properties?.src;
if (typeof src === 'string' && isCacheableUrl(src)) images.push(node);
const property = node.tagName === 'img' ? 'src' : node.tagName === 'source' ? 'srcSet' : null;
const src = property ? node.properties?.[property] : undefined;
if (typeof src === 'string' && isCacheableUrl(src)) images.push({ node, property });
});
if (!images.length) return;
await rewriteBatch(
images,
(node) => node.properties.src,
(node, asset) => {
node.properties.src = asset.src;
addImageProperties(node.properties, asset);
({ node, property }) => node.properties[property],
({ node, property }, asset) => {
node.properties[property] = asset.src;
if (node.tagName === 'img') addImageProperties(node.properties, asset);
},
);
};
}

function markThemePictures(tree) {
visit(tree, 'element', (node) => {
if (node.tagName !== 'picture') return;
for (const child of node.children ?? []) {
if (child.type !== 'element' || child.tagName !== 'source') continue;
const media = child.properties?.media;
if (media === '(prefers-color-scheme: dark)') {
child.properties.dataChangelogTheme = 'dark';
} else if (media === '(prefers-color-scheme: light)') {
child.properties.dataChangelogTheme = 'light';
}
}
});
}

export default remarkCacheChangelogImages;

/**
Expand Down Expand Up @@ -412,6 +437,7 @@ export async function copyCachedChangelogImages(distDirectory) {
export async function cacheImagesInHtml(html) {
const tree = fromHtml(html, { fragment: true });
await rehypeCacheChangelogImages()(tree);
markThemePictures(tree);
const videos = [];
visit(tree, 'element', (node, index, parent) => {
if (
Expand Down
57 changes: 57 additions & 0 deletions tests/changelog-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, expect, test } from 'bun:test';
import { createHash } from 'node:crypto';
import { rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fromHtml } from 'hast-util-from-html';
import { toHtml } from 'hast-util-to-html';
import { renderChangelogBodies } from '../src/lib/changelog-markdown';
Expand Down Expand Up @@ -32,6 +35,60 @@ describe('renderChangelogBodies', () => {
expect(html).toContain(`<a href="${attachment}"`);
});

test('caches GitHub release theme pictures and marks their variants', async () => {
const dark =
'https://github.com/OpenTubeX/media/releases/download/attachments/test-settings-dark.png';
const light =
'https://github.com/OpenTubeX/media/releases/download/attachments/test-settings-light.png';
const cacheFilename = (url: string) =>
`${createHash('sha1').update(url).digest('hex').slice(0, 20)}.optimized.webp`;
const cachedFiles = [dark, light].map(cacheFilename);
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
const originalFetch = globalThis.fetch;
let fetchCount = 0;
globalThis.fetch = async () => {
fetchCount += 1;
const response = new Response(png, {
headers: {
'content-length': String(png.byteLength),
'content-type': 'application/octet-stream',
},
});
Object.defineProperty(response, 'url', {
value: `https://release-assets.githubusercontent.com/test-${fetchCount}`,
});
return response;
};

try {
const [html] = await renderChangelogBodies([
`<picture>\n <source media="(prefers-color-scheme: dark)" srcset="${dark}">\n <source media="(prefers-color-scheme: light)" srcset="${light}">\n <img alt="Settings" src="${dark}">\n</picture>`,
]);

expect(fetchCount).toBe(2);
expect(html).toContain(
`<source media="(prefers-color-scheme: dark)" srcset="/changelog-images/${cachedFiles[0]}" data-changelog-theme="dark">`,
);
expect(html).toContain(
`<source media="(prefers-color-scheme: light)" srcset="/changelog-images/${cachedFiles[1]}" data-changelog-theme="light">`,
);
expect(html).toContain(
`<img alt="Settings" src="/changelog-images/${cachedFiles[0]}" width="1" height="1"`,
);
} finally {
globalThis.fetch = originalFetch;
await Promise.all(
cachedFiles.flatMap((file) => [
rm(resolve('.cache/changelog-images', file), { force: true }),
rm(resolve('public/changelog-images', file), { force: true }),
]),
);
}
});

test('restores standalone non-video attachments as links', async () => {
const attachment =
'https://github.com/user-attachments/assets/00000000-0000-4000-8000-000000000000';
Expand Down