2022-12-18 update
- increased text size a bit - removed unused css from kbd tag - removed unused white link css - added space before all headers except h1 - moved type decclaration files to one directory - replaced tag icon to hashtag - redesigned post card - removed unnecessary margin from post card titles - removed post card content preview - organized recursive parsing code
This commit is contained in:
parent
823f599dc0
commit
b43094fef9
21 changed files with 455 additions and 421 deletions
|
@ -6,13 +6,14 @@
|
||||||
* - series must start with a number followed by an underscore
|
* - series must start with a number followed by an underscore
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from "fs" // read and write files
|
import fs from "fs"
|
||||||
|
|
||||||
import { Map, SeriesMap } from "./types"
|
|
||||||
import { recursiveParse } from "./recursiveParse"
|
import { recursiveParse } from "./recursiveParse"
|
||||||
import { contentDirectoryPath, mapFilePath, markdownPath } from "./config"
|
import { contentDirectoryPath, mapFilePath, markdownPath } from "./config"
|
||||||
import { saveIndex } from "./searchIndex"
|
import { saveIndex } from "./searchIndex"
|
||||||
|
|
||||||
|
import { Map, ParseMode, SeriesMap } from "../types/typing"
|
||||||
|
|
||||||
// searchable data that will be converted to JSON string
|
// searchable data that will be converted to JSON string
|
||||||
export const map: Map = {
|
export const map: Map = {
|
||||||
date: {},
|
date: {},
|
||||||
|
@ -53,9 +54,9 @@ if (!fs.lstatSync(markdownPath + "/unsearchable").isDirectory())
|
||||||
if (!fs.lstatSync(markdownPath + "/series").isDirectory())
|
if (!fs.lstatSync(markdownPath + "/series").isDirectory())
|
||||||
throw Error(`Cannot find directory: ${markdownPath + "/posts"}`)
|
throw Error(`Cannot find directory: ${markdownPath + "/posts"}`)
|
||||||
|
|
||||||
recursiveParse("posts", markdownPath + "/posts")
|
recursiveParse(ParseMode.POSTS, markdownPath + "/posts")
|
||||||
recursiveParse("unsearchable", markdownPath + "/unsearchable")
|
recursiveParse(ParseMode.UNSEARCHABLE, markdownPath + "/unsearchable")
|
||||||
recursiveParse("series", markdownPath + "/series")
|
recursiveParse(ParseMode.SERIES, markdownPath + "/series")
|
||||||
|
|
||||||
// sort dates
|
// sort dates
|
||||||
let dateKeys: string[] = []
|
let dateKeys: string[] = []
|
||||||
|
|
|
@ -28,7 +28,7 @@ const md = markdownIt({
|
||||||
})
|
})
|
||||||
.use(markdownItAnchor, {})
|
.use(markdownItAnchor, {})
|
||||||
|
|
||||||
export function parseMarkdown(markdownRaw: string): string {
|
export default function parseMarkdown(markdownRaw: string): string {
|
||||||
return (
|
return (
|
||||||
md.render(markdownRaw.slice(nthIndex(markdownRaw, "---", 2) + 3)) || ""
|
md.render(markdownRaw.slice(nthIndex(markdownRaw, "---", 2) + 3)) || ""
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,287 +5,352 @@ import toc from "markdown-toc" // table of contents generation
|
||||||
import { JSDOM } from "jsdom" // HTML DOM parsing
|
import { JSDOM } from "jsdom" // HTML DOM parsing
|
||||||
|
|
||||||
import { nthIndex, path2FileOrFolderName, path2URL, writeToJSON } from "./util"
|
import { nthIndex, path2FileOrFolderName, path2URL, writeToJSON } from "./util"
|
||||||
import { parseMarkdown } from "./parseMarkdown"
|
import parseMarkdown from "./parseMarkdown"
|
||||||
|
|
||||||
import { contentDirectoryPath } from "./config"
|
import { contentDirectoryPath } from "./config"
|
||||||
import { addDocument } from "./searchIndex"
|
import { addDocument } from "./searchIndex"
|
||||||
import { map, seriesMap } from "."
|
import { map, seriesMap } from "."
|
||||||
|
|
||||||
// A recursive function that calls itself for every files and directories that it finds
|
import { MarkdownData, ParseMode, PostData } from "../types/typing"
|
||||||
export function recursiveParse(
|
|
||||||
mode: "posts" | "series" | "unsearchable",
|
// path that should not be checked when parsing in unsearchable mode
|
||||||
fileOrFolderPath: string
|
const illegalPaths = [
|
||||||
) {
|
"./markdown/unsearchable/posts",
|
||||||
if (mode == "unsearchable") {
|
"./markdown/unsearchable/series",
|
||||||
// illegal names
|
]
|
||||||
if (
|
|
||||||
fileOrFolderPath == "./markdown/unsearchable/posts" ||
|
interface DataToPass {
|
||||||
fileOrFolderPath == "./markdown/unsearchable/series"
|
path: string
|
||||||
)
|
urlPath: string
|
||||||
throw Error(
|
fileOrFolderName: string
|
||||||
`Illegal name (posts/series) in path: "${fileOrFolderPath}".`
|
markdownRaw: string
|
||||||
)
|
markdownData: MarkdownData
|
||||||
|
humanizedDuration: string
|
||||||
|
totalWords: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recursive function that calls itself for every files and directories that it finds
|
||||||
|
*
|
||||||
|
* @param {ParseMode} mode
|
||||||
|
* @param {string} path - path of file or folder
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function recursiveParse(mode: ParseMode, path: string): void {
|
||||||
|
// don't parse specific directories when parsing unsearchable content
|
||||||
|
if (mode == ParseMode.UNSEARCHABLE) {
|
||||||
|
if (illegalPaths.includes(path)) {
|
||||||
|
throw Error(`Illegal name (posts/series) in path: "${path}".`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// get string after the last slash character
|
const fileOrFolderName = path2FileOrFolderName(path)
|
||||||
const fileOrFolderName = path2FileOrFolderName(fileOrFolderPath)
|
|
||||||
|
|
||||||
// ignore if file or directory name starts with a underscore
|
|
||||||
if (fileOrFolderName.startsWith("_")) return
|
if (fileOrFolderName.startsWith("_")) return
|
||||||
|
|
||||||
// get data about the given path
|
const stats = fs.lstatSync(path)
|
||||||
const stats = fs.lstatSync(fileOrFolderPath)
|
|
||||||
|
|
||||||
// if it's a directory, call this function to every files/directories in it
|
// if it's a directory, call this function to every files/directories in it
|
||||||
// if it's a file, parse it and then save it to file
|
// if it's a file, parse it and then save it to file
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
fs.readdirSync(fileOrFolderPath).map((childPath) => {
|
fs.readdirSync(path).map((childPath) => {
|
||||||
recursiveParse(mode, `${fileOrFolderPath}/${childPath}`)
|
recursiveParse(mode, `${path}/${childPath}`)
|
||||||
})
|
})
|
||||||
} else if (stats.isFile()) {
|
} else if (stats.isFile()) {
|
||||||
// skip if it is not a markdown file
|
parseFile(mode, path, fileOrFolderName)
|
||||||
if (!fileOrFolderName.endsWith(".md")) {
|
}
|
||||||
console.log(`Ignoring non markdown file at: ${fileOrFolderPath}`)
|
}
|
||||||
return
|
|
||||||
|
function parseFile(
|
||||||
|
mode: ParseMode,
|
||||||
|
path: string,
|
||||||
|
fileOrFolderName: string
|
||||||
|
): void {
|
||||||
|
// skip if it is not a markdown file
|
||||||
|
if (!fileOrFolderName.endsWith(".md")) {
|
||||||
|
console.log(`Ignoring non markdown file at: ${path}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownRaw = fs.readFileSync(path, "utf8")
|
||||||
|
const markdownData: MarkdownData = parseFrontMatter(markdownRaw, path, mode)
|
||||||
|
|
||||||
|
// https://github.com/pritishvaidya/read-time-estimate
|
||||||
|
const { humanizedDuration, totalWords } = readTimeEstimate(
|
||||||
|
markdownData.content,
|
||||||
|
275,
|
||||||
|
12,
|
||||||
|
500,
|
||||||
|
["img", "Image"]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dataToPass: DataToPass = {
|
||||||
|
path,
|
||||||
|
urlPath: path2URL(path),
|
||||||
|
fileOrFolderName,
|
||||||
|
markdownRaw,
|
||||||
|
markdownData,
|
||||||
|
humanizedDuration,
|
||||||
|
totalWords,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case ParseMode.POSTS: {
|
||||||
|
parsePost(dataToPass)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// read markdown file
|
case ParseMode.UNSEARCHABLE: {
|
||||||
const markdownRaw = fs.readFileSync(fileOrFolderPath, "utf8")
|
dataToPass.urlPath = dataToPass.urlPath.slice(
|
||||||
|
dataToPass.urlPath
|
||||||
// parse markdown metadata
|
|
||||||
const markdownData = matter(
|
|
||||||
markdownRaw.slice(0, nthIndex(markdownRaw, "---", 2) + 3)
|
|
||||||
).data
|
|
||||||
|
|
||||||
if (!markdownData.title)
|
|
||||||
throw Error(`Title is not defined in file: ${fileOrFolderPath}`)
|
|
||||||
|
|
||||||
const dom = new JSDOM(parseMarkdown(markdownRaw))
|
|
||||||
|
|
||||||
// add .hljs to all block codes
|
|
||||||
dom.window.document.querySelectorAll("pre > code").forEach((item) => {
|
|
||||||
item.classList.add("hljs")
|
|
||||||
})
|
|
||||||
|
|
||||||
markdownData.content = dom.window.document.documentElement.innerHTML
|
|
||||||
|
|
||||||
// https://github.com/pritishvaidya/read-time-estimate
|
|
||||||
const { humanizedDuration, totalWords } = readTimeEstimate(
|
|
||||||
markdownData.content,
|
|
||||||
275,
|
|
||||||
12,
|
|
||||||
500,
|
|
||||||
["img", "Image"]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (mode == "posts") {
|
|
||||||
if (!markdownData.date) {
|
|
||||||
throw Error(`Date is not defined in file: ${fileOrFolderPath}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// path that will be used as site url (starts with a slash)
|
|
||||||
const urlPath = path2URL(fileOrFolderPath)
|
|
||||||
|
|
||||||
writeToJSON(
|
|
||||||
`${contentDirectoryPath}${urlPath}.json`,
|
|
||||||
JSON.stringify({
|
|
||||||
content: markdownData.content,
|
|
||||||
toc: toc(markdownRaw).json,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Parse data that will be written to map.js
|
|
||||||
const postData = {
|
|
||||||
title: markdownData.title,
|
|
||||||
preview: "",
|
|
||||||
date: "",
|
|
||||||
readTime: humanizedDuration,
|
|
||||||
wordCount: totalWords,
|
|
||||||
tags: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
// content preview
|
|
||||||
// parsedMarkdown.excerpt is intentionally not used
|
|
||||||
// todo: fix potential improper closing of html tag
|
|
||||||
const slicedContent = markdownData.content.split(" ")
|
|
||||||
if (slicedContent.length > 19) {
|
|
||||||
postData.preview = slicedContent.slice(0, 19).join(" ") + " ..."
|
|
||||||
} else {
|
|
||||||
postData.preview = markdownData.content
|
|
||||||
}
|
|
||||||
|
|
||||||
// date
|
|
||||||
const postDate = new Date(markdownData.date)
|
|
||||||
postData.date = postDate.toLocaleString("default", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})
|
|
||||||
|
|
||||||
const YYYY_MM_DD = postDate.toISOString().split("T")[0]
|
|
||||||
if (map.date[YYYY_MM_DD]) {
|
|
||||||
map.date[YYYY_MM_DD].push(urlPath)
|
|
||||||
} else {
|
|
||||||
map.date[YYYY_MM_DD] = [urlPath]
|
|
||||||
}
|
|
||||||
|
|
||||||
//tags
|
|
||||||
postData.tags = markdownData.tags
|
|
||||||
if (postData.tags) {
|
|
||||||
postData.tags.forEach((tag) => {
|
|
||||||
if (map.tags[tag]) {
|
|
||||||
map.tags[tag].push(urlPath)
|
|
||||||
} else {
|
|
||||||
map.tags[tag] = [urlPath]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
map.posts[urlPath] = postData
|
|
||||||
addDocument({
|
|
||||||
title: markdownData.title,
|
|
||||||
body: markdownData.content,
|
|
||||||
url: urlPath,
|
|
||||||
})
|
|
||||||
} else if (mode == "unsearchable") {
|
|
||||||
// path that will be used as site url (starts with a slash)
|
|
||||||
const _urlPath = path2URL(fileOrFolderPath)
|
|
||||||
const urlPath = _urlPath.slice(
|
|
||||||
_urlPath
|
|
||||||
.slice(1) // ignore the first slash
|
.slice(1) // ignore the first slash
|
||||||
.indexOf("/") + 1
|
.indexOf("/") + 1
|
||||||
)
|
)
|
||||||
|
|
||||||
writeToJSON(
|
parseUnsearchable(dataToPass)
|
||||||
`${contentDirectoryPath}/unsearchable${urlPath}.json`,
|
break
|
||||||
JSON.stringify({
|
}
|
||||||
content: markdownData.content,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Parse data that will be written to map.js
|
case ParseMode.SERIES: {
|
||||||
map.unsearchable[urlPath] = {
|
let urlPath = dataToPass.urlPath
|
||||||
title: markdownData.title,
|
|
||||||
}
|
|
||||||
|
|
||||||
addDocument({
|
|
||||||
title: markdownData.title,
|
|
||||||
body: markdownData.content,
|
|
||||||
url: urlPath,
|
|
||||||
})
|
|
||||||
} else if (mode == "series") {
|
|
||||||
if (
|
|
||||||
!fileOrFolderName.includes("_") &&
|
|
||||||
!fileOrFolderName.startsWith("0")
|
|
||||||
)
|
|
||||||
throw Error(
|
|
||||||
`Invalid series post file name at: ${fileOrFolderPath}`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!markdownData.date) {
|
|
||||||
throw Error(`Date is not defined in file: ${fileOrFolderPath}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// path that will be used as site url (starts with a slash)
|
|
||||||
let urlPath = path2URL(fileOrFolderPath)
|
|
||||||
urlPath = urlPath.slice(0, urlPath.lastIndexOf("_"))
|
urlPath = urlPath.slice(0, urlPath.lastIndexOf("_"))
|
||||||
urlPath = urlPath.replace(/\/$/, "") // remove trailing slash
|
dataToPass.urlPath = urlPath.replace(/\/$/, "") // remove trailing slash
|
||||||
|
|
||||||
writeToJSON(
|
parseSeries(dataToPass)
|
||||||
`${contentDirectoryPath}${urlPath}.json`,
|
break
|
||||||
JSON.stringify({
|
|
||||||
content: markdownData.content,
|
|
||||||
toc: toc(markdownRaw).json,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Parse data that will be written to map.js
|
|
||||||
const postData = {
|
|
||||||
title: markdownData.title,
|
|
||||||
preview: "",
|
|
||||||
date: "",
|
|
||||||
readTime: humanizedDuration,
|
|
||||||
wordCount: totalWords,
|
|
||||||
tags: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
// content preview
|
|
||||||
// parsedMarkdown.excerpt is intentionally not used
|
|
||||||
// todo: fix potential improper closing of html tag
|
|
||||||
const slicedContent = markdownData.content.split(" ")
|
|
||||||
if (slicedContent.length > 19) {
|
|
||||||
postData.preview = slicedContent.slice(0, 19).join(" ") + " ..."
|
|
||||||
} else {
|
|
||||||
postData.preview = markdownData.content
|
|
||||||
}
|
|
||||||
|
|
||||||
// date
|
|
||||||
const postDate = new Date(markdownData.date)
|
|
||||||
postData.date = postDate.toLocaleString("default", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
})
|
|
||||||
|
|
||||||
const YYYY_MM_DD = postDate.toISOString().split("T")[0]
|
|
||||||
if (map.date[YYYY_MM_DD]) {
|
|
||||||
map.date[YYYY_MM_DD].push(urlPath)
|
|
||||||
} else {
|
|
||||||
map.date[YYYY_MM_DD] = [urlPath]
|
|
||||||
}
|
|
||||||
|
|
||||||
//tags
|
|
||||||
postData.tags = markdownData.tags
|
|
||||||
if (postData.tags) {
|
|
||||||
postData.tags.forEach((tag) => {
|
|
||||||
if (map.tags[tag]) {
|
|
||||||
map.tags[tag].push(urlPath)
|
|
||||||
} else {
|
|
||||||
map.tags[tag] = [urlPath]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileOrFolderName.startsWith("0")) {
|
|
||||||
map.series[urlPath] = { ...postData, order: [], length: 0 }
|
|
||||||
} else {
|
|
||||||
map.posts[urlPath] = postData
|
|
||||||
addDocument({
|
|
||||||
title: markdownData.title,
|
|
||||||
body: markdownData.content,
|
|
||||||
url: urlPath,
|
|
||||||
})
|
|
||||||
for (const key of Object.keys(map.series)) {
|
|
||||||
if (
|
|
||||||
urlPath.slice(0, urlPath.lastIndexOf("/")).includes(key)
|
|
||||||
) {
|
|
||||||
const index = parseInt(
|
|
||||||
fileOrFolderName.slice(
|
|
||||||
0,
|
|
||||||
fileOrFolderName.lastIndexOf("_")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isNaN(index)) {
|
|
||||||
throw Error(
|
|
||||||
`Invalid series index at: ${fileOrFolderPath}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemToPush = {
|
|
||||||
index: index,
|
|
||||||
url: urlPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seriesMap[key]) {
|
|
||||||
seriesMap[key].push(itemToPush)
|
|
||||||
} else {
|
|
||||||
seriesMap[key] = [itemToPush]
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePost(data: DataToPass): void {
|
||||||
|
const {
|
||||||
|
urlPath,
|
||||||
|
markdownRaw,
|
||||||
|
markdownData,
|
||||||
|
humanizedDuration,
|
||||||
|
totalWords,
|
||||||
|
} = data
|
||||||
|
|
||||||
|
const postData: PostData = {
|
||||||
|
title: markdownData.title,
|
||||||
|
date: "",
|
||||||
|
readTime: humanizedDuration,
|
||||||
|
wordCount: totalWords,
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dates
|
||||||
|
*/
|
||||||
|
|
||||||
|
const postDate = new Date(markdownData.date)
|
||||||
|
postData.date = postDate.toLocaleString("default", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
|
||||||
|
const YYYY_MM_DD = postDate.toISOString().split("T")[0]
|
||||||
|
if (map.date[YYYY_MM_DD]) {
|
||||||
|
map.date[YYYY_MM_DD].push(urlPath)
|
||||||
|
} else {
|
||||||
|
map.date[YYYY_MM_DD] = [urlPath]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags
|
||||||
|
*/
|
||||||
|
|
||||||
|
postData.tags = markdownData.tags
|
||||||
|
if (postData.tags) {
|
||||||
|
postData.tags.forEach((tag) => {
|
||||||
|
if (map.tags[tag]) {
|
||||||
|
map.tags[tag].push(urlPath)
|
||||||
|
} else {
|
||||||
|
map.tags[tag] = [urlPath]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
map.posts[urlPath] = postData
|
||||||
|
addDocument({
|
||||||
|
title: markdownData.title,
|
||||||
|
body: markdownData.content,
|
||||||
|
url: urlPath,
|
||||||
|
})
|
||||||
|
writeToJSON(
|
||||||
|
`${contentDirectoryPath}${urlPath}.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
content: markdownData.content,
|
||||||
|
toc: toc(markdownRaw).json,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSeries(data: DataToPass): void {
|
||||||
|
const {
|
||||||
|
path,
|
||||||
|
urlPath,
|
||||||
|
fileOrFolderName,
|
||||||
|
markdownRaw,
|
||||||
|
markdownData,
|
||||||
|
humanizedDuration,
|
||||||
|
totalWords,
|
||||||
|
} = data
|
||||||
|
|
||||||
|
if (!fileOrFolderName.includes("_") && !fileOrFolderName.startsWith("0"))
|
||||||
|
throw Error(`Invalid series post file name at: ${path}`)
|
||||||
|
|
||||||
|
const postData: PostData = {
|
||||||
|
title: markdownData.title,
|
||||||
|
date: "",
|
||||||
|
readTime: humanizedDuration,
|
||||||
|
wordCount: totalWords,
|
||||||
|
tags: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date
|
||||||
|
*/
|
||||||
|
|
||||||
|
const postDate = new Date(markdownData.date)
|
||||||
|
postData.date = postDate.toLocaleString("default", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
|
||||||
|
const YYYY_MM_DD = postDate.toISOString().split("T")[0]
|
||||||
|
if (map.date[YYYY_MM_DD]) {
|
||||||
|
map.date[YYYY_MM_DD].push(urlPath)
|
||||||
|
} else {
|
||||||
|
map.date[YYYY_MM_DD] = [urlPath]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags
|
||||||
|
*/
|
||||||
|
|
||||||
|
postData.tags = markdownData.tags
|
||||||
|
if (postData.tags) {
|
||||||
|
postData.tags.forEach((tag) => {
|
||||||
|
if (map.tags[tag]) {
|
||||||
|
map.tags[tag].push(urlPath)
|
||||||
|
} else {
|
||||||
|
map.tags[tag] = [urlPath]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// series markdown starting with 0 is a series descriptor
|
||||||
|
if (fileOrFolderName.startsWith("0")) {
|
||||||
|
map.series[urlPath] = {
|
||||||
|
...postData,
|
||||||
|
order: [],
|
||||||
|
length: 0,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addDocument({
|
||||||
|
title: markdownData.title,
|
||||||
|
body: markdownData.content,
|
||||||
|
url: urlPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
map.posts[urlPath] = postData
|
||||||
|
|
||||||
|
for (const key of Object.keys(map.series)) {
|
||||||
|
if (urlPath.slice(0, urlPath.lastIndexOf("/")).includes(key)) {
|
||||||
|
const index = parseInt(
|
||||||
|
fileOrFolderName.slice(0, fileOrFolderName.lastIndexOf("_"))
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isNaN(index))
|
||||||
|
throw Error(`Invalid series index at: ${path}`)
|
||||||
|
|
||||||
|
const itemToPush = {
|
||||||
|
index: index,
|
||||||
|
url: urlPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesMap[key]) {
|
||||||
|
seriesMap[key].push(itemToPush)
|
||||||
|
} else {
|
||||||
|
seriesMap[key] = [itemToPush]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
writeToJSON(
|
||||||
|
`${contentDirectoryPath}${urlPath}.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
content: markdownData.content,
|
||||||
|
toc: toc(markdownRaw).json,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUnsearchable(data: DataToPass): void {
|
||||||
|
const { urlPath, markdownData } = data
|
||||||
|
|
||||||
|
addDocument({
|
||||||
|
title: markdownData.title,
|
||||||
|
body: markdownData.content,
|
||||||
|
url: urlPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parse data that will be written to map.js
|
||||||
|
map.unsearchable[urlPath] = {
|
||||||
|
title: markdownData.title,
|
||||||
|
}
|
||||||
|
|
||||||
|
writeToJSON(
|
||||||
|
`${contentDirectoryPath}/unsearchable${urlPath}.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
content: markdownData.content,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* todo: accurately calculate start and end of front matter
|
||||||
|
*
|
||||||
|
* @param {string} markdownRaw
|
||||||
|
* @param {string} path
|
||||||
|
*
|
||||||
|
* @returns {MarkdownData}
|
||||||
|
*/
|
||||||
|
function parseFrontMatter(
|
||||||
|
markdownRaw: string,
|
||||||
|
path: string,
|
||||||
|
mode: ParseMode
|
||||||
|
): MarkdownData {
|
||||||
|
const result = matter(
|
||||||
|
markdownRaw.slice(0, nthIndex(markdownRaw, "---", 2) + 3)
|
||||||
|
).data
|
||||||
|
|
||||||
|
if (!result.title) throw Error(`Title is not defined in file: ${path}`)
|
||||||
|
|
||||||
|
if (mode != ParseMode.UNSEARCHABLE && !result.date)
|
||||||
|
throw Error(`Date is not defined in file: ${path}`)
|
||||||
|
|
||||||
|
const dom = new JSDOM(parseMarkdown(markdownRaw))
|
||||||
|
|
||||||
|
// add .hljs class to all block codes
|
||||||
|
dom.window.document.querySelectorAll("pre > code").forEach((item) => {
|
||||||
|
item.classList.add("hljs")
|
||||||
|
})
|
||||||
|
|
||||||
|
result.content = dom.window.document.documentElement.innerHTML
|
||||||
|
|
||||||
|
return result as MarkdownData
|
||||||
|
}
|
||||||
|
|
|
@ -1,56 +0,0 @@
|
||||||
export interface Map {
|
|
||||||
// key: YYYY-MM-DD
|
|
||||||
// value: url
|
|
||||||
date: {
|
|
||||||
[key: string]: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// key: tag name
|
|
||||||
// value: url
|
|
||||||
tags: {
|
|
||||||
[key: string]: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// list of all meta data
|
|
||||||
meta: {
|
|
||||||
tags: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchable, non-series posts
|
|
||||||
// must have a post date
|
|
||||||
// tag is not required
|
|
||||||
posts: {
|
|
||||||
[key: string]: {
|
|
||||||
title: string
|
|
||||||
date: string
|
|
||||||
tags: string[]
|
|
||||||
preview: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// series posts have "previous post" and "next post" button so they need to be ordered
|
|
||||||
series: {
|
|
||||||
[key: string]: {
|
|
||||||
title: string
|
|
||||||
length: number
|
|
||||||
order: string[] // url order
|
|
||||||
tags: string[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// urls of unsearchable posts
|
|
||||||
// it is here to quickly check if a post exists or not
|
|
||||||
unsearchable: {
|
|
||||||
[key: string]: {
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SeriesMap {
|
|
||||||
// key: url
|
|
||||||
[key: string]: {
|
|
||||||
index: number
|
|
||||||
url: string
|
|
||||||
}[]
|
|
||||||
}
|
|
|
@ -3,7 +3,12 @@ import { relative } from "path"
|
||||||
|
|
||||||
import { markdownPath } from "./config"
|
import { markdownPath } from "./config"
|
||||||
|
|
||||||
// converts file path to url
|
/**
|
||||||
|
* converts file path to url path that will be used in the url (starts with a slash)
|
||||||
|
*
|
||||||
|
* @param {string} pathToConvert
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
export function path2URL(pathToConvert: string): string {
|
export function path2URL(pathToConvert: string): string {
|
||||||
return `/${relative(markdownPath, pathToConvert)}`
|
return `/${relative(markdownPath, pathToConvert)}`
|
||||||
.replace(/\.[^/.]+$/, "") // remove the file extension
|
.replace(/\.[^/.]+$/, "") // remove the file extension
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Helmet } from "react-helmet-async"
|
||||||
import storage from "local-storage-fallback"
|
import storage from "local-storage-fallback"
|
||||||
import { isIE } from "react-device-detect"
|
import { isIE } from "react-device-detect"
|
||||||
|
|
||||||
import { ThemeType } from "./types/styled-comonents"
|
import { ThemeType } from "../types/styled-components"
|
||||||
|
|
||||||
import Loading from "./components/Loading"
|
import Loading from "./components/Loading"
|
||||||
import Navbar from "./components/Navbar"
|
import Navbar from "./components/Navbar"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import styled from "styled-components"
|
import styled from "styled-components"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
import { Post } from "../types/typings"
|
import { PostData } from "../../types/typing"
|
||||||
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||||
import {
|
import {
|
||||||
|
@ -40,6 +40,7 @@ const StyledPostCard = styled(MainContent)`
|
||||||
const StyledTitle = styled.h1`
|
const StyledTitle = styled.h1`
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-style: bold;
|
font-style: bold;
|
||||||
|
margin: 0;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
@ -51,15 +52,7 @@ const StyledMetaContainer = styled.small`
|
||||||
})};
|
})};
|
||||||
`
|
`
|
||||||
|
|
||||||
const StyledPostCardContent = styled.div`
|
interface _PostDateBase extends PostData {
|
||||||
color: ${(props) =>
|
|
||||||
theming.theme(props.theme.currentTheme, {
|
|
||||||
light: "grey",
|
|
||||||
dark: "lightgrey",
|
|
||||||
})};
|
|
||||||
`
|
|
||||||
|
|
||||||
interface _PostDateBase extends Post {
|
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,16 +65,17 @@ const PostCard = (props: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledPostCard
|
<StyledPostCard
|
||||||
key={props.postData.url}
|
onClick={() =>
|
||||||
onClick={() => {
|
|
||||||
navigate(process.env.PUBLIC_URL + props.postData.url)
|
navigate(process.env.PUBLIC_URL + props.postData.url)
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
<StyledTitle>{props.postData?.title || "No title"}</StyledTitle>
|
<StyledTitle>{props.postData?.title || "No title"}</StyledTitle>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
<StyledMetaContainer>
|
<StyledMetaContainer>
|
||||||
<TagList direction="left">
|
<TagList direction="left">
|
||||||
{props.postData.tags ? (
|
{props.postData.tags &&
|
||||||
props.postData.tags.map((tag) => {
|
props.postData.tags.map((tag) => {
|
||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
|
@ -89,11 +83,9 @@ const PostCard = (props: Props) => {
|
||||||
text={tag}
|
text={tag}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})}
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</TagList>
|
</TagList>
|
||||||
|
<hr />
|
||||||
<FontAwesomeIcon icon={faCalendar} />
|
<FontAwesomeIcon icon={faCalendar} />
|
||||||
|
|
||||||
{props.postData?.date || "Unknown date"}
|
{props.postData?.date || "Unknown date"}
|
||||||
|
@ -110,15 +102,6 @@ const PostCard = (props: Props) => {
|
||||||
? props.postData.wordCount + " words"
|
? props.postData.wordCount + " words"
|
||||||
: "unknown words"}
|
: "unknown words"}
|
||||||
</StyledMetaContainer>
|
</StyledMetaContainer>
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<StyledPostCardContent
|
|
||||||
className="white-link"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: props.postData.preview,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</StyledPostCard>
|
</StyledPostCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { MouseEvent } from "react"
|
import { MouseEvent } from "react"
|
||||||
import styled from "styled-components"
|
import styled from "styled-components"
|
||||||
|
|
||||||
import { faTag } from "@fortawesome/free-solid-svg-icons"
|
import { faHashtag } from "@fortawesome/free-solid-svg-icons"
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||||
|
|
||||||
import theming from "../styles/theming"
|
import theming from "../styles/theming"
|
||||||
|
@ -24,7 +24,7 @@ interface Props {
|
||||||
const Tag = (props: Props) => {
|
const Tag = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<StyledTag onClick={props.onClick || undefined}>
|
<StyledTag onClick={props.onClick || undefined}>
|
||||||
<FontAwesomeIcon icon={faTag} /> {props.text}
|
<FontAwesomeIcon icon={faHashtag} /> {props.text}
|
||||||
</StyledTag>
|
</StyledTag>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@ import {
|
||||||
faHourglass,
|
faHourglass,
|
||||||
} from "@fortawesome/free-solid-svg-icons"
|
} from "@fortawesome/free-solid-svg-icons"
|
||||||
|
|
||||||
import { FetchedPage } from "../../types/typings"
|
import { FetchedPage } from "../../../types/typing"
|
||||||
import theming from "../../styles/theming"
|
import theming from "../../styles/theming"
|
||||||
|
|
||||||
const StyledMetaContainer = styled.div`
|
const StyledMetaContainer = styled.div`
|
||||||
|
|
|
@ -8,7 +8,7 @@ import styled from "styled-components"
|
||||||
|
|
||||||
import theming from "../../styles/theming"
|
import theming from "../../styles/theming"
|
||||||
|
|
||||||
import { FetchedPage } from "../../types/typings"
|
import { FetchedPage } from "../../../types/typing"
|
||||||
|
|
||||||
const StyledTocToggleButton = styled.button`
|
const StyledTocToggleButton = styled.button`
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useLocation } from "react-router-dom"
|
||||||
import styled from "styled-components"
|
import styled from "styled-components"
|
||||||
import { HashLink } from "react-router-hash-link"
|
import { HashLink } from "react-router-hash-link"
|
||||||
|
|
||||||
import { TocElement, FetchedPage, Map } from "../../types/typings"
|
import { TocElement, FetchedPage, Map } from "../../../types/typing"
|
||||||
|
|
||||||
import MainContent from "../../components/MainContent"
|
import MainContent from "../../components/MainContent"
|
||||||
import Loading from "../../components/Loading"
|
import Loading from "../../components/Loading"
|
||||||
|
|
|
@ -12,7 +12,7 @@ import PostCard from "../components/PostCard"
|
||||||
import _map from "../data/map.json"
|
import _map from "../data/map.json"
|
||||||
import theming from "../styles/theming"
|
import theming from "../styles/theming"
|
||||||
|
|
||||||
import { Map } from "../types/typings"
|
import { Map } from "../../types/typing"
|
||||||
|
|
||||||
const map: Map = _map
|
const map: Map = _map
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import Select from "react-select"
|
||||||
import theming from "../../styles/theming"
|
import theming from "../../styles/theming"
|
||||||
|
|
||||||
import { Query } from "."
|
import { Query } from "."
|
||||||
import { Map } from "../../types/typings"
|
import { Map } from "../../../types/typing"
|
||||||
import _map from "../../data/map.json"
|
import _map from "../../data/map.json"
|
||||||
|
|
||||||
const map: Map = _map
|
const map: Map = _map
|
||||||
|
|
|
@ -19,7 +19,7 @@ import MainContent from "../../components/MainContent"
|
||||||
import SearchBar from "./SearchBar"
|
import SearchBar from "./SearchBar"
|
||||||
import TagSelect, { TagsData } from "./TagSelect"
|
import TagSelect, { TagsData } from "./TagSelect"
|
||||||
|
|
||||||
import { Map } from "../../types/typings"
|
import { Map } from "../../../types/typing"
|
||||||
|
|
||||||
import "react-date-range/dist/styles.css"
|
import "react-date-range/dist/styles.css"
|
||||||
import "react-date-range/dist/theme/default.css"
|
import "react-date-range/dist/theme/default.css"
|
||||||
|
|
|
@ -96,7 +96,7 @@ const kbdCSS = css`
|
||||||
dark: "white",
|
dark: "white",
|
||||||
})};
|
})};
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
font-size: 10px;
|
font-size: 13.5px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
box-shadow: ${(props) =>
|
box-shadow: ${(props) =>
|
||||||
theming.theme(props.theme.currentTheme, {
|
theming.theme(props.theme.currentTheme, {
|
||||||
|
@ -108,11 +108,6 @@ const kbdCSS = css`
|
||||||
light: "#F7F7F7",
|
light: "#F7F7F7",
|
||||||
dark: "black",
|
dark: "black",
|
||||||
})};
|
})};
|
||||||
text-shadow: ${(props) =>
|
|
||||||
theming.theme(props.theme.currentTheme, {
|
|
||||||
light: "0 1px 0 white",
|
|
||||||
dark: "0 1px 0 black",
|
|
||||||
})};
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
@ -165,14 +160,15 @@ const blockquoteCSS = css`
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const whiteLinkCSS = css`
|
const headerCSS = css`
|
||||||
.white-link a {
|
/* intentionally left out h1 */
|
||||||
text-decoration: none;
|
|
||||||
color: ${theming.color.linkColor};
|
|
||||||
|
|
||||||
&:visited {
|
h2,
|
||||||
color: ${theming.color.linkColor};
|
h3,
|
||||||
}
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin-top: 2.5rem;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
@ -182,7 +178,7 @@ const globalStyle = css`
|
||||||
${kbdCSS}
|
${kbdCSS}
|
||||||
${tableCSS}
|
${tableCSS}
|
||||||
${blockquoteCSS}
|
${blockquoteCSS}
|
||||||
${whiteLinkCSS}
|
${headerCSS}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
|
@ -23,7 +23,7 @@ export default {
|
||||||
x2_small: "3px",
|
x2_small: "3px",
|
||||||
x_small: "8px",
|
x_small: "8px",
|
||||||
small: 0,
|
small: 0,
|
||||||
medium: "14px",
|
medium: "1rem",
|
||||||
large: 0,
|
large: 0,
|
||||||
x_large: 0,
|
x_large: 0,
|
||||||
screen_size1: "1000px",
|
screen_size1: "1000px",
|
||||||
|
|
50
source/src/types/typings.d.ts
vendored
50
source/src/types/typings.d.ts
vendored
|
@ -1,50 +0,0 @@
|
||||||
export interface TocElement {
|
|
||||||
slug: string
|
|
||||||
content: string
|
|
||||||
i: number
|
|
||||||
lvl: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Post {
|
|
||||||
title: string
|
|
||||||
preview: string
|
|
||||||
date: string
|
|
||||||
readTime: string
|
|
||||||
wordCount: number
|
|
||||||
tags?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Series {
|
|
||||||
title: string
|
|
||||||
preview: string
|
|
||||||
date: string
|
|
||||||
readTime: string
|
|
||||||
wordCount: number
|
|
||||||
order: string[]
|
|
||||||
length: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FetchedPage {
|
|
||||||
title: string
|
|
||||||
preview: string
|
|
||||||
date: string
|
|
||||||
readTime: string
|
|
||||||
wordCount: number
|
|
||||||
tags: string[]
|
|
||||||
toc: JSX.Element | undefined
|
|
||||||
content: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Map {
|
|
||||||
date: { [date: string]: string[] }
|
|
||||||
tags: { [tag: string]: string[] }
|
|
||||||
meta: { tags: string[] }
|
|
||||||
posts: { [url: string]: Post }
|
|
||||||
series: { [url: string]: Series }
|
|
||||||
unsearchable: { [url: string]: { title: string } }
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "*.json" {
|
|
||||||
const data: Map
|
|
||||||
export default data
|
|
||||||
}
|
|
90
source/types/typing.ts
Normal file
90
source/types/typing.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
export enum ParseMode {
|
||||||
|
POSTS,
|
||||||
|
SERIES,
|
||||||
|
UNSEARCHABLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TocElement {
|
||||||
|
slug: string
|
||||||
|
content: string
|
||||||
|
i: number
|
||||||
|
lvl: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Base {}
|
||||||
|
|
||||||
|
export interface PostData {
|
||||||
|
title: string
|
||||||
|
date: string
|
||||||
|
readTime: string
|
||||||
|
wordCount: number
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Series {
|
||||||
|
title: string
|
||||||
|
date: string
|
||||||
|
readTime: string
|
||||||
|
wordCount: number
|
||||||
|
order: string[]
|
||||||
|
length: number
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchedPage {
|
||||||
|
title: string
|
||||||
|
date: string
|
||||||
|
readTime: string
|
||||||
|
wordCount: number
|
||||||
|
tags: string[]
|
||||||
|
toc?: JSX.Element
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Map {
|
||||||
|
// key: YYYY-MM-DD
|
||||||
|
// value: url
|
||||||
|
date: { [key: string]: string[] }
|
||||||
|
|
||||||
|
// key: tag name
|
||||||
|
// value: url
|
||||||
|
tags: {
|
||||||
|
[key: string]: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// list of all meta data
|
||||||
|
meta: {
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchable, non-series posts
|
||||||
|
// must have a post date
|
||||||
|
// tag is not required
|
||||||
|
posts: {
|
||||||
|
[key: string]: PostData
|
||||||
|
}
|
||||||
|
|
||||||
|
// series posts have "previous post" and "next post" button so they need to be ordered
|
||||||
|
series: { [key: string]: Series }
|
||||||
|
|
||||||
|
// urls of unsearchable posts
|
||||||
|
// it is here to quickly check if a post exists or not
|
||||||
|
unsearchable: { [key: string]: { title: string } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesEntry {
|
||||||
|
index: number
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesMap {
|
||||||
|
// key: url
|
||||||
|
[key: string]: SeriesEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkdownData {
|
||||||
|
content: string
|
||||||
|
date: string
|
||||||
|
title: string
|
||||||
|
tags: string[]
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue