├── .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 | 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 | 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 | --------------------------------------------------------------------------------