Skip to content

Commit 9ffed2b

Browse files
essential-randomnessMs Boba
authored andcommitted
Fix some zod-transform-socials parsing issues
1 parent 41f6902 commit 9ffed2b

7 files changed

Lines changed: 307 additions & 18 deletions

File tree

.changeset/social-bsky-handles.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@fujocoded/zod-transform-socials": patch
3+
---
4+
5+
Tighten and extend Bluesky profile URL matching. The `bsky.app`/`bsky.social`
6+
profile form now validates the handle as a real domain and additionally
7+
recognizes DID handles such as `did:plc:…` and `did:web:…`.
8+
9+
Also escapes the literal dots in every platform's match pattern so lookalike
10+
hosts no longer match by accident.

.changeset/social-npm-unscoped.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@fujocoded/zod-transform-socials": patch
3+
---
4+
5+
Recognize unscoped npm packages. The npm match previously required a scope, so
6+
URLs like `npmjs.com/package/social-links` fell through to `custom`; they now
7+
resolve to the `npm` platform. Underscores are also accepted in package names.

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

zod-transform-socials/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
},
4949
"scripts": {
5050
"build": "tsup src/index.ts src/zod4.ts --format cjs,esm --dts --clean",
51+
"test": "vitest",
5152
"test:compat": "npm run build && node --experimental-strip-types scripts/check-compat.ts",
5253
"typecheck": "tsc --noEmit",
5354
"validate": " npx publint"
@@ -59,6 +60,7 @@
5960
"@types/node": "^22.0.0",
6061
"tsup": "^8.1.0",
6162
"typescript": "^5.5.2",
63+
"vitest": "^3.0.5",
6264
"zod": "^3.25.76"
6365
},
6466
"peerDependencies": {

zod-transform-socials/src/social-links.ts

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export type DomainShortcuts = {
2020
const escapeForRegex = (input: string) =>
2121
input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2222

23+
const DOMAIN_LABEL = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?";
24+
const HANDLE = `${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})+`;
25+
// Bluesky profile URLs accept a DID in place of a handle, e.g.
26+
// `bsky.app/profile/did:plc:abc123` or `did:web:example.com`.
27+
const DID = "did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]";
28+
2329
export type CreateSocialLinksConfig = {
2430
/**
2531
* Register extra domains against a platform with a known URL shape
@@ -33,81 +39,81 @@ export const createSocialLinks = (config: CreateSocialLinksConfig = {}) => {
3339

3440
const tumblrMatches: ProfileMatch[] = [
3541
{
36-
match: "https?://www.tumblr.com/([a-z0-9-]+)/?.*",
42+
match: "https?://www\\.tumblr\\.com/([a-z0-9-]+)/?.*",
3743
// TODO: more may be necessary for things like extracting usernames
3844
group: 1,
3945
},
4046
// Must be last because it's a more general match, or www will be as a username
4147
{
42-
match: "https?://([a-z0-9-]+).tumblr.com/?.*",
48+
match: "https?://([a-z0-9-]+)\\.tumblr\\.com/?.*",
4349
// TODO: more may be necessary for things like extracting usernames
4450
group: 1,
4551
},
4652
];
4753
socialLinks.addProfile("tumblr", tumblrMatches);
4854
const kofiMatches: ProfileMatch[] = [
4955
{
50-
match: "https?://ko-fi.com/([a-z0-9-_]+)",
56+
match: "https?://ko-fi\\.com/([a-z0-9-_]+)",
5157
group: 1,
5258
},
5359
];
5460
socialLinks.addProfile("ko-fi", kofiMatches);
5561

5662
const inprntMatches: ProfileMatch[] = [
5763
{
58-
match: "https?://(?:www.)?inprnt.com/gallery/([a-z0-9-]+)/?",
64+
match: "https?://(?:www\\.)?inprnt\\.com/gallery/([a-z0-9-]+)/?",
5965
group: 1,
6066
},
6167
];
6268
socialLinks.addProfile("inprnt", inprntMatches);
6369

6470
const neocitiesMatches: ProfileMatch[] = [
6571
{
66-
match: "https?://([a-z0-9-]+).neocities.org",
72+
match: "https?://([a-z0-9-]+)\\.neocities\\.org",
6773
group: 1,
6874
},
6975
];
7076
socialLinks.addProfile("neocities", neocitiesMatches);
7177

7278
const blueSkyMatches: ProfileMatch[] = [
7379
{
74-
match: "https?://([a-z0-9-]+).bsky.(?:app|social)/?.*",
80+
match: "https?://([a-z0-9-]+)\\.bsky\\.(?:app|social)/?.*",
7581
group: 1,
7682
},
7783
{
78-
match: "https?://bsky.(?:app|social)/profile/([a-z0-9-.]+)/?.*",
84+
match: `https?://bsky\\.(?:app|social)/profile/(${HANDLE}|${DID})/?.*`,
7985
group: 1,
8086
},
8187
];
82-
socialLinks.addProfile("bsky", blueSkyMatches);
88+
replaceMatches(socialLinks, "bsky", blueSkyMatches);
8389

8490
const ao3Matches: ProfileMatch[] = [
8591
{
86-
match: "https?://archiveofourown.org/users/([a-z0-9-]+)",
92+
match: "https?://archiveofourown\\.org/users/([a-z0-9-]+)",
8793
group: 1,
8894
},
8995
];
9096
socialLinks.addProfile("archiveofourown", ao3Matches);
9197

9298
const dreamwidthMatches: ProfileMatch[] = [
9399
{
94-
match: "https?://([a-z0-9-]+).dreamwidth.org",
100+
match: "https?://([a-z0-9-]+)\\.dreamwidth\\.org",
95101
group: 1,
96102
},
97103
];
98104
socialLinks.addProfile("dreamwidth", dreamwidthMatches);
99105

100106
const furaffinityMatches: ProfileMatch[] = [
101107
{
102-
match: "https?://www.furaffinity.net/user/([a-z0-9-]+)",
108+
match: "https?://www\\.furaffinity\\.net/user/([a-z0-9-]+)",
103109
group: 1,
104110
},
105111
];
106112
socialLinks.addProfile("furaffinity", furaffinityMatches);
107113

108114
const carrdMatches: ProfileMatch[] = [
109115
{
110-
match: "https?://([a-z0-9-]+).carrd.co/?",
116+
match: "https?://([a-z0-9-]+)\\.carrd\\.co/?",
111117
group: 1,
112118
},
113119
];
@@ -116,16 +122,19 @@ export const createSocialLinks = (config: CreateSocialLinksConfig = {}) => {
116122
const kickstarterMatches: ProfileMatch[] = [
117123
{
118124
// https://www.kickstarter.com/projects/essential-randomness/the-fujoshi-guide-to-web-development
119-
match: "https?://www.kickstarter.com/projects/[a-z0-9-]+/([a-z0-9-]+)/?",
125+
match:
126+
"https?://www\\.kickstarter\\.com/projects/[a-z0-9-]+/([a-z0-9-]+)/?",
120127
group: 1,
121128
},
122129
];
123130
socialLinks.addProfile("kickstarter", kickstarterMatches);
124131

125132
const npmMatches: ProfileMatch[] = [
126133
{
127-
// https://www.npmjs.com/package/@bobaboard/ao3.js
128-
match: "https?://www.npmjs.com/package/([a-z0-9-@]+/[a-z0-9-\\.]+)/?",
134+
// Scoped and unscoped packages, e.g.
135+
// `npmjs.com/package/@bobaboard/ao3.js` or `npmjs.com/package/social-links`.
136+
match:
137+
"https?://www\\.npmjs\\.com/package/((?:@[a-z0-9-._]+/)?[a-z0-9-._]+)/?",
129138
group: 1,
130139
},
131140
];
@@ -137,20 +146,20 @@ export const createSocialLinks = (config: CreateSocialLinksConfig = {}) => {
137146
const gitHubMatches: ProfileMatch[] = [
138147
{
139148
// https://github.com/FujoWebDev/AO3.js
140-
match: "https?://github.com/([a-z0-9-]+/[a-z0-9-\\.]+)/?",
149+
match: "https?://github\\.com/([a-z0-9-]+/[a-z0-9-\\.]+)/?",
141150
group: 1,
142151
},
143152
{
144153
// https://github.com/orgs/FujoWebDev/
145-
match: "https?://github.com/orgs/([a-z0-9-]+)/?",
154+
match: "https?://github\\.com/orgs/([a-z0-9-]+)/?",
146155
group: 1,
147156
},
148157
];
149158
appendMatches(socialLinks, "github", gitHubMatches);
150159

151160
const xMatches: ProfileMatch[] = [
152161
{
153-
match: "(?:https?://)?(?:www.)?x.com/@?([a-z0-9-\\.]+)/?.*",
162+
match: "(?:https?://)?(?:www\\.)?x\\.com/@?([a-z0-9-\\.]+)/?.*",
154163
group: 1,
155164
},
156165
];
@@ -178,6 +187,15 @@ const appendMatches = (
178187
socialLinks.profiles.set(platform, [...existing, ...matches]);
179188
};
180189

190+
const replaceMatches = (
191+
socialLinks: SocialLinksLib,
192+
platform: string,
193+
matches: ProfileMatch[],
194+
) => {
195+
// @ts-expect-error profiles is private on SocialLinks
196+
socialLinks.profiles.set(platform, matches);
197+
};
198+
181199
// This top-level call is safe even under `"sideEffects": false` in package.json
182200
// because the logic in `createSocialLinks` only mutates the returned
183201
// object, and doesn't touch anything observable from outside the package.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, test } from "vitest";
2+
import { transformSocial, createTransformSocial } from "../src/transform.ts";
3+
import { socialLinks } from "../src/social-links.ts";
4+
5+
describe("Bluesky: bsky.app/social profile URLs", () => {
6+
test("extracts a domain handle and tags the platform + icon", () => {
7+
expect(
8+
transformSocial("https://bsky.app/profile/fujoweb.dev/post/3abc"),
9+
).toEqual({
10+
url: "https://bsky.app/profile/fujoweb.dev/post/3abc",
11+
platform: "bsky",
12+
username: "fujoweb.dev",
13+
icon: "simple-icons:bluesky",
14+
});
15+
});
16+
17+
test("matches bsky.social as well as bsky.app", () => {
18+
const result = transformSocial("https://bsky.social/profile/fujoweb.dev");
19+
expect(result.platform).toBe("bsky");
20+
expect(result.username).toBe("fujoweb.dev");
21+
});
22+
23+
test("normalizes uppercase URLs before matching", () => {
24+
const result = transformSocial("https://BSKY.APP/profile/FujoWeb.Dev");
25+
expect(result.platform).toBe("bsky");
26+
expect(result.username).toBe("fujoweb.dev");
27+
});
28+
29+
test("accepts multi-label handles", () => {
30+
expect(
31+
transformSocial("https://bsky.app/profile/boba-tan.staging.bsky.dev")
32+
.username,
33+
).toBe("boba-tan.staging.bsky.dev");
34+
});
35+
});
36+
37+
describe("Bluesky: subdomain profile URLs", () => {
38+
test("extracts the subdomain as the username", () => {
39+
const result = transformSocial("https://fujoweb.bsky.app");
40+
expect(result.platform).toBe("bsky");
41+
expect(result.username).toBe("fujoweb");
42+
});
43+
44+
test("works for the bsky.social subdomain form too", () => {
45+
const result = transformSocial("https://fujoweb.bsky.social/some/path");
46+
expect(result.platform).toBe("bsky");
47+
expect(result.username).toBe("fujoweb");
48+
});
49+
});
50+
51+
describe("Bluesky: invalid handles fall through to `custom`", () => {
52+
test("rejects handles with consecutive dots", () => {
53+
expect(
54+
transformSocial("https://bsky.app/profile/bad..handle").platform,
55+
).toBe("custom");
56+
});
57+
58+
test("rejects single-label handles (HANDLE requires a dot)", () => {
59+
expect(transformSocial("https://bsky.app/profile/fujoweb").platform).toBe(
60+
"custom",
61+
);
62+
});
63+
64+
test("rejects handles with a leading hyphen in a label", () => {
65+
expect(transformSocial("https://bsky.app/profile/-bad.dev").platform).toBe(
66+
"custom",
67+
);
68+
});
69+
70+
test("does not match an unescaped-dot lookalike host", () => {
71+
expect(
72+
transformSocial("https://bskyyapp/profile/fujoweb.dev").platform,
73+
).toBe("custom");
74+
});
75+
});
76+
77+
describe("Bluesky: DID profile URLs", () => {
78+
test("recognizes a did:plc handle", () => {
79+
expect(transformSocial("https://bsky.app/profile/did:plc:b0b474n")).toEqual(
80+
{
81+
url: "https://bsky.app/profile/did:plc:b0b474n",
82+
platform: "bsky",
83+
username: "did:plc:b0b474n",
84+
icon: "simple-icons:bluesky",
85+
},
86+
);
87+
});
88+
89+
test("recognizes a did:web handle", () => {
90+
const result = transformSocial(
91+
"https://bsky.app/profile/did:web:example.com",
92+
);
93+
expect(result.platform).toBe("bsky");
94+
expect(result.username).toBe("did:web:example.com");
95+
});
96+
97+
// The DID pattern mirrors `@atproto/syntax`'s `ensureValidDidRegex`, which
98+
// allows upper-case ASCII in the method-specific identifier (for `did:web`
99+
// hosts and percent-encoding). Since this library lowercases profiles by
100+
//default, however, going directly through the common entry point would
101+
// make it impossible to test this case.
102+
// This is here to future proof and make sure that the uppercase remains
103+
// valid, even if we stop lowercasing.
104+
test("matches a mixed-case did:web via the raw case-sensitive matcher", () => {
105+
const url = "https://bsky.app/profile/did:web:FujoWeb.dev";
106+
expect(socialLinks.detectProfile(url)).toBe("bsky");
107+
expect(socialLinks.getProfileId("bsky", url)).toBe("did:web:FujoWeb.dev");
108+
});
109+
110+
// The trailing-character anchor (`[a-zA-Z0-9._-]$`) forbids a DID ending in
111+
// `:` or `%`, so the matcher trims a stray trailing colon off the username.
112+
test("does not capture a trailing colon as part of the DID", () => {
113+
expect(
114+
transformSocial("https://bsky.app/profile/did:plc:abc123:").username,
115+
).toBe("did:plc:abc123");
116+
});
117+
});
118+
119+
describe("Bluesky via createTransformSocial", () => {
120+
test("a freshly created transformer matches Bluesky identically", () => {
121+
const { transformSocial: scoped } = createTransformSocial();
122+
expect(scoped("https://bsky.app/profile/fujoweb.dev")).toEqual({
123+
url: "https://bsky.app/profile/fujoweb.dev",
124+
platform: "bsky",
125+
username: "fujoweb.dev",
126+
icon: "simple-icons:bluesky",
127+
});
128+
});
129+
});

0 commit comments

Comments
 (0)