feat(docs): Local tutorial

pull/8427/head
Puru Vijay 4 years ago
parent 339ea85d55
commit 1959b0f52d

@ -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,94 @@
// @ts-check
import fs from 'node:fs';
import { extract_frontmatter } from '../markdown';
const base = '../../site/content/tutorial/';
/** @typedef {{
* 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 }[]
* }[];
* }[]} TutorialData */
/**
* @returns {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 completionStatesData = { initial: [], complete: [] };
for (const appDir of fs.readdirSync(tutorial_base_dir)) {
if (!appDir.startsWith('app-')) continue;
const appDirPath = `${tutorial_base_dir}/${appDir}`;
const appContents = fs.readdirSync(appDirPath, 'utf-8');
for (const file of appContents) {
completionStatesData[appDir === 'app-a' ? 'initial' : 'complete'].push({
name: file,
type: file.split('.').at(-1),
content: fs.readFileSync(`${appDirPath}/${file}`, 'utf-8'),
});
}
}
section.tutorials.push({
title: metadata.title,
slug,
content: body,
dir: `${subdir}/${section_dir}`,
...completionStatesData,
});
}
tutorials.push(section);
}
return tutorials;
}
/**
* @param {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,97 @@
import * as fs from 'fs';
import * as path from 'path';
import { createShikiHighlighter } from 'shiki-twoslash';
import { extract_frontmatter, transform } from '../markdown';
// import { render, replace_placeholders } from './render.js';
// import { parse_route_id } from '../../../../../../packages/kit/src/utils/routing.js';
import { createHash } from 'crypto';
import { normalizeSlugify } from '../docs';
const base = '../../site/content/tutorial/';
const languages = {
bash: 'bash',
env: 'bash',
html: 'html',
svelte: 'svelte',
sv: 'svelte',
js: 'javascript',
css: 'css',
diff: 'diff',
ts: 'typescript',
'': '',
};
/**
* @param {import('./get-tutorial-data').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,11 @@
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,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';
import fs from 'fs';
const base = '../../site/content/tutorial/';
export async function load({ params }) {
const tutorial_data = get_tutorial_data();
const tutorials_list = get_tutorial_list(tutorial_data);
const tutorial = get_parsed_tutorial(tutorial_data, params.slug);
return {
tutorials_list,
tutorial,
slug: params.slug,
};
// throw error(404);
}

@ -1,31 +1,32 @@
<script>
import '@sveltejs/site-kit/styles/code.css';
import { browser } from '$app/environment';
import { getContext } from 'svelte';
import '@sveltejs/site-kit/styles/code.css';
import Repl from '@sveltejs/repl';
import ScreenToggle from '$lib/components/ScreenToggle.svelte';
import TableOfContents from './_TableOfContents.svelte';
import Repl from '@sveltejs/repl';
import TableOfContents from './TableOfContents.svelte';
import {
mapbox_setup, // needed for context API tutorial
svelteUrl,
} from '../../../config.js';
import { mapbox_setup, svelteUrl } from '../../../config.js';
/** @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">
@ -265,13 +266,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/Icon.svelte';
/** @type {import('$lib/server/tutorial/types').TutorialsList} */
export let sections;
export let slug;
export let selected;
@ -28,16 +29,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}
Loading…
Cancel
Save