diff --git a/gatsby-config.js b/gatsby-config.js index 8a5f792..4753281 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -86,6 +86,11 @@ module.exports = { protocol: 'https', hostname: process.env.HOST_NAME, generateRedirectObjectsForPermanentRedirects: true, + params: { + '**/*.md': { + ContentType: 'text/markdown; charset=utf-8', + }, + }, }, }, { diff --git a/gatsby-node.js b/gatsby-node.js index 9e834dd..fd91dfc 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -85,16 +85,231 @@ exports.createPages = async ({ graphql, actions }) => { }); }; -exports.onPostBuild = () => { - const sourcePath = path.join(__dirname, 'src/pages/docs/sitemap.xml'); - const destinationPath = path.join(__dirname, 'public/docs/sitemap.xml'); +// --------------------------------------------------------------------------- +// LLM-friendly outputs: per-page .md companions + llms.txt index. +// Each source .md under src/pages/docs/test-management/ gets a sibling +// .md written to public/ alongside the rendered HTML. Plus an +// llms.txt index and a hand-authored .md for the /docs/test-management/ +// home page (which is rendered from index.jsx and therefore has no source +// .md for the glob to pick up). +// --------------------------------------------------------------------------- - if (fs.existsSync(sourcePath)) { - fs.copyFileSync(sourcePath, destinationPath); +const SITE_URL = 'https://testsigma.com'; +const TM_SRC_ROOT = path.join(__dirname, 'src/pages/docs/test-management'); +const TM_URL_PREFIX = '/docs/test-management'; + +const transformInfoBlocks = (body) => + body.replace( + /\[\[info\s*\|\s*([^\]]+)\]\]\n((?:\|[^\n]*\n?)+)/g, + (_, label, lines) => { + const cleaned = lines + .replace(/\n+$/, '') + .split('\n') + .map((line) => '> ' + line.replace(/^\|\s?/, '')) + .join('\n'); + return `> ${label.trim()}\n${cleaned}\n`; + } + ); + +const transformStorylaneEmbeds = (body) => + body.replace( + /]*>\s*]*storylane[^>]*>[\s\S]*?<\/script>\s*]*class="sl-embed"[^>]*>[\s\S]*?]*src="([^"]+)"[\s\S]*?<\/iframe>\s*<\/div>\s*<\/div>/g, + '> [Interactive demo: open in browser]($1)\n' + ); + +const isTableLine = (line) => + /^\s*\|.*\|\s*$/.test(line) || /^\s*\|[\s\-:|]+$/.test(line); + +const transformBrOutsideTables = (body) => { + const lines = body.split('\n'); + const out = []; + let buffer = []; + let inTable = false; + + const flush = () => { + if (buffer.length === 0) return; + const segment = buffer.join('\n'); + out.push(inTable ? segment : segment.replace(//gi, '\n\n')); + buffer = []; + }; + + for (const line of lines) { + const lineInTable = isTableLine(line); + if (lineInTable !== inTable) { + flush(); + inTable = lineInTable; + } + buffer.push(line); + } + flush(); + return out.join('\n'); +}; + +const transformAnchorsToMarkdown = (body) => + body.replace(/]*>([^<]+)<\/a>/g, '[$2]($1)'); + +const stripScripts = (body) => + body.replace(/]*>[\s\S]*?<\/script>/g, ''); + +const transformBody = (raw) => { + let body = raw; + body = transformInfoBlocks(body); + body = transformStorylaneEmbeds(body); + body = transformBrOutsideTables(body); + body = transformAnchorsToMarkdown(body); + body = stripScripts(body); + return body; +}; + +const deriveSlug = (srcPath) => { + let slug = srcPath.replace(path.join(__dirname, 'src/pages'), ''); + slug = slug.replace(/\.md$/, ''); + if (slug.endsWith('/index')) slug = slug.slice(0, -'/index'.length); + return slug + '/'; +}; + +const yamlEscape = (s) => `'${String(s).replace(/'/g, "''")}'`; + +const buildMinimalFrontmatter = (data, slug) => { + const fields = { + title: data.title, + metadesc: data.metadesc, + canonical: data.canonical || `${SITE_URL}${slug}`, + }; + if (data.page_id) fields.page_id = data.page_id; + + const yaml = Object.entries(fields) + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => `${k}: ${yamlEscape(v)}`) + .join('\n'); + return `---\n${yaml}\n---\n`; +}; + +const titleCase = (slug) => + slug + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + +const writeLlmsTxt = (entries, outPath) => { + const groups = {}; + for (const e of entries) { + if (!groups[e.topSection]) groups[e.topSection] = []; + groups[e.topSection].push(e); + } + for (const k of Object.keys(groups)) { + groups[k].sort((a, b) => a.order - b.order); + } + const sortedGroupNames = Object.keys(groups).sort( + (a, b) => groups[a][0].order - groups[b][0].order + ); + + const lines = [ + '# Test Management by Testsigma', + '', + '> Documentation for Test Management by Testsigma — test cases, test plans, test runs, reports, and AI-powered automation with Atto.', + '', + ]; + for (const name of sortedGroupNames) { + lines.push(`## ${titleCase(name)}`); + for (const e of groups[name]) { + const url = `${SITE_URL}${e.slug.replace(/\/$/, '')}.md`; + const desc = e.metadesc ? `: ${e.metadesc}` : ''; + lines.push(`- [${e.title}](${url})${desc}`); + } + lines.push(''); + } + + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, lines.join('\n'), 'utf8'); +}; + +const TM_HOME_MD = `--- +title: 'Test Management by Testsigma' +canonical: 'https://testsigma.com/docs/test-management/' +--- +# Test Management by Testsigma — Documentation + +The complete documentation for Test Management by Testsigma — test cases, test plans, test runs, reports, and AI-powered automation with Atto. + +See [llms.txt](https://testsigma.com/docs/test-management/llms.txt) for the full document index, or browse top sections: + +- [Introduction & Overview](https://testsigma.com/docs/test-management/introduction/overview.md) +- [Atto AI Overview](https://testsigma.com/docs/test-management/atto/overview.md) +- [Test Cases](https://testsigma.com/docs/test-management/test-cases/manage-test-cases.md) +- [Test Plans](https://testsigma.com/docs/test-management/test-plans/manage-test-plans.md) +- [Test Runs](https://testsigma.com/docs/test-management/test-runs/manage-test-runs.md) +- [Reports](https://testsigma.com/docs/test-management/reports/view-reports.md) +- [Settings](https://testsigma.com/docs/test-management/settings/manage-properties.md) +- [Manage Users](https://testsigma.com/docs/test-management/manage-users/invite-users.md) +- [Integrations](https://testsigma.com/docs/test-management/integrations/jira.md) +- [API Reference](https://testsigma.com/docs/test-management/api-reference/overview.md) +- [CI/CD Integrations](https://testsigma.com/docs/test-management/ci-cd-integrations/jenkins.md) +`; + +exports.onPostBuild = async () => { + // Existing behavior: copy sitemap.xml to public/. + const sitemapSrc = path.join(__dirname, 'src/pages/docs/sitemap.xml'); + const sitemapDst = path.join(__dirname, 'public/docs/sitemap.xml'); + if (fs.existsSync(sitemapSrc)) { + fs.copyFileSync(sitemapSrc, sitemapDst); console.log('Sitemap.xml copied to public folder!'); } else { console.error('Sitemap.xml not found in src/pages/docs/'); } + + // Walk test-management markdown sources and emit clean .md companions. + const sources = glob.sync(`${TM_SRC_ROOT}/**/*.md`); + const indexEntries = []; + + await Promise.all( + sources.map(async (srcPath) => { + const raw = fs.readFileSync(srcPath, 'utf8'); + const { data, content } = frontmatter(raw); + if (data.noindex === true) return; + + const slug = deriveSlug(srcPath); + const outPath = path.join( + __dirname, + 'public', + slug.replace(/\/$/, '') + '.md' + ); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + + const transformed = transformBody(content); + const header = buildMinimalFrontmatter(data, slug); + fs.writeFileSync( + outPath, + `${header}\n# ${data.title}\n\n${transformed}`, + 'utf8' + ); + + const parts = slug.split('/').filter(Boolean); + indexEntries.push({ + slug, + title: data.title, + metadesc: data.metadesc || '', + topSection: parts[2] || 'misc', + order: typeof data.order === 'number' ? data.order : 999, + }); + }) + ); + + // Hand-authored .md for /docs/test-management/ (the home is rendered from + // index.jsx, so there's no source .md to walk). + const homeMdPath = path.join(__dirname, 'public', `${TM_URL_PREFIX}.md`); + fs.mkdirSync(path.dirname(homeMdPath), { recursive: true }); + fs.writeFileSync(homeMdPath, TM_HOME_MD, 'utf8'); + + // llms.txt index over all emitted pages. + writeLlmsTxt( + indexEntries, + path.join(__dirname, 'public', TM_URL_PREFIX, 'llms.txt') + ); + + console.log( + `Emitted ${indexEntries.length} .md companions + llms.txt + test-management.md` + ); }; /* Create Header and Footer diff --git a/src/components/CopyPageMenu.jsx b/src/components/CopyPageMenu.jsx new file mode 100644 index 0000000..44fa0b9 --- /dev/null +++ b/src/components/CopyPageMenu.jsx @@ -0,0 +1,259 @@ +import React, { useState } from 'react'; +import OnClickOut from 'react-onclickout'; +import './CopyPageMenu.scss'; + +const IconCopy = () => ( + +); + +const IconDocument = () => ( + +); + +const IconChatBubble = () => ( + +); + +const IconSparkle = () => ( + +); + +const IconSearch = () => ( + +); + +const IconExternal = () => ( + +); + +const CopyPageMenu = ({ slug }) => { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + + const safeSlug = typeof slug === 'string' ? slug : ''; + const origin = + typeof window !== 'undefined' + ? window.location.origin + : 'https://testsigma.com'; + const mdUrl = `${origin}${safeSlug.replace(/\/$/, '')}.md`; + const prompt = `Read ${mdUrl} and help me with: `; + const close = () => setOpen(false); + + const copyMarkdown = async (e) => { + e.preventDefault(); + try { + const res = await fetch(mdUrl); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const text = await res.text(); + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch (err) { + // eslint-disable-next-line no-console + console.error('CopyPageMenu: failed to copy markdown', err); + } + }; + + const openExternal = (url) => (e) => { + e.preventDefault(); + window.open(url, '_blank', 'noopener,noreferrer'); + close(); + }; + + const chatgptUrl = `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`; + const claudeUrl = `https://claude.ai/new?q=${encodeURIComponent(prompt)}`; + const perplexityUrl = `https://www.perplexity.ai/?q=${encodeURIComponent( + prompt + )}`; + + return ( + + + + ); +}; + +export default CopyPageMenu; diff --git a/src/components/CopyPageMenu.scss b/src/components/CopyPageMenu.scss new file mode 100644 index 0000000..15a6ac1 --- /dev/null +++ b/src/components/CopyPageMenu.scss @@ -0,0 +1,176 @@ +.copy-page-menu { + position: relative; + display: inline-block; + font-family: inherit; +} + +.doc-page-actions { + padding-top: 8px; +} + +// ----------------------------------------------------------------------------- +// Trigger button +// ----------------------------------------------------------------------------- +.copy-page-menu-trigger { + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + height: auto; + padding: 7px 12px; + margin: 0; + background: #fff; + border: 1px solid #e2e5ea; + border-radius: 6px; + color: #1f2937; + font-size: 14px; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; + transition: border-color 0.15s ease, background 0.15s ease, + box-shadow 0.15s ease; + + &:hover { + border-color: #c7cdd6; + background: #f8fafc; + } + + &:focus, + &:focus-visible { + outline: none; + border-color: #94a3b8; + box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.18); + } + + .copy-icon, + .caret-down { + display: inline-flex; + align-items: center; + line-height: 1; + color: #4b5563; + + svg { + display: block; + } + } + + .trigger-label { + line-height: 1.2; + } +} + +// ----------------------------------------------------------------------------- +// Dropdown panel +// Aggressive overrides because `.doc-page` ancestor sets ul/li/a styles that +// would otherwise bleed in (bullets, 40px margin, green link color, fixed +// 24px anchor height, !important padding on li). +// ----------------------------------------------------------------------------- +.copy-page-menu-list { + position: absolute; + top: calc(100% + 6px); + right: 0; + left: auto; + z-index: 50; + width: 300px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14), + 0 2px 6px rgba(15, 23, 42, 0.06); + list-style: none !important; + margin: 0 !important; + padding: 6px !important; + + li { + list-style: none !important; + margin: 0 !important; + padding: 0 !important; + line-height: 1.2; + + &::marker, + &::before, + &::after { + content: none !important; + display: none !important; + } + } + + a { + display: flex !important; + align-items: center; + gap: 12px; + height: auto !important; + padding: 9px 10px !important; + margin: 0 !important; + color: #111827 !important; + font-weight: 500 !important; + text-decoration: none !important; + border-radius: 6px; + transition: background-color 0.1s ease; + + &:hover, + &:focus { + background: #f3f4f6 !important; + color: #111827 !important; + text-decoration: none !important; + } + + .menu-icon { + flex: 0 0 16px; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #4b5563; + + svg { + width: 16px; + height: 16px; + display: block; + } + } + + .item-text { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-width: 0; + gap: 2px; + } + + .item-label { + font-size: 13px !important; + font-weight: 600 !important; + color: #111827 !important; + line-height: 1.25 !important; + letter-spacing: -0.01em; + } + + .item-sub { + font-size: 11.5px !important; + font-weight: 400 !important; + color: #6b7280 !important; + line-height: 1.3 !important; + letter-spacing: 0; + } + + .external-icon { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + color: #9ca3af; + opacity: 0; + transition: opacity 0.1s ease; + + svg { + display: block; + } + } + + &:hover .external-icon, + &:focus .external-icon { + opacity: 1; + } + } +} diff --git a/src/components/seo.jsx b/src/components/seo.jsx index bace280..1412e1f 100644 --- a/src/components/seo.jsx +++ b/src/components/seo.jsx @@ -201,6 +201,12 @@ function SEO({ ) : ( )} + + {/* Algolia Instantsearch IE11 support v3 */} {/*