Skip to content

Commit 666a992

Browse files
committed
Implement posts table of contents
1 parent 18f5ba1 commit 666a992

File tree

12 files changed

+375
-14
lines changed

12 files changed

+375
-14
lines changed

app/Providers/AppServiceProvider.php

+18-10
Original file line numberDiff line numberDiff line change
@@ -50,30 +50,38 @@ protected function bootCarbon(): void
5050

5151
protected function bootCollections(): void
5252
{
53-
Collection::computed('posts', 'summary', function (Entry $entry) {
54-
if (! isset($entry->content) || ! is_string($entry->content)) {
53+
Collection::computed('posts', 'summary', function (Entry $post) {
54+
if (! isset($post->content) || ! is_string($post->content)) {
5555
return null;
5656
}
5757

58-
$summary = substr($entry->content, 0, strpos($entry->content, '<h2') ?: 0);
58+
$summary = substr($post->content, 0, strpos($post->content, '<h2') ?: 0);
5959
$summary = preg_replace('/<a(\s|>)[^>]*>(.*?)<\/a>/', '$2', $summary) ?: '';
6060
$summary = preg_replace('/<img[^>]*>/', '', $summary);
6161

6262
return $summary;
6363
});
6464

65-
Collection::computed('posts', 'duration', function (Entry $entry) {
66-
if (! isset($entry->content) || ! is_string($entry->content)) {
65+
Collection::computed('posts', 'duration', function (Entry $post) {
66+
if (! isset($post->content) || ! is_string($post->content)) {
6767
return null;
6868
}
6969

70-
$words = str_word_count(strip_tags($entry->content));
70+
$words = str_word_count(strip_tags($post->content));
7171

7272
return round($words / 200);
7373
});
7474

75-
Collection::computed('projects', 'stateClasses', function (Entry $entry) {
76-
switch ($entry->value('state')) {
75+
Collection::computed('posts', 'landmarks', function (Entry $post) {
76+
if (! isset($post->content) || ! is_string($post->content)) {
77+
return null;
78+
}
79+
80+
return parse_landmarks($post->content);
81+
});
82+
83+
Collection::computed('projects', 'stateClasses', function (Entry $project) {
84+
switch ($project->value('state')) {
7785
case 'live':
7886
return 'bg-jade-lighter text-jade-darker';
7987
case 'archived':
@@ -84,8 +92,8 @@ protected function bootCollections(): void
8492
}
8593
});
8694

87-
Collection::computed('projects', 'images', function (Entry $entry) {
88-
$id = $entry->id();
95+
Collection::computed('projects', 'images', function (Entry $project) {
96+
$id = $project->id();
8997

9098
if (! is_string($id)) {
9199
return [];

app/Support/helpers.php

+105
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,108 @@ function sglobal(string $key)
5858
}
5959

6060
}
61+
62+
if (! function_exists('parse_landmarks')) {
63+
64+
function _parse_html_headings(string $html): array
65+
{
66+
preg_match_all('/<h(\d) id="([^"]+)"[^>]*>(.+?)<\/h\d>/sm', $html, $matches);
67+
68+
return array_map(
69+
function ($_, $level, $anchor, $title) {
70+
return (object) [
71+
'title' => trim(_remove_anchor_from_title($title)),
72+
'anchor' => "#$anchor",
73+
'level' => intval($level),
74+
];
75+
},
76+
...$matches
77+
);
78+
}
79+
80+
function _remove_anchor_from_title(string $title): string
81+
{
82+
return preg_replace('/<a href="#[^"]+"[^>]*>(.*?)<\/a>/sm', '$1', $title);
83+
}
84+
85+
function _create_ancestor_landmark($previousLandmark, $header) {
86+
while ($previousLandmark->level !== $header->level) {
87+
$previousLandmark = $previousLandmark->parent;
88+
}
89+
90+
$landmark = (object) [
91+
'level' => $header->level,
92+
'title' => $header->title,
93+
'anchor' => $header->anchor,
94+
'parent' => $previousLandmark->parent,
95+
'children' => [],
96+
];
97+
98+
return tap($landmark, function ($landmark) use ($previousLandmark) {
99+
$previousLandmark->parent->children[] = $landmark;
100+
});
101+
}
102+
103+
function _create_descendant_landmark($previousLandmark, $header) {
104+
while ($previousLandmark->level !== $header->level - 1) {
105+
$childLandmark = (object) [
106+
'level' => $previousLandmark->level + 1,
107+
'parent' => $previousLandmark,
108+
'children' => [],
109+
];
110+
111+
$previousLandmark->children[] = $childLandmark;
112+
$previousLandmark = $childLandmark;
113+
}
114+
115+
$landmark = (object) [
116+
'level' => $header->level,
117+
'title' => $header->title,
118+
'anchor' => $header->anchor,
119+
'parent' => $previousLandmark,
120+
'children' => [],
121+
];
122+
123+
return tap($landmark, function ($landmark) use ($previousLandmark) {
124+
$previousLandmark->children[] = $landmark;
125+
});
126+
}
127+
128+
function _clean_landmark_tree($landmark) {
129+
while ($landmark->level !== 1) {
130+
$landmark = $landmark->parent;
131+
}
132+
133+
_clean_landmark($landmark);
134+
135+
return $landmark;
136+
}
137+
138+
function _clean_landmark($landmark) {
139+
unset($landmark->parent);
140+
141+
if (empty($landmark->children)) {
142+
unset($landmark->children);
143+
} else {
144+
array_map('_clean_landmark', $landmark->children);
145+
}
146+
}
147+
148+
function parse_landmarks(string $html): array
149+
{
150+
$headings = _parse_html_headings($html);
151+
$currentLandmark = (object) [
152+
'level' => 1,
153+
'children' => [],
154+
];
155+
156+
foreach ($headings as $heading) {
157+
$currentLandmark = $currentLandmark->level >= $heading->level
158+
? _create_ancestor_landmark($currentLandmark, $heading)
159+
: _create_descendant_landmark($currentLandmark, $heading);
160+
}
161+
162+
return _clean_landmark_tree($currentLandmark)->children ?? [];
163+
}
164+
165+
}

config/statamic/markdown.php

+6-3
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@
1818
'configs' => [
1919

2020
'default' => [
21-
// 'heading_permalink' => [
22-
// 'symbol' => '#',
23-
// ],
21+
'heading_permalink' => [
22+
'apply_id_to_heading' => true,
23+
'fragment_prefix' => '',
24+
'id_prefix' => '',
25+
'symbol' => '',
26+
],
2427
],
2528

2629
],

phpunit.xml

+3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
colors="true"
66
>
77
<testsuites>
8+
<testsuite name="Unit">
9+
<directory>tests/Unit</directory>
10+
</testsuite>
811
<testsuite name="Feature">
912
<directory>tests/Feature</directory>
1013
</testsuite>

resources/assets/css/main.css

+49
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,52 @@
1414
@theme {
1515
--color-chamaleon: hsl(120, 40%, 80%);
1616
}
17+
18+
@layer components {
19+
h1:has(> .heading-permalink),
20+
h2:has(> .heading-permalink),
21+
h3:has(> .heading-permalink),
22+
h4:has(> .heading-permalink),
23+
h5:has(> .heading-permalink),
24+
h6:has(> .heading-permalink) {
25+
@apply relative;
26+
27+
.heading-permalink {
28+
@apply absolute top-1/2 left-0 hidden size-6 -translate-x-full -translate-y-1/2 pr-1;
29+
30+
&::before {
31+
--clickable-size: 44px;
32+
--clickable-inset-by: min(
33+
0px,
34+
calc((100% - var(--clickable-size)) / 2)
35+
);
36+
37+
content: '';
38+
position: absolute;
39+
top: var(--clickable-inset-by);
40+
left: var(--clickable-inset-by);
41+
right: var(--clickable-inset-by);
42+
bottom: var(--clickable-inset-by);
43+
}
44+
45+
&::after {
46+
@apply inline-block size-6;
47+
48+
content: '';
49+
50+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23888888' d='M17 7h-4v2h4c1.65 0 3 1.35 3 3s-1.35 3-3 3h-4v2h4c2.76 0 5-2.24 5-5s-2.24-5-5-5m-6 8H7c-1.65 0-3-1.35-3-3s1.35-3 3-3h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4zm-3-4h8v2H8z'/%3E%3C/svg%3E");
51+
background-repeat: no-repeat;
52+
background-size: 1em;
53+
}
54+
}
55+
}
56+
57+
h1:has(> .heading-permalink):hover .heading-permalink,
58+
h2:has(> .heading-permalink):hover .heading-permalink,
59+
h3:has(> .heading-permalink):hover .heading-permalink,
60+
h4:has(> .heading-permalink):hover .heading-permalink,
61+
h5:has(> .heading-permalink):hover .heading-permalink,
62+
h6:has(> .heading-permalink):hover .heading-permalink {
63+
@apply block;
64+
}
65+
}

resources/blueprints/collections/posts/post.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ tabs:
1515
type: markdown
1616
display: Content
1717
antlers: true
18+
heading_anchors: true
1819
sidebar:
1920
display: Sidebar
2021
sections:

resources/views/blog/show.blade.php

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
@section('main')
44
<article>
5-
<div class="max-w-readable overflow-hidden">
5+
<div class="max-w-readable">
66
<h1>{{ $title }}</h1>
77

88
<div class="mb-4 flex">
@@ -28,6 +28,11 @@ class="text-blue-darker ml-2 flex items-center text-xs font-normal"
2828
@antlers
2929
{{ content }}
3030
@endantlers
31+
32+
<x-table-of-contents
33+
:title="$title"
34+
:landmarks="$landmarks->value()"
35+
/>
3136
</div>
3237
</article>
3338

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<ul class="m-0 list-none space-y-2 p-0">
2+
@foreach ($landmarks as $landmark)
3+
@isset($landmark->title)
4+
<li class="mb-1">
5+
<a
6+
href="{{ $landmark->anchor }}"
7+
style="margin-left: {{ $landmark->level - 2 }}rem"
8+
@click="close()"
9+
>
10+
{!! $landmark->title !!}
11+
</a>
12+
</li>
13+
@endisset
14+
15+
@isset($landmark->children)
16+
<x-table-of-contents-list :landmarks="$landmark->children" />
17+
@endisset
18+
@endforeach
19+
</ul>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<div
2+
x-data="{
3+
isOpen: false,
4+
progress: 0,
5+
open() {
6+
this.isOpen = true
7+
},
8+
close() {
9+
this.isOpen = false
10+
},
11+
updateProgress(progress) {
12+
const scrollTop =
13+
window.pageYOffset ||
14+
document.documentElement.scrollTop ||
15+
document.body.scrollTop ||
16+
0
17+
18+
this.progress = (
19+
(100 * scrollTop) /
20+
(document.body.clientHeight - window.innerHeight)
21+
).toFixed(2)
22+
},
23+
}"
24+
x-init="updateProgress()"
25+
:style="`--progress: ${progress}%`"
26+
@scroll.document="updateProgress()"
27+
@keydown.escape.document="close()"
28+
>
29+
<aside
30+
class="fixed inset-y-0 left-0 z-40 w-screen -translate-x-full transform overflow-y-auto bg-white px-8 pt-4 shadow-md transition-transform duration-200 md:w-auto"
31+
:class="isOpen ? 'translate-x-0' : '-translate-x-full'"
32+
>
33+
<button
34+
type="button"
35+
aria-label="Close"
36+
class="absolute top-0 right-0 mt-5 mr-4 md:hidden"
37+
@click="close()"
38+
>
39+
<s:partial src="icons/close" class="size-4" />
40+
</button>
41+
<nav
42+
aria-label="Table of contents"
43+
class="[&_a]:no-underline [&_a]:hover:underline [&_a]:focus:underline [&_a][data-current]:underline"
44+
>
45+
<a
46+
href="#main"
47+
class="text-blue-darkest mb-3 block pr-2 text-lg font-semibold md:pr-0"
48+
aria-hidden="true"
49+
@click="close()"
50+
>
51+
{{ $title }}
52+
</a>
53+
54+
<x-table-of-contents-list :$landmarks />
55+
</nav>
56+
</aside>
57+
58+
<div
59+
x-show="isOpen"
60+
x-transition.opacity
61+
class="bg-overlay-dark fixed inset-0 z-10"
62+
@click="close()"
63+
></div>
64+
65+
<button
66+
type="button"
67+
class="group right-[calc(max(0px,(100vw-theme('maxWidth.content'))/2))] fixed top-0 mt-4 mr-4 flex h-12 w-12 items-center justify-center md:mt-16 md:-mr-1 md:h-8 md:w-8"
68+
@click="open()"
69+
>
70+
<div
71+
class="bg-grey-light absolute inset-0 hidden rounded-full group-hover:block"
72+
></div>
73+
<div
74+
class="md:p-125rem [background-image:conic-gradient(theme('colors.blue-darker')_var(--progress),transparent_0)] absolute inset-0 rounded-full p-1"
75+
>
76+
<div
77+
class="group-hover:bg-grey-light h-full w-full rounded-full bg-white"
78+
></div>
79+
</div>
80+
81+
<s:partial src="icons/list-bullet" class="relative size-6 md:size-5" />
82+
</button>
83+
</div>
+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<svg
2+
class="{{ class ?? '' }}"
3+
xmlns="http://www.w3.org/2000/svg"
4+
viewBox="0 0 20 20"
5+
>
6+
<path
7+
d="M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z"
8+
/>
9+
</svg>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<svg
2+
class="{{ class ?? '' }}"
3+
xmlns="http://www.w3.org/2000/svg"
4+
viewBox="0 0 20 20"
5+
>
6+
<path
7+
d="M1 4h2v2H1V4zm4 0h14v2H5V4zM1 9h2v2H1V9zm4 0h14v2H5V9zm-4 5h2v2H1v-2zm4 0h14v2H5v-2z"
8+
/>
9+
</svg>

0 commit comments

Comments
 (0)