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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/blog-assets/2026/07/it-camp-2026.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/blog-assets/2026/07/onno-w-purbo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 40 additions & 2 deletions scripts/sync-blog-posts.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { mkdir, writeFile } from 'node:fs/promises';

const BLOG_REPO = 'IndopenSource/Blog-IndopenSource';
const OUT_FILE = new URL('../src/data/blog-posts.json', import.meta.url);
const BLOG_ASSET_DIR = new URL('../public/blog-assets/', import.meta.url);
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;

async function requestJson(url) {
Expand Down Expand Up @@ -156,6 +157,38 @@ function resolveThumbnail(value, articlePath, branch) {
return new URL(value, `https://raw.githubusercontent.com/${BLOG_REPO}/${branch}/${articleDirectory}/`).toString();
}

async function mirrorBlogAsset(value, articlePath, branch) {
const resolved = resolveThumbnail(value, articlePath, branch);
if (!resolved.startsWith(`https://raw.githubusercontent.com/${BLOG_REPO}/`)) return resolved;

const assetName = new URL(resolved).pathname.split('/').pop()?.replace(/[^A-Za-z0-9._-]/g, '');
if (!assetName) return resolved;

const [year, month] = articlePath.split('/').slice(1, 3);
const assetDirectory = new URL(`${year}/${month}/`, BLOG_ASSET_DIR);
const response = await fetch(resolved);
if (!response.ok) {
console.warn(`Could not mirror blog asset ${resolved}: HTTP ${response.status}`);
return resolved;
}

await mkdir(assetDirectory, { recursive: true });
await writeFile(new URL(assetName, assetDirectory), Buffer.from(await response.arrayBuffer()));
return `/blog-assets/${year}/${month}/${assetName}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect ASTRO_BASE for mirrored body images

When the site is built with a non-root base path, such as the CI build's ASTRO_BASE=/indopensource.org, this root-relative URL is inserted verbatim into article content by mirrorContentAssets; that HTML is later emitted through set:html, so unlike hero thumbnails it never passes through withBase. Inline blog images therefore request /blog-assets/... from the domain root instead of /<base>/blog-assets/... and break on sub-path previews/forks. Store a base-aware URL or rewrite body image src values during render.

Useful? React with 👍 / 👎.

}

async function mirrorContentAssets(content, articlePath, branch) {
const rawAssetUrls = [...new Set(
content.match(/https:\/\/raw\.githubusercontent\.com\/IndopenSource\/Blog-IndopenSource\/[^\s"'<>)]*/g) || []
)];

for (const url of rawAssetUrls) {
content = content.replaceAll(url, await mirrorBlogAsset(url, articlePath, branch));
}

return content;
}

async function getCommitMeta(path) {
const commits = await requestJson(`https://api.github.com/repos/${BLOG_REPO}/commits?path=${encodeURIComponent(path)}&per_page=100`);
const firstCommit = commits.at(-1);
Expand Down Expand Up @@ -269,6 +302,8 @@ for (const path of articleFiles) {
)
]
: commitMeta.authors.length ? commitMeta.authors : [resolvedAuthor.author];
const thumbnail = await mirrorBlogAsset(data.thumbnail || data.image || data.cover || '', path, defaultBranch);
const mirroredContent = await mirrorContentAssets(content, path, defaultBranch);

posts.push({
slug: slugFromPath(path),
Expand All @@ -282,8 +317,11 @@ for (const path of articleFiles) {
status: data.status || 'draft',
lang: data.lang === 'en' ? 'en' : 'id',
translationKey: data.translationKey || '',
thumbnail: resolveThumbnail(data.thumbnail || data.image || data.cover || '', path, defaultBranch),
content,
thumbnail,
thumbnailWidth: Number(data.thumbnailWidth) || 0,
thumbnailHeight: Number(data.thumbnailHeight) || 0,
thumbnailType: data.thumbnailType || '',
content: mirroredContent,
sourceUrl: file.html_url,
// The visible "release" date is the editorial frontmatter `date` (the date
// the author intended), falling back to the first commit only when absent.
Expand Down
7 changes: 6 additions & 1 deletion src/components/BlogArticlePage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const releaseDate = post.releasedAt
: '';
const thumbnail = post.thumbnail || '/brand/indopensource-hero.jpg';
const thumbnailUrl = /^https?:\/\//.test(thumbnail) ? thumbnail : withBase(thumbnail);
const thumbnailWidth = post.thumbnailWidth || 1280;
const thumbnailHeight = post.thumbnailHeight || 720;
const heroAlt = english ? `Article thumbnail: ${post.title}` : `Thumbnail artikel: ${post.title}`;
const authorByline = post.authorFromFrontmatter
? english
Expand Down Expand Up @@ -65,6 +67,9 @@ const jsonLd = {
title={seoTitle}
description={post.description}
image={thumbnailUrl}
imageWidth={post.thumbnailWidth}
imageHeight={post.thumbnailHeight}
imageType={post.thumbnailType}
type="article"
robots={published ? undefined : 'noindex, nofollow, noarchive'}
publishedAt={published ? post.releasedAt : undefined}
Expand Down Expand Up @@ -99,7 +104,7 @@ const jsonLd = {
))}
</div>
<figure class="riso-hover riso-shadow mt-8 overflow-hidden border-2 border-ink bg-ink">
<img class="aspect-[16/9] w-full object-cover" src={thumbnailUrl} alt={heroAlt} width="1280" height="720" loading="eager" fetchpriority="high" decoding="async" />
<img class="aspect-[16/9] w-full object-cover" src={thumbnailUrl} alt={heroAlt} width={thumbnailWidth} height={thumbnailHeight} loading="eager" fetchpriority="high" decoding="async" />
</figure>
</PageHeader>

Expand Down
72 changes: 45 additions & 27 deletions src/data/blog-posts.json

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ interface Props {
description?: string;
/** Open Graph / Twitter image, absolute or site-relative. */
image?: string;
imageWidth?: number;
imageHeight?: number;
imageType?: string;
/** Open Graph `og:type` (e.g. `website`, `article`). */
type?: string;
/** Search-engine indexing policy for this page. */
Expand Down Expand Up @@ -38,6 +41,9 @@ const {
title = 'IndopenSource',
description = 'Roadmap website IndopenSource untuk proyek open source Indonesia.',
image = '/brand/indopensource-hero.jpg',
imageWidth,
imageHeight,
imageType,
type = 'website',
robots = 'index, follow',
publishedAt,
Expand Down Expand Up @@ -210,6 +216,9 @@ const csp = [
<meta property="og:url" content={canonicalUrl} />
<meta property="og:image" content={imageUrl} />
<meta property="og:image:alt" content={title} />
{imageWidth && <meta property="og:image:width" content={String(imageWidth)} />}
{imageHeight && <meta property="og:image:height" content={String(imageHeight)} />}
{imageType && <meta property="og:image:type" content={imageType} />}
{type === 'article' && publishedAt && <meta property="article:published_time" content={publishedAt} />}
{type === 'article' && modifiedAt && <meta property="article:modified_time" content={modifiedAt} />}
<meta name="twitter:card" content="summary_large_image" />
Expand Down
3 changes: 3 additions & 0 deletions src/lib/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export interface BlogPost {
/** Shared key used to connect translated versions of one article. */
translationKey?: string;
thumbnail: string;
thumbnailWidth?: number;
thumbnailHeight?: number;
thumbnailType?: string;
content: string;
sourceUrl: string;
/** Editorial publication date (frontmatter `date`), falling back to commit metadata. */
Expand Down
16 changes: 16 additions & 0 deletions test/lib.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
// redefining any logic here, so the tests fail if that behaviour regresses.

import { strict as assert } from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { describe, it } from 'node:test';

import { projectSlug, rankProjectOwners } from '../src/lib/projects.ts';
Expand Down Expand Up @@ -138,6 +139,21 @@ describe('article language routing', () => {
});
});

describe('synced article thumbnails', () => {
it('uses same-origin files with complete Open Graph metadata', () => {
const posts = JSON.parse(readFileSync(new URL('../src/data/blog-posts.json', import.meta.url), 'utf8'));

for (const post of posts) {
assert.match(post.thumbnail, /^\/blog-assets\//);
assert.ok(existsSync(new URL(`../public${post.thumbnail}`, import.meta.url)), `missing ${post.thumbnail}`);
assert.ok(post.thumbnailWidth > 0);
assert.ok(post.thumbnailHeight > 0);
assert.match(post.thumbnailType, /^image\//);
assert.doesNotMatch(post.content, /raw\.githubusercontent\.com/);
}
});
});

describe('renderArticle (frontmatter content)', () => {
it('renders Markdown to HTML', () => {
const html = renderArticle('# Title\n\nA **bold** paragraph.');
Expand Down