@lukoweb/apitogo 0.1.148 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ import { createHash } from "node:crypto";
2
+ import { access, lstat, readFile, rm, symlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { glob } from "glob";
5
+ import { ensureStateDir } from "./layout.js";
6
+ import type { ResolvedConfigV2 } from "./resolve.js";
7
+ import { CONTENT_LINK, type PreparedPortal } from "./wrapper.js";
8
+
9
+ const exists = (file: string) =>
10
+ access(file).then(
11
+ () => true,
12
+ () => false,
13
+ );
14
+
15
+ async function relink(target: string, link: string): Promise<void> {
16
+ if (
17
+ await lstat(link).then(
18
+ () => true,
19
+ () => false,
20
+ )
21
+ )
22
+ await rm(link, { recursive: true, force: true });
23
+ await symlink(target, link, "dir");
24
+ }
25
+
26
+ /**
27
+ * Makes `generated/` a self-contained site root the existing build can run in: the wrapper, an empty site
28
+ * config, and links to the tenant's files. Tenant files are linked, never copied, edited or patched.
29
+ */
30
+ export async function materializeWorkspace(
31
+ resolved: ResolvedConfigV2,
32
+ prepared: Pick<PreparedPortal, "wrapperPath">,
33
+ ): Promise<string> {
34
+ const root = path.dirname(prepared.wrapperPath);
35
+ await ensureStateDir(resolved.state);
36
+ await relink(resolved.configDir, path.join(root, CONTENT_LINK));
37
+ const publicDir = resolved.portal && path.join(resolved.portal.dir, "public");
38
+ const publicLink = path.join(root, "public");
39
+ if (publicDir && (await exists(publicDir))) {
40
+ await relink(publicDir, publicLink);
41
+ } else {
42
+ await rm(publicLink, { recursive: true, force: true });
43
+ }
44
+ return root;
45
+ }
46
+
47
+ /**
48
+ * The config as the build sees it. `project.id` links the folder to a platform project and never changes what is
49
+ * built, so writing it (deploy does, once) must not count as a change.
50
+ */
51
+ async function configFingerprint(configPath: string): Promise<string> {
52
+ const text = await readFile(configPath, "utf8");
53
+ try {
54
+ const raw = JSON.parse(text) as { project?: { id?: unknown } };
55
+ if (raw.project) {
56
+ delete raw.project.id;
57
+ if (Object.keys(raw.project).length === 0) delete raw.project;
58
+ }
59
+ return JSON.stringify(raw);
60
+ } catch {
61
+ return text;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Hash of everything that decides the build output: config, docs content, specs, theme, assets, the manifest
67
+ * (without its timestamp), the generated wrapper and the CLI version. Equal hashes mean the previous build is still valid.
68
+ */
69
+ export async function portalArtifactHash(
70
+ resolved: ResolvedConfigV2,
71
+ manifestText: string | undefined,
72
+ cliVersion: string,
73
+ wrapper = "",
74
+ ): Promise<string> {
75
+ const portal = resolved.portal;
76
+ const patterns = [
77
+ ...(portal?.docs.content ?? []),
78
+ ...(resolved.gateway?.specs ?? []),
79
+ ...(portal
80
+ ? [portal.theme, path.join(portal.dir, "public", "**", "*")]
81
+ : []),
82
+ ];
83
+ const positive = patterns.filter((p) => !p.startsWith("!"));
84
+ const ignore = patterns
85
+ .filter((p) => p.startsWith("!"))
86
+ .map((p) => p.slice(1));
87
+ const files = [
88
+ ...new Set(
89
+ (
90
+ await Promise.all(
91
+ positive.map((pattern) =>
92
+ glob(pattern.split(path.sep).join("/"), {
93
+ absolute: true,
94
+ nodir: true,
95
+ ignore: [...ignore, "**/node_modules/**"],
96
+ }),
97
+ ),
98
+ )
99
+ ).flat(),
100
+ ),
101
+ ].sort();
102
+
103
+ const hash = createHash("sha256");
104
+ hash.update(`cli:${cliVersion}\n`);
105
+ hash.update(await configFingerprint(resolved.configPath));
106
+ hash.update(`wrapper:${wrapper}\n`);
107
+ if (manifestText !== undefined) {
108
+ const manifest = JSON.parse(manifestText) as Record<string, unknown>;
109
+ delete manifest.generatedAt;
110
+ hash.update(`manifest:${JSON.stringify(manifest)}\n`);
111
+ }
112
+ for (const file of files) {
113
+ hash.update(`file:${path.relative(resolved.configDir, file)}\n`);
114
+ hash.update(await readFile(file));
115
+ }
116
+ return hash.digest("hex");
117
+ }
@@ -0,0 +1,397 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ConfigError, type ConfigIssue } from "./errors.js";
4
+ import {
5
+ hasCapability,
6
+ missingManifestServices,
7
+ type ServiceManifest,
8
+ serviceReady,
9
+ stubManifest,
10
+ } from "./manifest.js";
11
+ import { type PortalProfile, portalProfile } from "./portal-profile.js";
12
+ import type { ResolvedConfigV2 } from "./resolve.js";
13
+
14
+ export const WRAPPER_FILE = "apitogo.config.tsx";
15
+
16
+ /** Name of the link, inside `generated/`, that points at the tenant's config directory. */
17
+ export const CONTENT_LINK = "content";
18
+
19
+ /**
20
+ * Turns an absolute docs glob into one the site root understands. The build globs relative to its root, so
21
+ * tenant content is reached through the `content` link and never copied.
22
+ */
23
+ export function contentGlob(configDir: string, glob: string): string {
24
+ const negated = glob.startsWith("!");
25
+ const absolute = negated ? glob.slice(1) : glob;
26
+ const relative = path.relative(configDir, absolute).split(path.sep).join("/");
27
+ return `${negated ? "!" : ""}/${CONTENT_LINK}/${relative}`;
28
+ }
29
+
30
+ /** Capabilities of the plans service that put something on the console. Any one of them registers the plans plugin. */
31
+ const PLANS_CONSOLE_CAPABILITIES = [
32
+ "catalog",
33
+ "checkout",
34
+ "subscriptions",
35
+ "keys",
36
+ "usage",
37
+ ] as const;
38
+
39
+ /**
40
+ * What the generated wrapper registers, and why each piece is in or out. Every `false` was decided from the
41
+ * profile and the manifest, so a build without billing or sign-in contains no billing or sign-in code.
42
+ */
43
+ export type PortalPlan = {
44
+ /** Docs pages, navigation and search APItoGo builds. */
45
+ docs: boolean;
46
+ /** OpenAPI reference pages and playground. */
47
+ apiReference: boolean;
48
+ /** OpenID sign-in against the identity issuer. */
49
+ auth?: {
50
+ issuer: string;
51
+ clientId: string;
52
+ scopes: string[];
53
+ audience?: string;
54
+ };
55
+ /** Pricing, subscriptions, API keys and usage pages. */
56
+ plansPlugin?: { apiBaseUrl: string; keysApiBaseUrl?: string };
57
+ /** Account pages. */
58
+ userPanel: boolean;
59
+ /** Public sign-up page; only when identity declares `nativeSignup`. */
60
+ signup: boolean;
61
+ /** Console pieces that were left out and the reason, for `apitogo plan` and for tests. */
62
+ skipped: { piece: string; reason: string }[];
63
+ /** True only for a stub manifest. Production builds refuse it. */
64
+ preview: boolean;
65
+ };
66
+
67
+ /** npm packages the generated site needs, beyond `@lukoweb/apitogo`, for what the plan registers. */
68
+ export function requiredPackages(plan: PortalPlan): string[] {
69
+ return [
70
+ ...(plan.plansPlugin ? ["@lukoweb/apitogo-plugin-dev-portal-billing"] : []),
71
+ ...(plan.userPanel ? ["@lukoweb/apitogo-module-user-panel"] : []),
72
+ ];
73
+ }
74
+
75
+ /** Console routes the portal serves, by link name. */
76
+ const CONSOLE_ROUTES = {
77
+ signup: "/signup",
78
+ login: "/login",
79
+ pricing: "/pricing",
80
+ keys: "/account/credentials",
81
+ usage: "/account/usage",
82
+ } as const;
83
+
84
+ /**
85
+ * Links to the console pages this build really serves, for an external docs site to point at. A link appears
86
+ * only when the page exists (its capability is on) and the portal URL is known; the portal never guesses a URL.
87
+ */
88
+ export function consoleLinks(
89
+ manifest: ServiceManifest,
90
+ plan: PortalPlan,
91
+ ): Record<string, string> {
92
+ const base = manifest.services.portal?.url?.replace(/\/+$/, "");
93
+ if (!base || !plan.auth) return {};
94
+ const { plans } = manifest.services;
95
+ const present: Record<keyof typeof CONSOLE_ROUTES, boolean> = {
96
+ signup: plan.signup,
97
+ login: true,
98
+ pricing: !!plan.plansPlugin && hasCapability(plans, "catalog"),
99
+ keys: !!plan.plansPlugin && hasCapability(plans, "keys"),
100
+ usage: !!plan.plansPlugin && hasCapability(plans, "usage"),
101
+ };
102
+ return Object.fromEntries(
103
+ Object.entries(CONSOLE_ROUTES)
104
+ .filter(([name]) => present[name as keyof typeof CONSOLE_ROUTES])
105
+ .map(([name, route]) => [name, `${base}${route}`]),
106
+ );
107
+ }
108
+
109
+ /**
110
+ * The `services.json` shipped at the site root: the deployed manifest plus console links the manifest did not
111
+ * declare itself. Public values only. A stub manifest is never shipped.
112
+ */
113
+ export function publishedManifest(
114
+ manifest: ServiceManifest,
115
+ plan: PortalPlan,
116
+ ): ServiceManifest | undefined {
117
+ if (manifest.preview) return undefined;
118
+ const links = {
119
+ ...consoleLinks(manifest, plan),
120
+ ...manifest.services.portal?.console?.links,
121
+ };
122
+ // Nothing to offer an external docs site: ship no manifest, so recording a URL never changes the build.
123
+ if (Object.keys(links).length === 0) return undefined;
124
+ const portal = manifest.services.portal ?? {};
125
+ return {
126
+ ...manifest,
127
+ services: {
128
+ ...manifest.services,
129
+ portal: { ...portal, console: { ...portal.console, links } },
130
+ },
131
+ };
132
+ }
133
+
134
+ const DEFAULT_SCOPES = ["openid", "profile", "email"];
135
+
136
+ /**
137
+ * Pure: decides which plugins and modules the portal registers. A service contributes only when it has a manifest
138
+ * entry, is ready and declares the capability that the piece needs.
139
+ */
140
+ export function planPortal(
141
+ profile: PortalProfile,
142
+ manifest: ServiceManifest,
143
+ ): PortalPlan {
144
+ const skipped: PortalPlan["skipped"] = [];
145
+ const skip = (piece: string, reason: string) =>
146
+ void skipped.push({ piece, reason });
147
+ const { identity, plans } = manifest.services;
148
+
149
+ const plan: PortalPlan = {
150
+ docs: profile.modules.docs,
151
+ apiReference: profile.pipelines.openapi,
152
+ userPanel: false,
153
+ signup: false,
154
+ skipped,
155
+ preview: manifest.preview === true,
156
+ };
157
+
158
+ if (!profile.enabled) return plan;
159
+
160
+ if (!profile.console.enabled) {
161
+ skip("console", "portal.console.enabled is false");
162
+ return plan;
163
+ }
164
+
165
+ if (!identity) {
166
+ skip("sign-in", "services.json has no identity entry");
167
+ } else if (!serviceReady(identity)) {
168
+ skip("sign-in", `identity is ${identity.status}, not ready`);
169
+ } else if (!identity.issuer || !identity.clientId) {
170
+ skip("sign-in", "identity entry has no issuer or clientId");
171
+ } else {
172
+ plan.auth = {
173
+ issuer: identity.issuer,
174
+ clientId: identity.clientId,
175
+ scopes: identity.scopes ?? DEFAULT_SCOPES,
176
+ audience: identity.audience,
177
+ };
178
+ plan.userPanel = true;
179
+ plan.signup = hasCapability(identity, "nativeSignup");
180
+ if (!plan.signup) skip("sign-up", "identity.nativeSignup is false");
181
+ }
182
+
183
+ if (!plans) {
184
+ skip("plans", "services.json has no plans entry");
185
+ } else if (!serviceReady(plans)) {
186
+ skip("plans", `plans is ${plans.status}, not ready`);
187
+ } else if (!plans.apiBaseUrl) {
188
+ skip("plans", "plans entry has no apiBaseUrl");
189
+ } else if (
190
+ !PLANS_CONSOLE_CAPABILITIES.some((name) => hasCapability(plans, name))
191
+ ) {
192
+ skip("plans", "plans declares none of the console capabilities");
193
+ } else if (!plan.auth) {
194
+ skip("plans", "plans pages need sign-in, which is not available");
195
+ } else {
196
+ plan.plansPlugin = {
197
+ apiBaseUrl: plans.apiBaseUrl,
198
+ keysApiBaseUrl: plans.keysApiBaseUrl,
199
+ };
200
+ }
201
+
202
+ return plan;
203
+ }
204
+
205
+ const json = (value: unknown) => JSON.stringify(value, null, 2);
206
+
207
+ /** Name of the site config the wrapper imports, next to it in `generated/`. */
208
+ export const SITE_CONFIG_FILE = "apitogo.config.json";
209
+
210
+ /**
211
+ * The site settings (title, modules, docs globs, theme, sign-in) in the shape the existing build reads from
212
+ * `apitogo.config.json`. It is generated, never edited, and lives in `generated/`.
213
+ */
214
+ export function buildSiteConfig(
215
+ resolved: ResolvedConfigV2,
216
+ plan: PortalPlan,
217
+ theme?: unknown,
218
+ ): Record<string, unknown> {
219
+ const portal = resolved.portal;
220
+ return {
221
+ site: {
222
+ title: portal?.name ?? resolved.project.name,
223
+ ...(plan.preview
224
+ ? { banner: { message: PREVIEW_BANNER_MESSAGE, color: "caution" } }
225
+ : {}),
226
+ },
227
+ modules: {
228
+ docs: { enabled: plan.docs },
229
+ userPanel: { enabled: plan.userPanel },
230
+ },
231
+ ...(plan.docs
232
+ ? {
233
+ docs: {
234
+ files: portal?.docs.content.map((glob) =>
235
+ contentGlob(resolved.configDir, glob),
236
+ ),
237
+ },
238
+ }
239
+ : {}),
240
+ ...(theme !== undefined ? { theme } : {}),
241
+ ...(plan.auth ? { authentication: { type: "openid", ...plan.auth } } : {}),
242
+ ...(plan.preview ? { preview: true } : {}),
243
+ };
244
+ }
245
+
246
+ /** Shown on every page of a preview run, so a stand-in manifest is never mistaken for a deployed portal. */
247
+ export const PREVIEW_BANNER_MESSAGE =
248
+ "Preview mode: sign-in and plans use a stand-in manifest. Run `apitogo deploy` for the real services.";
249
+
250
+ /** The wrapper source. Imports exist only for what the plan registers. */
251
+ export function renderWrapper(plan: PortalPlan): string {
252
+ const imports = [
253
+ 'import { buildApitogoConfig } from "@lukoweb/apitogo/build-config";',
254
+ ...(plan.plansPlugin
255
+ ? [
256
+ 'import { devPortalBillingPlugin } from "@lukoweb/apitogo-plugin-dev-portal-billing";',
257
+ ]
258
+ : []),
259
+ `import siteConfig from "./${SITE_CONFIG_FILE}";`,
260
+ ];
261
+ const plugins = plan.plansPlugin
262
+ ? `[devPortalBillingPlugin(${JSON.stringify(plan.plansPlugin)})]`
263
+ : "[]";
264
+
265
+ return [
266
+ "// Generated by apitogo from apitogo.config.json and services.json. Do not edit: it is rewritten on every build.",
267
+ ...(plan.preview
268
+ ? [
269
+ "// PREVIEW: built from a stand-in manifest for local runs. A production build refuses this file.",
270
+ ]
271
+ : []),
272
+ ...imports,
273
+ "",
274
+ "export default buildApitogoConfig({",
275
+ " ...siteConfig,",
276
+ ` plugins: ${plugins},`,
277
+ "});",
278
+ "",
279
+ ].join("\n");
280
+ }
281
+
282
+ /** The portal theme file, when it exists and parses. `check` reports an invalid one; the build just skips it. */
283
+ async function readTheme(resolved: ResolvedConfigV2): Promise<unknown> {
284
+ const file = resolved.portal?.theme;
285
+ if (!file) return undefined;
286
+ try {
287
+ return JSON.parse(await readFile(file, "utf8"));
288
+ } catch {
289
+ return undefined;
290
+ }
291
+ }
292
+
293
+ export type PrepareMode = "development" | "production";
294
+
295
+ export type PreparedPortal = {
296
+ profile: PortalProfile;
297
+ plan: PortalPlan;
298
+ wrapper: string;
299
+ /** Contents of the generated site config the wrapper imports. */
300
+ siteConfig: Record<string, unknown>;
301
+ /** The manifest to ship at the site root, when there is a deployed one. */
302
+ publishedManifest?: ServiceManifest;
303
+ /** Absolute path the wrapper was written to, under the state directory's `generated/`. */
304
+ wrapperPath: string;
305
+ /** Where the manifest came from: the deployed file, a stand-in for local runs, or not needed. */
306
+ manifestSource: "file" | "preview-stub" | "none";
307
+ };
308
+
309
+ /**
310
+ * `portal.prepare`: picks the manifest, plans the registrations and writes the wrapper into `generated/`.
311
+ *
312
+ * - A deployed manifest is always used when present.
313
+ * - Without one, a local run (`development`) falls back to a labelled stub; a `production` build fails with
314
+ * PORTAL_MANIFEST_MISSING when the portal depends on a service, and needs no manifest when it does not.
315
+ * - A stub manifest is never accepted in `production`.
316
+ *
317
+ * With `write: false` nothing is written, so `plan` can ask what a build would do without changing anything.
318
+ */
319
+ export async function preparePortal(
320
+ resolved: ResolvedConfigV2,
321
+ manifest: ServiceManifest | undefined,
322
+ mode: PrepareMode,
323
+ options: { write?: boolean } = {},
324
+ ): Promise<PreparedPortal> {
325
+ const profile = portalProfile(resolved);
326
+ const issues: ConfigIssue[] = [];
327
+ let effective: ServiceManifest;
328
+ let manifestSource: PreparedPortal["manifestSource"];
329
+
330
+ if (manifest?.preview === true && mode === "production") {
331
+ throw new ConfigError(resolved.configPath, [
332
+ {
333
+ code: "PORTAL_MANIFEST_PREVIEW",
334
+ path: "services.json",
335
+ message:
336
+ "services.json is a preview stub and cannot be used in a production build.",
337
+ hint: "Run `apitogo deploy` to write a real services.json.",
338
+ },
339
+ ]);
340
+ }
341
+
342
+ if (manifest) {
343
+ effective = manifest;
344
+ manifestSource = "file";
345
+ if (mode === "production") {
346
+ issues.push(...missingManifestServices(resolved, manifest));
347
+ }
348
+ } else if (mode === "development") {
349
+ effective = stubManifest(resolved);
350
+ manifestSource = "preview-stub";
351
+ } else {
352
+ const missing = missingManifestServices(resolved, undefined);
353
+ issues.push(...missing);
354
+ effective = stubManifest(resolved);
355
+ manifestSource = missing.length === 0 ? "none" : "file";
356
+ }
357
+
358
+ if (issues.length > 0) throw new ConfigError(resolved.configPath, issues);
359
+
360
+ // An empty manifest is what production uses when nothing is required.
361
+ if (manifestSource === "none") {
362
+ effective = { ...effective, preview: undefined, services: {} };
363
+ }
364
+
365
+ const plan = planPortal(profile, effective);
366
+ const siteConfig = buildSiteConfig(resolved, plan, await readTheme(resolved));
367
+ const wrapper = renderWrapper(plan);
368
+ const wrapperPath = path.join(resolved.state.generatedDir, WRAPPER_FILE);
369
+ if (options.write !== false) {
370
+ await mkdir(path.dirname(wrapperPath), { recursive: true });
371
+ await writeFile(
372
+ path.join(path.dirname(wrapperPath), SITE_CONFIG_FILE),
373
+ `${JSON.stringify(siteConfig, null, 2)}\n`,
374
+ );
375
+ const temporary = `${wrapperPath}.${process.pid}.tmp`;
376
+ try {
377
+ await writeFile(temporary, wrapper, "utf8");
378
+ await rename(temporary, wrapperPath);
379
+ } catch (error) {
380
+ await rm(temporary, { force: true });
381
+ throw error;
382
+ }
383
+ }
384
+ return {
385
+ profile,
386
+ plan,
387
+ wrapper,
388
+ siteConfig,
389
+ // Without a console there are no console links to offer, so a docs-only portal ships no manifest.
390
+ publishedManifest:
391
+ manifestSource === "file" && profile.console.enabled
392
+ ? publishedManifest(effective, plan)
393
+ : undefined,
394
+ wrapperPath,
395
+ manifestSource,
396
+ };
397
+ }
package/src/vite/html.ts CHANGED
@@ -12,7 +12,6 @@ export function getDevHtml({
12
12
  <meta charset="UTF-8" />
13
13
  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
14
14
  <!--app-helmet-->
15
- <link rel="preconnect" href="https://cdn.zudoku.dev/">
16
15
  </head>
17
16
  <body>
18
17
  <div id="root"><!--app-html--></div>
@@ -44,7 +43,6 @@ export function getBuildHtml({
44
43
  <script type="module" crossorigin src="${jsEntry}"></script>
45
44
  ${cssLinks}
46
45
  <!--app-helmet-->
47
- <link rel="preconnect" href="https://cdn.zudoku.dev/">
48
46
  </head>
49
47
  <body>
50
48
  <div id="root"><!--app-html--></div>
@@ -125,12 +125,27 @@ const processFontConfig = (fontConfig?: FontConfig) => {
125
125
  };
126
126
  };
127
127
 
128
+ /**
129
+ * The default fonts ship inside the portal (Latin subset, four weights) so a first paint makes no third-party
130
+ * request. Fonts a tenant picks by name still load from Google Fonts: that is their explicit choice.
131
+ */
132
+ const bundledFont = (pkg: string) => {
133
+ // Absolute paths: the CSS is compiled from the site root, which does not see this package's dependencies.
134
+ const dir = path.dirname(
135
+ createRequire(import.meta.url).resolve(`@fontsource/${pkg}/package.json`),
136
+ );
137
+ return [400, 500, 600, 700].map((weight) =>
138
+ path.join(dir, `latin-${weight}.css`).split(path.sep).join("/"),
139
+ );
140
+ };
141
+
128
142
  const DEFAULT_FONT_SANS = '"IBM Plex Sans", system-ui, sans-serif';
129
143
  const DEFAULT_FONT_MONO = '"JetBrains Mono", monospace';
130
144
  const DEFAULT_FONT_HEADING = '"Space Grotesk", system-ui, sans-serif';
131
145
 
132
146
  const processFonts = async (themeConfig: ZudokuConfig["theme"]) => {
133
147
  const imports: string[] = [];
148
+ const bundled: string[] = [];
134
149
  const families = {
135
150
  sans: DEFAULT_FONT_SANS,
136
151
  serif: undefined as string | undefined,
@@ -193,14 +208,14 @@ const processFonts = async (themeConfig: ZudokuConfig["theme"]) => {
193
208
 
194
209
  // Add default font imports if no user or registry fonts
195
210
  if (!userFonts.sans.fontFamily && !registryFonts.sans) {
196
- imports.push(getGoogleFontUrl("IBM Plex Sans"));
211
+ bundled.push(...bundledFont("ibm-plex-sans"));
197
212
  }
198
213
  if (!userFonts.mono.fontFamily && !registryFonts.mono) {
199
- imports.push(getGoogleFontUrl("JetBrains Mono"));
214
+ bundled.push(...bundledFont("jetbrains-mono"));
200
215
  }
201
- imports.push(getGoogleFontUrl("Space Grotesk"));
216
+ bundled.push(...bundledFont("space-grotesk"));
202
217
 
203
- return { imports, families };
218
+ return { imports, bundled, families };
204
219
  };
205
220
 
206
221
  const MODULE_SOURCE_PACKAGES = [
@@ -268,9 +283,10 @@ export const viteThemePlugin = (): Plugin => {
268
283
  const fonts = await processFonts(themeConfig);
269
284
 
270
285
  // Add all font imports at once
271
- if (fonts.imports.length > 0) {
272
- themeCss.push(...fonts.imports.map((url) => `@import url('${url}');`));
273
- }
286
+ themeCss.push(
287
+ ...fonts.bundled.map((specifier) => `@import '${specifier}';`),
288
+ ...fonts.imports.map((url) => `@import url('${url}');`),
289
+ );
274
290
 
275
291
  // Handle registry theme variables
276
292
  if (themeConfig.registryUrl) {
@@ -1,73 +0,0 @@
1
- @font-face {
2
- font-family: Geist;
3
- font-style: normal;
4
- font-weight: 100;
5
- font-display: swap;
6
- src: url("https://cdn.zudoku.dev/geist/Geist-Thin.woff2") format("woff2");
7
- }
8
-
9
- @font-face {
10
- font-family: Geist;
11
- font-style: normal;
12
- font-weight: 200;
13
- font-display: swap;
14
- src: url("https://cdn.zudoku.dev/geist/Geist-UltraLight.woff2")
15
- format("woff2");
16
- }
17
-
18
- @font-face {
19
- font-family: Geist;
20
- font-style: normal;
21
- font-weight: 300;
22
- font-display: swap;
23
- src: url("https://cdn.zudoku.dev/geist/Geist-Light.woff2") format("woff2");
24
- }
25
-
26
- @font-face {
27
- font-family: Geist;
28
- font-style: normal;
29
- font-weight: 400;
30
- font-display: swap;
31
- src: url("https://cdn.zudoku.dev/geist/Geist-Regular.woff2") format("woff2");
32
- }
33
-
34
- @font-face {
35
- font-family: Geist;
36
- font-style: normal;
37
- font-weight: 500;
38
- font-display: swap;
39
- src: url("https://cdn.zudoku.dev/geist/Geist-Medium.woff2") format("woff2");
40
- }
41
-
42
- @font-face {
43
- font-family: Geist;
44
- font-style: normal;
45
- font-weight: 600;
46
- font-display: swap;
47
- src: url("https://cdn.zudoku.dev/geist/Geist-SemiBold.woff2") format("woff2");
48
- }
49
-
50
- @font-face {
51
- font-family: Geist;
52
- font-style: normal;
53
- font-weight: 700;
54
- font-display: swap;
55
- src: url("https://cdn.zudoku.dev/geist/Geist-Bold.woff2") format("woff2");
56
- }
57
-
58
- @font-face {
59
- font-family: Geist;
60
- font-style: normal;
61
- font-weight: 800;
62
- font-display: swap;
63
- src: url("https://cdn.zudoku.dev/geist/Geist-Black.woff2") format("woff2");
64
- }
65
-
66
- @font-face {
67
- font-family: Geist;
68
- font-style: normal;
69
- font-weight: 900;
70
- font-display: swap;
71
- src: url("https://cdn.zudoku.dev/geist/Geist-UltraBlack.woff2")
72
- format("woff2");
73
- }