407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
import {
|
|
lineEnding,
|
|
lineText,
|
|
type BlockLike,
|
|
} from './durableMarkdownBlocks'
|
|
import {
|
|
isVaultAttachmentUrl,
|
|
portableAttachmentPathFromCurrentVaultAssetUrl,
|
|
portableAttachmentPathFromCurrentVaultPath,
|
|
} from './vaultAttachments'
|
|
|
|
interface FileAttachmentPayload {
|
|
name: string
|
|
url: string
|
|
caption?: string
|
|
}
|
|
|
|
interface SerializeFileAttachmentBlocksOptions {
|
|
blocks: unknown[]
|
|
serializeOrdinaryBlocks: (blocks: unknown[]) => string
|
|
vaultPath?: VaultPath
|
|
}
|
|
|
|
interface FlushOrdinaryBlocksOptions {
|
|
chunks: string[]
|
|
pending: unknown[]
|
|
serializeOrdinaryBlocks: (blocks: unknown[]) => string
|
|
}
|
|
|
|
type FileAttachmentLineTransform = (payload: FileAttachmentPayload) => string | null
|
|
type AttachmentUrlReader = (value: unknown) => AttachmentUrl | null
|
|
type MarkdownFence = { character: string; length: number }
|
|
type AttachmentUrl = string
|
|
type Markdown = string
|
|
type MarkdownLine = string
|
|
type MarkdownText = string
|
|
type VaultPath = string
|
|
|
|
interface PreProcessFileAttachmentMarkdownOptions {
|
|
markdown: Markdown
|
|
}
|
|
|
|
interface TransformFileAttachmentLinksOptions {
|
|
markdown: Markdown
|
|
readUrl?: AttachmentUrlReader
|
|
transform: FileAttachmentLineTransform
|
|
}
|
|
|
|
interface TransformLineOptions {
|
|
fence: MarkdownFence | null
|
|
line: MarkdownLine
|
|
readUrl: AttachmentUrlReader
|
|
transform: FileAttachmentLineTransform
|
|
}
|
|
|
|
interface TransformedLine {
|
|
fence: MarkdownFence | null
|
|
line: MarkdownLine
|
|
}
|
|
|
|
const FILE_ATTACHMENT_TOKEN_PREFIX = '@@TOLARIA_FILE_ATTACHMENT:'
|
|
const FILE_ATTACHMENT_TOKEN_SUFFIX = '@@'
|
|
const STANDALONE_ATTACHMENT_LINK_PATTERN = /^( {0,3})\[((?:\\.|[^\]\\\n])*)\]\((<[^>\n]+>|(?:\\.|[^)\s\n])+)(?:[ \t]+"((?:\\.|[^"\\\n])*)")?\)[ \t]*$/u
|
|
|
|
function splitMarkdownLines(markdown: Markdown): MarkdownLine[] {
|
|
const lines = markdown.match(/[^\n]*(?:\n|$)/g) ?? []
|
|
return lines.filter((line, index) => line !== '' || index < lines.length - 1)
|
|
}
|
|
|
|
function safeDecode(value: MarkdownText): MarkdownText {
|
|
try {
|
|
return decodeURIComponent(value)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
function escapeMarkdownLabel(value: MarkdownText): MarkdownText {
|
|
return value.replace(/\\/g, '\\\\').replace(/\]/g, '\\]')
|
|
}
|
|
|
|
function escapeMarkdownTitle(value: MarkdownText): MarkdownText {
|
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
}
|
|
|
|
function escapeMarkdownDestination(value: AttachmentUrl): MarkdownText {
|
|
if (/[\s<>]/u.test(value)) return `<${value.replace(/>/g, '%3E')}>`
|
|
return value.replace(/\\/g, '\\\\').replace(/\)/g, '\\)')
|
|
}
|
|
|
|
function unescapeMarkdownText(value: MarkdownText): MarkdownText {
|
|
return value.replace(/\\([\\\]"'])/g, '$1')
|
|
}
|
|
|
|
function unescapeMarkdownDestination(value: MarkdownText): AttachmentUrl {
|
|
const withoutAngles = value.startsWith('<') && value.endsWith('>')
|
|
? value.slice(1, -1)
|
|
: value
|
|
return withoutAngles.replace(/\\([\\()<>])/g, '$1')
|
|
}
|
|
|
|
function fileNameFromUrl(url: AttachmentUrl): MarkdownText {
|
|
const path = safeDecode(url.split(/[?#]/u)[0] ?? url)
|
|
return path.split(/[\\/]/u).filter(Boolean).at(-1) ?? url
|
|
}
|
|
|
|
function serializeMarkdownFileLink(payload: FileAttachmentPayload): Markdown {
|
|
const title = payload.caption ? ` "${escapeMarkdownTitle(payload.caption)}"` : ''
|
|
return `[${escapeMarkdownLabel(payload.name)}](${escapeMarkdownDestination(payload.url)}${title})`
|
|
}
|
|
|
|
function encodePayload(payload: FileAttachmentPayload): MarkdownText {
|
|
return encodeURIComponent(JSON.stringify(payload))
|
|
}
|
|
|
|
function fileAttachmentToken(payload: FileAttachmentPayload): MarkdownText {
|
|
return `${FILE_ATTACHMENT_TOKEN_PREFIX}${encodePayload(payload)}${FILE_ATTACHMENT_TOKEN_SUFFIX}`
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
function readNonEmptyText(value: unknown): MarkdownText | null {
|
|
return typeof value === 'string' && value.trim() ? value : null
|
|
}
|
|
|
|
function readAttachmentUrl(value: unknown): AttachmentUrl | null {
|
|
const url = readNonEmptyText(value)
|
|
return url && isVaultAttachmentUrl({ url }) ? url : null
|
|
}
|
|
|
|
function readAnyLinkUrl(value: unknown): AttachmentUrl | null {
|
|
return readNonEmptyText(value)
|
|
}
|
|
|
|
function readPayloadName(record: Record<string, unknown>, url: AttachmentUrl): MarkdownText {
|
|
return readNonEmptyText(record.name) ?? fileNameFromUrl(url)
|
|
}
|
|
|
|
function readOptionalCaption(value: unknown): MarkdownText | undefined {
|
|
return typeof value === 'string' && value ? value : undefined
|
|
}
|
|
|
|
function fileAttachmentPayload(payload: FileAttachmentPayload): FileAttachmentPayload {
|
|
return payload.caption
|
|
? payload
|
|
: { name: payload.name, url: payload.url }
|
|
}
|
|
|
|
function normalizePayload(value: unknown): FileAttachmentPayload | null {
|
|
if (!isRecord(value)) return null
|
|
|
|
const url = readAttachmentUrl(value.url)
|
|
if (!url) return null
|
|
|
|
return fileAttachmentPayload({
|
|
name: readPayloadName(value, url),
|
|
url,
|
|
caption: readOptionalCaption(value.caption),
|
|
})
|
|
}
|
|
|
|
function readFileAttachmentToken(text: MarkdownText): FileAttachmentPayload | null {
|
|
const trimmed = text.trim()
|
|
if (!trimmed.startsWith(FILE_ATTACHMENT_TOKEN_PREFIX)) return null
|
|
if (!trimmed.endsWith(FILE_ATTACHMENT_TOKEN_SUFFIX)) return null
|
|
|
|
const encoded = trimmed.slice(
|
|
FILE_ATTACHMENT_TOKEN_PREFIX.length,
|
|
-FILE_ATTACHMENT_TOKEN_SUFFIX.length,
|
|
)
|
|
try {
|
|
return normalizePayload(JSON.parse(decodeURIComponent(encoded)))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function readStandaloneFileAttachmentLink(
|
|
text: MarkdownText,
|
|
readUrl: AttachmentUrlReader = readAttachmentUrl,
|
|
): FileAttachmentPayload | null {
|
|
const match = STANDALONE_ATTACHMENT_LINK_PATTERN.exec(text)
|
|
if (!match) return null
|
|
|
|
const url = readUrl(unescapeMarkdownDestination(match[3] ?? ''))
|
|
if (!url) return null
|
|
|
|
const name = unescapeMarkdownText(match[2] ?? '') || fileNameFromUrl(url)
|
|
const caption = match[4] ? unescapeMarkdownText(match[4]) : undefined
|
|
return fileAttachmentPayload({ name, url, caption })
|
|
}
|
|
|
|
function readFenceOpening(text: MarkdownText): MarkdownFence | null {
|
|
const match = /^( {0,3})(`{3,}|~{3,})/u.exec(text)
|
|
const fence = match?.[2]
|
|
return fence ? { character: fence.charAt(0), length: fence.length } : null
|
|
}
|
|
|
|
function isFenceClosing(text: MarkdownText, fence: MarkdownFence): boolean {
|
|
const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/u.exec(text)
|
|
const closing = match?.[2]
|
|
return !!closing && closing.charAt(0) === fence.character && closing.length >= fence.length
|
|
}
|
|
|
|
function transformAttachmentLine(
|
|
line: MarkdownLine,
|
|
readUrl: AttachmentUrlReader,
|
|
transform: FileAttachmentLineTransform,
|
|
): MarkdownLine {
|
|
const payload = readStandaloneFileAttachmentLink(lineText({ line }), readUrl)
|
|
if (!payload) return line
|
|
|
|
const transformed = transform(payload)
|
|
return transformed ? `${transformed}${lineEnding({ line })}` : line
|
|
}
|
|
|
|
function transformFencedLine(line: MarkdownLine, text: MarkdownText, fence: MarkdownFence): TransformedLine {
|
|
return {
|
|
line,
|
|
fence: isFenceClosing(text, fence) ? null : fence,
|
|
}
|
|
}
|
|
|
|
function transformUnfencedLine(
|
|
line: MarkdownLine,
|
|
text: MarkdownText,
|
|
readUrl: AttachmentUrlReader,
|
|
transform: FileAttachmentLineTransform,
|
|
): TransformedLine {
|
|
const opening = readFenceOpening(text)
|
|
if (opening) {
|
|
return {
|
|
line,
|
|
fence: opening,
|
|
}
|
|
}
|
|
|
|
const transformedLine = transformAttachmentLine(line, readUrl, transform)
|
|
return {
|
|
line: transformedLine,
|
|
fence: null,
|
|
}
|
|
}
|
|
|
|
function transformLine(options: TransformLineOptions): TransformedLine {
|
|
const text = lineText({ line: options.line })
|
|
if (options.fence) {
|
|
return transformFencedLine(options.line, text, options.fence)
|
|
}
|
|
|
|
return transformUnfencedLine(options.line, text, options.readUrl, options.transform)
|
|
}
|
|
|
|
function transformStandaloneFileAttachmentLinks(options: TransformFileAttachmentLinksOptions): Markdown {
|
|
const lines: MarkdownLine[] = []
|
|
let fence: MarkdownFence | null = null
|
|
const readUrl = options.readUrl ?? readAttachmentUrl
|
|
|
|
for (const line of splitMarkdownLines(options.markdown)) {
|
|
const transformed = transformLine({ line, fence, readUrl, transform: options.transform })
|
|
lines.push(transformed.line)
|
|
fence = transformed.fence
|
|
}
|
|
|
|
return lines.join('')
|
|
}
|
|
|
|
function readTokenPayload(block: BlockLike): FileAttachmentPayload | null {
|
|
const content = block.content
|
|
if (!Array.isArray(content) || content.length !== 1) return null
|
|
|
|
const item = content[0]
|
|
if (item?.type !== 'text' || typeof item.text !== 'string') return null
|
|
return readFileAttachmentToken(item.text)
|
|
}
|
|
|
|
function buildFileAttachmentBlock(block: BlockLike, payload: FileAttachmentPayload): BlockLike {
|
|
return {
|
|
...block,
|
|
type: 'file',
|
|
props: {
|
|
...block.props,
|
|
backgroundColor: block.props?.backgroundColor ?? 'default',
|
|
name: payload.name,
|
|
url: payload.url,
|
|
caption: payload.caption ?? '',
|
|
},
|
|
content: undefined,
|
|
children: [],
|
|
}
|
|
}
|
|
|
|
function injectFileAttachmentBlock(block: BlockLike): BlockLike {
|
|
const payload = readTokenPayload(block)
|
|
if (payload) return buildFileAttachmentBlock(block, payload)
|
|
|
|
const children = Array.isArray(block.children)
|
|
? block.children.map(injectFileAttachmentBlock)
|
|
: block.children
|
|
return { ...block, children }
|
|
}
|
|
|
|
export function injectFileAttachmentBlocks(blocks: unknown[]): unknown[] {
|
|
return (blocks as BlockLike[]).map(injectFileAttachmentBlock)
|
|
}
|
|
|
|
function readBlockAttachmentUrl(block: BlockLike, vaultPath?: VaultPath): AttachmentUrl | null {
|
|
if (block.type !== 'file') return null
|
|
|
|
const url = readAttachmentUrl(block.props?.url)
|
|
if (url) return url
|
|
|
|
const rawUrl = readNonEmptyText(block.props?.url)
|
|
if (!rawUrl || !vaultPath) return null
|
|
return portableAttachmentPathFromCurrentVaultPath({ path: rawUrl, vaultPath })
|
|
}
|
|
|
|
function readBlockAttachmentName(block: BlockLike, url: AttachmentUrl): MarkdownText {
|
|
return block.props?.name?.trim() || fileNameFromUrl(url)
|
|
}
|
|
|
|
function readFileAttachmentBlockPayload(block: BlockLike, vaultPath?: VaultPath): FileAttachmentPayload | null {
|
|
const url = readBlockAttachmentUrl(block, vaultPath)
|
|
if (!url) return null
|
|
|
|
return fileAttachmentPayload({
|
|
name: readBlockAttachmentName(block, url),
|
|
url,
|
|
caption: block.props?.caption?.trim(),
|
|
})
|
|
}
|
|
|
|
function flushOrdinaryBlocks({
|
|
chunks,
|
|
pending,
|
|
serializeOrdinaryBlocks,
|
|
}: FlushOrdinaryBlocksOptions): unknown[] {
|
|
if (pending.length === 0) return pending
|
|
|
|
const markdown = serializeOrdinaryBlocks(pending).trimEnd()
|
|
if (markdown) chunks.push(markdown)
|
|
return []
|
|
}
|
|
|
|
export function serializeFileAttachmentBlocks({
|
|
blocks,
|
|
serializeOrdinaryBlocks,
|
|
vaultPath,
|
|
}: SerializeFileAttachmentBlocksOptions): string {
|
|
const chunks: string[] = []
|
|
let pending: unknown[] = []
|
|
|
|
for (const block of blocks as BlockLike[]) {
|
|
const payload = readFileAttachmentBlockPayload(block, vaultPath)
|
|
if (!payload) {
|
|
pending.push(block)
|
|
continue
|
|
}
|
|
|
|
pending = flushOrdinaryBlocks({ chunks, pending, serializeOrdinaryBlocks })
|
|
chunks.push(serializeMarkdownFileLink(payload))
|
|
}
|
|
|
|
flushOrdinaryBlocks({ chunks, pending, serializeOrdinaryBlocks })
|
|
return chunks.join('\n\n')
|
|
}
|
|
|
|
function hasFileAttachmentBlock(block: BlockLike): boolean {
|
|
if (readFileAttachmentBlockPayload(block)) return true
|
|
return Array.isArray(block.children)
|
|
? block.children.some(hasFileAttachmentBlock)
|
|
: false
|
|
}
|
|
|
|
export function hasFileAttachmentBlocks(blocks: unknown[]): boolean {
|
|
return (blocks as BlockLike[]).some(hasFileAttachmentBlock)
|
|
}
|
|
|
|
function portableAttachmentUrl(payload: FileAttachmentPayload, vaultPath: VaultPath): AttachmentUrl | null {
|
|
return portableAttachmentPathFromCurrentVaultAssetUrl({ url: payload.url, vaultPath })
|
|
?? portableAttachmentPathFromCurrentVaultPath({ path: payload.url, vaultPath })
|
|
}
|
|
|
|
export function portableFileAttachmentUrls(markdown: Markdown, vaultPath: VaultPath): Markdown {
|
|
if (!vaultPath) return markdown
|
|
|
|
return transformStandaloneFileAttachmentLinks({
|
|
markdown,
|
|
readUrl: readAnyLinkUrl,
|
|
transform: (payload) => {
|
|
const url = portableAttachmentUrl(payload, vaultPath)
|
|
return url ? serializeMarkdownFileLink({ ...payload, url }) : null
|
|
},
|
|
})
|
|
}
|
|
|
|
export function preProcessFileAttachmentMarkdown(
|
|
options: PreProcessFileAttachmentMarkdownOptions,
|
|
): Markdown {
|
|
return transformStandaloneFileAttachmentLinks({
|
|
markdown: options.markdown,
|
|
transform: fileAttachmentToken,
|
|
})
|
|
}
|