@@ -217,17 +217,168 @@ const PROFILE_CARD_COLS = [
217217 */
218218const BIO_SNIPPET_CHARS = 600
219219
220+ /** A block whose whole text is bold and no longer than this reads as a heading, not as prose. */
221+ const HEADING_LIKE_CHARS = 80
222+
223+ /** Openers we treat as an aside, mapped to the closer that ends them. */
224+ const BRACKET_PAIRS : Record < string , string > = { '(' : ')' , '[' : ']' , '{' : '}' }
225+
226+ /**
227+ * Leading blocks that are pure scaffolding: a section label the bio itself repeats ("About me"), or
228+ * an editorial note about the document rather than about the person. Matched case-insensitively
229+ * against the block's whole text, so a paragraph merely *starting* with "Note that..." is prose and
230+ * survives.
231+ */
232+ const LABEL_BLOCK = / ^ ( a b o u t m e | s u m m a r y ) \s * [: .] ? $ / i
233+ const META_NOTE_BLOCK = / ^ ( n o t e | n b | d i s c l a i m e r | e d i t | u p d a t e | p s ) \b \s * [: \- – — ] / i
234+
235+ /** Every text node under `node`, in document order. */
236+ const textNodes = ( node : JSONContent ) : JSONContent [ ] =>
237+ node . type === 'text' ? [ node ] : ( node . content ?? [ ] ) . flatMap ( textNodes )
238+
239+ /**
240+ * The block's text with marks flattened. Inline siblings are joined without a separator (a bolded
241+ * word mid-sentence is its own text node); anything else — list items, table cells — gets a space so
242+ * words from different blocks don't run together.
243+ */
244+ const blockText = ( node : JSONContent ) : string => {
245+ if ( node . type === 'text' ) return node . text ?? ''
246+ const children = node . content ?? [ ]
247+ const parts = children . map ( blockText )
248+ const separator = children . every ( ( c ) => c . type === 'text' ) ? '' : ' '
249+ return parts . join ( separator ) . replace ( / \s + / g, ' ' ) . trim ( )
250+ }
251+
252+ /** Whether every non-blank text node in the block carries the bold mark. */
253+ const isAllBold = ( node : JSONContent ) => {
254+ const nodes = textNodes ( node ) . filter ( ( n ) => ( n . text ?? '' ) . trim ( ) )
255+ return nodes . length > 0 && nodes . every ( ( n ) => n . marks ?. some ( ( m ) => m . type === 'bold' ) )
256+ }
257+
258+ /**
259+ * Index of the bracket that closes the one opening `text`, or -1 if `text` doesn't open with a
260+ * bracket / never closes it. Nesting is counted so `[updated (2 Aug)]` closes at the last character.
261+ */
262+ const closingBracketIndex = ( text : string ) => {
263+ const open = text [ 0 ]
264+ const close = BRACKET_PAIRS [ open ]
265+ if ( ! close ) return - 1
266+ let depth = 0
267+ for ( let i = 0 ; i < text . length ; i ++ ) {
268+ if ( text [ i ] === open ) depth ++
269+ else if ( text [ i ] === close && -- depth === 0 ) return i
270+ }
271+ return - 1
272+ }
273+
274+ /** A block that is nothing but a bracketed aside, e.g. "[profile updated 2 Aug 2026]". */
275+ const isBracketedBlock = ( text : string ) => closingBracketIndex ( text ) === text . length - 1
276+
277+ /**
278+ * Drops a bracketed aside that opens the bio inline — the same "[profile updated ...]" preamble, but
279+ * written at the head of the first real paragraph instead of on its own line. Repeats, since bios
280+ * stack them ("[updated 2 Aug] (he/him) Hi, ...").
281+ */
282+ const stripLeadingBrackets = ( text : string ) => {
283+ let out = text . trim ( )
284+ for ( ; ; ) {
285+ const end = closingBracketIndex ( out )
286+ if ( end <= 0 || end === out . length - 1 ) return out
287+ out = out . slice ( end + 1 ) . trim ( )
288+ }
289+ }
290+
291+ /**
292+ * Whether a *leading* block is preliminary — scaffolding a reader skims past to reach the bio. Only
293+ * ever asked about blocks before the first real prose, so a heading deeper in the bio is left alone.
294+ */
295+ const isPreliminaryBlock = ( node : JSONContent , text : string ) =>
296+ ! text ||
297+ node . type === 'heading' ||
298+ isBracketedBlock ( text ) ||
299+ LABEL_BLOCK . test ( text ) ||
300+ META_NOTE_BLOCK . test ( text ) ||
301+ // "1. Introduction" / "Introduction" written as bold prose rather than as a heading node.
302+ ( isAllBold ( node ) && text . length <= HEADING_LIKE_CHARS )
303+
304+ /**
305+ * At most this many leading lines are dropped. A bio that is heading-shaped all the way down is
306+ * more likely to be unusual formatting than to be all preamble, and the snippet should still show
307+ * something from the top of it.
308+ */
309+ const MAX_DROPPED_BLOCKS = 5
310+
311+ /**
312+ * The visual lines of a block, as pseudo-blocks. Bios pasted from a doc routinely arrive as one
313+ * paragraph whose "paragraphs" are `hardBreak` pairs, so the preamble ("[profile updated 2 Aug
314+ * 2026]", "1. Introduction") sits inside the same node as the bio itself — splitting here is what
315+ * lets {@link isPreliminaryBlock} see those lines at all. Blocks without a hard break are their own
316+ * single line.
317+ */
318+ const blockLines = ( node : JSONContent ) : JSONContent [ ] => {
319+ const children = node . content ?? [ ]
320+ if ( ! children . some ( ( c ) => c . type === 'hardBreak' ) ) return [ node ]
321+ const lines : JSONContent [ ] = [ ]
322+ let current : JSONContent [ ] = [ ]
323+ for ( const child of children ) {
324+ if ( child . type === 'hardBreak' ) {
325+ if ( current . length ) lines . push ( { ...node , content : current } )
326+ current = [ ]
327+ } else {
328+ current . push ( child )
329+ }
330+ }
331+ if ( current . length ) lines . push ( { ...node , content : current } )
332+ return lines
333+ }
334+
335+ /**
336+ * The bio with its preamble removed: title headings, "About me" labels, bracketed status notes, and
337+ * editorial notes about the document itself (Richard's date-me doc opens with a parenthetical about
338+ * reading it on Google Docs). Falls back to the untouched document when stripping would leave
339+ * nothing — a bio that is *only* a heading is better shown than blanked.
340+ *
341+ * Scans line by line (see {@link blockLines}) and stops at the first line of real prose, so only a
342+ * leading run is ever dropped and a heading further down the bio is left alone.
343+ */
344+ const stripBioPreamble = ( bio : JSONContent ) => {
345+ const blocks = bio ?. content
346+ if ( ! Array . isArray ( blocks ) ) return bio
347+ let dropped = 0
348+ for ( let i = 0 ; i < blocks . length ; i ++ ) {
349+ const lines = blockLines ( blocks [ i ] )
350+ let start = 0
351+ while (
352+ start < lines . length &&
353+ dropped < MAX_DROPPED_BLOCKS &&
354+ isPreliminaryBlock ( lines [ start ] , blockText ( lines [ start ] ) )
355+ ) {
356+ start ++
357+ dropped ++
358+ }
359+ // Prose found in this block: keep its surviving lines and everything after it.
360+ const kept = lines . slice ( start )
361+ if ( kept . length ) return { ...bio , content : [ ...kept , ...blocks . slice ( i + 1 ) ] }
362+ // The whole block was preamble — carry the drop count into the next one.
363+ }
364+ return bio
365+ }
366+
220367/**
221368 * The card renders `parseJsonContentToText(profile.bio)` clamped to a few lines, so shipping the whole
222369 * rich-text document is wasted. Computed here rather than read from the `bio_text` column: that column
223370 * is built with `string_agg(DISTINCT ...)` for search, which reorders and dedupes the text nodes — fine
224371 * for a tsvector, wrong for something a person reads.
372+ *
373+ * The snippet starts at the first block of actual bio (see {@link stripBioPreamble}) rather than at
374+ * the top of the document, so the few lines a card shows aren't spent on a title or a changelog note.
225375 */
226376const toBioSnippet = ( bio : unknown ) => {
227- const text = parseJsonContentToText ( bio as JSONContent )
228- . replace ( / \s + / g, ' ' )
229- . trim ( )
230- return text . length > BIO_SNIPPET_CHARS ? `${ text . slice ( 0 , BIO_SNIPPET_CHARS ) } …` : text
377+ const content = bio as JSONContent
378+ const stripped = typeof content === 'object' && content ? stripBioPreamble ( content ) : content
379+ const text = stripLeadingBrackets ( parseJsonContentToText ( stripped ) . replace ( / \s + / g, ' ' ) . trim ( ) )
380+ const snippet = text || parseJsonContentToText ( content ) . replace ( / \s + / g, ' ' ) . trim ( )
381+ return snippet . length > BIO_SNIPPET_CHARS ? `${ snippet . slice ( 0 , BIO_SNIPPET_CHARS ) } …` : snippet
231382}
232383
233384let profileCols : any
0 commit comments