Ghost 成员自定义字段类型包 @tryghost/custom-field-types:共享目录如何统一存储路由与值校验
2026/9/8 19:45:01 网站建设 项目流程

Ghost 成员自定义字段类型包 @tryghost/custom-field-types:共享目录如何统一存储路由与值校验

【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost

Ghost 允许发布者为会员(member)定义自定义字段,而字段"什么值才算合法"这一规则必须被前后端同时遵守:Ghost 核心(core)在保存时强制执行,后台管理界面(admin)在输入时即时反馈。@tryghost/custom-field-types就是这个规则的"单一事实来源":一个位于 packages/custom-field-types 的 workspace 共享包,同时提供字段类型目录、值校验 schema、字段身份(identity)格式化与 CSV 列映射。读完后,你将理解 Ghost 如何用 zod 声明式地约束自定义字段取值、如何让 composite 类型(如地址)安全地展开成扁平 CSV 列,以及一个 ESM 包在 CommonJS 核心中通过require(esm)消费时所受的硬性约束。

包的定位与三个导出入口

包的描述在 package.json 中一句话概括:"Shared catalog of member custom field types: storage routing and value validation, consumed by Ghost core and admin"——它是存储路由和值校验的共享目录,消费方是 Ghost core 与 admin。

该包提供三个入口,均带source导出条件:

导出源码编译产物职责
@tryghost/custom-field-typessrc/index.tsbuild/index.js类型目录FIELD_TYPES及其派生 API
@tryghost/custom-field-types/csvsrc/csv.tsbuild/csv.js值 ↔ CSV 列的映射
@tryghost/custom-field-types/identitysrc/identity.tsbuild/identity.jsnamespace.key.part身份字符串的格式化与解析

source条件是本 monorepo 的机制:monorepo 内部的消费方在开发/测试时直接解析到原始src/*.ts,无需先构建;生产环境和对外发布的 tarball 才走build/编译产物(见 package.json 的exports字段)。core 侧的实际消费可见于 values-service.ts,它从根导出与/identity子导出同时引入FIELD_TYPESsubFieldsOfCUSTOM_NAMESPACEformatIdentityparseIdentity

类型目录:三种字段类型与"记录型"抽象

目录的源头是一个常量元组,zod 枚举与FIELD_TYPES的键都从它派生:

// packages/custom-field-types/src/index.ts export const FIELD_TYPE_IDS = ['short_text', 'long_text', 'address'] as const; export type FieldType = (typeof FIELD_TYPE_IDS)[number]; export const FieldTypeSchema = z.enum(FIELD_TYPE_IDS);

一个字段类型要么是值本身(声明一个值必须满足的 schema),要么是记录(record):一组命名部件,每个部件本身也是一个值,整个类型的 schema 从部件派生——"部件本身也可以是记录"在类型上是允许的。当前目录中唯一走 record 路径的是address

defineFieldTypes用一个Record<FieldType, FieldTypeDefinition>的约束实现了穷尽性检查:一旦向FIELD_TYPE_IDS新增 id,而这里没有对应声明,编译直接失败,"没有任何代码知道如何比较它的类型不存在"。

标量类型:short_text 与 long_text

两个文本类型共享同一个 builder,保证任何字段对空白字符的处理一致——纯空格的值等于没有值,先 trim 也保证上限量的是值本身而不是填充:

const text = () => z.string({ error: 'Enter text.' }).trim(); const longText = () => text().refine((value) => byteLength(value) <= MAX_LONG_TEXT_BYTES, { error: 'This text is too long to save. Shorten it a little.', });

几个关键设计决策(均可在 src/index.ts 找到对应实现):

  • 字节上限而非字符上限MAX_LONG_TEXT_BYTES = 65535,因为 MySQLTEXT列按字节计量:按字符设限会接受多字节值而列装不下。计数用TextEncoder而非Buffer,因为这个包也会跑在浏览器里。
  • short_text上限 255 字符PART_TYPES.short_texttext().max(255)),对应 VARCHAR 列的量级。
  • 错误消息是纯字面量、无插值,因此每一条都可直接当翻译 key 使用。翻译动作不在此包内:Ghost 的解析器从"渲染该字符串的 app"中读取t()调用,今天唯一消费方 admin 本身未做翻译。测试 test/index.test.ts 专门断言:每条规则失败给出的都是一句完整句子(首字母大写、以句号结尾),且不允许回落到 zod 默认的 "Too big / Invalid input" 措辞——规则与句子绑定,两者不会漂移。

部件类型:PART_TYPES

record 的部件各有其类型,"每种东西定义一次,规则也定义一次":

部件类型规则失败消息设计说明
short_text至多 255 字符Use 255 characters or fewer.街道地址需要比邮编更大的空间
postal_code至多 32 字符Use 32 characters or fewer.是合理性上限而非格式校验:各国邮编格式差异太大,且"国家"是同级的兄弟部件,邮编规则自己看不到
country_code正则/^[A-Za-z]{2}$/toUpperCase()Enter a 2-letter country code, like US.只校验 ISO 3166-1 alpha-2 的形状,对照国家列表;大小写归一化使gb/GB是同一个值

country_code值得展开:源码注释解释了为什么在入口处按"两个 ASCII 字母"校验,而不是在出口按长度校验——因为大写化不保持长度ß变成SS变成ASS)。测试用例(test/index.test.ts)把ß(连字大写化为 FI)以及12!!都断言为拒绝。另一个刻意的取舍是不维护国家闭集:"名单的归属是有争议的,闭集会让 Ghost 替每个站点的每个成员裁决"——测试特意断言XK(科索沃的非正式代码)等代码可通过。

地址:一个 record 类型

// packages/custom-field-types/src/index.ts address: record( { line1: part('short_text'), line2: part('short_text'), city: part('short_text'), state: part('short_text'), postal_code: part('postal_code'), country: part('country_code'), }, { error: 'Enter at least one part of the address.' }, ),

record()构造器(src/index.ts)做了三件事:

  1. 每个部件包上clearabletext().pipe(z.union([z.literal(''), schema])).optional()。这是"写入语义"的核心——写入只声明"要改什么":没提到的部件保持原样,空字符串是"清除该部件"的声明,而不是部件的值。因此无论部件自身规则是长度上限(空串天然通过)还是格式正则(空串本应被拒),都必须接受空串,否则会出现"某个部件能设而不能删"。
  2. strictObject:未声明的部件名被拒绝而不是静默丢弃,拒绝信息保留 zod 原话并点名问题 key——"拼错的部件在这里被拒,因为部件的声明就在这个文件里,别处没有"。
  3. 至少一个非空部件refine要求Object.values(parts).some(entry => typeof entry === 'string')。注意源码强调这个检查是"承重"的:显式undefined的可选部件会解析为"持有 undefined 的键",简单存在性检查会让{ line1: undefined }蒙混过关。所有部件都是可选的,因为没有任何部件在所有国家都存在(爱尔兰没有邮编、港式门牌地址没有 city);"需要哪些部件"是按国家的问题,只有采集表单知道国家。

另一个边界:地址不含收件人姓名。包裹确实需要姓名,但那是"寄包裹"的事实而非地址类型的事实(礼物订阅、公司收件、c/o 场景下姓名常不等于账户名),需要的站点应自建字段——这也与 Stripe Address 对象"姓名在地址之外"的形态一致。测试 test/index.test.ts 断言{ name: 'Bex Jones' }是非法地址。

目录的派生 API

包还导出若干供消费方使用的类型级与运行时 API:

  • subFieldsOf(type):按声明顺序返回部件键数组;标量类型或本构建"没听说过"的类型返回null(降级而非报错——旧版 admin 对着新服务器拿到未知类型时,"读作无部件"让服务器保持权威)。
  • partTypesOf(type)keyof映射成{ line1: 'short_text', …, postal_code: 'postal_code', country: 'country_code' }形式的部件类型表;无部件的类型返回null
  • AddressValue/Address:地址 schema 与其推断类型,命名对齐 admin 侧"讲 address 而非 record"的类型。
  • FieldValue/PartsOf<T>:由 schema 派生(而非手工枚举),新增类型时自动加宽;PartsOfT上分布式求值,持有FieldType泛型变量的调用方拿到"该类型声明的部件"而不是所有类型的空交集。

这些契约在 test/index.test.ts 中被逐条钉死,包括address的部件顺序恰为['line1', 'line2', 'city', 'state', 'postal_code', 'country']

identity 模块:字段在所有"写下名字的表面"上的统一命名

src/identity.ts 定义了一个字段在 CSV 列头、过滤器取值、错误路径、URL 等所有以文本写下名字的地方如何被命名:

export const QUALIFIER = 'metafields'; // 容器限定词:payload key、路由前缀、CSV 列前缀、过滤 relation 别名各写一次 export const SEPARATOR = '.'; export const CUSTOM_NAMESPACE = 'custom'; // 站点员工定义字段所在命名空间 export const IDENTITY_SEGMENT = /^[a-z0-9_]+$/;

格式为namespace.key,composite 追加部件路径:custom.shipping_address.country。按点号位置切分是安全的,因为 key 里永远不含点(key 铸造处强制)。两个函数构成可逆对:

parseIdentity('custom.shipping_address.country') // → { namespace: 'custom', key: 'shipping_address', partPath: 'country' } formatIdentity({ namespace: 'custom', key: 'company', partPath: null }) // → 'custom.company'

parseIdentity拒绝任何不是严格namespace-dot-key的输入(空串、尾点、大写、连字符、空格均返回null,见 test/identity.test.ts);而命名空间本身是数据而非注册表,parseIdentity('transistor.private_url')同样合法。CUSTOM_NAMESPACE被注释为"只是常量":存储这些字段的表没有 namespace 列,它只装该命名空间的字段,查询层需替行补齐这个隐式名字;其他层不应与之比较——"没人声明过字段的命名空间是空命名空间,不是错误"。

csv 模块:扁平文件与嵌套值之间的往返

CSV 是扁平的,值不一定是,所以一个 composite 占每个子字段一列。列形状从类型自身的 value schema 派生(经subFieldsOf),而不是在旁边另行声明——两者无法漂移,子字段增删或改为可选时依然正确(可选子字段仍是列,只是更常空着)。

导出侧(src/csv.ts):

csvColumnsForField({ namespace: 'custom', key: 'shipping_address', type: 'address' }) // → [{ column: 'metafields.custom.shipping_address.line1', subField: 'line1' }, …] csvCellsForFields([nickname, address], { custom: { … } }) // → { 'metafields.custom.nickname': 'Bex', // 'metafields.custom.shipping_address.line1': '1 High Street', … }

两个不变量由 test/csv.test.ts 固化:

  • 列集合只由字段定义决定,与成员是否持有值无关——导出用单行取表头,无值的字段不产生列就会从整个文件里消失;
  • 命名空间隔离同名冲突:发布者可把字段命名为email,其列是metafields.custom.email而非顶替核心导出的email列。

导入侧的fieldValuesFromCsvRowcsvCellsForFields的逆运算,规则(同样有测试逐条钉住):

  • 只有非空单元格才写回字段;空单元格或缺列 = 不触碰现有值,不是清除——重新导入一份部分编辑过的导出不会抹掉发布者没动过的值;
  • 每个子单元格全空白的 composite 整体省略(导出把"没有地址"与"全空白地址"写成同一种东西);只要有非空白单元格,就只读非空白部件、拼成部分值交调用方整体校验(格式不对就整行失败);
  • 指向已不存在字段的列被丢弃而非报错;
  • decodeCell钩子默认是恒等函数,由调用方负责具体文件的反序列化——成员导入器会传一个剥掉导出公式前缀的实现——CSV 词表本身不持有任何转义知识。

isCustomFieldColumnmetafields前缀识别自定义列,且刻意不把custom_fields_note这类"只是以这个词开头"的核心列认作自定义列(test/csv.test.ts)。

开发、构建与 require(esm) 约束

从仓库根目录执行(README 的 Develop 一节):

pnpm --filter @tryghost/custom-field-types build # tsc 编译到 build/(ESM) pnpm --filter @tryghost/custom-field-types test # 类型检查 + 单元测试 pnpm --filter @tryghost/custom-field-types dev # 变更时重建

其中test实际展开为test:typestsc --noEmit -p test/tsconfig.json,见 package.json 的test:types)与test:unitNODE_ENV=testing vitest run --coverage)。nx 已将build的输出声明为{projectRoot}/build,供增量缓存。

有三条硬性工程约束来自 README,全部与包的模块形态绑定:

  1. ESM-only,tsc+module: nodenextsrc/内的相对导入必须显式带扩展名,且要写真实的.ts后缀(import {x} from './x.ts'),tsc通过rewriteRelativeImportExtensions在输出时改写成.js。这一点在源码里随处可见,例如 csv.ts 的import { subFieldsOf } from './index.ts'
  2. source导出条件:monorepo 内开发/测试直接用原始 TS,生产用build/
  3. 禁止顶层awaitghost/core是 CommonJS,通过require()消费本包,在 Ghost 支持的 Node 版本(22.13+/24)上依赖 Node 的require(esm)支持。该支持有一条硬约束:包内任何模块出现顶层await都会使模块图变为异步,require()抛出ERR_REQUIRE_ASYNC_MODULE。因此模块级初始化必须保持同步,且有一条 ESLint 规则强制这一点(该包 lint 配置在 eslint.config.mjs)。

核心侧如何消费:从校验到存储

在 core 端,目录是值服务的校验依据:values-service.ts 从包中引入FIELD_TYPES与 identity 工具,把请求里的metafields.custom.*属性解析为字段身份,只对站点真实定义的 active 字段做写入;composite 的每个部件落到members_custom_field_values表的一条叶子行(path升序保证部件每次以相同顺序装配)。按 test/index.test.ts 的注释,逐类型的行为级结果(校验、地址往返、子字段 422)由成员自定义字段 HTTP API 的集成测试端到端证明,而本包的单元测试只钉住目录契约本身——这正是"规则只写在一处"的分层方式。

小结

@tryghost/custom-field-types用一个极小的 API 面解决了"同一合法性规则被多个渲染层共享"的问题:

  • FIELD_TYPES+PART_TYPES唯一的规则来源,穷尽性由Record<FieldType, …>在编译期保证;
  • 写入语义(缺失 = 保持、空串 = 清除、未知名 = 拒绝)在标量与部件两层一致执行;
  • identitycsv两个子导出把"字段如何被命名"与"值如何落入扁平列"从 core/admin 各自的实现中抽走,让导出文件可以不经手工重映射地重新导入;
  • ESM +source条件 + 无顶层await的组合,使这个纯逻辑包同时满足浏览器运行(TextEncoder)、monorepo 内免构建开发、以及 CommonJS 核心经require(esm)消费三种场景。

想继续深入,建议按 src/index.ts 的注释通读一遍,再对照 test/index.test.ts、test/csv.test.ts、test/identity.test.ts 中每条用例对应的语义断言——该包的测试注释几乎把每条规则的"为什么"都讲清楚了。

【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询