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