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
3 changes: 3 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import partytown from '@astrojs/partytown';
import rehypeToc from 'rehype-toc';
import rehypeSlug from 'rehype-slug';
import remarkLinkCard from 'remark-link-card-plus';
import { remarkMermaidInjector } from './src/plugins/remark/remark-mermaid-injector.mjs';

// https://astro.build/config
export default defineConfig({
Expand All @@ -26,8 +27,10 @@ export default defineConfig({
}),
],
markdown: {
excludeLangs: ['mermaid'],
rehypePlugins: [rehypeSlug, [rehypeToc, { headings: ['h2', 'h3', 'h4'] }]],
remarkPlugins: [
remarkMermaidInjector,
[
remarkLinkCard,
{ cache: false, shortenUrl: true, thumbnailPosition: 'left' },
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@
"prettier": "^3.2.5",
"prettier-plugin-astro": "^0.13.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"sass": "^1.77.4"
"sass": "^1.77.4",
"unist-util-visit": "^5.0.0"
},
"pnpm": {
"overrides": {
Expand Down
13 changes: 4 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions src/layouts/BlogLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ const authorX = author?.links?.find(l => l.name === 'X')?.id;
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="sitemap" href="/sitemap-index.xml" />
<link
rel="alternate"
type="application/rss+xml"
title="Blogs"
href="/rss.xml"
/>
<link
rel="alternate"
type="application/rss+xml"
title={`Blogs by ${blog.data.author}`}
href={`/${blog.data.author}/rss.xml`}
/>
<SEO
charset="UTF-8"
title={title}
Expand Down
6 changes: 6 additions & 0 deletions src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ const meta = {
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="sitemap" href="/sitemap-index.xml" />
<link
rel="alternate"
type="application/rss+xml"
title="Blogs"
href="/rss.xml"
/>
<SEO
charset="UTF-8"
title={meta.title}
Expand Down
115 changes: 115 additions & 0 deletions src/lib/getFeed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { type RSSOptions } from '@astrojs/rss';
import { getCollection } from 'astro:content';
import siteInfo from '@/data/siteInfo';

function extractImageUrl(body: string) {
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The function extractImageUrl (singular) returns an array or null, but the name suggests it returns a single URL. Consider renaming to extractImageUrls (plural) or extractImages to better reflect that it returns multiple images.

Copilot uses AI. Check for mistakes.
if (!body) return null;
const relativeImages = [];
const absoluteImages = [];
const imgTagRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/g;
const mdImageRegex = /!\[[^\]]*\]\(([^)]+)\)/g;

const imgMatches = [...body.matchAll(imgTagRegex)];
const mdMatches = [...body.matchAll(mdImageRegex)];
const allUrls = [
...imgMatches.map(match => match[1]),
...mdMatches.map(match => match[1]),
Comment on lines +7 to +16
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The regex patterns don't account for escaped characters in markdown images and HTML attributes. For example, an image with src="image\".jpg" or markdown like !\[alt\](url) could produce incorrect matches. Additionally, the patterns don't handle multiline attributes. Consider using a more robust parser or at minimum adding tests to verify edge cases are handled correctly.

Suggested change
const relativeImages = [];
const absoluteImages = [];
const imgTagRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/g;
const mdImageRegex = /!\[[^\]]*\]\(([^)]+)\)/g;
const imgMatches = [...body.matchAll(imgTagRegex)];
const mdMatches = [...body.matchAll(mdImageRegex)];
const allUrls = [
...imgMatches.map(match => match[1]),
...mdMatches.map(match => match[1]),
const relativeImages: { url: string; type: string; length: number }[] = [];
const absoluteImages: { url: string; type: string; length: number }[] = [];
// Match <img> tags with a src attribute, allowing escaped quotes inside the URL and multiline attributes.
const imgTagRegex =
/<img\b[^>]*\bsrc=(["'])(?<url>(?:\\.|(?!\1).)*?)\1[^>]*>/gis;
// Match Markdown images, allowing escaped characters in alt text and URL.
const mdImageRegex =
/!\[(?:[^\]\\]|\\.)*]\((?<url>(?:[^)\\]|\\.)+)\)/g;
const imgMatches = [...body.matchAll(imgTagRegex)];
const mdMatches = [...body.matchAll(mdImageRegex)];
const allUrls = [
...imgMatches.map(match => (match.groups?.url ?? match[1])),
...mdMatches.map(match => (match.groups?.url ?? match[1])),

Copilot uses AI. Check for mistakes.
];

if (allUrls.length === 0) return null;

for (const url of allUrls) {
const isRelative = url.startsWith('/');
let fullUrl = url;
const ext = fullUrl.split('?')[0].split('.').pop()?.toLowerCase();
let type;

switch (ext) {
case 'jpg':
case 'jpeg':
type = 'image/jpeg';
break;
case 'png':
type = 'image/png';
break;
case 'gif':
type = 'image/gif';
break;
case 'webp':
type = 'image/webp';
break;
case 'svg':
type = 'image/svg+xml';
break;
default:
continue;
Comment on lines +24 to +45
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

If an image URL has no file extension (e.g., https://example.com/image or dynamic image endpoints), the ext variable will be undefined or the last path segment, causing the image to be skipped via the continue statement. This could exclude valid images from CDNs or image services that don't use file extensions. Consider defaulting to image/jpeg or determining the type from response headers for such URLs.

Suggested change
const ext = fullUrl.split('?')[0].split('.').pop()?.toLowerCase();
let type;
switch (ext) {
case 'jpg':
case 'jpeg':
type = 'image/jpeg';
break;
case 'png':
type = 'image/png';
break;
case 'gif':
type = 'image/gif';
break;
case 'webp':
type = 'image/webp';
break;
case 'svg':
type = 'image/svg+xml';
break;
default:
continue;
const pathWithoutQuery = fullUrl.split('?')[0];
const lastSegment = pathWithoutQuery.split('/').pop() || '';
const hasExtension = lastSegment.includes('.');
const ext = hasExtension ? lastSegment.split('.').pop()!.toLowerCase() : undefined;
let type: string | undefined;
if (!hasExtension) {
// No file extension in URL; default to a generic image type.
type = 'image/jpeg';
} else {
switch (ext) {
case 'jpg':
case 'jpeg':
type = 'image/jpeg';
break;
case 'png':
type = 'image/png';
break;
case 'gif':
type = 'image/gif';
break;
case 'webp':
type = 'image/webp';
break;
case 'svg':
type = 'image/svg+xml';
break;
default:
continue;
}

Copilot uses AI. Check for mistakes.
}

const imageObj = {
url: fullUrl,
type: type,
length: 0,
};

if (isRelative) {
relativeImages.push(imageObj);
} else {
absoluteImages.push(imageObj);
}
}
Comment on lines +48 to +59
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The extractImageUrl function extracts relative URLs (starting with /) but doesn't convert them to absolute URLs for RSS enclosures. While @astrojs/rss may auto-resolve relative link URLs using the site parameter, RSS enclosures traditionally require absolute URLs and it's unclear if the library handles this for enclosures. Consider explicitly converting relative URLs to absolute by prepending the site URL: url: isRelative ? new URL(fullUrl, siteUrl).href : fullUrl.

Copilot uses AI. Check for mistakes.

const images = [...relativeImages, ...absoluteImages];
return images.length > 0 ? images : null;
Comment on lines +54 to +62
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The image extraction logic prioritizes relative URLs over absolute URLs without considering which image is actually first in the content. This means if a blog post has an external image first (like a header image from GitHub), it will be deprioritized in favor of a local image that appears later. Consider preserving the original order or explicitly documenting this prioritization behavior, as RSS readers typically display the first enclosure as the featured image.

Copilot uses AI. Check for mistakes.
}

async function getFeed(
siteUrl: string,
Comment on lines +65 to +66
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The siteUrl parameter is typed as string, but context.site is a URL object (or potentially undefined). This type mismatch could cause issues. Consider changing the parameter type to string | URL or URL | undefined and handling the conversion within the function, or explicitly convert at the call site using context.site?.toString() or context.site.href.

Copilot uses AI. Check for mistakes.
maxItems?: number,
filter?: { tag?: string; author?: string }
) {
let blogs = await getCollection('blog');
if (filter) {
if (filter.author) {
blogs = blogs.filter(blog => blog.data.author === filter.author);
}
if (filter.tag) {
blogs = blogs.filter(blog => blog.data.tags?.includes(filter.tag!));
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The non-null assertion operator ! is used on filter.tag even though you've already checked if (filter.tag) on line 75. While this works, it's redundant. TypeScript's control flow analysis should already narrow the type after the if check, making the ! unnecessary. Consider removing it for cleaner code.

Suggested change
blogs = blogs.filter(blog => blog.data.tags?.includes(filter.tag!));
blogs = blogs.filter(blog => blog.data.tags?.includes(filter.tag));

Copilot uses AI. Check for mistakes.
}
}
blogs = blogs
.sort(
(a, b) =>
new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime()
)
.slice(0, maxItems || 50);
let title = siteInfo.appName;
if (filter?.author) {
title += ` - Author: ${filter.author}`;
Comment on lines +85 to +87
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The title for author-specific feeds changed from ${siteInfo.appName} - ${author} to ${siteInfo.appName} - Author: ${filter.author}. While adding "Author:" is more descriptive, this represents a breaking change for users who have subscribed to the feed under the old title. Feed readers may treat this as a different feed. If maintaining compatibility is important, consider keeping the original format.

Copilot uses AI. Check for mistakes.
}
if (filter?.tag) {
title += ` - Tag: ${filter.tag}`;
}
Comment on lines +85 to +91
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

Building the title through string concatenation with += makes it harder to read and maintain. Consider using a template literal or array join for clarity, e.g., [siteInfo.appName, filter?.author && 'Author: ' + filter.author, filter?.tag && 'Tag: ' + filter.tag].filter(Boolean).join(' - ').

Suggested change
let title = siteInfo.appName;
if (filter?.author) {
title += ` - Author: ${filter.author}`;
}
if (filter?.tag) {
title += ` - Tag: ${filter.tag}`;
}
const titleSegments = [
siteInfo.appName,
filter?.author && `Author: ${filter.author}`,
filter?.tag && `Tag: ${filter.tag}`,
];
const title = titleSegments.filter(Boolean).join(' - ');

Copilot uses AI. Check for mistakes.
const rssOptions: RSSOptions = {
title: title,
description: siteInfo.description,
site: siteUrl,
items: blogs.map(blog => ({
title: blog.data.title,
pubDate: new Date(blog.data.pubDate),
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The pubDate is converted to a Date object with new Date(blog.data.pubDate), but according to the content schema, pubDate is defined as a string. If this string is not in a valid date format, this could produce an "Invalid Date". Consider validating the date format in the schema using z.string().datetime() or z.coerce.date() for more robust handling, or add error handling here to gracefully handle invalid dates.

Copilot uses AI. Check for mistakes.
description: blog.data.description,
link: blog.data.externalUrl
? blog.data.externalUrl
: `/blog/${blog.slug}/`,
categories: blog.data.tags,
enclosure: extractImageUrl(blog.body)?.[0] || {
url: `/og/${blog.slug}.png`,
type: 'image/png',
length: 0,
},
})),
customData: `<language>ja-jp</language>`,
};
return rssOptions;
}

export { getFeed };
21 changes: 3 additions & 18 deletions src/pages/[author]/rss.xml.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import siteInfo from '@/data/siteInfo';
import { getFeed } from '@/lib/getFeed';
import member from '@/data/member';

export async function getStaticPaths() {
Expand All @@ -13,21 +12,7 @@ export async function getStaticPaths() {

export async function GET(context) {
const { author } = context.params;
const blogs = await getCollection('blog');
const feedOption = await getFeed(context.site, 50, { author });

const filteredBlogs = blogs.filter(blog => blog.data.author === author);

return rss({
title: `${siteInfo.appName} - ${author}`,
description: `${author}の記事一覧`,
site: context.site,
items: filteredBlogs.map(blog => ({
title: blog.data.title,
pubDate: blog.data.pubDate,
description: blog.data.description,
customData: blog.data.customData,
link: `/blog/${blog.slug}/`,
})),
customData: '<language>ja-jp</language>',
});
return rss(feedOption);
}
19 changes: 3 additions & 16 deletions src/pages/rss.xml.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,7 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import siteInfo from '@/data/siteInfo';
import { getFeed } from '@/lib/getFeed';

export async function GET(context) {
const blogs = await getCollection('blog');
return rss({
title: siteInfo.appName,
description: siteInfo.description,
site: context.site,
items: blogs.map(blog => ({
title: blog.data.title,
pubDate: blog.data.pubDate,
description: blog.data.description,
customData: blog.data.customData,
link: `/blog/${blog.slug}/`,
})),
customData: `<language>ja-jp</language>`,
});
const feedOption = await getFeed(context.site);
return rss(feedOption);
}
88 changes: 88 additions & 0 deletions src/plugins/remark/remark-mermaid-injector.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { visit } from 'unist-util-visit';

/**
* remark プラグイン
*
* markdown ASTにてmermaidコードブロックがある場合、クライアントでの描画スクリプト(mermaid.js処理)を末尾に挿入
*
*/
export function remarkMermaidInjector() {
return function (tree) {
let mermaidFound = false;

// mermaidコードブロックの存在を確認
visit(tree, 'code', node => {
if (node.lang === 'mermaid') {
mermaidFound = true;
return false;
}
});
Comment on lines +14 to +19
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The early return with return false is inefficient because it stops traversing the tree after finding the first mermaid block, but you still need to process all mermaid blocks later. Consider removing the early return and simply setting the flag, allowing the visitor to check all nodes. This would make the code more straightforward and avoid an unnecessary second implicit traversal.

Copilot uses AI. Check for mistakes.

// mermaidブロックがある場合、末尾にスクリプトを追加
if (mermaidFound) {
const scriptNode = {
type: 'html',
value: `<script>
async function initMermaidDiagrams() {
const blocks = document.querySelectorAll(
'pre[data-language="mermaid"] code'
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The selector 'pre[data-language="mermaid"] code' assumes a specific HTML structure with a data-language attribute. However, when Astro processes markdown with excludeLangs: ['mermaid'], it's unclear if this attribute is actually set. You should verify that this selector matches the actual HTML output, or consider using a more reliable selector like 'pre code.language-mermaid' which is the standard class naming convention for Prism/highlight.js code blocks.

Suggested change
'pre[data-language="mermaid"] code'
'pre[data-language="mermaid"] code, pre code.language-mermaid'

Copilot uses AI. Check for mistakes.
);

if (blocks.length === 0) return;

try {
if (!window.mermaid) {
const script = document.createElement("script");
script.src =
"https://cdn.jsdelivr.net/npm/mermaid@latest/dist/mermaid.min.js";
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

Using @latest for the Mermaid CDN URL means users will automatically receive potentially breaking changes without warning. This could cause diagrams to break unexpectedly in production. Consider pinning to a specific major or minor version (e.g., mermaid@11 or mermaid@11.5.0) to ensure stability while still allowing compatible updates.

Copilot uses AI. Check for mistakes.
await new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
Comment on lines +35 to +42
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The script dynamically loads from an external CDN without Subresource Integrity (SRI) verification. This creates a security risk: if the CDN is compromised or serves malicious code, it could execute arbitrary JavaScript on your site. Consider either bundling Mermaid as a dependency or adding SRI hashes to verify the integrity of the loaded script.

Copilot uses AI. Check for mistakes.
}

mermaid.initialize({
startOnLoad: false,
});

for (let i = 0; i < blocks.length; i++) {
const code = blocks[i];
const pre = code.parentElement;
let chart = code.textContent.trim();

try {
const { svg } = await mermaid.render(
\`mermaid-\${i}\`,
chart
);

const container = document.createElement("div");
container.className = \`mermaid-container my-8\`;

const wrapper = document.createElement("div");
wrapper.className = \`mermaid-diagram rounded-lg min-h-[100px] bg-transparent\`;

wrapper.innerHTML = svg;
container.appendChild(wrapper);
pre.parentNode.replaceChild(container, pre);
} catch (error) {
console.warn(
\`Mermaid diagram \${i + 1} failed, keeping as code block\`,
error
);
}
}
} catch (error) {
console.warn("Mermaid library failed to load, keeping code blocks", error);
}
}

document.addEventListener("DOMContentLoaded", initMermaidDiagrams);
Comment on lines +80 to +81
Copy link

Copilot AI Jan 7, 2026

Choose a reason for hiding this comment

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

The script loads and initializes Mermaid on every page with mermaid blocks, but if multiple markdown files with mermaid diagrams are rendered on the same page (e.g., a blog listing), this could result in multiple identical scripts being added. While the window.mermaid check prevents duplicate library loads, having multiple event listeners on DOMContentLoaded is redundant. Consider moving this to a global script in the layout or using a module-based approach to ensure single initialization.

Suggested change
document.addEventListener("DOMContentLoaded", initMermaidDiagrams);
if (!window.__mermaidDomContentLoadedListenerAdded) {
window.__mermaidDomContentLoadedListenerAdded = true;
document.addEventListener("DOMContentLoaded", initMermaidDiagrams);
}

Copilot uses AI. Check for mistakes.
</script>`,
};

tree.children.push(scriptNode);
}
};
}