diff --git a/.agents/skills/manage-case-studies/SKILL.md b/.agents/skills/manage-case-studies/SKILL.md new file mode 100644 index 0000000000..f73f054e5c --- /dev/null +++ b/.agents/skills/manage-case-studies/SKILL.md @@ -0,0 +1,135 @@ +--- +name: manage-case-studies +description: Safely add, edit, reorder, promote, or remove case studies on the Neon website. Use whenever a request changes a case-study company, including short requests that name only the company and ask to update its logo or link, change its category or order, replace a hero card, edit a testimonial, remove it, or update related /use-cases references. +--- + +# Manage Case Studies + +Work from the repository root. Preserve unrelated changes. Treat the logo grid, hero cards, and testimonials as separate surfaces. + +## 1. Inspect before changing + +Read only the files relevant to the requested surface: + +- Grid data: `content/data/case-studies.yaml` +- Category definitions: `content/data/case-study-categories.yaml` +- Data mapping: `src/utils/api-local-data.js` +- Grid behavior: `src/components/pages/case-studies/cards/cards.jsx` +- Hero cards: `src/components/pages/case-studies/hero/hero.jsx` +- Testimonials: `src/components/pages/case-studies/testimonials/testimonials.jsx` +- Cross-page references: `content/data/use-cases.yaml` +- Schema validation: `scripts/validate-content-data.js` and `scripts/validate-case-studies.js` +- Logos: `public/images/case-studies/` + +Use `rg` to find the case-study ID, title, blog slug, URL, and logo path across the repository before editing or deleting anything. Do not treat the independently hardcoded `/enterprise` case-studies block as part of this page. + +## 2. Gate required input + +Identify the operation and target surface. A plain “add a case study” request means add one grid card only. Promote it to the hero or testimonials only when explicitly requested. + +Before adding a grid card, require: + +- Company display title and a unique lowercase kebab-case ID. Derive the ID only when unambiguous. +- An actual `.svg` file supplied by the user. Do not invent, trace, download, or rasterize a logo unless explicitly asked. +- Exactly one destination type: + - Internal: an existing `content/blog/posts/.md` or `.mdx` file. + - External: a complete HTTPS URL. +- At least one category slug from `content/data/case-study-categories.yaml`. +- Requested placement. Prepend the entry when no placement is specified because YAML order is display order. + +Allow an empty quote and author for a grid-only card. Require a quote and author name for a testimonial or a case study referenced by `content/data/use-cases.yaml`. Treat the author role as optional unless the user supplies or explicitly requests it. Never invent customer quotes, attribution, roles, metrics, or destination URLs. Ask only for missing blocking data. + +Before replacing a logo, require a newly supplied `.svg` and inspect every reference to the current asset. Do not overwrite a shared logo unless that scope is explicit. + +For a hero card, additionally require the card position or card to replace, category label, display quote/title, author line, and destination. Keep two cards unless the user explicitly requests a layout change. Reuse the existing background assigned to that position unless a new supplied background is part of the request. + +For a testimonial, additionally require a unique numeric order and a logo that remains legible on the light testimonial background. Reuse the primary logo only when it is already suitable. Otherwise stop and require a separate user-supplied SVG; never recolor, edit, or synthesize an alternate logo. Do not change shared JSX, CSS, filters, or rendering to compensate for an unsuitable logo unless the user explicitly requests a design change. + +## 3. Apply the operation + +### Add a grid card + +Copy the supplied SVG without altering its artwork to `public/images/case-studies/.svg`. Record positive intrinsic dimensions that preserve its aspect ratio. + +Add this complete shape to `content/data/case-studies.yaml`: + +```yaml +- id: '' + title: '' + isFeatured: false + logo: + mediaItemUrl: '/images/case-studies/.svg' + mediaDetails: + width: + height: + quote: '' + author: + name: '' + post: '' + isInternal: true + internalPostSlug: '' + externalUrl: '' + categories: + - slug: '' + name: '' +``` + +For an external case study, set `isInternal: false`, clear `internalPostSlug`, and set `externalUrl`. Keep `isFeatured`; the repository validator requires it, but it does not control the current hero or grid layout. + +### Edit or reorder + +Keep the ID stable unless renaming it is explicitly required. When changing link type, populate the selected destination and clear the other one. When changing a category, copy both slug and name exactly from the category definition file. Move the entire YAML entry to reorder it. + +Search for duplicated hero copy and `/use-cases` references and update them only when they are in scope. The hero does not read the YAML automatically. + +### Add or edit a testimonial + +Add to the same YAML entry: + +```yaml + caseStudiesPage: + testimonialOrder: + testimonialLogo: + mediaItemUrl: '/images/case-studies/.svg' + mediaDetails: + width: + height: +``` + +Omit `testimonialLogo` to fall back to the primary logo. Add `testimonialLogo` only for an existing or user-supplied asset, never for an agent-created derivative. Keep an ordinary testimonial add, edit, or removal YAML-only. Testimonials are selected and sorted only by `testimonialOrder`; `isFeatured` has no effect. Restrict trusted inline quote markup to the patterns already used by the file, such as ``. + +To remove only a testimonial, remove its `caseStudiesPage` block and keep the grid entry. + +### Add or edit a hero card + +Edit `CARDS` in `src/components/pages/case-studies/hero/hero.jsx`. Supply `logo`, `category`, trusted `title` HTML, `author`, `linkText`, `linkUrl`, and the position’s background. Use `titleTheme: 'large'` only when the larger treatment fits. A hero edit does not require changing the YAML unless the grid card is also in scope. + +To remove only a hero card, replace or remove its `CARDS` entry as requested; do not alter the YAML implicitly. + +### Remove + +Search first for the ID, logo path, and destination. If `content/data/use-cases.yaml` references the ID, require a replacement case study or explicit removal of that use-case dependency. If the item appears in the hero, clarify whether the hero card must also be replaced; deleting the YAML entry does not remove it. + +Remove the YAML entry. This also removes its testimonial. Do not delete its blog post, external resource, logo, or `/enterprise` content unless explicitly requested. Delete an SVG only after proving with `rg` that no reference remains. + +### Change categories + +Treat category slugs as public URL fragments such as `/case-studies#ai`. Preserve slugs unless the user explicitly accepts broken or migrated inbound links. Update all case-study memberships before removing a category. Never add the synthetic `all` category to YAML. + +## 4. Validate and report + +Run from the repository root after every operation: + +```bash +node scripts/validate-case-studies.js +``` + +For an added or edited grid entry, also target the stricter checks: + +```bash +node scripts/validate-case-studies.js --id +``` + +The script includes the repository schema validator and adds asset, URL, blog-file, category-name, testimonial-order, and cross-page checks. Do not claim validation passed if dependencies are unavailable; report the blocker. + +If JSX changed, run ESLint on the changed component. Always run `git diff --check`, inspect the final diff, and confirm that no unrelated files changed. Report the surfaces changed, destination tested, validation commands, and any intentionally retained assets or content. diff --git a/.agents/skills/manage-case-studies/agents/openai.yaml b/.agents/skills/manage-case-studies/agents/openai.yaml new file mode 100644 index 0000000000..8ec4c4c905 --- /dev/null +++ b/.agents/skills/manage-case-studies/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: 'Manage Case Studies' + short_description: 'Safely add, edit, and remove case studies' + default_prompt: 'Use $manage-case-studies to add, edit, or remove a case study safely.' diff --git a/.claude/skills/manage-case-studies b/.claude/skills/manage-case-studies new file mode 120000 index 0000000000..1d158f7f2b --- /dev/null +++ b/.claude/skills/manage-case-studies @@ -0,0 +1 @@ +../../.agents/skills/manage-case-studies \ No newline at end of file diff --git a/scripts/validate-case-studies.js b/scripts/validate-case-studies.js new file mode 100755 index 0000000000..e2eeef5821 --- /dev/null +++ b/scripts/validate-case-studies.js @@ -0,0 +1,296 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +let yaml; +try { + yaml = require('js-yaml'); +} catch (_error) { + console.error( + 'Missing dependency "js-yaml". Install the repository dependencies before validation.' + ); + process.exit(2); +} + +const args = process.argv.slice(2); +let targetId = null; + +for (let index = 0; index < args.length; index += 1) { + if (args[index] === '--id' && args[index + 1]) { + targetId = args[index + 1]; + index += 1; + } else { + console.error(`Usage: ${path.basename(process.argv[1])} [--id ]`); + process.exit(2); + } +} + +const root = process.cwd(); +const requiredPaths = [ + 'content/data/case-studies.yaml', + 'content/data/case-study-categories.yaml', + 'content/data/use-cases.yaml', + 'scripts/validate-content-data.js', +]; + +const missingRootPaths = requiredPaths.filter( + (relativePath) => !fs.existsSync(path.join(root, relativePath)) +); + +if (missingRootPaths.length > 0) { + console.error('Run this validator from the neon-next repository root.'); + missingRootPaths.forEach((relativePath) => console.error(`- Missing ${relativePath}`)); + process.exit(2); +} + +const readYaml = (relativePath) => { + try { + return yaml.load(fs.readFileSync(path.join(root, relativePath), 'utf8')); + } catch (error) { + console.error(`Unable to read or parse ${relativePath}: ${error.message}`); + process.exit(1); + } +}; +const caseStudies = readYaml('content/data/case-studies.yaml'); +const categories = readYaml('content/data/case-study-categories.yaml'); +const useCases = readYaml('content/data/use-cases.yaml'); +const errors = []; + +const addError = (location, message) => errors.push(`${location}: ${message}`); +const isNonEmptyString = (value) => typeof value === 'string' && value.trim().length > 0; +const isPositiveNumber = (value) => Number.isFinite(value) && value > 0; +const kebabCasePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const absoluteHttpsUrlPattern = /^https:\/\/[^/?#]+(?:[/?#]|$)/i; +const blogPostsRoot = path.resolve(root, 'content/blog/posts'); + +const validateQuoteMarkup = (quote, location) => { + if (typeof quote !== 'string') return; + + const textWithoutAllowedMarkup = quote.replace(/<\/?mark>/g, ''); + if (/[<>]/.test(textWithoutAllowedMarkup)) { + addError(location, 'may only contain plain text and ... markup'); + return; + } + + let openMarks = 0; + for (const tag of quote.match(/<\/?mark>/g) || []) { + if (tag === '') { + openMarks += 1; + } else if (openMarks === 0) { + addError(location, 'contains an unmatched tag'); + return; + } else { + openMarks -= 1; + } + } + + if (openMarks > 0) { + addError(location, 'contains an unmatched tag'); + } +}; + +let schemaErrors; +try { + const { validateContentData } = require(path.join(root, 'scripts/validate-content-data.js')); + schemaErrors = validateContentData(); +} catch (error) { + console.error(`Repository schema validation crashed: ${error.message}`); + process.exit(2); +} + +schemaErrors.forEach((error) => addError('repository schema', error)); + +if (schemaErrors.length > 0) { + console.error('Case-study validation failed:\n'); + errors.forEach((error) => console.error(`- ${error}`)); + console.error(`\nTotal errors: ${errors.length}`); + process.exit(1); +} + +if (!Array.isArray(caseStudies) || !Array.isArray(categories) || !Array.isArray(useCases)) { + addError('content/data', 'expected case studies, categories, and use cases to be arrays'); +} else { + const categoryNames = new Map(categories.map((category) => [category.slug, category.name])); + const ids = new Map(caseStudies.map((item) => [item.id, item])); + const testimonialOrders = new Map(); + + const validateLogo = (logo, location) => { + const mediaPath = logo?.mediaItemUrl; + if (!isNonEmptyString(mediaPath)) return; + + if (!mediaPath.startsWith('/images/case-studies/') || !mediaPath.endsWith('.svg')) { + addError(location, 'logo must be an SVG under /images/case-studies/'); + return; + } + if (mediaPath.split('/').includes('..') || mediaPath.includes('\\')) { + addError(location, 'logo path must not contain traversal segments'); + return; + } + + const relativeAssetPath = path.posix.join('public', mediaPath); + const assetPath = path.resolve(root, relativeAssetPath); + const publicRoot = path.resolve(root, 'public'); + if (!assetPath.startsWith(`${publicRoot}${path.sep}`)) { + addError(location, 'logo path escapes public/'); + return; + } + if (!fs.existsSync(assetPath)) { + addError(location, `missing asset ${relativeAssetPath}`); + return; + } + const svg = fs.readFileSync(assetPath, 'utf8'); + if (!svg.includes(' 0.02) { + addError(location, 'configured dimensions must preserve the SVG viewBox aspect ratio'); + } + } + }; + + caseStudies.forEach((item, index) => { + const location = `case-studies[${index}] (${item.id || 'missing id'})`; + + if (isNonEmptyString(item.id) && !kebabCasePattern.test(item.id)) { + addError(`${location}.id`, 'must be lowercase kebab-case'); + } + + validateLogo(item.logo, `${location}.logo`); + validateQuoteMarkup(item.quote, `${location}.quote`); + + const memberships = new Set(); + (item.categories || []).forEach((category, categoryIndex) => { + const categoryLocation = `${location}.categories[${categoryIndex}]`; + if (memberships.has(category.slug)) { + addError(categoryLocation, `duplicate category slug "${category.slug}"`); + } + memberships.add(category.slug); + if (categoryNames.get(category.slug) !== category.name) { + addError(categoryLocation, 'category name must match its canonical definition'); + } + }); + + if (item.isInternal) { + if (isNonEmptyString(item.externalUrl)) { + addError(`${location}.externalUrl`, 'must be empty for an internal case study'); + } + if (isNonEmptyString(item.internalPostSlug)) { + if (!kebabCasePattern.test(item.internalPostSlug)) { + addError( + `${location}.internalPostSlug`, + 'must be lowercase kebab-case without path segments' + ); + } else { + const markdownPath = path.resolve(blogPostsRoot, `${item.internalPostSlug}.md`); + const mdxPath = path.resolve(blogPostsRoot, `${item.internalPostSlug}.mdx`); + const resolvesDirectlyUnderPosts = [markdownPath, mdxPath].every( + (candidatePath) => path.dirname(candidatePath) === blogPostsRoot + ); + + if (!resolvesDirectlyUnderPosts) { + addError( + `${location}.internalPostSlug`, + 'must resolve directly under content/blog/posts/' + ); + } else if (!fs.existsSync(markdownPath) && !fs.existsSync(mdxPath)) { + addError(`${location}.internalPostSlug`, 'does not resolve to an existing blog post'); + } + } + } + } else { + if (isNonEmptyString(item.internalPostSlug)) { + addError(`${location}.internalPostSlug`, 'must be empty for an external case study'); + } + if (isNonEmptyString(item.externalUrl)) { + try { + const parsed = new URL(item.externalUrl); + if ( + !absoluteHttpsUrlPattern.test(item.externalUrl) || + parsed.protocol !== 'https:' || + !parsed.hostname + ) { + addError(`${location}.externalUrl`, 'must be a complete HTTPS URL with a hostname'); + } + } catch (_error) { + addError(`${location}.externalUrl`, 'must be a valid URL'); + } + } + } + + const testimonialOrder = item.caseStudiesPage?.testimonialOrder; + if (item.caseStudiesPage !== undefined && !Number.isFinite(testimonialOrder)) { + addError(`${location}.caseStudiesPage.testimonialOrder`, 'must be a finite number'); + } + if (Number.isFinite(testimonialOrder)) { + if (testimonialOrders.has(testimonialOrder)) { + addError( + `${location}.caseStudiesPage.testimonialOrder`, + `duplicates ${testimonialOrders.get(testimonialOrder)}` + ); + } else { + testimonialOrders.set(testimonialOrder, item.id); + } + if (!isNonEmptyString(item.quote)) { + addError(`${location}.quote`, 'must be non-empty for a testimonial'); + } + if (!isNonEmptyString(item.author?.name)) { + addError(`${location}.author.name`, 'must be non-empty for a testimonial'); + } + if (item.caseStudiesPage.testimonialLogo) { + validateLogo( + item.caseStudiesPage.testimonialLogo, + `${location}.caseStudiesPage.testimonialLogo` + ); + } + } + }); + + useCases.forEach((useCase, index) => { + const linked = ids.get(useCase.linkedCaseStudy); + if (!linked) return; + if (!isNonEmptyString(linked.quote)) { + addError(`use-cases[${index}].linkedCaseStudy`, `${linked.id} must have a quote`); + } + if (!isNonEmptyString(linked.author?.name)) { + addError(`use-cases[${index}].linkedCaseStudy`, `${linked.id} must have an author name`); + } + }); + + if (targetId) { + const target = ids.get(targetId); + if (!target) { + addError('--id', `case study "${targetId}" was not found`); + } else if (!Array.isArray(target.categories) || target.categories.length === 0) { + addError(`case study "${targetId}"`, 'an added or edited case study needs a category'); + } + } +} + +if (errors.length > 0) { + console.error('Case-study validation failed:\n'); + errors.forEach((error) => console.error(`- ${error}`)); + console.error(`\nTotal errors: ${errors.length}`); + process.exit(1); +} + +console.log( + targetId + ? `Case-study validation passed for "${targetId}" and all shared data.` + : 'Case-study validation passed for all shared data.' +);