From 9f7f1ee953d5a4267f2969f7585105a2fc6a7ed7 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Wed, 22 Jul 2026 16:12:01 -0500 Subject: [PATCH] chore(docs): whats new page and skill Add new summarize-releases skill for generating monthly release summaries and implement related scripts and tests. Enhance site configuration to support updates feed and improve metadata handling for recent updates. Signed-off-by: Cory Rylan --- .agents/skills/summarize-releases/SKILL.md | 152 +++++ .../scripts/collect-releases.js | 623 ++++++++++++++++++ pnpm-lock.yaml | 45 +- .../internals/tools/src/api/utils.test.ts | 6 +- projects/internals/tools/src/api/utils.ts | 5 +- projects/site/eleventy.config.js | 2 + projects/site/package.json | 1 + projects/site/src/_11ty/layouts/common.js | 14 +- projects/site/src/_11ty/layouts/docs.css | 1 + projects/site/src/_11ty/layouts/metadata.js | 25 +- .../site/src/_11ty/layouts/metadata.test.ts | 39 ++ projects/site/src/_11ty/layouts/page.11ty.js | 4 +- .../site/src/_11ty/layouts/whats-new.11ty.js | 99 +++ .../site/src/_11ty/layouts/whats-new.test.ts | 101 +++ .../site/src/_11ty/plugins/llms-txt.test.ts | 1 + .../site/src/_11ty/plugins/sitemap-xml.js | 53 +- .../src/_11ty/plugins/sitemap-xml.test.ts | 21 +- .../site/src/_11ty/plugins/updates-feed.js | 156 +++++ .../src/_11ty/plugins/updates-feed.test.ts | 137 ++++ .../site/src/_11ty/transforms/html-minify.js | 12 +- .../src/_11ty/transforms/html-minify.test.ts | 25 + .../site/src/_11ty/utils/content-dates.js | 22 + .../src/_11ty/utils/content-dates.test.ts | 18 + projects/site/src/docs/whats-new/04-2026.md | 27 + projects/site/src/docs/whats-new/05-2026.md | 35 + projects/site/src/docs/whats-new/06-2026.md | 34 + .../site/src/docs/whats-new/index.11ty.js | 65 ++ projects/site/src/examples/index.test.ts | 1 + 28 files changed, 1652 insertions(+), 72 deletions(-) create mode 100644 .agents/skills/summarize-releases/SKILL.md create mode 100644 .agents/skills/summarize-releases/scripts/collect-releases.js create mode 100644 projects/site/src/_11ty/layouts/whats-new.11ty.js create mode 100644 projects/site/src/_11ty/layouts/whats-new.test.ts create mode 100644 projects/site/src/_11ty/plugins/updates-feed.js create mode 100644 projects/site/src/_11ty/plugins/updates-feed.test.ts create mode 100644 projects/site/src/_11ty/transforms/html-minify.test.ts create mode 100644 projects/site/src/_11ty/utils/content-dates.js create mode 100644 projects/site/src/_11ty/utils/content-dates.test.ts create mode 100644 projects/site/src/docs/whats-new/04-2026.md create mode 100644 projects/site/src/docs/whats-new/05-2026.md create mode 100644 projects/site/src/docs/whats-new/06-2026.md create mode 100644 projects/site/src/docs/whats-new/index.11ty.js diff --git a/.agents/skills/summarize-releases/SKILL.md b/.agents/skills/summarize-releases/SKILL.md new file mode 100644 index 0000000000..b8b93a4d1b --- /dev/null +++ b/.agents/skills/summarize-releases/SKILL.md @@ -0,0 +1,152 @@ +--- +name: summarize-releases +description: Create a monthly NVIDIA Elements “What’s New” docs page and a concise, copy-ready summary from local release tags, tagged changelogs, package versions, and commit history. Use for a scheduled release PR, monthly update, release roundup, changelog digest, Slack blurb, announcement, or plain-language explanation of recent Elements releases. +--- + +# Summarize Releases + +Turn recent NVIDIA Elements releases into a dated docs page that explains what changed and why users should care. Leave the repository with a focused, validated change that a scheduled agent can propose as a pull request. + +## Collect the evidence + +Run the collector from the repository root. Pass the requested calendar month; otherwise the collector uses the previous calendar month. + +```shell +mise exec -- node .agents/skills/summarize-releases/scripts/collect-releases.js +``` + +The script is the source of truth for: + +- the docs page path, URL, title, covered month, publication dates, layout, and collection tags +- release tags and their creation dates +- package names and versions from each tag +- tagged `package.json` version checks +- the matching section from each tagged `CHANGELOG.md` +- repeated-commit merging across package changelogs +- conventional commit metadata, messages, and changed-file statistics + +The collector only reads the local Git clone. It does not call the GitHub API, query npm, or require a token. + +Useful options: + +```shell +mise exec -- node .agents/skills/summarize-releases/scripts/collect-releases.js --month 2026-07 +mise exec -- node .agents/skills/summarize-releases/scripts/collect-releases.js --month 2026-07 --json +``` + +Use `--include-prereleases` only when the user wants preview releases. + +For scheduled monthly runs, omit `--month` to collect the previous calendar month. Pass `--month YYYY-MM` for historical or explicit reruns. The collector treats the current month as month-to-date and rejects future months. + +## Check repository freshness + +The collector can only report tags available in the clone. When the user requests the latest releases and the clone might be stale or shallow, fetch the public tags before collecting: + +```shell +git fetch --tags origin +``` + +If fetching is unavailable, state that the result covers only the local tags. Do not claim that the report includes the latest releases. + +## Investigate the changes + +Read the complete evidence packet before drafting. Treat changelog text as a lead, not a complete plain-language explanation. + +For each potentially user-facing `feat`, `fix`, or `perf` change: + +1. Read its full commit message and changed-file list from the packet. +2. Inspect an ambiguous or important diff with `git show --stat --patch --`. +3. Check public APIs, component behavior, examples, documentation, and tests to determine the user-visible effect. +4. State only claims supported by that evidence. + +Changelogs in this monorepo can repeat the same commit across package releases and can mention changes outside the named package. Summarize each unique change once. Do not attribute a change to a package solely because it appears in that package’s changelog; use its scope and changed files. + +Give less attention to release automation, CI, dependency bumps, refactors, generated metadata, and test-only work unless it changes installation, compatibility, documentation, performance, or another user workflow. Call out breaking changes and required migration steps first. + +## Write the docs page + +If the packet contains releases, create the file at `page.filePath` from the packet. The reporting period determines the file path and `updateMonth`; the collector run date supplies the initial publication and modification dates. Do not choose other values. + +Before writing, check whether the target already exists. Stop and report the existing page instead of overwriting it unless the user explicitly asks to revise that month. A scheduled rerun must not create a duplicate monthly page. + +Use this structure: + +```markdown +--- +{ + title: '', + description: '', + layout: '', + tags: , + date: '', + datePublished: '', + dateModified: '', + updateMonth: '' +} +--- + + + +## Highlights + +- **.** +- **.** + +## Released packages + +- `` +``` + +Apply these rules: + +- Write for Elements users, not repository maintainers. +- Explain outcomes and benefits instead of repeating conventional commit subjects. +- Include two to five highlights and keep the introductory summary concise. +- Use descriptive link text. Link a new or materially changed component to its docs page when one exists. +- Include each released package once with exact versions. Use a range only when the reporting period includes every version in that range. +- Let the shared What’s New layout render the page title, RSS subscription link, and release-links footer. Do not duplicate them in the Markdown content. +- Keep `datePublished` fixed after publication. Advance `dateModified` only when genuinely revising the page content. +- Omit raw commit hashes, internal implementation details, release automation, and empty sections. +- Keep the page useful on its own. Do not mention the scheduled agent, evidence packet, or pull request. +- Do not edit the What’s New index for each run. Its collection lists tagged pages automatically. + +If the packet contains no release tags, do not create a page or propose an empty release pull request. Report that the local clone contains no NVIDIA Elements releases for the period. Do not substitute unreleased commits from `main` unless the user explicitly asks for a preview. + +## Prepare the copy-ready blurb + +After writing the page, return one self-contained block that users can paste into Slack or another channel. Base it on the page, and link its title to `page.url` when you know the deployed site URL. Do not put it in a code fence. + +Use this shape: + +```text +✨ What’s new in NVIDIA Elements — + + + +- +- + +Released: +Release notes: +``` + +Keep the blurb roughly 80–180 words unless the user requests another length. Do not repeat detailed research notes unless the user asks for them. + +## Check the page + +Run these checks from the repository root, replacing the placeholder with `page.filePath`: + +```shell +mise exec -- vale +mise exec -- pnpm exec prettier --check +mise exec -- pnpm --dir projects/site run build +git diff --check +``` + +Inspect the built page and the What’s New index when practical. Keep the diff limited to the new monthly page unless another file must change for the page to build correctly. + +When the task explicitly requests a pull request, follow the host’s authorized Git publishing workflow only after validation. Otherwise, leave the validated file ready for review and report its path. + +## Handle incomplete evidence + +The collector skips tags whose package version does not match the tag name and reports them as warnings. Investigate skipped tags before drafting, and do not include them unless local evidence proves they represent a package release. Treat missing changelog sections as evidence errors. Use the tag message only when the script marks it as the notes source, and disclose the fallback. If a changelog references a commit that the clone does not contain, fetch more history or disclose the missing evidence. Never invent a summary from an incomplete release title. diff --git a/.agents/skills/summarize-releases/scripts/collect-releases.js b/.agents/skills/summarize-releases/scripts/collect-releases.js new file mode 100644 index 0000000000..1969c70e6b --- /dev/null +++ b/.agents/skills/summarize-releases/scripts/collect-releases.js @@ -0,0 +1,623 @@ +#!/usr/bin/env node + +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const REPOSITORY = 'NVIDIA/elements'; +const REPOSITORY_URL = `https://github.com/${REPOSITORY}`; +const MAX_MARKDOWN_FILES = 12; +const RELEASE_TAG_PATTERN = /^(@nvidia-elements\/(.+))-v(.+)$/; +const RELEASE_COMMIT_PATTERN = /^chore\(release\):/i; +const CONVENTIONAL_COMMIT_PATTERN = + /^(feat|fix|perf|docs|refactor|chore|build|ci|test|style|revert)(?:\(([^)]+)\))?(!)?:\s*(.+)$/i; + +function compactError(error) { + return (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').trim(); +} + +function readOptionValue(argv, index, name) { + const value = argv[index + 1]; + + if (!value || value.startsWith('--')) { + throw new Error(`${name} requires a value.`); + } + + return value; +} + +function parseArgs(argv) { + const options = { + includePrereleases: false, + json: false, + repoDir: process.cwd() + }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + switch (argument) { + case '--help': + case '-h': + options.help = true; + break; + case '--include-prereleases': + options.includePrereleases = true; + break; + case '--json': + options.json = true; + break; + case '--month': + options.month = readOptionValue(argv, index, '--month'); + index += 1; + break; + case '--repo-dir': + options.repoDir = readOptionValue(argv, index, '--repo-dir'); + index += 1; + break; + default: + throw new Error(`Unknown option: ${argument}`); + } + } + + return options; +} + +function parseMonth(value) { + const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(value); + + if (!match) { + throw new Error(`Invalid month: ${value}. Use YYYY-MM.`); + } + + return { monthIndex: Number(match[2]) - 1, year: Number(match[1]) }; +} + +function getPeriod(options, now = new Date()) { + const reference = new Date(now); + + if (Number.isNaN(reference.getTime())) { + throw new Error(`Invalid current date: ${now}`); + } + + const defaultMonth = new Date(Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth() - 1, 1)); + const selection = + options.month ?? `${defaultMonth.getUTCFullYear()}-${String(defaultMonth.getUTCMonth() + 1).padStart(2, '0')}`; + const { monthIndex, year } = parseMonth(selection); + const since = new Date(Date.UTC(year, monthIndex, 1)); + const monthEnd = new Date(Date.UTC(year, monthIndex + 1, 1) - 1); + + if (since > reference) { + throw new Error('--month must not be in the future.'); + } + + const until = reference < monthEnd ? reference : monthEnd; + return { since: since.toISOString(), until: until.toISOString() }; +} + +function formatUtcDate(date, options) { + return new Intl.DateTimeFormat('en-US', { ...options, timeZone: 'UTC' }).format(date); +} + +function formatMonth(period) { + const since = new Date(period.since); + return formatUtcDate(since, { month: 'long', year: 'numeric' }); +} + +function getPageMetadata(period, now = new Date()) { + const since = new Date(period.since); + const publicationDate = new Date(now); + + if (Number.isNaN(publicationDate.getTime())) { + throw new Error(`Invalid publication date: ${now}`); + } + + const month = String(since.getUTCMonth() + 1).padStart(2, '0'); + const year = since.getUTCFullYear(); + const slug = `${month}-${year}`; + const date = publicationDate.toISOString().slice(0, 10); + + return { + date, + dateModified: date, + datePublished: date, + filePath: `projects/site/src/docs/whats-new/${slug}.md`, + layout: 'whats-new.11ty.js', + tags: ['whats-new', 'updates'], + title: `What’s new in NVIDIA Elements: ${formatMonth(period)}`, + updateMonth: since.toISOString().slice(0, 10), + url: `/docs/whats-new/${slug}/` + }; +} + +function parseElementsTag(tagName) { + const match = tagName.match(RELEASE_TAG_PATTERN); + + return match + ? { + packageName: match[1], + packageSlug: match[2], + version: match[3] + } + : undefined; +} + +function parseConventionalCommit(subject, message = subject) { + const match = subject.match(CONVENTIONAL_COMMIT_PATTERN); + + return { + breaking: Boolean(match?.[3]) || /(^|\n)BREAKING[ -]CHANGE:/i.test(message), + description: match?.[4] ?? subject, + scope: match?.[2]?.toLowerCase(), + type: match?.[1]?.toLowerCase() + }; +} + +function extractChangelogSection(changelog, version) { + const lines = changelog.split(/\r?\n/); + const headingPattern = /^##\s+(?:)?([^\s<]+)\s+\((\d{4}-\d{2}-\d{2})\)(?:<\/small>)?\s*$/; + + for (let index = 0; index < lines.length; index += 1) { + const match = lines[index].match(headingPattern); + + if (match?.[1] !== version) { + continue; + } + + let end = index + 1; + + while (end < lines.length && !/^##\s+/.test(lines[end])) { + end += 1; + } + + return { + date: match[2], + notes: lines + .slice(index + 1, end) + .join('\n') + .trim() + }; + } + + return undefined; +} + +function parseReleaseNoteCommits(notes, repository = REPOSITORY) { + const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const linkPattern = new RegExp( + `\\[([0-9a-f]{7,40})\\]\\((https://github\\.com/${escapedRepository}/commit/([0-9a-f]{7,40}))\\)`, + 'gi' + ); + const commits = []; + + for (const line of notes.split(/\r?\n/)) { + for (const match of line.matchAll(linkPattern)) { + const subject = line + .replace(/^\s*[-*+]\s+/, '') + .replace(match[0], '') + .replace(/[\s()]+$/g, '') + .trim(); + + commits.push({ + sha: match[3].toLowerCase(), + subject, + url: match[2] + }); + } + } + + return commits; +} + +async function runGit(args, cwd) { + const { stdout } = await execFileAsync('git', args, { + cwd, + env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' }, + maxBuffer: 20 * 1024 * 1024, + timeout: 30_000 + }); + + return stdout; +} + +async function findGitRoot(repoDir) { + try { + return (await runGit(['rev-parse', '--show-toplevel'], path.resolve(repoDir))).trim(); + } catch { + return undefined; + } +} + +async function readFileAtTag(tagName, filePath, gitRoot) { + try { + return await runGit(['show', `${tagName}:${filePath}`], gitRoot); + } catch { + return undefined; + } +} + +async function readPackageJsonAtTag(tagName, packageJsonPath, gitRoot) { + const source = await readFileAtTag(tagName, packageJsonPath, gitRoot); + + if (!source) { + return undefined; + } + + try { + return JSON.parse(source); + } catch { + return undefined; + } +} + +async function findPackageMetadataAtTag({ gitRoot, packageName, packageSlug, tagName }) { + const directPackageJsonPath = `projects/${packageSlug}/package.json`; + const directPackageJson = await readPackageJsonAtTag(tagName, directPackageJsonPath, gitRoot); + + if (directPackageJson?.name === packageName) { + return { + changelogPath: `projects/${packageSlug}/CHANGELOG.md`, + packageJson: directPackageJson, + packageJsonPath: directPackageJsonPath + }; + } + + const files = await runGit(['ls-tree', '-r', '--name-only', tagName, '--', 'projects'], gitRoot); + const packageJsonPaths = files.split('\n').filter(filePath => /^projects\/[^/]+\/package\.json$/.test(filePath)); + + for (const packageJsonPath of packageJsonPaths) { + const packageJson = await readPackageJsonAtTag(tagName, packageJsonPath, gitRoot); + + if (packageJson?.name === packageName) { + return { + changelogPath: path.posix.join(path.posix.dirname(packageJsonPath), 'CHANGELOG.md'), + packageJson, + packageJsonPath + }; + } + } + + return undefined; +} + +function isInPeriod(timestamp, period) { + const value = Date.parse(timestamp); + return value >= Date.parse(period.since) && value <= Date.parse(period.until); +} + +function isPrerelease(version) { + return /(?:^|[.-])(alpha|beta|rc)(?:[.-]|\d|$)/i.test(version); +} + +function compareReleaseDates(left, right) { + return Date.parse(left.releasedAt) - Date.parse(right.releasedAt) || left.tagName.localeCompare(right.tagName); +} + +async function collectGitReleases({ gitRoot, includePrereleases, period }) { + const output = await runGit( + ['for-each-ref', '--sort=-creatordate', '--format=%(refname:strip=2)%09%(creatordate:iso-strict)', 'refs/tags'], + gitRoot + ); + const releases = []; + const warnings = []; + + for (const row of output.trim().split('\n').filter(Boolean)) { + const [tagName, rawReleasedAt] = row.split('\t'); + const parsedTag = parseElementsTag(tagName); + + if (!parsedTag) { + continue; + } + + const releasedAt = new Date(rawReleasedAt).toISOString(); + + if (!isInPeriod(releasedAt, period) || (!includePrereleases && isPrerelease(parsedTag.version))) { + continue; + } + + const commitSha = (await runGit(['rev-list', '-n', '1', tagName], gitRoot)).trim(); + const metadata = await findPackageMetadataAtTag({ gitRoot, tagName, ...parsedTag }); + let changelogDate; + let notes; + let notesSource = 'tag-message'; + + if (!metadata) { + warnings.push(`Skipped ${tagName} because it does not contain package metadata for ${parsedTag.packageName}.`); + continue; + } + + if (metadata.packageJson.version !== parsedTag.version) { + warnings.push( + `Skipped ${tagName} because it points to package version ${metadata.packageJson.version ?? 'unknown'} instead of ${parsedTag.version}.` + ); + continue; + } + + const changelog = await readFileAtTag(tagName, metadata.changelogPath, gitRoot); + const section = changelog ? extractChangelogSection(changelog, parsedTag.version) : undefined; + + if (section) { + changelogDate = section.date; + notes = section.notes; + notesSource = 'tagged-changelog'; + } else { + warnings.push(`${tagName} does not contain a ${parsedTag.version} changelog section.`); + } + + if (notes === undefined) { + notes = (await runGit(['show', '-s', '--format=%b', tagName, '--'], gitRoot)).trim(); + } + + releases.push({ + ...parsedTag, + changelogDate, + changelogPath: metadata.changelogPath, + commitSha, + notes, + notesSource, + packageJsonPath: metadata.packageJsonPath, + packageJsonVersion: metadata.packageJson.version, + releasedAt, + tagName, + url: `${REPOSITORY_URL}/releases/tag/${encodeURIComponent(tagName)}`, + versionMatchesTag: true + }); + } + + releases.sort(compareReleaseDates); + + return { releases, warnings }; +} + +async function loadGitCommit(sha, gitRoot) { + try { + const output = await runGit( + ['show', '--no-ext-diff', '--no-renames', '--numstat', '--format=%H%x00%aI%x00%an%x00%B%x00', sha, '--'], + gitRoot + ); + const [fullSha, authoredAt, author, message, numstat = ''] = output.split('\0'); + const [subject = '', ...bodyLines] = message.trim().split(/\r?\n/); + const files = numstat + .trim() + .split('\n') + .filter(Boolean) + .map(line => { + const [rawAdditions, rawDeletions, ...pathParts] = line.split('\t'); + + return { + additions: rawAdditions === '-' ? undefined : Number(rawAdditions), + deletions: rawDeletions === '-' ? undefined : Number(rawDeletions), + path: pathParts.join('\t') + }; + }); + + return { + authoredAt, + author, + body: bodyLines.join('\n').trim(), + files, + message: message.trim(), + sha: fullSha, + subject + }; + } catch { + return undefined; + } +} + +function mergeChange(target, source) { + target.releaseTags = [...new Set([...(target.releaseTags ?? []), ...(source.releaseTags ?? [])])]; + target.subject ||= source.subject; + target.url ||= source.url; + + for (const [key, value] of Object.entries(source)) { + if (value !== undefined && !['releaseTags', 'subject', 'url'].includes(key)) { + target[key] = value; + } + } + + return target; +} + +async function collectChanges({ gitRoot, releases, loadCommit = loadGitCommit }) { + const candidates = new Map(); + + for (const release of releases) { + for (const commit of parseReleaseNoteCommits(release.notes)) { + const existing = candidates.get(commit.sha) ?? { ...commit, releaseTags: [] }; + mergeChange(existing, { ...commit, releaseTags: [release.tagName] }); + candidates.set(commit.sha, existing); + } + } + + const changes = new Map(); + let unenrichedCount = 0; + + for (const candidate of candidates.values()) { + if (RELEASE_COMMIT_PATTERN.test(candidate.subject)) { + continue; + } + + const local = await loadCommit(candidate.sha, gitRoot); + const record = { + ...candidate, + ...(local ?? {}), + url: candidate.url || `${REPOSITORY_URL}/commit/${candidate.sha}` + }; + + if (!local) { + unenrichedCount += 1; + } + + Object.assign(record, parseConventionalCommit(record.subject, record.message)); + + if (RELEASE_COMMIT_PATTERN.test(record.subject)) { + continue; + } + + const key = record.sha.toLowerCase(); + const existing = changes.get(key); + changes.set(key, existing ? mergeChange(existing, record) : record); + } + + return { changes: [...changes.values()], unenrichedCount }; +} + +async function createEvidence(options, { now = new Date() } = {}) { + const period = getPeriod(options, now); + const gitRoot = await findGitRoot(options.repoDir); + + if (!gitRoot) { + throw new Error('Run this script from an NVIDIA Elements Git clone or pass --repo-dir.'); + } + + const { releases, warnings } = await collectGitReleases({ + gitRoot, + includePrereleases: options.includePrereleases, + period + }); + const { changes, unenrichedCount } = await collectChanges({ gitRoot, releases }); + + if (releases.length > 0 && changes.length === 0) { + warnings.push('The selected changelog sections did not contain commit links.'); + } + + if (unenrichedCount > 0) { + warnings.push(`${unenrichedCount} changelog commit(s) were not available in the local clone.`); + } + + return { + changes, + generatedAt: new Date(now).toISOString(), + gitRoot, + page: getPageMetadata(period, now), + period, + releasePageUrl: `${REPOSITORY_URL}/releases`, + releases, + repository: REPOSITORY, + source: 'local-git-tags-and-changelogs', + warnings + }; +} + +function formatFile(file) { + const additions = file.additions === undefined ? '?' : file.additions; + const deletions = file.deletions === undefined ? '?' : file.deletions; + return `\`${file.path}\` (+${additions}/-${deletions})`; +} + +function formatMarkdown(evidence) { + const lines = [ + '# NVIDIA Elements release evidence', + '', + `- Period: ${evidence.period.since} through ${evidence.period.until}`, + `- Source: ${evidence.source}`, + `- Releases: ${evidence.releases.length}`, + `- Unique non-release commits: ${evidence.changes.length}`, + `- Release page: ${evidence.releasePageUrl}`, + `- Docs page: ${evidence.page.filePath}`, + `- Docs title: ${evidence.page.title}` + ]; + + if (evidence.warnings.length > 0) { + lines.push('', '## Warnings', '', ...evidence.warnings.map(warning => `- ${warning}`)); + } + + if (evidence.releases.length === 0) { + lines.push('', 'No NVIDIA Elements release tags were found in this period.'); + return `${lines.join('\n')}\n`; + } + + lines.push('', '## Releases'); + + for (const release of evidence.releases) { + lines.push( + '', + `### ${release.packageName} ${release.version}`, + '', + `- Tagged: ${release.releasedAt}`, + `- Tag: ${release.tagName}`, + `- Package metadata: ${release.packageJsonPath ?? 'not found'} (${release.packageJsonVersion ?? 'unknown'})`, + `- Notes source: ${release.notesSource}${release.changelogPath ? ` (${release.changelogPath})` : ''}`, + `- URL: ${release.url}` + ); + + if (release.notes.trim()) { + lines.push('', release.notes.trim()); + } + } + + lines.push('', '## Unique commit evidence'); + + for (const change of evidence.changes) { + const shortSha = change.sha.slice(0, 7); + lines.push( + '', + `### ${change.subject || shortSha}`, + '', + `- Commit: ${change.url}`, + `- SHA: ${change.sha}`, + `- Included by: ${change.releaseTags.join(', ')}`, + `- Classification: ${[change.type, change.scope].filter(Boolean).join(' / ') || 'unclassified'}${ + change.breaking ? ' / BREAKING' : '' + }` + ); + + if (change.author || change.authoredAt) { + lines.push(`- Authored: ${[change.author, change.authoredAt].filter(Boolean).join(' — ')}`); + } + + if (change.body) { + lines.push(`- Commit details: ${change.body.replace(/\s+/g, ' ').trim()}`); + } + + if (change.files?.length) { + const visibleFiles = change.files.slice(0, MAX_MARKDOWN_FILES).map(formatFile).join(', '); + const remainder = change.files.length - MAX_MARKDOWN_FILES; + lines.push(`- Changed files: ${visibleFiles}${remainder > 0 ? `, plus ${remainder} more` : ''}`); + } + } + + return `${lines.join('\n')}\n`; +} + +function getHelp() { + return `Usage: node .agents/skills/summarize-releases/scripts/collect-releases.js [options] + +Collect release and commit evidence from local NVIDIA Elements tags and changelogs. + +Options: + --month Calendar month to collect (default: previous month) + --include-prereleases Include prereleases + --repo-dir NVIDIA Elements clone (default: current directory) + --json Print structured JSON instead of Markdown + --help Show this help + +This script does not use the GitHub API or require a token. +`; +} + +async function main() { + try { + const options = parseArgs(process.argv.slice(2)); + + if (options.help) { + process.stdout.write(getHelp()); + return; + } + + const evidence = await createEvidence(options); + process.stdout.write(options.json ? `${JSON.stringify(evidence, null, 2)}\n` : formatMarkdown(evidence)); + } catch (error) { + process.stderr.write(`Error: ${compactError(error)}\n`); + process.exitCode = 1; + } +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + await main(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4ab2043c1..a9e2b28356 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1388,6 +1388,9 @@ importers: '@11ty/eleventy': specifier: 'catalog:' version: 3.1.6 + '@11ty/eleventy-plugin-rss': + specifier: 3.0.0 + version: 3.0.0 '@11ty/eleventy-plugin-vite': specifier: 'catalog:' version: 8.0.0(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(sugarss@5.0.1(postcss@8.5.16))(terser@5.48.0)(yaml@2.9.0) @@ -2207,6 +2210,9 @@ packages: resolution: {integrity: sha512-QK1tRFBhQdZASnYU8GMzpTdsMMFLVAkuU0gVVILqNyp09xJJZb81kAS3AFrNrwBCsgLxTdWHJ8N64+OTTsoKkA==} engines: {node: '>=18'} + '@11ty/eleventy-plugin-rss@3.0.0': + resolution: {integrity: sha512-kKW4DcR57xAyRx0e8gNhKh56ahHVEaAj8/TuXQDnw+B46ig2bWADJAlyj/GdV37IG5ja9dZ4SgKZrs/CHz6YWQ==} + '@11ty/eleventy-plugin-syntaxhighlight@5.0.2': resolution: {integrity: sha512-T6xVVRDJuHlrFMHbUiZkHjj5o1IlLzZW+1IL9eUsyXFU7rY2ztcYhZew/64vmceFFpQwzuSfxQOXxTJYmKkQ+A==} @@ -2765,13 +2771,6 @@ packages: resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} - '@csstools/css-calc@3.2.0': - resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-calc@3.2.1': resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} engines: {node: '>=20.19.0'} @@ -2792,14 +2791,6 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.3': - resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} - peerDependencies: - css-tree: ^3.2.1 - peerDependenciesMeta: - css-tree: - optional: true - '@csstools/css-syntax-patches-for-csstree@1.1.6': resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} peerDependencies: @@ -14157,6 +14148,15 @@ snapshots: - posthtml - supports-color + '@11ty/eleventy-plugin-rss@3.0.0': + dependencies: + '@11ty/eleventy-utils': 2.0.7 + '@11ty/posthtml-urls': 1.0.3 + debug: 4.4.3 + posthtml: 0.16.7 + transitivePeerDependencies: + - supports-color + '@11ty/eleventy-plugin-syntaxhighlight@5.0.2': dependencies: prismjs: 1.30.0 @@ -14508,7 +14508,7 @@ snapshots: '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 - '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -14932,11 +14932,6 @@ snapshots: '@csstools/color-helpers@6.0.2': {} - '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) @@ -14945,7 +14940,7 @@ snapshots: '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/color-helpers': 6.0.2 - '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -14953,10 +14948,6 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': - optionalDependencies: - css-tree: 3.2.1 - '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 @@ -20399,7 +20390,7 @@ snapshots: cssstyle@6.2.0: dependencies: '@asamuzakjp/css-color': 5.1.11 - '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) css-tree: 3.2.1 lru-cache: 11.3.5 diff --git a/projects/internals/tools/src/api/utils.test.ts b/projects/internals/tools/src/api/utils.test.ts index beb87b6e04..33ff762ec9 100644 --- a/projects/internals/tools/src/api/utils.test.ts +++ b/projects/internals/tools/src/api/utils.test.ts @@ -132,6 +132,8 @@ describe('getContextAPIs', () => { describe('getLatestPublishedVersions', () => { const projects = [{ name: '@nvidia-elements/core', version: '1.0.0', description: '', readme: '', changelog: '' }]; + const fetchErrorMessage = + 'Could not fetch latest version from https://registry.npmjs.org/@nvidia-elements/core/latest'; beforeEach(() => { vi.resetModules(); @@ -148,7 +150,7 @@ describe('getLatestPublishedVersions', () => { vi.mocked(fetch).mockRejectedValue(new Error('Network error')); const { getLatestPublishedVersions } = await import('./utils.js'); - await expect(getLatestPublishedVersions(projects)).rejects.toThrow(/Could not fetch latest versions from/); + await expect(getLatestPublishedVersions(projects)).rejects.toThrow(fetchErrorMessage); }); it.each(['cli', 'dev'])( @@ -161,7 +163,7 @@ describe('getLatestPublishedVersions', () => { const result = await getLatestPublishedVersions(projects); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Could not fetch latest versions from')); + expect(warnSpy).toHaveBeenCalledWith(fetchErrorMessage); expect(result).toEqual({ '@nvidia-elements/core': '0.0.0' }); } ); diff --git a/projects/internals/tools/src/api/utils.ts b/projects/internals/tools/src/api/utils.ts index 23697c8b50..4a9951b5d7 100644 --- a/projects/internals/tools/src/api/utils.ts +++ b/projects/internals/tools/src/api/utils.ts @@ -205,7 +205,8 @@ export async function getLatestPublishedVersions(projects: Project[]): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 3000); - return fetch(`${NPM_REGISTRY_URL}/${name}/latest`, { signal: controller.signal }) + const url = `${NPM_REGISTRY_URL}/${name}/latest`; + return fetch(url, { signal: controller.signal }) .then(res => { if (!res.ok) { throw new Error(`Failed to fetch ${name} from ${NPM_REGISTRY_URL}: ${res.status} ${res.statusText}`); @@ -213,7 +214,7 @@ export async function getLatestPublishedVersions(projects: Project[]): Promise { - const message = `Could not fetch latest versions from ${NPM_REGISTRY_URL}`; + const message = `Could not fetch latest version from ${url}`; if (process.env.ELEMENTS_ENV === 'mcp') { throw new Error(message); } else { diff --git a/projects/site/eleventy.config.js b/projects/site/eleventy.config.js index 4855406fa5..f0e0462413 100644 --- a/projects/site/eleventy.config.js +++ b/projects/site/eleventy.config.js @@ -19,6 +19,7 @@ import { searchPlugin } from './src/_11ty/plugins/search.js'; import { agentSkillsPlugin } from './src/_11ty/plugins/agent-skills.js'; import { llmsTxtPlugin } from './src/_11ty/plugins/llms-txt.js'; import { sitemapPlugin } from './src/_11ty/plugins/sitemap-xml.js'; +import { updatesFeedPlugin } from './src/_11ty/plugins/updates-feed.js'; import { elementLoaderTransform } from './src/_11ty/transforms/element-loader.js'; import { anchorGeneratorTransform } from './src/_11ty/transforms/anchor-generator.js'; import { siteUrlsTransform } from './src/_11ty/transforms/site-urls.js'; @@ -206,6 +207,7 @@ export default function (eleventyConfig) { eleventyConfig.addPlugin(HtmlBasePlugin, { baseHref: process.env.ELEVENTY_RUN_MODE === 'build' ? ELEMENTS_SITE_ORIGIN : '/' }); + eleventyConfig.addPlugin(updatesFeedPlugin); eleventyConfig.addTransform('site-urls', siteUrlsTransform); if (process.env.ELEVENTY_RUN_MODE === 'build') { diff --git a/projects/site/package.json b/projects/site/package.json index 731c7b0586..9d1b7bab7d 100644 --- a/projects/site/package.json +++ b/projects/site/package.json @@ -243,6 +243,7 @@ }, "devDependencies": { "@11ty/eleventy": "catalog:", + "@11ty/eleventy-plugin-rss": "3.0.0", "@11ty/eleventy-plugin-vite": "catalog:", "@eslint/js": "catalog:", "@internals/eslint": "workspace:*", diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js index 341b6bd5ef..4982860cf7 100644 --- a/projects/site/src/_11ty/layouts/common.js +++ b/projects/site/src/_11ty/layouts/common.js @@ -2,7 +2,8 @@ /* global process */ import { ELEMENTS_PLAYGROUND_BASE_URL, ELEMENTS_REPO_BASE_URL } from '../utils/env.js'; -import { DEPLOYED_SITE_URL } from '../utils/site-url.js'; +import { UPDATE_FEEDS } from '../plugins/updates-feed.js'; +import { DEPLOYED_SITE_URL, getSiteUrl } from '../utils/site-url.js'; import { AUTHOR_CREDENTIALS, AUTHOR_NAME, @@ -31,6 +32,8 @@ export const renderBaseHead = data => { + ${UPDATE_FEEDS.map(({ label, outputPath, type }) => ``).join('\n ')} + @@ -168,8 +171,9 @@ export const renderDocsNav = data => /* html */ ` Vue - - About + + About + What’s New Changelog Metrics Support @@ -437,8 +441,8 @@ export function renderGlobalsScript(data = { disableTheme: false }) { export function renderBasePageHeader(data) { return /* html */ ` - NV - Elements + NV + NVIDIA Elements Catalog ${ELEMENTS_PLAYGROUND_BASE_URL ? /* html */ `Playground` : ''} Starters diff --git a/projects/site/src/_11ty/layouts/docs.css b/projects/site/src/_11ty/layouts/docs.css index e615aea856..67fc187e3a 100644 --- a/projects/site/src/_11ty/layouts/docs.css +++ b/projects/site/src/_11ty/layouts/docs.css @@ -171,6 +171,7 @@ h3[nve-text*='mkd'] { display: flex; align-items: center; gap: var(--nve-ref-space-sm); + width: 100%; } h2#installation { diff --git a/projects/site/src/_11ty/layouts/metadata.js b/projects/site/src/_11ty/layouts/metadata.js index cd52ffeda0..d291372165 100644 --- a/projects/site/src/_11ty/layouts/metadata.js +++ b/projects/site/src/_11ty/layouts/metadata.js @@ -1,4 +1,5 @@ import { siteData } from '../../index.11tydata.js'; +import { getContentDates } from '../utils/content-dates.js'; import { BASE_URL, DEPLOYED_SITE_URL, getSiteUrl } from '../utils/site-url.js'; export { BASE_URL }; @@ -414,21 +415,6 @@ function isApiReferencePage(data, meta) { ); } -function normalizeDate(value) { - if (value instanceof Date) return value.toISOString(); - if (typeof value !== 'string') return null; - - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date.toISOString(); -} - -function getContentDates(data) { - return { - datePublished: normalizeDate(data.datePublished ?? data.published ?? data.git?.created ?? data.git?.createdTime), - dateModified: normalizeDate(data.dateModified ?? data.modified ?? data.git?.modified ?? data.git?.modifiedTime) - }; -} - function getAuthor() { return { '@id': AUTHOR_ID, @@ -451,6 +437,7 @@ function getAuthor() { function getArticle(data, meta) { const isDocs = meta.url.startsWith('/docs/'); const isApiReference = isApiReferencePage(data, meta); + const isUpdate = data.tags?.includes('updates'); const element = findElementByTag(data.tag ?? data.component?.data?.tag); const dates = getContentDates(data); const article = { @@ -460,9 +447,11 @@ function getArticle(data, meta) { ? 'CollectionPage' : isApiReference ? 'APIReference' - : isDocs - ? 'TechArticle' - : 'WebPage', + : isUpdate + ? 'BlogPosting' + : isDocs + ? 'TechArticle' + : 'WebPage', headline: meta.title, description: meta.description, url: meta.canonicalUrl, diff --git a/projects/site/src/_11ty/layouts/metadata.test.ts b/projects/site/src/_11ty/layouts/metadata.test.ts index 0a2e8a005b..5533cdc026 100644 --- a/projects/site/src/_11ty/layouts/metadata.test.ts +++ b/projects/site/src/_11ty/layouts/metadata.test.ts @@ -116,7 +116,10 @@ interface PageData { all: { url: string }[]; }; content?: string; + dateModified?: Date | string; + datePublished?: Date | string; tag?: string; + tags?: string[]; } function createMeta(url: string, overrides: Partial = {}): MetadataInput { @@ -384,6 +387,22 @@ describe('renderBaseHead', () => { '' ); }); + + it('should expose RSS and Atom update feeds for html discovery', () => { + const html = renderBaseHead({ + page: { url: '/' }, + collections: { all: [] }, + title: 'Test Page', + description: 'Test description.' + }); + + expect(html).toContain( + '' + ); + expect(html).toContain( + '' + ); + }); }); describe('renderDocsNav', () => { @@ -570,6 +589,26 @@ describe('renderJsonLd', () => { expect(article).not.toHaveProperty('dateModified'); }); + it('should emit update posts as dated BlogPosting schema', () => { + const graph = getGraph( + createData({ + dateModified: '2026-07-24', + datePublished: '2026-07-22', + tags: ['whats-new', 'updates'] + }), + createMeta('/docs/whats-new/06-2026/') + ); + const article = findNode(graph, 'BlogPosting'); + + expect(article).toMatchObject({ + dateModified: '2026-07-24T00:00:00.000Z', + datePublished: '2026-07-22T00:00:00.000Z', + headline: 'Test Page | NVIDIA Elements', + mainEntityOfPage: 'https://nvidia.github.io/elements/docs/whats-new/06-2026/' + }); + expect(findNode(graph, 'TechArticle')).toBeUndefined(); + }); + it('should omit non-generated breadcrumb pages from structured data', () => { const html = renderJsonLd( { diff --git a/projects/site/src/_11ty/layouts/page.11ty.js b/projects/site/src/_11ty/layouts/page.11ty.js index e3f16de4f4..b62b8858e6 100644 --- a/projects/site/src/_11ty/layouts/page.11ty.js +++ b/projects/site/src/_11ty/layouts/page.11ty.js @@ -18,8 +18,8 @@ export function render(data) { - NV - Elements + NV + NVIDIA Elements Catalog ${ELEMENTS_PLAYGROUND_BASE_URL ? /* html */ `Playground` : ''} Starters diff --git a/projects/site/src/_11ty/layouts/whats-new.11ty.js b/projects/site/src/_11ty/layouts/whats-new.11ty.js new file mode 100644 index 0000000000..5c561df601 --- /dev/null +++ b/projects/site/src/_11ty/layouts/whats-new.11ty.js @@ -0,0 +1,99 @@ +import { getContentDates } from '../utils/content-dates.js'; +import { getSiteUrl } from '../utils/site-url.js'; + +export const data = { + layout: 'docs.11ty.js' +}; + +export function renderUpdatesFeedLink() { + return /* html */ ` + + + Subscribe via RSS + + + `; +} + +export function getUpdateMonth(entry) { + return new Date(entry.data?.updateMonth ?? entry.date); +} + +export function formatUpdateMonth(entry) { + return new Intl.DateTimeFormat('en-US', { + month: 'long', + timeZone: 'UTC', + year: 'numeric' + }).format(getUpdateMonth(entry)); +} + +function formatContentDate(date) { + return new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'long', + timeZone: 'UTC', + year: 'numeric' + }).format(new Date(date)); +} + +export function sortUpdatesNewestFirst(entries) { + return [...entries].sort((left, right) => getUpdateMonth(right) - getUpdateMonth(left)); +} + +export function renderUpdateDates(data) { + const { datePublished, dateModified } = getContentDates(data); + const dates = []; + + if (datePublished) { + dates.push(`Published `); + } + + if (dateModified && datePublished && Date.parse(dateModified) > Date.parse(datePublished)) { + dates.push(`Updated `); + } + + return dates.length > 0 ? `

${dates.join(' · ')}

` : ''; +} + +export function getRecentUpdates(data) { + return sortUpdatesNewestFirst(data.collections?.['whats-new'] ?? []).slice(0, 6); +} + +export function renderRecentUpdates(data) { + const entries = getRecentUpdates(data); + if (entries.length === 0) return ''; + + return /* html */ ` + + `; +} + +export function render(data) { + const recentUpdates = renderRecentUpdates(data); + + return /* html */ ` +
+

${data.title}

+
+
+ + ${recentUpdates} +
+ `; +} diff --git a/projects/site/src/_11ty/layouts/whats-new.test.ts b/projects/site/src/_11ty/layouts/whats-new.test.ts new file mode 100644 index 0000000000..25b5102dea --- /dev/null +++ b/projects/site/src/_11ty/layouts/whats-new.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { + data, + getRecentUpdates, + render, + renderRecentUpdates, + renderUpdateDates, + renderUpdatesFeedLink +} from './whats-new.11ty.js'; + +function createUpdate(month: number) { + const monthLabel = String(month).padStart(2, '0'); + + return { + data: { updateMonth: `2026-${monthLabel}-01` }, + date: new Date('2026-07-22T00:00:00Z'), + url: `/docs/whats-new/${monthLabel}-2026/` + }; +} + +describe('What’s New layout', () => { + it('should use the documentation layout', () => { + expect(data).toEqual({ layout: 'docs.11ty.js' }); + }); + + it('should render consistent post chrome around the update content', () => { + const content = '

Highlights

'; + const current = createUpdate(7); + const html = render({ + collections: { 'whats-new': [current] }, + content, + dateModified: '2026-07-22', + datePublished: '2026-07-22', + page: { url: current.url }, + title: 'What’s new in NVIDIA Elements: July 2026' + }); + + expect(html).toContain('

What’s new in NVIDIA Elements: July 2026

'); + expect(html).toContain('Published '); + expect(html).toContain(renderUpdatesFeedLink()); + expect(html).toContain(content); + expect(html).toContain('href="/docs/changelog/"'); + expect(html).toContain('href="https://github.com/NVIDIA/elements/releases"'); + }); + + it('should show an updated date only after a genuine later revision', () => { + expect(renderUpdateDates({ dateModified: '2026-07-22', datePublished: '2026-07-22' })).toBe( + '

Published

' + ); + expect(renderUpdateDates({ dateModified: '2026-07-24', datePublished: '2026-07-22' })).toContain( + 'Updated ' + ); + expect(renderUpdateDates({})).toBe(''); + }); + + it('should omit the updated date when modification predates publication', () => { + const html = renderUpdateDates({ dateModified: '2026-07-20', datePublished: '2026-07-22' }); + + expect(html).toContain('Published '); + expect(html).not.toContain('Updated'); + }); + + it('should list the six most recent updates newest first on every post', () => { + const updates = [1, 2, 3, 4, 5, 6, 7, 8].map(createUpdate); + const pageData = { + collections: { 'whats-new': updates }, + page: { url: updates[0].url } + }; + + expect(getRecentUpdates(pageData)).toEqual(updates.slice(2).reverse()); + + const html = renderRecentUpdates(pageData); + expect(html).toContain('aria-label="Recent updates"'); + expect(html).toContain('August 2026'); + expect(html).toContain('March 2026'); + expect(html).not.toContain('February 2026'); + expect(html).not.toContain('January 2026'); + }); + + it('should mark the viewed update as the current page when it is recent', () => { + const current = createUpdate(6); + const pageData = { + collections: { 'whats-new': [createUpdate(5), current, createUpdate(7)] }, + page: { url: current.url } + }; + + expect(renderRecentUpdates(pageData)).toContain(`href="${current.url}" aria-current="page"`); + }); + + it('should omit the recent-updates navigation when no posts are available', () => { + expect(renderRecentUpdates({ collections: { 'whats-new': [] } })).toBe(''); + }); + + it('should link to the RSS feed with feed metadata', () => { + const html = renderUpdatesFeedLink(); + + expect(html).toContain('href="https://nvidia.github.io/elements/feed.xml"'); + expect(html).toContain('rel="alternate noopener"'); + expect(html).toContain('type="application/rss+xml"'); + }); +}); diff --git a/projects/site/src/_11ty/plugins/llms-txt.test.ts b/projects/site/src/_11ty/plugins/llms-txt.test.ts index 66b45b5b5d..9830857640 100644 --- a/projects/site/src/_11ty/plugins/llms-txt.test.ts +++ b/projects/site/src/_11ty/plugins/llms-txt.test.ts @@ -13,6 +13,7 @@ async function importLlmsTxt() { afterEach(() => { vi.unstubAllEnvs(); + vi.resetModules(); }); describe('createLlmsTxtContent', () => { diff --git a/projects/site/src/_11ty/plugins/sitemap-xml.js b/projects/site/src/_11ty/plugins/sitemap-xml.js index ed989d4d4a..5c647d81a9 100644 --- a/projects/site/src/_11ty/plugins/sitemap-xml.js +++ b/projects/site/src/_11ty/plugins/sitemap-xml.js @@ -1,9 +1,12 @@ import { promises as fsp } from 'node:fs'; +import { getExplicitModifiedDate, normalizeContentDate } from '../utils/content-dates.js'; import { getSiteUrl } from '../utils/site-url.js'; const EXCLUDED_PREFIXES = ['/docs/changelog/', '/docs/metrics/', '/examples/', '/404']; const UTILITY_FILE_URLS = ['/llms.txt', '/llms-full.txt']; const ROBOTS_NOINDEX = /]*name=["']robots["'][^>]*content=["'][^"']*\bnoindex\b/i; +const JSON_LD_SCRIPT = + /]*\btype=(?:"application\/ld\+json"|'application\/ld\+json'|application\/ld\+json)[^>]*>([\s\S]*?)<\/script>/gi; export function isSitemapPageUrl(url) { if (!url) return false; @@ -20,23 +23,45 @@ function isPublishableResult(result) { return !ROBOTS_NOINDEX.test(result.content ?? ''); } -export function sitemapPlugin(eleventyConfig) { - eleventyConfig.on('eleventy.after', async ({ results } = {}) => { - const urls = [...new Set((results ?? []).filter(isPublishableResult).map(result => result.url))].sort(); - const entries = urls.map(url => { - const loc = getSiteUrl(url); - return ['', `${loc}`, ''].join('\n'); +function getResultModifiedDate(result) { + const explicitDate = getExplicitModifiedDate(result.data); + if (explicitDate) return explicitDate; + + for (const script of (result.content ?? '').matchAll(JSON_LD_SCRIPT)) { + const structuredDate = /"dateModified":"([^"]+)"/.exec(script[1])?.[1]; + if (structuredDate) return normalizeContentDate(structuredDate); + } + + return null; +} + +export function renderSitemap(results = []) { + const pages = new Map(results.filter(isPublishableResult).map(result => [result.url, result])); + const entries = [...pages.values()] + .sort((left, right) => left.url.localeCompare(right.url)) + .map(result => { + const loc = getSiteUrl(result.url); + const lastModified = getResultModifiedDate(result); + return [ + '', + `${loc}`, + ...(lastModified ? [`${lastModified}`] : []), + '' + ].join('\n'); }); - const xml = [ - '', - '', - ...entries, - '', - '' - ].join('\n'); + return [ + '', + '', + ...entries, + '', + '' + ].join('\n'); +} +export function sitemapPlugin(eleventyConfig) { + eleventyConfig.on('eleventy.after', async ({ results } = {}) => { await fsp.mkdir('./.11ty-vite/public/', { recursive: true }); - await fsp.writeFile('./.11ty-vite/public/sitemap.xml', xml, 'utf-8'); + await fsp.writeFile('./.11ty-vite/public/sitemap.xml', renderSitemap(results), 'utf-8'); }); } diff --git a/projects/site/src/_11ty/plugins/sitemap-xml.test.ts b/projects/site/src/_11ty/plugins/sitemap-xml.test.ts index 4866dfd9c5..dc0bdb4bd4 100644 --- a/projects/site/src/_11ty/plugins/sitemap-xml.test.ts +++ b/projects/site/src/_11ty/plugins/sitemap-xml.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isSitemapPageUrl } from './sitemap-xml.js'; +import { isSitemapPageUrl, renderSitemap } from './sitemap-xml.js'; describe('isSitemapPageUrl', () => { it('should include crawlable html pages', () => { @@ -21,4 +21,23 @@ describe('isSitemapPageUrl', () => { expect(isSitemapPageUrl('/docs/metrics/')).toBe(false); expect(isSitemapPageUrl('/examples/')).toBe(false); }); + + it('should emit lastmod only from explicit modification metadata', () => { + const sitemap = renderSitemap([ + { + content: '', + url: '/docs/whats-new/06-2026/' + }, + { + content: '{"dateModified":"2026-07-25T00:00:00.000Z"}', + url: '/docs/about/support/' + } + ]); + + expect(sitemap).toContain( + 'https://nvidia.github.io/elements/docs/whats-new/06-2026/\n2026-07-24T00:00:00.000Z' + ); + expect(sitemap).toContain('https://nvidia.github.io/elements/docs/about/support/'); + expect(sitemap.match(//g)).toHaveLength(1); + }); }); diff --git a/projects/site/src/_11ty/plugins/updates-feed.js b/projects/site/src/_11ty/plugins/updates-feed.js new file mode 100644 index 0000000000..0b78daa99f --- /dev/null +++ b/projects/site/src/_11ty/plugins/updates-feed.js @@ -0,0 +1,156 @@ +import { promises as fsp } from 'node:fs'; +import nodePath from 'node:path'; +import rssPlugin from '@11ty/eleventy-plugin-rss'; +import { getContentDates, normalizeContentDate } from '../utils/content-dates.js'; +import { ELEMENTS_SITE_ORIGIN } from '../utils/site-url.js'; + +const DEFAULT_PUBLIC_OUTPUT_PATH = './.11ty-vite/public'; + +export const UPDATES_COLLECTION = 'updates'; +export const RSS_UPDATES_COLLECTION = 'rssUpdates'; +export const ATOM_UPDATES_COLLECTION = 'atomUpdates'; + +export const UPDATE_FEEDS = [ + { + collection: RSS_UPDATES_COLLECTION, + inputPath: 'updates-feed-rss.njk', + label: 'RSS', + outputPath: '/feed.xml', + type: 'rss' + }, + { + collection: ATOM_UPDATES_COLLECTION, + inputPath: 'updates-feed-atom.njk', + label: 'Atom', + outputPath: '/atom.xml', + type: 'atom' + } +]; + +export const UPDATE_FEED_METADATA = { + author: { name: 'NVIDIA Elements' }, + base: ELEMENTS_SITE_ORIGIN, + language: 'en', + subtitle: + 'What’s new in the NVIDIA Elements Design System, including product updates, release highlights, and announcements.', + title: 'NVIDIA Elements updates' +}; + +export const RSS_FEED_TEMPLATE = ` + + + {{ metadata.title }} + {{ metadata.base | addPathPrefixToFullUrl }} + + {{ metadata.subtitle }} + {{ metadata.language or page.lang }} + {%- for post in collections['${RSS_UPDATES_COLLECTION}'] | reverse %} + {%- set absolutePostUrl = post.url | htmlBaseUrl(metadata.base) %} + + {{ post.data.title }} + {{ absolutePostUrl }} + {{ post.content | renderTransforms(post.data.page, metadata.base) }} + {{ post.date | dateToRfc822("UTC") }} + {{ metadata.author.name }} + {{ absolutePostUrl }} + + {%- endfor %} + +`; + +export const ATOM_FEED_TEMPLATE = ` + + {{ metadata.title }} + {{ metadata.subtitle }} + + + {{ collections['${ATOM_UPDATES_COLLECTION}'] | getNewestCollectionItemDate | dateToRfc3339 }} + {{ metadata.base | addPathPrefixToFullUrl }} + + {{ metadata.author.name }} + + {%- for post in collections['${ATOM_UPDATES_COLLECTION}'] | reverse %} + {%- set absolutePostUrl %}{{ post.url | htmlBaseUrl(metadata.base) }}{% endset %} + + {{ post.data.title }} + + {{ post.data.dateModified | dateToRfc3339 }} + {{ post.data.datePublished | dateToRfc3339 }} + {{ absolutePostUrl }} + {{ post.data.summary | escape }} + {{ post.content | renderTransforms(post.data.page, metadata.base) | escape }} + + {%- endfor %} +`; + +export async function writeUpdateFeeds(results, publicOutputPath = DEFAULT_PUBLIC_OUTPUT_PATH) { + const feeds = new Map(UPDATE_FEEDS.map(feed => [feed.outputPath, feed])); + const generatedFeeds = results.filter(result => feeds.has(result.url)); + + await fsp.mkdir(publicOutputPath, { recursive: true }); + await Promise.all( + generatedFeeds.map(result => + fsp.writeFile(nodePath.join(publicOutputPath, result.url.slice(1)), result.content, 'utf-8') + ) + ); +} + +function getFeedDates(post) { + const dates = getContentDates(post.data); + const published = new Date(dates.datePublished ?? normalizeContentDate(post.date)); + const modified = new Date(dates.dateModified ?? published); + + return { modified, published }; +} + +export function sortUpdatesByMonth(posts) { + return [...posts].sort((left, right) => { + const leftMonth = new Date(left.data.updateMonth ?? left.date); + const rightMonth = new Date(right.data.updateMonth ?? right.date); + return leftMonth - rightMonth; + }); +} + +function createFeedPost(post, { atom = false } = {}) { + const { modified, published } = getFeedDates(post); + return { + get content() { + return post.content; + }, + data: { + ...post.data, + ...(atom ? { summary: post.data.description } : {}), + dateModified: modified, + datePublished: published + }, + date: atom ? modified : published, + url: post.url + }; +} + +export function getRssUpdatesCollection(collectionApi) { + return sortUpdatesByMonth(collectionApi.getFilteredByTag(UPDATES_COLLECTION)).map(post => createFeedPost(post)); +} + +export function getAtomUpdatesCollection(collectionApi) { + return sortUpdatesByMonth(collectionApi.getFilteredByTag(UPDATES_COLLECTION)).map(post => + createFeedPost(post, { atom: true }) + ); +} + +export function updatesFeedPlugin(eleventyConfig, { publicOutputPath = DEFAULT_PUBLIC_OUTPUT_PATH } = {}) { + eleventyConfig.addCollection(RSS_UPDATES_COLLECTION, getRssUpdatesCollection); + eleventyConfig.addCollection(ATOM_UPDATES_COLLECTION, getAtomUpdatesCollection); + eleventyConfig.addPlugin(rssPlugin); + + for (const feed of UPDATE_FEEDS) { + eleventyConfig.addTemplate(feed.inputPath, feed.type === 'atom' ? ATOM_FEED_TEMPLATE : RSS_FEED_TEMPLATE, { + eleventyExcludeFromCollections: [feed.collection], + eleventyImport: { collections: [feed.collection] }, + layout: false, + metadata: UPDATE_FEED_METADATA, + permalink: feed.outputPath + }); + } + eleventyConfig.on('eleventy.after', ({ results } = {}) => writeUpdateFeeds(results ?? [], publicOutputPath)); +} diff --git a/projects/site/src/_11ty/plugins/updates-feed.test.ts b/projects/site/src/_11ty/plugins/updates-feed.test.ts new file mode 100644 index 0000000000..8b0704b64e --- /dev/null +++ b/projects/site/src/_11ty/plugins/updates-feed.test.ts @@ -0,0 +1,137 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + ATOM_FEED_TEMPLATE, + ATOM_UPDATES_COLLECTION, + RSS_FEED_TEMPLATE, + RSS_UPDATES_COLLECTION, + UPDATE_FEEDS, + UPDATE_FEED_METADATA, + UPDATES_COLLECTION, + getAtomUpdatesCollection, + getRssUpdatesCollection, + updatesFeedPlugin +} from './updates-feed.js'; + +describe('updatesFeedPlugin', () => { + let publicOutputPath; + + afterEach(async () => { + if (publicOutputPath) await rm(publicOutputPath, { force: true, recursive: true }); + }); + + it('should generate RSS and Atom from the updates collection', () => { + const addCollection = vi.fn(); + const addPlugin = vi.fn(); + const addTemplate = vi.fn(); + const on = vi.fn(); + + updatesFeedPlugin({ addCollection, addPlugin, addTemplate, on }); + + expect(addCollection).toHaveBeenCalledWith(RSS_UPDATES_COLLECTION, getRssUpdatesCollection); + expect(addCollection).toHaveBeenCalledWith(ATOM_UPDATES_COLLECTION, getAtomUpdatesCollection); + expect(addPlugin).toHaveBeenCalledOnce(); + expect(addPlugin.mock.calls[0]).toHaveLength(1); + expect(addTemplate.mock.calls).toEqual( + UPDATE_FEEDS.map(feed => [ + feed.inputPath, + feed.type === 'atom' ? ATOM_FEED_TEMPLATE : RSS_FEED_TEMPLATE, + { + eleventyExcludeFromCollections: [feed.collection], + eleventyImport: { collections: [feed.collection] }, + layout: false, + metadata: UPDATE_FEED_METADATA, + permalink: feed.outputPath + } + ]) + ); + expect(on).toHaveBeenCalledWith('eleventy.after', expect.any(Function)); + }); + + it('should use explicit publication dates and descriptions in chronologically sorted feeds', () => { + const post = { + content: '

Post content.

', + data: { + dateModified: '2026-07-23', + datePublished: '2026-07-22', + description: 'The post description.', + title: 'What’s new', + updateMonth: '2026-07-01' + }, + date: new Date('2026-07-22T00:00:00Z'), + url: '/docs/whats-new/07-2026/' + }; + const collectionApi = { + getFilteredByTag: vi.fn().mockReturnValue([ + post, + { + ...post, + data: { ...post.data, title: 'What’s new in June', updateMonth: '2026-06-01' }, + url: '/docs/whats-new/06-2026/' + } + ]) + }; + const rssPosts = getRssUpdatesCollection(collectionApi); + const atomPosts = getAtomUpdatesCollection(collectionApi); + const rssPost = rssPosts[1]; + const atomPost = atomPosts[1]; + + expect(rssPosts.map(entry => entry.url)).toEqual(['/docs/whats-new/06-2026/', '/docs/whats-new/07-2026/']); + expect(atomPosts.map(entry => entry.url)).toEqual(['/docs/whats-new/06-2026/', '/docs/whats-new/07-2026/']); + expect(rssPost).toMatchObject({ + data: { + dateModified: new Date('2026-07-23T00:00:00Z'), + datePublished: new Date('2026-07-22T00:00:00Z') + }, + date: new Date('2026-07-22T00:00:00Z'), + url: post.url + }); + expect(atomPost).toMatchObject({ + data: { + dateModified: new Date('2026-07-23T00:00:00Z'), + datePublished: new Date('2026-07-22T00:00:00Z'), + summary: post.data.description + }, + date: new Date('2026-07-23T00:00:00Z'), + url: post.url + }); + expect(rssPost.content).toBe(post.content); + expect(atomPost.content).toBe(post.content); + expect(collectionApi.getFilteredByTag).toHaveBeenCalledWith(UPDATES_COLLECTION); + expect(post.data).not.toHaveProperty('summary'); + }); + + it('should emit separate published and updated Atom dates', () => { + expect(ATOM_FEED_TEMPLATE).toContain('{{ post.data.dateModified | dateToRfc3339 }}'); + expect(ATOM_FEED_TEMPLATE).toContain('{{ post.data.datePublished | dateToRfc3339 }}'); + expect(RSS_FEED_TEMPLATE).toContain('{{ post.date | dateToRfc822("UTC") }}'); + }); + + it('should escape Atom text constructs', () => { + expect(ATOM_FEED_TEMPLATE).toContain('{{ post.data.summary | escape }}'); + expect(ATOM_FEED_TEMPLATE).toContain( + '{{ post.content | renderTransforms(post.data.page, metadata.base) | escape }}' + ); + }); + + it('should publish generated feeds to the Vite public directory', async () => { + publicOutputPath = await mkdtemp(join(tmpdir(), 'elements-updates-feed-')); + const on = vi.fn(); + const atom = 'updates'; + updatesFeedPlugin({ addCollection: vi.fn(), addPlugin: vi.fn(), addTemplate: vi.fn(), on }, { publicOutputPath }); + const afterBuild = on.mock.calls.find(([event]) => event === 'eleventy.after')?.[1]; + + await afterBuild({ + results: [ + { content: 'updates', url: '/feed.xml' }, + { content: atom, url: '/atom.xml' }, + { content: '', url: '/index.html' } + ] + }); + + await expect(readFile(join(publicOutputPath, 'feed.xml'), 'utf-8')).resolves.toBe('updates'); + await expect(readFile(join(publicOutputPath, 'atom.xml'), 'utf-8')).resolves.toBe(atom); + }); +}); diff --git a/projects/site/src/_11ty/transforms/html-minify.js b/projects/site/src/_11ty/transforms/html-minify.js index f409d6d1d8..8bbe6a2287 100644 --- a/projects/site/src/_11ty/transforms/html-minify.js +++ b/projects/site/src/_11ty/transforms/html-minify.js @@ -3,7 +3,17 @@ import htmlMinify from 'html-minifier-next'; -export async function htmlMinifyTransform(content) { +const IS_FULL_HTML_DOCUMENT = /]/i; + +function isHtmlOutput(outputPath, content) { + if (outputPath) return outputPath.endsWith('.html'); + + return IS_FULL_HTML_DOCUMENT.test(content); +} + +export async function htmlMinifyTransform(content, outputPath) { + if (!isHtmlOutput(outputPath ?? this.page?.outputPath, content)) return content; + let result = ''; try { result = process.env.ELEVENTY_RUN_MODE !== 'watch' ? await minifyHTML(content) : content; diff --git a/projects/site/src/_11ty/transforms/html-minify.test.ts b/projects/site/src/_11ty/transforms/html-minify.test.ts new file mode 100644 index 0000000000..f96dd6765f --- /dev/null +++ b/projects/site/src/_11ty/transforms/html-minify.test.ts @@ -0,0 +1,25 @@ +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { htmlMinifyTransform } from './html-minify.js'; + +vi.stubEnv('ELEVENTY_RUN_MODE', 'build'); + +afterAll(() => { + vi.unstubAllEnvs(); +}); + +describe('htmlMinifyTransform', () => { + it('should minify html output', async () => { + const html = '\n\n Updates\n'; + const result = await htmlMinifyTransform.call({ page: {} }, html, '/index.html'); + + expect(result).not.toBe(html); + expect(result).not.toContain('\n'); + expect(result).toContain(''); + }); + + it('should preserve XML output', async () => { + const xml = '\n'; + + await expect(htmlMinifyTransform.call({ page: {} }, xml, '/atom.xml')).resolves.toBe(xml); + }); +}); diff --git a/projects/site/src/_11ty/utils/content-dates.js b/projects/site/src/_11ty/utils/content-dates.js new file mode 100644 index 0000000000..38d0069221 --- /dev/null +++ b/projects/site/src/_11ty/utils/content-dates.js @@ -0,0 +1,22 @@ +export function normalizeContentDate(value) { + if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString(); + if (typeof value !== 'string') return null; + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +export function getContentDates(data = {}) { + return { + datePublished: normalizeContentDate( + data.datePublished ?? data.published ?? data.git?.created ?? data.git?.createdTime + ), + dateModified: normalizeContentDate( + data.dateModified ?? data.modified ?? data.git?.modified ?? data.git?.modifiedTime + ) + }; +} + +export function getExplicitModifiedDate(data = {}) { + return normalizeContentDate(data.dateModified ?? data.modified); +} diff --git a/projects/site/src/_11ty/utils/content-dates.test.ts b/projects/site/src/_11ty/utils/content-dates.test.ts new file mode 100644 index 0000000000..d4ec4da321 --- /dev/null +++ b/projects/site/src/_11ty/utils/content-dates.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeContentDate } from './content-dates.js'; + +describe('normalizeContentDate', () => { + const date = '2026-07-22T12:00:00.000Z'; + + it('should return null for invalid Date instances', () => { + expect(normalizeContentDate(new Date('invalid'))).toBeNull(); + }); + + it('should normalize valid Date instances', () => { + expect(normalizeContentDate(new Date(date))).toBe(date); + }); + + it('should normalize valid date strings', () => { + expect(normalizeContentDate(date)).toBe(date); + }); +}); diff --git a/projects/site/src/docs/whats-new/04-2026.md b/projects/site/src/docs/whats-new/04-2026.md new file mode 100644 index 0000000000..1d3ec6ee26 --- /dev/null +++ b/projects/site/src/docs/whats-new/04-2026.md @@ -0,0 +1,27 @@ +--- +{ + title: 'What’s new in NVIDIA Elements: April 2026', + description: 'April releases improve Data Grid rendering, expand lint safeguards, and make CLI context more precise.', + layout: 'whats-new.11ty.js', + tags: ['whats-new', 'updates'], + date: '2026-07-22', + datePublished: '2026-07-22', + dateModified: '2026-07-22', + updateMonth: '2026-04-01' +} +--- + +April improves dynamic layouts, catches composition problems earlier, and gives agent workflows leaner context. + +## Highlights + +- **Keep dynamic Data Grids responsive.** [Data Grid](/docs/elements/data-grid/) batches column measurements before applying widths, reducing repeated layout work when columns resize or render again. +- **Catch composition and migration issues earlier.** [Elements lint rules](/docs/lint/) now detect deprecated utility values, slotted popovers, and Tailwind classes on Elements components while better understanding popover triggers and Lit bindings. +- **Send agents leaner, more precise context.** The [Elements CLI](/docs/cli/) reduces example and tool metadata overhead, matches package names more strictly, and improves migration paths and published-version checks. + +## Released packages + +- `@nvidia-elements/cli` 0.0.8–0.0.9 +- `@nvidia-elements/core` 0.0.8–0.0.11 +- `@nvidia-elements/lint` 0.1.0–0.1.2, 0.2.0 +- `@nvidia-elements/themes` 0.0.9 diff --git a/projects/site/src/docs/whats-new/05-2026.md b/projects/site/src/docs/whats-new/05-2026.md new file mode 100644 index 0000000000..acd3b51f34 --- /dev/null +++ b/projects/site/src/docs/whats-new/05-2026.md @@ -0,0 +1,35 @@ +--- +{ + title: 'What’s new in NVIDIA Elements: May 2026', + description: 'May releases add localized number formatting, flexible Combobox tags, agent tooling, and safer migration checks.', + layout: 'whats-new.11ty.js', + tags: ['whats-new', 'updates'], + date: '2026-07-22', + datePublished: '2026-07-22', + dateModified: '2026-07-22', + updateMonth: '2026-05-01' +} +--- + +May adds new formatting and selection APIs while expanding the tools that help teams build and migrate Elements projects. + +## Highlights + +- **Format numbers for any locale.** The new [Format Number](/docs/elements/format-number/) component handles decimals, currencies, percentages, units, compact notation, and server-rendered fallback content through `Intl.NumberFormat`. +- **Control multiselect tag layouts.** [Combobox](/docs/elements/combobox/) can hide or wrap selected tags with `tag-layout`, giving dense selections more predictable overflow behavior and replacing the deprecated `notags` attribute. +- **Bring Elements guidance into agent workflows.** The CLI can serve authoring and migration [Skills](/docs/skills/), while [MCP Apps](/docs/integrations/mcp-apps/) add interactive icon, token, and example previews with clearer error feedback. +- **Catch migration problems before runtime.** Expanded [lint rules](/docs/lint/) identify deprecated CSS imports and packages, invalid popover composition, and unsupported Tailwind utilities on Elements components. +- **Keep controls and editors dependable.** Button contrast and read-only state handling are more consistent, delayed popovers clean up correctly, and Monaco restores reliable paste behavior. + +## Released packages + +- `@nvidia-elements/cli` 0.0.10–0.0.12, 0.1.0, 0.2.0–0.2.3 +- `@nvidia-elements/code` 0.0.8–0.0.9 +- `@nvidia-elements/core` 0.0.12, 0.1.0–0.1.4, 0.2.0–0.2.1 +- `@nvidia-elements/create` 0.0.8 +- `@nvidia-elements/forms` 0.0.8 +- `@nvidia-elements/lint` 0.2.1, 0.3.0–0.3.1 +- `@nvidia-elements/markdown` 0.0.8–0.0.9 +- `@nvidia-elements/monaco` 0.0.8–0.0.11 +- `@nvidia-elements/styles` 0.0.8–0.0.9 +- `@nvidia-elements/themes` 0.0.10–0.0.11 diff --git a/projects/site/src/docs/whats-new/06-2026.md b/projects/site/src/docs/whats-new/06-2026.md new file mode 100644 index 0000000000..b5ad9d5eb4 --- /dev/null +++ b/projects/site/src/docs/whats-new/06-2026.md @@ -0,0 +1,34 @@ +--- +{ + title: 'What’s new in NVIDIA Elements: June 2026', + description: 'June promotes Elements 2.0, adds reusable form-control mixins, expands starters, and improves CLI installation.', + layout: 'whats-new.11ty.js', + tags: ['whats-new', 'updates'], + date: '2026-07-22', + datePublished: '2026-07-22', + dateModified: '2026-07-22', + updateMonth: '2026-06-01' +} +--- + +June moves Elements onto the stable 2.0 release line and broadens the APIs and starters available for production projects. + +## Highlights + +- **Prepare for the 2.0 upgrade.** The stable 2.0 packages remove deprecated component and style APIs. Review the [migration guide](/docs/about/migration/) before upgrading; updated lint rules identify old tags, attributes, slots, popover settings, and CSS custom properties. +- **Build consistent form controls.** The Forms package adds reusable button, checkbox, select, and slider mixins that coordinate native behavior, state, validation, commands, and popover triggers. +- **Start more kinds of applications.** New starters cover [MCP Apps](/docs/integrations/mcp-apps/), [Lit libraries](/docs/integrations/lit-library/), and [HTMX with Go](/docs/integrations/go-htmx/). The existing [Go starter](/docs/integrations/go/) also gains corrected CDN loading and a simpler server setup. +- **Install the CLI across platforms.** The [Elements CLI](/docs/cli/) adds a PowerShell installer for Windows and strengthens shell installation, version checks, build approvals, and error feedback. +- **Keep controls stable through lifecycle changes.** Forms improve ARIA state management, while Core restores native reset behavior, prevents duplicate observers after reconnection, and adapts scoped registries and popovers to Chromium changes. + +## Released packages + +- `@nvidia-elements/cli` 0.3.0–0.3.1, 2.0.0–2.0.1, 2.1.0–2.1.3 +- `@nvidia-elements/code` 2.0.0–2.0.1 +- `@nvidia-elements/core` 0.2.2–0.2.4, 2.0.0–2.0.2 +- `@nvidia-elements/forms` 0.1.0, 2.0.0–2.0.1 +- `@nvidia-elements/lint` 0.4.0, 2.0.0–2.0.1 +- `@nvidia-elements/markdown` 2.0.0–2.0.1 +- `@nvidia-elements/monaco` 0.0.12, 2.0.0–2.0.1 +- `@nvidia-elements/styles` 0.0.10, 2.0.0–2.0.2 +- `@nvidia-elements/themes` 2.0.0 diff --git a/projects/site/src/docs/whats-new/index.11ty.js b/projects/site/src/docs/whats-new/index.11ty.js new file mode 100644 index 0000000000..686bda6ea2 --- /dev/null +++ b/projects/site/src/docs/whats-new/index.11ty.js @@ -0,0 +1,65 @@ +import { + formatUpdateMonth, + getUpdateMonth, + renderUpdatesFeedLink, + sortUpdatesNewestFirst +} from '../../_11ty/layouts/whats-new.11ty.js'; + +export const data = { + title: 'What’s New', + description: + 'Monthly NVIDIA Elements Design System highlights, package updates, and links to detailed release information.', + layout: 'docs.11ty.js', + permalink: '/docs/whats-new/' +}; + +export function render(data) { + const entries = sortUpdatesNewestFirst(data.collections['whats-new'] ?? []); + + return this.renderTemplate( + /* html */ ` +# ${data.title} + + + +
+

Follow the latest NVIDIA Elements features, fixes, and package releases.

+ ${renderUpdatesFeedLink()} +
+ +
+${entries + .map( + entry => /* html */ ` + + + +

Monthly update -

+
+ +

${entry.data.description}

+
+ +
+ Read update +
+
+
+
+` + ) + .join('')} +
+`, + 'md' + ); +} diff --git a/projects/site/src/examples/index.test.ts b/projects/site/src/examples/index.test.ts index 21239698fe..4c790cec9e 100644 --- a/projects/site/src/examples/index.test.ts +++ b/projects/site/src/examples/index.test.ts @@ -33,6 +33,7 @@ afterEach(() => { vi.unstubAllEnvs(); vi.doUnmock('../index.11tydata.js'); vi.doUnmock('@internals/tools/playground'); + vi.resetModules(); }); describe('example page urls', () => {