llms-full.txt는 페이지 렌더링에 사용되는 배열과 동일한 배열에서 생성된 574,626 바이트입니다
요약
Nakodo는 76개의 공개 페이지를 하나의 단일 렌더러와 동적 라우트를 통해 효율적으로 관리합니다. 특히, llms-full.txt 파일은 사이트의 어시스턴트(assistants)가 읽을 수 있도록 객체 구조에서 생성되었으며, 이는 개발 과정의 복잡성을 줄이는 데 기여했습니다. 이 시스템은 Rich 텍스트를 단순 문자열로 처리하며, 인라인 마크업([label](href), **bold**)만 지원하여 마크다운 생성기와 페이지 렌더러 모두에 적합한 트릭을 사용합니다.
핵심 포인트
- 단일 렌더러와 동적 라우트를 사용하여 여러 페이지를 효율적으로 관리함.
- llms-full.txt는 AI 어시스턴트가 사이트를 읽도록 객체 구조에서 생성된 데이터 파일임.
- Rich 텍스트 처리는 인라인 마크업(링크, 볼드)만 지원하여 마크다운 호환성을 유지함.
Nakodo에는 76개의 공개 페이지가 있습니다. 이 중 63개는 페이지 자체가 템플릿이 아니기 때문에 여덟 개의 동적 라우트(dynamic routes)에 의해 렌더링됩니다. 즉, 페이지는 src/content 내의 TypeScript 객체이며, 라우트는 그중 하나를 선택하여 단일 렌더러에게 전달합니다.
이 결정은 일반적인 이유로 내려졌습니다: 하나의 렌더러, 하나의 테스트 세트, MDX 빌드 단계가 없기 때문입니다. 제가 예상하지 못했던 이점은 llms.txt와 llms-full.txt를 추가했을 때 발생했습니다. 이 두 파일은 llmstxt.org가 사이트를 읽는 어시스턴트(assistants)를 위해 제안하는 것입니다. 이것들을 수동으로 작성하려면 모든 페이지에 대한 두 번째 복사본이 필요하며, 이는 정확하게 시작하지만 2주 만에 틀리게 됩니다. 이 파일들은 객체로부터 생성하는 데는 오후 시간이 걸렸습니다.
한 페이지는 하나의 객체
모든 검색 의도 페이지(search intent page), 즉 모든 가이드, 니치 페이지(niche page), 오디언스 페이지(audience page), 도구 페이지(tool page)는 동일한 형태를 가지고 있습니다:
export type ContentPage = {
slug: string;
label: string; // breadcrumbs, cards, footer links
...
섹션(section)은 ID, 제목(heading), 그리고 블록 목록으로 구성되며, 블록(block)은 여덟 가지 형태의 유니온(union)입니다:
export type Block =
| { type: "p"; text: Rich }
| { type: "list"; items: Rich[]; ordered?: boolean }
...
63개 페이지에 걸쳐 385개의 섹션, 853개의 블록, 그리고 306개의 FAQ가 있습니다. 이 분포는 제가 안심하는 방식으로 치우쳐 있습니다: 단락(paragraph) 524개, 목록(list) 139개, 용어 목록(term list) 53개, 표(table) 50개, 태그 행(tag row) 26개, 단계 목록(step list) 22개, 복사 가능한 템플릿(copyable template) 20개, 콜아웃(callout) 19개. 만약 블록 유형이 두 번 사용되었다면, 그것은 그 유형이 글쓰기 목적보다는 특정 페이지를 위해 발명된 신호일 것입니다.
Rich text는 의도적으로 두 개의 토큰입니다
Rich는 단순히 string입니다. 인라인 마크업(inline markup)은 하나의 정규 표현식(regex)으로 이루어져 있습니다:
export const RICH_TOKEN = /([^\)]+[^\]]+)\[([^\]]+\)\([^\]\s]+\)|\*\*([^\*]+)\*\*/g;
링크와 볼드체(bold)뿐입니다. 그 위의 주석이 모든 이유를 설명합니다:
// The inline markup content is written in: [label](href) and **bold**. Kept to
// two forms so the same string is valid markdown for llms-full.txt.
```}{
이것이 마크다운 생성기가 의존하는 트릭입니다. 페이지 렌더러는 토큰을 순회하며 `<a>`와 `<strong>`를 출력하고, 마크다운 생성기는 문자열을 변경 없이 그대로 출력합니다. 왜냐하면 그 문자열 자체가 이미 마크다운이었기 때문입니다. 코드베이스 어디에도 직렬화(serialiser) 과정도 없고, 이스케이프 처리(escaping pass) 과정도 없으며, HTML을 마크다운으로 변환하는 과정도 없습니다. 마크업, 메타 태그 및 구조화된 데이터 없이 텍스트가 필요한 두 소비자(consumer)는 다른 대체 함수를 사용하여 동일한 정규 표현식(regex)을 호출합니다:
export function plain(text: Rich): string {
return text.replace(RICH_TOKEN, (_, label, _href, bold) => label ?? bold ?? "");
}
## 생성기는 하나의 스위치
`block()`은 블록을 마크다운으로 변환하며, 컴파일러는 유니온 타입이 포괄적(exhaustive)이고 함수가 `string`을 반환하기 때문에 이를 잊도록 허용하지 않습니다:
function block(b: Block): string {
switch (b.type) {
case "p":
...
여기에 아홉 번째 블록 타입을 추가하면, 해당 파일을 사용하는 페이지가 배포되기 전에 컴파일이 실패합니다. 이것이 제가 원했던 유일한 강제(enforcement)였습니다. 출력 모양이 올바른지 테스트하는 것이 아니라, 출력이 존재할 수 없다면 타입 에러를 발생시키는 것입니다.
여기에 있는 두 가지 세부 사항은 나머지 파일보다 더 많은 고민을 필요로 했습니다:
**링크는 호스트를 가져야 합니다.** 콘텐츠 링크는 사이트 상대(site relative)이며, 이는 페이지에는 적합하지만 누군가 다운로드하는 텍스트 파일에는 부적절합니다. 완성된 문서에 한 번 적용되는 하나의 정규 표현식은 다음과 같습니다:
const absolute = (md: string) => md.replace(/\](/g, ](${SITE_URL}/);
**테이블 셀은 단락이 아닙니다.** 셀 내부의 파이프(`|`)는 셀을 종료시키고, 셀 내부의 개행 문자(`
`)는 행을 종료시키므로, 셀은 결합되기 전에 이스케이프되고 평탄화(flattened)됩니다:
const cell = (s: string) => absolute(s).replace(/|/g, "\|").replace(/\n/g, " ");
[llms.txt](https://nakodo.app/llms.txt)에는 세 번째 경우가 있습니다. 사양의 형태는 링크 뒤에 노트가 오는 것이며, 자체 링크를 포함하는 노트를 링크 목록에서 읽으면 무의미하게 보이므로, 해당 노트는 `plain()`을 거치고 요약(summary)의 링크들은 제거됩니다:
const link = (path: string, name: string, text: string) =>
`- [${name}](${absoluteUrl(path)}): ${plain(text)}`;
## 두 파일 모두 정적입니다
라우트는 각각 8줄로 구성되어 있습니다:
import { llmsFullTxt } from "@/content/markdown";
// 빌드 시점에 src/content에서 한 번 생성됩니다.
...
`force-static`은 보이는 것보다 더 중요합니다. `llmsFullTxt()`는 모든 페이지의 모든 섹션을 연결(concatenate)합니다. 비용이 많이 드는 것은 아니지만, 공짜도 아닙니다. 그리고 이것을 두 번 실행할 이유가 없습니다. 빌드 시점에 한 번 실행되며 그 결과는 CDN에 있는 파일이 됩니다.
크기가 사람들이 궁금해하는 부분입니다. `llms.txt`는 35,064 바이트이며, 제품 페이지와 모든 콘텐츠 페이지를 링크 및 한 줄 요약 형태로 그룹화한 것입니다. `llms-full.txt`는 574,626 바이트, 92,301 단어, 6,345줄로 구성되어 있습니다: 전체 사이트를 하나의 마크다운 문서로 만들고, 각 페이지 앞에 해당 소스 URL과 `updated` 날짜를 붙이며, 이들을 `---`로 구분합니다. 두 파일 모두 페이지와 비교(diff)할 수 있습니다. [가이드 인덱스](https://nakodo.app/guides)에서 아무 페이지나 골라 H1을 검색해보세요. 본문 텍스트는 동일한 문자열이기 때문에 같습니다.
## 이것이 할 가치가 있는 이유
레지스트리에는 상단에 하나의 주석이 있으며, 이것이 진짜 논거입니다:
// 모든 공개 페이지를 한 곳에 모았습니다. 사이트맵(sitemap), llms.txt, llms-full.txt,
// 허브(hubs), 푸터(footer) 및 "다음 읽을거리" 링크 모두 이 목록에서 파생됩니다. 따라서 새 페이지가 어느 목록에서도 누락될 수 없습니다: 사이트맵에 누락된 페이지는 여전히...
여섯 개의 소비자, 하나의 목록입니다. 이것이 제거하는 실패 모드는 충돌(crash)이 아닙니다. 그것은 존재하고 렌더링되며 잘 읽히지만, 그에 대해 알려줄 수 있는 단 하나의 파일에서 빠져있는 페이지입니다. 아무도 한 달 동안 알아차리지 못합니다.
같은 목록 덕분에 테스트가 테이블 셀 내부를 포함하여 콘텐츠의 어느 곳에 있는 깨진 내부 링크도 거부할 수 있습니다:
assert.ok(resolveLink(path), ${from}: link to unknown page ${href});
삭제한 페이지로 연결되는 링크는 몇 주 후 크롤링에서 실패하는 것이 아니라, `pnpm test`를 실패하게 만듭니다.
제가 다르게 할 한 가지는 여기서부터 시작하는 것입니다. 이 페이지들에 대한 저희의 첫 번째 시도는 파일들로 이루어졌고, 그것들을 객체로 변환하는 과정은 지루한 하루였습니다. 콘텐츠 타입들은 변환되기 전에 나온 것이 아니라, 변환을 통해 도출되었습니다. 아마도 그래야만 하는 방식일 겁니다. 5페이지를 작성하기 전에는 63페이지의 형태를 설계할 수 없기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기