diff --git a/package.json b/package.json index a231b0b..734edf3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "canonry", "private": true, - "version": "1.28.2", + "version": "1.29.0", "type": "module", "packageManager": "pnpm@10.28.2", "scripts": { diff --git a/packages/api-routes/src/index.ts b/packages/api-routes/src/index.ts index 7e2177a..739b3ce 100644 --- a/packages/api-routes/src/index.ts +++ b/packages/api-routes/src/index.ts @@ -207,6 +207,7 @@ export async function apiRoutes(app: FastifyInstance, opts: ApiRoutesOptions) { } satisfies GoogleRoutesOptions) await api.register(wordpressRoutes, { wordpressConnectionStore: opts.wordpressConnectionStore, + routePrefix: opts.routePrefix ?? '/api/v1', } satisfies WordpressRoutesOptions) await api.register(cdpRoutes, { getCdpStatus: opts.getCdpStatus, diff --git a/packages/api-routes/src/openapi.ts b/packages/api-routes/src/openapi.ts index 4e7a0f1..e64df88 100644 --- a/packages/api-routes/src/openapi.ts +++ b/packages/api-routes/src/openapi.ts @@ -1675,6 +1675,45 @@ const routeCatalog: OpenApiOperation[] = [ 404: { description: 'Project, connection, or page not found.' }, }, }, + { + method: 'post', + path: '/api/v1/projects/{name}/wordpress/pages/meta/bulk', + summary: 'Bulk update SEO meta for multiple pages', + tags: ['wordpress'], + parameters: [nameParameter], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['entries'], + properties: { + entries: { + type: 'array', + items: { + type: 'object', + required: ['slug'], + properties: { + slug: stringSchema, + title: stringSchema, + description: stringSchema, + noindex: booleanSchema, + }, + }, + }, + env: { type: 'string', enum: ['live', 'staging'] }, + }, + }, + }, + }, + }, + responses: { + 200: { description: 'Bulk SEO meta update results returned.' }, + 400: { description: 'Invalid entries or environment.' }, + 404: { description: 'Project or connection not found.' }, + }, + }, { method: 'get', path: '/api/v1/projects/{name}/wordpress/schema', @@ -1716,6 +1755,48 @@ const routeCatalog: OpenApiOperation[] = [ 404: { description: 'Project, connection, or page not found.' }, }, }, + { + method: 'post', + path: '/api/v1/projects/{name}/wordpress/schema/deploy', + summary: 'Deploy JSON-LD schema to WordPress pages', + tags: ['wordpress'], + parameters: [nameParameter], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['profile'], + properties: { + profile: { + type: 'object', + description: 'Business profile and per-slug schema mapping', + }, + env: { type: 'string', enum: ['live', 'staging'] }, + }, + }, + }, + }, + }, + responses: { + 200: { description: 'Schema deployment results returned.' }, + 400: { description: 'Invalid profile or environment.' }, + 404: { description: 'Project or connection not found.' }, + }, + }, + { + method: 'get', + path: '/api/v1/projects/{name}/wordpress/schema/status', + summary: 'Get JSON-LD schema status for all pages', + tags: ['wordpress'], + parameters: [nameParameter, wordpressEnvQueryParameter], + responses: { + 200: { description: 'Schema status per page returned.' }, + 400: { description: 'Invalid environment.' }, + 404: { description: 'Project or connection not found.' }, + }, + }, { method: 'get', path: '/api/v1/projects/{name}/wordpress/llms-txt', @@ -1803,6 +1884,39 @@ const routeCatalog: OpenApiOperation[] = [ 404: { description: 'Project or connection not found.' }, }, }, + { + method: 'post', + path: '/api/v1/projects/{name}/wordpress/onboard', + summary: 'Full WordPress onboarding workflow', + tags: ['wordpress'], + parameters: [nameParameter], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['url', 'username', 'appPassword'], + properties: { + url: stringSchema, + stagingUrl: stringSchema, + username: stringSchema, + appPassword: stringSchema, + defaultEnv: { type: 'string', enum: ['live', 'staging'] }, + profile: objectSchema, + skipSchema: booleanSchema, + skipSubmit: booleanSchema, + }, + }, + }, + }, + }, + responses: { + 200: { description: 'Onboarding result with step-by-step status.' }, + 400: { description: 'Invalid onboarding request.' }, + 404: { description: 'Project not found.' }, + }, + }, // GA4 routes { method: 'post', diff --git a/packages/api-routes/src/wordpress.ts b/packages/api-routes/src/wordpress.ts index 1524605..8477b6d 100644 --- a/packages/api-routes/src/wordpress.ts +++ b/packages/api-routes/src/wordpress.ts @@ -5,11 +5,14 @@ import { buildManualLlmsTxtUpdate, buildManualSchemaUpdate, buildManualStagingPush, + bulkSetSeoMeta, createPage, + deploySchemaFromProfile, diffPageAcrossEnvironments, getLlmsTxt, getPageDetail, getPageSchema, + getSchemaStatus, getSiteStatus, getWpStagingAdminUrl, listActivePlugins, @@ -21,7 +24,7 @@ import { verifyWordpressConnection, WordpressApiError, } from '@ainyc/canonry-integration-wordpress' -import type { WordpressConnectionRecord } from '@ainyc/canonry-integration-wordpress' +import type { SchemaProfileFile, WordpressConnectionRecord } from '@ainyc/canonry-integration-wordpress' import { resolveProject, writeAuditLog } from './helpers.js' export interface WordpressConnectionStore { @@ -36,6 +39,7 @@ export interface WordpressConnectionStore { export interface WordpressRoutesOptions { wordpressConnectionStore?: WordpressConnectionStore + routePrefix?: string } function parseEnvInput( @@ -354,6 +358,46 @@ export async function wordpressRoutes(app: FastifyInstance, opts: WordpressRoute }) }) + app.post<{ + Params: { name: string } + Body: { + entries: Array<{ slug: string; title?: string; description?: string; noindex?: boolean }> + env?: WordpressEnv + } + }>('/projects/:name/wordpress/pages/meta/bulk', async (request, reply) => { + return withWordpressErrorHandling(reply, async () => { + const store = requireStore(reply) + if (!store) return + const project = resolveProject(app.db, request.params.name) + const connection = requireConnection(store, project.name, reply) + if (!connection) return + const entries = request.body?.entries + if (!Array.isArray(entries) || entries.length === 0) { + const err = validationError('entries array is required and must not be empty') + return reply.status(err.statusCode).send(err.toJSON()) + } + for (const entry of entries) { + if (!entry.slug?.trim()) { + const err = validationError('each entry must have a slug') + return reply.status(err.statusCode).send(err.toJSON()) + } + } + const env = parseEnvInput(request.body?.env) + const result = await bulkSetSeoMeta(connection, entries, env) + const applied = result.results.filter((r) => r.status === 'applied') + if (applied.length > 0) { + writeAuditLog(app.db, { + projectId: project.id, + actor: 'api', + action: 'wordpress.page-meta-updated', + entityType: 'wordpress_page', + entityId: `bulk(${applied.map((r) => r.slug).join(',')})`, + }) + } + return result + }) + }) + app.get<{ Params: { name: string } Querystring: { slug?: string; env?: string } @@ -395,6 +439,41 @@ export async function wordpressRoutes(app: FastifyInstance, opts: WordpressRoute }) }) + app.post<{ + Params: { name: string } + Body: { profile: SchemaProfileFile; env?: WordpressEnv } + }>('/projects/:name/wordpress/schema/deploy', async (request, reply) => { + return withWordpressErrorHandling(reply, async () => { + const store = requireStore(reply) + if (!store) return + const project = resolveProject(app.db, request.params.name) + const connection = requireConnection(store, project.name, reply) + if (!connection) return + const profile = request.body?.profile + if (!profile?.business?.name || !profile?.pages || Object.keys(profile.pages).length === 0) { + const err = validationError('profile with business.name and non-empty pages is required') + return reply.status(err.statusCode).send(err.toJSON()) + } + const env = parseEnvInput(request.body?.env) + return deploySchemaFromProfile(connection, profile, env) + }) + }) + + app.get<{ + Params: { name: string } + Querystring: { env?: string } + }>('/projects/:name/wordpress/schema/status', async (request, reply) => { + return withWordpressErrorHandling(reply, async () => { + const store = requireStore(reply) + if (!store) return + const project = resolveProject(app.db, request.params.name) + const connection = requireConnection(store, project.name, reply) + if (!connection) return + const env = parseEnvInput(request.query?.env) + return getSchemaStatus(connection, env) + }) + }) + app.get<{ Params: { name: string } Querystring: { env?: string } @@ -493,4 +572,242 @@ export async function wordpressRoutes(app: FastifyInstance, opts: WordpressRoute return buildManualStagingPush(connection) }) }) + + // POST /projects/:name/wordpress/onboard — compound onboarding command + app.post<{ + Params: { name: string } + Body: { + url: string + username: string + appPassword: string + stagingUrl?: string + defaultEnv?: WordpressEnv + profile?: SchemaProfileFile + skipSchema?: boolean + skipSubmit?: boolean + } + }>('/projects/:name/wordpress/onboard', async (request, reply) => { + return withWordpressErrorHandling(reply, async () => { + const store = requireStore(reply) + if (!store) return + + const project = resolveProject(app.db, request.params.name) + const { url, username, appPassword, stagingUrl, profile, skipSchema, skipSubmit } = request.body ?? {} + + if (!url || !username || !appPassword) { + const err = validationError('url, username, and appPassword are required') + return reply.status(err.statusCode).send(err.toJSON()) + } + + const defaultEnv = parseEnvInput(request.body?.defaultEnv, 'defaultEnv') + ?? (stagingUrl ? 'staging' : 'live') + + if (defaultEnv === 'staging' && !stagingUrl) { + const err = validationError('defaultEnv "staging" requires stagingUrl') + return reply.status(err.statusCode).send(err.toJSON()) + } + + type StepResult = { name: string; status: 'completed' | 'skipped' | 'failed'; summary?: string; error?: string } + const steps: StepResult[] = [] + let connection: WordpressConnectionRecord | null = null + let pageUrls: string[] = [] + + // Step 1: Connect + try { + const now = new Date().toISOString() + const existing = store.getConnection(project.name) + const nextConnection: WordpressConnectionRecord = { + projectName: project.name, + url, + stagingUrl, + username, + appPassword, + defaultEnv, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + } + await verifyWordpressConnection(nextConnection) + connection = store.upsertConnection(nextConnection) + writeAuditLog(app.db, { + projectId: project.id, + actor: 'api', + action: 'wordpress.connected', + entityType: 'wordpress_connection', + entityId: project.name, + }) + steps.push({ name: 'connect', status: 'completed', summary: `Connected to ${url}` }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + steps.push({ name: 'connect', status: 'failed', error: msg }) + return { projectName: project.name, steps } + } + + // Step 2: Audit + let auditIssues: Array<{ slug: string; code: string }> = [] + let auditPages: Array<{ slug: string; title: string }> = [] + try { + const audit = await runAudit(connection) + const issueCount = audit.issues?.length ?? 0 + const pageCount = audit.pages?.length ?? 0 + auditIssues = audit.issues + auditPages = audit.pages + + // Get proper permalink URLs from listPages (handles hierarchical slugs + custom permalinks) + const pageSummaries = await listPages(connection) + pageUrls = pageSummaries + .map((p) => p.link) + .filter((link): link is string => typeof link === 'string' && link.length > 0) + + steps.push({ name: 'audit', status: 'completed', summary: `${pageCount} pages audited, ${issueCount} issues` }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + steps.push({ name: 'audit', status: 'failed', error: msg }) + return { projectName: project.name, steps } + } + + // Step 3: Set meta (bulk, for pages missing title/description) + // Build entries with the page title as a fallback value for missing SEO fields + try { + const metaEntries: Array<{ slug: string; title?: string; description?: string }> = [] + for (const issue of auditIssues) { + if (issue.code === 'missing-meta-description' || issue.code === 'missing-seo-title') { + const existing = metaEntries.find((e) => e.slug === issue.slug) + const page = auditPages.find((p) => p.slug === issue.slug) + if (!existing) { + metaEntries.push({ + slug: issue.slug, + title: issue.code === 'missing-seo-title' ? (page?.title ?? issue.slug) : undefined, + description: issue.code === 'missing-meta-description' ? (page?.title ?? issue.slug) : undefined, + }) + } else { + if (issue.code === 'missing-seo-title' && !existing.title) { + existing.title = page?.title ?? issue.slug + } + if (issue.code === 'missing-meta-description' && !existing.description) { + existing.description = page?.title ?? issue.slug + } + } + } + } + if (metaEntries.length === 0) { + steps.push({ name: 'set-meta', status: 'skipped', summary: 'No pages with missing meta found' }) + } else { + const result = await bulkSetSeoMeta(connection, metaEntries) + const applied = result.results.filter((r) => r.status === 'applied').length + const manual = result.results.filter((r) => r.status === 'manual').length + const skipped = result.results.filter((r) => r.status === 'skipped').length + steps.push({ + name: 'set-meta', + status: 'completed', + summary: `${applied} applied, ${manual} manual-assist, ${skipped} skipped`, + }) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + steps.push({ name: 'set-meta', status: 'failed', error: msg }) + return { projectName: project.name, steps } + } + + // Step 4: Schema deploy (if profile provided and not skipped) + if (skipSchema || !profile) { + steps.push({ + name: 'schema-deploy', + status: 'skipped', + summary: skipSchema ? 'Skipped via --skip-schema' : 'No --profile provided', + }) + } else { + try { + if (!profile.business?.name || !profile.pages || Object.keys(profile.pages).length === 0) { + steps.push({ name: 'schema-deploy', status: 'skipped', summary: 'Profile missing business.name or pages' }) + } else { + const result = await deploySchemaFromProfile(connection, profile) + const deployed = result.results.filter((r) => r.status === 'deployed').length + const stripped = result.results.filter((r) => r.status === 'stripped').length + const skipped = result.results.filter((r) => r.status === 'skipped').length + steps.push({ + name: 'schema-deploy', + status: 'completed', + summary: `${deployed} deployed, ${stripped} stripped (manual-assist), ${skipped} skipped`, + }) + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + steps.push({ name: 'schema-deploy', status: 'failed', error: msg }) + return { projectName: project.name, steps } + } + } + + // Step 5 & 6: Submit URLs to Google/Bing (via app.inject) + if (skipSubmit || pageUrls.length === 0) { + const reason = skipSubmit ? 'Skipped via --skip-submit' : 'No page URLs to submit' + steps.push({ name: 'google-submit', status: 'skipped', summary: reason }) + steps.push({ name: 'bing-submit', status: 'skipped', summary: reason }) + } else { + // Step 5: Google submit + try { + const authHeader = request.headers.authorization + const googleRes = await app.inject({ + method: 'POST', + url: `${opts.routePrefix ?? '/api/v1'}/projects/${encodeURIComponent(project.name)}/google/indexing/request`, + payload: { urls: pageUrls }, + headers: authHeader ? { authorization: authHeader } : {}, + }) + if (googleRes.statusCode === 200) { + const body = JSON.parse(googleRes.body) + const succeeded = body.results?.filter((r: { status: string }) => r.status === 'success').length ?? 0 + steps.push({ name: 'google-submit', status: 'completed', summary: `${succeeded}/${pageUrls.length} URLs submitted` }) + } else { + const body = JSON.parse(googleRes.body) + const msg = body.message || body.error || `HTTP ${googleRes.statusCode}` + // Treat "not configured" as skipped, not failed + if (googleRes.statusCode === 400 || googleRes.statusCode === 404) { + steps.push({ name: 'google-submit', status: 'skipped', summary: msg }) + } else { + steps.push({ name: 'google-submit', status: 'failed', error: msg }) + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + steps.push({ name: 'google-submit', status: 'skipped', summary: `Google not available: ${msg}` }) + } + + // Step 6: Bing submit + try { + const authHeader = request.headers.authorization + const bingRes = await app.inject({ + method: 'POST', + url: `${opts.routePrefix ?? '/api/v1'}/projects/${encodeURIComponent(project.name)}/bing/request-indexing`, + payload: { urls: pageUrls }, + headers: authHeader ? { authorization: authHeader } : {}, + }) + if (bingRes.statusCode === 200) { + const body = JSON.parse(bingRes.body) + const succeeded = body.results?.filter((r: { status: string }) => r.status === 'success').length ?? 0 + steps.push({ name: 'bing-submit', status: 'completed', summary: `${succeeded}/${pageUrls.length} URLs submitted` }) + } else { + const body = JSON.parse(bingRes.body) + const msg = body.message || body.error || `HTTP ${bingRes.statusCode}` + if (bingRes.statusCode === 400 || bingRes.statusCode === 404) { + steps.push({ name: 'bing-submit', status: 'skipped', summary: msg }) + } else { + steps.push({ name: 'bing-submit', status: 'failed', error: msg }) + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + steps.push({ name: 'bing-submit', status: 'skipped', summary: `Bing not available: ${msg}` }) + } + } + + writeAuditLog(app.db, { + projectId: project.id, + actor: 'api', + action: 'wordpress.onboarded', + entityType: 'wordpress_connection', + entityId: project.name, + }) + + return { projectName: project.name, steps } + }) // end withWordpressErrorHandling + }) } diff --git a/packages/api-routes/test/wordpress.test.ts b/packages/api-routes/test/wordpress.test.ts index 687eaae..4d1d955 100644 --- a/packages/api-routes/test/wordpress.test.ts +++ b/packages/api-routes/test/wordpress.test.ts @@ -439,4 +439,372 @@ describe('WordPress routes', () => { nextSteps: ['Open the WP STAGING admin page on the live site.'], }) }) + + it('bulk set-meta applies plugin meta for writable sites and returns manual for non-writable', async () => { + const now = new Date().toISOString() + connections.set('test-project', { + projectName: 'test-project', + url: 'https://example.com', + username: 'admin', + appPassword: 'app-pass', + defaultEnv: 'live', + createdAt: now, + updatedAt: now, + }) + + const wordpressModule = await import('@ainyc/canonry-integration-wordpress') + vi.spyOn(wordpressModule, 'bulkSetSeoMeta').mockResolvedValue({ + env: 'live', + strategy: 'plugin', + results: [ + { slug: 'about', status: 'applied' }, + { slug: 'missing-page', status: 'skipped', error: 'Page "missing-page" not found' }, + ], + }) + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/projects/test-project/wordpress/pages/meta/bulk', + payload: { + entries: [ + { slug: 'about', title: 'About Us', description: 'About page' }, + { slug: 'missing-page', title: 'Missing' }, + ], + }, + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.env).toBe('live') + expect(body.strategy).toBe('plugin') + expect(body.results).toHaveLength(2) + expect(body.results[0]).toMatchObject({ slug: 'about', status: 'applied' }) + expect(body.results[1]).toMatchObject({ slug: 'missing-page', status: 'skipped' }) + }) + + it('bulk set-meta rejects empty entries array', async () => { + const now = new Date().toISOString() + connections.set('test-project', { + projectName: 'test-project', + url: 'https://example.com', + username: 'admin', + appPassword: 'app-pass', + defaultEnv: 'live', + createdAt: now, + updatedAt: now, + }) + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/projects/test-project/wordpress/pages/meta/bulk', + payload: { entries: [] }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json()).toEqual({ + error: { + code: 'VALIDATION_ERROR', + message: 'entries array is required and must not be empty', + }, + }) + }) + + it('schema deploy deploys JSON-LD from profile and returns per-slug results', async () => { + const now = new Date().toISOString() + connections.set('test-project', { + projectName: 'test-project', + url: 'https://example.com', + username: 'admin', + appPassword: 'app-pass', + defaultEnv: 'live', + createdAt: now, + updatedAt: now, + }) + + const wordpressModule = await import('@ainyc/canonry-integration-wordpress') + vi.spyOn(wordpressModule, 'deploySchemaFromProfile').mockResolvedValue({ + env: 'live', + results: [ + { slug: 'home', status: 'deployed', schemasInjected: ['Organization', 'LocalBusiness'] }, + { slug: 'faq', status: 'stripped', manualAssist: { + manualRequired: true, + targetUrl: 'https://example.com/faq', + adminUrl: 'https://example.com/wp-admin/', + content: '{"@type":"FAQPage"}', + nextSteps: ['Add schema manually.'], + } }, + { slug: 'nonexistent', status: 'skipped', error: 'Page "nonexistent" not found' }, + ], + }) + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/projects/test-project/wordpress/schema/deploy', + payload: { + profile: { + business: { name: 'Test Co', url: 'https://example.com' }, + pages: { + home: ['Organization', 'LocalBusiness'], + faq: [{ type: 'FAQPage', faqs: [{ q: 'Q?', a: 'A.' }] }], + nonexistent: ['WebPage'], + }, + }, + }, + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.results).toHaveLength(3) + expect(body.results[0]).toMatchObject({ slug: 'home', status: 'deployed' }) + expect(body.results[1]).toMatchObject({ slug: 'faq', status: 'stripped' }) + expect(body.results[1].manualAssist).toBeDefined() + expect(body.results[2]).toMatchObject({ slug: 'nonexistent', status: 'skipped' }) + }) + + it('schema deploy rejects profiles without business.name', async () => { + const now = new Date().toISOString() + connections.set('test-project', { + projectName: 'test-project', + url: 'https://example.com', + username: 'admin', + appPassword: 'app-pass', + defaultEnv: 'live', + createdAt: now, + updatedAt: now, + }) + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/projects/test-project/wordpress/schema/deploy', + payload: { + profile: { business: {}, pages: { home: ['Organization'] } }, + }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json().error.code).toBe('VALIDATION_ERROR') + }) + + it('schema deploy rejects profiles with empty pages', async () => { + const now = new Date().toISOString() + connections.set('test-project', { + projectName: 'test-project', + url: 'https://example.com', + username: 'admin', + appPassword: 'app-pass', + defaultEnv: 'live', + createdAt: now, + updatedAt: now, + }) + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/projects/test-project/wordpress/schema/deploy', + payload: { + profile: { business: { name: 'Test Co' }, pages: {} }, + }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json().error.code).toBe('VALIDATION_ERROR') + }) + + it('schema status returns per-page schema summary', async () => { + const now = new Date().toISOString() + connections.set('test-project', { + projectName: 'test-project', + url: 'https://example.com', + username: 'admin', + appPassword: 'app-pass', + defaultEnv: 'live', + createdAt: now, + updatedAt: now, + }) + + const wordpressModule = await import('@ainyc/canonry-integration-wordpress') + vi.spyOn(wordpressModule, 'getSchemaStatus').mockResolvedValue({ + env: 'live', + pages: [ + { slug: 'home', title: 'Home', canonrySchemas: ['Organization'], thirdPartySchemas: ['WebSite'], hasCanonrySchema: true }, + { slug: 'about', title: 'About', canonrySchemas: [], thirdPartySchemas: [], hasCanonrySchema: false }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/api/v1/projects/test-project/wordpress/schema/status', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.pages).toHaveLength(2) + expect(body.pages[0]).toMatchObject({ slug: 'home', hasCanonrySchema: true }) + expect(body.pages[1]).toMatchObject({ slug: 'about', hasCanonrySchema: false }) + }) + + it('onboard runs all steps sequentially and returns step results', async () => { + const wordpressModule = await import('@ainyc/canonry-integration-wordpress') + vi.spyOn(wordpressModule, 'verifyWordpressConnection').mockResolvedValue({ + url: 'https://example.com', + reachable: true, + pageCount: 2, + version: '6.8.1', + plugins: [], + authenticatedUser: { id: 1, slug: 'admin' }, + }) + vi.spyOn(wordpressModule, 'runAudit').mockResolvedValue({ + env: 'live', + pages: [ + { slug: 'home', title: 'Home', status: 'publish', wordCount: 500, seo: { title: 'Home', description: 'Desc', noindex: false, writable: false, writeTargets: [] }, schemaPresent: false, issues: [] }, + { slug: 'about', title: 'About Us', status: 'publish', wordCount: 300, seo: { title: null, description: null, noindex: false, writable: false, writeTargets: [] }, schemaPresent: false, issues: [] }, + ], + issues: [ + { slug: 'about', severity: 'medium', code: 'missing-seo-title', message: 'Missing title' }, + { slug: 'about', severity: 'medium', code: 'missing-meta-description', message: 'Missing description' }, + ], + }) + vi.spyOn(wordpressModule, 'listPages').mockResolvedValue([ + { id: 1, slug: 'home', title: 'Home', status: 'publish', modifiedAt: '2026-03-27T12:00:00Z', link: 'https://example.com/home/' }, + { id: 2, slug: 'about', title: 'About Us', status: 'publish', modifiedAt: '2026-03-27T12:00:00Z', link: 'https://example.com/about-us/team/' }, + ]) + const bulkMetaSpy = vi.spyOn(wordpressModule, 'bulkSetSeoMeta').mockResolvedValue({ + env: 'live', + strategy: 'manual', + results: [{ slug: 'about', status: 'manual' }], + }) + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/projects/test-project/wordpress/onboard', + payload: { + url: 'https://example.com', + username: 'admin', + appPassword: 'app-pass', + skipSchema: true, + skipSubmit: true, + }, + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.projectName).toBe('test-project') + expect(body.steps).toHaveLength(6) + expect(body.steps[0]).toMatchObject({ name: 'connect', status: 'completed' }) + expect(body.steps[1]).toMatchObject({ name: 'audit', status: 'completed' }) + expect(body.steps[2]).toMatchObject({ name: 'set-meta', status: 'completed' }) + expect(body.steps[3]).toMatchObject({ name: 'schema-deploy', status: 'skipped' }) + expect(body.steps[4]).toMatchObject({ name: 'google-submit', status: 'skipped' }) + expect(body.steps[5]).toMatchObject({ name: 'bing-submit', status: 'skipped' }) + + // P1 fix: set-meta entries must include actual title/description values + expect(bulkMetaSpy).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ slug: 'about', title: 'About Us', description: 'About Us' }), + ]), + ) + }) + + it('onboard rejects staging defaultEnv without stagingUrl', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/projects/test-project/wordpress/onboard', + payload: { + url: 'https://example.com', + username: 'admin', + appPassword: 'app-pass', + defaultEnv: 'staging', + }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json().error.message).toContain('stagingUrl') + }) + + it('onboard halts and reports on connection failure', async () => { + const wordpressModule = await import('@ainyc/canonry-integration-wordpress') + vi.spyOn(wordpressModule, 'verifyWordpressConnection').mockRejectedValue( + new WordpressApiError('AUTH_INVALID', 'Authentication failed', 401), + ) + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/projects/test-project/wordpress/onboard', + payload: { + url: 'https://example.com', + username: 'admin', + appPassword: 'wrong-pass', + }, + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.steps).toHaveLength(1) + expect(body.steps[0]).toMatchObject({ name: 'connect', status: 'failed', error: 'Authentication failed' }) + }) + + it('onboard validates required fields', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/projects/test-project/wordpress/onboard', + payload: { url: 'https://example.com' }, + }) + + expect(res.statusCode).toBe(400) + expect(res.json().error.code).toBe('VALIDATION_ERROR') + }) + + it('bulk set-meta returns manual-assist results for sites without SEO plugins', async () => { + const now = new Date().toISOString() + connections.set('test-project', { + projectName: 'test-project', + url: 'https://example.com', + username: 'admin', + appPassword: 'app-pass', + defaultEnv: 'live', + createdAt: now, + updatedAt: now, + }) + + const wordpressModule = await import('@ainyc/canonry-integration-wordpress') + vi.spyOn(wordpressModule, 'bulkSetSeoMeta').mockResolvedValue({ + env: 'live', + strategy: 'manual', + results: [ + { + slug: 'about', + status: 'manual', + manualAssist: { + manualRequired: true, + targetUrl: 'https://example.com/about', + adminUrl: 'https://example.com/wp-admin/', + content: 'Title: About Us\nDescription: About page', + nextSteps: [ + 'Open the WordPress editor for page "about".', + 'Install an SEO plugin (Yoast SEO, Rank Math, or AIOSEO) to manage meta fields via REST, or set the values manually in the page editor.', + 'Apply the meta values listed above.', + 'Publish/update the page.', + ], + }, + }, + ], + }) + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/projects/test-project/wordpress/pages/meta/bulk', + payload: { + entries: [ + { slug: 'about', title: 'About Us', description: 'About page' }, + ], + }, + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.strategy).toBe('manual') + expect(body.results[0].status).toBe('manual') + expect(body.results[0].manualAssist).toBeDefined() + expect(body.results[0].manualAssist.manualRequired).toBe(true) + }) }) diff --git a/packages/canonry/package.json b/packages/canonry/package.json index 881bb4d..0d60a83 100644 --- a/packages/canonry/package.json +++ b/packages/canonry/package.json @@ -1,6 +1,6 @@ { "name": "@ainyc/canonry", - "version": "1.28.2", + "version": "1.29.0", "type": "module", "description": "The ultimate open-source AEO monitoring tool - track how answer engines cite your domain", "license": "FSL-1.1-ALv2", diff --git a/packages/canonry/src/cli-commands/wordpress.ts b/packages/canonry/src/cli-commands/wordpress.ts index e0facb6..3a34e98 100644 --- a/packages/canonry/src/cli-commands/wordpress.ts +++ b/packages/canonry/src/cli-commands/wordpress.ts @@ -5,14 +5,18 @@ import { getBoolean, getString, requirePositional, requireProject, requireString import { usageError } from '../cli-error.js' import { wordpressAudit, + wordpressBulkSetMeta, wordpressConnect, wordpressCreatePage, wordpressDiff, wordpressDisconnect, wordpressLlmsTxt, + wordpressOnboard, wordpressPage, wordpressPages, wordpressSchema, + wordpressSchemaDeploy, + wordpressSchemaStatus, wordpressSetLlmsTxt, wordpressSetMeta, wordpressSetSchema, @@ -244,15 +248,27 @@ export const WORDPRESS_CLI_COMMANDS: readonly CliCommandSpec[] = [ }, { path: ['wordpress', 'set-meta'], - usage: 'canonry wordpress set-meta [--title ] [--description <text>] [--noindex|--index] [--live|--staging] [--format json]', + usage: 'canonry wordpress set-meta <project> <slug> [--title <title>] [--description <text>] [--noindex|--index] [--from <file>] [--live|--staging] [--format json]', options: { title: stringOption(), description: stringOption(), noindex: { type: 'boolean', default: false }, index: { type: 'boolean', default: false }, + from: stringOption(), ...envOptions, }, run: async (input) => { + const fromFile = getString(input.values, 'from') + if (fromFile) { + const usage = 'canonry wordpress set-meta <project> --from <file> [--live|--staging] [--format json]' + const project = requireProject(input, 'wordpress.set-meta', usage) + await wordpressBulkSetMeta(project, { + from: fromFile, + env: resolveEnv(input, 'wordpress.set-meta', usage), + format: input.format, + }) + return + } const usage = 'canonry wordpress set-meta <project> <slug> [--title <title>] [--description <text>] [--noindex|--index] [--live|--staging] [--format json]' const project = requireProject(input, 'wordpress.set-meta', usage) const slug = requirePositional(input, 1, { @@ -270,6 +286,41 @@ export const WORDPRESS_CLI_COMMANDS: readonly CliCommandSpec[] = [ }) }, }, + { + path: ['wordpress', 'schema', 'deploy'], + usage: 'canonry wordpress schema deploy <project> --profile <file> [--live|--staging] [--format json]', + options: { + profile: stringOption(), + ...envOptions, + }, + run: async (input) => { + const usage = 'canonry wordpress schema deploy <project> --profile <file> [--live|--staging] [--format json]' + const project = requireProject(input, 'wordpress.schema.deploy', usage) + const profile = requireStringOption(input, 'profile', { + message: '--profile is required', + command: 'wordpress.schema.deploy', + usage, + }) + await wordpressSchemaDeploy(project, { + profile, + env: resolveEnv(input, 'wordpress.schema.deploy', usage), + format: input.format, + }) + }, + }, + { + path: ['wordpress', 'schema', 'status'], + usage: 'canonry wordpress schema status <project> [--live|--staging] [--format json]', + options: envOptions, + run: async (input) => { + const usage = 'canonry wordpress schema status <project> [--live|--staging] [--format json]' + const project = requireProject(input, 'wordpress.schema.status', usage) + await wordpressSchemaStatus(project, { + env: resolveEnv(input, 'wordpress.schema.status', usage), + format: input.format, + }) + }, + }, { path: ['wordpress', 'schema'], usage: 'canonry wordpress schema <project> <slug> [--live|--staging] [--format json]', @@ -351,6 +402,45 @@ export const WORDPRESS_CLI_COMMANDS: readonly CliCommandSpec[] = [ }) }, }, + { + path: ['wordpress', 'onboard'], + usage: 'canonry wordpress onboard <project> --url <url> --user <user> [--app-password <pw>] [--profile <file>] [--skip-schema] [--skip-submit] [--live|--staging] [--format json]', + options: { + url: stringOption(), + user: stringOption(), + 'app-password': stringOption(), + 'staging-url': stringOption(), + profile: stringOption(), + 'skip-schema': { type: 'boolean', default: false }, + 'skip-submit': { type: 'boolean', default: false }, + ...envOptions, + }, + run: async (input) => { + const usage = 'canonry wordpress onboard <project> --url <url> --user <user> [--app-password <pw>] [--profile <file>] [--format json]' + const project = requireProject(input, 'wordpress.onboard', usage) + const url = requireStringOption(input, 'url', { + message: '--url is required', + command: 'wordpress.onboard', + usage, + }) + const user = requireStringOption(input, 'user', { + message: '--user is required', + command: 'wordpress.onboard', + usage, + }) + await wordpressOnboard(project, { + url, + user, + appPassword: getString(input.values, 'app-password'), + stagingUrl: getString(input.values, 'staging-url'), + defaultEnv: resolveEnv(input, 'wordpress.onboard', usage), + profile: getString(input.values, 'profile'), + skipSchema: getBoolean(input.values, 'skip-schema'), + skipSubmit: getBoolean(input.values, 'skip-submit'), + format: input.format, + }) + }, + }, { path: ['wordpress', 'audit'], usage: 'canonry wordpress audit <project> [--live|--staging] [--format json]', diff --git a/packages/canonry/src/cli.ts b/packages/canonry/src/cli.ts index 40fdd7d..e3e4c18 100644 --- a/packages/canonry/src/cli.ts +++ b/packages/canonry/src/cli.ts @@ -94,8 +94,12 @@ Usage: canonry wordpress create-page <project> Create a WordPress page (--title, --slug, --content/--content-file) canonry wordpress update-page <project> <slug> Update a WordPress page (--content/--content-file) canonry wordpress set-meta <project> <slug> Update REST-exposed SEO meta + canonry wordpress set-meta <project> --from <file> Bulk update SEO meta from JSON file canonry wordpress schema <project> <slug> Read rendered JSON-LD schema + canonry wordpress schema deploy <project> --profile <file> Deploy JSON-LD schema to pages + canonry wordpress schema status <project> Show schema status per page canonry wordpress set-schema <project> <slug> Generate manual schema handoff + canonry wordpress onboard <project> --url <url> --user <user> Full onboarding workflow canonry wordpress llms-txt <project> Read /llms.txt canonry wordpress set-llms-txt <project> Generate manual llms.txt handoff canonry wordpress audit <project> Audit WordPress pages for SEO/content issues diff --git a/packages/canonry/src/client.ts b/packages/canonry/src/client.ts index d8cf4e5..5b2ac8a 100644 --- a/packages/canonry/src/client.ts +++ b/packages/canonry/src/client.ts @@ -2,12 +2,16 @@ import { loadConfig } from './config.js' import type { WordpressAuditIssueDto, WordpressAuditPageDto, + WordpressBulkMetaResultDto, WordpressDiffDto, WordpressEnv, WordpressManualAssistDto, + WordpressOnboardResultDto, WordpressPageDetailDto, WordpressPageSummaryDto, WordpressSchemaBlockDto, + WordpressSchemaDeployResultDto, + WordpressSchemaStatusResultDto, WordpressStatusDto, } from '@ainyc/canonry-contracts' @@ -501,6 +505,16 @@ export class ApiClient { return this.request<WordpressPageDetailDto>('POST', `/projects/${encodeURIComponent(project)}/wordpress/page/meta`, body) } + async wordpressBulkSetMeta( + project: string, + body: { + entries: Array<{ slug: string; title?: string; description?: string; noindex?: boolean }> + env?: WordpressEnv + }, + ): Promise<WordpressBulkMetaResultDto> { + return this.request<WordpressBulkMetaResultDto>('POST', `/projects/${encodeURIComponent(project)}/wordpress/pages/meta/bulk`, body) + } + async wordpressSchema( project: string, slug: string, @@ -518,6 +532,39 @@ export class ApiClient { return this.request<WordpressManualAssistDto>('POST', `/projects/${encodeURIComponent(project)}/wordpress/schema/manual`, body) } + async wordpressSchemaDeploy( + project: string, + body: { profile: unknown; env?: WordpressEnv }, + ): Promise<WordpressSchemaDeployResultDto> { + return this.request<WordpressSchemaDeployResultDto>('POST', `/projects/${encodeURIComponent(project)}/wordpress/schema/deploy`, body) + } + + async wordpressSchemaStatus( + project: string, + env?: WordpressEnv, + ): Promise<WordpressSchemaStatusResultDto> { + const params = new URLSearchParams() + if (env) params.set('env', env) + const qs = params.toString() + return this.request<WordpressSchemaStatusResultDto>('GET', `/projects/${encodeURIComponent(project)}/wordpress/schema/status${qs ? `?${qs}` : ''}`) + } + + async wordpressOnboard( + project: string, + body: { + url: string + username: string + appPassword: string + stagingUrl?: string + defaultEnv?: WordpressEnv + profile?: unknown + skipSchema?: boolean + skipSubmit?: boolean + }, + ): Promise<WordpressOnboardResultDto> { + return this.request<WordpressOnboardResultDto>('POST', `/projects/${encodeURIComponent(project)}/wordpress/onboard`, body) + } + async wordpressLlmsTxt( project: string, env?: WordpressEnv, diff --git a/packages/canonry/src/commands/wordpress.ts b/packages/canonry/src/commands/wordpress.ts index 1eac673..b5d0479 100644 --- a/packages/canonry/src/commands/wordpress.ts +++ b/packages/canonry/src/commands/wordpress.ts @@ -304,6 +304,100 @@ export async function wordpressSetMeta( printPageDetail(result) } +export async function wordpressBulkSetMeta( + project: string, + opts: { from: string; env?: WordpressEnv; format?: string }, +): Promise<void> { + const fs = await import('node:fs/promises') + const path = await import('node:path') + + const filePath = path.resolve(opts.from) + let raw: string + try { + raw = await fs.readFile(filePath, 'utf8') + } catch { + throw new CliError({ + code: 'FILE_READ_ERROR', + message: `Cannot read file: ${filePath}`, + displayMessage: `Error: cannot read file "${opts.from}". Check the path and permissions.`, + details: { path: filePath }, + }) + } + + let parsed: Record<string, { title?: string; description?: string; noindex?: boolean }> + try { + parsed = JSON.parse(raw) as typeof parsed + } catch { + throw new CliError({ + code: 'INVALID_JSON', + message: `File is not valid JSON: ${filePath}`, + displayMessage: `Error: "${opts.from}" is not valid JSON.`, + details: { path: filePath }, + }) + } + + const entries = Object.entries(parsed).map(([slug, meta]) => ({ + slug, + title: meta.title, + description: meta.description, + noindex: meta.noindex, + })) + + if (entries.length === 0) { + throw new CliError({ + code: 'EMPTY_META_FILE', + message: 'Meta file contains no entries', + displayMessage: `Error: "${opts.from}" contains no entries. Expected JSON object keyed by slug.`, + details: { path: filePath }, + }) + } + + const client = getClient() + const result = await client.wordpressBulkSetMeta(project, { entries, env: opts.env }) + + if (opts.format === 'json') { + printJson(result) + return + } + + const applied = result.results.filter((r) => r.status === 'applied') + const skipped = result.results.filter((r) => r.status === 'skipped') + const manual = result.results.filter((r) => r.status === 'manual') + + console.log(`Bulk SEO meta update (${result.env}, strategy: ${result.strategy}):\n`) + + if (applied.length > 0) { + console.log(` Applied (${applied.length}):`) + for (const r of applied) { + console.log(` ${r.slug}`) + } + } + + if (skipped.length > 0) { + console.log(`\n Skipped (${skipped.length}):`) + for (const r of skipped) { + console.log(` ${r.slug}: ${r.error ?? 'unknown reason'}`) + } + } + + if (manual.length > 0) { + console.log(`\n Manual action required (${manual.length}):`) + console.log(' No SEO plugin with REST-writable meta fields was detected.') + console.log(' Install Yoast SEO, Rank Math, or AIOSEO, or update these pages manually:\n') + for (const r of manual) { + if (r.manualAssist) { + console.log(` ${r.slug}:`) + console.log(` Admin: ${r.manualAssist.adminUrl ?? '-'}`) + console.log(` Values: ${r.manualAssist.content}`) + } else { + console.log(` ${r.slug}`) + } + } + } + + console.log(`\nTotal: ${applied.length} applied, ${skipped.length} skipped, ${manual.length} manual`) +} + export async function wordpressSchema(project: string, slug: string, opts: { env?: WordpressEnv; format?: string }): Promise<void> { const client = getClient() const result = await client.wordpressSchema(project, slug, opts.env) @@ -332,6 +426,201 @@ export async function wordpressSetSchema( printManualAssist(`Schema update for "${body.slug}"`, result) } +export async function wordpressSchemaDeploy( + project: string, + opts: { profile: string; env?: WordpressEnv; format?: string }, +): Promise<void> { + const fs = await import('node:fs/promises') + const path = await import('node:path') + const yaml = await import('yaml' as string).catch(() => null) + + const filePath = path.resolve(opts.profile) + let raw: string + try { + raw = await fs.readFile(filePath, 'utf8') + } catch { + throw new CliError({ + code: 'FILE_READ_ERROR', + message: `Cannot read file: ${filePath}`, + displayMessage: `Error: cannot read file "${opts.profile}". Check the path and permissions.`, + details: { path: filePath }, + }) + } + + let parsed: unknown + try { + if (yaml?.parse) { + parsed = yaml.parse(raw) + } else { + parsed = JSON.parse(raw) + } + } catch { + throw new CliError({ + code: 'INVALID_PROFILE', + message: `File is not valid YAML or JSON: ${filePath}`, + displayMessage: `Error: "${opts.profile}" is not valid YAML or JSON.`, + details: { path: filePath }, + }) + } + + const profile = parsed as { business?: { name?: string }; pages?: Record<string, unknown> } + if (!profile?.business?.name || !profile?.pages || Object.keys(profile.pages).length === 0) { + throw new CliError({ + code: 'INVALID_PROFILE', + message: 'Profile must have business.name and non-empty pages', + displayMessage: 'Error: profile file must contain business.name and at least one page entry.', + details: { path: filePath }, + }) + } + + const client = getClient() + const result = await client.wordpressSchemaDeploy(project, { profile: parsed, env: opts.env }) + + if (opts.format === 'json') { + printJson(result) + return + } + + console.log(`Schema deploy (${result.env}):\n`) + for (const r of result.results) { + const types = r.schemasInjected?.join(', ') ?? '' + switch (r.status) { + case 'deployed': + console.log(` ${r.slug}: deployed (${types})`) + break + case 'stripped': + console.log(` ${r.slug}: STRIPPED — WordPress removed <script> tags. Manual action required.`) + if (r.manualAssist) { + console.log(` Admin: ${r.manualAssist.adminUrl ?? '-'}`) + for (const step of r.manualAssist.nextSteps) { + console.log(` - ${step}`) + } + } + break + case 'skipped': + console.log(` ${r.slug}: skipped — ${r.error ?? 'unknown'}`) + break + case 'failed': + console.log(` ${r.slug}: FAILED — ${r.error ?? 'unknown'}`) + break + } + } + + const deployed = result.results.filter((r) => r.status === 'deployed').length + const stripped = result.results.filter((r) => r.status === 'stripped').length + const skipped = result.results.filter((r) => r.status === 'skipped').length + const failed = result.results.filter((r) => r.status === 'failed').length + console.log(`\nTotal: ${deployed} deployed, ${stripped} stripped, ${skipped} skipped, ${failed} failed`) +} + +export async function wordpressSchemaStatus( + project: string, + opts: { env?: WordpressEnv; format?: string }, +): Promise<void> { + const client = getClient() + const result = await client.wordpressSchemaStatus(project, opts.env) + + if (opts.format === 'json') { + printJson(result) + return + } + + console.log(`Schema status (${result.env}):\n`) + if (result.pages.length === 0) { + console.log(' No pages found.') + return + } + + const slugWidth = Math.max(4, ...result.pages.map((p) => p.slug.length)) + console.log(` ${'SLUG'.padEnd(slugWidth)} CANONRY THIRD-PARTY`) + console.log(` ${'─'.repeat(slugWidth)} ${'─'.repeat(17)} ${'─'.repeat(20)}`) + for (const page of result.pages) { + const canonry = page.canonrySchemas.length > 0 ? page.canonrySchemas.join(', ') : '-' + const thirdParty = page.thirdPartySchemas.length > 0 ? page.thirdPartySchemas.join(', ') : '-' + console.log(` ${page.slug.padEnd(slugWidth)} ${canonry.padEnd(17)} ${thirdParty}`) + } +} + +export async function wordpressOnboard( + project: string, + opts: { + url: string + user: string + appPassword?: string + stagingUrl?: string + defaultEnv?: WordpressEnv + profile?: string + skipSchema?: boolean + skipSubmit?: boolean + format?: string + }, +): Promise<void> { + const appPassword = opts.appPassword ?? await promptForAppPassword() + if (!appPassword) { + throw new CliError({ + code: 'WORDPRESS_APP_PASSWORD_REQUIRED', + message: 'WordPress Application Password is required', + displayMessage: 'Error: WordPress Application Password is required (pass --app-password or enter interactively).', + details: { project }, + }) + } + + let profileData: unknown | undefined + if (opts.profile) { + const fs = await import('node:fs/promises') + const path = await import('node:path') + const yaml = await import('yaml' as string).catch(() => null) + + const filePath = path.resolve(opts.profile) + let raw: string + try { + raw = await fs.readFile(filePath, 'utf8') + } catch { + throw new CliError({ + code: 'FILE_READ_ERROR', + message: `Cannot read file: ${filePath}`, + displayMessage: `Error: cannot read file "${opts.profile}".`, + details: { path: filePath }, + }) + } + try { + profileData = yaml?.parse ? yaml.parse(raw) : JSON.parse(raw) + } catch { + throw new CliError({ + code: 'INVALID_PROFILE', + message: `File is not valid YAML or JSON: ${filePath}`, + displayMessage: `Error: "${opts.profile}" is not valid YAML or JSON.`, + details: { path: filePath }, + }) + } + } + + const client = getClient() + const result = await client.wordpressOnboard(project, { + url: opts.url, + username: opts.user, + appPassword, + stagingUrl: opts.stagingUrl, + defaultEnv: opts.defaultEnv, + profile: profileData, + skipSchema: opts.skipSchema, + skipSubmit: opts.skipSubmit, + }) + + if (opts.format === 'json') { + printJson(result) + return + } + + console.log(`WordPress onboarding for "${project}":\n`) + for (const step of result.steps) { + const icon = step.status === 'completed' ? '+' : step.status === 'skipped' ? '-' : 'x' + console.log(` [${icon}] ${step.name}: ${step.status}`) + if (step.summary) console.log(` ${step.summary}`) + if (step.error) console.log(` Error: ${step.error}`) + } +} + export async function wordpressLlmsTxt(project: string, opts: { env?: WordpressEnv; format?: string }): Promise<void> { const client = getClient() const result = await client.wordpressLlmsTxt(project, opts.env) diff --git a/packages/canonry/test/wordpress-commands.test.ts b/packages/canonry/test/wordpress-commands.test.ts index fff2641..ed7a2bc 100644 --- a/packages/canonry/test/wordpress-commands.test.ts +++ b/packages/canonry/test/wordpress-commands.test.ts @@ -482,6 +482,142 @@ describe('wordpress CLI commands', () => { expect(body.content).toBe('{"@type":"FAQPage"}') }) + it('bulk set-meta reports an error when the --from file does not exist', async () => { + originalConfigDir = process.env.CANONRY_CONFIG_DIR + + const harness = await startHarness() + closeHarness = harness.close + process.env.CANONRY_CONFIG_DIR = harness.tmpDir + + const result = await invokeCli([ + 'wordpress', 'set-meta', 'test-proj', '--from', '/does/not/exist.json', + ]) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('cannot read file') + }) + + it('bulk set-meta reports an error when the --from file is not valid JSON', async () => { + originalConfigDir = process.env.CANONRY_CONFIG_DIR + + const harness = await startHarness() + closeHarness = harness.close + process.env.CANONRY_CONFIG_DIR = harness.tmpDir + + const metaFile = path.join(harness.tmpDir, 'meta.json') + fs.writeFileSync(metaFile, 'not json', 'utf-8') + + const result = await invokeCli([ + 'wordpress', 'set-meta', 'test-proj', '--from', metaFile, + ]) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('not valid JSON') + }) + + it('schema deploy reports an error when the --profile file does not exist', async () => { + originalConfigDir = process.env.CANONRY_CONFIG_DIR + + const harness = await startHarness() + closeHarness = harness.close + process.env.CANONRY_CONFIG_DIR = harness.tmpDir + + const result = await invokeCli([ + 'wordpress', 'schema', 'deploy', 'test-proj', '--profile', '/does/not/exist.yaml', + ]) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('cannot read file') + }) + + it('schema status outputs JSON with empty pages when no pages are published', async () => { + originalConfigDir = process.env.CANONRY_CONFIG_DIR + originalFetch = globalThis.fetch + + const now = new Date().toISOString() + const harness = await startHarness({ + wordpress: { + connections: [ + { + projectName: 'test-proj', + url: 'https://example.com', + username: 'admin', + appPassword: 'app-pass', + defaultEnv: 'live', + createdAt: now, + updatedAt: now, + }, + ], + }, + }) + closeHarness = harness.close + process.env.CANONRY_CONFIG_DIR = harness.tmpDir + + globalThis.fetch = async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + if (url.startsWith(harness.serverUrl)) { + return originalFetch(input, init) + } + if (url.includes('/wp-json/wp/v2/pages?per_page=100&page=1')) { + return jsonResponse([], { + headers: { + 'x-wp-total': '0', + 'x-wp-totalpages': '1', + }, + }) + } + throw new Error(`Unhandled URL: ${url}`) + } + + const result = await invokeCli([ + 'wordpress', 'schema', 'status', 'test-proj', '--format', 'json', + ]) + + const body = parseJsonOutput(result.stdout) as { env: string; pages: unknown[] } + expect(body.env).toBe('live') + expect(body.pages).toEqual([]) + }) + + it('onboard returns a failed connect step when WordPress credentials are invalid', async () => { + originalConfigDir = process.env.CANONRY_CONFIG_DIR + originalFetch = globalThis.fetch + + const harness = await startHarness() + closeHarness = harness.close + process.env.CANONRY_CONFIG_DIR = harness.tmpDir + + globalThis.fetch = async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + if (url.startsWith(harness.serverUrl)) { + return originalFetch(input, init) + } + if (url.includes('/wp-json/wp/v2/users/me?')) { + return new Response( + JSON.stringify({ + code: 'rest_not_logged_in', + message: 'You are not currently logged in.', + data: { status: 401 }, + }), + { status: 401, headers: { 'content-type': 'application/json' } }, + ) + } + throw new Error(`Unhandled URL: ${url}`) + } + + const result = await invokeCli([ + 'wordpress', 'onboard', 'test-proj', + '--url', 'https://example.com', + '--user', 'admin', + '--app-password', 'wrong-pass', + '--skip-schema', + '--skip-submit', + '--format', 'json', + ]) + + const body = parseJsonOutput(result.stdout) as { steps: Array<{ name: string; status: string }> } + expect(body.steps[0]).toMatchObject({ name: 'connect', status: 'failed' }) + }) + it('renders actionable errors when SEO meta writes are unsupported', async () => { originalConfigDir = process.env.CANONRY_CONFIG_DIR originalFetch = globalThis.fetch diff --git a/packages/contracts/src/wordpress.ts b/packages/contracts/src/wordpress.ts index 9fb4e6d..0c5ae47 100644 --- a/packages/contracts/src/wordpress.ts +++ b/packages/contracts/src/wordpress.ts @@ -111,6 +111,65 @@ export const wordpressAuditPageDtoSchema = z.object({ }) export type WordpressAuditPageDto = z.infer<typeof wordpressAuditPageDtoSchema> +export const wordpressBulkMetaEntryResultDtoSchema = z.object({ + slug: z.string(), + status: z.enum(['applied', 'skipped', 'manual']), + error: z.string().optional(), + manualAssist: wordpressManualAssistDtoSchema.optional(), +}) +export type WordpressBulkMetaEntryResultDto = z.infer<typeof wordpressBulkMetaEntryResultDtoSchema> + +export const wordpressBulkMetaResultDtoSchema = z.object({ + env: wordpressEnvSchema, + strategy: z.enum(['plugin', 'manual']), + results: z.array(wordpressBulkMetaEntryResultDtoSchema), +}) +export type WordpressBulkMetaResultDto = z.infer<typeof wordpressBulkMetaResultDtoSchema> + +export const wordpressSchemaDeployEntryResultDtoSchema = z.object({ + slug: z.string(), + status: z.enum(['deployed', 'stripped', 'skipped', 'failed']), + schemasInjected: z.array(z.string()).optional(), + manualAssist: wordpressManualAssistDtoSchema.optional(), + error: z.string().optional(), +}) +export type WordpressSchemaDeployEntryResultDto = z.infer<typeof wordpressSchemaDeployEntryResultDtoSchema> + +export const wordpressSchemaDeployResultDtoSchema = z.object({ + env: wordpressEnvSchema, + results: z.array(wordpressSchemaDeployEntryResultDtoSchema), +}) +export type WordpressSchemaDeployResultDto = z.infer<typeof wordpressSchemaDeployResultDtoSchema> + +export const wordpressSchemaStatusPageDtoSchema = z.object({ + slug: z.string(), + title: z.string(), + canonrySchemas: z.array(z.string()), + thirdPartySchemas: z.array(z.string()), + hasCanonrySchema: z.boolean(), +}) +export type WordpressSchemaStatusPageDto = z.infer<typeof wordpressSchemaStatusPageDtoSchema> + +export const wordpressSchemaStatusResultDtoSchema = z.object({ + env: wordpressEnvSchema, + pages: z.array(wordpressSchemaStatusPageDtoSchema), +}) +export type WordpressSchemaStatusResultDto = z.infer<typeof wordpressSchemaStatusResultDtoSchema> + +export const wordpressOnboardStepDtoSchema = z.object({ + name: z.string(), + status: z.enum(['completed', 'skipped', 'failed']), + summary: z.string().optional(), + error: z.string().optional(), +}) +export type WordpressOnboardStepDto = z.infer<typeof wordpressOnboardStepDtoSchema> + +export const wordpressOnboardResultDtoSchema = z.object({ + projectName: z.string(), + steps: z.array(wordpressOnboardStepDtoSchema), +}) +export type WordpressOnboardResultDto = z.infer<typeof wordpressOnboardResultDtoSchema> + export const wordpressDiffDtoSchema = z.object({ slug: z.string(), live: wordpressDiffPageDtoSchema, diff --git a/packages/integration-wordpress/src/index.ts b/packages/integration-wordpress/src/index.ts index 79c5dfe..427f7dd 100644 --- a/packages/integration-wordpress/src/index.ts +++ b/packages/integration-wordpress/src/index.ts @@ -1,23 +1,44 @@ export type { WordpressConnectionRecord, WordpressRestPage, WordpressSiteContext } from './types.js' export { WordpressApiError } from './types.js' +export type { BulkMetaEntry, SeoWriteStrategy } from './wordpress-client.js' +export type { + BusinessAddress, + BusinessProfile, + FaqEntry, + SchemaPageEntry, + SchemaProfileFile, +} from './schema-templates.js' +export { + generateSchema, + isSupportedSchemaType, + parseSchemaPageEntry, + supportedSchemaTypes, +} from './schema-templates.js' export { buildManualLlmsTxtUpdate, buildManualSchemaUpdate, buildManualStagingPush, + bulkSetSeoMeta, createPage, + deploySchema, + deploySchemaFromProfile, + detectSeoWriteStrategy, diffPageAcrossEnvironments, getLlmsTxt, getPageBySlug, getPageDetail, getPageSchema, + getSchemaStatus, getSiteStatus, getWpStagingAdminUrl, + injectCanonrySchema, listActivePlugins, listPages, parseEnv, resolveEnvironment, runAudit, setSeoMeta, + stripCanonrySchema, updatePageBySlug, verifyWordpressConnection, } from './wordpress-client.js' diff --git a/packages/integration-wordpress/src/schema-templates.ts b/packages/integration-wordpress/src/schema-templates.ts new file mode 100644 index 0000000..09a1097 --- /dev/null +++ b/packages/integration-wordpress/src/schema-templates.ts @@ -0,0 +1,149 @@ +export interface BusinessAddress { + street?: string + city?: string + state?: string + zip?: string + country?: string +} + +export interface BusinessProfile { + name: string + url?: string + description?: string + phone?: string + email?: string + address?: BusinessAddress +} + +export interface FaqEntry { + q: string + a: string +} + +export type SchemaPageEntry = + | string + | { type: string; faqs?: FaqEntry[] } + +export interface SchemaProfileFile { + business: BusinessProfile + pages: Record<string, SchemaPageEntry[]> +} + +const SUPPORTED_TYPES = new Set([ + 'LocalBusiness', + 'Organization', + 'FAQPage', + 'Service', + 'WebPage', +]) + +export function isSupportedSchemaType(type: string): boolean { + return SUPPORTED_TYPES.has(type) +} + +export function supportedSchemaTypes(): string[] { + return [...SUPPORTED_TYPES] +} + +function buildAddress(address: BusinessAddress): Record<string, unknown> { + return { + '@type': 'PostalAddress', + ...(address.street ? { streetAddress: address.street } : {}), + ...(address.city ? { addressLocality: address.city } : {}), + ...(address.state ? { addressRegion: address.state } : {}), + ...(address.zip ? { postalCode: address.zip } : {}), + ...(address.country ? { addressCountry: address.country } : {}), + } +} + +function generateLocalBusiness(profile: BusinessProfile): Record<string, unknown> { + const schema: Record<string, unknown> = { + '@context': 'https://schema.org', + '@type': 'LocalBusiness', + name: profile.name, + } + if (profile.url) schema.url = profile.url + if (profile.description) schema.description = profile.description + if (profile.phone) schema.telephone = profile.phone + if (profile.email) schema.email = profile.email + if (profile.address) schema.address = buildAddress(profile.address) + return schema +} + +function generateOrganization(profile: BusinessProfile): Record<string, unknown> { + const schema: Record<string, unknown> = { + '@context': 'https://schema.org', + '@type': 'Organization', + name: profile.name, + } + if (profile.url) schema.url = profile.url + if (profile.description) schema.description = profile.description + if (profile.phone) schema.telephone = profile.phone + if (profile.email) schema.email = profile.email + if (profile.address) schema.address = buildAddress(profile.address) + return schema +} + +function generateFAQPage(profile: BusinessProfile, faqs?: FaqEntry[]): Record<string, unknown> { + const schema: Record<string, unknown> = { + '@context': 'https://schema.org', + '@type': 'FAQPage', + name: profile.name, + } + if (faqs && faqs.length > 0) { + schema.mainEntity = faqs.map((faq) => ({ + '@type': 'Question', + name: faq.q, + acceptedAnswer: { + '@type': 'Answer', + text: faq.a, + }, + })) + } + return schema +} + +function generateService(profile: BusinessProfile): Record<string, unknown> { + const schema: Record<string, unknown> = { + '@context': 'https://schema.org', + '@type': 'Service', + name: profile.name, + } + if (profile.url) schema.url = profile.url + if (profile.description) schema.description = profile.description + if (profile.address) schema.areaServed = buildAddress(profile.address) + return schema +} + +function generateWebPage(profile: BusinessProfile): Record<string, unknown> { + const schema: Record<string, unknown> = { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: profile.name, + } + if (profile.url) schema.url = profile.url + if (profile.description) schema.description = profile.description + return schema +} + +export function generateSchema( + type: string, + profile: BusinessProfile, + overrides?: { faqs?: FaqEntry[] }, +): Record<string, unknown> { + switch (type) { + case 'LocalBusiness': return generateLocalBusiness(profile) + case 'Organization': return generateOrganization(profile) + case 'FAQPage': return generateFAQPage(profile, overrides?.faqs) + case 'Service': return generateService(profile) + case 'WebPage': return generateWebPage(profile) + default: throw new Error(`Unsupported schema type: ${type}`) + } +} + +export function parseSchemaPageEntry(entry: SchemaPageEntry): { type: string; faqs?: FaqEntry[] } { + if (typeof entry === 'string') { + return { type: entry } + } + return { type: entry.type, faqs: entry.faqs } +} diff --git a/packages/integration-wordpress/src/wordpress-client.ts b/packages/integration-wordpress/src/wordpress-client.ts index a14969f..d9bc766 100644 --- a/packages/integration-wordpress/src/wordpress-client.ts +++ b/packages/integration-wordpress/src/wordpress-client.ts @@ -2,6 +2,8 @@ import crypto from 'node:crypto' import type { WordpressAuditIssueDto, WordpressAuditPageDto, + WordpressBulkMetaEntryResultDto, + WordpressBulkMetaResultDto, WordpressDiffDto, WordpressDiffPageDto, WordpressEnv, @@ -9,12 +11,18 @@ import type { WordpressPageDetailDto, WordpressPageSummaryDto, WordpressSchemaBlockDto, + WordpressSchemaDeployEntryResultDto, + WordpressSchemaDeployResultDto, + WordpressSchemaStatusPageDto, + WordpressSchemaStatusResultDto, WordpressSeoStateDto, WordpressSiteStatusDto, } from '@ainyc/canonry-contracts' import { wordpressEnvSchema } from '@ainyc/canonry-contracts' import type { WordpressConnectionRecord, WordpressRestPage, WordpressSiteContext } from './types.js' import { WordpressApiError } from './types.js' +import type { BusinessProfile, SchemaPageEntry, SchemaProfileFile } from './schema-templates.js' +import { generateSchema, isSupportedSchemaType, parseSchemaPageEntry } from './schema-templates.js' const PAGE_FIELDS = 'id,slug,status,link,modified,modified_gmt,title,content,meta' const PAGE_LIST_FIELDS = 'id,slug,status,link,modified,modified_gmt,title' @@ -594,6 +602,344 @@ export async function setSeoMeta( return getPageDetail(connection, slug, site.env, plugins) } +export type SeoWriteStrategy = { strategy: 'plugin' | 'manual'; plugins: string[] | null } + +export async function detectSeoWriteStrategy( + connection: WordpressConnectionRecord, + env?: WordpressEnv, +): Promise<SeoWriteStrategy> { + const site = resolveEnvironment(connection, env) + const plugins = await listActivePlugins(connection, site.env) + const pages = await listPages(connection, site.env) + if (pages.length === 0) { + return { strategy: 'manual', plugins } + } + + const samplePage = await getPageBySlug(connection, pages[0]!.slug, site.env) + const writeTargets = resolveSeoWriteTargets(samplePage.meta, plugins) + return { + strategy: writeTargets.length > 0 ? 'plugin' : 'manual', + plugins, + } +} + +function buildManualMetaAssist( + siteUrl: string, + slug: string, + link: string | null | undefined, + meta: { title?: string; description?: string; noindex?: boolean }, +): WordpressManualAssistDto { + const fields: string[] = [] + if (meta.title != null) fields.push(`Title: ${meta.title}`) + if (meta.description != null) fields.push(`Description: ${meta.description}`) + if (meta.noindex != null) fields.push(`Noindex: ${meta.noindex}`) + return { + manualRequired: true, + targetUrl: link ?? `${siteUrl}/${slug}`, + adminUrl: `${siteUrl}/wp-admin/`, + content: fields.join('\n'), + nextSteps: [ + `Open the WordPress editor for page "${slug}".`, + 'Install an SEO plugin (Yoast SEO, Rank Math, or AIOSEO) to manage meta fields via REST, or set the values manually in the page editor.', + 'Apply the meta values listed above.', + 'Publish/update the page.', + ], + } +} + +export interface BulkMetaEntry { + slug: string + title?: string + description?: string + noindex?: boolean +} + +export async function bulkSetSeoMeta( + connection: WordpressConnectionRecord, + entries: BulkMetaEntry[], + env?: WordpressEnv, +): Promise<WordpressBulkMetaResultDto> { + const site = resolveEnvironment(connection, env) + const { strategy, plugins } = await detectSeoWriteStrategy(connection, site.env) + + const results = await mapWithConcurrency<BulkMetaEntry, WordpressBulkMetaEntryResultDto>( + entries, + 3, + async (entry) => { + try { + const page = await getPageBySlug(connection, entry.slug, site.env) + + if (strategy === 'manual') { + return { + slug: entry.slug, + status: 'manual', + manualAssist: buildManualMetaAssist( + site.siteUrl, + entry.slug, + page.link, + entry, + ), + } + } + + const writeTargets = resolveSeoWriteTargets(page.meta, plugins) + if (writeTargets.length === 0 || !page.meta) { + return { + slug: entry.slug, + status: 'manual', + manualAssist: buildManualMetaAssist( + site.siteUrl, + entry.slug, + page.link, + entry, + ), + } + } + + const patch: Record<string, unknown> = {} + for (const target of SEO_TARGETS) { + if (entry.title != null && writeTargets.includes(target.titleKey)) patch[target.titleKey] = entry.title + if (entry.description != null && writeTargets.includes(target.descriptionKey)) patch[target.descriptionKey] = entry.description + if (entry.noindex != null && writeTargets.includes(target.noindexKey)) { + patch[target.noindexKey] = encodeNoindexValue(target.noindexKey, entry.noindex) + } + } + + if (Object.keys(patch).length === 0) { + return { + slug: entry.slug, + status: 'manual', + manualAssist: buildManualMetaAssist( + site.siteUrl, + entry.slug, + page.link, + entry, + ), + } + } + + await fetchJson<WordpressRestPage>( + connection, + site.siteUrl, + `/wp-json/wp/v2/pages/${page.id}`, + { + method: 'POST', + body: JSON.stringify({ + meta: { ...(page.meta ?? {}), ...patch }, + }), + }, + ) + + return { slug: entry.slug, status: 'applied' } + } catch (error) { + if (error instanceof WordpressApiError && error.code === 'NOT_FOUND') { + return { slug: entry.slug, status: 'skipped', error: `Page "${entry.slug}" not found` } + } + return { + slug: entry.slug, + status: 'skipped', + error: error instanceof Error ? error.message : String(error), + } + } + }, + ) + + return { env: site.env, strategy, results } +} + +const CANONRY_SCHEMA_START = '<!-- canonry:schema:start -->' +const CANONRY_SCHEMA_END = '<!-- canonry:schema:end -->' + +export function stripCanonrySchema(content: string): string { + const regex = new RegExp( + `${escapeRegExp(CANONRY_SCHEMA_START)}[\\s\\S]*?${escapeRegExp(CANONRY_SCHEMA_END)}`, + 'g', + ) + return content.replace(regex, '').replace(/\n{3,}/g, '\n\n').trim() +} + +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function injectCanonrySchema(content: string, schemas: Record<string, unknown>[]): string { + if (schemas.length === 0) return content + const blocks = schemas + .map((schema) => `<script type="application/ld+json">${JSON.stringify(schema).replace(/<\//g, '<\\/')}</script>`) + .join('\n') + const injection = `\n\n${CANONRY_SCHEMA_START}\n${blocks}\n${CANONRY_SCHEMA_END}` + const stripped = stripCanonrySchema(content) + return stripped + injection +} + +async function verifySchemaInjection( + connection: WordpressConnectionRecord, + slug: string, + env: WordpressEnv, +): Promise<boolean> { + const page = await getPageBySlug(connection, slug, env) + const raw = page.content?.raw ?? page.content?.rendered ?? '' + return raw.includes(CANONRY_SCHEMA_START) +} + +export async function deploySchema( + connection: WordpressConnectionRecord, + slug: string, + schemas: Record<string, unknown>[], + env?: WordpressEnv, +): Promise<WordpressSchemaDeployEntryResultDto> { + const site = resolveEnvironment(connection, env) + try { + const page = await getPageBySlug(connection, slug, site.env) + const currentContent = page.content?.raw ?? page.content?.rendered ?? '' + const updatedContent = injectCanonrySchema(currentContent, schemas) + + await fetchJson<WordpressRestPage>( + connection, + site.siteUrl, + `/wp-json/wp/v2/pages/${page.id}`, + { + method: 'POST', + body: JSON.stringify({ content: updatedContent }), + }, + ) + + const persisted = await verifySchemaInjection(connection, slug, site.env) + if (!persisted) { + return { + slug, + status: 'stripped', + schemasInjected: schemas.map((s) => String(s['@type'] ?? 'Unknown')), + manualAssist: { + manualRequired: true, + targetUrl: page.link ?? `${site.siteUrl}/${slug}`, + adminUrl: `${site.siteUrl}/wp-admin/`, + content: schemas.map((s) => JSON.stringify(s, null, 2)).join('\n\n'), + nextSteps: [ + `WordPress stripped the schema <script> tags for page "${slug}". The connected user likely lacks the unfiltered_html capability.`, + 'Grant the user Administrator or Super Admin role, or add the schema manually in the page editor or via a schema plugin.', + 'Paste the JSON-LD blocks provided above.', + ], + }, + } + } + + return { + slug, + status: 'deployed', + schemasInjected: schemas.map((s) => String(s['@type'] ?? 'Unknown')), + } + } catch (error) { + if (error instanceof WordpressApiError && error.code === 'NOT_FOUND') { + return { slug, status: 'skipped', error: `Page "${slug}" not found` } + } + return { + slug, + status: 'failed', + error: error instanceof Error ? error.message : String(error), + } + } +} + +export async function deploySchemaFromProfile( + connection: WordpressConnectionRecord, + profile: SchemaProfileFile, + env?: WordpressEnv, +): Promise<WordpressSchemaDeployResultDto> { + const site = resolveEnvironment(connection, env) + + const slugEntries = Object.entries(profile.pages) + const results = await mapWithConcurrency< + [string, SchemaPageEntry[]], + WordpressSchemaDeployEntryResultDto + >( + slugEntries, + 3, + async ([slug, entries]) => { + const parsed = entries.map(parseSchemaPageEntry) + const unsupported = parsed.filter((p) => !isSupportedSchemaType(p.type)) + if (unsupported.length > 0) { + return { + slug, + status: 'failed', + error: `Unsupported schema type(s): ${unsupported.map((u) => u.type).join(', ')}`, + } + } + + const schemas = parsed.map((p) => generateSchema(p.type, profile.business, { faqs: p.faqs })) + return deploySchema(connection, slug, schemas, site.env) + }, + ) + + return { env: site.env, results } +} + +export async function getSchemaStatus( + connection: WordpressConnectionRecord, + env?: WordpressEnv, +): Promise<WordpressSchemaStatusResultDto> { + const site = resolveEnvironment(connection, env) + const pages = await listPages(connection, site.env) + const details = await mapWithConcurrency( + pages, + 5, + async (page) => getPageDetail(connection, page.slug, site.env), + ) + + const statusPages: WordpressSchemaStatusPageDto[] = details.map((page) => { + const rawContent = page.content + const hasCanonryMarker = rawContent.includes(CANONRY_SCHEMA_START) + + const allSchemaTypes = page.schemaBlocks.map((b) => b.type) + const canonrySchemas: string[] = [] + const thirdPartySchemas: string[] = [] + + if (hasCanonryMarker) { + const markerRegex = new RegExp( + `${escapeRegExp(CANONRY_SCHEMA_START)}([\\s\\S]*?)${escapeRegExp(CANONRY_SCHEMA_END)}`, + ) + const match = markerRegex.exec(rawContent) + if (match?.[1]) { + const jsonLdRegex = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi + let jsonMatch: RegExpExecArray | null + while ((jsonMatch = jsonLdRegex.exec(match[1])) !== null) { + try { + const parsed = JSON.parse(jsonMatch[1]!.trim()) as Record<string, unknown> + canonrySchemas.push(String(parsed['@type'] ?? 'Unknown')) + } catch { + // ignore + } + } + } + } + + // Count-based subtraction: each canonry schema type accounts for one + // occurrence in allSchemaTypes; remaining occurrences are third-party + const canonryCounts = new Map<string, number>() + for (const t of canonrySchemas) { + canonryCounts.set(t, (canonryCounts.get(t) ?? 0) + 1) + } + for (const schemaType of allSchemaTypes) { + const remaining = canonryCounts.get(schemaType) ?? 0 + if (remaining > 0) { + canonryCounts.set(schemaType, remaining - 1) + } else { + thirdPartySchemas.push(schemaType) + } + } + + return { + slug: page.slug, + title: page.title, + canonrySchemas, + thirdPartySchemas, + hasCanonrySchema: hasCanonryMarker, + } + }) + + return { env: site.env, pages: statusPages } +} + export async function getLlmsTxt( connection: WordpressConnectionRecord, env?: WordpressEnv, diff --git a/packages/integration-wordpress/test/wordpress-client.test.ts b/packages/integration-wordpress/test/wordpress-client.test.ts index ba0b68b..9259a45 100644 --- a/packages/integration-wordpress/test/wordpress-client.test.ts +++ b/packages/integration-wordpress/test/wordpress-client.test.ts @@ -6,8 +6,15 @@ import { getPageDetail, runAudit, setSeoMeta, + stripCanonrySchema, + injectCanonrySchema, verifyWordpressConnection, } from '../src/index.js' +import { + generateSchema, + isSupportedSchemaType, + supportedSchemaTypes, +} from '../src/schema-templates.js' function createConnection(overrides: Partial<WordpressConnectionRecord> = {}): WordpressConnectionRecord { return { @@ -286,6 +293,159 @@ describe('wordpress client', () => { expect(pageSummaryRequest).not.toContain('context=edit') }) + describe('stripCanonrySchema', () => { + it('removes only canonry-marked schema blocks', () => { + const content = [ + '<p>Hello</p>', + '<!-- canonry:schema:start -->', + '<script type="application/ld+json">{"@type":"Organization"}</script>', + '<!-- canonry:schema:end -->', + '<script type="application/ld+json">{"@type":"WebSite"}</script>', + ].join('\n') + + const result = stripCanonrySchema(content) + expect(result).not.toContain('canonry:schema:start') + expect(result).not.toContain('Organization') + expect(result).toContain('WebSite') + expect(result).toContain('<p>Hello</p>') + }) + + it('returns content unchanged when no canonry markers present', () => { + const content = '<p>Hello</p>\n<script type="application/ld+json">{"@type":"WebSite"}</script>' + expect(stripCanonrySchema(content)).toBe(content) + }) + + it('handles multiple canonry blocks', () => { + const content = [ + '<!-- canonry:schema:start -->', + '<script type="application/ld+json">{"@type":"A"}</script>', + '<!-- canonry:schema:end -->', + '<p>Middle</p>', + '<!-- canonry:schema:start -->', + '<script type="application/ld+json">{"@type":"B"}</script>', + '<!-- canonry:schema:end -->', + ].join('\n') + + const result = stripCanonrySchema(content) + expect(result).not.toContain('@type') + expect(result).toContain('<p>Middle</p>') + }) + }) + + describe('injectCanonrySchema', () => { + it('appends marked schema blocks to content', () => { + const content = '<p>Hello</p>' + const schemas = [{ '@context': 'https://schema.org', '@type': 'Organization', name: 'Test' }] + const result = injectCanonrySchema(content, schemas) + + expect(result).toContain('<!-- canonry:schema:start -->') + expect(result).toContain('<!-- canonry:schema:end -->') + expect(result).toContain('"@type":"Organization"') + expect(result).toContain('<p>Hello</p>') + }) + + it('escapes </script> sequences in schema values to prevent XSS', () => { + const schemas = [{ name: 'evil</script><script>alert(1)</script>' }] + const result = injectCanonrySchema('<p>Hello</p>', schemas) + expect(result).not.toContain('</script><script>') + expect(result).toContain('<\\/script>') + }) + + it('replaces existing canonry blocks before injecting', () => { + const content = [ + '<p>Hello</p>', + '<!-- canonry:schema:start -->', + '<script type="application/ld+json">{"@type":"OldSchema"}</script>', + '<!-- canonry:schema:end -->', + ].join('\n') + const schemas = [{ '@type': 'NewSchema' }] + const result = injectCanonrySchema(content, schemas) + + expect(result).not.toContain('OldSchema') + expect(result).toContain('NewSchema') + // Only one set of markers + expect(result.match(/canonry:schema:start/g)?.length).toBe(1) + }) + }) + + describe('schema templates', () => { + const profile = { + name: 'Test Co', + url: 'https://example.com', + phone: '+1-555-0100', + address: { + street: '123 Main St', + city: 'New York', + state: 'NY', + zip: '10001', + }, + } + + it('generates LocalBusiness schema', () => { + const schema = generateSchema('LocalBusiness', profile) + expect(schema['@context']).toBe('https://schema.org') + expect(schema['@type']).toBe('LocalBusiness') + expect(schema['name']).toBe('Test Co') + expect(schema['telephone']).toBe('+1-555-0100') + expect(schema['address']).toBeDefined() + }) + + it('generates Organization schema', () => { + const schema = generateSchema('Organization', profile) + expect(schema['@type']).toBe('Organization') + expect(schema['name']).toBe('Test Co') + expect(schema['url']).toBe('https://example.com') + }) + + it('generates FAQPage schema with faqs', () => { + const schema = generateSchema('FAQPage', profile, { + faqs: [{ q: 'What?', a: 'This.' }], + }) + expect(schema['@type']).toBe('FAQPage') + expect(schema['mainEntity']).toHaveLength(1) + const faq = (schema['mainEntity'] as Array<Record<string, unknown>>)[0] + expect(faq['@type']).toBe('Question') + expect(faq['name']).toBe('What?') + }) + + it('generates Service schema', () => { + const schema = generateSchema('Service', profile) + expect(schema['@type']).toBe('Service') + }) + + it('generates WebPage schema', () => { + const schema = generateSchema('WebPage', profile) + expect(schema['@type']).toBe('WebPage') + }) + + it('throws for unsupported schema types', () => { + expect(() => generateSchema('UnsupportedType', profile)).toThrow('Unsupported schema type') + }) + + it('isSupportedSchemaType returns true for valid types', () => { + expect(isSupportedSchemaType('LocalBusiness')).toBe(true) + expect(isSupportedSchemaType('Organization')).toBe(true) + expect(isSupportedSchemaType('Invalid')).toBe(false) + }) + + it('supportedSchemaTypes returns all types', () => { + const types = supportedSchemaTypes() + expect(types).toContain('LocalBusiness') + expect(types).toContain('Organization') + expect(types).toContain('FAQPage') + expect(types).toContain('Service') + expect(types).toContain('WebPage') + }) + + it('omits optional fields when not provided', () => { + const minimal = { name: 'Minimal Co' } + const schema = generateSchema('Organization', minimal) + expect(schema['name']).toBe('Minimal Co') + expect(schema['telephone']).toBeUndefined() + expect(schema['address']).toBeUndefined() + }) + }) + it('returns an actionable error message when auth fails on connect', async () => { globalThis.fetch = async (input: string | URL | Request) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url diff --git a/skills/canonry-setup/references/canonry-cli.md b/skills/canonry-setup/references/canonry-cli.md index ffebeeb..1e64ba7 100644 --- a/skills/canonry-setup/references/canonry-cli.md +++ b/skills/canonry-setup/references/canonry-cli.md @@ -195,6 +195,33 @@ canonry bing request-indexing <project> --all-unindexed # submit all unindexed canonry bing performance <project> # search performance data ``` +## WordPress Integration + +```bash +canonry wordpress connect <project> --url <url> --user <user> # connect (prompts for app password) +canonry wordpress disconnect <project> # disconnect +canonry wordpress status <project> # connection status +canonry wordpress pages <project> [--live|--staging] # list pages +canonry wordpress page <project> <slug> # show page detail +canonry wordpress create-page <project> --title <t> --slug <s> --content <c> # create page +canonry wordpress update-page <project> <slug> --content <c> # update page +canonry wordpress set-meta <project> <slug> --title <t> # set SEO meta (single page) +canonry wordpress set-meta <project> --from <file> # bulk set SEO meta from JSON +canonry wordpress schema <project> <slug> # read page JSON-LD +canonry wordpress schema deploy <project> --profile <file> # deploy schema from profile +canonry wordpress schema status <project> # schema status per page +canonry wordpress set-schema <project> <slug> # manual schema handoff +canonry wordpress audit <project> # audit pages for SEO issues +canonry wordpress diff <project> <slug> # compare live vs staging +canonry wordpress staging status <project> # staging config status +canonry wordpress staging push <project> # manual staging push handoff +canonry wordpress llms-txt <project> # read /llms.txt +canonry wordpress set-llms-txt <project> # manual llms.txt handoff +canonry wordpress onboard <project> --url <url> --user <user> # full onboarding workflow +``` + +**Onboard** runs: connect → audit → set-meta → schema deploy → Google submit → Bing submit. Use `--skip-schema` or `--skip-submit` to skip steps. `--profile <file>` provides business data and page-to-schema mapping for schema deployment. + ## Google Analytics 4 GA4 integration uses service account authentication (no OAuth). The service account must have Viewer access on the GA4 property.