├── .editorconfig ├── .eslintrc.js ├── .github ├── lock.yml ├── stale.yml └── workflows │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── bin └── test.ts ├── commands └── make_edgewire.ts ├── configure.ts ├── eslint.config.js ├── index.ts ├── package.json ├── providers ├── README.md └── edgewire_provider.ts ├── services └── edgewire.ts ├── src ├── component │ ├── context.ts │ ├── main.ts │ ├── manager.ts │ └── registry.ts ├── component_hook │ ├── main.ts │ └── registry.ts ├── define_config.ts ├── edge │ ├── tags │ │ ├── edgewire.ts │ │ └── edgewire_scripts.ts │ └── utils.ts ├── edgewire.ts ├── errors.ts ├── events.ts ├── extensions.ts ├── features │ ├── lifecycle │ │ ├── component_hook.ts │ │ └── mixins │ │ │ └── lifecycle_hooks.ts │ └── validation │ │ ├── component_hook.ts │ │ └── handles_validation.ts ├── mixins │ ├── with_attributes.ts │ └── with_lifecycle_hooks.ts ├── request │ └── manager.ts ├── types.ts ├── utils.ts ├── utils │ ├── checksum.ts │ └── object.ts ├── view.ts └── view_context.ts ├── stubs ├── README.md ├── main.ts ├── make │ └── edgewire │ │ ├── component.stub │ │ └── view.stub └── start │ └── components.stub ├── tests ├── bootstrap.ts ├── functional │ └── example.spec.ts ├── testable.ts ├── utils │ ├── component_state.ts │ ├── html.ts │ ├── render.ts │ └── test_component.ts └── views │ └── test.edge ├── tsconfig.json └── tsnode.esm.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [**.min.js] 15 | indent_style = ignore 16 | insert_final_newline = ignore 17 | 18 | [MakeFile] 19 | indent_style = space 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | extends: ['@foa/eslint-config/adonis-package.js'], 5 | parser: '@typescript-eslint/parser', 6 | parserOptions: { 7 | project: './tsconfig.lint.json', 8 | tsconfigRootDir: __dirname, 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ignoreUnless: {{ STALE_BOT }} 3 | --- 4 | # Configuration for Lock Threads - https://github.com/dessant/lock-threads-app 5 | 6 | # Number of days of inactivity before a closed issue or pull request is locked 7 | daysUntilLock: 60 8 | 9 | # Skip issues and pull requests created before a given timestamp. Timestamp must 10 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable 11 | skipCreatedBefore: false 12 | 13 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable 14 | exemptLabels: ['Type: Security'] 15 | 16 | # Label to add before locking, such as `outdated`. Set to `false` to disable 17 | lockLabel: false 18 | 19 | # Comment to post before locking. Set to `false` to disable 20 | lockComment: > 21 | This thread has been automatically locked since there has not been 22 | any recent activity after it was closed. Please open a new issue for 23 | related bugs. 24 | 25 | # Assign `resolved` as the reason for locking. Set to `false` to disable 26 | setLockReason: false 27 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ignoreUnless: {{ STALE_BOT }} 3 | --- 4 | # Number of days of inactivity before an issue becomes stale 5 | daysUntilStale: 60 6 | 7 | # Number of days of inactivity before a stale issue is closed 8 | daysUntilClose: 7 9 | 10 | # Issues with these labels will never be considered stale 11 | exemptLabels: 12 | - 'Type: Security' 13 | 14 | # Label to use when marking an issue as stale 15 | staleLabel: 'Status: Abandoned' 16 | 17 | # Comment to post when marking an issue as stale. Set to `false` to disable 18 | markComment: > 19 | This issue has been automatically marked as stale because it has not had 20 | recent activity. It will be closed if no further activity occurs. Thank you 21 | for your contributions. 22 | 23 | # Comment to post when closing a stale issue. Set to `false` to disable 24 | closeComment: false 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Install 13 | run: npm install 14 | - name: Run lint 15 | run: npm run lint 16 | 17 | typecheck: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Install 22 | run: npm install 23 | - name: Run typecheck 24 | run: npm run typecheck 25 | 26 | tests: 27 | runs-on: ${{ matrix.os }} 28 | strategy: 29 | matrix: 30 | os: [ubuntu-latest, windows-latest] 31 | node-version: 32 | - 20.10.0 33 | - 21.x 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Use Node.js ${{ matrix.node-version }} 37 | uses: actions/setup-node@v1 38 | with: 39 | node-version: ${{ matrix.node-version }} 40 | - name: Install 41 | run: npm install 42 | - name: Run tests 43 | run: npm test 44 | windows: 45 | runs-on: windows-latest 46 | strategy: 47 | matrix: 48 | node-version: 49 | - 20.10.0 50 | - 21.x 51 | steps: 52 | - uses: actions/checkout@v2 53 | - name: Use Node.js ${{ matrix.node-version }} 54 | uses: actions/setup-node@v1 55 | with: 56 | node-version: ${{ matrix.node-version }} 57 | - name: Install 58 | run: npm install 59 | - name: Run tests 60 | run: npm test 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_STORE 4 | .nyc_output 5 | .idea 6 | .vscode/ 7 | *.sublime-project 8 | *.sublime-workspace 9 | *.log 10 | build 11 | dist 12 | yarn.lock 13 | shrinkwrap.yaml 14 | package-lock.json 15 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | coverage 4 | *.html 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2023 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | ## @foadonis/edgewire 5 | 6 | ### Build reactive interfaces without leaving Adonis.js 7 | 8 |
9 |
10 | 11 |
12 | 13 | [![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) 14 | 15 |
16 | 17 | ## Description 18 | 19 | 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. 20 | 21 | ## Quickstart 22 | 23 | [Installation & Getting Started](https://friendsofadonis.github.io/docs/edgewire/introduction) 24 | 25 | ## License 26 | 27 | [MIT licensed](LICENSE.md). 28 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Test runner entrypoint 4 | |-------------------------------------------------------------------------- 5 | | 6 | | The "test.ts" file is the entrypoint for running tests using Japa. 7 | | 8 | | Either you can run this file directly or use the "test" 9 | | command to run this file and monitor file changes. 10 | | 11 | */ 12 | 13 | process.env.NODE_ENV = 'test' 14 | process.env.PORT = '3332' 15 | 16 | import 'reflect-metadata' 17 | import { prettyPrintError } from '@adonisjs/core' 18 | import { configure, processCLIArgs, run } from '@japa/runner' 19 | import { IgnitorFactory } from '@adonisjs/core/factories' 20 | import { HttpContext } from '@adonisjs/core/http' 21 | import edge from 'edge.js' 22 | 23 | /** 24 | * URL to the application root. AdonisJS need it to resolve 25 | * paths to file and directories for scaffolding commands 26 | */ 27 | const APP_ROOT = new URL('../tmp', import.meta.url) 28 | 29 | /** 30 | * The importer is used to import files in context of the 31 | * application. 32 | */ 33 | const IMPORTER = (filePath: string) => { 34 | if (filePath.startsWith('./') || filePath.startsWith('../')) { 35 | return import(new URL(filePath, APP_ROOT).href) 36 | } 37 | return import(filePath) 38 | } 39 | 40 | new IgnitorFactory() 41 | .merge({ 42 | rcFileContents: { 43 | providers: [ 44 | () => import('../providers/edgewire_provider.js'), 45 | () => import('@adonisjs/core/providers/edge_provider'), 46 | ], 47 | }, 48 | }) 49 | .withCoreConfig() 50 | .withCoreProviders() 51 | .create(APP_ROOT, { importer: IMPORTER }) 52 | .tap((app) => { 53 | app.booting(async () => {}) 54 | app.starting(async () => { 55 | app.config.set('edgewire.viewPath', 'viewPath') 56 | 57 | const router = await app.container.make('router') 58 | router.get('/edgewire/test', ({ request }: HttpContext) => { 59 | const { name, params } = request.qs() 60 | return edge.renderRaw(`@!edgewire('${name}')`) 61 | }) 62 | }) 63 | app.listen('SIGTERM', () => app.terminate()) 64 | app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate()) 65 | }) 66 | .testRunner() 67 | .configure(async (app) => { 68 | const { runnerHooks, ...config } = await import('../tests/bootstrap.js') 69 | 70 | processCLIArgs(process.argv.splice(2)) 71 | configure({ 72 | suites: [ 73 | { 74 | name: 'functional', 75 | files: ['tests/functional/**/*.spec.(js|ts)'], 76 | }, 77 | { 78 | name: 'unit', 79 | files: ['tests/unit/**/*.spec.(js|ts)'], 80 | }, 81 | ], 82 | ...config, 83 | ...{ 84 | setup: runnerHooks.setup, 85 | teardown: runnerHooks.teardown.concat([() => app.terminate()]), 86 | }, 87 | }) 88 | }) 89 | .run(() => run()) 90 | .catch((error) => { 91 | process.exitCode = 1 92 | prettyPrintError(error) 93 | }) 94 | -------------------------------------------------------------------------------- /commands/make_edgewire.ts: -------------------------------------------------------------------------------- 1 | import { args, BaseCommand } from '@adonisjs/core/ace' 2 | import { CommandOptions } from '@adonisjs/core/types/ace' 3 | import { stubsRoot } from '../stubs/main.js' 4 | import string from '@adonisjs/core/helpers/string' 5 | import path from 'node:path' 6 | 7 | export default class MakeEdgewire extends BaseCommand { 8 | static commandName = 'make:edgewire' 9 | static description = 'Make a new Edgewire component' 10 | static options: CommandOptions = { 11 | startApp: true, 12 | allowUnknownFlags: true, 13 | } 14 | 15 | @args.string({ description: 'Name of the migration file' }) 16 | declare name: string 17 | 18 | async run() { 19 | const codemods = await this.createCodemods() 20 | 21 | const component = this.createComponent() 22 | const view = this.createView() 23 | 24 | await codemods.makeUsingStub(stubsRoot, 'make/edgewire/component.stub', { 25 | component, 26 | view, 27 | }) 28 | 29 | await codemods.makeUsingStub(stubsRoot, 'make/edgewire/view.stub', { 30 | component, 31 | view, 32 | }) 33 | 34 | const morph = await codemods.getTsMorphProject() 35 | 36 | if (!morph) { 37 | this.logger.warning( 38 | 'An issue occured when retrieving ts-morph. start/view.ts has not been updated' 39 | ) 40 | return 41 | } 42 | 43 | const startView = morph.getSourceFileOrThrow('start/view.ts') 44 | 45 | startView.addImportDeclaration({ 46 | moduleSpecifier: component.importPath, 47 | defaultImport: component.className, 48 | }) 49 | 50 | startView.addStatements(`edgewire.component('${component.name}', ${component.className})`) 51 | 52 | await startView.save() 53 | } 54 | 55 | createView() { 56 | return { 57 | path: 'edgewire', 58 | fileName: string.create(this.name).snakeCase().ext('.edge').toString(), 59 | templatePath: path.join('edgewire', string.create(this.name).snakeCase().toString()), 60 | } 61 | } 62 | 63 | createComponent() { 64 | return { 65 | path: '', 66 | name: string.create(this.name).snakeCase().toString(), 67 | className: string.create(this.name).pascalCase().suffix('Component').toString(), 68 | fileName: string.create(this.name).snakeCase().ext('.ts').toString(), 69 | importPath: ['#components', string.create(this.name).snakeCase().toString()].join('/'), 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /configure.ts: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | Configure hook 4 | |-------------------------------------------------------------------------- 5 | | 6 | | The configure hook is called when someone runs "node ace configure " 7 | | command. You are free to perform any operations inside this function to 8 | | configure the package. 9 | | 10 | | To make things easier, you have access to the underlying "ConfigureCommand" 11 | | instance and you can use codemods to modify the source files. 12 | | 13 | */ 14 | 15 | import ConfigureCommand from '@adonisjs/core/commands/configure' 16 | import { stubsRoot } from './stubs/main.js' 17 | 18 | export async function configure(command: ConfigureCommand) { 19 | const codemods = await command.createCodemods() 20 | 21 | await codemods.installPackages([ 22 | { 23 | name: 'edge', 24 | isDevDependency: false, 25 | }, 26 | ]) 27 | 28 | await codemods.makeUsingStub(stubsRoot, 'start/components.stub', { 29 | filePath: command.app.startPath('components.ts'), 30 | }) 31 | 32 | await codemods.updateRcFile((transformer) => { 33 | transformer.addCommand('edgewire/commands') 34 | transformer.addPreloadFile('#start/components') 35 | transformer.addProvider('edgewire/providers/edgewire_provider') 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | 3 | export default configPkg() 4 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | export { configure } from './configure.js' 4 | 5 | export { defineConfig } from './src/define_config.js' 6 | export { Component } from './src/component/main.js' 7 | export { view } from './src/view.js' 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@foadonis/edgewire", 3 | "description": "", 4 | "version": "0.0.0", 5 | "engines": { 6 | "node": ">=20.6.0" 7 | }, 8 | "type": "module", 9 | "files": [ 10 | "build/src", 11 | "build/providers", 12 | "build/services", 13 | "build/stubs", 14 | "build/index.d.ts", 15 | "build/index.js" 16 | ], 17 | "exports": { 18 | ".": "./build/index.js", 19 | "./types": "./build/src/types.js", 20 | "./providers/*": "./build/providers/*.js", 21 | "./services/*": "./build/services/*.js", 22 | "./commands": "./build/commands/main.js" 23 | }, 24 | "scripts": { 25 | "clean": "del-cli build", 26 | "copy:templates": "copyfiles \"stubs/**/*.stub\" build", 27 | "index:commands": "adonis-kit index build/commands", 28 | "typecheck": "tsc --noEmit", 29 | "lint": "eslint . --ext=.ts", 30 | "format": "prettier --write .", 31 | "quick:test": "node --import=./tsnode.esm.js --enable-source-maps bin/test.ts", 32 | "test": "c8 npm run quick:test", 33 | "prebuild": "npm run lint && npm run clean", 34 | "build": "tsc", 35 | "dev": "tsc --watch", 36 | "postbuild": "pnpm run copy:templates && pnpm run index:commands", 37 | "release": "np", 38 | "version": "npm run build", 39 | "prepublishOnly": "npm run build" 40 | }, 41 | "keywords": [], 42 | "author": "", 43 | "license": "MIT", 44 | "devDependencies": { 45 | "@adonisjs/assembler": "^7.7.0", 46 | "@adonisjs/core": "^6.12.0", 47 | "@adonisjs/eslint-config": "^2.0.0-beta.7", 48 | "@adonisjs/prettier-config": "^1.3.0", 49 | "@adonisjs/tsconfig": "^1.3.0", 50 | "@japa/api-client": "^2.0.3", 51 | "@japa/assert": "^3.0.0", 52 | "@japa/plugin-adonisjs": "^3.0.1", 53 | "@japa/runner": "^3.1.4", 54 | "@swc/core": "^1.6.3", 55 | "@types/lodash": "^4.17.7", 56 | "@types/node": "^20.14.5", 57 | "c8": "^10.1.2", 58 | "copyfiles": "^2.4.1", 59 | "del-cli": "^5.1.0", 60 | "edge.js": "^6.0.2", 61 | "eslint": "^9.9.0", 62 | "html-entities": "^2.5.2", 63 | "np": "^10.0.6", 64 | "prettier": "^3.3.2", 65 | "reflect-metadata": "^0.2.2", 66 | "ts-node": "^10.9.2", 67 | "typescript": "^5.4.5" 68 | }, 69 | "peerDependencies": { 70 | "@adonisjs/core": "^6.2.0", 71 | "edge.js": "^6.0.2", 72 | "reflect-metadata": "^0.2.2" 73 | }, 74 | "publishConfig": { 75 | "access": "public", 76 | "tag": "latest" 77 | }, 78 | "np": { 79 | "message": "chore(release): %s", 80 | "tag": "latest", 81 | "branch": "main", 82 | "anyBranch": false 83 | }, 84 | "c8": { 85 | "reporter": [ 86 | "text", 87 | "html" 88 | ], 89 | "exclude": [ 90 | "tests/**" 91 | ] 92 | }, 93 | "prettier": "@adonisjs/prettier-config", 94 | "dependencies": { 95 | "@adonisjs/shield": "^8.1.1", 96 | "@poppinss/hooks": "^7.2.4", 97 | "edge-error": "^4.0.1", 98 | "edge-parser": "^9.0.2", 99 | "lodash": "^4.17.21", 100 | "ts-morph": "^23.0.0" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /providers/README.md: -------------------------------------------------------------------------------- 1 | # The providers directory 2 | 3 | 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. 4 | 5 | Learn more about [package entrypoints](https://nodejs.org/api/packages.html#package-entry-points). 6 | -------------------------------------------------------------------------------- /providers/edgewire_provider.ts: -------------------------------------------------------------------------------- 1 | import edge from 'edge.js' 2 | import { edgewireTag } from '../src/edge/tags/edgewire.js' 3 | import { ApplicationService } from '@adonisjs/core/types' 4 | import { ComponentRegistry } from '../src/component/registry.js' 5 | import { edgewireScriptsTag } from '../src/edge/tags/edgewire_scripts.js' 6 | import { Edgewire } from '../src/edgewire.js' 7 | import { LifecycleComponentHook } from '../src/features/lifecycle/component_hook.js' 8 | import { ComponentHookRegistry } from '../src/component_hook_registry.js' 9 | import { ValidationComponentHook } from '../src/features/validation/component_hook.js' 10 | 11 | export default class EdgewireProvider { 12 | constructor(protected app: ApplicationService) {} 13 | 14 | register() { 15 | edge.registerTag(edgewireTag) 16 | edge.registerTag(edgewireScriptsTag) 17 | 18 | this.app.container.singleton(ComponentRegistry, () => { 19 | return new ComponentRegistry() 20 | }) 21 | 22 | this.app.container.singleton(ComponentHookRegistry, () => { 23 | return new ComponentHookRegistry() 24 | }) 25 | } 26 | 27 | async boot() { 28 | await import('../src/extensions.js') 29 | 30 | const router = await this.app.container.make('router') 31 | const edgewire = await this.app.container.make(Edgewire) 32 | 33 | router.post('/edgewire/update', (ctx) => edgewire.handleUpdate(ctx)).as('edgewire') 34 | 35 | for (const hook of [LifecycleComponentHook, ValidationComponentHook]) { 36 | edgewire.componentHook(hook) 37 | } 38 | } 39 | 40 | async start() {} 41 | } 42 | -------------------------------------------------------------------------------- /services/edgewire.ts: -------------------------------------------------------------------------------- 1 | import app from '@adonisjs/core/services/app' 2 | import { Edgewire } from '../src/edgewire.js' 3 | 4 | let edgewire: Edgewire 5 | 6 | await app.booted(async () => { 7 | edgewire = await app.container.make(Edgewire) 8 | }) 9 | 10 | export { edgewire as default } 11 | -------------------------------------------------------------------------------- /src/component/context.ts: -------------------------------------------------------------------------------- 1 | import { Component } from './main.js' 2 | 3 | export class ComponentContext { 4 | readonly isMounting: boolean 5 | readonly component: Component 6 | 7 | effects: any = {} 8 | #memo = [] 9 | 10 | constructor(component: Component, isMounting = false) { 11 | this.component = component 12 | this.isMounting = isMounting 13 | } 14 | 15 | addEffect(key: string, value: string) { 16 | this.effects[key] = value 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/component/main.ts: -------------------------------------------------------------------------------- 1 | import { HttpContext } from '@adonisjs/core/http' 2 | import { compose } from '@adonisjs/core/helpers' 3 | import { LifecycleHooks } from '../features/lifecycle/mixins/lifecycle_hooks.js' 4 | import { WithAttributes } from '../mixins/with_attributes.js' 5 | import { View } from '../view.js' 6 | 7 | class BaseComponent {} 8 | 9 | export abstract class Component extends compose(BaseComponent, LifecycleHooks, WithAttributes) { 10 | #id: string 11 | #name: string 12 | #ctx: HttpContext 13 | 14 | constructor(name: string, id: string, ctx: HttpContext) { 15 | super() 16 | this.#id = id 17 | this.#name = name 18 | this.#ctx = ctx 19 | } 20 | 21 | render?(): Promise 22 | 23 | boot?(): void 24 | mount?(args: Record): void 25 | 26 | public get id() { 27 | return this.#id 28 | } 29 | 30 | public get name() { 31 | return this.#name 32 | } 33 | 34 | public get ctx() { 35 | return this.#ctx 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/component/manager.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@adonisjs/core' 2 | import string from '@adonisjs/core/helpers/string' 3 | import { HttpContext } from '@adonisjs/core/http' 4 | import app from '@adonisjs/core/services/app' 5 | import { ComponentRegistry } from './registry.js' 6 | import { ComponentHookRegistry } from '../component_hook/registry.js' 7 | import { insertAttributesIntoHtmlRoot } from '../utils.js' 8 | import { ComponentCall, ComponentSnapshot, ComponentUpdates } from '../types.js' 9 | import { Component } from './main.js' 10 | import { getPublicProperties } from '../utils/object.js' 11 | import { View } from '../view.js' 12 | import { ViewContext } from '../view_context.js' 13 | import { ComponentContext } from './context.js' 14 | import { generateChecksum, verifyChecksum } from '../utils/checksum.js' 15 | import { E_INVALID_CHECKSUM } from '../errors.js' 16 | 17 | @inject() 18 | export class ComponentManager { 19 | #componentsRegistry: ComponentRegistry 20 | #componentHookRegistry: ComponentHookRegistry 21 | 22 | constructor(componentsRegistry: ComponentRegistry, componentHookRegistry: ComponentHookRegistry) { 23 | this.#componentsRegistry = componentsRegistry 24 | this.#componentHookRegistry = componentHookRegistry 25 | } 26 | 27 | async mount(name: string, ctx: HttpContext) { 28 | const component = this.#componentsRegistry.new(ctx, name) 29 | 30 | const mount = this.#componentHookRegistry.hooks.runner('mount') 31 | 32 | await mount.run(component, {}) 33 | 34 | let html = await this.#render(component) 35 | 36 | html = insertAttributesIntoHtmlRoot(html, { 37 | 'wire:effects': [], 38 | 'wire:snapshot': this.#snapshot(component), 39 | }) 40 | 41 | await mount.cleanup(html) 42 | 43 | return html 44 | } 45 | 46 | async update( 47 | snapshot: ComponentSnapshot, 48 | updates: ComponentUpdates, 49 | calls: ComponentCall[], 50 | ctx: HttpContext 51 | ) { 52 | const { component, context } = this.#fromSnapshot(snapshot, ctx) 53 | const { data, memo } = snapshot 54 | 55 | this.#updateProperties(component, updates, data, context) 56 | this.#callMethods(component, calls, context) 57 | 58 | const newSnapshot = this.#snapshot(component, context) 59 | 60 | let html = await this.#render(component) 61 | html = insertAttributesIntoHtmlRoot(html, { 62 | 'wire:snapshot': newSnapshot, 63 | }) 64 | 65 | context.addEffect('html', html) 66 | 67 | return { snapshot: newSnapshot, effects: context.effects } 68 | } 69 | 70 | async #getView(component: Component) { 71 | let view: View 72 | const viewPath = app.config.get('edgewire.viewPath') 73 | const properties = getPublicProperties(component) 74 | if (component.render) { 75 | const output = await component.render() 76 | if (typeof output === 'string') { 77 | view = View.raw(output, properties) 78 | } else { 79 | view = output.with(properties) 80 | } 81 | } else { 82 | const name = string.create(component.name).removeSuffix('component').dashCase().toString() 83 | view = View.template(`${viewPath}/${name}`, properties) 84 | } 85 | 86 | return { view, properties } 87 | } 88 | 89 | async #render(component: Component, _default?: string): Promise { 90 | const { view, properties } = await this.#getView(component) 91 | const viewContext = new ViewContext() 92 | 93 | const render = this.#componentHookRegistry.hooks.runner('render') 94 | await render.run(component, view, properties) 95 | 96 | let html = await view.render() 97 | html = insertAttributesIntoHtmlRoot(html, { 98 | 'wire:id': component.id, 99 | }) 100 | 101 | const replaceHtml = (newHtml: string) => { 102 | html = newHtml 103 | } 104 | 105 | await render.cleanup(html, replaceHtml, viewContext) 106 | 107 | return html 108 | } 109 | 110 | #snapshot(component: Component, context?: ComponentContext): ComponentSnapshot { 111 | const data = this.#dehydrateProperties(component, context) 112 | 113 | const snapshot: Omit = { 114 | data, 115 | memo: { 116 | id: component.id, 117 | name: component.name, 118 | children: [], 119 | }, 120 | } 121 | 122 | return { 123 | ...snapshot, 124 | checksum: generateChecksum(JSON.stringify(snapshot)), 125 | } 126 | } 127 | 128 | #fromSnapshot(snapshot: ComponentSnapshot, ctx: HttpContext) { 129 | const { checksum, ..._snapshot } = snapshot 130 | 131 | if (!verifyChecksum(JSON.stringify(_snapshot), checksum)) { 132 | throw new E_INVALID_CHECKSUM([snapshot.memo.name]) 133 | } 134 | 135 | const component = this.#componentsRegistry.new(ctx, snapshot.memo.name, snapshot.memo.id) 136 | const context = new ComponentContext(component) 137 | 138 | this.#hydrateProperties(component, snapshot.data, context) 139 | 140 | return { component, context } 141 | } 142 | 143 | #dehydrateProperties(component: Component, context?: ComponentContext) { 144 | const data: any = {} 145 | 146 | for (const propertyName of Object.getOwnPropertyNames(component)) { 147 | // @ts-ignore 148 | data[propertyName] = component[propertyName] 149 | } 150 | 151 | return data 152 | } 153 | 154 | #hydrateProperties( 155 | component: Component, 156 | data: ComponentSnapshot['data'], 157 | context: ComponentContext 158 | ) { 159 | for (const [key, value] of Object.entries(data)) { 160 | // TODO: Check if property exists 161 | 162 | // @ts-ignore 163 | component[key] = value 164 | } 165 | } 166 | 167 | #updateProperties( 168 | component: Component, 169 | updates: ComponentUpdates, 170 | data: ComponentSnapshot['data'], 171 | context: ComponentContext 172 | ) { 173 | for (const [path, value] of Object.entries(updates)) { 174 | this.#updateProperty(component, path, value, context) 175 | } 176 | } 177 | 178 | #updateProperty(component: Component, path: string, value: string, context: ComponentContext) { 179 | // TODO: Handle path segments 180 | // @ts-ignore 181 | component[path] = value 182 | } 183 | 184 | #callMethods(component: Component, calls: ComponentCall[], context: ComponentContext) { 185 | const returns = [] 186 | for (const call of calls) { 187 | const { method, params } = call 188 | 189 | // @ts-ignore 190 | component[method](...params) 191 | } 192 | 193 | // TODO: Add context effect 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/component/registry.ts: -------------------------------------------------------------------------------- 1 | // TODO: Add ability to register component without name (infer name from class) 2 | import string from '@adonisjs/core/helpers/string' 3 | import { HttpContext } from '@adonisjs/core/http' 4 | 5 | export class ComponentRegistry { 6 | #aliases = new Map() 7 | 8 | public component(name: string, component: any) { 9 | this.#aliases.set(name, component) 10 | } 11 | 12 | public new(ctx: HttpContext, name: string, id?: string) { 13 | const Component = this.getClass(name) 14 | const component = new Component(name, id ?? string.random(20), ctx) 15 | return component 16 | } 17 | 18 | private getClass(name: string) { 19 | return this.#aliases.get(name) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/component_hook/main.ts: -------------------------------------------------------------------------------- 1 | import Hooks from '@poppinss/hooks' 2 | import { Component } from '../component/main.js' 3 | import { View } from '../view.js' 4 | import { ViewContext } from '../view_context.js' 5 | 6 | export type ComponentHookEvents = { 7 | boot: [[Component], []] 8 | mount: [[Component, any], [string]] 9 | hydrate: [[Component], []] 10 | update: [[Component, string, string, any], []] 11 | call: [[Component, string, any[], boolean], []] 12 | exception: [[Component, unknown, boolean], []] 13 | render: [[Component, View, any], [string, (html: string) => any, ViewContext]] 14 | dehydrate: [[Component], []] 15 | } 16 | 17 | export type ComponentHook = (hooks: Hooks) => void 18 | -------------------------------------------------------------------------------- /src/component_hook/registry.ts: -------------------------------------------------------------------------------- 1 | import Hooks from '@poppinss/hooks' 2 | import { ComponentHook, ComponentHookEvents } from './main.js' 3 | 4 | export class ComponentHookRegistry { 5 | components: ComponentHook[] = [] 6 | hooks: Hooks 7 | 8 | constructor() { 9 | this.hooks = new Hooks() 10 | } 11 | 12 | async register(Hook: ComponentHook) { 13 | Hook(this.hooks) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/define_config.ts: -------------------------------------------------------------------------------- 1 | import { EdgewireConfig } from './types.js' 2 | 3 | export function defineConfig(config: EdgewireConfig): EdgewireConfig { 4 | return config 5 | } 6 | -------------------------------------------------------------------------------- /src/edge/tags/edgewire.ts: -------------------------------------------------------------------------------- 1 | import { TagContract } from 'edge.js/types' 2 | import { isSubsetOf, parseJsArg, unallowedExpression } from '../utils.js' 3 | import { Parser, expressions } from 'edge-parser' 4 | 5 | /** 6 | * A list of allowed expressions for the component name 7 | */ 8 | const ALLOWED_EXPRESSION_FOR_COMPONENT_NAME = [ 9 | expressions.Identifier, 10 | expressions.Literal, 11 | expressions.LogicalExpression, 12 | expressions.MemberExpression, 13 | expressions.ConditionalExpression, 14 | expressions.CallExpression, 15 | expressions.TemplateLiteral, 16 | ] as const 17 | 18 | /** 19 | * Returns the component name and props by parsing the component jsArg expression 20 | * 21 | * @see https://github.com/edge-js/edge/blob/develop/src/tags/component.ts#L45 22 | */ 23 | function getComponentNameAndProps( 24 | expression: any, 25 | parser: Parser, 26 | filename: string 27 | ): [string, string] { 28 | let name: string 29 | 30 | /** 31 | * Use the first expression inside the sequence expression as the name 32 | * of the component 33 | */ 34 | if (expression.type === expressions.SequenceExpression) { 35 | name = expression.expressions.shift() 36 | } else { 37 | name = expression 38 | } 39 | 40 | /** 41 | * Ensure the component name is a literal value or an expression that 42 | * outputs a literal value 43 | */ 44 | isSubsetOf(name, ALLOWED_EXPRESSION_FOR_COMPONENT_NAME, () => { 45 | unallowedExpression( 46 | `"${parser.utils.stringify(name)}" is not a valid argument for component name`, 47 | filename, 48 | parser.utils.getExpressionLoc(name) 49 | ) 50 | }) 51 | 52 | /** 53 | * Parse rest of sequence expressions as an objectified string. 54 | */ 55 | if (expression.type === expressions.SequenceExpression) { 56 | /** 57 | * We only need to entertain the first expression of the sequence 58 | * expression, as components allows a max of two arguments 59 | */ 60 | const firstSequenceExpression = expression.expressions[0] 61 | return [parser.utils.stringify(name), parser.utils.stringify(firstSequenceExpression)] 62 | } 63 | 64 | /** 65 | * When top level expression is not a sequence expression, then we assume props 66 | * as empty stringified object. 67 | */ 68 | return [parser.utils.stringify(name), '{}'] 69 | } 70 | 71 | export const edgewireTag: TagContract = { 72 | block: false, 73 | seekable: true, 74 | tagName: 'edgewire', 75 | compile(parser, buffer, token) { 76 | const awaitKeyword = parser.asyncMode ? 'await ' : '' 77 | const parsed = parseJsArg(parser, token) 78 | 79 | const [name, props] = getComponentNameAndProps(parsed, parser, token.filename) 80 | 81 | buffer.outputExpression( 82 | `${awaitKeyword}template.edgewire.mount(${name})`, 83 | token.filename, 84 | token.loc.start.line, 85 | false 86 | ) 87 | }, 88 | } 89 | -------------------------------------------------------------------------------- /src/edge/tags/edgewire_scripts.ts: -------------------------------------------------------------------------------- 1 | import edge, { edgeGlobals } from 'edge.js' 2 | import { TagContract } from 'edge.js/types' 3 | 4 | export const edgewireScriptsTag: TagContract = { 5 | tagName: 'edgewireScripts', 6 | seekable: true, 7 | block: false, 8 | compile(parser, buffer, token) { 9 | const url = '/livewire.js' 10 | 11 | const csrf = '' // TODO: Generate CSRF 12 | const updateUri = '/edgewire/update' 13 | 14 | buffer.outputRaw( 15 | `` 16 | ) 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /src/edge/utils.ts: -------------------------------------------------------------------------------- 1 | import { TagToken } from 'edge.js/types' 2 | import { expressions as expressionsList, Parser } from 'edge-parser' 3 | import { EdgeError } from 'edge-error' 4 | 5 | type ExpressionList = readonly (keyof typeof expressionsList | 'ObjectPattern' | 'ArrayPattern')[] 6 | 7 | /** 8 | * Raise an `E_UNALLOWED_EXPRESSION` exception. Filename and expression is 9 | * required to point the error stack to the correct file 10 | * 11 | * @see https://github.com/edge-js/edge/blob/develop/src/utils.ts#L87 12 | */ 13 | export function unallowedExpression( 14 | message: string, 15 | filename: string, 16 | loc: { line: number; col: number } 17 | ) { 18 | throw new EdgeError(message, 'E_UNALLOWED_EXPRESSION', { 19 | line: loc.line, 20 | col: loc.col, 21 | filename: filename, 22 | }) 23 | } 24 | 25 | /** 26 | * Validates the expression type to be part of the allowed 27 | * expressions only. 28 | * 29 | * The filename is required to report errors. 30 | * 31 | * ```js 32 | * isNotSubsetOf(expression, ['Literal', 'Identifier'], () => {}) 33 | * ``` 34 | * @link https://github.com/edge-js/edge/blob/develop/src/utils.ts#L109 35 | */ 36 | export function isSubsetOf( 37 | expression: any, 38 | expressions: ExpressionList, 39 | errorCallback: () => void 40 | ) { 41 | if (!expressions.includes(expression.type)) { 42 | errorCallback() 43 | } 44 | } 45 | 46 | /** 47 | * Parses the jsArg by generating and transforming its AST 48 | * 49 | * @see https://github.com/edge-js/edge/blob/develop/src/utils.ts#L142 50 | */ 51 | export function parseJsArg(parser: Parser, token: TagToken) { 52 | return parser.utils.transformAst( 53 | parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), 54 | token.filename, 55 | parser 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/edgewire.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@adonisjs/core' 2 | import { HttpContext } from '@adonisjs/core/http' 3 | import { View } from './view.js' 4 | import { ComponentRegistry } from './component/registry.js' 5 | import { ComponentHookRegistry } from './component_hook/registry.js' 6 | import { ComponentHook } from './component_hook/main.js' 7 | import { ComponentManager } from './component/manager.js' 8 | import { RequestManager } from './request/manager.js' 9 | 10 | @inject() 11 | export class Edgewire { 12 | #componentRegistry: ComponentRegistry 13 | #componentHookRegistry: ComponentHookRegistry 14 | 15 | #componentsManager: ComponentManager 16 | #requestManager: RequestManager 17 | 18 | constructor( 19 | componentRegistry: ComponentRegistry, 20 | componentHookRegistry: ComponentHookRegistry, 21 | componentManager: ComponentManager, 22 | requestManager: RequestManager 23 | ) { 24 | this.#componentRegistry = componentRegistry 25 | this.#componentHookRegistry = componentHookRegistry 26 | this.#componentsManager = componentManager 27 | this.#requestManager = requestManager 28 | } 29 | 30 | component(name: string, component: any) { 31 | this.#componentRegistry.component(name, component) 32 | } 33 | 34 | mount(name: string, ctx: HttpContext) { 35 | return this.#componentsManager.mount(name, ctx) 36 | } 37 | 38 | handleUpdate(ctx: HttpContext) { 39 | return this.#requestManager.handleUpdate(ctx) 40 | } 41 | 42 | view(templatePath: string, state: Record = {}) { 43 | return View.template(templatePath, state) 44 | } 45 | 46 | componentHook(hook: ComponentHook) { 47 | this.#componentHookRegistry.register(hook) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { createError } from '@adonisjs/core/exceptions' 2 | 3 | export const E_INVALID_CHECKSUM = createError<[string]>( 4 | 'Invalid checksum for component "%s"', 5 | 'E_INVALID_CHECKSUM', 6 | 403 7 | ) 8 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import { Component } from './component.js' 2 | import { ComponentContext } from './component_context.js' 3 | import { View } from './view.js' 4 | 5 | declare module '@adonisjs/core/types' { 6 | interface EventsList { 7 | 'edgewire:render': { component: Component; view: View; properties: Record } 8 | 'edgewire:render:after': { component: Component; view: View; properties: Record } 9 | 10 | 'edgewire:hydrate': { component: Component; context: ComponentContext } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/extensions.ts: -------------------------------------------------------------------------------- 1 | import { Template } from 'edge.js' 2 | import { Edgewire } from './edgewire.js' 3 | import edgewire from '../services/edgewire.js' 4 | import { Application } from '@adonisjs/core/app' 5 | 6 | // TODO: Might want to avoid using global 7 | Template.getter('edgewire', function (this: Template) { 8 | return edgewire 9 | }) 10 | 11 | Application.macro('componentsPath', function < 12 | T extends Record, 13 | >(this: Application, ...paths: string[]) { 14 | return this.makePath('app', 'components', ...paths) 15 | }) 16 | 17 | declare module 'edge.js' { 18 | interface Template { 19 | edgewire: Edgewire 20 | } 21 | } 22 | 23 | declare module '@adonisjs/core/app' { 24 | interface Application> { 25 | componentsPath(...paths: string[]): string 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/features/lifecycle/component_hook.ts: -------------------------------------------------------------------------------- 1 | import Hooks from '@poppinss/hooks' 2 | import { ComponentHookEvents } from '../../component_hook.js' 3 | 4 | export const LifecycleComponentHook = (hooks: Hooks) => { 5 | hooks.add('boot', async (component) => { 6 | await component.hooks.runner('boot').run() 7 | await component.hooks.runner('initialize').run() 8 | await component.hooks.runner('mount').run() 9 | await component.hooks.runner('booted').run() 10 | }) 11 | 12 | hooks.add('render', async (component, view, data) => { 13 | await component.hooks.runner('rendering').run(view, data) 14 | return async (html) => { 15 | await component.hooks.runner('rendered').run(view, html) 16 | } 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/features/lifecycle/mixins/lifecycle_hooks.ts: -------------------------------------------------------------------------------- 1 | import Hooks from '@poppinss/hooks' 2 | import { View } from '../../../view.js' 3 | 4 | type Events = { 5 | boot: [[], []] 6 | initialize: [[], []] 7 | mount: [[], []] 8 | hydrate: [[], []] 9 | exception: [[unknown, boolean], []] 10 | rendering: [[View, any], []] 11 | rendered: [[View, string], []] 12 | dehydrate: [[], []] 13 | booted: [[], []] 14 | } 15 | 16 | type Constructor = new (...args: any[]) => { 17 | mount?(...args: any[]): void 18 | } 19 | 20 | export function LifecycleHooks(superclass: T) { 21 | return class LifecycleHooksImpl extends superclass { 22 | #hooks: Hooks 23 | 24 | constructor(...args: any[]) { 25 | super(...args) 26 | this.#hooks = new Hooks() 27 | 28 | if (this.mount) { 29 | this.#hooks.add('mount', this.mount) 30 | } 31 | } 32 | 33 | public get hooks(): Hooks { 34 | return this.#hooks 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/features/validation/component_hook.ts: -------------------------------------------------------------------------------- 1 | import Hooks from '@poppinss/hooks' 2 | import { ComponentHookEvents } from '../../component_hook.js' 3 | 4 | export const ValidationComponentHook = (hooks: Hooks) => {} 5 | -------------------------------------------------------------------------------- /src/features/validation/handles_validation.ts: -------------------------------------------------------------------------------- 1 | export type Constructor = new (...args: any[]) => {} 2 | 3 | export function HandlesValidation(superclass: T) { 4 | return class HandlesValidationImpl extends superclass {} 5 | } 6 | -------------------------------------------------------------------------------- /src/mixins/with_attributes.ts: -------------------------------------------------------------------------------- 1 | import { Constructor } from '../types.js' 2 | 3 | export function WithAttributes(Base: T) { 4 | return class WithAttributes extends Base {} 5 | } 6 | -------------------------------------------------------------------------------- /src/mixins/with_lifecycle_hooks.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriendsOfAdonis/edgewire/20b1713dc30939901e8bbe18ebc220564d8de42b/src/mixins/with_lifecycle_hooks.ts -------------------------------------------------------------------------------- /src/request/manager.ts: -------------------------------------------------------------------------------- 1 | import { HttpContext } from '@adonisjs/core/http' 2 | import { inject } from '@adonisjs/core' 3 | import { ComponentManager } from '../component/manager.js' 4 | 5 | @inject() 6 | export class RequestManager { 7 | #componentManager: ComponentManager 8 | 9 | constructor(componentManager: ComponentManager) { 10 | this.#componentManager = componentManager 11 | } 12 | 13 | public async handleUpdate(ctx: HttpContext) { 14 | const payloads = ctx.request.body().components // TODO: Type this 15 | 16 | const componentResponses = [] 17 | for (const payload of payloads) { 18 | const { snapshot, effects } = await this.#componentManager.update( 19 | JSON.parse(payload.snapshot), 20 | payload.updates, 21 | payload.calls, 22 | ctx 23 | ) 24 | 25 | componentResponses.push({ snapshot: JSON.stringify(snapshot), effects }) 26 | } 27 | 28 | const response = { 29 | components: componentResponses, 30 | } 31 | 32 | return response 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Constructor = new (...args: any[]) => T 2 | 3 | export type EdgewireConfig = { 4 | viewPath: string 5 | } 6 | 7 | export type ComponentSnapshot = { 8 | data: any 9 | checksum: string 10 | memo: { 11 | id: string 12 | name: string 13 | [key: string]: any 14 | } 15 | } 16 | 17 | export type ComponentUpdates = Record 18 | 19 | export type ComponentEffect = any 20 | 21 | export type ComponentCall = { 22 | path: string | null 23 | method: string 24 | params: any[] 25 | } 26 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { edgeGlobals } from 'edge.js' 2 | 3 | /** 4 | * @see https://github.com/livewire/livewire/blob/main/src/Drawer/Utils.php#L13 5 | */ 6 | export function insertAttributesIntoHtmlRoot(html: string, attributes: Record) { 7 | // TODO: avoid mutating attributes 8 | for (const [key, value] of Object.entries(attributes)) { 9 | if (typeof value === 'object') { 10 | attributes[key] = edgeGlobals.html.escape(JSON.stringify(value)) 11 | } 12 | } 13 | 14 | const attributesStr = edgeGlobals.html.attrs(attributes).value 15 | 16 | const regex = new RegExp(/(?:\n\s*|^\s*)<([a-zA-Z0-9\-]+)/) 17 | 18 | const matches = html.match(regex) 19 | 20 | if (!matches) { 21 | // TODO: Error 22 | throw new Error('Missing root') 23 | } 24 | 25 | const leftEndAt = matches[1].length + 1 26 | 27 | const left = html.slice(0, leftEndAt) 28 | const right = html.slice(leftEndAt) 29 | 30 | return `${left} ${attributesStr}${right}` 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/checksum.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto' 2 | 3 | export function verifyChecksum(content: string, checksum: string) { 4 | return true 5 | } 6 | 7 | export function generateChecksum(value: string) { 8 | return crypto.createHash('md5').update(value, 'utf8').digest('hex') 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | export function getPublicProperties(object: any) { 2 | const output: Record = {} 3 | 4 | for (const propertyName of Object.getOwnPropertyNames(object)) { 5 | output[propertyName] = object[propertyName] 6 | } 7 | 8 | return output 9 | } 10 | -------------------------------------------------------------------------------- /src/view.ts: -------------------------------------------------------------------------------- 1 | import edge from 'edge.js' 2 | 3 | export class View { 4 | constructor( 5 | private templatePath?: string, 6 | private content?: string, 7 | private state: Record = {} 8 | ) {} 9 | 10 | render() { 11 | if (this.templatePath) { 12 | return edge.render(this.templatePath, this.state) 13 | } 14 | 15 | if (this.content) { 16 | return edge.renderRaw(this.content, this.state) 17 | } 18 | 19 | throw new Error('THis should not happen') 20 | } 21 | 22 | with(state: Record) { 23 | this.state = { 24 | ...this.state, 25 | ...state, 26 | } 27 | return this 28 | } 29 | 30 | static raw(content: string, state: Record = {}) { 31 | return new View(undefined, content, state) 32 | } 33 | 34 | static template(templatePath: string, state: Record = {}) { 35 | return new View(templatePath, undefined, state) 36 | } 37 | } 38 | 39 | export function view(templatePath: string, state: Record = {}) { 40 | return View.template(templatePath, state) 41 | } 42 | -------------------------------------------------------------------------------- /src/view_context.ts: -------------------------------------------------------------------------------- 1 | export class ViewContext {} 2 | -------------------------------------------------------------------------------- /stubs/README.md: -------------------------------------------------------------------------------- 1 | # The stubs directory 2 | 3 | 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. 4 | 5 | - Inside the `package.json` file, we have defined a `copy:templates` script that copies the `stubs` folder to the `build` folder. 6 | - Ensure the `build/stubs` are always published to npm via the `files` array inside the `package.json` file. 7 | -------------------------------------------------------------------------------- /stubs/main.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | 4 | /** 5 | * Path to the root directory where the stubs are stored. We use 6 | * this path within commands and the configure hook 7 | */ 8 | export const stubsRoot = dirname(fileURLToPath(import.meta.url)) 9 | -------------------------------------------------------------------------------- /stubs/make/edgewire/component.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ 3 | to: app.componentsPath(component.path, component.fileName) 4 | }) 5 | }}} 6 | import { Component, view } from 'edgewire' 7 | 8 | export default class {{ component.className }} extends Component { 9 | render() { 10 | return view('{{ view.templatePath }}') 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /stubs/make/edgewire/view.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ 3 | to: app.viewsPath(view.path, view.fileName) 4 | }) 5 | }}} 6 |
7 | -------------------------------------------------------------------------------- /stubs/start/components.stub: -------------------------------------------------------------------------------- 1 | {{{ 2 | exports({ 3 | to: filePath 4 | }) 5 | }}} 6 | import edgewire from 'edgewire/services/edgewire' 7 | -------------------------------------------------------------------------------- /tests/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { Config } from '@japa/runner/types' 3 | import { assert } from '@japa/assert' 4 | import { apiClient } from '@japa/api-client' 5 | import { pluginAdonisJS } from '@japa/plugin-adonisjs' 6 | import app from '@adonisjs/core/services/app' 7 | import testUtils from '@adonisjs/core/services/test_utils' 8 | 9 | export const plugins: Config['plugins'] = [ 10 | assert(), 11 | apiClient({ baseURL: 'http://localhost:3332' }), 12 | pluginAdonisJS(app, { baseURL: './tmp' }), 13 | ] 14 | 15 | export const runnerHooks: Required> = { 16 | setup: [], 17 | teardown: [], 18 | } 19 | 20 | export const configureSuite: Config['configureSuite'] = (suite) => { 21 | if (['functional'].includes(suite.name)) { 22 | return suite.setup(() => { 23 | console.log('test') 24 | return testUtils.httpServer().start() 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/functional/example.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { test } from '@japa/runner' 3 | import { Testable } from '../testable.js' 4 | import { TestComponent } from '../utils/test_component.js' 5 | 6 | class HelloComponent extends TestComponent {} 7 | 8 | test.group('Example', () => { 9 | test('add two numbers', async ({ assert, client }) => { 10 | await Testable.create(client, HelloComponent, {}) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /tests/testable.ts: -------------------------------------------------------------------------------- 1 | import emitter from '@adonisjs/core/services/emitter' 2 | import edgewire from '../services/edgewire.js' 3 | import { Component } from '../src/component.js' 4 | import string from '@adonisjs/core/helpers/string' 5 | import { ApiClient } from '@japa/api-client' 6 | import { View } from '../src/view.js' 7 | import { ComponentState } from './utils/component_state.js' 8 | import { extractAttributeDataFromHtml } from './utils/html.js' 9 | 10 | export class Testable { 11 | static async create( 12 | client: ApiClient, 13 | component: new (...args: any[]) => Component, 14 | params: Record 15 | ) { 16 | const name = string.random(16) 17 | 18 | edgewire.component(name, component) 19 | 20 | await this.initialRender(client, name, params) 21 | } 22 | 23 | private static async initialRender(client: ApiClient, name: string, params: Record) { 24 | const p = [ 25 | new Promise((res) => { 26 | emitter.once('edgewire:render', ({ view }) => { 27 | res(view) 28 | }) 29 | }), 30 | new Promise((res) => { 31 | emitter.once('edgewire:hydrate', ({ component }) => { 32 | res(component) 33 | }) 34 | }), 35 | ] as const 36 | 37 | const response = await client.get('/edgewire/test').qs('name', name).qs('params', params) 38 | 39 | const [view, component] = await Promise.all(p) 40 | 41 | const snapshot = extractAttributeDataFromHtml(response.text(), 'wire:snapshot') 42 | const effects = extractAttributeDataFromHtml(response.text(), 'wire:effects') 43 | 44 | return new ComponentState(component, response, view, snapshot, effects) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/utils/component_state.ts: -------------------------------------------------------------------------------- 1 | import { ApiResponse } from '@japa/api-client' 2 | import { Component } from '../../src/component.js' 3 | import { View } from '../../src/view.js' 4 | import { ComponentEffect, ComponentSnapshot } from '../../src/types.js' 5 | 6 | export class ComponentState { 7 | constructor( 8 | public component: Component, 9 | public response: ApiResponse, 10 | public view: View, 11 | public snapshot: ComponentSnapshot, 12 | public effects: ComponentEffect[] 13 | ) {} 14 | } 15 | -------------------------------------------------------------------------------- /tests/utils/html.ts: -------------------------------------------------------------------------------- 1 | import { decode } from 'html-entities' 2 | 3 | export function extractAttributeDataFromHtml(html: string, attribute: string) { 4 | const regex = new RegExp(`${attribute}="([^"]+)"`) 5 | const data = html.match(regex) 6 | 7 | if (!data?.length) { 8 | throw new Error('Attribute not found') 9 | } 10 | 11 | return JSON.parse(decode(data[1])) 12 | } 13 | -------------------------------------------------------------------------------- /tests/utils/render.ts: -------------------------------------------------------------------------------- 1 | export class Render {} 2 | -------------------------------------------------------------------------------- /tests/utils/test_component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '../../src/component.js' 2 | 3 | export class TestComponent extends Component { 4 | async render() { 5 | return '
' 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/views/test.edge: -------------------------------------------------------------------------------- 1 |
2 |
3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build", 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsnode.esm.js: -------------------------------------------------------------------------------- 1 | /* 2 | |-------------------------------------------------------------------------- 3 | | TS-Node ESM hook 4 | |-------------------------------------------------------------------------- 5 | | 6 | | Importing this file before any other file will allow you to run TypeScript 7 | | code directly using TS-Node + SWC. For example 8 | | 9 | | node --import="./tsnode.esm.js" bin/test.ts 10 | | node --import="./tsnode.esm.js" index.ts 11 | | 12 | | 13 | | Why not use "--loader=ts-node/esm"? 14 | | Because, loaders have been deprecated. 15 | */ 16 | 17 | import { register } from 'node:module' 18 | register('ts-node/esm', import.meta.url) 19 | --------------------------------------------------------------------------------