mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-10-16 06:15:43 -04:00
feat: add Handlebars template engine with peer-aware render context and consolidate eslint-prettier into a single adaptive template
- Replace per-framework template files (`eslint.config.next.mjs`, `eslint.config.tanstack.mjs`) with a single `eslint.config.mjs` that branches via Handlebars `{{#if peers.framework}}` conditionals; add `handlebars` as a direct dependency to `@stanza/registry`, `apps/cli`, and `apps/web`
- Extend the template render context with a `peers: Partial<Record<CategoryId, string>>` map so any template can inspect the active framework, auth provider, etc.; thread it through `buildRenderContext`, `synthesizeTemplates`, the CLI `codemod-runner`, and the web `getBuilderState` server function
- Extract and export `activePeerIds(manifest, appId?)` from `@stanza/registry` to derive the peer map from a manifest (app-scoped for `home: app` categories); add `selectedPeerIds(selections)` in `apps/web/src/lib/selection.ts` as the URL-params-derived equivalent
- Add integration tests verifying framework-conditional eslint-prettier output for `default`, `next`, and `tanstack-start` adapters; expand resolver tests for `activePeerIds` app-scoping and for modules that declare both a `default` adapter and a framework-specific override
This commit is contained in:
19 files changed
+858
-697
No files matched your search
@@ -48,6 +48,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.4.0",
|
"@clack/prompts": "^1.4.0",
|
||||||
"citty": "^0.2.2",
|
"citty": "^0.2.2",
|
||||||
|
"handlebars": "^4.7.9",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"semver": "^7.8.1",
|
"semver": "^7.8.1",
|
||||||
"ts-morph": "^28.0.0",
|
"ts-morph": "^28.0.0",
|
||||||
|
|||||||
@@ -330,3 +330,91 @@ describe("tooling slot (single-choice, repo-scoped)", () => {
|
|||||||
expect(manifest.modules.tooling[0].id).toBe("biome");
|
expect(manifest.modules.tooling[0].id).toBe("biome");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("tooling-eslint-prettier — framework-conditional rendering", () => {
|
||||||
|
it("installs without a framework via the default adapter (pure-TS config)", async () => {
|
||||||
|
await cmdInit(args({ name: "app", yes: true, tooling: "eslint-prettier" }));
|
||||||
|
expect(process.exitCode).toBeFalsy();
|
||||||
|
|
||||||
|
const projectRoot = path.join(tmp, "app");
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "stanza.json"), "utf8"));
|
||||||
|
expect(manifest.modules.tooling?.[0]).toMatchObject({
|
||||||
|
id: "eslint-prettier",
|
||||||
|
adapter: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = fs.readFileSync(path.join(projectRoot, "eslint.config.mjs"), "utf8");
|
||||||
|
// Framework-specific blocks must be absent.
|
||||||
|
expect(config).not.toContain("@next/eslint-plugin-next");
|
||||||
|
expect(config).not.toContain("eslint-plugin-react");
|
||||||
|
expect(config).not.toContain("core-web-vitals");
|
||||||
|
expect(config).not.toContain(".next");
|
||||||
|
expect(config).not.toContain("routeTree.gen.ts");
|
||||||
|
// Pure-TS base is still present.
|
||||||
|
expect(config).toContain('import tseslint from "typescript-eslint";');
|
||||||
|
expect(config).toContain('import prettier from "eslint-config-prettier";');
|
||||||
|
|
||||||
|
const rootPkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
|
||||||
|
expect(rootPkg.devDependencies.eslint).toBeTruthy();
|
||||||
|
expect(rootPkg.devDependencies["@next/eslint-plugin-next"]).toBeUndefined();
|
||||||
|
expect(rootPkg.devDependencies["eslint-plugin-react"]).toBeUndefined();
|
||||||
|
expect(rootPkg.devDependencies["eslint-plugin-react-hooks"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("layers Next-specific plugins when framework=next", async () => {
|
||||||
|
await cmdInit(args({ name: "app", yes: true, framework: "next", tooling: "eslint-prettier" }));
|
||||||
|
expect(process.exitCode).toBeFalsy();
|
||||||
|
|
||||||
|
const projectRoot = path.join(tmp, "app");
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "stanza.json"), "utf8"));
|
||||||
|
expect(manifest.modules.tooling?.[0]).toMatchObject({
|
||||||
|
id: "eslint-prettier",
|
||||||
|
adapter: "next",
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = fs.readFileSync(path.join(projectRoot, "eslint.config.mjs"), "utf8");
|
||||||
|
expect(config).toContain('import next from "@next/eslint-plugin-next";');
|
||||||
|
expect(config).toContain("core-web-vitals");
|
||||||
|
expect(config).toContain("**/.next/**");
|
||||||
|
// TanStack-specific bits must not leak in.
|
||||||
|
expect(config).not.toContain('import react from "eslint-plugin-react";');
|
||||||
|
expect(config).not.toContain("routeTree.gen.ts");
|
||||||
|
|
||||||
|
const rootPkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
|
||||||
|
expect(rootPkg.devDependencies["@next/eslint-plugin-next"]).toBeTruthy();
|
||||||
|
expect(rootPkg.devDependencies["eslint-plugin-react-hooks"]).toBeTruthy();
|
||||||
|
expect(rootPkg.devDependencies["eslint-plugin-react"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("layers TanStack-specific plugins when framework=tanstack-start", async () => {
|
||||||
|
await cmdInit(
|
||||||
|
args({
|
||||||
|
name: "app",
|
||||||
|
yes: true,
|
||||||
|
framework: "tanstack-start",
|
||||||
|
tooling: "eslint-prettier",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(process.exitCode).toBeFalsy();
|
||||||
|
|
||||||
|
const projectRoot = path.join(tmp, "app");
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "stanza.json"), "utf8"));
|
||||||
|
expect(manifest.modules.tooling?.[0]).toMatchObject({
|
||||||
|
id: "eslint-prettier",
|
||||||
|
adapter: "tanstack-start",
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = fs.readFileSync(path.join(projectRoot, "eslint.config.mjs"), "utf8");
|
||||||
|
expect(config).toContain('import react from "eslint-plugin-react";');
|
||||||
|
expect(config).toContain("routeTree.gen.ts");
|
||||||
|
expect(config).toContain("react/react-in-jsx-scope");
|
||||||
|
// Next-specific bits must not leak in.
|
||||||
|
expect(config).not.toContain("@next/eslint-plugin-next");
|
||||||
|
expect(config).not.toContain("core-web-vitals");
|
||||||
|
|
||||||
|
const rootPkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
|
||||||
|
expect(rootPkg.devDependencies["eslint-plugin-react"]).toBeTruthy();
|
||||||
|
expect(rootPkg.devDependencies["eslint-plugin-react-hooks"]).toBeTruthy();
|
||||||
|
expect(rootPkg.devDependencies["@next/eslint-plugin-next"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
TemplateRef,
|
TemplateRef,
|
||||||
} from "@stanza/registry";
|
} from "@stanza/registry";
|
||||||
import {
|
import {
|
||||||
|
activePeerIds,
|
||||||
buildRenderContext,
|
buildRenderContext,
|
||||||
categoryHome,
|
categoryHome,
|
||||||
installPackageJsonTargets,
|
installPackageJsonTargets,
|
||||||
@@ -122,11 +123,14 @@ export async function applyModule(args: {
|
|||||||
// the active target so a module shipped into multiple apps renders correctly
|
// the active target so a module shipped into multiple apps renders correctly
|
||||||
// per app. `seedApp` covers repo/package-scoped templates — they don't
|
// per app. `seedApp` covers repo/package-scoped templates — they don't
|
||||||
// reference `app.*` but `buildRenderContext` still needs a valid `AppSpec`.
|
// reference `app.*` but `buildRenderContext` still needs a valid `AppSpec`.
|
||||||
|
// Peers are resolved per-app so app-scoped templates see the right framework
|
||||||
|
// (e.g. web's framework vs native's) when conditionally branching.
|
||||||
const renderContextFor = (app: AppSpec): TemplateContext =>
|
const renderContextFor = (app: AppSpec): TemplateContext =>
|
||||||
buildRenderContext({
|
buildRenderContext({
|
||||||
projectName: manifest.name,
|
projectName: manifest.name,
|
||||||
app,
|
app,
|
||||||
packageName,
|
packageName,
|
||||||
|
peers: activePeerIds(manifest, app.id),
|
||||||
});
|
});
|
||||||
const seedApp = targetApps[0]!;
|
const seedApp = targetApps[0]!;
|
||||||
|
|
||||||
@@ -593,6 +597,7 @@ export async function revertCodemods(args: {
|
|||||||
projectName: manifest.name,
|
projectName: manifest.name,
|
||||||
app,
|
app,
|
||||||
packageName,
|
packageName,
|
||||||
|
peers: activePeerIds(manifest, app.id),
|
||||||
});
|
});
|
||||||
const project = lazyProject(appRoot);
|
const project = lazyProject(appRoot);
|
||||||
const ctx = buildContext({
|
const ctx = buildContext({
|
||||||
|
|||||||
@@ -20,20 +20,21 @@
|
|||||||
"@stanza/registry": "workspace:*",
|
"@stanza/registry": "workspace:*",
|
||||||
"@tabler/icons-react": "^3.44.0",
|
"@tabler/icons-react": "^3.44.0",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@takumi-rs/image-response": "^1.4.1",
|
"@takumi-rs/image-response": "^1.5.1",
|
||||||
"@tanstack/react-devtools": "^0.10.5",
|
"@tanstack/react-devtools": "^0.10.5",
|
||||||
"@tanstack/react-hotkeys": "^0.10.0",
|
"@tanstack/react-hotkeys": "^0.10.0",
|
||||||
"@tanstack/react-router": "^1.170.7",
|
"@tanstack/react-router": "^1.170.8",
|
||||||
"@tanstack/react-router-devtools": "^1.167.0",
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@tanstack/react-router-ssr-query": "^1.167.0",
|
"@tanstack/react-router-ssr-query": "^1.167.0",
|
||||||
"@tanstack/react-start": "^1.168.10",
|
"@tanstack/react-start": "^1.168.13",
|
||||||
"@tanstack/router-plugin": "^1.168.10",
|
"@tanstack/router-plugin": "^1.168.11",
|
||||||
"@vercel/functions": "^3.6.0",
|
"@vercel/functions": "^3.6.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"fumadocs-core": "^16.9.1",
|
"fumadocs-core": "^16.9.1",
|
||||||
"fumadocs-mdx": "^15.0.8",
|
"fumadocs-mdx": "^15.0.8",
|
||||||
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.1",
|
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.1",
|
||||||
|
"handlebars": "^4.7.9",
|
||||||
"lru-cache": "^11.5.0",
|
"lru-cache": "^11.5.0",
|
||||||
"nitro": "^3.0.260522-beta",
|
"nitro": "^3.0.260522-beta",
|
||||||
"posthog-js": "^1.376.0",
|
"posthog-js": "^1.376.0",
|
||||||
|
|||||||
@@ -93,6 +93,21 @@ function pendingPeers(
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active peer ids — the URL-derived equivalent of the CLI runner's
|
||||||
|
* `activePeerIds(manifest, app.id)`. Single source of truth for what
|
||||||
|
* `synthesizeTemplates` should expose under `{{peers.<category>}}` in the
|
||||||
|
* Handlebars render context.
|
||||||
|
*/
|
||||||
|
export function selectedPeerIds(selections: Selections): Partial<Record<CategoryId, string>> {
|
||||||
|
const out: Partial<Record<CategoryId, string>> = {};
|
||||||
|
for (const category of PEER_CATEGORIES) {
|
||||||
|
const id = selections[category]?.[0];
|
||||||
|
if (id) out[category] = id;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve each selected module's adapter against the chosen one-cardinality
|
* Resolve each selected module's adapter against the chosen one-cardinality
|
||||||
* peers — the same logic the CLI uses. Modules whose peers aren't satisfied are
|
* peers — the same logic the CLI uses. Modules whose peers aren't satisfied are
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import {
|
|||||||
} from "@stanza/registry";
|
} from "@stanza/registry";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
|
||||||
import { DEFAULT_BUILDER_APPS, parseSelections, resolveSelected } from "@/lib/selection";
|
import {
|
||||||
|
DEFAULT_BUILDER_APPS,
|
||||||
|
parseSelections,
|
||||||
|
resolveSelected,
|
||||||
|
selectedPeerIds,
|
||||||
|
} from "@/lib/selection";
|
||||||
import type { BuilderSearch } from "@/lib/selection";
|
import type { BuilderSearch } from "@/lib/selection";
|
||||||
import type { Preview } from "@/server/highlighter";
|
import type { Preview } from "@/server/highlighter";
|
||||||
import { getHighlighter, renderPreview } from "@/server/highlighter.server";
|
import { getHighlighter, renderPreview } from "@/server/highlighter.server";
|
||||||
@@ -47,6 +52,7 @@ export const getBuilderState = createServerFn({ method: "GET" })
|
|||||||
|
|
||||||
const { name, pm, selections } = parseSelections(data);
|
const { name, pm, selections } = parseSelections(data);
|
||||||
const resolved = resolveSelected(modules, selections);
|
const resolved = resolveSelected(modules, selections);
|
||||||
|
const peers = selectedPeerIds(selections);
|
||||||
|
|
||||||
// Templates carry their own content; package.json, stanza.json, and
|
// Templates carry their own content; package.json, stanza.json, and
|
||||||
// .env.example are synthesized — the CLI never ships them as templates, it
|
// .env.example are synthesized — the CLI never ships them as templates, it
|
||||||
@@ -61,6 +67,7 @@ export const getBuilderState = createServerFn({ method: "GET" })
|
|||||||
const previewFiles: { path: string; content: string }[] = synthesizeTemplates(resolved, {
|
const previewFiles: { path: string; content: string }[] = synthesizeTemplates(resolved, {
|
||||||
name,
|
name,
|
||||||
apps,
|
apps,
|
||||||
|
peers,
|
||||||
}).map((tpl) => ({ path: tpl.path, content: tpl.content }));
|
}).map((tpl) => ({ path: tpl.path, content: tpl.content }));
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
const pkgJsons = synthesizePackageJsons(resolved, { name, apps, packageManager: pm });
|
const pkgJsons = synthesizePackageJsons(resolved, { name, apps, packageManager: pm });
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@changesets/cli": "^2.31.0",
|
"@changesets/cli": "^2.31.0",
|
||||||
"@stanza/registry": "workspace:*",
|
"@stanza/registry": "workspace:*",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
|
"jiti": "^2.7.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite-plus": "catalog:"
|
"vite-plus": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"./resolver": "./src/resolver.ts"
|
"./resolver": "./src/resolver.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"handlebars": "^4.7.9",
|
||||||
"validate-npm-package-name": "^8.0.0",
|
"validate-npm-package-name": "^8.0.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export {
|
|||||||
} from "./manifest";
|
} from "./manifest";
|
||||||
|
|
||||||
export type { ResolveContext, ResolveResult, ResolveError } from "./resolver";
|
export type { ResolveContext, ResolveResult, ResolveError } from "./resolver";
|
||||||
export { resolveAdapter, isCompatible, categoryOrder } from "./resolver";
|
export { resolveAdapter, isCompatible, categoryOrder, activePeerIds } from "./resolver";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
PackageManager,
|
PackageManager,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { assert, describe, expect, it } from "vite-plus/test";
|
|||||||
|
|
||||||
import { emptyManifest } from "./manifest";
|
import { emptyManifest } from "./manifest";
|
||||||
import { defineModule, type Module } from "./module";
|
import { defineModule, type Module } from "./module";
|
||||||
import { resolveAdapter } from "./resolver";
|
import { activePeerIds, resolveAdapter } from "./resolver";
|
||||||
|
|
||||||
const drizzle: Module = defineModule({
|
const drizzle: Module = defineModule({
|
||||||
id: "drizzle",
|
id: "drizzle",
|
||||||
@@ -156,3 +156,69 @@ describe("resolveAdapter — add-ons", () => {
|
|||||||
expect(result.error.kind).toBe("missing-peer");
|
expect(result.error.kind).toBe("missing-peer");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Module with both a generic default and a framework-specific override —
|
||||||
|
// mirrors the tooling-eslint-prettier shape after its standalone refactor.
|
||||||
|
const eslintLike: Module = defineModule({
|
||||||
|
id: "eslint-like",
|
||||||
|
category: "tooling",
|
||||||
|
label: "ESLint-like",
|
||||||
|
description: "",
|
||||||
|
version: "0.1.0",
|
||||||
|
adapters: [
|
||||||
|
{ key: "next", match: { framework: "next" } },
|
||||||
|
{ key: "default", match: {} },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveAdapter — default + framework-specific adapters coexist", () => {
|
||||||
|
it("picks the framework-specific adapter when its peer is active", () => {
|
||||||
|
const result = resolveAdapter(eslintLike, {
|
||||||
|
manifest: emptyManifest({ name: "t" }),
|
||||||
|
pending: { framework: next },
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
assert(result.ok);
|
||||||
|
expect(result.adapter.key).toBe("next");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the empty-match default when no framework is selected", () => {
|
||||||
|
const result = resolveAdapter(eslintLike, {
|
||||||
|
manifest: emptyManifest({ name: "t" }),
|
||||||
|
pending: {},
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
assert(result.ok);
|
||||||
|
expect(result.adapter.key).toBe("default");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("activePeerIds", () => {
|
||||||
|
it("returns an empty record for a manifest with no selections", () => {
|
||||||
|
expect(activePeerIds(emptyManifest({ name: "t" }))).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the installed framework id from the manifest", () => {
|
||||||
|
const manifest = {
|
||||||
|
...emptyManifest({ name: "t" }),
|
||||||
|
modules: {
|
||||||
|
framework: [{ id: "next", version: "0.1.0", adapter: "default", apps: ["web"] }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(activePeerIds(manifest, "web")).toEqual({ framework: "next" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes app-home peer lookups to the targetAppId", () => {
|
||||||
|
const manifest = {
|
||||||
|
...emptyManifest({ name: "t" }),
|
||||||
|
modules: {
|
||||||
|
framework: [
|
||||||
|
{ id: "next", version: "0.1.0", adapter: "default", apps: ["web"] },
|
||||||
|
{ id: "expo", version: "0.1.0", adapter: "default", apps: ["native"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(activePeerIds(manifest, "web").framework).toBe("next");
|
||||||
|
expect(activePeerIds(manifest, "native").framework).toBe("expo");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,7 +45,7 @@ export type ResolveResult =
|
|||||||
* always wins on tiebreak when no peers are required.
|
* always wins on tiebreak when no peers are required.
|
||||||
*/
|
*/
|
||||||
export function resolveAdapter(module: Module, context: ResolveContext): ResolveResult {
|
export function resolveAdapter(module: Module, context: ResolveContext): ResolveResult {
|
||||||
const activePeers = activePeerIds(context);
|
const activePeers = activePeerIdsForContext(context);
|
||||||
|
|
||||||
// Check declared peers are satisfied (id present + on allow-list if specified).
|
// Check declared peers are satisfied (id present + on allow-list if specified).
|
||||||
for (const category of PEER_CATEGORIES) {
|
for (const category of PEER_CATEGORIES) {
|
||||||
@@ -87,20 +87,39 @@ export function isCompatible(module: Module, context: ResolveContext): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Active peer ids — only `cardinality: "one"` categories can be peers, so we
|
* Active peer ids from the manifest alone — only `cardinality: "one"`
|
||||||
* iterate `PEER_CATEGORIES` and read the single installed/pending pick. When
|
* categories can be peers, so we iterate `PEER_CATEGORIES` and read the
|
||||||
* `targetAppId` is set, the installed lookup is scoped to that app so e.g.
|
* single installed pick. When `targetAppId` is set, the lookup is scoped to
|
||||||
* web's styling adapter peer-matches against web's framework, not native's.
|
* that app so e.g. web's styling adapter peer-matches against web's
|
||||||
|
* framework, not native's.
|
||||||
|
*
|
||||||
|
* Exported so callers outside the resolver (the CLI runner and web
|
||||||
|
* synthesize, which both feed peers into the template render context) share
|
||||||
|
* exactly one implementation.
|
||||||
|
*/
|
||||||
|
export function activePeerIds(
|
||||||
|
manifest: StanzaManifest,
|
||||||
|
targetAppId?: string,
|
||||||
|
): Partial<Record<CategoryId, string>> {
|
||||||
|
const out: Partial<Record<CategoryId, string>> = {};
|
||||||
|
for (const category of PEER_CATEGORIES) {
|
||||||
|
const installed = selectedOne(manifest, category, targetAppId)?.id;
|
||||||
|
if (installed) out[category] = installed;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layer in-flight `pending` picks over the manifest's installed peers.
|
||||||
* Pending picks aren't yet app-scoped — they belong to the same in-flight
|
* Pending picks aren't yet app-scoped — they belong to the same in-flight
|
||||||
* resolution and only one app is being processed at a time.
|
* resolution and only one app is being processed at a time.
|
||||||
*/
|
*/
|
||||||
function activePeerIds(context: ResolveContext): Partial<Record<CategoryId, string>> {
|
function activePeerIdsForContext(context: ResolveContext): Partial<Record<CategoryId, string>> {
|
||||||
const out: Partial<Record<CategoryId, string>> = {};
|
const installed = activePeerIds(context.manifest, context.targetAppId);
|
||||||
|
const out: Partial<Record<CategoryId, string>> = { ...installed };
|
||||||
for (const category of PEER_CATEGORIES) {
|
for (const category of PEER_CATEGORIES) {
|
||||||
const pending = context.pending[category]?.id;
|
const pending = context.pending[category]?.id;
|
||||||
const installed = selectedOne(context.manifest, category, context.targetAppId)?.id;
|
if (pending) out[category] = pending;
|
||||||
const chosen = pending ?? installed;
|
|
||||||
if (chosen) out[category] = chosen;
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export type SynthesizedTemplate = {
|
|||||||
*/
|
*/
|
||||||
export function synthesizeTemplates(
|
export function synthesizeTemplates(
|
||||||
resolved: Partial<Record<CategoryId, SynthesizeEntry[]>>,
|
resolved: Partial<Record<CategoryId, SynthesizeEntry[]>>,
|
||||||
opts: { name: string; apps?: AppSpec[] },
|
opts: { name: string; apps?: AppSpec[]; peers?: Partial<Record<CategoryId, string>> },
|
||||||
): SynthesizedTemplate[] {
|
): SynthesizedTemplate[] {
|
||||||
const apps = opts.apps && opts.apps.length > 0 ? opts.apps : [defaultWebApp()];
|
const apps = opts.apps && opts.apps.length > 0 ? opts.apps : [defaultWebApp()];
|
||||||
const out: SynthesizedTemplate[] = [];
|
const out: SynthesizedTemplate[] = [];
|
||||||
@@ -170,6 +170,7 @@ export function synthesizeTemplates(
|
|||||||
projectName: opts.name,
|
projectName: opts.name,
|
||||||
app,
|
app,
|
||||||
packageName,
|
packageName,
|
||||||
|
peers: opts.peers,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const tpl of entry.adapter.templates ?? []) {
|
for (const tpl of entry.adapter.templates ?? []) {
|
||||||
|
|||||||
@@ -40,18 +40,58 @@ describe("renderTemplate", () => {
|
|||||||
expect(renderTemplate("{{ project.name }}", ctx)).toBe("acme");
|
expect(renderTemplate("{{ project.name }}", ctx)).toBe("acme");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on a missing top-level key", () => {
|
it("renders missing keys as empty strings (non-strict Handlebars)", () => {
|
||||||
expect(() => renderTemplate("{{missing}}", ctx)).toThrow(/missing or non-string key "missing"/);
|
expect(renderTemplate("[{{missing}}]", ctx)).toBe("[]");
|
||||||
|
expect(renderTemplate("[{{packages.nope.name}}]", ctx)).toBe("[]");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on a missing nested key", () => {
|
it("does not HTML-escape values (noEscape=true)", () => {
|
||||||
expect(() => renderTemplate("{{packages.nope.name}}", ctx)).toThrow(
|
// Without noEscape, the "/" in package names would become "/".
|
||||||
/missing or non-string key "packages.nope.name"/,
|
expect(renderTemplate("{{package.name}}", ctx)).toBe("@acme/auth");
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when a path resolves to an object instead of a string", () => {
|
describe("peers + conditionals", () => {
|
||||||
expect(() => renderTemplate("{{project}}", ctx)).toThrow(/missing or non-string key "project"/);
|
const withNext = buildRenderContext({
|
||||||
|
projectName: "acme",
|
||||||
|
app: webApp,
|
||||||
|
packageName: "",
|
||||||
|
peers: { framework: "next" },
|
||||||
|
});
|
||||||
|
const withTanstack = buildRenderContext({
|
||||||
|
projectName: "acme",
|
||||||
|
app: webApp,
|
||||||
|
packageName: "",
|
||||||
|
peers: { framework: "tanstack-start" },
|
||||||
|
});
|
||||||
|
const noFramework = buildRenderContext({
|
||||||
|
projectName: "acme",
|
||||||
|
app: webApp,
|
||||||
|
packageName: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an `if (eq)` block when the active peer matches", () => {
|
||||||
|
const tpl = '{{#if (eq peers.framework "next")}}NEXT{{/if}}';
|
||||||
|
expect(renderTemplate(tpl, withNext)).toBe("NEXT");
|
||||||
|
expect(renderTemplate(tpl, withTanstack)).toBe("");
|
||||||
|
expect(renderTemplate(tpl, noFramework)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches hyphenated peer ids", () => {
|
||||||
|
const tpl = '{{#if (eq peers.framework "tanstack-start")}}TS{{/if}}';
|
||||||
|
expect(renderTemplate(tpl, withTanstack)).toBe("TS");
|
||||||
|
expect(renderTemplate(tpl, withNext)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an `unless` block when the active peer is absent", () => {
|
||||||
|
const tpl = "{{#unless peers.framework}}NONE{{/unless}}";
|
||||||
|
expect(renderTemplate(tpl, noFramework)).toBe("NONE");
|
||||||
|
expect(renderTemplate(tpl, withNext)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the raw peer id under {{peers.<category>}}", () => {
|
||||||
|
expect(renderTemplate("[{{peers.framework}}]", withNext)).toBe("[next]");
|
||||||
|
expect(renderTemplate("[{{peers.framework}}]", noFramework)).toBe("[]");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +106,19 @@ describe("buildRenderContext", () => {
|
|||||||
expect(ctx.packages.auth?.name).toBe("@acme/auth");
|
expect(ctx.packages.auth?.name).toBe("@acme/auth");
|
||||||
expect(ctx.packages.db?.name).toBe("@acme/db");
|
expect(ctx.packages.db?.name).toBe("@acme/db");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("materializes every PEER_CATEGORIES key under `peers` (undefined when unset)", () => {
|
||||||
|
const ctx = buildRenderContext({
|
||||||
|
projectName: "acme",
|
||||||
|
app: webApp,
|
||||||
|
packageName: "",
|
||||||
|
peers: { framework: "next" },
|
||||||
|
});
|
||||||
|
expect(ctx.peers.framework).toBe("next");
|
||||||
|
// styling is a PEER_CATEGORY but not set — the key exists, value is undefined.
|
||||||
|
expect("styling" in ctx.peers).toBe(true);
|
||||||
|
expect(ctx.peers.styling).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const drizzle: Module = defineModule({
|
const drizzle: Module = defineModule({
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import Handlebars from "handlebars";
|
||||||
|
|
||||||
import type { AppSpec } from "./manifest";
|
import type { AppSpec } from "./manifest";
|
||||||
import { PACKAGE_DIRS } from "./module";
|
import { type CategoryId, PACKAGE_DIRS, PEER_CATEGORIES } from "./module";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mustache-style context consumed by {@link renderTemplate}. The shape is the
|
* Handlebars context consumed by {@link renderTemplate}. The shape is the
|
||||||
* public contract template authors write against:
|
* public contract template authors write against:
|
||||||
*
|
*
|
||||||
* {{ project.name }} — the manifest's `name` (a.k.a. the npm scope)
|
* {{ project.name }} — the manifest's `name` (a.k.a. the npm scope)
|
||||||
@@ -13,6 +15,10 @@ import { PACKAGE_DIRS } from "./module";
|
|||||||
* Empty string for categories whose `home.kind` isn't "package".
|
* Empty string for categories whose `home.kind` isn't "package".
|
||||||
* {{ packages.<dir>.name }} — any other package by its dir, e.g. {{ packages.db.name }}
|
* {{ packages.<dir>.name }} — any other package by its dir, e.g. {{ packages.db.name }}
|
||||||
* → "@my-app/db". One entry per `PACKAGE_DIRS` member.
|
* → "@my-app/db". One entry per `PACKAGE_DIRS` member.
|
||||||
|
* {{ peers.<category> }} — id of the active one-cardinality module in that
|
||||||
|
* category, or undefined. Useful with the `eq`
|
||||||
|
* helper for conditional blocks:
|
||||||
|
* {{#if (eq peers.framework "next")}}…{{/if}}
|
||||||
*
|
*
|
||||||
* `app.*` is rebound per target app on every render pass, so a module installed
|
* `app.*` is rebound per target app on every render pass, so a module installed
|
||||||
* into two apps renders correctly in each.
|
* into two apps renders correctly in each.
|
||||||
@@ -22,30 +28,29 @@ export type TemplateContext = {
|
|||||||
app: { id: string; dir: string; kind: string };
|
app: { id: string; dir: string; kind: string };
|
||||||
package: { name: string };
|
package: { name: string };
|
||||||
packages: Record<string, { name: string }>;
|
packages: Record<string, { name: string }>;
|
||||||
|
peers: Partial<Record<CategoryId, string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Module-singleton instance so registered helpers don't leak into other
|
||||||
|
// Handlebars consumers (the global is shared otherwise).
|
||||||
|
const hb = Handlebars.create();
|
||||||
|
|
||||||
|
// String-equality helper for matching against a peer id, e.g.
|
||||||
|
// {{#if (eq peers.framework "next")}}…{{/if}}
|
||||||
|
hb.registerHelper("eq", (a: unknown, b: unknown) => a === b);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve `{{ a.b.c }}` references against a {@link TemplateContext}. Pure
|
* Render a Handlebars template against a {@link TemplateContext}. Output is
|
||||||
* regex + path walk — no helpers, no logic. Templates that need branching
|
* never HTML-escaped (`noEscape: true`) because templates emit code (JS/TS/
|
||||||
* should live as separate files and the codemod picks which one to render.
|
* JSON), not markup.
|
||||||
*
|
*
|
||||||
* Throws on missing keys so silent stub-outs can't ship to users. The CLI's
|
* Missing keys render as empty strings (Handlebars' default non-strict
|
||||||
* apply path and the web preview share this primitive, so a fresh `{{ ... }}`
|
* behavior). The CLI's apply path and the web preview share this primitive,
|
||||||
* with a typo fails the same way in both.
|
* so substitutions resolve identically in both.
|
||||||
*/
|
*/
|
||||||
export function renderTemplate(source: string, context: TemplateContext): string {
|
export function renderTemplate(source: string, context: TemplateContext): string {
|
||||||
return source.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_, key: string) => {
|
const template = hb.compile(source, { noEscape: true });
|
||||||
const value = key.split(".").reduce<unknown>((acc, part) => {
|
return template(context);
|
||||||
if (acc !== null && typeof acc === "object" && part in acc) {
|
|
||||||
return Reflect.get(acc, part);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, context);
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
throw new Error(`renderTemplate: missing or non-string key "${key}"`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,20 +62,29 @@ export function renderTemplate(source: string, context: TemplateContext): string
|
|||||||
* Each (module, target-app) iteration in the runner builds a fresh context so
|
* Each (module, target-app) iteration in the runner builds a fresh context so
|
||||||
* `app.*` points at the currently-applying app — that's how the same module's
|
* `app.*` points at the currently-applying app — that's how the same module's
|
||||||
* templates land correctly when shipped into multiple apps.
|
* templates land correctly when shipped into multiple apps.
|
||||||
|
*
|
||||||
|
* Every `PEER_CATEGORIES` key is materialized under `peers` (with `undefined`
|
||||||
|
* when no module is selected) so templates can safely reference any peer slot
|
||||||
|
* — that lets `{{peers.framework}}` resolve to `undefined` rather than walk
|
||||||
|
* off the end of a missing path.
|
||||||
*/
|
*/
|
||||||
export function buildRenderContext(opts: {
|
export function buildRenderContext(opts: {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
app: AppSpec;
|
app: AppSpec;
|
||||||
packageName: string;
|
packageName: string;
|
||||||
|
peers?: Partial<Record<CategoryId, string>>;
|
||||||
}): TemplateContext {
|
}): TemplateContext {
|
||||||
const packages: Record<string, { name: string }> = {};
|
const packages: Record<string, { name: string }> = {};
|
||||||
for (const dir of PACKAGE_DIRS) {
|
for (const dir of PACKAGE_DIRS) {
|
||||||
packages[dir] = { name: `@${opts.projectName}/${dir}` };
|
packages[dir] = { name: `@${opts.projectName}/${dir}` };
|
||||||
}
|
}
|
||||||
|
const peers: Partial<Record<CategoryId, string>> = {};
|
||||||
|
for (const cat of PEER_CATEGORIES) peers[cat] = opts.peers?.[cat];
|
||||||
return {
|
return {
|
||||||
project: { name: opts.projectName },
|
project: { name: opts.projectName },
|
||||||
app: { id: opts.app.id, dir: opts.app.dir, kind: opts.app.kind },
|
app: { id: opts.app.id, dir: opts.app.dir, kind: opts.app.kind },
|
||||||
package: { name: opts.packageName },
|
package: { name: opts.packageName },
|
||||||
packages,
|
packages,
|
||||||
|
peers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Generated
+457
-592
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,12 @@
|
|||||||
import { defineModule } from "@stanza/registry";
|
import { defineModule, type TemplateRef } from "@stanza/registry";
|
||||||
|
|
||||||
|
// Same templates regardless of which framework adapter wins. The eslint config
|
||||||
|
// is a single Handlebars template that branches on `{{peers.framework}}`, so
|
||||||
|
// pure-TS, Next, and TanStack Start all render from the same source file.
|
||||||
|
const templates: TemplateRef[] = [
|
||||||
|
{ src: "eslint.config.mjs", dest: "eslint.config.mjs", scope: "repo", template: true },
|
||||||
|
{ src: "prettier.config.mjs", dest: "prettier.config.mjs", scope: "repo" },
|
||||||
|
];
|
||||||
|
|
||||||
export default defineModule({
|
export default defineModule({
|
||||||
id: "eslint-prettier",
|
id: "eslint-prettier",
|
||||||
@@ -7,13 +15,14 @@ export default defineModule({
|
|||||||
description: "ESLint (flat config) for linting and Prettier for formatting.",
|
description: "ESLint (flat config) for linting and Prettier for formatting.",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
homepage: "https://eslint.org",
|
homepage: "https://eslint.org",
|
||||||
// ESLint config differs per framework, so dispatch on the framework pick.
|
// Framework-agnostic base; adapters layer on framework-specific plugins via
|
||||||
peers: { framework: ["next", "tanstack-start"] },
|
// Handlebars conditionals in the shared template — same shape as the
|
||||||
|
// tooling-biome / tooling-oxlint-oxfmt siblings, which also install without
|
||||||
|
// a framework peer.
|
||||||
devDependencies: {
|
devDependencies: {
|
||||||
eslint: "^9.39.0",
|
eslint: "^9.39.0",
|
||||||
"@eslint/js": "^9.39.0",
|
"@eslint/js": "^9.39.0",
|
||||||
"typescript-eslint": "^8.46.0",
|
"typescript-eslint": "^8.46.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.0",
|
|
||||||
prettier: "^3.6.2",
|
prettier: "^3.6.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
},
|
},
|
||||||
@@ -25,20 +34,25 @@ export default defineModule({
|
|||||||
{
|
{
|
||||||
key: "next",
|
key: "next",
|
||||||
match: { framework: "next" },
|
match: { framework: "next" },
|
||||||
devDependencies: { "@next/eslint-plugin-next": "^16.2.6" },
|
devDependencies: {
|
||||||
templates: [
|
"@next/eslint-plugin-next": "^16.2.6",
|
||||||
{ src: "eslint.config.next.mjs", dest: "eslint.config.mjs", scope: "repo" },
|
"eslint-plugin-react-hooks": "^7.0.0",
|
||||||
{ src: "prettier.config.mjs", dest: "prettier.config.mjs", scope: "repo" },
|
},
|
||||||
],
|
templates,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "tanstack-start",
|
key: "tanstack-start",
|
||||||
match: { framework: "tanstack-start" },
|
match: { framework: "tanstack-start" },
|
||||||
devDependencies: { "eslint-plugin-react": "^7.37.5" },
|
devDependencies: {
|
||||||
templates: [
|
"eslint-plugin-react": "^7.37.5",
|
||||||
{ src: "eslint.config.tanstack.mjs", dest: "eslint.config.mjs", scope: "repo" },
|
"eslint-plugin-react-hooks": "^7.0.0",
|
||||||
{ src: "prettier.config.mjs", dest: "prettier.config.mjs", scope: "repo" },
|
},
|
||||||
],
|
templates,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "default",
|
||||||
|
match: {},
|
||||||
|
templates,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
{{#if (eq peers.framework "next")}}
|
||||||
|
import next from "@next/eslint-plugin-next";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq peers.framework "tanstack-start")}}
|
||||||
|
import react from "eslint-plugin-react";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
{{/if}}
|
||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/dist/**",
|
||||||
|
{{#if (eq peers.framework "next")}}
|
||||||
|
"**/.next/**",
|
||||||
|
"**/next-env.d.ts",
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq peers.framework "tanstack-start")}}
|
||||||
|
"**/.output/**",
|
||||||
|
"**/.nitro/**",
|
||||||
|
"**/routeTree.gen.ts",
|
||||||
|
{{/if}}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{{#if (eq peers.framework "next")}}
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
"@next/next": next,
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...next.configs.recommended.rules,
|
||||||
|
...next.configs["core-web-vitals"].rules,
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{{/if}}
|
||||||
|
{{#if (eq peers.framework "tanstack-start")}}
|
||||||
|
{
|
||||||
|
plugins: { react, "react-hooks": reactHooks },
|
||||||
|
settings: { react: { version: "detect" } },
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{{/if}}
|
||||||
|
prettier,
|
||||||
|
);
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import js from "@eslint/js";
|
|
||||||
import next from "@next/eslint-plugin-next";
|
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
import prettier from "eslint-config-prettier";
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ["**/.next/**", "**/node_modules/**", "**/next-env.d.ts", "**/dist/**"] },
|
|
||||||
js.configs.recommended,
|
|
||||||
...tseslint.configs.recommended,
|
|
||||||
{
|
|
||||||
plugins: {
|
|
||||||
"@next/next": next,
|
|
||||||
"react-hooks": reactHooks,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...next.configs.recommended.rules,
|
|
||||||
...next.configs["core-web-vitals"].rules,
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Disables stylistic rules that conflict with Prettier — keep last.
|
|
||||||
prettier,
|
|
||||||
);
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import js from "@eslint/js";
|
|
||||||
import react from "eslint-plugin-react";
|
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
|
||||||
import tseslint from "typescript-eslint";
|
|
||||||
import prettier from "eslint-config-prettier";
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ["**/dist/**", "**/.output/**", "**/.nitro/**", "**/node_modules/**", "**/routeTree.gen.ts"] },
|
|
||||||
js.configs.recommended,
|
|
||||||
...tseslint.configs.recommended,
|
|
||||||
{
|
|
||||||
plugins: { react, "react-hooks": reactHooks },
|
|
||||||
settings: { react: { version: "detect" } },
|
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
"react/react-in-jsx-scope": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Disables stylistic rules that conflict with Prettier — keep last.
|
|
||||||
prettier,
|
|
||||||
);
|
|
||||||
Reference in new issue
Block a user