Skip to content

Commit 3a39bb7

Browse files
committed
chore(content): add check:library-content audit for blog, library, and customer posts
Validates frontmatter against the strict ContentFrontmatterSchema, slug/folder parity, local ogImage existence, MDX compilation with remark-gfm, FAQ placement, and internal post links (including retired and moved slugs). Moves the library slug redirect maps into lib/library/retired-slugs.ts so next.config.ts and the audit share one source. Fixes two FAQ answers that rendered Markdown links as literal text.
1 parent 60a1f6f commit 3a39bb7

7 files changed

Lines changed: 698 additions & 30 deletions

File tree

‎apps/sim/content/library/ai-agent-vs-chatbot/index.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ ogImage: /library/ai-agent-vs-chatbot/cover.jpg
1212
draft: false
1313
faq:
1414
- q: "Do AI agents always produce more accurate answers than chatbots?"
15-
a: "Answer accuracy is the degree to which a system's response is correct and supported by the available evidence. In [Sim](https://www.sim.ai), you can inspect each workflow step and control which instructions, data, and tools the model uses. This visibility helps you find the source of an error and improve the workflow without assuming that an agent is inherently more accurate than a chatbot."
15+
a: "Answer accuracy is the degree to which a system's response is correct and supported by the available evidence. In Sim, you can inspect each workflow step and control which instructions, data, and tools the model uses. This visibility helps you find the source of an error and improve the workflow without assuming that an agent is inherently more accurate than a chatbot."
1616
- q: "Can a chatbot and an AI agent work together?"
17-
a: "A hybrid application uses a chatbot for conversation and an AI agent for actions that require tools or multiple steps. Sim connects our [Chat interface](https://docs.sim.ai/execution/chat) to agent workflows you build in a visual workspace with connected integrations. You can give users one conversational interface while the agent handles work across connected applications."
17+
a: "A hybrid application uses a chatbot for conversation and an AI agent for actions that require tools or multiple steps. Sim connects our Chat interface to agent workflows you build in a visual workspace with connected integrations. You can give users one conversational interface while the agent handles work across connected applications."
1818
- q: "Do I need to know how to code to build an AI agent?"
1919
a: "A visual agent builder represents workflow logic as configurable blocks that connect models to tools, so you do not have to program every step. Sim provides a visual workspace for creating and testing these workflows. You can add code when needed without building the orchestration layer from scratch."
2020
- q: "Can AI agents run with local models?"
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* AEO/GEO-style posts (listicles, comparisons, how-tos) split out of `/blog`
3+
* into the dedicated `/library` section so `/blog` stays editorial-only.
4+
* `next.config.ts` redirects `/blog/<slug>` to `/library/<slug>` for each.
5+
*/
6+
export const LIBRARY_MOVED_BLOG_SLUGS = [
7+
'best-zapier-alternatives',
8+
'ai-agents-vs-rpa',
9+
'ai-agent-vs-chatbot',
10+
'openai-vs-n8n-vs-sim',
11+
'ai-agent-ideas',
12+
'how-to-create-an-ai-agent',
13+
] as const
14+
15+
/**
16+
* Library articles retired by merging into a stronger article on the same
17+
* search intent, keyed by retired slug. `next.config.ts` redirects each
18+
* retired URL to the surviving article, and `check:library-content` rejects
19+
* new links to a retired slug.
20+
*/
21+
export const LIBRARY_MERGED_SLUGS: Readonly<Record<string, string>> = {
22+
'automation-anywhere-alternative': 'ai-agents-vs-rpa',
23+
'ai-native-vs-traditional-workflow-automation':
24+
'ai-native-workflow-automation-vs-traditional-automation',
25+
'best-ai-workflow-builders-small-teams-2026': 'best-ai-workflow-builders',
26+
'best-ai-agent-builder-2026': 'best-ai-agent-platforms-2026',
27+
'best-ai-agent-builders-slack-crm-automation-2026': 'best-ai-agents-for-slack',
28+
'best-open-source-ai-agent-frameworks': 'open-source-ai-agent-platforms',
29+
}

‎apps/sim/next.config.ts‎

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,34 +9,7 @@ import {
99
getWorkflowExecutionCSPPolicy,
1010
} from './lib/core/security/csp'
1111
import { LANDING_ROUTES } from './lib/landing/routes'
12-
13-
/**
14-
* AEO/GEO-style posts (listicles, comparisons, how-tos) split out of `/blog`
15-
* into the dedicated `/library` section so `/blog` stays editorial-only.
16-
*/
17-
const LIBRARY_MOVED_BLOG_SLUGS = [
18-
'best-zapier-alternatives',
19-
'ai-agents-vs-rpa',
20-
'ai-agent-vs-chatbot',
21-
'openai-vs-n8n-vs-sim',
22-
'ai-agent-ideas',
23-
'how-to-create-an-ai-agent',
24-
] as const
25-
26-
/**
27-
* Library articles retired by merging into a stronger article on the same
28-
* search intent, keyed by retired slug. Keeps indexed URLs and inbound links
29-
* pointing at the surviving article.
30-
*/
31-
const LIBRARY_MERGED_SLUGS: Record<string, string> = {
32-
'automation-anywhere-alternative': 'ai-agents-vs-rpa',
33-
'ai-native-vs-traditional-workflow-automation':
34-
'ai-native-workflow-automation-vs-traditional-automation',
35-
'best-ai-workflow-builders-small-teams-2026': 'best-ai-workflow-builders',
36-
'best-ai-agent-builder-2026': 'best-ai-agent-platforms-2026',
37-
'best-ai-agent-builders-slack-crm-automation-2026': 'best-ai-agents-for-slack',
38-
'best-open-source-ai-agent-frameworks': 'open-source-ai-agent-platforms',
39-
}
12+
import { LIBRARY_MERGED_SLUGS, LIBRARY_MOVED_BLOG_SLUGS } from './lib/library/retired-slugs'
4013

4114
const nextConfig: NextConfig = {
4215
devIndicators: false,

‎bun.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"check:native-typecheck": "bun run scripts/check-native-typecheck.ts",
7272
"check:source-text": "bun run scripts/check-source-text.ts",
7373
"check:site-urls": "bun run scripts/check-site-urls.ts",
74+
"check:library-content": "bun run scripts/check-library-content.ts",
7475
"check:spec-example-ids": "bun run scripts/check-spec-example-ids.ts",
7576
"check:script-tests": "bun run scripts/check-script-test-coverage.ts",
7677
"check:test-patterns": "bun run scripts/check-test-patterns.ts",
@@ -162,6 +163,7 @@
162163
"devDependencies": {
163164
"@babel/parser": "7.29.2",
164165
"@biomejs/biome": "2.0.6",
166+
"@mdx-js/mdx": "3.1.1",
165167
"@octokit/rest": "^21.0.0",
166168
"@sim/utils": "workspace:*",
167169
"@types/opentype.js": "1.3.10",
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
'![diagram](/library/clean/diagram.png) 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

Comments
 (0)