diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f7d4aee..e713e592 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,3 +94,27 @@ jobs: steps: - uses: actions/checkout@v4 - run: node -e "const fs=require('fs'); const dir='deploy/grafana/dashboards'; fs.readdirSync(dir).filter(f=>f.endsWith('.json')).forEach(f=>{const d=JSON.parse(fs.readFileSync(dir+'/'+f,'utf8'));if(!d.uid||!d.schemaVersion)throw new Error('missing uid/schemaVersion in '+f);if(!d.templating||!d.templating.list.some(v=>v.name==='organization_id'))throw new Error('missing organization_id variable in '+f);console.log('OK',f)})" + + docs: + runs-on: ubuntu-latest + defaults: + run: + working-directory: docs + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install dependencies + run: npm install + - name: Validate frontmatter + run: npx tsx scripts/validate-frontmatter.ts + - name: Build static site + run: npm run build + - name: Generate Pagefind index + run: npx pagefind --site out + - name: Check dead links + uses: lycheeverse/lychee-action@v2 + with: + args: --offline --include-fragments --root-dir docs/out --fallback-extensions html --exclude-path docs/out/404.html './docs/out/**/*.html' + fail: true diff --git a/docs/__tests__/scripts/validate-frontmatter.test.ts b/docs/__tests__/scripts/validate-frontmatter.test.ts new file mode 100644 index 00000000..1fc56134 --- /dev/null +++ b/docs/__tests__/scripts/validate-frontmatter.test.ts @@ -0,0 +1,71 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + validateFrontmatter, + validateFrontmatterOrThrow, +} from "../../scripts/validate-frontmatter"; + +const tempDirs: string[] = []; + +function createTempPagesDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdx-frontmatter-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("validateFrontmatter", () => { + it("returns an error when title is missing", () => { + const pagesDir = createTempPagesDir(); + fs.writeFileSync( + path.join(pagesDir, "missing-title.mdx"), + "---\ndescription: test\n---\n# Hello", + ); + + const result = validateFrontmatter(pagesDir); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.message).toMatch(/title/i); + expect(() => validateFrontmatterOrThrow(pagesDir)).toThrow(/title/i); + }); + + it("succeeds when title is present", () => { + const pagesDir = createTempPagesDir(); + fs.writeFileSync( + path.join(pagesDir, "valid.mdx"), + "---\ntitle: Hello World\n---\n# Hello", + ); + + const result = validateFrontmatter(pagesDir); + + expect(result.errors).toHaveLength(0); + expect(result.successCount).toBe(1); + expect(validateFrontmatterOrThrow(pagesDir).successCount).toBe(1); + }); + + it("flags draft pages in production mode", () => { + const pagesDir = createTempPagesDir(); + fs.writeFileSync( + path.join(pagesDir, "draft.mdx"), + "---\ntitle: Draft Page\ndraft: true\n---\n# Draft", + ); + + const devResult = validateFrontmatter(pagesDir, { production: false }); + const prodResult = validateFrontmatter(pagesDir, { production: true }); + + expect(devResult.errors).toHaveLength(0); + expect(devResult.successCount).toBe(1); + expect(prodResult.errors).toHaveLength(1); + expect(prodResult.errors[0]?.message).toMatch(/draft/i); + expect(() => + validateFrontmatterOrThrow(pagesDir, { production: true }), + ).toThrow(/draft/i); + }); +}); diff --git a/docs/components/EditPageLink.tsx b/docs/components/EditPageLink.tsx index d8b2ddf0..e4ba2406 100644 --- a/docs/components/EditPageLink.tsx +++ b/docs/components/EditPageLink.tsx @@ -1,12 +1,12 @@ -import { useConfig } from 'nextra-theme-docs'; - type EditPageLinkProps = { filePath: string; }; +const DOCS_REPOSITORY_BASE = + 'https://github.com/Corevice/open-git/blob/main/docs'; + export function EditPageLink({ filePath }: EditPageLinkProps) { - const { docsRepositoryBase } = useConfig(); - const href = `${docsRepositoryBase}/tree/main/${filePath}`; + const href = `${DOCS_REPOSITORY_BASE}/${filePath}`; return このページを編集; } diff --git a/docs/next.config.mjs b/docs/next.config.mjs index 95d7253b..4815af26 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -8,8 +8,9 @@ const withNextra = nextra({ /** @type {import('next').NextConfig} */ const nextConfig = { output: 'export', - // Static export (required so the Pagefind postbuild can index `out/`) is not - // compatible with the default next/image optimization loader. + // Static export to `out/` (so the Pagefind postbuild and the docs CI + // workflow can index it) is not compatible with the default next/image + // optimization loader. images: { unoptimized: true, }, diff --git a/docs/package.json b/docs/package.json index 67e3c589..32591720 100644 --- a/docs/package.json +++ b/docs/package.json @@ -7,18 +7,21 @@ "build": "next build", "start": "next start", "lint": "next lint", + "validate-frontmatter": "tsx scripts/validate-frontmatter.ts", + "check-links": "blc http://localhost:3000 -ro --filter-level 3", "test": "vitest run", + "typecheck": "tsc --noEmit", "postbuild": "pagefind --site out" }, "dependencies": { + "gray-matter": "^4.0.3", "next": "^15.0.0", "nextra": "^3.0.0", "nextra-theme-docs": "^3.0.0", + "next-themes": "^0.4.0", "prismjs": "^1.29.0", "react": "^19.0.0", - "react-dom": "^19.0.0", - "next-themes": "^0.4.0", - "gray-matter": "^4.0.3" + "react-dom": "^19.0.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.0.0", @@ -29,9 +32,16 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.0.0", + "broken-link-checker": "^0.7.8", "jsdom": "^25.0.0", - "pagefind": "^1.1.0", + "pagefind": "^1.3.0", + "tsx": "^4.19.0", "typescript": "^5.4.0", "vitest": "^2.0.0" + }, + "allowScripts": { + "esbuild@0.28.1": true, + "esbuild@0.21.5": true, + "sharp@0.34.5": true } } diff --git a/docs/pages/contributing/index.mdx b/docs/pages/contributing/index.mdx index 96bd2281..3bdef761 100644 --- a/docs/pages/contributing/index.mdx +++ b/docs/pages/contributing/index.mdx @@ -12,7 +12,7 @@ Issue の報告、Pull Request の提出、ドキュメントの改善など、 ## ライセンス -本プロジェクトは [LICENSE](/LICENSE) ファイルに記載されたライセンスの下で提供されています。コントリビューションを行う前にライセンス条項をご確認ください。 +本プロジェクトは [LICENSE](https://github.com/Corevice/open-git/blob/main/LICENSE) ファイルに記載されたライセンスの下で提供されています。コントリビューションを行う前にライセンス条項をご確認ください。 ## 関連リンク diff --git a/docs/scripts/validate-frontmatter.ts b/docs/scripts/validate-frontmatter.ts new file mode 100644 index 00000000..b089c2bf --- /dev/null +++ b/docs/scripts/validate-frontmatter.ts @@ -0,0 +1,115 @@ +import fs from "fs"; +import path from "path"; +import matter from "gray-matter"; + +export interface ValidationError { + file: string; + message: string; +} + +export interface ValidationResult { + errors: ValidationError[]; + successCount: number; +} + +export interface ValidateOptions { + production?: boolean; +} + +function isProduction(options: ValidateOptions): boolean { + if (options.production !== undefined) { + return options.production; + } + return process.env.NODE_ENV === "production"; +} + +function walkMdxFiles(dir: string, files: string[] = []): string[] { + if (!fs.existsSync(dir)) { + return files; + } + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkMdxFiles(fullPath, files); + } else if (entry.isFile() && entry.name.endsWith(".mdx")) { + files.push(fullPath); + } + } + + return files; +} + +export function validateFrontmatter( + pagesDir: string, + options: ValidateOptions = {}, +): ValidationResult { + const production = isProduction(options); + const errors: ValidationError[] = []; + let successCount = 0; + + for (const filePath of walkMdxFiles(pagesDir)) { + const content = fs.readFileSync(filePath, "utf8"); + const { data } = matter(content); + + const title = data.title; + if (typeof title !== "string" || title.trim() === "") { + errors.push({ + file: filePath, + message: 'Missing required "title" in frontmatter', + }); + continue; + } + + if (production && data.draft === true) { + errors.push({ + file: filePath, + message: "Draft pages are not allowed in production builds", + }); + continue; + } + + successCount++; + } + + return { errors, successCount }; +} + +export function validateFrontmatterOrThrow( + pagesDir: string, + options: ValidateOptions = {}, +): ValidationResult { + const result = validateFrontmatter(pagesDir, options); + if (result.errors.length > 0) { + const details = result.errors + .map((error) => `${error.file}: ${error.message}`) + .join("\n"); + throw new Error(`Frontmatter validation failed:\n${details}`); + } + return result; +} + +function main(): void { + const pagesDir = path.join(process.cwd(), "pages"); + const result = validateFrontmatter(pagesDir); + + if (result.errors.length > 0) { + console.error("Frontmatter validation failed:"); + for (const error of result.errors) { + console.error(` ${error.file}: ${error.message}`); + } + process.exit(1); + } + + console.log( + `Frontmatter validation passed: ${result.successCount} file(s) checked`, + ); +} + +const isMainModule = + process.argv[1] !== undefined && + path.resolve(process.argv[1]) === path.resolve(__filename); + +if (isMainModule) { + main(); +}