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
8 changes: 7 additions & 1 deletion .ai/plans/fumadocs-portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ release cuts the preset, the fonts and the layout. `app.css` imports
links their Google Fonts stylesheet, the generated `theme.css` holds only the
primary colour's two rules, and `layout.tsx` renders the notebook layout alone.

*Amended 2026-09-25* (PR #366): the Google Fonts link is gone. `app.css`
imports `@fontsource-variable/geist` and `geist-mono`, which the CLI copies into
the build project, and sets `--font-sans` / `--font-mono` in `@theme`.

*Amended 2026-09-24* (`.ai/plans/generated-pages.md`, sections 4 and 6):
`src/lib/source.ts` declares a second collection over the relative literal
`'generated'`, where the CLI writes the SDK and context plugin pages from the
Expand Down Expand Up @@ -311,7 +315,7 @@ Follow the five-layer conventions in `.ai/instructions.md` and the skills in
- Large, densely connected APIs still yield large output folders (section 5:
a Stripe-shaped spec approaches 1 GB at full size). The remaining lever is the
second copy of each page's payload; see section 12.
- Generated site is not self-contained: Geist fonts load from Google Fonts at runtime and Fumadocs' page actions link to ChatGPT/Claude/Cursor (decided to keep as-is for v1). *Amended 2026-09-23:* the fonts are now a shortlist in `portal.brand.fonts`, each still from Google Fonts, and `system` for both makes no font request at all; `portal.ai.pageActions: false` removes the external links. *Amended 2026-09-24:* the shortlist is cut for the first release, so Geist and Geist Mono are fixed and load from Google Fonts again, as in v1; `portal.ai.pageActions: false` still removes the external links.
- Generated site is not self-contained: Geist fonts load from Google Fonts at runtime and Fumadocs' page actions link to ChatGPT/Claude/Cursor (decided to keep as-is for v1). *Amended 2026-09-23:* the fonts are now a shortlist in `portal.brand.fonts`, each still from Google Fonts, and `system` for both makes no font request at all; `portal.ai.pageActions: false` removes the external links. *Amended 2026-09-24:* the shortlist is cut for the first release, so Geist and Geist Mono are fixed and load from Google Fonts again, as in v1; `portal.ai.pageActions: false` still removes the external links. *Amended 2026-09-25:* the fonts are bundled with the site from `@fontsource-variable` (PR #366), because the Google stylesheet held up first paint and hydration by 1–3 s on a first visit; only the page actions still reach another origin.
- Verified on Windows only; macOS/Linux via the new CI matrix.
- Try-it playground requires CORS on the customer's API (Fumadocs' proxy needs a server). Out of scope.
- Fumadocs UI is mid-transition to `@fumadocs/base-ui`; fumadocs-openapi has had three majors in about a year; several single-maintainer and 0.x packages in the tree (`lucide-react`, `yuku-analyzer` with native binaries, `zbsearch`, `h3` RC). Mitigated by pinning and the e2e test.
Expand Down Expand Up @@ -377,6 +381,7 @@ the new-layout branch in the sample repository.
`--destination` and `--no-reload`. Release notes also mention
`apimatic autocomplete --refresh-cache`, the deferred `$ref` file-read risk
(section 9) and that generated sites load Geist from Google Fonts.
*Amended 2026-09-25:* drop the Google Fonts line; the fonts are bundled with the site (PR #366).
3. **Sample repository** `sample-docs-as-code-portal`: done 2026-09-17. `v2` is a
permanent branch for the CLI 2 layout (`54d2cb3`: spec renamed
`spec/petstore.json`, deploy workflow pinned to `@apimatic/cli@2`, artifact
Expand Down Expand Up @@ -435,3 +440,4 @@ the new-layout branch in the sample repository.
`discriminator.mapping` and JSON-pointer-escaped names.
- Section 9 items stand: `$ref` file-read/SSRF deferred, Google Fonts at
runtime, try-it playground needs CORS on the customer's API.
*Amended 2026-09-25:* the Google Fonts item is closed; the fonts are bundled (PR #366).
10 changes: 9 additions & 1 deletion .ai/plans/portal-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Delivered as **one PR** (section 11).
| `languages` | The shared top-level block `sdk publish` writes. Required for the portal: at least one entry, each keyed by a `Language` enum value and holding an object. Its `publishing` record (#350) is optional: an entry without one is a language that is wanted but not yet published, and counts. An unknown language key is an error on the portal path (the plugin path stays lenient and preserves it). |
| Layout | Fumadocs' notebook layout with the tabs in the header, fixed (section 15). |
| Primary colour | Overrides `--color-fd-primary`, a contrast-picked `--color-fd-primary-foreground`, and `--color-fd-ring`. Fumadocs' neutral theme supplies every other token (section 15). |
| Fonts | Geist and Geist Mono from Google Fonts, fixed in the template (section 15). |
| Fonts | Geist and Geist Mono from Google Fonts, fixed in the template (section 15). *Amended 2026-09-25:* bundled with the portal instead (section 15). |
| Favicon default | The light logo; none when there is no logo. The APIMatic mark is never shipped onto a customer's domain. |
| Site name default | Derived from `info.title` when the project has exactly one spec. With two or more, `site.name` is required. |
| Unknown keys | Reported by dotted path, `'portal.brand.colour' is not a 'portal' setting.` No near-miss hints, including for today's flat keys: `title` is reported as unknown like any other key. The live `url`/`site` → `siteUrl` hints are deleted. |
Expand Down Expand Up @@ -685,6 +685,7 @@ did not change the pins. Re-check each entry if a pin moves.
`getOpenAPIPageProps().payload.bundled` carries.
- **Fonts.** `app.css` loads Geist and Geist Mono from Google Fonts and sets
`--default-font-family` / `--default-mono-font-family` in `@theme`.
*Amended 2026-09-25:* see the note at the end of section 15.

## 11. Delivery

Expand Down Expand Up @@ -910,6 +911,13 @@ and `ai.pageActions`.
setting seldom delivered one, and each family's weight axis had to be checked
by hand against Google, where one wrong entry fails the whole font request.
The identity loses `fontsUrl`, and `theme.css` its `@theme` block.
*Amended 2026-09-25 (PR #366):* the fonts are bundled, not linked from Google:
the stylesheet held up first paint and hydration by 1–3 s on a first visit.
`app.css` imports `@fontsource-variable/geist` and `geist-mono`, which the CLI
copies into the build project (`COPIED_DEPENDENCIES`) so their `url()`s
resolve on the project's drive, and sets `--font-sans` / `--font-mono` so the
`font-sans` and `font-mono` classes get Geist too. The link in `__root.tsx`
is gone.
- **`home.cta`** is gone, and the `home` namespace with it; no home page has a
button under its title, which was the default. A portal that wants a landing
page writes `content/index.md`, which can link wherever it likes. The
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
"@apimatic/sdk": "0.2.0-alpha.10",
"@clack/core": "1.0.0-alpha.1",
"@clack/prompts": "1.0.0-alpha.1",
"@fontsource-variable/geist": "5.3.0",
"@fontsource-variable/geist-mono": "5.3.0",
"@fumadocs/api-docs": "0.2.8",
"@oclif/core": "^4.2.8",
"@oclif/plugin-autocomplete": "^3.2.24",
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

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

8 changes: 6 additions & 2 deletions portal-template/NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,9 @@ its own copyright:
Shiki, Tailwind CSS, React and Vite are likewise MIT licensed; see their own
packages for the full notices.

The generated portal additionally loads the Geist typeface from Google Fonts at
runtime.
The generated portal also bundles the Geist and Geist Mono typefaces, from the
Fontsource packages @fontsource-variable/geist and @fontsource-variable/geist-mono.
They are licensed under the SIL Open Font License 1.1, not MIT:

Geist and Geist Mono
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
13 changes: 1 addition & 12 deletions portal-template/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,6 @@ const loadPageTree = createServerFn({ method: 'GET' })
.middleware([staticFunctionMiddleware])
.handler(async () => ({ pageTree: await source.serializePageTree(source.getPageTree()) }));

// Linked from the page rather than imported by `app.css`, so the browser fetches it alongside the
// stylesheet instead of after it.
const fontLinks = [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' as const },
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap'
}
];

// Without one the browser tab shows the blank-document icon on every page.
const iconLinks = portal.favicon
? [{ rel: 'icon', href: portal.favicon.url, ...(portal.favicon.type ? { type: portal.favicon.type } : {}) }]
Expand All @@ -45,7 +34,7 @@ export const Route = createRootRoute({
{ title: portal.name },
...(portal.description ? [{ name: 'description', content: portal.description }] : [])
],
links: [...fontLinks, { rel: 'stylesheet', href: appCss }, ...iconLinks]
links: [{ rel: 'stylesheet', href: appCss }, ...iconLinks]
}),
component: RootComponent
});
Expand Down
8 changes: 6 additions & 2 deletions portal-template/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@import 'fumadocs-openapi/css/preset.css';
/* Served with the site: a cross-origin font stylesheet would hold up first paint and hydration. */
@import '@fontsource-variable/geist';
@import '@fontsource-variable/geist-mono';
/* Written by the CLI from the 'portal' block of apimatic.json: the colour overrides. Imported
last, so what it sets outranks the theme and presets above. */
@import './theme.css';

/* The `font-sans` and `font-mono` classes read these, and the page defaults derive from them. */
@theme {
--default-font-family: 'Geist', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--default-mono-font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
--font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
}

Expand Down
19 changes: 17 additions & 2 deletions src/infrastructure/portal-project-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ import { envInfo } from './env-info.js';
import { FileService } from './file-service.js';
import { PortalPagesService } from './portal-pages-service.js';

// Copied, not linked: Tailwind rebases their `url()`s onto the project, and no relative path crosses drives.
export const COPIED_DEPENDENCIES = ['@fontsource-variable/geist', '@fontsource-variable/geist-mono'];

// Linked one by one rather than through a single link to the CLI's `node_modules`: under a
// pnpm global install, `npx` or `pnpm dlx` the package has no nested `node_modules`, and a
// single link also lets Vite write its scratch files into the CLI's own install directory.
export const TEMPLATE_DEPENDENCIES = [
export const LINKED_DEPENDENCIES = [
'@fumadocs/api-docs',
'@scalar/json-magic',
'@tailwindcss/vite',
Expand All @@ -39,6 +42,8 @@ export const TEMPLATE_DEPENDENCIES = [
'vite'
];

export const TEMPLATE_DEPENDENCIES = [...COPIED_DEPENDENCIES, ...LINKED_DEPENDENCIES];

const CONTENT_DIRECTORY_PLACEHOLDER = "'__APIMATIC_CONTENT_DIR__'";

/** Beside `portal.config.json`; `src/lib/portal.ts` imports it. */
Expand Down Expand Up @@ -100,6 +105,7 @@ export class PortalProjectService {

await this.fileService.copyDirectoryContents(template, projectDirectory);
await this.linkDependencies(projectDirectory);
await this.copyDependencies(projectDirectory);
await this.writeConfiguration(
projectDirectory,
source,
Expand Down Expand Up @@ -192,7 +198,7 @@ export class PortalProjectService {
const modules = projectDirectory.join('node_modules');
await this.fileService.createDirectoryIfNotExists(modules);

for (const dependency of TEMPLATE_DEPENDENCIES) {
for (const dependency of LINKED_DEPENDENCIES) {
const target = this.packageDirectory(dependency);
if (target === undefined) {
continue;
Expand All @@ -206,6 +212,15 @@ export class PortalProjectService {
}
}

private async copyDependencies(projectDirectory: DirectoryPath): Promise<void> {
for (const dependency of COPIED_DEPENDENCIES) {
const target = this.packageDirectory(dependency);
if (target !== undefined) {
await this.fileService.copyDirectoryContents(target, projectDirectory.join('node_modules', dependency));
}
}
}

private async writeConfiguration(
projectDirectory: DirectoryPath,
source: PortalSource,
Expand Down
12 changes: 9 additions & 3 deletions test/e2e/portal-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,11 +351,17 @@ const stylesheetOf = (output: DirectoryPath) => {
).to.deep.equal([]);
});

it('loads Geist and the neutral theme', () => {
it('bundles Geist and loads the neutral theme', () => {
const css = stylesheetOf(output);

expect(read('index.html')).to.contain('https://fonts.googleapis.com/css2?family=Geist:wght@100..900');
expect(css).to.contain('--default-font-family:"Geist"');
expect(read('index.html')).not.to.contain('fonts.googleapis.com');
// Fumadocs' type tables and playground output use the `font-mono` class, which reads `--font-mono`.
expect(css).to.contain('--font-sans:"Geist Variable"');
expect(css).to.contain('--font-mono:"Geist Mono Variable"');
expect(css).to.contain('--default-font-family:var(--font-sans)');
expect(css).to.contain('--default-mono-font-family:var(--font-mono)');
expect(css).to.match(/url\(\/assets\/geist-latin-wght-normal-[\w-]+\.woff2\)/);
expect(css).to.match(/url\(\/assets\/geist-mono-latin-wght-normal-[\w-]+\.woff2\)/);
// The theme's light primary, which nothing in the fixture overrides.
expect(css).to.match(/--color-fd-primary:#171717/);
});
Expand Down
22 changes: 17 additions & 5 deletions test/infrastructure/portal-project-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import semver from 'semver';
import sinon from 'sinon';
import { FileService } from '../../src/infrastructure/file-service';
import {
COPIED_DEPENDENCIES,
GENERATED_DIRECTORY_NAME,
PortalProjectService,
TEMPLATE_DEPENDENCIES
Expand Down Expand Up @@ -103,26 +104,37 @@ describe('PortalProjectService', () => {
});

describe('prepare', () => {
it('links every dependency the template imports, resolved to a real package', async () => {
it('installs every dependency the template imports, resolved to a real package', async () => {
(await service.prepare(project, sourceFor(), NO_ARTIFACTS))._unsafeUnwrap();

const modules = path.join(project.toString(), 'node_modules');
const linked = fs
const installed = fs
.readdirSync(modules, { withFileTypes: true })
.flatMap((entry) =>
entry.name.startsWith('@')
? fs.readdirSync(path.join(modules, entry.name)).map((nested) => `${entry.name}/${nested}`)
: [entry.name]
);

expect(linked).to.include.members(['react', 'vite', 'fumadocs-ui', '@tanstack/react-start']);
for (const name of linked) {
// Each link must reach that package's own manifest, not merely exist.
expect(installed).to.include.members(['react', 'vite', 'fumadocs-ui', '@tanstack/react-start']);
for (const name of installed) {
// Each entry must reach that package's own manifest, not merely exist.
const manifest = JSON.parse(fs.readFileSync(path.join(modules, name, 'package.json'), 'utf8'));
expect(manifest.name, `${name} resolves to the wrong package`).to.equal(name);
}
});

// Linked, their `url()`s would resolve from the CLI's install, which may be on another drive.
it('copies the font packages into the project instead of linking them', async () => {
(await service.prepare(project, sourceFor(), NO_ARTIFACTS))._unsafeUnwrap();

for (const name of COPIED_DEPENDENCIES) {
const directory = path.join(project.toString(), 'node_modules', name);
expect(fs.lstatSync(directory).isSymbolicLink(), `${name} is linked`).to.be.false;
expect(fs.readdirSync(path.join(directory, 'files')).some((file) => file.endsWith('.woff2'))).to.be.true;
}
});

it('copies the template rather than moving it', async () => {
(await service.prepare(project, sourceFor(), NO_ARTIFACTS))._unsafeUnwrap();

Expand Down
Loading
Loading