商品目录:带选项的变体、以分计价、分类、合集、翻译和安全的库存预留
代码8 个文件上下文约 576 个 token扫描通过
安装
$
genpm add @core/catalog你将获得
- 源代码位于 src/lib/catalog/,共 8 个文件。 (38.6 kB)
- AI 规则位于 src/lib/catalog/AGENTS.md,另附 IDE 规则文件。
- 添加到 .env.example 的环境变量:STORE_CURRENCY。
- 自动为你解析 @core/contracts, @core/db, @core/money, @core/rich-text。
README
此包没有 README。
约 576 个 token→ src/lib/catalog/AGENTS.md→ .cursor/rules/genpm-core-catalog.mdc
这正是你的 AI 在 src/lib/catalog 中工作时读取的内容。不会向其上下文添加其他任何内容。
@core/catalog — rules for AI agents
Purpose
Products for a store: up to 3 options (Size, Color…) and their variants with SKU, price/compare-at/cost in minor
units, stock with backorder rules, weight and image; categories (tree), manual ordered collections, tags, attributes,
per-locale translations, media references (@core/media ids), sitemap/search sources and admin resources.
reserveStock locks the variant row so two buyers can't get the last unit. No cart, prices rules or multi-warehouse.
Map
index.ts— public API:createProduct,updateProduct,getProduct,listProducts,reserveStock,releaseStock,upsertCategory,setCollection,categoryTree,lowStockVariants,catalogAdminResources.products.ts— products, variants, listing, stock.taxonomy.ts— categories and collections.sources.ts—productSitemapSource,productSearchSource.admin.ts— admin resources.schema.ts— tables.
Integration
- Env:
STORE_CURRENCY(ISO code, defaultEUR). Migrations as insrc/lib/db/AGENTS.md. - Product page:
const p = await getProduct(slug, { locale })(404 when null); showp.variantswithformat(v.price, locale)(@core/money) andv.availability; renderp.descriptionwith @core/rich-text. - Listing:
listProducts({ category, tag, collection, sort, page, minPrice, maxPrice, inStock }). - Register
productSitemapSource()(seo),productSearchSource()(search) and...catalogAdminResources()(admin). - JSON-LD with
jsonLd.product(@core/seo) usingavailabilityand the real price. - Verify: create a product with two variants in the admin and see it at
/products/<slug>.
Conventions
- Prices are integers in
STORE_CURRENCY; the server price is the only valid price (carts recompute it). - Reserve stock only inside the order transaction (
reserveStock(id, qty, tx)); release it on cancel/expiry. - Archive products instead of deleting them: orders keep references to their variants.
- Permissions:
products:read|create|update|delete.
Don't
- Don't show a compare-at price that isn't real (the EU requires the lowest price of the last 30 days for discounts).
- Don't decrement
stockwith plain updates outsidereserveStock/releaseStock. - Don't expose
costAmountin public pages or APIs.
# @core/catalog — rules for AI agents
## Purpose
Products for a store: up to 3 options (Size, Color…) and their variants with SKU, price/compare-at/cost in minor
units, stock with backorder rules, weight and image; categories (tree), manual ordered collections, tags, attributes,
per-locale translations, media references (@core/media ids), sitemap/search sources and admin resources.
`reserveStock` locks the variant row so two buyers can't get the last unit. No cart, prices rules or multi-warehouse.
## Map
- `index.ts` — public API: `createProduct`, `updateProduct`, `getProduct`, `listProducts`, `reserveStock`, `releaseStock`, `upsertCategory`, `setCollection`, `categoryTree`, `lowStockVariants`, `catalogAdminResources`.
- `products.ts` — products, variants, listing, stock. `taxonomy.ts` — categories and collections.
- `sources.ts` — `productSitemapSource`, `productSearchSource`. `admin.ts` — admin resources. `schema.ts` — tables.
## Integration
1. Env: `STORE_CURRENCY` (ISO code, default `EUR`). Migrations as in `src/lib/db/AGENTS.md`.
2. Product page: `const p = await getProduct(slug, { locale })` (404 when null); show `p.variants` with
`format(v.price, locale)` (@core/money) and `v.availability`; render `p.description` with @core/rich-text.
3. Listing: `listProducts({ category, tag, collection, sort, page, minPrice, maxPrice, inStock })`.
4. Register `productSitemapSource()` (seo), `productSearchSource()` (search) and `...catalogAdminResources()` (admin).
5. JSON-LD with `jsonLd.product` (@core/seo) using `availability` and the real price.
6. Verify: create a product with two variants in the admin and see it at `/products/<slug>`.
## Conventions
- Prices are integers in `STORE_CURRENCY`; the server price is the only valid price (carts recompute it).
- Reserve stock only inside the order transaction (`reserveStock(id, qty, tx)`); release it on cancel/expiry.
- Archive products instead of deleting them: orders keep references to their variants.
- Permissions: `products:read|create|update|delete`.
## Don't
- Don't show a compare-at price that isn't real (the EU requires the lowest price of the last 30 days for discounts).
- Don't decrement `stock` with plain updates outside `reserveStock`/`releaseStock`.
- Don't expose `costAmount` in public pages or APIs.
应用 .genpmignore 后将被注入的确切目录树。固定于
// Recursos del panel: productos (con variantes en el mismo formulario), categorías y colecciones.
import { and, asc, count, desc, eq, ilike, inArray, or, type SQL, sql } from 'drizzle-orm';
import { z } from 'zod';
import type { AdminContext, AdminResource } from '../contracts/index.ts';
import { getDb } from '../db/index.ts';
import { CatalogError, createProduct, getProductById, ProductInput, type ProductView, updateProduct, VariantInput } from './products.ts';
import { type Category, categories, products } from './schema.ts';
import { CategoryInput, upsertCategory } from './taxonomy.ts';
async function need(ctx: AdminContext, perm: string) {
if (!(await ctx.can(perm))) throw new CatalogError('forbidden', `forbidden: ${perm}`);
}
/**
* Fila del panel: el producto con la misma forma que su entrada (`ProductInput`), para que editar y guardar sea un
* ida y vuelta sin pérdidas: categorías por slug y variantes con precios en unidades menores.
*/
export type ProductAdminRow = Omit<ProductView, 'categories' | 'variants'> & {
categories: string[];
variants: Array<z.input<typeof VariantInput>>;
};
export function toAdminRow(p: ProductView): ProductAdminRow {
return {
...p,
categories: p.categories.map((c) => c.slug),
variants: p.variants.map((v) => ({
id: v.id,
...(v.sku && { sku: v.sku }),
options: v.options,
price: v.priceAmount,
compareAt: v.compareAtAmount,
cost: v.costAmount,
stock: v.stock,
trackStock: v.trackStock,
allowBackorder: v.allowBackorder,
lowStockThreshold: v.lowStockThreshold,
weightGrams: v.weightGrams,
active: v.active,
mediaId: v.mediaId,
})),
};
}
const row = async (id: string) => {
const v = await getProductById(id);
return v ? toAdminRow(v) : null;
};
export const productsAdminResource: AdminResource<ProductAdminRow> = {
name: 'products',
label: { singular: 'Product', plural: 'Products' },
group: 'Store',
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true, list: true },
{ name: 'slug', label: 'Slug', type: 'slug', required: true },
{ name: 'status', label: 'Status', type: 'select', list: true, options: ['draft', 'active', 'archived'].map((v) => ({ value: v, label: v })) },
{ name: 'description', label: 'Description', type: 'richText' },
{ name: 'media', label: 'Images', type: 'media', many: true },
{ name: 'categories', label: 'Categories', type: 'relation', relationTo: 'product-categories', relationKey: 'slug', many: true },
{ name: 'options', label: 'Options (e.g. Size: S, M, L)', type: 'json' },
{ name: 'variants', label: 'Variants (price in cents, stock, SKU)', type: 'json', required: true },
{ name: 'tags', label: 'Tags', type: 'json' },
{ name: 'brand', label: 'Brand', type: 'text' },
{ name: 'seoTitle', label: 'SEO title', type: 'text' },
{ name: 'seoDescription', label: 'SEO description', type: 'textarea' },
],
input: ProductInput,
title: (p) => p.name,
async list(q, ctx) {
await need(ctx, 'products:read');
const conds: SQL[] = [];
if (q.filters?.status) conds.push(sql`${products.status} = ${q.filters.status}`);
if (q.search) {
const like = `%${q.search.replace(/[%_\\]/g, (m) => `\\${m}`)}%`;
conds.push(or(ilike(products.name, like), ilike(products.slug, like), sql`exists (select 1 from product_variants v where v.product_id = ${products.id} and v.sku ilike ${like})`)!);
}
const where = conds.length ? and(...conds) : undefined;
const [total] = await getDb().select({ n: count() }).from(products).where(where);
const rows = await getDb().select({ id: products.id }).from(products).where(where).orderBy(desc(products.updatedAt)).limit(q.pageSize).offset((Math.max(q.page, 1) - 1) * q.pageSize);
const views = await Promise.all(rows.map((r) => row(r.id)));
return { rows: views.filter((v): v is ProductAdminRow => v !== null), total: total?.n ?? 0 };
},
async get(id, ctx) {
await need(ctx, 'products:read');
return row(id);
},
async create(input, ctx) {
await need(ctx, 'products:create');
const p = await createProduct(input as z.input<typeof ProductInput>);
return (await row(p.id))!;
},
async update(id, input, ctx) {
await need(ctx, 'products:update');
await updateProduct(id, input as z.input<typeof ProductInput>);
return (await row(id))!;
},
actions: [
{
name: 'archive',
label: 'Archive',
permission: 'products:update',
confirm: true,
available: (p) => p.status !== 'archived',
async run(id, _i, ctx) {
await need(ctx, 'products:update');
await getDb().update(products).set({ status: 'archived' }).where(eq(products.id, id));
return (await row(id))!;
},
},
],
};
/** Añade `parent` (slug del padre) a las filas: así el formulario usa la misma forma que `CategoryInput`. */
async function withParent(rows: Category[]): Promise<Array<Category & { parent: string | null }>> {
const ids = [...new Set(rows.map((r) => r.parentId).filter((x): x is string => !!x))];
const parents = ids.length ? await getDb().select({ id: categories.id, slug: categories.slug }).from(categories).where(inArray(categories.id, ids)) : [];
return rows.map((r) => ({ ...r, parent: parents.find((p) => p.id === r.parentId)?.slug ?? null }));
}
/** Nombre `product-categories` para no chocar con las categorías del blog en el mismo panel. */
export const categoriesAdminResource: AdminResource<Category & { parent: string | null }> = {
name: 'product-categories',
label: { singular: 'Category', plural: 'Categories' },
group: 'Store',
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true, list: true },
{ name: 'slug', label: 'Slug', type: 'slug', required: true, list: true },
{ name: 'parent', label: 'Parent', type: 'relation', relationTo: 'product-categories', relationKey: 'slug' },
{ name: 'description', label: 'Description', type: 'textarea' },
{ name: 'position', label: 'Order', type: 'number' },
],
input: CategoryInput,
title: (c) => c.name,
async list(q, ctx) {
await need(ctx, 'products:read');
const where = q.search ? ilike(categories.name, `%${q.search.replace(/[%_\\]/g, (m) => `\\${m}`)}%`) : undefined;
const [total] = await getDb().select({ n: count() }).from(categories).where(where);
const rows = await getDb().select().from(categories).where(where).orderBy(asc(categories.position), asc(categories.name)).limit(q.pageSize).offset((Math.max(q.page, 1) - 1) * q.pageSize);
return { rows: await withParent(rows), total: total?.n ?? 0 };
},
async get(id, ctx) {
await need(ctx, 'products:read');
const [row] = await getDb().select().from(categories).where(eq(categories.id, id));
return row ? ((await withParent([row]))[0] ?? null) : null;
},
async create(input, ctx) {
await need(ctx, 'products:create');
return (await withParent([await upsertCategory(input as z.input<typeof CategoryInput>)]))[0]!;
},
async update(_id, input, ctx) {
await need(ctx, 'products:update');
return (await withParent([await upsertCategory(input as z.input<typeof CategoryInput>)]))[0]!;
},
async delete(id, ctx) {
await need(ctx, 'products:delete');
await getDb().delete(categories).where(eq(categories.id, id));
},
};
export const catalogAdminResources = () => [productsAdminResource, categoriesAdminResource];
此包未声明 MCP 服务器。
| 版本 | 提交 | 发布时间 | 扫描 |
|---|---|---|---|
| 1.0.0 | 79d3468 | 4小时前 | 扫描通过 |
- npm
- zod ^4.0.0
- 建议
- GenPM 会给出 npm 命令建议,只有你同意时才会运行。
- 扫描
- 扫描通过 · 0 个问题
- 提交
- v1.0.0 → 79d3468925acbde57d17a18a32e9d4bdefc28b5e · 获取后已校验
- 脚本
- 无。GenPM 从不运行包中的代码。
- 许可证
- MIT
- 质量
- 100/100
- 可识别的许可证已满足
- AGENTS.md 说明了用途已满足
- AGENTS.md 包含集成步骤已满足
- AGENTS.md 列出约定或禁止事项已满足
- 包含测试已满足
- 通过安全扫描已满足
- 最近 6 个月内发布已满足
- 已验证的发布者已满足
- 摘要和关键词已满足
- 举报
- 发现问题了吗?