Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 73 additions & 69 deletions js/dist/shinychat.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions js/dist/shinychat.js.map

Large diffs are not rendered by default.

264 changes: 264 additions & 0 deletions js/src/markdown/plugins/normalizeAsideMarkdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import type { Html, ListItem, Root as MdastRoot } from "mdast"
import type { Node, Parent } from "unist"
import type { Plugin } from "unified"

export const remarkNormalizeListItemAsides: Plugin<[], MdastRoot> =
function () {
return (tree, file) => {
const source = file.toString()
const sourceEnd = tree.position?.end.offset
if (sourceEnd !== undefined && sourceEnd > source.length) return

const normalized = normalizeListItemAsides(source, tree)
if (!normalized) return

file.value = normalized
return this.parse(file) as MdastRoot
}
}

interface AsideRegion {
start: number
end: number
targetIndent: number
blockquoteDepth: number
}

interface AsideTag {
start: number
end: number
closing: boolean
selfClosing: boolean
targetIndent: number | null
}

function normalizeListItemAsides(
source: string,
tree: MdastRoot,
): string | null {
const regions = findProtectedRegions(source, tree)
if (regions.length === 0) return null

let normalized = source
for (const region of regions.sort(
(left, right) => right.start - left.start,
)) {
normalized = normalizeRegion(normalized, region)
}

return normalized === source ? null : normalized
}

function findProtectedRegions(source: string, tree: MdastRoot): AsideRegion[] {
const tags = collectAsideTags(tree)
const stack: AsideTag[] = []
const regions: AsideRegion[] = []

for (const tag of tags) {
if (tag.closing) {
const opening = stack.pop()
if (opening && opening.targetIndent !== null) {
const blockquoteDepth = blockquoteDepthAt(source, opening.start)
regions.push({
start: opening.start,
end: tag.end,
targetIndent:
opening.targetIndent -
blockquotePrefixWidth(source, opening.start, blockquoteDepth),
blockquoteDepth,
})
}
continue
}

if (tag.selfClosing) continue
if (stack.some((entry) => entry.targetIndent !== null)) {
tag.targetIndent = null
}
stack.push(tag)
}

return regions
}

function collectAsideTags(tree: MdastRoot): AsideTag[] {
const tags: AsideTag[] = []
collectNodeAsideTags(tree, null, tags)
return tags.sort((a, b) => a.start - b.start)
}

function collectNodeAsideTags(
node: Node,
listItem: ListItem | null,
tags: AsideTag[],
): void {
const currentListItem =
node.type === "listItem" ? (node as ListItem) : listItem

if (node.type === "html") {
collectHtmlAsideTags(node as Html, currentListItem, tags)
}

if ("children" in node) {
for (const child of (node as Parent).children) {
collectNodeAsideTags(child, currentListItem, tags)
}
}
}

function collectHtmlAsideTags(
node: Html,
listItem: ListItem | null,
tags: AsideTag[],
): void {
const offset = node.position?.start.offset
if (offset === undefined) return

const pattern =
/<\/shiny-aside\s*>|<shiny-aside(?=[\s/>])(?:"[^"]*"|'[^']*'|[^"'>])*>/g

for (const match of node.value.matchAll(pattern)) {
const value = match[0]
const start = offset + match.index
tags.push({
start,
end: start + value.length,
closing: value.startsWith("</"),
selfClosing: /\/\s*>$/.test(value),
targetIndent: listItem ? listItemIndent(listItem) : null,
})
}
}

function listItemIndent(listItem: ListItem): number | null {
for (const child of listItem.children) {
const column = child.position?.start.column
if (column !== undefined) return column - 1
}
return null
}

function normalizeRegion(source: string, region: AsideRegion): string {
const openingLineStart = source.lastIndexOf("\n", region.start - 1) + 1
const openingPrefix = source.slice(openingLineStart, region.start)
const openingIsAtLineStart = /^[\t ]*$/.test(openingPrefix)
const openingLineEnd = source.indexOf("\n", region.start)
const rewriteStart = openingIsAtLineStart
? openingLineStart
: openingLineEnd === -1
? region.end
: openingLineEnd + 1

if (rewriteStart >= region.end) return source

const fragment = source.slice(rewriteStart, region.end)
const baseline = minimumIndent(fragment, region.blockquoteDepth)
if (baseline === null || baseline >= region.targetIndent) return source

const indent = " ".repeat(region.targetIndent - baseline)
const normalized = indentLines(fragment, indent, region.blockquoteDepth)
return source.slice(0, rewriteStart) + normalized + source.slice(region.end)
}

function minimumIndent(value: string, blockquoteDepth: number): number | null {
let minimum: number | null = null

for (const line of value.split(/\r?\n/)) {
const content = line.slice(blockquotePrefixLength(line, blockquoteDepth))
if (content.trim() === "") continue
const indent = indentationWidth(content)
minimum = minimum === null ? indent : Math.min(minimum, indent)
}

return minimum
}

function indentLines(
value: string,
indent: string,
blockquoteDepth: number,
): string {
return value
.split(/(\r?\n)/)
.map((line, index) => {
if (index % 2 === 1) return line

const prefixLength = blockquotePrefixLength(line, blockquoteDepth)
const content = line.slice(prefixLength)
if (content.trim() === "") return line

return line.slice(0, prefixLength) + indent + content
})
.join("")
}

function blockquoteDepthAt(source: string, offset: number): number {
return blockquotePrefixDepth(sourceLineAt(source, offset))
}

function blockquotePrefixWidth(
source: string,
offset: number,
blockquoteDepth: number,
): number {
if (blockquoteDepth === 0) return 0

const line = sourceLineAt(source, offset)
return indentationWidth(
line.slice(0, blockquotePrefixLength(line, blockquoteDepth)),
)
}

function sourceLineAt(source: string, offset: number): string {
const lineStart = source.lastIndexOf("\n", offset - 1) + 1
const lineEnd = source.indexOf("\n", offset)
return source.slice(lineStart, lineEnd === -1 ? source.length : lineEnd)
}

function blockquotePrefixLength(line: string, depth: number): number {
let index = 0
let count = 0

while (count < depth) {
const start = index
while (index - start < 3 && line[index] === " ") index++
if (line[index] !== ">") return start

index++
if (line[index] === " " || line[index] === "\t") index++
count++
}

return index
}

function blockquotePrefixDepth(line: string): number {
let index = 0
let depth = 0

while (true) {
const start = index
while (index - start < 3 && line[index] === " ") index++
if (line[index] !== ">") return depth

index++
if (line[index] === " " || line[index] === "\t") index++
depth++
}
}

function indentationWidth(value: string): number {
let width = 0

for (const character of value) {
if (character === " ") {
width++
} else if (character === "\t") {
width += 4 - (width % 4)
} else {
break
}
}

return width
}
53 changes: 52 additions & 1 deletion js/src/markdown/plugins/rehypeGroupAsides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,34 @@ function collectAndRemoveRootAsides(root: Root): Element[] {
return collected
}

function moveLooseListItemAsidesIntoParagraphs(tree: Root): void {
visit(tree, "element", (node: Element) => {
if (node.tagName !== "li" || !hasNestedParagraph(node)) return

let precedingParagraph: Element | null = null
let index = 0
while (index < node.children.length) {
const child = node.children[index]!
if (child.type === "element" && child.tagName === "p") {
precedingParagraph = child
index += 1
continue
}

if (precedingParagraph && isAside(child)) {
precedingParagraph.children.push(child)
node.children.splice(index, 1)
continue
}

if (child.type !== "text" || child.value.trim() !== "") {
precedingParagraph = null
}
index += 1
}
})
}

function makeGroup(children: Element[]): Element {
return {
type: "element",
Expand Down Expand Up @@ -87,16 +115,39 @@ function assignAnonymousAsideIndexes(tree: Root): void {
}

function transform(tree: Root): void {
moveLooseListItemAsidesIntoParagraphs(tree)

visit(tree, "element", (node: Element) => {
if (!isAsideContainer(node)) return
node.children.push(...makeGroups(collectAndRemoveAsides(node)))
const found = collectAndRemoveAsides(node)
if (found.length === 0) return
trimTrailingAsideBreaks(node.children)
node.children.push(...makeGroups(found))
})

const rootGroups = makeGroups(collectAndRemoveRootAsides(tree))
tree.children.push(...rootGroups)
assignAnonymousAsideIndexes(tree)
}

function trimTrailingAsideBreaks(children: ElementContent[]): void {
while (children.length > 0) {
const last = children[children.length - 1]!
if (last.type === "element" && last.tagName === "br") {
children.pop()
continue
}
if (last.type !== "text") return

const value = last.value.trimEnd()
if (value) {
last.value = value
return
}
children.pop()
}
}

/**
* Rehype plugin that processes every <shiny-aside> found anywhere within
* a paragraph or tight list item. Asides carrying a `label` collapse into
Expand Down
2 changes: 2 additions & 0 deletions js/src/markdown/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
rehypeRewriteAsideToTemplate,
rehypeRewriteAsideFromTemplate,
} from "./plugins/rewriteAsideTemplate"
import { remarkNormalizeListItemAsides } from "./plugins/normalizeAsideMarkdown"

/**
* Frozen processor for markdown content.
Expand All @@ -32,6 +33,7 @@ import {
export const markdownProcessor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkNormalizeListItemAsides)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRewriteAsideToTemplate)
.use(rehypeRaw)
Expand Down
Loading
Loading