mirror of https://github.com/requarks/wiki
parent
50e615db9a
commit
6dc3c3b10f
@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Rewriting a link in a page's SOURCE, for a page whose target moved.
|
||||||
|
*
|
||||||
|
* The render half is `rewriteRenderLinks` in `helpers/pageLinks.ts`, which has a parsed tree to work
|
||||||
|
* on. This half does not: there is no markdown or asciidoc parser on this side (a page's HTML is
|
||||||
|
* produced in the editor's browser), so a link is found by the syntax around it instead. What that
|
||||||
|
* buys is an edit that touches exactly the characters of the href and nothing else — no reformatted
|
||||||
|
* paragraph, no normalized list — which is what an author opening the page afterwards expects to see
|
||||||
|
* in its history.
|
||||||
|
*
|
||||||
|
* The href is looked for exactly as the `pageLinks` row recorded it, which is exactly as the render
|
||||||
|
* carried it. For both syntaxes that is the destination as written in the source, so the two agree;
|
||||||
|
* where they do not (an entity-encoded query, say) the link is simply not found, and the caller
|
||||||
|
* leaves that page alone rather than rewriting its render out from under its source.
|
||||||
|
*
|
||||||
|
* Code is left alone — a fenced block and an inline code span in markdown, a listing, literal or
|
||||||
|
* comment block in asciidoc — since a link there is an example of a link and not one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** What a page's content is, as far as finding a link in it goes. */
|
||||||
|
export type SourceSyntax = 'markdown' | 'adoc'
|
||||||
|
|
||||||
|
export interface SourceRewrite {
|
||||||
|
/** The source with every link that was found rewritten. */
|
||||||
|
content: string
|
||||||
|
/** The old hrefs that were found at least once, and so rewritten. */
|
||||||
|
replaced: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an href is a full URL, which is the only kind that can stand in prose on its own. */
|
||||||
|
function isAbsoluteUrl(href: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(href)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every place `href` can stand as a link destination, as a pattern whose first group is whatever
|
||||||
|
* comes before it — kept, and written back unchanged.
|
||||||
|
*/
|
||||||
|
function patternsFor(href: string, syntax: SourceSyntax): RegExp[] {
|
||||||
|
const h = escapeRegExp(href)
|
||||||
|
// -> Raw HTML is allowed in both, and an author reaching for it writes an ordinary anchor
|
||||||
|
const patterns = [new RegExp(`(\\bhref\\s*=\\s*["'])${h}(?=["'])`, 'g')]
|
||||||
|
|
||||||
|
if (syntax === 'markdown') {
|
||||||
|
patterns.push(
|
||||||
|
// -> `[text](href)`, `[text](<href>)`, `[text](href "title")` -- and the image form, which
|
||||||
|
// shares it
|
||||||
|
new RegExp(`(\\]\\(\\s*<?)${h}(?=>?(?:\\)|\\s))`, 'g'),
|
||||||
|
// -> A reference definition, `[label]: href`, at the start of its line
|
||||||
|
new RegExp(`(^[ \\t]{0,3}\\[[^\\]\\n]+\\]:[ \\t]*<?)${h}(?=>?(?:[ \\t]|$))`, 'gm')
|
||||||
|
)
|
||||||
|
if (isAbsoluteUrl(href)) {
|
||||||
|
patterns.push(
|
||||||
|
// -> `<https://…>`. Only for a full URL: `</b>` is a closing tag, not a link to `/b`
|
||||||
|
new RegExp(`(<)${h}(?=>)`, 'g'),
|
||||||
|
// -> A bare URL, which the renderer's linkify turns into a link
|
||||||
|
new RegExp(`(^|[\\s(])${h}(?=[\\s).,;:!?]|$)`, 'gm')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
patterns.push(
|
||||||
|
// -> `link:href[text]`, and the passthrough form a target with odd characters needs
|
||||||
|
new RegExp(`(\\blink:)${h}(?=\\[)`, 'g'),
|
||||||
|
new RegExp(`(\\blink:\\+\\+)${h}(?=\\+\\+\\[)`, 'g')
|
||||||
|
)
|
||||||
|
if (isAbsoluteUrl(href)) {
|
||||||
|
// -> `https://…[text]`, or a bare URL, which asciidoctor links on its own
|
||||||
|
patterns.push(new RegExp(`(^|[\\s(<])${h}(?=\\[|[\\s>).,;:!?]|$)`, 'gm'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a markdown line opens or closes a fence, and with what. */
|
||||||
|
const FENCE = /^[ \t]{0,3}(`{3,}|~{3,})/
|
||||||
|
|
||||||
|
/** Delimiters of the asciidoc blocks whose content is not markup. */
|
||||||
|
const ADOC_VERBATIM = /^(-{4,}|\.{4,}|\/{4,})[ \t]*$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a source into the stretches a link may be rewritten in and the ones it may not.
|
||||||
|
*
|
||||||
|
* Line by line for the blocks, and within a markdown line for inline code spans — a backtick run
|
||||||
|
* closes only on a run of the same length, which is the rule markdown itself uses.
|
||||||
|
*/
|
||||||
|
function segments(source: string, syntax: SourceSyntax): { text: string; code: boolean }[] {
|
||||||
|
const out: { text: string; code: boolean }[] = []
|
||||||
|
const push = (text: string, code: boolean) => {
|
||||||
|
const last = out.at(-1)
|
||||||
|
if (last && last.code === code) {
|
||||||
|
last.text += text
|
||||||
|
} else if (text) {
|
||||||
|
out.push({ text, code })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fence: string | null = null
|
||||||
|
for (const line of source.split(/(?<=\n)/)) {
|
||||||
|
const bare = line.replace(/\r?\n$/, '')
|
||||||
|
if (syntax === 'markdown') {
|
||||||
|
const marker = FENCE.exec(bare)?.[1]
|
||||||
|
if (fence) {
|
||||||
|
push(line, true)
|
||||||
|
if (
|
||||||
|
marker &&
|
||||||
|
marker[0] === fence[0] &&
|
||||||
|
marker.length >= fence.length &&
|
||||||
|
!bare.trim().slice(marker.length).trim()
|
||||||
|
) {
|
||||||
|
fence = null
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (marker) {
|
||||||
|
fence = marker
|
||||||
|
push(line, true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// -> Inline code spans within an ordinary line
|
||||||
|
let rest = line
|
||||||
|
for (;;) {
|
||||||
|
const open = /`+/.exec(rest)
|
||||||
|
if (!open) {
|
||||||
|
push(rest, false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const close = rest.indexOf(open[0], open.index + open[0].length)
|
||||||
|
if (close < 0) {
|
||||||
|
push(rest, false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
push(rest.slice(0, open.index), false)
|
||||||
|
push(rest.slice(open.index, close + open[0].length), true)
|
||||||
|
rest = rest.slice(close + open[0].length)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const marker = ADOC_VERBATIM.exec(bare)?.[1]
|
||||||
|
if (fence) {
|
||||||
|
push(line, true)
|
||||||
|
if (marker === fence) {
|
||||||
|
fence = null
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (marker) {
|
||||||
|
fence = marker
|
||||||
|
push(line, true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
push(line, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite every link destination in `source` that is one of `replacements`' keys.
|
||||||
|
*
|
||||||
|
* All of them in one pass over the segments, so that an href rewritten to something that is itself
|
||||||
|
* another key — a page moved onto the old path of another — is not rewritten twice.
|
||||||
|
*/
|
||||||
|
export function rewriteSourceLinks(
|
||||||
|
source: string,
|
||||||
|
syntax: SourceSyntax,
|
||||||
|
replacements: ReadonlyMap<string, string>
|
||||||
|
): SourceRewrite {
|
||||||
|
const replaced = new Set<string>()
|
||||||
|
if (!source || replacements.size < 1) {
|
||||||
|
return { content: source, replaced }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -> Longest first, so `/guides/setup` is tried before `/guides` wherever both could match
|
||||||
|
const hrefs = [...replacements.keys()].sort((a, b) => b.length - a.length)
|
||||||
|
const compiled = hrefs.map((href) => ({ href, patterns: patternsFor(href, syntax) }))
|
||||||
|
|
||||||
|
const content = segments(source, syntax)
|
||||||
|
.map((segment) => {
|
||||||
|
if (segment.code) {
|
||||||
|
return segment.text
|
||||||
|
}
|
||||||
|
// -> Placeholders first, then the real values, so one replacement cannot feed the next
|
||||||
|
const placed: string[] = []
|
||||||
|
let text = segment.text
|
||||||
|
for (const { href, patterns } of compiled) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
text = text.replace(pattern, (_match, before: string) => {
|
||||||
|
replaced.add(href)
|
||||||
|
placed.push(replacements.get(href)!)
|
||||||
|
return `${before}\uE000${placed.length - 1}\uE000`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.replace(/\uE000(\d+)\uE000/g, (_match, index: string) => placed[Number(index)])
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
return { content, replaced }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.4 KiB |
@ -0,0 +1,37 @@
|
|||||||
|
import { notify } from '@/composables/notify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the mover what became of the pages linking to a page they just moved.
|
||||||
|
*
|
||||||
|
* Shared by the two places a page is moved from (the page view's action rail and the file manager),
|
||||||
|
* which open the same dialog and get the same `relinked` back from `pageStore.pageMove`.
|
||||||
|
*
|
||||||
|
* A move that fixed everything says so in one line; one that could not fix every page says which,
|
||||||
|
* and stays on screen until dismissed, because those pages are now links to nowhere that somebody
|
||||||
|
* has to go and repair by hand.
|
||||||
|
*
|
||||||
|
* @param {{ updated: number, skippedCount: number, skipped: { title: string }[] } | null} relinked
|
||||||
|
* @param {Function} t The caller's `t` from `useI18n`
|
||||||
|
*/
|
||||||
|
export function notifyRelinked(relinked, t) {
|
||||||
|
if (!relinked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (relinked.updated > 0) {
|
||||||
|
notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: t('pageRenameDialog.updateLinksDone', { count: relinked.updated })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (relinked.skippedCount > 0) {
|
||||||
|
notify({
|
||||||
|
type: 'warning',
|
||||||
|
message: t('pageRenameDialog.updateLinksSkipped', {
|
||||||
|
count: relinked.skippedCount,
|
||||||
|
// -> Only the pages the server would name to this reader; the count covers the rest
|
||||||
|
pages: relinked.skipped.map((page) => page.title).join(', ') || '—'
|
||||||
|
}),
|
||||||
|
timeout: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue