handle markdown
This commit is contained in:
parent
6e4be6ca3e
commit
c250df55a0
11 changed files with 648 additions and 4 deletions
78
src/components/inflators/descriptionList.ts
Normal file
78
src/components/inflators/descriptionList.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { type ProjectListingInfo } from 'src/types/shared/project'
|
||||
import { getFormattedPeriod } from 'src/utilities/dom'
|
||||
|
||||
/**
|
||||
* Queries for description list nodes and inflates them with extra functionality
|
||||
* @param _window the reference to the window
|
||||
*/
|
||||
export const inflateDescriptionListEmbeds = (_projectInfo?: ProjectListingInfo) => {
|
||||
document.querySelectorAll('dl:not(.no-inflate)').forEach((_element) => {
|
||||
new DescriptionListElement(_element as HTMLDListElement, _projectInfo)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflates description list elements with extra functionality, namely tags and time period
|
||||
*/
|
||||
export class DescriptionListElement {
|
||||
element: HTMLDListElement
|
||||
|
||||
constructor(_element: HTMLDListElement, _projectInfo?: ProjectListingInfo) {
|
||||
this.element = _element
|
||||
|
||||
if (!!_projectInfo) {
|
||||
this._inflatePeriod(_projectInfo)
|
||||
}
|
||||
this._inflateTags()
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for the presence of a .period element wrapping around <dt> and <dd>,
|
||||
* and inflates the inner <dd> element by replacing the first instance of $PERIOD with formatted period
|
||||
* @param _projectInfo the project info in which the project's time period is defined.
|
||||
*/
|
||||
_inflatePeriod = (_projectInfo: ProjectListingInfo) => {
|
||||
const periodWrapper = this.element.querySelector('.period')
|
||||
if (!!periodWrapper) {
|
||||
if (!!_projectInfo.period) {
|
||||
const periodDescriptionElement = periodWrapper.querySelector('dd')
|
||||
if (!!periodDescriptionElement) {
|
||||
periodDescriptionElement.innerHTML = periodDescriptionElement.innerHTML.replace('$PERIOD', getFormattedPeriod(_projectInfo.period))
|
||||
} else {
|
||||
console.warn('Found a .period element but not an inner dd element to format on. The .period class should be on a div wrapping around <dt> and <dd>')
|
||||
}
|
||||
} else {
|
||||
console.warn('Found a .period element but the project information is missing a period.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for the presence of a .tags element wrapping around <dt> and <dd>,
|
||||
* and inflates the inner <dd> element by replacing the inner key-value JSON object with an array of span elements,
|
||||
* where the span's inner text corresponds to the keys of the JSON object,
|
||||
* and the span's class corresponds to the values of the JSON object.
|
||||
*/
|
||||
_inflateTags = () => {
|
||||
const tagsWrapper = this.element.querySelector('.tags')
|
||||
if (!!tagsWrapper) {
|
||||
let originalContent: { [key: string]: string } = { }
|
||||
try {
|
||||
const tagsDescriptionElement = tagsWrapper.querySelector('dd')
|
||||
if (!!tagsDescriptionElement) {
|
||||
originalContent = JSON.parse(tagsDescriptionElement.innerHTML)
|
||||
const formattedText = Object.keys(originalContent).map((key) => (
|
||||
`<span class="tag ${originalContent[key]}">${key}</span>`
|
||||
)).join('')
|
||||
tagsDescriptionElement.innerHTML = formattedText
|
||||
} else {
|
||||
console.warn('Found a .tags element but not an inner dd element to format on. The .tags class should be on a div wrapping around <dt> and <dd>.')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Found a .tags element but the inner dd element was improperly formatted. The <dd> element should contain a valid JSON object.')
|
||||
console.error(originalContent)
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
129
src/components/inflators/details.ts
Normal file
129
src/components/inflators/details.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
export type DetailsAnimationState =
|
||||
| 'idle'
|
||||
| 'opening'
|
||||
| 'closing'
|
||||
|
||||
/**
|
||||
* Queries for HTMLDetailsElement nodes and inflates them with extra functionality
|
||||
* @param _window the reference to the window
|
||||
*/
|
||||
export const inflateDetailsElements = () => {
|
||||
const styles = window.getComputedStyle(document.documentElement)
|
||||
const options: KeyframeAnimationOptions = {
|
||||
duration: Number(styles.getPropertyValue('--theme-transition-duration')?.replace('s', '') || 0.5) * 1000,
|
||||
easing: styles.getPropertyValue('--theme-transition-function') || 'ease-in-out',
|
||||
}
|
||||
|
||||
document.querySelectorAll('details:not(.no-inflate)').forEach((_element) => {
|
||||
new DetailsElement(_element as HTMLDetailsElement, options)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflates HTMLDetailsElement with extra functionality, namely animations
|
||||
*/
|
||||
export class DetailsElement {
|
||||
animationOptions?: KeyframeAnimationOptions
|
||||
element: HTMLDetailsElement
|
||||
summary: HTMLElement
|
||||
content: HTMLElement
|
||||
animation: Animation | null = null
|
||||
animationState: DetailsAnimationState = 'idle'
|
||||
|
||||
set _animationState(_value: DetailsAnimationState) {
|
||||
this.animationState = _value
|
||||
this.element.classList.remove('opening', 'closing')
|
||||
if (this.animationState !== 'idle') {
|
||||
this.element.classList.add(this.animationState)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(_element: HTMLDetailsElement, _animationOptions?: KeyframeAnimationOptions) {
|
||||
this.element = _element
|
||||
this.summary = _element.querySelector('summary')!
|
||||
this.content = _element.querySelector('.content')!
|
||||
if (!this.summary) {
|
||||
console.error('Summary was not found on this <details> element; therefore, it cannot be inflated, and may not be compliant with web standards')
|
||||
} else if (!this.content) {
|
||||
console.warn('Content was not found on this <details> element; therefore, it cannot be inflated')
|
||||
} else {
|
||||
this.summary.addEventListener('click', (e) => this.onClick(e))
|
||||
this.animationOptions = _animationOptions
|
||||
this.element.classList.add('embed', 'details')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for when the shrink/expand animations finish
|
||||
*/
|
||||
onAnimationFinish(_isOpen: boolean) {
|
||||
this.element.open = _isOpen
|
||||
this.animation = null
|
||||
this._animationState = 'idle'
|
||||
this.element.style.height = this.element.style.overflow = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for user clicking the summary element
|
||||
*/
|
||||
onClick(_event: Event) {
|
||||
_event.preventDefault()
|
||||
this.element.style.overflow = 'hidden'
|
||||
if (this.animationState === 'closing' || !this.element.open) {
|
||||
this.open()
|
||||
} else if (this.animationState === 'opening' || this.element.open) {
|
||||
this.shrink()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrinks the <details> block with animation
|
||||
*/
|
||||
shrink() {
|
||||
this._animationState = 'closing'
|
||||
|
||||
const startHeight = `${this.element.offsetHeight}px`
|
||||
const endHeight = `${this.summary.offsetHeight}px`
|
||||
|
||||
if (!!this.animation) {
|
||||
this.animation.cancel()
|
||||
}
|
||||
|
||||
this.animation = this.element.animate({
|
||||
height: [ startHeight, endHeight ]
|
||||
}, this.animationOptions)
|
||||
|
||||
this.animation.onfinish = () => this.onAnimationFinish(false)
|
||||
this.animation.oncancel = () => this._animationState = 'idle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the <details> block for expanding
|
||||
*/
|
||||
open() {
|
||||
this.element.style.height = `${this.element.offsetHeight}px`
|
||||
this.element.open = true
|
||||
window.requestAnimationFrame(() => this.expand())
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the <details> block with animation
|
||||
*/
|
||||
expand() {
|
||||
this._animationState = 'opening'
|
||||
|
||||
const startHeight = `${this.element.offsetHeight}px`
|
||||
const endHeight = `${this.summary.offsetHeight + this.content.offsetHeight}px`
|
||||
|
||||
if (!!this.animation) {
|
||||
this.animation.cancel();
|
||||
}
|
||||
|
||||
this.animation = this.element.animate({
|
||||
height: [ startHeight, endHeight ]
|
||||
}, this.animationOptions)
|
||||
|
||||
this.animation.onfinish = () => this.onAnimationFinish(true)
|
||||
this.animation.oncancel = () => this._animationState = 'idle'
|
||||
}
|
||||
}
|
17
src/components/inflators/embed.ts
Normal file
17
src/components/inflators/embed.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { inflateDescriptionListEmbeds } from './descriptionList'
|
||||
import { inflateDetailsElements } from './details'
|
||||
import { inflateImageEmbeds } from './image'
|
||||
import { inflateVideoEmbeds } from './video'
|
||||
|
||||
import { type ProjectListingInfo } from 'src/types/shared/project'
|
||||
|
||||
/**
|
||||
* Inflates various supported embeds
|
||||
* @param _window the reference to the window
|
||||
*/
|
||||
export const inflateEmbeds = (_projectInfo?: ProjectListingInfo) => {
|
||||
inflateDescriptionListEmbeds(_projectInfo)
|
||||
inflateDetailsElements()
|
||||
inflateImageEmbeds()
|
||||
inflateVideoEmbeds()
|
||||
}
|
51
src/components/inflators/image.ts
Normal file
51
src/components/inflators/image.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
//import { ModalService } from 'src/components/services/modal'
|
||||
|
||||
/**
|
||||
* Queries for HTMLImageElement nodes and inflates them with extra functionality
|
||||
* @param _window the reference to the window
|
||||
*/
|
||||
export const inflateImageEmbeds = () => {
|
||||
document.querySelectorAll('.content img:not(.no-inflate)').forEach((_element) => {
|
||||
new ImageElement(_element as HTMLImageElement)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflates HTMLImageElement with extra functionality,
|
||||
* namely previewing images in the modal service and displaying captions from titles
|
||||
*/
|
||||
export class ImageElement {
|
||||
element: HTMLImageElement
|
||||
caption: HTMLParagraphElement | null = null
|
||||
wrapper: HTMLDivElement
|
||||
|
||||
constructor(_element: HTMLImageElement) {
|
||||
this.element = _element
|
||||
const parent = this.element.parentElement!
|
||||
this.wrapper = document.createElement('div')
|
||||
this.wrapper.classList.add('embed', 'image')
|
||||
const subWrapper = document.createElement('div')
|
||||
subWrapper.classList.add('image-wrapper')
|
||||
parent.appendChild(this.wrapper)
|
||||
this.wrapper.appendChild(subWrapper)
|
||||
subWrapper.appendChild(this.element)
|
||||
if (!!this.element.title) {
|
||||
this.caption = document.createElement('p')
|
||||
this.wrapper.appendChild(this.caption)
|
||||
this.caption.innerHTML = this.element.title
|
||||
}
|
||||
this.element.addEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for when the image is clicked
|
||||
* @param event the click event
|
||||
*/
|
||||
onClick = (event: Event) => {
|
||||
// event.preventDefault()
|
||||
// let cloned = this.wrapper.cloneNode(true) as HTMLElement
|
||||
// let image = cloned.firstChild as HTMLElement
|
||||
// image.removeEventListener('click', this.onClick)
|
||||
// ModalService.open(cloned)
|
||||
}
|
||||
}
|
29
src/components/inflators/video.ts
Normal file
29
src/components/inflators/video.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { findFirstMatchingChild } from 'src/utilities/dom'
|
||||
|
||||
/**
|
||||
* Queries for iframe, video, or object nodes and inflates them with extra functionality
|
||||
* @param _window the reference to the window
|
||||
*/
|
||||
export const inflateVideoEmbeds = () => {
|
||||
document.querySelectorAll('.video:not(.no-inflate)').forEach((_element) => {
|
||||
new VideoElement(_element)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflates iframe, video, or object elements with extra functionality, namely embed styles
|
||||
*/
|
||||
export class VideoElement {
|
||||
element: Element
|
||||
videoElement: HTMLIFrameElement | HTMLVideoElement | HTMLObjectElement
|
||||
|
||||
constructor(_element: Element) {
|
||||
this.element = _element
|
||||
this.videoElement = findFirstMatchingChild(_element, 'iframe', 'video', 'object') as any
|
||||
if (!this.videoElement) {
|
||||
console.warn('A video embed was defined but contained no video elements within it')
|
||||
} else {
|
||||
this.element.classList.add('embed', 'video')
|
||||
}
|
||||
}
|
||||
}
|
23
src/components/shared/embedable-content.vue
Normal file
23
src/components/shared/embedable-content.vue
Normal file
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import { inflateEmbeds } from 'src/components/inflators/embed'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
inflateEmbeds()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.content(
|
||||
v-html='content'
|
||||
)
|
||||
</template>
|
||||
|
||||
<style scope lang="sass">
|
||||
|
||||
</style>
|
10
src/content-env.d.ts
vendored
10
src/content-env.d.ts
vendored
|
@ -2,11 +2,19 @@ declare module 'content/routes.js' {
|
|||
type Template =
|
||||
| 'markdown'
|
||||
|
||||
type RouteDefinition = {
|
||||
type GenericRouteDefinition = {
|
||||
id: string
|
||||
template: Template
|
||||
}
|
||||
|
||||
type MarkdownDefinition = GenericRouteDefinition & {
|
||||
template: 'markdown'
|
||||
content: string
|
||||
}
|
||||
|
||||
type RouteDefinition =
|
||||
| MarkdownDefinition
|
||||
|
||||
type RouteCollection = { [key: string]: RouteDefinition }
|
||||
|
||||
const routes: RouteCollection
|
||||
|
|
52
src/utilities/dom.ts
Normal file
52
src/utilities/dom.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import rfdc from 'rfdc'
|
||||
|
||||
import { type DateRange } from 'src/types/shared/dateRange'
|
||||
import { type FilterState } from 'src/types/shared/filter'
|
||||
|
||||
export const deepCopy = rfdc()
|
||||
|
||||
/**
|
||||
* Runs element.querySelector for every selector in the order they were passed in until an element is found
|
||||
* @param element the element to query children from
|
||||
* @param selectors the selectors to query for
|
||||
* @returns the first child element found from the selectors specified
|
||||
*/
|
||||
export const findFirstMatchingChild = ( element: ParentNode, ...selectors: string[] ) => {
|
||||
let returnElement: Element | undefined
|
||||
selectors.every((selector) => {
|
||||
const foundElement = element.querySelector(selector)
|
||||
if (!!foundElement) {
|
||||
returnElement = foundElement
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return returnElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the period object into a readable string
|
||||
* @param period the period to format
|
||||
* @returns the formatted string
|
||||
*/
|
||||
export const getFormattedPeriod = (period: DateRange) => {
|
||||
return `${period.from}${!!period.to ? ' - ' + period.to : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a project's tags to see if any of them match any of the state tags
|
||||
* @param state the state to check against
|
||||
* @param tags the tags to check
|
||||
* @returns
|
||||
* - `true` if any of the project's tags are `true` in the state
|
||||
* - `true` if none of the tags in the filter state are `true` (indicating no filters being set in the panel)
|
||||
* - `false` if the project has no tags or if none of its tags are toggled to `true` in the filters panel
|
||||
*/
|
||||
export const doFiltersMatchTags = (state: FilterState, tags?: string[]) => {
|
||||
const anyIsTrue = !!Object.keys(state).find(tag => !!state[tag])
|
||||
if (!anyIsTrue) {
|
||||
return true
|
||||
} else {
|
||||
return !!tags?.find(tag => !!state[tag])
|
||||
}
|
||||
}
|
191
src/utilities/fetch.ts
Normal file
191
src/utilities/fetch.ts
Normal file
|
@ -0,0 +1,191 @@
|
|||
import DOMPurify from 'dompurify'
|
||||
import yaml from 'js-yaml'
|
||||
import { marked } from 'marked'
|
||||
|
||||
/**
|
||||
* Config used for DOMPurify.
|
||||
* This config allows for most HTML elements and a handful of attributes
|
||||
* necessary for the site to load intended markdown/etc
|
||||
*/
|
||||
const domPurifyConfig = {
|
||||
ALLOW_ARIA_ATTR: true,
|
||||
ALLOWED_ATTR: [
|
||||
'allowfullscreen',
|
||||
'allow',
|
||||
'alt',
|
||||
'class',
|
||||
'href',
|
||||
'id',
|
||||
'src',
|
||||
'style',
|
||||
'title',
|
||||
],
|
||||
ALLOWED_TAGS: [
|
||||
'a',
|
||||
'p',
|
||||
'span',
|
||||
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'mark',
|
||||
's',
|
||||
'sub',
|
||||
'sup',
|
||||
'small',
|
||||
'strike',
|
||||
'strong',
|
||||
'u',
|
||||
|
||||
'abbr',
|
||||
'audio',
|
||||
'blockquote',
|
||||
'cite',
|
||||
'code',
|
||||
'details',
|
||||
'data',
|
||||
'dfn',
|
||||
'div',
|
||||
'iframe',
|
||||
'img',
|
||||
'pre',
|
||||
'summary',
|
||||
'samp',
|
||||
'time',
|
||||
'track',
|
||||
'var',
|
||||
'video',
|
||||
|
||||
'figcaption',
|
||||
'figure',
|
||||
|
||||
'br',
|
||||
'hr',
|
||||
'wbr',
|
||||
|
||||
'ol',
|
||||
'ul',
|
||||
'li',
|
||||
|
||||
'dd',
|
||||
'dl',
|
||||
'dt',
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a file and returns the blob content of the file
|
||||
* @param path the path of the file to load
|
||||
* @returns the blob of the file
|
||||
*/
|
||||
export const fetchAndReturnBlob = async (path: string) => {
|
||||
const response = await fetch(path)
|
||||
const blob = await response.blob()
|
||||
return blob
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and sanitizes text files
|
||||
* @param path the path of the text file to load
|
||||
* @returns the content of the text file after sanitizing
|
||||
*/
|
||||
export const fetchAndReturnText = async (path: string) => {
|
||||
const blob = await fetchAndReturnBlob(path)
|
||||
const text = await blob.text()
|
||||
const sanitized = DOMPurify.sanitize(text, domPurifyConfig)
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches, sanitizes, and parses YAML files
|
||||
* @param path the path of the YAML file to load
|
||||
* @returns the content of the YAML file after sanitizing then parsing
|
||||
*/
|
||||
export const fetchAndParseYaml = async <T>(path: string) => {
|
||||
const text = await fetchAndReturnText(path)
|
||||
return yaml.load(text) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches, sanitizes, and parses Markdown files
|
||||
* @param path the path of the markdown document to load
|
||||
* @returns the project parsed from the markdown document
|
||||
*/
|
||||
export const fetchAndParseMarkdown = async (path: string) => {
|
||||
const document = await fetchAndReturnText(path)
|
||||
return marked.parse(document)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches content type from an image
|
||||
* @param path the path of the file to check
|
||||
* @returns the Content-Type header of the file
|
||||
*/
|
||||
export const getContentType = async (path: string) => {
|
||||
const response = await fetch(path, {method: 'HEAD'})
|
||||
return response.headers.get('Content-Type')
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a url is a valid image
|
||||
* @param url the url of the image
|
||||
* @returns true if valid image, false otherwise
|
||||
*/
|
||||
export const isValidImage = async (url?: string): Promise<boolean> => {
|
||||
if (!url) {
|
||||
return false
|
||||
}
|
||||
const contentType = await getContentType(url)
|
||||
return contentType?.startsWith('image') || false
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Promise which waits for a condition to be true
|
||||
* @param condition the condition which should be met
|
||||
* @param interval the period of time (ms) to wait between attempts
|
||||
* @param tries the number of attempts (up to and including this number) to try before determining the condition couldn't be met
|
||||
* @returns A Promise which will resolve when the condition is met or reject if timed out after a period of `(interval * tries)`
|
||||
*/
|
||||
export const awaitCondition = (condition: () => boolean, interval: number = 100, tries: number = 100) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let _attempts = 1
|
||||
const wait: any = () => {
|
||||
_attempts += 1
|
||||
console.log(condition())
|
||||
if (condition()) {
|
||||
return resolve()
|
||||
} else if (_attempts > tries) {
|
||||
reject()
|
||||
} else {
|
||||
setTimeout(wait, interval)
|
||||
}
|
||||
}
|
||||
wait()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods related to local storage
|
||||
*/
|
||||
export const storage = {
|
||||
/**
|
||||
* Retrieves a value from local storage
|
||||
* @param key the key of the local storage item
|
||||
* @returns the parsed object or null
|
||||
*/
|
||||
read: <T>(key: string) => {
|
||||
const value = window.localStorage.getItem(key)
|
||||
return !!value
|
||||
? JSON.parse(value) as T
|
||||
: null
|
||||
},
|
||||
|
||||
/**
|
||||
* Stores a value to local storage
|
||||
* @param key the key of the local storage item
|
||||
* @param value the object to store
|
||||
*/
|
||||
write: <T>(key: string, value: T) => {
|
||||
window.localStorage.setItem(key, JSON.stringify(value))
|
||||
},
|
||||
}
|
52
src/utilities/marked.ts
Normal file
52
src/utilities/marked.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { MarkedExtension } from 'marked'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
|
||||
let slugger: GithubSlugger
|
||||
let sectionLevel = 1
|
||||
const sectionRegexps = new Array(7).fill(undefined).map((_, i) => new RegExp(`^ {0,3}(#{${i + 1}} )[^]*?(?:\\n(?=\\1)|$)`))
|
||||
const headingRegex = new RegExp(/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/)
|
||||
|
||||
export const headingSectionsExtension = (): MarkedExtension => {
|
||||
return {
|
||||
hooks: {
|
||||
preprocess(src) {
|
||||
slugger = new GithubSlugger()
|
||||
return src
|
||||
},
|
||||
postprocess(html) {
|
||||
return html
|
||||
}
|
||||
},
|
||||
extensions: [{
|
||||
name: 'headingSections',
|
||||
level: 'block',
|
||||
start(src: string) {
|
||||
// Match when # is at the beginning of a line.
|
||||
return src.match(/^ {0,3}#/m)?.index
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const match = sectionRegexps[sectionLevel].exec(src)
|
||||
if (!match) {
|
||||
return
|
||||
}
|
||||
|
||||
sectionLevel++
|
||||
// Tokenize text inside the section.
|
||||
// Only add sectionBlock token for headers one level up from current level.
|
||||
const tokens = this.lexer.blockTokens(match[0])
|
||||
sectionLevel--
|
||||
|
||||
return {
|
||||
type: 'headingSections',
|
||||
heading: headingRegex.exec(match[0])?.[2].trim(),
|
||||
raw: match[0],
|
||||
level: sectionLevel + 1,
|
||||
tokens,
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
return `<section id=${slugger.slug(token.heading)}>\n${this.parser.parse(token.tokens!)}</section>\n`
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
|
@ -1,16 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import embedableContent from 'src/components/shared/embedable-content.vue'
|
||||
|
||||
import { fetchAndParseMarkdown } from 'src/utilities/fetch'
|
||||
import { useRouteStore } from 'src/routes'
|
||||
|
||||
const content = ref('')
|
||||
const currentRoute = useRoute()
|
||||
const routeStore = useRouteStore()
|
||||
const routeConfig = routeStore._routes[currentRoute.path]
|
||||
|
||||
console.log(routeStore._routes)
|
||||
console.log(routeStore._routes[currentRoute.path])
|
||||
const init = async () => {
|
||||
content.value = await fetchAndParseMarkdown(routeConfig.content)
|
||||
}
|
||||
|
||||
init()
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
p Marked works
|
||||
.template.markdown
|
||||
embedable-content(
|
||||
v-if='content'
|
||||
:content='content'
|
||||
)
|
||||
</template>
|
||||
|
||||
<style scoped lang="sass">
|
||||
|
|
Loading…
Add table
Reference in a new issue