[READ-ONLY] Mirror of https://github.com/flo-bit/svleek. simple but beautiful documentation generator from markdown
flo-bit.github.io/svleek/
documentation
documentation-generator
markdown
svelte
sveltekit
tailwind
15 kB
423 lines
1import fs from 'fs';
2import path from 'path';
3import { JSDOM } from 'jsdom';
4import markdownit from 'markdown-it';
5import hljs from 'highlight.js'; // https://highlightjs.org
6
7const docsPath = './docs';
8const routesPath = './src/routes/docs';
9const staticPath = './static';
10
11let config;
12const menu = {};
13
14function addClassesToElement(element, classes) {
15 classes.split(' ').forEach((className) => {
16 element.classList.add(className);
17 });
18}
19
20function addAllTailwindClassesToElement(dom) {
21 dom.window.document.querySelectorAll('h1').forEach((element) => {
22 addClassesToElement(
23 element,
24 'mt-16 text-3xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100 sm:text-4xl'
25 );
26 });
27 dom.window.document.querySelectorAll('p').forEach((element) => {
28 addClassesToElement(element, 'mt-6 leading-8');
29 });
30 dom.window.document.querySelectorAll('h2').forEach((element) => {
31 addClassesToElement(
32 element,
33 'mt-16 text-2xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100'
34 );
35 });
36 dom.window.document.querySelectorAll('h3').forEach((element) => {
37 addClassesToElement(element, 'mt-6 text-xl leading-8');
38 });
39 dom.window.document.querySelectorAll('img').forEach((element) => {
40 // add base_url to src, if it doesn't start with http
41 const src = element.getAttribute('src');
42 if (!src.startsWith('http')) element.setAttribute('src', config.base_url + src);
43
44 addClassesToElement(element, 'rounded-xl bg-neutral-50 dark:bg-neutral-950 object-cover');
45 });
46 dom.window.document.querySelectorAll('blockquote').forEach((element) => {
47 addClassesToElement(
48 element,
49 'font-semibold text-neutral-900 dark:text-neutral-100 mt-10 border-l border-accent-600 dark:border-accent-500 pl-9'
50 );
51 });
52 dom.window.document.querySelectorAll('a').forEach((element) => {
53 const href = element.getAttribute('href');
54 if (!href.startsWith('http')) element.setAttribute('href', config.base_url + href);
55
56 addClassesToElement(
57 element,
58 'font-semibold text-accent-600 hover:text-accent-500 dark:text-accent-500 dark:hover:text-accent-400'
59 );
60 });
61
62 dom.window.document.querySelectorAll('ul').forEach((element) => {
63 addClassesToElement(element, 'mt-8 space-y-4 text-neutral-600 dark:text-neutral-400');
64 });
65 dom.window.document.querySelectorAll('ol').forEach((element) => {
66 addClassesToElement(element, 'mt-8 space-y-4 text-neutral-600 dark:text-neutral-400');
67 });
68 dom.window.document.querySelectorAll('ul>li').forEach((element) => {
69 const content = element.innerHTML;
70 // find first :
71 const firstColonIndex = content.indexOf(':');
72 if (firstColonIndex === -1) {
73 element.innerHTML = `<span class="text-neutral-900 dark:text-neutral-100 font-semibold">${content}</span>`;
74 } else {
75 const firstPart = content.substring(0, firstColonIndex);
76 const secondPart = content.substring(firstColonIndex + 1);
77 element.innerHTML = `<span><span class="text-neutral-900 dark:text-neutral-100 font-semibold">${firstPart}</span> ${secondPart}</span>`;
78 }
79 const circle = dom.window.document.createElement('div');
80 circle.setAttribute(
81 'class',
82 'mt-3 h-1.5 w-1.5 flex-none bg-accent-600 dark:bg-accent-500 rounded-full'
83 );
84 circle.setAttribute('aria-hidden', 'true');
85 element.prepend(circle);
86
87 addClassesToElement(element, 'flex gap-x-3 items-start');
88 });
89
90 dom.window.document.querySelectorAll('ol>li').forEach((element) => {
91 const content = element.innerHTML;
92 // find first :
93 const firstColonIndex = content.indexOf(':');
94 if (firstColonIndex === -1) {
95 element.innerHTML = `<span class="text-neutral-900 dark:text-neutral-100 font-semibold">${content}</span>`;
96 } else {
97 const firstPart = content.substring(0, firstColonIndex);
98 const secondPart = content.substring(firstColonIndex + 1);
99 element.innerHTML = `<span><span class="text-neutral-900 dark:text-neutral-100 font-semibold">${firstPart}</span> ${secondPart}</span>`;
100 }
101 const num = dom.window.document.createElement('div');
102 num.setAttribute(
103 'class',
104 'flex-none text-accent-600 dark:text-accent-500 font-semibold text-lg'
105 );
106 num.setAttribute('aria-hidden', 'true');
107 // find out the number
108 const itemsArray = Array.from(element.closest('ol').querySelectorAll('li'));
109
110 // Find the index of the element
111 const number = itemsArray.indexOf(element) + 1;
112 num.innerHTML = number;
113 element.prepend(num);
114
115 addClassesToElement(element, 'flex gap-x-3 items-start');
116 });
117
118 dom.window.document.querySelectorAll('pre').forEach((element) => {
119 // Create a new div element
120 let wrapperDiv = dom.window.document.createElement('div');
121 wrapperDiv.setAttribute('class', 'relative');
122
123 // Insert the new div right before the pre element in the DOM
124 element.parentNode.insertBefore(wrapperDiv, element);
125
126 // Move the pre element inside the new div
127 wrapperDiv.appendChild(element);
128 addClassesToElement(
129 element,
130 'rounded-xl bg-neutral-100 dark:bg-neutral-900 p-4 mt-6 relative overflow-x-scroll'
131 );
132 // add button to copy the code
133 const copyButton = dom.window.document.createElement('button');
134 copyButton.setAttribute(
135 'class',
136 'absolute top-2 right-2 text-xs text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-300'
137 );
138
139 // remove the leading and trailing newlines
140 copyButton.innerHTML = `Copy`;
141 element.setAttribute(
142 'onclick',
143 'navigator.clipboard.writeText(this.querySelector("code").textContent.trim())'
144 );
145 wrapperDiv.appendChild(copyButton);
146 });
147
148 // find blockquotes that start with [!WARNING]
149 dom.window.document.querySelectorAll('blockquote').forEach((element) => {
150 // get first p
151 const firstP = element.querySelector('p');
152 if (firstP?.textContent?.startsWith('[!WARNING]')) {
153 // rename blockquote to warning
154 const warning = dom.window.document.createElement('div');
155 addClassesToElement(
156 warning,
157 'rounded-md bg-yellow-50 dark:bg-yellow-500/5 p-4 dark:ring-1 dark:ring-yellow-500/10 mt-6'
158 );
159 const flex = dom.window.document.createElement('div');
160 warning.appendChild(flex);
161 addClassesToElement(flex, 'flex');
162 const svg = dom.window.document.createElement('svg');
163 flex.appendChild(svg);
164 addClassesToElement(svg, 'h-5 w-5 text-yellow-500 flex-shrink-0');
165 svg.setAttribute('viewBox', '0 0 20 20');
166 svg.setAttribute('fill', 'currentColor');
167 svg.setAttribute('aria-hidden', 'true');
168 const path = dom.window.document.createElement('path');
169 svg.appendChild(path);
170 path.setAttribute('fill-rule', 'evenodd');
171 path.setAttribute(
172 'd',
173 'M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z'
174 );
175 const div = dom.window.document.createElement('div');
176 flex.appendChild(div);
177 addClassesToElement(div, 'ml-3');
178 const h3 = dom.window.document.createElement('h3');
179 div.appendChild(h3);
180 addClassesToElement(h3, 'text-sm font-medium text-yellow-800 dark:text-yellow-400');
181 h3.innerHTML = 'Attention needed';
182 const div2 = dom.window.document.createElement('div');
183 div.appendChild(div2);
184 addClassesToElement(div2, 'mt-2 text-sm text-yellow-700 dark:text-yellow-300');
185 const p = dom.window.document.createElement('p');
186 div2.appendChild(p);
187 p.innerHTML = firstP.textContent.replace('[!WARNING]', '');
188
189 element.parentNode.insertBefore(warning, element);
190 element.remove();
191 }
192 });
193
194 dom.window.document.querySelectorAll('hr').forEach((element) => {
195 addClassesToElement(element, 'mt-8 opacity-10');
196 });
197
198 let allCodeElements = Array.from(dom.window.document.querySelectorAll('code'));
199 let filteredCodeElements = allCodeElements.filter((el) => !el.closest('pre'));
200
201 filteredCodeElements.forEach((element) => {
202 addClassesToElement(element, 'rounded-md bg-neutral-200 dark:bg-neutral-800 p-1');
203 });
204}
205
206function convertMarkdownToHTML(markdown) {
207 const md = markdownit({
208 highlight: function (str, lang) {
209 if (lang && hljs.getLanguage(lang)) {
210 try {
211 str = hljs.highlight(str, { language: lang }).value;
212 // remove the leading and trailing newlines
213 //str = str.replace(/^\n+|\n+$/g, '');
214 return str;
215 } catch (__) {
216 console.log(__);
217 }
218 }
219
220 return ''; // use external default escaping
221 }
222 });
223 const content = md.render(markdown);
224 const dom = new JSDOM(content);
225 addAllTailwindClassesToElement(dom);
226
227 const div1 = dom.window.document.createElement('div');
228 div1.setAttribute('class', 'px-6 py-16 lg:px-8 max-w-[100vw]');
229 const div2 = dom.window.document.createElement('div');
230 div2.setAttribute(
231 'class',
232 'mx-auto max-w-3xl text-base leading-7 text-neutral-700 dark:text-neutral-300'
233 );
234 div1.appendChild(div2);
235 div2.innerHTML = dom.window.document.querySelector('body').innerHTML;
236 dom.window.document.querySelector('body').innerHTML = div1.outerHTML;
237
238 // replace { and } with { and }
239 return dom.window.document
240 .querySelector('body')
241 .innerHTML.replace(/{/g, '{')
242 .replace(/}/g, '}');
243}
244
245function processFile(filePath, destPath) {
246 const markdown = fs.readFileSync(filePath, 'utf-8');
247 const html = convertMarkdownToHTML(markdown);
248 const pageContent = `${html}`;
249 // create the directory if it doesn't exist
250 if (!fs.existsSync(destPath)) {
251 fs.mkdirSync(destPath, { recursive: true });
252 }
253 fs.writeFileSync(`${destPath}/+page.svelte`, pageContent);
254}
255
256function processDirectory(dirPath, destPath, parentMenu = null) {
257 if (!fs.existsSync(destPath)) {
258 fs.mkdirSync(destPath, { recursive: true });
259 }
260
261 const files = fs.readdirSync(dirPath).sort();
262 files.forEach((file) => {
263 const filePath = path.join(dirPath, file);
264 const fileStats = fs.statSync(filePath);
265
266 if (!fileStats.isDirectory() && !file.endsWith('.md')) {
267 return;
268 }
269 // Remove the leading number and hyphen and the trailing .md
270 const fileName = file.replace(/^\d+-/, '').replace(/\.md$/, '');
271 const newDestPath = path.join(destPath, fileName);
272
273 // add to menu
274 const menuEntry = {
275 // replace - with space and turn the first letter to uppercase
276 title: fileName.replace(/-/g, ' ').replace(/^\w/, (c) => c.toUpperCase()),
277 // remove src/routes from the path
278 href: config.base_url + newDestPath.replace(/^src\/routes/, '')
279 };
280 if (parentMenu) {
281 parentMenu.children = parentMenu.children || [];
282 parentMenu.children.push(menuEntry);
283 }
284 if (fileStats.isDirectory()) {
285 processDirectory(filePath, newDestPath, menuEntry);
286 } else {
287 processFile(filePath, newDestPath);
288 }
289 });
290}
291
292function addMenu(menu) {
293 // find let menu = [xyz]; in +layout.svelte and replace it with the menu
294 const layoutPath = './src/routes/docs/+layout.svelte';
295 const layoutContent = fs.readFileSync(layoutPath, 'utf-8');
296 const newLayoutContent = layoutContent.replace(
297 /let menu = \[(\S|\s)*\];/,
298 `let menu = ${JSON.stringify(menu.children, null, 2)};`
299 );
300 fs.writeFileSync(layoutPath, newLayoutContent);
301}
302
303function cleanFolder(folder) {
304 // delete everything except +layout.svelte from target folder
305 const files = fs.readdirSync(folder);
306 files.forEach((file) => {
307 const filePath = path.join(folder, file);
308 const fileStats = fs.statSync(filePath);
309 if (fileStats.isDirectory()) {
310 fs.rmdirSync(filePath, { recursive: true });
311 } else if (file !== '+layout.svelte') {
312 fs.unlinkSync(filePath);
313 }
314 });
315}
316
317function readConfig() {
318 // read from docs/config.json
319 const configPath = docsPath + '/svleek.config.json';
320 const configContent = fs.readFileSync(configPath, 'utf-8');
321 const config = JSON.parse(configContent);
322 return config;
323}
324
325function copyAssets(srcDir, destDir) {
326 // Create destination directory if it doesn't exist
327 if (!fs.existsSync(destDir)) {
328 fs.mkdirSync(destDir, { recursive: true });
329 }
330
331 // Read source directory contents
332 const items = fs.readdirSync(srcDir);
333
334 items.forEach((item) => {
335 const srcPath = path.join(srcDir, item);
336 const destPath = path.join(destDir, item);
337
338 // Check if the item is a directory or file
339 const stat = fs.statSync(srcPath);
340 if (stat.isDirectory()) {
341 // Recursively copy directory
342 copyAssets(srcPath, destPath);
343 } else {
344 // Copy file
345 fs.copyFileSync(srcPath, destPath);
346 }
347 });
348}
349
350function setupStartPage() {
351 const heroScreenshot = config.image;
352 const heroTitle = config.title;
353 const heroSubtitle = config.subtitle;
354 const heroButtonHref = config.first_page;
355 const heroButtonLabel = config.button_label;
356
357 const pagePath = './src/routes/+page.svelte';
358 const pageContent = fs.readFileSync(pagePath, 'utf-8');
359 const newPageContent = pageContent.replace(
360 /<HeroScreenshot [\s|\S]*\/>/,
361 `<HeroScreenshot title="${heroTitle}" subtitle="${heroSubtitle}" image="${
362 config.base_url + heroScreenshot
363 }" buttonHref="${config.base_url + heroButtonHref}" buttonLabel="${heroButtonLabel}"/>`
364 );
365 fs.writeFileSync(pagePath, newPageContent);
366}
367
368function setLogo() {
369 // read logo from ./docs/logo.svg
370 let logo = '';
371 try {
372 const svgPath = docsPath + '/logo.svg';
373 const svgContent = fs.readFileSync(svgPath, 'utf-8');
374 logo = svgContent.replace(/<svg/, '<svg class="{size} fill-accent-600 dark:fill-accent-600"');
375 } catch (e) {
376 console.warn('No logo.svg found in ./docs');
377 }
378
379 // replace <slot></slot> in ./src/lib/Logo.svelte with the logo
380 const logoPath = './src/lib/Logo.svelte';
381 const logoContent = fs.readFileSync(logoPath, 'utf-8');
382
383 let newLogoContent = logoContent.replace(/<slot>[\s|\S]*<\/slot>/, `<slot>${logo}</slot>`);
384
385 // replace <a href="link" with <a href="config.base_url + link"
386 newLogoContent = newLogoContent.replace(/<a href="(.*)">/, `<a href="${config.base_url}$1">`);
387
388 console.log(newLogoContent == logoContent);
389 fs.writeFileSync(logoPath, newLogoContent);
390}
391
392function setGithubLink() {
393 // set link in ./src/lib/Github.svelte
394 // from config.github
395 const githubPath = './src/lib/Github.svelte';
396 const githubContent = fs.readFileSync(githubPath, 'utf-8');
397 const newGithubContent = githubContent.replace(
398 /let link = '.*';/,
399 `let link = '${config.github}';`
400 );
401 fs.writeFileSync(githubPath, newGithubContent);
402}
403
404function setAccentColor() {
405 const tailwindConfigPath = './tailwind.config.js';
406 const tailwindConfigContent = fs.readFileSync(tailwindConfigPath, 'utf-8');
407 const newTailwindConfigContent = tailwindConfigContent.replace(
408 /colors: {\s*accent: colors\..*\s*}\s/,
409 `colors: { accent: colors.${config.accent} } `
410 );
411 fs.writeFileSync(tailwindConfigPath, newTailwindConfigContent);
412}
413
414config = readConfig();
415setAccentColor();
416setGithubLink();
417setLogo();
418cleanFolder(routesPath);
419cleanFolder(staticPath);
420copyAssets(docsPath, staticPath);
421processDirectory(docsPath, routesPath, menu);
422setupStartPage();
423addMenu(menu);