├── .editorconfig ├── .gitignore ├── .stackblitzrc ├── README.md ├── demo ├── astro.config.js ├── package.json ├── public │ └── favicon.ico ├── src │ ├── components │ │ ├── Button.web.d.ts │ │ └── Button.web.js │ └── pages │ │ └── index.astro └── tsconfig.json ├── jsconfig.json ├── package.json ├── packages └── web-components │ ├── README.md │ ├── index.d.ts │ ├── index.js │ └── package.json └── sandbox.config.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 4 8 | indent_style = tab 9 | insert_final_newline = true 10 | trim_trailing_whitespace = false 11 | 12 | [*.md] 13 | indent_size = 2 14 | indent_style = space 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | package-lock.json 4 | *.log* 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.stackblitzrc: -------------------------------------------------------------------------------- 1 | { 2 | "startCommand": "npm start", 3 | "env": { 4 | "ENABLE_CJS_IMPORTS": true 5 | } 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web Components 2 | 3 | **Web Components** lets you use native Web Components (`.web.js`) as Astro Components. 4 | 5 | **components/Button.web.js**: 6 | ```js 7 | export default class Button extends HTMLElement { 8 | constructor() { 9 | let host = super() 10 | let root = host.attachShadow({ mode: 'open' }) 11 | 12 | root.innerHTML = `` 13 | } 14 | } 15 | 16 | customElements.define('h-button', Button) 17 | ``` 18 | 19 | **pages/index.astro**: 20 | ```astro 21 | --- 22 | import Button from '../components/Button.web.js' 23 | --- 24 | 25 | 26 | 27 | 28 | Button Example 29 | 30 | 31 |

Button Example

32 | 33 | 34 | 35 | ``` 36 | 37 | **Rendered HTML**: 38 | ```html 39 | 40 | 41 | 42 | 43 | 44 | 45 | Button Example 46 | 47 | 48 | 49 |

Button Example

50 | click me 51 | 52 | 53 | ``` 54 | 55 | 56 | 57 | ## Usage 58 | 59 | Install **Web Components** to your project. 60 | 61 | ```shell 62 | npm install @astropub/web-components 63 | ``` 64 | 65 | Add **Web Components** to your Astro configuration. 66 | 67 | ```js 68 | import { webcomponents } from '@astropub/web-components' 69 | 70 | /** @type {import('astro').AstroUserConfig} */ 71 | const config = { 72 | vite: { 73 | plugins: [ 74 | webcomponents() 75 | ] 76 | } 77 | } 78 | 79 | export default config 80 | ``` 81 | 82 | Enjoy! 83 | 84 | [![Open in StackBlitz][open-img]][open-url] 85 | 86 | 87 | 88 | ## Project Structure 89 | 90 | Inside of the project, you'll see the following folders and files: 91 | 92 | ``` 93 | / 94 | ├── demo/ 95 | │ ├── public/ 96 | │ └── src/ 97 | │ └── pages/ 98 | │ └── index.astro 99 | └── packages/ 100 | └── web-components/ 101 | ├── index.js 102 | └── package.json 103 | ``` 104 | 105 | This project uses **workspaces** to develop a single package, `@atropub/web-components`. 106 | 107 | It also includes a minimal Astro project, `demo`, for developing and demonstrating the plugin. 108 | 109 | 110 | 111 | ## Commands 112 | 113 | All commands are run from the root of the project, from a terminal: 114 | 115 | | Command | Action | 116 | |:----------------|:---------------------------------------------| 117 | | `npm install` | Installs dependencies | 118 | | `npm run start` | Starts local dev server at `localhost:3000` | 119 | | `npm run build` | Build your production site to `./dist/` | 120 | | `npm run serve` | Preview your build locally, before deploying | 121 | 122 | Want to learn more? 123 | Read [our documentation][docs-url] or jump into our [Discord server][chat-url]. 124 | 125 | 126 | [chat-url]: https://astro.build/chat 127 | [docs-url]: https://github.com/withastro/astro 128 | [open-img]: https://developer.stackblitz.com/img/open_in_stackblitz.svg 129 | [open-url]: https://stackblitz.com/github/astro-community/web-components/ 130 | -------------------------------------------------------------------------------- /demo/astro.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { defineConfig } from 'astro/config'; 4 | import webcomponents from '@astropub/web-components' 5 | 6 | export default defineConfig({ 7 | vite: { 8 | plugins: [ 9 | webcomponents() 10 | ] 11 | } 12 | }) 13 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@astropub/web-components-demo", 3 | "type": "module", 4 | "version": "0.1.0", 5 | "private": true, 6 | "scripts": { 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "serve": "astro preview" 10 | }, 11 | "devDependencies": { 12 | "@astropub/web-components": "0.1.0", 13 | "astro": "latest" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astro-community/web-components/bed453bab4c3e247341dd611c3b64251abc14c52/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/src/components/Button.web.d.ts: -------------------------------------------------------------------------------- 1 | export interface Props { 2 | type?: 'button' | 'submit' | 'reset' 3 | } 4 | 5 | export default function (props: Props): HTMLElement 6 | -------------------------------------------------------------------------------- /demo/src/components/Button.web.js: -------------------------------------------------------------------------------- 1 | 2 | export default class Button extends HTMLElement { 3 | constructor() { 4 | let host = super() 5 | let root = host.attachShadow({ mode: 'open' }) 6 | 7 | root.innerHTML = `` 8 | } 9 | } 10 | 11 | customElements.define('h-button', Button) 12 | -------------------------------------------------------------------------------- /demo/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Button from '../components/Button.web.js' 3 | --- 4 | 5 | 6 | 7 | 8 | Button: Web Components 9 | 10 | 11 |

Button

12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable top-level await, and other modern ESM features. 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | // Enable node-style module resolution, for things like npm package imports. 7 | "moduleResolution": "node", 8 | // Enable JSON imports. 9 | "resolveJsonModule": true, 10 | // Enable stricter transpilation for better output. 11 | "isolatedModules": false, 12 | // Add type definitions for our Vite runtime. 13 | "types": ["vite/client"] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "packages/**/*.*", 4 | "demo/**/*.*" 5 | ], 6 | "exclude": [ 7 | "node_modules" 8 | ], 9 | "compilerOptions": { 10 | "allowJs": true, 11 | "declaration": true, 12 | "declarationDir": ".", 13 | "declarationMap": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "skipLibCheck": true, 17 | "sourceMap": true, 18 | "strict": true, 19 | "target": "esnext" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@astropub/web-components-root", 3 | "type": "module", 4 | "version": "0.1.0", 5 | "workspaces": [ 6 | "demo", 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "bump:patch": "npm --workspaces --git-tag-version false version patch && npm --git-tag-version false version patch", 11 | "bump:minor": "npm --workspaces --git-tag-version false version minor && npm --git-tag-version false version minor", 12 | "bump:major": "npm --workspaces --git-tag-version false version major && npm --git-tag-version false version major", 13 | "start": "cd demo; astro dev", 14 | "build": "cd demo; astro build", 15 | "serve": "cd demo; astro preview", 16 | "release": "npm --workspaces publish --access public" 17 | }, 18 | "prettier": { 19 | "semi": false, 20 | "singleQuote": true, 21 | "trailingComma": "es5", 22 | "useTabs": true, 23 | "overrides": [ 24 | { 25 | "files": [ 26 | "*.json", 27 | "*.md", 28 | "*.stackblitzrc" 29 | ], 30 | "options": { 31 | "useTabs": false 32 | } 33 | } 34 | ] 35 | }, 36 | "devDependencies": { 37 | "@types/node": "latest", 38 | "astro": "latest" 39 | }, 40 | "private": true 41 | } 42 | -------------------------------------------------------------------------------- /packages/web-components/README.md: -------------------------------------------------------------------------------- 1 | # Web Components 2 | 3 | **Web Components** lets you use native Web Components (`.web.js`) as Astro Components. 4 | 5 | **components/Button.web.js**: 6 | ```js 7 | export default class Button extends HTMLElement { 8 | constructor() { 9 | let host = super() 10 | let root = host.attachShadow({ mode: 'open' }) 11 | 12 | root.innerHTML = `` 13 | } 14 | } 15 | 16 | customElements.define('h-button', Button) 17 | ``` 18 | 19 | **pages/index.astro**: 20 | ```astro 21 | --- 22 | import Button from '../components/Button.web.js' 23 | --- 24 | 25 | 26 | 27 | 28 | Button Example 29 | 30 | 31 |

Button Example

32 | 33 | 34 | 35 | ``` 36 | 37 | **Rendered HTML**: 38 | ```html 39 | 40 | 41 | 42 | 43 | 44 | 45 | Button Example 46 | 47 | 48 | 49 |

Button Example

50 | click me 51 | 52 | 53 | ``` 54 | 55 | 56 | 57 | ## Usage 58 | 59 | Install **Web Components** to your project. 60 | 61 | ```shell 62 | npm install @astropub/web-components 63 | ``` 64 | 65 | Add **Web Components** to your Astro configuration. 66 | 67 | ```js 68 | import { webcomponents } from '@astropub/web-components' 69 | 70 | /** @type {import('astro').AstroUserConfig} */ 71 | const config = { 72 | vite: { 73 | plugins: [ 74 | webcomponents() 75 | ] 76 | } 77 | } 78 | 79 | export default config 80 | ``` 81 | 82 | Enjoy! 83 | -------------------------------------------------------------------------------- /packages/web-components/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | import type * as rollup from 'rollup' 5 | import type * as vite from 'vite' 6 | 7 | export interface PluginFactory { 8 | (options?: PluginOptions): vite.PluginOption 9 | } 10 | 11 | export interface PluginOptions {} 12 | 13 | export interface Plugin 14 | extends Omit< 15 | vite.Plugin, 16 | 'load' | 'resolveId' | 'resolveDynamicImport' | 'transform' 17 | > { 18 | name?: string 19 | 20 | resolveId?: { 21 | ( 22 | this: rollup.PluginContext, 23 | importee: string, 24 | importer: string | undefined, 25 | options: { 26 | custom: rollup.CustomPluginOptions | undefined 27 | isEntry: boolean 28 | }, 29 | isSSR: boolean 30 | ): rollup.ResolveIdResult | Promise | void 31 | } 32 | 33 | resolveDynamicImport?: { 34 | ( 35 | this: rollup.PluginContext, 36 | importee: string | rollup.AcornNode, 37 | importer: string 38 | ): rollup.ResolveIdResult | Promise | void 39 | } 40 | 41 | load?: { 42 | (this: PluginContext, importee: string, isSSR: boolean): 43 | | rollup.LoadResult 44 | | Promise 45 | | void 46 | } 47 | 48 | transform?: { 49 | ( 50 | this: rollup.TransformPluginContext, 51 | code: string, 52 | importee: string, 53 | isSSR: boolean 54 | ): rollup.TransformResult | Promise | void 55 | } 56 | 57 | renderChunk?: { 58 | ( 59 | this: rollup.PluginContext, 60 | code: string, 61 | chunk: rollup.RenderedChunk, 62 | options: rollup.NormalizedOutputOptions 63 | ): 64 | | Promise<{ code: string; map?: SourceMapInput } | void> 65 | | { code: string; map?: SourceMapInput } 66 | | string 67 | | void 68 | } 69 | } 70 | 71 | export declare var webcomponents: PluginFactory 72 | 73 | export default webcomponents 74 | 75 | export type AstroHTMLElement = { 76 | (props: Props): typeof HTMLElement 77 | } 78 | 79 | export type HTMLElement = AstroHTMLElement 80 | 81 | export var HTMLElement: AstroHTMLElement 82 | -------------------------------------------------------------------------------- /packages/web-components/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | // @ts-check 4 | 5 | import * as fs from 'node:fs' 6 | 7 | /** Returns the posix-normalized path from the given path. */ 8 | let normalize = (/** @type {string} */ path) => path.replace(/\\+/g, '/').replace(/^(?=[A-Za-z]:\/)/, '/').replace(/%/g, '%25').replace(/\n/g, '%0A').replace(/\r/g, '%0D').replace(/\t/g, '%09') 9 | 10 | let extensionForWebComponents = /\.web(\.(js|ts))?$/ 11 | 12 | export let Element = globalThis.HTMLElement 13 | 14 | export function webcomponents() { 15 | let cacheDir = new URL('/', 'file:') 16 | 17 | /** @type {Plugin} */ 18 | let plugin = { 19 | name: '@astropub/web-components', 20 | configResolved(config) { 21 | cacheDir = new URL(normalize(config.cacheDir) + '/web-components/', 'file:') 22 | 23 | fs.mkdirSync(cacheDir, { recursive: true }) 24 | 25 | let plugins = /** @type {Plugin[]} */ (config.plugins) 26 | let index = plugins.findIndex(configPlugin => configPlugin.name === plugin.name) 27 | 28 | if (index !== -1) { 29 | plugin = plugins[index] 30 | 31 | plugins.splice(index, 1) 32 | plugins.unshift(plugin) 33 | } 34 | }, 35 | resolveId(importee, importer = '', options) { 36 | let hasExtensionForWebComponents = extensionForWebComponents.test(importee) 37 | let hasImportedFromGeneratedFile = importer.startsWith(cacheDir.pathname) 38 | let hasImportedFromIgnorableFile = !importer || importer.endsWith('.html') 39 | 40 | if (hasExtensionForWebComponents && !hasImportedFromGeneratedFile && !hasImportedFromIgnorableFile) { 41 | return this.resolve(importee, importer, { ...options, skipSelf: true }).then( 42 | (resolved) => { 43 | let originalPath = resolved?.id || '' 44 | let modifiedPath = new URL(toHash(originalPath) + '.astro', cacheDir).pathname 45 | let modifiedData = getModifiedData(originalPath) 46 | 47 | fs.writeFileSync(modifiedPath, modifiedData) 48 | 49 | return modifiedPath 50 | } 51 | ) 52 | } 53 | } 54 | } 55 | 56 | return plugin 57 | } 58 | 59 | export default webcomponents 60 | 61 | let getModifiedData = (importee = '') => [ 62 | `---`, 63 | `import Component from '${importee}'`, 64 | `---`, 65 | ``, 66 | `` 67 | ].join('\n') 68 | 69 | let toAlphabeticChar = (/** @type {number} */ code) => String.fromCharCode(code + (code > 25 ? 39 : 97)) 70 | 71 | let toAlphabeticName = (/** @type {number} */ code) => { 72 | let name = '' 73 | let x 74 | 75 | for (x = Math.abs(code); x > 52; x = (x / 52) | 0) name = toAlphabeticChar(x % 52) + name 76 | 77 | return toAlphabeticChar(x % 52) + name 78 | } 79 | 80 | let toPhash = (/** @type {number} */ h, /** @type {string} */ x) => { 81 | let i = x.length 82 | while (i) h = (h * 33) ^ x.charCodeAt(--i) 83 | return h 84 | } 85 | 86 | let toHash = (/** @type {any} */ value) => toAlphabeticName( 87 | toPhash( 88 | 5381, 89 | JSON.stringify(value) 90 | ) >>> 0 91 | ) 92 | 93 | /** @typedef {import('./index').Plugin} Plugin */ 94 | -------------------------------------------------------------------------------- /packages/web-components/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@astropub/web-components", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "exports": { 8 | ".": "./index.js" 9 | }, 10 | "files": [ 11 | "index.js", 12 | "index.d.ts" 13 | ], 14 | "keywords": [ 15 | "astro-plugin", 16 | "component", 17 | "components", 18 | "element", 19 | "elements", 20 | "html", 21 | "htmlelement", 22 | "javascript", 23 | "js", 24 | "web", 25 | "web-component", 26 | "web-components" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "infiniteLoopProtection": true, 3 | "hardReloadOnChange": false, 4 | "view": "browser", 5 | "template": "node", 6 | "container": { 7 | "port": 3000, 8 | "startScript": "start", 9 | "node": "14" 10 | } 11 | } 12 | --------------------------------------------------------------------------------