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
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@
"prebuild": "npm run build:search && npm run build:og",
"prebuild:static": "npm run build:search && npm run build:og",
"predev": "npm run build:search",
"build": "DOCS_SEO_QUIET_GENERATED=1 NODE_OPTIONS='--import tsx/esm' next build && DOCS_SEO_QUIET_GENERATED=1 NODE_OPTIONS='--import tsx/esm' npx @vahor/next-broken-links",
"build:static": "DOCS_SEO_QUIET_GENERATED=1 NODE_OPTIONS='--import tsx/esm' next build && DOCS_SEO_QUIET_GENERATED=1 NODE_OPTIONS='--import tsx/esm' npx @vahor/next-broken-links",
"build": "DOCS_SEO_QUIET_GENERATED=1 NODE_OPTIONS='--import tsx/esm' next build && node scripts/generate-llm-md-files.mjs && node scripts/check-llm-md-output.mjs && DOCS_SEO_QUIET_GENERATED=1 NODE_OPTIONS='--import tsx/esm' npx @vahor/next-broken-links",
"build:static": "DOCS_SEO_QUIET_GENERATED=1 NODE_OPTIONS='--import tsx/esm' next build && node scripts/generate-llm-md-files.mjs && node scripts/check-llm-md-output.mjs && DOCS_SEO_QUIET_GENERATED=1 NODE_OPTIONS='--import tsx/esm' npx @vahor/next-broken-links",
"dev": "DOCS_SEO_QUIET_GENERATED=1 NODE_OPTIONS='--import tsx/esm' next dev --turbo -p 3001",
"start": "next start",
"serve": "serve dist -p 8080",
"postinstall": "tsx node_modules/fumadocs-mdx/dist/bin.js",
"check-links": "node scripts/check-links.js",
"check:links": "node scripts/check-links.mjs",
"check:llm-md": "node scripts/check-llm-md-output.mjs",
"check:meta": "node scripts/check-meta.mjs",
"check:redirects": "node scripts/check-redirects.mjs",
"test:llm-md": "node --test scripts/test/*.test.mjs",
"quality": "npm run check:meta && npm run check:redirects && npm run check:links && npm run build"
},
"dependencies": {
Expand Down
243 changes: 243 additions & 0 deletions scripts/check-llm-md-output.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
#!/usr/bin/env node

import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
const DEFAULT_DIST_DIR = path.join(REPO_ROOT, 'dist');
const DEFAULT_REQUIRED_PATHS = ['index.md', 'sdk/all-modules.md'];
const HTML_OR_NEXT_ERROR_PATTERN = /<!doctype html|<html\b|404: This page could not be found|self\.__next_f|BAILOUT_TO_CLIENT_SIDE_RENDERING/i;
const IGNORED_HTML_INDEX_PATHS = new Set(['404/index.html', '_not-found/index.html']);

async function exists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}

async function collectMarkdownFiles(dir, baseDir = dir) {
const results = [];
const entries = await fs.readdir(dir, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);

if (entry.isDirectory()) {
results.push(...await collectMarkdownFiles(fullPath, baseDir));
continue;
}

if (entry.isFile() && entry.name.endsWith('.md')) {
results.push(path.relative(baseDir, fullPath).split(path.sep).join('/'));
}
}

return results.sort((a, b) => a.localeCompare(b));
}

async function collectIndexHtmlFiles(dir, baseDir = dir) {
const results = [];
const entries = await fs.readdir(dir, { withFileTypes: true });

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);

if (entry.isDirectory()) {
results.push(...await collectIndexHtmlFiles(fullPath, baseDir));
continue;
}

if (entry.isFile() && entry.name === 'index.html') {
results.push(path.relative(baseDir, fullPath).split(path.sep).join('/'));
}
}

return results.sort((a, b) => a.localeCompare(b));
}

function htmlPathForMarkdown(relativePath) {
if (relativePath === 'index.md') return 'index.html';

const withoutExtension = relativePath.slice(0, -'.md'.length);
return `${withoutExtension}/index.html`;
}

function markdownPathForHtmlIndex(relativePath) {
if (relativePath === 'index.html') return 'index.md';

return `${relativePath.slice(0, -'/index.html'.length)}.md`;
}

function normalizeBody(value) {
return value.replace(/\s+/g, ' ').trim();
}

export async function validateFileSystemOutput({
distDir = DEFAULT_DIST_DIR,
requiredPaths = DEFAULT_REQUIRED_PATHS,
} = {}) {
const errors = [];

if (!await exists(distDir)) {
return [`Missing dist directory: ${distDir}`];
}

const manifestPath = path.join(distDir, 'llm-md-manifest.json');
if (await exists(manifestPath)) {
errors.push('Temporary manifest was not removed: llm-md-manifest.json');
}

for (const requiredPath of requiredPaths) {
if (!await exists(path.join(distDir, requiredPath))) {
errors.push(`Missing required Markdown file: ${requiredPath}`);
}
}

const markdownFiles = await collectMarkdownFiles(distDir);
const markdownFileSet = new Set(markdownFiles);
const htmlIndexFiles = await collectIndexHtmlFiles(distDir);

for (const relativePath of htmlIndexFiles) {
if (IGNORED_HTML_INDEX_PATHS.has(relativePath)) continue;

const expectedMarkdownPath = markdownPathForHtmlIndex(relativePath);
if (!markdownFileSet.has(expectedMarkdownPath)) {
errors.push(`Missing Markdown file for exported HTML page: ${expectedMarkdownPath}`);
}
}

for (const relativePath of markdownFiles) {
const markdownPath = path.join(distDir, relativePath);
const content = await fs.readFile(markdownPath, 'utf8');

if (HTML_OR_NEXT_ERROR_PATTERN.test(content)) {
errors.push(`${relativePath} contains HTML/Next.js error content`);
}

if (!content.trimStart().startsWith('# ')) {
errors.push(`${relativePath} does not look like an LLM Markdown page`);
}

const matchingHtmlPath = path.join(distDir, htmlPathForMarkdown(relativePath));
if (await exists(matchingHtmlPath)) {
const html = await fs.readFile(matchingHtmlPath, 'utf8');
if (normalizeBody(html) === normalizeBody(content)) {
errors.push(`${relativePath} duplicates HTML output at ${htmlPathForMarkdown(relativePath)}`);
}
}
}

return errors;
}

async function fetchText(url) {
const response = await fetch(url, { redirect: 'follow' });
const text = await response.text();

return { response, text };
}

export async function validateHttpOutput({
baseUrl,
markdownPath = '/sdk/all-modules.md',
htmlPath = '/sdk/all-modules/',
missingPath = '/does-not-exist.md',
} = {}) {
if (!baseUrl) return [];

const errors = [];
const origin = baseUrl.replace(/\/+$/, '');
const markdown = await fetchText(`${origin}${markdownPath}`);
const html = await fetchText(`${origin}${htmlPath}`);
const missing = await fetchText(`${origin}${missingPath}`);

if (!markdown.response.ok) {
errors.push(`${markdownPath} returned HTTP ${markdown.response.status}`);
}

if (HTML_OR_NEXT_ERROR_PATTERN.test(markdown.text)) {
errors.push(`${markdownPath} returned HTML/Next.js error content`);
}

if (!markdown.text.trimStart().startsWith('# All Modules')) {
errors.push(`${markdownPath} does not start with the All Modules Markdown heading`);
}

if (!html.response.ok) {
errors.push(`${htmlPath} returned HTTP ${html.response.status}`);
}

if (normalizeBody(html.text) === normalizeBody(markdown.text)) {
errors.push(`${markdownPath} duplicates ${htmlPath} response body`);
}

if (missing.response.ok) {
errors.push(`${missingPath} unexpectedly returned HTTP ${missing.response.status}`);
}

return errors;
}

function parseArgs(argv) {
const options = {
distDir: DEFAULT_DIST_DIR,
requiredPaths: [...DEFAULT_REQUIRED_PATHS],
baseUrl: undefined,
};

for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];

if (arg === '--dist') {
options.distDir = path.resolve(argv[++index]);
continue;
}

if (arg === '--base-url') {
options.baseUrl = argv[++index];
continue;
}

if (arg === '--required') {
options.requiredPaths.push(argv[++index]);
continue;
}

throw new Error(`Unknown argument: ${arg}`);
}

return options;
}

async function main() {
const options = parseArgs(process.argv.slice(2));
const errors = [
...await validateFileSystemOutput(options),
...await validateHttpOutput(options),
];

if (errors.length > 0) {
console.error(`LLM Markdown output check failed with ${errors.length} issue(s):`);
for (const error of errors) console.error(`- ${error}`);
process.exitCode = 1;
return;
}

console.log('LLM Markdown output check passed.');
}

const invokedDirectly = process.argv[1]
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);

if (invokedDirectly) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
if (error instanceof Error && error.stack) console.error(error.stack);
process.exitCode = 1;
});
}
117 changes: 117 additions & 0 deletions scripts/generate-llm-md-files.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env node

import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
const DEFAULT_DIST_DIR = path.join(REPO_ROOT, 'dist');
const DEFAULT_MANIFEST_PATH = path.join(DEFAULT_DIST_DIR, 'llm-md-manifest.json');

function assertSafeRelativePath(relativePath) {
const segments = relativePath.split('/');
if (
path.isAbsolute(relativePath)
|| segments.some((segment) => segment === '.' || segment === '..' || segment.length === 0)
) {
throw new Error(`Invalid manifest entry url path: ${relativePath}`);
}
}

export function urlToMarkdownRelativePath(url) {
if (typeof url !== 'string' || url.length === 0) {
throw new Error(`Invalid manifest entry url: ${JSON.stringify(url)}`);
}

if (url.includes('?') || url.includes('#')) {
throw new Error(`Invalid manifest entry url: ${JSON.stringify(url)}`);
}

const trimmed = url.replace(/^\/+/, '').replace(/\/+$/, '');
if (trimmed.length === 0) return 'index.md';

assertSafeRelativePath(trimmed);

return `${trimmed}.md`;
}

function validateEntry(entry, index) {
if (!entry || typeof entry !== 'object') {
throw new Error(`Invalid manifest entry at index ${index}: expected object`);
}

if (typeof entry.url !== 'string' || entry.url.length === 0) {
throw new Error(`Invalid manifest entry at index ${index}: missing url`);
}

if (typeof entry.content !== 'string') {
throw new Error(`Invalid manifest entry at index ${index}: missing content`);
}
}

export async function readManifest(manifestPath = DEFAULT_MANIFEST_PATH) {
const raw = await fs.readFile(manifestPath, 'utf8');
const parsed = JSON.parse(raw);

if (!Array.isArray(parsed)) {
throw new Error(`Expected manifest to be an array, got ${typeof parsed}`);
}

parsed.forEach(validateEntry);
return parsed;
}

export async function generateMarkdownFiles({
distDir = DEFAULT_DIST_DIR,
manifestPath = DEFAULT_MANIFEST_PATH,
} = {}) {
const entries = await readManifest(manifestPath);
const written = [];

for (const entry of entries) {
const relativePath = urlToMarkdownRelativePath(entry.url);
const target = path.resolve(distDir, relativePath);
const distRoot = path.resolve(distDir);

if (!target.startsWith(`${distRoot}${path.sep}`)) {
throw new Error(`Refusing to write Markdown outside dist: ${relativePath}`);
}

await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, entry.content, 'utf8');
written.push(relativePath);
}

await fs.unlink(manifestPath);
return written;
}

async function main() {
console.log(`Generating per-page Markdown files from ${DEFAULT_MANIFEST_PATH}`);

let written;
try {
written = await generateMarkdownFiles();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Could not generate per-page Markdown files: ${message}`);
}

for (const relativePath of written) {
console.log(` wrote dist/${relativePath}`);
}

console.log(`Generated ${written.length} per-page Markdown file(s)`);
}

const invokedDirectly = process.argv[1]
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);

if (invokedDirectly) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
if (error instanceof Error && error.stack) console.error(error.stack);
process.exitCode = 1;
});
}
Loading
Loading