@temir.ra/create-hono-spa 0.1.0-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Version 0
2
+
3
+ ## 0.1.0-pre.1
4
+
5
+ 1. First version of the template.
package/README.md ADDED
@@ -0,0 +1,214 @@
1
+ # Introduction
2
+
3
+ A template for single-page applications distributed as Hono sub-app libraries. The generated package bundles a frontend shell (HTML, JS, CSS, PWA manifest, favicon) as static assets and exposes a `createSpa()` function that returns a `Hono` instance serving them. Intended to be consumed by a [`@temir.ra/create-hono-server`](https://www.npmjs.com/package/@temir.ra/create-hono-server) backend, where it is mounted as a route group.
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Quick Start](#quick-start)
8
+ 2. [AI Assistant Context](#ai-assistant-context)
9
+ 3. [Documentation](#documentation)
10
+ 1. [`template/`](#template)
11
+ 2. [Architecture](#architecture)
12
+ 3. [`src/spa.ts`](#srcspa-ts)
13
+ 4. [`src/constants.ts`](#srcconstants-ts)
14
+ 5. [`assets/`](#assets)
15
+ 4. [DevOps](#devops)
16
+ 1. [Change Management](#change-management)
17
+ 2. [Publish](#publish)
18
+ 1. [npmjs.org](#npmjsorg)
19
+ 2. [Custom registry](#custom-registry)
20
+
21
+ # Quick Start
22
+
23
+ *`bun create` caches the template package - a newer published version will not be picked up automatically. Pin the version or clear the cache to use the latest.*
24
+
25
+ ```bash
26
+ # placeholder:
27
+ # <NEW_PACKAGE: <NEW_PACKAGE>
28
+ # <@_VERSION: <@_VERSION>
29
+
30
+ # identify the latest version of the template package as <@_VERSION.
31
+ bun info "@temir.ra/create-hono-spa" version
32
+ # create a new library from the template version
33
+ bun create --no-install --no-git "@temir.ra/hono-spa<@_VERSION>" <NEW_PACKAGE>
34
+
35
+ # or
36
+
37
+ # clear package manager cache to ensure the latest template version is used
38
+ bun pm cache rm
39
+ # create a new library from the latest template version
40
+ bun create --no-install --no-git "@temir.ra/hono-spa" <NEW_PACKAGE>
41
+
42
+ # dependencies must be installed manually
43
+ cd <NEW_PACKAGE>
44
+ bun install
45
+ ```
46
+
47
+ # AI Assistant Context
48
+
49
+ To generate an AI coding assistant context file for this project:
50
+
51
+ > Generate an AI coding assistant context file. Analyze the project structure and source files, using this README as the primary reference for architecture and conventions. Give particular attention to: the SPA-as-library abstraction and how assets are served via import.meta.url without any file copying on the consuming server, the `./spa` export condition and createSpa() as the library's public API, and the base href patching mechanism that enables client-side routing regardless of mount path.
52
+
53
+ # Documentation
54
+
55
+ The following sections explain the configurations and conventions baked into the generated package.
56
+
57
+ ## `template/`
58
+
59
+ The files in `template/` were created from the [`@temir.ra/ts-lib@0.5.0`](https://www.npmjs.com/package/@temir.ra/create-ts-lib/v/0.5.0) template and then updated to fit the needs of a Hono SPA template.
60
+
61
+ ```powershell
62
+ bun create --no-install --no-git --force "@temir.ra/ts-lib@0.5.0" "template/"
63
+ ```
64
+
65
+ [Official documentation](https://hono.dev/docs/) provided by the Hono team is a great resource.
66
+
67
+ ## Architecture
68
+
69
+ The generated SPA package is a library - not a runnable server. It abstracts three things from the consuming server:
70
+
71
+ **Asset serving** - All frontend assets (HTML shell, JS bundle, CSS, PWA manifest, favicon) are resolved from the package directory via `import.meta.url` and served by the sub-app itself. The consuming server does not configure static file serving or copy any files.
72
+
73
+ **Client-side routing** - The `/app/*` wildcard returns `index.html` for every navigation path. The `<base href>` tag is patched at request time from `basePath` and `path`, so relative asset URLs resolve correctly wherever the sub-app is mounted.
74
+
75
+ **PWA scaffold** - The `assets/` directory ships with the structure expected by browsers for PWA installation: manifest, favicon, and screenshots.
76
+
77
+ The consuming server treats the package as an opaque route group passed to `endpointGroups`. See [`@temir.ra/create-hono-server`](https://www.npmjs.com/package/@temir.ra/create-hono-server) for the mount pattern.
78
+
79
+ ## `src/spa.ts`
80
+
81
+ **Built-in routes:**
82
+
83
+ | Path | Description |
84
+ |---|---|
85
+ | `/health` | Returns `ok` with status 200 |
86
+ | `/buildinfo` | Returns the contents of `buildinfo.txt` |
87
+ | `/app/*` | Serves `index.html` with runtime substitutions applied |
88
+ | `/<faviconPath>` | Serves `favicon.svg` as `image/svg+xml` |
89
+ | `/<webmanifestPath>` | Serves `manifest.webmanifest` as `application/manifest+json` |
90
+ | `/<indexJsPath>` | Serves `dist/index.bundle.js` |
91
+ | `/<indexCssPath>` | Serves `dist/index.css` |
92
+ | `/<siteCssPath>` | Serves `dist/site.css` |
93
+
94
+ **Options:**
95
+
96
+ ```typescript
97
+ type CreateSpaOptions = {
98
+ basePath?: string; // outer base path matching the server host's basePath
99
+ path?: string; // sub-path for this SPA within the server
100
+ metaDescriptionContent?: string; // replaces content="" in the description meta tag
101
+ faviconPath?: string; // serve path for the favicon (default: 'favicon.svg')
102
+ webmanifestPath?: string; // default: 'manifest.webmanifest'
103
+ vAppContainerId?: string; // id attribute of the root app container div
104
+ indexJsPath?: string; // default: 'index.js'
105
+ indexCssPath?: string; // default: 'index.css'
106
+ siteCssPath?: string; // default: 'site.css'
107
+ }
108
+ ```
109
+
110
+ ## `src/constants.ts`
111
+
112
+ ```typescript
113
+ export const packageUrl = new URL('../', import.meta.url);
114
+ export const buildinfoUrl = new URL('buildinfo.txt', packageUrl);
115
+ export const distUrl = new URL('dist/', packageUrl);
116
+ export const indexJsUrl = new URL('index.bundle.js', distUrl);
117
+ export const assetsUrl = new URL('assets/@scope/package-name/', packageUrl);
118
+ export const indexHtmlUrl = new URL('index.html', assetsUrl);
119
+ // ...
120
+ ```
121
+
122
+ Replace `@scope/package-name` with your actual package name when adapting the template. Update `constants.ts` and rename the `assets/@scope/package-name/` directory to match.
123
+
124
+ ## `assets/`
125
+
126
+ The template provides a PWA shell scaffold:
127
+
128
+ ```
129
+ assets/
130
+ └── @scope/
131
+ └── package-name/
132
+ ├── index.html ← SPA entry point; base href patched at request time
133
+ ├── favicon.svg
134
+ ├── manifest.webmanifest
135
+ └── images/
136
+ ├── mobile-screenshot.png ← used in manifest for PWA install UI
137
+ └── wide-screenshot.png
138
+ ```
139
+
140
+ `assets/` is included in the `files` field of `package.json` and published with the package. The naming convention (`assets/@scope/package-name/`) follows the asset resolution contract from [`@temir.ra/create-ts-lib`](https://www.npmjs.com/package/@temir.ra/create-ts-lib#asset-resolution) - scoped directories prevent naming collisions when multiple SPA packages coexist in the same server.
141
+
142
+ # DevOps
143
+
144
+ ```bash
145
+ # remove dist/ and tsconfig.build.tsbuildinfo
146
+ bun run clean
147
+
148
+ # remove dist/ only
149
+ bun run clean:dist
150
+
151
+ # remove tsconfig.build.tsbuildinfo only
152
+ bun run clean:tsbuildinfo
153
+
154
+ # compile + bundle
155
+ bun run build
156
+
157
+ # create a new test hono spa in example/
158
+ bun run dist/cli.bundle.js -- example
159
+ ```
160
+
161
+ **Publish** - see [Publish](#publish).
162
+
163
+ ## Change Management
164
+
165
+ 1. Create a new branch for the change.
166
+ 2. Make the changes and commit.
167
+ 3. Bump the version in [`package.json`](package.json).
168
+ 4. Add an entry for the new version in [`CHANGELOG.md`](CHANGELOG.md).
169
+ 5. Pull request the branch.
170
+ 6. After merge, run `bun run build` - ensures artifacts are current before publish.
171
+ 7. Publish.
172
+
173
+ ## Publish
174
+
175
+ See the following sources to configure the target registry and authentication.
176
+
177
+ - [Configuring npm - `npmrc`](https://docs.npmjs.com/cli/v10/configuring-npm/npmrc)
178
+ - [Bun package manager - `install.registry`](https://bun.com/docs/runtime/bunfig#install-scopes)
179
+
180
+ ⚠️ Package Scope and the authentication for the target registry must be aligned.
181
+
182
+ ### `npmjs.org`
183
+
184
+ Publish to the public npm registry.
185
+
186
+ ```powershell
187
+ # authenticate
188
+ npm login
189
+ # publish
190
+ bun publish --registry https://registry.npmjs.org/ --access public
191
+ ```
192
+
193
+ ### Custom registry
194
+
195
+ ```bash
196
+ # placeholder:
197
+ # <SCOPE_WITHOUT_AT: <SCOPE_WITHOUT_AT>
198
+ # <REGISTRY_URL: <REGISTRY_URL>
199
+ # <BUN_PUBLISH_AUTH_TOKEN: <BUN_PUBLISH_AUTH_TOKEN>
200
+ ```
201
+
202
+ `~/.bunfig.toml` or `bunfig.toml`:
203
+
204
+ ```toml
205
+ [install.scopes]
206
+ "<SCOPE_WITHOUT_AT>" = { url = "<REGISTRY_URL>", token = "$BUN_PUBLISH_AUTH_TOKEN" }
207
+ ```
208
+
209
+ ```powershell
210
+ # authenticate
211
+ $env:BUN_PUBLISH_AUTH_TOKEN = "<BUN_PUBLISH_AUTH_TOKEN>"
212
+ # publish
213
+ bun publish
214
+ ```
package/buildinfo.txt ADDED
@@ -0,0 +1 @@
1
+ 0.1.0-pre.1+bad3cf6
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import{cpSync as s,readFileSync as f,renameSync as u,writeFileSync as P}from"fs";import{resolve as t}from"path";import{resolve as o}from"path";import{fileURLToPath as h}from"url";var d=new URL("../",import.meta.url),n=o(h(d)),m=o(n,"template/"),p=o(n,"CHANGELOG.md"),g=o(n,"buildinfo.txt"),l=o(n,"README.md");try{let r=process.argv[2];if(!r)throw Error("Package name argument is required. Usage: `create-hono-spa <package-name>`");let a=r.replace(/\\/g,"/"),e=t(process.cwd(),a);s(m,e,{recursive:!0}),s(p,t(e,"CHANGELOG-template.md")),s(g,t(e,"buildinfo-template.txt")),s(l,t(e,"README-template.md"));let c=t(e,"package.json"),i=JSON.parse(f(c,"utf-8"));i.name=a,P(c,JSON.stringify(i,null,2)),u(t(e,"gitignore"),t(e,".gitignore")),console.log(`Template has been successfully instantiated at '${e}' with package name '${a}'.`)}catch(r){let a=r instanceof Error?r:Error(String(r));console.error("Error:",a.message),process.exit(1)}
3
+
4
+ //# debugId=22A62DDB3C86A9D764756E2164756E21
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["..\\src\\cli.ts", "..\\src\\constants.ts"],
4
+ "sourcesContent": [
5
+ "#!/usr/bin/env node\r\n\r\nimport { cpSync, readFileSync, renameSync, writeFileSync } from 'fs';\r\nimport { resolve, basename } from 'path';\r\nimport {\r\n templatePath,\r\n changelogPath,\r\n buildinfoPath,\r\n readmePath\r\n} from './constants.js';\r\n\r\n\r\ntry {\r\n\r\n const packageNameArgument = process.argv[2];\r\n if (!packageNameArgument) throw new Error('Package name argument is required. Usage: `create-hono-spa <package-name>`');\r\n const packageName = packageNameArgument.replace(/\\\\/g, '/');\r\n\r\n const destinationPath = resolve(process.cwd(), packageName);\r\n\r\n cpSync(templatePath, destinationPath, { recursive: true });\r\n cpSync(changelogPath, resolve(destinationPath, 'CHANGELOG-template.md'));\r\n cpSync(buildinfoPath, resolve(destinationPath, 'buildinfo-template.txt'));\r\n cpSync(readmePath, resolve(destinationPath, 'README-template.md'));\r\n\r\n const packageJsonPath = resolve(destinationPath, 'package.json');\r\n const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));\r\n packageJson.name = packageName;\r\n writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));\r\n\r\n renameSync(resolve(destinationPath, 'gitignore'), resolve(destinationPath, '.gitignore'));\r\n\r\n console.log(`Template has been successfully instantiated at '${destinationPath}' with package name '${packageName}'.`);\r\n\r\n}\r\ncatch (error) {\r\n const err = error instanceof Error ? error : new Error(String(error));\r\n console.error('Error:', err.message);\r\n process.exit(1);\r\n}\r\n",
6
+ "import { resolve } from 'path';\r\nimport { fileURLToPath } from 'url';\r\n\r\n\r\nexport const packageUrl: URL = new URL('../', import.meta.url);\r\nexport const packagePath: string = resolve(fileURLToPath(packageUrl));\r\n\r\nexport const templatePath: string = resolve(packagePath, 'template/');\r\n\r\nexport const changelogPath: string = resolve(packagePath, 'CHANGELOG.md');\r\nexport const buildinfoPath: string = resolve(packagePath, 'buildinfo.txt');\r\nexport const readmePath: string = resolve(packagePath, 'README.md');\r\n"
7
+ ],
8
+ "mappings": ";AAEA,iBAAS,kBAAQ,gBAAc,mBAAY,WAC3C,kBAAS,aCHT,kBAAS,aACT,wBAAS,YAGF,IAAM,EAAkB,IAAI,IAAI,MAAO,YAAY,GAAG,EAChD,EAAsB,EAAQ,EAAc,CAAU,CAAC,EAEvD,EAAuB,EAAQ,EAAa,WAAW,EAEvD,EAAwB,EAAQ,EAAa,cAAc,EAC3D,EAAwB,EAAQ,EAAa,eAAe,EAC5D,EAAqB,EAAQ,EAAa,WAAW,EDClE,GAAI,CAEA,IAAM,EAAsB,QAAQ,KAAK,GACzC,GAAI,CAAC,EAAqB,MAAU,MAAM,4EAA4E,EACtH,IAAM,EAAc,EAAoB,QAAQ,MAAO,GAAG,EAEpD,EAAkB,EAAQ,QAAQ,IAAI,EAAG,CAAW,EAE1D,EAAO,EAAc,EAAiB,CAAE,UAAW,EAAK,CAAC,EACzD,EAAO,EAAe,EAAQ,EAAiB,uBAAuB,CAAC,EACvE,EAAO,EAAe,EAAQ,EAAiB,wBAAwB,CAAC,EACxE,EAAO,EAAY,EAAQ,EAAiB,oBAAoB,CAAC,EAEjE,IAAM,EAAkB,EAAQ,EAAiB,cAAc,EACzD,EAAc,KAAK,MAAM,EAAa,EAAiB,OAAO,CAAC,EACrE,EAAY,KAAO,EACnB,EAAc,EAAiB,KAAK,UAAU,EAAa,KAAM,CAAC,CAAC,EAEnE,EAAW,EAAQ,EAAiB,WAAW,EAAG,EAAQ,EAAiB,YAAY,CAAC,EAExF,QAAQ,IAAI,mDAAmD,yBAAuC,KAAe,EAGzH,MAAO,EAAO,CACV,IAAM,EAAM,aAAiB,MAAQ,EAAY,MAAM,OAAO,CAAK,CAAC,EACpE,QAAQ,MAAM,SAAU,EAAI,OAAO,EACnC,QAAQ,KAAK,CAAC",
9
+ "debugId": "22A62DDB3C86A9D764756E2164756E21",
10
+ "names": []
11
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { cpSync, readFileSync, renameSync, writeFileSync } from 'fs';
3
+ import { resolve, basename } from 'path';
4
+ import { templatePath, changelogPath, buildinfoPath, readmePath } from './constants.js';
5
+ try {
6
+ const packageNameArgument = process.argv[2];
7
+ if (!packageNameArgument)
8
+ throw new Error('Package name argument is required. Usage: `create-hono-spa <package-name>`');
9
+ const packageName = packageNameArgument.replace(/\\/g, '/');
10
+ const destinationPath = resolve(process.cwd(), packageName);
11
+ cpSync(templatePath, destinationPath, { recursive: true });
12
+ cpSync(changelogPath, resolve(destinationPath, 'CHANGELOG-template.md'));
13
+ cpSync(buildinfoPath, resolve(destinationPath, 'buildinfo-template.txt'));
14
+ cpSync(readmePath, resolve(destinationPath, 'README-template.md'));
15
+ const packageJsonPath = resolve(destinationPath, 'package.json');
16
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
17
+ packageJson.name = packageName;
18
+ writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
19
+ renameSync(resolve(destinationPath, 'gitignore'), resolve(destinationPath, '.gitignore'));
20
+ console.log(`Template has been successfully instantiated at '${destinationPath}' with package name '${packageName}'.`);
21
+ }
22
+ catch (error) {
23
+ const err = error instanceof Error ? error : new Error(String(error));
24
+ console.error('Error:', err.message);
25
+ process.exit(1);
26
+ }
@@ -0,0 +1,7 @@
1
+ export declare const packageUrl: URL;
2
+ export declare const packagePath: string;
3
+ export declare const templatePath: string;
4
+ export declare const changelogPath: string;
5
+ export declare const buildinfoPath: string;
6
+ export declare const readmePath: string;
7
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,EAAE,GAAqC,CAAC;AAC/D,eAAO,MAAM,WAAW,EAAE,MAA2C,CAAC;AAEtE,eAAO,MAAM,YAAY,EAAE,MAA0C,CAAC;AAEtE,eAAO,MAAM,aAAa,EAAE,MAA6C,CAAC;AAC1E,eAAO,MAAM,aAAa,EAAE,MAA8C,CAAC;AAC3E,eAAO,MAAM,UAAU,EAAE,MAA0C,CAAC"}
@@ -0,0 +1,8 @@
1
+ import { resolve } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ export const packageUrl = new URL('../', import.meta.url);
4
+ export const packagePath = resolve(fileURLToPath(packageUrl));
5
+ export const templatePath = resolve(packagePath, 'template/');
6
+ export const changelogPath = resolve(packagePath, 'CHANGELOG.md');
7
+ export const buildinfoPath = resolve(packagePath, 'buildinfo.txt');
8
+ export const readmePath = resolve(packagePath, 'README.md');
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@temir.ra/create-hono-spa",
3
+ "version": "0.1.0-pre.1",
4
+ "description": "SPA template with Hono server backend",
5
+ "author": "temir.ra",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "typescript",
9
+ "template",
10
+ "hono",
11
+ "spa",
12
+ "bun"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://git.chimps.quest/trs/create-hono-spa.git"
17
+ },
18
+ "type": "module",
19
+ "imports": {
20
+ "#src/*.js": "./src/*.ts"
21
+ },
22
+ "bin": "./dist/cli.bundle.js",
23
+ "files": [
24
+ "dist",
25
+ "CHANGELOG.md",
26
+ "buildinfo.txt",
27
+ "template"
28
+ ],
29
+ "scripts": {
30
+ "clean:dist": "rm -rf dist/",
31
+ "clean:tsbuildinfo": "rm -f tsconfig.build.tsbuildinfo",
32
+ "clean": "bun run clean:dist && bun run clean:tsbuildinfo",
33
+ "prebuild": "bun run scripts/buildinfo.ts",
34
+ "tests": "bun test",
35
+ "build": "bun run build:lib && bun run build:cli-bundle",
36
+ "build:lib": "tsc --project tsconfig.build.json",
37
+ "build:cli-bundle": "bun build src/cli.ts --entry-naming \"[dir]/[name].bundle.[ext]\" --outdir dist --target node --format esm --minify --sourcemap=external",
38
+ "typecheck": "tsc --noEmit"
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "latest",
42
+ "typescript": "^5.9.3"
43
+ }
44
+ }
@@ -0,0 +1,5 @@
1
+ # Version 0
2
+
3
+ ## 0.0.0
4
+
5
+ 1. Versioning initialized.
@@ -0,0 +1,115 @@
1
+ # Introduction
2
+
3
+ *&lt;INTRO TEXT&gt;*
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Quick Start](#quick-start)
8
+ 2. [AI Assistant Context](#ai-assistant-context)
9
+ 3. [Documentation](#documentation)
10
+ 4. [DevOps](#devops)
11
+ 1. [Change Management](#change-management)
12
+ 2. [Publish](#publish)
13
+ 1. [npmjs.org](#npmjsorg)
14
+ 2. [Custom registry](#custom-registry)
15
+
16
+ # Quick Start
17
+
18
+ ```bash
19
+ # remove dist/ and tsconfig.build.tsbuildinfo
20
+ bun run clean
21
+
22
+ # remove dist/ only
23
+ bun run clean:dist
24
+
25
+ # remove tsconfig.build.tsbuildinfo only
26
+ bun run clean:tsbuildinfo
27
+
28
+ # compile + bundle
29
+ bun run build
30
+
31
+ # run tests
32
+ bun run tests
33
+
34
+ # run src/dev.ts in watch mode
35
+ bun run dev
36
+ ```
37
+
38
+ **Publish** - see [Publish](#publish).
39
+
40
+ # AI Assistant Context
41
+
42
+ To generate an AI coding assistant context file for this project:
43
+
44
+ > Generate an AI coding assistant context file. `README-template.md` is available now and documents the architectural conventions of this package — use it as your primary source. The generated context file must be self-contained: `README-template.md` will be deleted after context generation, so do not reference it in the output. Give particular attention to: the dual tsconfig setup and the import constraints it imposes, the `#src/*.js` alias and where it may and may not be used, the `./spa` export condition and createSpa() as the library's public API, the asset resolution via import.meta.url and why it is reliable when the package is loaded externalized, and the base href patching mechanism for client-side routing.
45
+
46
+ # Documentation
47
+
48
+ *&lt;DOCUMENTATION&gt;*
49
+
50
+ ## Asset Resolution
51
+
52
+ *&lt;!-- Remove this section if the library has no runtime assets. Replace `@scope/lib-name` throughout with the actual package name. --&gt;*
53
+
54
+ Assets are placed under `assets/@scope/lib-name/` (scoped to the package name to prevent collisions). They are resolved at runtime via `import.meta.url` and loaded with `fetch()` (browser) or `readFile` (Node). `assets/` must be listed in `files` in `package.json`.
55
+
56
+ `import.meta.url` is reliable when the library is loaded as a discrete module (from CDN or `node_modules/`). When the consumer bundles the library, they must copy the assets - see below.
57
+
58
+ ### For consumers bundling this library
59
+
60
+ Copy `node_modules/@scope/lib-name/assets/` into your build output alongside the bundle, preserving the relative path. No code configuration is required - only the file copy.
61
+
62
+ # DevOps
63
+
64
+ ## Change Management
65
+
66
+ 1. Create a new branch for the change.
67
+ 2. Make the changes and commit.
68
+ 3. Bump the version in [`package.json`](package.json).
69
+ 4. Add an entry for the new version in [`CHANGELOG.md`](CHANGELOG.md).
70
+ 5. Pull request the branch.
71
+ 6. After merge, run `bun run build` - ensures artifacts are current before publish.
72
+ 7. Publish.
73
+
74
+ ## Publish
75
+
76
+ See the following sources to configure the target registry and authentication.
77
+
78
+ - [Configuring npm - `npmrc`](https://docs.npmjs.com/cli/v10/configuring-npm/npmrc)
79
+ - [Bun package manager - `install.registry`](https://bun.com/docs/runtime/bunfig#install-scopes)
80
+
81
+ ⚠️ Package Scope and the authentication for the target registry must be aligned.
82
+
83
+ ### `npmjs.org`
84
+
85
+ Publish to the public npm registry.
86
+
87
+ ```powershell
88
+ # authenticate
89
+ npm login
90
+ # publish
91
+ bun publish --registry https://registry.npmjs.org/ --access public
92
+ ```
93
+
94
+ ### Custom registry
95
+
96
+ ```bash
97
+ # placeholder:
98
+ # <SCOPE_WITHOUT_AT: <SCOPE_WITHOUT_AT>
99
+ # <REGISTRY_URL: <REGISTRY_URL>
100
+ # <BUN_PUBLISH_AUTH_TOKEN: <BUN_PUBLISH_AUTH_TOKEN>
101
+ ```
102
+
103
+ `~/.bunfig.toml` or `bunfig.toml`:
104
+
105
+ ```toml
106
+ [install.scopes]
107
+ "<SCOPE_WITHOUT_AT>" = { url = "<REGISTRY_URL>", token = "$BUN_PUBLISH_AUTH_TOKEN" }
108
+ ```
109
+
110
+ ```powershell
111
+ # authenticate
112
+ $env:BUN_PUBLISH_AUTH_TOKEN = "<BUN_PUBLISH_AUTH_TOKEN>"
113
+ # publish
114
+ bun publish
115
+ ```
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" fill="currentColor" viewBox="0 0 192 192">
2
+ <text x="50%" y="50%" font-family="monospace" font-size="72" dominant-baseline="middle" text-anchor="middle">{app}</text>
3
+ </svg>
@@ -0,0 +1,25 @@
1
+ <!DOCTYPE html>
2
+ <base href="/" />
3
+
4
+ <html lang="en">
5
+
6
+ <head>
7
+ <meta charset="UTF-8" />
8
+ <meta name="description" content="" />
9
+ <meta name="viewport" content="width=device-width, interactive-widget=resizes-content" />
10
+
11
+ <link rel="icon" href="favicon.svg" sizes="any" type="image/svg+xml" />
12
+ <link rel="manifest" href="manifest.webmanifest" />
13
+
14
+ </head>
15
+
16
+ <body>
17
+
18
+ <div id="" class="v-app-container"></div>
19
+ <script type="module" src="index.js"></script>
20
+ <link rel="stylesheet" href="index.css" />
21
+ <link rel="stylesheet" href="site.css" />
22
+
23
+ </body>
24
+
25
+ </html>
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "",
3
+ "short_name": "",
4
+ "id": "",
5
+ "start_url": "",
6
+ "display": "standalone",
7
+ "icons": [
8
+ {
9
+ "src": "favicon.svg",
10
+ "sizes": "192x192",
11
+ "type": "image/svg+xml"
12
+ }
13
+ ],
14
+ "screenshots": [
15
+ {
16
+ "src": "images/wide-screenshot.png",
17
+ "type": "image/png",
18
+ "sizes": "1280x720"
19
+ },
20
+ {
21
+ "src": "images/mobile-screenshot.png",
22
+ "type": "image/png",
23
+ "sizes": "414x896"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,7 @@
1
+ dist
2
+ node_modules
3
+ buildinfo.txt
4
+ *.tsbuildinfo
5
+ buildinfo-template.txt
6
+ CHANGELOG-template.md
7
+ README-template.md
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "",
3
+ "version": "0.0.0+FE",
4
+ "description": "",
5
+ "author": "",
6
+ "license": "",
7
+ "keywords": [
8
+ "typescript"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": ""
13
+ },
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "entrypoint": "./src/index.ts",
18
+ "types": "./dist/index.d.ts",
19
+ "browser": "./dist/index.bundle.js",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./spa": {
23
+ "entrypoint": "./src/spa.ts",
24
+ "types": "./dist/spa.d.ts",
25
+ "browser": "./dist/spa.bundle.js",
26
+ "import": "./dist/spa.js"
27
+ }
28
+ },
29
+ "imports": {
30
+ "#src/*.js": "./src/*.ts"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "CHANGELOG.md",
35
+ "buildinfo.txt",
36
+ "assets"
37
+ ],
38
+ "scripts": {
39
+ "clean:dist": "rm -rf dist/",
40
+ "clean:tsbuildinfo": "rm -f tsconfig.build.tsbuildinfo",
41
+ "clean": "bun run clean:dist && bun run clean:tsbuildinfo",
42
+ "prebuild": "bun run scripts/buildinfo.ts",
43
+ "tests": "bun test",
44
+ "build": "bun run build:lib && bun run build:lib-bundle",
45
+ "build:lib": "tsc --project tsconfig.build.json",
46
+ "build:lib-bundle": "bun run scripts/build-lib-bundle.ts",
47
+ "typecheck": "tsc --noEmit",
48
+ "dev": "bun run --watch src/dev.ts"
49
+ },
50
+ "devDependencies": {
51
+ "@types/bun": "latest",
52
+ "typescript": "^5.9.3",
53
+ "hono": "^4.12.5"
54
+ }
55
+ }
@@ -0,0 +1,156 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import CDN_REWRITE_MAP from './cdn-rewrite-map.json';
4
+
5
+
6
+ interface ExportConditions {
7
+ [key: string]: string | undefined;
8
+ import?: string;
9
+ types?: string;
10
+ browser?: string;
11
+ entrypoint?: string;
12
+ }
13
+
14
+ interface DependencyMap {
15
+ [packageName: string]: string;
16
+ }
17
+
18
+ interface PackageManifest {
19
+ version: string;
20
+ exports?: Record<string, ExportConditions>;
21
+ dependencies?: DependencyMap;
22
+ devDependencies?: DependencyMap;
23
+ peerDependencies?: DependencyMap;
24
+ }
25
+
26
+ function getManifest(packageIdentifier?: string): PackageManifest {
27
+
28
+ const manifestPath = packageIdentifier
29
+ ? join('node_modules', packageIdentifier, 'package.json')
30
+ : 'package.json';
31
+
32
+ let manifest: PackageManifest;
33
+ try {
34
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
35
+ } catch {
36
+ if (packageIdentifier)
37
+ throw new Error("[scripts/build-lib-bundle.ts] Could not read manifest for '" + packageIdentifier + "' at '" + manifestPath + "'. Is it installed?");
38
+ else
39
+ throw new Error("[scripts/build-lib-bundle.ts] Could not read package manifest at '" + manifestPath + "'.");
40
+ }
41
+
42
+ return manifest;
43
+
44
+ }
45
+
46
+ function getManifestEntrypoints(packageManifest: PackageManifest): string[] {
47
+
48
+ const exports = packageManifest.exports;
49
+ if (!exports) throw new Error('[scripts/build-lib-bundle.ts] No exports field found in package.json.');
50
+
51
+ const entrypoints = Object.entries(exports)
52
+ .map(([key, conditions]) => {
53
+ if (!conditions.entrypoint) throw new Error(`[scripts/build-lib-bundle.ts] Export '${key}' does not have an 'entrypoint' condition.`);
54
+ return conditions.entrypoint;
55
+ });
56
+
57
+ return entrypoints;
58
+
59
+ }
60
+
61
+ function getPackageVersion(manifest: PackageManifest, packageIdentifier?: string): string {
62
+
63
+ let version: string | undefined;
64
+ if (packageIdentifier) {
65
+
66
+ const dependencies = {
67
+ ...manifest.dependencies,
68
+ ...manifest.devDependencies,
69
+ ...manifest.peerDependencies,
70
+ };
71
+
72
+ version = dependencies[packageIdentifier];
73
+ if (!version) throw new Error(`[scripts/build-lib-bundle.ts] Package '${packageIdentifier}' is not listed in dependencies.`);
74
+
75
+ }
76
+ else {
77
+ if (!manifest.version) throw new Error('[scripts/build-lib-bundle.ts] Package manifest does not contain a version field.');
78
+ version = manifest.version;
79
+ }
80
+
81
+ return version;
82
+
83
+ }
84
+
85
+ function resolveCdnUrl(importSpecifier: string, urlTemplate: string): string {
86
+ const manifest = getManifest();
87
+ const version = getPackageVersion(manifest, importSpecifier);
88
+ return urlTemplate.replace('<VERSION>', version);
89
+ }
90
+
91
+ const cdnRewritePlugin = {
92
+ name: 'cdn-rewrite',
93
+ setup(build: any) {
94
+
95
+ const resolved = new Map<string, string>();
96
+ for (const [importSpecifier, urlTemplate] of Object.entries(CDN_REWRITE_MAP) as [string, string][]) {
97
+ const url = resolveCdnUrl(importSpecifier, urlTemplate);
98
+ resolved.set(importSpecifier, url);
99
+ console.log(`[cdn-rewrite] '${importSpecifier}' → '${url}'`);
100
+ }
101
+
102
+ build.onResolve({ filter: /\*/ }, (args: any) => {
103
+ const url = resolved.get(args.path);
104
+ if (url) return { path: url, external: true };
105
+ });
106
+
107
+ },
108
+ };
109
+
110
+
111
+ const entrypoints = getManifestEntrypoints(getManifest());
112
+ console.log('[scripts/build-lib-bundle.ts] Entrypoints:', entrypoints);
113
+
114
+ let buildResult;
115
+
116
+ console.log('[scripts/build-lib-bundle.ts] Starting ESM bundle build...');
117
+ buildResult = await Bun.build({
118
+ entrypoints,
119
+ outdir: 'dist',
120
+ naming: '[dir]/[name].bundle.[ext]',
121
+ target: 'browser',
122
+ format: 'esm',
123
+ minify: true,
124
+ sourcemap: 'external',
125
+ plugins: [cdnRewritePlugin],
126
+ });
127
+
128
+ if (!buildResult.success) {
129
+ console.error('[scripts/build-lib-bundle.ts] Build failed:');
130
+ for (const message of buildResult.logs) {
131
+ console.error(message);
132
+ }
133
+ process.exit(1);
134
+ }
135
+ console.log('[scripts/build-lib-bundle.ts] ESM bundle build completed successfully.');
136
+
137
+ console.log('[scripts/build-lib-bundle.ts] Starting IIFE bundle build...');
138
+ buildResult = await Bun.build({
139
+ entrypoints,
140
+ outdir: 'dist',
141
+ naming: '[dir]/[name].iife.[ext]',
142
+ target: 'browser',
143
+ format: 'iife',
144
+ minify: true,
145
+ sourcemap: 'external',
146
+ plugins: [cdnRewritePlugin],
147
+ });
148
+
149
+ if (!buildResult.success) {
150
+ console.error('[scripts/build-lib-bundle.ts] Build failed:');
151
+ for (const message of buildResult.logs) {
152
+ console.error(message);
153
+ }
154
+ process.exit(1);
155
+ }
156
+ console.log('[scripts/build-lib-bundle.ts] IIFE bundle build completed successfully.');
@@ -0,0 +1,25 @@
1
+ import { execSync } from 'child_process';
2
+ import { readFileSync, writeFileSync } from 'fs';
3
+
4
+
5
+ const BUILD_INFO_FILE = 'buildinfo.txt';
6
+ const GIT_COMMAND = 'git rev-parse --short HEAD';
7
+
8
+ const version: string = JSON.parse(readFileSync('package.json', 'utf-8')).version;
9
+
10
+ let gitHash = '';
11
+ try {
12
+ gitHash = execSync(GIT_COMMAND, { stdio: ['ignore', 'pipe', 'ignore'] })
13
+ .toString()
14
+ .trim();
15
+ } catch { }
16
+
17
+ const buildinfo = gitHash
18
+ ? version.includes('+')
19
+ ? `${version}.${gitHash}`
20
+ : `${version}+${gitHash}`
21
+ : version;
22
+
23
+ writeFileSync(BUILD_INFO_FILE, buildinfo.trim(), 'utf-8');
24
+
25
+ console.log(`'${BUILD_INFO_FILE}' has been updated with build info: ${buildinfo}`);
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1,14 @@
1
+
2
+ export const packageUrl: URL = new URL('../', import.meta.url);
3
+
4
+ export const buildinfoUrl: URL = new URL('buildinfo.txt', packageUrl);
5
+
6
+ export const distUrl: URL = new URL('dist/', packageUrl);
7
+ export const indexJsUrl: URL = new URL('index.bundle.js', distUrl);
8
+ export const indexCssUrl: URL = new URL('index.css', distUrl);
9
+ export const siteCssUrl: URL = new URL('site.css', distUrl);
10
+
11
+ export const assetsUrl: URL = new URL('assets/@scope/package-name/', packageUrl);
12
+ export const indexHtmlUrl: URL = new URL('index.html', assetsUrl);
13
+ export const webmanifestUrl: URL = new URL('manifest.webmanifest', assetsUrl);
14
+ export const faviconUrl: URL = new URL('favicon.svg', assetsUrl);
File without changes
@@ -0,0 +1,135 @@
1
+ import { Hono, type Env } from 'hono';
2
+ import {
3
+ buildinfoUrl,
4
+ faviconUrl,
5
+ indexCssUrl,
6
+ indexHtmlUrl,
7
+ indexJsUrl,
8
+ siteCssUrl,
9
+ webmanifestUrl
10
+ } from './constants.js';
11
+
12
+
13
+ type CreateSpaOptions = {
14
+ basePath?: string,
15
+ path?: string,
16
+ metaDescriptionContent?: string,
17
+ faviconPath?: string,
18
+ webmanifestPath?: string,
19
+ indexCssPath?: string,
20
+ vAppContainerId?: string,
21
+ indexJsPath?: string,
22
+ siteCssPath?: string,
23
+ }
24
+ export function createSpa<E extends Env>(options: CreateSpaOptions): Hono<E> {
25
+
26
+ const trimmedBasePath = options.basePath ? options.basePath.trim().replace(/^\/+/, '').replace(/\/+$/, '') : '';
27
+ const trimmedPath = options.path ? options.path.trim().replace(/^\/+/, '').replace(/\/+$/, '') : '';
28
+ const baseHref =
29
+ (trimmedBasePath ? `/${trimmedBasePath}/` : '/')
30
+ +
31
+ (trimmedPath ? `${trimmedPath}/` : '')
32
+ ;
33
+
34
+ const spa = new Hono<E>().basePath(`/${trimmedPath}`);
35
+
36
+ spa.get('/health', (c) => c.text('OK'));
37
+ spa.get('/buildinfo', async (c) => {
38
+ try {
39
+ const buildInfo = await fetch(buildinfoUrl).then(res => res.text());
40
+ return c.text(buildInfo);
41
+ } catch (error) {
42
+ throw error;
43
+ return c.status(500);
44
+ }
45
+ });
46
+
47
+ spa.get('/app/*', async (c) => {
48
+ try {
49
+
50
+ let indexHtml = await fetch(indexHtmlUrl).then(res => res.text());
51
+
52
+ indexHtml = indexHtml.replace('<base href="/" />', `<base href="${baseHref}" />`);
53
+
54
+ if (options.metaDescriptionContent)
55
+ indexHtml = indexHtml.replace('<meta name="description" content="" />', `<meta name="description" content="${options.metaDescriptionContent}" />`);
56
+
57
+ if (options.faviconPath)
58
+ indexHtml = indexHtml.replace('<link rel="icon" href="favicon.svg" sizes="any" type="image/svg+xml" />', `<link rel="icon" href="${options.faviconPath}" sizes="any" type="image/svg+xml" />`);
59
+
60
+ if (options.webmanifestPath)
61
+ indexHtml = indexHtml.replace('<link rel="manifest" href="manifest.webmanifest" />', `<link rel="manifest" href="${options.webmanifestPath}" />`);
62
+
63
+ if (options.vAppContainerId)
64
+ indexHtml = indexHtml.replace('<div id="" class="v-app-container"></div>', `<div id="${options.vAppContainerId}" class="v-app-container"></div>`);
65
+
66
+ if (options.indexJsPath)
67
+ indexHtml = indexHtml.replace('<script type="module" src="index.js"></script>', `<script type="module" src="${options.indexJsPath}"></script>`);
68
+
69
+ if (options.indexCssPath)
70
+ indexHtml = indexHtml.replace('<link rel="stylesheet" href="index.css" />', `<link rel="stylesheet" href="${options.indexCssPath}" />`);
71
+
72
+ if (options.siteCssPath)
73
+ indexHtml = indexHtml.replace('<link rel="stylesheet" href="site.css" />', `<link rel="stylesheet" href="${options.siteCssPath}" />`);
74
+
75
+ return c.html(indexHtml);
76
+
77
+ } catch (error) {
78
+ throw error;
79
+ return c.status(500);
80
+ }
81
+ });
82
+
83
+ spa.get(`/${options.faviconPath || 'favicon.svg'}`, async (c) => {
84
+ try {
85
+ const svg = await fetch(faviconUrl).then(res => res.text());
86
+ return c.text(svg, 200, { 'Content-Type': 'image/svg+xml' });
87
+ } catch (error) {
88
+ throw error;
89
+ return c.status(500);
90
+ }
91
+ });
92
+
93
+ spa.get(`/${options.webmanifestPath || 'manifest.webmanifest'}`, async (c) => {
94
+ try {
95
+ const webmanifest = await fetch(webmanifestUrl).then(res => res.json());
96
+ return c.json(webmanifest, 200, { 'Content-Type': 'application/manifest+json', });
97
+ } catch (error) {
98
+ throw error;
99
+ return c.status(500);
100
+ }
101
+ });
102
+
103
+ spa.get(`/${options.indexJsPath || 'index.js'}`, async (c) => {
104
+ try {
105
+ const js = await fetch(indexJsUrl).then(res => res.text());
106
+ return c.text(js, 200, { 'Content-Type': 'application/javascript' });
107
+ } catch (error) {
108
+ throw error;
109
+ return c.status(500);
110
+ }
111
+ });
112
+
113
+ spa.get(`/${options.indexCssPath || 'index.css'}`, async (c) => {
114
+ try {
115
+ const css = await fetch(indexCssUrl).then(res => res.text());
116
+ return c.text(css, 200, { 'Content-Type': 'text/css' });
117
+ } catch (error) {
118
+ throw error;
119
+ return c.status(500);
120
+ }
121
+ });
122
+
123
+ spa.get(`/${options.siteCssPath || 'site.css'}`, async (c) => {
124
+ try {
125
+ const css = await fetch(siteCssUrl).then(res => res.text());
126
+ return c.text(css, 200, { 'Content-Type': 'text/css' });
127
+ } catch (error) {
128
+ throw error;
129
+ return c.status(500);
130
+ }
131
+ });
132
+
133
+ return spa;
134
+
135
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from 'bun:test';
2
+ import { readFileSync } from 'fs';
3
+ import { fileURLToPath } from 'url';
4
+
5
+
6
+ const buildinfoUrl = new URL('../buildinfo.txt', import.meta.url);
7
+
8
+ // taken from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
9
+ // Captures: [1]=major, [2]=minor, [3]=patch, [4]=pre-release, [5]=build-metadata
10
+ const SEMVER_REGEX =
11
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
12
+
13
+ function readBuildinfo(): string {
14
+ return readFileSync(fileURLToPath(buildinfoUrl), 'utf-8').trim();
15
+ }
16
+
17
+ describe('buildInfo', () => {
18
+
19
+ it('should be a valid semver string', () => {
20
+ expect(readBuildinfo()).toMatch(SEMVER_REGEX);
21
+ });
22
+
23
+ });
@@ -0,0 +1,19 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "module": "nodenext",
6
+ "moduleResolution": "nodenext",
7
+ "emitDeclarationOnly": false,
8
+ },
9
+ "include": [
10
+ "src/**/*.ts"
11
+ ],
12
+ "exclude": [
13
+ "src/dev.ts",
14
+ "node_modules",
15
+ "dist",
16
+ "tests",
17
+ "scripts"
18
+ ]
19
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "lib": [
6
+ "ES2022",
7
+ "DOM"
8
+ ],
9
+ "moduleResolution": "bundler",
10
+ "strict": true,
11
+ "verbatimModuleSyntax": true,
12
+ "noUncheckedIndexedAccess": true,
13
+ "exactOptionalPropertyTypes": true,
14
+ "noImplicitOverride": true,
15
+ "isolatedDeclarations": true,
16
+ "esModuleInterop": true,
17
+ "composite": true,
18
+ "skipLibCheck": true,
19
+ "forceConsistentCasingInFileNames": true,
20
+ "resolveJsonModule": true,
21
+ "declaration": true,
22
+ "declarationMap": true,
23
+ "emitDeclarationOnly": true,
24
+ "outDir": "./dist",
25
+ "rootDir": ".",
26
+ },
27
+ "include": [
28
+ "src/**/*.ts",
29
+ "tests/**/*.ts",
30
+ "scripts/**/*.ts",
31
+ "scripts/**/*.json"
32
+ ],
33
+ "exclude": [
34
+ "node_modules",
35
+ "dist"
36
+ ]
37
+ }