feat(web): use Markdown helper class to interact with markdown

This commit is contained in:
Pujit Mehrotra
2024-11-19 09:59:39 -05:00
parent 83e00c640a
commit 2f4ff21986
3 changed files with 69 additions and 31 deletions

View File

@@ -10,18 +10,19 @@ import {
import { useMutation } from '@vue/apollo-composable'; import { useMutation } from '@vue/apollo-composable';
import type { NotificationFragmentFragment } from '~/composables/gql/graphql'; import type { NotificationFragmentFragment } from '~/composables/gql/graphql';
import { NotificationType } from '~/composables/gql/graphql'; import { NotificationType } from '~/composables/gql/graphql';
import { safeParseMarkdown } from '~/helpers/markdown';
import { import {
archiveNotification as archiveMutation, archiveNotification as archiveMutation,
deleteNotification as deleteMutation, deleteNotification as deleteMutation,
} from './graphql/notification.query'; } from './graphql/notification.query';
import { Markdown } from '@/helpers/markdown';
const props = defineProps<NotificationFragmentFragment>(); const props = defineProps<NotificationFragmentFragment>();
const descriptionMarkup = computedAsync(async () => { const descriptionMarkup = computedAsync(async () => {
try { try {
return await safeParseMarkdown(props.description); return await Markdown.parse(props.description);
} catch (e) { } catch (e) {
console.error(e)
return props.description; return props.description;
} }
}, ''); }, '');

View File

@@ -1,13 +1,41 @@
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { marked } from 'marked'; import { Marked, type MarkedExtension } from 'marked';
const defaultMarkedExtension: MarkedExtension = {
hooks: {
// must define as a function (instead of a lambda) to preserve/reflect bindings downstream
postprocess(html) {
return DOMPurify.sanitize(html);
},
},
};
/** /**
* Parses arbitrary markdown content as sanitized html. May throw if parsing fails. * Helper class to build or conveniently use a markdown parser.
*
* @param markdownContent string of markdown content
* @returns safe, sanitized html content
*/ */
export async function safeParseMarkdown(markdownContent: string) { export class Markdown {
const parsed = await marked.parse(markdownContent); private static instance = Markdown.create();
return DOMPurify.sanitize(parsed);
/**
* Creates a `Marked` instance with default MarkedExtension's already added.
*
* Default behaviors:
* - Sanitizes html after parsing
*
* @param args any number of Marked Extensions
* @returns Marked parser instance
*/
static create(...args: Parameters<Marked['use']>) {
return new Marked(defaultMarkedExtension, ...args);
}
/**
* Parses arbitrary markdown content as sanitized html. May throw if parsing fails.
*
* @param markdownContent string of markdown content
* @returns safe, sanitized html content
*/
static async parse(markdownContent: string): Promise<string> {
return Markdown.instance.parse(markdownContent);
}
} }

View File

@@ -1,16 +1,15 @@
import { marked } from 'marked'; import { Markdown } from '@/helpers/markdown';
import { request } from '~/composables/services/request';
import { DOCS_RELEASE_NOTES } from '~/helpers/urls';
import { useCallbackStore } from '~/store/callbackActions';
// import { useServerStore } from '~/store/server';
import type { ServerUpdateOsResponse } from '~/types/server';
import { Marked } from 'marked';
import { baseUrl } from 'marked-base-url'; import { baseUrl } from 'marked-base-url';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import prerelease from 'semver/functions/prerelease'; import prerelease from 'semver/functions/prerelease';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { DOCS_RELEASE_NOTES } from '~/helpers/urls';
import { request } from '~/composables/services/request';
import { useCallbackStore } from '~/store/callbackActions';
// import { useServerStore } from '~/store/server';
import type { ServerUpdateOsResponse } from '~/types/server';
import { safeParseMarkdown } from '~/helpers/markdown';
export const useUpdateOsChangelogStore = defineStore('updateOsChangelog', () => { export const useUpdateOsChangelogStore = defineStore('updateOsChangelog', () => {
const callbackStore = useCallbackStore(); const callbackStore = useCallbackStore();
// const serverStore = useServerStore(); // const serverStore = useServerStore();
@@ -30,10 +29,15 @@ export const useUpdateOsChangelogStore = defineStore('updateOsChangelog', () =>
if (!releaseForUpdate.value || !releaseForUpdate.value?.changelog) { if (!releaseForUpdate.value || !releaseForUpdate.value?.changelog) {
return ''; return '';
} }
return releaseForUpdate.value?.changelog ?? `https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/${releaseForUpdate.value.version}.md`; return (
releaseForUpdate.value?.changelog ??
`https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/${releaseForUpdate.value.version}.md`
);
}); });
const isReleaseForUpdateStable = computed(() => releaseForUpdate.value ? prerelease(releaseForUpdate.value.version) === null : false); const isReleaseForUpdateStable = computed(() =>
releaseForUpdate.value ? prerelease(releaseForUpdate.value.version) === null : false
);
const parsedChangelog = ref<string>(''); const parsedChangelog = ref<string>('');
const parseChangelogFailed = ref<string>(''); const parseChangelogFailed = ref<string>('');
// used to remove the first <h1></h1> and it's contents from the parsedChangelog // used to remove the first <h1></h1> and it's contents from the parsedChangelog
@@ -49,7 +53,10 @@ export const useUpdateOsChangelogStore = defineStore('updateOsChangelog', () =>
return parseChangelogFailed.value; return parseChangelogFailed.value;
} }
if (parsedChangelog.value) { if (parsedChangelog.value) {
return parsedChangelog.value.match(/<h1>(.*?)<\/h1>/)?.[1] ?? `Version ${releaseForUpdate.value?.version} ${releaseForUpdate.value?.date}`; return (
parsedChangelog.value.match(/<h1>(.*?)<\/h1>/)?.[1] ??
`Version ${releaseForUpdate.value?.version} ${releaseForUpdate.value?.date}`
);
} }
return ''; return '';
}); });
@@ -72,7 +79,7 @@ export const useUpdateOsChangelogStore = defineStore('updateOsChangelog', () =>
.text(); .text();
// set base url for relative links // set base url for relative links
marked.use(baseUrl(DOCS_RELEASE_NOTES.toString())); const marked = Markdown.create(baseUrl(DOCS_RELEASE_NOTES.toString()));
// open links in new tab & replace .md from links // open links in new tab & replace .md from links
const renderer = new marked.Renderer(); const renderer = new marked.Renderer();
@@ -80,20 +87,20 @@ export const useUpdateOsChangelogStore = defineStore('updateOsChangelog', () =>
options: { options: {
sanitize: true, sanitize: true,
}, },
render: marked.Renderer.prototype.link render: marked.Renderer.prototype.link,
}; };
renderer.link = function (href, title, text) { renderer.link = function (href, title, text) {
const anchor = anchorRender.render(href, title, text); const anchor = anchorRender.render(href, title, text);
return anchor return anchor
.replace('<a', '<a target=\'_blank\' ') // open links in new tab .replace('<a', "<a target='_blank' ") // open links in new tab
.replace('.md', ''); // remove .md from links .replace('.md', ''); // remove .md from links
}; };
marked.setOptions({ marked.setOptions({
renderer renderer,
}); });
parsedChangelog.value = await safeParseMarkdown(changelogMarkdownRaw); parsedChangelog.value = await marked.parse(changelogMarkdownRaw);
} catch (error: unknown) { } catch (error: unknown) {
const caughtError = error as Error; const caughtError = error as Error;
parseChangelogFailed.value = parseChangelogFailed.value =
@@ -106,12 +113,14 @@ export const useUpdateOsChangelogStore = defineStore('updateOsChangelog', () =>
const fetchAndConfirmInstall = (sha256: string) => { const fetchAndConfirmInstall = (sha256: string) => {
callbackStore.send( callbackStore.send(
window.location.href, window.location.href,
[{ [
sha256, {
type: 'updateOs', sha256,
}], type: 'updateOs',
},
],
undefined, undefined,
'forUpc', 'forUpc'
); );
}; };