Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(fonts): experimental release #12775

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
5 changes: 5 additions & 0 deletions .changeset/happy-spies-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': minor
---

Adds first-party support for fonts
1 change: 1 addition & 0 deletions packages/astro/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ declare module 'astro:assets' {
inferRemoteSize: typeof import('./dist/assets/utils/index.js').inferRemoteSize;
Image: typeof import('./components/Image.astro').default;
Picture: typeof import('./components/Picture.astro').default;
Font: typeof import('./components/Font.astro').default;
};

type ImgAttributes = import('./dist/type-utils.js').WithRequired<
Expand Down
25 changes: 25 additions & 0 deletions packages/astro/components/Font.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
// TODO: remove dynamic import when fonts are stabilized
const { fontsData } = await import('virtual:astro:assets/fonts/internal').catch(() => {
throw new Error('experimental.fonts not enabled');
});

interface Props {
family: string;
preload?: boolean;
}

const { family, preload = false } = Astro.props;
const data = fontsData.get(family);
if (!data) {
throw new Error(`No data for ${family}`);
}
---

<style set:html={data.css}></style>
{
preload &&
data.preloadData.map(({ url, type }) => (
<link rel="preload" href={url} as="font" type={`font/${type}`} crossorigin />
))
}
4 changes: 3 additions & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"./assets/endpoint/*": "./dist/assets/endpoint/*.js",
"./assets/services/sharp": "./dist/assets/services/sharp.js",
"./assets/services/noop": "./dist/assets/services/noop.js",
"./assets/fonts/providers/*": "./dist/assets/fonts/providers/*.js",
"./loaders": "./dist/content/loaders/index.js",
"./content/runtime": "./dist/content/runtime.js",
"./content/runtime-assets": "./dist/content/runtime-assets.js",
Expand Down Expand Up @@ -113,7 +114,7 @@
"test:e2e:match": "playwright test -g",
"test:e2e:chrome": "playwright test",
"test:e2e:firefox": "playwright test --config playwright.firefox.config.js",
"test:types": "tsc --project tsconfig.tests.json",
"test:types": "tsc --project test/types/tsconfig.json",
"test:unit": "astro-scripts test \"test/units/**/*.test.js\" --teardown ./test/units/teardown.js",
"test:integration": "astro-scripts test \"test/*.test.js\""
},
Expand Down Expand Up @@ -165,6 +166,7 @@
"tinyexec": "^0.3.2",
"tsconfck": "^3.1.4",
"ultrahtml": "^1.5.3",
"unifont": "^0.1.7",
"unist-util-visit": "^5.0.0",
"unstorage": "^1.14.4",
"vfile": "^6.0.3",
Expand Down
11 changes: 11 additions & 0 deletions packages/astro/src/assets/fonts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# fonts

The vite plugin orchestrates the fonts logic:

- Retrieves data from the config
- Initializes font providers
- Fetches fonts data
- In dev, serves a middleware that dynamically loads and caches fonts data
- In build, download fonts data (from cache if possible)

The `<Font />` component is the only aspect not managed in the vite plugin, since it's exported from `astro:assets`.
62 changes: 62 additions & 0 deletions packages/astro/src/assets/fonts/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type * as unifont from 'unifont';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';

type Storage = Required<unifont.UnifontOptions>['storage'];

export const createStorage = ({
base,
}: {
base: URL;
}): Storage => {
return {
getItem: async (key) => {
const dest = new URL('./' + key, base);
try {
if (!existsSync(dest)) {
return;
}
const content = await readFile(dest, 'utf-8');
try {
return JSON.parse(content);
} catch {
// If we can't parse the content, we assume the entry does not exist
return;
}
} catch (e) {
throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: e });
}
},
setItem: async (key, value) => {
const dest = new URL('./' + key, base);
try {
await mkdir(dirname(fileURLToPath(dest)), { recursive: true });
return await writeFile(dest, JSON.stringify(value), 'utf-8');
} catch (e) {
throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: e });
}
},
};
};

export const createCache = ({ storage }: { storage: Storage }) => {
return {
cache: async (
key: string,
cb: () => Promise<string>,
): Promise<{ cached: boolean; data: string }> => {
const existing = await storage.getItem(key);
if (existing) {
return { cached: true, data: existing };
}
const data = await cb();
await storage.setItem(key, data);
return { cached: false, data };
},
};
};

export type Cache = ReturnType<typeof createCache>;
21 changes: 21 additions & 0 deletions packages/astro/src/assets/fonts/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { GOOGLE_PROVIDER_NAME } from './providers/google.js';
import { LOCAL_PROVIDER_NAME } from './providers/local.js';
import type * as unifont from 'unifont';

export const BUILTIN_PROVIDERS = [GOOGLE_PROVIDER_NAME, LOCAL_PROVIDER_NAME] as const;

export const DEFAULTS: unifont.ResolveFontOptions = {
weights: ['400'],
styles: ['normal', 'italic'],
subsets: ['cyrillic-ext', 'cyrillic', 'greek-ext', 'greek', 'vietnamese', 'latin-ext', 'latin'],
fallbacks: undefined,
};

export const VIRTUAL_MODULE_ID = 'virtual:astro:assets/fonts/internal';
export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;

// Requires a trailing slash
export const URL_PREFIX = '/_astro/fonts/';
export const CACHE_DIR = './fonts/';

export const FONT_TYPES = ['woff2', 'woff', 'otf', 'ttf', 'eot'];
5 changes: 5 additions & 0 deletions packages/astro/src/assets/fonts/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { FontProvider } from './types.js';

export function defineFontProvider<TName extends string>(provider: FontProvider<TName>) {
return provider;
}
57 changes: 57 additions & 0 deletions packages/astro/src/assets/fonts/providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createRequire } from 'node:module';
import type { AstroSettings } from '../../types/astro.js';
import { google } from './providers/google.js';
import { local } from './providers/local.js';
import type { FontProvider, ResolvedFontProvider } from './types.js';
import { fileURLToPath } from 'node:url';
import type { ModuleLoader } from '../../core/module-loader/loader.js';

function resolveEntrypoint(settings: AstroSettings, entrypoint: string): string {
const require = createRequire(settings.config.root);

try {
return require.resolve(entrypoint);
} catch {
return fileURLToPath(new URL(entrypoint, settings.config.root));
}
}

async function resolveMod(
id: string,
moduleLoader?: ModuleLoader,
): Promise<Pick<ResolvedFontProvider, 'provider'>> {
try {
const mod = await (moduleLoader ? moduleLoader.import(id) : import(id));
if (!mod.provider && typeof mod.provider !== 'function') {
// TODO: improve
throw new Error('Not a function');
}
return {
provider: mod.provider,
};
} catch (e) {
// TODO: AstroError
throw e;
}
}

export async function resolveProviders({
settings,
providers: _providers,
moduleLoader,
}: {
settings: AstroSettings;
providers: Array<FontProvider<any>>;
moduleLoader?: ModuleLoader;
}): Promise<Array<ResolvedFontProvider>> {
const providers = [google(), local(), ..._providers];
const resolvedProviders: Array<ResolvedFontProvider> = [];

for (const { name, entrypoint, config } of providers) {
const id = resolveEntrypoint(settings, entrypoint.toString());
const { provider } = await resolveMod(id, moduleLoader);
resolvedProviders.push({ name, config, provider });
}

return resolvedProviders;
}
14 changes: 14 additions & 0 deletions packages/astro/src/assets/fonts/providers/adobe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineFontProvider } from '../helpers.js';
import { providers } from 'unifont';

type Provider = typeof providers.adobe;

export function adobe(config: Parameters<Provider>[0]) {
return defineFontProvider({
name: 'adobe',
entrypoint: 'astro/assets/fonts/providers/adobe',
config,
});
}

export const provider: Provider = providers.adobe;
13 changes: 13 additions & 0 deletions packages/astro/src/assets/fonts/providers/bunny.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineFontProvider } from '../helpers.js';
import { providers } from 'unifont';

type Provider = typeof providers.bunny;

export function bunny() {
return defineFontProvider({
name: 'bunny',
entrypoint: 'astro/assets/fonts/providers/bunny',
});
}

export const provider: Provider = providers.bunny;
13 changes: 13 additions & 0 deletions packages/astro/src/assets/fonts/providers/fontshare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineFontProvider } from '../helpers.js';
import { providers } from 'unifont';

type Provider = typeof providers.fontshare;

export function fontshare() {
return defineFontProvider({
name: 'fontshare',
entrypoint: 'astro/assets/fonts/providers/fontshare',
});
}

export const provider: Provider = providers.fontshare;
13 changes: 13 additions & 0 deletions packages/astro/src/assets/fonts/providers/fontsource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineFontProvider } from '../helpers.js';
import { providers } from 'unifont';

type Provider = typeof providers.fontsource;

export function fontsource() {
return defineFontProvider({
name: 'fontsource',
entrypoint: 'astro/assets/fonts/providers/fontsource',
});
}

export const provider: Provider = providers.fontsource;
18 changes: 18 additions & 0 deletions packages/astro/src/assets/fonts/providers/google.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { defineFontProvider } from '../helpers.js';
import { providers } from 'unifont';

type Provider = typeof providers.google;

export const GOOGLE_PROVIDER_NAME = 'google';

// TODO: https://github.com/unjs/unifont/issues/108
// This provider downloads too many files when there's a variable font
// available. This is bad because it doesn't align with our default font settings
export function google() {
return defineFontProvider({
name: GOOGLE_PROVIDER_NAME,
entrypoint: 'astro/assets/fonts/providers/google',
});
}

export const provider: Provider = providers.google;
12 changes: 12 additions & 0 deletions packages/astro/src/assets/fonts/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { adobe } from './adobe.js';
import { bunny } from './bunny.js';
import { fontshare } from './fontshare.js';
import { fontsource } from './fontsource.js';

/** TODO: jsdoc */
export const fontProviders = {
adobe,
bunny,
fontshare,
fontsource,
};
17 changes: 17 additions & 0 deletions packages/astro/src/assets/fonts/providers/local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { providers } from 'unifont';
import { defineFontProvider } from '../helpers.js';

export const LOCAL_PROVIDER_NAME = 'local';

export function local() {
return defineFontProvider({
name: LOCAL_PROVIDER_NAME,
entrypoint: 'astro/assets/fonts/providers/local',
});
}

// TODO: implement
export const provider = () =>
Object.assign(providers.google(), {
_name: 'local',
});
44 changes: 44 additions & 0 deletions packages/astro/src/assets/fonts/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { BUILTIN_PROVIDERS } from './constants.js';
import type { GOOGLE_PROVIDER_NAME } from './providers/google.js';
import type { LOCAL_PROVIDER_NAME } from './providers/local.js';
import type * as unifont from 'unifont';

export interface FontProvider<TName extends string> {
name: TName;
entrypoint: string | URL;
config?: Record<string, any>;
}

export interface ResolvedFontProvider {
name: string;
provider: (config?: Record<string, any>) => UnifontProvider;
config?: Record<string, any>;
}

export type UnifontProvider = unifont.Provider;

// TODO: support optional as prop
interface FontFamilyAttributes extends Partial<unifont.ResolveFontOptions> {
name: string;
provider: string;
}

// TODO: make provider optional and default to google
interface LocalFontFamily extends Omit<FontFamilyAttributes, 'provider'> {
provider: LocalProviderName;
// TODO: refine type
src: string;
}

interface CommonFontFamily<TProvider extends string>
extends Omit<FontFamilyAttributes, 'provider'> {
provider: TProvider;
}

export type FontFamily<TProvider extends string> = TProvider extends LocalProviderName
? LocalFontFamily
: CommonFontFamily<TProvider>;

export type LocalProviderName = typeof LOCAL_PROVIDER_NAME;
export type GoogleProviderName = typeof GOOGLE_PROVIDER_NAME;
export type BuiltInProvider = (typeof BUILTIN_PROVIDERS)[number];
Loading
Loading