Skip to content
Draft
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
21 changes: 21 additions & 0 deletions src/hooks.server.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
import { building } from '$app/environment';
import {
isMarkdownNegotiable,
markdownResponse,
prefersMarkdown
} from '$lib/server/markdown-negotiation.js';
import { markdownForPath } from '$lib/server/markdown-pages.js';

// Resolve the %lang% placeholder in app.html. Only blog/project detail pages are
// bilingual (Korean default); the rest of the site is English chrome. The client
// reconciles this with the actually-shown language after hydration.
/** @type {import('@sveltejs/kit').Handle} */
export const handle = async ({ event, resolve }) => {
const { pathname } = event.url;

// Prerender must emit HTML. `building` is also the crawler, whose Accept we
// must not treat as an agent. Production prerendered pages never reach this
// hook — Vercel rewrites those markdown requests to /api/markdown instead.
if (
!building &&
!event.isDataRequest &&
isMarkdownNegotiable(pathname) &&
prefersMarkdown(event.request.headers.get('accept'))
) {
const lang = event.url.searchParams.get('lang') || event.cookies.get('preferred-lang');
const result = markdownForPath(pathname, { lang });
if (result) return markdownResponse(result.body, { status: result.status });
}

const isContent = pathname.startsWith('/blog/') || pathname.startsWith('/projects/');

let lang = 'en';
Expand Down
13 changes: 13 additions & 0 deletions src/lib/server/content-signals.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// @ts-check

/**
* The site's Content Signals preference. Duplicated into `static/robots.txt`
* (a static file cannot import this) — keep the two in lockstep. Tests assert
* they match.
*
* search: cite and index. ai-input: agents may ground on the page (the reason
* markdown negotiation exists). ai-train: do not use the text to train models.
*
* @see https://contentsignals.org/
*/
export const CONTENT_SIGNAL = 'ai-train=no, search=yes, ai-input=yes';
207 changes: 207 additions & 0 deletions src/lib/server/markdown-format.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// @ts-check
import { homeSchema } from '$lib/seo/jsonld.js';
import { PAGE_METRICS } from '$lib/health/metrics.js';
import { SITE_URL } from './sitemap.js';

/** @typedef {import('$lib/types').Post} Post */
/** @typedef {import('$lib/types').Project} Project */
/** @typedef {Record<string, unknown>} Metadata */

/**
* YAML frontmatter. Only fields with a value are emitted, matching Cloudflare's
* Markdown-for-Agents layout.
*
* @param {Record<string, string | undefined | null>} fields
*/
export function yamlFrontmatter(fields) {
const lines = [];
for (const [key, value] of Object.entries(fields)) {
if (value == null || value === '') continue;
lines.push(`${key}: ${yamlEscape(value)}`);
}
if (lines.length === 0) return '';
return `---\n${lines.join('\n')}\n---\n\n`;
}

/** @param {string} value */
function yamlEscape(value) {
if (/[:#{}[\],&*?|>!%@`'"]/.test(value) || value !== value.trim()) {
return JSON.stringify(value);
}
return value;
}

/**
* Rewrite root-relative markdown links (`](/x)`) to absolute URLs so the
* document still resolves off-site. Already-absolute, hash, and mailto targets
* are left alone.
*
* @param {string} markdown
* @param {string} origin
*/
export function absolutizeMarkdown(markdown, origin) {
return markdown.replace(/\]\((\/[^)]*)\)/g, (_m, path) => `](${origin}${path})`);
}

/**
* @param {string} markdown
* @param {unknown} schema
*/
export function withJsonLd(markdown, schema) {
return `${markdown.trimEnd()}\n\n\`\`\`json\n${JSON.stringify(schema, null, '\t')}\n\`\`\`\n`;
}

/**
* @param {{ title: string, href: string, description?: string, when?: string }} item
*/
function listItem(item) {
const when = item.when ? ` (${item.when})` : '';
const description = item.description?.trim() ? ` — ${item.description.trim()}` : '';
return `- [${item.title}](${item.href})${description}${when}`;
}

/**
* @param {{ title: string, description: string, items: { title: string, href: string, description?: string, when?: string }[] }} opts
*/
export function listingMarkdown({ title, description, items }) {
return [
yamlFrontmatter({ title, description }),
`# ${title}`,
'',
description,
'',
...items.map(listItem),
''
].join('\n');
}

/**
* @param {{ posts: Post[], projects: Project[] }} opts
*/
export function homeMarkdown({ posts, projects }) {
const description =
'Injoon Oh (오인준) — a student interested in math, science, and computers. Blog, projects, and more.';
const body = [
yamlFrontmatter({
title: 'Injoon Oh',
description,
image: `${SITE_URL}/api/og?template=home`
}),
'# Injoon Oh',
'',
'I am a student who is interested in math, science, and computers.',
'',
"I love exploring new concepts and getting to know cool new things. Whether it's tackling complex equations, researching about scientific stuff, or trying the latest tech, I'm always eager to learn.",
'',
'Although I have not decided the specific domain due to the industry evolving so rapidly, I want to be a computer programmer when I grow up.',
'',
'## Blog',
'',
...posts.map((post) =>
listItem({
title: post.title,
href: `${SITE_URL}/blog/${post.slug}`,
description: post.description,
when: post.date
})
),
'',
'## Projects',
'',
...projects.map((project) =>
listItem({
title: project.title,
href: `${SITE_URL}/projects/${project.slug}`,
description: project.description,
when: project.year
})
),
'',
'## Elsewhere',
'',
`- [Now](${SITE_URL}/now) — what I'm doing now`,
`- [Health](${SITE_URL}/health) — Apple Watch metrics`,
`- [RSS](${SITE_URL}/rss.xml)`,
''
].join('\n');
return withJsonLd(body, homeSchema());
}

export function nowMarkdown() {
const description = 'What Injoon Oh is doing now.';
return [
yamlFrontmatter({
title: 'Now — Injoon Oh',
description,
image: `${SITE_URL}/api/og?template=now`
}),
'# Now',
'',
description,
'',
`The live page is at ${SITE_URL}/now.`,
''
].join('\n');
}

export function healthMarkdown() {
const description = 'Steps, movement and energy, straight off an Apple Watch.';
return [
yamlFrontmatter({
title: 'Health — Injoon Oh',
description,
image: `${SITE_URL}/api/og?template=health`
}),
'# Health',
'',
description,
'',
'Daily metrics from an Apple Watch:',
'',
...PAGE_METRICS.map((metric) => `- ${metric.label}`),
'',
`The charts live at ${SITE_URL}/health.`,
''
].join('\n');
}

/**
* Pick the published raw source for one slug. Korean is the site default;
* `lang=en` (or the language cookie) selects English when that twin exists.
*
* @param {Record<string, string>} rawEn
* @param {Record<string, string>} rawKo
* @param {Record<string, unknown>} metaEn
* @param {Record<string, unknown>} metaKo
* @param {string} kind `'blog'` | `'projects'`
* @param {string} slug
* @param {string | null | undefined} langPref
* @returns {{ raw: string, meta: Metadata, lang: 'en' | 'ko' } | null}
*/
export function pickArticle(rawEn, rawKo, metaEn, metaKo, kind, slug, langPref) {
const enKey = `/src/content/${kind}/en/${slug}.md`;
const koKey = `/src/content/${kind}/ko/${slug}.md`;
const enMeta = /** @type {Metadata | undefined} */ (metaEn[enKey]);
const koMeta = /** @type {Metadata | undefined} */ (metaKo[koKey]);
const enRaw = enMeta?.published === true ? rawEn[enKey] : undefined;
const koRaw = koMeta?.published === true ? rawKo[koKey] : undefined;
if (!enRaw && !koRaw) return null;

const preferEn = langPref === 'en';
if (preferEn && enRaw && enMeta) return { raw: enRaw, meta: { ...enMeta, slug }, lang: 'en' };
if (koRaw && koMeta) return { raw: koRaw, meta: { ...koMeta, slug }, lang: 'ko' };
if (enRaw && enMeta) return { raw: enRaw, meta: { ...enMeta, slug }, lang: 'en' };
return null;
}

/** @param {string | undefined} cover */
export function coverUrl(cover) {
if (!cover) return undefined;
if (/^https?:\/\//i.test(cover)) return cover;
return `${SITE_URL}${cover.startsWith('/') ? cover : `/${cover}`}`;
}

/** @param {string} path */
export function notFoundMarkdown(path) {
return yamlFrontmatter({ title: 'Not found' }) + `# Not found\n\nNo page at ${path}.\n`;
}
141 changes: 141 additions & 0 deletions src/lib/server/markdown-format.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import {
absolutizeMarkdown,
healthMarkdown,
homeMarkdown,
listingMarkdown,
nowMarkdown,
pickArticle,
withJsonLd,
yamlFrontmatter
} from './markdown-format.js';

describe('yamlFrontmatter', () => {
it('omits empty fields', () => {
expect(yamlFrontmatter({ title: 'Hi', description: '', image: null })).toBe(
'---\ntitle: Hi\n---\n\n'
);
});

it('quotes values that would break YAML', () => {
expect(yamlFrontmatter({ title: 'Hello: world' })).toContain('title: "Hello: world"');
});
});

describe('absolutizeMarkdown', () => {
it('rewrites root-relative links and leaves the rest', () => {
const src = '[a](/blog/x) [b](https://ex.com) [c](#top) [d](mailto:me@x.com)';
expect(absolutizeMarkdown(src, 'https://www.injoon5.com')).toBe(
'[a](https://www.injoon5.com/blog/x) [b](https://ex.com) [c](#top) [d](mailto:me@x.com)'
);
});
});

describe('withJsonLd', () => {
it('appends a fenced json block', () => {
expect(withJsonLd('# Hi\n', { '@type': 'WebSite' })).toBe(
'# Hi\n\n```json\n{\n\t"@type": "WebSite"\n}\n```\n'
);
});
});

describe('listingMarkdown', () => {
it('renders a titled list of links', () => {
const md = listingMarkdown({
title: 'Blog',
description: 'Posts.',
items: [
{
title: 'Hello',
href: 'https://www.injoon5.com/blog/hello',
description: 'A post',
when: '2025-01-01'
}
]
});
expect(md).toContain('title: Blog');
expect(md).toContain('# Blog');
expect(md).toContain('- [Hello](https://www.injoon5.com/blog/hello) — A post (2025-01-01)');
});
});

describe('homeMarkdown', () => {
it('includes intro, listings, and JSON-LD', () => {
const md = homeMarkdown({
posts: [
{
type: ['blog'],
title: 'A post',
slug: 'a-post',
description: 'About a thing',
date: '2025-02-09',
coverimage: '',
tags: [],
published: true
}
],
projects: [
{
title: 'A project',
slug: 'a-project',
description: 'Did a thing',
year: '2025',
tags: [],
coverimage: '',
published: true
}
]
});
expect(md).toContain('# Injoon Oh');
expect(md).toContain('/blog/a-post');
expect(md).toContain('/projects/a-project');
expect(md).toContain('"@type": "WebSite"');
expect(md.startsWith('---\n')).toBe(true);
});
});

describe('nowMarkdown / healthMarkdown', () => {
it('emits titled documents', () => {
expect(nowMarkdown()).toContain('# Now');
expect(healthMarkdown()).toContain('# Health');
expect(healthMarkdown()).toMatch(/- Steps/);
});
});

describe('pickArticle', () => {
const rawEn = { '/src/content/blog/en/us-camp.md': '# EN' };
const rawKo = { '/src/content/blog/ko/us-camp.md': '# KO' };
const metaEn = { '/src/content/blog/en/us-camp.md': { published: true, title: 'EN' } };
const metaKo = { '/src/content/blog/ko/us-camp.md': { published: true, title: 'KO' } };

it('defaults to Korean', () => {
expect(pickArticle(rawEn, rawKo, metaEn, metaKo, 'blog', 'us-camp', null)?.lang).toBe('ko');
expect(pickArticle(rawEn, rawKo, metaEn, metaKo, 'blog', 'us-camp', null)?.raw).toBe('# KO');
});

it('selects English when asked', () => {
expect(pickArticle(rawEn, rawKo, metaEn, metaKo, 'blog', 'us-camp', 'en')?.lang).toBe('en');
});

it('falls back when the preferred twin is unpublished', () => {
const unpublishedEn = { '/src/content/blog/en/us-camp.md': { published: false, title: 'EN' } };
expect(pickArticle(rawEn, rawKo, unpublishedEn, metaKo, 'blog', 'us-camp', 'en')?.lang).toBe(
'ko'
);
});

it('returns null for unpublished / missing slugs', () => {
expect(pickArticle({}, {}, {}, {}, 'blog', 'nope', null)).toBeNull();
expect(
pickArticle(
rawEn,
rawKo,
{ '/src/content/blog/en/us-camp.md': { published: false } },
{},
'blog',
'us-camp',
null
)
).toBeNull();
});
});
Loading
Loading