(core) Add Markdown cell format

Summary:
Text columns can now display their values as Markdown-formatted text
by changing their cell format to "Markdown". A minimal subset of the
Markdown specification is currently supported.

Test Plan: Browser tests.

Reviewers: Spoffy, dsagal

Reviewed By: Spoffy, dsagal

Subscribers: dsagal, Spoffy

Differential Revision: https://phab.getgrist.com/D4326
This commit is contained in:
George Gevoian
2024-08-22 23:51:09 -04:00
parent 5c486e686e
commit 292c894b93
26 changed files with 353 additions and 84 deletions

View File

@@ -4,7 +4,7 @@ import {logTelemetryEvent} from 'app/client/lib/telemetry';
import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
import {renderer} from 'app/client/ui/DocTutorialRenderer';
import {cssPopupBody, FLOATING_POPUP_TOOLTIP_KEY, FloatingPopup} from 'app/client/ui/FloatingPopup';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {sanitizeTutorialHTML} from 'app/client/ui/sanitizeHTML';
import {hoverTooltip, setHoverTooltip} from 'app/client/ui/tooltips';
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
import {mediaXSmall, theme, vars} from 'app/client/ui2018/cssVars';
@@ -13,7 +13,7 @@ import {loadingSpinner} from 'app/client/ui2018/loaders';
import {confirmModal, modal} from 'app/client/ui2018/modals';
import {parseUrlId} from 'app/common/gristUrls';
import {dom, makeTestId, Observable, styled} from 'grainjs';
import {marked} from 'marked';
import {marked, Token} from 'marked';
import debounce = require('lodash/debounce');
import range = require('lodash/range');
import sortBy = require('lodash/sortBy');
@@ -219,7 +219,7 @@ export class DocTutorial extends FloatingPopup {
return value ? String(value) : undefined;
};
const walkTokens = (token: marked.Token) => {
const walkTokens = (token: Token) => {
if (token.type === 'image') {
imageUrls.push(token.href);
}
@@ -231,13 +231,13 @@ export class DocTutorial extends FloatingPopup {
let slideContent = getValue('slide_content');
if (!slideContent) { return null; }
slideContent = sanitizeHTML(await marked.parse(slideContent, {
slideContent = sanitizeTutorialHTML(await marked.parse(slideContent, {
async: true, renderer, walkTokens
}));
let boxContent = getValue('box_content');
if (boxContent) {
boxContent = sanitizeHTML(await marked.parse(boxContent, {
boxContent = sanitizeTutorialHTML(await marked.parse(boxContent, {
async: true, renderer, walkTokens
}));
}

View File

@@ -2,7 +2,7 @@ import {marked} from 'marked';
export const renderer = new marked.Renderer();
renderer.image = (href: string | null, title: string | null, _text: string) => {
renderer.image = ({href, title}) => {
let classes = 'doc-tutorial-popup-thumbnail';
const hash = href?.split('#')?.[1];
if (hash) {
@@ -17,6 +17,6 @@ renderer.image = (href: string | null, title: string | null, _text: string) => {
</div>`;
};
renderer.link = (href: string | null, _title: string | null, text: string) => {
renderer.link = ({href, text}) => {
return `<a href="${href}" target="_blank">${text}</a>`;
};

View File

@@ -0,0 +1,12 @@
import {gristIconLink} from 'app/client/ui2018/links';
import escape from 'lodash/escape';
import {marked} from 'marked';
export const renderer = new marked.Renderer();
renderer.link = ({href, text}) => gristIconLink(href, text).outerHTML;
// Disable Markdown features that we aren't ready to support yet.
renderer.hr = ({raw}) => raw;
renderer.html = ({raw}) => escape(raw);
renderer.image = ({raw}) => raw;

View File

@@ -1,16 +1,30 @@
import DOMPurify from 'dompurify';
import createDOMPurifier from 'dompurify';
const config = {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allowFullscreen'],
};
export function sanitizeHTML(source: string | Node): string {
return defaultPurifier.sanitize(source);
}
DOMPurify.addHook('uponSanitizeAttribute', (node) => {
export function sanitizeTutorialHTML(source: string | Node): string {
return tutorialPurifier.sanitize(source, {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allowFullscreen'],
});
}
const defaultPurifier = createDOMPurifier();
defaultPurifier.addHook('uponSanitizeAttribute', handleSanitizeAttribute);
const tutorialPurifier = createDOMPurifier();
tutorialPurifier.addHook('uponSanitizeAttribute', handleSanitizeAttribute);
tutorialPurifier.addHook('uponSanitizeElement', handleSanitizeTutorialElement);
function handleSanitizeAttribute(node: Element) {
if (!('target' in node)) { return; }
node.setAttribute('target', '_blank');
});
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
}
function handleSanitizeTutorialElement(node: Element, data: createDOMPurifier.SanitizeElementHookEvent) {
if (data.tagName !== 'iframe') { return; }
const src = node.getAttribute('src');
@@ -18,9 +32,5 @@ DOMPurify.addHook('uponSanitizeElement', (node, data) => {
return;
}
return node.parentNode?.removeChild(node);
});
export function sanitizeHTML(source: string | Node): string {
return DOMPurify.sanitize(source, config);
node.parentNode?.removeChild(node);
}