For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Spec: docs/superpowers/specs/2026-05-13-marketplace-search-design.md
Goal: Replace the 96-item flat marketplace sidebar with category groups, and add a searchable catalog page driven by per-file frontmatter and a generated TypeScript data module.
Architecture: Single source of truth = category: frontmatter in each marketplace markdown. A Node generator scans frontmatter and emits connectors.generated.ts, which a new Markdoc-registered React component (<ConnectorCatalog />) imports. The sidebar is restructured into 22 category subgroups via agen-for-work/sidebars.yaml. A CI validator enforces frontmatter correctness and data-file freshness.
Tech Stack:
- Node 22 with the built-in
node:testrunner (no extra test deps) gray-matterandjs-yaml(already present via Redocly toolchain)- React 19 +
styled-components(already used byProductCard) - Redocly Realm Markdoc components
Created:
@theme/markdoc/components/ConnectorCatalog/categories.ts—CONNECTOR_CATEGORIESarray andConnectorCategorytype@theme/markdoc/components/ConnectorCatalog/connectors.generated.ts— auto-generatedCONNECTORSarray (committed)@theme/markdoc/components/ConnectorCatalog/ConnectorCatalog.tsx— React UI@theme/markdoc/components/ConnectorCatalog/ConnectorCatalog.markdoc.ts— Markdoc schema@theme/markdoc/components/ConnectorCatalog/index.ts— barrel exportscripts/generate-connectors.mjs— frontmatter → data file generatorscripts/generate-connectors.test.mjs— unit tests (node:test)scripts/check-marketplace-frontmatter.mjs— validator (CI)scripts/check-marketplace-frontmatter.test.mjs— unit tests (node:test)scripts/bootstrap-connector-categories.mjs— one-time, deleted after PRscripts/bootstrap-connector-categories.test.mjs— one-time, deleted after PRagen-for-work/connectors/marketplace.md— landing page with{% ConnectorCatalog /%}
Modified:
@theme/markdoc/schema.ts— registerConnectorCatalog@theme/markdoc/components.tsx— registerConnectorCatalogagen-for-work/sidebars.yaml— replace flat Marketplace list with 22 category subgroupsagen-for-work/connectors/marketplace/*.md(96 files) — prepend frontmatter withcategory:package.json— addgenerate:connectorsandcheck:marketplace-frontmatternpm scripts
Not modified: any non-marketplace markdown; redocly.yaml; CSS theme files (component uses inline styled-components only).
- Pre-flight checks (deps, sample frontmatter format)
categories.ts- Generator (TDD)
- Validator (TDD)
package.jsonnpm scripts wiring- Bootstrap script (TDD, one-shot run)
- Run bootstrap → commit 96 frontmatter additions
- Run generator → commit
connectors.generated.ts - Markdoc schema + React component
- Register component
- Landing page
- Sidebar restructure
- Final validation + smoke test
Each task ends with one atomic commit.
Files: none modified — verification only.
- Step 1: Confirm Node version is ≥18 (built-in
node:testrequires it)
Run: node --version Expected: v22.x or higher.
- Step 2: Confirm
gray-matteris already installed
Run: ls node_modules/gray-matter/index.js Expected: file exists. If missing, run npm install --save-dev gray-matter.
- Step 3: Confirm
js-yamlis already installed
Run: ls node_modules/js-yaml/index.js Expected: file exists. If missing, run npm install --save-dev js-yaml.
- Step 4: Confirm no marketplace MD currently has frontmatter
Run:
count=0
for f in agen-for-work/connectors/marketplace/*.md; do
head -1 "$f" | grep -q '^---' && count=$((count+1))
done
echo "files with frontmatter: $count"Expected: files with frontmatter: 0
If the count is non-zero, stop and reconcile manually before proceeding — the bootstrap (Task 6) assumes a clean slate.
No commit.
Files:
Create:
@theme/markdoc/components/ConnectorCatalog/categories.ts[ ] Step 1: Create the directory and file
Path: @theme/markdoc/components/ConnectorCatalog/categories.ts
// Source of truth: dashboard repo,
// app/v2/src/tools/stores/envStore/workforceApp/workforceApp.types.ts
// → `ConnectorCategory` enum (as of 2026-05-13).
// Refresh manually when the product taxonomy changes.
export const CONNECTOR_CATEGORIES = [
'Productivity',
'Communication',
'Project Management',
'CRM',
'Development',
'Storage',
'Finance',
'Customer Support',
'Marketing',
'E-commerce',
'Analytics',
'Monitoring',
'DevOps',
'HR',
'Video Conferencing',
'Scheduling',
'Identity',
'Design',
'Forms',
'IT Service Management',
'Documents',
'Automation',
'Data',
'Social Media',
'Feature Flags',
'Content Management',
'Sales Intelligence',
'Product Management',
'Infrastructure',
'AI',
] as const
export type ConnectorCategory = (typeof CONNECTOR_CATEGORIES)[number]- Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit @theme/markdoc/components/ConnectorCatalog/categories.ts Expected: no output, exit code 0.
If tsc complains about lack of a tsconfig context, run npx tsc --noEmit from the repo root and confirm the file appears in the program's emit.
- Step 3: Commit
git add @theme/markdoc/components/ConnectorCatalog/categories.ts
git commit -m "feat(theme): add ConnectorCategory taxonomy for marketplace catalog"Files:
Create:
scripts/generate-connectors.mjsTest:
scripts/generate-connectors.test.mjs[ ] Step 1: Write the failing test
Path: scripts/generate-connectors.test.mjs
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { generateConnectors } from './generate-connectors.mjs'
function makeFixture() {
const root = mkdtempSync(join(tmpdir(), 'connectors-test-'))
const dir = join(root, 'marketplace')
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, 'slack.md'),
`---\ncategory: Communication\n---\n\n## Slack integration\n`,
)
writeFileSync(
join(dir, 'monday.md'),
`---\ncategory: Project Management\ndisplayName: monday.com\n---\n\n## monday.com integration\n`,
)
writeFileSync(
join(dir, 'asana.md'),
`---\ncategory: Project Management\n---\n\n## Asana integration\n`,
)
return { root, dir }
}
test('generateConnectors returns entries sorted by category-then-name', () => {
const { root, dir } = makeFixture()
try {
const entries = generateConnectors({
marketplaceDir: dir,
hrefPrefix: 'connectors/marketplace',
})
assert.deepEqual(
entries.map((e) => `${e.category}:${e.name}`),
[
'Communication:Slack',
'Project Management:Asana',
'Project Management:monday.com',
],
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('generateConnectors uses displayName when present, otherwise humanizes filename', () => {
const { root, dir } = makeFixture()
try {
const entries = generateConnectors({
marketplaceDir: dir,
hrefPrefix: 'connectors/marketplace',
})
const monday = entries.find((e) => e.id === 'monday')
assert.equal(monday.name, 'monday.com')
const slack = entries.find((e) => e.id === 'slack')
assert.equal(slack.name, 'Slack')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('generateConnectors throws when a file is missing category', () => {
const { root, dir } = makeFixture()
writeFileSync(join(dir, 'broken.md'), `## Broken integration\n`)
try {
assert.throws(
() =>
generateConnectors({
marketplaceDir: dir,
hrefPrefix: 'connectors/marketplace',
}),
/broken\.md.*category/,
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('generateConnectors throws when category is not in CONNECTOR_CATEGORIES', () => {
const { root, dir } = makeFixture()
writeFileSync(
join(dir, 'rogue.md'),
`---\ncategory: NotARealCategory\n---\n\n## Rogue\n`,
)
try {
assert.throws(
() =>
generateConnectors({
marketplaceDir: dir,
hrefPrefix: 'connectors/marketplace',
}),
/rogue\.md.*NotARealCategory/,
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})- Step 2: Run the test and watch it fail
Run: node --test scripts/generate-connectors.test.mjs Expected: FAIL with Cannot find module './generate-connectors.mjs'.
- Step 3: Implement
scripts/generate-connectors.mjs
Path: scripts/generate-connectors.mjs
#!/usr/bin/env node
import { readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { join, basename, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import matter from 'gray-matter'
const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(HERE, '..')
const CONNECTOR_CATEGORIES = [
'Productivity',
'Communication',
'Project Management',
'CRM',
'Development',
'Storage',
'Finance',
'Customer Support',
'Marketing',
'E-commerce',
'Analytics',
'Monitoring',
'DevOps',
'HR',
'Video Conferencing',
'Scheduling',
'Identity',
'Design',
'Forms',
'IT Service Management',
'Documents',
'Automation',
'Data',
'Social Media',
'Feature Flags',
'Content Management',
'Sales Intelligence',
'Product Management',
'Infrastructure',
'AI',
]
function humanize(id) {
return id
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
export function generateConnectors({ marketplaceDir, hrefPrefix }) {
const files = readdirSync(marketplaceDir)
.filter((f) => f.endsWith('.md'))
.sort()
const entries = files.map((file) => {
const fullPath = join(marketplaceDir, file)
const raw = readFileSync(fullPath, 'utf8')
const { data } = matter(raw)
const id = basename(file, '.md')
if (!data.category) {
throw new Error(
`${file}: missing required frontmatter field "category"`,
)
}
if (!CONNECTOR_CATEGORIES.includes(data.category)) {
throw new Error(
`${file}: category "${data.category}" is not in CONNECTOR_CATEGORIES`,
)
}
return {
id,
name: data.displayName || humanize(id),
category: data.category,
href: `${hrefPrefix}/${file}`,
}
})
entries.sort((a, b) => {
const ai = CONNECTOR_CATEGORIES.indexOf(a.category)
const bi = CONNECTOR_CATEGORIES.indexOf(b.category)
if (ai !== bi) return ai - bi
return a.name.localeCompare(b.name, 'en', { sensitivity: 'base' })
})
return entries
}
function render(entries) {
const lines = [
`// AUTO-GENERATED by scripts/generate-connectors.mjs — do not edit by hand.`,
`// Re-generate with: npm run generate:connectors`,
`import type { ConnectorCategory } from './categories'`,
``,
`export interface ConnectorEntry {`,
` id: string`,
` name: string`,
` category: ConnectorCategory`,
` href: string`,
`}`,
``,
`export const CONNECTORS: ConnectorEntry[] = ${JSON.stringify(entries, null, 2)}`,
``,
]
return lines.join('\n')
}
// Allow `import { generateConnectors }` from the test file and CLI execution
// from npm script.
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (isMain) {
const marketplaceDir = resolve(ROOT, 'agen-for-work/connectors/marketplace')
const outFile = resolve(
ROOT,
'@theme/markdoc/components/ConnectorCatalog/connectors.generated.ts',
)
const entries = generateConnectors({
marketplaceDir,
hrefPrefix: 'connectors/marketplace',
})
writeFileSync(outFile, render(entries))
console.log(`Wrote ${entries.length} connectors to ${outFile}`)
}- Step 4: Re-run the test
Run: node --test scripts/generate-connectors.test.mjs Expected: all 4 tests pass.
- Step 5: Commit
git add scripts/generate-connectors.mjs scripts/generate-connectors.test.mjs
git commit -m "feat(scripts): add connector frontmatter→data generator"Files:
Create:
scripts/check-marketplace-frontmatter.mjsTest:
scripts/check-marketplace-frontmatter.test.mjs[ ] Step 1: Write the failing test
Path: scripts/check-marketplace-frontmatter.test.mjs
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { checkMarketplace } from './check-marketplace-frontmatter.mjs'
function setup() {
const root = mkdtempSync(join(tmpdir(), 'check-test-'))
const marketplaceDir = join(root, 'agen-for-work/connectors/marketplace')
const sidebarsPath = join(root, 'agen-for-work/sidebars.yaml')
const generatedPath = join(
root,
'@theme/markdoc/components/ConnectorCatalog/connectors.generated.ts',
)
mkdirSync(marketplaceDir, { recursive: true })
mkdirSync(join(root, '@theme/markdoc/components/ConnectorCatalog'), {
recursive: true,
})
return { root, marketplaceDir, sidebarsPath, generatedPath }
}
function writeValidFixture({ marketplaceDir, sidebarsPath, generatedPath }) {
writeFileSync(
join(marketplaceDir, 'slack.md'),
`---\ncategory: Communication\n---\n\n## Slack\n`,
)
writeFileSync(
sidebarsPath,
`- group: Marketplace\n items:\n - label: Slack\n page: connectors/marketplace/slack.md\n`,
)
// generated will be filled by the function under test using the
// generator, so we write what generateConnectors would produce.
writeFileSync(
generatedPath,
[
'// AUTO-GENERATED by scripts/generate-connectors.mjs — do not edit by hand.',
'// Re-generate with: npm run generate:connectors',
"import type { ConnectorCategory } from './categories'",
'',
'export interface ConnectorEntry {',
' id: string',
' name: string',
' category: ConnectorCategory',
' href: string',
'}',
'',
'export const CONNECTORS: ConnectorEntry[] = [',
' {',
' "id": "slack",',
' "name": "Slack",',
' "category": "Communication",',
' "href": "connectors/marketplace/slack.md"',
' }',
']',
'',
].join('\n'),
)
}
test('checkMarketplace passes on a fully valid fixture', () => {
const ctx = setup()
try {
writeValidFixture(ctx)
const result = checkMarketplace({
marketplaceDir: ctx.marketplaceDir,
sidebarsPath: ctx.sidebarsPath,
generatedPath: ctx.generatedPath,
hrefPrefix: 'connectors/marketplace',
})
assert.deepEqual(result.errors, [])
} finally {
rmSync(ctx.root, { recursive: true, force: true })
}
})
test('checkMarketplace reports stale generated file', () => {
const ctx = setup()
try {
writeValidFixture(ctx)
writeFileSync(ctx.generatedPath, '// stale\n')
const result = checkMarketplace({
marketplaceDir: ctx.marketplaceDir,
sidebarsPath: ctx.sidebarsPath,
generatedPath: ctx.generatedPath,
hrefPrefix: 'connectors/marketplace',
})
assert.equal(result.errors.length, 1)
assert.match(result.errors[0], /stale/i)
} finally {
rmSync(ctx.root, { recursive: true, force: true })
}
})
test('checkMarketplace reports file present on disk but missing from sidebar', () => {
const ctx = setup()
try {
writeValidFixture(ctx)
writeFileSync(
join(ctx.marketplaceDir, 'orphan.md'),
`---\ncategory: Communication\n---\n\n## Orphan\n`,
)
const result = checkMarketplace({
marketplaceDir: ctx.marketplaceDir,
sidebarsPath: ctx.sidebarsPath,
generatedPath: ctx.generatedPath,
hrefPrefix: 'connectors/marketplace',
})
assert.ok(
result.errors.some((e) => /orphan/.test(e)),
`expected an error mentioning orphan, got: ${JSON.stringify(result.errors)}`,
)
} finally {
rmSync(ctx.root, { recursive: true, force: true })
}
})
test('checkMarketplace reports sidebar entry pointing at a missing file', () => {
const ctx = setup()
try {
writeValidFixture(ctx)
writeFileSync(
ctx.sidebarsPath,
`- group: Marketplace\n items:\n - label: Slack\n page: connectors/marketplace/slack.md\n - label: Ghost\n page: connectors/marketplace/ghost.md\n`,
)
const result = checkMarketplace({
marketplaceDir: ctx.marketplaceDir,
sidebarsPath: ctx.sidebarsPath,
generatedPath: ctx.generatedPath,
hrefPrefix: 'connectors/marketplace',
})
assert.ok(
result.errors.some((e) => /ghost/.test(e)),
`expected an error mentioning ghost, got: ${JSON.stringify(result.errors)}`,
)
} finally {
rmSync(ctx.root, { recursive: true, force: true })
}
})- Step 2: Run the test and watch it fail
Run: node --test scripts/check-marketplace-frontmatter.test.mjs Expected: FAIL with Cannot find module './check-marketplace-frontmatter.mjs'.
- Step 3: Implement
scripts/check-marketplace-frontmatter.mjs
Path: scripts/check-marketplace-frontmatter.mjs
#!/usr/bin/env node
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join, resolve, dirname, basename } from 'node:path'
import { fileURLToPath } from 'node:url'
import yaml from 'js-yaml'
import { generateConnectors } from './generate-connectors.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(HERE, '..')
function collectMarketplacePagesFromSidebar(node, found = []) {
if (!node) return found
if (Array.isArray(node)) {
for (const item of node) collectMarketplacePagesFromSidebar(item, found)
return found
}
if (typeof node === 'object') {
if (typeof node.page === 'string' && node.page.startsWith('connectors/marketplace/')) {
found.push(node.page)
}
if (Array.isArray(node.items)) {
collectMarketplacePagesFromSidebar(node.items, found)
}
}
return found
}
function renderGeneratedTs(entries) {
const lines = [
`// AUTO-GENERATED by scripts/generate-connectors.mjs — do not edit by hand.`,
`// Re-generate with: npm run generate:connectors`,
`import type { ConnectorCategory } from './categories'`,
``,
`export interface ConnectorEntry {`,
` id: string`,
` name: string`,
` category: ConnectorCategory`,
` href: string`,
`}`,
``,
`export const CONNECTORS: ConnectorEntry[] = ${JSON.stringify(entries, null, 2)}`,
``,
]
return lines.join('\n')
}
export function checkMarketplace({
marketplaceDir,
sidebarsPath,
generatedPath,
hrefPrefix,
}) {
const errors = []
let entries
try {
entries = generateConnectors({ marketplaceDir, hrefPrefix })
} catch (err) {
errors.push(err.message)
return { errors }
}
if (!existsSync(generatedPath)) {
errors.push(`Generated file is missing: ${generatedPath}`)
} else {
const actual = readFileSync(generatedPath, 'utf8')
const expected = renderGeneratedTs(entries)
if (actual.trim() !== expected.trim()) {
errors.push(
`connectors.generated.ts is stale — run \`npm run generate:connectors\` and commit the result.`,
)
}
}
const sidebarYaml = readFileSync(sidebarsPath, 'utf8')
const sidebar = yaml.load(sidebarYaml)
const pagesInSidebar = new Set(
collectMarketplacePagesFromSidebar(sidebar).map((p) => basename(p, '.md')),
)
const filesOnDisk = new Set(
readdirSync(marketplaceDir)
.filter((f) => f.endsWith('.md'))
.map((f) => basename(f, '.md')),
)
for (const id of filesOnDisk) {
if (!pagesInSidebar.has(id)) {
errors.push(
`${id}.md exists on disk but is not referenced in sidebars.yaml under Marketplace.`,
)
}
}
for (const id of pagesInSidebar) {
if (!filesOnDisk.has(id)) {
errors.push(
`sidebars.yaml references connectors/marketplace/${id}.md but the file does not exist.`,
)
}
}
return { errors }
}
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (isMain) {
const { errors } = checkMarketplace({
marketplaceDir: resolve(ROOT, 'agen-for-work/connectors/marketplace'),
sidebarsPath: resolve(ROOT, 'agen-for-work/sidebars.yaml'),
generatedPath: resolve(
ROOT,
'@theme/markdoc/components/ConnectorCatalog/connectors.generated.ts',
),
hrefPrefix: 'connectors/marketplace',
})
if (errors.length > 0) {
for (const e of errors) console.error(`✗ ${e}`)
process.exit(1)
}
console.log(`✓ Marketplace frontmatter and sidebar are in sync.`)
}- Step 4: Re-run the test
Run: node --test scripts/check-marketplace-frontmatter.test.mjs Expected: all 4 tests pass.
- Step 5: Commit
git add scripts/check-marketplace-frontmatter.mjs scripts/check-marketplace-frontmatter.test.mjs
git commit -m "feat(scripts): add marketplace frontmatter and sidebar validator"Files:
Modify:
package.json[ ] Step 1: Add two scripts to
package.json
Edit package.json "scripts" block. Current:
"scripts": {
"dev": "redocly preview",
"start": "redocly preview-docs",
"build": "redocly bundle sample@v1 -o dist/bundle.yaml",
"test": "redocly lint",
"check:content-style": "node scripts/check-content-style.mjs"
}Change to:
"scripts": {
"dev": "redocly preview",
"start": "redocly preview-docs",
"build": "redocly bundle sample@v1 -o dist/bundle.yaml",
"test": "redocly lint",
"check:content-style": "node scripts/check-content-style.mjs",
"check:marketplace-frontmatter": "node scripts/check-marketplace-frontmatter.mjs",
"generate:connectors": "node scripts/generate-connectors.mjs"
}- Step 2: Verify the new scripts exist (even though they will fail right now)
Run: npm run generate:connectors --silent || true Expected: script runs, then likely fails because frontmatter isn't there yet — that's fine. Confirms the wiring works.
- Step 3: Commit
git add package.json
git commit -m "chore(scripts): wire generate:connectors and check:marketplace-frontmatter"Files:
- Create:
scripts/bootstrap-connector-categories.mjs - Test:
scripts/bootstrap-connector-categories.test.mjs
The bootstrap script reads IntegrationCategoryMap and ConnectorCategory from the dashboard repo (path supplied via DASHBOARD_REPO env var) and prepends ---\ncategory: <name>\n---\n\n to each marketplace markdown file that does not yet have frontmatter.
- Step 1: Write the failing test
Path: scripts/bootstrap-connector-categories.test.mjs
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { applyBootstrap } from './bootstrap-connector-categories.mjs'
function setup() {
const root = mkdtempSync(join(tmpdir(), 'bootstrap-test-'))
const marketplaceDir = join(root, 'marketplace')
mkdirSync(marketplaceDir, { recursive: true })
return { root, marketplaceDir }
}
test('applyBootstrap prepends frontmatter to a file without it', () => {
const ctx = setup()
try {
writeFileSync(
join(ctx.marketplaceDir, 'slack.md'),
`## Slack integration\n\nDescription.\n`,
)
const updated = applyBootstrap({
marketplaceDir: ctx.marketplaceDir,
idToCategory: { slack: 'Communication' },
})
assert.equal(updated, 1)
const after = readFileSync(join(ctx.marketplaceDir, 'slack.md'), 'utf8')
assert.equal(
after,
`---\ncategory: Communication\n---\n\n## Slack integration\n\nDescription.\n`,
)
} finally {
rmSync(ctx.root, { recursive: true, force: true })
}
})
test('applyBootstrap leaves files with existing frontmatter untouched', () => {
const ctx = setup()
try {
writeFileSync(
join(ctx.marketplaceDir, 'jira.md'),
`---\ncategory: Old\n---\n\n## Jira integration\n`,
)
const updated = applyBootstrap({
marketplaceDir: ctx.marketplaceDir,
idToCategory: { jira: 'Project Management' },
})
assert.equal(updated, 0)
const after = readFileSync(join(ctx.marketplaceDir, 'jira.md'), 'utf8')
assert.equal(after, `---\ncategory: Old\n---\n\n## Jira integration\n`)
} finally {
rmSync(ctx.root, { recursive: true, force: true })
}
})
test('applyBootstrap throws if a file has no entry in idToCategory', () => {
const ctx = setup()
try {
writeFileSync(join(ctx.marketplaceDir, 'unknown.md'), `## Unknown\n`)
assert.throws(
() =>
applyBootstrap({
marketplaceDir: ctx.marketplaceDir,
idToCategory: {},
}),
/unknown/,
)
} finally {
rmSync(ctx.root, { recursive: true, force: true })
}
})- Step 2: Run the test and watch it fail
Run: node --test scripts/bootstrap-connector-categories.test.mjs Expected: FAIL with Cannot find module './bootstrap-connector-categories.mjs'.
- Step 3: Implement the bootstrap script
Path: scripts/bootstrap-connector-categories.mjs
#!/usr/bin/env node
import { readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { join, basename, resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(HERE, '..')
export function applyBootstrap({ marketplaceDir, idToCategory }) {
const files = readdirSync(marketplaceDir).filter((f) => f.endsWith('.md'))
let updated = 0
for (const file of files) {
const id = basename(file, '.md')
const full = join(marketplaceDir, file)
const text = readFileSync(full, 'utf8')
if (text.startsWith('---\n')) {
continue
}
const category = idToCategory[id]
if (!category) {
throw new Error(
`No category mapping for "${id}" — extend IntegrationCategoryMap in the dashboard, or hand-pick a value before continuing.`,
)
}
const frontmatter = `---\ncategory: ${category}\n---\n\n`
writeFileSync(full, frontmatter + text)
updated++
}
return updated
}
// Parse the dashboard's workforceApp.types.ts to extract the
// integration-id → category map. Regex-based: brittle but ok for one-shot.
function parseDashboardMap(typesFilePath) {
const src = readFileSync(typesFilePath, 'utf8')
const enumBlock = src.match(/export enum ConnectorCategory \{([\s\S]+?)\}/)
if (!enumBlock) throw new Error('Could not find ConnectorCategory enum')
const categoryEnum = {}
for (const m of enumBlock[1].matchAll(/(\w+)\s*=\s*'([^']+)'/g)) {
categoryEnum[m[1]] = m[2]
}
const intBlock = src.match(
/export enum WorkforceIntegrationEnum \{([\s\S]+?)\}/,
)
if (!intBlock) throw new Error('Could not find WorkforceIntegrationEnum')
const integrationEnum = {}
for (const m of intBlock[1].matchAll(/(\w+)\s*=\s*'([^']+)'/g)) {
integrationEnum[m[1]] = m[2]
}
const mapBlock = src.match(
/export const IntegrationCategoryMap[\s\S]+?\{([\s\S]+?)\};/,
)
if (!mapBlock) throw new Error('Could not find IntegrationCategoryMap')
const idToCategory = {}
for (const m of mapBlock[1].matchAll(
/WorkforceIntegrationEnum\.(\w+)\]:\s*ConnectorCategory\.(\w+)/g,
)) {
const id = integrationEnum[m[1]]
const category = categoryEnum[m[2]]
if (id && category) idToCategory[id] = category
}
return idToCategory
}
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (isMain) {
const dashboardRepo = process.env.DASHBOARD_REPO
if (!dashboardRepo) {
console.error(
'Set DASHBOARD_REPO=/path/to/dashboard to point at a local checkout.',
)
process.exit(1)
}
const typesFile = resolve(
dashboardRepo,
'app/v2/src/tools/stores/envStore/workforceApp/workforceApp.types.ts',
)
const idToCategory = parseDashboardMap(typesFile)
const updated = applyBootstrap({
marketplaceDir: resolve(ROOT, 'agen-for-work/connectors/marketplace'),
idToCategory,
})
console.log(`Updated ${updated} files with frontmatter.`)
}- Step 4: Re-run the test
Run: node --test scripts/bootstrap-connector-categories.test.mjs Expected: all 3 tests pass.
- Step 5: Commit
git add scripts/bootstrap-connector-categories.mjs scripts/bootstrap-connector-categories.test.mjs
git commit -m "chore(scripts): add one-shot bootstrap for connector frontmatter"Files: modifies all agen-for-work/connectors/marketplace/*.md.
- Step 1: Verify dashboard repo path
Run: ls /Users/serslon/Projects/FrontEgg/dashboard/app/v2/src/tools/stores/envStore/workforceApp/workforceApp.types.ts Expected: file exists.
If the path is different on your machine, adjust the env var below.
- Step 2: Run the bootstrap
Run:
DASHBOARD_REPO=/Users/serslon/Projects/FrontEgg/dashboard node scripts/bootstrap-connector-categories.mjsExpected: Updated 96 files with frontmatter.
- Step 3: Sanity-check the diff
Run: git status --short agen-for-work/connectors/marketplace/ | wc -l Expected: 96.
Run: head -5 agen-for-work/connectors/marketplace/slack.md (or any file) Expected: starts with
---
category: Communication
---followed by the original first H2.
- Step 4: Confirm content-style checker still passes
Run: npm run check:content-style Expected: no errors. (Frontmatter blocks should be skipped by the content-style checker — see scripts/check-content-style.mjs. If new failures appear, stop and fix the script before committing.)
- Step 5: Commit
git add agen-for-work/connectors/marketplace/
git commit -m "docs(marketplace): add category frontmatter to all 96 connector guides"Files:
Create:
@theme/markdoc/components/ConnectorCatalog/connectors.generated.ts[ ] Step 1: Run the generator
Run: npm run generate:connectors Expected: Wrote 96 connectors to /Users/serslon/Projects/FrontEgg/docs/@theme/markdoc/components/ConnectorCatalog/connectors.generated.ts
- Step 2: Inspect the output
Run: head -20 @theme/markdoc/components/ConnectorCatalog/connectors.generated.ts Expected:
starts with the auto-generated header comment;
declares
CONNECTORSas anConnectorEntry[];first entry is from Productivity (e.g. Airtable);
pretty-printed JSON, 2-space indent.
[ ] Step 3: Confirm validator passes
Run: npm run check:marketplace-frontmatter Expected: ✓ Marketplace frontmatter and sidebar are in sync. (sidebar check passes against the current 96-item flat structure; we restructure later in Task 12, and the validator will still find every file referenced.)
- Step 4: Commit
git add @theme/markdoc/components/ConnectorCatalog/connectors.generated.ts
git commit -m "feat(theme): generate initial connectors.generated.ts (96 entries)"Files:
Create:
@theme/markdoc/components/ConnectorCatalog/ConnectorCatalog.markdoc.tsCreate:
@theme/markdoc/components/ConnectorCatalog/ConnectorCatalog.tsxCreate:
@theme/markdoc/components/ConnectorCatalog/index.ts[ ] Step 1: Create the Markdoc schema
Path: @theme/markdoc/components/ConnectorCatalog/ConnectorCatalog.markdoc.ts
import { Schema } from '@markdoc/markdoc'
export const ConnectorCatalog: Schema & { tagName: string } = {
attributes: {},
render: 'ConnectorCatalog',
tagName: 'ConnectorCatalog',
}- Step 2: Create the React component
Path: @theme/markdoc/components/ConnectorCatalog/ConnectorCatalog.tsx
import React, { useMemo, useState } from 'react'
import styled from 'styled-components'
import { CONNECTORS } from './connectors.generated'
import { CONNECTOR_CATEGORIES, type ConnectorCategory } from './categories'
type CategoryFilter = 'all' | ConnectorCategory
export const ConnectorCatalog = () => {
const [query, setQuery] = useState('')
const [category, setCategory] = useState<CategoryFilter>('all')
const activeCategories = useMemo(
() =>
CONNECTOR_CATEGORIES.filter((c) =>
CONNECTORS.some((entry) => entry.category === c),
),
[],
)
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return CONNECTORS.filter((entry) => {
if (category !== 'all' && entry.category !== category) return false
if (q && !entry.name.toLowerCase().includes(q)) return false
return true
})
}, [query, category])
const sectioned = query.trim() === '' && category === 'all'
const groups = useMemo(() => {
if (!sectioned) {
return [
{
category: category === 'all' ? null : category,
entries: [...filtered].sort((a, b) =>
a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }),
),
},
]
}
return activeCategories.map((c) => ({
category: c,
entries: filtered.filter((e) => e.category === c),
}))
}, [filtered, activeCategories, sectioned, category])
const reset = () => {
setQuery('')
setCategory('all')
}
return (
<Wrapper>
<Controls>
<SearchInput
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search ${CONNECTORS.length} connectors…`}
aria-label="Search connectors"
/>
<Select
value={category}
onChange={(e) => setCategory(e.target.value as CategoryFilter)}
aria-label="Filter by category"
>
<option value="all">All categories</option>
{activeCategories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</Select>
</Controls>
{filtered.length === 0 ? (
<Empty>
<p>No connectors match your search.</p>
<ResetButton type="button" onClick={reset}>
Clear filters
</ResetButton>
</Empty>
) : (
groups.map((group, idx) => (
<Section key={group.category ?? `flat-${idx}`}>
{group.category && <SectionTitle>{group.category}</SectionTitle>}
<Grid>
{group.entries.map((entry) => (
<Tile key={entry.id} href={entry.href}>
{entry.name}
</Tile>
))}
</Grid>
</Section>
))
)}
</Wrapper>
)
}
const Wrapper = styled.div`
font-family: var(--font-family-base);
`
const Controls = styled.div`
display: flex;
gap: 12px;
margin: 16px 0 24px;
flex-wrap: wrap;
`
const SearchInput = styled.input`
flex: 1 1 240px;
padding: 10px 14px;
font-size: 14px;
border: 1px solid var(--card-border-color, #2d2d37);
border-radius: 8px;
background: transparent;
color: inherit;
&:focus {
outline: none;
border-color: var(--card-border-color-hover, #3d3d47);
}
`
const Select = styled.select`
flex: 0 0 220px;
padding: 10px 14px;
font-size: 14px;
border: 1px solid var(--card-border-color, #2d2d37);
border-radius: 8px;
background: transparent;
color: inherit;
`
const Section = styled.section`
margin-bottom: 28px;
`
const SectionTitle = styled.h3`
font-size: 16px;
font-weight: 600;
margin: 0 0 12px;
color: var(--icon-card-title-color);
`
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px;
`
const Tile = styled.a`
display: block;
padding: 12px 14px;
border: 1px solid var(--card-border-color, #2d2d37);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
text-decoration: none !important;
color: inherit;
transition: border-color 0.2s ease;
&:hover {
border-color: var(--card-border-color-hover, #3d3d47);
}
`
const Empty = styled.div`
text-align: center;
padding: 32px 16px;
color: var(--icon-card-description-color);
`
const ResetButton = styled.button`
margin-top: 12px;
padding: 8px 16px;
background: transparent;
border: 1px solid var(--card-border-color, #2d2d37);
border-radius: 6px;
cursor: pointer;
color: inherit;
font-family: inherit;
font-size: 14px;
`- Step 3: Create the barrel export
Path: @theme/markdoc/components/ConnectorCatalog/index.ts
export { ConnectorCatalog } from './ConnectorCatalog'- Step 4: Verify TypeScript compiles
Run: npx tsc --noEmit Expected: no errors.
- Step 5: Commit
git add @theme/markdoc/components/ConnectorCatalog/ConnectorCatalog.markdoc.ts
git add @theme/markdoc/components/ConnectorCatalog/ConnectorCatalog.tsx
git add @theme/markdoc/components/ConnectorCatalog/index.ts
git commit -m "feat(theme): add ConnectorCatalog Markdoc component"Files:
Modify:
@theme/markdoc/schema.tsModify:
@theme/markdoc/components.tsx[ ] Step 1: Add the schema export
Edit @theme/markdoc/schema.ts. After line export { ProductCard } from './components/ProductCard/ProductCard.markdoc', append:
export { ConnectorCatalog } from './components/ConnectorCatalog/ConnectorCatalog.markdoc'- Step 2: Add the component export
Edit @theme/markdoc/components.tsx. After line export { ProductCard } from './components/ProductCard/ProductCard', append:
export { ConnectorCatalog } from './components/ConnectorCatalog/ConnectorCatalog'- Step 3: Verify TypeScript still compiles
Run: npx tsc --noEmit Expected: no errors.
- Step 4: Commit
git add @theme/markdoc/schema.ts @theme/markdoc/components.tsx
git commit -m "feat(theme): register ConnectorCatalog in Markdoc schema and components"Files:
Create:
agen-for-work/connectors/marketplace.md[ ] Step 1: Write the landing page
Path: agen-for-work/connectors/marketplace.md
---
title: Marketplace
---
## Marketplace
Browse all available connectors. Use the search field to find a connector by name, or filter by category.
- Step 2: Verify content-style checker passes for this file
Run: npm run check:content-style Expected: no new errors. The first heading is H2 (## Marketplace); no H1; no emojis; no -> / => arrows.
- Step 3: Commit
git add agen-for-work/connectors/marketplace.md
git commit -m "docs(marketplace): add searchable catalog landing page"Files:
- Modify:
agen-for-work/sidebars.yaml
This is a mechanical replacement of one block. The current - group: Marketplace block (between approximately lines 53 and 268, containing 96 flat items) is replaced with a Marketplace group whose page: points to the landing and whose items: is 22 category subgroups.
- Step 1: Read the current Marketplace block
Run: sed -n '53,268p' agen-for-work/sidebars.yaml | head -20 Expected: shows - group: Marketplace, expanded: true, then 96 - label: ... page: ... entries.
- Step 2: Build the replacement using the generator output
The generator already sorts entries by (category-index, name). Use it to build the 22 subgroups. Add this temporary helper (you'll delete it after pasting):
Run:
node --input-type=module -e "
import { generateConnectors } from './scripts/generate-connectors.mjs'
const entries = generateConnectors({
marketplaceDir: 'agen-for-work/connectors/marketplace',
hrefPrefix: 'connectors/marketplace',
})
const seen = new Set(entries.map((e) => e.category))
const orderedCategories = [
'Productivity','Communication','Project Management','CRM','Development','Storage','Finance','Customer Support','Marketing','E-commerce','Analytics','Monitoring','DevOps','HR','Video Conferencing','Scheduling','Identity','Design','Forms','IT Service Management','Documents','Automation','Data','Social Media','Feature Flags','Content Management','Sales Intelligence','Product Management','Infrastructure','AI',
].filter((c) => seen.has(c))
const lines = []
lines.push(' - group: Marketplace')
lines.push(' page: connectors/marketplace.md')
lines.push(' expanded: false')
lines.push(' items:')
for (const cat of orderedCategories) {
lines.push(\` - group: \${cat}\`)
lines.push(' expanded: false')
lines.push(' items:')
for (const e of entries.filter((x) => x.category === cat)) {
lines.push(\` - label: \${e.name}\`)
lines.push(\` page: \${e.href}\`)
}
}
console.log(lines.join('\n'))
" > /tmp/marketplace-block.yaml- Step 3: Replace the old Marketplace block in
sidebars.yaml
Open agen-for-work/sidebars.yaml and replace the existing block that begins at:
- group: Marketplace
expanded: true
items:
- label: Acuity Scheduling…and ends with the last marketplace entry (e.g. page: connectors/marketplace/zoom.md), with the content of /tmp/marketplace-block.yaml. Preserve the four-space indentation.
- Step 4: Verify YAML parses
Run: node --input-type=module -e "import yaml from 'js-yaml'; import { readFileSync } from 'fs'; yaml.load(readFileSync('agen-for-work/sidebars.yaml', 'utf8')); console.log('ok')" Expected: ok
- Step 5: Run Redocly lint
Run: npm run test Expected: no errors.
- Step 6: Run the marketplace validator
Run: npm run check:marketplace-frontmatter Expected: ✓ Marketplace frontmatter and sidebar are in sync.
- Step 7: Clean up the temp file
Run: rm /tmp/marketplace-block.yaml
- Step 8: Commit
git add agen-for-work/sidebars.yaml
git commit -m "docs(marketplace): group 96 connectors into 22 category subgroups in sidebar"Files: none modified.
- Step 1: Run the full check suite
Run: npm run test && npm run check:content-style && npm run check:marketplace-frontmatter Expected: all three pass with no errors.
- Step 2: Run unit tests for the new scripts
Run: node --test scripts/generate-connectors.test.mjs scripts/check-marketplace-frontmatter.test.mjs scripts/bootstrap-connector-categories.test.mjs Expected: all tests pass.
- Step 3: Smoke test in the browser
Run: npm run dev
Open the local preview (URL shown in the dev server output, typically http://localhost:8080). Navigate to /agen-for-work/connectors/marketplace.
Verify:
Page renders without console errors.
22 H3 section headers visible (Productivity first), 96 tiles total distributed across them.
Typing "sla" in the search input narrows the result to Slack.
Selecting "CRM" in the dropdown shows only the CRM section.
Combining "sla" + "Communication" filters correctly.
Empty result ("zzzz" in search) shows the "Clear filters" button; clicking it resets state.
Sidebar shows
Marketplacecollapsed; expanding shows 22 collapsed subgroups.Clicking any connector navigates to its guide and auto-expands its category group in the sidebar.
[ ] Step 4: Delete the one-shot bootstrap script
The bootstrap has been run and committed. It is not used in CI and should not stay in the repo.
Run:
rm scripts/bootstrap-connector-categories.mjs scripts/bootstrap-connector-categories.test.mjs
git add -A
git commit -m "chore(scripts): remove one-shot bootstrap after successful run"- Step 5: Push and open PR (optional, gated on user approval)
Ask the user before pushing — repo policy says PRs require at least one reviewer and the user prefers explicit confirmation for shared-state operations.