mirror of https://github.com/sveltejs/svelte
feat(site-2): Local tutorial (#8427)
* feat(docs): Local tutorial * Refactor some stuff * Better error handling * Fix search imports * Prerender tutorial * try prerendering hack * fix super stupid display hidden bug * Shorten the rendered URL * Shorten URL code even morepull/8453/head
parent
99611ad82e
commit
35e7a852fc
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"useTabs": true
|
"useTabs": true,
|
||||||
|
"trailingComma": "es5"
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,81 @@
|
|||||||
|
// @ts-check
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { extract_frontmatter } from '../markdown/index.js';
|
||||||
|
|
||||||
|
const base = '../../site/content/tutorial/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {import('./types').TutorialData}
|
||||||
|
*/
|
||||||
|
export function get_tutorial_data() {
|
||||||
|
const tutorials = [];
|
||||||
|
|
||||||
|
for (const subdir of fs.readdirSync(base)) {
|
||||||
|
const section = {
|
||||||
|
title: '', // Initialise with empty
|
||||||
|
slug: subdir.split('-').slice(1).join('-'),
|
||||||
|
tutorials: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!(fs.statSync(`${base}/${subdir}`).isDirectory() || subdir.endsWith('meta.json'))) continue;
|
||||||
|
|
||||||
|
if (!subdir.endsWith('meta.json'))
|
||||||
|
section.title = JSON.parse(fs.readFileSync(`${base}/${subdir}/meta.json`, 'utf-8')).title;
|
||||||
|
|
||||||
|
for (const section_dir of fs.readdirSync(`${base}/${subdir}`)) {
|
||||||
|
const match = /\d{2}-(.+)/.exec(section_dir);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const slug = match[1];
|
||||||
|
|
||||||
|
const tutorial_base_dir = `${base}/${subdir}/${section_dir}`;
|
||||||
|
|
||||||
|
// Read the file, get frontmatter
|
||||||
|
const contents = fs.readFileSync(`${tutorial_base_dir}/text.md`, 'utf-8');
|
||||||
|
const { metadata, body } = extract_frontmatter(contents);
|
||||||
|
|
||||||
|
// Get the contents of the apps.
|
||||||
|
const completion_states_data = { initial: [], complete: [] };
|
||||||
|
for (const app_dir of fs.readdirSync(tutorial_base_dir)) {
|
||||||
|
if (!app_dir.startsWith('app-')) continue;
|
||||||
|
|
||||||
|
const app_dir_path = `${tutorial_base_dir}/${app_dir}`;
|
||||||
|
const app_contents = fs.readdirSync(app_dir_path, 'utf-8');
|
||||||
|
|
||||||
|
for (const file of app_contents) {
|
||||||
|
completion_states_data[app_dir === 'app-a' ? 'initial' : 'complete'].push({
|
||||||
|
name: file,
|
||||||
|
type: file.split('.').at(-1),
|
||||||
|
content: fs.readFileSync(`${app_dir_path}/${file}`, 'utf-8'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section.tutorials.push({
|
||||||
|
title: metadata.title,
|
||||||
|
slug,
|
||||||
|
content: body,
|
||||||
|
dir: `${subdir}/${section_dir}`,
|
||||||
|
...completion_states_data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tutorials.push(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tutorials;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('./types').TutorialData} tutorial_data
|
||||||
|
* @returns {import('./types').TutorialsList}
|
||||||
|
*/
|
||||||
|
export function get_tutorial_list(tutorial_data) {
|
||||||
|
return tutorial_data.map((section) => ({
|
||||||
|
title: section.title,
|
||||||
|
tutorials: section.tutorials.map((tutorial) => ({
|
||||||
|
title: tutorial.title,
|
||||||
|
slug: tutorial.slug,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
@ -0,0 +1,89 @@
|
|||||||
|
import { createShikiHighlighter } from 'shiki-twoslash';
|
||||||
|
import { transform } from '../markdown';
|
||||||
|
|
||||||
|
const languages = {
|
||||||
|
bash: 'bash',
|
||||||
|
env: 'bash',
|
||||||
|
html: 'svelte',
|
||||||
|
svelte: 'svelte',
|
||||||
|
sv: 'svelte',
|
||||||
|
js: 'javascript',
|
||||||
|
css: 'css',
|
||||||
|
diff: 'diff',
|
||||||
|
ts: 'typescript',
|
||||||
|
'': '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('./types').TutorialData} tutorial_data
|
||||||
|
* @param {string} slug
|
||||||
|
*/
|
||||||
|
export async function get_parsed_tutorial(tutorial_data, slug) {
|
||||||
|
const tutorial = tutorial_data
|
||||||
|
.find(({ tutorials }) => tutorials.find((t) => t.slug === slug))
|
||||||
|
?.tutorials?.find((t) => t.slug === slug);
|
||||||
|
|
||||||
|
if (!tutorial) return null;
|
||||||
|
|
||||||
|
const body = tutorial.content;
|
||||||
|
|
||||||
|
const highlighter = await createShikiHighlighter({ theme: 'css-variables' });
|
||||||
|
|
||||||
|
const content = transform(body, {
|
||||||
|
/**
|
||||||
|
* @param {string} html
|
||||||
|
*/
|
||||||
|
heading(html) {
|
||||||
|
const title = html
|
||||||
|
.replace(/<\/?code>/g, '')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
return title;
|
||||||
|
},
|
||||||
|
code: (source, language) => {
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
source = source
|
||||||
|
.replace(/^([\-\+])?((?: )+)/gm, (match, prefix = '', spaces) => {
|
||||||
|
if (prefix && language !== 'diff') return match;
|
||||||
|
|
||||||
|
// for no good reason at all, marked replaces tabs with spaces
|
||||||
|
let tabs = '';
|
||||||
|
for (let i = 0; i < spaces.length; i += 4) {
|
||||||
|
tabs += ' ';
|
||||||
|
}
|
||||||
|
return prefix + tabs;
|
||||||
|
})
|
||||||
|
.replace(/\*\\\//g, '*/');
|
||||||
|
|
||||||
|
html = highlighter.codeToHtml(source, { lang: languages[language] });
|
||||||
|
|
||||||
|
html = html
|
||||||
|
.replace(
|
||||||
|
/^(\s+)<span class="token comment">([\s\S]+?)<\/span>\n/gm,
|
||||||
|
(match, intro_whitespace, content) => {
|
||||||
|
// we use some CSS trickery to make comments break onto multiple lines while preserving indentation
|
||||||
|
const lines = (intro_whitespace + content).split('\n');
|
||||||
|
return lines
|
||||||
|
.map((line) => {
|
||||||
|
const match = /^(\s*)(.*)/.exec(line);
|
||||||
|
const indent = (match[1] ?? '').replace(/\t/g, ' ').length;
|
||||||
|
|
||||||
|
return `<span class="token comment wrapped" style="--indent: ${indent}ch">${
|
||||||
|
line ?? ''
|
||||||
|
}</span>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.replace(/\/\*…\*\//g, '…');
|
||||||
|
|
||||||
|
return html;
|
||||||
|
},
|
||||||
|
codespan: (text) => '<code>' + text + '</code>',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...tutorial, content };
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
export type TutorialData = {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
tutorials: {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
dir: string;
|
||||||
|
content: string;
|
||||||
|
initial: { name: string; type: string; content: string }[];
|
||||||
|
complete: { name: string; type: string; content: string }[];
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export interface Tutorial {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TutorialSection {
|
||||||
|
title: string;
|
||||||
|
tutorials: Tutorial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TutorialsList = TutorialSection[];
|
@ -1,6 +0,0 @@
|
|||||||
import { PUBLIC_API_BASE } from '$env/static/public';
|
|
||||||
|
|
||||||
export async function load({ fetch }) {
|
|
||||||
const tutorials = await fetch(`${PUBLIC_API_BASE}/docs/svelte/tutorial`).then((r) => r.json());
|
|
||||||
return { tutorials };
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { setContext } from 'svelte';
|
|
||||||
|
|
||||||
/** @type {import('./$types').PageData} */
|
|
||||||
export let data;
|
|
||||||
|
|
||||||
setContext('tutorial', { sections: data.tutorials });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<slot />
|
|
@ -1,5 +1,7 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
export function load() {
|
export function load() {
|
||||||
throw redirect(301, '/tutorial/basics');
|
throw redirect(301, '/tutorial/basics');
|
||||||
}
|
}
|
@ -1,17 +0,0 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import { PUBLIC_API_BASE } from '$env/static/public';
|
|
||||||
|
|
||||||
export async function load({ fetch, params, setHeaders }) {
|
|
||||||
// TODO: Use local data
|
|
||||||
const tutorial = await fetch(`${PUBLIC_API_BASE}/docs/svelte/tutorial/${params.slug}`);
|
|
||||||
|
|
||||||
if (!tutorial.ok) {
|
|
||||||
throw redirect(301, '/tutorial/basics');
|
|
||||||
}
|
|
||||||
|
|
||||||
setHeaders({
|
|
||||||
'cache-control': 'public, max-age=60',
|
|
||||||
});
|
|
||||||
|
|
||||||
return { tutorial: await tutorial.json(), slug: params.slug };
|
|
||||||
}
|
|
@ -0,0 +1,20 @@
|
|||||||
|
import { get_parsed_tutorial } from '$lib/server/tutorial';
|
||||||
|
import { get_tutorial_data, get_tutorial_list } from '$lib/server/tutorial/get-tutorial-data';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
|
export async function load({ params }) {
|
||||||
|
const tutorial_data = get_tutorial_data();
|
||||||
|
const tutorials_list = get_tutorial_list(tutorial_data);
|
||||||
|
|
||||||
|
const tutorial = await get_parsed_tutorial(tutorial_data, params.slug);
|
||||||
|
|
||||||
|
if (!tutorial) throw error(404);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tutorials_list,
|
||||||
|
tutorial,
|
||||||
|
slug: params.slug,
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in new issue