mirror of https://github.com/sveltejs/svelte
feat(site-2): Make blog logic consistent with other content (#8447)
parent
b569018fef
commit
050c1031b7
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,60 @@
|
|||||||
|
// @ts-check
|
||||||
|
import fs from 'fs';
|
||||||
|
import { extract_frontmatter } from '../markdown';
|
||||||
|
|
||||||
|
const BLOG_NAME_REGEX = /^(\d{4}-\d{2}-\d{2})-(.+)\.md$/;
|
||||||
|
const BASE = '../../site/content/blog';
|
||||||
|
|
||||||
|
/** @returns {import('./types').BlogData} */
|
||||||
|
export function get_blog_data(base = BASE) {
|
||||||
|
/** @type {import('./types').BlogData} */
|
||||||
|
const blog_posts = [];
|
||||||
|
|
||||||
|
for (const file of fs.readdirSync(base).reverse()) {
|
||||||
|
if (!BLOG_NAME_REGEX.test(file)) continue;
|
||||||
|
|
||||||
|
const { date, date_formatted, slug } = get_date_and_slug(file);
|
||||||
|
const { metadata, body } = extract_frontmatter(fs.readFileSync(`${base}/${file}`, 'utf-8'));
|
||||||
|
|
||||||
|
blog_posts.push({
|
||||||
|
date,
|
||||||
|
date_formatted,
|
||||||
|
content: body,
|
||||||
|
description: metadata.description,
|
||||||
|
draft: metadata.draft === 'true',
|
||||||
|
slug,
|
||||||
|
title: metadata.title,
|
||||||
|
author: {
|
||||||
|
name: metadata.author,
|
||||||
|
url: metadata.authorURL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return blog_posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {import('./types').BlogData} blog_data */
|
||||||
|
export function get_blog_list(blog_data) {
|
||||||
|
return blog_data.map(({ slug, date, title, description, draft }) => ({
|
||||||
|
slug,
|
||||||
|
date,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
draft
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} filename */
|
||||||
|
function get_date_and_slug(filename) {
|
||||||
|
const match = BLOG_NAME_REGEX.exec(filename);
|
||||||
|
if (!match) throw new Error(`Invalid filename for blog: '${filename}'`);
|
||||||
|
|
||||||
|
const [, date, slug] = match;
|
||||||
|
const [y, m, d] = date.split('-');
|
||||||
|
const date_formatted = `${months[+m - 1]} ${+d} ${y}`;
|
||||||
|
|
||||||
|
return { date, date_formatted, slug };
|
||||||
|
}
|
||||||
|
|
||||||
|
const months = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
|
@ -1,75 +1,96 @@
|
|||||||
import fs from 'fs';
|
// @ts-check
|
||||||
import { extract_frontmatter } from '../markdown';
|
import { createShikiHighlighter } from 'shiki-twoslash';
|
||||||
import { transform } from './marked';
|
import { SHIKI_LANGUAGE_MAP, escape, transform } from '../markdown';
|
||||||
|
|
||||||
const base = '../../site/content/blog';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {import('./types').BlogPostSummary[]}
|
* @param {import('./types').BlogData} blog_data
|
||||||
|
* @param {string} slug
|
||||||
*/
|
*/
|
||||||
export function get_index() {
|
export async function get_processed_blog_post(blog_data, slug) {
|
||||||
return fs
|
const post = blog_data.find((post) => post.slug === slug);
|
||||||
.readdirSync(base)
|
|
||||||
.reverse()
|
|
||||||
.map((file) => {
|
|
||||||
if (!file.endsWith('.md')) return;
|
|
||||||
|
|
||||||
const { date, slug } = get_date_and_slug(file);
|
if (!post) return null;
|
||||||
|
|
||||||
const content = fs.readFileSync(`${base}/${file}`, 'utf-8');
|
const highlighter = await createShikiHighlighter({ theme: 'css-variables' });
|
||||||
const { metadata } = extract_frontmatter(content);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slug,
|
...post,
|
||||||
date,
|
content: transform(post.content, {
|
||||||
title: metadata.title,
|
/**
|
||||||
description: metadata.description,
|
* @param {string} html
|
||||||
draft: !!metadata.draft
|
*/
|
||||||
};
|
heading(html) {
|
||||||
});
|
const title = html
|
||||||
}
|
.replace(/<\/?code>/g, '')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
/**
|
return title;
|
||||||
* @param {string} slug
|
},
|
||||||
* @returns {import('./types').BlogPost}
|
code: (source, language) => {
|
||||||
*/
|
let html = '';
|
||||||
export function get_post(slug) {
|
|
||||||
for (const file of fs.readdirSync(`${base}`)) {
|
|
||||||
if (!file.endsWith('.md')) continue;
|
|
||||||
if (file.slice(11, -3) !== slug) continue;
|
|
||||||
|
|
||||||
const { date, date_formatted } = get_date_and_slug(file);
|
source = source
|
||||||
|
.replace(/^([\-\+])?((?: )+)/gm, (match, prefix = '', spaces) => {
|
||||||
|
if (prefix && language !== 'diff') return match;
|
||||||
|
|
||||||
const content = fs.readFileSync(`${base}/${file}`, 'utf-8');
|
// for no good reason at all, marked replaces tabs with spaces
|
||||||
const { metadata, body } = extract_frontmatter(content);
|
let tabs = '';
|
||||||
|
for (let i = 0; i < spaces.length; i += 4) {
|
||||||
|
tabs += ' ';
|
||||||
|
}
|
||||||
|
return prefix + tabs;
|
||||||
|
})
|
||||||
|
.replace(/\*\\\//g, '*/');
|
||||||
|
|
||||||
return {
|
if (language === 'diff') {
|
||||||
date,
|
const lines = source.split('\n').map((content) => {
|
||||||
date_formatted,
|
let type = null;
|
||||||
title: metadata.title,
|
if (/^[\+\-]/.test(content)) {
|
||||||
description: metadata.description,
|
type = content[0] === '+' ? 'inserted' : 'deleted';
|
||||||
author: {
|
content = content.slice(1);
|
||||||
name: metadata.author,
|
}
|
||||||
url: metadata.authorURL
|
|
||||||
},
|
|
||||||
draft: !!metadata.draft,
|
|
||||||
content: transform(body)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} filename */
|
return {
|
||||||
function get_date_and_slug(filename) {
|
type,
|
||||||
const match = /^(\d{4}-\d{2}-\d{2})-(.+)\.md$/.exec(filename);
|
content: escape(content)
|
||||||
if (!match) throw new Error(`Invalid filename for blog: '${filename}'`);
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const [, date, slug] = match;
|
html = `<pre class="language-diff"><code>${lines
|
||||||
const [y, m, d] = date.split('-');
|
.map((line) => {
|
||||||
const date_formatted = `${months[+m - 1]} ${+d} ${y}`;
|
if (line.type) return `<span class="${line.type}">${line.content}\n</span>`;
|
||||||
|
return line.content + '\n';
|
||||||
|
})
|
||||||
|
.join('')}</code></pre>`;
|
||||||
|
} else {
|
||||||
|
html = highlighter.codeToHtml(source, { lang: SHIKI_LANGUAGE_MAP[language] });
|
||||||
|
|
||||||
return { date, date_formatted, slug };
|
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;
|
||||||
|
|
||||||
const months = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
|
return `<span class="token comment wrapped" style="--indent: ${indent}ch">${
|
||||||
|
line ?? ''
|
||||||
|
}</span>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.replace(/\/\*…\*\//g, '…');
|
||||||
|
}
|
||||||
|
|
||||||
function format_date(date) {}
|
return html;
|
||||||
|
},
|
||||||
|
codespan: (text) => '<code>' + text + '</code>'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -1,187 +0,0 @@
|
|||||||
import PrismJS from 'prismjs';
|
|
||||||
import 'prismjs/components/prism-bash.js';
|
|
||||||
import 'prismjs/components/prism-diff.js';
|
|
||||||
import 'prismjs/components/prism-typescript.js';
|
|
||||||
import 'prism-svelte';
|
|
||||||
import { marked } from 'marked';
|
|
||||||
|
|
||||||
const escape_test = /[&<>"']/;
|
|
||||||
const escape_replace = /[&<>"']/g;
|
|
||||||
const escape_test_no_encode = /[<>"']|&(?!#?\w+;)/;
|
|
||||||
const escape_replace_no_encode = /[<>"']|&(?!#?\w+;)/g;
|
|
||||||
const escape_replacements = {
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": '''
|
|
||||||
};
|
|
||||||
const get_escape_replacement = (ch) => escape_replacements[ch];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} html
|
|
||||||
* @param {boolean} [encode]
|
|
||||||
*/
|
|
||||||
export function escape(html, encode) {
|
|
||||||
if (encode) {
|
|
||||||
if (escape_test.test(html)) {
|
|
||||||
return html.replace(escape_replace, get_escape_replacement);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (escape_test_no_encode.test(html)) {
|
|
||||||
return html.replace(escape_replace_no_encode, get_escape_replacement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prism_languages = {
|
|
||||||
bash: 'bash',
|
|
||||||
env: 'bash',
|
|
||||||
html: 'markup',
|
|
||||||
svelte: 'svelte',
|
|
||||||
js: 'javascript',
|
|
||||||
css: 'css',
|
|
||||||
diff: 'diff',
|
|
||||||
ts: 'typescript',
|
|
||||||
'': ''
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @type {Partial<import('marked').Renderer>} */
|
|
||||||
const default_renderer = {
|
|
||||||
code(code, infostring, escaped) {
|
|
||||||
const lang = (infostring || '').match(/\S*/)[0];
|
|
||||||
|
|
||||||
const prism_language = prism_languages[lang];
|
|
||||||
|
|
||||||
if (prism_language) {
|
|
||||||
const highlighted = PrismJS.highlight(code, PrismJS.languages[prism_language], lang);
|
|
||||||
return `<div class="code-block"><pre class="language-${prism_language}"><code>${highlighted}</code></pre></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
'<div class="code-block"><pre><code>' +
|
|
||||||
(escaped ? code : escape(code, true)) +
|
|
||||||
'</code></pre></div>'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
blockquote(quote) {
|
|
||||||
return '<blockquote>\n' + quote + '</blockquote>\n';
|
|
||||||
},
|
|
||||||
|
|
||||||
html(html) {
|
|
||||||
return html;
|
|
||||||
},
|
|
||||||
|
|
||||||
heading(text, level) {
|
|
||||||
return '<h' + level + '>' + text + '</h' + level + '>\n';
|
|
||||||
},
|
|
||||||
|
|
||||||
hr() {
|
|
||||||
return '<hr>\n';
|
|
||||||
},
|
|
||||||
|
|
||||||
list(body, ordered, start) {
|
|
||||||
const type = ordered ? 'ol' : 'ul',
|
|
||||||
startatt = ordered && start !== 1 ? ' start="' + start + '"' : '';
|
|
||||||
return '<' + type + startatt + '>\n' + body + '</' + type + '>\n';
|
|
||||||
},
|
|
||||||
|
|
||||||
listitem(text) {
|
|
||||||
return '<li>' + text + '</li>\n';
|
|
||||||
},
|
|
||||||
|
|
||||||
checkbox(checked) {
|
|
||||||
return '<input ' + (checked ? 'checked="" ' : '') + 'disabled="" type="checkbox"' + '' + '> ';
|
|
||||||
},
|
|
||||||
|
|
||||||
paragraph(text) {
|
|
||||||
return '<p>' + text + '</p>\n';
|
|
||||||
},
|
|
||||||
|
|
||||||
table(header, body) {
|
|
||||||
if (body) body = '<tbody>' + body + '</tbody>';
|
|
||||||
|
|
||||||
return '<table>\n' + '<thead>\n' + header + '</thead>\n' + body + '</table>\n';
|
|
||||||
},
|
|
||||||
|
|
||||||
tablerow(content) {
|
|
||||||
return '<tr>\n' + content + '</tr>\n';
|
|
||||||
},
|
|
||||||
|
|
||||||
tablecell(content, flags) {
|
|
||||||
const type = flags.header ? 'th' : 'td';
|
|
||||||
const tag = flags.align ? '<' + type + ' align="' + flags.align + '">' : '<' + type + '>';
|
|
||||||
return tag + content + '</' + type + '>\n';
|
|
||||||
},
|
|
||||||
|
|
||||||
// span level renderer
|
|
||||||
strong(text) {
|
|
||||||
return '<strong>' + text + '</strong>';
|
|
||||||
},
|
|
||||||
|
|
||||||
em(text) {
|
|
||||||
return '<em>' + text + '</em>';
|
|
||||||
},
|
|
||||||
|
|
||||||
codespan(text) {
|
|
||||||
return '<code>' + text + '</code>';
|
|
||||||
},
|
|
||||||
|
|
||||||
br() {
|
|
||||||
return '<br>';
|
|
||||||
},
|
|
||||||
|
|
||||||
del(text) {
|
|
||||||
return '<del>' + text + '</del>';
|
|
||||||
},
|
|
||||||
|
|
||||||
link(href, title, text) {
|
|
||||||
if (href === null) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
let out = '<a href="' + escape(href) + '"';
|
|
||||||
if (title) {
|
|
||||||
out += ' title="' + title + '"';
|
|
||||||
}
|
|
||||||
out += '>' + text + '</a>';
|
|
||||||
return out;
|
|
||||||
},
|
|
||||||
|
|
||||||
image(href, title, text) {
|
|
||||||
if (href === null) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
let out = '<img src="' + href + '" alt="' + text + '"';
|
|
||||||
if (title) {
|
|
||||||
out += ' title="' + title + '"';
|
|
||||||
}
|
|
||||||
out += '>';
|
|
||||||
return out;
|
|
||||||
},
|
|
||||||
|
|
||||||
text(text) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} markdown
|
|
||||||
* @param {Partial<import('marked').Renderer>} renderer
|
|
||||||
*/
|
|
||||||
export function transform(markdown, renderer = {}) {
|
|
||||||
marked.use({
|
|
||||||
renderer: {
|
|
||||||
// we have to jump through these hoops because of marked's API design choices —
|
|
||||||
// options are global, and merged in confusing ways. You can't do e.g.
|
|
||||||
// `new Marked(options).parse(markdown)`
|
|
||||||
...default_renderer,
|
|
||||||
...renderer
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return marked(markdown);
|
|
||||||
}
|
|
@ -1,9 +1,9 @@
|
|||||||
import { get_index } from '$lib/server/blog';
|
import { get_blog_data, get_blog_list } from '$lib/server/blog/get-blog-data';
|
||||||
|
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|
||||||
export async function load() {
|
export async function load() {
|
||||||
return {
|
return {
|
||||||
posts: get_index()
|
posts: get_blog_list(get_blog_data())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in new issue