Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion site/app/category/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import HeygrcCard from '@/components/HeygrcCard';
import ListPage from '@/components/ListPage';
import { categories, getCategory, skillsInCategory } from '@/lib/content';
import { categories, getCategory, skillsInCategory, HEYGRC_LINE, HEYGRC_LIST_PATHS } from '@/lib/content';
import { firstSentence } from '@/lib/text';
import { pageMeta } from '@/lib/seo';

Expand Down Expand Up @@ -35,6 +36,7 @@ export default async function CategoryPage({ params }: Props) {
crumbs={[{ name: 'Skills', href: '/skills/' }, { name: c.label, href: `/category/${c.slug}/` }]}
list={list}
path={`/category/${c.slug}/`}
after={HEYGRC_LIST_PATHS.includes(`/category/${c.slug}/`) ? <HeygrcCard body={HEYGRC_LINE} className="list-card" /> : undefined}
/>
);
}
4 changes: 3 additions & 1 deletion site/app/framework/[tag]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import HeygrcCard from '@/components/HeygrcCard';
import ListPage from '@/components/ListPage';
import { frameworks, getFramework, skillsWithFramework } from '@/lib/content';
import { frameworks, getFramework, skillsWithFramework, HEYGRC_LINE, HEYGRC_LIST_PATHS } from '@/lib/content';
import { firstSentence } from '@/lib/text';
import { pageMeta } from '@/lib/seo';

Expand Down Expand Up @@ -40,6 +41,7 @@ export default async function FrameworkPage({ params }: Props) {
crumbs={[{ name: 'Skills', href: '/skills/' }, { name: f.label, href: `/framework/${f.tag}/` }]}
list={list}
path={`/framework/${f.tag}/`}
after={HEYGRC_LIST_PATHS.includes(`/framework/${f.tag}/`) ? <HeygrcCard body={HEYGRC_LINE} className="list-card" /> : undefined}
/>
);
}
8 changes: 8 additions & 0 deletions site/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ a.tag:hover { background: #e2e7ee; }
.card p { margin: 0; }
.card .quiet { font-size: 13px; color: var(--muted-2); margin-top: 10px; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
/* heyGRC, from the same team: one small touch of its violet, nothing more. */
.heygrc { border-left: 4px solid #4D4CD7; }
.heygrc p { color: var(--ink-2); font-size: 15px; }
.heygrc .quiet { font-size: 13px; color: var(--muted-2); }
.list-card { max-width: 720px; margin-top: 40px; }
.heygrc-band { border-left: 4px solid #4D4CD7; padding-left: 28px; }
.heygrc-band p.two-jobs { margin-top: 12px; font-size: 15px; color: var(--muted-2); }

.picker .tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.tab { border: 1px solid var(--line); background: transparent; border-radius: 999px; padding: 6px 14px; font-size: 13.5px; color: var(--muted); }
Expand Down Expand Up @@ -268,6 +275,7 @@ a.tag:hover { background: #e2e7ee; }
.prose h2 { font-size: 23px; margin-top: 36px; }
.ft { margin-top: 88px; }
.ft .by { margin-left: 0; width: 100%; }
.heygrc-band { padding-left: 18px; }
.pills legend { float: none; width: 100%; margin-bottom: 2px; }
}

Expand Down
3 changes: 2 additions & 1 deletion site/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Metadata, Viewport } from 'next';
import { Inter_Tight, JetBrains_Mono } from 'next/font/google';
import Link from 'next/link';
import { Analytics } from '@vercel/analytics/next';
import { COPILOT_FOOTER_URL, meta, SITE_URL } from '@/lib/content';
import { COPILOT_FOOTER_URL, HEYGRC_URL, meta, SITE_URL } from '@/lib/content';
import { HOME_DESCRIPTION, HOME_TITLE, pageMeta } from '@/lib/seo';
import './globals.css';

Expand Down Expand Up @@ -70,6 +70,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<a href="/llms.txt">llms.txt</a>
</nav>
<a className="by" href={COPILOT_FOOTER_URL}>by ISMS Copilot</a>
<a className="also" href={HEYGRC_URL}>Also from us: heyGRC, compliance in your pull requests</a>
</div>
<p className="ft-note">
ISO, IEC, AICPA, SOC 2, PCI DSS, NIST and other framework names appear only as tags saying which framework a skill relates to; they belong to their owners. grcskills is not affiliated with, endorsed by or sponsored by any standards body or regulator, and the skills contain no text from the standards.
Expand Down
11 changes: 10 additions & 1 deletion site/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import CopyButton from '@/components/CopyButton';
import JsonLd from '@/components/JsonLd';
import SearchBox from '@/components/SearchBox';
import SkillCard from '@/components/SkillCard';
import { categories, featuredSkill, meta, SITE_URL, skills, toRow } from '@/lib/content';
import { categories, featuredSkill, HEYGRC_LINE, HEYGRC_URL, meta, SITE_URL, skills, toRow } from '@/lib/content';
import { HOME_DESCRIPTION, HOME_H1, HOME_SUBLINE, HOME_TITLE, pageMeta } from '@/lib/seo';

export const metadata: Metadata = pageMeta({
Expand Down Expand Up @@ -51,6 +51,15 @@ export default function Home() {
{featured && <SkillCard skill={featured} />}
</section>

<section className="section close heygrc-band" aria-labelledby="after">
<div>
<h2 id="after">After the skill: evidence on each pull request</h2>
<p>{HEYGRC_LINE}</p>
<p className="two-jobs">Also from us. ISMS Copilot answers framework questions while a skill runs. heyGRC reviews each pull request after it, against the frameworks you pick.</p>
</div>
<a className="b2" href={HEYGRC_URL}>heyGRC for GRC teams</a>
</section>

<section className="section find" aria-labelledby="find">
<div className="find-intro">
<h2 id="find">Find a skill</h2>
Expand Down
6 changes: 5 additions & 1 deletion site/app/skill/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import Breadcrumbs from '@/components/Breadcrumbs';
import HeygrcCard from '@/components/HeygrcCard';
import InstallPicker from '@/components/InstallPicker';
import JsonLd from '@/components/JsonLd';
import Row from '@/components/Row';
import { copilotUrl, frameworkLabel, getAuthor, getSkill, githubUrl, installCommands, meta, relatedTo, skills, toRow } from '@/lib/content';
import { copilotUrl, frameworkLabel, getAuthor, getSkill, githubUrl, HEYGRC_SKILLS, installCommands, meta, relatedTo, skills, toRow } from '@/lib/content';
import { skillLd } from '@/lib/ld';
import { pageMeta } from '@/lib/seo';

Expand Down Expand Up @@ -39,6 +40,7 @@ export default async function SkillPage({ params }: Props) {
const author = getAuthor(s.author);
const cmd = installCommands(s.slug);
const related = relatedTo(s);
const heygrc = Object.hasOwn(HEYGRC_SKILLS, s.slug) ? HEYGRC_SKILLS[s.slug] : undefined;
return (
<div className="page">
<Breadcrumbs items={[{ name: 'Skills', href: '/skills/' }, { name: s.categoryLabel, href: `/category/${s.category}/` }, { name: s.title, href: `/skill/${s.slug}/` }]} />
Expand Down Expand Up @@ -84,6 +86,8 @@ export default async function SkillPage({ params }: Props) {
<p className="quiet">ISMS Copilot answers framework questions for this skill. Optional; the skill works without it.</p>
</section>

{heygrc && <HeygrcCard body={heygrc} />}

{author && (
<section className="card" aria-labelledby="author">
<h2 id="author">Author</h2>
Expand Down
17 changes: 17 additions & 0 deletions site/components/HeygrcCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { HEYGRC_URL } from '@/lib/content';

// The heyGRC card: under the Install card on the skill pages in HEYGRC_SKILLS,
// and below the list on the pages in HEYGRC_LIST_PATHS. Text only: the CSP
// allows images from this origin alone.
export default function HeygrcCard({ body, className = '' }: { body: string; className?: string }) {
return (
<section className={`card heygrc ${className}`.trim()} aria-labelledby="heygrc">
<h2 id="heygrc">Evidence on each pull request</h2>
<p>{body}</p>
<div className="actions">
<a className="b2 b-sm" href={HEYGRC_URL}>heyGRC for GRC teams</a>
</div>
<p className="quiet">From the same team as grcskills. You pick the frameworks it checks. Optional; the skills work without it.</p>
</section>
);
}
4 changes: 3 additions & 1 deletion site/components/ListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import JsonLd from './JsonLd';
import Row from './Row';
import { SITE_URL, toRow, type Skill } from '@/lib/content';

export default function ListPage({ title, lead, intro, crumbs, list, path, listHeading }: { title: string; lead: React.ReactNode; intro?: string; crumbs: Crumb[]; list: Skill[]; path: string; listHeading?: string }) {
// `after` renders below the list (the heyGRC card on a few list pages).
export default function ListPage({ title, lead, intro, crumbs, list, path, listHeading, after }: { title: string; lead: React.ReactNode; intro?: string; crumbs: Crumb[]; list: Skill[]; path: string; listHeading?: string; after?: React.ReactNode }) {
return (
<div className="page">
<Breadcrumbs items={crumbs} />
Expand All @@ -18,6 +19,7 @@ export default function ListPage({ title, lead, intro, crumbs, list, path, listH
) : (
<p className="empty">No skills here yet.</p>
)}
{after}
<JsonLd
data={{
'@context': 'https://schema.org',
Expand Down
16 changes: 16 additions & 0 deletions site/lib/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ export const featuredSkill = () => getSkill(FEATURED_SLUG) ?? skills[0];

export const copilotUrl = (slug: string) => `https://www.ismscopilot.com/mcp?ref=grcskills-${slug}`;
export const COPILOT_FOOTER_URL = 'https://www.ismscopilot.com/?ref=grcskills-footer';

// heyGRC, from the same team: reviews each pull request against the frameworks
// the customer picks, after a skill has run. heyGRC measures by page path, so the link carries no query
// string (founder decisions X3 and X4, 2026-10-20).
export const HEYGRC_URL = 'https://heygrc.com/for/grc-teams';
// The only skill pages that show the heyGRC card: the ones whose output lives
// in pull requests. Any other skill page shows none.
export const HEYGRC_SKILLS: Record<string, string> = {
'secure-development-policy-from-pipeline': 'Once you sign this policy off, heyGRC reviews each pull request against the frameworks you pick and cites the control behind each finding.',
'change-management-evidence-sampling': 'Your sample shows how past changes went. heyGRC reviews each pull request as it opens and leaves its check and findings on it, so the next sample starts from records.',
'policy-gap-review-against-practice': 'Once policy and practice match, look at the next changes too. heyGRC reviews each pull request and gives you the evidence, with the control behind each finding cited.',
'grc-setup-review': 'Your review names what to fix. For the controls that live in code, heyGRC reviews each pull request and gives you the evidence.',
};
// List pages that show the same card, by path.
export const HEYGRC_LIST_PATHS = ['/category/evidence/', '/category/policies/', '/framework/cra/'];
export const HEYGRC_LINE = 'Your policy says every change is reviewed. heyGRC checks each pull request and gives you the evidence, with the control behind each finding cited.';
export const githubUrl = (repoPath: string) => `${meta.repoUrl}/blob/${meta.tag}/${repoPath}`;
export const githubFolderUrl = (repoPath: string) => `${meta.repoUrl}/tree/${meta.tag}/${repoPath.replace(/\/SKILL\.md$/, '')}`;
export const relatedTo = (s: Skill) => s.related.map(getSkill).filter((x): x is Skill => Boolean(x));
Expand Down
40 changes: 40 additions & 0 deletions site/tests/built.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,43 @@ test('vercel.json CSP: strict script-src, no unsafe-inline or unsafe-eval for sc
assert.equal(all.find((h) => h.key === 'X-Content-Type-Options').value, 'nosniff');
assert.equal(all.find((h) => h.key === 'Referrer-Policy').value, 'strict-origin-when-cross-origin');
});

// heyGRC cross-promotion (founder decisions X3 and X4, 2026-10-20).
const HEYGRC = 'https://heygrc.com/for/grc-teams';
const HEYGRC_SKILLS = ['secure-development-policy-from-pipeline', 'change-management-evidence-sampling', 'policy-gap-review-against-practice', 'grc-setup-review'];
const HEYGRC_LISTS = ['category/evidence/index.html', 'category/policies/index.html', 'framework/cra/index.html'];
const heygrcCard = (html) => /<section class="card heygrc[^"]*"/.test(html);

test('heyGRC: home band, footer on every page, card on exactly 4 skill pages and 3 list pages', opts, () => {
const home = read('index.html');
const band = home.match(/<section class="section close heygrc-band"[\s\S]*?<\/section>/)?.[0];
assert.ok(band, 'home band');
assert.ok(band.includes(`href="${HEYGRC}"`), 'home band links to heyGRC');
assert.ok(home.indexOf('id="back"') < home.indexOf('heygrc-band') && home.indexOf('heygrc-band') < home.indexOf('id="find"'), 'band sits after What comes back');
for (const rel of pages()) {
const ft = read(rel).match(/<footer class="ft">[\s\S]*?<\/footer>/)?.[0] ?? '';
assert.ok(ft.includes(`<a class="also" href="${HEYGRC}">Also from us: heyGRC`), `${rel}: footer link`);
assert.ok(ft.includes('>by ISMS Copilot</a>'), `${rel}: Copilot footer link unchanged`);
}
for (const s of content.skills) {
const html = read(`skill/${s.slug}/index.html`);
assert.equal(heygrcCard(html), HEYGRC_SKILLS.includes(s.slug), `${s.slug}: heyGRC card only on the listed skills`);
if (HEYGRC_SKILLS.includes(s.slug)) {
const aside = html.match(/<aside class="aside"[\s\S]*?<\/aside>/)?.[0] ?? '';
assert.ok(aside.indexOf('id="install"') < aside.indexOf('id="heygrc"'), `${s.slug}: card under the Install card`);
}
}
for (const rel of pages().filter((r) => r.startsWith('category/') || r.startsWith('framework/'))) {
assert.equal(heygrcCard(read(rel)), HEYGRC_LISTS.includes(rel), `${rel}: heyGRC card only on the listed pages`);
}
});

test('heyGRC links carry no query string or fragment, and no image loads from heygrc.com', opts, () => {
for (const f of htmlFiles()) {
const html = fs.readFileSync(f, 'utf8');
for (const m of html.matchAll(/(?:href|src)="(https?:\/\/[^"]*heygrc\.com[^"]*)"/g)) {
assert.equal(decode(m[1]), HEYGRC, `${path.relative(out, f)}: ${m[1]}`);
}
assert.doesNotMatch(html, /src="https?:\/\/[^"]*heygrc\.com/, `${path.relative(out, f)}: image from heygrc.com`);
}
});
Loading