Skip to content
Last updated

Marketplace navigation and searchable catalog — implementation plan

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:test runner (no extra test deps)
  • gray-matter and js-yaml (already present via Redocly toolchain)
  • React 19 + styled-components (already used by ProductCard)
  • Redocly Realm Markdoc components

File map

Created:

  • @theme/markdoc/components/ConnectorCatalog/categories.tsCONNECTOR_CATEGORIES array and ConnectorCategory type
  • @theme/markdoc/components/ConnectorCatalog/connectors.generated.ts — auto-generated CONNECTORS array (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 export
  • scripts/generate-connectors.mjs — frontmatter → data file generator
  • scripts/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 PR
  • scripts/bootstrap-connector-categories.test.mjs — one-time, deleted after PR
  • agen-for-work/connectors/marketplace.md — landing page with {% ConnectorCatalog /%}

Modified:

  • @theme/markdoc/schema.ts — register ConnectorCatalog
  • @theme/markdoc/components.tsx — register ConnectorCatalog
  • agen-for-work/sidebars.yaml — replace flat Marketplace list with 22 category subgroups
  • agen-for-work/connectors/marketplace/*.md (96 files) — prepend frontmatter with category:
  • package.json — add generate:connectors and check:marketplace-frontmatter npm scripts

Not modified: any non-marketplace markdown; redocly.yaml; CSS theme files (component uses inline styled-components only).


Task ordering

  1. Pre-flight checks (deps, sample frontmatter format)
  2. categories.ts
  3. Generator (TDD)
  4. Validator (TDD)
  5. package.json npm scripts wiring
  6. Bootstrap script (TDD, one-shot run)
  7. Run bootstrap → commit 96 frontmatter additions
  8. Run generator → commit connectors.generated.ts
  9. Markdoc schema + React component
  10. Register component
  11. Landing page
  12. Sidebar restructure
  13. Final validation + smoke test

Each task ends with one atomic commit.


Task 1: Pre-flight checks

Files: none modified — verification only.

  • Step 1: Confirm Node version is ≥18 (built-in node:test requires it)

Run: node --version Expected: v22.x or higher.

  • Step 2: Confirm gray-matter is 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-yaml is 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.


Task 2: Create categories.ts

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"

Task 3: TDD generate-connectors.mjs

Files:

  • Create: scripts/generate-connectors.mjs

  • Test: 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"

Task 4: TDD check-marketplace-frontmatter.mjs

Files:

  • Create: scripts/check-marketplace-frontmatter.mjs

  • Test: 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"

Task 5: Wire npm scripts

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"

Task 6: TDD bootstrap script (one-shot)

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"

Task 7: Run bootstrap, commit 96 file changes

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.mjs

Expected: 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"

Task 8: Generate and commit connectors.generated.ts

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 CONNECTORS as an ConnectorEntry[];

  • 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)"

Task 9: Markdoc schema and React component

Files:

  • Create: @theme/markdoc/components/ConnectorCatalog/ConnectorCatalog.markdoc.ts

  • Create: @theme/markdoc/components/ConnectorCatalog/ConnectorCatalog.tsx

  • Create: @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"

Task 10: Register the component

Files:

  • Modify: @theme/markdoc/schema.ts

  • Modify: @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"

Task 11: Create the landing page

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"

Task 12: Restructure sidebars.yaml

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"

Task 13: Final smoke test + cleanup

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 Marketplace collapsed; 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.