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 =
|
type Template =
|
||||||
| 'markdown'
|
| 'markdown'
|
||||||
|
|
||||||
type RouteDefinition = {
|
type GenericRouteDefinition = {
|
||||||
id: string
|
id: string
|
||||||
template: Template
|
template: Template
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MarkdownDefinition = GenericRouteDefinition & {
|
||||||
|
template: 'markdown'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteDefinition =
|
||||||
|
| MarkdownDefinition
|
||||||
|
|
||||||
type RouteCollection = { [key: string]: RouteDefinition }
|
type RouteCollection = { [key: string]: RouteDefinition }
|
||||||
|
|
||||||
const routes: RouteCollection
|
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">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
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'
|
import { useRouteStore } from 'src/routes'
|
||||||
|
|
||||||
|
const content = ref('')
|
||||||
const currentRoute = useRoute()
|
const currentRoute = useRoute()
|
||||||
const routeStore = useRouteStore()
|
const routeStore = useRouteStore()
|
||||||
|
const routeConfig = routeStore._routes[currentRoute.path]
|
||||||
|
|
||||||
console.log(routeStore._routes)
|
const init = async () => {
|
||||||
console.log(routeStore._routes[currentRoute.path])
|
content.value = await fetchAndParseMarkdown(routeConfig.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
p Marked works
|
.template.markdown
|
||||||
|
embedable-content(
|
||||||
|
v-if='content'
|
||||||
|
:content='content'
|
||||||
|
)
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="sass">
|
<style scoped lang="sass">
|
||||||
|
|
Loading…
Add table
Reference in a new issue