Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Repository housekeeping that must not ship inside the marketplace archive.
.github export-ignore
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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`.
26 changes: 26 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 20 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 14 additions & 14 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
Expand Down
57 changes: 28 additions & 29 deletions dist/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,48 @@ 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: ""
})]),
_: 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))])])]),
Expand All @@ -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
61 changes: 61 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -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 <label> wrapping one counts as labelling it
const CONTROL_COMPONENTS = [
'BaseInput',
'BaseTextarea',
'BaseMoney',
'BaseMultiselect',
'BaseDatePicker',
'BaseSelectInput',
'BaseSwitch',
'BaseCheckbox',
]

export default [
...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
files: ['**/*.vue'],
languageOptions: { parserOptions: { parser: tseslint.parser } },
},
{
rules: {
'vue/multi-word-component-names': 'off',
'vue/require-default-prop': 'off',
'vue/one-component-per-file': 'off',
'vue/require-prop-types': 'off',
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/multiline-html-element-content-newline': 'off',
},
},
// Accessibility, as in the host: what a keyboard or screen reader user
// cannot reach or hear. Only native elements are checked.
{
files: ['**/*.vue'],
plugins: { 'vuejs-accessibility': pluginVueA11y },
rules: {
'vuejs-accessibility/alt-text': 'error',
'vuejs-accessibility/anchor-has-content': 'error',
'vuejs-accessibility/aria-props': 'error',
'vuejs-accessibility/aria-role': 'error',
'vuejs-accessibility/click-events-have-key-events': 'error',
'vuejs-accessibility/form-control-has-label': 'error',
'vuejs-accessibility/heading-has-content': 'error',
'vuejs-accessibility/iframe-has-title': 'error',
'vuejs-accessibility/interactive-supports-focus': 'error',
'vuejs-accessibility/label-has-for': [
'error',
{ required: { some: ['nesting', 'id'] }, controlComponents: CONTROL_COMPONENTS },
],
'vuejs-accessibility/mouse-events-have-key-events': 'error',
'vuejs-accessibility/no-autofocus': 'warn',
'vuejs-accessibility/no-static-element-interactions': 'error',
'vuejs-accessibility/role-has-required-aria-props': 'error',
'vuejs-accessibility/tabindex-no-positive': 'error',
},
},
]
8 changes: 4 additions & 4 deletions module.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
"requires": {},
"schema_version": 2,
"slug": "hello-world",
"version": "1.1.0",
"version": "1.2.0",
"license": "AGPL-3.0-only",
"compatibility": {
"invoiceshelf": "^3.0.0",
"module_api": "^1.1.0",
"php": "^8.3.0",
"invoiceshelf": ">=3.0.0-alpha.3 <4.0.0",
"module_api": "^1.3.0",
"php": "^8.4.0",
"extensions": [
"ext-json"
]
Expand Down
Loading
Loading