Skip to content
Open
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
33 changes: 33 additions & 0 deletions .tasks/phase-2-content-routes/LOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Phase 2 LOG

- Harness: cursor
- Started: 2026-09-10
- Status: PR-READY #59
- Base: `feat/phase-1.5-visual` (#58)

## Notes

- 콘텐츠 원본: `legacy-next/pages/posts/**`, `legacy-next/pages/study/**`.
- `llm-wiki-template` clone(`/tmp/llm-wiki-template`) 확인 — wiki 페이지 21개 존재하나 공개안전 선별·frontmatter 매핑 작업량 대비 Phase 2 게이트(6편) 충족으로 **이식 생략**. 후속 PR 가능.
- PLAN-UPDATED: colorway는 `createThemeNested {}` + `ColorwayShell`/`ArticleLayout` variant style (StyleX babel 0.19는 non-empty nested override 불가).
- `hello.mdx`는 `draft: true` — PROD 빌드 `dist/articles/hello` 없음 확인.
- Review fix: `MdxPre` → default slot → `CodeBlock` slot mode; `check-code-blocks.mjs`로 from-jekyll fence 검증; same-page `#anchor` check-links 검사.

## Gates (HEAD 88bd9d5)

- `bun run check` — 0 errors (105 files)
- `bunx oxfmt --check .` — OK
- `bunx oxlint .` — OK
- `bunx vitest run` — 8 passed
- `bun run lint:stylex` — OK
- `bun run build` — 33 pages
- `bun run build-storybook` — OK
- `bun run check-links` — 33 html OK (same-page anchors 포함)
- `bun run check-code-blocks` — OK (language-mdx + code text)
- draft exclusion — `dist/articles/hello` absent

## 2026-09-10 review

- Sol `gpt-5.6-sol-medium` 1차: `REVIEW CHANGES phase-2 59`. usage: not visible.
- Sol 재리뷰: `REVIEW APPROVE phase-2 59`. usage: not visible.
- `gh pr ready 59`. 머지는 사람. 스택: #56 → #58 → #59.
10 changes: 8 additions & 2 deletions .tasks/phase-2-content-routes/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,11 @@

## 승인 요청

- [ ] 구경로 shim 방식 동의? (vs 404 안내)
- [ ] llm-wiki 선별 기준(공개안전) 동의?
- [x] 구경로 shim 방식 동의? (vs 404 안내) — 2026-09-10 스택 진행 지시. PLAN 기본안(shim + canonical) 채택.
- [x] llm-wiki 선별 기준(공개안전) 동의? — 동. 회사정보·개인정보·저작권 불통과분은 이식하지 않는다. `raw/notes` 금지.

## 승인된 편차 (2026-09-10)

- StyleX 0.19 `unstable_createThemeNested`에 비어 있지 않은 오버라이드를 넣으면 babel-plugin이 크래시한다 (`undefined.slice`). colorway 시각 차등은 `ColorwayShell`/`ArticleLayout` variant + 팔레트 토큰으로 낸다. `themes.ts`의 createTheme 껍데기는 `{}`로 둔다.
- `llm-wiki-template` 이식은 6편 게이트와 분리. wiki 21개 확인했으나 공개안전 큐레이션은 후속.

19 changes: 17 additions & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import { defineConfig } from "astro/config";
import stylex from "@stylexjs/unplugin";

import preact from "@astrojs/preact";
import mdx from "@astrojs/mdx";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeExternalLinks from "rehype-external-links";
import rehypeSlug from "rehype-slug";
import remarkGfm from "remark-gfm";
import remarkSmartypants from "remark-smartypants";

const markdownPlugins = {
remarkPlugins: [remarkGfm, remarkSmartypants],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: "wrap" }],
[rehypeExternalLinks, { target: "_blank", rel: ["noopener", "noreferrer"] }],
],
};

export default defineConfig({
site: "https://gitgitwi.github.io",

markdown: {
syntaxHighlight: "prism",
...markdownPlugins,
},

vite: {
Expand All @@ -22,5 +37,5 @@ export default defineConfig({
],
},

integrations: [preact()],
integrations: [preact(), mdx(markdownPlugins)],
});
282 changes: 257 additions & 25 deletions bun.lock

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@
"lint": "oxlint .",
"lint:stylex": "eslint -c eslint.stylex.config.js src",
"test": "vitest run --passWithNoTests",
"check-links": "node scripts/check-links.mjs",
"check-code-blocks": "node scripts/check-code-blocks.mjs",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@astrojs/markdown-remark": "^7.3.1",
"@astrojs/markdown-satteri": "^0.4.1",
"@astrojs/mdx": "^8.0.1",
"@astrojs/preact": "^6.0.5",
"@astrojs/prism": "^4.0.2",
"@fontsource/caveat": "^5.3.0",
Expand All @@ -27,6 +32,11 @@
"astro": "7.3.2",
"preact": "^10.29.8",
"prismjs": "^1.30.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-external-links": "^3.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-smartypants": "^3.0.3",
"sharp": "0.35.4",
"three": "^0.186.0"
},
Expand Down
11 changes: 11 additions & 0 deletions public/redirects.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"posts": {
"design-system/01": "/articles/design-system/01",
"from-jekyll-to-nextjs-mdx/00": "/articles/from-jekyll-to-nextjs/00",
"til/tdd/fullstack-tdd-by-newbie": "/til/tdd/fullstack-tdd-by-newbie",
"til/ts/enum-to-template-literal": "/til/ts/enum-to-template-literal"
},
"study": {
"refactoring-javascript/01": "/til/refactoring-javascript/01"
}
}
23 changes: 23 additions & 0 deletions scripts/check-code-blocks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const articlePath = join(rootDir, "dist/articles/from-jekyll-to-nextjs/00/index.html");

const html = await readFile(articlePath, "utf8");

if (!/language-\w+/.test(html)) {
console.error("check-code-blocks: missing language-* class in migrated article HTML");
process.exit(1);
}

const codeTextMarkers = ["Markdown", "React"];
const hasCodeText = codeTextMarkers.some((marker) => html.includes(marker));

if (!hasCodeText) {
console.error("check-code-blocks: fenced code body appears empty in from-jekyll article");
process.exit(1);
}

console.log("check-code-blocks: OK (from-jekyll article has language class and code text)");
138 changes: 138 additions & 0 deletions scripts/check-links.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { readFile, readdir } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const distDir = join(rootDir, "dist");
const siteOrigin = "https://gitgitwi.github.io";

const walkHtml = async (dir) => {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walkHtml(fullPath)));
} else if (entry.name.endsWith(".html")) {
files.push(fullPath);
}
}
return files;
};

const stripBase = (href) => {
if (href.startsWith(siteOrigin)) {
return href.slice(siteOrigin.length) || "/";
}
return href;
};

const hrefToDistPath = (href) => {
const pathOnly = stripBase(href.split("#")[0].split("?")[0]);
if (!pathOnly.startsWith("/")) return null;
const normalized = pathOnly.replace(/\/$/, "") || "/";
if (normalized === "/") {
const home = join(distDir, "index.html");
return { direct: home, asIndex: home, anchor: null, pathOnly: normalized };
}
const direct = join(distDir, normalized.slice(1));
const asIndex = join(direct, "index.html");
return { direct, asIndex, anchor: href.includes("#") ? href.split("#")[1] : null, pathOnly };
};

const collectFromHtml = (html) => {
const hrefs = [...html.matchAll(/\shref="([^"]+)"/g)].map((match) => match[1]);
const srcs = [...html.matchAll(/\ssrc="([^"]+)"/g)].map((match) => match[1]);
const ids = new Set([...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]));
return { hrefs, srcs, ids };
};

const isInternal = (url) =>
url.startsWith("/") ||
url.startsWith(siteOrigin) ||
(!url.includes("://") && !url.startsWith("mailto:") && !url.startsWith("tel:"));

const isIgnored = (url) =>
url.startsWith("javascript:") || url.startsWith("data:") || url.endsWith(".pdf");

const errors = [];

const assertPathExists = async (target) => {
const { direct, asIndex } = target;
try {
await readFile(direct);
return direct;
} catch {
try {
await readFile(asIndex);
return asIndex;
} catch {
return null;
}
}
};

const run = async () => {
let htmlFiles;
try {
htmlFiles = await walkHtml(distDir);
} catch {
console.error("dist/ not found — run `bun run build` first.");
process.exit(1);
}

for (const file of htmlFiles) {
const html = await readFile(file, "utf8");
const relFile = file.slice(distDir.length + 1);
const { hrefs, srcs, ids } = collectFromHtml(html);

for (const href of hrefs) {
if (isIgnored(href)) continue;

if (href.startsWith("#")) {
const anchor = href.slice(1);
if (anchor && !ids.has(anchor)) {
errors.push(`${relFile}: missing same-page anchor #${anchor}`);
}
continue;
}

if (!isInternal(href)) continue;

const mapped = hrefToDistPath(href);
if (!mapped) continue;
const found = await assertPathExists(mapped);
if (!found) {
errors.push(`${relFile}: broken internal link ${href}`);
continue;
}
if (mapped.anchor) {
const targetHtml = await readFile(found, "utf8");
const targetIds = collectFromHtml(targetHtml).ids;
if (!targetIds.has(mapped.anchor)) {
errors.push(`${relFile}: missing anchor #${mapped.anchor} in ${mapped.pathOnly}`);
}
}
}

for (const src of srcs) {
if (!isInternal(src) || isIgnored(src)) continue;
const mapped = hrefToDistPath(src);
if (!mapped) continue;
const found = await assertPathExists(mapped);
if (!found) {
errors.push(`${relFile}: broken image/asset ${src}`);
}
}
}

if (errors.length > 0) {
console.error(`check-links: ${errors.length} issue(s)`);
for (const message of errors) console.error(` - ${message}`);
process.exit(1);
}

console.log(`check-links: OK (${htmlFiles.length} html files scanned)`);
};

await run();
16 changes: 13 additions & 3 deletions src/components/content/CodeBlock.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { codeBlockStyles } from './CodeBlock.stylex.ts';

type Props = {
lang: string;
code: string;
code?: string;
preClass?: string;
showCopy?: boolean;
};

const { lang, code, showCopy = true } = Astro.props;
const { lang, code, preClass, showCopy = true } = Astro.props;
const blockId = `code-${Math.random().toString(36).slice(2, 9)}`;
const useSlot = Astro.slots.has('default');
---

<div {...stylex.attrs(codeBlockStyles.wrapper)} data-code-block={blockId}>
Expand All @@ -26,7 +28,15 @@ const blockId = `code-${Math.random().toString(36).slice(2, 9)}`;
}
</div>
<div {...stylex.attrs(codeBlockStyles.pre)}>
<Prism lang={lang} code={code} />
{
useSlot ? (
<pre class={preClass}>
<slot />
</pre>
) : (
<Prism lang={lang} code={code ?? ''} />
)
}
</div>
</div>

Expand Down
14 changes: 14 additions & 0 deletions src/components/content/MdxPre.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
import CodeBlock from './CodeBlock.astro';

type Props = {
class?: string;
};

const { class: className } = Astro.props as Props;
const lang = className?.match(/language-(\S+)/)?.[1] ?? 'text';
---

<CodeBlock lang={lang} preClass={className}>
<slot />
</CodeBlock>
24 changes: 24 additions & 0 deletions src/components/content/MdxPre.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import MdxPre from "./MdxPre.astro";

export default {
title: "content/MdxPre",
component: MdxPre,
};

export const TypeScript = {
args: {
class: "language-typescript",
slots: {
default: '<code class="language-typescript">const hello = () =&gt; `World!`;</code>',
},
},
};

export const Bash = {
args: {
class: "language-bash",
slots: {
default: '<code class="language-bash">bun run dev\nbun run build</code>',
},
},
};
30 changes: 30 additions & 0 deletions src/components/content/SmartLink.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
import * as stylex from '@stylexjs/stylex';

import { smartLinkStyles } from './SmartLink.stylex.ts';

type Props = {
href?: string;
rel?: string;
target?: string;
class?: string;
};

const { href = '#', rel, target, class: className } = Astro.props;
const isExternal =
href.startsWith('http://') ||
href.startsWith('https://') ||
href.startsWith('//');
const resolvedRel = isExternal ? (rel ?? 'noopener noreferrer') : rel;
const resolvedTarget = isExternal ? (target ?? '_blank') : target;
---

<a
href={href}
rel={resolvedRel}
target={resolvedTarget}
class={className}
{...stylex.attrs(smartLinkStyles.link, isExternal ? smartLinkStyles.external : null)}
>
<slot />
</a>
Loading