diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..1dfdf29
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,22 @@
+# http://editorconfig.org
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.json]
+insert_final_newline = ignore
+
+[**.min.js]
+indent_style = ignore
+insert_final_newline = ignore
+
+[MakeFile]
+indent_style = space
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..89a6346
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,10 @@
+/** @type {import("eslint").Linter.Config} */
+module.exports = {
+ root: true,
+ extends: ['@foa/eslint-config/adonis-package.js'],
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ project: './tsconfig.lint.json',
+ tsconfigRootDir: __dirname,
+ },
+}
diff --git a/.github/lock.yml b/.github/lock.yml
new file mode 100644
index 0000000..ea7cf67
--- /dev/null
+++ b/.github/lock.yml
@@ -0,0 +1,26 @@
+---
+ignoreUnless: {{ STALE_BOT }}
+---
+# Configuration for Lock Threads - https://github.com/dessant/lock-threads-app
+
+# Number of days of inactivity before a closed issue or pull request is locked
+daysUntilLock: 60
+
+# Skip issues and pull requests created before a given timestamp. Timestamp must
+# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
+skipCreatedBefore: false
+
+# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
+exemptLabels: ['Type: Security']
+
+# Label to add before locking, such as `outdated`. Set to `false` to disable
+lockLabel: false
+
+# Comment to post before locking. Set to `false` to disable
+lockComment: >
+ This thread has been automatically locked since there has not been
+ any recent activity after it was closed. Please open a new issue for
+ related bugs.
+
+# Assign `resolved` as the reason for locking. Set to `false` to disable
+setLockReason: false
diff --git a/.github/stale.yml b/.github/stale.yml
new file mode 100644
index 0000000..d21cf6c
--- /dev/null
+++ b/.github/stale.yml
@@ -0,0 +1,24 @@
+---
+ignoreUnless: {{ STALE_BOT }}
+---
+# Number of days of inactivity before an issue becomes stale
+daysUntilStale: 60
+
+# Number of days of inactivity before a stale issue is closed
+daysUntilClose: 7
+
+# Issues with these labels will never be considered stale
+exemptLabels:
+ - 'Type: Security'
+
+# Label to use when marking an issue as stale
+staleLabel: 'Status: Abandoned'
+
+# Comment to post when marking an issue as stale. Set to `false` to disable
+markComment: >
+ This issue has been automatically marked as stale because it has not had
+ recent activity. It will be closed if no further activity occurs. Thank you
+ for your contributions.
+
+# Comment to post when closing a stale issue. Set to `false` to disable
+closeComment: false
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..6e82cdd
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,60 @@
+name: test
+
+on:
+ - push
+ - pull_request
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install
+ run: npm install
+ - name: Run lint
+ run: npm run lint
+
+ typecheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install
+ run: npm install
+ - name: Run typecheck
+ run: npm run typecheck
+
+ tests:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ node-version:
+ - 20.10.0
+ - 21.x
+ steps:
+ - uses: actions/checkout@v4
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v1
+ with:
+ node-version: ${{ matrix.node-version }}
+ - name: Install
+ run: npm install
+ - name: Run tests
+ run: npm test
+ windows:
+ runs-on: windows-latest
+ strategy:
+ matrix:
+ node-version:
+ - 20.10.0
+ - 21.x
+ steps:
+ - uses: actions/checkout@v2
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v1
+ with:
+ node-version: ${{ matrix.node-version }}
+ - name: Install
+ run: npm install
+ - name: Run tests
+ run: npm test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..966cd35
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+node_modules
+coverage
+.DS_STORE
+.nyc_output
+.idea
+.vscode/
+*.sublime-project
+*.sublime-workspace
+*.log
+build
+dist
+yarn.lock
+shrinkwrap.yaml
+package-lock.json
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..43c97e7
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+package-lock=false
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..da1f07a
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,4 @@
+build
+docs
+coverage
+*.html
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..1bb5ef3
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,9 @@
+# The MIT License
+
+Copyright (c) 2023
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..68a7d80
--- /dev/null
+++ b/README.md
@@ -0,0 +1,27 @@
+
+
+
+## @foadonis/edgewire
+
+### Build reactive interfaces without leaving Adonis.js
+
+
+
+
+
+
+[![PRs Welcome](https://img.shields.io/badge/PRs-Are%20welcome-brightgreen.svg?style=flat-square)](https://makeapullrequest.com) [![License](https://img.shields.io/github/license/FriendsOfAdonis/edgewire?label=License&style=flat-square)](LICENCE) [![@foadonis/edgewire](https://img.shields.io/npm/v/%40foadonis%2Fedgewire?style=flat-square)](https://www.npmjs.com/package/@foadonis/edgewire)
+
+
+
+## Description
+
+Edgewire allows you to build reactive interfaces without leaving your server using Adonis. It is heavily inspired by the PHP library [Livewire](https://livewire.laravel.com/docs/quickstart) and even share some code.
+
+## Quickstart
+
+[Installation & Getting Started](https://friendsofadonis.github.io/docs/edgewire/introduction)
+
+## License
+
+[MIT licensed](LICENSE.md).
diff --git a/bin/test.ts b/bin/test.ts
new file mode 100644
index 0000000..16c9fd1
--- /dev/null
+++ b/bin/test.ts
@@ -0,0 +1,93 @@
+/*
+|--------------------------------------------------------------------------
+| Test runner entrypoint
+|--------------------------------------------------------------------------
+|
+| The "test.ts" file is the entrypoint for running tests using Japa.
+|
+| Either you can run this file directly or use the "test"
+| command to run this file and monitor file changes.
+|
+*/
+
+process.env.NODE_ENV = 'test'
+process.env.PORT = '3332'
+
+import 'reflect-metadata'
+import { prettyPrintError } from '@adonisjs/core'
+import { configure, processCLIArgs, run } from '@japa/runner'
+import { IgnitorFactory } from '@adonisjs/core/factories'
+import { HttpContext } from '@adonisjs/core/http'
+import edge from 'edge.js'
+
+/**
+ * URL to the application root. AdonisJS need it to resolve
+ * paths to file and directories for scaffolding commands
+ */
+const APP_ROOT = new URL('../tmp', import.meta.url)
+
+/**
+ * The importer is used to import files in context of the
+ * application.
+ */
+const IMPORTER = (filePath: string) => {
+ if (filePath.startsWith('./') || filePath.startsWith('../')) {
+ return import(new URL(filePath, APP_ROOT).href)
+ }
+ return import(filePath)
+}
+
+new IgnitorFactory()
+ .merge({
+ rcFileContents: {
+ providers: [
+ () => import('../providers/edgewire_provider.js'),
+ () => import('@adonisjs/core/providers/edge_provider'),
+ ],
+ },
+ })
+ .withCoreConfig()
+ .withCoreProviders()
+ .create(APP_ROOT, { importer: IMPORTER })
+ .tap((app) => {
+ app.booting(async () => {})
+ app.starting(async () => {
+ app.config.set('edgewire.viewPath', 'viewPath')
+
+ const router = await app.container.make('router')
+ router.get('/edgewire/test', ({ request }: HttpContext) => {
+ const { name, params } = request.qs()
+ return edge.renderRaw(`@!edgewire('${name}')`)
+ })
+ })
+ app.listen('SIGTERM', () => app.terminate())
+ app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
+ })
+ .testRunner()
+ .configure(async (app) => {
+ const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
+
+ processCLIArgs(process.argv.splice(2))
+ configure({
+ suites: [
+ {
+ name: 'functional',
+ files: ['tests/functional/**/*.spec.(js|ts)'],
+ },
+ {
+ name: 'unit',
+ files: ['tests/unit/**/*.spec.(js|ts)'],
+ },
+ ],
+ ...config,
+ ...{
+ setup: runnerHooks.setup,
+ teardown: runnerHooks.teardown.concat([() => app.terminate()]),
+ },
+ })
+ })
+ .run(() => run())
+ .catch((error) => {
+ process.exitCode = 1
+ prettyPrintError(error)
+ })
diff --git a/commands/make_edgewire.ts b/commands/make_edgewire.ts
new file mode 100644
index 0000000..0d4143f
--- /dev/null
+++ b/commands/make_edgewire.ts
@@ -0,0 +1,72 @@
+import { args, BaseCommand } from '@adonisjs/core/ace'
+import { CommandOptions } from '@adonisjs/core/types/ace'
+import { stubsRoot } from '../stubs/main.js'
+import string from '@adonisjs/core/helpers/string'
+import path from 'node:path'
+
+export default class MakeEdgewire extends BaseCommand {
+ static commandName = 'make:edgewire'
+ static description = 'Make a new Edgewire component'
+ static options: CommandOptions = {
+ startApp: true,
+ allowUnknownFlags: true,
+ }
+
+ @args.string({ description: 'Name of the migration file' })
+ declare name: string
+
+ async run() {
+ const codemods = await this.createCodemods()
+
+ const component = this.createComponent()
+ const view = this.createView()
+
+ await codemods.makeUsingStub(stubsRoot, 'make/edgewire/component.stub', {
+ component,
+ view,
+ })
+
+ await codemods.makeUsingStub(stubsRoot, 'make/edgewire/view.stub', {
+ component,
+ view,
+ })
+
+ const morph = await codemods.getTsMorphProject()
+
+ if (!morph) {
+ this.logger.warning(
+ 'An issue occured when retrieving ts-morph. start/view.ts has not been updated'
+ )
+ return
+ }
+
+ const startView = morph.getSourceFileOrThrow('start/view.ts')
+
+ startView.addImportDeclaration({
+ moduleSpecifier: component.importPath,
+ defaultImport: component.className,
+ })
+
+ startView.addStatements(`edgewire.component('${component.name}', ${component.className})`)
+
+ await startView.save()
+ }
+
+ createView() {
+ return {
+ path: 'edgewire',
+ fileName: string.create(this.name).snakeCase().ext('.edge').toString(),
+ templatePath: path.join('edgewire', string.create(this.name).snakeCase().toString()),
+ }
+ }
+
+ createComponent() {
+ return {
+ path: '',
+ name: string.create(this.name).snakeCase().toString(),
+ className: string.create(this.name).pascalCase().suffix('Component').toString(),
+ fileName: string.create(this.name).snakeCase().ext('.ts').toString(),
+ importPath: ['#components', string.create(this.name).snakeCase().toString()].join('/'),
+ }
+ }
+}
diff --git a/configure.ts b/configure.ts
new file mode 100644
index 0000000..2a9aeb9
--- /dev/null
+++ b/configure.ts
@@ -0,0 +1,37 @@
+/*
+|--------------------------------------------------------------------------
+| Configure hook
+|--------------------------------------------------------------------------
+|
+| The configure hook is called when someone runs "node ace configure "
+| command. You are free to perform any operations inside this function to
+| configure the package.
+|
+| To make things easier, you have access to the underlying "ConfigureCommand"
+| instance and you can use codemods to modify the source files.
+|
+*/
+
+import ConfigureCommand from '@adonisjs/core/commands/configure'
+import { stubsRoot } from './stubs/main.js'
+
+export async function configure(command: ConfigureCommand) {
+ const codemods = await command.createCodemods()
+
+ await codemods.installPackages([
+ {
+ name: 'edge',
+ isDevDependency: false,
+ },
+ ])
+
+ await codemods.makeUsingStub(stubsRoot, 'start/components.stub', {
+ filePath: command.app.startPath('components.ts'),
+ })
+
+ await codemods.updateRcFile((transformer) => {
+ transformer.addCommand('edgewire/commands')
+ transformer.addPreloadFile('#start/components')
+ transformer.addProvider('edgewire/providers/edgewire_provider')
+ })
+}
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..3b21353
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,3 @@
+import { configPkg } from '@adonisjs/eslint-config'
+
+export default configPkg()
diff --git a/index.ts b/index.ts
new file mode 100644
index 0000000..fe23552
--- /dev/null
+++ b/index.ts
@@ -0,0 +1,7 @@
+import 'reflect-metadata'
+
+export { configure } from './configure.js'
+
+export { defineConfig } from './src/define_config.js'
+export { Component } from './src/component.js'
+export { view } from './src/view.js'
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8c8f034
--- /dev/null
+++ b/package.json
@@ -0,0 +1,101 @@
+{
+ "name": "@foadonis/edgewire",
+ "description": "",
+ "version": "0.0.0",
+ "engines": {
+ "node": ">=20.6.0"
+ },
+ "type": "module",
+ "files": [
+ "build/src",
+ "build/providers",
+ "build/services",
+ "build/stubs",
+ "build/index.d.ts",
+ "build/index.js"
+ ],
+ "exports": {
+ ".": "./build/index.js",
+ "./types": "./build/src/types.js",
+ "./providers/*": "./build/providers/*.js",
+ "./services/*": "./build/services/*.js",
+ "./commands": "./build/commands/main.js"
+ },
+ "scripts": {
+ "clean": "del-cli build",
+ "copy:templates": "copyfiles \"stubs/**/*.stub\" build",
+ "index:commands": "adonis-kit index build/commands",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint . --ext=.ts",
+ "format": "prettier --write .",
+ "quick:test": "node --import=./tsnode.esm.js --enable-source-maps bin/test.ts",
+ "test": "c8 npm run quick:test",
+ "prebuild": "npm run lint && npm run clean",
+ "build": "tsc",
+ "dev": "tsc --watch",
+ "postbuild": "pnpm run copy:templates && pnpm run index:commands",
+ "release": "np",
+ "version": "npm run build",
+ "prepublishOnly": "npm run build"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "MIT",
+ "devDependencies": {
+ "@adonisjs/assembler": "^7.7.0",
+ "@adonisjs/core": "^6.12.0",
+ "@adonisjs/eslint-config": "^1.3.0",
+ "@adonisjs/prettier-config": "^1.3.0",
+ "@adonisjs/tsconfig": "^1.3.0",
+ "@japa/api-client": "^2.0.3",
+ "@japa/assert": "^3.0.0",
+ "@japa/plugin-adonisjs": "^3.0.1",
+ "@japa/runner": "^3.1.4",
+ "@swc/core": "^1.6.3",
+ "@types/lodash": "^4.17.7",
+ "@types/node": "^20.14.5",
+ "c8": "^10.1.2",
+ "copyfiles": "^2.4.1",
+ "del-cli": "^5.1.0",
+ "edge.js": "^6.0.2",
+ "eslint": "^8.57.0",
+ "html-entities": "^2.5.2",
+ "np": "^10.0.6",
+ "prettier": "^3.3.2",
+ "reflect-metadata": "^0.2.2",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.4.5"
+ },
+ "peerDependencies": {
+ "@adonisjs/core": "^6.2.0",
+ "edge.js": "^6.0.2",
+ "reflect-metadata": "^0.2.2"
+ },
+ "publishConfig": {
+ "access": "public",
+ "tag": "latest"
+ },
+ "np": {
+ "message": "chore(release): %s",
+ "tag": "latest",
+ "branch": "main",
+ "anyBranch": false
+ },
+ "c8": {
+ "reporter": [
+ "text",
+ "html"
+ ],
+ "exclude": [
+ "tests/**"
+ ]
+ },
+ "prettier": "@adonisjs/prettier-config",
+ "dependencies": {
+ "@adonisjs/shield": "^8.1.1",
+ "edge-error": "^4.0.1",
+ "edge-parser": "^9.0.2",
+ "lodash": "^4.17.21",
+ "ts-morph": "^23.0.0"
+ }
+}
diff --git a/providers/README.md b/providers/README.md
new file mode 100644
index 0000000..ac2ab06
--- /dev/null
+++ b/providers/README.md
@@ -0,0 +1,5 @@
+# The providers directory
+
+The `providers` directory contains the service providers exported by your application. Make sure to register these providers within the `exports` collection (aka package entrypoints) defined within the `package.json` file.
+
+Learn more about [package entrypoints](https://nodejs.org/api/packages.html#package-entry-points).
diff --git a/providers/edgewire_provider.ts b/providers/edgewire_provider.ts
new file mode 100644
index 0000000..d09bc99
--- /dev/null
+++ b/providers/edgewire_provider.ts
@@ -0,0 +1,24 @@
+import edge from 'edge.js'
+import { edgewireTag } from '../src/edge/tags/edgewire.js'
+import { ApplicationService } from '@adonisjs/core/types'
+import { ComponentRegistry } from '../src/component_registry.js'
+import { edgewireScriptsTag } from '../src/edge/tags/edgewire_scripts.js'
+
+export default class EdgewireProvider {
+ constructor(protected app: ApplicationService) {}
+
+ register() {
+ edge.registerTag(edgewireTag)
+ edge.registerTag(edgewireScriptsTag)
+
+ this.app.container.singleton(ComponentRegistry, () => {
+ return new ComponentRegistry()
+ })
+ }
+
+ async boot() {
+ await import('../src/extensions.js')
+ }
+
+ async start() {}
+}
diff --git a/services/edgewire.ts b/services/edgewire.ts
new file mode 100644
index 0000000..c58b3e4
--- /dev/null
+++ b/services/edgewire.ts
@@ -0,0 +1,10 @@
+import app from '@adonisjs/core/services/app'
+import { Edgewire } from '../src/edgewire.js'
+
+let edgewire: Edgewire
+
+await app.booted(async () => {
+ edgewire = await app.container.make(Edgewire)
+})
+
+export { edgewire as default }
diff --git a/src/component.ts b/src/component.ts
new file mode 100644
index 0000000..21569a8
--- /dev/null
+++ b/src/component.ts
@@ -0,0 +1,34 @@
+import { HttpContext } from '@adonisjs/core/http'
+import { View } from './view.js'
+import { compose } from '@adonisjs/core/helpers'
+import { WithAttributes } from './mixins/with_attributes.js'
+
+class BaseComponent {}
+
+export abstract class Component extends compose(BaseComponent, WithAttributes) {
+ #id: string
+ #name: string
+ #ctx: HttpContext
+
+ constructor(name: string, id: string, ctx: HttpContext) {
+ super()
+ this.#id = id
+ this.#name = name
+ this.#ctx = ctx
+ }
+
+ render?(): Promise
+ mount?(args: Record): void
+
+ public get id() {
+ return this.#id
+ }
+
+ public get name() {
+ return this.#name
+ }
+
+ public get ctx() {
+ return this.#ctx
+ }
+}
diff --git a/src/component_context.ts b/src/component_context.ts
new file mode 100644
index 0000000..acc0904
--- /dev/null
+++ b/src/component_context.ts
@@ -0,0 +1,18 @@
+import { Component } from './component.js'
+
+export class ComponentContext {
+ readonly isMounting: boolean
+ readonly component: Component
+
+ effects: any = {}
+ #memo = []
+
+ constructor(component: Component, isMounting = false) {
+ this.component = component
+ this.isMounting = isMounting
+ }
+
+ addEffect(key: string, value: string) {
+ this.effects[key] = value
+ }
+}
diff --git a/src/component_registry.ts b/src/component_registry.ts
new file mode 100644
index 0000000..56052f8
--- /dev/null
+++ b/src/component_registry.ts
@@ -0,0 +1,21 @@
+// TODO: Add ability to register component without name (infer name from class)
+import string from '@adonisjs/core/helpers/string'
+import { HttpContext } from '@adonisjs/core/http'
+
+export class ComponentRegistry {
+ #aliases = new Map()
+
+ public component(name: string, component: any) {
+ this.#aliases.set(name, component)
+ }
+
+ public new(ctx: HttpContext, name: string, id?: string) {
+ const Component = this.getClass(name)
+ const component = new Component(name, id ?? string.random(20), ctx)
+ return component
+ }
+
+ private getClass(name: string) {
+ return this.#aliases.get(name)
+ }
+}
diff --git a/src/define_config.ts b/src/define_config.ts
new file mode 100644
index 0000000..b1d0aa8
--- /dev/null
+++ b/src/define_config.ts
@@ -0,0 +1,5 @@
+import { EdgewireConfig } from './types.js'
+
+export function defineConfig(config: EdgewireConfig): EdgewireConfig {
+ return config
+}
diff --git a/src/edge/tags/edgewire.ts b/src/edge/tags/edgewire.ts
new file mode 100644
index 0000000..4d16f2f
--- /dev/null
+++ b/src/edge/tags/edgewire.ts
@@ -0,0 +1,88 @@
+import { TagContract } from 'edge.js/types'
+import { isSubsetOf, parseJsArg, unallowedExpression } from '../utils.js'
+import { Parser, expressions } from 'edge-parser'
+
+/**
+ * A list of allowed expressions for the component name
+ */
+const ALLOWED_EXPRESSION_FOR_COMPONENT_NAME = [
+ expressions.Identifier,
+ expressions.Literal,
+ expressions.LogicalExpression,
+ expressions.MemberExpression,
+ expressions.ConditionalExpression,
+ expressions.CallExpression,
+ expressions.TemplateLiteral,
+] as const
+
+/**
+ * Returns the component name and props by parsing the component jsArg expression
+ *
+ * @see https://github.com/edge-js/edge/blob/develop/src/tags/component.ts#L45
+ */
+function getComponentNameAndProps(
+ expression: any,
+ parser: Parser,
+ filename: string
+): [string, string] {
+ let name: string
+
+ /**
+ * Use the first expression inside the sequence expression as the name
+ * of the component
+ */
+ if (expression.type === expressions.SequenceExpression) {
+ name = expression.expressions.shift()
+ } else {
+ name = expression
+ }
+
+ /**
+ * Ensure the component name is a literal value or an expression that
+ * outputs a literal value
+ */
+ isSubsetOf(name, ALLOWED_EXPRESSION_FOR_COMPONENT_NAME, () => {
+ unallowedExpression(
+ `"${parser.utils.stringify(name)}" is not a valid argument for component name`,
+ filename,
+ parser.utils.getExpressionLoc(name)
+ )
+ })
+
+ /**
+ * Parse rest of sequence expressions as an objectified string.
+ */
+ if (expression.type === expressions.SequenceExpression) {
+ /**
+ * We only need to entertain the first expression of the sequence
+ * expression, as components allows a max of two arguments
+ */
+ const firstSequenceExpression = expression.expressions[0]
+ return [parser.utils.stringify(name), parser.utils.stringify(firstSequenceExpression)]
+ }
+
+ /**
+ * When top level expression is not a sequence expression, then we assume props
+ * as empty stringified object.
+ */
+ return [parser.utils.stringify(name), '{}']
+}
+
+export const edgewireTag: TagContract = {
+ block: false,
+ seekable: true,
+ tagName: 'edgewire',
+ compile(parser, buffer, token) {
+ const awaitKeyword = parser.asyncMode ? 'await ' : ''
+ const parsed = parseJsArg(parser, token)
+
+ const [name, props] = getComponentNameAndProps(parsed, parser, token.filename)
+
+ buffer.outputExpression(
+ `${awaitKeyword}template.edgewire.mount(${name})`,
+ token.filename,
+ token.loc.start.line,
+ false
+ )
+ },
+}
diff --git a/src/edge/tags/edgewire_scripts.ts b/src/edge/tags/edgewire_scripts.ts
new file mode 100644
index 0000000..41777a3
--- /dev/null
+++ b/src/edge/tags/edgewire_scripts.ts
@@ -0,0 +1,18 @@
+import edge, { edgeGlobals } from 'edge.js'
+import { TagContract } from 'edge.js/types'
+
+export const edgewireScriptsTag: TagContract = {
+ tagName: 'edgewireScripts',
+ seekable: true,
+ block: false,
+ compile(parser, buffer, token) {
+ const url = '/livewire.js'
+
+ const csrf = '' // TODO: Generate CSRF
+ const updateUri = '/edgewire/update'
+
+ buffer.outputRaw(
+ ``
+ )
+ },
+}
diff --git a/src/edge/utils.ts b/src/edge/utils.ts
new file mode 100644
index 0000000..258515f
--- /dev/null
+++ b/src/edge/utils.ts
@@ -0,0 +1,57 @@
+import { TagToken } from 'edge.js/types'
+import { expressions as expressionsList, Parser } from 'edge-parser'
+import { EdgeError } from 'edge-error'
+
+type ExpressionList = readonly (keyof typeof expressionsList | 'ObjectPattern' | 'ArrayPattern')[]
+
+/**
+ * Raise an `E_UNALLOWED_EXPRESSION` exception. Filename and expression is
+ * required to point the error stack to the correct file
+ *
+ * @see https://github.com/edge-js/edge/blob/develop/src/utils.ts#L87
+ */
+export function unallowedExpression(
+ message: string,
+ filename: string,
+ loc: { line: number; col: number }
+) {
+ throw new EdgeError(message, 'E_UNALLOWED_EXPRESSION', {
+ line: loc.line,
+ col: loc.col,
+ filename: filename,
+ })
+}
+
+/**
+ * Validates the expression type to be part of the allowed
+ * expressions only.
+ *
+ * The filename is required to report errors.
+ *
+ * ```js
+ * isNotSubsetOf(expression, ['Literal', 'Identifier'], () => {})
+ * ```
+ * @link https://github.com/edge-js/edge/blob/develop/src/utils.ts#L109
+ */
+export function isSubsetOf(
+ expression: any,
+ expressions: ExpressionList,
+ errorCallback: () => void
+) {
+ if (!expressions.includes(expression.type)) {
+ errorCallback()
+ }
+}
+
+/**
+ * Parses the jsArg by generating and transforming its AST
+ *
+ * @see https://github.com/edge-js/edge/blob/develop/src/utils.ts#L142
+ */
+export function parseJsArg(parser: Parser, token: TagToken) {
+ return parser.utils.transformAst(
+ parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename),
+ token.filename,
+ parser
+ )
+}
diff --git a/src/edgewire.ts b/src/edgewire.ts
new file mode 100644
index 0000000..1702d4e
--- /dev/null
+++ b/src/edgewire.ts
@@ -0,0 +1,39 @@
+import { inject } from '@adonisjs/core'
+import { ComponentRegistry } from './component_registry.js'
+import { HandleComponents } from './handle_components.js'
+import { HandleRequests } from './handle_requests.js'
+import { HttpContext } from '@adonisjs/core/http'
+import { View } from './view.js'
+
+@inject()
+export class Edgewire {
+ #componentRegistry: ComponentRegistry
+ #handleComponents: HandleComponents
+ #handleRequests: HandleRequests
+
+ constructor(
+ componentRegistry: ComponentRegistry,
+ handleComponents: HandleComponents,
+ handleRequests: HandleRequests
+ ) {
+ this.#componentRegistry = componentRegistry
+ this.#handleComponents = handleComponents
+ this.#handleRequests = handleRequests
+ }
+
+ component(name: string, component: any) {
+ this.#componentRegistry.component(name, component)
+ }
+
+ mount(name: string, ctx: HttpContext) {
+ return this.#handleComponents.mount(name, ctx)
+ }
+
+ handleUpdate(ctx: HttpContext) {
+ return this.#handleRequests.handleUpdate(ctx)
+ }
+
+ view(templatePath: string, state: Record = {}) {
+ return View.template(templatePath, state)
+ }
+}
diff --git a/src/errors.ts b/src/errors.ts
new file mode 100644
index 0000000..9c8a550
--- /dev/null
+++ b/src/errors.ts
@@ -0,0 +1,7 @@
+import { createError } from '@adonisjs/core/exceptions'
+
+export const E_INVALID_CHECKSUM = createError<[string]>(
+ 'Invalid checksum for component "%s"',
+ 'E_INVALID_CHECKSUM',
+ 403
+)
diff --git a/src/events.ts b/src/events.ts
new file mode 100644
index 0000000..4a2e0cb
--- /dev/null
+++ b/src/events.ts
@@ -0,0 +1,12 @@
+import { Component } from './component.js'
+import { ComponentContext } from './component_context.js'
+import { View } from './view.js'
+
+declare module '@adonisjs/core/types' {
+ interface EventsList {
+ 'edgewire:render': { component: Component; view: View; properties: Record }
+ 'edgewire:render:after': { component: Component; view: View; properties: Record }
+
+ 'edgewire:hydrate': { component: Component; context: ComponentContext }
+ }
+}
diff --git a/src/extensions.ts b/src/extensions.ts
new file mode 100644
index 0000000..6c62dd0
--- /dev/null
+++ b/src/extensions.ts
@@ -0,0 +1,27 @@
+import { Template } from 'edge.js'
+import { Edgewire } from './edgewire.js'
+import edgewire from '../services/edgewire.js'
+import { Application } from '@adonisjs/core/app'
+
+// TODO: Might want to avoid using global
+Template.getter('edgewire', function (this: Template) {
+ return edgewire
+})
+
+Application.macro('componentsPath', function <
+ T extends Record,
+>(this: Application, ...paths: string[]) {
+ return this.makePath('app', 'components', ...paths)
+})
+
+declare module 'edge.js' {
+ interface Template {
+ edgewire: Edgewire
+ }
+}
+
+declare module '@adonisjs/core/app' {
+ interface Application> {
+ componentsPath(...paths: string[]): string
+ }
+}
diff --git a/src/handle_components.ts b/src/handle_components.ts
new file mode 100644
index 0000000..2fd9ea9
--- /dev/null
+++ b/src/handle_components.ts
@@ -0,0 +1,182 @@
+import { inject } from '@adonisjs/core'
+import { Component } from './component.js'
+import { ComponentRegistry } from './component_registry.js'
+import { insertAttributesIntoHtmlRoot } from './utils.js'
+import { ComponentSnapshot, ComponentCall, ComponentUpdates } from './types.js'
+import { ComponentContext } from './component_context.js'
+import { E_INVALID_CHECKSUM } from './errors.js'
+import { generateChecksum, verifyChecksum } from './utils/checksum.js'
+import { View } from './view.js'
+import string from '@adonisjs/core/helpers/string'
+import { getPublicProperties } from './utils/object.js'
+import { HttpContext } from '@adonisjs/core/http'
+import app from '@adonisjs/core/services/app'
+import emitter from '@adonisjs/core/services/emitter'
+
+@inject()
+export class HandleComponents {
+ #componentsRegistry: ComponentRegistry
+
+ constructor(edgewire: ComponentRegistry) {
+ this.#componentsRegistry = edgewire
+ }
+
+ async mount(name: string, ctx: HttpContext) {
+ const component = this.#componentsRegistry.new(ctx, name)
+ const context = new ComponentContext(component, true)
+
+ let html = await this.#render(component)
+
+ emitter.emit('edgewire:hydrate', { component, context })
+
+ html = insertAttributesIntoHtmlRoot(html, {
+ 'wire:effects': [],
+ 'wire:snapshot': this.#snapshot(component),
+ })
+
+ return html
+ }
+
+ async update(
+ snapshot: ComponentSnapshot,
+ updates: ComponentUpdates,
+ calls: ComponentCall[],
+ ctx: HttpContext
+ ) {
+ const { component, context } = this.#fromSnapshot(snapshot, ctx)
+ const { data, memo } = snapshot
+
+ this.#updateProperties(component, updates, data, context)
+ this.#callMethods(component, calls, context)
+
+ const newSnapshot = this.#snapshot(component, context)
+
+ let html = await this.#render(component)
+ html = insertAttributesIntoHtmlRoot(html, {
+ 'wire:effects': [],
+ 'wire:snapshot': newSnapshot,
+ })
+
+ return { snapshot: newSnapshot, effects: context.effects }
+ }
+
+ async #getView(component: Component) {
+ let view: View
+ const viewPath = app.config.get('edgewire.viewPath')
+ const properties = getPublicProperties(component)
+ if (component.render) {
+ const output = await component.render()
+ if (typeof output === 'string') {
+ view = View.raw(output, properties)
+ } else {
+ view = output.with(properties)
+ }
+ } else {
+ const name = string.create(component.name).removeSuffix('component').dashCase().toString()
+ view = View.template(`${viewPath}/${name}`, properties)
+ }
+
+ return { view, properties }
+ }
+
+ async #render(component: Component, _default?: string): Promise {
+ const { view, properties } = await this.#getView(component)
+
+ emitter.emit('edgewire:render', { component, view, properties })
+
+ let html = await view.render()
+ html = insertAttributesIntoHtmlRoot(html, {
+ 'wire:id': component.id,
+ })
+
+ emitter.emit('edgewire:render:after', { component, view, properties })
+
+ return html
+ }
+
+ #snapshot(component: Component, context?: ComponentContext): ComponentSnapshot {
+ const data = this.#dehydrateProperties(component, context)
+
+ const snapshot: Omit = {
+ data,
+ memo: {
+ id: component.id,
+ name: component.name,
+ children: [],
+ },
+ }
+
+ return {
+ ...snapshot,
+ checksum: generateChecksum(JSON.stringify(snapshot)),
+ }
+ }
+
+ #fromSnapshot(snapshot: ComponentSnapshot, ctx: HttpContext) {
+ const { checksum, ..._snapshot } = snapshot
+
+ if (!verifyChecksum(JSON.stringify(_snapshot), checksum)) {
+ throw new E_INVALID_CHECKSUM([snapshot.memo.name])
+ }
+
+ const component = this.#componentsRegistry.new(ctx, snapshot.memo.name, snapshot.memo.id)
+ const context = new ComponentContext(component)
+
+ this.#hydrateProperties(component, snapshot.data, context)
+
+ return { component, context }
+ }
+
+ #dehydrateProperties(component: Component, context?: ComponentContext) {
+ const data: any = {}
+
+ for (const propertyName of Object.getOwnPropertyNames(component)) {
+ // @ts-ignore
+ data[propertyName] = component[propertyName]
+ }
+
+ return data
+ }
+
+ #hydrateProperties(
+ component: Component,
+ data: ComponentSnapshot['data'],
+ context: ComponentContext
+ ) {
+ for (const [key, value] of Object.entries(data)) {
+ // TODO: Check if property exists
+
+ // @ts-ignore
+ component[key] = value
+ }
+ }
+
+ #updateProperties(
+ component: Component,
+ updates: ComponentUpdates,
+ data: ComponentSnapshot['data'],
+ context: ComponentContext
+ ) {
+ for (const [path, value] of Object.entries(updates)) {
+ this.#updateProperty(component, path, value, context)
+ }
+ }
+
+ #updateProperty(component: Component, path: string, value: string, context: ComponentContext) {
+ // TODO: Handle path segments
+ // @ts-ignore
+ component[path] = value
+ }
+
+ #callMethods(component: Component, calls: ComponentCall[], context: ComponentContext) {
+ const returns = []
+ for (const call of calls) {
+ const { method, params } = call
+
+ // @ts-ignore
+ component[method](...params)
+ }
+
+ // TODO: Add context effect
+ }
+}
diff --git a/src/handle_requests.ts b/src/handle_requests.ts
new file mode 100644
index 0000000..28d1cba
--- /dev/null
+++ b/src/handle_requests.ts
@@ -0,0 +1,34 @@
+import { HttpContext } from '@adonisjs/core/http'
+import { HandleComponents } from './handle_components.js'
+import { inject } from '@adonisjs/core'
+
+@inject()
+export class HandleRequests {
+ #handleComponents: HandleComponents
+
+ constructor(handleComponents: HandleComponents) {
+ this.#handleComponents = handleComponents
+ }
+
+ public async handleUpdate(ctx: HttpContext) {
+ const payloads = ctx.request.body().components // TODO: Type this
+
+ const componentResponses = []
+ for (const payload of payloads) {
+ const { snapshot, effects } = await this.#handleComponents.update(
+ JSON.parse(payload.snapshot),
+ payload.updates,
+ payload.calls,
+ ctx
+ )
+
+ componentResponses.push({ snapshot: JSON.stringify(snapshot), effects })
+ }
+
+ const response = {
+ components: componentResponses,
+ }
+
+ return response
+ }
+}
diff --git a/src/mixins/with_attributes.ts b/src/mixins/with_attributes.ts
new file mode 100644
index 0000000..8ca610b
--- /dev/null
+++ b/src/mixins/with_attributes.ts
@@ -0,0 +1,5 @@
+import { Constructor } from '../types.js'
+
+export function WithAttributes(Base: T) {
+ return class WithAttributes extends Base {}
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..4d8f9a3
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,25 @@
+export type Constructor = new (...args: any[]) => T
+
+export type EdgewireConfig = {
+ viewPath: string
+}
+
+export type ComponentSnapshot = {
+ data: any
+ checksum: string
+ memo: {
+ id: string
+ name: string
+ [key: string]: any
+ }
+}
+
+export type ComponentUpdates = Record
+
+export type ComponentEffect = any
+
+export type ComponentCall = {
+ path: string | null
+ method: string
+ params: any[]
+}
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..0e7aa00
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,31 @@
+import { edgeGlobals } from 'edge.js'
+
+/**
+ * @see https://github.com/livewire/livewire/blob/main/src/Drawer/Utils.php#L13
+ */
+export function insertAttributesIntoHtmlRoot(html: string, attributes: Record) {
+ // TODO: avoid mutating attributes
+ for (const [key, value] of Object.entries(attributes)) {
+ if (typeof value === 'object') {
+ attributes[key] = edgeGlobals.html.escape(JSON.stringify(value))
+ }
+ }
+
+ const attributesStr = edgeGlobals.html.attrs(attributes).value
+
+ const regex = new RegExp(/(?:\n\s*|^\s*)<([a-zA-Z0-9\-]+)/)
+
+ const matches = html.match(regex)
+
+ if (!matches) {
+ // TODO: Error
+ throw new Error('Missing root')
+ }
+
+ const leftEndAt = matches[1].length + 1
+
+ const left = html.slice(0, leftEndAt)
+ const right = html.slice(leftEndAt)
+
+ return `${left} ${attributesStr}${right}`
+}
diff --git a/src/utils/checksum.ts b/src/utils/checksum.ts
new file mode 100644
index 0000000..75c7cc7
--- /dev/null
+++ b/src/utils/checksum.ts
@@ -0,0 +1,9 @@
+import crypto from 'node:crypto'
+
+export function verifyChecksum(content: string, checksum: string) {
+ return true
+}
+
+export function generateChecksum(value: string) {
+ return crypto.createHash('md5').update(value, 'utf8').digest('hex')
+}
diff --git a/src/utils/object.ts b/src/utils/object.ts
new file mode 100644
index 0000000..1bee58e
--- /dev/null
+++ b/src/utils/object.ts
@@ -0,0 +1,9 @@
+export function getPublicProperties(object: any) {
+ const output: Record = {}
+
+ for (const propertyName of Object.getOwnPropertyNames(object)) {
+ output[propertyName] = object[propertyName]
+ }
+
+ return output
+}
diff --git a/src/view.ts b/src/view.ts
new file mode 100644
index 0000000..3652fed
--- /dev/null
+++ b/src/view.ts
@@ -0,0 +1,41 @@
+import edge from 'edge.js'
+
+export class View {
+ constructor(
+ private templatePath?: string,
+ private content?: string,
+ private state: Record = {}
+ ) {}
+
+ render() {
+ if (this.templatePath) {
+ return edge.render(this.templatePath, this.state)
+ }
+
+ if (this.content) {
+ return edge.renderRaw(this.content, this.state)
+ }
+
+ throw new Error('THis should not happen')
+ }
+
+ with(state: Record) {
+ this.state = {
+ ...this.state,
+ ...state,
+ }
+ return this
+ }
+
+ static raw(content: string, state: Record = {}) {
+ return new View(undefined, content, state)
+ }
+
+ static template(templatePath: string, state: Record = {}) {
+ return new View(templatePath, undefined, state)
+ }
+}
+
+export function view(templatePath: string, state: Record = {}) {
+ return View.template(templatePath, state)
+}
diff --git a/stubs/README.md b/stubs/README.md
new file mode 100644
index 0000000..4dd2dac
--- /dev/null
+++ b/stubs/README.md
@@ -0,0 +1,6 @@
+# The stubs directory
+
+The `stubs` directory stores all the stubs needed by your package. It could be config files you will publish during the initial setup or stubs you want to use within the scaffolding commands.
+
+- Inside the `package.json` file, we have defined a `copy:templates` script that copies the `stubs` folder to the `build` folder.
+- Ensure the `build/stubs` are always published to npm via the `files` array inside the `package.json` file.
diff --git a/stubs/main.ts b/stubs/main.ts
new file mode 100644
index 0000000..361d6cd
--- /dev/null
+++ b/stubs/main.ts
@@ -0,0 +1,8 @@
+import { dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+/**
+ * Path to the root directory where the stubs are stored. We use
+ * this path within commands and the configure hook
+ */
+export const stubsRoot = dirname(fileURLToPath(import.meta.url))
diff --git a/stubs/make/edgewire/component.stub b/stubs/make/edgewire/component.stub
new file mode 100644
index 0000000..24c0e7e
--- /dev/null
+++ b/stubs/make/edgewire/component.stub
@@ -0,0 +1,12 @@
+{{{
+ exports({
+ to: app.componentsPath(component.path, component.fileName)
+ })
+}}}
+import { Component, view } from 'edgewire'
+
+export default class {{ component.className }} extends Component {
+ render() {
+ return view('{{ view.templatePath }}')
+ }
+}
diff --git a/stubs/make/edgewire/view.stub b/stubs/make/edgewire/view.stub
new file mode 100644
index 0000000..a3f5737
--- /dev/null
+++ b/stubs/make/edgewire/view.stub
@@ -0,0 +1,6 @@
+{{{
+ exports({
+ to: app.viewsPath(view.path, view.fileName)
+ })
+}}}
+
diff --git a/stubs/start/components.stub b/stubs/start/components.stub
new file mode 100644
index 0000000..87f12e4
--- /dev/null
+++ b/stubs/start/components.stub
@@ -0,0 +1,6 @@
+{{{
+ exports({
+ to: filePath
+ })
+}}}
+import edgewire from 'edgewire/services/edgewire'
diff --git a/tests/bootstrap.ts b/tests/bootstrap.ts
new file mode 100644
index 0000000..0e2ce23
--- /dev/null
+++ b/tests/bootstrap.ts
@@ -0,0 +1,27 @@
+import 'reflect-metadata'
+import { Config } from '@japa/runner/types'
+import { assert } from '@japa/assert'
+import { apiClient } from '@japa/api-client'
+import { pluginAdonisJS } from '@japa/plugin-adonisjs'
+import app from '@adonisjs/core/services/app'
+import testUtils from '@adonisjs/core/services/test_utils'
+
+export const plugins: Config['plugins'] = [
+ assert(),
+ apiClient({ baseURL: 'http://localhost:3332' }),
+ pluginAdonisJS(app, { baseURL: './tmp' }),
+]
+
+export const runnerHooks: Required> = {
+ setup: [],
+ teardown: [],
+}
+
+export const configureSuite: Config['configureSuite'] = (suite) => {
+ if (['functional'].includes(suite.name)) {
+ return suite.setup(() => {
+ console.log('test')
+ return testUtils.httpServer().start()
+ })
+ }
+}
diff --git a/tests/functional/example.spec.ts b/tests/functional/example.spec.ts
new file mode 100644
index 0000000..f672559
--- /dev/null
+++ b/tests/functional/example.spec.ts
@@ -0,0 +1,12 @@
+import 'reflect-metadata'
+import { test } from '@japa/runner'
+import { Testable } from '../testable.js'
+import { TestComponent } from '../utils/test_component.js'
+
+class HelloComponent extends TestComponent {}
+
+test.group('Example', () => {
+ test('add two numbers', async ({ assert, client }) => {
+ await Testable.create(client, HelloComponent, {})
+ })
+})
diff --git a/tests/testable.ts b/tests/testable.ts
new file mode 100644
index 0000000..6b3f1c2
--- /dev/null
+++ b/tests/testable.ts
@@ -0,0 +1,46 @@
+import emitter from '@adonisjs/core/services/emitter'
+import edgewire from '../services/edgewire.js'
+import { Component } from '../src/component.js'
+import string from '@adonisjs/core/helpers/string'
+import { ApiClient } from '@japa/api-client'
+import { View } from '../src/view.js'
+import { ComponentState } from './utils/component_state.js'
+import { extractAttributeDataFromHtml } from './utils/html.js'
+
+export class Testable {
+ static async create(
+ client: ApiClient,
+ component: new (...args: any[]) => Component,
+ params: Record
+ ) {
+ const name = string.random(16)
+
+ edgewire.component(name, component)
+
+ await this.initialRender(client, name, params)
+ }
+
+ private static async initialRender(client: ApiClient, name: string, params: Record) {
+ const p = [
+ new Promise((res) => {
+ emitter.once('edgewire:render', ({ view }) => {
+ res(view)
+ })
+ }),
+ new Promise((res) => {
+ emitter.once('edgewire:hydrate', ({ component }) => {
+ res(component)
+ })
+ }),
+ ] as const
+
+ const response = await client.get('/edgewire/test').qs('name', name).qs('params', params)
+
+ const [view, component] = await Promise.all(p)
+
+ const snapshot = extractAttributeDataFromHtml(response.text(), 'wire:snapshot')
+ const effects = extractAttributeDataFromHtml(response.text(), 'wire:effects')
+
+ return new ComponentState(component, response, view, snapshot, effects)
+ }
+}
diff --git a/tests/utils/component_state.ts b/tests/utils/component_state.ts
new file mode 100644
index 0000000..1e7d17c
--- /dev/null
+++ b/tests/utils/component_state.ts
@@ -0,0 +1,14 @@
+import { ApiResponse } from '@japa/api-client'
+import { Component } from '../../src/component.js'
+import { View } from '../../src/view.js'
+import { ComponentEffect, ComponentSnapshot } from '../../src/types.js'
+
+export class ComponentState {
+ constructor(
+ public component: Component,
+ public response: ApiResponse,
+ public view: View,
+ public snapshot: ComponentSnapshot,
+ public effects: ComponentEffect[]
+ ) {}
+}
diff --git a/tests/utils/html.ts b/tests/utils/html.ts
new file mode 100644
index 0000000..9612396
--- /dev/null
+++ b/tests/utils/html.ts
@@ -0,0 +1,12 @@
+import { decode } from 'html-entities'
+
+export function extractAttributeDataFromHtml(html: string, attribute: string) {
+ const regex = new RegExp(`${attribute}="([^"]+)"`)
+ const data = html.match(regex)
+
+ if (!data?.length) {
+ throw new Error('Attribute not found')
+ }
+
+ return JSON.parse(decode(data[1]))
+}
diff --git a/tests/utils/render.ts b/tests/utils/render.ts
new file mode 100644
index 0000000..faca95e
--- /dev/null
+++ b/tests/utils/render.ts
@@ -0,0 +1 @@
+export class Render {}
diff --git a/tests/utils/test_component.ts b/tests/utils/test_component.ts
new file mode 100644
index 0000000..6d4adbe
--- /dev/null
+++ b/tests/utils/test_component.ts
@@ -0,0 +1,7 @@
+import { Component } from '../../src/component.js'
+
+export class TestComponent extends Component {
+ async render() {
+ return ''
+ }
+}
diff --git a/tests/views/test.edge b/tests/views/test.edge
new file mode 100644
index 0000000..d58db9e
--- /dev/null
+++ b/tests/views/test.edge
@@ -0,0 +1,2 @@
+
+
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..9174cdc
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@adonisjs/tsconfig/tsconfig.package.json",
+ "compilerOptions": {
+ "rootDir": "./",
+ "outDir": "./build",
+ "emitDecoratorMetadata": true,
+ "experimentalDecorators": true
+ }
+}
diff --git a/tsnode.esm.js b/tsnode.esm.js
new file mode 100644
index 0000000..0cce922
--- /dev/null
+++ b/tsnode.esm.js
@@ -0,0 +1,18 @@
+/*
+|--------------------------------------------------------------------------
+| TS-Node ESM hook
+|--------------------------------------------------------------------------
+|
+| Importing this file before any other file will allow you to run TypeScript
+| code directly using TS-Node + SWC. For example
+|
+| node --import="./tsnode.esm.js" bin/test.ts
+| node --import="./tsnode.esm.js" index.ts
+|
+|
+| Why not use "--loader=ts-node/esm"?
+| Because, loaders have been deprecated.
+*/
+
+import { register } from 'node:module'
+register('ts-node/esm', import.meta.url)