Add profile auti-fill from content or URL

This commit is contained in:
MartinBraquet
2026-03-29 18:26:45 +02:00
parent ad51aea069
commit 6e4c6f29b5
27 changed files with 1825 additions and 251 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.compassconnections.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 71
versionName "1.13.0"
versionCode 72
versionName "1.14.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -1,6 +1,6 @@
{
"name": "@compass/api",
"version": "1.28.0",
"version": "1.29.0",
"private": true,
"description": "Backend API endpoints",
"main": "src/serve.ts",
@@ -28,6 +28,7 @@
"dependencies": {
"@google-cloud/monitoring": "4.0.0",
"@google-cloud/secret-manager": "4.2.1",
"@mozilla/readability": "0.6.0",
"@sentry/node": "10.41.0",
"@tiptap/core": "2.10.4",
"cors": "2.8.5",
@@ -35,8 +36,10 @@
"express": "5.0.0",
"firebase-admin": "13.5.0",
"gcp-metadata": "6.1.0",
"jsdom": "29.0.1",
"jsonwebtoken": "9.0.0",
"lodash": "4.17.23",
"marked": "17.0.5",
"openapi-types": "12.1.3",
"pg-promise": "12.6.1",
"posthog-node": "4.11.0",
@@ -50,6 +53,7 @@
},
"devDependencies": {
"@types/cors": "2.8.17",
"@types/jsdom": "28.0.1",
"@types/jsonwebtoken": "^9.0.0",
"@types/lodash": "^4.17.0",
"@types/swagger-ui-express": "4.1.8",

View File

@@ -10,7 +10,7 @@ import {getLastSeenChannelTime, setChannelLastSeenTime} from 'api/get-channel-se
import {getHiddenProfiles} from 'api/get-hidden-profiles'
import {getLastMessages} from 'api/get-last-messages'
import {getMessagesCountEndpoint} from 'api/get-messages-count'
import {getOptions} from 'api/get-options'
import {getOptionsEndpoint} from 'api/get-options'
import {getPinnedCompatibilityQuestions} from 'api/get-pinned-compatibility-questions'
import {getChannelMessagesEndpoint} from 'api/get-private-messages'
import {getUser} from 'api/get-user'
@@ -78,11 +78,12 @@ import {type APIHandler, typedEndpoint} from './helpers/endpoint'
import {hideComment} from './hide-comment'
import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel'
import {likeProfile} from './like-profile'
import {llmExtractProfileEndpoint} from './llm-extract-profile'
import {markAllNotifsRead} from './mark-all-notifications-read'
import {removePinnedPhoto} from './remove-pinned-photo'
import {report} from './report'
import {rsvpEvent} from './rsvp-event'
import {searchLocation} from './search-location'
import {searchLocationEndpoint} from './search-location'
import {searchNearCity} from './search-near-city'
import {searchUsers} from './search-users'
import {setCompatibilityAnswer} from './set-compatibility-answer'
@@ -602,7 +603,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'get-likes-and-ships': getLikesAndShips,
'get-messages-count': getMessagesCountEndpoint,
'get-notifications': getNotifications,
'get-options': getOptions,
'get-options': getOptionsEndpoint,
'get-profile-answers': getProfileAnswers,
'get-profiles': getProfiles,
'get-supabase-token': getSupabaseToken,
@@ -622,7 +623,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'remove-pinned-photo': removePinnedPhoto,
'save-subscription': saveSubscription,
'save-subscription-mobile': saveSubscriptionMobile,
'search-location': searchLocation,
'search-location': searchLocationEndpoint,
'search-near-city': searchNearCity,
'search-users': searchUsers,
'set-channel-seen-time': setChannelLastSeenTime,
@@ -644,6 +645,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'user/by-id/:id/unblock': unblockUser,
vote: vote,
'validate-username': validateUsernameEndpoint,
'llm-extract-profile': llmExtractProfileEndpoint,
// 'user/:username': getUser,
// 'user/:username/lite': getDisplayUser,
// 'user/by-id/:id/lite': getDisplayUser,

View File

@@ -1,24 +1,44 @@
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {OPTION_TABLES} from 'common/profiles/constants'
import {OptionTableKey} from 'common/profiles/constants'
import {validateTable} from 'common/profiles/options'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {log} from 'shared/utils'
export const getOptions: APIHandler<'get-options'> = async ({table}, _auth) => {
if (!OPTION_TABLES.includes(table)) throw APIErrors.badRequest('Invalid table')
export async function getOptions(table: OptionTableKey, locale?: string): Promise<string[]> {
validateTable(table)
const pg = createSupabaseDirectClient()
const result = await tryCatch(
pg.manyOrNone<{name: string}>(`SELECT interests.name
FROM interests`),
)
let query: string
const params: any[] = []
if (locale) {
// Get translated options for the specified locale
const translationTable = `${table}_translations`
query = `
SELECT COALESCE(t.name, o.name) as name
FROM ${table} o
LEFT JOIN ${translationTable} t ON o.id = t.option_id AND t.locale = $1
ORDER BY o.id
`
params.push(locale)
} else {
// Get default options (fallback to English)
query = `SELECT name FROM ${table} ORDER BY id`
}
const result = await tryCatch(pg.manyOrNone<{name: string}>(query, params))
if (result.error) {
log('Error getting profile options', result.error)
throw APIErrors.internalServerError('Error getting profile options')
}
const names = result.data.map((row) => row.name)
return result.data.map((row) => row.name)
}
export const getOptionsEndpoint: APIHandler<'get-options'> = async ({table, locale}, _auth) => {
const names = await getOptions(table, locale)
return {names}
}

View File

@@ -0,0 +1,374 @@
import {JSONContent} from '@tiptap/core'
import {getOptions} from 'api/get-options'
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {searchLocation} from 'api/search-location'
import {
DIET_CHOICES,
EDUCATION_CHOICES,
GENDERS,
LANGUAGE_CHOICES,
MBTI_CHOICES,
POLITICAL_CHOICES,
RACE_CHOICES,
RELATIONSHIP_CHOICES,
RELATIONSHIP_STATUS_CHOICES,
RELIGION_CHOICES,
ROMANTIC_CHOICES,
} from 'common/choices'
import {debug} from 'common/logger'
import {ProfileWithoutUser} from 'common/profiles/profile'
import {parseJsonContentToText} from 'common/util/parse'
import {createHash} from 'crypto'
import {promises as fs} from 'fs'
import {tmpdir} from 'os'
import {join} from 'path'
import {log} from 'shared/monitoring/log'
import {convertToJSONContent, extractGoogleDocId} from 'shared/parse'
const MAX_CONTEXT_LENGTH = 7 * 10 * 30 * 50
const USE_CACHE = true
const CACHE_DIR = join(tmpdir(), 'compass-llm-cache')
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
function getCacheKey(content: string): string {
if (!USE_CACHE) return ''
const hash = createHash('sha256')
hash.update(content)
return hash.digest('hex')
}
async function getCachedResult(cacheKey: string): Promise<Partial<ProfileWithoutUser> | null> {
if (!USE_CACHE) return null
try {
const cacheFile = join(CACHE_DIR, `${cacheKey}.json`)
const stats = await fs.stat(cacheFile)
if (Date.now() - stats.mtime.getTime() > CACHE_TTL_MS) {
await fs.unlink(cacheFile)
return null
}
const cachedData = await fs.readFile(cacheFile, 'utf-8')
return JSON.parse(cachedData)
} catch {
return null
}
}
async function setCachedResult(
cacheKey: string,
result: Partial<ProfileWithoutUser>,
): Promise<void> {
if (!USE_CACHE) return
try {
await fs.mkdir(CACHE_DIR, {recursive: true})
const cacheFile = join(CACHE_DIR, `${cacheKey}.json`)
await fs.writeFile(cacheFile, JSON.stringify(result), 'utf-8')
debug('Cached LLM result', {cacheKey: cacheKey.substring(0, 8)})
} catch (error) {
log('Failed to write cache', {cacheKey, error})
// Don't throw - caching failure shouldn't break the main flow
}
}
async function callGemini(text: string) {
const apiKey = process.env.GEMINI_API_KEY
if (!apiKey) {
log('GEMINI_API_KEY not configured')
throw APIErrors.internalServerError('Profile extraction service is not configured')
}
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
contents: [
{
parts: [
{
text: text.slice(0, MAX_CONTEXT_LENGTH),
},
],
},
],
generationConfig: {
temperature: 0,
topP: 0.95,
topK: 40,
responseMimeType: 'application/json',
},
}),
},
)
if (!response.ok) {
const errorText = await response.text()
log('Gemini API error', {status: response.status, error: errorText})
throw APIErrors.internalServerError('Failed to extract profile data')
}
const data = await response.json()
const outputText = data.candidates?.[0]?.content?.parts?.[0]?.text
return outputText
}
async function _callClaude(text: string) {
// We don't use it as there is no free tier
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) {
log('ANTHROPIC_API_KEY not configured')
throw APIErrors.internalServerError('Profile extraction service is not configured')
}
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-5',
max_tokens: 1024,
temperature: 0,
messages: [
{
role: 'user',
content: text.slice(0, MAX_CONTEXT_LENGTH),
},
],
}),
})
if (!response.ok) {
const errorText = await response.text()
log('Anthropic API error', {status: response.status, error: errorText})
throw APIErrors.internalServerError('Failed to extract profile data')
}
const data = await response.json()
const outputText = data.content?.[0]?.text
return outputText
}
async function callLLM(content: string, locale?: string): Promise<Partial<ProfileWithoutUser>> {
const [INTERESTS, CAUSE_AREAS, WORK_AREAS] = await Promise.all([
getOptions('interests', locale),
getOptions('causes', locale),
getOptions('work', locale),
])
const PROFILE_FIELDS: Partial<Record<keyof ProfileWithoutUser, any>> = {
// Basic info
age: 'Number. Age in years.',
gender: `One of: ${Object.values(GENDERS).join(', ')}. Infer if you have enough evidence`,
height_in_inches: 'Number. Height converted to inches.',
city: 'String. Current city of residence (English spelling).',
country: 'String. Current country of residence (English spelling).',
city_latitude: 'Number. Latitude of current city.',
city_longitude: 'Number. Longitude of current city.',
// Background
raised_in_city: 'String. City where they grew up (English spelling).',
raised_in_country: 'String. Country where they grew up (English spelling).',
raised_in_lat: 'Number. Latitude of city where they grew up.',
raised_in_lon: 'Number. Longitude of city where they grew up.',
university: 'String. University or college attended.',
education_level: `One of: ${Object.values(EDUCATION_CHOICES).join(', ')}`,
company: 'String. Current employer or company name.',
occupation_title: 'String. Current job title.',
// Lifestyle
is_smoker: 'Boolean. Whether they smoke.',
drinks_per_month: 'Number. Estimated alcoholic drinks per month.',
has_kids: 'Number. 0 if no kids, otherwise number of kids.',
wants_kids_strength:
'Number 04. How strongly they want kids (0 = definitely not, 4 = definitely yes).',
diet: `Array. Any of: ${Object.values(DIET_CHOICES).join(', ')}`,
ethnicity: `Array. Any of: ${Object.values(RACE_CHOICES).join(', ')}`,
// Identity — big5 only if person explicitly states a score, never infer from personality description
mbti: `One of: ${Object.values(MBTI_CHOICES).join(', ')}`,
big5_openness: 'Number 0100. Only if explicitly self-reported, never infer.',
big5_conscientiousness: 'Number 0100. Only if explicitly self-reported, never infer.',
big5_extraversion: 'Number 0100. Only if explicitly self-reported, never infer.',
big5_agreeableness: 'Number 0100. Only if explicitly self-reported, never infer.',
big5_neuroticism: 'Number 0100. Only if explicitly self-reported, never infer.',
// Beliefs
religion: `Array. Any of: ${Object.values(RELIGION_CHOICES).join(', ')}`,
religious_beliefs:
'String. Free-form elaboration on religious views, only if explicitly stated.',
political_beliefs: `Array. Any of: ${Object.values(POLITICAL_CHOICES).join(', ')}`,
political_details:
'String. Free-form elaboration on political views, only if explicitly stated.',
// Preferences
pref_age_min: 'Number. Minimum preferred age of match.',
pref_age_max: 'Number. Maximum preferred age of match.',
pref_gender: `Array. Any of: ${Object.values(GENDERS).join(', ')}`,
pref_relation_styles: `Array. Any of: ${Object.values(RELATIONSHIP_CHOICES).join(', ')}`,
pref_romantic_styles: `Array. Any of: ${Object.values(ROMANTIC_CHOICES).join(', ')}`,
relationship_status: `Array. Any of: ${Object.values(RELATIONSHIP_STATUS_CHOICES).join(', ')}`,
// Languages
languages: `Array. Any of: ${Object.values(LANGUAGE_CHOICES).join(', ')}. If none, infer from text.`,
// Free-form
headline:
'String. Summary of who they are, in their own voice (first person). Maximum 200 characters total. Cannot be null.',
keywords: 'Array of 36 short tags summarising the person.',
links: 'Object. Any personal URLs found (site, github, linkedin, twitter, etc.).',
// Taxonomies — match existing labels first, only add new if truly no close match exists
interests: `Array. Prefer existing labels, only add new if no close match. Any of: ${INTERESTS.join(', ')}`,
causes: `Array. Prefer existing labels, only add new if no close match. Any of: ${CAUSE_AREAS.join(', ')}`,
work: `Array. Use only existing labels, do not add new if no close match. Any of: ${WORK_AREAS.join(', ')}`,
}
const EXTRACTION_PROMPT = `You are a profile information extraction expert analyzing text from a personal webpage, LinkedIn, bio, or similar source.
TASK: Extract structured profile data and return it as a single valid JSON object.
RULES:
- Only extract information that is EXPLICITLY stated — do not infer, guess, or hallucinate
- Return null for missing scalar fields, [] for missing array fields
- For taxonomy fields (interests, causes, work): match existing labels first; only add a new label if truly no existing one is close
- For big5 scores: only populate if the person explicitly states a test result — never infer from personality description
- Return valid JSON only — no markdown, no explanation, no extra text
SCHEMA (each value describes the expected type and accepted values):
${JSON.stringify(PROFILE_FIELDS, null, 2)}
TEXT TO ANALYZE:
`
const text = EXTRACTION_PROMPT + content
if (text.length > MAX_CONTEXT_LENGTH) {
log('Content exceeds maximum length', {length: text.length})
throw APIErrors.badRequest('Content exceeds maximum length')
}
debug({text})
const cacheKey = getCacheKey(text)
const cached = await getCachedResult(cacheKey)
if (cached) {
debug('Using cached LLM result', {cacheKey: cacheKey.substring(0, 8)})
return cached
}
const outputText = await callGemini(text)
// const outputText = JSON.stringify({})
if (!outputText) {
throw APIErrors.internalServerError('Failed to parse LLM response')
}
let parsed: Partial<ProfileWithoutUser>
try {
parsed = JSON.parse(outputText)
} catch (parseError) {
log('Failed to parse LLM response as JSON', {outputText, parseError})
throw APIErrors.internalServerError('Failed to parse extracted data')
}
if (parsed.city) {
if (!parsed.city_latitude || !parsed.city_longitude) {
const result = await searchLocation({term: parsed.city, limit: 1})
const locations = result.data?.data
parsed.city_latitude = locations?.[0]?.latitude
parsed.city_longitude = locations?.[0]?.longitude
parsed.country ??= locations?.[0]?.country
}
}
if (parsed.raised_in_city) {
if (!parsed.raised_in_lat || !parsed.raised_in_lon) {
const result = await searchLocation({term: parsed.raised_in_city, limit: 1})
const locations = result.data?.data
parsed.raised_in_lat = locations?.[0]?.latitude
parsed.raised_in_lon = locations?.[0]?.longitude
parsed.raised_in_country ??= locations?.[0]?.country
}
}
await setCachedResult(cacheKey, parsed)
return parsed
}
export async function fetchOnlineProfile(url: string | undefined): Promise<JSONContent> {
if (!url) throw APIErrors.badRequest('Content or URL is required')
try {
// 1. Google Docs shortcut
const googleDocId = extractGoogleDocId(url)
if (googleDocId) {
url = `https://docs.google.com/document/d/${googleDocId}/export?format=html`
}
// 2. Fetch with proper headers
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; bot/1.0)',
Accept: 'text/html,text/plain,*/*',
},
})
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`)
}
const contentType = response.headers.get('content-type') ?? ''
const content = await response.text()
log('Fetched content from URL', {url, contentType, contentLength: content.length})
debug({content})
// 3. Route by content type
return convertToJSONContent(content, contentType, url)
} catch (error) {
log('Error fetching URL', {url, error})
throw APIErrors.badRequest('Failed to fetch content from URL')
}
}
export const llmExtractProfileEndpoint: APIHandler<'llm-extract-profile'> = async (
parsedBody,
auth,
) => {
const {url, locale} = parsedBody
let content = parsedBody.content
log('Extracting profile from content', {
contentLength: content?.length,
url,
locale,
userId: auth.uid,
})
if (content && url) {
throw APIErrors.badRequest('Content and URL cannot be provided together')
}
let bio
if (!content) {
bio = await fetchOnlineProfile(url)
debug(JSON.stringify(bio, null, 2))
content = parseJsonContentToText(bio)
}
const extracted = await callLLM(content, locale)
if (bio) {
extracted.bio = bio
}
log('Profile extracted successfully', {extracted})
return extracted
}

View File

@@ -1,8 +1,13 @@
import {ValidatedAPIParams} from 'common/api/schema'
import {geodbFetch} from 'common/geodb'
import {APIHandler} from './helpers/endpoint'
export const searchLocation: APIHandler<'search-location'> = async (body) => {
export const searchLocationEndpoint: APIHandler<'search-location'> = async (body) => {
return await searchLocation(body)
}
export async function searchLocation(body: ValidatedAPIParams<'search-location'>) {
const {term, limit} = body
const endpoint = `/cities?namePrefix=${term}&limit=${limit ?? 10}&offset=0&sort=-population`
// const endpoint = `/countries?namePrefix=${term}&limit=${limit ?? 10}&offset=0`

View File

@@ -1,18 +1,15 @@
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {OPTION_TABLES} from 'common/profiles/constants'
import {OptionTableKey} from 'common/profiles/constants'
import {validateTable} from 'common/profiles/options'
import {tryCatch} from 'common/util/try-catch'
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
import {log} from 'shared/utils'
function validateTable(table: 'interests' | 'causes' | 'work') {
if (!OPTION_TABLES.includes(table)) throw APIErrors.badRequest('Invalid table')
}
export async function setProfileOptions(
tx: SupabaseDirectClient,
profileId: number,
userId: string,
table: 'interests' | 'causes' | 'work',
table: OptionTableKey,
values: string[] | undefined | null,
) {
validateTable(table)

View File

@@ -1,7 +1,7 @@
jest.mock('shared/supabase/init')
jest.mock('common/util/try-catch')
import {getOptions} from 'api/get-options'
import {getOptionsEndpoint} from 'api/get-options'
import {AuthedUser} from 'api/helpers/endpoint'
import {sqlMatch} from 'common/test-utils'
import {tryCatch} from 'common/util/try-catch'
@@ -31,7 +31,7 @@ describe('getOptions', () => {
;(mockPg.manyOrNone as jest.Mock).mockResolvedValue(null)
;(tryCatch as jest.Mock).mockResolvedValue({data: mockData, error: null})
const result: any = await getOptions({table: mockTable}, mockAuth, mockReq)
const result: any = await getOptionsEndpoint({table: mockTable}, mockAuth, mockReq)
expect(result.names).toContain(mockData[0].name)
expect(mockPg.manyOrNone).toBeCalledTimes(1)
@@ -48,7 +48,9 @@ describe('getOptions', () => {
jest.spyOn(Array.prototype, 'includes').mockReturnValue(false)
expect(getOptions({table: mockTable}, mockAuth, mockReq)).rejects.toThrow('Invalid table')
expect(getOptionsEndpoint({table: mockTable}, mockAuth, mockReq)).rejects.toThrow(
'Invalid table',
)
})
it('should throw if unable to get profile options', async () => {
@@ -60,7 +62,7 @@ describe('getOptions', () => {
;(mockPg.manyOrNone as jest.Mock).mockResolvedValue(null)
;(tryCatch as jest.Mock).mockResolvedValue({data: null, error: Error})
expect(getOptions({table: mockTable}, mockAuth, mockReq)).rejects.toThrow(
expect(getOptionsEndpoint({table: mockTable}, mockAuth, mockReq)).rejects.toThrow(
'Error getting profile options',
)
})

View File

@@ -1,7 +1,7 @@
jest.mock('common/geodb')
import {AuthedUser} from 'api/helpers/endpoint'
import {searchLocation} from 'api/search-location'
import {searchLocationEndpoint} from 'api/search-location'
import * as geodbModules from 'common/geodb'
describe('searchLocation', () => {
@@ -24,7 +24,7 @@ describe('searchLocation', () => {
;(geodbModules.geodbFetch as jest.Mock).mockResolvedValue(mockReturn)
const result = await searchLocation(mockBody, mockAuth, mockReq)
const result = await searchLocationEndpoint(mockBody, mockAuth, mockReq)
expect(result).toBe(mockReturn)
expect(geodbModules.geodbFetch).toBeCalledTimes(1)

458
backend/shared/src/parse.ts Normal file
View File

@@ -0,0 +1,458 @@
import {Readability} from '@mozilla/readability'
import {JSONContent} from '@tiptap/core'
import {debug} from 'common/logger'
import {JSDOM} from 'jsdom'
import {marked} from 'marked'
export function htmlToJSONContent(html: string, url: string): JSONContent {
const originalDom = new JSDOM(html, {url})
const classStyles = extractClassStyles(originalDom.window.document)
const isGoogleDoc = !!extractGoogleDocId(url)
if (!isGoogleDoc) {
const reader = new Readability(originalDom.window.document)
const article = reader.parse()
if (article?.content) {
const cleanDom = new JSDOM(article.content)
return parseHtmlBodyToJSONContent(cleanDom.window.document, classStyles)
}
}
return parseHtmlBodyToJSONContent(originalDom.window.document, classStyles)
}
function plainTextToJSONContent(text: string): JSONContent {
const paragraphs = text
.split(/\n{2,}/) // split on blank lines
.map((p) => p.trim())
.filter(Boolean)
.map((p) => ({
type: 'paragraph' as const,
content: [{type: 'text' as const, text: p}],
}))
return {type: 'doc', content: paragraphs}
}
function extractClassStyles(document: Document): Map<string, Record<string, string>> {
const classStyles = new Map<string, Record<string, string>>()
for (const styleEl of document.querySelectorAll('style')) {
const css = styleEl.textContent ?? ''
// Match .className { prop: value; prop: value }
const ruleRegex = /\.([a-zA-Z0-9_-]+)\s*\{([^}]+)}/g
let match
while ((match = ruleRegex.exec(css)) !== null) {
const className = match[1]
const declarations = match[2]
const styles = parseStyleString(declarations)
classStyles.set(className, styles)
}
}
return classStyles
}
export function parseHtmlBodyToJSONContent(
document: Document,
classStyles?: Map<string, Record<string, string>>,
): JSONContent {
const body = document.body
classStyles ??= extractClassStyles(document)
const content = parseBlockElements(body.children, classStyles)
return {type: 'doc', content}
}
function parseBlockElements(
children: HTMLCollection | Element[],
classStyles: Map<string, Record<string, string>>,
): JSONContent[] {
const content: JSONContent[] = []
for (const el of Array.from(children)) {
const tag = el.tagName.toLowerCase()
const node = parseBlockElement(el, tag, classStyles)
if (!node) continue
if ((node as any).type === '__fragment') {
// Recursively flatten — fragments can contain fragments
content.push(...flattenFragment(node as any))
} else {
content.push(node)
}
}
return content
}
function flattenFragment(node: any): JSONContent[] {
return node.content.flatMap((child: any) =>
child.type === '__fragment' ? flattenFragment(child) : [child],
)
}
function parseBlockElement(
el: Element,
tag: string,
classStyles: Map<string, Record<string, string>>,
): JSONContent | null {
// console.debug('parseBlockElement', {tag, el})
// Headings h1h6
if (/^h[1-6]$/.test(tag)) {
return {
type: 'heading',
attrs: {level: parseInt(tag[1])},
content: parseInlineElements(el, classStyles),
}
}
// Paragraph
if (tag === 'p') {
const inline = parseInlineElements(el, classStyles)
return inline.length > 0 ? {type: 'paragraph', content: inline} : null
}
// Lists
if (tag === 'ol') {
return {
type: 'orderedList',
attrs: {start: 1}, // ← required by TipTap's OrderedList extension
content: parseListItems(el, classStyles),
}
}
if (tag === 'ul') {
return {
type: 'bulletList',
attrs: {},
content: parseListItems(el, classStyles),
}
}
// Blockquote
if (tag === 'blockquote') {
return {
type: 'blockquote',
content: parseBlockElements(el.children, classStyles),
}
}
// Code block <pre><code>...</code></pre>
if (tag === 'pre') {
const codeEl = el.querySelector('code')
const language = codeEl?.className.match(/language-(\w+)/)?.[1] ?? null
return {
type: 'codeBlock',
attrs: {language},
content: [{type: 'text', text: (codeEl ?? el).textContent ?? ''}],
}
}
// Inline code outside of pre (treat as paragraph)
if (tag === 'code') {
return {
type: 'paragraph',
content: [{type: 'text', text: el.textContent ?? '', marks: [{type: 'code'}]}],
}
}
// Horizontal rule
if (tag === 'hr') {
return {type: 'horizontalRule'}
}
// Image
if (tag === 'img') {
const src = el.getAttribute('src')
if (!src || !src.startsWith('http')) return null
return {
type: 'image',
attrs: {
src,
alt: el.getAttribute('alt') ?? null,
title: el.getAttribute('title') ?? null,
},
}
}
// Figure (image + optional caption)
if (tag === 'figure') {
const img = el.querySelector('img')
const caption = el.querySelector('figcaption')?.textContent ?? null
const src = img?.getAttribute('src')
if (!src || !src.startsWith('http')) return null
return {
type: 'image',
attrs: {
src: img?.getAttribute('src'),
alt: img?.getAttribute('alt') ?? caption,
title: caption,
},
}
}
// Table
if (tag === 'table') {
return parseTable(el, classStyles)
}
// Container elements — recurse into children
if (['div', 'section', 'article', 'main', 'header', 'footer', 'aside'].includes(tag)) {
const inner = parseBlockElements(el.children, classStyles)
if (inner.length === 0) return null
if (inner.length === 1) return inner[0]
// Always use fragment — never paragraph — for multiple block children
return {type: '__fragment', content: inner} as any
}
return null
}
function parseStyleString(style: string): Record<string, string> {
return Object.fromEntries(
style
.split(';')
.map((s) => s.trim())
.filter(Boolean)
.map((declaration) => {
const [prop, ...rest] = declaration.split(':')
const value = rest.join(':').trim()
// Convert kebab-case to camelCase (e.g. font-weight → fontWeight)
const camelProp = prop.trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase())
return [camelProp, value]
}),
)
}
function parseListItems(
listEl: Element,
classStyles: Map<string, Record<string, string>>,
): JSONContent[] {
return Array.from(listEl.querySelectorAll(':scope > li')).map((li) => {
const nestedList = li.querySelector('ul, ol')
const blockContent: JSONContent[] = [
{type: 'paragraph', content: parseInlineElements(li, classStyles, true)},
]
if (nestedList) {
const nestedTag = nestedList.tagName.toLowerCase()
blockContent.push({
type: nestedTag === 'ul' ? 'bulletList' : 'orderedList',
content: parseListItems(nestedList, classStyles),
})
}
return {type: 'listItem', content: blockContent}
})
}
function parseTable(
tableEl: Element,
classStyles: Map<string, Record<string, string>>,
): JSONContent {
const rows = Array.from(tableEl.querySelectorAll('tr'))
return {
type: 'table',
content: rows.map((row, rowIndex) => ({
type: 'tableRow',
content: Array.from(row.querySelectorAll('td, th')).map((cell) => ({
type: rowIndex === 0 || cell.tagName.toLowerCase() === 'th' ? 'tableHeader' : 'tableCell',
attrs: {
colspan: parseInt(cell.getAttribute('colspan') ?? '1'),
rowspan: parseInt(cell.getAttribute('rowspan') ?? '1'),
},
content: [{type: 'paragraph', content: parseInlineElements(cell, classStyles)}],
})),
})),
}
}
function parseInlineElements(
el: Element,
classStyles: Map<string, Record<string, string>>,
skipNested = false,
): JSONContent[] {
const nodes: JSONContent[] = []
for (const child of el.childNodes) {
// Plain text node
if (child.nodeType === 3) {
let text = child.textContent ?? ''
// Remove HTML tags from text
text = text.replace('<aside>', '\n').replace('</aside>', '\n')
if (text.trim()) nodes.push({type: 'text', text})
continue
}
if (child.nodeType !== 1) continue
const childEl = child as Element
const tag = childEl.tagName.toLowerCase()
// Skip nested lists when extracting list item text
if (skipNested && ['ul', 'ol'].includes(tag)) continue
// Line break
if (tag === 'br') {
nodes.push({type: 'hardBreak'})
continue
}
// Inline image
if (tag === 'img') {
const src = childEl.getAttribute('src')
if (src && src.startsWith('http')) nodes.push({type: 'image', attrs: {src}})
continue
}
// Marks
const marks = getMarks(childEl, tag, classStyles)
const isInlineContainer = [
'span',
'a',
'strong',
'em',
'b',
'i',
'u',
's',
'mark',
'code',
'label',
].includes(tag)
const hasChildElements = childEl.children.length > 0
if (isInlineContainer && hasChildElements) {
// Recurse into children and apply this element's marks on top
const innerNodes = parseInlineElements(childEl, classStyles, skipNested)
for (const inner of innerNodes) {
if (inner.type === 'text' && marks.length > 0) {
// Merge marks — avoid duplicates
const existingTypes = new Set((inner.marks ?? []).map((m: any) => m.type))
const newMarks = marks.filter((m) => !existingTypes.has(m.type as string))
nodes.push({
...inner,
marks: [...(inner.marks ?? []), ...newMarks],
} as JSONContent)
} else {
nodes.push(inner)
}
}
continue
}
const text = childEl.textContent ?? ''
if (!text) continue
nodes.push({
type: 'text',
text,
...(marks.length > 0 && {marks: marks as Array<{type: string; attrs?: Record<string, any>}>}),
})
}
return nodes
}
function getMarks(
el: Element,
tag: string,
classStyles: Map<string, Record<string, string>>,
): JSONContent[] {
const marks: JSONContent[] = []
if (['b', 'strong'].includes(tag)) marks.push({type: 'bold'})
if (['i', 'em'].includes(tag)) marks.push({type: 'italic'})
if (tag === 'u') marks.push({type: 'underline'})
if (['s', 'strike', 'del'].includes(tag)) marks.push({type: 'strike'})
if (tag === 'code') marks.push({type: 'code'})
if (tag === 'mark') marks.push({type: 'highlight'})
if (tag === 'a') {
const href = cleanHref(el.getAttribute('href') ?? '')
marks.push({
type: 'link',
attrs: {href, target: '_blank'},
})
}
const style: Record<string, string> = {}
const classes = Array.from(el.classList)
for (const cls of classes) {
const resolved = classStyles.get(cls)
if (resolved) Object.assign(style, resolved)
}
const inlineStyle = parseStyleString(el.getAttribute('style') ?? '')
Object.assign(style, inlineStyle)
if (!marks.find((m) => m.type === 'bold') && /^(bold|[7-9]\d{2})$/.test(style.fontWeight ?? '')) {
marks.push({type: 'bold'})
}
if (!marks.find((m) => m.type === 'italic') && style.fontStyle === 'italic') {
marks.push({type: 'italic'})
}
if (style.textDecoration?.includes('underline') && !marks.find((m) => m.type === 'underline')) {
marks.push({type: 'underline'})
}
if (style.textDecoration?.includes('line-through') && !marks.find((m) => m.type === 'strike')) {
marks.push({type: 'strike'})
}
return marks
}
function cleanHref(href: string): string {
try {
const url = new URL(href)
if (url.hostname === 'www.google.com' && url.pathname === '/url') {
return url.searchParams.get('q') ?? href
}
} catch (error) {
debug('Invalid URL:', href, error)
}
return href
}
export function extractGoogleDocId(url: string) {
const patterns = [
/\/document\/d\/([a-zA-Z0-9-_]+)/, // standard /d/{id}/ format
/id=([a-zA-Z0-9-_]+)/, // ?id= query param format
/^([a-zA-Z0-9-_]+)$/, // raw ID passed directly
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) return match[1]
}
return null
}
function markdownToJSONContent(markdown: string): JSONContent {
const html = marked(markdown) as string
const dom = new JSDOM(html)
return parseHtmlBodyToJSONContent(dom.window.document)
}
export function convertToJSONContent(
content: string,
contentType: string,
url: string,
): JSONContent {
if (contentType.includes('text/html')) {
return htmlToJSONContent(content, url) // use Readability for articles
}
if (contentType.includes('text/markdown') || url.endsWith('.md')) {
return markdownToJSONContent(content)
}
// plain text fallback
return plainTextToJSONContent(content)
}

View File

@@ -17,6 +17,11 @@
"@tiptap/extension-image": "2.10.4",
"@tiptap/extension-link": "2.10.4",
"@tiptap/extension-mention": "2.10.4",
"@tiptap/extension-table": "2.10.4",
"@tiptap/extension-table-cell": "2.10.4",
"@tiptap/extension-table-header": "2.10.4",
"@tiptap/extension-table-row": "2.10.4",
"@tiptap/extension-underline": "2.10.4",
"@tiptap/pm": "2.10.4",
"@tiptap/starter-kit": "2.10.4",
"@tiptap/suggestion": "2.10.4",

View File

@@ -11,7 +11,7 @@ import {ChatMessage} from 'common/chat-message'
import {Notification} from 'common/notifications'
import {CompatibilityScore} from 'common/profiles/compatibility-score'
import {MAX_COMPATIBILITY_QUESTION_LENGTH, OPTION_TABLES} from 'common/profiles/constants'
import {Profile, ProfileRow} from 'common/profiles/profile'
import {Profile, ProfileRow, ProfileWithoutUser} from 'common/profiles/profile'
import {Stats} from 'common/stats' // mqp: very unscientific, just balancing our willingness to accept load
import {PrivateMessageChannel} from 'common/supabase/private-messages'
import {Row} from 'common/supabase/utils'
@@ -1266,6 +1266,21 @@ export const API = (_apiTypeCheck = {
summary: 'Validate if a username is available',
tag: 'Users',
},
'llm-extract-profile': {
method: 'POST',
authed: true,
rateLimited: true,
props: z
.object({
content: z.string().min(1).optional(),
url: z.string().url().optional(),
locale: z.string().optional(),
})
.strict(),
returns: {} as Partial<ProfileWithoutUser>,
summary: 'Extract profile information from text using LLM',
tag: 'Profiles',
},
} as const)
export type APIPath = keyof typeof API

View File

@@ -1,9 +1,12 @@
import {debug} from 'common/logger'
import {ProfileRow} from 'common/profiles/profile'
import {sleep} from 'common/util/time'
export const geodbHost = 'wft-geo-db.p.rapidapi.com'
export const geodbFetch = async (endpoint: string) => {
export const geodbFetch = async (
endpoint: string,
): Promise<{status: 'success' | 'failure'; data: any}> => {
const apiKey = process.env.GEODB_API_KEY
if (!apiKey) {
return {status: 'failure', data: 'Missing GEODB API key'}
@@ -21,6 +24,11 @@ export const geodbFetch = async (endpoint: string) => {
})
if (!res.ok) {
if (res.status === 429) {
debug('geodbFetch', endpoint, 'Rate limited')
await sleep(1100)
return geodbFetch(endpoint)
}
throw new Error(`HTTP error! Status: ${res.status} ${await res.text()}`)
}

View File

@@ -11,3 +11,35 @@ export function trimStrings<T extends Record<string, unknown | string>>(body: T)
}
return body
}
export const isUrl = (text: string): boolean => {
if (!text || typeof text !== 'string') return false
// Remove leading/trailing whitespace
const trimmedText = text.trim()
// If it already starts with a protocol, test as-is
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmedText)) {
try {
new URL(trimmedText)
return true
} catch {
return false
}
}
// Try adding https:// prefix for common domain patterns
if (
/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.test(trimmedText) ||
/^www\.[a-zA-Z0-9.-]+/.test(trimmedText)
) {
try {
new URL(`https://${trimmedText}`)
return true
} catch {
return false
}
}
return false
}

View File

@@ -0,0 +1,6 @@
import {APIErrors} from 'common/api/utils'
import {OPTION_TABLES} from 'common/profiles/constants'
export function validateTable(table: 'interests' | 'causes' | 'work') {
if (!OPTION_TABLES.includes(table)) throw APIErrors.badRequest('Invalid table')
}

View File

@@ -28,6 +28,7 @@ export const secrets = (
'VAPID_PRIVATE_KEY',
'DB_ENC_MASTER_KEY_BASE64',
'GOOGLE_CLIENT_SECRET',
'GEMINI_API_KEY',
// Some typescript voodoo to keep the string literal types while being not readonly.
] as const
).concat()

View File

@@ -2,6 +2,11 @@ import {getSchema, getText, getTextSerializersFromSchema, JSONContent} from '@ti
import {Image} from '@tiptap/extension-image'
import {Link} from '@tiptap/extension-link'
import {Mention} from '@tiptap/extension-mention'
import Table from '@tiptap/extension-table'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import TableRow from '@tiptap/extension-table-row'
import Underline from '@tiptap/extension-underline'
import {Node as ProseMirrorNode} from '@tiptap/pm/model'
import {StarterKit} from '@tiptap/starter-kit'
import {find} from 'linkifyjs'
@@ -49,6 +54,11 @@ export const extensions = [
Iframe.extend({
renderText: ({node}) => ('[embed]' + node.attrs.src ? `(${node.attrs.src})` : ''),
}),
Table.configure({resizable: false}),
TableRow,
TableCell,
TableHeader,
Underline,
]
const extensionSchema = getSchema(extensions)

View File

@@ -5,3 +5,8 @@ export function removeEmojis(str: string) {
'',
)
}
export function urlize(s: string) {
if (s.startsWith('http://') || s.startsWith('https://')) return s
return `https://${s}`
}

View File

@@ -52,11 +52,32 @@
"@tiptap/extension-blockquote": "2.10.4",
"@tiptap/extension-bold": "2.10.4",
"@tiptap/extension-bubble-menu": "2.10.4",
"@tiptap/extension-bullet-list": "2.10.4",
"@tiptap/extension-code": "2.10.4",
"@tiptap/extension-code-block": "2.10.4",
"@tiptap/extension-document": "2.10.4",
"@tiptap/extension-dropcursor": "2.10.4",
"@tiptap/extension-floating-menu": "2.10.4",
"@tiptap/extension-gapcursor": "2.10.4",
"@tiptap/extension-hard-break": "2.10.4",
"@tiptap/extension-heading": "2.10.4",
"@tiptap/extension-history": "2.10.4",
"@tiptap/extension-horizontal-rule": "2.10.4",
"@tiptap/extension-image": "2.10.4",
"@tiptap/extension-italic": "2.10.4",
"@tiptap/extension-link": "2.10.4",
"@tiptap/extension-list-item": "2.10.4",
"@tiptap/extension-mention": "2.10.4",
"@tiptap/extension-ordered-list": "2.10.4",
"@tiptap/extension-paragraph": "2.10.4",
"@tiptap/extension-strike": "2.10.4",
"@tiptap/extension-table": "2.10.4",
"@tiptap/extension-table-cell": "2.10.4",
"@tiptap/extension-table-header": "2.10.4",
"@tiptap/extension-table-row": "2.10.4",
"@tiptap/extension-text": "2.10.4",
"@tiptap/extension-text-style": "2.10.4",
"@tiptap/extension-underline": "2.10.4",
"@tiptap/html": "2.10.4",
"@tiptap/pm": "2.10.4",
"@tiptap/starter-kit": "2.10.4",

View File

@@ -10,6 +10,7 @@ cd "$(dirname "$0")"/..
export NEXT_PUBLIC_ISOLATED_ENV=true
export $(cat .env.local | grep -v '^#' | xargs)
export $(cat .env.test | grep -v '^#' | xargs)
# Ensure Supabase local stack is running; if not, reset/start it

View File

@@ -203,3 +203,33 @@ export function BaseBio({defaultValue, onBlur, onEditor, onClickTips}: BaseBioPr
</div>
)
}
interface BaseTextEditorProps {
placeholder?: any
defaultValue?: any
onBlur?: (editor: any) => void
onEditor?: (editor: any) => void
onChange?: () => void
}
export function BaseTextEditor({
placeholder,
defaultValue,
onBlur,
onEditor,
onChange,
}: BaseTextEditorProps) {
const t = useT()
const editor = useTextEditor({
max: MAX_DESCRIPTION_LENGTH,
defaultValue: defaultValue,
placeholder: placeholder ?? t('common.text_editor.placeholder', 'Write here...'),
onChange: onChange,
})
useEffect(() => {
onEditor?.(editor)
}, [editor, onEditor])
return <TextEditor editor={editor} onBlur={() => onBlur?.(editor)} />
}

View File

@@ -29,7 +29,15 @@ export function FloatingFormatMenu(props: {
const unsetLink = () => editor.chain().focus().unsetLink().run()
return (
<BubbleMenu editor={editor} className="text-ink-0 bg-ink-700 flex gap-2 rounded-sm p-1">
<BubbleMenu
editor={editor}
shouldShow={({state}) => {
// CellSelection has $anchorCell, regular selections don't
if ('$anchorCell' in state.selection) return false
return !state.selection.empty
}}
className="text-ink-0 bg-ink-700 flex gap-2 rounded-sm p-1"
>
{url === null ? (
<>
{advanced && (

View File

@@ -0,0 +1,65 @@
import {isUrl} from 'common/parsing'
import {useT} from 'web/lib/locale'
import {BaseTextEditor} from './bio/editable-bio'
import {Button} from './buttons/button'
import {Col} from './layout/col'
interface LLMExtractSectionProps {
parsingEditor: any
setParsingEditor: (editor: any) => void
isExtracting: boolean
isSubmitting: boolean
onExtract: () => void
}
export function LLMExtractSection({
parsingEditor,
setParsingEditor,
isExtracting,
isSubmitting,
onExtract,
}: LLMExtractSectionProps) {
const t = useT()
const parsingText = parsingEditor?.getText?.()
return (
<Col className={'gap-4'}>
<div className="">
{t(
'profile.llm.extract.description',
'Auto-fill your profile by dropping a link (LinkedIn, Notion, Google Docs, personal website, etc.) or pasting your content directly.',
)}
</div>
<div className="guidance">
{t(
'profile.llm.extract.guidance',
'Heads up: we use Google AI to extract your info. Google may use this content to improve their models. Prefer to keep things private? Just fill the form manually — no AI involved.',
)}
</div>
<BaseTextEditor
onEditor={(e) => {
if (e) setParsingEditor(e)
}}
onChange={() => {
// Trigger re-render to update button state and text on every key stroke
setParsingEditor({...parsingEditor})
}}
placeholder={t(
'profile.llm.extract.placeholder',
'Insert a URL or paste your profile content here.',
)}
/>
<Button
onClick={onExtract}
disabled={isExtracting || !parsingEditor?.getJSON?.() || isSubmitting}
loading={isExtracting}
className="self-start"
>
{isUrl(parsingText)
? t('profile.llm.extract.button_url', 'Extract Profile Data from URL')
: t('profile.llm.extract.button_text', 'Extract Profile Data from Text')}
</Button>
</Col>
)
}

View File

@@ -1,4 +1,3 @@
import {PlusIcon, XMarkIcon} from '@heroicons/react/24/solid'
import {Editor} from '@tiptap/react'
import clsx from 'clsx'
import {
@@ -15,36 +14,38 @@ import {
ROMANTIC_CHOICES,
} from 'common/choices'
import {debug} from 'common/logger'
import {isUrl} from 'common/parsing'
import {MultipleChoiceOptions} from 'common/profiles/multiple-choice'
import {Profile, ProfileWithoutUser} from 'common/profiles/profile'
import {PLATFORM_LABELS, type Site, SITE_ORDER, Socials} from 'common/socials'
import {BaseUser} from 'common/user'
import {range} from 'lodash'
import {Fragment, useRef, useState} from 'react'
import {urlize} from 'common/util/string'
import {invert, range} from 'lodash'
import {useRef, useState} from 'react'
import Textarea from 'react-expanding-textarea'
import toast from 'react-hot-toast'
import {AddOptionEntry} from 'web/components/add-option-entry'
import {SignupBio} from 'web/components/bio/editable-bio'
import {Button, IconButton} from 'web/components/buttons/button'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {CustomLink} from 'web/components/links'
import {LLMExtractSection} from 'web/components/llm-extract-section'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {City, CityRow, profileToCity, useCitySearch} from 'web/components/search-location'
import {SocialLinksSection} from 'web/components/social-links-section'
import {Carousel} from 'web/components/widgets/carousel'
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
import {Input} from 'web/components/widgets/input'
import {PlatformSelect} from 'web/components/widgets/platform-select'
import {RadioToggleGroup} from 'web/components/widgets/radio-toggle-group'
import {Select} from 'web/components/widgets/select'
import {Slider} from 'web/components/widgets/slider'
import {Title} from 'web/components/widgets/title'
import {useChoicesContext} from 'web/hooks/use-choices'
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale'
import {track} from 'web/lib/service/analytics'
import {colClassName, labelClassName} from 'web/pages/signup'
import {SocialIcon} from './user/social'
import {AddPhotosWidget} from './widgets/add-photos'
export const OptionalProfileUserForm = (props: {
@@ -59,20 +60,8 @@ export const OptionalProfileUserForm = (props: {
const [isSubmitting, setIsSubmitting] = useState(false)
const [uploadingImages, setUploadingImages] = useState(false)
const [lookingRelationship, setLookingRelationship] = useState(
(profile.pref_relation_styles || []).includes('relationship'),
)
const [ageError, setAgeError] = useState<string | null>(null)
const t = useT()
const [heightFeet, setHeightFeet] = useState<number | undefined>(
profile.height_in_inches ? Math.floor((profile['height_in_inches'] ?? 0) / 12) : undefined,
)
const [heightInches, setHeightInches] = useState<number | undefined>(
profile.height_in_inches ? Math.floor((profile['height_in_inches'] ?? 0) % 12) : undefined,
)
const [newLinkPlatform, setNewLinkPlatform] = useState('')
const [newLinkValue, setNewLinkValue] = useState('')
const choices = useChoicesContext()
const [interestChoices, setInterestChoices] = useState(choices.interests)
@@ -81,13 +70,92 @@ export const OptionalProfileUserForm = (props: {
const [keywordsString, setKeywordsString] = useState<string>(profile.keywords?.join(', ') || '')
const lookingRelationship = (profile.pref_relation_styles || []).includes('relationship')
const heightFeet =
typeof profile.height_in_inches === 'number'
? Math.floor(profile.height_in_inches / 12)
: undefined
const heightInches =
typeof profile.height_in_inches === 'number' ? profile.height_in_inches % 12 : undefined
const [isExtracting, setIsExtracting] = useState(false)
const [parsingEditor, setParsingEditor] = useState<any>(null)
const handleLLMExtract = async () => {
const llmContent = parsingEditor?.getText?.() ?? ''
if (!llmContent) {
toast.error(t('profile.llm.extract.error_empty', 'Please enter content to extract from'))
return
}
setIsExtracting(true)
try {
const isInputUrl = isUrl(llmContent)
const payload = isInputUrl ? {url: urlize(llmContent).trim()} : {content: llmContent.trim()}
const extracted = await api('llm-extract-profile', payload)
for (const data of Object.entries(extracted)) {
const key = data[0]
let value = data[1]
let choices, setChoices: any
if (key === 'interests') {
choices = interestChoices
setChoices = setInterestChoices
} else if (key === 'causes') {
choices = causeChoices
setChoices = setCauseChoices
} else if (key === 'work') {
choices = workChoices
setChoices = setWorkChoices
}
if (choices && setChoices) {
const newFields: string[] = []
const converter = invert(choices)
value = (value as string[]).map((interest: string) => {
if (!converter[interest]) newFields.push(interest)
return converter[interest] ?? interest
})
if (newFields.length) {
setChoices((prev: any) => ({
...prev,
...Object.fromEntries(newFields.map((e) => [e, e])),
}))
}
debug({value, converter})
} else if (key === 'keywords') setKeywordsString((value as string[]).join(', '))
setProfile(
key as keyof ProfileWithoutUser,
value as ProfileWithoutUser[keyof ProfileWithoutUser],
)
}
if (!isInputUrl) setProfile('bio', parsingEditor?.getJSON?.())
debug({text: parsingEditor?.getText?.(), json: parsingEditor?.getJSON?.(), extracted})
parsingEditor?.commands?.clearContent?.()
toast.success(
t('profile.llm.extract.success', 'Profile data extracted! Please review below.'),
)
} catch (error) {
console.error(error)
toast.error(t('profile.llm.extract.error', 'Failed to extract profile data'))
} finally {
setIsExtracting(false)
}
}
const errorToast = () => {
toast.error(t('profile.optional.error.invalid_fields', 'Some fields are incorrect...'))
}
const handleSubmit = async () => {
if (parsingEditor?.getText?.()?.trim()) {
await handleLLMExtract()
}
// Validate age before submitting
if (profile['age'] !== null && profile['age'] !== undefined) {
if (typeof profile['age'] === 'number') {
if (profile['age'] < 18) {
setAgeError(t('profile.optional.age.error_min', 'You must be at least 18 years old'))
setIsSubmitting(false)
@@ -115,18 +183,6 @@ export const OptionalProfileUserForm = (props: {
setIsSubmitting(false)
}
const updateUserLink = (platform: string, value: string | null) => {
setProfile('links', {...((profile.links as Socials) ?? {}), [platform]: value})
}
const addNewLink = () => {
if (newLinkPlatform && newLinkValue) {
updateUserLink(newLinkPlatform.toLowerCase().trim(), newLinkValue.trim())
setNewLinkPlatform('')
setNewLinkValue('')
}
}
function setProfileCity(inputCity: City | undefined) {
if (!inputCity) {
setProfile('geodb_city_id', null)
@@ -189,19 +245,20 @@ export const OptionalProfileUserForm = (props: {
return (
<>
{/*<Row className={'justify-end'}>*/}
{/* <Button*/}
{/* disabled={isSubmitting}*/}
{/* loading={isSubmitting}*/}
{/* onClick={handleSubmit}*/}
{/* >*/}
{/* {buttonLabel ?? t('common.next', 'Next')} / {t('common.skip', 'Skip')}*/}
{/* </Button>*/}
{/*</Row>*/}
<Title>{t('profile.optional.subtitle', 'Optional information')}</Title>
<Col className={'gap-8'}>
<Category title={t('profile.llm.extract.title', 'Auto-fill')} className={'mt-0'} />
<LLMExtractSection
parsingEditor={parsingEditor}
setParsingEditor={setParsingEditor}
isExtracting={isExtracting}
isSubmitting={isSubmitting}
onExtract={handleLLMExtract}
/>
<hr className="border border-b my-4" />
<Category
title={t('profile.optional.category.personal_info', 'Personal Information')}
className={'mt-0'}
@@ -286,13 +343,8 @@ export const OptionalProfileUserForm = (props: {
type="number"
data-testid="height-feet"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === '') {
setHeightFeet(undefined)
} else {
setHeightFeet(Number(e.target.value))
const heightInInches = Number(e.target.value) * 12 + (heightInches ?? 0)
setProfile('height_in_inches', heightInInches)
}
const heightInInches = Number(e.target.value || 0) * 12 + (heightInches ?? 0)
setProfile('height_in_inches', heightInInches)
}}
className={'w-16'}
value={typeof heightFeet === 'number' ? Math.floor(heightFeet) : ''}
@@ -305,13 +357,8 @@ export const OptionalProfileUserForm = (props: {
type="number"
data-testid="height-inches"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === '') {
setHeightInches(undefined)
} else {
setHeightInches(Number(e.target.value))
const heightInInches = Number(e.target.value) + 12 * (heightFeet ?? 0)
setProfile('height_in_inches', heightInInches)
}
const heightInInches = Number(e.target.value || 0) + 12 * (heightFeet ?? 0)
setProfile('height_in_inches', heightInInches)
}}
className={'w-16'}
value={typeof heightInches === 'number' ? Math.floor(heightInches) : ''}
@@ -328,14 +375,10 @@ export const OptionalProfileUserForm = (props: {
data-testid="height-centimeters"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === '') {
setHeightFeet(undefined)
setHeightInches(undefined)
setProfile('height_in_inches', null)
} else {
// Convert cm to inches
const totalInches = Number(e.target.value) / 2.54
setHeightFeet(Math.floor(totalInches / 12))
setHeightInches(totalInches % 12)
setProfile('height_in_inches', totalInches)
}
}}
@@ -449,7 +492,7 @@ export const OptionalProfileUserForm = (props: {
.filter(Boolean)
setProfile('keywords', keywords)
}}
className={'w-full sm:w-96'}
className={'w-full sm:w-[600px]'}
value={keywordsString}
placeholder={t(
'profile.optional.keywords_placeholder',
@@ -534,7 +577,6 @@ export const OptionalProfileUserForm = (props: {
translationPrefix={'profile.relationship'}
onChange={(selected) => {
setProfile('pref_relation_styles', selected)
setLookingRelationship((selected || []).includes('relationship'))
}}
/>
</Col>
@@ -915,70 +957,7 @@ export const OptionalProfileUserForm = (props: {
<Category title={t('profile.optional.socials', 'Socials')} />
<Col className={clsx(colClassName, 'pb-4')}>
{/*<label className={clsx(labelClassName)}>*/}
{/* {t('profile.optional.socials', 'Socials')}*/}
{/*</label>*/}
<div className="grid w-full grid-cols-[8rem_1fr_auto] gap-2">
{Object.entries((profile.links ?? {}) as Socials)
.filter(([_, value]) => value != null)
.map(([platform, value]) => (
<Fragment key={platform}>
<div className="col-span-3 mt-2 flex items-center gap-2 self-center sm:col-span-1">
<SocialIcon site={platform as any} className="text-primary-700 h-4 w-4" />
{PLATFORM_LABELS[platform as Site] ?? platform}
</div>
<Input
type="text"
value={value!}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateUserLink(platform, e.target.value)
}
className="col-span-2 sm:col-span-1"
/>
<IconButton onClick={() => updateUserLink(platform, null)}>
<XMarkIcon className="h-6 w-6" />
<div className="sr-only">{t('common.remove', 'Remove')}</div>
</IconButton>
</Fragment>
))}
{/* Spacer */}
<div className="col-span-3 h-4" />
<PlatformSelect
value={newLinkPlatform}
onChange={setNewLinkPlatform}
className="h-full !w-full"
/>
<Input
type="text"
placeholder={
SITE_ORDER.includes(newLinkPlatform as any) && newLinkPlatform != 'site'
? t('profile.optional.username_or_url', 'Username or URL')
: t('profile.optional.site_url', 'Site URL')
}
value={newLinkValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewLinkValue(e.target.value)}
// disable password managers
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-bwignore="true"
data-protonpass-ignore="true"
className="w-full"
/>
<Button
color="gray-outline"
onClick={addNewLink}
disabled={!newLinkPlatform || !newLinkValue}
>
<PlusIcon className="h-6 w-6" />
<div className="sr-only">{t('common.add', 'Add')}</div>
</Button>
</div>
</Col>
<SocialLinksSection profile={profile} setProfile={setProfile} />
<Category title={t('profile.basics.bio', 'Bio')} className={'mt-0'} />
<label className={clsx('guidance')}>

View File

@@ -0,0 +1,98 @@
import {PlusIcon, XMarkIcon} from '@heroicons/react/24/solid'
import clsx from 'clsx'
import {ProfileWithoutUser} from 'common/profiles/profile'
import {PLATFORM_LABELS, type Site, SITE_ORDER, Socials} from 'common/socials'
import {Fragment, useState} from 'react'
import {Button, IconButton} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Input} from 'web/components/widgets/input'
import {PlatformSelect} from 'web/components/widgets/platform-select'
import {useT} from 'web/lib/locale'
import {SocialIcon} from './user/social'
interface SocialLinksSectionProps {
profile: ProfileWithoutUser
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K]) => void
}
export function SocialLinksSection({profile, setProfile}: SocialLinksSectionProps) {
const t = useT()
const [newLinkPlatform, setNewLinkPlatform] = useState('')
const [newLinkValue, setNewLinkValue] = useState('')
const updateUserLink = (platform: string, value: string | null) => {
setProfile('links', {...((profile.links as Socials) ?? {}), [platform]: value})
}
const addNewLink = () => {
if (newLinkPlatform && newLinkValue) {
updateUserLink(newLinkPlatform.toLowerCase().trim(), newLinkValue.trim())
setNewLinkPlatform('')
setNewLinkValue('')
}
}
return (
<Col className={clsx('pb-4')}>
<div className="grid w-full grid-cols-[8rem_1fr_auto] gap-2">
{Object.entries((profile.links ?? {}) as Socials)
.filter(([_, value]) => value != null)
.map(([platform, value]) => (
<Fragment key={platform}>
<div className="col-span-3 mt-2 flex items-center gap-2 self-center sm:col-span-1">
<SocialIcon site={platform as any} className="text-primary-700 h-4 w-4" />
{PLATFORM_LABELS[platform as Site] ?? platform}
</div>
<Input
type="text"
value={value!}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateUserLink(platform, e.target.value)
}
className="col-span-2 sm:col-span-1"
/>
<IconButton onClick={() => updateUserLink(platform, null)}>
<XMarkIcon className="h-6 w-6" />
<div className="sr-only">{t('common.remove', 'Remove')}</div>
</IconButton>
</Fragment>
))}
{/* Spacer */}
<div className="col-span-3 h-4" />
<PlatformSelect
value={newLinkPlatform}
onChange={setNewLinkPlatform}
className="h-full !w-full"
/>
<Input
type="text"
placeholder={
SITE_ORDER.includes(newLinkPlatform as any) && newLinkPlatform != 'site'
? t('profile.optional.username_or_url', 'Username or URL')
: t('profile.optional.site_url', 'Site URL')
}
value={newLinkValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewLinkValue(e.target.value)}
// disable password managers
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-bwignore="true"
data-protonpass-ignore="true"
className="w-full"
/>
<Button
color="gray-outline"
onClick={addNewLink}
disabled={!newLinkPlatform || !newLinkValue}
>
<PlusIcon className="h-6 w-6" />
<div className="sr-only">{t('common.add', 'Add')}</div>
</Button>
</div>
</Col>
)
}

View File

@@ -1,25 +1,31 @@
import CharacterCount from '@tiptap/extension-character-count'
import {Link} from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import Table from '@tiptap/extension-table'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import TableRow from '@tiptap/extension-table-row'
import Underline from '@tiptap/extension-underline'
import {TextSelection} from '@tiptap/pm/state'
import type {Content, JSONContent} from '@tiptap/react'
import {Editor, EditorContent, Extensions, mergeAttributes, useEditor} from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import clsx from 'clsx'
import {richTextToString} from 'common/util/parse'
import Iframe from 'common/util/tiptap-iframe'
import {debounce, noop} from 'lodash'
import {ReactNode, useCallback, useEffect, useMemo} from 'react'
import {debounce} from 'lodash'
import {createElement, ReactNode, useCallback, useEffect, useMemo} from 'react'
import {CustomLink} from 'web/components/links'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {safeLocalStorage} from 'web/lib/util/local'
import {EmojiExtension} from '../editor/emoji/emoji-extension'
import {FloatingFormatMenu} from '../editor/floating-format-menu'
import {BasicImage, DisplayImage, MediumDisplayImage} from '../editor/image'
import {BasicImage, DisplayImage} from '../editor/image'
import {nodeViewMiddleware} from '../editor/nodeview-middleware'
import {StickyFormatMenu} from '../editor/sticky-format-menu'
import {Upload, useUploadMutation} from '../editor/upload-extension'
import {DisplayMention} from '../editor/user-mention/mention-extension'
import {generateReact} from '../editor/utils'
import {Linkify} from './linkify'
import {linkClass} from './site-link'
@@ -49,6 +55,11 @@ const editorExtensions = (simple = false): Extensions =>
DisplayMention,
Iframe,
Upload,
Table.configure({resizable: false}),
TableRow,
TableCell,
TableHeader,
Underline,
])
const proseClass = (size: 'sm' | 'md' | 'lg') =>
@@ -72,8 +83,9 @@ export function useTextEditor(props: {
key?: string // unique key for autosave. If set, plz call `editor.commands.clearContent(true)` on submit to clear autosave
extensions?: Extensions
className?: string
onChange?: () => void
}) {
const {placeholder, className, max, defaultValue, size = 'md', key} = props
const {placeholder, className, max, defaultValue, size = 'md', key, onChange} = props
const simple = size === 'sm'
const [content, setContent] = usePersistentLocalState<JSONContent | undefined>(
@@ -109,13 +121,29 @@ export function useTextEditor(props: {
})
const editor = useEditor({
editorProps: getEditorProps(),
editorProps: {
...getEditorProps(),
handleDOMEvents: {},
transformPastedHTML: (html) => html,
},
immediatelyRender: false,
onUpdate: !key
? noop
: ({editor}) => {
save(editor.getJSON())
},
onUpdate: ({editor}) => {
if (key) {
save(editor.getJSON())
}
onChange?.()
},
onTransaction({transaction, editor}) {
const {selection} = transaction
// If CellSelection sneaks through, convert to TextSelection
if ('$anchorCell' in selection) {
const {$anchorCell} = selection as any
const tr = editor.state.tr.setSelection(
TextSelection.create(editor.state.doc, $anchorCell.pos),
)
editor.view.dispatch(tr)
}
},
extensions: [
...editorExtensions(simple),
Placeholder.configure({
@@ -204,13 +232,7 @@ function RichContent(props: {content: JSONContent; className?: string; size?: 's
const jsxContent = useMemo(() => {
try {
return generateReact(content, [
StarterKit as any,
size === 'sm' ? DisplayImage : size === 'md' ? MediumDisplayImage : BasicImage,
DisplayLink,
DisplayMention,
Iframe,
])
return renderJSONContent(content, size)
} catch (e) {
console.error('Error generating react', e, 'for content', content)
return ''
@@ -233,6 +255,105 @@ function RichContent(props: {content: JSONContent; className?: string; size?: 's
)
}
function renderJSONContent(doc: JSONContent, size: 'sm' | 'md' | 'lg'): ReactNode {
return recurse(doc, 0, size)
}
function recurse(node: JSONContent, key: number, size: 'sm' | 'md' | 'lg'): ReactNode {
const children = node.content?.map((n, i) => recurse(n, i, size))
switch (node.type) {
case 'doc':
return <>{children}</>
case 'paragraph':
return <p key={key}>{children}</p>
case 'heading':
return createElement(`h${node.attrs?.level ?? 1}`, {key}, children)
case 'bulletList':
return <ul key={key}>{children}</ul>
case 'orderedList':
return (
<ol key={key} start={node.attrs?.start ?? 1}>
{children}
</ol>
)
case 'listItem':
return <li key={key}>{children}</li>
case 'blockquote':
return <blockquote key={key}>{children}</blockquote>
case 'codeBlock':
return (
<pre key={key}>
<code>{children}</code>
</pre>
)
case 'horizontalRule':
return <hr key={key} />
case 'hardBreak':
return <br key={key} />
case 'image':
return (
<img
key={key}
src={node.attrs?.src}
alt={node.attrs?.alt ?? ''}
title={node.attrs?.title ?? undefined}
className={size === 'sm' ? 'max-h-32' : size === 'md' ? 'max-h-64' : undefined}
/>
)
case 'table':
return (
<table key={key}>
<tbody>{children}</tbody>
</table>
)
case 'tableRow':
return <tr key={key}>{children}</tr>
case 'tableHeader':
return (
<th key={key} colSpan={node.attrs?.colspan ?? 1} rowSpan={node.attrs?.rowspan ?? 1}>
{children}
</th>
)
case 'tableCell':
return (
<td key={key} colSpan={node.attrs?.colspan ?? 1} rowSpan={node.attrs?.rowspan ?? 1}>
{children}
</td>
)
case 'text':
return applyMarks(node.text ?? '', node.marks ?? [], key)
default:
return null
}
}
function applyMarks(text: string, marks: JSONContent[], key: number): ReactNode {
return marks.reduce(
(node, mark) => {
switch (mark.type) {
case 'bold':
return <strong>{node}</strong>
case 'italic':
return <em>{node}</em>
case 'underline':
return <u>{node}</u>
case 'strike':
return <s>{node}</s>
case 'code':
return <code>{node}</code>
case 'highlight':
return <mark>{node}</mark>
case 'link':
return <CustomLink href={mark.attrs?.href}>{node}</CustomLink>
default:
return node
}
},
(<span key={key}>{text}</span>) as ReactNode,
)
}
// backwards compatibility: we used to store content as strings
export function Content(props: {
content: JSONContent | string

459
yarn.lock
View File

@@ -74,6 +74,33 @@
resolved "https://registry.yarnpkg.com/@apphosting/common/-/common-0.0.9.tgz#af0b5375e8169e1d6ad32eebdec9d67d5caf669b"
integrity sha512-ZbPZDcVhEN+8m0sf90PmQN4xWaKmmySnBSKKPaIOD0JvcDsRr509WenFEFlojP++VSxwFZDGG/TYsHs1FMMqpw==
"@asamuzakjp/css-color@^5.0.1":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-5.0.1.tgz#3b9462a9b52f3c6680a0945a3d0851881017550f"
integrity sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==
dependencies:
"@csstools/css-calc" "^3.1.1"
"@csstools/css-color-parser" "^4.0.2"
"@csstools/css-parser-algorithms" "^4.0.0"
"@csstools/css-tokenizer" "^4.0.0"
lru-cache "^11.2.6"
"@asamuzakjp/dom-selector@^7.0.3":
version "7.0.4"
resolved "https://registry.yarnpkg.com/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz#744ad572c70b00cc8e92e76d539b14afb7bd99b1"
integrity sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==
dependencies:
"@asamuzakjp/nwsapi" "^2.3.9"
bidi-js "^1.0.3"
css-tree "^3.2.1"
is-potential-custom-element-name "^1.0.1"
lru-cache "^11.2.7"
"@asamuzakjp/nwsapi@^2.3.9":
version "2.3.9"
resolved "https://registry.yarnpkg.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz#ad5549322dfe9d153d4b4dd6f7ff2ae234b06e24"
integrity sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.2":
version "7.24.2"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae"
@@ -1272,6 +1299,13 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@bramus/specificity@^2.4.2":
version "2.4.2"
resolved "https://registry.yarnpkg.com/@bramus/specificity/-/specificity-2.4.2.tgz#aa8db8eb173fdee7324f82284833106adeecc648"
integrity sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==
dependencies:
css-tree "^3.0.0"
"@capacitor/android@7.4.4":
version "7.4.4"
resolved "https://registry.yarnpkg.com/@capacitor/android/-/android-7.4.4.tgz#a14a1e844bd5079982427e247fdd17555b5fbedd"
@@ -1405,6 +1439,39 @@
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
"@csstools/color-helpers@^6.0.2":
version "6.0.2"
resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-6.0.2.tgz#82c59fd30649cf0b4d3c82160489748666e6550b"
integrity sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==
"@csstools/css-calc@^3.1.1":
version "3.1.1"
resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-3.1.1.tgz#78b494996dac41a02797dcca18ac3b46d25b3fd7"
integrity sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==
"@csstools/css-color-parser@^4.0.2":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz#c27e03a3770d0352db92d668d6dde427a37859e5"
integrity sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==
dependencies:
"@csstools/color-helpers" "^6.0.2"
"@csstools/css-calc" "^3.1.1"
"@csstools/css-parser-algorithms@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz#e1c65dc09378b42f26a111fca7f7075fc2c26164"
integrity sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==
"@csstools/css-syntax-patches-for-csstree@^1.1.1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz#bef07c507732a052b662302e7cb4e9fdf522af90"
integrity sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==
"@csstools/css-tokenizer@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz#798a33950d11226a0ebb6acafa60f5594424967f"
integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==
"@dabh/diagnostics@^2.0.8":
version "2.0.8"
resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.8.tgz#ead97e72ca312cf0e6dd7af0d300b58993a31a5e"
@@ -1739,6 +1806,11 @@
"@eslint/core" "^0.17.0"
levn "^0.4.1"
"@exodus/bytes@^1.11.0", "@exodus/bytes@^1.15.0", "@exodus/bytes@^1.6.0":
version "1.15.0"
resolved "https://registry.yarnpkg.com/@exodus/bytes/-/bytes-1.15.0.tgz#54479e0f406cbad024d6fe1c3190ecca4468df3b"
integrity sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==
"@faker-js/faker@10.1.0":
version "10.1.0"
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-10.1.0.tgz#eb72869d01ccbff41a77aa7ac851ce1ac9371129"
@@ -3291,6 +3363,11 @@
zod "^3.25 || ^4.0"
zod-to-json-schema "^3.25.0"
"@mozilla/readability@0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@mozilla/readability/-/readability-0.6.0.tgz#134e3ce3ff1676716e550de0b8de957bcc59208b"
integrity sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==
"@next/env@16.1.6":
version "16.1.6"
resolved "https://registry.yarnpkg.com/@next/env/-/env-16.1.6.tgz#0f85979498249a94ef606ef535042a831f905e89"
@@ -5017,35 +5094,35 @@
dependencies:
tippy.js "^6.3.7"
"@tiptap/extension-bullet-list@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz#2347683ab898471ab7df2c3e63b20e8d3d7c46f3"
integrity sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==
"@tiptap/extension-bullet-list@2.10.4", "@tiptap/extension-bullet-list@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.10.4.tgz#0b86fb2782b4a7fc012a23d3e265bcc798e70a12"
integrity sha512-JVwDPgOBYRU2ivaadOh4IaQYXQEiSw6sB36KT/bwqJF2GnEvLiMwptdRMn9Uuh6xYR3imjIZtV6uZAoneZdd6g==
"@tiptap/extension-character-count@2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-character-count/-/extension-character-count-2.10.4.tgz#dccf731a48eaf93075500709929a1bf0eff1d8f9"
integrity sha512-NoVLQI/zTEdA0EZe5+oinQ+F/WQMblZRdEWgfsUtQoLRloSAF+pFeQwDenpejdOuWqixT4vdzpboBocj4uQLsw==
"@tiptap/extension-code-block@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz#0a622d5bf92c9db55e9f5eaba1a6a8d7a015b1f1"
integrity sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==
"@tiptap/extension-code-block@2.10.4", "@tiptap/extension-code-block@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.10.4.tgz#a86a4477acc995c5185e32dd60188f4e173f9341"
integrity sha512-qS4jnbJqghNMT2+B+GQ807ATgqkL9OQ//NlL+ZwVSe+DPDduNA9B6IB9SrWENDfOnzekpi7kcEcm+RenELARRQ==
"@tiptap/extension-code@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.27.2.tgz#bfbaf07f67232144c6865ffbea20896e02c6fe6f"
integrity sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==
"@tiptap/extension-code@2.10.4", "@tiptap/extension-code@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.10.4.tgz#d6d1fd9e7f1b457700b6c47e115fb1cc9b9039f8"
integrity sha512-Vj/N0nbSQiV1o7X7pRySK9Fu72Dd266gm27TSlsts6IwJu5MklFvz7ezJUWoLjt2wmCV8/U/USmk/39ic9qjvg==
"@tiptap/extension-document@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.27.2.tgz#697ee04c03c7b37bc37d942d60fcc5fa304988b5"
integrity sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==
"@tiptap/extension-document@2.10.4", "@tiptap/extension-document@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.10.4.tgz#68575c122f6d71556dae6f9df6173903b7cdfdf9"
integrity sha512-1Pqrl6Rr9bVEHJ3zO2dM7UUA0Qn/r70JQ9YLlestjW1sbMaMuY3Ifvu2uSyUE7SAGV3gvxwNVQCrv8f0VlVEaA==
"@tiptap/extension-dropcursor@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz#c0f62e32a6c7bc7dc8cc6b6edd84d9173bc1db16"
integrity sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==
"@tiptap/extension-dropcursor@2.10.4", "@tiptap/extension-dropcursor@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.10.4.tgz#6dea1c17878a8a0d68c7915f34b7b02980e91c90"
integrity sha512-0XEM/yNLaMc/sZlYOau7XpHyYiHT9LwXUe7kmze/L8eowIa/iLvmRbcnUd3rtlZ7x7wooE6UO9c7OtlREg4ZBw==
"@tiptap/extension-floating-menu@2.10.4", "@tiptap/extension-floating-menu@^2.10.4":
version "2.10.4"
@@ -5054,25 +5131,25 @@
dependencies:
tippy.js "^6.3.7"
"@tiptap/extension-gapcursor@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz#2e82dd87cb2dfcca90f0abb3b43f1f6748a54e2c"
integrity sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==
"@tiptap/extension-gapcursor@2.10.4", "@tiptap/extension-gapcursor@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.10.4.tgz#697f0f599c5fe90f584e3f94e4866959211c8688"
integrity sha512-KbJfoaqTZePpkWAN+klpK5j0UVtELxN7H5B0J556/UCB/rnq+OsdEFHPks2Ss9TidqWzRUqcxUE50UZ7b8h7Ug==
"@tiptap/extension-hard-break@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz#250200feb316cfb40ed8e9188ee6684c2811b475"
integrity sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==
"@tiptap/extension-hard-break@2.10.4", "@tiptap/extension-hard-break@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.10.4.tgz#57767f35950405c457789faa7b793830860096cc"
integrity sha512-nW9wubW1A/CO2Ssn9wNMP08tR9Oarg9VUGzJ5qNuz38DDNyntE1SyDS+XStkeMq5nKqJ3YKhukyAJH/PiRq4Mg==
"@tiptap/extension-heading@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz#10afd812475c6a3f62a26bd1975998bfa94cb9fb"
integrity sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==
"@tiptap/extension-heading@2.10.4", "@tiptap/extension-heading@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.10.4.tgz#6df708f74f2f2d0716f6e3d32100c29c8be3fe68"
integrity sha512-7D0h0MIvE97Gx3Qwuo2xnPDK07WfCnyh4tpOPBOus4e1g6sgxVkwDwhbkYWiwvIrf4BUVJflnke/DEDCVp6/Eg==
"@tiptap/extension-history@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.27.2.tgz#43c6d976c521dc1cf2d4a0707df7d8328be0e9a9"
integrity sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==
"@tiptap/extension-history@2.10.4", "@tiptap/extension-history@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.10.4.tgz#58e1b819149354538526475896a9904e954d02a6"
integrity sha512-fg6BNxbpMMtgKaiNI/GLcCzkxIQMwSYBhO9LA0CxLvmsWGU+My4r9W3DK6HwNoRJ9+6OleDPSLo1P73fbSTtEA==
"@tiptap/extension-horizontal-rule@2.10.4", "@tiptap/extension-horizontal-rule@^2.10.4":
version "2.10.4"
@@ -5084,10 +5161,10 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-image/-/extension-image-2.10.4.tgz#577a0e9b92be845b91165d29136327c951bb967e"
integrity sha512-fPdAqP4M1zwz5jyrQNIEL4OvvGeJso45svaaBLV342yRLOpbVIgAp/RsuWSGDQTUWoGhdkHdIrbH2bUGNEbMBg==
"@tiptap/extension-italic@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz#91b6ded7b84ed218a8c07ed979332d0dbf923d2b"
integrity sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==
"@tiptap/extension-italic@2.10.4", "@tiptap/extension-italic@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.10.4.tgz#000cd53a5d54efe1c8bfa51a2bd6003268dcd01d"
integrity sha512-8MIQ+wsbyxNCZDCFTVTOXrS2AvFyOhtlBNgVU2+6r6xnJV4AcfEA3qclysqrjOlL117ped/nzDeoB0AeX0CI+Q==
"@tiptap/extension-link@2.10.4":
version "2.10.4"
@@ -5096,45 +5173,70 @@
dependencies:
linkifyjs "^4.1.0"
"@tiptap/extension-list-item@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz#562a8a5f56ed7ac70cd4fab37d7fbcd29e9dc078"
integrity sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==
"@tiptap/extension-list-item@2.10.4", "@tiptap/extension-list-item@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.10.4.tgz#3ea8e597567d448b00ac4d7b0ef9d1b91bc1d306"
integrity sha512-8K3WUD5fPyw2poQKnJGGm7zlfeIbpld92+SRF4M9wkp95EzvgexTlodvxlrL3i8zKXcQQVyExWA8kCcGPFb9bA==
"@tiptap/extension-mention@2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.10.4.tgz#a01856024f948daf2e1f9ed820059f1bdb15ff73"
integrity sha512-pVouKWxSVQSy4zn6HrljPIP1AG826gkm/w18Asi8QnZvR0AMqGLh9q7qd9Kc0j8NKoCzlzK8hECGlYPEaBldow==
"@tiptap/extension-ordered-list@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz#12f2c4309512429a0c21863e741db00356573a4b"
integrity sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==
"@tiptap/extension-ordered-list@2.10.4", "@tiptap/extension-ordered-list@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.10.4.tgz#3da20e5087329a9999837b5860954bd14b14ef60"
integrity sha512-NaeEu+qFG2O0emc8WlwOM7DKNKOaqHWuNkuKrrmQzslgL+UQSEGlGMo6NEJ5sLLckPBDpIa0MuRm30407JE+cg==
"@tiptap/extension-paragraph@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz#e6873c16993bf21b831ecac41bbd137dc5945eb4"
integrity sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==
"@tiptap/extension-paragraph@2.10.4", "@tiptap/extension-paragraph@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.10.4.tgz#e2c94eaee7c69b7c38f98abd50927a8a6811dc49"
integrity sha512-SRNVhT8OXqjpZtcyuOtofbtOpXXFrQrjqqCc/yXebda//2SfUTOvB16Lss77vQOWi6xr7TF1mZuowJgSTkcczw==
"@tiptap/extension-placeholder@2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-placeholder/-/extension-placeholder-2.10.4.tgz#706e30941e3232894db647ab4254e8ba65f54920"
integrity sha512-leWG4xP7cvddR6alGZS7yojOh9941bxehgAeQDLlEisaJcNa2Od5Vbap2zipjc5sXMxZakQVChL27oH1wWhHkQ==
"@tiptap/extension-strike@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz#9291f6dd9bcf00e1c2b7e043f9d9b18cf35f1db1"
integrity sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==
"@tiptap/extension-strike@2.10.4", "@tiptap/extension-strike@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.10.4.tgz#8c450d1cb26f6cb0b79403aed2bf0bd6d1d0cd98"
integrity sha512-OibipsomFpOJWTPVX/z4Z53HgwDA93lE/loHGa+ONJfML1dO6Zd6UTwzaVO1/g8WOwRgwkYu/6JnhxLKRlP8Lg==
"@tiptap/extension-text-style@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz#5f27d512e8421b5160be37aab17c47dde88a8bea"
integrity sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==
"@tiptap/extension-table-cell@2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-cell/-/extension-table-cell-2.10.4.tgz#cdbcc86d54a0f3f3393a73a242098118f48517e9"
integrity sha512-vYwRYt3xPaAU4hxoz3OMGPQzcAxaxEVri6VSRMWg4BN3x4DwWevBTAk59Ho9nkJpaRuXO6c5pIxcwWCZM0Aw0w==
"@tiptap/extension-text@^2.10.4":
version "2.27.2"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.27.2.tgz#8b387a95cef4adb112bfb1ed00a8bc50d9204476"
integrity sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==
"@tiptap/extension-table-header@2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-header/-/extension-table-header-2.10.4.tgz#a7b8177baeca97e075288724b446fb81bdd2bcc4"
integrity sha512-NVi/KMBh9IAzpukjptCsH+gibZB3VxgCc+wuFk41QqI5ABnTPKWflnQ0wRo7IC6wC/tUi4YBahh20dL/wBJn3w==
"@tiptap/extension-table-row@2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table-row/-/extension-table-row-2.10.4.tgz#7106d407e8654eebfe884ffce2a1caf9877034d8"
integrity sha512-kpQQSZQNYHhencIk+lzs+zWtgg6nUXHIVQKZUg5dVT0VP2JNO7wPM6d8HgnscvxOkJNRVF/Q5dYe0Cb4tROIKg==
"@tiptap/extension-table@2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-2.10.4.tgz#a60bb346c0a608f8b81cd6a3a182967d06f1e764"
integrity sha512-ak1RT8n0WQFNnVsZ9e6QFLWlRQP0IjT+Yp/PTsx5fSmqkiiwQKGs1ILCJWlBB3H0hV7N69aaOtK3h/35lmqoEg==
"@tiptap/extension-text-style@2.10.4", "@tiptap/extension-text-style@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text-style/-/extension-text-style-2.10.4.tgz#07af922c266fef7da13cc83befbbd5da512b7402"
integrity sha512-ibq7avkcwHyUSG53Hf+P31rrwsKVbbiqbWZM4kXC7M2X3iUwFrtvaa+SWzyWQfE1jl2cCrD1+rfSkj/alcOKGg==
"@tiptap/extension-text@2.10.4", "@tiptap/extension-text@^2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.10.4.tgz#ca5c7618ec122cfd72399b8c593023cf4a5829d5"
integrity sha512-wPdVxCHrIS9S+8n08lgyyqRZPj9FBbyLlFt74/lV5yBC3LOorq1VKdjrTskmaj4jud7ImXoKDyBddAYTHdJ1xw==
"@tiptap/extension-underline@2.10.4":
version "2.10.4"
resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-2.10.4.tgz#5892a3dc7997818c0bbc0dde4e48b036d6786975"
integrity sha512-KhlCndQFMe/Gsz+3qkVn9z1utDy8y1igvdePijMjA5B8PTu0hPs2Q1d6szfLTBdtoFNkCokknxzXhSY0OFJEyQ==
"@tiptap/html@2.10.4":
version "2.10.4"
@@ -5709,6 +5811,16 @@
expect "^29.0.0"
pretty-format "^29.0.0"
"@types/jsdom@28.0.1":
version "28.0.1"
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-28.0.1.tgz#2c014d8c0eca6135233519bff8c49f7aadfeda63"
integrity sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==
dependencies:
"@types/node" "*"
"@types/tough-cookie" "*"
parse5 "^7.0.0"
undici-types "^7.21.0"
"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.6":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
@@ -6830,6 +6942,13 @@ before-after-hook@^2.2.0:
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c"
integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==
bidi-js@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2"
integrity sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==
dependencies:
require-from-string "^2.0.2"
big-integer@1.6.x, big-integer@^1.6.48:
version "1.6.52"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85"
@@ -8032,6 +8151,14 @@ css-tree@^2.3.1:
mdn-data "2.0.30"
source-map-js "^1.0.1"
css-tree@^3.0.0, css-tree@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-3.2.1.tgz#86cac7011561272b30e6b1e042ba6ce047aa7518"
integrity sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==
dependencies:
mdn-data "2.27.1"
source-map-js "^1.2.1"
css-tree@~2.2.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.2.1.tgz#36115d382d60afd271e377f9c5f67d02bd48c032"
@@ -8163,6 +8290,14 @@ data-uri-to-buffer@^6.0.2:
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz#8a58bb67384b261a38ef18bea1810cb01badd28b"
integrity sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==
data-urls@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-7.0.0.tgz#6dce8b63226a1ecfdd907ce18a8ccfb1eee506d3"
integrity sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==
dependencies:
whatwg-mimetype "^5.0.0"
whatwg-url "^16.0.0"
data-view-buffer@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2"
@@ -8299,6 +8434,11 @@ decimal.js-light@^2.5.1:
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
decimal.js@^10.6.0:
version "10.6.0"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a"
integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==
decode-named-character-reference@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz#25c32ae6dd5e21889549d40f676030e9514cc0ed"
@@ -8763,6 +8903,11 @@ entities@^5.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-5.0.0.tgz#b2ab51fe40d995817979ec79dd621154c3c0f62b"
integrity sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==
entities@^6.0.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694"
integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==
env-paths@^2.2.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2"
@@ -10911,6 +11056,13 @@ hosted-git-info@^7.0.0:
dependencies:
lru-cache "^10.0.1"
html-encoding-sniffer@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz#f8d9390b3b348b50d4f61c16dd2ef5c05980a882"
integrity sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==
dependencies:
"@exodus/bytes" "^1.6.0"
html-entities@^2.5.2:
version "2.6.0"
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.6.0.tgz#7c64f1ea3b36818ccae3d3fb48b6974208e984f8"
@@ -11513,6 +11665,11 @@ is-plain-object@^5.0.0:
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
is-potential-custom-element-name@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
is-promise@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3"
@@ -12294,6 +12451,33 @@ jsdoc@^4.0.0:
strip-json-comments "^3.1.0"
underscore "~1.13.2"
jsdom@29.0.1:
version "29.0.1"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-29.0.1.tgz#b2db17191533dd5ba1e0d4c61fe9fa2289e87be9"
integrity sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==
dependencies:
"@asamuzakjp/css-color" "^5.0.1"
"@asamuzakjp/dom-selector" "^7.0.3"
"@bramus/specificity" "^2.4.2"
"@csstools/css-syntax-patches-for-csstree" "^1.1.1"
"@exodus/bytes" "^1.15.0"
css-tree "^3.2.1"
data-urls "^7.0.0"
decimal.js "^10.6.0"
html-encoding-sniffer "^6.0.0"
is-potential-custom-element-name "^1.0.1"
lru-cache "^11.2.7"
parse5 "^8.0.0"
saxes "^6.0.0"
symbol-tree "^3.2.4"
tough-cookie "^6.0.1"
undici "^7.24.5"
w3c-xmlserializer "^5.0.0"
webidl-conversions "^8.0.1"
whatwg-mimetype "^5.0.0"
whatwg-url "^16.0.1"
xml-name-validator "^5.0.0"
jsesc@^2.5.1:
version "2.5.2"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
@@ -12802,6 +12986,11 @@ lru-cache@^11.0.0:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.2.tgz#40fd37edffcfae4b2940379c0722dc6eeaa75f24"
integrity sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==
lru-cache@^11.2.6, lru-cache@^11.2.7:
version "11.2.7"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.7.tgz#9127402617f34cd6767b96daee98c28e74458d35"
integrity sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==
lru-cache@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
@@ -12930,6 +13119,11 @@ marked-terminal@^7.0.0:
node-emoji "^2.2.0"
supports-hyperlinks "^3.1.0"
marked@17.0.5:
version "17.0.5"
resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.5.tgz#8fc6878a439463a007b05d346d2ad50a87ec3f0e"
integrity sha512-6hLvc0/JEbRjRgzI6wnT2P1XuM1/RrrDEX0kPt0N7jGm1133g6X7DlxFasUIx+72aKAr904GTxhSLDrd5DIlZg==
marked@^13.0.2:
version "13.0.3"
resolved "https://registry.yarnpkg.com/marked/-/marked-13.0.3.tgz#5c5b4a5d0198060c7c9bc6ef9420a7fed30f822d"
@@ -13065,6 +13259,11 @@ mdn-data@2.0.30:
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc"
integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==
mdn-data@2.27.1:
version "2.27.1"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.27.1.tgz#e37b9c50880b75366c4d40ac63d9bbcacdb61f0e"
integrity sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==
mdurl@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
@@ -14376,6 +14575,20 @@ parse5@^6.0.1:
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
parse5@^7.0.0:
version "7.3.0"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05"
integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==
dependencies:
entities "^6.0.0"
parse5@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-8.0.0.tgz#aceb267f6b15f9b6e6ba9e35bfdd481fc2167b12"
integrity sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==
dependencies:
entities "^6.0.0"
parseley@^0.12.0:
version "0.12.1"
resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.12.1.tgz#4afd561d50215ebe259e3e7a853e62f600683aef"
@@ -14492,6 +14705,11 @@ pg-connection-string@^2.11.0:
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.11.0.tgz#5dca53ff595df33ba9db812e181b19909866d10b"
integrity sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==
pg-connection-string@^2.12.0:
version "2.12.0"
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.12.0.tgz#4084f917902bb2daae3dc1376fe24ac7b4eaccf2"
integrity sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==
pg-connection-string@^2.9.1:
version "2.9.1"
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.9.1.tgz#bb1fd0011e2eb76ac17360dc8fa183b2d3465238"
@@ -14527,10 +14745,10 @@ pg-pool@^3.11.0:
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.11.0.tgz#adf9a6651a30c839f565a3cc400110949c473d69"
integrity sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==
pg-pool@^3.12.0:
version "3.12.0"
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.12.0.tgz#798c84ec7d42ba03fff056ebe575daa6e14feab8"
integrity sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==
pg-pool@^3.13.0:
version "3.13.0"
resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.13.0.tgz#416482e9700e8f80c685a6ae5681697a413c13a3"
integrity sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==
pg-promise@12.6.1:
version "12.6.1"
@@ -14542,7 +14760,7 @@ pg-promise@12.6.1:
pg-minify "1.8.0"
spex "4.1.0"
pg-protocol@*, pg-protocol@^1.12.0:
pg-protocol@*:
version "1.12.0"
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.12.0.tgz#e9827f3e1dae6cdcb78d009cba5bb699d88ae998"
integrity sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==
@@ -14557,6 +14775,11 @@ pg-protocol@^1.11.0:
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.11.0.tgz#2502908893edaa1e8c0feeba262dd7b40b317b53"
integrity sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==
pg-protocol@^1.13.0:
version "1.13.0"
resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.13.0.tgz#fdaf6d020bca590d58bb991b4b16fc448efe0511"
integrity sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==
pg-query-stream@4.12.0:
version "4.12.0"
resolved "https://registry.yarnpkg.com/pg-query-stream/-/pg-query-stream-4.12.0.tgz#fd4496d75e5f7c7f233dee0c88e813e165b86b2f"
@@ -14589,13 +14812,13 @@ pg@8.18.0:
pg-cloudflare "^1.3.0"
pg@8.x:
version "8.19.0"
resolved "https://registry.yarnpkg.com/pg/-/pg-8.19.0.tgz#2cb45322471c1ed05786ee7ec09bd91abdfe3eeb"
integrity sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==
version "8.20.0"
resolved "https://registry.yarnpkg.com/pg/-/pg-8.20.0.tgz#1a274de944cb329fd6dd77a6d371a005ba6b136d"
integrity sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==
dependencies:
pg-connection-string "^2.11.0"
pg-pool "^3.12.0"
pg-protocol "^1.12.0"
pg-connection-string "^2.12.0"
pg-pool "^3.13.0"
pg-protocol "^1.13.0"
pg-types "2.2.0"
pgpass "1.0.5"
optionalDependencies:
@@ -15347,7 +15570,7 @@ punycode.js@^2.3.1:
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
punycode@^2.1.0:
punycode@^2.1.0, punycode@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
@@ -16185,6 +16408,13 @@ sax@>=0.6.0, sax@^1.2.4:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f"
integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==
saxes@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5"
integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==
dependencies:
xmlchars "^2.2.0"
scheduler@^0.27.0:
version "0.27.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd"
@@ -17154,6 +17384,11 @@ swagger-ui-express@5.0.1:
dependencies:
swagger-ui-dist ">=5.0.0"
symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
tabbable@^6.0.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.4.0.tgz#36eb7a06d80b3924a22095daf45740dea3bf5581"
@@ -17397,6 +17632,18 @@ tippy.js@^6.3.7:
dependencies:
"@popperjs/core" "^2.9.0"
tldts-core@^7.0.27:
version "7.0.27"
resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.0.27.tgz#4be95bd03b318f2232ea4c1554c4ae9980c77f69"
integrity sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==
tldts@^7.0.5:
version "7.0.27"
resolved "https://registry.yarnpkg.com/tldts/-/tldts-7.0.27.tgz#43c3fc6123eb07a3e12ae1868a9f2d1a5889028c"
integrity sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==
dependencies:
tldts-core "^7.0.27"
tmp@^0.2.1:
version "0.2.3"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae"
@@ -17436,6 +17683,13 @@ touch@^3.1.0:
dependencies:
nopt "~1.0.10"
tough-cookie@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.1.tgz#a495f833836609ed983c19bc65639cfbceb54c76"
integrity sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==
dependencies:
tldts "^7.0.5"
toxic@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/toxic/-/toxic-1.0.1.tgz#8c2e2528da591100adc3883f2c0e56acfb1c7288"
@@ -17443,6 +17697,13 @@ toxic@^1.0.0:
dependencies:
lodash "^4.17.10"
tr46@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-6.0.0.tgz#f5a1ae546a0adb32a277a2278d0d17fa2f9093e6"
integrity sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==
dependencies:
punycode "^2.3.1"
tr46@~0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
@@ -17848,11 +18109,21 @@ underscore@~1.13.2:
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441"
integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==
undici-types@^7.21.0:
version "7.24.6"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91"
integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==
undici-types@~6.21.0:
version "6.21.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
undici@^7.24.5:
version "7.24.6"
resolved "https://registry.yarnpkg.com/undici/-/undici-7.24.6.tgz#b7e977e1bab53d0aab3fa5722fc1efff093aa5e9"
integrity sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"
@@ -18177,6 +18448,13 @@ w3c-keyname@^2.2.0:
resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
w3c-xmlserializer@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c"
integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==
dependencies:
xml-name-validator "^5.0.0"
wait-on@9.0.4:
version "9.0.4"
resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-9.0.4.tgz#ddf3a44ebd18f380621855d52973069ff2cb5b54"
@@ -18228,6 +18506,11 @@ webidl-conversions@^3.0.0:
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
webidl-conversions@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz#0657e571fe6f06fcb15ca50ed1fdbcb495cd1686"
integrity sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==
websocket-driver@>=0.5.1:
version "0.7.4"
resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760"
@@ -18247,6 +18530,20 @@ whatwg-fetch@^3.4.1:
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70"
integrity sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==
whatwg-mimetype@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz#d8232895dbd527ceaee74efd4162008fb8a8cf48"
integrity sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==
whatwg-url@^16.0.0, whatwg-url@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-16.0.1.tgz#047f7f4bd36ef76b7198c172d1b1cebc66f764dd"
integrity sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==
dependencies:
"@exodus/bytes" "^1.11.0"
tr46 "^6.0.0"
webidl-conversions "^8.0.1"
whatwg-url@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
@@ -18494,6 +18791,11 @@ xml-js@^1.6.11:
dependencies:
sax "^1.2.4"
xml-name-validator@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673"
integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==
xml2js@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.5.0.tgz#d9440631fbb2ed800203fad106f2724f62c493b7"
@@ -18520,6 +18822,11 @@ xmlbuilder@~11.0.0:
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
xmlchars@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
xmlcreate@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/xmlcreate/-/xmlcreate-2.0.4.tgz#0c5ab0f99cdd02a81065fa9cd8f8ae87624889be"