@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprig-and-prose/tutorial-svelte",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "A calm SvelteKit package that transforms markdown files into paged tutorial routes",
6
6
  "main": "src/index.js",
@@ -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
- <h1>{data.title}</h1>
36
- {@html data.html}
37
- <nav class="tutorial-nav">
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
- </nav>
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
- if (!hasH1 || pages.length === 0) {
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 `#` (H1) titles.',
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 H1 heading from content since we render title separately
101
- // Remove the first line if it's an H1 heading
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('# ') && !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 H1 headings (# )
3
- * Ignores H1 headings inside fenced code blocks (```)
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
- // Match 3 or more backticks or tildes at the start of the line
20
- if (/^[`~]{3,}/.test(trimmed)) {
21
- // Toggle code block state
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
- // Only check for H1 if we're not inside a code block
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
- hasH1 = true;
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 # and trim)
35
- const title = line.replace(/^#\s+/, '').trim();
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
- hasH1,
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
- if (!hasH1 || pages.length === 0) {
116
- // Skip files with no H1 headings
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