Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: PMarkdownRenderer #637

Merged
merged 72 commits into from
Feb 13, 2023
Merged

Conversation

znicholasbrown
Copy link
Contributor

@znicholasbrown znicholasbrown commented Feb 6, 2023

This PR introduces a markdown-rendering component which wraps the marked and dompurify libraries to deliver prefect-design-enabled components built off markdown + html strings.

The lexer (the program that extracts tokens from the string) is run in a worker similar to the one used by the PCodeHighlight component but sanitization (removing any potentially dangerous HTML attributes or tags) happens in the main thread. This is because it's not possible to use dompurify (or its wrapped sibling isomorphic-dompurify) without at least introducing JSDOM and even then it may not work.

Markdown support to add:

  • strong
  • emphasis
  • strikethrough
  • headings (1-6)
  • lists
    • ordered
    • unordered
    • task
  • inline code block
  • fenced code block
    • syntax highlighting
    • extended language tokens (js is added to javascript, for example)
  • blockquote
  • nested blockquote tokens
    • strong
    • emphasis
    • strikethrough
    • headings (1-6)
    • block quote
    • image
    • line break
    • paragraphs
    • horizontal rule
    • table
    • checkbox
    • ordered list
    • unordered list
    • task list
    • links
    • anchors
    • inline code block
    • fenced code block
    • highlighted fenced code block
  • image
  • line break
  • paragraphs
  • horizontal rule
  • table
  • checkbox
  • links
  • anchors
  • html
    • sanitization
    • rendering

@znicholasbrown
Copy link
Contributor Author

znicholasbrown commented Feb 9, 2023

There's 1 issue I'm still trying to address with this BUT it's not a blocker: table heights are infinite right now so I'm trying to find a solution that:

  1. lets us set a max table height but allow resizing (y direction only)
  2. fixes table headers while scrolling
  3. works for small tables as well

this is surprisingly more difficult than i anticipated, display: table doesn't play nicely

const router = useRouter()

const kebabHash = computed(() => {
return kebabCase(props.hash ?? '')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a bad contract but it does ensure that we have properly-formed urls absent encoded entities

Copy link
Contributor

@marichka-offen marichka-offen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great!

@stackoverfloweth
Copy link
Contributor

seems like there's something up with the "live" demo
https://user-images.githubusercontent.com/6098901/217919685-b42ea144-4616-4435-ad4f-879fb4a788be.mov

@znicholasbrown
Copy link
Contributor Author

Good catch @stackoverfloweth - looks like there's a piece of the table translation I hadn't accounted for that the fiber demo is using; working on that now!

src/components/Html/PHtml.vue Outdated Show resolved Hide resolved
src/components/HashLink/PHashLink.vue Show resolved Hide resolved
import { kebabCase } from '@/utilities'

const props = defineProps<{
depth?: number,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does depth do?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depth determines the heading tag used for semantic markup (h1, h2, etc); makes it really easy to use the component as-is if you're styling headers on tags instead of classes without additional wrappers/classes

import { marked } from 'marked'
import { computed, ref, watch } from 'vue'
import { getRootVNode } from '@/components/MarkdownRenderer/parser'
import MarkdownTokenWorker from '@/components/MarkdownRenderer/worker?worker&inline'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?worker?inline? is that intentional?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You know I'm not sure how a chunked worker would behave in the package since we haven't done that before... so I guess yes it's intentional only because I've not tried it any other way

@@ -0,0 +1,206 @@
import { marked } from 'marked'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it true that is only used in PMarkdownRenderer? Is there a reason to break it out into a separate ts file? Or was that only to keep the vue file shorter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah just here! And yeah that's exactly right; ideally I'd break this file out even further into factory classes like you mentioned here

src/components/MarkdownRenderer/parser.ts Outdated Show resolved Hide resolved
src/components/MarkdownRenderer/parser.ts Outdated Show resolved Hide resolved
src/components/MarkdownRenderer/parser.ts Outdated Show resolved Hide resolved
src/components/MarkdownRenderer/parser.ts Outdated Show resolved Hide resolved
export const getRootVNode = (tokens: marked.TokensList | [], options: ParserOptions): VNode => {
const children: VNode[] = tokens.map((token) => getVNode(token, options))
return h('article', { class: [baseClass] }, children)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this file should probably be arranged slightly different in the future. Perhaps more of a factory pattern where each supported component gets it's own file/class for parsing token. But for a 1st pass I think this is easy enough to follow 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed! I had the same thought last night that I'd break these out if I realized how many different branches this would take. If anyone else is finding this tough to follow and would prefer I took the time to break them out now I'm game, otherwise will focus attention elsewhere.

@znicholasbrown
Copy link
Contributor Author

All feedback has been addressed 👍🏻

@@ -24,14 +24,13 @@
tokens.value = message.tokens
}

const worker: Worker = new MarkdownTokenWorker()
const worker = new MarkdownTokenWorker()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eslint says something is unused now

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you have eslint running in your vscode?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yea I missed an issue there cause I'm getting spammed with unknown css rules for tailwind despite us having definitions for those @rules

return 'tokens' in token
}

export function isCodeBlock(token: Token): token is Token & { type: 'code' } {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all of these types guard below aren't doing anything that an inline if wouldn't equally accomplish. Usually a type guard checks a couple of things and changes the input from unknown to something specific. These are taking Token and returning Token but with a falsy check basically already done. Unless these are really reused in a lot of places, I would just do your falsy check in the component

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tightened the input restriction to unknown but I don't think you're correct that a type guard has to take an unknown type, even in this codebase, and certainly not more broadly. The point of these is to sufficiently narrow a Token to a more actionable subtype (I'm not sure I understand what you mean by returning a token with a falsy check).

An inline if statement would do the same thing, you're right, but that's what was here when you asked for a type guard, so maybe I misunderstood your initial comment. Keeping these guards inline is less readable (in my opinion) and more repetitive.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for being misleading. I do not believe every type guard needs to take unknown, the point I was trying to make (poorly) is that a type guard IMO should take some type and through some condition return a different type. I think what I was hoping for in my original comment about type guards was that there was a different sub type that we could use in a function like mapChildTokens. In that case, a type guard would be a great way to narrow from Token to ParentToken or whatever and then feed it to the properly function. In this case, the type you're returning is the same type you were given, but with a single truthy check performed on it. Maybe that's fine? Just seemed strange to me. Feel free to disregard

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh! In which case I think you'll be happy with the updated type guards - they're functionally the same but they do return a different type in the marked.Tokens namespace


try {
const url = new URL(route)
return url.host !== window.location.host
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

@znicholasbrown znicholasbrown merged commit c59c77e into main Feb 13, 2023
@znicholasbrown znicholasbrown deleted the feature-markdown-render-2023-01-02 branch February 13, 2023 20:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants