Skip to content

Commit c0434b4

Browse files
Add first article
1 parent c303a4a commit c0434b4

12 files changed

Lines changed: 4790 additions & 1971 deletions

File tree

Assets/images/header_ident.svg

Lines changed: 0 additions & 73 deletions
This file was deleted.

public/images/header_ident.svg

Lines changed: 0 additions & 73 deletions
This file was deleted.
88.9 KB
Loading
77.9 KB
Loading
474 KB
Loading

public/sitemap.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
<changefreq>monthly</changefreq>
2626
<priority>0.5</priority>
2727
</url>
28+
<url>
29+
<loc>https://adaengine.org/articles/introducing-adaengine-0-1-0</loc>
30+
<lastmod>2026-06-1 13:36</lastmod>
31+
<changefreq>monthly</changefreq>
32+
<priority>0.7</priority>
33+
</url>
2834
<url>
2935
<loc>https://adaengine.org/demos/bunnies-stress-example</loc>
3036
<lastmod>2026-05-31</lastmod>

src/content.ts

Lines changed: 167 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
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

39
export 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

2759
function 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 /^https?:\/\//.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 (!/^(https?:\/\/|\/|\.\/|\.\.\/|[A-Za-z0-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

147271
function 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-z0-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

405551
function 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

Comments
 (0)