From 1dcac0bbcf748d9a135ea6d401a9118b7335fe70 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sat, 24 Oct 2026 01:54:22 +0200 Subject: [PATCH] Bring Hello World up to the current module contract Register the page with registerPage instead of adding a raw route, target module API 1.3 on InvoiceShelf from 3.0.0-alpha.3 and PHP 8.4, use SDK 3.4 and its release workflow, and keep .github out of the release archive. Add the CI the reference module was missing (lint, tests, package validation, typecheck and a dist/ freshness check), contributor and agent notes, and a README that walks through running the module inside a host. Version 1.2.0. --- .gitattributes | 2 + .github/workflows/ci.yml | 47 + .github/workflows/release.yml | 2 +- AGENTS.md | 9 + CONTRIBUTING.md | 26 + README.md | 45 +- composer.json | 28 +- dist/init.js | 57 +- eslint.config.js | 61 ++ module.json | 8 +- package.json | 14 +- pnpm-lock.yaml | 1281 +++++++++++++++++++++++++- resources/js/env.d.ts | 1 + resources/js/global.d.ts | 13 + resources/js/init.ts | 20 +- resources/js/pages/DashboardPage.vue | 8 +- resources/js/shims-vue.d.ts | 7 + tsconfig.json | 16 + 18 files changed, 1554 insertions(+), 91 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 AGENTS.md create mode 100644 CONTRIBUTING.md create mode 100644 eslint.config.js create mode 100644 resources/js/env.d.ts create mode 100644 resources/js/global.d.ts create mode 100644 resources/js/shims-vue.d.ts create mode 100644 tsconfig.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a54575e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Repository housekeeping that must not ship inside the marketplace archive. +.github export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..44f7ed3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: sodium + coverage: none + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install PHP dependencies + run: composer install --no-interaction --prefer-dist + + - name: Verify PHP and package contracts + run: | + composer run lint + composer run test + vendor/bin/invoiceshelf-module validate-module module.json + vendor/bin/invoiceshelf-module validate-package . + + - name: Verify frontend + run: | + pnpm install --frozen-lockfile + pnpm run lint + pnpm exec tsc --noEmit + pnpm run build + git diff --exit-code -- dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd5b886..c9007ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ permissions: jobs: release: - uses: InvoiceShelf/modules/.github/workflows/module-release.yml@3.2.0 + uses: InvoiceShelf/modules/.github/workflows/module-release.yml@3.4.0 with: channel: stable secrets: inherit diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8544658 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# AGENTS.md + +Guide for coding agents working on the official Hello World module for InvoiceShelf 3. + +- **What it is.** The smallest complete module: a page registered with `registerPage`, a sidebar entry, a per-company settings form, committed `dist/` assets, a data cleanup hook and a signed release workflow. It is the module authors' reference, so every change should still read as an example. +- **SDK.** `invoiceshelf/modules` comes from the `InvoiceShelf/modules` VCS repository. Read its README before changing anything that touches the module contract (`module.json`, `composer.json`, the release workflow). +- **Frontend.** `resources/js/init.ts` registers everything inside `window.InvoiceShelf.booting(...)`. Vue is the host's (`window.__invoiceshelf_vue`, see `vite.config.js`), and so are the globally registered `Base*` components. Never call `createApp`. Rebuild and commit `dist/init.js` after any change under `resources/js/`: the host does not run Node. +- **Checks.** `composer run lint`, `composer run test`, `vendor/bin/invoiceshelf-module validate-package .`, `pnpm run lint`, `pnpm exec tsc --noEmit`, `pnpm run build`; CI fails when `dist/` differs from a fresh build. +- **In a host.** See the README's "Local development". The page lives at `/admin/modules/hello-world/dashboard`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..293dfa1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing + +Hello World is the reference module for InvoiceShelf 3: keep it small, and keep every part of it something a module author can copy. + +## Checks + +The same as CI: + +```bash +composer install +composer run lint +composer run test +vendor/bin/invoiceshelf-module validate-module module.json +vendor/bin/invoiceshelf-module validate-package . + +pnpm install --frozen-lockfile +pnpm run lint +pnpm exec tsc --noEmit +pnpm run build # commit dist/: the host never runs Node +``` + +PHP 8.4, Node 24, pnpm 11. Running the module inside a host is described in the README under "Local development". + +## Releasing + +Set `version` in `module.json` and `package.json`, rebuild `dist/`, merge, then push an unprefixed tag equal to the version (`1.2.0`, not `v1.2.0`). The tag workflow calls the SDK's reusable release workflow, which validates, packages, signs and registers the release with the marketplace. diff --git a/README.md b/README.md index 297095c..0c8ab13 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,38 @@ # InvoiceShelf Hello World module -A deliberately small, official lifecycle sample for the InvoiceShelf v3 marketplace. It is a reference for the **Module API 1.1** contract—not a template for the newer 1.2 AI-driver and typed frontend-extension APIs. +The official reference module for InvoiceShelf 3: the smallest complete module, meant to be read and copied. -It demonstrates a module page, sidebar entry, per-company settings, committed frontend assets, signed marketplace packaging, enable/disable behavior, and data-aware uninstall. The source and compiled asset are licensed `AGPL-3.0-only`. +It shows a page registered with `registerPage`, a sidebar entry, a per-company settings form, committed frontend assets, a data cleanup hook for uninstall, and the signed marketplace release workflow. Source and compiled assets are licensed `AGPL-3.0-only`. -## Exact compatibility +## Compatibility -The checked-in `module.json` declares: - -| Dependency | Constraint | +| Dependency | Constraint in `module.json` | | --- | --- | -| InvoiceShelf | `^3.0.0` | -| Module API | `^1.1.0` | -| PHP | `^8.3.0` | -| Required extension | `ext-json` | +| InvoiceShelf | `>=3.0.0-alpha.3 <4.0.0` | +| Module API | `^1.3.0` | +| PHP | `^8.4.0` | +| Extensions | `ext-json` | -`^3.0.0` begins with the final 3.0.0 release, so this sample does not declare compatibility with InvoiceShelf prereleases such as `3.0.0-alpha.*`. Do not copy that constraint for a module intended for the current v3 preview; use an explicit prerelease range after testing it instead. +The InvoiceShelf range names the alpha it starts at on purpose: a caret range such as `^3.0.0` excludes every `3.0.0-alpha.*` prerelease. -## Development +## Local development -Requirements: PHP 8.3 or newer, Composer, Node.js 22, and pnpm 11. +You need PHP 8.4, Composer, Node 24 and pnpm 11, and an InvoiceShelf 3.x checkout to run the module in. -```bash -composer install -pnpm install --frozen-lockfile -composer run lint -composer run test -pnpm run build -vendor/bin/invoiceshelf-module validate-package . -``` +1. **Put the module where the host looks for modules.** Clone it into the host's `Modules/HelloWorld` (the folder name is the module's `name` in `module.json`). In the InvoiceShelf devenv, keep `module-helloworld` as a sibling checkout and symlink it instead: `ln -s ../../module-helloworld InvoiceShelf/Modules/HelloWorld`, plus a bind mount of the sibling directory into the PHP container so the link resolves there too. +2. **Install the module's own tools.** In the module directory: `composer install` (the SDK, PHPUnit, Pint) and `pnpm install`. +3. **Build the frontend.** `pnpm run build` writes `dist/init.js`. There is no hot reload: rebuild after every change under `resources/js/` and reload the page. +4. **Install it into the host.** From the host: `php artisan install:module HelloWorld 1.2.0`. That runs the module's migrations, enables it and fires the lifecycle events, as a marketplace install would. +5. **Open it.** Sign in and use the Hello World entry in the sidebar (`/admin/modules/hello-world/dashboard`). -`dist/init.js` is committed because InvoiceShelf installs immutable module packages without running Composer or Node package managers. Rebuild and commit it whenever the Vue source changes. +The module's tests run without a host, on Orchestra Testbench: `composer run test`. ## Lifecycle and data removal -Disabling the module removes its active UI and routes while keeping its configuration. Uninstalling removes the package. When an administrator explicitly selects **Remove module data**, InvoiceShelf calls the module's `DataCleanup` hook first, runs every module migration's `down()` method, then removes host-managed settings. +Disabling the module removes its page and sidebar entry and keeps its settings. Uninstalling removes the package. When an administrator ticks **Remove module data**, InvoiceShelf calls the module's `DataCleanup` hook first, runs every migration's `down()`, then removes the settings the host stored. -Hello World does not create persistent module data, so its cleanup implementation is intentionally empty. It is still a useful reference: cleanup must be safe to retry and should remove module-owned files, external resources, or shared-table rows that reversible migrations do not cover. +Hello World stores nothing of its own, so its cleanup is intentionally empty. A real cleanup must be safe to run twice and remove what reversible migrations do not cover: files, external resources, rows in shared tables. ## Releases -The manifest version is currently `1.1.0`. Release tags must be exact, unprefixed SemVer matching that version, such as `1.1.0`; the tag workflow uses the SDK `3.2.0` release workflow to validate, package, sign, and register the module with the InvoiceShelf marketplace. +Releases are cut by pushing a tag equal to `module.json`'s `version` (unprefixed, such as `1.2.0`). The tag workflow uses the SDK's reusable workflow (`module-release.yml@3.4.0`) to validate, package, sign and register the release with the InvoiceShelf marketplace. See [CONTRIBUTING.md](CONTRIBUTING.md) for the checks to run first. diff --git a/composer.json b/composer.json index b4f390a..c35a935 100644 --- a/composer.json +++ b/composer.json @@ -9,14 +9,14 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "ext-json": "*", - "invoiceshelf/modules": "^3.2" + "invoiceshelf/modules": "^3.4" }, - "require-dev": { - "laravel/pint": "^1.26", - "orchestra/testbench": "^11.0", - "phpunit/phpunit": "^12.0" + "require-dev": { + "laravel/pint": "^1.26", + "orchestra/testbench": "^11.0", + "phpunit/phpunit": "^12.0" }, "autoload": { "psr-4": { @@ -32,16 +32,16 @@ "test": "vendor/bin/phpunit", "lint": "vendor/bin/pint --test" }, - "config": { - "allow-plugins": { - "wikimedia/composer-merge-plugin": true - }, - "sort-packages": true + "config": { + "allow-plugins": { + "wikimedia/composer-merge-plugin": false + }, + "sort-packages": true }, "minimum-stability": "stable", - "prefer-stable": true, - "repositories": { - "invoiceshelf-modules": { + "prefer-stable": true, + "repositories": { + "invoiceshelf-modules": { "type": "vcs", "url": "https://github.com/InvoiceShelf/modules.git" } diff --git a/dist/init.js b/dist/init.js index f50f44d..05fc289 100644 --- a/dist/init.js +++ b/dist/init.js @@ -3,21 +3,21 @@ const { Fragment: e, createBlock: t, createElementBlock: n, createElementVNode: var f = { class: "mt-6 grid gap-6 lg:grid-cols-3" }, p = { class: "p-6 sm:p-8" }, m = { class: "flex items-center gap-4" }, h = { class: "flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary-50" }, g = { class: "mt-6 flex flex-wrap gap-3" }, _ = { class: "rounded-full bg-surface-secondary px-3 py-1 text-xs font-medium text-body" }, v = { class: "p-6 sm:p-8" }, y = { class: "mt-4 space-y-3 text-sm text-muted" }, b = "Hello from an official InvoiceShelf module!", x = /* @__PURE__ */ a({ __name: "DashboardPage", setup(a) { - let x = [ - "The module route is registered before the app mounts.", + let x = "1.2.0", S = [ + "The page is registered with registerPage before the app mounts.", "The sidebar contribution comes from the module registry.", "Settings are rendered and stored by the host per company.", - "Version 1.0.1 was delivered as a signed, immutable update." + `Version ${x} was delivered as a signed, immutable update.` ]; - return (a, S) => { - let C = c("BaseBreadcrumbItem"), w = c("BaseBreadcrumb"), T = c("BasePageHeader"), E = c("BaseIcon"), D = c("BaseCard"), O = c("BasePage"); - return o(), t(O, null, { - default: d(() => [i(T, { title: "Hello World" }, { - default: d(() => [i(w, null, { - default: d(() => [i(C, { + return (a, C) => { + let w = c("BaseBreadcrumbItem"), T = c("BaseBreadcrumb"), E = c("BasePageHeader"), D = c("BaseIcon"), O = c("BaseCard"), k = c("BasePage"); + return o(), t(k, null, { + default: d(() => [i(E, { title: "Hello World" }, { + default: d(() => [i(T, null, { + default: d(() => [i(w, { title: "Home", to: "dashboard" - }), i(C, { + }), i(w, { title: "Hello World", to: "#", active: "" @@ -25,26 +25,26 @@ var f = { class: "mt-6 grid gap-6 lg:grid-cols-3" }, p = { class: "p-6 sm:p-8" } _: 1 })]), _: 1 - }), r("div", f, [i(D, { class: "lg:col-span-2" }, { + }), r("div", f, [i(O, { class: "lg:col-span-2" }, { default: d(() => [r("div", p, [ - r("div", m, [r("div", h, [i(E, { + r("div", m, [r("div", h, [i(D, { name: "HandRaisedIcon", class: "h-7 w-7 text-primary-500" - })]), r("div", null, [S[0] ||= r("p", { class: "text-sm font-medium text-primary-600" }, "Installed from the marketplace", -1), r("h2", { class: "mt-1 text-2xl font-semibold text-heading" }, l(b))])]), - S[3] ||= r("p", { class: "mt-5 max-w-2xl text-sm leading-6 text-muted" }, " This page, its sidebar link, and the settings form are supplied by an independently versioned InvoiceShelf module. The host loads only the module's compiled local asset. ", -1), - S[4] ||= r("div", { class: "mt-5 inline-flex rounded-lg bg-green-50 px-3 py-2 text-sm font-medium text-green-700" }, " Marketplace update path verified ", -1), + })]), r("div", null, [C[0] ||= r("p", { class: "text-sm font-medium text-primary-600" }, "Installed from the marketplace", -1), r("h2", { class: "mt-1 text-2xl font-semibold text-heading" }, l(b))])]), + C[3] ||= r("p", { class: "mt-5 max-w-2xl text-sm leading-6 text-muted" }, " This page, its sidebar link, and the settings form are supplied by an independently versioned InvoiceShelf module. The host loads only the module's compiled local asset. ", -1), + C[4] ||= r("div", { class: "mt-5 inline-flex rounded-lg bg-green-50 px-3 py-2 text-sm font-medium text-green-700" }, " Marketplace update path verified ", -1), r("div", g, [ - r("span", _, " Version " + l(u("1.1.0")), 1), - S[1] ||= r("span", { class: "rounded-full bg-surface-secondary px-3 py-1 text-xs font-medium text-body" }, " Module API 1 ", -1), - S[2] ||= r("span", { class: "rounded-full bg-surface-secondary px-3 py-1 text-xs font-medium text-body" }, " Signed package ", -1) + r("span", _, " Version " + l(u(x)), 1), + C[1] ||= r("span", { class: "rounded-full bg-surface-secondary px-3 py-1 text-xs font-medium text-body" }, " Module API 1.3 ", -1), + C[2] ||= r("span", { class: "rounded-full bg-surface-secondary px-3 py-1 text-xs font-medium text-body" }, " Signed package ", -1) ]) ])]), _: 1 - }), i(D, null, { - default: d(() => [r("div", v, [S[5] ||= r("h3", { class: "text-base font-semibold text-heading" }, "What this verifies", -1), r("ul", y, [(o(), n(e, null, s(x, (e) => r("li", { + }), i(O, null, { + default: d(() => [r("div", v, [C[5] ||= r("h3", { class: "text-base font-semibold text-heading" }, "What this verifies", -1), r("ul", y, [(o(), n(e, null, s(S, (e) => r("li", { key: e, class: "flex items-start gap-2" - }, [i(E, { + }, [i(D, { name: "CheckCircleIcon", class: "mt-0.5 h-4 w-4 shrink-0 text-green-500" }), r("span", null, l(e), 1)])), 64))])])]), @@ -54,15 +54,14 @@ var f = { class: "mt-6 grid gap-6 lg:grid-cols-3" }, p = { class: "p-6 sm:p-8" } }); }; } -}); -//#endregion -//#region resources/js/init.ts -window.InvoiceShelf.booting((e, t) => { - t.addRoute("admin", { - path: "modules/hello-world/dashboard", - name: "modules.hello-world.dashboard", +}), S = "hello-world"; +window.InvoiceShelf.booting((e, t, n) => { + n.registerPage({ + id: "dashboard", + module: S, + path: "dashboard", component: x, - meta: { requiresAuth: !0 } + meta: { title: "Hello World" } }); }); //#endregion diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..6cf9c07 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,61 @@ +import pluginVue from 'eslint-plugin-vue' +import pluginVueA11y from 'eslint-plugin-vuejs-accessibility' +import tseslint from 'typescript-eslint' + +// The host's form controls, so a