├── .changeset ├── README.md ├── config.json └── rich-swans-cry.md ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .vscode ├── extensions.json └── launch.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── biome.json ├── docs ├── .gitignore ├── .vscode │ ├── extensions.json │ └── launch.json ├── README.md ├── astro.config.mts ├── package.json ├── public │ └── favicon.svg ├── src │ ├── assets │ │ └── houston.webp │ ├── content │ │ ├── config.ts │ │ └── docs │ │ │ ├── conventions │ │ │ ├── configurations.mdx │ │ │ ├── routes.mdx │ │ │ └── styles.mdx │ │ │ ├── core-concepts.mdx │ │ │ ├── getting-started.mdx │ │ │ ├── index.mdx │ │ │ ├── reference │ │ │ ├── author.mdx │ │ │ └── user.mdx │ │ │ ├── upgrade-guide.mdx │ │ │ └── why.mdx │ ├── env.d.ts │ ├── styles │ │ └── global.css │ └── utils.ts └── tsconfig.json ├── package.json ├── package ├── .gitignore ├── CHANGELOG.md ├── README.md ├── package.json ├── src │ ├── index.ts │ ├── internal │ │ ├── consts.ts │ │ ├── error-map.ts │ │ └── types.ts │ └── utils │ │ ├── modules.ts │ │ ├── options.ts │ │ ├── package.ts │ │ ├── path.ts │ │ └── resolver.ts ├── tests │ ├── mock │ │ ├── package │ │ │ ├── index.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.svg │ │ │ └── src │ │ │ │ ├── assets │ │ │ │ └── levi.png │ │ │ │ ├── components │ │ │ │ └── Heading.astro │ │ │ │ ├── layouts │ │ │ │ └── Layout.astro │ │ │ │ ├── middleware.ts │ │ │ │ ├── middleware │ │ │ │ ├── index.ts │ │ │ │ ├── middleware.ts │ │ │ │ ├── post.ts │ │ │ │ └── pre.ts │ │ │ │ ├── pages │ │ │ │ └── index.astro │ │ │ │ └── styles │ │ │ │ └── global.css │ │ └── project │ │ │ └── src │ │ │ └── env.d.ts │ ├── modules.test.js │ └── theme.test.js ├── tsconfig.build.json ├── tsconfig.json └── tsup.config.ts ├── playground ├── .gitignore ├── astro.config.mts ├── package.json ├── src │ ├── CustomHeading.astro │ ├── custom.css │ └── env.d.ts └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tests ├── e2e │ └── ssg │ │ ├── astro.config.ts │ │ ├── config.js │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── src │ │ └── env.d.ts │ │ ├── test-results │ │ └── .last-run.json │ │ └── tests │ │ └── theme.spec.ts └── themes │ ├── theme-playground │ ├── index.ts │ ├── package.json │ ├── public │ │ └── favicon.svg │ └── src │ │ ├── assets │ │ ├── ai.png │ │ ├── cursed.png │ │ ├── levi.png │ │ ├── sit.png │ │ └── starlight.png │ │ ├── components │ │ └── Heading.astro │ │ ├── layouts │ │ └── Layout.astro │ │ ├── pages │ │ ├── api │ │ │ └── json.json.ts │ │ ├── cats │ │ │ ├── [...cat].astro │ │ │ └── index.astro │ │ └── index.astro │ │ └── styles │ │ └── global.css │ └── theme-ssg │ ├── index.ts │ ├── package.json │ ├── public │ └── favicon.svg │ └── src │ ├── assets │ └── cat.png │ ├── components │ └── Heading.astro │ ├── layouts │ └── Layout.astro │ ├── pages │ ├── config.json.ts │ └── index.astro │ └── styles │ └── global.css └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": ["docs", "playground", "theme-playground", "theme-ssg", "test-e2e-ssg"] 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/rich-swans-cry.md: -------------------------------------------------------------------------------- 1 | --- 2 | "astro-theme-provider": patch 3 | --- 4 | 5 | Upgrade dependencies to latest versions 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | # Set default charset 8 | charset = utf-8 9 | # Unix-style newlines 10 | end_of_line = lf 11 | # No automatic newline endings 12 | insert_final_newline = false 13 | # 2 character-sized tab indentation 14 | indent_size = 2 15 | indent_style = tab 16 | # remove whitespace before newlines 17 | trim_trailing_whitespace = true 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | issues: write 18 | id-token: write 19 | actions: read 20 | checks: read 21 | deployments: read 22 | discussions: read 23 | packages: read 24 | pages: read 25 | repository-projects: read 26 | security-events: read 27 | statuses: read 28 | steps: 29 | - name: Checkout Repo 30 | uses: actions/checkout@v4 31 | 32 | - name: Setup PNPM 33 | uses: pnpm/action-setup@v2 34 | 35 | - name: Setup Node 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: 20.x 39 | cache: "pnpm" 40 | 41 | - name: Install Dependencies 42 | run: pnpm install 43 | 44 | - name: Build Package 45 | run: pnpm package:build 46 | 47 | - name: Create Release Pull Request or Publish to npm 48 | id: changesets 49 | uses: changesets/action@v1 50 | with: 51 | version: pnpm exec changeset version 52 | publish: pnpm exec changeset publish 53 | commit: "[ci] release" 54 | title: "[ci] release" 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | paths-ignore: 5 | - 'playground/**' 6 | - 'docs/**' 7 | jobs: 8 | test: 9 | timeout-minutes: 10 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: lts/* 16 | - uses: pnpm/action-setup@v3 17 | with: 18 | run_install: true 19 | - name: Install Browsers 20 | run: pnpm exec playwright install --with-deps 21 | - name: Build Package 22 | run: pnpm package:build 23 | - name: Run Unit Tests 24 | run: pnpm test:unit 25 | - name: Run e2e Tests 26 | run: pnpm test:e2e 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## What is `astro-theme-provider`? 2 | 3 | Astro Theme Provider is a tool that allows you to author themes for [Astro](https://astro.build/) like a normal project and export your work as an integration for others to use. 4 | 5 | ### Core Goals 6 | 7 | - Inject configurable pages and assets into projects using an integration 8 | - Provide/create integrations without having to write any integration code 9 | - Provide APIs for theme users to override pages and assets 10 | - Install theme integrations using a single command `astro add my-theme` 11 | - Typesafe configurations, imports/modules, etc 12 | - Work in any enviroment by default 13 | 14 | ## Contibuting to `astro-theme-provider` 15 | 16 | ### How can I contribute? 17 | 18 | There are many ways to contribute and many of them do not involve code, giving feedback and asking questions helps a lot. We encourage contributions of any kind! Some ways you can help contribute are: 19 | 20 | - Help improve the [documentation](https://astro-theme-provider.netlify.app/) 21 | - Use `astro-theme-provider`, give feedback about your experience and open issues for any bugs you find 22 | - Participate in discussions in the [Discord Channel](https://chat.astrolicious.dev), [Issues Tab](https://github.com/astrolicious/astro-theme-provider/issues), [Discussions Tab](https://github.com/astrolicious/astro-theme-provider/discussions), etc and give suggestions for improvments or enhancments 23 | - Take ownership of an issue (typically tagged as `help wanted`), this can be anything from a simple bug fix to a large enhancement for the project 24 | - Review PRs, it is important that code is reviewed and approved by someone that did not author the PR 25 | 26 | ### Setting up local repo 27 | 28 | > **Note**: This repo uses ***[pNPM](https://pnpm.io/)***, you must use ***pNPM*** as your package manager 29 | 30 | 1. Clone the repo locally 31 | 32 | ``` 33 | git clone https://github.com/astrolicious/astro-theme-provider.git 34 | ``` 35 | 36 | 2. Install dependencies 37 | 38 | ``` 39 | pnpm install 40 | ``` 41 | 42 | 3. Install browsers for e2e testing 43 | 44 | ``` 45 | pnpm exec playwright install --with-deps 46 | ``` 47 | 48 | 4. Build the package 49 | 50 | ``` 51 | pnpm package:dev 52 | ``` 53 | 54 | Now that the repo has been setup and the package has been built, you can use `pnpm test` to test your changes and use `pnpm playground:dev` to play around with your changes 55 | 56 | ### PRs 57 | 58 | - Use the command `pnpm lint:fix` to lint your PR (last commit) 59 | - Use the command `pnpm changeset` to add a changeset to your PR 60 | 61 | #### Merging 62 | 63 | - PRs must have passing checks before merging 64 | - Always squash merge 65 | 66 | ### Repo Structure 67 | 68 | ``` 69 | docs // starlight website for documentation 70 | package/ 71 | ├── src // `astro-theme-provider` package code 72 | └── tests // Unit tests for package code 73 | playground // Playground for testing changes 74 | tests/ 75 | ├── e2e // e2e tests using playwright 76 | └── themes 77 | ├── theme-playground // test theme used inside of playground 78 | └── ... 79 | ``` 80 | 81 | 82 | ### Commands 83 | 84 | | Command | Action | 85 | | :------------------------ | :----------------------------------------------------- | 86 | | `pnpm test` | run all tests | 87 | | `pnpm test:unit` | run unit tests | 88 | | `pnpm test:e2e` | run e2e tests using playwright | 89 | | `pnpm package:dev` | build the `astro-theme-provider` package in watch mode | 90 | | `pnpm package:build` | build the `astro-theme-provider` package using `tsup` | 91 | | `pnpm playground:dev` | run the dev server for the playground | 92 | | `pnpm playground:build` | build the playground project | 93 | | `pnpm docs:dev` | run dev server for docs | 94 | | `pnpm docs:build` | build docs project` | 95 | | `pnpm lint` | lint | 96 | | `pnpm lint:fix` | apply lint | 97 | | `pnpm changeset` | create a changeset for your changes | 98 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `astro-theme-provider` 2 | 3 | [![npm version](https://img.shields.io/npm/v/astro-theme-provider?labelColor=red&color=grey)](https://www.npmjs.com/package/astro-theme-provider) 4 | ![beta](https://img.shields.io/badge/Beta-orange) 5 | 6 | Author themes for Astro like a normal project and export your work as an integration for others to use 7 | 8 | ### [Documentation](https://astro-theme-provider.netlify.app) 9 | 10 | ### [Theme Template](https://github.com/astrolicious/astro-theme-provider-template) 11 | 12 | 13 | ### Contributing 14 | 15 | - [Contributing Guide](https://github.com/astrolicious/astro-theme-provider/blob/main/CONTRIBUTING.md) 16 | - [Discord Channel](https://chat.astrolicious.dev) 17 | - [Discussions](https://github.com/astrolicious/astro-theme-provider/discussions) 18 | - [Issues](https://github.com/astrolicious/astro-theme-provider/issues) 19 | 20 | ### Example 21 | 22 | **Authoring a Theme**: 23 | 24 | ``` 25 | package/ 26 | ├── public/ 27 | ├── src/ 28 | │ ├── assets/ 29 | │ ├── components/ 30 | │ ├── layouts/ 31 | │ ├── pages/ 32 | │ └── styles/ 33 | ├── index.ts 34 | └── package.json 35 | ``` 36 | 37 | ```ts 38 | // package/index.ts 39 | import defineTheme from 'astro-theme-provider'; 40 | import { z } from 'astro/zod' 41 | 42 | export default defineTheme({ 43 | schema: z.object({ 44 | title: z.string(), 45 | }) 46 | }) 47 | ``` 48 | 49 | **Using a Theme**: 50 | 51 | ```ts 52 | // astro.config.mjs 53 | import { defineConfig } from 'astro/config'; 54 | import Blog from 'blog-theme'; 55 | 56 | export default defineConfig({ 57 | integrations: [ 58 | Blog({ 59 | config: { 60 | title: "My Blog" 61 | }, 62 | pages: { 63 | '/404': false, // Toggle routes off 64 | '/blog': '/projects', // Overwrite routes 65 | }, 66 | overrides: { 67 | components: { 68 | Hero: './src/Custom.astro' // Overwrite theme assets 69 | }, 70 | styles: [ 71 | "./src/custom.css" // Add custom stylesheets 72 | ], 73 | }, 74 | }), 75 | ], 76 | }); 77 | ``` 78 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.5.2/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "vcs": { 7 | "enabled": true, 8 | "clientKind": "git", 9 | "useIgnoreFile": true 10 | }, 11 | "formatter": { 12 | "lineWidth": 120 13 | }, 14 | "linter": { 15 | "enabled": true, 16 | "rules": { 17 | "recommended": true 18 | } 19 | }, 20 | "files": { 21 | "ignore": ["package.json"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /docs/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /docs/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Starlight Starter Kit: Basics 2 | 3 | [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) 4 | 5 | ``` 6 | npm create astro@latest -- --template starlight 7 | ``` 8 | 9 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics) 10 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics) 11 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs) 12 | 13 | > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 14 | 15 | ## 🚀 Project Structure 16 | 17 | Inside of your Astro + Starlight project, you'll see the following folders and files: 18 | 19 | ``` 20 | . 21 | ├── public/ 22 | ├── src/ 23 | │ ├── assets/ 24 | │ ├── content/ 25 | │ │ ├── docs/ 26 | │ │ └── config.ts 27 | │ └── env.d.ts 28 | ├── astro.config.mjs 29 | ├── package.json 30 | └── tsconfig.json 31 | ``` 32 | 33 | Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. 34 | 35 | Images can be added to `src/assets/` and embedded in Markdown with a relative link. 36 | 37 | Static assets, like favicons, can be placed in the `public/` directory. 38 | 39 | ## 🧞 Commands 40 | 41 | All commands are run from the root of the project, from a terminal: 42 | 43 | | Command | Action | 44 | | :------------------------ | :----------------------------------------------- | 45 | | `npm install` | Installs dependencies | 46 | | `npm run dev` | Starts local dev server at `localhost:4321` | 47 | | `npm run build` | Build your production site to `./dist/` | 48 | | `npm run preview` | Preview your build locally, before deploying | 49 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | 50 | | `npm run astro -- --help` | Get help using the Astro CLI | 51 | 52 | ## 👀 Want to learn more? 53 | 54 | Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). 55 | -------------------------------------------------------------------------------- /docs/astro.config.mts: -------------------------------------------------------------------------------- 1 | import starlight from "@astrojs/starlight"; 2 | import { defineConfig } from "astro/config"; 3 | import { packageVersion } from "./src/utils"; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | integrations: [ 8 | starlight({ 9 | title: "Astro Theme Provider", 10 | credits: true, 11 | lastUpdated: true, 12 | social: { 13 | discord: "https://chat.astrolicious.dev/", 14 | github: "https://github.com/astrolicious/astro-theme-provider", 15 | }, 16 | editLink: { 17 | baseUrl: "https://github.com/astrolicious/astro-theme-provider/edit/main/docs", 18 | }, 19 | customCss: ["./src/styles/global.css"], 20 | sidebar: [ 21 | { 22 | label: "Introduction", 23 | items: [ 24 | { 25 | label: "Why?", 26 | link: "/why", 27 | badge: { 28 | text: "New", 29 | variant: "success", 30 | }, 31 | }, 32 | { 33 | label: "Core Concepts", 34 | link: "/core-concepts", 35 | badge: { 36 | text: "New", 37 | variant: "success", 38 | }, 39 | }, 40 | { 41 | label: "Getting Started", 42 | link: "/getting-started", 43 | }, 44 | ], 45 | }, 46 | { 47 | label: "Conventions and Techniques", 48 | items: [ 49 | // { 50 | // label: "Theme Configurations", 51 | // link: "#", 52 | // }, 53 | { 54 | label: "Styling a Theme", 55 | link: "/conventions/styles", 56 | badge: { 57 | text: "New", 58 | variant: "success", 59 | }, 60 | }, 61 | { 62 | label: "Authoring Routes", 63 | link: "#", 64 | attrs: { 65 | style: "opacity:.5", 66 | }, 67 | badge: { 68 | text: "WIP", 69 | variant: "caution", 70 | }, 71 | }, 72 | { 73 | label: "Authoring Components", 74 | link: "#", 75 | attrs: { 76 | style: "opacity:.5", 77 | }, 78 | badge: { 79 | text: "WIP", 80 | variant: "caution", 81 | }, 82 | }, 83 | // { 84 | // label: "Client Scripts & Frameworks", 85 | // link: "#", 86 | // }, 87 | // { 88 | // label: "Integration Wrappers", 89 | // link: "#", 90 | // badge: { 91 | // text: "Advanced", 92 | // variant: "caution", 93 | // } 94 | // }, 95 | ], 96 | }, 97 | { 98 | label: "Reference", 99 | items: [ 100 | { 101 | label: "Author API", 102 | link: "/reference/author", 103 | }, 104 | { 105 | label: "User API", 106 | link: "/reference/user", 107 | }, 108 | ], 109 | }, 110 | { 111 | label: "Upgrade Guide", 112 | link: "/upgrade-guide", 113 | }, 114 | { 115 | label: `v${packageVersion} Changelog ↗`, 116 | link: `https://github.com/astrolicious/astro-theme-provider/blob/main/package/CHANGELOG.md#${packageVersion.replaceAll(".", "")}`, 117 | attrs: { 118 | target: "_blank", 119 | }, 120 | }, 121 | { 122 | label: "Need Help? ↗", 123 | link: "https://chat.astrolicious.dev/", 124 | }, 125 | ], 126 | }), 127 | ], 128 | }); 129 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.1", 6 | "scripts": { 7 | "dev": "astro dev", 8 | "start": "astro dev", 9 | "build": "astro build", 10 | "preview": "astro preview", 11 | "astro": "astro" 12 | }, 13 | "dependencies": { 14 | "@astrojs/check": "^0.9.4", 15 | "@astrojs/starlight": "^0.32.2", 16 | "astro": "^5.4.3", 17 | "sharp": "^0.33.5" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/src/assets/houston.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/docs/src/assets/houston.webp -------------------------------------------------------------------------------- /docs/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from "astro:content"; 2 | import { docsSchema } from "@astrojs/starlight/schema"; 3 | 4 | export const collections = { 5 | docs: defineCollection({ schema: docsSchema() }), 6 | }; 7 | -------------------------------------------------------------------------------- /docs/src/content/docs/conventions/configurations.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configurations 3 | description: Configurations 4 | --- 5 | 6 | ### Overriding Default Modules 7 | 8 | Authors can override the default virtual modules using the [`imports`](/reference/author/#imports) option 9 | 10 | ```ts 11 | import defineTheme from 'astro-theme-provider'; 12 | 13 | export default defineTheme({ 14 | name: 'my-theme', 15 | imports: { 16 | // Define style files manually to control order 17 | styles: [ 18 | './src/styles/reset.css', 19 | './src/styles/base.css', 20 | ], 21 | // Remap globs to deeply nested folders 22 | assets: 'assets/nested/**/*.png', 23 | layouts: 'layouts/nested/**/*.astro', 24 | components: 'components/nested/**/*.astro' 25 | } 26 | }) 27 | ``` 28 | 29 | ### Disabling Default Modules 30 | 31 | Authors can disable the default virtual modules using the [`imports`](/reference/author/#imports) option and setting the modules to `false` 32 | 33 | ```ts 34 | import defineTheme from 'astro-theme-provider'; 35 | 36 | export default defineTheme({ 37 | name: 'my-theme', 38 | imports: { 39 | styles: false, 40 | assets: false, 41 | layouts: false, 42 | components: false 43 | } 44 | }) 45 | ``` 46 | 47 | ### Custom Virtual Modules -------------------------------------------------------------------------------- /docs/src/content/docs/conventions/routes.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Conventions and Techniques 3 | description: Conventions and techniques for authoring themes using Astro Theme Provider 4 | --- 5 | 6 | ## Theme Context 7 | 8 | ### Check Installed Integrations 9 | 10 | The context module contains a list of all integrations running inside the user's project. 11 | This list can be used to support [conditionally injected integrations](#conditionally-inject-integrations) or integrations that may be used alongside a theme. 12 | For example: 13 | 14 | ```ts 15 | // package/src/pages/index.astro 16 | --- 17 | import { integrations } from 'my-theme:context' 18 | --- 19 | 20 | /* 21 | The integration "@inox-tools/sitemap-ext" has 22 | a special API for per-route configurations 23 | 24 | Themes can opt-in to this alternative sitemap integration 25 | by checking for the existence of the integration and providing a value 26 | */ 27 | if (integrations.has('@inox-tools/sitemap-ext')) { 28 | import('sitemap-ext:config') 29 | .then(({ default: sitemap }) => sitemap(true)) 30 | } 31 | ``` 32 | 33 | ### Get Final Pattern of Injected Routes 34 | 35 | Routes injected by the Theme Provider can be overwritten and disabled. The context module contains a map of all possibly injected routes and their final pattern. 36 | 37 | ```tsx 38 | // package/src/pages/blog/[...slug].astro 39 | --- 40 | import { pages } from 'my-theme:context' 41 | --- 42 | 43 | if (pages.get('/blog')) { 44 | 45 | } 46 | ``` 47 | 48 | 49 | ## Injecting Routes 50 | 51 | ## Injecting Middleware 52 | 53 | ### Ordering Middleware -------------------------------------------------------------------------------- /docs/src/content/docs/conventions/styles.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Styling a theme 3 | description: Conventions and techniques for styling theme integrations created with Astro Theme Provider 4 | --- 5 | 6 | import { FileTree } from '@astrojs/starlight/components'; 7 | 8 | Theme integrations can be styled just like any other Astro project, but there are a few conventions that theme authors should follow to create themes that are customizable and compatible with each other. 9 | 10 | ## Virtual modules 11 | 12 | Astro Theme Provider uses virtual modules for styling, enabling complete customization for theme users through overrides: 13 | 14 | ```tsx 15 | // package/src/pages/index.astro 16 | --- 17 | import "my-theme:styles"; 18 | --- 19 | ``` 20 | 21 | When styling a theme, always use virtual imports so that theme users can override the styles: 22 | 23 | ```ts 24 | // package/src/pages/index.astro 25 | --- 26 | // DO use virtual imports 27 | import "my-theme:styles"; 28 | // DO NOT use relative imports 29 | import "../styles/global.css"; 30 | --- 31 | ``` 32 | 33 | ### Default style module 34 | 35 | By default, all styles inside the `src/styles` directory will be combined into a virtual module: 36 | 37 | 38 | - package 39 | - src 40 | - **styles** 41 | - reset.css 42 | - styles.css 43 | - utilities.css 44 | 45 | 46 | ```tsx 47 | // package/src/pages/index.astro 48 | --- 49 | import 'my-theme:styles'; 50 | --- 51 | ``` 52 | 53 | The default style module can be overridden using the [`imports` option](/reference/author#imports). 54 | This may be useful for cases like importing fonts or controlling the order of styles inside the module. For example: 55 | 56 | ```ts 57 | export default defineTheme({ 58 | name: 'my-theme', 59 | imports: { 60 | styles: [ 61 | '@fontsource-variable/inter' 62 | './src/styles/reset.css', 63 | './src/styles/global.css', 64 | './src/styles/utilities.css', 65 | ], 66 | }, 67 | }) 68 | ``` 69 | 70 | ### Custom style modules 71 | 72 | Themes have access to a [default style module](#default-style-module), but authors can create as many style modules as they want using the [`imports` option](/reference/author#imports). 73 | 74 | ```ts 75 | export default defineTheme({ 76 | name: 'my-theme', 77 | imports: { 78 | // Use a string to glob files inside the 'src' folder 79 | 'styles/blog': 'styles/blog/**/*.{css,scss}', 80 | 81 | // Use an array to manually define imports and control order 82 | 'styles/gallery': [ 83 | '@fontsource-variable/inter', 84 | './src/styles/gallery/reset.css', 85 | './src/styles/gallery/global.css', 86 | './src/styles/gallery/utilities.css', 87 | ], 88 | }, 89 | }) 90 | ``` 91 | 92 | ```tsx 93 | // package/src/pages/index.astro 94 | --- 95 | import 'my-theme:styles/blog'; 96 | import 'my-theme:styles/gallery'; 97 | --- 98 | ``` 99 | 100 | ## Creating overridable styles 101 | 102 | All themes should be authored with style systems that are easy to customize, compatible with user projects, and compatible with other theme integrations. 103 | 104 | ### Importing styles 105 | 106 | Styles should always be imported using [virtual modules](#virtual-modules): 107 | 108 | ```ts 109 | // package/src/pages/index.astro 110 | --- 111 | // DO use virtual imports 112 | import "my-theme:styles"; 113 | // DO NOT use relative imports 114 | import "../styles/global.css"; 115 | --- 116 | ``` 117 | 118 | Styles will apply as a cascade, in the order they are imported. 119 | Always place virtual style imports _below_ all other imports to avoid issues with theme users overriding styles. 120 | 121 | ```ts 122 | // package/src/pages/index.astro 123 | --- 124 | import { Layout } from 'my-theme:layout' 125 | import { Component } from 'my-theme:components' 126 | // Place style import below all other imports 127 | import "my-theme:styles"; 128 | --- 129 | ``` 130 | 131 | ### CSS Layers 132 | 133 | All styles inside a theme should be authored using [CSS layers](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer). 134 | 135 | ```css "@layer my-theme" 136 | @layer my-theme { 137 | :root { 138 | --my-color-bg: #222222; 139 | } 140 | 141 | body { 142 | background-color: var(--my-color-bg); 143 | } 144 | } 145 | ``` 146 | 147 | CSS layers allows users to have _full control_ over the order that styles cascade. For example: 148 | 149 | ```css 150 | @layer blog-theme, my-theme, docs-theme, user-overrides; 151 | ``` 152 | 153 | Read more about CSS layers here: 154 | 155 | - [`@layer`- MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer) 156 | - [Cascade layers - MDN](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Cascade_layers#the_layer_statement_at-rule_for_named_layers) 157 | - [A Complete Guide to CSS Cascade Layers - Miriam Suzanne](https://css-tricks.com/css-cascade-layers/) 158 | 159 | 160 | ### CSS variables 161 | 162 | Theme authors should make use of CSS variables to enable style themes that are easy to customize and override. For example: 163 | 164 | ```css "--my-color-bg" "--my-color-fg" 165 | :root { 166 | --my-color-fg: #fefefe; 167 | --my-color-bg: #222222; 168 | } 169 | 170 | /* DO use css variables */ 171 | body { 172 | background-color: var(--my-color-bg); 173 | color: var(--my-color-fg); 174 | } 175 | 176 | /* DO NOT hardcode values */ 177 | body { 178 | background-color: #222222; 179 | color: #fefefe; 180 | } 181 | ``` 182 | 183 | CSS variables should always include a unique prefix to avoid collisions with existing styles. 184 | 185 | Read more about creating style systems with CSS variables here: 186 | {/* Results of quick google search, better suggestions are appreciated */} 187 | - [Building Design Systems with CSS Variables - Nicolas Pardo ](https://gorillalogic.com/blog/building-design-systems-with-css-variables) 188 | - [Building your own Modern CSS Design System - Rocky Kev](https://dev.to/rockykev/building-your-own-modern-css-design-system-32kd) 189 | - [How to create better themes with CSS variables - Michelle Barker](https://blog.logrocket.com/create-better-themes-with-css-variables/) 190 | 191 | ### Class names 192 | 193 | Theme authors should include classes on elements, even if the element is not being styled. 194 | Classes allow users to target elements inside a theme with style changes. For example: 195 | 196 | ```html 197 | 198 |
199 |

200 | 201 |

202 |
203 | 204 | 205 |
206 |

207 | 208 |

209 |
210 | ``` 211 | 212 | Class names should always include a unique prefix to prevent collisions with existing styles. 213 | 214 | Read more about choosing names for classes here: 215 | - [BEM by Example - Nathan Rambeck](https://sparkbox.com/foundry/bem_by_example) 216 | 217 | ## Style patterns to avoid 218 | 219 | ### Style attributes 220 | 221 | Avoid using style attributes as they have a [high specificity](https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity#inline_styles), making them hard for users override. 222 | 223 | ```html 224 | 225 | ``` 226 | 227 | -------------------------------------------------------------------------------- /docs/src/content/docs/core-concepts.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Core Concepts 3 | description: Core concepts of Astro Theme Provider 4 | --- 5 | 6 | import { FileTree } from '@astrojs/starlight/components'; 7 | 8 | This page aims to explain how Astro Theme Provider works at a fundamental level for _[theme authors](#authoring-a-theme)_ and _[theme users](#using-a-theme)_. 9 | 10 | - If you are interested in learning about what Astro Theme Provider is and why you want to use it, you can read more here: [Why?](/why) 11 | - If you need specific advice about how to author a theme, check out the guides under "Conventions and Techniques" in the left sidebar. 12 | - If you are a _theme user_, please refer to the [User API](/reference/user) and your theme's documentation. 13 | 14 | ## Authoring a Theme 15 | 16 | Using Astro Theme Provider to author a theme integration is simple, but there are a few core concepts that one should know before getting started. 17 | 18 | ### Project Structure 19 | 20 | Themes are developed inside a [pnpm](https://pnpm.io/) monorepository. 21 | 22 | 23 | - / 24 | - package/ 25 | - ... 26 | - playground/ 27 | - ... 28 | - package.json 29 | - pnpm-lock.yaml 30 | - pnpm-workspace.yaml 31 | - tsconfig.json 32 | 33 | 34 | The theme integration itself lives inside a package with a structure similar to a normal Astro project: 35 | 36 | 37 | - package/ 38 | - public/ 39 | - src/ 40 | - assets/ 41 | - components/ 42 | - layouts/ 43 | - pages/ 44 | - styles/ 45 | - index.ts 46 | - package.json 47 | - README.md 48 | 49 | 50 | Theme integrations must be authored alongside a playground. 51 | The playground is responsible for generating types during development and enables previewing changes made to the theme. 52 | 53 | 54 | - playground/ 55 | - public/ 56 | - src/ 57 | - astro.config.mjs 58 | - package.json 59 | - tsconfig.json 60 | 61 | 62 | 63 | ### Theme Configuration 64 | 65 | Theme authors define the shape for the theme user configuration using a [Zod](https://zod.dev/) schema. 66 | 67 | ```ts 68 | // package/index.ts 69 | import defineTheme from 'astro-theme-provider'; 70 | import { z } from 'astro/zod'; 71 | 72 | export default defineTheme({ 73 | name: 'my-theme', 74 | schema: z.object({ 75 | title: z.string(), 76 | description: z.string().optional() 77 | }) 78 | }) 79 | ``` 80 | 81 | > **Important**: The theme config object must be JSON serializable. Values like functions and classes do not work inside theme configurations. 82 | 83 | Pages and components can then be designed so that theme users can easily customize them without touching core parts of the theme. 84 | 85 | ```tsx 86 | // package/src/pages/index.astro 87 | --- 88 | import config from 'my-theme:config' 89 | --- 90 | 91 | 92 | 93 | {config.title} 94 | 95 | 96 |

{config.title}

97 |

{config.description}

98 | 99 | 100 | ``` 101 | 102 | ### Theme Context 103 | 104 | Theme integrations are _dynamic_, they may be used inside a variety of projects with different combinations of user configurations and enviroments. 105 | To account for this, themes have built-in utilities to access information about the context the theme is running in. For example: 106 | 107 | ```tsx 108 | --- 109 | import { pages, integrations } from 'my-theme:context'; 110 | --- 111 | 112 | { integrations.has('@astrojs/rss') && 113 | 114 | } 115 | 116 | { pages.get('/blog') && 117 | 118 | } 119 | ``` 120 | 121 | This allows theme authors to create dynamic themes that adapt to a user's project and configuration. 122 | 123 | ### Virtual Modules 124 | 125 | Theme authors should load components and assets as virtual modules. This enables complete customization for a theme user via overrides. 126 | 127 | 128 | - package/ 129 | - src/ 130 | - assets/ 131 | - logo.png 132 | - components/ 133 | - Hero.astro 134 | - layouts/ 135 | - Layout.astro 136 | - styles/ 137 | - global.css 138 | 139 | 140 | ```tsx 141 | // package/src/pages/index.astro 142 | --- 143 | import { Layout } from 'my-theme:layouts'; 144 | import { Hero } from 'my-theme:components'; 145 | import { logo } from 'my-theme:assets'; 146 | import "my-theme:styles"; 147 | --- 148 | 149 | 150 | 151 | 152 | ``` 153 | 154 | ## Using a Theme 155 | 156 | Once a theme integration has been authored and published to NPM, a user can add it to their project with a single command: 157 | 158 | ```sh 159 | pnpm astro add my-theme 160 | ``` 161 | 162 | This will install the theme package and add it to the `integrations` array inside the Astro config: 163 | 164 | ```ts 165 | // astro.config.mjs 166 | import { defineConfig } from 'astro/config'; 167 | import myTheme from 'my-theme'; 168 | 169 | export default defineConfig({ 170 | integrations: [ 171 | myTheme({ 172 | // ... 173 | }), 174 | ], 175 | }); 176 | ``` 177 | 178 | ### Configuring a theme 179 | 180 | Users can configure a theme using the `config` options that were defined by the theme author: 181 | 182 | ```ts 183 | myTheme({ 184 | config: { 185 | title: 'My Site!', 186 | description: 'My Astro Theme Provider Site!' 187 | } 188 | }) 189 | ``` 190 | 191 | ### Overriding part of a theme 192 | 193 | For complete customization, various parts of a theme can be overriden by a theme user. For example: 194 | 195 | **Overriding routes**: 196 | 197 | ```ts 198 | myTheme({ 199 | pages: { 200 | // Toggling routes off 201 | "/404": false, 202 | // Renaming injected routes 203 | "/blog": "/posts", 204 | } 205 | }) 206 | ``` 207 | 208 | **Overriding styles**: 209 | 210 | ```ts 211 | myTheme({ 212 | overrides: { 213 | // Appending new styles 214 | styles: [ 215 | './src/styles/override.css' 216 | ] 217 | } 218 | }) 219 | ``` 220 | 221 | **Overriding assets**: 222 | 223 | ```ts 224 | myTheme({ 225 | overrides: { 226 | // Changing an image 227 | assets: { 228 | logo: './src/assets/custom-logo.png' 229 | } 230 | } 231 | }) 232 | ``` 233 | 234 | **Disabling integrations**: 235 | 236 | ```ts 237 | myTheme({ 238 | integrations: { 239 | // Remove injected integration 240 | "@astrojs/sitemap": false 241 | } 242 | }) 243 | ``` 244 | -------------------------------------------------------------------------------- /docs/src/content/docs/getting-started.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | description: How to get started using Astro Theme Provider 4 | sidebar: 5 | order: 10 6 | --- 7 | import { FileTree, Steps } from '@astrojs/starlight/components'; 8 | 9 | ## Quick Start 10 | 11 | This quickest way to get started with Astro Theme Provider is to clone the [theme template](https://github.com/astrolicious/astro-theme-provider-template): 12 | 13 | 14 | 15 | 1. Clone the [theme template](https://github.com/astrolicious/astro-theme-provider-template): 16 | 17 | ```sh 18 | git clone https://github.com/astrolicious/astro-theme-provider-template.git my-theme 19 | ``` 20 | 21 | 1. Navigate to the created directory and install dependencies: 22 | 23 | ```sh 24 | cd my-theme/ 25 | pnpm install 26 | ``` 27 | 28 | 1. Run the playground to generate types for theme development and preview any changes: 29 | 30 | ```sh 31 | pnpm playground:dev 32 | ``` 33 | 34 | 1. Explore! Learn how Astro Theme Provider works by navigating the theme and reading the docs. 35 | 36 | 37 | 38 | ## Setup Manually 39 | 40 | If you are creating a theme inside an existing repository, you will have to set things up manually: 41 | 42 | 43 | 44 | 1. Create a package directory with the following structure: 45 | 46 | 47 | - package/ 48 | - public/ 49 | - src/ 50 | - assets/ 51 | - components/ 52 | - layouts/ 53 | - pages/ 54 | - styles/ 55 | - index.ts 56 | - package.json 57 | - README.md 58 | 59 | 60 | 1. Create a playground directory to generate types and test changes: 61 | 62 | ```sh 63 | pnpm create astro@latest playground --template minimal --no-git -y 64 | ``` 65 | 66 | 67 | - playground/ 68 | - public/ 69 | - src/ 70 | - astro.config.mjs 71 | - package.json 72 | - tsconfig.json 73 | 74 | 75 | 1. Add the package and playground directories to the workspace: 76 | 77 | ```yaml 78 | // pnpm-workspace.yaml 79 | packages: 80 | - 'package' 81 | - 'playground' 82 | ``` 83 | 84 | 1. Add the theme package to the playground's `package.json` and re-install dependencies: 85 | 86 | ```json 87 | // playground/package.json 88 | { 89 | "dependencies": { 90 | "astro": "^4.9.0", 91 | "my-theme": "workspace:^" 92 | } 93 | } 94 | ``` 95 | 96 | ```sh 97 | pnpm install 98 | ``` 99 | 100 | 1. Add the theme package to the `integrations` array inside the playground's Astro configuration: 101 | 102 | ```ts 103 | // playground/astro.config.mjs 104 | import { defineConfig } from 'astro/config'; 105 | import myTheme from 'my-theme'; 106 | 107 | export default defineConfig({ 108 | integrations: [ 109 | myTheme({ 110 | // ... 111 | }), 112 | ] 113 | }); 114 | ``` 115 | 116 | 1. Run the playground to generate types for theme development and preview any changes 117 | 118 | ```sh 119 | pnpm --filter playground dev 120 | ``` 121 | 122 | 123 | -------------------------------------------------------------------------------- /docs/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Astro Theme Provider 3 | description: A tool for authoring Astro themes as integrations 4 | # banner: 5 | # content: Astro Theme Provider is currently a work in progress, Coming Soon! 6 | tableOfContents: false 7 | banner: 8 | content: | 9 | Astro Theme Provider is in Beta! Want to help? 10 | Contribute Now 11 | hero: 12 | tagline: A tool for creating theme integrations in Astro 13 | image: 14 | file: ../../assets/houston.webp 15 | actions: 16 | # - text: What is Astro Theme Provider? 17 | # link: /introduction/ 18 | # variant: 'secondary' 19 | - text: Get Started 20 | link: /getting-started/ 21 | variant: primary 22 | - text: Theme Template 23 | link: https://github.com/astrolicious/astro-theme-provider-template 24 | icon: external 25 | --- 26 | import { Card, CardGrid } from '@astrojs/starlight/components'; 27 | 28 | 29 | 30 | Author themes that can be published and consumed as a package. 31 | 32 | 33 | Author themes similar to a normal Astro project, no integration code required. 34 | 35 | 36 | Pages and assets can be customized by theme users. 37 | 38 | 39 | Type safe for both theme authors and theme users. 40 | 41 | 42 | -------------------------------------------------------------------------------- /docs/src/content/docs/reference/author.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Author API 3 | description: Author API reference for Astro Theme Provider 4 | --- 5 | import { FileTree } from '@astrojs/starlight/components'; 6 | 7 | ## Default Structure 8 | 9 | 10 | - package/ 11 | - public/ 12 | - src/ 13 | - assets/ 14 | - components/ 15 | - layouts/ 16 | - pages/ 17 | - styles/ 18 | - **index.ts** 19 | - package.json 20 | 21 | 22 | ```ts 23 | // package/index.ts 24 | import { z } from 'astro/zod' 25 | import defineTheme from 'astro-theme-provider'; 26 | 27 | export default defineTheme({ 28 | name: "my-theme", 29 | schema: z.object({ 30 | title: z.string(), 31 | description: z.sting().optional() 32 | }) 33 | }) 34 | ``` 35 | 36 | ## Options Reference 37 | 38 | ### `name` 39 | 40 | **Type**: `string` 41 | 42 | **Required** 43 | 44 | The name of a theme, must be unique. If you are authoring your theme as a package, it is recomended to use the package name as the name of your theme. 45 | 46 | ### `schema` 47 | 48 | **Type**: [`Zod Schema`](https://zod.dev/?id=basic-usage) 49 | 50 | **Default**: `ZodTypeAny` 51 | 52 | A zod schema for validating the default value of a theme's config module. The schema must be JSON serializable. 53 | 54 | **Example**: 55 | 56 | ```ts 57 | schema: z.object({ 58 | title: z.string(), 59 | description: z.string().optional() 60 | }) 61 | ``` 62 | 63 | ```ts 64 | --- 65 | import config from "my-theme:config" 66 | 67 | console.log(config.title, config.description) 68 | --- 69 | ``` 70 | 71 | ### `srcDir` 72 | 73 | **Type**: `string` 74 | 75 | **Default**: `"src"` 76 | 77 | Directory that contains a theme's assets (pages, components, images, css, etc). Assets inside the src directory can be accessed using virtual modules (see `imports`). 78 | 79 | ### `pageDir` 80 | 81 | **Type**: `string` | [`AstroPagesOption`](https://github.com/BryceRussell/astro-pages/tree/main/package#option-reference) 82 | 83 | **Default**: `"pages"` 84 | 85 | Directory that contains all of the pages for a theme. 86 | 87 | ### `publicDir` 88 | 89 | **Type**: `string` | [`AstroPublicOption`](https://github.com/BryceRussell/astro-public/tree/main/package#option-reference) 90 | 91 | **Default**: `"public"` 92 | 93 | Directory that contains a theme's static assets (`favicon.svg`, etc). By default, these assets act as placeholders and can be overwritten by assets located in a user's `public` real folder. 94 | 95 | ### `middlewareDir` 96 | 97 | **Type**: `string` | `false` 98 | 99 | **Default**: `srcDir` 100 | 101 | Directory that contains a theme's middleware. Relative paths are resolved relative to the theme's `srcDir`, use `false` to disable middleware injection. 102 | 103 | ### `integrations` 104 | 105 | **Type**: `Array, integrations: string[] }) => AstroIntegration | false | null | undefined>` 106 | 107 | An array of integrations that will be included with the theme: 108 | 109 | ```ts 110 | integrations: [ 111 | mdx(), 112 | sitemap(), 113 | ] 114 | ``` 115 | 116 | ```ts 117 | integrations: [ 118 | // Check for other integrations 119 | ({ integrations }) => { 120 | if (!integrations.contains('@astrojs/sitemap')) { 121 | return inoxsitemap() 122 | } 123 | }, 124 | ] 125 | ``` 126 | 127 | ```ts 128 | integrations: [ 129 | // Pass user options to integrations 130 | ({ config }) => { 131 | if (config.sitemap) { 132 | return inoxsitemap(config.sitemap) 133 | } 134 | }, 135 | ] 136 | ``` 137 | 138 | ### `imports` 139 | 140 | **Type**: `Record | string[] | string | false>` 141 | 142 | **Default**: 143 | 144 | ```js 145 | imports: { 146 | assets: "assets**.{jpeg,jpg,png,tiff,webp,gif,svg,avif}", 147 | components: "components/**.{astro,tsx,jsx,svelte,vue}", 148 | layouts: "layouts/**.astro", 149 | styles: "styles/**.{css,scss,sass,styl,less}", 150 | } 151 | ``` 152 | 153 | Create virtual modules to access assets inside a theme and allow users to override them. 154 | 155 | **`false`**: 156 | 157 | Toggle default virtual modules off 158 | 159 | ```js 160 | imports: { 161 | assets: false, 162 | components: false, 163 | layouts: false, 164 | styles: false, 165 | } 166 | ``` 167 | 168 | **`string`**: 169 | 170 | Glob files into a virtual module, glob patterns are relative to a theme's `srcDir`. 171 | 172 | 173 | - package/ 174 | - src/ 175 | - components/ 176 | - Hero.astro 177 | - Card.astro 178 | - Button.astro 179 | 180 | 181 | ```ts 182 | imports: { 183 | components: "components/**.astro", 184 | } 185 | ``` 186 | 187 | ```ts 188 | import { Hero, Card, Button } from "my-theme:components" 189 | ``` 190 | 191 | **`array`**: 192 | 193 | Use an array to combine imports into a single virtual module. 194 | 195 | ```ts 196 | imports: { 197 | styles: [ 198 | "./src/styles/reset.css", 199 | "./src/styles/global.css", 200 | "./src/styles/utils.css", 201 | ], 202 | } 203 | ``` 204 | 205 | ```ts 206 | import "my-theme:styles" 207 | ``` 208 | 209 | **`object`**: 210 | 211 | Use an object to create more complex custom virtual modules. 212 | 213 | ```ts 214 | imports: { 215 | components: { 216 | imports: [ "./src/styles/global.css" ], 217 | default: "./src/layout/Layout.astro", 218 | Head: "./src/components/Head.astro", 219 | Button: "./src/components/Button.astro" 220 | } 221 | } 222 | ``` 223 | 224 | ```ts 225 | import Layout, { Head, Button } from "my-theme:components" 226 | ``` 227 | 228 | ### `log` 229 | 230 | **Type**: `"verbose" | "minimal" | boolean` 231 | 232 | **Default**: `true` 233 | 234 | Toggle logging for the theme. 235 | 236 | | Level | Description | 237 | | --- | --- | 238 | | `false` | Zero logging | 239 | | `"minimal" \| true` | Log warnings 240 | | `"verbose"` | Log everything, including debug information | 241 | 242 | ### `entrypoint` 243 | 244 | **Type**: `string` 245 | 246 | **Default**: directory of the file the theme is exported from 247 | 248 | A path to the root directory, or file inside the root directory, of a theme. 249 | 250 | :::caution 251 | For advanced use cases only, this should be infered automatically 252 | ::: 253 | 254 | ## Virtual Module Reference 255 | 256 | ### `theme:config` 257 | 258 | A virtual module used to expose the [user configuration](/reference/user#config). 259 | The shape for this module can be define using the [`schema` option](#schema): 260 | 261 | ```ts 262 | import { z } from 'astro/zod' 263 | import defineTheme from 'astro-theme-provider'; 264 | 265 | export default defineTheme({ 266 | name: "my-theme", 267 | schema: z.object({ 268 | title: z.string(), 269 | description: z.sting().optional() 270 | }) 271 | }) 272 | ``` 273 | 274 | After a [`schema`](#schema) is defined, authors can use this virtual module to create configurable pages and components: 275 | 276 | ```tsx 277 | --- 278 | import config from "my-theme:config" 279 | --- 280 | 281 |

{config.title}

282 |

{config.description}

283 | ``` 284 | 285 | ### `theme:context` 286 | 287 | A virtual module that contains utilities and information related to the context or enviroment a theme is running in. 288 | 289 | ```ts 290 | declare module "theme:context" { 291 | export const pages: Map; 292 | export const integrations: Set; 293 | } 294 | ``` 295 | 296 | #### `pages` 297 | 298 | A `Map` that contains the final pattern that page is injected with: 299 | 300 | ```tsx 301 | --- 302 | import { pages } from "my-theme:context" 303 | --- 304 | 305 | { pages.get('/blog') && 306 | Blog 307 | } 308 | ``` 309 | 310 | #### `integrations` 311 | 312 | A `Set` that contains the name of every integration inside the user's project. 313 | 314 | ```tsx 315 | --- 316 | import { integrations } from "my-theme:context" 317 | --- 318 | 319 | { integrations.get('@astrojs/rss') && 320 | RSS 321 | } 322 | ``` 323 | 324 | ### `theme:layouts` 325 | 326 | By default, all Astro files inside a theme's `src/layouts` folder are available as named exports inside the `:layouts` module. 327 | 328 | 329 | - package/ 330 | - src/ 331 | - **layouts/** 332 | - Layout.astro 333 | 334 | 335 | ```ts 336 | import { Layout } from "my-theme:layouts"; 337 | ``` 338 | 339 | This virtual module can be configured using the [`imports` option](#imports). 340 | 341 | ### `theme:components` 342 | 343 | By default, all Astro files inside a theme's `src/components` folder are available as named exports inside the `:components` module. 344 | 345 | 346 | - package/ 347 | - src/ 348 | - **components/** 349 | - Hero.astro 350 | - Card.astro 351 | - Button.astro 352 | 353 | 354 | ```ts 355 | import { Hero, Card, Button } from "my-theme:components"; 356 | ``` 357 | 358 | This virtual module can be configured using the [`imports` option](#imports). 359 | 360 | ### `theme:assets` 361 | 362 | By default, all images inside a theme's `src/assets` folder are available as named exports inside the `:assets` module. 363 | 364 | 365 | - package/ 366 | - src/ 367 | - **assets/** 368 | - logo.png 369 | - background.png 370 | 371 | 372 | ```ts 373 | import { logo, background } from "my-theme:assets"; 374 | ``` 375 | 376 | This virtual module can be configured using the [`imports` option](#imports). 377 | 378 | ### `theme:styles` 379 | 380 | By default, all styles inside a theme's `src/styles` folder are available inside the `:styles` module. 381 | 382 | 383 | - package/ 384 | - src/ 385 | - **styles/** 386 | - reset.css 387 | - styles.css 388 | - utilities.css 389 | 390 | 391 | ```ts 392 | import "my-theme:styles"; 393 | ``` 394 | 395 | This virtual module can be configured using the [`imports` option](#imports). 396 | 397 | Read more: 398 | - [Styling a Theme](/conventions/styles) 399 | - [Virtual style modules](/conventions/styles#virtual-style-modules) 400 | 401 | ## Example Project 402 | 403 | Author a theme inside a package similar to a normal project: 404 | 405 | 406 | - package/ 407 | - public/ 408 | - favicon.svg 409 | - src/ 410 | - assets/ 411 | - logo.png 412 | - components/ 413 | - Card.astro 414 | - layouts/ 415 | - Layout.astro 416 | - pages/ 417 | - index.astro 418 | - styles/ 419 | - global.css 420 | - index.ts 421 | - package.json 422 | - README.md 423 | 424 | 425 | Export the theme integration from the package entrypoint: 426 | 427 | ```ts 428 | // package/index.ts 429 | import { z } from 'astro/zod'; 430 | import defineTheme from 'astro-theme-provider'; 431 | 432 | export default defineTheme({ 433 | name: "my-theme", 434 | schema: z.object({ 435 | title: z.string(), 436 | description: z.string().optional() 437 | }) 438 | }) 439 | ``` 440 | 441 | Use the generated virtual modules to author the theme: 442 | 443 | ```tsx 444 | // package/src/pages/index.astro 445 | --- 446 | import { Layout } from 'my-theme:layouts'; 447 | import { Card } from 'my-theme:components'; 448 | import { logo } from 'my-theme:assets'; 449 | import config from 'my-theme:config'; 450 | import 'my-theme:styles'; 451 | 452 | const { 453 | title, 454 | description = "Welcome to my theme" 455 | } = config 456 | --- 457 | 458 | 459 | 460 | 461 | ``` 462 | -------------------------------------------------------------------------------- /docs/src/content/docs/reference/user.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: User API 3 | description: User API reference for Astro Theme Provider 4 | --- 5 | 6 | All themes authored using Astro Theme Provider share a basic API for using the theme. 7 | 8 | ## Reference 9 | 10 | ### `config` 11 | 12 | The value/type for this property is defined by a [theme's `schema`](/reference/author#schema). 13 | This configuration can be used by a user to customize the theme. 14 | 15 | ```ts 16 | config: { 17 | title: 'My Blog', 18 | description: 'This is my theme', 19 | favicon: '/favicon.svg' 20 | } 21 | ``` 22 | 23 | ### `pages` 24 | 25 | An object that can be used to configure the pages that are injected by a theme. 26 | Users can use this configuration to disable or rename pages. 27 | 28 | ```ts 29 | pages: { 30 | '/': '/blog', // Rename pattern 31 | '/404': false, // Toggle page off 32 | '/[...slug]': '/blog/[...slug]', 33 | }, 34 | ``` 35 | 36 | ### `overrides` 37 | 38 | An object that can be used to override the virtual modules that are created by a theme. 39 | Users can use this configuration to append custom styles or replace components and assets inside of a theme. 40 | 41 | ```ts 42 | overrides: { 43 | components: { 44 | // Override exports from a virtual modules using an object 45 | Hero: './src/components/CustomHero.astro', 46 | }, 47 | styles: [ 48 | // Append imports to a virtual module using an array 49 | './src/styles/custom-styles.css', 50 | ], 51 | } 52 | ``` 53 | 54 | ### `integrations` 55 | 56 | An object that can be used to disable integrations that are injected by a theme. 57 | 58 | ```ts 59 | integrations: { 60 | "@astrojs/sitemap": false, 61 | }, 62 | ``` 63 | 64 | ## Examples 65 | 66 | Examples for what a theme might look like inside of a user's Astro config. 67 | 68 | ### Simple customization 69 | 70 | ```ts 71 | // astro.config.mjs 72 | import { defineConfig } from 'astro/config'; 73 | import myTheme from 'my-theme'; 74 | 75 | export default defineConfig({ 76 | integrations: [ 77 | myTheme({ 78 | config: { 79 | title: 'My Theme', 80 | description: 'This is my theme!', 81 | }, 82 | }), 83 | ] 84 | }); 85 | ``` 86 | 87 | ### Advanced customization 88 | 89 | ```ts 90 | // astro.config.mjs 91 | import { defineConfig } from 'astro/config'; 92 | import blogTheme from 'blog-theme'; 93 | 94 | export default defineConfig({ 95 | integrations: [ 96 | blogTheme({ 97 | config: { 98 | title: 'My Blog', 99 | description: 'This is my theme', 100 | favicon: '/favicon.svg', 101 | }, 102 | pages: { 103 | '/': '/blog', 104 | '/404': false, 105 | '/[...slug]': '/blog/[...slug]', 106 | }, 107 | overrides: { 108 | components: { 109 | Card: './src/Card.astro', 110 | Pagination: './src/Pagination.astro', 111 | }, 112 | styles: [ 113 | "./src/global.css", 114 | ], 115 | }, 116 | integrations: { 117 | "@astrojs/sitemap": false, 118 | }, 119 | }) 120 | ] 121 | }); 122 | ``` 123 | -------------------------------------------------------------------------------- /docs/src/content/docs/upgrade-guide.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Upgrade Guide 3 | description: How to upgrade to the latest version of Astro Theme Provider 4 | --- 5 | import { FileTree } from '@astrojs/starlight/components'; 6 | 7 | ## `0.5.0` 8 | 9 | ### Added: Virtual import for theme utilities 10 | 11 | The virtual import `:context` is now reserved, authors can no longer create custom virtual modules with this name, example: 12 | 13 | ```diff 14 | defineTheme({ 15 | imports: { 16 | - context: { 17 | + options: { 18 | // ... 19 | } 20 | } 21 | }) 22 | ``` 23 | 24 | ## `0.4.0` 25 | 26 | ### Renamed: Virtual Imports 27 | 28 | Virtual imports have been updated to follow [conventions for virtual modules](https://vitejs.dev/guide/api-plugin.html#virtual-modules-convention). 29 | This fix now allows theme packages use an `export` property inside the `package.json` without breaking. 30 | 31 | ```diff 32 | - import { Component } from 'my-theme/components' 33 | - import 'my-theme/styles' 34 | + import { Component } from 'my-theme:components' 35 | + import 'my-theme:styles' 36 | ``` 37 | 38 | ### Renamed: Default module for styling 39 | 40 | The default module for styling `css` has been renamed to `styles`: 41 | 42 | ```diff 43 | - import 'my-theme/css' 44 | + import 'my-theme:styles' 45 | ``` 46 | 47 | Make sure to also update the name of the folder inside of your theme: 48 | 49 | 50 | - package/ 51 | - src/ 52 | - **styles/** \<- Renamed from "css" 53 | - ... 54 | 55 | -------------------------------------------------------------------------------- /docs/src/content/docs/why.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Why? 3 | description: Why Astro Theme Provider? 4 | --- 5 | 6 | ## What is Astro Theme Provider? 7 | 8 | Astro Theme provider is a tool for authoring _theme integrations_. 9 | These themes are authored inside a package similar to a normal Astro project. 10 | Themes can then be published as an integration for others to use. 11 | 12 | ## Why use Astro Theme Provider? 13 | 14 | ### Problems with current themes 15 | 16 | In Astro, typically a theme is authored as a complete project and published as a template that users can clone and customize. 17 | Theme templates are a very common pattern, but this pattern has many downsides that theme authors may not be aware of: 18 | 19 | - **Not beginner friendly**: The largest demographic of theme users are people who are new to Astro and web development in general. Theme templates may be hard to setup and understand for new developers. 20 | - **Cannot be updated easily**: Merging improvements from a theme template can be difficult if not impossible once a user customizes their local copy of the template. 21 | - **Cannot be used in existing projects**: Theme templates are authored as complete Astro projects, they cannot be used inside existing projects. 22 | 23 | ### Themes as integrations 24 | 25 | A better way to create themes is to shift away from project templates and towards Astro integrations. 26 | Integrations have many different advantages over a traditional template; the largest one being that integrations can be consumed as an NPM package. 27 | This allows users to: 28 | 29 | - Install themes with a single command (e.g., `astro add my-theme`) 30 | - Update themes with a single command (e.g., `pnpm upgrade my-theme`) 31 | - Use a theme inside an existing project 32 | - Use multiple themes inside a single project 33 | - Manage and configure themes from the Astro config 34 | 35 | ### Improving theme creation 36 | 37 | Although theme integrations may be more user-friendly, they are generally much harder to author, requiring a deep level of knowledge about Astro and Vite to create. 38 | Astro Theme Provider solves this by allowing theme authors to create theme integrations without having to write any integration code. 39 | Themes are authored inside a package similar to a normal Astro project, and are then exported as an integration for others to use. 40 | 41 | ## Real-world Example 42 | 43 | The best example of a theme integration, and pioneer of the pattern, is [Starlight](https://starlight.astro.build/getting-started/). 44 | Starlight allows you to build an entire documentation website with a configuration file and some markdown. 45 | It has become one of the most popular themes for Astro because it is easy to setup and use. 46 | Starlight's success proves that theme integrations are a powerful pattern that has been under utilized in the Astro ecosystem and Astro Theme Provider wants to change that! 47 | -------------------------------------------------------------------------------- /docs/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /docs/src/styles/global.css: -------------------------------------------------------------------------------- 1 | /* Dark mode colors. */ 2 | :root { 3 | --sl-color-accent-low: #072d00; 4 | --sl-color-accent: #247f00; 5 | --sl-color-accent-high: #aad7a0; 6 | --sl-color-white: #ffffff; 7 | --sl-color-gray-1: #eeeeee; 8 | --sl-color-gray-2: #c2c2c2; 9 | --sl-color-gray-3: #8b8b8b; 10 | --sl-color-gray-4: #585858; 11 | --sl-color-gray-5: #383838; 12 | --sl-color-gray-6: #272727; 13 | --sl-color-black: #181818; 14 | } 15 | 16 | /* Light mode colors. */ 17 | :root[data-theme="light"] { 18 | --sl-color-accent-low: #c0e2b8; 19 | --sl-color-accent: #258100; 20 | --sl-color-accent-high: #0d3e00; 21 | --sl-color-white: #181818; 22 | --sl-color-gray-1: #272727; 23 | --sl-color-gray-2: #383838; 24 | --sl-color-gray-3: #585858; 25 | --sl-color-gray-4: #8b8b8b; 26 | --sl-color-gray-5: #c2c2c2; 27 | --sl-color-gray-6: #eeeeee; 28 | --sl-color-gray-7: #f6f6f6; 29 | --sl-color-black: #ffffff; 30 | } 31 | -------------------------------------------------------------------------------- /docs/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "node:fs"; 2 | import { dirname, resolve } from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | 5 | const thisFile = fileURLToPath(import.meta.url); 6 | const thisDir = dirname(thisFile); 7 | export const rootDir = resolve(thisDir, "../"); 8 | export const packageDir = resolve(rootDir, "../package"); 9 | export const packageJSON = resolve(packageDir, "package.json"); 10 | export const packageJSONJSON = JSON.parse(readFileSync(packageJSON, "utf-8") || "{}"); 11 | export const packageVersion = packageJSONJSON.version; 12 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "packageManager": "pnpm@9.7.0", 5 | "engines": { 6 | "node": ">=18.19.0" 7 | }, 8 | "scripts": { 9 | "update:all": "pnpm recursive update --include-workspace-root", 10 | "test": "node --test && pnpm --filter \"./tests/e2e/**\" test", 11 | "test:e2e": "pnpm --filter \"./tests/e2e/**\" test", 12 | "test:unit": "node --test", 13 | "package:dev": "pnpm --filter astro-theme-provider dev", 14 | "package:build": "pnpm --filter astro-theme-provider build", 15 | "playground:dev": "pnpm --filter playground dev", 16 | "playground:build": "pnpm --filter playground build", 17 | "docs:dev": "pnpm --filter docs dev", 18 | "docs:build": "pnpm --filter docs build", 19 | "changeset": "changeset", 20 | "lint": "biome check .", 21 | "lint:fix": "biome check --apply ." 22 | }, 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@biomejs/biome": "^1.9.4", 26 | "@changesets/cli": "^2.28.1", 27 | "@playwright/test": "^1.51.0", 28 | "astro": "^5.4.3" 29 | } 30 | } -------------------------------------------------------------------------------- /package/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /test-results/ 3 | /playwright-report/ 4 | /blob-report/ 5 | /playwright/.cache/ 6 | -------------------------------------------------------------------------------- /package/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # astro-theme-provider 2 | 3 | ## 0.7.1 4 | 5 | ### Patch Changes 6 | 7 | - 3b70666: Upgrade dependencies 8 | - df50737: Migrate away from the AIK utility `addDts` to the native utility `injectTypes` 9 | - aa826a7: Only emit package warnings on the first start 10 | 11 | ## 0.7.0 12 | 13 | ### Minor Changes 14 | 15 | - e169ab8: Re-release 0.6.2 as a minor due to compatibility issues with Astro 4.0 16 | 17 | ## 0.6.2 18 | 19 | ### Patch Changes 20 | 21 | - 52e2a61: Upgrade to Astro 5.0 22 | 23 | - Fix automatic resolution of theme entrypoint 24 | 25 | ## 0.6.1 26 | 27 | ### Patch Changes 28 | 29 | - cb2bbd3: Fix error for theme's that do not have a `public` directory 30 | 31 | ## 0.6.0 32 | 33 | ### Minor Changes 34 | 35 | - 0d49101: Fixed circular import case when overriding components, for example: 36 | 37 | ```tsx 38 | // src/CustomButton.astro 39 | --- 40 | import { Button } from 'my-theme:components'; 41 | --- 42 | 43 | 46 | ``` 47 | 48 | ```tsx 49 | import { defineConfig } from "astro/config"; 50 | import myTheme from "my-theme"; 51 | 52 | export default defineConfig({ 53 | integrations: [ 54 | myTheme({ 55 | overrides: { 56 | components: { 57 | Button: "./src/CustomButton.astro", 58 | }, 59 | }, 60 | }), 61 | ], 62 | }); 63 | ``` 64 | 65 | - 0d49101: Simplified generated types and removed extra types that are not being used internally. 66 | - 8ecb96d: Change type for theme integrations from `AstroDbIntegration` to `AstroIntegration` 67 | - f604446: Added the ability for theme authors to toggle the public directory off: 68 | 69 | ```ts 70 | defineTheme({ 71 | name: "my-theme", 72 | publicDir: false, 73 | }); 74 | ``` 75 | 76 | ## 0.5.0 77 | 78 | ### Minor Changes 79 | 80 | - 5188e12: Added a user facing API for disabling integrations injected by a theme 81 | 82 | ```ts 83 | import { defineConfig } from "astro/config"; 84 | import myTheme from "my-theme"; 85 | 86 | export default defineConfig({ 87 | integrations: [ 88 | myTheme({ 89 | integrations: { 90 | "@astrojs/sitemap": false, 91 | }, 92 | }), 93 | ], 94 | }); 95 | ``` 96 | 97 | - 5188e12: Updated the type of the user config to `z.input` instead of `z.infer` for proper typing 98 | - cfcdca1: Added a utility to query the final path of a page: 99 | 100 | ```astro 101 | --- 102 | import { pages } from 'my-theme:context' 103 | --- 104 | 105 | { pages.has('/blog') && 106 | Blog 107 | } 108 | ``` 109 | 110 | - 5188e12: Added a built in virtual module for theme utilities `:context`. 111 | 112 | This name is now reserved, authors can no longer create custom virtual modules with this name, example: 113 | 114 | ```diff 115 | defineTheme({ 116 | imports: { 117 | - context: { 118 | + options: { 119 | // ... 120 | } 121 | } 122 | }) 123 | ``` 124 | 125 | - 5188e12: Added a utility to query what integrations are inside the project: 126 | 127 | ```astro 128 | --- 129 | import { integrations } from 'my-theme:context' 130 | 131 | if (integrations.has('@inox-tools/sitemap-ext')) { 132 | import('sitemap-ext:config').then((sitemap) => { 133 | sitemap.default(true) 134 | }) 135 | } 136 | --- 137 | ``` 138 | 139 | ## 0.4.0 140 | 141 | ### Minor Changes 142 | 143 | - b1947f8: Change virtual module separator from `/` to `:`. 144 | 145 | ```diff 146 | - import "my-theme/styles" 147 | + import "my-theme:styles" 148 | ``` 149 | 150 | - b1947f8: Renamed `/css` directory to `/styles`. Change imports as: 151 | 152 | ```diff 153 | - import "my-theme:css" 154 | + import "my-theme:styles" 155 | ``` 156 | 157 | ## 0.3.0 158 | 159 | ### Minor Changes 160 | 161 | - 0388fd8: Add support for adding integrations to a theme 162 | 163 | ```ts 164 | integrations: [ 165 | // Add integrations 166 | inoxsitemap(), 167 | // Check for other integrations 168 | ({ integrations }) => { 169 | if (!integrations.contains("@astrojs/sitemap")) { 170 | return inoxsitemap(); 171 | } 172 | }, 173 | // Pass user options to integrations 174 | ({ config }) => { 175 | if (config.sitemap) { 176 | return inoxsitemap(config.sitemap); 177 | } 178 | }, 179 | ]; 180 | ``` 181 | 182 | - f66b214: Add support for adding middleware to a theme 183 | 184 | ``` 185 | package/ 186 | ├── src/ 187 | │ ├── middleware.ts // Support middleware like Astro, defaults to 'pre' 188 | │ └── middleware/ 189 | │ ├── index.ts // Same as `src/middleware.ts` 190 | │ ├── pre.ts // Middleware with order 'pre' 191 | │ └── post.ts // Middleware with order 'post' 192 | └── index.ts 193 | ``` 194 | 195 | ```ts 196 | defineTheme({ 197 | name: "my-theme", 198 | middlewareDir: false, // Disable middleware injection 199 | }); 200 | ``` 201 | 202 | ## 0.2.0 203 | 204 | ### Minor Changes 205 | 206 | - 1a40dfd: Fixed typing for theme integrations, `name` property is now required again 207 | - 9655a45: Added a `log` option for theme authors 208 | 209 | - `false`: No logging 210 | - `"minimal" | true`: Default logging, includes warnings 211 | - `"verbose"`: Log everything, including debug information like page injection and static asset handling 212 | 213 | Fixed warnings for a missing README throwing errors if README did not exist 214 | 215 | - ca1f3b3: Updated root directory for glob modules, glob patterns are now relative to a theme's `srcDir` 216 | 217 | ```diff 218 | imports: { 219 | - css: '**.css' 220 | + css: 'css/**.css' 221 | } 222 | ``` 223 | 224 | - 12b5819: Moved the default location of the public dir to the root of a theme 225 | 226 | ```diff 227 | package/ 228 | + ├── public 229 | ├── src/ 230 | - │ ├── public 231 | │ └── ... 232 | └── ... 233 | ``` 234 | 235 | ### Patch Changes 236 | 237 | - b503fed: Upgrade to `astro-integration-kit` 0.11.0, package HMR is now only applied inside the playground 238 | 239 | ## 0.1.2 240 | 241 | ### Patch Changes 242 | 243 | - b189ddf: Fixed package names having priority over manually defined names for the theme name 244 | - b189ddf: Theme configs are now optional 245 | - b189ddf: Upgrade dep 246 | 247 | ## 0.1.1 248 | 249 | ### Patch Changes 250 | 251 | - 54bfa24: Add support for `@astrojs/db` (Astro Studio) 252 | - 1bdf366: 253 | - Update author option `modules` to `imports` 254 | - Added support for `default` exports for virtual modules 255 | - 9227ddf: 256 | - Add a `/src` directory for themes for better organization 257 | - Update author options: 258 | - Added `srcDir` option 259 | - Updated `pages` to `pageDir` 260 | - Updated `public` to `publicDir` 261 | - `schema` is not required and is no longer limited to just objects 262 | 263 | ## 0.1.0 264 | 265 | ### Major refactor 266 | 267 | - Migrated to Astro Integration Kit utilities (`watchIntegration`, `addVirtualImports`, `addDts`) 268 | - 38e6289: Add support for `/public` folder 269 | - Added ability to dynamically create virtual modules (previously static) 270 | - Added automatic type generation for virtual modules 271 | - Added support for a file-based routing directory `/pages` to replace the `entrypoint` option 272 | - Infer theme name from package name 273 | - Removed `virtual:` prefix for virtual imports 274 | - Removed `context` module 275 | -------------------------------------------------------------------------------- /package/README.md: -------------------------------------------------------------------------------- 1 | # `astro-theme-provider` 2 | 3 | [![npm version](https://img.shields.io/npm/v/astro-theme-provider?labelColor=red&color=grey)](https://www.npmjs.com/package/astro-theme-provider) 4 | ![beta](https://img.shields.io/badge/Beta-orange) 5 | 6 | Author themes for Astro like a normal project and export your work as an integration for others to use 7 | 8 | ### [Documentation](https://astro-theme-provider.netlify.app) 9 | 10 | ### [Theme Template](https://github.com/astrolicious/astro-theme-provider-template) 11 | 12 | 13 | ### Contributing 14 | 15 | - [Contributing Guide](https://github.com/astrolicious/astro-theme-provider/blob/main/CONTRIBUTING.md) 16 | - [Discord Channel](https://chat.astrolicious.dev) 17 | - [Discussions](https://github.com/astrolicious/astro-theme-provider/discussions) 18 | - [Issues](https://github.com/astrolicious/astro-theme-provider/issues) -------------------------------------------------------------------------------- /package/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-theme-provider", 3 | "version": "0.7.1", 4 | "description": "Easily create theme integrations for Astro", 5 | "keywords": [ 6 | "astro", 7 | "withastro", 8 | "astro-integration" 9 | ], 10 | "author": "Bryce Russell", 11 | "license": "Unlicense", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/astrolicious/astro-theme-provider", 15 | "directory": "packages/astro-theme-provider" 16 | }, 17 | "bugs": "https://github.com/astrolicious/astro-theme-provider/issues", 18 | "homepage": "https://astro-theme-provider.netlify.app/", 19 | "scripts": { 20 | "test": "node --test", 21 | "dev": "tsup --watch", 22 | "build": "tsup" 23 | }, 24 | "type": "module", 25 | "exports": { 26 | ".": { 27 | "types": "./dist/index.d.ts", 28 | "default": "./dist/index.js" 29 | } 30 | }, 31 | "files": [ 32 | "dist" 33 | ], 34 | "peerDependencies": { 35 | "@astrojs/db": ">=0.8.0", 36 | "astro": ">=3" 37 | }, 38 | "peerDependenciesMeta": { 39 | "@astrojs/db": { 40 | "optional": true 41 | } 42 | }, 43 | "devDependencies": { 44 | "@astrojs/db": "^0.14.8", 45 | "@types/node": "^22.13.10", 46 | "playwright": "^1.51.0", 47 | "tsup": "^8.4.0", 48 | "typescript": "^5.8.2", 49 | "vite": "^6.2.1" 50 | }, 51 | "dependencies": { 52 | "astro-integration-kit": "^0.18.0", 53 | "astro-pages": "^0.3.1", 54 | "astro-public": "^0.1.1", 55 | "callsites": "^4.2.0", 56 | "fast-glob": "^3.3.3" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /package/src/index.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from "node:fs"; 2 | import { basename, extname, join, resolve } from "node:path"; 3 | import type { AstroIntegration } from "astro"; 4 | import { addIntegration, addVitePlugin, watchDirectory } from "astro-integration-kit"; 5 | import { addPageDir } from "astro-pages"; 6 | import type { IntegrationOption as PageDirIntegrationOption } from "astro-pages"; 7 | import staticDir from "astro-public"; 8 | import { AstroError } from "astro/errors"; 9 | import { z } from "astro/zod"; 10 | import callsites from "callsites"; 11 | import fg from "fast-glob"; 12 | import { GLOB_ASTRO, GLOB_COMPONENTS, GLOB_IGNORE, GLOB_IMAGES, GLOB_STYLES } from "./internal/consts.js"; 13 | import { errorMap } from "./internal/error-map.js"; 14 | import type { AuthorOptions, UserOptions } from "./internal/types.js"; 15 | import { 16 | createVirtualModule, 17 | globToModuleObject, 18 | isEmptyModuleObject, 19 | resolveModuleObject, 20 | toModuleObject, 21 | } from "./utils/modules.ts"; 22 | import { PackageJSON, warnThemePackage } from "./utils/package.js"; 23 | import { 24 | addLeadingSlash, 25 | normalizePath, 26 | removeTrailingSlash, 27 | resolveDirectory, 28 | resolveFilepath, 29 | validatePattern, 30 | } from "./utils/path.js"; 31 | import { createVirtualResolver } from "./utils/resolver.ts"; 32 | 33 | const thisFile = resolveFilepath("./", import.meta.url); 34 | 35 | export default function ( 36 | partialAuthorOptions: AuthorOptions, 37 | ) { 38 | let { 39 | log: logLevel = true, 40 | name: themeName, 41 | schema: configSchema = z.any(), 42 | entrypoint: themeEntrypoint = callsites() 43 | .map((callsite) => (callsite as NodeJS.CallSite).getScriptNameOrSourceURL()) 44 | .find((path) => path && !path.startsWith("file://") && path !== thisFile)!, 45 | srcDir: themeSrc = "src", 46 | pageDir = "pages", 47 | publicDir = "public", 48 | middlewareDir = "./", 49 | imports: themeImports = {}, 50 | integrations: themeIntegrations = [], 51 | } = partialAuthorOptions; 52 | 53 | themeEntrypoint = resolveFilepath("./", themeEntrypoint); 54 | 55 | const themeRoot = resolveDirectory("./", themeEntrypoint); 56 | 57 | const themePackage = new PackageJSON(themeRoot); 58 | 59 | themeSrc = resolveDirectory(themeRoot, themeSrc); 60 | 61 | if (middlewareDir) { 62 | middlewareDir = resolveDirectory(themeSrc, middlewareDir); 63 | } 64 | 65 | if (typeof pageDir === "string") { 66 | pageDir = { dir: pageDir }; 67 | } 68 | 69 | pageDir = { ...pageDir, cwd: themeSrc, log: logLevel }; 70 | 71 | if (publicDir || typeof publicDir === "string") { 72 | if (typeof publicDir === "string") { 73 | publicDir = { dir: publicDir }; 74 | } 75 | publicDir = { ...publicDir, cwd: themeRoot, log: logLevel }; 76 | } 77 | 78 | themeImports = { 79 | assets: `assets/${GLOB_IMAGES}`, 80 | components: `components/${GLOB_COMPONENTS}`, 81 | layouts: `layouts/${GLOB_ASTRO}`, 82 | styles: `styles/${GLOB_STYLES}`, 83 | ...themeImports, 84 | }; 85 | 86 | // Return theme integration 87 | return (userOptions: UserOptions = {}) => { 88 | const { config: userConfigPartial = {}, pages: userPages = {}, overrides: userOverrides = {} } = userOptions; 89 | 90 | // Parse/validate config passed by user, throw formatted error if it is invalid 91 | const { 92 | data: userConfig, 93 | success: parseSuccess, 94 | error: parseError, 95 | } = configSchema.safeParse(userConfigPartial, { errorMap }); 96 | 97 | if (!parseSuccess) { 98 | throw new AstroError( 99 | `Invalid configuration passed to '${themeName}' integration\n`, 100 | parseError.issues.map((issue) => issue.message).join("\n"), 101 | ); 102 | } 103 | 104 | let themeTypesBuffer = ` 105 | type ThemeName = "${themeName}"; 106 | 107 | declare namespace AstroThemeProvider { 108 | export interface Themes { 109 | "${themeName}": true; 110 | } 111 | 112 | export interface ThemeOptions { 113 | "${themeName}": { 114 | pages?: { [Pattern in keyof ThemeRoutes]?: string | boolean } & {} 115 | overrides?: { 116 | [Module in keyof ThemeExports]?: 117 | ThemeExports[Module] extends Record 118 | ? ThemeExports[Module] extends string[] 119 | ? string[] 120 | : { [Export in keyof ThemeExports[Module]]?: string } 121 | : never 122 | } & {} 123 | integrations?: keyof ThemeIntegrationsResolved extends never 124 | ? \`\$\{ThemeName\} is not injecting any integrations\` 125 | : { [Name in keyof ThemeIntegrationsResolved]?: boolean } & {} 126 | }; 127 | } 128 | } 129 | `; 130 | 131 | const themeIntegration: AstroIntegration = { 132 | name: themeName, 133 | hooks: { 134 | // How should this get typed? Return type should be "AstroIntegration" but this requires "AstroDbIntegration" 135 | // @ts-expect-error 136 | "astro:db:setup": ({ extendDb }) => { 137 | const configEntrypoint = resolve(themeRoot, "db/cofig.ts"); 138 | const seedEntrypoint = resolve(themeRoot, "db/seed.ts"); 139 | if (existsSync(configEntrypoint)) extendDb({ configEntrypoint }); 140 | if (existsSync(seedEntrypoint)) extendDb({ seedEntrypoint }); 141 | }, 142 | "astro:config:setup": (params) => { 143 | const { config, logger, isRestart, injectRoute, addMiddleware } = params; 144 | 145 | const projectRoot = resolveDirectory("./", config.root); 146 | 147 | // Record of virtual imports and their content 148 | const virtualImports: Parameters[0]["imports"] = { 149 | [`${themeName}:config`]: `export default ${JSON.stringify(userConfig)}`, 150 | [`${themeName}:context`]: "", 151 | }; 152 | 153 | // Module type buffers 154 | const moduleBuffers: Record = { 155 | [`${themeName}:config`]: ` 156 | const config: NonNullable[0]>["config"]>; 157 | export default config; 158 | `, 159 | [`${themeName}:context`]: "", 160 | }; 161 | 162 | // Interface type buffers 163 | const interfaceBuffers = { 164 | ThemeExports: "", 165 | ThemeRoutes: "", 166 | ThemeIntegrations: "", 167 | ThemeIntegrationsResolved: "", 168 | }; 169 | 170 | if (logLevel && !isRestart) { 171 | // Warn about issues with theme's `package.json` 172 | warnThemePackage(themePackage, logger); 173 | } 174 | 175 | // HMR for theme author's package 176 | watchDirectory(params, themeRoot); 177 | 178 | // Sideload integration to handle the public directory 179 | if (publicDir && existsSync(resolveDirectory(publicDir.cwd!, publicDir.dir, false))) { 180 | addIntegration(params, { integration: staticDir(publicDir) }); 181 | } 182 | 183 | // Integrations inside the config (including the theme) and integrations injected by the theme 184 | const integrationsExisting: Record = Object.fromEntries( 185 | config.integrations.map((i) => [i.name, true]), 186 | ); 187 | // Integrations added by a theme but possibly do not exist because a user disabled it 188 | const integrationsPossible: Record = {}; 189 | // Integrations that are injected into a theme 190 | const integrationsInjected: Record = {}; 191 | // Integrations ignored/disabled by a user 192 | const integrationsIgnored: Record = {}; 193 | 194 | for (const option of themeIntegrations) { 195 | let integration: ReturnType any>>; 196 | 197 | // Handle integration options that might be a callback for conditonal injection 198 | if (typeof option === "function") { 199 | integration = option({ config: userConfig, integrations: Object.keys(integrationsExisting) }); 200 | } else { 201 | integration = option; 202 | } 203 | 204 | if (!integration) continue; 205 | 206 | const { name } = integration; 207 | 208 | integrationsPossible[name] = true; 209 | 210 | // Allow users to ignore/disable an integration 211 | if (userOptions.integrations && name in userOptions.integrations && !userOptions.integrations[name]) { 212 | integrationsIgnored[name] = false; 213 | continue; 214 | } 215 | 216 | integrationsInjected[name] = true; 217 | integrationsExisting[name] = true; 218 | 219 | // Add the integration 220 | addIntegration(params, { integration }); 221 | } 222 | 223 | // Virtual module for integration utilities 224 | virtualImports[`${themeName}:context`] += `\nexport const integrations = new Set(${JSON.stringify( 225 | Array.from(Object.keys(integrationsExisting)), 226 | )})`; 227 | moduleBuffers[`${themeName}:context`] += `\nexport const integrations: Set`; 228 | 229 | // Type interfaces for theme integrations, used to build other types like the user config 230 | interfaceBuffers.ThemeIntegrations = `${JSON.stringify(integrationsPossible, null, 4).slice(1, -1)}` || "\n"; 231 | interfaceBuffers.ThemeIntegrationsResolved = 232 | `${JSON.stringify({ ...integrationsInjected, ...integrationsIgnored }, null, 4).slice(1, -1)}` || "\n"; 233 | 234 | // Add middleware 235 | if (middlewareDir) { 236 | const middlewareGlob = ["middleware.{ts,js}", "middleware/*{ts,js}", GLOB_IGNORE].flat(); 237 | const middlewareEntrypoints = fg.globSync(middlewareGlob, { cwd: middlewareDir, absolute: true }); 238 | 239 | for (const entrypoint of middlewareEntrypoints) { 240 | const name = basename(entrypoint).slice(0, -extname(entrypoint).length); 241 | if (["middleware", "index", "pre"].includes(name)) { 242 | addMiddleware({ entrypoint, order: "pre" }); 243 | } 244 | if (name === "post") { 245 | addMiddleware({ entrypoint, order: "post" }); 246 | } 247 | } 248 | } 249 | 250 | // Reserved names for built-in virtual modules 251 | const reservedNames = new Set(["config", "context", "content", "collections", "db"]); 252 | 253 | // Dynamically create virtual modules using globs, imports, or exports 254 | for (let [name, option] of Object.entries(themeImports)) { 255 | if (!option) continue; 256 | 257 | // Reserved module/import names 258 | if (reservedNames.has(name)) { 259 | logger.warn(`Module name '${name}' is reserved for the built in virtual import '${themeName}:${name}'`); 260 | continue; 261 | } 262 | 263 | // Turn a glob string into a module object 264 | if (typeof option === "string") { 265 | option = globToModuleObject(themeSrc, option); 266 | } 267 | 268 | const moduleName = normalizePath(join(themeName, name)).replace(/\//, ":"); 269 | 270 | const resolvedModuleObject = resolveModuleObject(themeRoot, toModuleObject(option)); 271 | 272 | const virtualModule = createVirtualModule(moduleName, resolvedModuleObject); 273 | 274 | const orignalContent = virtualModule.content(); 275 | 276 | virtualImports[moduleName] = orignalContent; 277 | 278 | const virtualModuleOverride = createVirtualModule(moduleName, resolvedModuleObject); 279 | 280 | const resolvedModuleOverride = 281 | name in userOverrides ? resolveModuleObject(projectRoot, toModuleObject(userOverrides[name]!)) : null; 282 | 283 | const isEmptyOverride = resolvedModuleOverride ? isEmptyModuleObject(resolvedModuleOverride) : true; 284 | 285 | if (!isEmptyOverride) { 286 | virtualModuleOverride.merge(resolvedModuleOverride!); 287 | } 288 | 289 | const overrideContent = virtualModuleOverride.content(); 290 | 291 | if (!isEmptyOverride) { 292 | const overrideExports = new Set(Object.values(virtualModuleOverride.exports)); 293 | if (overrideExports.size > 0) { 294 | virtualImports[moduleName] = ({ importer }) => { 295 | if (importer && overrideExports.has(importer)) { 296 | return orignalContent; 297 | } 298 | return overrideContent; 299 | }; 300 | } else { 301 | virtualImports[moduleName] = overrideContent; 302 | } 303 | } 304 | 305 | moduleBuffers[moduleName] = virtualModule.types.module(); 306 | 307 | const interfaceTypes = virtualModule.types.interface(); 308 | 309 | interfaceBuffers.ThemeExports += ` 310 | "${name}": ${interfaceTypes ? `{\n${interfaceTypes}\n}` : JSON.stringify(virtualModule.imports)}, 311 | `; 312 | } 313 | 314 | const pageDirOption: PageDirIntegrationOption = { ...pageDir, config, logger }; 315 | 316 | // Initialize route injection 317 | const { pages: pagesInjected, injectPages } = addPageDir(pageDirOption); 318 | 319 | const pagesResolved: Record = Object.fromEntries( 320 | Object.keys(pagesInjected).map((pattern) => [pattern, pattern]), 321 | ); 322 | 323 | // Generate types for possibly injected routes 324 | interfaceBuffers.ThemeRoutes += Object.keys(pagesInjected) 325 | .map((pattern) => `\n"${pattern}": true`) 326 | .join(""); 327 | 328 | // Filter out routes the theme user toggled off 329 | for (const oldPattern of Object.keys(userPages)) { 330 | // Skip pages that are not defined by author 331 | if (!pagesInjected?.[oldPattern!]) continue; 332 | 333 | let newPattern = userPages[oldPattern as keyof typeof userPages]; 334 | 335 | // If user passes falsy value remove the route 336 | if (!newPattern) { 337 | pagesResolved[oldPattern] = false; 338 | delete pagesInjected[oldPattern]; 339 | continue; 340 | } 341 | 342 | // If user defines a string, override route pattern 343 | if (typeof newPattern === "string") { 344 | newPattern = addLeadingSlash(removeTrailingSlash(newPattern)); 345 | if (!validatePattern(newPattern, oldPattern)) { 346 | throw new AstroError( 347 | "Invalid page override, pattern must contain the same params in the same location", 348 | `New: ${newPattern}\nOld: ${oldPattern}`, 349 | ); 350 | } 351 | // Add new pattern 352 | pagesInjected[newPattern] = pagesInjected[oldPattern]!; 353 | pagesResolved[oldPattern] = newPattern; 354 | // Remove old pattern 355 | delete pagesInjected[oldPattern]; 356 | } 357 | } 358 | 359 | // Virtual module for integration utilities 360 | virtualImports[`${themeName}:context`] += `\nexport const pages = new Map(Object.entries(${JSON.stringify( 361 | pagesResolved, 362 | )}))`; 363 | moduleBuffers[`${themeName}:context`] += `\nexport const pages: Map<${Object.keys(pagesResolved) 364 | .map((p) => `"${p}"`) 365 | .join(" | ")}, string | false>`; 366 | 367 | // Inject routes/pages 368 | injectPages(injectRoute); 369 | 370 | // Add virtual modules 371 | addVitePlugin(params, { 372 | plugin: createVirtualResolver({ 373 | name: themeName, 374 | imports: virtualImports, 375 | }), 376 | }); 377 | 378 | // Add interfaces to global type buffer 379 | for (const [name, buffer] of Object.entries(interfaceBuffers)) { 380 | if (!buffer) continue; 381 | themeTypesBuffer += ` 382 | interface ${name} { 383 | ${buffer} 384 | } 385 | `; 386 | } 387 | 388 | // Add modules to global type buffer 389 | for (const [name, buffer] of Object.entries(moduleBuffers)) { 390 | if (!buffer) continue; 391 | themeTypesBuffer += ` 392 | declare module "${name}" { 393 | ${buffer} 394 | } 395 | `; 396 | } 397 | }, 398 | "astro:config:done": ({ injectTypes }) => { 399 | injectTypes({ 400 | filename: "theme.d.ts", 401 | content: themeTypesBuffer, 402 | }); 403 | }, 404 | }, 405 | }; 406 | 407 | return themeIntegration; 408 | }; 409 | } 410 | -------------------------------------------------------------------------------- /package/src/internal/consts.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/withastro/astro/blob/main/packages/astro/src/assets/consts.ts#L17 2 | export const IMAGE_FORMATS = ["jpeg", "jpg", "png", "tiff", "webp", "gif", "svg", "avif"]; 3 | 4 | // https://github.com/withastro/astro/blob/main/packages/astro/src/core/constants.ts#L5 5 | export const MARKDOWN_FORMATS = ["markdown", "mdown", "mdwn", "mdoc", "mkdn", "mdx", "mkd", "md"]; 6 | 7 | export const CSS_FORMATS = ["css", "scss", "sass", "styl", "less"]; 8 | 9 | export const DATA_FORMATS = ["json", "yaml"]; 10 | 11 | export const API_FORMATS = ["ts", "js"]; 12 | 13 | export const UI_FRAMEWORK_FORMATS = ["tsx", "jsx", "svelte", "vue", "lit"]; 14 | 15 | export const GLOB_STYLES = `**.{${CSS_FORMATS.join(",")}}`; 16 | export const GLOB_API = `**.{${API_FORMATS.join(",")}}`; 17 | export const GLOB_DATA = `**.{${DATA_FORMATS.join(",")}}`; 18 | export const GLOB_ASTRO = "**.astro"; 19 | export const GLOB_IMAGES = `**.{${IMAGE_FORMATS.join(",")}}`; 20 | export const GLOB_MARKDOWN = `**.{${MARKDOWN_FORMATS.join(",")}}`; 21 | export const GLOB_UI_FRAMEWORK = `**.{${UI_FRAMEWORK_FORMATS.join(",")}}`; 22 | export const GLOB_COMPONENTS = `**.{astro,${UI_FRAMEWORK_FORMATS.join(",")}}`; 23 | export const GLOB_PAGES = `**.{astro,${API_FORMATS.join(",")}}`; 24 | 25 | export const GLOB_IGNORE = ["!**/node_modules"]; 26 | -------------------------------------------------------------------------------- /package/src/internal/error-map.ts: -------------------------------------------------------------------------------- 1 | import type { ZodErrorMap } from "astro/zod"; 2 | 3 | /** 4 | * This is a modified version of Astro's error map. source: 5 | * https://github.com/withastro/astro/blob/main/packages/astro/src/content/error-map.ts 6 | */ 7 | 8 | type TypeOrLiteralErrByPathEntry = { 9 | code: "invalid_type" | "invalid_literal"; 10 | received: unknown; 11 | expected: unknown[]; 12 | }; 13 | 14 | export const errorMap: ZodErrorMap = (baseError, ctx) => { 15 | const baseErrorPath = flattenErrorPath(baseError.path); 16 | if (baseError.code === "invalid_union") { 17 | // Optimization: Combine type and literal errors for keys that are common across ALL union types 18 | // Ex. a union between `{ key: z.literal('tutorial') }` and `{ key: z.literal('blog') }` will 19 | // raise a single error when `key` does not match: 20 | // > Did not match union. 21 | // > key: Expected `'tutorial' | 'blog'`, received 'foo' 22 | const typeOrLiteralErrByPath = new Map(); 23 | for (const unionError of baseError.unionErrors.flatMap((e) => e.errors)) { 24 | if (unionError.code === "invalid_type" || unionError.code === "invalid_literal") { 25 | const flattenedErrorPath = flattenErrorPath(unionError.path); 26 | if (typeOrLiteralErrByPath.has(flattenedErrorPath)) { 27 | typeOrLiteralErrByPath.get(flattenedErrorPath)?.expected.push(unionError.expected); 28 | } else { 29 | typeOrLiteralErrByPath.set(flattenedErrorPath, { 30 | code: unionError.code, 31 | received: (unionError as any).received, 32 | expected: [unionError.expected], 33 | }); 34 | } 35 | } 36 | } 37 | const messages: string[] = [ 38 | prefix(baseErrorPath, typeOrLiteralErrByPath.size ? "Did not match union:" : "Did not match union."), 39 | ]; 40 | return { 41 | message: messages 42 | .concat( 43 | [...typeOrLiteralErrByPath.entries()] 44 | // If type or literal error isn't common to ALL union types, 45 | // filter it out. Can lead to confusing noise. 46 | .filter(([, error]) => error.expected.length === baseError.unionErrors.length) 47 | .map(([key, error]) => 48 | key === baseErrorPath 49 | ? // Avoid printing the key again if it's a base error 50 | `> ${getTypeOrLiteralMsg(error)}` 51 | : `> ${prefix(key, getTypeOrLiteralMsg(error))}`, 52 | ), 53 | ) 54 | .join("\n"), 55 | }; 56 | } 57 | if (baseError.code === "invalid_literal" || baseError.code === "invalid_type") { 58 | return { 59 | message: prefix( 60 | baseErrorPath, 61 | getTypeOrLiteralMsg({ 62 | code: baseError.code, 63 | received: (baseError as any).received, 64 | expected: [baseError.expected], 65 | }), 66 | ), 67 | }; 68 | } 69 | if (baseError.message) { 70 | return { message: prefix(baseErrorPath, baseError.message) }; 71 | } 72 | return { message: prefix(baseErrorPath, ctx.defaultError) }; 73 | }; 74 | 75 | const getTypeOrLiteralMsg = (error: TypeOrLiteralErrByPathEntry): string => { 76 | if (error.received === "undefined") return "Required"; 77 | const expectedDeduped = new Set(error.expected); 78 | switch (error.code) { 79 | case "invalid_type": 80 | return `Expected type \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify(error.received)}`; 81 | case "invalid_literal": 82 | return `Expected \`${unionExpectedVals(expectedDeduped)}\`, received ${JSON.stringify(error.received)}`; 83 | } 84 | }; 85 | 86 | const prefix = (key: string, msg: string) => (key.length ? `**${key}**: ${msg}` : msg); 87 | 88 | const unionExpectedVals = (expectedVals: Set) => 89 | [...expectedVals] 90 | .map((expectedVal, idx) => { 91 | if (idx === 0) return JSON.stringify(expectedVal); 92 | const sep = " | "; 93 | return `${sep}${JSON.stringify(expectedVal)}`; 94 | }) 95 | .join(""); 96 | 97 | const flattenErrorPath = (errorPath: (string | number)[]) => errorPath.join("."); 98 | -------------------------------------------------------------------------------- /package/src/internal/types.ts: -------------------------------------------------------------------------------- 1 | import type { AstroIntegration } from "astro"; 2 | import type { Option as PageDirOption } from "astro-pages"; 3 | import type { Option as StaticDirOption } from "astro-public/types"; 4 | import type { z } from "astro/zod"; 5 | import type { ModuleExports, ModuleImports, ModuleObject } from "../utils/modules.ts"; 6 | 7 | export type ValueOrArray = T | ValueOrArray[]; 8 | 9 | export type NestedStringArray = ValueOrArray; 10 | 11 | export type Prettify = { [K in keyof T]: T[K] } & {}; 12 | 13 | export interface PackageJSONOptions { 14 | private?: boolean; 15 | name?: string; 16 | description?: string; 17 | keywords?: string[]; 18 | homepage?: string; 19 | repository?: 20 | | string 21 | | { 22 | type: string; 23 | url: string; 24 | directory?: string; 25 | }; 26 | } 27 | 28 | export type AuthorOptions = Prettify<{ 29 | name: ThemeName; 30 | entrypoint?: string; 31 | srcDir?: string; 32 | pageDir?: PageDirOption | string; 33 | publicDir?: StaticDirOption | string | false | null | undefined; 34 | middlewareDir?: string | false | null | undefined; 35 | log?: "verbose" | "minimal" | boolean; 36 | schema?: Schema; 37 | imports?: Record; 38 | integrations?: Array< 39 | | AstroIntegration 40 | | ((options: { config: z.infer; integrations: string[] }) => 41 | | AstroIntegration 42 | | false 43 | | null 44 | | undefined 45 | | void) 46 | >; 47 | }>; 48 | 49 | export type UserOptions = { 50 | config?: z.input; 51 | } & AstroThemeProvider.ThemeOptions[ThemeName]; 52 | 53 | declare global { 54 | namespace AstroThemeProvider { 55 | export interface ThemeOptions 56 | extends Record< 57 | string, 58 | { 59 | pages?: Record; 60 | overrides?: Record>; 61 | integrations?: Record; 62 | } 63 | > {} 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /package/src/utils/modules.ts: -------------------------------------------------------------------------------- 1 | import { basename, extname, resolve } from "node:path"; 2 | import fg from "fast-glob"; 3 | import { GLOB_IGNORE } from "../internal/consts.js"; 4 | import { mergeOptions } from "./options.js"; 5 | import { isCSSFile, isImageFile, normalizePath, resolveDirectory } from "./path.js"; 6 | 7 | const RESOLVED = Symbol("resolved"); 8 | 9 | export type ImportOption = string | false | null | undefined; 10 | 11 | export type ModuleImports = (ImportOption | ModuleImports)[]; 12 | 13 | export type ResolvedModuleImports = string[]; 14 | 15 | export type ModuleExports = { 16 | imports?: ModuleImports; 17 | default?: ImportOption; 18 | } & { [name: string]: ImportOption }; 19 | 20 | export interface ResolvedModuleExports { 21 | [id: string]: string; 22 | } 23 | 24 | export interface ModuleObject { 25 | imports?: ModuleImports; 26 | exports?: ModuleExports; 27 | } 28 | 29 | export interface ResolvedModuleObject extends ModuleObject { 30 | root: string; 31 | imports: ResolvedModuleImports; 32 | exports: ResolvedModuleExports; 33 | [RESOLVED]: true; 34 | } 35 | 36 | export interface VirtualModule extends ResolvedModuleObject { 37 | name: string; 38 | merge: (source: ResolvedModuleObject | VirtualModule) => void; 39 | content: (content?: string) => string; 40 | types: { 41 | module: () => string; 42 | interface: () => string; 43 | }; 44 | } 45 | 46 | export function camelCase(str: string) { 47 | return str.replace(/(-|<|>|:|"|\/|\\|\||\?|\*|\s)./g, (x) => x[1]!.toUpperCase()); 48 | } 49 | 50 | export function resolveId(root: string, id: string) { 51 | return normalizePath(id.startsWith(".") ? resolve(root || "./", id) : id); 52 | } 53 | 54 | export function isSideEffectImport(path: string) { 55 | return isCSSFile(path); 56 | } 57 | 58 | export function isEmptyModuleObject({ 59 | imports = [], 60 | exports = {}, 61 | }: ModuleObject | ResolvedModuleObject | VirtualModule): boolean { 62 | return !imports.length && !Object.keys(exports).length; 63 | } 64 | 65 | export function mergeModuleObjects( 66 | target: T, 67 | { imports = [], exports = {} }: S, 68 | ): T { 69 | return mergeOptions(target, { imports, exports }) as T; 70 | } 71 | 72 | export function toModuleObject(option: ModuleImports | ModuleExports | ModuleObject): ModuleObject { 73 | if (Array.isArray(option)) { 74 | option = { imports: option, exports: {} }; 75 | } 76 | 77 | const { imports = [], exports = option as ModuleExports } = option as ModuleObject; 78 | 79 | delete exports.imports; 80 | 81 | return { imports, exports }; 82 | } 83 | 84 | export function resolveImportArray(root: string, imports: ModuleImports, store?: Set): ResolvedModuleImports { 85 | store ||= new Set(); 86 | 87 | for (const i of imports) { 88 | if (!i) continue; 89 | if (Array.isArray(i)) { 90 | resolveImportArray(root, i, store); 91 | continue; 92 | } 93 | store.add(resolveId(root, i)); 94 | } 95 | 96 | return Array.from(store); 97 | } 98 | 99 | export function resolveExportObject(root: string, exports: ModuleExports): ResolvedModuleExports { 100 | const resolved: ResolvedModuleExports = {}; 101 | for (const name in exports) { 102 | const id = exports[name]; 103 | if (!id) continue; 104 | resolved[camelCase(name)] = resolveId(root, id); 105 | } 106 | return resolved; 107 | } 108 | 109 | export function resolveModuleObject( 110 | root: string, 111 | module: ModuleObject | ResolvedModuleObject | VirtualModule, 112 | ): ResolvedModuleObject { 113 | if (RESOLVED in module) return module; 114 | const { imports = [], exports = {} } = module; 115 | const resolvedRoot = normalizePath(resolveDirectory("./", root, false)); 116 | return { 117 | root: resolvedRoot, 118 | imports: resolveImportArray(resolvedRoot, imports), 119 | exports: resolveExportObject(resolvedRoot, exports), 120 | [RESOLVED]: true, 121 | }; 122 | } 123 | 124 | export function globToModuleObject(root: string, glob: string | string[]): ResolvedModuleObject { 125 | const files = fg.sync([glob, GLOB_IGNORE].flat(), { cwd: root, absolute: true }); 126 | 127 | const imports: ResolvedModuleImports = []; 128 | const exports: ResolvedModuleExports = {}; 129 | 130 | for (const file of files.reverse()) { 131 | if (isSideEffectImport(file)) { 132 | imports.push(file); 133 | continue; 134 | } 135 | const name = basename(file).slice(0, -extname(file).length); 136 | exports[name] = file; 137 | } 138 | 139 | return { 140 | root, 141 | imports, 142 | exports, 143 | [RESOLVED]: true, 144 | }; 145 | } 146 | 147 | export function createVirtualModule(name: string, module: ResolvedModuleObject): VirtualModule { 148 | const virtual: VirtualModule = { 149 | name, 150 | ...module, 151 | merge: (source) => mergeModuleObjects(virtual, source), 152 | content: (content) => generateModuleContent(virtual, content), 153 | types: { 154 | module: () => generateModuleTypes(virtual), 155 | interface: () => generateInterfaceTypes(virtual), 156 | }, 157 | }; 158 | return virtual; 159 | } 160 | 161 | export function generateModuleContent({ imports, exports }: ResolvedModuleObject | VirtualModule, content = "") { 162 | return `${generateModuleImportContent(imports)}\n${content}\n${generateModuleExportContent(exports)}`; 163 | } 164 | 165 | export function generateModuleImportContent(imports: ResolvedModuleImports) { 166 | let buffer = ""; 167 | 168 | for (const path of imports) { 169 | buffer += `\nimport ${JSON.stringify(path)};`; 170 | } 171 | 172 | return buffer; 173 | } 174 | 175 | export function generateModuleExportContent(exports: ResolvedModuleExports) { 176 | let buffer = ""; 177 | 178 | for (const [name, path] of Object.entries(exports || {})) { 179 | if (!path || isSideEffectImport(path)) continue; 180 | 181 | if (name === "default") { 182 | buffer += `export default ${JSON.stringify(path)}`; 183 | continue; 184 | } 185 | 186 | buffer += `\nexport { default as ${name} } from ${JSON.stringify(path)};`; 187 | } 188 | 189 | return buffer; 190 | } 191 | 192 | export function generateInterfaceTypes(option: Parameters[0]) { 193 | return generateTypesFromModule(option, ({ name, type }) => `\n${name}: ${type};`); 194 | } 195 | 196 | export function generateModuleTypes(option: Parameters[0]) { 197 | return generateTypesFromModule(option, ({ name, type }) => 198 | name === "default" ? `\nconst _default: ${type};\nexport default _default;` : `\nexport const ${name}: ${type};`, 199 | ); 200 | } 201 | 202 | export function generateTypesFromModule( 203 | module: ResolvedModuleObject | VirtualModule, 204 | generate: ({ name, path, type }: { name: string; path: string; type: string }) => string, 205 | ) { 206 | let buffer = ""; 207 | 208 | const { exports = {} } = module; 209 | 210 | for (const [name, path] of Object.entries(exports)) { 211 | if (!path || isSideEffectImport(path)) continue; 212 | 213 | let type; 214 | 215 | if (isImageFile(path)) { 216 | type = `import("astro").ImageMetadata`; 217 | } else { 218 | type = `typeof import(${JSON.stringify(path)}).default`; 219 | } 220 | 221 | const line = generate({ name, path, type }); 222 | 223 | buffer += line; 224 | } 225 | 226 | return buffer; 227 | } 228 | -------------------------------------------------------------------------------- /package/src/utils/options.ts: -------------------------------------------------------------------------------- 1 | export function mergeOptions(target: Record, source: Record) { 2 | for (const key in source) { 3 | const value = source[key]; 4 | 5 | if (typeof value === "object" && value !== null) { 6 | if (Array.isArray(value) && Array.isArray(target[key])) { 7 | // Combine array values 8 | target[key].push(...value); 9 | continue; 10 | } 11 | 12 | if ( 13 | typeof target[key] === "object" && 14 | target[key] !== null && 15 | // Skip zod schemas 16 | !("_def" in value) 17 | ) { 18 | // Combine object values 19 | target[key] = mergeOptions(target[key], value); 20 | continue; 21 | } 22 | } 23 | 24 | // Overwrite all other values 25 | target[key] = value; 26 | } 27 | 28 | return target; 29 | } 30 | -------------------------------------------------------------------------------- /package/src/utils/package.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from "node:fs"; 2 | import type { HookParameters } from "astro"; 3 | import { AstroError } from "astro/errors"; 4 | import type { PackageJSONOptions } from "../internal/types.js"; 5 | import { resolveFilepath } from "./path.js"; 6 | 7 | export class PackageJSON { 8 | path: string; 9 | json!: PackageJSONOptions; 10 | 11 | constructor(path: string) { 12 | if (path.endsWith("package.json")) { 13 | this.path = resolveFilepath("./", path); 14 | } else { 15 | this.path = resolveFilepath(path, "package.json"); 16 | } 17 | this.read(); 18 | } 19 | 20 | read() { 21 | try { 22 | this.json = JSON.parse(readFileSync(this.path, "utf-8")); 23 | } catch (error) { 24 | throw new AstroError(`Could not read "package.json"`, this.path); 25 | } 26 | return this; 27 | } 28 | 29 | // write() { 30 | // writeFileSync(this.entrypoint, this.toString(), 'utf-8') 31 | // } 32 | 33 | toString() { 34 | return JSON.stringify(this.json); 35 | } 36 | } 37 | 38 | export function warnThemePackage(pkg: PackageJSON, logger: HookParameters<"astro:config:setup">["logger"]) { 39 | const { name, private: isPrivate, keywords = [], description, homepage, repository } = pkg.json; 40 | 41 | // If package is not private, warn theme author about issues with package 42 | if (!isPrivate) { 43 | let hasIssues = false; 44 | 45 | const warn = (condition: boolean, message: string) => { 46 | if (condition) { 47 | hasIssues = true; 48 | logger.warn(message); 49 | } 50 | }; 51 | 52 | // Warn theme author if `astro-integration` keyword does not exist inside 'package.json' 53 | warn( 54 | !keywords.includes("astro-integration"), 55 | `Add the 'astro-integration' keyword to your theme's 'package.json':\n\n\t"keywords": [ "astro-integration" ],\n\nAstro uses this value to support the command 'astro add ${name}'\n`, 56 | ); 57 | 58 | // Warn theme author if no 'description' property exists inside 'package.json' 59 | warn( 60 | !description, 61 | `Add a 'description' to your theme's 'package.json':\n\n\t"description": "My awesome Astro theme!",\n\nAstro uses this value to populate the integrations page https://astro.build/integrations/\n`, 62 | ); 63 | 64 | // Warn theme author if no 'homepage' property exists inside 'package.json' 65 | warn( 66 | !homepage, 67 | `Add a 'homepage' to your theme's 'package.json':\n\n\t"homepage": "https://github.com/UserName/theme-playground",\n\nAstro uses this value to populate the integrations page https://astro.build/integrations/\n`, 68 | ); 69 | 70 | // Warn theme author if no 'repository' property exists inside 'package.json' 71 | warn( 72 | !repository, 73 | `Add a 'repository' to your theme's 'package.json':\n\n\t"repository": ${JSON.stringify( 74 | { 75 | type: "git", 76 | url: `https://github.com/UserName/${name}`, 77 | directory: "package", 78 | }, 79 | null, 80 | 4, 81 | ).replaceAll( 82 | "\n", 83 | "\n\t", 84 | )}\n\nAstro uses this value to populate the integrations page https://astro.build/integrations/\n`, 85 | ); 86 | 87 | // Warn theme author if package does not have a README 88 | warn( 89 | !existsSync(resolveFilepath(pkg.path, "README.md", false)), 90 | `Add a 'README.md' to the root of your theme's package!\n\nNPM uses this file to populate the package page https://www.npmjs.com/package/${name}\n`, 91 | ); 92 | 93 | if (hasIssues) { 94 | logger.warn( 95 | "Is this a private package?\n\n\t'private': true\n\nSet private as true in your theme's 'package.json' to suppress these warnings\n", 96 | ); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /package/src/utils/path.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from "node:fs"; 2 | import { dirname, extname, isAbsolute, resolve } from "node:path"; 3 | import { fileURLToPath } from "node:url"; 4 | import { AstroError } from "astro/errors"; 5 | import { CSS_FORMATS, IMAGE_FORMATS } from "../internal/consts.js"; 6 | 7 | export function isImageFile(path: string): boolean { 8 | return IMAGE_FORMATS.includes(extname(path).slice(1).toLowerCase()); 9 | } 10 | 11 | export function isCSSFile(path: string): boolean { 12 | return CSS_FORMATS.includes(extname(path).slice(1).toLowerCase()); 13 | } 14 | 15 | export function removeLeadingSlash(path: string) { 16 | return path.replace(/\/+$/, ""); 17 | } 18 | 19 | export function addLeadingSlash(path: string) { 20 | return `/${removeLeadingSlash(path)}`; 21 | } 22 | 23 | export function removeTrailingSlash(path: string) { 24 | return path.replace(/^\/+/, ""); 25 | } 26 | 27 | export function addTailingSlash(path: string) { 28 | return `${removeTrailingSlash(path)}/`; 29 | } 30 | 31 | export function removeLeadingAndTrailingSlashes(path: string) { 32 | return removeTrailingSlash(removeLeadingSlash(path)); 33 | } 34 | 35 | export function addLeadingAndTrailingSlashes(path: string) { 36 | return `/${removeLeadingAndTrailingSlashes(path)}/`; 37 | } 38 | 39 | export function normalizePath(path: string) { 40 | return path.replace(/\\+|\/+/g, "/"); 41 | } 42 | 43 | export function normalizePattern(pattern: string) { 44 | return removeLeadingAndTrailingSlashes(pattern) 45 | .split("/") 46 | .map((slug) => (slug.includes("[") ? slug : "/")) 47 | .join(""); 48 | } 49 | 50 | export function validatePattern(newPattern: string, oldPattern: string) { 51 | return normalizePattern(newPattern) === normalizePattern(oldPattern); 52 | } 53 | 54 | export function resolveDirectory(base: string, path: string | URL, message: boolean | string = true): string { 55 | if (path instanceof URL || path.startsWith("file:/")) { 56 | path = fileURLToPath(path); 57 | } 58 | 59 | if (!isAbsolute(path)) { 60 | path = resolve(resolveDirectory("./", base), path); 61 | } 62 | 63 | if (extname(path)) { 64 | path = dirname(path); 65 | } 66 | 67 | path = normalizePath(path); 68 | 69 | if (message && !existsSync(path)) { 70 | if (message === true) message = "Resolved directory does not exist"; 71 | throw new AstroError(message, path); 72 | } 73 | 74 | return path; 75 | } 76 | 77 | export function resolveFilepath(base: string, path: string | URL, message: string | boolean = true): string { 78 | if (path instanceof URL || path.startsWith("file:/")) { 79 | path = fileURLToPath(path); 80 | } 81 | 82 | if (!isAbsolute(path)) { 83 | path = resolve(resolveDirectory("./", base), path); 84 | } 85 | 86 | if (!extname(path)) { 87 | throw new AstroError("Expected a filepath but recieved a directory", `"${path}"`); 88 | } 89 | 90 | path = normalizePath(path); 91 | 92 | if (message && !existsSync(path)) { 93 | if (message === true) message = "Resolved filepath does not exist"; 94 | throw new AstroError(message, path); 95 | } 96 | 97 | return path; 98 | } 99 | -------------------------------------------------------------------------------- /package/src/utils/resolver.ts: -------------------------------------------------------------------------------- 1 | import { AstroError } from "astro/errors"; 2 | import type { Plugin } from "vite"; 3 | 4 | type MightBeAString = string | false | null | undefined; 5 | 6 | interface Params { 7 | ssr: boolean | undefined; 8 | importer: string | undefined; 9 | } 10 | 11 | const resolveId = (id: string): string => { 12 | return `\0${id}`; 13 | }; 14 | 15 | export function createVirtualResolver({ 16 | name, 17 | imports, 18 | }: { 19 | name: string; 20 | imports: Record MightBeAString) | MightBeAString>; 21 | }): Plugin { 22 | const staticIds = new Map(); 23 | const dynamicIds = new Map MightBeAString>(); 24 | for (const [id, option] of Object.entries(imports)) { 25 | if (id.startsWith("astro:")) { 26 | throw new AstroError(`Cannot create virtual import "${id}", the prefix "astro:" is reserved for Astro core.`); 27 | } 28 | if (typeof option === "string") { 29 | staticIds.set(resolveId(id), option); 30 | } 31 | if (typeof option === "function") { 32 | dynamicIds.set(resolveId(id), option); 33 | } 34 | } 35 | return { 36 | name: `${name}-virtual-resolver`, 37 | resolveId(id, importer) { 38 | if (id in imports === false) return null; 39 | const resolvedId = resolveId(id); 40 | if (staticIds.has(resolvedId)) return resolvedId; 41 | if (dynamicIds.has(resolvedId)) { 42 | const params = new URLSearchParams(); 43 | if (importer) params.set("importer", importer); 44 | return `${resolvedId}?${params.toString()}&x`; 45 | } 46 | return null; 47 | }, 48 | load(id, { ssr } = {}) { 49 | if (!id.startsWith("\0")) return null; 50 | const [resolvedId, rawParams] = id.split("?", 2); 51 | if (!resolvedId) return null; 52 | const staticOption = staticIds.get(resolvedId); 53 | if (staticOption) return staticOption; 54 | const dynamicOption = dynamicIds.get(resolvedId); 55 | if (dynamicOption) { 56 | const params = new URLSearchParams(rawParams); 57 | const importer = params.get("importer") || undefined; 58 | const option = { ssr, importer }; 59 | const value = dynamicOption.call(this, option) || null; 60 | return value; 61 | } 62 | return null; 63 | }, 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /package/tests/mock/package/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/index.ts -------------------------------------------------------------------------------- /package/tests/mock/package/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "theme-mock", 3 | "private": true 4 | } 5 | -------------------------------------------------------------------------------- /package/tests/mock/package/public/favicon.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/public/favicon.svg -------------------------------------------------------------------------------- /package/tests/mock/package/src/assets/levi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/src/assets/levi.png -------------------------------------------------------------------------------- /package/tests/mock/package/src/components/Heading.astro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/src/components/Heading.astro -------------------------------------------------------------------------------- /package/tests/mock/package/src/layouts/Layout.astro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/src/layouts/Layout.astro -------------------------------------------------------------------------------- /package/tests/mock/package/src/middleware.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/src/middleware.ts -------------------------------------------------------------------------------- /package/tests/mock/package/src/middleware/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/src/middleware/index.ts -------------------------------------------------------------------------------- /package/tests/mock/package/src/middleware/middleware.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/src/middleware/middleware.ts -------------------------------------------------------------------------------- /package/tests/mock/package/src/middleware/post.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/src/middleware/post.ts -------------------------------------------------------------------------------- /package/tests/mock/package/src/middleware/pre.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/src/middleware/pre.ts -------------------------------------------------------------------------------- /package/tests/mock/package/src/pages/index.astro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/src/pages/index.astro -------------------------------------------------------------------------------- /package/tests/mock/package/src/styles/global.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/package/tests/mock/package/src/styles/global.css -------------------------------------------------------------------------------- /package/tests/mock/project/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /package/tests/modules.test.js: -------------------------------------------------------------------------------- 1 | import * as assert from "node:assert/strict"; 2 | import { isAbsolute } from "node:path"; 3 | import { describe, it } from "node:test"; 4 | import { resolveModuleObject, toModuleObject } from "../dist/utils/modules.js"; 5 | 6 | describe("toModuleObject()", () => { 7 | const imports = ["./global.css"]; 8 | 9 | const exports = { 10 | Layout: "./Layout.astro", 11 | }; 12 | 13 | it("should convert an import array", () => { 14 | assert.deepEqual(toModuleObject(imports), { imports, exports: {} }); 15 | }); 16 | 17 | it("should convert an export object", () => { 18 | assert.deepEqual(toModuleObject(exports), { imports: [], exports }); 19 | }); 20 | 21 | it("should convert an export object with imports", () => { 22 | assert.deepEqual(toModuleObject({ imports, ...exports }), { imports, exports }); 23 | }); 24 | 25 | it("should not change module object", () => { 26 | const moduleObject = { imports, exports }; 27 | assert.deepEqual(toModuleObject(moduleObject), moduleObject); 28 | }); 29 | }); 30 | 31 | describe("resolveModuleObject()", () => { 32 | it("should have absolute root", () => { 33 | const resolvedModuledObject = resolveModuleObject(import.meta.url, {}); 34 | 35 | assert.equal(isAbsolute(resolvedModuledObject.root), true); 36 | }); 37 | 38 | it("should have absolute imports", () => { 39 | const resolvedModuledObject = resolveModuleObject(import.meta.url, { imports: ["./global.css"] }); 40 | 41 | assert.equal(resolvedModuledObject.imports.every(isAbsolute), true); 42 | }); 43 | 44 | it("should have absolute exports", () => { 45 | const resolvedModuledObject = resolveModuleObject(import.meta.url, { 46 | exports: { 47 | default: "./Layout.astro", 48 | }, 49 | }); 50 | 51 | assert.equal(Object.values(resolvedModuledObject.exports).every(isAbsolute), true); 52 | }); 53 | 54 | it("should not have absolute imports", () => { 55 | const resolvedModuledObject = resolveModuleObject(import.meta.url, { imports: ["package"] }); 56 | 57 | assert.equal(resolvedModuledObject.imports.every(isAbsolute), false); 58 | }); 59 | 60 | it("should not have absolute exports", () => { 61 | const resolvedModuledObject = resolveModuleObject(import.meta.url, { 62 | exports: { 63 | default: "package", 64 | }, 65 | }); 66 | 67 | assert.equal(Object.values(resolvedModuledObject.exports).every(isAbsolute), false); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /package/tests/theme.test.js: -------------------------------------------------------------------------------- 1 | import * as assert from "node:assert/strict"; 2 | import { existsSync, readFileSync } from "node:fs"; 3 | import { dirname, resolve } from "node:path"; 4 | import { afterEach, describe, it, mock } from "node:test"; 5 | import { fileURLToPath, pathToFileURL } from "node:url"; 6 | import { z } from "astro/zod"; 7 | import _defineTheme from "../dist/index.js"; 8 | 9 | const thisFile = fileURLToPath(import.meta.url).toString(); 10 | const mockDir = resolve(dirname(thisFile), "./mock"); 11 | const projectRoot = resolve(mockDir, "project"); 12 | const projectSrc = resolve(projectRoot, "src"); 13 | const packageRoot = resolve(mockDir, "package"); 14 | const packageSrc = resolve(packageRoot, "src"); 15 | const packagePages = resolve(packageSrc, "pages"); 16 | const packageEntrypoint = resolve(packageRoot, "index.ts"); 17 | const packageJSON = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf-8")); 18 | const packageName = packageJSON.name; 19 | const astroIntegration = { name: "astro-integration" }; 20 | const defaultModules = { 21 | [`${packageName}:config`]: {}, 22 | [`${packageName}:assets`]: ["assets/levi.png"], 23 | [`${packageName}:components`]: ["components/Heading.astro"], 24 | [`${packageName}:layouts`]: ["layouts/Layout.astro"], 25 | [`${packageName}:styles`]: ["styles/global.css"], 26 | }; 27 | 28 | const defineTheme = (option) => { 29 | return _defineTheme(Object.assign(option, { name: "theme-mock", entrypoint: packageEntrypoint })); 30 | }; 31 | 32 | const resolveId = (id) => { 33 | return `\0${id}`; 34 | }; 35 | 36 | const normalizePath = (path) => { 37 | return path.replace(/\\+|\/+/g, "/"); 38 | }; 39 | 40 | const astroConfigSetupParamsStub = (params) => ({ 41 | logger: { 42 | info: mock.fn(), 43 | warn: mock.fn(), 44 | error: mock.fn(), 45 | debug: mock.fn(), 46 | }, 47 | addWatchFile: mock.fn(), 48 | command: "dev", 49 | injectRoute: mock.fn(), 50 | updateConfig: mock.fn(), 51 | addMiddleware: mock.fn(), 52 | config: { 53 | root: pathToFileURL(`${projectRoot}/`), 54 | srcDir: pathToFileURL(`${projectSrc}/`), 55 | integrations: [astroIntegration], 56 | }, 57 | ...(params || {}), 58 | }); 59 | 60 | describe("defineTheme", () => { 61 | afterEach(() => { 62 | mock.reset(); 63 | }); 64 | 65 | it("should run", () => { 66 | assert.doesNotThrow(() => defineTheme({})); 67 | }); 68 | 69 | it("should throw if no src", () => { 70 | assert.throws(() => defineTheme({ srcDir: "_" })); 71 | }); 72 | 73 | describe("theme integration", () => { 74 | it("should run", () => { 75 | assert.doesNotThrow(() => defineTheme({})()); 76 | }); 77 | 78 | it("should validate schema", () => { 79 | assert.throws(() => defineTheme({ schema: z.literal(true) })({ config: false })); 80 | }); 81 | 82 | describe("astro:config:setup", () => { 83 | it("should run", () => { 84 | const theme = defineTheme({})(); 85 | const params = astroConfigSetupParamsStub(); 86 | 87 | assert.doesNotThrow(() => theme.hooks["astro:config:setup"]?.(params)); 88 | }); 89 | 90 | it("should inject pages", () => { 91 | const theme = defineTheme({})(); 92 | const params = astroConfigSetupParamsStub(); 93 | const entrypoint = normalizePath(resolve(packagePages, "index.astro")); 94 | 95 | theme.hooks["astro:config:setup"]?.(params); 96 | 97 | assert.deepEqual(params.injectRoute.mock.calls[0].arguments[0], { 98 | entryPoint: entrypoint, 99 | entrypoint, 100 | pattern: "/", 101 | }); 102 | }); 103 | 104 | it("should remove pages", () => { 105 | const theme = defineTheme({})({ pages: { "/": false } }); 106 | const params = astroConfigSetupParamsStub(); 107 | const entrypoint = normalizePath(resolve(packagePages, "index.astro")); 108 | 109 | theme.hooks["astro:config:setup"]?.(params); 110 | 111 | assert.deepEqual(params.injectRoute.mock.calls, []); 112 | }); 113 | 114 | it("should override pages", () => { 115 | const theme = defineTheme({})({ pages: { "/": "/a" } }); 116 | const params = astroConfigSetupParamsStub(); 117 | const entrypoint = normalizePath(resolve(packagePages, "index.astro")); 118 | 119 | theme.hooks["astro:config:setup"]?.(params); 120 | 121 | assert.deepEqual(params.injectRoute.mock.calls[0].arguments[0], { 122 | entryPoint: entrypoint, 123 | entrypoint, 124 | pattern: "/a", 125 | }); 126 | }); 127 | 128 | it("should inject middleware", () => { 129 | const theme = defineTheme({})(); 130 | const params = astroConfigSetupParamsStub(); 131 | 132 | theme.hooks["astro:config:setup"]?.(params); 133 | 134 | const calls = params.addMiddleware.mock.calls; 135 | 136 | assert.equal(calls.length, 5); 137 | 138 | for (const call of calls) { 139 | const name = call.arguments[0].entrypoint.split("/").pop().split(".").shift(); 140 | const order = call.arguments[0].order; 141 | 142 | if (["middleware", "index", "pre"].includes(name)) { 143 | assert.equal(order, "pre"); 144 | } 145 | 146 | if (name === "post") { 147 | assert.equal(order, "post"); 148 | } 149 | } 150 | }); 151 | 152 | it("should inject integrations", () => { 153 | const theme = defineTheme({ integrations: [astroIntegration] })(); 154 | const params = astroConfigSetupParamsStub(); 155 | 156 | theme.hooks["astro:config:setup"]?.(params); 157 | 158 | const call = params.updateConfig.mock.calls.find( 159 | (call) => call.arguments[0]?.integrations?.[0].name === astroIntegration.name, 160 | ); 161 | 162 | assert.equal(call.arguments[0].integrations[0], astroIntegration); 163 | }); 164 | 165 | it("should not inject integrations", () => { 166 | const theme = defineTheme({ integrations: [astroIntegration] })({ 167 | integrations: { "astro-integration": false }, 168 | }); 169 | const params = astroConfigSetupParamsStub(); 170 | 171 | theme.hooks["astro:config:setup"]?.(params); 172 | 173 | const call = params.updateConfig.mock.calls.some( 174 | (call) => call.arguments[0]?.integrations?.[0].name === astroIntegration.name, 175 | ); 176 | 177 | assert.equal(call, false); 178 | }); 179 | 180 | it("should inject integrations with user config", () => { 181 | const theme = defineTheme({ 182 | schema: z.object({ a: z.literal("do-not-add").nullish() }), 183 | integrations: [ 184 | ({ config }) => { 185 | if (!config.a) return astroIntegration; 186 | }, 187 | ], 188 | })({ 189 | config: { a: "do-not-add" }, 190 | }); 191 | 192 | const params = astroConfigSetupParamsStub(); 193 | 194 | theme.hooks["astro:config:setup"]?.(params); 195 | 196 | const called = params.updateConfig.mock.calls.some( 197 | (call) => call.arguments[0]?.integrations?.[0].name === astroIntegration.name, 198 | ); 199 | 200 | assert.equal(called, false); 201 | }); 202 | 203 | it("should resolve virtual modules", () => { 204 | const theme = defineTheme({})(); 205 | const params = astroConfigSetupParamsStub(); 206 | 207 | theme.hooks["astro:config:setup"]?.(params); 208 | 209 | const plugin = params.updateConfig.mock.calls.at(-1).arguments[0].vite.plugins[0]; 210 | 211 | for (const moduleName of Object.keys(defaultModules)) { 212 | assert.equal(plugin.resolveId(moduleName), resolveId(moduleName)); 213 | } 214 | }); 215 | 216 | it("should generate virtual modules", () => { 217 | const theme = defineTheme({})(); 218 | const params = astroConfigSetupParamsStub(); 219 | 220 | theme.hooks["astro:config:setup"]?.(params); 221 | 222 | const plugin = params.updateConfig.mock.calls.at(-1).arguments[0].vite.plugins[0]; 223 | 224 | for (const [moduleName, moduleFiles] of Object.entries(defaultModules)) { 225 | if (!Array.isArray(moduleFiles)) continue; 226 | const resolved = resolveId(moduleName); 227 | const content = plugin.load(resolved); 228 | for (const file of moduleFiles) { 229 | const testImport = new RegExp(`import \"${normalizePath(resolve(packageSrc, file))}\";`, "g"); 230 | const testExport = new RegExp(`export {.*} from \"${normalizePath(resolve(packageSrc, file))}\";`, "g"); 231 | assert.equal(testImport.test(content) || testExport.test(content), true); 232 | } 233 | } 234 | }); 235 | 236 | it("should override virtual modules", () => { 237 | const overrides = { 238 | assets: ["./levi.png"], 239 | components: ["./Heading.astro"], 240 | layouts: ["./Layout.astro"], 241 | styles: ["./global.css"], 242 | }; 243 | 244 | const theme = defineTheme({})({ overrides }); 245 | const params = astroConfigSetupParamsStub(); 246 | 247 | theme.hooks["astro:config:setup"]?.(params); 248 | 249 | const plugin = params.updateConfig.mock.calls.at(-1).arguments[0].vite.plugins[0]; 250 | 251 | for (const [moduleName, moduleFiles] of Object.entries(defaultModules)) { 252 | if (!Array.isArray(moduleFiles)) continue; 253 | const resolved = resolveId(moduleName); 254 | const content = plugin.load(resolved); 255 | const key = moduleName.split(":").pop(); 256 | for (const file of overrides[key]) { 257 | const testImport = new RegExp(`import \"${normalizePath(resolve(projectRoot, file))}\";`, "g"); 258 | const testExport = new RegExp(`export {.*} from \"${normalizePath(resolve(projectRoot, file))}\";`, "g"); 259 | assert.equal(testImport.test(content) || testExport.test(content), true); 260 | } 261 | } 262 | }); 263 | }); 264 | }); 265 | }); 266 | -------------------------------------------------------------------------------- /package/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["tests"] 4 | } 5 | -------------------------------------------------------------------------------- /package/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest", 3 | "compilerOptions": { 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "jsx": "preserve" 7 | }, 8 | "exclude": ["dist"] 9 | } 10 | -------------------------------------------------------------------------------- /package/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | import { peerDependencies } from "./package.json"; 3 | 4 | export default defineConfig((options) => { 5 | const dev = !!options.watch; 6 | return { 7 | entry: ["src/**/*.(ts|js)"], 8 | format: ["esm"], 9 | target: "node18", 10 | bundle: true, 11 | dts: true, 12 | sourcemap: true, 13 | clean: true, 14 | splitting: true, 15 | noExternal: ["astro-pages", "astro-public"], 16 | minify: !dev, 17 | external: [...Object.keys(peerDependencies)], 18 | tsconfig: "tsconfig.build.json", 19 | }; 20 | }); 21 | -------------------------------------------------------------------------------- /playground/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /playground/astro.config.mts: -------------------------------------------------------------------------------- 1 | import sitemap from "@astrojs/sitemap"; 2 | import { createResolver } from "astro-integration-kit"; 3 | import { hmrIntegration } from "astro-integration-kit/dev"; 4 | import { defineConfig } from "astro/config"; 5 | 6 | const { default: themePlayground } = await import("theme-playground"); 7 | 8 | export default defineConfig({ 9 | integrations: [ 10 | themePlayground({ 11 | config: { 12 | title: "Hey!", 13 | description: "This is a theme created using", 14 | // sitemap: false 15 | }, 16 | pages: { 17 | // '/cats': '/dogs', 18 | // '/cats/[...cat]': '/dogs/[...cat]', 19 | }, 20 | overrides: { 21 | components: { 22 | // Heading: './src/CustomHeading.astro' 23 | }, 24 | styles: [ 25 | // "./src/custom.css" 26 | ], 27 | }, 28 | integrations: { 29 | "@astrojs/sitemap": false, 30 | }, 31 | }), 32 | hmrIntegration({ 33 | directory: createResolver(import.meta.url).resolve("../package/dist"), 34 | }), 35 | // sitemap() 36 | ], 37 | }); 38 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "private": true, 4 | "scripts": { 5 | "dev": "astro dev", 6 | "start": "astro dev", 7 | "build": "astro build", 8 | "preview": "astro preview", 9 | "astro": "astro" 10 | }, 11 | "dependencies": { 12 | "@astrojs/sitemap": "^3.2.1", 13 | "astro": "^5.4.3", 14 | "astro-integration-kit": "^0.18.0", 15 | "sharp": "^0.33.5", 16 | "theme-playground": "workspace:^" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /playground/src/CustomHeading.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Heading } from "theme-playground:components"; 3 | --- 4 | 5 | 6 | Hey! This is a custom heading 7 | -------------------------------------------------------------------------------- /playground/src/custom.css: -------------------------------------------------------------------------------- 1 | * { 2 | color: red !important; 3 | } 4 | -------------------------------------------------------------------------------- /playground/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest", 3 | "include": [".astro/types.d.ts", "**/*"], 4 | "exclude": ["dist"] 5 | } 6 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'package' 3 | - 'playground' 4 | - 'docs' 5 | - 'tests/*/**' -------------------------------------------------------------------------------- /tests/e2e/ssg/astro.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "astro/config"; 2 | import Theme from "theme-ssg"; 3 | import config from "./config"; 4 | 5 | export default defineConfig({ 6 | integrations: [ 7 | Theme({ 8 | config, 9 | }), 10 | ], 11 | }); 12 | -------------------------------------------------------------------------------- /tests/e2e/ssg/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | title: "Hello!", 3 | description: "Welcome", 4 | }; 5 | -------------------------------------------------------------------------------- /tests/e2e/ssg/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-e2e-ssg", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.1", 6 | "scripts": { 7 | "dev": "astro dev", 8 | "start": "astro dev", 9 | "build": "astro check && astro build", 10 | "preview": "astro preview", 11 | "astro": "astro", 12 | "test": "playwright test" 13 | }, 14 | "dependencies": { 15 | "@astrojs/check": "^0.9.4", 16 | "astro": "^5.2.5", 17 | "theme-ssg": "workspace:^", 18 | "typescript": "^5.7.3" 19 | }, 20 | "devDependencies": { 21 | "@playwright/test": "^1.50.1", 22 | "@types/node": "^22.13.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/e2e/ssg/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { type PlaywrightTestConfig, defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig( 13 | { 14 | testDir: "./tests", 15 | /* Run tests in files in parallel */ 16 | fullyParallel: true, 17 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 18 | forbidOnly: !!process.env.CI, 19 | /* Retry on CI only */ 20 | retries: process.env.CI ? 2 : 0, 21 | /* Opt out of parallel tests on CI. */ 22 | workers: process.env.CI ? 1 : undefined, 23 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 24 | reporter: "list", 25 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 26 | use: { 27 | /* Base URL to use in actions like `await page.goto('/')`. */ 28 | baseURL: "http://localhost:4321/", 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: "on-first-retry", 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: process.env.CI 35 | ? [ 36 | { 37 | name: "chromium", 38 | use: { ...devices["Desktop Chrome"] }, 39 | }, 40 | { 41 | name: "firefox", 42 | use: { ...devices["Desktop Firefox"] }, 43 | }, 44 | { 45 | name: "webkit", 46 | use: { ...devices["Desktop Safari"] }, 47 | }, 48 | ] 49 | : [ 50 | { 51 | name: "chromium", 52 | use: { ...devices["Desktop Chrome"] }, 53 | }, 54 | ], 55 | 56 | /* Run your local dev server before starting the tests */ 57 | webServer: { 58 | command: "pnpm dev", 59 | url: "http://localhost:4321", 60 | reuseExistingServer: !process.env.CI, 61 | }, 62 | } as PlaywrightTestConfig /* TS throws a fit without this */, 63 | ); 64 | -------------------------------------------------------------------------------- /tests/e2e/ssg/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | /// 6 | /// 7 | -------------------------------------------------------------------------------- /tests/e2e/ssg/test-results/.last-run.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "passed", 3 | "failedTests": [] 4 | } -------------------------------------------------------------------------------- /tests/e2e/ssg/tests/theme.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import config from "../config"; 3 | 4 | test("injected config", async ({ request }) => { 5 | const response = await request.get("/config.json"); 6 | const json = await response.json(); 7 | expect(json).toEqual(config); 8 | }); 9 | 10 | test("injected pages", async ({ page }) => { 11 | await page.goto("/"); 12 | await expect(page).toHaveTitle("Theme SSG"); 13 | }); 14 | 15 | test("injected public", async ({ request }) => { 16 | const response = await request.get("/favicon.svg"); 17 | expect(response.status()).toBe(200); 18 | }); 19 | 20 | test("injected css", async ({ page }) => { 21 | await page.goto("/"); 22 | 23 | const element = await page.waitForSelector("body"); 24 | const color = await element.evaluate((el) => { 25 | return window.getComputedStyle(el).getPropertyValue("background-color"); 26 | }); 27 | 28 | expect(color).toBe("rgb(0, 255, 0)"); 29 | }); 30 | 31 | test("injected components", async ({ page }) => { 32 | await page.goto("/"); 33 | expect(await page.innerText("h1")).toBe("Theme SSG"); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/themes/theme-playground/index.ts: -------------------------------------------------------------------------------- 1 | import sitemap from "@astrojs/sitemap"; 2 | import defineTheme from "astro-theme-provider"; 3 | import { z } from "astro/zod"; 4 | 5 | export default defineTheme({ 6 | name: "theme-playground", 7 | schema: z.object({ 8 | title: z.string(), 9 | description: z.string().optional(), 10 | sitemap: z.boolean().optional().default(true), 11 | }), 12 | imports: { 13 | test: { 14 | default: "./src/components/Heading.astro", 15 | }, 16 | }, 17 | integrations: [({ config }) => config.sitemap && sitemap()], 18 | }); 19 | -------------------------------------------------------------------------------- /tests/themes/theme-playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "theme-playground", 3 | "private": true, 4 | "description": "My awesome theme!", 5 | "license": "MIT", 6 | "homepage": "https://github.com/UserName/theme-playground", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/UserName/theme-playground", 10 | "directory": "package" 11 | }, 12 | "keywords": [ 13 | "astro-integration" 14 | ], 15 | "scripts": {}, 16 | "devDependencies": { 17 | "@types/node": "^22.13.10", 18 | "astro": "^5.4.3" 19 | }, 20 | "dependencies": { 21 | "@astrojs/sitemap": "^3.2.1", 22 | "astro-theme-provider": "workspace:^" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/themes/theme-playground/public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/themes/theme-playground/src/assets/ai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/tests/themes/theme-playground/src/assets/ai.png -------------------------------------------------------------------------------- /tests/themes/theme-playground/src/assets/cursed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/tests/themes/theme-playground/src/assets/cursed.png -------------------------------------------------------------------------------- /tests/themes/theme-playground/src/assets/levi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/tests/themes/theme-playground/src/assets/levi.png -------------------------------------------------------------------------------- /tests/themes/theme-playground/src/assets/sit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/tests/themes/theme-playground/src/assets/sit.png -------------------------------------------------------------------------------- /tests/themes/theme-playground/src/assets/starlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/tests/themes/theme-playground/src/assets/starlight.png -------------------------------------------------------------------------------- /tests/themes/theme-playground/src/components/Heading.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import config from "theme-playground:config"; 3 | 4 | interface Props { 5 | title?: string; 6 | } 7 | 8 | const { title } = Astro.props; 9 | --- 10 | 11 |

12 | { Astro.slots.has('default') 13 | && 14 | || title 15 | || config.title 16 | } 17 |

18 | -------------------------------------------------------------------------------- /tests/themes/theme-playground/src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import "theme-playground:styles"; 3 | --- 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Theme 13 | 14 | 15 | {Astro.url.pathname !== '/' && Home} 16 |
17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/themes/theme-playground/src/pages/api/json.json.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | 3 | export const GET: APIRoute = async () => { 4 | return new Response( 5 | JSON.stringify({ 6 | name: "Levi", 7 | description: "cat", 8 | }), 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /tests/themes/theme-playground/src/pages/cats/[...cat].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Image } from "astro:assets"; 3 | import { Layout } from "theme-playground:layouts"; 4 | 5 | export async function getStaticPaths() { 6 | const images = Object.entries(await import("theme-playground:assets")); 7 | return images.map(([name, image], i) => { 8 | return { 9 | params: { 10 | cat: name, 11 | }, 12 | props: { 13 | // @ts-ignore 14 | next: images[i >= images.length - 1 ? 0 : i <= images.length - 1 ? i + 1 : 0][0], 15 | name, 16 | image, 17 | }, 18 | }; 19 | }); 20 | } 21 | 22 | interface Props { 23 | next: string; 24 | name: string; 25 | image: ImageMetadata; 26 | } 27 | 28 | const { next, name, image } = Astro.props; 29 | --- 30 | 31 | 32 | Levi sitting 33 |

Hey I'm "{name}" Levi

34 | 37 |
-------------------------------------------------------------------------------- /tests/themes/theme-playground/src/pages/cats/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Image } from "astro:assets"; 3 | import { sit } from "theme-playground:assets"; 4 | import { Layout } from "theme-playground:layouts"; 5 | --- 6 | 7 | 8 | Levi sitting 9 |

Hey I'm Levi

10 | 13 |
14 | -------------------------------------------------------------------------------- /tests/themes/theme-playground/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Heading } from "theme-playground:components"; 3 | import config from "theme-playground:config"; 4 | import { integrations, pages } from "theme-playground:context"; 5 | import { Layout } from "theme-playground:layouts"; 6 | --- 7 | 8 | 9 | 10 |

{config.description}

11 |

astro-theme-providerrocket

12 |

Explore

13 | 16 |

Pages

17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | { Object.entries(Object.fromEntries(pages)).map(path => { 26 | return 27 | 28 | 29 | 30 | })} 31 | 32 |
InjectedResolved
{path[0]}{path[1] || 'false'}
33 |

Integrations

34 |
    35 | {Array.from(integrations).map(name => { 36 | return
  • {name}
  • 37 | })} 38 |
39 | 40 | 41 | 48 | -------------------------------------------------------------------------------- /tests/themes/theme-playground/src/styles/global.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | body > a { 10 | position: absolute; 11 | top: 1rem; 12 | left: 1rem; 13 | } 14 | 15 | main { 16 | height: 100%; 17 | display: grid; 18 | place-content: center; 19 | text-align: center; 20 | margin-top: -3rem; 21 | } 22 | 23 | img { 24 | max-width: 250px; 25 | height: auto; 26 | } 27 | 28 | h1 { 29 | font-size: 5rem; 30 | } 31 | 32 | h2 { 33 | font-style: italic; 34 | font-weight: normal; 35 | font-size: 1.75rem; 36 | } 37 | 38 | code { 39 | background-color: rgba(0, 0, 0, 0.15); 40 | padding: 0.2rem; 41 | font-size: 1.5rem; 42 | } 43 | 44 | ul { 45 | list-style: none; 46 | margin: 0; 47 | padding: 0; 48 | } 49 | 50 | li { 51 | } 52 | -------------------------------------------------------------------------------- /tests/themes/theme-ssg/index.ts: -------------------------------------------------------------------------------- 1 | import defineTheme from "astro-theme-provider"; 2 | import { z } from "astro/zod"; 3 | 4 | export default defineTheme({ 5 | name: "theme-ssg", 6 | schema: z.object({ 7 | title: z.string(), 8 | description: z.string().optional(), 9 | }), 10 | imports: { 11 | test: { 12 | default: "./src/components/Heading.astro", 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /tests/themes/theme-ssg/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "theme-ssg", 3 | "private": true, 4 | "description": "My awesome theme!", 5 | "license": "MIT", 6 | "homepage": "https://github.com/UserName/theme-ssg", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/UserName/theme-ssg", 10 | "directory": "package" 11 | }, 12 | "keywords": [ 13 | "astro-integration" 14 | ], 15 | "scripts": {}, 16 | "devDependencies": { 17 | "@types/node": "^22.13.10", 18 | "astro": "^5.4.3" 19 | }, 20 | "dependencies": { 21 | "astro-theme-provider": "workspace:^" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/themes/theme-ssg/public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/themes/theme-ssg/src/assets/cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrolicious/astro-theme-provider/f96edbfcea88801740101f9dd014308a9a1adc58/tests/themes/theme-ssg/src/assets/cat.png -------------------------------------------------------------------------------- /tests/themes/theme-ssg/src/components/Heading.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import config from "theme-ssg:config"; 3 | 4 | interface Props { 5 | title?: string; 6 | } 7 | 8 | const { title } = Astro.props; 9 | --- 10 | 11 |

12 | { Astro.slots.has('default') 13 | && 14 | || title 15 | || config.title 16 | } 17 |

18 | -------------------------------------------------------------------------------- /tests/themes/theme-ssg/src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import "theme-ssg:styles"; 3 | --- 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Theme SSG 12 | 13 | 14 |
15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/themes/theme-ssg/src/pages/config.json.ts: -------------------------------------------------------------------------------- 1 | import config from "theme-ssg:config"; 2 | import type { APIRoute } from "astro"; 3 | 4 | export const GET: APIRoute = async () => { 5 | return new Response(JSON.stringify(config)); 6 | }; 7 | -------------------------------------------------------------------------------- /tests/themes/theme-ssg/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Heading } from "theme-ssg:components"; 3 | import config from "theme-ssg:config"; 4 | import { Layout } from "theme-ssg:layouts"; 5 | --- 6 | 7 | 8 | 9 | Theme SSG 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/themes/theme-ssg/src/styles/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #00ff00; 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } 4 | --------------------------------------------------------------------------------