1- import { highlightCode , languageClass } from './codeHighlight'
1+ import { highlightCode , languageClass } from './codeHighlight.ts'
2+ import authorEntries from '../Resources/authors.json' with { type : 'json' }
3+
4+ export type ArticleAuthor = {
5+ name : string
6+ url ?: string
7+ }
28
39export type ArticleFrontmatter = {
410 title : string
511 slug : string
612 description : string
713 date : string
14+ author : ArticleAuthor
815 tags ?: string [ ]
916 image ?: string
1017 published ?: boolean
@@ -16,13 +23,38 @@ export type Article = ArticleFrontmatter & {
1623 html : string
1724 excerpt : string
1825 readingTime : number
26+ toc : ArticleHeading [ ]
27+ }
28+
29+ export type ArticleHeading = {
30+ id : string
31+ title : string
32+ level : 2 | 3
33+ }
34+
35+ type AuthorSocial = {
36+ username ?: string
37+ social ?: string
38+ url ?: string
1939}
2040
21- const markdownModules = import . meta. glob ( './content/articles/*.md' , {
22- eager : true ,
23- query : '?raw' ,
24- import : 'default' ,
25- } ) as Record < string , string >
41+ type AuthorEntry = {
42+ name : string
43+ username ?: string
44+ url ?: string
45+ profileUrl ?: string
46+ socials ?: AuthorSocial [ ]
47+ }
48+
49+ const authors = authorEntries as AuthorEntry [ ]
50+
51+ const markdownModules = import . meta. env
52+ ? ( import . meta. glob ( './content/articles/*.md' , {
53+ eager : true ,
54+ query : '?raw' ,
55+ import : 'default' ,
56+ } ) as Record < string , string > )
57+ : { }
2658
2759function parseFrontmatter ( raw : string ) : { frontmatter : Record < string , unknown > ; body : string } {
2860 const normalized = raw . replace ( / \r \n / g, '\n' )
@@ -124,24 +156,116 @@ function isExternalUrl(value: string): boolean {
124156 return / ^ h t t p s ? : \/ \/ / . test ( value )
125157}
126158
127- function renderSafeLink ( label : string , href : string ) : string {
128- const escapedLabel = escapeHtml ( label )
159+ function normalizeAuthorKey ( value : string ) : string {
160+ return value . trim ( ) . replace ( / ^ @ / , '' ) . toLowerCase ( )
161+ }
162+
163+ function authorUrlFor ( author : AuthorEntry ) : string | undefined {
164+ if ( typeof author . url === 'string' ) return author . url
165+ if ( typeof author . profileUrl === 'string' ) return author . profileUrl
166+
167+ const github = author . socials ?. find ( ( social ) => social . social === 'github' )
168+ if ( typeof github ?. url === 'string' ) return github . url
169+ if ( typeof github ?. username === 'string' ) return `https://github.com/${ github . username . replace ( / ^ @ / , '' ) } `
170+ if ( typeof author . username === 'string' ) return `https://github.com/${ author . username . replace ( / ^ @ / , '' ) } `
171+
172+ return undefined
173+ }
174+
175+ function resolveAuthor ( value : unknown , filePath : string ) : ArticleAuthor {
176+ if ( typeof value !== 'string' || ! value . trim ( ) ) {
177+ throw new Error ( `Invalid article author in ${ filePath } ` )
178+ }
179+
180+ const key = normalizeAuthorKey ( value )
181+ const author = authors . find ( ( entry ) => normalizeAuthorKey ( entry . username ?? entry . name ) === key || normalizeAuthorKey ( entry . name ) === key )
129182
183+ if ( ! author ) {
184+ if ( isExternalUrl ( value ) ) return { name : value , url : value }
185+ return { name : value }
186+ }
187+
188+ return {
189+ name : author . name ,
190+ url : authorUrlFor ( author ) ,
191+ }
192+ }
193+
194+ function renderSafeLink ( label : string , href : string ) : string {
130195 if ( ! / ^ ( h t t p s ? : \/ \/ | \/ | \. \/ | \. \. \/ | [ A - Z a - z 0 - 9 / _ - ] ) / . test ( href ) ) {
131- return escapedLabel
196+ return renderInlineText ( label )
132197 }
133198
134199 const escapedHref = escapeHtml ( href )
135200 const target = isExternalUrl ( href ) ? ' target="_blank" rel="noreferrer"' : ''
136- return `<a href="${ escapedHref } "${ target } >${ escapedLabel } </a>`
201+ return `<a href="${ escapedHref } "${ target } >${ renderInlineText ( label ) } </a>`
137202}
138203
139- function renderInlineMarkdown ( text : string ) : string {
204+ function renderInlineText ( text : string ) : string {
140205 return escapeHtml ( text )
141206 . replace ( / ` ( [ ^ ` ] + ) ` / g, '<code>$1</code>' )
142207 . replace ( / \* \* ( [ ^ * ] + ) \* \* / g, '<strong>$1</strong>' )
143208 . replace ( / \* ( [ ^ * ] + ) \* / g, '<em>$1</em>' )
144- . replace ( / \[ ( [ ^ \] ] + ) \] \( ( [ ^ ) \s ] + ) \) / g, ( _match , label : string , href : string ) => renderSafeLink ( label , href ) )
209+ }
210+
211+ function findMarkdownLinkClose ( text : string , startIndex : number ) : number {
212+ let depth = 0
213+
214+ for ( let index = startIndex ; index < text . length ; index += 1 ) {
215+ const character = text [ index ]
216+
217+ if ( character === '(' ) {
218+ depth += 1
219+ continue
220+ }
221+
222+ if ( character === ')' ) {
223+ if ( depth === 0 ) {
224+ return index
225+ }
226+
227+ depth -= 1
228+ }
229+ }
230+
231+ return - 1
232+ }
233+
234+ function renderInlineMarkdown ( text : string ) : string {
235+ let html = ''
236+ let cursor = 0
237+
238+ while ( cursor < text . length ) {
239+ const linkStart = text . indexOf ( '[' , cursor )
240+
241+ if ( linkStart === - 1 ) {
242+ html += renderInlineText ( text . slice ( cursor ) )
243+ break
244+ }
245+
246+ const labelEnd = text . indexOf ( ']' , linkStart + 1 )
247+
248+ if ( labelEnd === - 1 || text [ labelEnd + 1 ] !== '(' ) {
249+ html += renderInlineText ( text . slice ( cursor , linkStart + 1 ) )
250+ cursor = linkStart + 1
251+ continue
252+ }
253+
254+ const hrefStart = labelEnd + 2
255+ const hrefEnd = findMarkdownLinkClose ( text , hrefStart )
256+
257+ if ( hrefEnd === - 1 ) {
258+ html += renderInlineText ( text . slice ( cursor , linkStart + 1 ) )
259+ cursor = linkStart + 1
260+ continue
261+ }
262+
263+ html += renderInlineText ( text . slice ( cursor , linkStart ) )
264+ html += renderSafeLink ( text . slice ( linkStart + 1 , labelEnd ) , text . slice ( hrefStart , hrefEnd ) )
265+ cursor = hrefEnd + 1
266+ }
267+
268+ return html
145269}
146270
147271function humanizeLanguage ( language : string ) : string {
@@ -268,9 +392,25 @@ function renderAdmonition(type: string, title: string, lines: string[]): string
268392 `
269393}
270394
271- function markdownToHtml ( markdown : string ) : string {
395+ function createHeadingId ( title : string , usedIds : Map < string , number > ) : string {
396+ const baseId =
397+ title
398+ . toLowerCase ( )
399+ . replace ( / ` ( [ ^ ` ] + ) ` / g, '$1' )
400+ . replace ( / & [ a - z ] + ; / gi, '' )
401+ . replace ( / [ ^ a - z 0 - 9 ] + / g, '-' )
402+ . replace ( / ^ - + | - + $ / g, '' ) || 'section'
403+ const count = usedIds . get ( baseId ) ?? 0
404+ usedIds . set ( baseId , count + 1 )
405+
406+ return count === 0 ? baseId : `${ baseId } -${ count + 1 } `
407+ }
408+
409+ export function markdownToHtml ( markdown : string ) : { html : string ; toc : ArticleHeading [ ] } {
272410 const lines = markdown . split ( '\n' )
273411 const html : string [ ] = [ ]
412+ const toc : ArticleHeading [ ] = [ ]
413+ const usedHeadingIds = new Map < string , number > ( )
274414 let inList = false
275415 let inCodeBlock = false
276416 let codeLanguage = ''
@@ -378,12 +518,18 @@ function markdownToHtml(markdown: string): string {
378518 }
379519
380520 if ( trimmed . startsWith ( '### ' ) ) {
381- html . push ( `<h3>${ renderInlineMarkdown ( trimmed . slice ( 4 ) ) } </h3>` )
521+ const title = trimmed . slice ( 4 )
522+ const id = createHeadingId ( title , usedHeadingIds )
523+ toc . push ( { id, title, level : 3 } )
524+ html . push ( `<h3 id="${ escapeHtml ( id ) } ">${ renderInlineMarkdown ( title ) } </h3>` )
382525 continue
383526 }
384527
385528 if ( trimmed . startsWith ( '## ' ) ) {
386- html . push ( `<h2>${ renderInlineMarkdown ( trimmed . slice ( 3 ) ) } </h2>` )
529+ const title = trimmed . slice ( 3 )
530+ const id = createHeadingId ( title , usedHeadingIds )
531+ toc . push ( { id, title, level : 2 } )
532+ html . push ( `<h2 id="${ escapeHtml ( id ) } ">${ renderInlineMarkdown ( title ) } </h2>` )
387533 continue
388534 }
389535
@@ -399,7 +545,7 @@ function markdownToHtml(markdown: string): string {
399545 flushCodeBlock ( )
400546 flushAdmonition ( )
401547
402- return html . join ( '\n' )
548+ return { html : html . join ( '\n' ) , toc }
403549}
404550
405551function stripMarkdown ( markdown : string ) : string {
@@ -426,6 +572,7 @@ function assertFrontmatter(frontmatter: Record<string, unknown>, filePath: strin
426572 const slug = frontmatter . slug
427573 const description = frontmatter . description
428574 const date = frontmatter . date
575+ const author = frontmatter . author
429576 const tags = frontmatter . tags
430577 const image = frontmatter . image
431578 const published = frontmatter . published
@@ -441,6 +588,7 @@ function assertFrontmatter(frontmatter: Record<string, unknown>, filePath: strin
441588 slug,
442589 description,
443590 date,
591+ author : resolveAuthor ( author , filePath ) ,
444592 tags : Array . isArray ( tags ) ? tags . filter ( ( tag ) : tag is string => typeof tag === 'string' ) : [ ] ,
445593 image : typeof image === 'string' ? image : undefined ,
446594 published : typeof published === 'boolean' ? published : true ,
@@ -453,12 +601,14 @@ export const articles: Article[] = Object.entries(markdownModules)
453601 . map ( ( [ filePath , raw ] ) => {
454602 const { frontmatter, body } = parseFrontmatter ( raw )
455603 const meta = assertFrontmatter ( frontmatter , filePath )
604+ const rendered = markdownToHtml ( body )
456605
457606 return {
458607 ...meta ,
459608 excerpt : createExcerpt ( body ) ,
460- html : markdownToHtml ( body ) ,
609+ html : rendered . html ,
461610 readingTime : calculateReadingTime ( body ) ,
611+ toc : rendered . toc ,
462612 }
463613 } )
464614 . filter ( ( article ) => article . published && ! article . draft )
0 commit comments