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
10 changes: 9 additions & 1 deletion site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,19 @@ export default defineConfig({
'Capture, frame and export showcase images of desktop and web apps for READMEs and portfolios.',
favicon: '/favicon.svg',
logo: { src: './src/assets/mark.svg', alt: '' },
customCss: ['./src/styles/theme.css'],
// The noctcore theme in its load order: the token contract, the shared
// pieces, the Observatory preset (it may tune a piece), then this site's own rules.
customCss: [
'./src/styles/noctcore/base.css',
'./src/styles/noctcore/components.css',
'./src/styles/noctcore/presets/observatory.css',
'./src/styles/site.css',
],
components: {
Head: './src/components/Head.astro',
Footer: './src/components/Footer.astro',
SiteTitle: './src/components/SiteTitle.astro',
SocialIcons: './src/components/SocialIcons.astro',
},
social: [
{ icon: 'github', label: 'GitHub', href: 'https://github.com/noctcore/showcase-kit' },
Expand Down
4 changes: 2 additions & 2 deletions site/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions site/ec.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineEcConfig } from '@astrojs/starlight/expressive-code';

import { noctcoreCodeConfig } from './src/styles/noctcore/expressive-code.mjs';

// Code frames follow the Observatory preset: its dark and light code themes,
// the frame chrome on Starlight's colours, and the code area on --nc-code-bg.
export default defineEcConfig({
...noctcoreCodeConfig('observatory'),
});
2 changes: 1 addition & 1 deletion site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"@astrojs/markdown-satteri": "0.4.1",
"@astrojs/starlight": "^0.42.2",
"@fontsource-variable/jetbrains-mono": "5.3.0",
"@fontsource-variable/space-grotesk": "5.3.0",
"@fontsource-variable/newsreader": "5.3.0",
"astro": "^7.3.3",
"satteri": "0.10.5"
},
Expand Down
28 changes: 17 additions & 11 deletions site/scripts/changelog/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,27 @@ describe('renderChangelogPage', () => {
expect(headings).toEqual(['## 1.0.0 {#v1.0.0}', '## 0.9.0 {#v0.9.0}']);
});

test('the meta line has the date, npm, the real tag and a compare link across tag formats', () => {
test('the meta line is one .nc-release-meta line: the date, npm, the real tag and a compare link across tag formats', () => {
expect(page).toContain(
'Released <time datetime="2026-03-02T08:00:00Z">2026-03-02</time> · ' +
'[npm](https://www.npmjs.com/package/@scope/pkg/v/1.0.0) · ' +
'[tag `v1.0.0`](https://github.com/o/r/releases/tag/v1.0.0) · ' +
'[changes since 0.9.0](https://github.com/o/r/compare/%40scope%2Fpkg%400.9.0...v1.0.0)',
'## 1.0.0 {#v1.0.0}\n\n<p class="nc-release-meta">' +
'<span>Released <time datetime="2026-03-02T08:00:00Z">2026-03-02</time></span>' +
'<a href="https://www.npmjs.com/package/@scope/pkg/v/1.0.0">npm</a>' +
'<a href="https://github.com/o/r/releases/tag/v1.0.0">tag <code>v1.0.0</code></a>' +
'<a href="https://github.com/o/r/compare/%40scope%2Fpkg%400.9.0...v1.0.0">changes since 0.9.0</a></p>\n',
);
// The oldest release has nothing to compare with.
expect(page).toContain(
'Released <time datetime="2026-02-01T23:59:59Z">2026-02-01</time> · [npm](https://www.npmjs.com/package/@scope/pkg/v/0.9.0) · ' +
'[tag `@scope/pkg@0.9.0`](https://github.com/o/r/releases/tag/%40scope%2Fpkg%400.9.0)\n',
'<p class="nc-release-meta"><span>Released <time datetime="2026-02-01T23:59:59Z">2026-02-01</time></span>' +
'<a href="https://www.npmjs.com/package/@scope/pkg/v/0.9.0">npm</a>' +
'<a href="https://github.com/o/r/releases/tag/%40scope%2Fpkg%400.9.0">tag <code>@scope/pkg@0.9.0</code></a></p>\n',
);
});

test('an untagged release says so and has no tag or compare link', () => {
const model = fixtureModel();
delete model.releases[0]!.date.tag;
const untagged = renderChangelogPage(model);
expect(untagged).toContain('[npm](https://www.npmjs.com/package/@scope/pkg/v/1.0.0) · not tagged yet\n');
expect(untagged).toContain('<a href="https://www.npmjs.com/package/@scope/pkg/v/1.0.0">npm</a><span>not tagged yet</span></p>\n');
expect(untagged).not.toContain('/compare/');
});

Expand All @@ -62,7 +64,7 @@ describe('renderChangelogPage', () => {
test('entries keep their links, thank everyone but the maintainer and keep follow-on lines in the item', () => {
expect(page).toContain(
[
'- [#12](https://github.com/o/r/pull/12) [`abc1234`](https://github.com/o/r/commit/abc1234def) Thanks [@guest](https://github.com/guest)! Drops `old()`.',
'- [#12](https://github.com/o/r/pull/12) <a class="nc-sha" href="https://github.com/o/r/commit/abc1234def">abc1234</a> Thanks [@guest](https://github.com/guest)! Drops `old()`.',
'',
' Second paragraph with `a | b` and `` a`b `` and a table-like `x|y|z`.',
'',
Expand All @@ -73,8 +75,12 @@ describe('renderChangelogPage', () => {
' :::',
].join('\n'),
);
expect(page).toContain('- [`0123abc`](https://github.com/o/r/commit/0123abc999) A maintainer fix.\n\n :::tip[Tip]\n');
expect(page).toContain('- [`def5678`](https://github.com/o/r/commit/def5678abc) A fix with a link but no author.\n');
expect(page).toContain(
'- <a class="nc-sha" href="https://github.com/o/r/commit/0123abc999">0123abc</a> A maintainer fix.\n\n :::tip[Tip]\n',
);
expect(page).toContain(
'- <a class="nc-sha" href="https://github.com/o/r/commit/def5678abc">def5678</a> A fix with a link but no author.\n',
);
expect(page).toContain('- First release, written by hand.\n');
expect(page).not.toContain('@maint');
});
Expand Down
39 changes: 26 additions & 13 deletions site/scripts/changelog/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { replaceAlerts, toAside, type Alert } from './alerts';
import type { ChangelogModel, DatedRelease } from './model';
import type { BumpType, Entry, Link } from './parse';
import { nextBump, type PendingChangeset } from './pending';
import { escapeXml } from './xml';

export const GROUP_TITLES: Readonly<Record<BumpType, string>> = {
major: 'Major changes',
Expand Down Expand Up @@ -40,28 +41,37 @@ export const compareUrl = (repo: string, from: string, to: string) =>
`https://github.com/${repo}/compare/${encodeURIComponent(from)}...${encodeURIComponent(to)}`;

const link = (text: string, url: string) => `[${text}](${url})`;
/** An HTML link, for markup Markdown cannot carry: a class, or a link inside an HTML block. */
const htmlLink = (html: string, url: string, className?: string) =>
`<a${className ? ` class="${className}"` : ''} href="${escapeXml(url)}">${html}</a>`;

/** A commit link as the feed shows it: the short hash as code. */
const commitCode = (commit: Link) => link(`\`${commit.label}\``, commit.url);
/** A commit link as the changelog page shows it: the theme's quiet .nc-sha. */
const commitSha = (commit: Link) => htmlLink(escapeXml(commit.label), commit.url, 'nc-sha');

/** The first line of an entry: links, thanks for anyone but the maintainer, the summary. */
function entryPrefix(entry: Entry, maintainer: string): string {
function entryPrefix(entry: Entry, maintainer: string, commitLink: (commit: Link) => string): string {
const parts: string[] = [];
if (entry.pr) parts.push(link(entry.pr.label, entry.pr.url));
if (entry.commit) parts.push(link(`\`${entry.commit.label}\``, entry.commit.url));
if (entry.commit) parts.push(commitLink(entry.commit));
const thanked = entry.authors.filter((author: Link) => author.label !== maintainer);
if (thanked.length > 0) parts.push(`Thanks ${thanked.map((a) => link(`@${a.label}`, a.url)).join(', ')}!`);
return parts.map((part) => `${part} `).join('');
}

/**
* One entry as a Markdown list item, alerts turned into whatever `alert`
* renders. Follow-on lines are indented two spaces to stay in the item.
* renders and the commit link into whatever `commitLink` renders (the short
* hash as code by default). Follow-on lines are indented two spaces to stay in the item.
*/
export function entryMarkdown(
entry: Entry,
options: { maintainer: string; where: string; alert: (alert: Alert) => string[] },
options: { maintainer: string; where: string; alert: (alert: Alert) => string[]; commitLink?: (commit: Link) => string },
): string {
const body = replaceAlerts(entry.body, options.alert, options.where);
const [first = '', ...rest] = body.split('\n');
return [`- ${entryPrefix(entry, options.maintainer)}${first}`, ...rest.map((line) => (line === '' ? '' : ` ${line}`))].join(
return [`- ${entryPrefix(entry, options.maintainer, options.commitLink ?? commitCode)}${first}`, ...rest.map((line) => (line === '' ? '' : ` ${line}`))].join(
'\n',
);
}
Expand All @@ -77,25 +87,28 @@ function groupsMarkdown(
'',
GROUP_LEGENDS[group.type],
'',
...group.entries.flatMap((entry) => [entryMarkdown(entry, { maintainer, where, alert: toAside }), '']),
...group.entries.flatMap((entry) => [entryMarkdown(entry, { maintainer, where, alert: toAside, commitLink: commitSha }), '']),
]);
}

/** Date, npm, tag and compare links under a version heading. */
/**
* Date, npm, tag and compare links under a version heading, as one line (the
* theme's .nc-release-meta). An HTML block, so its links are HTML too.
*/
function metaLine(model: ChangelogModel, release: DatedRelease, previous: DatedRelease | undefined): string {
const parts = [
`Released <time datetime="${release.date.timestamp}">${release.date.day}</time>`,
link('npm', npmVersionUrl(model.packageName, release.version)),
`<span>Released <time datetime="${release.date.timestamp}">${release.date.day}</time></span>`,
htmlLink('npm', npmVersionUrl(model.packageName, release.version)),
];
const { tag } = release.date;
if (tag) {
parts.push(link(`tag \`${tag}\``, tagUrl(model.repo, tag)));
parts.push(htmlLink(`tag <code>${escapeXml(tag)}</code>`, tagUrl(model.repo, tag)));
const from = previous?.date.tag;
if (from) parts.push(link(`changes since ${previous.version}`, compareUrl(model.repo, from, tag)));
if (from) parts.push(htmlLink(`changes since ${previous.version}`, compareUrl(model.repo, from, tag)));
} else {
parts.push('not tagged yet');
parts.push('<span>not tagged yet</span>');
}
return parts.join(' · ');
return `<p class="nc-release-meta">${parts.join('')}</p>`;
}

function unreleasedMarkdown(pending: readonly PendingChangeset[], maintainer: string): string[] {
Expand Down
61 changes: 61 additions & 0 deletions site/scripts/theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

import { SITE_DIR } from './site';
import {
DARK_SELECTOR,
LIGHT_SELECTOR,
PRESET_FILE,
SHARED_TOKEN,
blockTokens,
presetContract,
readTheme,
tokensDeclared,
tokensRead,
} from './theme';

describe('theme contract', () => {
const preset = readFileSync(PRESET_FILE, 'utf8');
const contract = presetContract();
const dark = blockTokens(preset, DARK_SELECTOR);
const light = blockTokens(preset, LIGHT_SELECTOR);

test('base.css and components.css read preset tokens (the contract is not empty)', () => {
expect(contract).toContain('--nc-bg');
expect(contract).toContain('--nc-font-body');
expect(contract).toContain('--nc-motif');
});

test('the preset sets every token base.css and components.css read, in its dark block', () => {
expect(contract.filter((token) => !dark.has(token))).toEqual([]);
});

test('the preset sets every colour token again in its light block', () => {
const perMode = contract.filter((token) => !SHARED_TOKEN.test(token));
expect(perMode.filter((token) => !light.has(token))).toEqual([]);
});

test('the preset reads no token it leaves unset', () => {
const own = new Set([...dark, ...light]);
const unset = [...tokensRead(preset)].filter((token) => !own.has(token));
expect(unset).toEqual([]);
});

test("the site's own rules read only tokens the theme sets", () => {
const site = readFileSync(join(SITE_DIR, 'src', 'styles', 'site.css'), 'utf8');
const theme = readTheme('base.css') + readTheme('components.css');
const set = new Set([...tokensDeclared(theme), ...dark, ...light]);
expect([...tokensRead(site)].filter((token) => !set.has(token))).toEqual([]);
});

test('the preset names no Starlight colour variable: base.css maps them once', () => {
expect(preset).not.toMatch(/--sl-color-[\w-]+\s*:/);
});

test('astro.config.mjs loads the theme in its order: base, components, the preset, then the site', () => {
const config = readFileSync(join(SITE_DIR, 'astro.config.mjs'), 'utf8');
const files = [...config.matchAll(/'\.\/src\/styles\/([^']+\.css)'/g)].map((m) => m[1]);
expect(files).toEqual(['noctcore/base.css', 'noctcore/components.css', 'noctcore/presets/observatory.css', 'site.css']);
});
});
56 changes: 56 additions & 0 deletions site/scripts/theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Reads the noctcore theme files (src/styles/noctcore/) for the contract test:
* which --nc-* tokens base.css and components.css read, and which ones a
* preset sets for dark and for light.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

import { SITE_DIR } from './site';

export const THEME_DIR = join(SITE_DIR, 'src', 'styles', 'noctcore');

/** The preset this site ships, as in astro.config.mjs `customCss`. */
export const PRESET_FILE = join(THEME_DIR, 'presets', 'observatory.css');

/** The selectors that open a preset's dark block (Starlight's default) and its light block. */
export const DARK_SELECTOR = ':root,\n::backdrop';
export const LIGHT_SELECTOR = ":root[data-theme='light'],\n[data-theme='light'] ::backdrop";

/**
* Tokens with one value for both modes (README "Type, shape and labels"): a
* preset sets them once in its dark block. Every other token is a colour or
* the motif and needs a value in each mode.
*/
export const SHARED_TOKEN = /^--nc-(?:font-|label-|radius$|measure$)/;

export const readTheme = (name: string) => readFileSync(join(THEME_DIR, name), 'utf8');

const stripComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, '');

/** Every --nc-* custom property a stylesheet reads through var(). */
export function tokensRead(css: string): Set<string> {
return new Set([...stripComments(css).matchAll(/var\(\s*(--nc-[\w-]+)/g)].map((m) => m[1]!));
}

/** Every --nc-* custom property a stylesheet declares, anywhere. */
export function tokensDeclared(css: string): Set<string> {
return new Set([...stripComments(css).matchAll(/(--nc-[\w-]+)\s*:/g)].map((m) => m[1]!));
}

/** The declarations of the rule whose selector list is exactly `selector`, at the top level. */
export function blockTokens(css: string, selector: string): Set<string> {
const text = stripComments(css);
const start = text.indexOf(`${selector} {`);
if (start === -1) throw new Error(`theme: no "${selector.replace(/\n/g, ' ')}" block`);
const open = text.indexOf('{', start);
const close = text.indexOf('}', open);
return tokensDeclared(text.slice(open + 1, close));
}

/** Tokens base.css and components.css read that neither file sets itself, so a preset must. */
export function presetContract(): string[] {
const shared = readTheme('base.css') + readTheme('components.css');
const own = tokensDeclared(shared);
return [...tokensRead(shared)].filter((token) => !own.has(token)).sort();
}
4 changes: 3 additions & 1 deletion site/src/components/GalleryImage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ const facts = [
display: block;
line-height: 0;
}
/* The frame radius of the theme's figures (.nc-frame), so the gallery, the
splash banner and a window-frame figure share one corner. */
.gallery-figure img {
width: 100%;
height: auto;
border-radius: 0.5rem;
border-radius: calc(var(--nc-radius) * 1.25);
}
figcaption {
display: flex;
Expand Down
4 changes: 0 additions & 4 deletions site/src/components/Head.astro
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
---
import Default from '@astrojs/starlight/components/Head.astro';
// Space Grotesk for headings and the wordmark, JetBrains Mono for code. Both
// self-hosted, so the site makes no third-party font requests.
import '@fontsource-variable/space-grotesk';
import '@fontsource-variable/jetbrains-mono';

// The release feed (src/pages/changelog.xml.ts), so feed readers find it from any page.
const feed = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/changelog.xml`;
Expand Down
20 changes: 6 additions & 14 deletions site/src/components/ReferenceCliOptions.astro
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function flag(option: CliOption): string {
---

<div class="reference-wrap">
<table class="reference-table">
<table class="nc-ref">
<thead>
<tr>
<th scope="col">Option</th>
Expand All @@ -35,11 +35,11 @@ function flag(option: CliOption): string {
{
options.map((option) => (
<tr>
<td class="reference-flag">
<td class="reference-flag" data-label="Option">
<code>{flag(option)}</code>
</td>
{!command && (
<td>
<td data-label="Applies to">
{option.exits
? 'any command line: prints, then exits without running a command'
: option.global
Expand All @@ -52,7 +52,9 @@ function flag(option: CliOption): string {
))}
</td>
)}
<td>{option.description}</td>
<td class="reference-meaning" data-label="Meaning">
{option.description}
</td>
</tr>
))
}
Expand All @@ -61,16 +63,6 @@ function flag(option: CliOption): string {
</div>

<style>
.reference-wrap {
overflow-x: auto;
}

.reference-table {
display: table;
width: 100%;
font-size: var(--sl-text-sm);
}

.reference-flag code {
white-space: nowrap;
}
Expand Down
Loading
Loading