diff --git a/.github/workflows/release-create.yml b/.github/workflows/release-create.yml new file mode 100644 index 0000000000..fabe7adf34 --- /dev/null +++ b/.github/workflows/release-create.yml @@ -0,0 +1,66 @@ +# Auto-releases on push to main (@next channel) +# For stable (@latest): cherry-pick commits to stable branch, then manually dispatch this workflow +name: 📦 Release Create + +on: + push: + branches: + - main + paths: + - 'apps/create/**' + - 'pnpm-workspace.yaml' + - '!**/*.md' + workflow_dispatch: + +permissions: + contents: write + packages: write + +concurrency: + group: release-create-${{ github.ref }} + cancel-in-progress: true + +jobs: + release: + runs-on: ubuntu-24.04 + steps: + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ steps.generate_token.outputs.token }} + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + registry-url: 'https://registry.npmjs.org' + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm --prefix apps/create run build + + - name: Test + run: pnpm --prefix apps/create run test + + - name: Release + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + LINEAR_TOKEN: ${{ secrets.LINEAR_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + working-directory: apps/create + run: pnpx semantic-release diff --git a/apps/create/.releaserc.cjs b/apps/create/.releaserc.cjs new file mode 100644 index 0000000000..e68edcbfec --- /dev/null +++ b/apps/create/.releaserc.cjs @@ -0,0 +1,50 @@ +/* eslint-env node */ +const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; + +const branches = [ + { name: 'stable', channel: 'latest' }, + { name: 'main', prerelease: 'next', channel: 'next' }, +]; + +const isPrerelease = branches.some((b) => typeof b === 'object' && b.name === branch && b.prerelease); + +const notesPlugin = isPrerelease + ? '@semantic-release/release-notes-generator' + : ['semantic-release-ai-notes', { style: 'concise' }]; + +const config = { + branches, + tagFormat: 'create-v${version}', + plugins: [ + 'semantic-release-commit-filter', + '@semantic-release/commit-analyzer', + notesPlugin, + ['@semantic-release/npm'], + ], +}; + +if (!isPrerelease) { + config.plugins.push([ + '@semantic-release/git', + { + assets: ['package.json'], + message: 'chore(create): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', + }, + ]); +} + +config.plugins.push(['semantic-release-linear-app', { + teamKeys: ['SD'], + addComment: true, + packageName: 'create', + commentTemplate: 'shipped in {package} {releaseLink} {channel}' +}]); + +config.plugins.push([ + '@semantic-release/github', + { + successComment: ':tada: This ${issue.pull_request ? "PR" : "issue"} is included in **@superdoc-dev/create** v${nextRelease.version}\n\nThe release is available on [GitHub release](${releases.find(release => release.pluginName === "@semantic-release/github").url})', + } +]); + +module.exports = config; diff --git a/apps/create/package.json b/apps/create/package.json new file mode 100644 index 0000000000..03a8e04cd9 --- /dev/null +++ b/apps/create/package.json @@ -0,0 +1,23 @@ +{ + "name": "@superdoc-dev/create", + "version": "0.0.0", + "description": "Set up SuperDoc in your project — AGENTS.md, skills, and MCP configuration", + "type": "module", + "license": "MIT", + "bin": { + "create-superdoc": "./dist/index.js" + }, + "files": [ + "dist", + "skill" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "bun build src/index.ts --outdir dist --target node", + "test": "bun test", + "release": "pnpx semantic-release", + "release:dry-run": "pnpx semantic-release --dry-run" + } +} diff --git a/apps/create/src/__tests__/templates.test.ts b/apps/create/src/__tests__/templates.test.ts new file mode 100644 index 0000000000..c6ccd19150 --- /dev/null +++ b/apps/create/src/__tests__/templates.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, rmSync, writeFileSync, mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { detectFramework, generateAgentsMd, type DetectedFramework } from '../templates'; + +describe('detectFramework', () => { + let testDir: string; + + afterEach(() => { + if (testDir && existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + function createDir(deps: Record = {}, devDeps: Record = {}): string { + testDir = mkdtempSync(join(tmpdir(), 'superdoc-fw-')); + const pkg: Record = {}; + if (Object.keys(deps).length) pkg.dependencies = deps; + if (Object.keys(devDeps).length) pkg.devDependencies = devDeps; + writeFileSync(join(testDir, 'package.json'), JSON.stringify(pkg)); + return testDir; + } + + const cases: [string, Record, DetectedFramework][] = [ + ['react', { react: '^19' }, 'react'], + ['@superdoc-dev/react', { '@superdoc-dev/react': '^1' }, 'react'], + ['next', { next: '^15', react: '^19' }, 'nextjs'], + ['vue', { vue: '^3' }, 'vue'], + ['nuxt', { nuxt: '^4', vue: '^3' }, 'nuxt'], + ['@angular/core', { '@angular/core': '^19' }, 'angular'], + ['svelte', { svelte: '^5' }, 'svelte'], + ]; + + for (const [dep, deps, expected] of cases) { + test(`detects ${expected} from ${dep}`, () => { + const dir = createDir(deps); + expect(detectFramework(dir)).toBe(expected); + }); + } + + test('returns vanilla when no framework deps', () => { + const dir = createDir({}); + expect(detectFramework(dir)).toBe('vanilla'); + }); + + test('returns vanilla when no package.json', () => { + testDir = mkdtempSync(join(tmpdir(), 'superdoc-fw-')); + expect(detectFramework(testDir)).toBe('vanilla'); + }); + + test('detects from devDependencies', () => { + testDir = mkdtempSync(join(tmpdir(), 'superdoc-fw-')); + writeFileSync(join(testDir, 'package.json'), JSON.stringify({ devDependencies: { react: '^19' } })); + expect(detectFramework(testDir)).toBe('react'); + }); + + test('prefers next over react', () => { + const dir = createDir({ next: '^15', react: '^19' }); + expect(detectFramework(dir)).toBe('nextjs'); + }); + + test('prefers nuxt over vue', () => { + const dir = createDir({ nuxt: '^4', vue: '^3' }); + expect(detectFramework(dir)).toBe('nuxt'); + }); +}); + +describe('generateAgentsMd', () => { + test('react template includes @superdoc-dev/react', () => { + const md = generateAgentsMd('react'); + expect(md).toContain('@superdoc-dev/react'); + expect(md).toContain('Embed editor — React'); + }); + + test('nextjs template includes use client', () => { + const md = generateAgentsMd('nextjs'); + expect(md).toContain("'use client'"); + expect(md).toContain('Embed editor — Next.js'); + expect(md).toContain('client component'); + }); + + test('nuxt template includes ClientOnly', () => { + const md = generateAgentsMd('nuxt'); + expect(md).toContain('ClientOnly'); + expect(md).toContain('Embed editor — Nuxt'); + expect(md).toContain('Disable SSR'); + }); + + test('vanilla template uses superdoc import', () => { + const md = generateAgentsMd('vanilla'); + expect(md).toContain("import { SuperDoc } from 'superdoc'"); + expect(md).toContain("import 'superdoc/style.css'"); + }); + + test('all templates contain knowledge corrections', () => { + for (const fw of ['react', 'nextjs', 'vue', 'nuxt', 'angular', 'svelte', 'vanilla'] as const) { + const md = generateAgentsMd(fw); + expect(md).toContain('Knowledge corrections'); + expect(md).toContain('python-docx'); + expect(md).toContain('superdoc.activeEditor.commands.addComment'); + expect(md).toContain('modules.toolbar.customButtons'); + } + }); + + test('all templates contain MCP and SDK sections', () => { + for (const fw of ['react', 'nextjs', 'vue', 'nuxt', 'angular', 'svelte', 'vanilla'] as const) { + const md = generateAgentsMd(fw); + expect(md).toContain('claude mcp add superdoc'); + expect(md).toContain('@superdoc-dev/sdk'); + expect(md).toContain('@superdoc-dev/cli'); + } + }); + + test('all templates contain correct API names', () => { + for (const fw of ['react', 'nextjs', 'vue', 'nuxt', 'angular', 'svelte', 'vanilla'] as const) { + const md = generateAgentsMd(fw); + expect(md).toContain("superdoc.on('ready'"); + expect(md).not.toContain('content-changed'); + expect(md).not.toContain('customItems'); + } + }); +}); diff --git a/apps/create/src/index.ts b/apps/create/src/index.ts new file mode 100644 index 0000000000..e6c8f6c688 --- /dev/null +++ b/apps/create/src/index.ts @@ -0,0 +1,141 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, writeFileSync, symlinkSync, unlinkSync, mkdirSync, cpSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createInterface } from 'node:readline'; +import { detectFramework, generateAgentsMd, FRAMEWORK_LABELS, FRAMEWORK_INSTALL } from './templates'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function resolveSkillSource(): string | null { + // In dist: __dirname is dist/, skill/ is at dist/../skill/ + const fromDist = join(__dirname, '..', 'skill'); + if (existsSync(fromDist)) return fromDist; + + // In dev: __dirname is src/, skill/ is at src/../skill/ + const fromSrc = join(__dirname, '..', 'skill'); + if (existsSync(fromSrc)) return fromSrc; + + return null; +} + +async function prompt(question: string, defaultValue?: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const suffix = defaultValue ? ` (${defaultValue})` : ''; + return new Promise((resolve) => { + rl.question(`${question}${suffix}: `, (answer) => { + rl.close(); + resolve(answer.trim() || defaultValue || ''); + }); + }); +} + +async function select(question: string, options: string[]): Promise { + console.log(`\n${question}`); + options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`)); + const answer = await prompt('Choose', '1'); + const idx = parseInt(answer, 10) - 1; + return idx >= 0 && idx < options.length ? idx : 0; +} + +async function main() { + const cwd = process.cwd(); + const force = process.argv.includes('--force'); + const nonInteractive = process.argv.includes('--yes') || process.argv.includes('-y'); + + console.log('\n SuperDoc project setup\n'); + + // 1. Detect framework + const fw = detectFramework(cwd); + console.log(` Framework: ${FRAMEWORK_LABELS[fw]}`); + + // 2. Choose agent tool + let setupMcp = false; + let agentTool = 'claude-code'; + + if (!nonInteractive) { + const agentIdx = await select('Which agent tool do you use?', [ + 'Claude Code', + 'Cursor', + 'Windsurf', + 'None / Skip', + ]); + agentTool = ['claude-code', 'cursor', 'windsurf', 'none'][agentIdx]; + setupMcp = agentTool !== 'none'; + } + + console.log(''); + + // 3. Write AGENTS.md + const agentsPath = join(cwd, 'AGENTS.md'); + const claudePath = join(cwd, 'CLAUDE.md'); + + if (existsSync(agentsPath) && !force) { + console.log(' AGENTS.md already exists (use --force to overwrite)'); + } else { + writeFileSync(agentsPath, generateAgentsMd(fw), 'utf-8'); + console.log(' Created AGENTS.md'); + } + + // 4. Create CLAUDE.md symlink + if (existsSync(claudePath)) { + if (force) { + unlinkSync(claudePath); + symlinkSync('AGENTS.md', claudePath); + console.log(' Created CLAUDE.md → AGENTS.md (overwritten)'); + } else { + console.log(' CLAUDE.md already exists (use --force to overwrite)'); + } + } else { + symlinkSync('AGENTS.md', claudePath); + console.log(' Created CLAUDE.md → AGENTS.md'); + } + + // 5. Install skills + const agentDirs = ['.claude', '.agents']; + const skillSource = resolveSkillSource(); + + for (const dir of agentDirs) { + const agentDir = join(cwd, dir); + if (!existsSync(agentDir)) continue; + + if (skillSource) { + const skillDir = join(agentDir, 'skills', 'superdoc'); + mkdirSync(skillDir, { recursive: true }); + cpSync(skillSource, skillDir, { recursive: true }); + console.log(` Installed skill to ${dir}/skills/superdoc/`); + } + } + + // 6. MCP setup instructions + if (setupMcp) { + console.log(''); + if (agentTool === 'claude-code') { + console.log(' Run this to connect your agent to DOCX files:'); + console.log(''); + console.log(' claude mcp add superdoc -- npx @superdoc-dev/mcp'); + } else if (agentTool === 'cursor') { + console.log(' Add to ~/.cursor/mcp.json:'); + console.log(''); + console.log(' { "mcpServers": { "superdoc": { "command": "npx", "args": ["@superdoc-dev/mcp"] } } }'); + } else if (agentTool === 'windsurf') { + console.log(' Add to ~/.codeium/windsurf/mcp_config.json:'); + console.log(''); + console.log(' { "mcpServers": { "superdoc": { "command": "npx", "args": ["@superdoc-dev/mcp"] } } }'); + } + } + + // 7. Next steps + console.log(''); + console.log(' Next steps:'); + console.log(` ${FRAMEWORK_INSTALL[fw]}`); + console.log(' https://docs.superdoc.dev/getting-started/quickstart'); + console.log(''); +} + +main().catch((err) => { + console.error(err.message || err); + process.exit(1); +}); diff --git a/apps/create/src/templates.ts b/apps/create/src/templates.ts new file mode 100644 index 0000000000..e9838ef77e --- /dev/null +++ b/apps/create/src/templates.ts @@ -0,0 +1,261 @@ +/** + * AGENTS.md template content for `create-superdoc`. + * Generates a consumer-facing guide tailored to the detected framework. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export type DetectedFramework = 'react' | 'vue' | 'angular' | 'svelte' | 'nextjs' | 'nuxt' | 'vanilla'; + +export function detectFramework(cwd: string): DetectedFramework { + let pkg: Record | null = null; + try { + const raw = readFileSync(join(cwd, 'package.json'), 'utf-8'); + pkg = JSON.parse(raw); + } catch { + return 'vanilla'; + } + + const allDeps = { + ...(pkg?.dependencies as Record | undefined), + ...(pkg?.devDependencies as Record | undefined), + }; + + // Order matters — check meta-frameworks before base frameworks + if (allDeps['next']) return 'nextjs'; + if (allDeps['nuxt']) return 'nuxt'; + if (allDeps['@angular/core']) return 'angular'; + if (allDeps['svelte']) return 'svelte'; + if (allDeps['@superdoc-dev/react'] || allDeps['react']) return 'react'; + if (allDeps['vue']) return 'vue'; + + return 'vanilla'; +} + +export const FRAMEWORK_INSTALL: Record = { + react: 'npm install @superdoc-dev/react', + nextjs: 'npm install @superdoc-dev/react', + vue: 'npm install superdoc', + nuxt: 'npm install superdoc', + angular: 'npm install superdoc', + svelte: 'npm install superdoc', + vanilla: 'npm install superdoc', +}; + +export const FRAMEWORK_LABELS: Record = { + react: 'React', + nextjs: 'Next.js (React)', + vue: 'Vue', + nuxt: 'Nuxt (Vue)', + angular: 'Angular', + svelte: 'Svelte', + vanilla: 'Vanilla JS', +}; + +function getEditorSnippet(fw: DetectedFramework): string { + if (fw === 'nextjs') { + return `## Embed editor — Next.js + +\`\`\`tsx +'use client'; + +import { SuperDocEditor } from '@superdoc-dev/react'; +import '@superdoc-dev/react/style.css'; + +export default function DocEditor({ file }: { file: File }) { + return ( + console.log('Ready', instance)} + /> + ); +} +\`\`\` + +SuperDoc uses browser APIs. In Next.js App Router, wrap it in a client component.`; + } + + if (fw === 'react') { + return `## Embed editor — React + +\`\`\`tsx +import { SuperDocEditor } from '@superdoc-dev/react'; +import '@superdoc-dev/react/style.css'; + +function App() { + return ( + console.log('Ready', instance)} + /> + ); +} +\`\`\``; + } + + if (fw === 'nuxt') { + return `## Embed editor — Nuxt + +SuperDoc uses browser APIs. Disable SSR for the component: + +\`\`\`vue + + + +\`\`\``; + } + + return `## Embed editor + +\`\`\`html +
+ +\`\`\``; +} + +export function generateAgentsMd(fw: DetectedFramework): string { + const installCmd = FRAMEWORK_INSTALL[fw]; + const label = FRAMEWORK_LABELS[fw]; + + return `# SuperDoc + +DOCX editing and tooling. Renders, edits, and automates .docx files in the browser and from backend code. + +- Detected framework: ${label} +- Docs: https://docs.superdoc.dev +- License: AGPLv3 / Commercial + +## Install + +\`\`\`bash +${installCmd} +\`\`\` + +${getEditorSnippet(fw)} + +## Configuration + +Key options for the editor: + +| Option | Type | Description | +|---|---|---| +| \`document\` | \`string \\| File \\| Blob\` | DOCX source — URL, File object, or Blob | +| \`documentMode\` | \`'editing' \\| 'viewing' \\| 'suggesting'\` | Editor mode | +| \`user\` | \`{ name, email }\` | Current user (for comments/tracked changes) | +| \`toolbar\` | \`string \\| HTMLElement\` | Toolbar mount selector or element | +| \`modules.comments\` | \`object\` | Comments panel configuration | +| \`modules.collaboration\` | \`object\` | Real-time collaboration (Yjs) | + +Full config: https://docs.superdoc.dev/core/superdoc/configuration + +## Theming + +SuperDoc UI uses \`--sd-*\` CSS custom properties. Override them in CSS or use \`createTheme()\`: + +\`\`\`javascript +import { createTheme } from 'superdoc'; + +const theme = createTheme({ + colors: { action: '#6366f1', bg: '#ffffff', text: '#1e293b', border: '#e2e8f0' }, + font: 'Inter, sans-serif', + vars: { '--sd-ui-toolbar-bg': '#f8fafc' }, +}); +document.documentElement.classList.add(theme); +\`\`\` + +## Document Engine — programmatic access + +Edit DOCX files from backend code or AI agents. Same operations, same document model. + +### MCP server (AI agents) + +\`\`\`bash +# Claude Code +claude mcp add superdoc -- npx @superdoc-dev/mcp + +# Cursor — add to ~/.cursor/mcp.json +# Windsurf — add to ~/.codeium/windsurf/mcp_config.json +\`\`\` + +### Node.js SDK + +\`\`\`bash +npm install @superdoc-dev/sdk +\`\`\` + +\`\`\`typescript +import { SuperDocClient } from '@superdoc-dev/sdk'; + +const client = new SuperDocClient({ defaultChangeMode: 'tracked' }); +const doc = await client.open({ doc: './contract.docx' }); +await doc.save(); +await doc.close(); +\`\`\` + +### CLI + +\`\`\`bash +npm install -g @superdoc-dev/cli +superdoc open contract.docx +superdoc find --type text --pattern "ACME Corp" +superdoc save && superdoc close +\`\`\` + +## Common tasks + +| Task | How | +|---|---| +| Import DOCX | Pass URL, File, or Blob to \`document\` option | +| Export DOCX | \`const blob = await superdoc.export({ isFinalDoc: true })\` | +| Track changes | Set \`documentMode: 'suggesting'\` or use SDK with \`defaultChangeMode: 'tracked'\` | +| Add comments | \`superdoc.activeEditor.commands.addComment({ content: 'text' })\` | +| Real-time collab | Configure \`modules.collaboration\` with a Yjs provider | +| Custom toolbar | Use \`modules.toolbar.customButtons\` array | +| Listen to events | \`superdoc.on('ready', ({ superdoc }) => { ... })\` | + +## Knowledge corrections + +- **Product name** is SuperDoc (capital S, capital D). Never "Superdoc" or "Super Doc." +- **Do not use python-docx** when SuperDoc is available. Use the CLI, SDK, or MCP server instead. +- **\`superdoc\`** is the npm package. React wrapper is \`@superdoc-dev/react\`. SDK is \`@superdoc-dev/sdk\`. +- **Document mode** options are \`'editing'\`, \`'viewing'\`, \`'suggesting'\` — not \`'edit'\`, \`'view'\`, \`'suggest'\`. +- **Always import the stylesheet**: \`import 'superdoc/style.css'\` (or \`@superdoc-dev/react/style.css\` for React). + +## Links + +- Quickstart: https://docs.superdoc.dev/getting-started/quickstart +- React guide: https://docs.superdoc.dev/getting-started/frameworks/react +- Document Engine: https://docs.superdoc.dev/document-engine/overview +- MCP server: https://docs.superdoc.dev/document-engine/ai-agents/mcp-server +- Examples: https://github.com/superdoc-dev/superdoc/tree/main/examples +`; +}