├── .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 | [](https://makeapullrequest.com) [](LICENCE) [](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 |
--------------------------------------------------------------------------------