diff --git a/.changeset/social-objects-extend.md b/.changeset/social-objects-extend.md new file mode 100644 index 0000000..eb5aaa4 --- /dev/null +++ b/.changeset/social-objects-extend.md @@ -0,0 +1,11 @@ +--- +"@fujocoded/zod-transform-socials": patch +--- + +Exports `SocialLinkObjectSchema` so projects can extend the object form of a +social link without rebuilding the whole schema. `SocialLinkInputSchema` is also +available as the clearer name for the default one-item input schema, with +matching `SocialLinkObject` and `SocialLinkInput` types. + +Adds standalone examples for preserving custom fields like `label` through +the transform. diff --git a/zod-transform-socials/README.md b/zod-transform-socials/README.md index 188f888..aa2f4b0 100644 --- a/zod-transform-socials/README.md +++ b/zod-transform-socials/README.md @@ -156,6 +156,40 @@ interface Props { } ``` +### Extending the input object + +If your content needs extra fields before the social links are transformed, +extend `SocialLinkObjectSchema` and build your own schema: + +```ts +import { + SocialLinkObjectSchema, + transformSocial, + urlSchema, +} from "@fujocoded/zod-transform-socials"; // or from "@fujocoded/zod-transform-socials/zod4" +import { z } from "zod"; + +const SocialLinkWithLabel = z.union([ + urlSchema.transform(transformSocial), + SocialLinkObjectSchema.extend({ + label: z.string().optional(), + }).transform((socialLinkData) => ({ + ...transformSocial(socialLinkData), + label: socialLinkData.label, + })), +]); + +const Member = z.object({ + name: z.string(), + contacts: z.array(SocialLinkWithLabel).default([]), +}); +``` + +For complete runnable versions, see the standalone extension examples: +[`03-zod-3-standalone/parse-extension.ts`](./__examples__/03-zod-3-standalone/parse-extension.ts) +and +[`04-zod-4-standalone/parse-extension.ts`](./__examples__/04-zod-4-standalone/parse-extension.ts). + ## Setting known domains For platforms without a fixed domain, like `mastodon`, the built-in matchers diff --git a/zod-transform-socials/__examples__/03-zod-3-standalone/README.md b/zod-transform-socials/__examples__/03-zod-3-standalone/README.md index fd5e2a0..f17f5ec 100644 --- a/zod-transform-socials/__examples__/03-zod-3-standalone/README.md +++ b/zod-transform-socials/__examples__/03-zod-3-standalone/README.md @@ -26,7 +26,14 @@ it: you build the schema, you call `.parse()`, you read the result. npm start ``` -3. Run the type check. This checks the same `parse.ts` file and proves +3. Run the extension script. It shows how to extend the object form with a + `label` field and keep that label in the transformed output: + + ```bash + npm run extension + ``` + +4. Run the type check. This checks both parse scripts and proves the package's exported types resolve correctly under Zod 3: ```bash diff --git a/zod-transform-socials/__examples__/03-zod-3-standalone/package.json b/zod-transform-socials/__examples__/03-zod-3-standalone/package.json index 6087bd6..27e6e1e 100644 --- a/zod-transform-socials/__examples__/03-zod-3-standalone/package.json +++ b/zod-transform-socials/__examples__/03-zod-3-standalone/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "extension": "node parse-extension.ts", "start": "node parse.ts", "typecheck": "tsc --noEmit" }, diff --git a/zod-transform-socials/__examples__/03-zod-3-standalone/parse-extension.ts b/zod-transform-socials/__examples__/03-zod-3-standalone/parse-extension.ts new file mode 100644 index 0000000..f621fae --- /dev/null +++ b/zod-transform-socials/__examples__/03-zod-3-standalone/parse-extension.ts @@ -0,0 +1,72 @@ +import { strict as assert } from "node:assert"; +import { + SocialLinkObjectSchema, + transformSocial, + urlSchema, +} from "@fujocoded/zod-transform-socials"; +import { z } from "zod"; + +const SocialLinkWithLabel = z.union([ + // Simple links get transformed just like the library does + urlSchema.transform(transformSocial), + SocialLinkObjectSchema.extend({ + // We extend object links with an optional label field + label: z.string().optional(), + }).transform((socialLinkData) => ({ + // We transform the socialLinkData just like the library does + ...transformSocial(socialLinkData), + // And we also add the label field + label: socialLinkData.label, + })), +]); + +const SocialLinksWithLabels = z.array(SocialLinkWithLabel).default([]); + +const Member = z.object({ + name: z.string(), + contacts: SocialLinksWithLabels, +}); + +const parsed = Member.parse({ + name: "essential-randomness", + contacts: [ + { + url: "https://essentialrandomness.com", + label: "Website", + }, + { + url: "https://indiepocalypse.social/@essentialrandom", + platform: "mastodon", + label: "Mastodon", + }, + "https://github.com/essential-randomness", + ], +}); + +assert.deepStrictEqual(parsed, { + name: "essential-randomness", + contacts: [ + { + url: "https://essentialrandomness.com", + platform: "custom", + username: null, + icon: null, + label: "Website", + }, + { + icon: "simple-icons:mastodon", + url: "https://indiepocalypse.social/@essentialrandom", + platform: "mastodon", + username: null, + label: "Mastodon", + }, + { + url: "https://github.com/essential-randomness", + platform: "github", + username: "essential-randomness", + icon: "simple-icons:github", + }, + ], +}); + +console.log(JSON.stringify(parsed, null, 2)); diff --git a/zod-transform-socials/__examples__/03-zod-3-standalone/parse.ts b/zod-transform-socials/__examples__/03-zod-3-standalone/parse.ts index 1d710e0..a212e64 100644 --- a/zod-transform-socials/__examples__/03-zod-3-standalone/parse.ts +++ b/zod-transform-socials/__examples__/03-zod-3-standalone/parse.ts @@ -2,8 +2,8 @@ import { strict as assert } from "node:assert"; import { z } from "zod"; import { SocialLinks } from "@fujocoded/zod-transform-socials"; import type { + SocialLinkInput, SocialLinksData, - SocialsSchema, } from "@fujocoded/zod-transform-socials"; // Use `SocialLinks` as a schema field to parse and enrich contact URLs. @@ -12,9 +12,9 @@ const Member = z.object({ contacts: SocialLinks, }); -// `SocialsSchema` types each item the schema will accept: a bare URL string, +// `SocialLinkInput` types each item the schema will accept: a bare URL string, // or an object that overrides the detected platform / username / icon. -const contacts: SocialsSchema[] = [ +const contacts: SocialLinkInput[] = [ "https://essentialrandomness.com", "https://essential-randomness.tumblr.com", "https://twitter.com/essentialrandom", diff --git a/zod-transform-socials/__examples__/03-zod-3-standalone/tsconfig.json b/zod-transform-socials/__examples__/03-zod-3-standalone/tsconfig.json index a1f8fb3..35aa0f7 100644 --- a/zod-transform-socials/__examples__/03-zod-3-standalone/tsconfig.json +++ b/zod-transform-socials/__examples__/03-zod-3-standalone/tsconfig.json @@ -7,5 +7,5 @@ "skipLibCheck": false, "noEmit": true }, - "include": ["parse.ts"] + "include": ["parse*.ts"] } diff --git a/zod-transform-socials/__examples__/04-zod-4-standalone/.npmrc b/zod-transform-socials/__examples__/04-zod-4-standalone/.npmrc new file mode 100644 index 0000000..ac77f78 --- /dev/null +++ b/zod-transform-socials/__examples__/04-zod-4-standalone/.npmrc @@ -0,0 +1,7 @@ +# Copy the local `file:../..` package into node_modules instead of symlinking it. +# Without this, TypeScript can realpath the package's `/zod4` declarations back +# to this workspace and resolve `zod/v4` against the workspace's Zod 3 dev +# dependency instead of this consumer's zod@4, mixing Zod type families. A real +# registry install has no workspace symlink, so this flag makes the local file +# install match published-package resolution. +install-links=true diff --git a/zod-transform-socials/__examples__/04-zod-4-standalone/README.md b/zod-transform-socials/__examples__/04-zod-4-standalone/README.md index 134168f..9249b86 100644 --- a/zod-transform-socials/__examples__/04-zod-4-standalone/README.md +++ b/zod-transform-socials/__examples__/04-zod-4-standalone/README.md @@ -26,7 +26,14 @@ standalone example, except the import comes from npm start ``` -3. Run the type check. This checks the same `parse.ts` file and proves +3. Run the extension script. It shows how to extend the object form with a + `label` field and keep that label in the transformed output: + + ```bash + npm run extension + ``` + +4. Run the type check. This checks both parse scripts and proves the package's `/zod4` exported types resolve correctly under Zod 4: ```bash diff --git a/zod-transform-socials/__examples__/04-zod-4-standalone/package-lock.json b/zod-transform-socials/__examples__/04-zod-4-standalone/package-lock.json index 60c6822..8fc5092 100644 --- a/zod-transform-socials/__examples__/04-zod-4-standalone/package-lock.json +++ b/zod-transform-socials/__examples__/04-zod-4-standalone/package-lock.json @@ -16,26 +16,17 @@ "typescript": "^5.5.4" } }, - "../..": { - "name": "@fujocoded/zod-transform-socials", - "version": "0.0.15", + "node_modules/@fujocoded/zod-transform-socials": { + "version": "0.1.0", + "resolved": "file:../..", "license": "MIT", "dependencies": { "social-links": "^1.14.0" }, - "devDependencies": { - "tsup": "^8.1.0", - "typescript": "^5.5.2", - "zod": "^3.25.76" - }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/@fujocoded/zod-transform-socials": { - "resolved": "../..", - "link": true - }, "node_modules/@types/node": { "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", @@ -46,6 +37,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/social-links": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/social-links/-/social-links-1.15.1.tgz", + "integrity": "sha512-GCB0h9sSo/MQt5F+GsJYqQf+abYkCPZtLXtvk3FrQj1qzEnKqy6y4NJfLXMENeJMJveybMqEt8oJN+uhw5KL5w==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/zod-transform-socials/__examples__/04-zod-4-standalone/package.json b/zod-transform-socials/__examples__/04-zod-4-standalone/package.json index 9375371..b54fd23 100644 --- a/zod-transform-socials/__examples__/04-zod-4-standalone/package.json +++ b/zod-transform-socials/__examples__/04-zod-4-standalone/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "extension": "node parse-extension.ts", "start": "node parse.ts", "typecheck": "tsc --noEmit" }, diff --git a/zod-transform-socials/__examples__/04-zod-4-standalone/parse-extension.ts b/zod-transform-socials/__examples__/04-zod-4-standalone/parse-extension.ts new file mode 100644 index 0000000..77cee7b --- /dev/null +++ b/zod-transform-socials/__examples__/04-zod-4-standalone/parse-extension.ts @@ -0,0 +1,70 @@ +import { strict as assert } from "node:assert"; +import { + SocialLinkObjectSchema, + transformSocial, + urlSchema, +} from "@fujocoded/zod-transform-socials/zod4"; +import { z } from "zod"; + +const SocialLinkWithLabel = z.union([ + // Simple links get transformed just like the library does + urlSchema.transform(transformSocial), + SocialLinkObjectSchema.extend({ + // We extend object links with an optional label field + label: z.string().optional(), + }).transform((socialLinkData) => ({ + // We transform the socialLinkData just like the library does + ...transformSocial(socialLinkData), + // And we also add the label field + label: socialLinkData.label, + })), +]); + +const Member = z.object({ + name: z.string(), + contacts: z.array(SocialLinkWithLabel).default([]), +}); + +const parsed = Member.parse({ + name: "essential-randomness", + contacts: [ + { + url: "https://essentialrandomness.com", + label: "Website", + }, + { + url: "https://indiepocalypse.social/@essentialrandom", + platform: "mastodon", + label: "Mastodon", + }, + "https://github.com/essential-randomness", + ], +}); + +assert.deepStrictEqual(parsed, { + name: "essential-randomness", + contacts: [ + { + url: "https://essentialrandomness.com", + platform: "custom", + username: null, + icon: null, + label: "Website", + }, + { + icon: "simple-icons:mastodon", + url: "https://indiepocalypse.social/@essentialrandom", + platform: "mastodon", + username: null, + label: "Mastodon", + }, + { + url: "https://github.com/essential-randomness", + platform: "github", + username: "essential-randomness", + icon: "simple-icons:github", + }, + ], +}); + +console.log(JSON.stringify(parsed, null, 2)); diff --git a/zod-transform-socials/__examples__/04-zod-4-standalone/parse.ts b/zod-transform-socials/__examples__/04-zod-4-standalone/parse.ts index 6669a31..cfc5e02 100644 --- a/zod-transform-socials/__examples__/04-zod-4-standalone/parse.ts +++ b/zod-transform-socials/__examples__/04-zod-4-standalone/parse.ts @@ -6,8 +6,8 @@ import { transformSocial, } from "@fujocoded/zod-transform-socials/zod4"; import type { + SocialLinkInput, SocialLinksData, - SocialsSchema, } from "@fujocoded/zod-transform-socials/zod4"; // Use `SocialLinks` as a schema field to parse and enrich contact URLs. @@ -16,15 +16,15 @@ const Member = z.object({ contacts: SocialLinks, }); -// `SocialsSchema` types each contact: a bare URL string, or an object that +// `SocialLinkInput` types each contact: a bare URL string, or an object that // overrides the detected platform / username / icon (here we force Mastodon // because the indiepocalypse.social host isn't a built-in match). const mastodonContact = { url: "https://indiepocalypse.social/@essentialrandom", platform: "mastodon", -} satisfies SocialsSchema; +} satisfies SocialLinkInput; -const contacts: SocialsSchema[] = [ +const contacts: SocialLinkInput[] = [ "https://essentialrandomness.com", "https://essential-randomness.tumblr.com", "https://twitter.com/essentialrandom", diff --git a/zod-transform-socials/__examples__/04-zod-4-standalone/tsconfig.json b/zod-transform-socials/__examples__/04-zod-4-standalone/tsconfig.json index a1f8fb3..35aa0f7 100644 --- a/zod-transform-socials/__examples__/04-zod-4-standalone/tsconfig.json +++ b/zod-transform-socials/__examples__/04-zod-4-standalone/tsconfig.json @@ -7,5 +7,5 @@ "skipLibCheck": false, "noEmit": true }, - "include": ["parse.ts"] + "include": ["parse*.ts"] } diff --git a/zod-transform-socials/__examples__/README.md b/zod-transform-socials/__examples__/README.md index 609780c..d10048c 100644 --- a/zod-transform-socials/__examples__/README.md +++ b/zod-transform-socials/__examples__/README.md @@ -10,7 +10,10 @@ Pick the one that matches your needs: The two Astro examples read the same YAML team profile through `glob()` and render the parsed `contacts` array on the index page. The two standalone -examples use one `parse.ts` entry each to parse the same input inline, assert -the transformed output, print it, and type-check the package imports. +examples use `parse.ts` to parse the same input inline, assert the transformed +output, print it, and type-check the package imports. + +The standalone examples also include `parse-extension.ts` to show how to extend +`SocialLinkObjectSchema` when you need extra fields to survive the transform. These also double as the package's compatibility check. diff --git a/zod-transform-socials/scripts/check-compat.ts b/zod-transform-socials/scripts/check-compat.ts index 302aec5..dab564a 100644 --- a/zod-transform-socials/scripts/check-compat.ts +++ b/zod-transform-socials/scripts/check-compat.ts @@ -92,6 +92,7 @@ const cases: TestCase[] = [ commands: [ ["npm", ["run", "typecheck"]], ["npm", ["start"]], + ["npm", ["run", "extension"]], ], }, { @@ -99,6 +100,7 @@ const cases: TestCase[] = [ commands: [ ["npm", ["run", "typecheck"]], ["npm", ["start"]], + ["npm", ["run", "extension"]], ], }, ]; diff --git a/zod-transform-socials/src/index.ts b/zod-transform-socials/src/index.ts index 82fdcbd..c4615cc 100644 --- a/zod-transform-socials/src/index.ts +++ b/zod-transform-socials/src/index.ts @@ -20,16 +20,18 @@ if (typeof (z as { toJSONSchema?: unknown }).toJSONSchema === "function") { ); } -const createSocialsSchema = (urlSchema: z.ZodString): z.ZodType => - z.union([ - urlSchema, - z.object({ - icon: z.string().optional(), - platform: z.string().optional(), - url: urlSchema, - username: z.string().optional(), - }), - ]); +const createSocialLinkObjectSchema = (urlSchema: z.ZodString) => + z.object({ + icon: z.string().optional(), + platform: z.string().optional(), + url: urlSchema, + username: z.string().optional(), + }); + +const createSocialLinkInputSchema = ( + urlSchema: z.ZodString, + SocialLinkObjectSchema = createSocialLinkObjectSchema(urlSchema), +) => z.union([urlSchema, SocialLinkObjectSchema]); const createSocialLinksSchema = ( SocialsSchema: z.ZodType, @@ -41,21 +43,39 @@ const createSocialLinksSchema = ( .default([]) as z.ZodType; export const urlSchema: z.ZodString = z.string().url(); -export const SocialsSchema = createSocialsSchema(urlSchema); +export const SocialLinkObjectSchema = createSocialLinkObjectSchema(urlSchema); +export const SocialLinkInputSchema = createSocialLinkInputSchema( + urlSchema, + SocialLinkObjectSchema, +); +/** + * @deprecated Use `SocialLinkInputSchema` instead. + */ +export const SocialsSchema = SocialLinkInputSchema; export const SocialLinks = createSocialLinksSchema( - SocialsSchema, + SocialLinkInputSchema, transformSocial, ); export const createSocialsTransformer = ( config: CreateSocialsConfig = {}, ): SocialsTransformer => { - const SocialsSchema = createSocialsSchema(z.string().url()); + const urlSchema = z.string().url(); + const SocialLinkObjectSchema = createSocialLinkObjectSchema(urlSchema); + const SocialLinkInputSchema = createSocialLinkInputSchema( + urlSchema, + SocialLinkObjectSchema, + ); const { transformSocial, socialLinks } = createTransformSocial(config); - const SocialLinks = createSocialLinksSchema(SocialsSchema, transformSocial); + const SocialLinks = createSocialLinksSchema( + SocialLinkInputSchema, + transformSocial, + ); return { - SocialsSchema, + SocialLinkObjectSchema, + SocialLinkInputSchema, + SocialsSchema: SocialLinkInputSchema, transformSocial, SocialLinks, socialLinks, @@ -64,10 +84,17 @@ export const createSocialsTransformer = ( export { transformSocial }; -export type SocialsSchema = SocialsInput; +export type SocialLinkInput = SocialsInput; +export type SocialLinkObject = z.infer; +/** + * @deprecated Use `SocialLinkInput` instead. + */ +export type SocialsSchema = SocialLinkInput; export type SocialLinksData = SocialLinkData[]; export type SOCIAL_TYPES = INNER_SOCIAL_TYPES; export type SocialsTransformer = { + SocialLinkObjectSchema: typeof SocialLinkObjectSchema; + SocialLinkInputSchema: typeof SocialLinkInputSchema; SocialsSchema: z.ZodType; transformSocial: typeof transformSocial; SocialLinks: z.ZodType; diff --git a/zod-transform-socials/src/zod4.ts b/zod-transform-socials/src/zod4.ts index 7cfd3ba..af150f2 100644 --- a/zod-transform-socials/src/zod4.ts +++ b/zod-transform-socials/src/zod4.ts @@ -15,19 +15,21 @@ import { } from "./transform.ts"; import * as z from "zod/v4"; -const createSocialsSchema = (urlSchema: z.ZodType) => - z.union([ - urlSchema, - z.object({ - icon: z.string().optional(), - platform: z.string().optional(), - url: urlSchema, - username: z.string().optional(), - }), - ]); +const createSocialLinkObjectSchema = (urlSchema: z.ZodType) => + z.object({ + icon: z.string().optional(), + platform: z.string().optional(), + url: urlSchema, + username: z.string().optional(), + }); + +const createSocialLinkInputSchema = ( + urlSchema: z.ZodType, + SocialLinkObjectSchema = createSocialLinkObjectSchema(urlSchema), +) => z.union([urlSchema, SocialLinkObjectSchema]); const createSocialLinksSchema = ( - SocialsSchema: ReturnType, + SocialsSchema: ReturnType, transformSocial: (social: z.infer) => SocialLinkData, ) => z @@ -36,19 +38,37 @@ const createSocialLinksSchema = ( .default([]); export const urlSchema = z.url(); -export const SocialsSchema = createSocialsSchema(urlSchema); +export const SocialLinkObjectSchema = createSocialLinkObjectSchema(urlSchema); +export const SocialLinkInputSchema = createSocialLinkInputSchema( + urlSchema, + SocialLinkObjectSchema, +); +/** + * @deprecated Use `SocialLinkInputSchema` instead. + */ +export const SocialsSchema = SocialLinkInputSchema; export const SocialLinks = createSocialLinksSchema( - SocialsSchema, + SocialLinkInputSchema, transformSocial, ); export const createSocialsTransformer = (config: CreateSocialsConfig = {}) => { - const SocialsSchema = createSocialsSchema(z.url()); + const urlSchema = z.url(); + const SocialLinkObjectSchema = createSocialLinkObjectSchema(urlSchema); + const SocialLinkInputSchema = createSocialLinkInputSchema( + urlSchema, + SocialLinkObjectSchema, + ); const { transformSocial, socialLinks } = createTransformSocial(config); - const SocialLinks = createSocialLinksSchema(SocialsSchema, transformSocial); + const SocialLinks = createSocialLinksSchema( + SocialLinkInputSchema, + transformSocial, + ); return { - SocialsSchema, + SocialLinkObjectSchema, + SocialLinkInputSchema, + SocialsSchema: SocialLinkInputSchema, transformSocial, SocialLinks, socialLinks, @@ -57,7 +77,12 @@ export const createSocialsTransformer = (config: CreateSocialsConfig = {}) => { export { transformSocial }; -export type SocialsSchema = z.infer; +export type SocialLinkInput = z.infer; +export type SocialLinkObject = z.infer; +/** + * @deprecated Use `SocialLinkInput` instead. + */ +export type SocialsSchema = SocialLinkInput; export type SocialLinksData = z.infer; export type SOCIAL_TYPES = INNER_SOCIAL_TYPES; export type { CreateSocialsConfig, DomainShortcuts, SocialLinkData };