From 91549deaa788f707dfb9124058ff545c9d43fb27 Mon Sep 17 00:00:00 2001 From: Niek Date: Wed, 21 Jan 2026 12:36:24 +0100 Subject: [PATCH 1/7] Add secondary locations support to Ashby plugin - Add new Locations data source that extracts unique locations from jobs - Add location reference and secondary locations fields to jobs - Support slugified location IDs for collection references - Allow data source selection in setup UI (Jobs vs Locations) - Add CLAUDE.md for repository documentation Co-Authored-By: Claude Opus 4.5 --- .vscode/settings.json | 2 +- CLAUDE.md | 71 +++++++++++++ plugins/ashby/src/App.css | 3 +- plugins/ashby/src/App.tsx | 2 +- plugins/ashby/src/api-types.ts | 19 ++++ .../ashby/src/components/SelectDataSource.tsx | 31 +++++- plugins/ashby/src/data.ts | 34 ++++++- plugins/ashby/src/dataSources.ts | 99 +++++++++++++++++-- 8 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 CLAUDE.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 53ce88cdd..a3a71610e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,7 +16,7 @@ "editor.defaultFormatter": "biomejs.biome" }, "[json]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "vscode.json-language-features" }, "[jsonc]": { "editor.defaultFormatter": "biomejs.biome" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..8ddfb8edd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +A monorepo of Framer plugins - small React apps that interact with the Framer editor. Contains ~35 plugins in `/plugins`, shared configuration packages in `/packages`, and starter templates in `/starters`. + +## Commands + +```bash +# Development +yarn dev --filter=[plugin-name] # Start dev server for a plugin +yarn build --filter=[plugin-name] # Build a plugin + +# Code Quality (run before committing) +yarn check --filter=[plugin-name] # Run all checks (biome, eslint, typescript, vitest) +yarn fix-biome # Auto-fix formatting +yarn fix-eslint -- --fix # Auto-fix lint issues + +# Individual checks +yarn turbo run check-typescript --filter=[plugin-name] +yarn turbo run check-eslint --filter=[plugin-name] +yarn turbo run check-biome --filter=[plugin-name] +``` + +## Architecture + +**Package Manager:** Yarn 4 workspaces +**Build Orchestration:** Turbo +**Bundler:** Vite with React SWC + +### Plugin Structure +Each plugin is a standalone React app: +- `framer.json` - Plugin metadata (id, name, modes) +- `src/main.tsx` - Entry point with framer-plugin initialization +- `src/App.tsx` - Main component +- `src/dataSources.ts` - Data source definitions (for CMS plugins) +- `src/data.ts` - Sync logic and data transformation + +### Shared Packages +- `@framer/eslint-config` - ESLint flat config with TypeScript-ESLint +- `@framer/vite-config` - Vite config with React, Tailwind, HTTPS + +### Key Dependencies +- `framer-plugin` - Official Framer plugin SDK +- `valibot` - Schema validation (used instead of zod) +- React 18 with TypeScript + +## Code Style + +**Formatting (Biome):** +- 4 spaces indentation, 120 char line width +- No semicolons, ES5 trailing commas +- Arrow functions without parens for single params + +**TypeScript:** +- Strict mode, ES2023 target +- Use `type` imports for type-only imports + +## Plugin Patterns + +**Data Persistence:** Use `framer.getPluginData()` / `setPluginData()` for storing configuration + +**CMS Plugins:** Follow the pattern in greenhouse/ashby plugins: +- `dataSources.ts` defines data sources with fields and fetch functions +- `data.ts` handles sync logic with `getItems()` and `syncCollection()` +- Collection references use `collectionReference` / `multiCollectionReference` field types +- `isMissingReferenceField()` checks if referenced collection exists + +**Field Mapping:** Multiple fields can reference the same source key with different `getValue` transformers diff --git a/plugins/ashby/src/App.css b/plugins/ashby/src/App.css index 0b00ecb77..a48f2258a 100644 --- a/plugins/ashby/src/App.css +++ b/plugins/ashby/src/App.css @@ -77,7 +77,8 @@ form { pointer-events: none; } -.setup .field { +.setup .field, +.setup label { display: flex; flex-direction: row; align-items: center; diff --git a/plugins/ashby/src/App.tsx b/plugins/ashby/src/App.tsx index d276e3ea7..ec37508c3 100644 --- a/plugins/ashby/src/App.tsx +++ b/plugins/ashby/src/App.tsx @@ -25,7 +25,7 @@ export function App({ collection, previousDataSourceId, previousSlugFieldId, pre void framer.showUI({ width: hasDataSourceSelected ? 400 : 320, - height: hasDataSourceSelected ? 427 : 285, + height: hasDataSourceSelected ? 427 : 325, minHeight: hasDataSourceSelected ? 427 : undefined, minWidth: hasDataSourceSelected ? 400 : undefined, resizable: hasDataSourceSelected, diff --git a/plugins/ashby/src/api-types.ts b/plugins/ashby/src/api-types.ts index 8df0d87c4..78d52e6ec 100644 --- a/plugins/ashby/src/api-types.ts +++ b/plugins/ashby/src/api-types.ts @@ -1,5 +1,11 @@ import * as v from "valibot" +/** Base interface for all data items that can be synced */ +export interface DataItem { + id: string + [key: string]: unknown +} + const AddressSchema = v.object({ addressLocality: v.optional(v.string()), addressRegion: v.optional(v.string()), @@ -65,6 +71,19 @@ export type Job = v.InferOutput export type Address = v.InferOutput export type CompensationComponent = v.InferOutput export type CompensationTiers = v.InferOutput +export type SecondaryLocation = v.InferOutput +export type JobAddress = v.InferOutput + +export const LocationSchema = v.object({ + id: v.string(), + name: v.string(), + locality: v.string(), + region: v.string(), + country: v.string(), + fullAddress: v.string(), +}) + +export type Location = v.InferOutput export function hasOwnProperty( object: T, diff --git a/plugins/ashby/src/components/SelectDataSource.tsx b/plugins/ashby/src/components/SelectDataSource.tsx index e5062feea..55816f8ea 100644 --- a/plugins/ashby/src/components/SelectDataSource.tsx +++ b/plugins/ashby/src/components/SelectDataSource.tsx @@ -18,7 +18,9 @@ export function SelectDataSource({ onSelectDataSource, }: SelectDataSourceProps) { const [jobBoardName, setJobBoardName] = useState(previousJobBoardName ?? "") - const [selectedDataSourceId] = useState(previousDataSourceId ?? dataSources[0]?.id ?? "") + const [selectedDataSourceId, setSelectedDataSourceId] = useState( + previousDataSourceId ?? dataSources[0]?.id ?? "" + ) const [isLoading, setIsLoading] = useState(false) const isAllowedToManage = useIsAllowedTo("ManagedCollection.setFields", ...syncMethods) @@ -72,19 +74,40 @@ export function SelectDataSource({ Ashby Hero
-
+
+ +
diff --git a/plugins/ashby/src/data.ts b/plugins/ashby/src/data.ts index 525424c0f..047e80e22 100644 --- a/plugins/ashby/src/data.ts +++ b/plugins/ashby/src/data.ts @@ -8,7 +8,7 @@ import { } from "framer-plugin" import * as v from "valibot" import { hasOwnProperty } from "./api-types" -import { type AshbyDataSource, type AshbyField, dataSources } from "./dataSources" +import { type AshbyDataSource, type AshbyField, dataSources, slugify } from "./dataSources" import { assertNever, isCollectionReference } from "./utils" export const dataSourceIdPluginKey = "dataSourceId" @@ -160,14 +160,18 @@ async function getItems( const fieldData: FieldDataInput = {} for (const [fieldName, rawValue] of Object.entries(item) as [string, unknown][]) { - const isFieldIgnored = !fieldsToSync.find(field => field.id === fieldName) const fields = fieldLookup.get(fieldName) ?? [] - if (fields.length === 0 || isFieldIgnored) { + if (fields.length === 0) { continue } for (const field of fields) { + const isFieldIgnored = !fieldsToSync.find(f => f.id === field.id) + if (isFieldIgnored) { + continue + } + const value = field.getValue ? field.getValue(rawValue) : rawValue switch (field.type) { @@ -199,7 +203,16 @@ async function getItems( break case "multiCollectionReference": { const ids: string[] = [] - if (v.is(ArrayWithIdsSchema, value)) { + + // Special handling for Ashby secondary locations + if (field.dataSourceId === "Locations" && Array.isArray(value)) { + for (const item of value) { + if (typeof item === "object" && item !== null && "location" in item) { + const locationName = (item as { location: string }).location + if (locationName) ids.push(slugify(locationName)) + } + } + } else if (v.is(ArrayWithIdsSchema, value)) { ids.push(...value.map(item => String(item.id))) } @@ -210,6 +223,19 @@ async function getItems( break } case "collectionReference": { + // For Ashby location references, the ID is the slugified location name + if (field.dataSourceId === "Locations") { + const locationName = typeof value === "string" ? value : null + if (!locationName) continue + + fieldData[field.id] = { + value: slugify(locationName), + type: "collectionReference", + } + break + } + + // Default behavior for other collection references if (typeof value !== "object" || value == null || !("id" in value)) { continue } diff --git a/plugins/ashby/src/dataSources.ts b/plugins/ashby/src/dataSources.ts index 0dea085c0..89e416405 100644 --- a/plugins/ashby/src/dataSources.ts +++ b/plugins/ashby/src/dataSources.ts @@ -1,8 +1,8 @@ import type { ManagedCollectionFieldInput } from "framer-plugin" import * as v from "valibot" -import { type Job, JobAddressSchema, JobSchema } from "./api-types" +import { type DataItem, type Job, type JobAddress, JobAddressSchema, JobSchema, type Location } from "./api-types" -export interface AshbyDataSource { +export interface AshbyDataSource { id: string name: string /** @@ -12,7 +12,7 @@ export interface AshbyDataSource { * The rest of the fields are the fields of the data source. */ fields: readonly AshbyField[] - fetch: (jobBoardName: string) => Promise + fetch: (jobBoardName: string) => Promise } async function fetchAshbyData(url: string): Promise { @@ -47,6 +47,76 @@ export type AshbyField = ManagedCollectionFieldInput & const JobApiResponseSchema = v.object({ jobs: v.array(JobSchema) }) +const locationsDataSourceName = "Locations" + +export function slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +function extractLocation(locationName: string, address: JobAddress | null): Location { + const postalAddress = address?.postalAddress + const parts = [ + postalAddress?.addressLocality?.trim(), + postalAddress?.addressRegion?.trim(), + postalAddress?.addressCountry?.trim(), + ].filter(Boolean) + + return { + id: slugify(locationName), + name: locationName, + locality: postalAddress?.addressLocality?.trim() ?? "", + region: postalAddress?.addressRegion?.trim() ?? "", + country: postalAddress?.addressCountry?.trim() ?? "", + fullAddress: [...new Set(parts)].join(", "), + } +} + +const locationsDataSource: AshbyDataSource = { + id: locationsDataSourceName, + name: locationsDataSourceName, + fields: [ + { id: "name", name: "Name", type: "string", canBeUsedAsSlug: true }, + { id: "locality", name: "Locality", type: "string" }, + { id: "region", name: "Region", type: "string" }, + { id: "country", name: "Country", type: "string" }, + { id: "fullAddress", name: "Full Address", type: "string" }, + ], + fetch: async (jobBoardName: string): Promise => { + const url = `https://api.ashbyhq.com/posting-api/job-board/${jobBoardName}?includeCompensation=true` + const data = v.safeParse(JobApiResponseSchema, await fetchAshbyData(url)) + + if (!data.success) { + console.log("Error parsing Ashby data:", data.issues) + throw new Error("Error parsing Ashby data") + } + + const locationMap = new Map() + + for (const job of data.output.jobs) { + // Primary location + const primary = extractLocation(job.location, job.address) + if (primary.id && !locationMap.has(primary.id)) { + locationMap.set(primary.id, primary) + } + + // Secondary locations + for (const secondary of job.secondaryLocations) { + const loc = extractLocation(secondary.location, secondary.address) + if (loc.id && !locationMap.has(loc.id)) { + locationMap.set(loc.id, loc) + } + } + } + + return Array.from(locationMap.values()) + }, +} + const jobsDataSource = createDataSource( { name: "Jobs", @@ -157,21 +227,36 @@ const jobsDataSource = createDataSource( return address.addressLocality?.trim() ?? "" }, }, + { + id: "locationRef", + key: "location", + name: "Location Reference", + type: "collectionReference", + dataSourceId: locationsDataSourceName, + collectionId: "", + }, + { + id: "secondaryLocations", + name: "Secondary Locations", + type: "multiCollectionReference", + dataSourceId: locationsDataSourceName, + collectionId: "", + }, ] ) -export const dataSources = [jobsDataSource] satisfies AshbyDataSource[] +export const dataSources: AshbyDataSource[] = [jobsDataSource, locationsDataSource] -function createDataSource( +function createDataSource( { name, fetch, }: { name: string - fetch: (jobBoardName: string) => Promise + fetch: (jobBoardName: string) => Promise }, [idField, slugField, ...fields]: [AshbyField, AshbyField, ...AshbyField[]] -): AshbyDataSource { +): AshbyDataSource { return { id: name, name, From 4164fff3b6035319ca7c323b0e1e3edb9ae1d0a5 Mon Sep 17 00:00:00 2001 From: Niek Date: Wed, 21 Jan 2026 13:30:22 +0100 Subject: [PATCH 2/7] Add getItemId to data sources for collection references - Add optional getItemId callback to AshbyDataSource interface - Locations data source defines getItemId using shared getLocationId function - Collection reference fields look up target data source's getItemId - Simplifies data.ts by removing special-case handling Co-Authored-By: Claude Opus 4.5 --- .vscode/settings.json | 2 +- plugins/ashby/src/data.ts | 61 +++++++++++--------------------- plugins/ashby/src/dataSources.ts | 29 ++++++++++++--- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index a3a71610e..53ce88cdd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,7 +16,7 @@ "editor.defaultFormatter": "biomejs.biome" }, "[json]": { - "editor.defaultFormatter": "vscode.json-language-features" + "editor.defaultFormatter": "biomejs.biome" }, "[jsonc]": { "editor.defaultFormatter": "biomejs.biome" diff --git a/plugins/ashby/src/data.ts b/plugins/ashby/src/data.ts index 047e80e22..9fde93cd2 100644 --- a/plugins/ashby/src/data.ts +++ b/plugins/ashby/src/data.ts @@ -8,7 +8,7 @@ import { } from "framer-plugin" import * as v from "valibot" import { hasOwnProperty } from "./api-types" -import { type AshbyDataSource, type AshbyField, dataSources, slugify } from "./dataSources" +import { type AshbyDataSource, type AshbyField, dataSources } from "./dataSources" import { assertNever, isCollectionReference } from "./utils" export const dataSourceIdPluginKey = "dataSourceId" @@ -96,7 +96,6 @@ export function mergeFieldsWithExistingFields( } const StringifiableSchema = v.union([v.string(), v.number(), v.boolean()]) -const ArrayWithIdsSchema = v.array(v.object({ id: v.number() })) async function getItems( dataSource: AshbyDataSource, @@ -159,7 +158,7 @@ async function getItems( } const fieldData: FieldDataInput = {} - for (const [fieldName, rawValue] of Object.entries(item) as [string, unknown][]) { + for (const [fieldName, rawValue] of Object.entries(item)) { const fields = fieldLookup.get(fieldName) ?? [] if (fields.length === 0) { @@ -172,7 +171,7 @@ async function getItems( continue } - const value = field.getValue ? field.getValue(rawValue) : rawValue + const value = !isCollectionReference(field) && field.getValue ? field.getValue(rawValue) : rawValue switch (field.type) { case "string": @@ -202,48 +201,29 @@ async function getItems( } break case "multiCollectionReference": { - const ids: string[] = [] - - // Special handling for Ashby secondary locations - if (field.dataSourceId === "Locations" && Array.isArray(value)) { - for (const item of value) { - if (typeof item === "object" && item !== null && "location" in item) { - const locationName = (item as { location: string }).location - if (locationName) ids.push(slugify(locationName)) - } - } - } else if (v.is(ArrayWithIdsSchema, value)) { - ids.push(...value.map(item => String(item.id))) + const targetDataSource = dataSources.find(ds => ds.id === field.dataSourceId) + if (!targetDataSource?.getItemId || !Array.isArray(rawValue)) { + fieldData[field.id] = { value: [], type: "multiCollectionReference" } + break } - fieldData[field.id] = { - value: ids, - type: "multiCollectionReference", + const ids: string[] = [] + for (const entry of rawValue) { + const id = targetDataSource.getItemId(entry) + if (id) ids.push(id) } + + fieldData[field.id] = { value: ids, type: "multiCollectionReference" } break } case "collectionReference": { - // For Ashby location references, the ID is the slugified location name - if (field.dataSourceId === "Locations") { - const locationName = typeof value === "string" ? value : null - if (!locationName) continue - - fieldData[field.id] = { - value: slugify(locationName), - type: "collectionReference", - } - break - } + const targetDataSource = dataSources.find(ds => ds.id === field.dataSourceId) + if (!targetDataSource?.getItemId) continue - // Default behavior for other collection references - if (typeof value !== "object" || value == null || !("id" in value)) { - continue - } + const id = targetDataSource.getItemId(rawValue) + if (!id) continue - fieldData[field.id] = { - value: String(value.id), - type: "collectionReference", - } + fieldData[field.id] = { value: id, type: "collectionReference" } break } case "image": @@ -318,7 +298,6 @@ export async function syncExistingCollection( framer.closePlugin("You are not allowed to sync this collection.", { variant: "error", }) - return { didSync: false } } try { @@ -327,7 +306,7 @@ export async function syncExistingCollection( const slugField = dataSource.fields.find(field => field.id === previousSlugFieldId) if (!slugField) { - framer.notify(`No field matches the slug field id “${previousSlugFieldId}”. Sync will not be performed.`, { + framer.notify(`No field matches the slug field id "${previousSlugFieldId}". Sync will not be performed.`, { variant: "error", }) return { didSync: false } @@ -337,7 +316,7 @@ export async function syncExistingCollection( return { didSync: true } } catch (error) { console.error(error) - framer.notify(`Failed to sync collection “${previousDataSourceId}”. Check browser console for more details.`, { + framer.notify(`Failed to sync collection "${previousDataSourceId}". Check browser console for more details.`, { variant: "error", }) return { didSync: false } diff --git a/plugins/ashby/src/dataSources.ts b/plugins/ashby/src/dataSources.ts index 89e416405..9c819dec1 100644 --- a/plugins/ashby/src/dataSources.ts +++ b/plugins/ashby/src/dataSources.ts @@ -13,6 +13,8 @@ export interface AshbyDataSource { */ fields: readonly AshbyField[] fetch: (jobBoardName: string) => Promise + /** Extracts the ID from a data entry. Required when other data sources reference this one. */ + getItemId?: (entry: unknown) => string | null } async function fetchAshbyData(url: string): Promise { @@ -39,7 +41,6 @@ export type AshbyField = ManagedCollectionFieldInput & | { key?: string type: "collectionReference" | "multiCollectionReference" - getValue?: never dataSourceId: string supportedCollections?: { id: string; name: string }[] } @@ -49,7 +50,7 @@ const JobApiResponseSchema = v.object({ jobs: v.array(JobSchema) }) const locationsDataSourceName = "Locations" -export function slugify(text: string): string { +function slugify(text: string): string { return text .toLowerCase() .trim() @@ -58,6 +59,20 @@ export function slugify(text: string): string { .replace(/^-+|-+$/g, "") } +/** Extracts the location ID from a location entry. Used both for creating Location items and for references. */ +function getLocationId(entry: unknown): string | null { + if (typeof entry === "string") { + return slugify(entry) + } + if (typeof entry === "object" && entry !== null && "location" in entry) { + const location = (entry as { location: unknown }).location + if (typeof location === "string") { + return slugify(location) + } + } + return null +} + function extractLocation(locationName: string, address: JobAddress | null): Location { const postalAddress = address?.postalAddress const parts = [ @@ -67,7 +82,7 @@ function extractLocation(locationName: string, address: JobAddress | null): Loca ].filter(Boolean) return { - id: slugify(locationName), + id: getLocationId(locationName) ?? "", name: locationName, locality: postalAddress?.addressLocality?.trim() ?? "", region: postalAddress?.addressRegion?.trim() ?? "", @@ -79,6 +94,7 @@ function extractLocation(locationName: string, address: JobAddress | null): Loca const locationsDataSource: AshbyDataSource = { id: locationsDataSourceName, name: locationsDataSourceName, + getItemId: getLocationId, fields: [ { id: "name", name: "Name", type: "string", canBeUsedAsSlug: true }, { id: "locality", name: "Locality", type: "string" }, @@ -251,15 +267,18 @@ function createDataSource( { name, fetch, + getItemId, }: { name: string fetch: (jobBoardName: string) => Promise + getItemId?: (entry: unknown) => string | null }, [idField, slugField, ...fields]: [AshbyField, AshbyField, ...AshbyField[]] ): AshbyDataSource { return { id: name, name, + getItemId, fields: [idField, slugField, ...fields], fetch, } @@ -273,8 +292,10 @@ function createDataSource( */ export function removeAshbyKeys(fields: AshbyField[]): ManagedCollectionFieldInput[] { return fields.map(originalField => { + if (originalField.type === "collectionReference" || originalField.type === "multiCollectionReference") { + return originalField + } const { getValue, ...field } = originalField - return field }) } From 43087ed01ef7ceac0c271d9c63a70fe9d2c8e6b6 Mon Sep 17 00:00:00 2001 From: Niek Date: Wed, 21 Jan 2026 13:32:55 +0100 Subject: [PATCH 3/7] Fix slugify to preserve non-ASCII characters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use Unicode property escapes (\p{L}\p{N}) instead of \w - Handles location names like "東京", "São Paulo", "München" - Add vitest and tests for slugify function Co-Authored-By: Claude Opus 4.5 --- plugins/ashby/package.json | 6 ++-- plugins/ashby/src/dataSources.test.ts | 43 +++++++++++++++++++++++++++ plugins/ashby/src/dataSources.ts | 4 +-- yarn.lock | 1 + 4 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 plugins/ashby/src/dataSources.test.ts diff --git a/plugins/ashby/package.json b/plugins/ashby/package.json index af75e9772..5e69275c3 100644 --- a/plugins/ashby/package.json +++ b/plugins/ashby/package.json @@ -10,7 +10,8 @@ "check-eslint": "run g:check-eslint", "preview": "run g:preview", "pack": "npx framer-plugin-tools@latest pack", - "check-typescript": "run g:check-typescript" + "check-typescript": "run g:check-typescript", + "check-vitest": "run g:check-vitest" }, "dependencies": { "framer-plugin": "^3.6.0", @@ -20,6 +21,7 @@ }, "devDependencies": { "@types/react": "^18.3.24", - "@types/react-dom": "^18.3.7" + "@types/react-dom": "^18.3.7", + "vitest": "^3.2.4" } } diff --git a/plugins/ashby/src/dataSources.test.ts b/plugins/ashby/src/dataSources.test.ts new file mode 100644 index 000000000..58f55feda --- /dev/null +++ b/plugins/ashby/src/dataSources.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest" +import { slugify } from "./dataSources" + +describe("slugify", () => { + it("converts to lowercase", () => { + expect(slugify("New York")).toBe("new-york") + }) + + it("replaces spaces with hyphens", () => { + expect(slugify("san francisco")).toBe("san-francisco") + }) + + it("removes special characters", () => { + expect(slugify("New York, NY")).toBe("new-york-ny") + }) + + it("trims whitespace", () => { + expect(slugify(" Berlin ")).toBe("berlin") + }) + + it("handles multiple spaces and hyphens", () => { + expect(slugify("Los Angeles - CA")).toBe("los-angeles-ca") + }) + + it("preserves non-ASCII letters", () => { + expect(slugify("São Paulo")).toBe("são-paulo") + expect(slugify("München")).toBe("münchen") + expect(slugify("東京")).toBe("東京") + expect(slugify("Zürich")).toBe("zürich") + }) + + it("handles mixed ASCII and non-ASCII", () => { + expect(slugify("Köln, Germany")).toBe("köln-germany") + }) + + it("returns empty string for empty input", () => { + expect(slugify("")).toBe("") + }) + + it("handles numbers", () => { + expect(slugify("Area 51")).toBe("area-51") + }) +}) diff --git a/plugins/ashby/src/dataSources.ts b/plugins/ashby/src/dataSources.ts index 9c819dec1..e5e76dc8e 100644 --- a/plugins/ashby/src/dataSources.ts +++ b/plugins/ashby/src/dataSources.ts @@ -50,11 +50,11 @@ const JobApiResponseSchema = v.object({ jobs: v.array(JobSchema) }) const locationsDataSourceName = "Locations" -function slugify(text: string): string { +export function slugify(text: string): string { return text .toLowerCase() .trim() - .replace(/[^\w\s-]/g, "") + .replace(/[^\p{L}\p{N}\s-]/gu, "") .replace(/[\s_-]+/g, "-") .replace(/^-+|-+$/g, "") } diff --git a/yarn.lock b/yarn.lock index f261d270e..6a6bef9b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3249,6 +3249,7 @@ __metadata: react: "npm:^18.3.1" react-dom: "npm:^18.3.1" valibot: "npm:^1.2.0" + vitest: "npm:^3.2.4" languageName: unknown linkType: soft From b1d6acf862294bd32971ac23aa46d48d1b2b407e Mon Sep 17 00:00:00 2001 From: Niek Date: Wed, 21 Jan 2026 13:35:55 +0100 Subject: [PATCH 4/7] Use SecondaryLocationSchema in getLocationId Replace manual 'in' operator check with valibot schema validation. Co-Authored-By: Claude Opus 4.5 --- plugins/ashby/src/api-types.ts | 2 +- plugins/ashby/src/dataSources.ts | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/plugins/ashby/src/api-types.ts b/plugins/ashby/src/api-types.ts index 78d52e6ec..6f90e1084 100644 --- a/plugins/ashby/src/api-types.ts +++ b/plugins/ashby/src/api-types.ts @@ -34,7 +34,7 @@ export const JobAddressSchema = v.object({ postalAddress: AddressSchema, }) -const SecondaryLocationSchema = v.object({ +export const SecondaryLocationSchema = v.object({ location: v.string(), address: JobAddressSchema, }) diff --git a/plugins/ashby/src/dataSources.ts b/plugins/ashby/src/dataSources.ts index e5e76dc8e..b6beb41a4 100644 --- a/plugins/ashby/src/dataSources.ts +++ b/plugins/ashby/src/dataSources.ts @@ -1,6 +1,14 @@ import type { ManagedCollectionFieldInput } from "framer-plugin" import * as v from "valibot" -import { type DataItem, type Job, type JobAddress, JobAddressSchema, JobSchema, type Location } from "./api-types" +import { + type DataItem, + type Job, + type JobAddress, + JobAddressSchema, + JobSchema, + type Location, + SecondaryLocationSchema, +} from "./api-types" export interface AshbyDataSource { id: string @@ -64,11 +72,8 @@ function getLocationId(entry: unknown): string | null { if (typeof entry === "string") { return slugify(entry) } - if (typeof entry === "object" && entry !== null && "location" in entry) { - const location = (entry as { location: unknown }).location - if (typeof location === "string") { - return slugify(location) - } + if (v.is(SecondaryLocationSchema, entry)) { + return slugify(entry.location) } return null } From 6cbc09ad2aafdc0f8b6c4b179a11de81505599f5 Mon Sep 17 00:00:00 2001 From: Niek Date: Wed, 21 Jan 2026 13:44:46 +0100 Subject: [PATCH 5/7] Add tests for extractLocation helper Co-Authored-By: Claude Opus 4.5 --- plugins/ashby/src/dataSources.test.ts | 90 ++++++++++++++++++++++++++- plugins/ashby/src/dataSources.ts | 2 +- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/plugins/ashby/src/dataSources.test.ts b/plugins/ashby/src/dataSources.test.ts index 58f55feda..c72ed7e25 100644 --- a/plugins/ashby/src/dataSources.test.ts +++ b/plugins/ashby/src/dataSources.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { slugify } from "./dataSources" +import { extractLocation, slugify } from "./dataSources" describe("slugify", () => { it("converts to lowercase", () => { @@ -41,3 +41,91 @@ describe("slugify", () => { expect(slugify("Area 51")).toBe("area-51") }) }) + +describe("extractLocation", () => { + it("extracts location with full address", () => { + const result = extractLocation("San Francisco", { + postalAddress: { + addressLocality: "San Francisco", + addressRegion: "CA", + addressCountry: "USA", + }, + }) + + expect(result).toEqual({ + id: "san-francisco", + name: "San Francisco", + locality: "San Francisco", + region: "CA", + country: "USA", + fullAddress: "San Francisco, CA, USA", + }) + }) + + it("handles null address", () => { + const result = extractLocation("Remote", null) + + expect(result).toEqual({ + id: "remote", + name: "Remote", + locality: "", + region: "", + country: "", + fullAddress: "", + }) + }) + + it("handles partial address", () => { + const result = extractLocation("Berlin", { + postalAddress: { + addressCountry: "Germany", + }, + }) + + expect(result).toEqual({ + id: "berlin", + name: "Berlin", + locality: "", + region: "", + country: "Germany", + fullAddress: "Germany", + }) + }) + + it("deduplicates address parts", () => { + const result = extractLocation("California", { + postalAddress: { + addressLocality: "CA", + addressRegion: "CA", + addressCountry: "USA", + }, + }) + + expect(result.fullAddress).toBe("CA, USA") + }) + + it("trims whitespace from address parts", () => { + const result = extractLocation("New York", { + postalAddress: { + addressLocality: " New York ", + addressRegion: " NY ", + addressCountry: " USA ", + }, + }) + + expect(result.locality).toBe("New York") + expect(result.region).toBe("NY") + expect(result.country).toBe("USA") + }) + + it("handles non-ASCII location names", () => { + const result = extractLocation("東京", { + postalAddress: { + addressCountry: "Japan", + }, + }) + + expect(result.id).toBe("東京") + expect(result.name).toBe("東京") + }) +}) diff --git a/plugins/ashby/src/dataSources.ts b/plugins/ashby/src/dataSources.ts index b6beb41a4..78e68b690 100644 --- a/plugins/ashby/src/dataSources.ts +++ b/plugins/ashby/src/dataSources.ts @@ -78,7 +78,7 @@ function getLocationId(entry: unknown): string | null { return null } -function extractLocation(locationName: string, address: JobAddress | null): Location { +export function extractLocation(locationName: string, address: JobAddress | null): Location { const postalAddress = address?.postalAddress const parts = [ postalAddress?.addressLocality?.trim(), From 7562c5a4e9f9c08e4a3c3a06720f76c7e996a3c6 Mon Sep 17 00:00:00 2001 From: Niek Date: Wed, 21 Jan 2026 13:51:07 +0100 Subject: [PATCH 6/7] fixup! Add getItemId to data sources for collection references --- plugins/ashby/src/dataSources.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/ashby/src/dataSources.ts b/plugins/ashby/src/dataSources.ts index 78e68b690..1c8bc8625 100644 --- a/plugins/ashby/src/dataSources.ts +++ b/plugins/ashby/src/dataSources.ts @@ -9,6 +9,7 @@ import { type Location, SecondaryLocationSchema, } from "./api-types" +import { isCollectionReference } from "./utils" export interface AshbyDataSource { id: string @@ -297,9 +298,8 @@ function createDataSource( */ export function removeAshbyKeys(fields: AshbyField[]): ManagedCollectionFieldInput[] { return fields.map(originalField => { - if (originalField.type === "collectionReference" || originalField.type === "multiCollectionReference") { - return originalField - } + if (isCollectionReference(originalField)) return originalField + const { getValue, ...field } = originalField return field }) From a84fcaac77d283850395b50b36e2ffac97d490d9 Mon Sep 17 00:00:00 2001 From: Niek Date: Wed, 21 Jan 2026 14:41:48 +0100 Subject: [PATCH 7/7] address PR comments --- CLAUDE.md | 71 --------------------------- plugins/ashby/src/dataSources.test.ts | 43 +--------------- plugins/ashby/src/dataSources.ts | 10 +--- plugins/ashby/src/slugify.test.ts | 39 +++++++++++++++ plugins/ashby/src/slugify.ts | 32 ++++++++++++ 5 files changed, 73 insertions(+), 122 deletions(-) delete mode 100644 CLAUDE.md create mode 100644 plugins/ashby/src/slugify.test.ts create mode 100644 plugins/ashby/src/slugify.ts diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8ddfb8edd..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,71 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Repository Overview - -A monorepo of Framer plugins - small React apps that interact with the Framer editor. Contains ~35 plugins in `/plugins`, shared configuration packages in `/packages`, and starter templates in `/starters`. - -## Commands - -```bash -# Development -yarn dev --filter=[plugin-name] # Start dev server for a plugin -yarn build --filter=[plugin-name] # Build a plugin - -# Code Quality (run before committing) -yarn check --filter=[plugin-name] # Run all checks (biome, eslint, typescript, vitest) -yarn fix-biome # Auto-fix formatting -yarn fix-eslint -- --fix # Auto-fix lint issues - -# Individual checks -yarn turbo run check-typescript --filter=[plugin-name] -yarn turbo run check-eslint --filter=[plugin-name] -yarn turbo run check-biome --filter=[plugin-name] -``` - -## Architecture - -**Package Manager:** Yarn 4 workspaces -**Build Orchestration:** Turbo -**Bundler:** Vite with React SWC - -### Plugin Structure -Each plugin is a standalone React app: -- `framer.json` - Plugin metadata (id, name, modes) -- `src/main.tsx` - Entry point with framer-plugin initialization -- `src/App.tsx` - Main component -- `src/dataSources.ts` - Data source definitions (for CMS plugins) -- `src/data.ts` - Sync logic and data transformation - -### Shared Packages -- `@framer/eslint-config` - ESLint flat config with TypeScript-ESLint -- `@framer/vite-config` - Vite config with React, Tailwind, HTTPS - -### Key Dependencies -- `framer-plugin` - Official Framer plugin SDK -- `valibot` - Schema validation (used instead of zod) -- React 18 with TypeScript - -## Code Style - -**Formatting (Biome):** -- 4 spaces indentation, 120 char line width -- No semicolons, ES5 trailing commas -- Arrow functions without parens for single params - -**TypeScript:** -- Strict mode, ES2023 target -- Use `type` imports for type-only imports - -## Plugin Patterns - -**Data Persistence:** Use `framer.getPluginData()` / `setPluginData()` for storing configuration - -**CMS Plugins:** Follow the pattern in greenhouse/ashby plugins: -- `dataSources.ts` defines data sources with fields and fetch functions -- `data.ts` handles sync logic with `getItems()` and `syncCollection()` -- Collection references use `collectionReference` / `multiCollectionReference` field types -- `isMissingReferenceField()` checks if referenced collection exists - -**Field Mapping:** Multiple fields can reference the same source key with different `getValue` transformers diff --git a/plugins/ashby/src/dataSources.test.ts b/plugins/ashby/src/dataSources.test.ts index c72ed7e25..355577ccb 100644 --- a/plugins/ashby/src/dataSources.test.ts +++ b/plugins/ashby/src/dataSources.test.ts @@ -1,46 +1,5 @@ import { describe, expect, it } from "vitest" -import { extractLocation, slugify } from "./dataSources" - -describe("slugify", () => { - it("converts to lowercase", () => { - expect(slugify("New York")).toBe("new-york") - }) - - it("replaces spaces with hyphens", () => { - expect(slugify("san francisco")).toBe("san-francisco") - }) - - it("removes special characters", () => { - expect(slugify("New York, NY")).toBe("new-york-ny") - }) - - it("trims whitespace", () => { - expect(slugify(" Berlin ")).toBe("berlin") - }) - - it("handles multiple spaces and hyphens", () => { - expect(slugify("Los Angeles - CA")).toBe("los-angeles-ca") - }) - - it("preserves non-ASCII letters", () => { - expect(slugify("São Paulo")).toBe("são-paulo") - expect(slugify("München")).toBe("münchen") - expect(slugify("東京")).toBe("東京") - expect(slugify("Zürich")).toBe("zürich") - }) - - it("handles mixed ASCII and non-ASCII", () => { - expect(slugify("Köln, Germany")).toBe("köln-germany") - }) - - it("returns empty string for empty input", () => { - expect(slugify("")).toBe("") - }) - - it("handles numbers", () => { - expect(slugify("Area 51")).toBe("area-51") - }) -}) +import { extractLocation } from "./dataSources" describe("extractLocation", () => { it("extracts location with full address", () => { diff --git a/plugins/ashby/src/dataSources.ts b/plugins/ashby/src/dataSources.ts index 1c8bc8625..dcc9d3c60 100644 --- a/plugins/ashby/src/dataSources.ts +++ b/plugins/ashby/src/dataSources.ts @@ -9,6 +9,7 @@ import { type Location, SecondaryLocationSchema, } from "./api-types" +import { slugify } from "./slugify" import { isCollectionReference } from "./utils" export interface AshbyDataSource { @@ -59,15 +60,6 @@ const JobApiResponseSchema = v.object({ jobs: v.array(JobSchema) }) const locationsDataSourceName = "Locations" -export function slugify(text: string): string { - return text - .toLowerCase() - .trim() - .replace(/[^\p{L}\p{N}\s-]/gu, "") - .replace(/[\s_-]+/g, "-") - .replace(/^-+|-+$/g, "") -} - /** Extracts the location ID from a location entry. Used both for creating Location items and for references. */ function getLocationId(entry: unknown): string | null { if (typeof entry === "string") { diff --git a/plugins/ashby/src/slugify.test.ts b/plugins/ashby/src/slugify.test.ts new file mode 100644 index 000000000..ba0ed9558 --- /dev/null +++ b/plugins/ashby/src/slugify.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest" +import { slugify } from "./slugify" + +describe("slugify", () => { + it("converts to lowercase", () => { + expect(slugify("New York")).toBe("new-york") + }) + + it("replaces spaces with hyphens", () => { + expect(slugify("san francisco")).toBe("san-francisco") + }) + + it("removes special characters", () => { + expect(slugify("New York, NY")).toBe("new-york-ny") + }) + + it("trims whitespace", () => { + expect(slugify(" Berlin ")).toBe("berlin") + }) + + it("preserves non-ASCII letters", () => { + expect(slugify("São Paulo")).toBe("são-paulo") + expect(slugify("München")).toBe("münchen") + expect(slugify("東京")).toBe("東京") + expect(slugify("Zürich")).toBe("zürich") + }) + + it("handles mixed ASCII and non-ASCII", () => { + expect(slugify("Köln, Germany")).toBe("köln-germany") + }) + + it("returns empty string for empty input", () => { + expect(slugify("")).toBe("") + }) + + it("handles numbers", () => { + expect(slugify("Area 51")).toBe("area-51") + }) +}) diff --git a/plugins/ashby/src/slugify.ts b/plugins/ashby/src/slugify.ts new file mode 100644 index 000000000..47d3639a7 --- /dev/null +++ b/plugins/ashby/src/slugify.ts @@ -0,0 +1,32 @@ +// Note: We don't use the "slugify" package here because we want a very specific +// behavior for our CMS and web pages. Make sure to pick the right slugify +// function for your use case! +// Characters that are problematic in URLs and should be replaced with dashes: +// - Whitespace (\s) +// - Underscore (_) - Google specifically recommends using `-` instead https://developers.google.com/search/docs/crawling-indexing/url-structure#:~:text=example.com/%F0%9F%A6%99%E2%9C%A8-,Use%20hyphens%20to%20separate%20words,-We%20recommend%20separating +// - URL reserved delimiters: ? # [ ] @ +// - Characters with special meaning: ! $ & ' * + , ; : = " < > % { } | \ ^ ` / +// - : denotes paths on MacOS / reserved for drive letters on Windows +// All other characters (including unicode letters, numbers, symbols) are kept. +const unsafeSlugCharactersRegExp = /[\s_?#[\]@!$&'*+,;:="<>%{}|\\^`/]+/gu +/** + * Trim leading and trailing dashes from a string. + * We can't use a regexp, because matching e.g. /-+$/gu leads to polynomial backtracking with e.g. '-'.repeat(54773) + '\x00-' + */ +export function trimDashes(str: string): string { + let start = 0 + let end = str.length + while (start < end && str[start] === "-") start++ + while (end > start && str[end - 1] === "-") end-- + return str.slice(start, end) +} +/** + * Takes a freeform string and converts it to a URL-safe slug. + * Replaces problematic URL characters and combinations with dashes while preserving: + * - Unicode letters and numbers + * - Unicode symbols (±, §, etc.) + * - URL-safe punctuation: - . ~ ( ) + */ +export function slugify(value: string): string { + return trimDashes(value.trim().toLowerCase().replace(unsafeSlugCharactersRegExp, "-")) +}