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
8 changes: 8 additions & 0 deletions docs/app/_llms/__fixtures__/component-grid/basic.output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## Buttons

- [Action Button](https://example.com/llms/docs/components/action-button.txt) — 기본 인터랙션 컴포넌트입니다.
- [Floating Action Button](https://example.com/llms/docs/components/floating-action-button.txt)

## Controls

- [Checkbox](https://example.com/llms/docs/components/checkbox.txt) — 옵션 선택 컴포넌트입니다.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<SomethingElse />
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<SomethingElse />
44 changes: 44 additions & 0 deletions docs/app/_llms/rules/component-grid-rule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "bun:test";
import { normalizeLLMBodyWithRules } from "../normalize-llm-body";
import { normalizeForAssert, readFixture } from "../test-utils";
import { buildMarkdown, componentGridRule, type ComponentEntry } from "./component-grid-rule";

const sampleEntries: ComponentEntry[] = [
{
category: "Controls",
title: "Checkbox",
description: "옵션 선택 컴포넌트입니다.",
url: "https://example.com/llms/docs/components/checkbox.txt",
},
{
category: "Buttons",
title: "Floating Action Button",
description: "",
url: "https://example.com/llms/docs/components/floating-action-button.txt",
},
{
category: "Buttons",
title: "Action Button",
description: "기본 인터랙션 컴포넌트입니다.",
url: "https://example.com/llms/docs/components/action-button.txt",
},
];

describe("componentGridRule", () => {
it("renders categorized markdown from entries", () => {
const expected = readFixture("component-grid", "basic.output.md");

const actual = buildMarkdown(sampleEntries);

expect(normalizeForAssert(actual)).toBe(normalizeForAssert(expected));
});

it("non-target node passthrough", () => {
const input = readFixture("component-grid", "passthrough.input.mdx");
const expected = readFixture("component-grid", "passthrough.output.mdx");

const actual = normalizeLLMBodyWithRules(input, [componentGridRule]);

expect(normalizeForAssert(actual)).toBe(normalizeForAssert(expected));
});
});
114 changes: 114 additions & 0 deletions docs/app/_llms/rules/component-grid-rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import matter from "gray-matter";
import type { MdxJsxFlowElement } from "mdast-util-mdx-jsx";
import { baseUrl } from "@/app/metadata";
import { getLLMMarkdownUrl } from "../config";
import type { Rule } from "./types";

export interface ComponentEntry {
category: string;
title: string;
description: string;
url: string;
}

type Frontmatter = {
title?: string;
description?: string;
deprecated?: boolean;
};

function resolveComponentsDir(): string | null {
const candidates = [
path.resolve(process.cwd(), "content/docs/components"),
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../content/docs/components"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
Comment on lines +23 to +32
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

컴포넌트 디렉터리를 찾지 못했을 때 무음 실패합니다.

resolveComponentsDir()null을 반환하면 loadEntries()는 빈 배열을 반환하고, transform은 원본 <ComponentGrid /> MDX 노드를 그대로 돌려줍니다(Line 108). 결과적으로 llms.txt 출력에 MDX 태그가 그대로 남게 되는데, 이는 이 PR이 해결하려던 원래 문제와 동일한 증상입니다. 최소한 경고 로그라도 남겨 CI/빌드 시에 감지할 수 있도록 하는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/app/_llms/rules/component-grid-rule.ts` around lines 23 - 32,
resolveComponentsDir currently returns null silently which causes loadEntries to
return an empty array and transform to leave the original <ComponentGrid /> node
unchanged; add a visible warning when the components directory cannot be found
so CI/builds can catch it. Specifically, update resolveComponentsDir (or
immediately after its return in loadEntries) to emit a warning via the project's
logging facility (e.g., processLogger.warn or console.warn if no logger is
available) that includes the attempted candidate paths and the fact that
component entries were not loaded; ensure the warning is produced before
transform runs so the missing directory is recorded during CI.


function titleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}

function loadEntries(): ComponentEntry[] {
const componentsDir = resolveComponentsDir();
if (!componentsDir) return [];

const entries: ComponentEntry[] = [];
for (const dirent of fs.readdirSync(componentsDir, { withFileTypes: true })) {
if (!dirent.isDirectory()) continue;
const match = dirent.name.match(/^\(([^)]+)\)$/);
if (!match) continue;

const category = titleCase(match[1]);
const categoryDir = path.join(componentsDir, dirent.name);

for (const file of fs.readdirSync(categoryDir)) {
if (!file.endsWith(".mdx")) continue;

const source = fs.readFileSync(path.join(categoryDir, file), "utf8");
const fm = matter(source).data as Frontmatter;
if (fm.deprecated) continue;

const slug = file.slice(0, -".mdx".length);
entries.push({
category,
title: fm.title ?? slug,
description: fm.description ?? "",
url: new URL(getLLMMarkdownUrl("docs", ["components", slug]), baseUrl).toString(),
});
}
}
return entries;
}

let cachedEntries: ComponentEntry[] | null = null;

function getEntries(): ComponentEntry[] {
if (cachedEntries === null) cachedEntries = loadEntries();
return cachedEntries;
}

export function buildMarkdown(entries: ComponentEntry[]): string {
const grouped = new Map<string, ComponentEntry[]>();
for (const entry of entries) {
if (!grouped.has(entry.category)) grouped.set(entry.category, []);
grouped.get(entry.category)!.push(entry);
}
for (const list of grouped.values()) {
list.sort((a, b) => a.title.localeCompare(b.title));
}

const sections: string[] = [];
for (const [category, list] of Array.from(grouped.entries()).sort(([a], [b]) =>
a.localeCompare(b),
)) {
const lines = [`## ${category}`, ""];
for (const entry of list) {
const suffix = entry.description ? ` — ${entry.description}` : "";
lines.push(`- [${entry.title}](${entry.url})${suffix}`);
}
sections.push(lines.join("\n"));
}
return sections.join("\n\n");
}

export const componentGridRule: Rule = {
name: "ComponentGrid",
match: (node): node is MdxJsxFlowElement =>
node.type === "mdxJsxFlowElement" && node.name === "ComponentGrid",
transform: (node) => {
try {
const entries = getEntries();
if (entries.length === 0) return [node];
return [{ type: "html", value: buildMarkdown(entries) }];
} catch {
return [node];
}
},
};
3 changes: 3 additions & 0 deletions docs/app/_llms/rules/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { changelogPageRule } from "./changelog-page-rule";
import { codeBlockTabsRule } from "./codeblock-tabs-rule";
import { componentExampleRule } from "./component-example-rule";
import { componentGridRule } from "./component-grid-rule";
import { platformStatusRule } from "./platform-status-rule";
import { progressBoardRule } from "./progress-board-rule";
import { typeTableRule } from "./type-table-rule";
Expand All @@ -17,6 +18,7 @@ export const activeRules: Rule[] = [
platformStatusRule,
progressBoardRule,
iconLibraryRule,
componentGridRule,
componentSpecBlockRule,
changelogPageRule,
];
Expand All @@ -25,6 +27,7 @@ export {
changelogPageRule,
codeBlockTabsRule,
componentExampleRule,
componentGridRule,
typeTableRule,
tokenReferenceRule,
componentSpecBlockRule,
Expand Down
Loading