|
| 1 | +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' |
| 2 | +import { tmpdir } from 'node:os' |
| 3 | +import path from 'node:path' |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' |
| 5 | +import { |
| 6 | + type ContentCheckConfig, |
| 7 | + checkContent, |
| 8 | + checkMergedSlugTargets, |
| 9 | + indexPosts, |
| 10 | + resolvePostArg, |
| 11 | +} from './check-library-content' |
| 12 | + |
| 13 | +let root: string |
| 14 | +let config: ContentCheckConfig |
| 15 | + |
| 16 | +const CLEAN_FRONTMATTER = { |
| 17 | + title: 'A clean library post', |
| 18 | + description: 'A description that is comfortably over twenty characters.', |
| 19 | + date: '2026-09-01', |
| 20 | + authors: '[sim]', |
| 21 | +} |
| 22 | + |
| 23 | +function writePost( |
| 24 | + section: string, |
| 25 | + slug: string, |
| 26 | + body: string, |
| 27 | + frontmatter: Record<string, string> = {} |
| 28 | +) { |
| 29 | + const fields = { |
| 30 | + slug, |
| 31 | + ...CLEAN_FRONTMATTER, |
| 32 | + ogImage: `/${section}/${slug}/cover.jpg`, |
| 33 | + ...frontmatter, |
| 34 | + } |
| 35 | + const yaml = Object.entries(fields) |
| 36 | + .map(([key, value]) => (value.startsWith('\n') ? `${key}:${value}` : `${key}: ${value}`)) |
| 37 | + .join('\n') |
| 38 | + const dir = path.join(config.contentDir, section, slug) |
| 39 | + mkdirSync(dir, { recursive: true }) |
| 40 | + writeFileSync(path.join(dir, 'index.mdx'), `---\n${yaml}\n---\n\n${body}\n`) |
| 41 | + const imageDir = path.join(config.publicDir, section, slug) |
| 42 | + mkdirSync(imageDir, { recursive: true }) |
| 43 | + writeFileSync(path.join(imageDir, 'cover.jpg'), '') |
| 44 | +} |
| 45 | + |
| 46 | +async function findingsFor(section: string, slug: string) { |
| 47 | + const { findings } = await checkContent(config, [{ section: section as 'library', slug }]) |
| 48 | + return findings.map(({ line, rule, message }) => ({ line, rule, message })) |
| 49 | +} |
| 50 | + |
| 51 | +beforeEach(() => { |
| 52 | + root = mkdtempSync(path.join(tmpdir(), 'library-content-')) |
| 53 | + config = { |
| 54 | + contentDir: path.join(root, 'content'), |
| 55 | + publicDir: path.join(root, 'public'), |
| 56 | + reservedSegments: { blog: new Set(['tags']), library: new Set(['tags']), customers: new Set() }, |
| 57 | + mergedSlugs: { 'old-guide': 'kept-guide' }, |
| 58 | + movedBlogSlugs: ['moved-post'], |
| 59 | + } |
| 60 | + mkdirSync(path.join(config.contentDir, 'authors'), { recursive: true }) |
| 61 | + writeFileSync(path.join(config.contentDir, 'authors', 'sim.json'), '{"id":"sim","name":"Sim"}') |
| 62 | + writePost('library', 'kept-guide', 'The surviving guide.') |
| 63 | +}) |
| 64 | + |
| 65 | +afterEach(() => { |
| 66 | + rmSync(root, { recursive: true, force: true }) |
| 67 | +}) |
| 68 | + |
| 69 | +describe('check-library-content', () => { |
| 70 | + it('passes a clean post with valid internal links, assets, reserved routes, and code samples', async () => { |
| 71 | + writePost( |
| 72 | + 'library', |
| 73 | + 'clean', |
| 74 | + [ |
| 75 | + '## Overview', |
| 76 | + 'See [the guide](/library/kept-guide) and https://www.sim.ai/library/kept-guide#intro.', |
| 77 | + ' and [tags](/library/tags) are not posts.', |
| 78 | + 'An apex https://sim.ai/library/missing link belongs to check:site-urls.', |
| 79 | + '| a | b |', |
| 80 | + '| - | - |', |
| 81 | + '| 1 | 2 |', |
| 82 | + '```md', |
| 83 | + '## FAQ', |
| 84 | + '[old](/library/old-guide)', |
| 85 | + '```', |
| 86 | + ].join('\n') |
| 87 | + ) |
| 88 | + expect(await findingsFor('library', 'clean')).toEqual([]) |
| 89 | + }) |
| 90 | + |
| 91 | + it('rejects an unknown frontmatter key such as canonical, at its line', async () => { |
| 92 | + writePost('library', 'post', 'Body.', { canonical: 'https://www.sim.ai/library/post' }) |
| 93 | + expect(await findingsFor('library', 'post')).toEqual([ |
| 94 | + { line: 8, rule: 'frontmatter', message: 'Unknown frontmatter key "canonical".' }, |
| 95 | + ]) |
| 96 | + }) |
| 97 | + |
| 98 | + it('rejects frontmatter that fails the schema', async () => { |
| 99 | + writePost('library', 'post', 'Body.', { title: 'Hi' }) |
| 100 | + const findings = await findingsFor('library', 'post') |
| 101 | + expect(findings).toHaveLength(1) |
| 102 | + expect(findings[0]).toMatchObject({ line: 3, rule: 'frontmatter' }) |
| 103 | + }) |
| 104 | + |
| 105 | + it('rejects invalid YAML', async () => { |
| 106 | + writePost('library', 'post', 'Body.', { title: 'Broken: [unclosed' }) |
| 107 | + expect((await findingsFor('library', 'post'))[0]).toMatchObject({ rule: 'frontmatter' }) |
| 108 | + }) |
| 109 | + |
| 110 | + it('rejects an author with no profile', async () => { |
| 111 | + writePost('library', 'post', 'Body.', { authors: '[ghost]' }) |
| 112 | + expect(await findingsFor('library', 'post')).toEqual([ |
| 113 | + { |
| 114 | + line: 6, |
| 115 | + rule: 'frontmatter', |
| 116 | + message: 'Author "ghost" has no profile in apps/sim/content/authors.', |
| 117 | + }, |
| 118 | + ]) |
| 119 | + }) |
| 120 | + |
| 121 | + it('rejects a slug that differs from the folder name', async () => { |
| 122 | + writePost('library', 'post', 'Body.', { slug: 'other' }) |
| 123 | + expect(await findingsFor('library', 'post')).toEqual([ |
| 124 | + { line: 2, rule: 'slug', message: 'slug "other" does not match the folder name "post".' }, |
| 125 | + ]) |
| 126 | + }) |
| 127 | + |
| 128 | + it('rejects a missing or remote ogImage', async () => { |
| 129 | + writePost('library', 'missing', 'Body.', { ogImage: '/library/missing/nope.jpg' }) |
| 130 | + writePost('library', 'remote', 'Body.', { ogImage: 'https://example.com/a.jpg' }) |
| 131 | + expect(await findingsFor('library', 'missing')).toEqual([ |
| 132 | + { |
| 133 | + line: 7, |
| 134 | + rule: 'og-image', |
| 135 | + message: 'ogImage "/library/missing/nope.jpg" does not exist under apps/sim/public.', |
| 136 | + }, |
| 137 | + ]) |
| 138 | + expect((await findingsFor('library', 'remote'))[0]).toMatchObject({ rule: 'og-image' }) |
| 139 | + }) |
| 140 | + |
| 141 | + it('reports an MDX compile error at its file line', async () => { |
| 142 | + writePost('library', 'post', 'Intro.\n\nA tag <div that never closes.') |
| 143 | + const findings = await findingsFor('library', 'post') |
| 144 | + expect(findings).toHaveLength(1) |
| 145 | + expect(findings[0]).toMatchObject({ line: 12, rule: 'mdx' }) |
| 146 | + }) |
| 147 | + |
| 148 | + it('rejects an FAQ heading in the body', async () => { |
| 149 | + writePost('library', 'post', 'Intro.\n\n## FAQs\n\nQ and A.') |
| 150 | + expect(await findingsFor('library', 'post')).toEqual([ |
| 151 | + { line: 12, rule: 'faq', message: 'Body has a "## FAQs" heading.' }, |
| 152 | + ]) |
| 153 | + }) |
| 154 | + |
| 155 | + it('rejects Markdown link syntax inside an FAQ answer, at the answer line', async () => { |
| 156 | + writePost('library', 'post', 'Body.', { |
| 157 | + faq: '\n - q: "Plain question?"\n a: "Plain answer."\n - q: "Linked?"\n a: "See [Sim](https://www.sim.ai)."', |
| 158 | + }) |
| 159 | + expect(await findingsFor('library', 'post')).toEqual([ |
| 160 | + { |
| 161 | + line: 12, |
| 162 | + rule: 'faq', |
| 163 | + message: 'faq[1].a contains Markdown link syntax, which renders as literal text.', |
| 164 | + }, |
| 165 | + ]) |
| 166 | + }) |
| 167 | + |
| 168 | + it('rejects links to missing, retired, and moved posts, naming the replacement', async () => { |
| 169 | + writePost( |
| 170 | + 'library', |
| 171 | + 'post', |
| 172 | + [ |
| 173 | + '[a](/library/missing)', |
| 174 | + '[b](https://www.sim.ai/library/old-guide)', |
| 175 | + '<a href="/blog/moved-post">c</a>', |
| 176 | + '[d](/blog/kept-guide)', |
| 177 | + ].join('\n') |
| 178 | + ) |
| 179 | + expect(await findingsFor('library', 'post')).toEqual([ |
| 180 | + { |
| 181 | + line: 10, |
| 182 | + rule: 'internal-link', |
| 183 | + message: '/library/missing does not exist (no apps/sim/content/library/missing/index.mdx).', |
| 184 | + }, |
| 185 | + { |
| 186 | + line: 11, |
| 187 | + rule: 'internal-link', |
| 188 | + message: '/library/old-guide is retired; it was merged into /library/kept-guide.', |
| 189 | + }, |
| 190 | + { |
| 191 | + line: 12, |
| 192 | + rule: 'internal-link', |
| 193 | + message: '/blog/moved-post moved to /library/moved-post.', |
| 194 | + }, |
| 195 | + { |
| 196 | + line: 13, |
| 197 | + rule: 'internal-link', |
| 198 | + message: '/blog/kept-guide does not exist (no apps/sim/content/blog/kept-guide/index.mdx).', |
| 199 | + }, |
| 200 | + ]) |
| 201 | + }) |
| 202 | + |
| 203 | + it('rejects a retired slug whose replacement no longer exists', () => { |
| 204 | + config.mergedSlugs = { 'old-guide': 'gone-guide' } |
| 205 | + const findings = checkMergedSlugTargets(config, indexPosts(config.contentDir), 'map.ts') |
| 206 | + expect(findings.map((finding) => finding.message)).toEqual([ |
| 207 | + 'Retired slug "old-guide" redirects to /library/gone-guide, which does not exist.', |
| 208 | + ]) |
| 209 | + }) |
| 210 | + |
| 211 | + it('resolves a post argument by section/slug or bare slug, and rejects unknown ones', () => { |
| 212 | + const posts = indexPosts(config.contentDir) |
| 213 | + expect(resolvePostArg('library/kept-guide', posts)).toEqual([ |
| 214 | + { section: 'library', slug: 'kept-guide' }, |
| 215 | + ]) |
| 216 | + expect(resolvePostArg('kept-guide', posts)).toEqual([ |
| 217 | + { section: 'library', slug: 'kept-guide' }, |
| 218 | + ]) |
| 219 | + expect(typeof resolvePostArg('blog/kept-guide', posts)).toBe('string') |
| 220 | + expect(typeof resolvePostArg('nope', posts)).toBe('string') |
| 221 | + }) |
| 222 | +}) |
0 commit comments