@sprig-and-prose/tutorial-svelte 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/kit/Tutorial.svelte +27 -4
- package/src/lib/createTutorialLoad.js +25 -6
- package/src/lib/parse.js +52 -14
- package/src/lib/routes.js +5 -3
package/package.json
CHANGED
package/src/kit/Tutorial.svelte
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
| {
|
|
4
4
|
kind: 'page';
|
|
5
5
|
title: string;
|
|
6
|
+
segmentTitle?: string;
|
|
6
7
|
html: string;
|
|
7
8
|
nav: { previous?: string; next?: string };
|
|
8
9
|
}
|
|
@@ -32,9 +33,13 @@
|
|
|
32
33
|
</script>
|
|
33
34
|
|
|
34
35
|
{#if data.kind === 'page'}
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
<div class="tutorial-page">
|
|
37
|
+
<h1>{data.title}</h1>
|
|
38
|
+
{#if data.segmentTitle}
|
|
39
|
+
<p class="subtitle">{data.segmentTitle}</p>
|
|
40
|
+
{/if}
|
|
41
|
+
{@html data.html}
|
|
42
|
+
<nav class="tutorial-nav">
|
|
38
43
|
{#if data.nav.previous}
|
|
39
44
|
<a href={data.nav.previous} class="nav-link nav-previous">Previous</a>
|
|
40
45
|
{:else}
|
|
@@ -45,7 +50,8 @@
|
|
|
45
50
|
{:else}
|
|
46
51
|
<span class="nav-link nav-next nav-placeholder"></span>
|
|
47
52
|
{/if}
|
|
48
|
-
|
|
53
|
+
</nav>
|
|
54
|
+
</div>
|
|
49
55
|
{:else if data.kind === 'index'}
|
|
50
56
|
<div class="directory-index">
|
|
51
57
|
<h1>Contents</h1>
|
|
@@ -161,5 +167,22 @@
|
|
|
161
167
|
.error-link:hover {
|
|
162
168
|
color: var(--text-secondary);
|
|
163
169
|
}
|
|
170
|
+
|
|
171
|
+
.tutorial-page {
|
|
172
|
+
max-width: 70ch;
|
|
173
|
+
margin: 0 auto;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.tutorial-page h1 {
|
|
177
|
+
margin: 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.tutorial-page .subtitle {
|
|
181
|
+
margin: 0 0 var(--sp-space-12, 3rem) 0;
|
|
182
|
+
font-size: var(--sp-font-body, 1rem);
|
|
183
|
+
line-height: 1.6;
|
|
184
|
+
color: var(--text-color, #111827);
|
|
185
|
+
margin-top: 0;
|
|
186
|
+
}
|
|
164
187
|
</style>
|
|
165
188
|
|
|
@@ -77,13 +77,31 @@ export function createTutorialLoad({ routeBase, modules, renderMarkdown, enableD
|
|
|
77
77
|
content = String(module.default);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
const { pages, hasH1 } = parseSegment(content);
|
|
80
|
+
const { pages, segmentTitle, hasH1, hasH2, h1Count, h2Count } = parseSegment(content);
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
// Validate H1: must be exactly 1
|
|
83
|
+
if (h1Count === 0) {
|
|
84
|
+
return {
|
|
85
|
+
kind: 'error',
|
|
86
|
+
code: 'no_h1',
|
|
87
|
+
message: 'I couldn\'t find a main title. Each tutorial file needs exactly one `#` heading at the top.',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (h1Count > 1) {
|
|
92
|
+
return {
|
|
93
|
+
kind: 'error',
|
|
94
|
+
code: 'multiple_h1',
|
|
95
|
+
message: 'I found multiple main titles. Each tutorial file should have exactly one `#` heading.',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Validate H2: must be 1 or more
|
|
100
|
+
if (!hasH2 || pages.length === 0) {
|
|
83
101
|
return {
|
|
84
102
|
kind: 'error',
|
|
85
103
|
code: 'no_pages',
|
|
86
|
-
message: 'I couldn\'t find any pages in this file yet. Pages start with
|
|
104
|
+
message: 'I couldn\'t find any pages in this file yet. Pages start with `##` (H2) titles.',
|
|
87
105
|
};
|
|
88
106
|
}
|
|
89
107
|
|
|
@@ -97,11 +115,11 @@ export function createTutorialLoad({ routeBase, modules, renderMarkdown, enableD
|
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
const page = pages[resolved.pageNum - 1];
|
|
100
|
-
// Strip the
|
|
101
|
-
// Remove the first line if it's an
|
|
118
|
+
// Strip the H2 heading from content since we render title separately
|
|
119
|
+
// Remove the first line if it's an H2 heading
|
|
102
120
|
let contentToRender = page.content;
|
|
103
121
|
const lines = contentToRender.split('\n');
|
|
104
|
-
if (lines.length > 0 && lines[0].trim().startsWith('
|
|
122
|
+
if (lines.length > 0 && lines[0].trim().startsWith('## ') && !lines[0].trim().startsWith('###')) {
|
|
105
123
|
contentToRender = lines.slice(1).join('\n').replace(/^\s+/, '');
|
|
106
124
|
}
|
|
107
125
|
const html = await renderMarkdown(contentToRender);
|
|
@@ -112,6 +130,7 @@ export function createTutorialLoad({ routeBase, modules, renderMarkdown, enableD
|
|
|
112
130
|
kind: 'page',
|
|
113
131
|
html,
|
|
114
132
|
title: page.title,
|
|
133
|
+
segmentTitle: segmentTitle || '',
|
|
115
134
|
page: resolved.pageNum,
|
|
116
135
|
totalPages: pages.length,
|
|
117
136
|
nav,
|
package/src/lib/parse.js
CHANGED
|
@@ -1,38 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Split markdown content on
|
|
3
|
-
*
|
|
2
|
+
* Split markdown content on H2 headings (## )
|
|
3
|
+
* Validates exactly one H1 heading (# ) and one or more H2 headings
|
|
4
|
+
* Ignores headings inside fenced code blocks (```)
|
|
4
5
|
* @param {string} markdownContent - Raw markdown content
|
|
5
|
-
* @returns {{ pages: Array<{ title: string, content: string }>, hasH1: boolean }}
|
|
6
|
+
* @returns {{ pages: Array<{ title: string, content: string }>, segmentTitle: string | null, hasH1: boolean, hasH2: boolean, h1Count: number, h2Count: number }}
|
|
6
7
|
*/
|
|
7
8
|
export function parseSegment(markdownContent) {
|
|
8
9
|
const lines = markdownContent.split('\n');
|
|
9
|
-
const pages = [];
|
|
10
|
-
let currentPage = null;
|
|
11
|
-
let hasH1 = false;
|
|
12
10
|
let inCodeBlock = false;
|
|
11
|
+
let segmentTitle = null;
|
|
12
|
+
let h1Count = 0;
|
|
13
|
+
let h2Count = 0;
|
|
13
14
|
|
|
15
|
+
// First pass: find and validate H1 (must be exactly 1)
|
|
14
16
|
for (let i = 0; i < lines.length; i++) {
|
|
15
17
|
const line = lines[i];
|
|
16
18
|
const trimmed = line.trim();
|
|
17
19
|
|
|
18
20
|
// Check for fenced code blocks (``` or ~~~)
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
// Must check the raw line (not trimmed) to catch code block fences with leading whitespace
|
|
22
|
+
const rawTrimmed = line.trimStart();
|
|
23
|
+
if (/^[`~]{3,}/.test(rawTrimmed)) {
|
|
22
24
|
inCodeBlock = !inCodeBlock;
|
|
25
|
+
continue;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
//
|
|
28
|
+
// Check for H1 (but not H2) - only if we're not inside a code block
|
|
26
29
|
if (!inCodeBlock && trimmed.startsWith('# ') && !trimmed.startsWith('##')) {
|
|
27
|
-
|
|
30
|
+
h1Count++;
|
|
31
|
+
if (h1Count === 1) {
|
|
32
|
+
// Extract the segment title from the first (and should be only) H1
|
|
33
|
+
segmentTitle = line.replace(/^#\s+/, '').trim();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Second pass: split on H2 headings
|
|
39
|
+
const pages = [];
|
|
40
|
+
let currentPage = null;
|
|
41
|
+
inCodeBlock = false;
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
|
|
47
|
+
// Check for fenced code blocks (``` or ~~~)
|
|
48
|
+
// Must check the raw line (not trimmed) to catch code block fences with leading whitespace
|
|
49
|
+
const rawTrimmed = line.trimStart();
|
|
50
|
+
if (/^[`~]{3,}/.test(rawTrimmed)) {
|
|
51
|
+
inCodeBlock = !inCodeBlock;
|
|
52
|
+
// Still add the code block fence line to content, but don't process it as a heading
|
|
53
|
+
if (currentPage !== null) {
|
|
54
|
+
currentPage.content += line + '\n';
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check for H2 if we're not inside a code block
|
|
60
|
+
if (!inCodeBlock && trimmed.startsWith('## ') && !trimmed.startsWith('###')) {
|
|
61
|
+
h2Count++;
|
|
28
62
|
|
|
29
63
|
// If we have a previous page, save it
|
|
30
64
|
if (currentPage !== null) {
|
|
31
65
|
pages.push(currentPage);
|
|
32
66
|
}
|
|
33
67
|
|
|
34
|
-
// Extract title (remove the
|
|
35
|
-
const title = line.replace(
|
|
68
|
+
// Extract title (remove the ## and trim)
|
|
69
|
+
const title = line.replace(/^##\s+/, '').trim();
|
|
36
70
|
|
|
37
71
|
// Start new page
|
|
38
72
|
currentPage = {
|
|
@@ -57,7 +91,11 @@ export function parseSegment(markdownContent) {
|
|
|
57
91
|
|
|
58
92
|
return {
|
|
59
93
|
pages,
|
|
60
|
-
|
|
94
|
+
segmentTitle,
|
|
95
|
+
hasH1: h1Count === 1,
|
|
96
|
+
hasH2: h2Count > 0,
|
|
97
|
+
h1Count,
|
|
98
|
+
h2Count,
|
|
61
99
|
};
|
|
62
100
|
}
|
|
63
101
|
|
package/src/lib/routes.js
CHANGED
|
@@ -110,10 +110,12 @@ export function generateEntries(modules, routeBase, enableDirectoryIndex) {
|
|
|
110
110
|
content = String(module.default);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
const { pages, hasH1 } = parseSegment(content);
|
|
113
|
+
const { pages, hasH1, hasH2, h1Count } = parseSegment(content);
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
// Skip files that don't meet validation requirements
|
|
116
|
+
// Must have exactly 1 H1 and at least 1 H2
|
|
117
|
+
if (h1Count !== 1 || !hasH2 || pages.length === 0) {
|
|
118
|
+
// Skip files that don't meet the new requirements
|
|
117
119
|
continue;
|
|
118
120
|
}
|
|
119
121
|
|