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