create-kerf-component 0.15.2
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/README.md +51 -0
- package/index.js +99 -0
- package/package.json +40 -0
- package/template/README.md +54 -0
- package/template/_gitignore +3 -0
- package/template/package.json +35 -0
- package/template/src/counter.tsx +70 -0
- package/template/src/index.ts +11 -0
- package/template/tsconfig.json +24 -0
- package/template/tsup.config.ts +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# create-kerf-component
|
|
2
|
+
|
|
3
|
+
Scaffold a publishable [kerf](https://github.com/brianwestphal/kerf) component
|
|
4
|
+
package that already follows kerf's hard packaging rules — so you don't have to
|
|
5
|
+
reverse-engineer them.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm create kerf-component@latest my-widgets
|
|
9
|
+
# or: npm init kerf-component my-widgets
|
|
10
|
+
# or: npx create-kerf-component my-widgets
|
|
11
|
+
cd my-widgets
|
|
12
|
+
npm install
|
|
13
|
+
npm run build
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Pass `.` to scaffold into the current directory. The target directory's basename
|
|
17
|
+
is used as the package name.
|
|
18
|
+
|
|
19
|
+
## What you get
|
|
20
|
+
|
|
21
|
+
A ready-to-publish component package that encodes the rules from the kerf docs
|
|
22
|
+
(*Building reusable component packages*):
|
|
23
|
+
|
|
24
|
+
- **`kerfjs` as a `peerDependency`, `external` in the tsup build** — never
|
|
25
|
+
bundled, so `isSafeHtml` brand checks and signal identity stay intact across
|
|
26
|
+
the package boundary.
|
|
27
|
+
- **ESM + `.d.ts` output** via `tsup`, with **subpath exports** (`.` and
|
|
28
|
+
`./counter`).
|
|
29
|
+
- **`tsconfig` with `jsxImportSource: "kerfjs"`** so the author's `.tsx` compiles
|
|
30
|
+
against kerf's JSX runtime; consumers need no extra setup.
|
|
31
|
+
- **An example `Counter` component** demonstrating the two patterns every kerf
|
|
32
|
+
component needs:
|
|
33
|
+
- **per-instance state via a factory + props** (`createCounter` → `<Counter store={…} />`), and
|
|
34
|
+
- **a `wire(root)` delegation disposer** (`wireCounter`) instead of inline event handlers.
|
|
35
|
+
|
|
36
|
+
## Layout produced
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
my-widgets/
|
|
40
|
+
├── package.json # peerDependencies.kerfjs, exports map, files: [dist]
|
|
41
|
+
├── tsconfig.json # jsxImportSource: "kerfjs"
|
|
42
|
+
├── tsup.config.ts # external: ['kerfjs'], format esm, dts
|
|
43
|
+
├── .gitignore
|
|
44
|
+
├── README.md
|
|
45
|
+
└── src/
|
|
46
|
+
├── index.ts # public barrel
|
|
47
|
+
└── counter.tsx # factory + component + wire() disposer
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This package is part of the kerf repository and releases in lockstep with
|
|
51
|
+
`kerfjs`.
|
package/index.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// create-kerf-component — scaffold a publishable kerf component package that
|
|
3
|
+
// already follows kerf's hard packaging rules (docs/13-component-packages.md):
|
|
4
|
+
// kerfjs as a peerDependency + external in the build (never bundled), ESM +
|
|
5
|
+
// .d.ts output, `jsxImportSource: "kerfjs"`, subpath exports, and an example
|
|
6
|
+
// component showing per-instance state via a factory + props and a `wire(root)`
|
|
7
|
+
// delegation disposer.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// npm create kerf-component@latest <dir>
|
|
11
|
+
// npm init kerf-component <dir>
|
|
12
|
+
// npx create-kerf-component <dir>
|
|
13
|
+
//
|
|
14
|
+
// <dir> is the target directory; its basename is the default package name (pass
|
|
15
|
+
// `.` to scaffold into the current directory). No third-party dependencies — the
|
|
16
|
+
// initializer is plain Node so `npm create` runs it with zero install latency.
|
|
17
|
+
|
|
18
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'template');
|
|
23
|
+
const TOKEN = /__PKG_NAME__/g;
|
|
24
|
+
|
|
25
|
+
function fail(msg) {
|
|
26
|
+
process.stderr.write(`create-kerf-component: ${msg}\n`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// npm package name rules, trimmed to what we actually need to guard: no spaces,
|
|
31
|
+
// no uppercase, no leading dot/underscore, URL-safe. (Scoped names like
|
|
32
|
+
// `@scope/name` are allowed.)
|
|
33
|
+
function isValidPackageName(name) {
|
|
34
|
+
return /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Recursively walk a directory, returning absolute file paths.
|
|
38
|
+
function walk(dir) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
41
|
+
const abs = join(dir, entry.name);
|
|
42
|
+
if (entry.isDirectory()) out.push(...walk(abs));
|
|
43
|
+
else out.push(abs);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function main(argv) {
|
|
49
|
+
const target = argv[2];
|
|
50
|
+
if (target == null || target === '' || target === '--help' || target === '-h') {
|
|
51
|
+
process.stdout.write(
|
|
52
|
+
'Usage: npm create kerf-component@latest <dir>\n' +
|
|
53
|
+
' (or: npx create-kerf-component <dir>)\n\n' +
|
|
54
|
+
'Scaffolds a kerf component package into <dir>. Pass `.` for the current directory.\n',
|
|
55
|
+
);
|
|
56
|
+
process.exit(target == null || target === '' ? 1 : 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const targetDir = resolve(process.cwd(), target);
|
|
60
|
+
const pkgName = target === '.' ? basename(targetDir) : basename(target);
|
|
61
|
+
|
|
62
|
+
if (!isValidPackageName(pkgName)) {
|
|
63
|
+
fail(`"${pkgName}" is not a valid npm package name.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
|
|
67
|
+
fail(`target directory "${target}" already exists and is not empty.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Copy the whole template tree, then post-process each file in place: replace
|
|
71
|
+
// the package-name token and rename the dotfile placeholders npm can't ship
|
|
72
|
+
// verbatim (`_gitignore` → `.gitignore`).
|
|
73
|
+
mkdirSync(targetDir, { recursive: true });
|
|
74
|
+
cpSync(TEMPLATE_DIR, targetDir, { recursive: true });
|
|
75
|
+
|
|
76
|
+
for (const file of walk(targetDir)) {
|
|
77
|
+
const text = readFileSync(file, 'utf8');
|
|
78
|
+
if (TOKEN.test(text)) writeFileSync(file, text.replace(TOKEN, pkgName));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const dotfiles = [['_gitignore', '.gitignore']];
|
|
82
|
+
for (const [from, to] of dotfiles) {
|
|
83
|
+
const src = join(targetDir, from);
|
|
84
|
+
if (existsSync(src)) renameSync(src, join(targetDir, to));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const cdHint = target === '.' ? '' : ` cd ${target}\n`;
|
|
88
|
+
process.stdout.write(
|
|
89
|
+
`\nScaffolded kerf component package "${pkgName}" in ${target}\n\n` +
|
|
90
|
+
'Next steps:\n' +
|
|
91
|
+
cdHint +
|
|
92
|
+
' npm install\n' +
|
|
93
|
+
' npm run build # tsup → ESM + .d.ts (kerfjs stays external)\n' +
|
|
94
|
+
' npm run typecheck\n\n' +
|
|
95
|
+
'Edit src/counter.tsx, then publish with `npm publish --access public`.\n',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
main(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-kerf-component",
|
|
3
|
+
"version": "0.15.2",
|
|
4
|
+
"description": "Scaffold a publishable kerf component package that already follows kerf's hard packaging rules.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Brian Westphal <brian.westphal@bleugris.com>",
|
|
8
|
+
"homepage": "https://brianwestphal.github.io/kerf/docs/13-component-packages/",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/brianwestphal/kerf",
|
|
12
|
+
"directory": "create-kerf-component"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/brianwestphal/kerf/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"kerf",
|
|
19
|
+
"kerfjs",
|
|
20
|
+
"create",
|
|
21
|
+
"scaffold",
|
|
22
|
+
"initializer",
|
|
23
|
+
"component"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"create-kerf-component": "index.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"index.js",
|
|
33
|
+
"template",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --test tests/*.test.js"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# __PKG_NAME__
|
|
2
|
+
|
|
3
|
+
A reusable [kerf](https://github.com/brianwestphal/kerf) component package,
|
|
4
|
+
scaffolded with `create-kerf-component`.
|
|
5
|
+
|
|
6
|
+
## Develop
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install
|
|
10
|
+
npm run build # tsup → dist/ (ESM + .d.ts); kerfjs stays external
|
|
11
|
+
npm run typecheck
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Use it
|
|
15
|
+
|
|
16
|
+
A kerf app that already has `jsxImportSource: "kerfjs"` configured can import and
|
|
17
|
+
render the component like any local function — there's no extra toolchain:
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { mount } from 'kerfjs';
|
|
21
|
+
import { Counter, createCounter, wireCounter } from '__PKG_NAME__';
|
|
22
|
+
|
|
23
|
+
const counter = createCounter(0); // per-instance state (a factory)
|
|
24
|
+
const root = document.getElementById('app')!;
|
|
25
|
+
|
|
26
|
+
mount(root, () => <Counter store={counter} label="Clicks" />);
|
|
27
|
+
|
|
28
|
+
const dispose = wireCounter(root, counter); // delegation disposer (call on teardown)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## The rules this package follows
|
|
32
|
+
|
|
33
|
+
These are kerf's hard packaging rules (see the kerf docs,
|
|
34
|
+
*Building reusable component packages*). The scaffold encodes them so you don't
|
|
35
|
+
have to:
|
|
36
|
+
|
|
37
|
+
- **`kerfjs` is a `peerDependency` and is `external` in the build — never
|
|
38
|
+
bundled.** A bundled second copy of kerfjs would break `isSafeHtml` brand
|
|
39
|
+
checks and signal identity across the package boundary.
|
|
40
|
+
- **No per-instance state in module scope.** A module-level `signal`/`store` is a
|
|
41
|
+
singleton shared by every instance and every app. Hand the consumer a factory
|
|
42
|
+
(`createCounter`) or accept a signal/store via props.
|
|
43
|
+
- **No inline JSX event handlers.** Components are pure `(props) => SafeHtml`
|
|
44
|
+
string-builders; emit `data-action` hooks and let the host wire events with
|
|
45
|
+
`delegate()` (see `wireCounter`), which returns a disposer.
|
|
46
|
+
- **Build emits ESM + `.d.ts`; `tsconfig` sets `jsxImportSource: "kerfjs"`.**
|
|
47
|
+
|
|
48
|
+
## Publish
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm publish --access public
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`prepublishOnly` runs the build first; `files` ships only `dist/` + docs.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PKG_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A reusable kerf component package.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./counter": {
|
|
13
|
+
"types": "./dist/counter.d.ts",
|
|
14
|
+
"import": "./dist/counter.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"kerfjs": ">=0.14.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"kerfjs": "^0.15.0",
|
|
32
|
+
"tsup": "^8",
|
|
33
|
+
"typescript": "^5"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { defineStore, delegate, type SafeHtml } from 'kerfjs';
|
|
2
|
+
|
|
3
|
+
export interface CounterState {
|
|
4
|
+
count: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* (a) Per-instance state via a FACTORY.
|
|
9
|
+
*
|
|
10
|
+
* Each call returns an independent store, so two `<Counter>` on the same page —
|
|
11
|
+
* or two apps importing this package — never share state. The trap to avoid is a
|
|
12
|
+
* module-scope `signal`/`store`: that would be a singleton shared by every
|
|
13
|
+
* instance. A reusable component must never own per-instance module state; hand
|
|
14
|
+
* the consumer a factory (here) or accept a signal/store via props.
|
|
15
|
+
*/
|
|
16
|
+
export function createCounter(start = 0) {
|
|
17
|
+
return defineStore({
|
|
18
|
+
initial: (): CounterState => ({ count: start }),
|
|
19
|
+
actions: (set, get) => ({
|
|
20
|
+
inc: () => set({ count: get().count + 1 }),
|
|
21
|
+
dec: () => set({ count: get().count - 1 }),
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CounterStore = ReturnType<typeof createCounter>;
|
|
27
|
+
|
|
28
|
+
export interface CounterProps {
|
|
29
|
+
store: CounterStore;
|
|
30
|
+
label?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The component is a pure function `(props) => SafeHtml`. It emits stable
|
|
35
|
+
* `data-action` hooks instead of inline event handlers — inline `onClick={...}`
|
|
36
|
+
* handlers don't survive kerf's morph (and the `no-inline-jsx-event-handlers`
|
|
37
|
+
* lint rule flags them). The host wires the events; see `wireCounter` below.
|
|
38
|
+
*/
|
|
39
|
+
export function Counter({ store, label = 'Count' }: CounterProps): SafeHtml {
|
|
40
|
+
return (
|
|
41
|
+
<div class="kerf-counter">
|
|
42
|
+
<button type="button" data-action="counter:dec" aria-label="Decrement">
|
|
43
|
+
−
|
|
44
|
+
</button>
|
|
45
|
+
<output>
|
|
46
|
+
{label}: {store.state.value.count}
|
|
47
|
+
</output>
|
|
48
|
+
<button type="button" data-action="counter:inc" aria-label="Increment">
|
|
49
|
+
+
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* (b) A `wire(root)` delegation disposer.
|
|
57
|
+
*
|
|
58
|
+
* Components can't attach their own listeners (no lifecycle, re-rendered every
|
|
59
|
+
* paint), so the host calls this ONCE at its `mount()` root and disposes on
|
|
60
|
+
* teardown. `delegate()` lives on the root, not on the component's nodes, so the
|
|
61
|
+
* single listener survives every re-render. Returns the disposer `delegate()`
|
|
62
|
+
* hands back — call it when the host unmounts.
|
|
63
|
+
*/
|
|
64
|
+
export function wireCounter(root: HTMLElement, store: CounterStore): () => void {
|
|
65
|
+
return delegate(root, 'click', '[data-action^="counter:"]', (_event, el) => {
|
|
66
|
+
const action = el.getAttribute('data-action');
|
|
67
|
+
if (action === 'counter:inc') store.actions.inc();
|
|
68
|
+
else if (action === 'counter:dec') store.actions.dec();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Public barrel. Re-export everything consumers import from the package root.
|
|
2
|
+
// (The `./counter` subpath export in package.json lets consumers also import the
|
|
3
|
+
// component directly: `import { Counter } from '__PKG_NAME__/counter'`.)
|
|
4
|
+
export {
|
|
5
|
+
Counter,
|
|
6
|
+
createCounter,
|
|
7
|
+
wireCounter,
|
|
8
|
+
type CounterProps,
|
|
9
|
+
type CounterState,
|
|
10
|
+
type CounterStore,
|
|
11
|
+
} from './counter.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUnusedLocals": true,
|
|
10
|
+
"noUnusedParameters": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// The one piece of kerf-specific wiring: author .tsx compiles against kerf's
|
|
18
|
+
// JSX runtime. Consumers need no extra setup — your package ships compiled JS
|
|
19
|
+
// whose JSX is already lowered to `kerfjs/jsx-runtime` calls.
|
|
20
|
+
"jsx": "react-jsx",
|
|
21
|
+
"jsxImportSource": "kerfjs"
|
|
22
|
+
},
|
|
23
|
+
"include": ["src"]
|
|
24
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup';
|
|
2
|
+
|
|
3
|
+
// One entry per `exports` subpath. tsup emits ESM + `.d.ts` for each.
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
entry: ['src/index.ts', 'src/counter.tsx'],
|
|
6
|
+
format: ['esm'],
|
|
7
|
+
dts: true,
|
|
8
|
+
clean: true,
|
|
9
|
+
// THE hard rule: kerfjs must NEVER be bundled. A component returns `SafeHtml`
|
|
10
|
+
// and reads signals; both rely on the consumer and this package sharing ONE
|
|
11
|
+
// SafeHtml class and ONE signals instance. Bundling a second copy of kerfjs
|
|
12
|
+
// would silently break `isSafeHtml` brand checks and signal identity across the
|
|
13
|
+
// boundary. Keep it external (it's a peerDependency).
|
|
14
|
+
external: ['kerfjs'],
|
|
15
|
+
});
|