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 more
pull/8453/head
Puru Vijay 1 year ago committed by GitHub
parent 99611ad82e
commit 35e7a852fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,5 +1,6 @@
{
"singleQuote": true,
"printWidth": 100,
"useTabs": true
"useTabs": true,
"trailingComma": "es5"
}

@ -42,7 +42,7 @@
return {
name: file.slice(0, dot),
type: file.slice(dot + 1),
source
source,
};
})
.filter((x) => x.type === 'svelte' || x.type === 'js')
@ -64,7 +64,7 @@
const components = process_example(data.files);
repl.set({
components
components,
});
}
});

@ -14,13 +14,13 @@ const escape_replacements = {
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
"'": '&#39;',
};
const get_escape_replacement = (ch) => escape_replacements[ch];
/**
* @param {string} html
* @param {boolean} encode
* @param {boolean} [encode]
*/
export function escape(html, encode) {
if (encode) {
@ -45,7 +45,7 @@ const prism_languages = {
css: 'css',
diff: 'diff',
ts: 'typescript',
'': ''
'': '',
};
/** @type {Partial<import('marked').Renderer>} */
@ -165,7 +165,7 @@ const default_renderer = {
text(text) {
return text;
}
},
};
/**
@ -179,8 +179,8 @@ export function transform(markdown, renderer = {}) {
// options are global, and merged in confusing ways. You can't do e.g.
// `new Marked(options).parse(markdown)`
...default_renderer,
...renderer
}
...renderer,
},
});
return marked(markdown);

@ -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(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/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[];

@ -6,7 +6,6 @@ export const prerender = true;
const base_dir = '../../site/content/docs/';
/** @type {import('./$types').LayoutServerLoad} */
export function load() {
const sections = fs.readdirSync(base_dir).map((file) => {
const { title } = extract_frontmatter(fs.readFileSync(`${base_dir}/${file}`, 'utf-8')).metadata;

@ -181,7 +181,7 @@
}
.content :global(code) {
padding: 0.4rem;
/* padding: 0.4rem; */
margin: 0 0.2rem;
top: -0.1rem;
background: var(--sk-back-4);

@ -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';
export const prerender = true;
export function load() {
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,
};
}

@ -1,31 +1,32 @@
<script>
import '@sveltejs/site-kit/styles/code.css';
import { browser } from '$app/environment';
import { getContext } from 'svelte';
import Repl from '@sveltejs/repl';
import ScreenToggle from '$lib/components/ScreenToggle.svelte';
import TableOfContents from './_TableOfContents.svelte';
import TableOfContents from './TableOfContents.svelte';
import { browser } from '$app/environment';
import { mapbox_setup, svelteUrl } from '../../../config.js';
import {
mapbox_setup, // needed for context API tutorial
svelteUrl,
} from '../../../config.js';
import '@sveltejs/site-kit/styles/code.css';
/** @type {import('./$types').PageData} */
export let data;
const { sections } = getContext('tutorial');
/** @type {import('@sveltejs/repl').default} */
let repl;
let prev;
let scrollable;
/** @type {Map<string, {
* slug: string,
* section: import('$lib/server/tutorial/types').TutorialSection,
* chapter: import('$lib/server/tutorial/types').Tutorial,
* prev: { slug: string, section: import('$lib/server/tutorial/types').TutorialSection, chapter: import('$lib/server/tutorial/types').Tutorial }
* next?: { slug: string, section: import('$lib/server/tutorial/types').TutorialSection, chapter: import('$lib/server/tutorial/types').Tutorial }
* }>} */
const lookup = new Map();
let width = browser ? window.innerWidth : 1000;
let offset = 0;
sections.forEach((section) => {
data.tutorials_list.forEach((section) => {
section.tutorials.forEach((chapter) => {
const obj = {
slug: chapter.slug,
@ -93,11 +94,11 @@
</script>
<svelte:head>
<title>{selected.section.name} / {selected.chapter.name} • Svelte Tutorial</title>
<title>{selected.section.title} / {selected.chapter.title} • Svelte Tutorial</title>
<meta name="twitter:title" content="Svelte tutorial" />
<meta name="twitter:description" content="{selected.section.name} / {selected.chapter.name}" />
<meta name="Description" content="{selected.section.name} / {selected.chapter.name}" />
<meta name="twitter:description" content="{selected.section.title} / {selected.chapter.title}" />
<meta name="Description" content="{selected.section.title} / {selected.chapter.title}" />
</svelte:head>
<svelte:window bind:innerWidth={width} />
@ -106,10 +107,10 @@
<div class="viewport offset-{offset}">
<div class="tutorial-text">
<div class="table-of-contents">
<TableOfContents {sections} slug={data.slug} {selected} />
<TableOfContents sections={data.tutorials_list} slug={data.slug} {selected} />
</div>
<div class="chapter-markup" bind:this={scrollable}>
<div class="chapter-markup content" bind:this={scrollable}>
{@html data.tutorial.content}
<div class="controls">
@ -152,6 +153,16 @@
{/if}
</div>
<!-- HACK to prerender -->
<p style="display: none;">
{#each data.tutorials_list as { tutorials }}
{#each tutorials as { slug }}
<!-- svelte-ignore a11y-missing-content -->
<a href="/tutorial/{slug}" />
{/each}
{/each}
</p>
<style>
.tutorial-outer {
position: relative;
@ -265,13 +276,27 @@
top: -0.1em;
}
.chapter-markup :global(:where(pre.language-markup)) {
background-color: var(--sk-code-bg);
color: var(--sk-code-base);
.chapter-markup :global(code) {
/* padding: 0.4rem; */
margin: 0 0.2rem;
top: -0.1rem;
background: var(--sk-back-4);
}
.chapter-markup :global(pre) :global(code) {
padding: 0;
margin: 0;
top: 0;
background: transparent;
}
.chapter-markup :global(pre) {
margin: 0 0 2rem 0;
width: 100%;
max-width: var(--sk-line-max-width);
padding: 1rem 1rem;
box-shadow: inset 1px 1px 6px hsla(205.7, 63.6%, 30.8%, 0.06);
border-radius: 0.5rem;
padding: 1rem;
margin: 0 0 1rem;
font-size: 14px;
}
.controls {

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { Icon } from '@sveltejs/site-kit/components';
/** @type {import('$lib/server/tutorial/types').TutorialsList} */
export let sections;
export let slug;
export let selected;
@ -13,7 +14,6 @@
<nav>
<a
rel="prefetch"
aria-label="Previous tutorial step"
class="no-underline"
href="/tutorial/{(selected.prev || selected).slug}"
@ -28,16 +28,16 @@
<span style="position: relative; top: -0.1em; margin: 0 0.5em 0 0"
><Icon name="menu" /></span
>
{selected.section.name} /
{selected.section.title} /
</strong>
{selected.chapter.name}
{selected.chapter.title}
</span>
<select value={slug} on:change={navigate}>
{#each sections as section, i}
<optgroup label="{i + 1}. {section.name}">
<optgroup label="{i + 1}. {section.title}">
{#each section.tutorials as chapter, i}
<option value={chapter.slug}>{String.fromCharCode(i + 97)}. {chapter.name}</option>
<option value={chapter.slug}>{String.fromCharCode(i + 97)}. {chapter.title}</option>
{/each}
</optgroup>
{/each}
@ -45,7 +45,6 @@
</div>
<a
rel="prefetch"
aria-label="Next tutorial step"
class="no-underline"
href="/tutorial/{(selected.next || selected).slug}"
@ -108,6 +107,6 @@
height: 100%;
opacity: 0.0001;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
</style>

@ -1,3 +1,4 @@
// @ts-check
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */

Loading…
Cancel
Save