Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
W
www
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
1
Issues
1
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
PLN
www
Commits
0870c645
Commit
0870c645
authored
Oct 07, 2026
by
PLN (Algolia)
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
blog: heading anchors, TOC builder, footnote margin clones in the chain
parent
54fc66b7
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
219 additions
and
2 deletions
+219
-2
blog.js
lib/blog.js
+32
-2
markdown.ts
lib/markdown.ts
+187
-0
No files found.
lib/blog.js
View file @
0870c645
import
fs
from
'fs'
;
import
path
from
'path'
;
import
matter
from
'gray-matter'
;
import
{
renderMarkdown
}
from
'./markdown'
;
import
{
buildTocFromHtml
,
renderMarkdown
}
from
'./markdown'
;
import
{
draftsVisible
,
parseShareId
,
shareTokenOf
}
from
'./content'
;
/**
...
...
@@ -117,6 +117,7 @@ export async function getBlogPost(slug, lang = DEFAULT_LANG) {
const
{
data
,
content
}
=
matter
(
fs
.
readFileSync
(
langFile
(
slug
,
lang_
),
'utf8'
));
const
meta
=
readMeta
(
slug
);
const
contentHtml
=
await
renderMarkdown
(
content
);
return
{
slug
,
...
meta
,
...
...
@@ -126,7 +127,36 @@ export async function getBlogPost(slug, lang = DEFAULT_LANG) {
lang
:
lang_
,
untranslated
:
lang_
===
lang
?
null
:
lang
,
languages
,
contentHtml
:
await
renderMarkdown
(
content
),
contentHtml
,
toc
:
buildTocFromHtml
(
contentHtml
),
};
}
/* ------------------------------------------------------------------ *
* Neighbours — prev/next by date
* ------------------------------------------------------------------ */
/**
* Adjacent posts by date around `slug`, from the visible listing (drafts
* follow the prod/dev rule). `older` is the previous post by date, `newer`
* the next. Links point at the requested language when the target post has
* it, else at the canonical URL. Related posts (post↔project threads) are a
* separate future surface (DESIGN.md /projects) — not folded in here.
*/
export
function
getBlogNeighbors
(
slug
,
lang
=
DEFAULT_LANG
)
{
const
posts
=
getBlogPosts
(
lang
);
const
index
=
posts
.
findIndex
((
post
)
=>
post
.
slug
===
slug
);
if
(
index
===
-
1
)
return
{
older
:
null
,
newer
:
null
};
const
hrefFor
=
(
post
)
=>
lang
!==
DEFAULT_LANG
&&
post
.
languages
.
includes
(
lang
)
?
`/blog/
${
post
.
slug
}
/
${
lang
}
`
:
`/blog/
${
post
.
slug
}
`
;
const
shape
=
(
post
)
=>
({
slug
:
post
.
slug
,
title
:
post
.
title
,
href
:
hrefFor
(
post
)
});
return
{
older
:
index
<
posts
.
length
-
1
?
shape
(
posts
[
index
+
1
])
:
null
,
newer
:
index
>
0
?
shape
(
posts
[
index
-
1
])
:
null
,
};
}
...
...
lib/markdown.ts
View file @
0870c645
...
...
@@ -92,6 +92,191 @@ export function rehypeFigure() {
};
}
/* ------------------------------------------------------------------ *
* Heading anchors + TOC
* ------------------------------------------------------------------ */
/** Recursively collect the text of a hast node (inline markup included). */
function
nodeText
(
node
:
any
):
string
{
if
(
node
.
type
===
'text'
)
return
node
.
value
;
if
(
node
.
type
!==
'element'
)
return
''
;
return
node
.
children
.
map
(
nodeText
).
join
(
''
);
}
/** GitHub-style slug: ASCII-folded, lowercased, hyphenated, deduped. */
function
headingSlug
(
text
:
string
,
seen
:
Map
<
string
,
number
>
):
string
{
const
base
=
text
.
normalize
(
'NFD'
)
.
replace
(
/
[
̀-ͯ
]
/g
,
''
)
// fold diacritics: é → e
.
toLowerCase
()
.
replace
(
/
[^
a-z0-9
\s
_-
]
/g
,
''
)
.
trim
()
.
replace
(
/
\s
+/g
,
'-'
)
||
'section'
;
const
count
=
seen
.
get
(
base
)
??
0
;
seen
.
set
(
base
,
count
+
1
);
return
count
===
0
?
base
:
`
${
base
}
-
${
count
}
`
;
}
/**
* rehype plugin: give h1–h4 stable ids (GitHub-slug style) so headings are
* deep-linkable and the TOC can be read back from the rendered HTML.
*/
export
function
rehypeHeadingIds
()
{
return
(
tree
:
any
)
=>
{
const
seen
=
new
Map
<
string
,
number
>
();
visit
(
tree
,
'element'
,
(
node
:
any
)
=>
{
if
(
!
/^h
[
1-4
]
$/
.
test
(
node
.
tagName
))
return
;
node
.
properties
=
node
.
properties
||
{};
node
.
properties
.
id
=
headingSlug
(
nodeText
(
node
),
seen
);
});
};
}
const
ENTITIES
:
Record
<
string
,
string
>
=
{
'&'
:
'&'
,
'<'
:
'<'
,
'>'
:
'>'
,
'"'
:
'"'
,
'''
:
"'"
,
'&'
:
'&'
,
'''
:
"'"
,
' '
:
' '
,
};
/**
* TOC from rendered HTML — the rendered headings are the single truth, so
* the builder and the page can never drift apart. Reads back the h2/h3 the
* rehypeHeadingIds pass minted; strips inline markup and decodes entities.
*/
export
function
buildTocFromHtml
(
html
:
string
)
{
const
toc
:
{
id
:
string
;
text
:
string
;
depth
:
number
}[]
=
[];
const
re
=
/<h
([
23
])([^
>
]
*
\b
id="
([^
"
]
*
)
"
[^
>
]
*
)
>
([\s\S]
*
?)
<
\/
h
\1
>/g
;
let
match
:
RegExpExecArray
|
null
;
while
((
match
=
re
.
exec
(
html
))
!==
null
)
{
const
text
=
match
[
4
]
.
replace
(
/<
[^
>
]
+>/g
,
''
)
.
replace
(
/&
(
amp|lt|gt|quot|#39|#x26|#x27|nbsp
)
;/g
,
(
e
)
=>
ENTITIES
[
e
]
??
e
)
.
trim
();
toc
.
push
({
id
:
match
[
3
],
text
,
depth
:
Number
(
match
[
1
])
});
}
return
toc
;
}
/* ------------------------------------------------------------------ *
* Footnote margin clones — Tufte-style notes, zero client JS
* ------------------------------------------------------------------ */
/**
* Clone a footnote definition's hast children into margin-note-safe inline
* shape: block elements (p, lists, headings, pre…) become styled spans so
* the clone can live inside a paragraph without the browser's HTML parser
* re-nesting it out. Backref arrows are dropped (meaningless off the
* endnote list); links, emphasis and images pass through.
*/
function
inlineSafe
(
node
:
any
):
any
{
if
(
node
.
type
===
'text'
)
return
node
;
if
(
node
.
type
===
'comment'
)
return
null
;
if
(
node
.
type
!==
'element'
)
return
node
;
if
(
node
.
tagName
===
'a'
&&
node
.
properties
?.
dataFootnoteBackref
!=
null
)
{
return
null
;
}
const
span
=
(
className
:
string
,
children
:
any
[])
=>
({
type
:
'element'
,
tagName
:
'span'
,
properties
:
{
className
:
[
className
]
},
children
,
});
const
flat
=
(
children
:
any
[])
=>
children
.
flatMap
((
child
)
=>
{
const
converted
=
inlineSafe
(
child
);
return
converted
===
null
?
[]
:
[
converted
];
});
if
(
/^
(
p|div|blockquote|h
[
1-6
])
$/
.
test
(
node
.
tagName
))
{
return
span
(
'prose-fn-block'
,
flat
(
node
.
children
));
}
if
(
node
.
tagName
===
'ul'
||
node
.
tagName
===
'ol'
)
{
return
span
(
'prose-fn-block'
,
node
.
children
.
flatMap
((
child
:
any
)
=>
child
.
type
===
'element'
&&
child
.
tagName
===
'li'
?
[
span
(
'prose-fn-item'
,
flat
(
child
.
children
))]
:
flat
([
child
]),
),
);
}
if
(
node
.
tagName
===
'li'
)
{
return
span
(
'prose-fn-item'
,
flat
(
node
.
children
));
}
if
(
node
.
tagName
===
'pre'
)
{
const
code
=
{
type
:
'element'
,
tagName
:
'code'
,
properties
:
{
className
:
[
'prose-fn-code'
]
},
children
:
flat
(
node
.
children
),
};
return
span
(
'prose-fn-block'
,
[
code
]);
}
return
{
...
node
,
children
:
flat
(
node
.
children
)
};
}
/**
* rehype plugin: after remark-rehype has minted the GFM footnote markup,
* clone each definition inline next to its reference. Structure:
* `<span class="prose-fn"><sup>…ref…</sup><span class="prose-fn-body"
* aria-hidden="true">…note…</span></span>`. The body span is display:none by
* default; CSS promotes it to a true margin note on wide screens and a
* hover/focus popover in between. The endnote section stays — it is the
* no-JS and mobile target, and the screen-reader source of truth (the
* inline clones are aria-hidden).
*/
export
function
rehypeMarginNotes
()
{
return
(
tree
:
any
)
=>
{
const
defs
=
new
Map
<
string
,
any
>
();
visit
(
tree
,
'element'
,
(
node
:
any
)
=>
{
if
(
node
.
tagName
!==
'li'
)
return
;
const
id
=
node
.
properties
?.
id
;
if
(
typeof
id
===
'string'
&&
id
.
startsWith
(
'user-content-fn-'
))
{
defs
.
set
(
id
,
node
);
}
});
if
(
defs
.
size
===
0
)
return
;
visit
(
tree
,
'element'
,
(
node
:
any
,
index
:
number
,
parent
:
any
)
=>
{
if
(
node
.
tagName
!==
'sup'
||
index
===
null
||
!
parent
)
return
;
const
anchor
=
node
.
children
.
find
(
(
child
:
any
)
=>
child
.
type
===
'element'
&&
child
.
tagName
===
'a'
&&
child
.
properties
?.
dataFootnoteRef
!=
null
,
);
if
(
!
anchor
)
return
;
const
def
=
defs
.
get
(
String
(
anchor
.
properties
.
href
).
slice
(
1
));
if
(
!
def
)
return
;
parent
.
children
[
index
]
=
{
type
:
'element'
,
tagName
:
'span'
,
properties
:
{
className
:
[
'prose-fn'
]
},
children
:
[
node
,
{
type
:
'element'
,
tagName
:
'span'
,
properties
:
{
className
:
[
'prose-fn-body'
],
ariaHidden
:
'true'
},
children
:
def
.
children
.
flatMap
((
child
:
any
)
=>
{
const
converted
=
inlineSafe
(
child
);
return
converted
===
null
?
[]
:
[
converted
];
}),
},
],
};
});
};
}
const
processor
=
unified
()
.
use
(
remarkParse
)
.
use
(
remarkGfm
)
...
...
@@ -99,6 +284,8 @@ const processor = unified()
.
use
(
remarkDirectiveTags
)
.
use
(
remarkRehype
)
.
use
(
rehypeFigure
)
.
use
(
rehypeHeadingIds
)
.
use
(
rehypeMarginNotes
)
// Dark theme on a dark pre (background comes from the blog CSS module —
// keepBackground: false keeps the pipeline's HTML free of hardcoded hex).
.
use
(
rehypePrettyCode
,
{
theme
:
'vesper'
,
keepBackground
:
false
})
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment