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, '');

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 Preserve source path in mirrored asset names

Because assetName keeps only the final path segment, two different source assets in the same article month with common names like cover.jpg or image.png are both written to the same public/blog-assets/YYYY/MM/<name> path and then both posts point at that same URL. The later fetch in the sync loop overwrites the earlier file, so one article renders the wrong thumbnail/body image; include the slug/source path or a hash in the mirrored filename.

Useful? React with 👍 / 👎.

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 Emit absolute image URLs in article JSON-LD

Returning root-relative paths here changes post.thumbnail from an absolute raw URL to /blog-assets/...; checked src/components/BlogArticlePage.astro and it still puts thumbnailUrl directly into jsonLd.image, while BaseLayout only normalizes the separate OG/Twitter image prop. For every mirrored article, the emitted BlogPosting JSON-LD therefore contains a relative image URL, which can make structured-data image discovery fail even though the meta tags are correct; normalize the JSON-LD image against the site origin.

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