├── .editorconfig
├── .github
├── CODE_OF_CONDUCT.md
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── ---bug-report.yml
│ ├── ---documentation.yml
│ ├── ---feature-suggestion.yml
│ └── ---help-wanted.yml
└── workflows
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── LICENCE
├── README.md
├── eslint.config.js
├── knip.json
├── package.json
├── packages
├── fontaine
│ ├── README.md
│ ├── package.json
│ ├── playground
│ │ ├── fonts
│ │ │ └── font.ttf
│ │ ├── index.css
│ │ ├── index.html
│ │ ├── package.json
│ │ └── vite.config.mjs
│ ├── src
│ │ ├── css.ts
│ │ ├── index.ts
│ │ ├── metrics.ts
│ │ └── transform.ts
│ ├── tea.yaml
│ ├── test
│ │ ├── e2e.spec.ts
│ │ ├── index.spec.ts
│ │ ├── metrics.spec.ts
│ │ └── transform.spec.ts
│ └── vitest.config.ts
└── fontless
│ ├── README.md
│ ├── build.config.ts
│ ├── examples
│ ├── analog-app
│ │ ├── .gitignore
│ │ ├── angular.json
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── .gitkeep
│ │ │ └── favicon.ico
│ │ ├── src
│ │ │ ├── app
│ │ │ │ ├── app.component.ts
│ │ │ │ ├── app.config.server.ts
│ │ │ │ ├── app.config.ts
│ │ │ │ └── pages
│ │ │ │ │ └── index.page.ts
│ │ │ ├── black-fox.ttf
│ │ │ ├── main.server.ts
│ │ │ ├── main.ts
│ │ │ ├── styles.css
│ │ │ └── vite-env.d.ts
│ │ ├── tsconfig.app.json
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── qwik-app
│ │ ├── .eslintignore
│ │ ├── .eslintrc.cjs
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── favicon.svg
│ │ │ ├── manifest.json
│ │ │ └── robots.txt
│ │ ├── src
│ │ │ ├── components
│ │ │ │ └── router-head
│ │ │ │ │ └── router-head.tsx
│ │ │ ├── entry.dev.tsx
│ │ │ ├── entry.preview.tsx
│ │ │ ├── entry.ssr.tsx
│ │ │ ├── global.css
│ │ │ ├── root.tsx
│ │ │ └── routes
│ │ │ │ ├── black-fox.ttf
│ │ │ │ ├── index.css
│ │ │ │ ├── index.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ └── service-worker.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.mts
│ ├── react-app
│ │ ├── .gitignore
│ │ ├── eslint.config.js
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ └── vite.svg
│ │ ├── src
│ │ │ ├── App.css
│ │ │ ├── App.tsx
│ │ │ ├── black-fox.ttf
│ │ │ ├── index.css
│ │ │ ├── main.tsx
│ │ │ └── vite-env.d.ts
│ │ ├── tsconfig.app.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ └── vite.config.ts
│ ├── remix-app
│ │ ├── .eslintrc.cjs
│ │ ├── .gitignore
│ │ ├── app
│ │ │ ├── entry.client.tsx
│ │ │ ├── entry.server.tsx
│ │ │ ├── index.css
│ │ │ ├── root.tsx
│ │ │ └── routes
│ │ │ │ ├── _index.tsx
│ │ │ │ └── styles
│ │ │ │ ├── black-fox.ttf
│ │ │ │ └── styles.css
│ │ ├── package.json
│ │ ├── postcss.config.js
│ │ ├── public
│ │ │ ├── favicon.ico
│ │ │ ├── logo-dark.png
│ │ │ └── logo-light.png
│ │ ├── tailwind.config.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ ├── solid-app
│ │ ├── .gitignore
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ └── vite.svg
│ │ ├── src
│ │ │ ├── App.css
│ │ │ ├── App.tsx
│ │ │ ├── black-fox.ttf
│ │ │ ├── index.css
│ │ │ ├── index.tsx
│ │ │ └── vite-env.d.ts
│ │ ├── tsconfig.app.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ └── vite.config.ts
│ ├── svelte-app
│ │ ├── .gitignore
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ └── vite.svg
│ │ ├── src
│ │ │ ├── App.svelte
│ │ │ ├── app.css
│ │ │ ├── black-fox.ttf
│ │ │ ├── main.ts
│ │ │ └── vite-env.d.ts
│ │ ├── svelte.config.js
│ │ ├── tsconfig.app.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ └── vite.config.ts
│ ├── vanilla-app
│ │ ├── .gitignore
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ │ └── vite.svg
│ │ ├── src
│ │ │ ├── black-fox.ttf
│ │ │ ├── main.ts
│ │ │ ├── style.css
│ │ │ └── vite-env.d.ts
│ │ ├── tsconfig.json
│ │ └── vite.config.ts
│ └── vue-app
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── public
│ │ └── vite.svg
│ │ ├── src
│ │ ├── App.vue
│ │ ├── black-fox.ttf
│ │ ├── main.ts
│ │ ├── style.css
│ │ └── vite-env.d.ts
│ │ ├── tsconfig.app.json
│ │ ├── tsconfig.json
│ │ ├── tsconfig.node.json
│ │ └── vite.config.ts
│ ├── package.json
│ ├── src
│ ├── assets.ts
│ ├── css
│ │ ├── parse.ts
│ │ └── render.ts
│ ├── defaults.ts
│ ├── index.ts
│ ├── package.json
│ ├── providers.ts
│ ├── resolve.ts
│ ├── storage.ts
│ ├── types.ts
│ ├── utils.ts
│ └── vite.ts
│ ├── test
│ ├── e2e.spec.ts
│ ├── parse.spec.ts
│ └── render.spec.ts
│ └── vitest.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── renovate.json
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 | trim_trailing_whitespace = true
7 | charset = utf-8
8 |
9 | [*.js]
10 | indent_style = space
11 | indent_size = 2
12 |
13 | [{package.json,*.yml,*.cjson}]
14 | indent_style = space
15 | indent_size = 2
16 |
--------------------------------------------------------------------------------
/.github/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | ## Our standards
8 |
9 | Examples of behavior that contributes to creating a positive environment include:
10 |
11 | - Using welcoming and inclusive language
12 | - Being respectful of differing viewpoints and experiences
13 | - Gracefully accepting constructive criticism
14 | - Focusing on what is best for the community
15 | - Showing empathy towards other community members
16 |
17 | Examples of unacceptable behavior by participants include:
18 |
19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances
20 | - Trolling, insulting/derogatory comments, and personal or political attacks
21 | - Public or private harassment
22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission
23 | - Other conduct which could reasonably be considered inappropriate in a professional setting
24 |
25 | ## Our responsibilities
26 |
27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28 |
29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
30 |
31 | ## Scope
32 |
33 | This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
34 |
35 | ## Enforcement
36 |
37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [daniel@roe.dev](mailto:daniel@roe.dev). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
38 |
39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
40 |
41 | ## Attribution
42 |
43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
44 |
45 | [homepage]: https://www.contributor-covenant.org
46 |
47 | For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq
48 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [danielroe]
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/---bug-report.yml:
--------------------------------------------------------------------------------
1 | name: 🐛 Bug report
2 | description: Something's not working
3 | labels: [bug]
4 | body:
5 | - type: textarea
6 | validations:
7 | required: true
8 | attributes:
9 | label: 🐛 The bug
10 | description: What isn't working? Describe what the bug is.
11 | - type: input
12 | validations:
13 | required: true
14 | attributes:
15 | label: 🛠️ To reproduce
16 | description: A reproduction of the bug via https://stackblitz.com/github/unjs/fontaine/tree/main/playground
17 | placeholder: https://stackblitz.com/[...]
18 | - type: textarea
19 | validations:
20 | required: true
21 | attributes:
22 | label: 🌈 Expected behaviour
23 | description: What did you expect to happen? Is there a section in the docs about this?
24 | - type: textarea
25 | attributes:
26 | label: ℹ️ Additional context
27 | description: Add any other context about the problem here.
28 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/---documentation.yml:
--------------------------------------------------------------------------------
1 | name: 📚 Documentation
2 | description: How do I ... ?
3 | labels: [documentation]
4 | body:
5 | - type: textarea
6 | validations:
7 | required: true
8 | attributes:
9 | label: 📚 Is your documentation request related to a problem?
10 | description: A clear and concise description of what the problem is.
11 | placeholder: I feel I should be able to [...] but I can't see how to do it from the docs.
12 | - type: textarea
13 | attributes:
14 | label: 🔍 Where should you find it?
15 | description: What page of the docs do you expect this information to be found on?
16 | - type: textarea
17 | attributes:
18 | label: ℹ️ Additional context
19 | description: Add any other context or information.
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/---feature-suggestion.yml:
--------------------------------------------------------------------------------
1 | name: 🆕 Feature suggestion
2 | description: Suggest an idea
3 | labels: [enhancement]
4 | body:
5 | - type: textarea
6 | validations:
7 | required: true
8 | attributes:
9 | label: 🆒 Your use case
10 | description: Add a description of your use case, and how this feature would help you.
11 | placeholder: When I do [...] I would expect to be able to do [...]
12 | - type: textarea
13 | validations:
14 | required: true
15 | attributes:
16 | label: 🆕 The solution you'd like
17 | description: Describe what you want to happen.
18 | - type: textarea
19 | attributes:
20 | label: 🔍 Alternatives you've considered
21 | description: Have you considered any alternative solutions or features?
22 | - type: textarea
23 | attributes:
24 | label: ℹ️ Additional info
25 | description: Is there any other context you think would be helpful to know?
26 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/---help-wanted.yml:
--------------------------------------------------------------------------------
1 | name: 🆘 Help
2 | description: I need help with ...
3 | labels: [help]
4 | body:
5 | - type: textarea
6 | validations:
7 | required: true
8 | attributes:
9 | label: 📚 What are you trying to do?
10 | description: A clear and concise description of your objective.
11 | placeholder: I'm not sure how to [...].
12 | - type: textarea
13 | attributes:
14 | label: 🔍 What have you tried?
15 | description: Have you looked through the docs? Tried different approaches? The more detail the better.
16 | - type: textarea
17 | attributes:
18 | label: ℹ️ Additional context
19 | description: Add any other context or information.
20 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 | push:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | lint:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 | - run: npm i -g --force corepack && corepack enable
18 | - uses: actions/setup-node@v4
19 | with:
20 | node-version: lts/*
21 | cache: pnpm
22 |
23 | - name: 📦 Install dependencies
24 | run: pnpm install
25 |
26 | - name: 🛠 Build project
27 | run: pnpm build
28 |
29 | - name: 🔠 Lint project
30 | run: pnpm lint
31 |
32 | - name: ✂️ Knip project
33 | run: pnpm test:knip
34 |
35 | - name: ⚙️ Check package engines
36 | run: pnpm test:versions
37 |
38 | test:
39 | runs-on: ubuntu-latest
40 |
41 | steps:
42 | - uses: actions/checkout@v4
43 | - run: npm i -g --force corepack && corepack enable
44 | - uses: actions/setup-node@v4
45 | with:
46 | node-version: lts/*
47 | cache: pnpm
48 |
49 | - name: 📦 Install dependencies
50 | run: pnpm install
51 |
52 | - name: 🛠 Build project
53 | run: pnpm build
54 |
55 | - name: 💪 Test types
56 | run: pnpm test:types
57 |
58 | - name: 🧪 Test project
59 | run: pnpm test:unit --coverage
60 |
61 | - name: 🟩 Coverage
62 | uses: codecov/codecov-action@v5
63 | env:
64 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
65 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | permissions:
4 | contents: write
5 |
6 | on:
7 | push:
8 | tags:
9 | - 'v*'
10 |
11 | jobs:
12 | release:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v4
16 | with:
17 | fetch-depth: 0
18 |
19 | - run: npm i -g --force corepack && corepack enable
20 | - uses: actions/setup-node@v4
21 | with:
22 | node-version: lts/*
23 | cache: pnpm
24 |
25 | - name: 📦 Install dependencies
26 | run: pnpm install
27 |
28 | - run: pnpm changelogithub
29 | env:
30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | coverage
4 | .vscode
5 | .DS_Store
6 | .eslintcache
7 | *.log*
8 | *.env*
9 | .husky
10 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | ## Our standards
8 |
9 | Examples of behavior that contributes to creating a positive environment include:
10 |
11 | - Using welcoming and inclusive language
12 | - Being respectful of differing viewpoints and experiences
13 | - Gracefully accepting constructive criticism
14 | - Focusing on what is best for the community
15 | - Showing empathy towards other community members
16 |
17 | Examples of unacceptable behavior by participants include:
18 |
19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances
20 | - Trolling, insulting/derogatory comments, and personal or political attacks
21 | - Public or private harassment
22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission
23 | - Other conduct which could reasonably be considered inappropriate in a professional setting
24 |
25 | ## Our responsibilities
26 |
27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28 |
29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
30 |
31 | ## Scope
32 |
33 | This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
34 |
35 | ## Enforcement
36 |
37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [daniel@roe.dev](mailto:daniel@roe.dev). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
38 |
39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
40 |
41 | ## Attribution
42 |
43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
44 |
45 | [homepage]: https://www.contributor-covenant.org
46 |
47 | For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq
48 |
--------------------------------------------------------------------------------
/LICENCE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Daniel Roe
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | packages/fontaine/README.md
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import antfu from '@antfu/eslint-config'
2 |
3 | export default antfu()
4 | .append({
5 | ignores: ['README.md', 'packages/*/README.md'],
6 | })
7 | .append({
8 | files: ['packages/fontless/examples/**'],
9 | rules: {
10 | 'unused-imports/no-unused-vars': 'off',
11 | },
12 | })
13 | .append({
14 | files: ['**/service-worker.ts'],
15 | rules: {
16 | 'ts/no-use-before-define': 'off',
17 | },
18 | })
19 | .append({
20 | ignores: [
21 | 'packages/fontless/examples/**/*',
22 | ],
23 | })
24 |
--------------------------------------------------------------------------------
/knip.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/knip@5/schema.json",
3 | "workspaces": {
4 | "packages/fontless/examples/analog-app": {
5 | "entry": [
6 | "src/**/*"
7 | ],
8 | "ignoreDependencies": [
9 | "@angular/forms",
10 | "@angular/platform-browser-dynamic",
11 | "front-matter",
12 | "prismjs",
13 | "@analogjs/vite-plugin-angular",
14 | "@analogjs/vitest-angular",
15 | "@angular-devkit/build-angular",
16 | "@angular/build",
17 | "@angular/compiler-cli",
18 | "vite-tsconfig-paths"
19 | ]
20 | },
21 | "packages/fontless/examples/qwik-app": {
22 | "entry": [
23 | "src/entry.*",
24 | "src/routes/*"
25 | ],
26 | "ignoreDependencies": [
27 | "@qwik-router-config",
28 | "@qwik-client-manifest"
29 | ]
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fontaine-monorepo",
3 | "preview": true,
4 | "type": "module",
5 | "packageManager": "pnpm@10.11.1",
6 | "description": "Automatic font fallback based on font metrics",
7 | "author": {
8 | "name": "Daniel Roe",
9 | "email": "daniel@roe.dev",
10 | "url": "https://github.com/danielroe"
11 | },
12 | "license": "MIT",
13 | "scripts": {
14 | "build": "pnpm --filter fontaine --filter fontless build",
15 | "lint": "eslint .",
16 | "test:unit": "pnpm -r test:unit",
17 | "test:types": "pnpm -r test:types",
18 | "test:knip": "knip",
19 | "test:versions": "installed-check -d --workspace-ignore='packages/fontless/examples/*'",
20 | "postinstall": "simple-git-hooks install && pnpm --filter fontaine build"
21 | },
22 | "devDependencies": {
23 | "@antfu/eslint-config": "4.13.2",
24 | "bumpp": "10.1.1",
25 | "changelogithub": "13.15.0",
26 | "eslint": "9.28.0",
27 | "installed-check": "9.3.0",
28 | "knip": "5.59.1",
29 | "lint-staged": "16.1.0",
30 | "simple-git-hooks": "2.13.0",
31 | "typescript": "5.8.3"
32 | },
33 | "resolutions": {
34 | "fontaine": "workspace:*",
35 | "fontless": "workspace:*"
36 | },
37 | "simple-git-hooks": {
38 | "pre-commit": "npx lint-staged"
39 | },
40 | "lint-staged": {
41 | "*.{js,ts,mjs,cjs,json,.*rc}": [
42 | "npx eslint --fix"
43 | ]
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/fontaine/README.md:
--------------------------------------------------------------------------------
1 | # fontaine
2 |
3 | [![npm version][npm-version-src]][npm-version-href]
4 | [![npm downloads][npm-downloads-src]][npm-downloads-href]
5 | [![Github Actions][github-actions-src]][github-actions-href]
6 | [![Codecov][codecov-src]][codecov-href]
7 |
8 | > Automatic font fallback based on font metrics
9 |
10 | - [✨ Changelog](https://github.com/unjs/fontaine/blob/main/CHANGELOG.md)
11 | - [▶️ Online playground](https://stackblitz.com/github/unjs/fontaine/tree/main/packages/fontaine/playground)
12 |
13 | ## Features
14 |
15 | - 💪 Reduces CLS by using local font fallbacks with crafted font metrics.
16 | - ✨ Generates font metrics and overrides automatically.
17 | - ⚡️ Pure CSS, zero runtime overhead.
18 |
19 | On the playground project, enabling/disabling `fontaine` makes the following difference rendering `/`, with no customisation required:
20 |
21 | | | Before | After |
22 | | ----------- | ------ | ------- |
23 | | CLS | `0.24` | `0.054` |
24 | | Performance | `92` | `100` |
25 |
26 | ## Installation
27 |
28 | With `pnpm`
29 |
30 | ```bash
31 | pnpm add -D fontaine
32 | ```
33 |
34 | Or, with `npm`
35 |
36 | ```bash
37 | npm install -D fontaine
38 | ```
39 |
40 | Or, with `yarn`
41 |
42 | ```bash
43 | yarn add -D fontaine
44 | ```
45 |
46 | ## Usage
47 |
48 | ```js
49 | import { FontaineTransform } from 'fontaine'
50 |
51 | // Astro config - astro.config.mjs
52 | import { defineConfig } from 'astro/config'
53 |
54 | const options = {
55 | // You can specify fallbacks as an array (applies to all fonts)
56 | fallbacks: ['BlinkMacSystemFont', 'Segoe UI', 'Helvetica Neue', 'Arial', 'Noto Sans'],
57 |
58 | // Or as an object to configure specific fallbacks per font family
59 | // fallbacks: {
60 | // Poppins: ['Helvetica Neue'],
61 | // 'JetBrains Mono': ['Courier New']
62 | // },
63 |
64 | // You may need to resolve assets like `/fonts/Roboto.woff2` to a particular directory
65 | resolvePath: id => `file:///path/to/public/dir${id}`,
66 | // overrideName: (originalName) => `${name} override`
67 | // sourcemap: false
68 | // skipFontFaceGeneration: (fallbackName) => fallbackName === 'Roboto override'
69 | }
70 |
71 | // Vite
72 | export default {
73 | plugins: [FontaineTransform.vite(options)]
74 | }
75 |
76 | // Next.js
77 | export default {
78 | webpack(config) {
79 | config.plugins = config.plugins || []
80 | config.plugins.push(FontaineTransform.webpack(options))
81 | return config
82 | },
83 | }
84 |
85 | // Docusaurus plugin - to be provided to the plugins option of docusaurus.config.js
86 | // n.b. you'll likely need to require fontaine rather than importing it
87 | const fontaine = require('fontaine')
88 |
89 | function fontainePlugin(_context, _options) {
90 | return {
91 | name: 'fontaine-plugin',
92 | configureWebpack(_config, _isServer) {
93 | return {
94 | plugins: [
95 | fontaine.FontaineTransform.webpack(options),
96 | ],
97 | }
98 | },
99 | }
100 | }
101 |
102 | // Gatsby config - gatsby-node.js
103 | const { FontaineTransform } = require('fontaine')
104 |
105 | exports.onCreateWebpackConfig = ({ stage, actions, getConfig }) => {
106 | const config = getConfig()
107 | config.plugins.push(FontaineTransform.webpack(options))
108 | actions.replaceWebpackConfig(config)
109 | }
110 |
111 | export default defineConfig({
112 | integrations: [],
113 | vite: {
114 | plugins: [
115 | FontaineTransform.vite({
116 | fallbacks: ['Arial'],
117 | resolvePath: id => new URL(`./public${id}`, import.meta.url), // id is the font src value in the CSS
118 | }),
119 | ],
120 | },
121 | })
122 | ```
123 |
124 | > **Note**
125 | > If you are using Nuxt, check out [nuxt-font-metrics](https://github.com/danielroe/nuxt-font-metrics) which uses `fontaine` under the hood.
126 |
127 | If your custom font is used through the mechanism of CSS variables, you'll need to make a tweak to your CSS variables to give fontaine a helping hand. Docusaurus is an example of this, it uses the `--ifm-font-family-base` variable to reference a custom font. In order that fontaine can connect the variable with the font, we need to add a `{Name of Font} override` suffix to that variable. What does this look like? Well imagine we were using the custom font Poppins which is referenced from the `--ifm-font-family-base` variable, we'd make the following adjustment:
128 |
129 | ```diff
130 | :root {
131 | /* ... */
132 | - --ifm-font-family-base: 'Poppins';
133 | + --ifm-font-family-base: 'Poppins', 'Poppins override';
134 | ```
135 |
136 | Behind the scenes, there is a 'Poppins override' `@font-face` rule that has been created by fontaine. By manually adding this override font family to our CSS variable, we make our site use the fallback `@font-face` rule with the correct font metrics that fontaine generates.
137 |
138 | ## How it works
139 |
140 | `fontaine` will scan your `@font-face` rules and generate fallback rules with the correct metrics. For example:
141 |
142 | ```css
143 | @font-face {
144 | font-family: 'Roboto';
145 | font-display: swap;
146 | src: url('/fonts/Roboto.woff2') format('woff2'), url('/fonts/Roboto.woff')
147 | format('woff');
148 | font-weight: 700;
149 | }
150 | /* This additional font-face declaration will be added to your CSS. */
151 | @font-face {
152 | font-family: 'Roboto override';
153 | src: local('BlinkMacSystemFont'), local('Segoe UI'), local('Helvetica Neue'),
154 | local('Arial'), local('Noto Sans');
155 | ascent-override: 92.7734375%;
156 | descent-override: 24.4140625%;
157 | line-gap-override: 0%;
158 | }
159 | ```
160 |
161 | Then, whenever you use `font-family: 'Roboto'`, `fontaine` will add the override to the font-family:
162 |
163 | ```css
164 | :root {
165 | font-family: 'Roboto';
166 | /* This becomes */
167 | font-family: 'Roboto', 'Roboto override';
168 | }
169 | ```
170 |
171 | ## 💻 Development
172 |
173 | - Clone this repository
174 | - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` (use `npm i -g corepack` for Node.js < 16.10)
175 | - Install dependencies using `pnpm install`
176 | - Run interactive tests using `pnpm dev`; launch a vite server using source code with `pnpm demo:dev`
177 |
178 | ## Credits
179 |
180 | This would not have been possible without:
181 |
182 | - amazing tooling and generated metrics from [capsizecss](https://seek-oss.github.io/capsize/)
183 | - suggestion and algorithm from [Katie Hempenius](https://katiehempenius.com/) & [Kara Erickson](https://github.com/kara) on the Google Aurora team - see [notes on calculating font metric overrides](https://docs.google.com/document/d/e/2PACX-1vRsazeNirATC7lIj2aErSHpK26hZ6dA9GsQ069GEbq5fyzXEhXbvByoftSfhG82aJXmrQ_sJCPBqcx_/pub)
184 | - package name suggestion from [**@clemcode**](https://github.com/clemcode)
185 |
186 | ## License
187 |
188 | Made with ❤️
189 |
190 | Published under [MIT License](./LICENCE).
191 |
192 |
193 |
194 | [npm-version-src]: https://img.shields.io/npm/v/fontaine?style=flat-square
195 | [npm-version-href]: https://npmjs.com/package/fontaine
196 | [npm-downloads-src]: https://img.shields.io/npm/dm/fontaine?style=flat-square
197 | [npm-downloads-href]: https://npmjs.com/package/fontaine
198 | [github-actions-src]: https://img.shields.io/github/actions/workflow/status/unjs/fontaine/ci.yml?branch=main&style=flat-square
199 | [github-actions-href]: https://github.com/unjs/fontaine/actions/workflows/ci.yml
200 | [codecov-src]: https://img.shields.io/codecov/c/gh/unjs/fontaine/main?style=flat-square
201 | [codecov-href]: https://codecov.io/gh/unjs/fontaine
202 |
--------------------------------------------------------------------------------
/packages/fontaine/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fontaine",
3 | "type": "module",
4 | "version": "0.6.0",
5 | "description": "Automatic font fallback based on font metrics",
6 | "author": {
7 | "name": "Daniel Roe",
8 | "email": "daniel@roe.dev",
9 | "url": "https://github.com/danielroe"
10 | },
11 | "license": "MIT",
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/unjs/fontaine.git",
15 | "directory": "packages/fontaine"
16 | },
17 | "keywords": [
18 | "fonts",
19 | "cls",
20 | "web-vitals",
21 | "performance"
22 | ],
23 | "sideEffects": false,
24 | "exports": {
25 | ".": {
26 | "import": "./dist/index.mjs",
27 | "require": "./dist/index.cjs"
28 | }
29 | },
30 | "main": "./dist/index.cjs",
31 | "module": "./dist/index.mjs",
32 | "types": "./dist/index.d.ts",
33 | "files": [
34 | "dist"
35 | ],
36 | "engines": {
37 | "node": ">=18.12.0"
38 | },
39 | "scripts": {
40 | "build": "unbuild",
41 | "dev": "vitest",
42 | "demo": "vite dev playground",
43 | "demo:dev": "pnpm demo --config test/vite.config.mjs",
44 | "lint": "eslint .",
45 | "prepublishOnly": "pnpm lint && pnpm test",
46 | "release": "pnpm test && bumpp && npm publish",
47 | "test": "vitest run"
48 | },
49 | "dependencies": {
50 | "@capsizecss/metrics": "^3.5.0",
51 | "@capsizecss/unpack": "^2.4.0",
52 | "css-tree": "^3.1.0",
53 | "magic-regexp": "^0.10.0",
54 | "magic-string": "^0.30.17",
55 | "pathe": "^2.0.3",
56 | "ufo": "^1.6.1",
57 | "unplugin": "^2.3.2"
58 | },
59 | "devDependencies": {
60 | "@types/css-tree": "2.3.10",
61 | "@types/node": "22.15.29",
62 | "@types/serve-handler": "6.1.4",
63 | "@vitest/coverage-v8": "3.2.0",
64 | "eslint": "9.28.0",
65 | "get-port-please": "3.1.2",
66 | "serve-handler": "6.1.6",
67 | "typescript": "5.8.3",
68 | "unbuild": "latest",
69 | "vite": "6.3.5",
70 | "vitest": "3.2.0"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/packages/fontaine/playground/fonts/font.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontaine/playground/fonts/font.ttf
--------------------------------------------------------------------------------
/packages/fontaine/playground/index.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Poppins variant';
3 | font-display: swap;
4 | src: url('/fonts/font.ttf') format('truetype');
5 | }
6 |
7 | @font-face {
8 | font-family: 'Roboto';
9 | font-display: swap;
10 | src: url('https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2')
11 | format('woff2');
12 | }
13 |
14 | @font-face {
15 | font-family: 'Inter';
16 | font-display: swap;
17 | src: url('https://fonts.gstatic.com/s/inter/v12/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2')
18 | format('woff2');
19 | }
20 |
21 | :root {
22 | /* Adding this manually for now */
23 | --someFont: 'Poppins variant', 'Poppins variant fallback';
24 | }
25 |
26 | h1 {
27 | font-family: 'Poppins variant', sans-serif;
28 | }
29 |
30 | .roboto {
31 | font-family: 'Roboto', Arial, Helvetica, sans-serif;
32 | }
33 |
34 | p {
35 | font-family: 'Poppins variant';
36 | }
37 |
38 | div {
39 | font-family: var(--someFont);
40 | }
41 |
42 | .inter {
43 | font-family: Inter;
44 | }
45 |
--------------------------------------------------------------------------------
/packages/fontaine/playground/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | fontaine demo
8 |
9 |
10 |
11 |
12 |
13 |
A headline
14 |
A subheading
15 |
16 | Id occaecat labore et adipisicing excepteur consequat et culpa pariatur quis qui officia non
17 | cillum. Adipisicing aliquip occaecat non est minim nulla esse. Mollit in ex esse Lorem
18 | consectetur elit consequat quis adipisicing enim et culpa. Irure nostrud laboris consequat
19 | veniam dolor quis ullamco sint.
20 |
21 |
22 | Consequat elit anim ex mollit cillum eiusmod voluptate. Sunt dolor Lorem proident esse amet
23 | duis velit amet consectetur qui voluptate sint adipisicing. Voluptate nostrud non quis laborum
24 | veniam commodo duis laboris dolore veniam commodo amet. Officia cillum est sunt anim ullamco
25 | tempor ipsum dolore nisi dolore ut. Velit eu minim minim non laborum exercitation.
26 |
27 |
28 | Reprehenderit fugiat sit proident id laboris amet nulla quis est dolor consequat ad eiusmod.
29 | Mollit laborum cupidatat nisi commodo enim eiusmod sit. Est dolor ipsum nulla pariatur
30 | pariatur esse ea est labore fugiat eu velit. Minim ex sunt Lorem nisi non officia.
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/packages/fontaine/playground/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "engines": {
4 | "node": "^18.12.0 || ^20.0.0 || >=22.0.0"
5 | },
6 | "scripts": {
7 | "dev": "vite"
8 | },
9 | "dependencies": {
10 | "fontaine": "latest",
11 | "vite": "^6.3.2"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/fontaine/playground/vite.config.mjs:
--------------------------------------------------------------------------------
1 | import { FontaineTransform } from 'fontaine'
2 | import { defineConfig } from 'vite'
3 |
4 | export default defineConfig({
5 | plugins: [
6 | FontaineTransform.vite({
7 | fallbacks: ['Arial'],
8 | // resolve absolute URL -> file
9 | resolvePath: id => new URL(`.${id}`, import.meta.url),
10 | }),
11 | ],
12 | })
13 |
--------------------------------------------------------------------------------
/packages/fontaine/src/css.ts:
--------------------------------------------------------------------------------
1 | import type { Font } from '@capsizecss/unpack'
2 | import type { CssNode } from 'css-tree'
3 | import { generate, parse, walk } from 'css-tree'
4 | import { charIn, createRegExp } from 'magic-regexp'
5 |
6 | // See: https://github.com/seek-oss/capsize/blob/master/packages/core/src/round.ts
7 | function toPercentage(value: number, fractionDigits = 4) {
8 | const percentage = value * 100
9 | return `${+percentage.toFixed(fractionDigits)}%`
10 | }
11 |
12 | function toCSS(properties: Record, indent = 2) {
13 | return Object.entries(properties)
14 | .map(([key, value]) => `${' '.repeat(indent)}${key}: ${value};`)
15 | .join('\n')
16 | }
17 |
18 | const QUOTES_RE = createRegExp(
19 | charIn('"\'').at.lineStart().or(charIn('"\'').at.lineEnd()),
20 | ['g'],
21 | )
22 |
23 | export const withoutQuotes = (str: string) => str.trim().replace(QUOTES_RE, '')
24 |
25 | // https://developer.mozilla.org/en-US/docs/Web/CSS/font-family
26 | const genericCSSFamilies = new Set([
27 | 'serif',
28 | 'sans-serif',
29 | 'monospace',
30 | 'cursive',
31 | 'fantasy',
32 | 'system-ui',
33 | 'ui-serif',
34 | 'ui-sans-serif',
35 | 'ui-monospace',
36 | 'ui-rounded',
37 | 'emoji',
38 | 'math',
39 | 'fangsong',
40 | ])
41 |
42 | const fontProperties = new Set(['font-weight', 'font-style', 'font-stretch'])
43 |
44 | interface FontProperties {
45 | 'font-weight'?: string
46 | 'font-style'?: string
47 | 'font-stretch'?: string
48 | }
49 |
50 | /**
51 | * Extracts font family and source information from a CSS @font-face rule using css-tree.
52 | *
53 | * @param {string} css - The CSS containing @font-face rules
54 | * @returns Array<{ family?: string, source?: string }> - Array of objects with font family and source information
55 | */
56 | export function parseFontFace(css: string | CssNode): Array<{ index: number, family: string, source?: string, properties: FontProperties }> {
57 | const families: Array<{ index: number, family: string, source?: string, properties: FontProperties }> = []
58 | const ast = typeof css === 'string' ? parse(css, { positions: true }) : css
59 |
60 | walk(ast, {
61 | visit: 'Atrule',
62 | enter(node) {
63 | if (node.name !== 'font-face')
64 | return
65 |
66 | let family: string | undefined
67 | const sources: string[] = []
68 | const properties: FontProperties = {}
69 |
70 | if (node.block) {
71 | walk(node.block, {
72 | visit: 'Declaration',
73 | enter(declaration) {
74 | if (declaration.property === 'font-family' && declaration.value.type === 'Value') {
75 | for (const child of declaration.value.children) {
76 | if (child.type === 'String') {
77 | family = withoutQuotes(child.value)
78 | break
79 | }
80 | if (child.type === 'Identifier' && !genericCSSFamilies.has(child.name)) {
81 | family = child.name
82 | break
83 | }
84 | }
85 | }
86 |
87 | if (fontProperties.has(declaration.property)) {
88 | if (declaration.value.type === 'Value') {
89 | for (const child of declaration.value.children) {
90 | const hasValue = !!properties[declaration.property as keyof FontProperties]
91 | properties[declaration.property as keyof FontProperties] ||= ''
92 | properties[declaration.property as keyof FontProperties] += (hasValue ? ' ' : '') + generate(child)
93 | }
94 | }
95 | }
96 |
97 | if (declaration.property === 'src') {
98 | walk(declaration.value, {
99 | visit: 'Url',
100 | enter(urlNode) {
101 | const source = withoutQuotes(urlNode.value)
102 | if (source) {
103 | sources.push(source)
104 | }
105 | },
106 | })
107 | }
108 | },
109 | })
110 | }
111 |
112 | if (family) {
113 | for (const source of sources) {
114 | families.push({ index: node.loc!.start.offset, family, source, properties })
115 | }
116 | if (!sources.length) {
117 | families.push({ index: node.loc!.start.offset, family, properties })
118 | }
119 | }
120 | },
121 | })
122 |
123 | return families
124 | }
125 |
126 | /**
127 | * Generates a fallback name based on the first font family specified in the input string.
128 | * @param {string} name - The full font family string.
129 | * @returns {string} - The fallback font name.
130 | */
131 | export function generateFallbackName(name: string) {
132 | const firstFamily = withoutQuotes(name.split(',').shift()!)
133 | return `${firstFamily} fallback`
134 | }
135 |
136 | interface FallbackOptions {
137 | /**
138 | * The name of the fallback font.
139 | */
140 | name: string
141 |
142 | /**
143 | * The fallback font family name.
144 | */
145 | font: string
146 |
147 | /**
148 | * Metrics for fallback face calculations.
149 | * @optional
150 | */
151 | metrics?: FontFaceMetrics
152 |
153 | /**
154 | * Additional properties that may be included dynamically
155 | */
156 | [key: string]: any
157 | }
158 |
159 | export type FontFaceMetrics = Pick<
160 | Font,
161 | 'ascent' | 'descent' | 'lineGap' | 'unitsPerEm' | 'xWidthAvg'
162 | >
163 |
164 | /**
165 | * Generates a CSS `@font-face' declaration for a font, taking fallback and resizing into account.
166 | * @param {FontFaceMetrics} metrics - The metrics of the preferred font. See {@link FontFaceMetrics}.
167 | * @param {FallbackOptions} fallback - The fallback options, including name, font and optional metrics. See {@link FallbackOptions}.
168 | * @returns {string} - The full `@font-face` CSS declaration.
169 | */
170 | export function generateFontFace(metrics: FontFaceMetrics, fallback: FallbackOptions) {
171 | const { name: fallbackName, font: fallbackFontName, metrics: fallbackMetrics, ...properties } = fallback
172 |
173 | // Credits to: https://github.com/seek-oss/capsize/blob/master/packages/core/src/createFontStack.ts
174 |
175 | // Calculate size adjust
176 | const preferredFontXAvgRatio = metrics.xWidthAvg / metrics.unitsPerEm
177 | const fallbackFontXAvgRatio = fallbackMetrics
178 | ? fallbackMetrics.xWidthAvg / fallbackMetrics.unitsPerEm
179 | : 1
180 |
181 | const sizeAdjust = fallbackMetrics && preferredFontXAvgRatio && fallbackFontXAvgRatio
182 | ? preferredFontXAvgRatio / fallbackFontXAvgRatio
183 | : 1
184 |
185 | const adjustedEmSquare = metrics.unitsPerEm * sizeAdjust
186 |
187 | // Calculate metric overrides for preferred font
188 | const ascentOverride = metrics.ascent / adjustedEmSquare
189 | const descentOverride = Math.abs(metrics.descent) / adjustedEmSquare
190 | const lineGapOverride = metrics.lineGap / adjustedEmSquare
191 |
192 | const declaration = {
193 | 'font-family': JSON.stringify(fallbackName),
194 | 'src': `local(${JSON.stringify(fallbackFontName)})`,
195 | 'size-adjust': toPercentage(sizeAdjust),
196 | 'ascent-override': toPercentage(ascentOverride),
197 | 'descent-override': toPercentage(descentOverride),
198 | 'line-gap-override': toPercentage(lineGapOverride),
199 | ...properties,
200 | }
201 |
202 | return `@font-face {\n${toCSS(declaration)}\n}\n`
203 | }
204 |
--------------------------------------------------------------------------------
/packages/fontaine/src/index.ts:
--------------------------------------------------------------------------------
1 | export { generateFallbackName, generateFontFace } from './css'
2 | export { getMetricsForFamily, readMetrics } from './metrics'
3 | export { FontaineTransform } from './transform'
4 |
5 | export type { FontaineTransformOptions } from './transform'
6 |
--------------------------------------------------------------------------------
/packages/fontaine/src/metrics.ts:
--------------------------------------------------------------------------------
1 | import type { Font } from '@capsizecss/unpack'
2 | import type { FontFaceMetrics } from './css'
3 |
4 | import { fileURLToPath } from 'node:url'
5 | import { fontFamilyToCamelCase } from '@capsizecss/metrics'
6 | import { fromFile, fromUrl } from '@capsizecss/unpack'
7 | import { parseURL } from 'ufo'
8 |
9 | import { withoutQuotes } from './css'
10 |
11 | const metricCache: Record = {}
12 |
13 | function filterRequiredMetrics({ ascent, descent, lineGap, unitsPerEm, xWidthAvg }: Pick) {
14 | return {
15 | ascent,
16 | descent,
17 | lineGap,
18 | unitsPerEm,
19 | xWidthAvg,
20 | }
21 | }
22 |
23 | /**
24 | * Retrieves the font metrics for a given font family from the metrics collection. Uses caching to avoid redundant calculations.
25 | * @param {string} family - The name of the font family for which metrics are requested.
26 | * @returns {Promise} - A promise that resolves with the filtered font metrics or null if not found. See {@link FontFaceMetrics}.
27 | * @async
28 | */
29 | export async function getMetricsForFamily(family: string) {
30 | family = withoutQuotes(family)
31 |
32 | if (family in metricCache)
33 | return metricCache[family]
34 |
35 | try {
36 | const name = fontFamilyToCamelCase(family)
37 | const { entireMetricsCollection } = await import('@capsizecss/metrics/entireMetricsCollection')
38 | const metrics = entireMetricsCollection[name as keyof typeof entireMetricsCollection]
39 |
40 | /* v8 ignore next 4 */
41 | if (!('descent' in metrics)) {
42 | metricCache[family] = null
43 | return null
44 | }
45 |
46 | const filteredMetrics = filterRequiredMetrics(metrics)
47 | metricCache[family] = filteredMetrics
48 | return filteredMetrics
49 | }
50 | catch {
51 | metricCache[family] = null
52 | return null
53 | }
54 | }
55 |
56 | const urlRequestCache = new Map>()
57 |
58 | /**
59 | * Reads font metrics from a specified source URL or file path. This function supports both local files and remote URLs.
60 | * It caches the results to optimise subsequent requests for the same source.
61 | * @param {URL | string} _source - The source URL or local file path from which to read the font metrics.
62 | * @returns {Promise} - A promise that resolves to the filtered font metrics or null if the source cannot be processed.
63 | * @async
64 | */
65 | export async function readMetrics(_source: URL | string) {
66 | const source = typeof _source !== 'string' && 'href' in _source ? _source.href : _source
67 |
68 | if (source in metricCache)
69 | return metricCache[source]
70 |
71 | const { protocol } = parseURL(source)
72 | if (!protocol)
73 | return null
74 |
75 | let metrics: Font
76 | if (protocol === 'file:') {
77 | metrics = await fromFile(fileURLToPath(source))
78 | }
79 | else {
80 | if (urlRequestCache.has(source)) {
81 | metrics = await urlRequestCache.get(source)!
82 | }
83 | else {
84 | const requestPromise = fromUrl(source)
85 | urlRequestCache.set(source, requestPromise)
86 |
87 | metrics = await requestPromise
88 | }
89 | }
90 |
91 | const filteredMetrics = filterRequiredMetrics(metrics)
92 | metricCache[source] = filteredMetrics
93 | return filteredMetrics
94 | }
95 |
--------------------------------------------------------------------------------
/packages/fontaine/src/transform.ts:
--------------------------------------------------------------------------------
1 | import { pathToFileURL } from 'node:url'
2 | import { parse, walk } from 'css-tree'
3 | import { anyOf, createRegExp, exactly } from 'magic-regexp'
4 | import MagicString from 'magic-string'
5 | import { isAbsolute } from 'pathe'
6 |
7 | import { parseURL } from 'ufo'
8 | import { createUnplugin } from 'unplugin'
9 | import { generateFallbackName, generateFontFace, parseFontFace, withoutQuotes } from './css'
10 | import { getMetricsForFamily, readMetrics } from './metrics'
11 |
12 | export interface FontaineTransformOptions {
13 | /**
14 | * Configuration options for the CSS transformation.
15 | * @optional
16 | */
17 | css?: {
18 | /**
19 | * Holds the current value of the CSS being transformed.
20 | * @optional
21 | */
22 | value?: string
23 | }
24 |
25 | /**
26 | * Font family fallbacks to use.
27 | * Can be an array of fallback font family names to use for all fonts,
28 | * or an object where keys are font family names and values are arrays of fallback font families.
29 | */
30 | fallbacks: string[] | Record
31 |
32 | /**
33 | * Function to resolve a given path to a valid URL or local path.
34 | * This is typically used to resolve font file paths.
35 | * @optional
36 | */
37 | resolvePath?: (path: string) => string | URL
38 |
39 | /**
40 | * A function to determine whether to skip font face generation for a given fallback name.
41 | * @optional
42 | */
43 | skipFontFaceGeneration?: (fallbackName: string) => boolean
44 |
45 | /**
46 | * Function to generate an unquoted font family name to use as a fallback.
47 | * This should return a valid CSS font family name and should not include quotes.
48 | * @optional
49 | */
50 | fallbackName?: (name: string) => string
51 | /** @deprecated use fallbackName */
52 | overrideName?: (name: string) => string
53 |
54 | /**
55 | * Specifies whether to create a source map for the transformation.
56 | * @optional
57 | */
58 | sourcemap?: boolean
59 | }
60 |
61 | const supportedExtensions = ['woff2', 'woff', 'ttf']
62 |
63 | const CSS_RE = createRegExp(
64 | exactly('.')
65 | .and(anyOf('sass', 'css', 'scss'))
66 | .at.lineEnd(),
67 | )
68 |
69 | const RELATIVE_RE = createRegExp(
70 | exactly('.').or('..').and(anyOf('/', '\\')).at.lineStart(),
71 | )
72 |
73 | /**
74 | * Transforms CSS files to include font fallbacks.
75 | *
76 | * @param options - The transformation options. See {@link FontaineTransformOptions}.
77 | * @returns The unplugin instance.
78 | */
79 | export const FontaineTransform = createUnplugin((options: FontaineTransformOptions) => {
80 | const cssContext = (options.css = options.css || {})
81 | cssContext.value = ''
82 | const resolvePath = options.resolvePath || (id => id)
83 | const fallbackName = options.fallbackName || options.overrideName || generateFallbackName
84 |
85 | const skipFontFaceGeneration = options.skipFontFaceGeneration || (() => false)
86 |
87 | function getFallbacksForFamily(family: string): string[] {
88 | if (Array.isArray(options.fallbacks)) {
89 | return options.fallbacks
90 | }
91 | return options.fallbacks[family] || []
92 | }
93 |
94 | function readMetricsFromId(path: string, importer: string) {
95 | const resolvedPath = isAbsolute(importer) && RELATIVE_RE.test(path)
96 | ? new URL(path, pathToFileURL(importer))
97 | : resolvePath(path)
98 | return readMetrics(resolvedPath)
99 | }
100 |
101 | return {
102 | name: 'fontaine-transform',
103 | enforce: 'pre',
104 | transformInclude(id) {
105 | const { pathname } = parseURL(id)
106 | return CSS_RE.test(pathname) || CSS_RE.test(id)
107 | },
108 | async transform(code, id) {
109 | const s = new MagicString(code)
110 |
111 | const ast = parse(code, { positions: true })
112 |
113 | for (const { family, source, index, properties } of parseFontFace(ast)) {
114 | if (!supportedExtensions.some(e => source?.endsWith(e)))
115 | continue
116 | if (skipFontFaceGeneration(fallbackName(family)))
117 | continue
118 |
119 | const metrics = (await getMetricsForFamily(family)) || (source && (await readMetricsFromId(source, id).catch(() => null)))
120 |
121 | /* v8 ignore next 2 */
122 | if (!metrics)
123 | continue
124 |
125 | const familyFallbacks = getFallbacksForFamily(family)
126 |
127 | // Iterate backwards: Browsers will use the last working font-face in the stylesheet
128 | for (let i = familyFallbacks.length - 1; i >= 0; i--) {
129 | const fallback = familyFallbacks[i]!
130 | const fallbackMetrics = await getMetricsForFamily(fallback)
131 |
132 | if (!fallbackMetrics)
133 | continue
134 |
135 | const fontFace = generateFontFace(metrics, {
136 | name: fallbackName(family),
137 | font: fallback,
138 | metrics: fallbackMetrics,
139 | ...properties,
140 | })
141 | cssContext.value += fontFace
142 | s.appendLeft(index, fontFace)
143 | }
144 | }
145 |
146 | walk(ast, {
147 | visit: 'Declaration',
148 | enter(node) {
149 | if (node.property !== 'font-family')
150 | return
151 | if (this.atrule && this.atrule.name === 'font-face')
152 | return
153 | if (node.value.type !== 'Value')
154 | /* v8 ignore next */ return
155 |
156 | for (const child of node.value.children) {
157 | let family: string | undefined
158 | if (child.type === 'String') {
159 | family = withoutQuotes(child.value)
160 | }
161 | else if (child.type === 'Identifier' && child.name !== 'inherit') {
162 | family = child.name
163 | }
164 |
165 | if (!family)
166 | continue
167 |
168 | s.appendRight(child.loc!.end.offset, `, "${fallbackName(family)}"`)
169 | return
170 | }
171 | },
172 | })
173 |
174 | if (s.hasChanged()) {
175 | return {
176 | code: s.toString(),
177 | /* v8 ignore next 3 */
178 | map: options.sourcemap
179 | ? s.generateMap({ source: id, includeContent: true })
180 | : undefined,
181 | }
182 | }
183 | },
184 | }
185 | })
186 |
--------------------------------------------------------------------------------
/packages/fontaine/tea.yaml:
--------------------------------------------------------------------------------
1 | # https://tea.xyz/what-is-this-file
2 | ---
3 | version: 1.0.0
4 | codeOwners:
5 | - '0xBb533C940878fdBa9E5434d659e05dAbEc4EC423'
6 | quorum: 1
7 |
--------------------------------------------------------------------------------
/packages/fontaine/test/e2e.spec.ts:
--------------------------------------------------------------------------------
1 | import { readdir, readFile } from 'node:fs/promises'
2 | import { fileURLToPath } from 'node:url'
3 | import { join } from 'pathe'
4 | import { build } from 'vite'
5 | import { describe, expect, it } from 'vitest'
6 |
7 | describe('fontaine', () => {
8 | it('e2e', async () => {
9 | const assetsDir = fileURLToPath(new URL('../playground/dist/assets', import.meta.url))
10 | await build({ root: fileURLToPath(new URL('../playground', import.meta.url)) })
11 | const cssFile = await readdir(assetsDir).then(files =>
12 | files.find(f => f.endsWith('.css')),
13 | )
14 | // @ts-expect-error there must be a file or we _want_ a test failure
15 | const css = await readFile(join(assetsDir, cssFile), 'utf-8')
16 | expect(css.replace(/\.\w+\.woff2/g, '.woff2')).toMatchInlineSnapshot(`
17 | "@font-face{font-family:Poppins variant fallback;src:local("Arial");size-adjust:112.1577%;ascent-override:93.6182%;descent-override:31.2061%;line-gap-override:8.916%}@font-face{font-family:Poppins variant;font-display:swap;src:url(/assets/font-CTKNfV9P.ttf) format("truetype")}@font-face{font-family:Roboto fallback;src:local("Arial");size-adjust:99.7809%;ascent-override:92.9771%;descent-override:24.4677%;line-gap-override:0%}@font-face{font-family:Roboto;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2) format("woff2")}@font-face{font-family:Inter fallback;src:local("Arial");size-adjust:107.1194%;ascent-override:90.4365%;descent-override:22.518%;line-gap-override:0%}@font-face{font-family:Inter;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v12/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2) format("woff2")}:root{--someFont: "Poppins variant", "Poppins variant fallback"}h1{font-family:Poppins variant,Poppins variant fallback,sans-serif}.roboto{font-family:Roboto,Roboto fallback,Arial,Helvetica,sans-serif}p{font-family:Poppins variant,Poppins variant fallback}div{font-family:var(--someFont)}.inter{font-family:Inter,Inter fallback}
18 | "
19 | `)
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/packages/fontaine/test/metrics.spec.ts:
--------------------------------------------------------------------------------
1 | import * as unpack from '@capsizecss/unpack'
2 | import { afterEach, describe, expect, it, vi } from 'vitest'
3 | import { readMetrics } from '../src/metrics'
4 |
5 | const mockFont = {
6 | ascent: 2000,
7 | descent: -500,
8 | lineGap: 200,
9 | unitsPerEm: 2048,
10 | xWidthAvg: 1000,
11 | }
12 |
13 | vi.mock('@capsizecss/unpack', () => ({
14 | fromFile: vi.fn(),
15 | fromUrl: vi.fn(),
16 | }))
17 |
18 | describe('readMetrics', () => {
19 | afterEach(() => {
20 | vi.clearAllMocks()
21 | })
22 |
23 | it('should cache url requests and only make one network call for concurrent requests', async () => {
24 | const url = 'https://example.com/font.ttf'
25 |
26 | let resolvePromise: (value: any) => void
27 | const delayedPromise = new Promise((resolve) => {
28 | resolvePromise = resolve
29 | })
30 |
31 | vi.mocked(unpack.fromUrl).mockReturnValue(delayedPromise as Promise)
32 |
33 | const promises = Array.from({ length: 200 }, () => readMetrics(url))
34 |
35 | resolvePromise!(mockFont)
36 |
37 | const results = await Promise.all(promises)
38 |
39 | expect(unpack.fromUrl).toHaveBeenCalledTimes(1)
40 |
41 | results.forEach((result) => {
42 | expect(result).toEqual(mockFont)
43 | })
44 | })
45 | })
46 |
--------------------------------------------------------------------------------
/packages/fontaine/test/transform.spec.ts:
--------------------------------------------------------------------------------
1 | import type { RollupPlugin } from 'unplugin'
2 | import type { FontaineTransformOptions } from '../src/transform'
3 | import { fileURLToPath } from 'node:url'
4 | import { fromFile, fromUrl } from '@capsizecss/unpack'
5 | import { describe, expect, it, vi } from 'vitest'
6 | import { FontaineTransform } from '../src'
7 |
8 | vi.mock('@capsizecss/unpack', { spy: true })
9 |
10 | describe('fontaine transform', () => {
11 | it('should not process non-CSS files', async () => {
12 | expect(await transform('.foo { font-family: Poppins; }', {}, 'test.vue')).toBeUndefined()
13 | expect(await transform('.foo { font-family: Poppins; }', {}, 'test.vue?lang=.css')).not.toBeUndefined()
14 | })
15 |
16 | it('should add fallback font family to `font-family` properties', async () => {
17 | expect(await transform(`
18 | .foo {
19 | font-family: Poppins;
20 | }
21 | .bar {
22 | font-family: var(--font-family, Poppins);
23 | }
24 | .baz {
25 | font-family: "Poppins Regular";
26 | }
27 | `))
28 | .toMatchInlineSnapshot(`
29 | ".foo {
30 | font-family: Poppins, "Poppins fallback";
31 | }
32 | .bar {
33 | font-family: var(--font-family, Poppins);
34 | }
35 | .baz {
36 | font-family: "Poppins Regular", "Poppins Regular fallback";
37 | }"
38 | `)
39 | })
40 |
41 | it('should add additional @font-face declarations', async () => {
42 | expect(await transform(`
43 | @font-face {
44 | font-family: Poppins;
45 | src: url('poppins.ttf');
46 | }
47 | `))
48 | .toMatchInlineSnapshot(`
49 | "@font-face {
50 | font-family: "Poppins fallback";
51 | src: local("Segoe UI");
52 | size-adjust: 112.7753%;
53 | ascent-override: 93.1055%;
54 | descent-override: 31.0352%;
55 | line-gap-override: 8.8672%;
56 | }
57 | @font-face {
58 | font-family: "Poppins fallback";
59 | src: local("Arial");
60 | size-adjust: 112.1577%;
61 | ascent-override: 93.6182%;
62 | descent-override: 31.2061%;
63 | line-gap-override: 8.916%;
64 | }
65 | @font-face {
66 | font-family: Poppins;
67 | src: url('poppins.ttf');
68 | }"
69 | `)
70 | })
71 |
72 | it('should add additional font properties to declarations', async () => {
73 | expect(await transform(`
74 | @font-face {
75 | font-family: Poppins;
76 | src: url('poppins.ttf');
77 | font-weight: 700;
78 | font-style: oblique 10deg;
79 | font-stretch: 75%;
80 | }`)).toMatchInlineSnapshot(`
81 | "@font-face {
82 | font-family: "Poppins fallback";
83 | src: local("Segoe UI");
84 | size-adjust: 112.7753%;
85 | ascent-override: 93.1055%;
86 | descent-override: 31.0352%;
87 | line-gap-override: 8.8672%;
88 | font-weight: 700;
89 | font-style: oblique 10deg;
90 | font-stretch: 75%;
91 | }
92 | @font-face {
93 | font-family: "Poppins fallback";
94 | src: local("Arial");
95 | size-adjust: 112.1577%;
96 | ascent-override: 93.6182%;
97 | descent-override: 31.2061%;
98 | line-gap-override: 8.916%;
99 | font-weight: 700;
100 | font-style: oblique 10deg;
101 | font-stretch: 75%;
102 | }
103 | @font-face {
104 | font-family: Poppins;
105 | src: url('poppins.ttf');
106 | font-weight: 700;
107 | font-style: oblique 10deg;
108 | font-stretch: 75%;
109 | }"
110 | `)
111 | })
112 |
113 | it('should read metrics from URLs', async () => {
114 | await transform(`
115 | @font-face {
116 | font-family: 'Unique Font';
117 | src: url('https://roe.dev/my.ttf');
118 | }
119 | `)
120 | expect(fromUrl).toHaveBeenCalledWith('https://roe.dev/my.ttf')
121 | })
122 |
123 | it('should read metrics from local paths', async () => {
124 | await transform(`
125 | @font-face {
126 | font-family: 'Unique Font';
127 | src: url('./my.ttf');
128 | }
129 | `, { resolvePath: id => new URL(id, import.meta.url) })
130 | expect(fromFile).toHaveBeenCalledWith(fileURLToPath(new URL('./my.ttf', import.meta.url)))
131 |
132 | // @ts-expect-error not typed as mock
133 | fromFile.mockReset()
134 | const cssFilename = fileURLToPath(new URL('./test.css', import.meta.url))
135 | await transform(`
136 | @font-face {
137 | font-family: 'Unique Font';
138 | src: url('./my.ttf');
139 | }
140 | `, {}, cssFilename)
141 | expect(fromFile).toHaveBeenCalledWith(fileURLToPath(new URL('./my.ttf', import.meta.url)))
142 | })
143 |
144 | it('should ignore unsupported extensions', async () => {
145 | // @ts-expect-error not typed as mock
146 | fromFile.mockReset()
147 | await transform(`
148 | @font-face {
149 | font-family: 'Unique Font';
150 | src: url('./my.wasm');
151 | }
152 | `, { resolvePath: id => new URL(id, import.meta.url) })
153 | expect(fromFile).not.toHaveBeenCalled()
154 | })
155 |
156 | it('should allow skipping font-face generation', async () => {
157 | const result = await transform(`
158 | @font-face {
159 | font-family: Poppins;
160 | src: url('poppins.ttf');
161 | }
162 | `, { skipFontFaceGeneration: () => true })
163 | expect(result).toBeUndefined()
164 | })
165 |
166 | it('should skip generating font face declarations for unsupported fallbacks', async () => {
167 | const result = await transform(`
168 | @font-face {
169 | font-family: Poppins;
170 | src: url('poppins.ttf');
171 | }
172 | `, { fallbacks: ['Bingle Bob the Unknown Font'] })
173 | expect(result).toBeUndefined()
174 | })
175 |
176 | it('should use specific fallbacks for different font families', async () => {
177 | expect(await transform(`
178 | @font-face {
179 | font-family: Poppins;
180 | src: url('poppins.ttf');
181 | }
182 | @font-face {
183 | font-family: 'JetBrains Mono';
184 | src: url('jetbrains-mono.ttf');
185 | }
186 | `, {
187 | fallbacks: {
188 | 'Poppins': ['Helvetica Neue'],
189 | 'JetBrains Mono': ['Courier New'],
190 | },
191 | }))
192 | .toMatchInlineSnapshot(`
193 | "@font-face {
194 | font-family: "Poppins fallback";
195 | src: local("Helvetica Neue");
196 | size-adjust: 111.1111%;
197 | ascent-override: 94.5%;
198 | descent-override: 31.5%;
199 | line-gap-override: 9%;
200 | }
201 | @font-face {
202 | font-family: Poppins;
203 | src: url('poppins.ttf');
204 | }
205 | @font-face {
206 | font-family: "JetBrains Mono fallback";
207 | src: local("Courier New");
208 | size-adjust: 99.9837%;
209 | ascent-override: 102.0166%;
210 | descent-override: 30.0049%;
211 | line-gap-override: 0%;
212 | }
213 | @font-face {
214 | font-family: 'JetBrains Mono';
215 | src: url('jetbrains-mono.ttf');
216 | }"
217 | `)
218 | })
219 | })
220 |
221 | async function transform(css: string, options: Partial = {}, filename = 'test.css') {
222 | const plugin = FontaineTransform.rollup({
223 | fallbacks: ['Arial', 'Segoe UI'],
224 | ...options,
225 | }) as RollupPlugin
226 | const result = await (plugin.transform as any)(css, filename)
227 | return result?.code.replace(/^ {6}/gm, '').trim()
228 | }
229 |
--------------------------------------------------------------------------------
/packages/fontaine/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath } from 'node:url'
2 | import { defineConfig } from 'vitest/config'
3 |
4 | export default defineConfig({
5 | resolve: {
6 | alias: {
7 | fontaine: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
8 | },
9 | },
10 | test: {
11 | coverage: {
12 | thresholds: {
13 | 100: true,
14 | },
15 | include: ['src'],
16 | reporter: ['text', 'json', 'html'],
17 | },
18 | },
19 | })
20 |
--------------------------------------------------------------------------------
/packages/fontless/README.md:
--------------------------------------------------------------------------------
1 | # fontless
2 |
3 | [![npm version][npm-version-src]][npm-version-href]
4 | [![npm downloads][npm-downloads-src]][npm-downloads-href]
5 | [![Github Actions][github-actions-src]][github-actions-href]
6 | [![Codecov][codecov-src]][codecov-href]
7 |
8 | > Magical plug-and-play font optimization for modern web applications
9 |
10 | ## Features
11 |
12 | - 🚀 **Optimized font loading**: Automatically loads and configures fonts with proper fallbacks
13 | - 🔤 **Multiple provider support**: Google Fonts, Bunny Fonts, FontShare, FontSource, and more using [unifont](https://github.com/unjs/unifont)
14 | - 📦 **Zero runtime overhead**: Pure CSS solution with no JavaScript required at runtime
15 | - 📏 **Metric-based fallbacks**: Reduces Cumulative Layout Shift (CLS) by using font metrics from [fontaine](https://github.com/unjs/fontaine)
16 | - 🔄 **CSS transformation**: Detects font-family usage in your CSS and injects optimized `@font-face` declarations
17 | - 🎯 **Framework agnostic**: Works with all modern frameworks (Vue, React, Solid, Svelte, Qwik, etc.)
18 |
19 | ## Installation
20 |
21 | ```sh
22 | # npm
23 | npm install fontless
24 |
25 | # pnpm
26 | pnpm install fontless
27 | ```
28 |
29 | ## Usage
30 |
31 | Add the `fontless` plugin to your Vite configuration:
32 |
33 | ```js
34 | // vite.config.js / vite.config.ts
35 | import { defineConfig } from 'vite'
36 | import { fontless } from 'fontless'
37 |
38 | export default defineConfig({
39 | plugins: [
40 | // ... other plugins
41 | fontless()
42 | ],
43 | })
44 | ```
45 |
46 | ### Using fonts in your CSS
47 |
48 | Simply use fonts in your CSS as you normally would, and fontless will handle optimization:
49 |
50 | ```css
51 | /* Your CSS */
52 | .google-font {
53 | font-family: "Poppins", sans-serif;
54 | }
55 |
56 | .bunny-font {
57 | font-family: "Aclonica", sans-serif;
58 | }
59 | ```
60 |
61 | ## Configuration
62 |
63 | You can customize fontless with various options:
64 |
65 | ```js
66 | fontless({
67 | // Configure available providers
68 | providers: {
69 | google: true, // Google Fonts
70 | bunny: true, // Bunny Fonts
71 | fontshare: true, // FontShare
72 | fontsource: true, // FontSource
73 | // Disable a provider
74 | adobe: false
75 | },
76 |
77 | // Provider priority order
78 | priority: ['google', 'bunny', 'fontshare'],
79 |
80 | // Default font settings
81 | defaults: {
82 | preload: true,
83 | weights: [400, 700],
84 | styles: ['normal', 'italic'],
85 | fallbacks: {
86 | 'sans-serif': ['Arial', 'Helvetica Neue']
87 | }
88 | },
89 |
90 | // Custom font family configurations
91 | families: [
92 | // Configure a specific font
93 | {
94 | name: 'Poppins',
95 | provider: 'google',
96 | weights: [300, 400, 600]
97 | },
98 | // Manual font configuration
99 | {
100 | name: 'CustomFont',
101 | src: [{ url: '/fonts/custom-font.woff2', format: 'woff2' }],
102 | weight: [400]
103 | }
104 | ],
105 |
106 | // Asset configuration
107 | assets: {
108 | prefix: '/_fonts'
109 | },
110 |
111 | // Experimental features
112 | experimental: {
113 | disableLocalFallbacks: false
114 | }
115 | })
116 | ```
117 |
118 | ## How It Works
119 |
120 | Fontless works by:
121 |
122 | 1. Scanning your CSS files for font-family declarations
123 | 2. Resolving fonts through various providers (Google, Bunny, etc.)
124 | 3. Generating optimized `@font-face` declarations with proper metrics
125 | 4. Adding fallback fonts with correct metric overrides to reduce CLS
126 | 5. Automatically downloading and managing font assets
127 |
128 | ## 💻 Development
129 |
130 | - Clone this repository
131 | - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable`
132 | - Install dependencies using `pnpm install`
133 | - Run interactive tests using `pnpm dev`
134 |
135 | ## License
136 |
137 | Made with ❤️
138 |
139 | Published under [MIT License](./LICENCE).
140 |
141 |
142 |
143 | [npm-version-src]: https://img.shields.io/npm/v/fontless?style=flat-square
144 | [npm-version-href]: https://npmjs.com/package/fontless
145 | [npm-downloads-src]: https://img.shields.io/npm/dm/fontless?style=flat-square
146 | [npm-downloads-href]: https://npm.chart.dev/fontless
147 | [github-actions-src]: https://img.shields.io/github/actions/workflow/status/unjs/fontaine/ci.yml?branch=main&style=flat-square
148 | [github-actions-href]: https://github.com/unjs/fontaine/actions/workflows/ci.yml
149 | [codecov-src]: https://img.shields.io/codecov/c/gh/unjs/fontaine/main?style=flat-square
150 | [codecov-href]: https://codecov.io/gh/unjs/fontaine
151 |
--------------------------------------------------------------------------------
/packages/fontless/build.config.ts:
--------------------------------------------------------------------------------
1 | import { defineBuildConfig } from 'unbuild'
2 |
3 | export default defineBuildConfig({
4 | declaration: 'node16',
5 | rollup: {
6 | dts: { respectExternal: false },
7 | },
8 | externals: ['vite'],
9 | })
10 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/.gitignore:
--------------------------------------------------------------------------------
1 | # See http://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # Compiled output
4 | /dist
5 | /tmp
6 | /out-tsc
7 | /bazel-out
8 |
9 | # Node
10 | /node_modules
11 | npm-debug.log
12 | yarn-error.log
13 |
14 | # IDEs and editors
15 | .idea/
16 | .project
17 | .classpath
18 | .c9/
19 | *.launch
20 | .settings/
21 | *.sublime-workspace
22 |
23 | # Visual Studio Code
24 | .vscode/*
25 | !.vscode/settings.json
26 | !.vscode/tasks.json
27 | !.vscode/launch.json
28 | !.vscode/extensions.json
29 | .history/*
30 |
31 | # Miscellaneous
32 | /.angular/cache
33 | /.nx/cache
34 | /.nx/workspace-data
35 | .sass-cache/
36 | /connect.lock
37 | /coverage
38 | /libpeerconnection.log
39 | testem.log
40 | /typings
41 |
42 | # System files
43 | .DS_Store
44 | Thumbs.db
45 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/angular.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3 | "version": 1,
4 | "newProjectRoot": "projects",
5 | "projects": {
6 | "my-app": {
7 | "projectType": "application",
8 | "root": "",
9 | "sourceRoot": "src",
10 | "prefix": "app",
11 | "architect": {
12 | "build": {
13 | "builder": "@analogjs/platform:vite",
14 | "options": {
15 | "configFile": "vite.config.ts",
16 | "main": "src/main.ts",
17 | "outputPath": "dist/client",
18 | "tsConfig": "tsconfig.app.json"
19 | },
20 | "defaultConfiguration": "production",
21 | "configurations": {
22 | "development": {
23 | "mode": "development"
24 | },
25 | "production": {
26 | "sourcemap": false,
27 | "mode": "production"
28 | }
29 | }
30 | },
31 | "serve": {
32 | "builder": "@analogjs/platform:vite-dev-server",
33 | "defaultConfiguration": "development",
34 | "options": {
35 | "buildTarget": "my-app:build",
36 | "port": 5173
37 | },
38 | "configurations": {
39 | "development": {
40 | "buildTarget": "my-app:build:development",
41 | "hmr": true
42 | },
43 | "production": {
44 | "buildTarget": "my-app:build:production"
45 | }
46 | }
47 | }
48 | }
49 | }
50 | },
51 | "cli": {
52 | "analytics": false
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | analog-app
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "analog-app",
3 | "type": "module",
4 | "version": "0.0.0",
5 | "private": true,
6 | "engines": {
7 | "node": ">=18.19.1"
8 | },
9 | "scripts": {
10 | "ng": "ng",
11 | "dev": "ng serve",
12 | "start": "pnpm run dev",
13 | "build": "ng build",
14 | "watch": "ng build --watch --configuration development",
15 | "test": "ng test"
16 | },
17 | "dependencies": {
18 | "@analogjs/content": "^1.12.0",
19 | "@analogjs/router": "^1.12.0",
20 | "@angular/animations": "^19.0.0",
21 | "@angular/common": "^19.0.0",
22 | "@angular/compiler": "^19.0.0",
23 | "@angular/core": "^19.0.0",
24 | "@angular/forms": "^19.0.0",
25 | "@angular/platform-browser": "^19.0.0",
26 | "@angular/platform-browser-dynamic": "^19.0.0",
27 | "@angular/platform-server": "^19.0.0",
28 | "@angular/router": "^19.0.0",
29 | "fontless": "latest",
30 | "front-matter": "^4.0.2",
31 | "marked": "^5.0.2",
32 | "marked-gfm-heading-id": "^3.1.0",
33 | "marked-highlight": "^2.0.1",
34 | "marked-mangle": "^1.1.7",
35 | "prismjs": "^1.29.0",
36 | "rxjs": "~7.8.0",
37 | "tslib": "^2.3.0",
38 | "zone.js": "~0.15.0"
39 | },
40 | "devDependencies": {
41 | "@analogjs/platform": "^1.12.0",
42 | "@analogjs/vite-plugin-angular": "^1.12.0",
43 | "@analogjs/vitest-angular": "^1.12.0",
44 | "@angular-devkit/build-angular": "^19.0.0",
45 | "@angular/build": "^19.0.0",
46 | "@angular/cli": "^19.0.0",
47 | "@angular/compiler-cli": "^19.0.0",
48 | "jsdom": "^22.0.0",
49 | "typescript": "~5.5.0",
50 | "vite": "^5.0.0",
51 | "vite-tsconfig-paths": "^4.2.0",
52 | "vitest": "^2.0.0"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/public/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/analog-app/public/.gitkeep
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/analog-app/public/favicon.ico
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/src/app/app.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core'
2 | import { RouterOutlet } from '@angular/router'
3 |
4 | @Component({
5 | selector: 'app-root',
6 | standalone: true,
7 | imports: [RouterOutlet],
8 | template: ' ',
9 | })
10 | export class AppComponent {}
11 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/src/app/app.config.server.ts:
--------------------------------------------------------------------------------
1 | import type { ApplicationConfig } from '@angular/core'
2 | import { mergeApplicationConfig } from '@angular/core'
3 | import { provideServerRendering } from '@angular/platform-server'
4 |
5 | import { appConfig } from './app.config'
6 |
7 | const serverConfig: ApplicationConfig = {
8 | providers: [provideServerRendering()],
9 | }
10 |
11 | export const config = mergeApplicationConfig(appConfig, serverConfig)
12 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/src/app/app.config.ts:
--------------------------------------------------------------------------------
1 | import type { ApplicationConfig } from '@angular/core'
2 | import { provideFileRouter, requestContextInterceptor } from '@analogjs/router'
3 | import {
4 | provideHttpClient,
5 | withFetch,
6 | withInterceptors,
7 | } from '@angular/common/http'
8 | import {
9 |
10 | provideZoneChangeDetection,
11 | } from '@angular/core'
12 | import { provideClientHydration } from '@angular/platform-browser'
13 |
14 | export const appConfig: ApplicationConfig = {
15 | providers: [
16 | provideZoneChangeDetection({ eventCoalescing: true }),
17 | provideFileRouter(),
18 | provideHttpClient(
19 | withFetch(),
20 | withInterceptors([requestContextInterceptor]),
21 | ),
22 | provideClientHydration(),
23 | ],
24 | }
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/src/app/pages/index.page.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core'
2 |
3 | @Component({
4 | selector: 'app-home',
5 | standalone: true,
6 | template: `
7 |
8 |
Google
9 |
Poppins
10 |
Press Start 2P
11 |
12 |
Bunny
13 |
Aclonica
14 |
Allan
15 |
16 |
FontShare
17 |
Panchang
18 |
19 |
FontSource
20 |
Luckiest
21 |
22 |
Local
23 |
Local font
24 |
25 | `,
26 | })
27 | export default class HomeComponent {}
28 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/src/black-fox.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/analog-app/src/black-fox.ttf
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/src/main.server.ts:
--------------------------------------------------------------------------------
1 | import { render } from '@analogjs/router/server'
2 | import { AppComponent } from './app/app.component'
3 | import { config } from './app/app.config.server'
4 |
5 | import 'zone.js/node'
6 | import '@angular/platform-server/init'
7 |
8 | export default render(AppComponent, config)
9 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/src/main.ts:
--------------------------------------------------------------------------------
1 | import { bootstrapApplication } from '@angular/platform-browser'
2 | import { AppComponent } from './app/app.component'
3 |
4 | import { appConfig } from './app/app.config'
5 | import 'zone.js'
6 |
7 | bootstrapApplication(AppComponent, appConfig)
8 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/src/styles.css:
--------------------------------------------------------------------------------
1 | :root {
2 | color: rgba(255, 255, 255, 0.87);
3 | background-color: #242424;
4 | }
5 |
6 | p {
7 | font-size: x-large;
8 | }
9 |
10 | .google-poppins {
11 | font-family: "Poppins", sans-serif;
12 | }
13 |
14 | .google-press-start {
15 | font-family: "Press Start 2P", sans-serif;
16 | }
17 |
18 | .bunny-aclonica {
19 | font-family: "Aclonica", sans-serif;
20 | }
21 |
22 | .bunny-allan {
23 | font-family: "Allan", sans-serif;
24 | }
25 |
26 | .font-share-panchang {
27 | font-family: "Panchang", sans-serif;
28 | }
29 |
30 | .font-source-luckiest {
31 | font-family: "Luckiest Guy", sans-serif;
32 | }
33 |
34 | @font-face {
35 | font-family: "Black Fox";
36 | src: url("./black-fox.ttf");
37 | }
38 |
39 | .local {
40 | font-family: "Black Fox", sans-serif;
41 | }
42 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "extends": "./tsconfig.json",
4 | "compilerOptions": {
5 | "types": [],
6 | "outDir": "./out-tsc/app"
7 | },
8 | "files": ["src/main.ts", "src/main.server.ts"],
9 | "include": [
10 | "src/**/*.d.ts",
11 | "src/app/pages/**/*.page.ts",
12 | "src/server/middleware/**/*.ts"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "compileOnSave": false,
4 | "compilerOptions": {
5 | "target": "ES2022",
6 | "lib": ["ES2022", "dom"],
7 | "useDefineForClassFields": false,
8 | "experimentalDecorators": true,
9 | "baseUrl": "./",
10 | "module": "ES2022",
11 | "moduleResolution": "node",
12 | "strict": true,
13 | "noFallthroughCasesInSwitch": true,
14 | "noImplicitOverride": true,
15 | "noImplicitReturns": true,
16 | "noPropertyAccessFromIndexSignature": true,
17 | "declaration": false,
18 | "downlevelIteration": true,
19 | "importHelpers": true,
20 | "outDir": "./dist/out-tsc",
21 | "sourceMap": true,
22 | "forceConsistentCasingInFileNames": true,
23 | "isolatedModules": true,
24 | "skipLibCheck": true
25 | },
26 | "angularCompilerOptions": {
27 | "enableI18nLegacyMessageIdFormat": false,
28 | "strictInjectionParameters": true,
29 | "strictInputAccessModifiers": true,
30 | "strictTemplates": true
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/packages/fontless/examples/analog-app/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import analog from '@analogjs/platform'
4 | import { fontless } from 'fontless'
5 | import { defineConfig } from 'vite'
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig(({ mode }) => ({
9 | build: {
10 | target: ['es2020'],
11 | },
12 | resolve: {
13 | mainFields: ['module'],
14 | },
15 | plugins: [
16 | analog({
17 | ssr: false,
18 | static: true,
19 | prerender: {
20 | routes: [],
21 | },
22 | }),
23 | fontless(),
24 | ],
25 | }))
26 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/.eslintignore:
--------------------------------------------------------------------------------
1 | **/*.log
2 | **/.DS_Store
3 | *.
4 | .vscode/settings.json
5 | .history
6 | .yarn
7 | bazel-*
8 | bazel-bin
9 | bazel-out
10 | bazel-qwik
11 | bazel-testlogs
12 | dist
13 | dist-dev
14 | lib
15 | lib-types
16 | etc
17 | external
18 | node_modules
19 | temp
20 | tsc-out
21 | tsdoc-metadata.json
22 | target
23 | output
24 | rollup.config.js
25 | build
26 | .cache
27 | .vscode
28 | .rollup.cache
29 | dist
30 | tsconfig.tsbuildinfo
31 | vite.config.mts
32 | *.spec.tsx
33 | *.spec.ts
34 | .netlify
35 | pnpm-lock.yaml
36 | package-lock.json
37 | yarn.lock
38 | server
39 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | browser: true,
5 | es2021: true,
6 | node: true,
7 | },
8 | extends: [
9 | 'eslint:recommended',
10 | 'plugin:@typescript-eslint/recommended',
11 | 'plugin:qwik/recommended',
12 | ],
13 | parser: '@typescript-eslint/parser',
14 | parserOptions: {
15 | tsconfigRootDir: __dirname,
16 | project: ['./tsconfig.json'],
17 | ecmaVersion: 2021,
18 | sourceType: 'module',
19 | ecmaFeatures: {
20 | jsx: true,
21 | },
22 | },
23 | plugins: ['@typescript-eslint'],
24 | rules: {
25 | '@typescript-eslint/no-explicit-any': 'off',
26 | '@typescript-eslint/explicit-module-boundary-types': 'off',
27 | '@typescript-eslint/no-inferrable-types': 'off',
28 | '@typescript-eslint/no-non-null-assertion': 'off',
29 | '@typescript-eslint/no-empty-interface': 'off',
30 | '@typescript-eslint/no-namespace': 'off',
31 | '@typescript-eslint/no-empty-function': 'off',
32 | '@typescript-eslint/no-this-alias': 'off',
33 | '@typescript-eslint/ban-types': 'off',
34 | '@typescript-eslint/ban-ts-comment': 'off',
35 | 'prefer-spread': 'off',
36 | 'no-case-declarations': 'off',
37 | 'no-console': 'off',
38 | '@typescript-eslint/no-unused-vars': ['error'],
39 | '@typescript-eslint/consistent-type-imports': 'warn',
40 | '@typescript-eslint/no-unnecessary-condition': 'warn',
41 | },
42 | }
43 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/.gitignore:
--------------------------------------------------------------------------------
1 | # Build
2 | /dist
3 | /lib
4 | /lib-types
5 | /server
6 |
7 | # Development
8 | node_modules
9 | .env
10 | *.local
11 |
12 | # Cache
13 | .cache
14 | .mf
15 | .rollup.cache
16 | tsconfig.tsbuildinfo
17 |
18 | # Logs
19 | logs
20 | *.log
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | pnpm-debug.log*
25 | lerna-debug.log*
26 |
27 | # Editor
28 | .vscode/*
29 | !.vscode/launch.json
30 | !.vscode/*.code-snippets
31 |
32 | .idea
33 | .DS_Store
34 | *.suo
35 | *.ntvs*
36 | *.njsproj
37 | *.sln
38 | *.sw?
39 |
40 | # Yarn
41 | .yarn/*
42 | !.yarn/releases
43 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/README.md:
--------------------------------------------------------------------------------
1 | # Qwik App ⚡️
2 |
3 | - [Qwik Docs](https://qwik.dev/)
4 | - [Discord](https://qwik.dev/chat)
5 | - [Qwik GitHub](https://github.com/QwikDev/qwik)
6 | - [@QwikDev](https://twitter.com/QwikDev)
7 | - [Vite](https://vitejs.dev/)
8 |
9 | ---
10 |
11 | ## Project Structure
12 |
13 | This project is using Qwik with [QwikRouter](https://qwik.dev/docs/). QwikRouter is just an extra set of tools on top of Qwik to make it easier to build a full site, including directory-based routing, layouts, and more.
14 |
15 | Inside your project, you'll see the following directory structure:
16 |
17 | ```
18 | ├── public/
19 | │ └── ...
20 | └── src/
21 | ├── components/
22 | │ └── ...
23 | └── routes/
24 | └── ...
25 | ```
26 |
27 | - `src/routes`: Provides the directory-based routing, which can include a hierarchy of `layout.tsx` layout files, and an `index.tsx` file as the page. Additionally, `index.ts` files are endpoints. Please see the [routing docs](https://qwik.dev/docs/routing/) for more info.
28 |
29 | - `src/components`: Recommended directory for components.
30 |
31 | - `public`: Any static assets, like images, can be placed in the public directory. Please see the [Vite public directory](https://vitejs.dev/guide/assets.html#the-public-directory) for more info.
32 |
33 | ## Add Integrations and deployment
34 |
35 | Use the `pnpm qwik add` command to add additional integrations. Some examples of integrations includes: Cloudflare, Netlify or Express Server, and the [Static Site Generator (SSG)](https://qwik.dev/docs/guides/static-site-generation/).
36 |
37 | ```shell
38 | pnpm qwik add # or `pnpm qwik add`
39 | ```
40 |
41 | ## Development
42 |
43 | Development mode uses [Vite's development server](https://vitejs.dev/). The `dev` command will server-side render (SSR) the output during development.
44 |
45 | ```shell
46 | npm start # or `pnpm start`
47 | ```
48 |
49 | > Note: during dev mode, Vite may request a significant number of `.js` files. This does not represent a Qwik production build.
50 |
51 | ## Preview
52 |
53 | The preview command will create a production build of the client modules, a production build of `src/entry.preview.tsx`, and run a local server. The preview server is only for convenience to preview a production build locally and should not be used as a production server.
54 |
55 | ```shell
56 | pnpm preview # or `pnpm preview`
57 | ```
58 |
59 | ## Production
60 |
61 | The production build will generate client and server modules by running both client and server build commands. The build command will use Typescript to run a type check on the source code.
62 |
63 | ```shell
64 | pnpm build # or `pnpm build`
65 | ```
66 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fontless-demo-qwik-app",
3 | "type": "module",
4 | "private": true,
5 | "description": "Qwik demo app",
6 | "engines": {
7 | "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
8 | },
9 | "engines-annotation": "Mostly required by sharp which needs a Node-API v9 compatible runtime",
10 | "trustedDependencies": ["sharp"],
11 | "trustedDependencies-annotation": "Needed for bun to allow running install scripts",
12 | "scripts": {
13 | "build": "qwik build",
14 | "build.client": "vite build",
15 | "build.preview": "vite build --ssr src/entry.preview.tsx",
16 | "build.types": "tsc --incremental --noEmit",
17 | "deploy": "echo 'Run \"npm run qwik add\" to install a server adapter'",
18 | "dev": "vite --mode ssr --port 5173",
19 | "dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force",
20 | "lint": "eslint \"src/**/*.ts*\"",
21 | "preview": "qwik build preview && vite preview --open --port 5173",
22 | "start": "vite --open --mode ssr",
23 | "qwik": "qwik"
24 | },
25 | "devDependencies": {
26 | "@qwik.dev/core": "^2.0.0-alpha.3",
27 | "@qwik.dev/router": "^2.0.0-alpha.3",
28 | "@types/eslint": "8.56.10",
29 | "@types/node": "20.14.11",
30 | "@typescript-eslint/eslint-plugin": "7.16.1",
31 | "@typescript-eslint/parser": "7.16.1",
32 | "eslint": "8.57.0",
33 | "eslint-plugin-qwik": "^2.0.0-alpha.3",
34 | "fontless": "latest",
35 | "typescript": "5.4.5",
36 | "vite": "5.4.10",
37 | "vite-tsconfig-paths": "^4.2.1"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/web-manifest-combined.json",
3 | "name": "qwik-project-name",
4 | "short_name": "Welcome to Qwik",
5 | "start_url": ".",
6 | "display": "standalone",
7 | "background_color": "#fff",
8 | "description": "A Qwik project app."
9 | }
10 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/public/robots.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/qwik-app/public/robots.txt
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/src/components/router-head/router-head.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from '@qwik.dev/core'
2 | import { useDocumentHead, useLocation } from '@qwik.dev/router'
3 |
4 | /**
5 | * The RouterHead component is placed inside of the document `` element.
6 | */
7 | export const RouterHead = component$(() => {
8 | const head = useDocumentHead()
9 | const loc = useLocation()
10 |
11 | return (
12 | <>
13 | {head.title}
14 |
15 |
16 |
17 |
18 |
19 | {head.meta.map(m => (
20 |
21 | ))}
22 |
23 | {head.links.map(l => (
24 |
25 | ))}
26 |
27 | {head.styles.map(s => (
28 |
29 | ))}
30 | >
31 | )
32 | })
33 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/src/entry.dev.tsx:
--------------------------------------------------------------------------------
1 | import type { RenderOptions } from '@qwik.dev/core'
2 | /*
3 | * WHAT IS THIS FILE?
4 | *
5 | * Development entry point using only client-side modules:
6 | * - Do not use this mode in production!
7 | * - No SSR
8 | * - No portion of the application is pre-rendered on the server.
9 | * - All of the application is running eagerly in the browser.
10 | * - More code is transferred to the browser than in SSR mode.
11 | * - Optimizer/Serialization/Deserialization code is not exercised!
12 | */
13 | import { render } from '@qwik.dev/core'
14 | import Root from './root'
15 |
16 | export default function (opts: RenderOptions) {
17 | return render(document, , opts)
18 | }
19 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/src/entry.preview.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | * WHAT IS THIS FILE?
3 | *
4 | * It's the bundle entry point for `npm run preview`.
5 | * That is, serving your app built in production mode.
6 | *
7 | * Feel free to modify this file, but don't remove it!
8 | *
9 | * Learn more about Vite's preview command:
10 | * - https://vitejs.dev/config/preview-options.html#preview-options
11 | *
12 | */
13 | import qwikRouterConfig from '@qwik-router-config'
14 | import { createQwikRouter } from '@qwik.dev/router/middleware/node'
15 | // make sure qwikRouterConfig is imported before entry
16 | import render from './entry.ssr'
17 |
18 | /**
19 | * The default export is the QwikRouter adapter used by Vite preview.
20 | */
21 | export default createQwikRouter({ render, qwikRouterConfig })
22 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/src/entry.ssr.tsx:
--------------------------------------------------------------------------------
1 | import type { RenderToStreamOptions } from '@qwik.dev/core/server'
2 | /**
3 | * WHAT IS THIS FILE?
4 | *
5 | * SSR entry point, in all cases the application is rendered outside the browser, this
6 | * entry point will be the common one.
7 | *
8 | * - Server (express, cloudflare...)
9 | * - npm run start
10 | * - npm run preview
11 | * - npm run build
12 | *
13 | */
14 | import { manifest } from '@qwik-client-manifest'
15 | import {
16 | renderToStream,
17 |
18 | } from '@qwik.dev/core/server'
19 | import Root from './root'
20 |
21 | export default function (opts: RenderToStreamOptions) {
22 | return renderToStream( , {
23 | manifest,
24 | ...opts,
25 | // Use container attributes to set attributes on the html tag.
26 | containerAttributes: {
27 | lang: 'en-us',
28 | ...opts.containerAttributes,
29 | },
30 | serverData: {
31 | ...opts.serverData,
32 | },
33 | })
34 | }
35 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/src/global.css:
--------------------------------------------------------------------------------
1 | :root {
2 | color: rgba(255, 255, 255, 0.87);
3 | background-color: #242424;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/src/root.tsx:
--------------------------------------------------------------------------------
1 | import { component$ } from '@qwik.dev/core'
2 | import { isDev } from '@qwik.dev/core/build'
3 | import {
4 | QwikRouterProvider,
5 | RouterOutlet,
6 | ServiceWorkerRegister,
7 | } from '@qwik.dev/router'
8 | import { RouterHead } from './components/router-head/router-head'
9 |
10 | import './global.css'
11 |
12 | export default component$(() => {
13 | /**
14 | * The root of a QwikRouter site always start with the component,
15 | * immediately followed by the document's and .
16 | *
17 | * Don't remove the `` and `` elements.
18 | */
19 |
20 | return (
21 |
22 |
23 |
24 | {!isDev && (
25 |
29 | )}
30 |
31 |
32 |
33 |
34 | {!isDev && }
35 |
36 |
37 | )
38 | })
39 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/src/routes/black-fox.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/qwik-app/src/routes/black-fox.ttf
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/src/routes/index.css:
--------------------------------------------------------------------------------
1 | p {
2 | font-size: x-large;
3 | }
4 |
5 | .google-poppins {
6 | font-family: "Poppins", sans-serif;
7 | }
8 |
9 | .google-press-start {
10 | font-family: "Press Start 2P", sans-serif;
11 | }
12 |
13 | .bunny-aclonica {
14 | font-family: "Aclonica", sans-serif;
15 | }
16 |
17 | .bunny-allan {
18 | font-family: "Allan", sans-serif;
19 | }
20 |
21 | .font-share-panchang {
22 | font-family: "Panchang", sans-serif;
23 | }
24 |
25 | .font-source-luckiest {
26 | font-family: "Luckiest Guy", sans-serif;
27 | }
28 |
29 | @font-face {
30 | font-family: "Black Fox";
31 | src: url("./black-fox.ttf");
32 | }
33 |
34 | .local {
35 | font-family: "Black Fox", sans-serif;
36 | }
37 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import type { DocumentHead } from '@qwik.dev/router'
2 | import { component$, useStyles$ } from '@qwik.dev/core'
3 | import styles from './index.css?inline'
4 |
5 | export default component$(() => {
6 | useStyles$(styles)
7 |
8 | return (
9 |
10 |
Google
11 |
Poppins
12 |
Press Start 2P
13 |
14 |
Bunny
15 |
Aclonica
16 |
Allan
17 |
18 |
FontShare
19 |
Panchang
20 |
21 |
FontSource
22 |
Luckiest
23 |
24 |
Local
25 |
Local font
26 |
27 | )
28 | })
29 |
30 | export const head: DocumentHead = {
31 | title: 'Welcome to Qwik',
32 | meta: [
33 | {
34 | name: 'description',
35 | content: 'Qwik site description',
36 | },
37 | ],
38 | }
39 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/src/routes/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { RequestHandler } from '@qwik.dev/router'
2 | import { component$, Slot } from '@qwik.dev/core'
3 |
4 | export const onGet: RequestHandler = async ({ cacheControl }) => {
5 | // Control caching for this request for best performance and to reduce hosting costs:
6 | // https://qwik.dev/docs/caching/
7 | cacheControl({
8 | // Always serve a cached response by default, up to a week stale
9 | staleWhileRevalidate: 60 * 60 * 24 * 7,
10 | // Max once every 5 seconds, revalidate on the server to get a fresh version of this page
11 | maxAge: 5,
12 | })
13 | }
14 |
15 | export default component$(() => {
16 | return
17 | })
18 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/src/routes/service-worker.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * WHAT IS THIS FILE?
3 | *
4 | * The service-worker.ts file is used to have state of the art prefetching.
5 | * https://qwik.dev/docs/advanced/speculative-module-fetching/
6 | *
7 | * Qwik uses a service worker to speed up your site and reduce latency, ie, not used in the traditional way of offline.
8 | * You can also use this file to add more functionality that runs in the service worker.
9 | */
10 | import { setupServiceWorker } from '@qwik.dev/router/service-worker'
11 |
12 | setupServiceWorker()
13 |
14 | addEventListener('install', () => self.skipWaiting())
15 |
16 | addEventListener('activate', () => self.clients.claim())
17 |
18 | declare const self: ServiceWorkerGlobalScope
19 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "target": "ES2017",
5 | "jsx": "react-jsx",
6 | "jsxImportSource": "@qwik.dev/core",
7 | "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"],
8 | "module": "ES2022",
9 | "moduleResolution": "Bundler",
10 | "paths": {
11 | "~/*": ["./src/*"]
12 | },
13 | "resolveJsonModule": true,
14 | "allowImportingTsExtensions": true,
15 | "allowJs": true,
16 | "strict": true,
17 | "noEmit": true,
18 | "outDir": "tmp",
19 | "esModuleInterop": true,
20 | "forceConsistentCasingInFileNames": true,
21 | "isolatedModules": true,
22 | "skipLibCheck": true
23 | },
24 | "files": ["./.eslintrc.cjs"],
25 | "include": ["src", "./*.d.ts", "./*.config.ts"]
26 | }
27 |
--------------------------------------------------------------------------------
/packages/fontless/examples/qwik-app/vite.config.mts:
--------------------------------------------------------------------------------
1 | import type { UserConfig } from 'vite'
2 | /**
3 | * This is the base config for vite.
4 | * When building, the adapter config is used which loads this file and extends it.
5 | */
6 | import { qwikVite } from '@qwik.dev/core/optimizer'
7 | import { qwikRouter } from '@qwik.dev/router/vite'
8 | import { fontless } from 'fontless'
9 | import { defineConfig } from 'vite'
10 | import tsconfigPaths from 'vite-tsconfig-paths'
11 | import pkg from './package.json'
12 |
13 | type PkgDep = Record
14 | const { dependencies = {}, devDependencies = {} } = pkg as any as {
15 | dependencies: PkgDep
16 | devDependencies: PkgDep
17 | [key: string]: unknown
18 | }
19 | errorOnDuplicatesPkgDeps(devDependencies, dependencies)
20 |
21 | /**
22 | * Note that Vite normally starts from `index.html` but the qwikRouter plugin makes start at `src/entry.ssr.tsx` instead.
23 | */
24 | export default defineConfig(({ command, mode }): UserConfig => {
25 | return {
26 | plugins: [qwikRouter(), qwikVite(), tsconfigPaths(), fontless()],
27 | // This tells Vite which dependencies to pre-build in dev mode.
28 | optimizeDeps: {
29 | // Put problematic deps that break bundling here, mostly those with binaries.
30 | // For example ['better-sqlite3'] if you use that in server functions.
31 | exclude: [],
32 | },
33 |
34 | /**
35 | * This is an advanced setting. It improves the bundling of your server code. To use it, make sure you understand when your consumed packages are dependencies or dev dependencies. (otherwise things will break in production)
36 | */
37 | // ssr:
38 | // command === "build" && mode === "production"
39 | // ? {
40 | // // All dev dependencies should be bundled in the server build
41 | // noExternal: Object.keys(devDependencies),
42 | // // Anything marked as a dependency will not be bundled
43 | // // These should only be production binary deps (including deps of deps), CLI deps, and their module graph
44 | // // If a dep-of-dep needs to be external, add it here
45 | // // For example, if something uses `bcrypt` but you don't have it as a dep, you can write
46 | // // external: [...Object.keys(dependencies), 'bcrypt']
47 | // external: Object.keys(dependencies),
48 | // }
49 | // : undefined,
50 |
51 | server: {
52 | headers: {
53 | // Don't cache the server response in dev mode
54 | 'Cache-Control': 'public, max-age=0',
55 | },
56 | },
57 | preview: {
58 | headers: {
59 | // Do cache the server response in preview (non-adapter production build)
60 | 'Cache-Control': 'public, max-age=600',
61 | },
62 | },
63 | }
64 | })
65 |
66 | // *** utils ***
67 |
68 | /**
69 | * Function to identify duplicate dependencies and throw an error
70 | * @param {object} devDependencies - List of development dependencies
71 | * @param {object} dependencies - List of production dependencies
72 | */
73 | function errorOnDuplicatesPkgDeps(
74 | devDependencies: PkgDep,
75 | dependencies: PkgDep,
76 | ) {
77 | let msg = ''
78 | // Create an array 'duplicateDeps' by filtering devDependencies.
79 | // If a dependency also exists in dependencies, it is considered a duplicate.
80 | const duplicateDeps = Object.keys(devDependencies).filter(
81 | dep => dependencies[dep],
82 | )
83 |
84 | // include any known qwik packages
85 | const qwikPkg = Object.keys(dependencies).filter(value =>
86 | /qwik/i.test(value),
87 | )
88 |
89 | // any errors for missing "qwik-router-config"
90 | // [PLUGIN_ERROR]: Invalid module "@qwik-router-config" is not a valid package
91 | msg = `Move qwik packages ${qwikPkg.join(', ')} to devDependencies`
92 |
93 | if (qwikPkg.length > 0) {
94 | throw new Error(msg)
95 | }
96 |
97 | // Format the error message with the duplicates list.
98 | // The `join` function is used to represent the elements of the 'duplicateDeps' array as a comma-separated string.
99 | msg = `
100 | Warning: The dependency "${duplicateDeps.join(', ')}" is listed in both "devDependencies" and "dependencies".
101 | Please move the duplicated dependencies to "devDependencies" only and remove it from "dependencies"
102 | `
103 |
104 | // Throw an error with the constructed message.
105 | if (duplicateDeps.length > 0) {
106 | throw new Error(msg)
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import reactHooks from 'eslint-plugin-react-hooks'
3 | import reactRefresh from 'eslint-plugin-react-refresh'
4 | import globals from 'globals'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fontless-demo-react-app",
3 | "type": "module",
4 | "version": "0.0.0",
5 | "private": true,
6 | "description": "React demo app",
7 | "scripts": {
8 | "dev": "vite --port 5174",
9 | "build": "tsc -b && vite build",
10 | "lint": "eslint .",
11 | "preview": "vite preview --port 5174"
12 | },
13 | "dependencies": {
14 | "react": "^18.3.1",
15 | "react-dom": "^18.3.1"
16 | },
17 | "devDependencies": {
18 | "@eslint/js": "^9.15.0",
19 | "@types/react": "^18.3.12",
20 | "@types/react-dom": "^18.3.1",
21 | "@vitejs/plugin-react-swc": "^3.5.0",
22 | "eslint": "^9.15.0",
23 | "eslint-plugin-react-hooks": "^5.0.0",
24 | "eslint-plugin-react-refresh": "^0.4.14",
25 | "fontless": "latest",
26 | "globals": "^15.12.0",
27 | "typescript": "~5.6.2",
28 | "typescript-eslint": "^8.15.0",
29 | "vite": "^6.0.1"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/src/App.css:
--------------------------------------------------------------------------------
1 | p {
2 | font-size: x-large;
3 | }
4 |
5 | .google-poppins {
6 | font-family: "Poppins", sans-serif;
7 | }
8 |
9 | .google-press-start {
10 | font-family: "Press Start 2P", sans-serif;
11 | }
12 |
13 | .bunny-aclonica {
14 | font-family: "Aclonica", sans-serif;
15 | }
16 |
17 | .bunny-allan {
18 | font-family: "Allan", sans-serif;
19 | }
20 |
21 | .font-share-panchang {
22 | font-family: "Panchang", sans-serif;
23 | }
24 |
25 | .font-source-luckiest {
26 | font-family: "Luckiest Guy", sans-serif;
27 | }
28 |
29 | @font-face {
30 | font-family: "Black Fox";
31 | src: url("./black-fox.ttf");
32 | }
33 |
34 | .local {
35 | font-family: "Black Fox", sans-serif;
36 | }
37 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/src/App.tsx:
--------------------------------------------------------------------------------
1 | import './App.css'
2 |
3 | function App() {
4 | return (
5 |
6 |
Google
7 |
Poppins
8 |
Press Start 2P
9 |
10 |
Bunny
11 |
Aclonica
12 |
Allan
13 |
14 |
FontShare
15 |
Panchang
16 |
17 |
FontSource
18 |
Luckiest
19 |
20 |
Local
21 |
Local font
22 |
23 | )
24 | }
25 |
26 | export default App
27 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/src/black-fox.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/react-app/src/black-fox.ttf
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | color: rgba(255, 255, 255, 0.87);
3 | background-color: #242424;
4 | }
5 |
6 | p {
7 | font-size: x-large;
8 | }
9 |
10 | .google-poppins {
11 | font-family: "Poppins", sans-serif;
12 | }
13 |
14 | .google-press-start {
15 | font-family: "Press Start 2P", sans-serif;
16 | }
17 |
18 | .bunny-aclonica {
19 | font-family: "Aclonica", sans-serif;
20 | }
21 |
22 | .bunny-allan {
23 | font-family: "Allan", sans-serif;
24 | }
25 |
26 | .font-share-panchang {
27 | font-family: "Panchang", sans-serif;
28 | }
29 |
30 | .font-source-luckiest {
31 | font-family: "Luckiest Guy", sans-serif;
32 | }
33 |
34 | @font-face {
35 | font-family: "Black Fox";
36 | src: url("./black-fox.ttf");
37 | }
38 |
39 | .local {
40 | font-family: "Black Fox", sans-serif;
41 | }
42 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import App from './App.tsx'
4 | import './index.css'
5 |
6 | createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "jsx": "react-jsx",
6 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
7 | "moduleDetection": "force",
8 | "useDefineForClassFields": true,
9 | "module": "ESNext",
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 |
15 | /* Linting */
16 | "strict": true,
17 | "noFallthroughCasesInSwitch": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noEmit": true,
21 | "isolatedModules": true,
22 | "skipLibCheck": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "references": [
3 | { "path": "./tsconfig.app.json" },
4 | { "path": "./tsconfig.node.json" }
5 | ],
6 | "files": []
7 | }
8 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "moduleDetection": "force",
7 | "module": "ESNext",
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 |
13 | /* Linting */
14 | "strict": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "noUnusedLocals": true,
17 | "noUnusedParameters": true,
18 | "noEmit": true,
19 | "isolatedModules": true,
20 | "skipLibCheck": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/react-app/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react-swc'
2 | import { fontless } from 'fontless'
3 | import { defineConfig } from 'vite'
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | plugins: [react(), fontless()],
8 | })
9 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /**
2 | * This is intended to be a basic starting point for linting in your app.
3 | * It relies on recommended configs out of the box for simplicity, but you can
4 | * and should modify this configuration to best suit your team's needs.
5 | */
6 |
7 | /** @type {import('eslint').Linter.Config} */
8 | module.exports = {
9 | root: true,
10 | parserOptions: {
11 | ecmaVersion: 'latest',
12 | sourceType: 'module',
13 | ecmaFeatures: {
14 | jsx: true,
15 | },
16 | },
17 | env: {
18 | browser: true,
19 | commonjs: true,
20 | es6: true,
21 | },
22 | ignorePatterns: ['!**/.server', '!**/.client'],
23 |
24 | // Base config
25 | extends: ['eslint:recommended'],
26 |
27 | overrides: [
28 | // React
29 | {
30 | files: ['**/*.{js,jsx,ts,tsx}'],
31 | plugins: ['react', 'jsx-a11y'],
32 | extends: [
33 | 'plugin:react/recommended',
34 | 'plugin:react/jsx-runtime',
35 | 'plugin:react-hooks/recommended',
36 | 'plugin:jsx-a11y/recommended',
37 | ],
38 | settings: {
39 | 'react': {
40 | version: 'detect',
41 | },
42 | 'formComponents': ['Form'],
43 | 'linkComponents': [
44 | { name: 'Link', linkAttribute: 'to' },
45 | { name: 'NavLink', linkAttribute: 'to' },
46 | ],
47 | 'import/resolver': {
48 | typescript: {},
49 | },
50 | },
51 | },
52 |
53 | // Typescript
54 | {
55 | files: ['**/*.{ts,tsx}'],
56 | plugins: ['@typescript-eslint', 'import'],
57 | parser: '@typescript-eslint/parser',
58 | settings: {
59 | 'import/internal-regex': '^~/',
60 | 'import/resolver': {
61 | node: {
62 | extensions: ['.ts', '.tsx'],
63 | },
64 | typescript: {
65 | alwaysTryTypes: true,
66 | },
67 | },
68 | },
69 | extends: [
70 | 'plugin:@typescript-eslint/recommended',
71 | 'plugin:import/recommended',
72 | 'plugin:import/typescript',
73 | ],
74 | },
75 |
76 | // Node
77 | {
78 | files: ['.eslintrc.cjs'],
79 | env: {
80 | node: true,
81 | },
82 | },
83 | ],
84 | }
85 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /build
5 | .env
6 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle hydrating your app on the client for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.client
5 | */
6 |
7 | import { RemixBrowser } from '@remix-run/react'
8 | import { startTransition, StrictMode } from 'react'
9 | import { hydrateRoot } from 'react-dom/client'
10 |
11 | startTransition(() => {
12 | hydrateRoot(
13 | document,
14 |
15 |
16 | ,
17 | )
18 | })
19 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle generating the HTTP Response for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.server
5 | */
6 |
7 | import type { AppLoadContext, EntryContext } from '@remix-run/node'
8 |
9 | import { PassThrough } from 'node:stream'
10 | import { createReadableStreamFromReadable } from '@remix-run/node'
11 | import { RemixServer } from '@remix-run/react'
12 | import { isbot } from 'isbot'
13 | import { renderToPipeableStream } from 'react-dom/server'
14 |
15 | const ABORT_DELAY = 5_000
16 |
17 | export default function handleRequest(
18 | request: Request,
19 | responseStatusCode: number,
20 | responseHeaders: Headers,
21 | remixContext: EntryContext,
22 | // This is ignored so we can keep it in the template for visibility. Feel
23 | // free to delete this parameter in your app if you're not using it!
24 | loadContext: AppLoadContext,
25 | ) {
26 | return isbot(request.headers.get('user-agent') || '')
27 | ? handleBotRequest(
28 | request,
29 | responseStatusCode,
30 | responseHeaders,
31 | remixContext,
32 | )
33 | : handleBrowserRequest(
34 | request,
35 | responseStatusCode,
36 | responseHeaders,
37 | remixContext,
38 | )
39 | }
40 |
41 | function handleBotRequest(
42 | request: Request,
43 | responseStatusCode: number,
44 | responseHeaders: Headers,
45 | remixContext: EntryContext,
46 | ) {
47 | return new Promise((resolve, reject) => {
48 | let shellRendered = false
49 | const { pipe, abort } = renderToPipeableStream(
50 | ,
55 | {
56 | onAllReady() {
57 | shellRendered = true
58 | const body = new PassThrough()
59 | const stream = createReadableStreamFromReadable(body)
60 |
61 | responseHeaders.set('Content-Type', 'text/html')
62 |
63 | resolve(
64 | new Response(stream, {
65 | headers: responseHeaders,
66 | status: responseStatusCode,
67 | }),
68 | )
69 |
70 | pipe(body)
71 | },
72 | onShellError(error: unknown) {
73 | reject(error)
74 | },
75 | onError(error: unknown) {
76 | responseStatusCode = 500
77 | // Log streaming rendering errors from inside the shell. Don't log
78 | // errors encountered during initial shell rendering since they'll
79 | // reject and get logged in handleDocumentRequest.
80 | if (shellRendered) {
81 | console.error(error)
82 | }
83 | },
84 | },
85 | )
86 |
87 | setTimeout(abort, ABORT_DELAY)
88 | })
89 | }
90 |
91 | function handleBrowserRequest(
92 | request: Request,
93 | responseStatusCode: number,
94 | responseHeaders: Headers,
95 | remixContext: EntryContext,
96 | ) {
97 | return new Promise((resolve, reject) => {
98 | let shellRendered = false
99 | const { pipe, abort } = renderToPipeableStream(
100 | ,
105 | {
106 | onShellReady() {
107 | shellRendered = true
108 | const body = new PassThrough()
109 | const stream = createReadableStreamFromReadable(body)
110 |
111 | responseHeaders.set('Content-Type', 'text/html')
112 |
113 | resolve(
114 | new Response(stream, {
115 | headers: responseHeaders,
116 | status: responseStatusCode,
117 | }),
118 | )
119 |
120 | pipe(body)
121 | },
122 | onShellError(error: unknown) {
123 | reject(error)
124 | },
125 | onError(error: unknown) {
126 | responseStatusCode = 500
127 | // Log streaming rendering errors from inside the shell. Don't log
128 | // errors encountered during initial shell rendering since they'll
129 | // reject and get logged in handleDocumentRequest.
130 | if (shellRendered) {
131 | console.error(error)
132 | }
133 | },
134 | },
135 | )
136 |
137 | setTimeout(abort, ABORT_DELAY)
138 | })
139 | }
140 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/app/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | color: rgba(255, 255, 255, 0.87);
3 | background-color: #242424;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/app/root.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react'
2 |
3 | import './index.css'
4 |
5 | export function Layout({ children }: { children: React.ReactNode }) {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | {children}
15 |
16 |
17 |
18 |
19 | )
20 | }
21 |
22 | export default function App() {
23 | return
24 | }
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/app/routes/_index.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction } from '@remix-run/node'
2 |
3 | import './styles/styles.css'
4 |
5 | export const meta: MetaFunction = () => {
6 | return [
7 | { title: 'New Remix App' },
8 | { name: 'description', content: 'Welcome to Remix!' },
9 | ]
10 | }
11 |
12 | export default function Index() {
13 | return (
14 |
15 |
Google
16 |
Poppins
17 |
Press Start 2P
18 |
19 |
Bunny
20 |
Aclonica
21 |
Allan
22 |
23 |
FontShare
24 |
Panchang
25 |
26 |
FontSource
27 |
Luckiest
28 |
29 |
Local
30 |
Local font
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/app/routes/styles/black-fox.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/remix-app/app/routes/styles/black-fox.ttf
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/app/routes/styles/styles.css:
--------------------------------------------------------------------------------
1 | p {
2 | font-size: x-large;
3 | }
4 |
5 | .google-poppins {
6 | font-family: "Poppins", sans-serif;
7 | }
8 |
9 | .google-press-start {
10 | font-family: "Press Start 2P", sans-serif;
11 | }
12 |
13 | .bunny-aclonica {
14 | font-family: "Aclonica", sans-serif;
15 | }
16 |
17 | .bunny-allan {
18 | font-family: "Allan", sans-serif;
19 | }
20 |
21 | .font-share-panchang {
22 | font-family: "Panchang", sans-serif;
23 | }
24 |
25 | .font-source-luckiest {
26 | font-family: "Luckiest Guy", sans-serif;
27 | }
28 |
29 | @font-face {
30 | font-family: "Black Fox";
31 | src: url("./black-fox.ttf");
32 | }
33 |
34 | .local {
35 | font-family: "Black Fox", sans-serif;
36 | }
37 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-app",
3 | "type": "module",
4 | "private": true,
5 | "sideEffects": false,
6 | "engines": {
7 | "node": ">=20.0.0"
8 | },
9 | "scripts": {
10 | "build": "remix vite:build",
11 | "dev": "remix vite:dev",
12 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
13 | "start": "remix-serve ./build/server/index.js",
14 | "typecheck": "tsc"
15 | },
16 | "dependencies": {
17 | "@remix-run/node": "^2.15.2",
18 | "@remix-run/react": "^2.15.2",
19 | "@remix-run/serve": "^2.15.2",
20 | "isbot": "^4.1.0",
21 | "react": "^18.2.0",
22 | "react-dom": "^18.2.0"
23 | },
24 | "devDependencies": {
25 | "@remix-run/dev": "^2.15.2",
26 | "@types/react": "^18.2.20",
27 | "@types/react-dom": "^18.2.7",
28 | "@typescript-eslint/eslint-plugin": "^6.7.4",
29 | "@typescript-eslint/parser": "^6.7.4",
30 | "autoprefixer": "^10.4.19",
31 | "eslint": "^8.38.0",
32 | "eslint-import-resolver-typescript": "^3.6.1",
33 | "eslint-plugin-import": "^2.28.1",
34 | "eslint-plugin-jsx-a11y": "^6.7.1",
35 | "eslint-plugin-react": "^7.33.2",
36 | "eslint-plugin-react-hooks": "^4.6.0",
37 | "fontless": "latest",
38 | "postcss": "^8.4.38",
39 | "tailwindcss": "^3.4.4",
40 | "typescript": "^5.1.6",
41 | "vite": "^5.1.0",
42 | "vite-tsconfig-paths": "^4.2.1"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/remix-app/public/favicon.ico
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/public/logo-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/remix-app/public/logo-dark.png
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/public/logo-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/remix-app/public/logo-light.png
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss'
2 |
3 | export default {
4 | content: ['./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}'],
5 | theme: {
6 | extend: {
7 | fontFamily: {
8 | sans: [
9 | 'Inter',
10 | 'ui-sans-serif',
11 | 'system-ui',
12 | 'sans-serif',
13 | 'Apple Color Emoji',
14 | 'Segoe UI Emoji',
15 | 'Segoe UI Symbol',
16 | 'Noto Color Emoji',
17 | ],
18 | },
19 | },
20 | },
21 | plugins: [],
22 | } satisfies Config
23 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "jsx": "react-jsx",
5 | "lib": [
6 | "DOM",
7 | "DOM.Iterable",
8 | "ES2022"
9 | ],
10 | "baseUrl": ".",
11 | "module": "ESNext",
12 | "moduleResolution": "Bundler",
13 | "paths": {
14 | "~/*": [
15 | "./app/*"
16 | ]
17 | },
18 | "resolveJsonModule": true,
19 | "types": [
20 | "@remix-run/node",
21 | "vite/client"
22 | ],
23 | "allowJs": true,
24 | "strict": true,
25 | // Vite takes care of building everything, not tsc.
26 | "noEmit": true,
27 | "esModuleInterop": true,
28 | "forceConsistentCasingInFileNames": true,
29 | "isolatedModules": true,
30 | "skipLibCheck": true
31 | },
32 | "include": [
33 | "**/*.ts",
34 | "**/*.tsx",
35 | "**/.server/**/*.ts",
36 | "**/.server/**/*.tsx",
37 | "**/.client/**/*.ts",
38 | "**/.client/**/*.tsx"
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/packages/fontless/examples/remix-app/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { vitePlugin as remix } from '@remix-run/dev'
2 | import { fontless } from 'fontless'
3 | import { defineConfig } from 'vite'
4 | import tsconfigPaths from 'vite-tsconfig-paths'
5 |
6 | declare module '@remix-run/node' {
7 | interface Future {
8 | v3_singleFetch: true
9 | }
10 | }
11 |
12 | export default defineConfig({
13 | plugins: [
14 | remix({
15 | future: {
16 | v3_fetcherPersist: true,
17 | v3_relativeSplatPath: true,
18 | v3_throwAbortReason: true,
19 | v3_singleFetch: true,
20 | v3_lazyRouteDiscovery: true,
21 | },
22 | }),
23 | tsconfigPaths(),
24 | fontless(),
25 | ],
26 | })
27 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + Solid + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "solid-app",
3 | "type": "module",
4 | "version": "0.0.0",
5 | "private": true,
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "solid-js": "^1.9.3"
13 | },
14 | "devDependencies": {
15 | "fontless": "latest",
16 | "typescript": "~5.6.2",
17 | "vite": "^6.0.5",
18 | "vite-plugin-solid": "^2.11.0"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/src/App.css:
--------------------------------------------------------------------------------
1 | p {
2 | font-size: x-large;
3 | }
4 |
5 | .google-poppins {
6 | font-family: "Poppins", sans-serif;
7 | }
8 |
9 | .google-press-start {
10 | font-family: "Press Start 2P", sans-serif;
11 | }
12 |
13 | .bunny-aclonica {
14 | font-family: "Aclonica", sans-serif;
15 | }
16 |
17 | .bunny-allan {
18 | font-family: "Allan", sans-serif;
19 | }
20 |
21 | .font-share-panchang {
22 | font-family: "Panchang", sans-serif;
23 | }
24 |
25 | .font-source-luckiest {
26 | font-family: "Luckiest Guy", sans-serif;
27 | }
28 |
29 | @font-face {
30 | font-family: "Black Fox";
31 | src: url("./black-fox.ttf");
32 | }
33 |
34 | .local {
35 | font-family: "Black Fox", sans-serif;
36 | }
37 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/src/App.tsx:
--------------------------------------------------------------------------------
1 | import './App.css'
2 |
3 | function App() {
4 | return (
5 |
6 |
Google
7 |
Poppins
8 |
Press Start 2P
9 |
10 |
Bunny
11 |
Aclonica
12 |
Allan
13 |
14 |
FontShare
15 |
Panchang
16 |
17 |
FontSource
18 |
Luckiest
19 |
20 |
Local
21 |
Local font
22 |
23 | )
24 | }
25 |
26 | export default App
27 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/src/black-fox.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/solid-app/src/black-fox.ttf
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | color: rgba(255, 255, 255, 0.87);
3 | background-color: #242424;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/src/index.tsx:
--------------------------------------------------------------------------------
1 | /* @refresh reload */
2 | import { render } from 'solid-js/web'
3 | import App from './App.tsx'
4 | import './index.css'
5 |
6 | const root = document.getElementById('root')
7 |
8 | render(() => , root!)
9 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "jsx": "preserve",
6 | "jsxImportSource": "solid-js",
7 | "lib": [
8 | "ES2020",
9 | "DOM",
10 | "DOM.Iterable"
11 | ],
12 | "moduleDetection": "force",
13 | "useDefineForClassFields": true,
14 | "module": "ESNext",
15 | /* Bundler mode */
16 | "moduleResolution": "bundler",
17 | "allowImportingTsExtensions": true,
18 | /* Linting */
19 | "strict": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noEmit": true,
24 | "isolatedModules": true,
25 | "skipLibCheck": true,
26 | "noUncheckedSideEffectImports": true
27 | },
28 | "include": [
29 | "src"
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "references": [
3 | { "path": "./tsconfig.app.json" },
4 | { "path": "./tsconfig.node.json" }
5 | ],
6 | "files": []
7 | }
8 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "moduleDetection": "force",
7 | "module": "ESNext",
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 |
13 | /* Linting */
14 | "strict": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "noUnusedLocals": true,
17 | "noUnusedParameters": true,
18 | "noEmit": true,
19 | "isolatedModules": true,
20 | "skipLibCheck": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/solid-app/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { fontless } from 'fontless'
2 | import { defineConfig } from 'vite'
3 | import solid from 'vite-plugin-solid'
4 |
5 | export default defineConfig({
6 | plugins: [solid(), fontless()],
7 | })
8 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + Svelte + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "svelte-app",
3 | "type": "module",
4 | "version": "0.0.0",
5 | "private": true,
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "preview": "vite preview",
10 | "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
11 | },
12 | "devDependencies": {
13 | "@sveltejs/vite-plugin-svelte": "^5.0.3",
14 | "@tsconfig/svelte": "^5.0.4",
15 | "fontless": "latest",
16 | "svelte": "^5.15.0",
17 | "svelte-check": "^4.1.1",
18 | "typescript": "~5.6.2",
19 | "vite": "^6.0.5"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/src/App.svelte:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Google
4 |
Poppins
5 |
Press Start 2P
6 |
7 |
Bunny
8 |
Aclonica
9 |
Allan
10 |
11 |
FontShare
12 |
Panchang
13 |
14 |
FontSource
15 |
Luckiest
16 |
17 |
Local
18 |
Local font
19 |
20 |
21 |
60 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/src/app.css:
--------------------------------------------------------------------------------
1 | :root {
2 | color: rgba(255, 255, 255, 0.87);
3 | background-color: #242424;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/src/black-fox.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/svelte-app/src/black-fox.ttf
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/src/main.ts:
--------------------------------------------------------------------------------
1 | import { mount } from 'svelte'
2 | import App from './App.svelte'
3 | import './app.css'
4 |
5 | const app = mount(App, {
6 | target: document.getElementById('app')!,
7 | })
8 |
9 | export default app
10 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/svelte.config.js:
--------------------------------------------------------------------------------
1 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
2 |
3 | export default {
4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
5 | // for more information about preprocessors
6 | preprocess: vitePreprocess(),
7 | }
8 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/svelte/tsconfig.json",
3 | "compilerOptions": {
4 | "target": "ESNext",
5 | "moduleDetection": "force",
6 | "useDefineForClassFields": true,
7 | "module": "ESNext",
8 | "resolveJsonModule": true,
9 | /**
10 | * Typecheck JS in `.svelte` and `.js` files by default.
11 | * Disable checkJs if you'd like to use dynamic types in JS.
12 | * Note that setting allowJs false does not prevent the use
13 | * of JS in `.svelte` files.
14 | */
15 | "allowJs": true,
16 | "checkJs": true,
17 | "isolatedModules": true
18 | },
19 | "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
20 | }
21 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "references": [
3 | { "path": "./tsconfig.app.json" },
4 | { "path": "./tsconfig.node.json" }
5 | ],
6 | "files": []
7 | }
8 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "moduleDetection": "force",
7 | "module": "ESNext",
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 |
13 | /* Linting */
14 | "strict": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "noUnusedLocals": true,
17 | "noUnusedParameters": true,
18 | "noEmit": true,
19 | "isolatedModules": true,
20 | "skipLibCheck": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/svelte-app/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { svelte } from '@sveltejs/vite-plugin-svelte'
2 | import { fontless } from 'fontless'
3 | import { defineConfig } from 'vite'
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | plugins: [svelte(), fontless()],
8 | })
9 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vanilla-app/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vanilla-app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vanilla-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vanilla-app",
3 | "type": "module",
4 | "version": "0.0.0",
5 | "private": true,
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview"
10 | },
11 | "devDependencies": {
12 | "fontless": "latest",
13 | "typescript": "~5.6.2",
14 | "vite": "^6.0.5"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vanilla-app/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vanilla-app/src/black-fox.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/vanilla-app/src/black-fox.ttf
--------------------------------------------------------------------------------
/packages/fontless/examples/vanilla-app/src/main.ts:
--------------------------------------------------------------------------------
1 | import './style.css'
2 |
3 | document.querySelector('#app')!.innerHTML = `
4 |
5 |
Google
6 |
Poppins
7 |
Press Start 2P
8 |
9 |
Bunny
10 |
Aclonica
11 |
Allan
12 |
13 |
FontShare
14 |
Panchang
15 |
16 |
FontSource
17 |
Luckiest
18 |
19 |
Local
20 |
Local font
21 |
22 | `
23 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vanilla-app/src/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | color: rgba(255, 255, 255, 0.87);
3 | background-color: #242424;
4 | }
5 |
6 | p {
7 | font-size: x-large;
8 | }
9 |
10 | .google-poppins {
11 | font-family: "Poppins", sans-serif;
12 | }
13 |
14 | .google-press-start {
15 | font-family: "Press Start 2P", sans-serif;
16 | }
17 |
18 | .bunny-aclonica {
19 | font-family: "Aclonica", sans-serif;
20 | }
21 |
22 | .bunny-allan {
23 | font-family: "Allan", sans-serif;
24 | }
25 |
26 | .font-share-panchang {
27 | font-family: "Panchang", sans-serif;
28 | }
29 |
30 | .font-source-luckiest {
31 | font-family: "Luckiest Guy", sans-serif;
32 | }
33 |
34 | @font-face {
35 | font-family: "Black Fox";
36 | src: url("./black-fox.ttf");
37 | }
38 |
39 | .local {
40 | font-family: "Black Fox", sans-serif;
41 | }
42 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vanilla-app/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vanilla-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
5 | "moduleDetection": "force",
6 | "useDefineForClassFields": true,
7 | "module": "ESNext",
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 |
13 | /* Linting */
14 | "strict": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "noUnusedLocals": true,
17 | "noUnusedParameters": true,
18 | "noEmit": true,
19 | "isolatedModules": true,
20 | "skipLibCheck": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["src"]
24 | }
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vanilla-app/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { fontless } from 'fontless'
2 | import { defineConfig } from 'vite'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [fontless()],
7 | })
8 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/README.md:
--------------------------------------------------------------------------------
1 | # Vue 3 + TypeScript + Vite
2 |
3 | This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `
12 |
13 |
14 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fontless-demo-vue-app",
3 | "type": "module",
4 | "version": "0.0.0",
5 | "private": true,
6 | "description": "Vue demo app",
7 | "scripts": {
8 | "dev": "vite --port 5175",
9 | "build": "vue-tsc -b && vite build",
10 | "preview": "vite preview --port 5175"
11 | },
12 | "dependencies": {
13 | "vue": "^3.5.13"
14 | },
15 | "devDependencies": {
16 | "@vitejs/plugin-vue": "^5.2.1",
17 | "fontless": "latest",
18 | "typescript": "~5.6.2",
19 | "vite": "^6.0.1",
20 | "vue-tsc": "^2.1.10"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Google
4 |
Poppins
5 |
Press Start 2P
6 |
7 |
Bunny
8 |
Aclonica
9 |
Allan
10 |
11 |
FontShare
12 |
Panchang
13 |
14 |
FontSource
15 |
Luckiest
16 |
17 |
Local
18 |
Local font
19 |
20 |
21 |
22 |
60 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/src/black-fox.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/unjs/fontaine/bfcd5176628b0412686172a9894489fc60dc2ee8/packages/fontless/examples/vue-app/src/black-fox.ttf
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/src/main.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import App from './App.vue'
3 | import './style.css'
4 |
5 | createApp(App).mount('#app')
6 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/src/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | color: rgba(255, 255, 255, 0.87);
3 | background-color: #242424;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2020",
5 | "jsx": "preserve",
6 | "lib": [
7 | "ES2020",
8 | "DOM",
9 | "DOM.Iterable"
10 | ],
11 | "moduleDetection": "force",
12 | "useDefineForClassFields": true,
13 | "module": "ESNext",
14 | /* Bundler mode */
15 | "moduleResolution": "bundler",
16 | "allowImportingTsExtensions": true,
17 | /* Linting */
18 | "strict": true,
19 | "noFallthroughCasesInSwitch": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noEmit": true,
23 | "isolatedModules": true,
24 | "skipLibCheck": true,
25 | "noUncheckedSideEffectImports": true
26 | },
27 | "include": [
28 | "src/**/*.ts",
29 | "src/**/*.tsx",
30 | "src/**/*.vue"
31 | ]
32 | }
33 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "references": [
3 | { "path": "./tsconfig.app.json" },
4 | { "path": "./tsconfig.node.json" }
5 | ],
6 | "files": []
7 | }
8 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2022",
5 | "lib": ["ES2023"],
6 | "moduleDetection": "force",
7 | "module": "ESNext",
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 |
13 | /* Linting */
14 | "strict": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "noUnusedLocals": true,
17 | "noUnusedParameters": true,
18 | "noEmit": true,
19 | "isolatedModules": true,
20 | "skipLibCheck": true,
21 | "noUncheckedSideEffectImports": true
22 | },
23 | "include": ["vite.config.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/packages/fontless/examples/vue-app/vite.config.ts:
--------------------------------------------------------------------------------
1 | import vue from '@vitejs/plugin-vue'
2 | import { fontless } from 'fontless'
3 | import { defineConfig } from 'vite'
4 |
5 | // https://vite.dev/config/
6 | export default defineConfig({
7 | plugins: [vue(), fontless()],
8 | })
9 |
--------------------------------------------------------------------------------
/packages/fontless/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fontless",
3 | "type": "module",
4 | "version": "0.0.2",
5 | "description": "Magical plug-and-play font optimization for modern web applications",
6 | "author": {
7 | "name": "Daniel Roe",
8 | "email": "daniel@roe.dev",
9 | "url": "https://github.com/danielroe"
10 | },
11 | "license": "MIT",
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/unjs/fontaine.git",
15 | "directory": "packages/fontless"
16 | },
17 | "keywords": [
18 | "font",
19 | "optimization",
20 | "vite-plugin",
21 | "css",
22 | "javascript"
23 | ],
24 | "sideEffects": false,
25 | "exports": {
26 | ".": "./dist/index.mjs"
27 | },
28 | "main": "./dist/index.mjs",
29 | "module": "./dist/index.mjs",
30 | "typesVersions": {
31 | "*": {
32 | "*": [
33 | "./dist/index.d.mts"
34 | ]
35 | }
36 | },
37 | "files": [
38 | "dist"
39 | ],
40 | "engines": {
41 | "node": ">=18.12.0"
42 | },
43 | "scripts": {
44 | "build": "unbuild",
45 | "dev": "vitest dev",
46 | "lint": "eslint . --fix",
47 | "prepack": "pnpm build",
48 | "prepublishOnly": "pnpm lint && pnpm test",
49 | "test": "pnpm test:unit && pnpm test:types",
50 | "test:unit": "vitest",
51 | "test:types": "tsc --noEmit"
52 | },
53 | "dependencies": {
54 | "consola": "^3.4.2",
55 | "css-tree": "^3.1.0",
56 | "defu": "^6.1.4",
57 | "esbuild": "^0.25.4",
58 | "fontaine": "workspace:*",
59 | "jiti": "^2.4.2",
60 | "magic-string": "^0.30.17",
61 | "ohash": "^2.0.11",
62 | "pathe": "^2.0.3",
63 | "ufo": "^1.6.1",
64 | "unifont": "^0.5.0",
65 | "unstorage": "^1.16.0"
66 | },
67 | "devDependencies": {
68 | "@types/css-tree": "2.3.10",
69 | "@vitest/coverage-v8": "3.2.0",
70 | "rollup": "4.41.1",
71 | "typescript": "5.8.3",
72 | "unbuild": "3.5.0",
73 | "vite": "6.3.5",
74 | "vitest": "3.2.0"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/packages/fontless/src/assets.ts:
--------------------------------------------------------------------------------
1 | import type { FontFaceData } from 'unifont'
2 | import type { RawFontFaceData } from './types'
3 | import { hash } from 'ohash'
4 | import { extname } from 'pathe'
5 | import { filename } from 'pathe/utils'
6 | import { hasProtocol, joinRelativeURL, joinURL } from 'ufo'
7 | import { formatToExtension, parseFont } from './css/render'
8 |
9 | function toArray(value?: T | T[]): T[] {
10 | return !value || Array.isArray(value) ? value as T[] : [value]
11 | }
12 |
13 | export interface NormalizeFontDataContext {
14 | dev: boolean
15 | renderedFontURLs: Map
16 | assetsBaseURL: string
17 | callback?: (filename: string, url: string) => void
18 | }
19 |
20 | export function normalizeFontData(context: NormalizeFontDataContext, faces: RawFontFaceData | FontFaceData[]): FontFaceData[] {
21 | const data: FontFaceData[] = []
22 | for (const face of toArray(faces)) {
23 | data.push({
24 | ...face,
25 | unicodeRange: toArray(face.unicodeRange),
26 | src: toArray(face.src).map((src) => {
27 | const source = typeof src === 'string' ? parseFont(src) : src
28 | if ('url' in source && hasProtocol(source.url, { acceptRelative: true })) {
29 | source.url = source.url.replace(/^\/\//, 'https://')
30 | const _url = source.url.replace(/\?.*/, '')
31 | const MAX_FILENAME_PREFIX_LENGTH = 50
32 | const file = [
33 | // TODO: investigate why negative ignore pattern below is being ignored
34 | hash(filename(_url) || _url).replace(/^-+/, '').slice(0, MAX_FILENAME_PREFIX_LENGTH),
35 | hash(source).replace(/-/, '_') + (extname(source.url) || formatToExtension(source.format) || ''),
36 | ].filter(Boolean).join('-')
37 |
38 | context.renderedFontURLs.set(file, source.url)
39 | source.originalURL = source.url
40 |
41 | source.url = context.dev
42 | ? joinRelativeURL(context.assetsBaseURL, file)
43 | : joinURL(context.assetsBaseURL, file)
44 |
45 | context.callback?.(file, source.url)
46 | }
47 |
48 | return source
49 | }),
50 | })
51 | }
52 | return data
53 | }
54 |
--------------------------------------------------------------------------------
/packages/fontless/src/css/parse.ts:
--------------------------------------------------------------------------------
1 | import type { Declaration } from 'css-tree'
2 | import type { FontFaceData } from 'unifont'
3 |
4 | const weightMap: Record = {
5 | 100: 'Thin',
6 | 200: 'ExtraLight',
7 | 300: 'Light',
8 | 400: 'Regular',
9 | 500: 'Medium',
10 | 600: 'SemiBold',
11 | 700: 'Bold',
12 | 800: 'ExtraBold',
13 | 900: 'Black',
14 | }
15 |
16 | const styleMap: Record = {
17 | italic: 'Italic',
18 | oblique: 'Oblique',
19 | normal: '',
20 | }
21 |
22 | function processRawValue(value: string) {
23 | return value.split(',').map(v => v.trim().replace(/^(?['"])(.*)\k$/, '$2'))
24 | }
25 |
26 | // https://developer.mozilla.org/en-US/docs/Web/CSS/font-family
27 | /* A generic family name only */
28 | const _genericCSSFamilies = [
29 | 'serif',
30 | 'sans-serif',
31 | 'monospace',
32 | 'cursive',
33 | 'fantasy',
34 | 'system-ui',
35 | 'ui-serif',
36 | 'ui-sans-serif',
37 | 'ui-monospace',
38 | 'ui-rounded',
39 | 'emoji',
40 | 'math',
41 | 'fangsong',
42 | ] as const
43 | export type GenericCSSFamily = typeof _genericCSSFamilies[number]
44 | const genericCSSFamilies = new Set(_genericCSSFamilies)
45 |
46 | /* Global values */
47 | const globalCSSValues = new Set([
48 | 'inherit',
49 | 'initial',
50 | 'revert',
51 | 'revert-layer',
52 | 'unset',
53 | ])
54 |
55 | export function extractGeneric(node: Declaration) {
56 | if (node.value.type === 'Raw') {
57 | return
58 | }
59 |
60 | for (const child of node.value.children) {
61 | if (child.type === 'Identifier' && genericCSSFamilies.has(child.name as GenericCSSFamily)) {
62 | return child.name as GenericCSSFamily
63 | }
64 | }
65 | }
66 |
67 | export function extractEndOfFirstChild(node: Declaration) {
68 | if (node.value.type === 'Raw') {
69 | return
70 | }
71 | for (const child of node.value.children) {
72 | if (child.type === 'String') {
73 | return child.loc!.end.offset!
74 | }
75 | if (child.type === 'Operator' && child.value === ',') {
76 | return child.loc!.start.offset!
77 | }
78 | }
79 | return node.value.children.last!.loc!.end.offset!
80 | }
81 |
82 | export function extractFontFamilies(node: Declaration) {
83 | if (node.value.type === 'Raw') {
84 | return processRawValue(node.value.value)
85 | }
86 |
87 | const families = [] as string[]
88 | // Use buffer strategy to handle unquoted 'minified' font-family names
89 | let buffer = ''
90 | for (const child of node.value.children) {
91 | if (child.type === 'Identifier' && !genericCSSFamilies.has(child.name as GenericCSSFamily) && !globalCSSValues.has(child.name)) {
92 | buffer = buffer ? `${buffer} ${child.name}` : child.name
93 | }
94 | if (buffer && child.type === 'Operator' && child.value === ',') {
95 | families.push(buffer.replace(/\\/g, ''))
96 | buffer = ''
97 | }
98 | if (buffer && child.type === 'Dimension') {
99 | buffer = (`${buffer} ${child.value}${child.unit}`).trim()
100 | }
101 | if (child.type === 'String') {
102 | families.push(child.value)
103 | }
104 | }
105 |
106 | if (buffer) {
107 | families.push(buffer)
108 | }
109 |
110 | return families
111 | }
112 |
113 | export function addLocalFallbacks(fontFamily: string, data: FontFaceData[]) {
114 | for (const face of data) {
115 | const style = (face.style ? styleMap[face.style] : '') ?? ''
116 |
117 | if (Array.isArray(face.weight)) {
118 | face.src.unshift(({ name: ([fontFamily, 'Variable', style].join(' ')).trim() }))
119 | }
120 | else if (face.src[0] && !('name' in face.src[0])) {
121 | const weights = (Array.isArray(face.weight) ? face.weight : [face.weight])
122 | .map(weight => weightMap[weight])
123 | .filter(Boolean)
124 |
125 | for (const weight of weights) {
126 | if (weight === 'Regular') {
127 | face.src.unshift({ name: ([fontFamily, style].join(' ')).trim() })
128 | }
129 | face.src.unshift({ name: ([fontFamily, weight, style].join(' ')).trim() })
130 | }
131 | }
132 | }
133 | return data
134 | }
135 |
--------------------------------------------------------------------------------
/packages/fontless/src/css/render.ts:
--------------------------------------------------------------------------------
1 | import type { FontFaceData, RemoteFontSource } from 'unifont'
2 | import type { FontSource } from '../types'
3 | import { generateFontFace as generateFallbackFontFace, getMetricsForFamily, readMetrics } from 'fontaine'
4 | import { extname, relative } from 'pathe'
5 | import { hasProtocol } from 'ufo'
6 |
7 | export function generateFontFace(family: string, font: FontFaceData) {
8 | return [
9 | '@font-face {',
10 | ` font-family: '${family}';`,
11 | ` src: ${renderFontSrc(font.src)};`,
12 | ` font-display: ${font.display || 'swap'};`,
13 | font.unicodeRange && ` unicode-range: ${font.unicodeRange};`,
14 | font.weight && ` font-weight: ${Array.isArray(font.weight) ? font.weight.join(' ') : font.weight};`,
15 | font.style && ` font-style: ${font.style};`,
16 | font.stretch && ` font-stretch: ${font.stretch};`,
17 | font.featureSettings && ` font-feature-settings: ${font.featureSettings};`,
18 | font.variationSettings && ` font-variation-settings: ${font.variationSettings};`,
19 | `}`,
20 | ].filter(Boolean).join('\n')
21 | }
22 |
23 | export async function generateFontFallbacks(family: string, data: FontFaceData, fallbacks?: Array<{ name: string, font: string }>) {
24 | if (!fallbacks?.length)
25 | return []
26 |
27 | const fontURL = data.src!.find(s => 'url' in s) as RemoteFontSource | undefined
28 | const metrics = await getMetricsForFamily(family) || (fontURL && await readMetrics(fontURL.originalURL || fontURL.url))
29 |
30 | if (!metrics)
31 | return []
32 |
33 | const css: string[] = []
34 | for (const fallback of fallbacks) {
35 | css.push(generateFallbackFontFace(metrics, {
36 | ...fallback,
37 | metrics: await getMetricsForFamily(fallback.font) || undefined,
38 | }))
39 | }
40 | return css
41 | }
42 |
43 | const formatMap: Record = {
44 | woff2: 'woff2',
45 | woff: 'woff',
46 | otf: 'opentype',
47 | ttf: 'truetype',
48 | eot: 'embedded-opentype',
49 | svg: 'svg',
50 | }
51 | const extensionMap = Object.fromEntries(Object.entries(formatMap).map(([key, value]) => [value, key]))
52 | export const formatToExtension = (format?: string) => format && format in extensionMap ? `.${extensionMap[format]}` : undefined
53 |
54 | export function parseFont(font: string) {
55 | // render as `url("url/to/font") format("woff2")`
56 | if (font.startsWith('/') || hasProtocol(font)) {
57 | const extension = extname(font).slice(1)
58 | const format = formatMap[extension]
59 |
60 | return {
61 | url: font,
62 | format,
63 | } satisfies RemoteFontSource as RemoteFontSource
64 | }
65 |
66 | // render as `local("Font Name")`
67 | return { name: font }
68 | }
69 |
70 | function renderFontSrc(sources: Exclude[]) {
71 | return sources.map((src) => {
72 | if ('url' in src) {
73 | let rendered = `url("${src.url}")`
74 | for (const key of ['format', 'tech'] as const) {
75 | if (key in src) {
76 | rendered += ` ${key}(${src[key]})`
77 | }
78 | }
79 | return rendered
80 | }
81 | return `local("${src.name}")`
82 | }).join(', ')
83 | }
84 |
85 | export function relativiseFontSources(font: FontFaceData, relativeTo: string) {
86 | return {
87 | ...font,
88 | src: font.src.map((source) => {
89 | if ('name' in source)
90 | return source
91 | if (!source.url.startsWith('/'))
92 | return source
93 | return {
94 | ...source,
95 | url: relative(relativeTo, source.url),
96 | }
97 | }),
98 | } satisfies FontFaceData
99 | }
100 |
--------------------------------------------------------------------------------
/packages/fontless/src/defaults.ts:
--------------------------------------------------------------------------------
1 | import type { FontlessOptions } from './types'
2 |
3 | import { providers } from 'unifont'
4 |
5 | export const defaultValues = {
6 | weights: [400],
7 | styles: ['normal', 'italic'] as const,
8 | subsets: [
9 | 'cyrillic-ext',
10 | 'cyrillic',
11 | 'greek-ext',
12 | 'greek',
13 | 'vietnamese',
14 | 'latin-ext',
15 | 'latin',
16 | ],
17 | fallbacks: {
18 | 'serif': ['Times New Roman'],
19 | 'sans-serif': ['Arial'],
20 | 'monospace': ['Courier New'],
21 | 'cursive': [],
22 | 'fantasy': [],
23 | 'system-ui': [
24 | 'BlinkMacSystemFont',
25 | 'Segoe UI',
26 | 'Roboto',
27 | 'Helvetica Neue',
28 | 'Arial',
29 | ],
30 | 'ui-serif': ['Times New Roman'],
31 | 'ui-sans-serif': ['Arial'],
32 | 'ui-monospace': ['Courier New'],
33 | 'ui-rounded': [],
34 | 'emoji': [],
35 | 'math': [],
36 | 'fangsong': [],
37 | },
38 | } satisfies FontlessOptions['defaults']
39 |
40 | export const defaultOptions: FontlessOptions = {
41 | processCSSVariables: 'font-prefixed-only',
42 | experimental: {
43 | disableLocalFallbacks: false,
44 | },
45 | defaults: {},
46 | assets: {
47 | prefix: '/_fonts',
48 | },
49 | local: {},
50 | google: {},
51 | adobe: {
52 | id: '',
53 | },
54 | providers: {
55 | adobe: providers.adobe,
56 | google: providers.google,
57 | googleicons: providers.googleicons,
58 | bunny: providers.bunny,
59 | fontshare: providers.fontshare,
60 | fontsource: providers.fontsource,
61 | },
62 | }
63 |
--------------------------------------------------------------------------------
/packages/fontless/src/index.ts:
--------------------------------------------------------------------------------
1 | export { normalizeFontData } from './assets'
2 |
3 | export type { NormalizeFontDataContext } from './assets'
4 | export { generateFontFace, parseFont } from './css/render'
5 |
6 | export { defaultOptions, defaultValues } from './defaults'
7 |
8 | export { resolveProviders } from './providers'
9 |
10 | export { createResolver } from './resolve'
11 |
12 | export type { Resolver } from './resolve'
13 | export type {
14 | FontFallback,
15 | FontFamilyManualOverride,
16 | FontFamilyOverrides,
17 | FontFamilyProviderOverride,
18 | FontlessOptions,
19 | FontProviderName,
20 | FontSource,
21 | ManualFontDetails,
22 | ProviderFontDetails,
23 | } from './types'
24 |
25 | export { resolveMinifyCssEsbuildOptions, transformCSS } from './utils'
26 | export type { FontFamilyInjectionPluginOptions } from './utils'
27 |
28 | export { fontless } from './vite'
29 |
--------------------------------------------------------------------------------
/packages/fontless/src/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fontless",
3 | "type": "module",
4 | "version": "0.11.3",
5 | "exports": {
6 | ".": "./index.ts"
7 | },
8 | "main": "./index.ts"
9 | }
10 |
--------------------------------------------------------------------------------
/packages/fontless/src/providers.ts:
--------------------------------------------------------------------------------
1 | import type { ProviderFactory } from 'unifont'
2 | import type { FontlessOptions } from './types'
3 |
4 | import { createJiti } from 'jiti'
5 |
6 | export async function resolveProviders(_providers: FontlessOptions['providers'] = {}, opts: { root: string, alias: Record }) {
7 | const jiti = createJiti(opts.root, { alias: opts.alias })
8 |
9 | const providers = { ..._providers }
10 | for (const key in providers) {
11 | const value = providers[key]
12 | if (value === false) {
13 | delete providers[key]
14 | }
15 | if (typeof value === 'string') {
16 | providers[key] = await jiti.import(value, { default: true })
17 | }
18 | }
19 | return providers as Record
20 | }
21 |
--------------------------------------------------------------------------------
/packages/fontless/src/resolve.ts:
--------------------------------------------------------------------------------
1 | import type { ConsolaInstance } from 'consola'
2 | import type { FontFaceData, Provider, UnifontOptions } from 'unifont'
3 | import type { GenericCSSFamily } from './css/parse'
4 | import type { FontFamilyManualOverride, FontFamilyProviderOverride, FontlessOptions, ManualFontDetails, ProviderFontDetails, RawFontFaceData } from './types'
5 |
6 | import type { FontFaceResolution } from './utils'
7 | import { consola } from 'consola'
8 | import { createUnifont } from 'unifont'
9 | import { addLocalFallbacks } from './css/parse'
10 | import { defaultValues } from './defaults'
11 |
12 | interface ResolverContext {
13 | exposeFont?: (font: ManualFontDetails | ProviderFontDetails) => void
14 | normalizeFontData: (faces: RawFontFaceData | FontFaceData[]) => FontFaceData[]
15 | logger?: ConsolaInstance
16 | storage?: UnifontOptions['storage']
17 | options: FontlessOptions
18 | providers: Record Provider>
19 | }
20 |
21 | export type Resolver = (fontFamily: string, override?: FontFamilyManualOverride | FontFamilyProviderOverride, fallbackOptions?: {
22 | fallbacks: string[]
23 | generic?: GenericCSSFamily
24 | }) => Promise
25 |
26 | export async function createResolver(context: ResolverContext): Promise {
27 | const { options, normalizeFontData, providers, exposeFont = () => {}, logger = consola.withTag('fontless') } = context
28 |
29 | const resolvedProviders: Array = []
30 | const prioritisedProviders = new Set()
31 |
32 | for (const [key, provider] of Object.entries(providers)) {
33 | if (options.providers?.[key] === false || (options.provider && options.provider !== key)) {
34 | delete providers[key]
35 | }
36 | else {
37 | const providerOptions = (options[key as 'google' | 'local' | 'adobe'] || {}) as Record
38 | resolvedProviders.push(provider(providerOptions))
39 | }
40 | }
41 |
42 | for (const val of options.priority || []) {
43 | if (val in providers)
44 | prioritisedProviders.add(val)
45 | }
46 | for (const provider in providers) {
47 | prioritisedProviders.add(provider)
48 | }
49 |
50 | const unifont = await createUnifont(resolvedProviders, { storage: context.storage })
51 |
52 | // Custom merging for defaults - providing a value for any default will override module
53 | // defaults entirely (to prevent array merging)
54 | const normalizedDefaults = {
55 | weights: [...new Set((options.defaults?.weights || defaultValues.weights).map(v => String(v)))],
56 | styles: [...new Set(options.defaults?.styles || defaultValues.styles)],
57 | subsets: [...new Set(options.defaults?.subsets || defaultValues.subsets)],
58 | fallbacks: Object.fromEntries(Object.entries(defaultValues.fallbacks).map(([key, value]) => [
59 | key,
60 | Array.isArray(options.defaults?.fallbacks) ? options.defaults.fallbacks : options.defaults?.fallbacks?.[key as GenericCSSFamily] || value,
61 | ])) as Record,
62 | }
63 |
64 | function addFallbacks(fontFamily: string, font: FontFaceData[]) {
65 | if (options.experimental?.disableLocalFallbacks) {
66 | return font
67 | }
68 | return addLocalFallbacks(fontFamily, font)
69 | }
70 |
71 | return async function resolveFontFaceWithOverride(fontFamily: string, override?: FontFamilyManualOverride | FontFamilyProviderOverride, fallbackOptions?: { fallbacks: string[], generic?: GenericCSSFamily }): Promise {
72 | const fallbacks = override?.fallbacks || normalizedDefaults.fallbacks[fallbackOptions?.generic || 'sans-serif']
73 |
74 | if (override && 'src' in override) {
75 | const fonts = addFallbacks(fontFamily, normalizeFontData({
76 | src: override.src,
77 | display: override.display,
78 | weight: override.weight,
79 | style: override.style,
80 | }))
81 | exposeFont({
82 | type: 'manual',
83 | fontFamily,
84 | fonts,
85 | })
86 | return {
87 | fallbacks,
88 | fonts,
89 | }
90 | }
91 |
92 | // Respect fonts that should not be resolved through `@nuxt/fonts`
93 | if (override?.provider === 'none') {
94 | return
95 | }
96 |
97 | // Respect custom weights, styles and subsets options
98 | const defaults = { ...normalizedDefaults, fallbacks }
99 | for (const key of ['weights', 'styles', 'subsets'] as const) {
100 | if (override?.[key]) {
101 | defaults[key as 'weights'] = override[key]!.map(v => String(v))
102 | }
103 | }
104 |
105 | // Handle explicit provider
106 | if (override?.provider) {
107 | if (override.provider in providers) {
108 | const result = await unifont.resolveFont(fontFamily, defaults, [override.provider])
109 | // Rewrite font source URLs to be proxied/local URLs
110 | const fonts = normalizeFontData(result?.fonts || [])
111 | if (!fonts.length || !result) {
112 | logger.warn(`Could not produce font face declaration from \`${override.provider}\` for font family \`${fontFamily}\`.`)
113 | return
114 | }
115 | const fontsWithLocalFallbacks = addFallbacks(fontFamily, fonts)
116 | exposeFont({
117 | type: 'override',
118 | fontFamily,
119 | provider: override.provider,
120 | fonts: fontsWithLocalFallbacks,
121 | })
122 | return {
123 | fallbacks: result.fallbacks || defaults.fallbacks,
124 | fonts: fontsWithLocalFallbacks,
125 | }
126 | }
127 |
128 | // If not registered, log and fall back to default providers
129 | logger.warn(`Unknown provider \`${override.provider}\` for font family \`${fontFamily}\`. Falling back to default providers.`)
130 | }
131 |
132 | const result = await unifont.resolveFont(fontFamily, defaults, [...prioritisedProviders])
133 | if (result) {
134 | // Rewrite font source URLs to be proxied/local URLs
135 | const fonts = normalizeFontData(result.fonts)
136 | if (fonts.length > 0) {
137 | const fontsWithLocalFallbacks = addFallbacks(fontFamily, fonts)
138 | // TODO: expose provider name in result
139 | exposeFont({
140 | type: 'auto',
141 | fontFamily,
142 | provider: result.provider || 'unknown',
143 | fonts: fontsWithLocalFallbacks,
144 | })
145 | return {
146 | fallbacks: result.fallbacks || defaults.fallbacks,
147 | fonts: fontsWithLocalFallbacks,
148 | }
149 | }
150 | if (override) {
151 | logger.warn(`Could not produce font face declaration for \`${fontFamily}\` with override.`)
152 | }
153 | }
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/packages/fontless/src/storage.ts:
--------------------------------------------------------------------------------
1 | import { createStorage } from 'unstorage'
2 | import fsDriver from 'unstorage/drivers/fs'
3 |
4 | const cacheBase = 'node_modules/.cache/fontless/meta'
5 |
6 | export const storage = createStorage({
7 | driver: fsDriver({ base: cacheBase }),
8 | })
9 |
--------------------------------------------------------------------------------
/packages/fontless/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { FontFaceData, LocalFontSource, Provider, ProviderFactory, providers, RemoteFontSource, ResolveFontOptions } from 'unifont'
2 |
3 | import type { GenericCSSFamily } from './css/parse'
4 |
5 | export type Awaitable = T | Promise
6 |
7 | export interface FontFallback {
8 | family?: string
9 | as: string
10 | }
11 |
12 | interface SharedFontDetails {
13 | fontFamily: string
14 | fonts: FontFaceData[]
15 | }
16 |
17 | export interface ManualFontDetails extends SharedFontDetails {
18 | type: 'manual'
19 | }
20 |
21 | export interface ProviderFontDetails extends SharedFontDetails {
22 | type: 'override' | 'auto'
23 | provider: string
24 | }
25 |
26 | // TODO: Font metric providers
27 | // export interface FontFaceAdjustments {
28 | // ascentOverride?: string // ascent-override
29 | // descentOverride?: string // descent-override
30 | // lineGapOverride?: string // line-gap-override
31 | // sizeAdjust?: string // size-adjust
32 | // }
33 |
34 | export type FontProviderName = (string & {}) | 'google' | 'local' | 'none'
35 |
36 | export interface FontFamilyOverrides {
37 | /** The font family to apply this override to. */
38 | name: string
39 | /** Inject `@font-face` regardless of usage in project. */
40 | global?: boolean
41 | /**
42 | * Enable or disable adding preload links to the initially rendered HTML.
43 | * This is true by default for the highest priority format unless a font is subsetted (to avoid over-preloading).
44 | */
45 | preload?: boolean
46 |
47 | // TODO:
48 | // as?: string
49 | }
50 | export interface FontFamilyProviderOverride extends FontFamilyOverrides, Partial & { weights: Array }> {
51 | /** The provider to use when resolving this font. */
52 | provider?: FontProviderName
53 | }
54 |
55 | export type FontSource = string | LocalFontSource | RemoteFontSource
56 |
57 | export interface RawFontFaceData extends Omit {
58 | src: FontSource | Array
59 | unicodeRange?: string | string[]
60 | }
61 |
62 | export interface FontFamilyManualOverride extends FontFamilyOverrides, RawFontFaceData {
63 | /** Font families to generate fallback metrics for. */
64 | fallbacks?: string[]
65 | }
66 |
67 | type ProviderOption = ((options: any) => Provider) | string | false
68 |
69 | export interface FontlessOptions {
70 | /**
71 | * Specify overrides for individual font families.
72 | *
73 | * ```ts
74 | * fonts: {
75 | * families: [
76 | * // do not resolve this font with any provider from `@nuxt/fonts`
77 | * { name: 'Custom Font', provider: 'none' },
78 | * // only resolve this font with the `google` provider
79 | * { name: 'My Font Family', provider: 'google' },
80 | * // specify specific font data
81 | * { name: 'Other Font', src: 'https://example.com/font.woff2' },
82 | * ]
83 | * }
84 | * ```
85 | */
86 | families?: Array
87 | defaults?: Partial<{
88 | preload: boolean
89 | weights: Array
90 | styles: ResolveFontOptions['styles']
91 | subsets: ResolveFontOptions['subsets']
92 | fallbacks?: Partial>
93 | }>
94 | providers?: {
95 | adobe?: ProviderOption
96 | bunny?: ProviderOption
97 | fontshare?: ProviderOption
98 | fontsource?: ProviderOption
99 | google?: ProviderOption
100 | googleicons?: ProviderOption
101 | [key: string]: ProviderOption | undefined
102 | }
103 | /** Configure the way font assets are exposed */
104 | assets: {
105 | /**
106 | * The baseURL where font files are served.
107 | * @default '/_fonts/'
108 | */
109 | prefix?: string
110 | /** Currently font assets are exposed as public assets as part of the build. This will be configurable in future */
111 | strategy?: 'public'
112 | }
113 | /** Options passed directly to `local` font provider (none currently) */
114 | local?: Record
115 | /** Options passed directly to `adobe` font provider */
116 | adobe?: typeof providers.adobe extends ProviderFactory ? O : Record
117 | /** Options passed directly to `bunny` font provider */
118 | bunny?: typeof providers.bunny extends ProviderFactory ? O : Record
119 | /** Options passed directly to `fontshare` font provider */
120 | fontshare?: typeof providers.fontshare extends ProviderFactory ? O : Record
121 | /** Options passed directly to `fontsource` font provider */
122 | fontsource?: typeof providers.fontsource extends ProviderFactory ? O : Record
123 | /** Options passed directly to `google` font provider */
124 | google?: typeof providers.google extends ProviderFactory ? O : Record
125 | /** Options passed directly to `googleicons` font provider */
126 | googleicons?: typeof providers.googleicons extends ProviderFactory ? O : Record
127 | /**
128 | * An ordered list of providers to check when resolving font families.
129 | *
130 | * After checking these providers, Nuxt Fonts will proceed by checking the
131 | *
132 | * Default behaviour is to check all user providers in the order they were defined, and then all built-in providers.
133 | */
134 | priority?: string[]
135 | /**
136 | * In some cases you may wish to use only one font provider. This is equivalent to disabling all other font providers.
137 | */
138 | provider?: FontProviderName
139 | /**
140 | * You can enable support for processing CSS variables for font family names.
141 | * @default 'font-prefixed-only'
142 | */
143 | processCSSVariables?: boolean | 'font-prefixed-only'
144 | experimental?: {
145 | /**
146 | * You can disable adding local fallbacks for generated font faces, like `local('Font Face')`.
147 | * @default false
148 | */
149 | disableLocalFallbacks?: boolean
150 | /**
151 | * You can enable support for processing CSS variables for font family names.
152 | * @default 'font-prefixed-only'
153 | * @deprecated This feature is no longer experimental. Use `processCSSVariables` instead. For Tailwind v4 users, setting this option to `true` is no longer needed or recommended.
154 | */
155 | processCSSVariables?: boolean | 'font-prefixed-only'
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/packages/fontless/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type { CssNode, StyleSheet } from 'css-tree'
2 | import type { TransformOptions } from 'esbuild'
3 | import type { FontFaceData, RemoteFontSource } from 'unifont'
4 | import type { ESBuildOptions } from 'vite'
5 | import type { GenericCSSFamily } from './css/parse'
6 | import type { Awaitable } from './types'
7 | import { parse, walk } from 'css-tree'
8 | import { transform } from 'esbuild'
9 | import MagicString from 'magic-string'
10 |
11 | import { dirname } from 'pathe'
12 | import { withLeadingSlash } from 'ufo'
13 | import { extractEndOfFirstChild, extractFontFamilies, extractGeneric } from './css/parse'
14 | import { generateFontFace, generateFontFallbacks, relativiseFontSources } from './css/render'
15 |
16 | export interface FontFaceResolution {
17 | fonts?: FontFaceData[]
18 | fallbacks?: string[]
19 | }
20 |
21 | export interface FontFamilyInjectionPluginOptions {
22 | esbuildOptions?: TransformOptions
23 | resolveFontFace: (fontFamily: string, fallbackOptions?: { fallbacks: string[], generic?: GenericCSSFamily }) => Awaitable
24 | dev: boolean
25 | processCSSVariables?: boolean | 'font-prefixed-only'
26 | shouldPreload: (fontFamily: string, font: FontFaceData) => boolean
27 | fontsToPreload: Map>
28 | }
29 |
30 | // Inlined from https://github.com/vitejs/vite/blob/main/packages/vite/src/node/plugins/css.ts#L1824-L1849
31 | export function resolveMinifyCssEsbuildOptions(options: ESBuildOptions): TransformOptions {
32 | const base: TransformOptions = {
33 | charset: options.charset ?? 'utf8',
34 | logLevel: options.logLevel,
35 | logLimit: options.logLimit,
36 | logOverride: options.logOverride,
37 | legalComments: options.legalComments,
38 | // added by this module
39 | target: options.target ?? 'chrome',
40 | }
41 |
42 | if (options.minifyIdentifiers != null || options.minifySyntax != null || options.minifyWhitespace != null) {
43 | return {
44 | ...base,
45 | minifyIdentifiers: options.minifyIdentifiers ?? true,
46 | minifySyntax: options.minifySyntax ?? true,
47 | minifyWhitespace: options.minifyWhitespace ?? true,
48 | }
49 | }
50 |
51 | return { ...base, minify: true }
52 | }
53 |
54 | export async function transformCSS(options: FontFamilyInjectionPluginOptions, code: string, id: string, opts: { relative?: boolean } = {}) {
55 | const s = new MagicString(code)
56 |
57 | const injectedDeclarations = new Set()
58 |
59 | const promises = [] as Promise[]
60 | async function addFontFaceDeclaration(fontFamily: string, fallbackOptions?: {
61 | generic?: GenericCSSFamily
62 | fallbacks: string[]
63 | index: number
64 | }) {
65 | const result = await options.resolveFontFace(fontFamily, {
66 | generic: fallbackOptions?.generic,
67 | fallbacks: fallbackOptions?.fallbacks || [],
68 | }) || {}
69 |
70 | if (!result.fonts || result.fonts.length === 0)
71 | return
72 |
73 | const fallbackMap = result.fallbacks?.map(f => ({ font: f, name: `${fontFamily} Fallback: ${f}` })) || []
74 | let insertFontFamilies = false
75 |
76 | const [topPriorityFont] = result.fonts.sort((a, b) => (a.meta?.priority || 0) - (b.meta?.priority || 0))
77 | if (topPriorityFont && options.shouldPreload(fontFamily, topPriorityFont)) {
78 | const fontToPreload = topPriorityFont.src.find((s): s is RemoteFontSource => 'url' in s)?.url
79 | if (fontToPreload) {
80 | const urls = options.fontsToPreload.get(id) || new Set()
81 | options.fontsToPreload.set(id, urls.add(fontToPreload))
82 | }
83 | }
84 |
85 | const prefaces: string[] = []
86 |
87 | for (const font of result.fonts) {
88 | const fallbackDeclarations = await generateFontFallbacks(fontFamily, font, fallbackMap)
89 | const declarations = [generateFontFace(fontFamily, opts.relative ? relativiseFontSources(font, withLeadingSlash(dirname(id))) : font), ...fallbackDeclarations]
90 |
91 | for (let declaration of declarations) {
92 | if (!injectedDeclarations.has(declaration)) {
93 | injectedDeclarations.add(declaration)
94 | if (!options.dev) {
95 | declaration = await transform(declaration, {
96 | loader: 'css',
97 | charset: 'utf8',
98 | minify: true,
99 | ...options.esbuildOptions,
100 | }).then(r => r.code || declaration).catch(() => declaration)
101 | }
102 | else {
103 | declaration += '\n'
104 | }
105 | prefaces.push(declaration)
106 | }
107 | }
108 |
109 | // Add font family names for generated fallbacks
110 | if (fallbackDeclarations.length) {
111 | insertFontFamilies = true
112 | }
113 | }
114 |
115 | s.prepend(prefaces.join(''))
116 |
117 | if (fallbackOptions && insertFontFamilies) {
118 | const insertedFamilies = fallbackMap.map(f => `"${f.name}"`).join(', ')
119 | s.prependLeft(fallbackOptions.index, `, ${insertedFamilies}`)
120 | }
121 | }
122 |
123 | const ast = parse(code, { positions: true })
124 |
125 | // Collect existing `@font-face` declarations (to skip adding them)
126 | const existingFontFamilies = new Set()
127 |
128 | // For nested CSS we need to keep track how long the parent selector is
129 | function processNode(node: CssNode, parentOffset = 0) {
130 | walk(node, {
131 | visit: 'Declaration',
132 | enter(node) {
133 | if (this.atrule?.name === 'font-face' && node.property === 'font-family') {
134 | for (const family of extractFontFamilies(node)) {
135 | existingFontFamilies.add(family)
136 | }
137 | }
138 | },
139 | })
140 |
141 | walk(node, {
142 | visit: 'Declaration',
143 | enter(node) {
144 | if ((
145 | (node.property !== 'font-family' && node.property !== 'font')
146 | && (options.processCSSVariables === false
147 | || (options.processCSSVariables === 'font-prefixed-only' && !node.property.startsWith('--font'))
148 | || (options.processCSSVariables === true && !node.property.startsWith('--'))))
149 | || this.atrule?.name === 'font-face') {
150 | return
151 | }
152 |
153 | // Only add @font-face for the first font-family in the list and treat the rest as fallbacks
154 | const [fontFamily, ...fallbacks] = extractFontFamilies(node)
155 | if (fontFamily && !existingFontFamilies.has(fontFamily)) {
156 | promises.push(addFontFaceDeclaration(fontFamily, node.value.type !== 'Raw'
157 | ? {
158 | fallbacks,
159 | generic: extractGeneric(node),
160 | index: extractEndOfFirstChild(node)! + parentOffset,
161 | }
162 | : undefined))
163 | }
164 | },
165 | })
166 |
167 | // Process nested CSS until `css-tree` supports it: https://github.com/csstree/csstree/issues/268#issuecomment-2417963908
168 | walk(node, {
169 | visit: 'Raw',
170 | enter(node) {
171 | const nestedRaw = parse(node.value, { positions: true }) as StyleSheet
172 | const isNestedCss = nestedRaw.children.some(child => child.type === 'Rule')
173 | if (!isNestedCss)
174 | return
175 | parentOffset += node.loc!.start.offset
176 | processNode(nestedRaw, parentOffset)
177 | },
178 | })
179 | }
180 |
181 | processNode(ast)
182 |
183 | await Promise.all(promises)
184 |
185 | return s
186 | }
187 |
--------------------------------------------------------------------------------
/packages/fontless/src/vite.ts:
--------------------------------------------------------------------------------
1 | import type { Plugin } from 'vite'
2 | import type { NormalizeFontDataContext } from './assets'
3 | import type { FontlessOptions } from './types'
4 | import type { FontFamilyInjectionPluginOptions } from './utils'
5 |
6 | import { Buffer } from 'node:buffer'
7 | import fsp from 'node:fs/promises'
8 | import { defu } from 'defu'
9 | import { resolve } from 'pathe'
10 | import { joinRelativeURL, joinURL } from 'ufo'
11 | import { normalizeFontData } from './assets'
12 | import { defaultOptions } from './defaults'
13 | import { resolveProviders } from './providers'
14 | import { createResolver } from './resolve'
15 | import { storage } from './storage'
16 | import { resolveMinifyCssEsbuildOptions, transformCSS } from './utils'
17 |
18 | export function fontless(_options?: FontlessOptions): Plugin {
19 | const options = defu(_options, defaultOptions satisfies FontlessOptions) as FontlessOptions
20 |
21 | let cssTransformOptions: FontFamilyInjectionPluginOptions
22 | let assetContext: NormalizeFontDataContext
23 | let publicDir: string
24 |
25 | return {
26 | name: 'vite-plugin-fontless',
27 |
28 | async configResolved(config) {
29 | assetContext = {
30 | dev: config.mode === 'development',
31 | renderedFontURLs: new Map(),
32 | assetsBaseURL: options.assets?.prefix || '/fonts',
33 | }
34 |
35 | publicDir = resolve(config.root, config.build.outDir, `.${joinURL(config.base, assetContext.assetsBaseURL)}`)
36 |
37 | const alias = Array.isArray(config.resolve.alias) ? {} : config.resolve.alias
38 | const providers = await resolveProviders(options.providers, { root: config.root, alias })
39 |
40 | const resolveFontFaceWithOverride = await createResolver({
41 | options,
42 | providers,
43 | normalizeFontData: normalizeFontData.bind({}, assetContext),
44 | })
45 |
46 | cssTransformOptions = {
47 | processCSSVariables: false,
48 | shouldPreload: () => false,
49 | fontsToPreload: new Map(),
50 | dev: config.mode === 'development',
51 | async resolveFontFace(fontFamily, fallbackOptions) {
52 | const override = options.families?.find(f => f.name === fontFamily)
53 |
54 | // This CSS will be injected in a separate location
55 | if (override?.global) {
56 | return
57 | }
58 |
59 | return resolveFontFaceWithOverride(fontFamily, override, fallbackOptions)
60 | },
61 | }
62 |
63 | if (!cssTransformOptions.dev && config.esbuild) {
64 | cssTransformOptions.esbuildOptions = defu(cssTransformOptions.esbuildOptions, resolveMinifyCssEsbuildOptions(config.esbuild))
65 | }
66 | },
67 | async transform(code, id) {
68 | // Early return if no font-family is used in this CSS
69 | if (!options.processCSSVariables && !code.includes('font-family:')) {
70 | return
71 | }
72 |
73 | const s = await transformCSS(cssTransformOptions, code, id)
74 |
75 | if (s.hasChanged()) {
76 | return {
77 | code: s.toString(),
78 | map: s.generateMap({ hires: true }),
79 | }
80 | }
81 | },
82 | async writeBundle() {
83 | for (const [filename, url] of assetContext.renderedFontURLs) {
84 | const key = `data:fonts:${filename}`
85 | // Use storage to cache the font data between builds
86 | let res = await storage.getItemRaw(key)
87 | if (!res) {
88 | res = await fetch(url)
89 | .then(r => r.arrayBuffer())
90 | .then(r => Buffer.from(r))
91 |
92 | await storage.setItemRaw(key, res)
93 | }
94 |
95 | // TODO: investigate how we can improve in dev surround
96 | await fsp.mkdir(publicDir, { recursive: true })
97 | await fsp.writeFile(joinRelativeURL(publicDir, filename), res)
98 | }
99 | },
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/packages/fontless/test/e2e.spec.ts:
--------------------------------------------------------------------------------
1 | import type { RollupOutput } from 'rollup'
2 | import { promises as fsp } from 'node:fs'
3 | import { readFile } from 'node:fs/promises'
4 | import { fileURLToPath } from 'node:url'
5 | import { join, resolve } from 'pathe'
6 | import { build } from 'vite'
7 | import { describe, expect, it } from 'vitest'
8 |
9 | const fixtures = await Array.fromAsync(fsp.glob('*', {
10 | cwd: fileURLToPath(new URL('../examples', import.meta.url)),
11 | }))
12 |
13 | describe.each(fixtures)('e2e %s', (fixture) => {
14 | it('should compile', { timeout: 20_000 }, async () => {
15 | const root = fileURLToPath(new URL(`../examples/${fixture}`, import.meta.url))
16 | let outputDir: string
17 | await build({
18 | root,
19 | plugins: [
20 | {
21 | name: 'spy',
22 | configResolved(config) {
23 | outputDir = resolve(root, config.build.outDir)
24 | },
25 | },
26 | ],
27 | }) as RollupOutput
28 |
29 | const files = await Array.fromAsync(fsp.glob('**/*', { cwd: outputDir! }))
30 |
31 | if (fixture === 'qwik-app') {
32 | let found = false
33 | for (const file of files) {
34 | if (file.endsWith('.js')) {
35 | const content = await readFile(join(outputDir!, file), 'utf-8')
36 | if (content.includes('url(/_fonts')) {
37 | found = true
38 | break
39 | }
40 | }
41 | }
42 | expect(found).toBe(true)
43 | }
44 | else {
45 | const css = files.find(file => file.endsWith('.css'))!
46 | expect(await readFile(join(outputDir!, css), 'utf-8')).toContain('url(/_fonts')
47 | }
48 |
49 | const font = files.find(file => file.endsWith('.woff2'))
50 | expect(font).toBeDefined()
51 | })
52 | })
53 |
--------------------------------------------------------------------------------
/packages/fontless/test/parse.spec.ts:
--------------------------------------------------------------------------------
1 | import { parse, walk } from 'css-tree'
2 | import { describe, expect, it } from 'vitest'
3 |
4 | import { transformCSS } from '../src'
5 | import { extractFontFamilies } from '../src/css/parse'
6 |
7 | describe('parsing', () => {
8 | it('should add declarations for `font-family`', async () => {
9 | expect(await transform(`:root { font-family: 'CustomFont' }`))
10 | .toMatchInlineSnapshot(`
11 | "@font-face {
12 | font-family: 'CustomFont';
13 | src: url("/customfont.woff2") format(woff2);
14 | font-display: swap;
15 | }
16 | :root { font-family: 'CustomFont' }"
17 | `)
18 | })
19 |
20 | it('should add declarations for `font`', async () => {
21 | expect(await transform(`:root { font: 1.2em 'CustomFont' }`))
22 | .toMatchInlineSnapshot(`
23 | "@font-face {
24 | font-family: 'CustomFont';
25 | src: url("/customfont.woff2") format(woff2);
26 | font-display: swap;
27 | }
28 | :root { font: 1.2em 'CustomFont' }"
29 | `)
30 | })
31 |
32 | it('should add declarations for `font-family` with CSS variables', async () => {
33 | expect(await transform(`:root { --custom-css-variable: 'CustomFont' }`))
34 | .toMatchInlineSnapshot(`
35 | "@font-face {
36 | font-family: 'CustomFont';
37 | src: url("/customfont.woff2") format(woff2);
38 | font-display: swap;
39 | }
40 | :root { --custom-css-variable: 'CustomFont' }"
41 | `)
42 | })
43 |
44 | it('should handle multi word and unquoted font families', async () => {
45 | expect(await transform(`
46 | :root { font-family:Open Sans}
47 | :root { font-family: Open Sans, sans-serif }
48 | :root { --test: Open Sans, sans-serif }
49 | `))
50 | .toMatchInlineSnapshot(`
51 | "@font-face {
52 | font-family: 'Open Sans';
53 | src: url("/open-sans.woff2") format(woff2);
54 | font-display: swap;
55 | }
56 | @font-face {
57 | font-family: "Open Sans Fallback: Times New Roman";
58 | src: local("Times New Roman");
59 | size-adjust: 115.3846%;
60 | ascent-override: 92.6335%;
61 | descent-override: 25.3906%;
62 | line-gap-override: 0%;
63 | }
64 |
65 |
66 | :root { font-family:Open Sans, "Open Sans Fallback: Times New Roman"}
67 | :root { font-family: Open Sans, "Open Sans Fallback: Times New Roman", sans-serif }
68 | :root { --test: Open Sans, sans-serif }
69 | "
70 | `)
71 | })
72 | it('should ignore any @font-face already in scope', async () => {
73 | expect(await transform([
74 | `@font-face { font-family: 'ScopedFont'; src: local("ScopedFont") }`,
75 | `:root { font-family: 'ScopedFont' }`,
76 | `:root { font-family: 'CustomFont' }`,
77 | ].join('\n')))
78 | .toMatchInlineSnapshot(`
79 | "@font-face {
80 | font-family: 'CustomFont';
81 | src: url("/customfont.woff2") format(woff2);
82 | font-display: swap;
83 | }
84 | @font-face { font-family: 'ScopedFont'; src: local("ScopedFont") }
85 | :root { font-family: 'ScopedFont' }
86 | :root { font-family: 'CustomFont' }"
87 | `)
88 | })
89 |
90 | it('should not insert font fallbacks if metrics cannot be resolved', async () => {
91 | expect(await transform(`:root { font-family: 'CustomFont', "OtherFont", sans-serif }`))
92 | .toMatchInlineSnapshot(`
93 | "@font-face {
94 | font-family: 'CustomFont';
95 | src: url("/customfont.woff2") format(woff2);
96 | font-display: swap;
97 | }
98 | :root { font-family: 'CustomFont', "OtherFont", sans-serif }"
99 | `)
100 | })
101 |
102 | it('should add `@font-face` declarations with metrics', async () => {
103 | expect(await transform(`:root { font-family: 'Poppins', 'Arial', sans-serif }`))
104 | .toMatchInlineSnapshot(`
105 | "@font-face {
106 | font-family: 'Poppins';
107 | src: url("/poppins.woff2") format(woff2);
108 | font-display: swap;
109 | }
110 | @font-face {
111 | font-family: "Poppins Fallback: Times New Roman";
112 | src: local("Times New Roman");
113 | size-adjust: 123.0769%;
114 | ascent-override: 85.3125%;
115 | descent-override: 28.4375%;
116 | line-gap-override: 8.125%;
117 | }
118 |
119 | @font-face {
120 | font-family: "Poppins Fallback: Arial";
121 | src: local("Arial");
122 | size-adjust: 112.1577%;
123 | ascent-override: 93.6182%;
124 | descent-override: 31.2061%;
125 | line-gap-override: 8.916%;
126 | }
127 |
128 | :root { font-family: 'Poppins', "Poppins Fallback: Times New Roman", "Poppins Fallback: Arial", 'Arial', sans-serif }"
129 | `)
130 | })
131 | })
132 |
133 | describe('parsing css', () => {
134 | it('should handle multi-word and unquoted font families', async () => {
135 | for (const family of ['\'Press Start 2P\'', 'Press Start 2P']) {
136 | const ast = parse(`:root { font-family: ${family} }`, { positions: true })
137 |
138 | const extracted = new Set()
139 | walk(ast, {
140 | visit: 'Declaration',
141 | enter(node) {
142 | if (node.property === 'font-family') {
143 | for (const family of extractFontFamilies(node)) {
144 | extracted.add(family)
145 | }
146 | }
147 | },
148 | })
149 | expect([...extracted]).toEqual(['Press Start 2P'])
150 | }
151 | })
152 |
153 | it('should handle nested CSS', async () => {
154 | const expected = await transform(`.parent { div { font-family: 'Poppins'; } p { font-family: 'Poppins'; @media (min-width: 768px) { @media (prefers-reduced-motion: reduce) { a { font-family: 'Lato'; } } } } }`)
155 | expect(expected).toMatchInlineSnapshot(`
156 | "@font-face {
157 | font-family: 'Lato';
158 | src: url("/lato.woff2") format(woff2);
159 | font-display: swap;
160 | }
161 | @font-face {
162 | font-family: "Lato Fallback: Times New Roman";
163 | src: local("Times New Roman");
164 | size-adjust: 107.2%;
165 | ascent-override: 92.0709%;
166 | descent-override: 19.8694%;
167 | line-gap-override: 0%;
168 | }
169 |
170 | @font-face {
171 | font-family: 'Poppins';
172 | src: url("/poppins.woff2") format(woff2);
173 | font-display: swap;
174 | }
175 | @font-face {
176 | font-family: "Poppins Fallback: Times New Roman";
177 | src: local("Times New Roman");
178 | size-adjust: 123.0769%;
179 | ascent-override: 85.3125%;
180 | descent-override: 28.4375%;
181 | line-gap-override: 8.125%;
182 | }
183 |
184 | .parent { div { font-family: 'Poppins', "Poppins Fallback: Times New Roman"; } p { font-family: 'Poppins', "Poppins Fallback: Times New Roman"; @media (min-width: 768px) { @media (prefers-reduced-motion: reduce) { a { font-family: 'Lato', "Lato Fallback: Times New Roman"; } } } } }"
185 | `)
186 | })
187 | })
188 |
189 | const slugify = (str: string) => str.toLowerCase().replace(/\W/g, '-')
190 | async function transform(css: string) {
191 | const result = await transformCSS({
192 | dev: true,
193 | processCSSVariables: true,
194 | shouldPreload: () => true,
195 | fontsToPreload: new Map(),
196 | resolveFontFace: (family, options) => ({
197 | fonts: [{ src: [{ url: `/${slugify(family)}.woff2`, format: 'woff2' }] }],
198 | fallbacks: options?.fallbacks ? ['Times New Roman', ...options.fallbacks] : undefined,
199 | }),
200 | }, css, 'some-id')
201 | return result?.toString()
202 | }
203 |
--------------------------------------------------------------------------------
/packages/fontless/test/render.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from 'vitest'
2 |
3 | import { generateFontFace } from '../src/css/render'
4 |
5 | describe('rendering @font-face', () => {
6 | it('should add declarations for `font-family`', () => {
7 | const css = generateFontFace('Inter', {
8 | src: [{ name: 'Inter Var' }, { url: '/inter.woff2' }],
9 | weight: [400, 700],
10 | })
11 | expect(css).toMatchInlineSnapshot(`
12 | "@font-face {
13 | font-family: 'Inter';
14 | src: local("Inter Var"), url("/inter.woff2");
15 | font-display: swap;
16 | font-weight: 400 700;
17 | }"
18 | `)
19 | })
20 | it('should support additional properties', () => {
21 | const css = generateFontFace('Helvetica Neue', {
22 | src: [{ url: '/helvetica-neue.woff2' }],
23 | stretch: 'expanded',
24 | display: 'fallback',
25 | style: 'italic',
26 | weight: '400',
27 | })
28 | expect(css).toMatchInlineSnapshot(`
29 | "@font-face {
30 | font-family: 'Helvetica Neue';
31 | src: url("/helvetica-neue.woff2");
32 | font-display: fallback;
33 | font-weight: 400;
34 | font-style: italic;
35 | font-stretch: expanded;
36 | }"
37 | `)
38 | })
39 | })
40 |
--------------------------------------------------------------------------------
/packages/fontless/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath } from 'node:url'
2 | import { defineConfig } from 'vitest/config'
3 |
4 | export default defineConfig({
5 | resolve: {
6 | alias: {
7 | fontless: fileURLToPath(
8 | new URL('./src/index.ts', import.meta.url).href,
9 | ),
10 | },
11 | },
12 | test: {
13 | coverage: {
14 | include: ['src'],
15 | reporter: ['text', 'json', 'html'],
16 | },
17 | },
18 | })
19 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - packages/*
3 | - packages/fontaine/playground
4 | - packages/fontless/examples/*
5 |
6 | ignoredBuiltDependencies:
7 | - esbuild
8 |
9 | onlyBuiltDependencies:
10 | - simple-git-hooks
11 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "github>danielroe/renovate"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2022",
4 | "lib": [
5 | "es2022",
6 | "esnext"
7 | ],
8 | "moduleDetection": "force",
9 | "module": "preserve",
10 | "resolveJsonModule": true,
11 | "allowJs": true,
12 | "strict": true,
13 | "noImplicitOverride": true,
14 | "noUncheckedIndexedAccess": true,
15 | "noEmit": true,
16 | "esModuleInterop": true,
17 | "forceConsistentCasingInFileNames": true,
18 | "isolatedModules": true,
19 | "verbatimModuleSyntax": true,
20 | "skipLibCheck": true
21 | },
22 | "exclude": [
23 | "packages/fontless/examples/*"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------