puml-for-markdown 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,232 +1,331 @@
1
- /**
2
- * Update any markdown links in a specified directory which reference a PUML diagram so they link directly to a
3
- * puml server svg link. This is required for GitHub integration
4
- * NOTE: Does not support cyclic dependencies, only DAGS
5
- */
6
-
7
- const fs = require('fs')
8
- const path = require('path')
9
- const assert = require('assert')
10
-
11
- const bent = require('bent')
12
- const Promise = require('bluebird')
13
- const _ = require('lodash')
14
- const glob = require('glob')
15
- const plantUmlEncoder = require('plantuml-encoder')
16
- const tiny = require('tinyurl');
17
- const parseGitIgnore = require('gitignore-globs');
18
-
19
-
20
- // HELPERS
21
-
22
- const getBuffer = bent('buffer')
23
- const mkdirIfDoesntExist = (p) => !fs.existsSync(p) && fs.mkdirSync(p, {recursive: true})
24
- const getPumlUrl = ({imgFormat, encodedData, shorten, pumlServerUrl}) => shorten ?
25
- tiny.shorten(getFullPumlUrl({imgFormat, encodedData, pumlServerUrl})) :
26
- getFullPumlUrl({imgFormat, encodedData, pumlServerUrl})
27
- const getFullPumlUrl = ({imgFormat, encodedData, pumlServerUrl}) => `${pumlServerUrl}/${imgFormat}/${encodedData}}`
28
- const mapUniqMatches = (s, re, mapper, match = re.exec(s), results = []) => {
29
- if (match) return mapUniqMatches(s, re, mapper, re.exec(s), results.concat([match]))
30
- return _.uniqBy(results, String).map(mapper)
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+ const { setTimeout: delay } = require('node:timers/promises')
4
+ const ignore = require('ignore')
5
+ const MarkdownIt = require('markdown-it')
6
+ const encoder = require('plantuml-encoder')
7
+ const { hash, writeAtomic, loadCache } = require('./lib/cache')
8
+ const { request, shorten, validUrl } = require('./lib/network')
9
+
10
+ const markdown = new MarkdownIt()
11
+ const diagramExtension = /\.(?:puml|plantuml)$/i
12
+ const posix = value => value.split(path.sep).join('/')
13
+ const isWithin = (parent, child) => {
14
+ const relative = path.relative(parent, child)
15
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
31
16
  }
17
+ const resolveReference = (file, ref) => path.resolve(path.dirname(file), ref.replace(/\\/g, '/'))
18
+ const fullUrl = (options, format, encoded) => `${options.pumlServerUrl}/${format}/${encoded}`
32
19
 
33
- const replaceIgnore = (ignoreRegex, str, replaceFn) => {
34
- const splitRegex = new RegExp(`(?:(${ignoreRegex}))`, 'g')
35
- const splitStrArr = str.split(splitRegex)
36
- const replacedSplitStrArr = splitStrArr.map((splitStr) => {
37
- if (splitStr.match(new RegExp(ignoreRegex))) return splitStr
38
- return replaceFn(splitStr)
39
- })
40
- return replacedSplitStrArr.join('')
20
+ const localFormat = options => options.imageFormats.includes('svg') ? 'svg' : 'png'
21
+ function imagePath(file, format, options) {
22
+ if (!isWithin(options.rootDirectory, file)) throw new Error(`Cannot export a diagram outside root-directory: ${file}`)
23
+ return path.join(options.distDirectory, path.relative(options.rootDirectory, file).replace(diagramExtension, `.${format}`))
41
24
  }
42
-
43
- const replaceMdIgnoringInlineCode = (mdString, replaceFn) => {
44
- return replaceIgnore('`[^\n]*?(?=`)`', mdString, replaceFn)
25
+ const relativeUrl = (from, to) => posix(path.relative(path.dirname(from), to)).split('/').map(part => encodeURIComponent(part).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)).join('/')
26
+
27
+ function normalizeOptions(input) {
28
+ const rootDirectory = path.resolve(input.rootDirectory || process.cwd())
29
+ const options = {
30
+ ...input,
31
+ rootDirectory,
32
+ markdownDirectory: path.resolve(input.markdownDirectory || rootDirectory),
33
+ pumlDirectory: path.resolve(input.pumlDirectory || rootDirectory),
34
+ distDirectory: path.resolve(input.distDirectory || path.join(rootDirectory, 'dist_puml')),
35
+ gitignorePath: path.resolve(input.gitignorePath || path.join(rootDirectory, '.gitignore')),
36
+ pumlServerUrl: (input.pumlServerUrl || 'https://www.plantuml.com/plantuml').replace(/\/+$/, ''),
37
+ shouldShortenLinks: input.shouldShortenLinks ?? true,
38
+ respectGitignore: input.respectGitignore ?? true,
39
+ imageFormats: input.imageFormats || ['png'],
40
+ intervalSeconds: Number(input.intervalSeconds ?? 2),
41
+ polrUrl: input.polrUrl?.replace(/\/+$/, ''),
42
+ outputImages: Boolean(input.outputImages || input.localImages),
43
+ }
44
+ if (options.respectGitignore && input.gitignorePath && !fs.existsSync(options.gitignorePath)) {
45
+ throw new Error(`Gitignore file does not exist: ${options.gitignorePath}`)
46
+ }
47
+ if (options.interactiveLinks && !options.localImages) throw new Error('--interactive-links requires --local-images')
48
+ if (options.polrUrl && !options.shouldShortenLinks) throw new Error('--polr-url cannot be combined with disabled shortening')
49
+ if (options.polrUrl) {
50
+ const polr = new URL(options.polrUrl)
51
+ if (!validUrl(options.polrUrl) || polr.search || polr.hash) throw new Error('Polr URL must be an HTTP(S) base URL without credentials, query or fragment')
52
+ }
53
+ if (options.hotReload && (options.check || options.dryRun)) throw new Error('--check and --dry-run cannot be combined with --hot-reload')
54
+ const server = new URL(options.pumlServerUrl)
55
+ if (!validUrl(options.pumlServerUrl) || server.search || server.hash) {
56
+ throw new Error('PlantUML server URL must be an HTTP(S) base URL without a query or fragment')
57
+ }
58
+ if (!Number.isFinite(options.intervalSeconds) || options.intervalSeconds <= 0 || options.intervalSeconds * 1000 > 2147483647) {
59
+ throw new Error('interval-seconds must be a positive number no greater than 2147483')
60
+ }
61
+ if (!Array.isArray(options.imageFormats) || !options.imageFormats.length || options.imageFormats.some(f => !['svg', 'png'].includes(f))) {
62
+ throw new Error('imageFormats must contain png and/or svg')
63
+ }
64
+ for (const key of ['rootDirectory', 'markdownDirectory', 'pumlDirectory']) {
65
+ if (!fs.statSync(options[key]).isDirectory()) throw new Error(`Invalid ${key}: ${options[key]}`)
66
+ }
67
+ if (options.outputImages && (isWithin(options.distDirectory, rootDirectory) || isWithin(options.distDirectory, options.pumlDirectory))) {
68
+ throw new Error('Image destination must not be the project or diagram directory, or an ancestor of either')
69
+ }
70
+ return options
45
71
  }
46
72
 
47
- const replaceMdIgnoringCodeBlocks = (mdString, replaceFn) => {
48
- return replaceIgnore('```[\\s\\S]*?(?=```)```', mdString, replaceFn)
49
- }
50
-
51
- const replaceMdIgnoringCode = (mdString, replaceFn) => {
52
- return replaceMdIgnoringCodeBlocks(mdString, str => replaceMdIgnoringInlineCode(str, replaceFn))
53
- }
54
-
55
- // DATA STRUCTURES
56
-
57
- class PumlLinks extends Map {
58
- constructor({ pumlPaths, shouldShortenLinks, pumlServerUrl }) {
59
- super();
60
- this.shouldShortenLinks = shouldShortenLinks
61
- this.pumlServerUrl = pumlServerUrl
62
- // We need to know which paths should be visited
63
- // Any path that's not marked 0 we know isn't a puml path because it doesn't correspond to a puml file
64
- pumlPaths.forEach(p => this.set(p, 0))
73
+ // Walk native paths instead of constructing globs from Windows paths. Never follow symlinks.
74
+ function discover(directory, extension, options) {
75
+ const baseRules = []
76
+ if (options.respectGitignore && fs.existsSync(options.gitignorePath)) {
77
+ baseRules.push({ base: options.rootDirectory, matcher: ignore().add(fs.readFileSync(options.gitignorePath, 'utf8')) })
78
+ }
79
+ const addRules = (dir, rules) => {
80
+ const file = path.join(dir, '.gitignore')
81
+ return options.respectGitignore && file !== options.gitignorePath && fs.existsSync(file)
82
+ ? [...rules, { base: dir, matcher: ignore().add(fs.readFileSync(file, 'utf8')) }] : rules
83
+ }
84
+ const ignored = (file, isDirectory, rules) => {
85
+ let result = false
86
+ for (const { base, matcher } of rules) {
87
+ if (!isWithin(base, file) || base === file) continue
88
+ const match = matcher.test(posix(path.relative(base, file)) + (isDirectory ? '/' : ''))
89
+ if (match.ignored) result = true
90
+ else if (match.unignored) result = false
65
91
  }
66
-
67
- get(p) {
68
- return super.get(p);
92
+ return result
93
+ }
94
+ let initialRules = baseRules
95
+ if (isWithin(options.rootDirectory, directory) && directory !== options.rootDirectory) {
96
+ let current = options.rootDirectory
97
+ for (const part of path.relative(options.rootDirectory, directory).split(path.sep)) {
98
+ initialRules = addRules(current, initialRules)
99
+ current = path.join(current, part)
100
+ if (ignored(current, true, initialRules)) return []
69
101
  }
70
-
71
- async set(pumlPath, v) {
72
- if (typeof v === 'number') return super.set(pumlPath, v)
73
-
74
- const encodedData = plantUmlEncoder.encode(v)
75
- const url = await getPumlUrl({
76
- imgFormat: 'svg', encodedData, shorten: this.shouldShortenLinks, pumlServerUrl: this.pumlServerUrl
77
- })
78
- return super.set(pumlPath, {encodedData, url, data: v});
79
- }
80
-
81
- has(pumlPath) {
82
- return super.has(pumlPath);
102
+ }
103
+ const result = []
104
+ function walk(dir, inherited) {
105
+ const rules = addRules(dir, inherited)
106
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
107
+ if (entry.isSymbolicLink() || ['.git', 'node_modules'].includes(entry.name)) continue
108
+ const file = path.join(dir, entry.name)
109
+ if (file === options.distDirectory || ignored(file, entry.isDirectory(), rules)) continue
110
+ if (entry.isDirectory()) walk(file, rules)
111
+ else if (entry.isFile() && extension.test(entry.name)) result.push(file)
83
112
  }
113
+ }
114
+ walk(directory, initialRules)
115
+ return result
84
116
  }
85
117
 
86
- // SAVING DIAGRAMS
87
-
88
- const downloadImg = async (outputPath, imgUrl) => {
89
- const imgBuffer = await getBuffer(imgUrl)
90
- fs.writeFileSync(outputPath, imgBuffer)
118
+ async function buildDiagrams(files, options, cache) {
119
+ const context = JSON.stringify([options.pumlServerUrl, options.polrUrl || null, hash(process.env.POLR_APIKEY || ""), options.shouldShortenLinks, !!options.localImages, !!options.interactiveLinks, localFormat(options)])
120
+ let rebuilt = 0, reused = 0, unresolved = 0
121
+ const known = new Set(files)
122
+ const completed = new Map()
123
+ const includedFiles = new Set()
124
+ const linkedFiles = new Set()
125
+ async function expand(file, chain, linkChain, owner, forRemote = false) {
126
+ if (chain.includes(file)) throw new Error(`Circular include: ${[...chain, file].join(' -> ')}`)
127
+ let data = fs.readFileSync(file, 'utf8')
128
+ // Resolve hyperlinks before inlining so links retain their originating file's directory.
129
+ const links = [...data.matchAll(/\$link\s*=\s*(["'])(.*?)\1/g)]
130
+ for (const [match, , ref] of links) {
131
+ if (/^[a-z][a-z\d+.-]*:/i.test(ref) || ref.startsWith('//') || !diagramExtension.test(ref)) continue
132
+ const target = resolveReference(file, ref)
133
+ if (!known.has(target)) throw new Error(`Missing or ignored diagram link "${ref}" in ${file}`)
134
+ linkedFiles.add(target)
135
+ const linked = await build(target, linkChain)
136
+ const url = options.localImages && !forRemote
137
+ ? relativeUrl(imagePath(owner, localFormat(options), options), imagePath(target, localFormat(options), options))
138
+ : linked.url
139
+ data = data.replaceAll(match, () => `$link="${url}"`)
140
+ }
141
+ const includes = [...data.matchAll(/^[ \t]*!include[ \t]+([^\r\n]+)(?=\r?$)/gm)]
142
+ for (const [match, raw] of includes) {
143
+ const ref = raw.trim().replace(/^"(.*)"$/, '$1')
144
+ if (/^https?:\/\//i.test(ref) || ref.startsWith('<')) continue
145
+ const target = resolveReference(file, ref)
146
+ // Macro-based and remote includes remain for the PlantUML server to evaluate.
147
+ if (!fs.existsSync(target)) {
148
+ if (/^(?:\.{1,2}[\\/]|[\\/])/.test(ref)) throw new Error(`Missing include "${ref}" in ${file}`)
149
+ continue
150
+ }
151
+ includedFiles.add(target)
152
+ const expanded = await expand(target, [...chain, file], linkChain, owner, forRemote)
153
+ data = data.replace(match, () => expanded)
154
+ }
155
+ return data
156
+ }
157
+ async function build(file, chain = []) {
158
+ if (chain.includes(file)) throw new Error(`Circular diagram link: ${[...chain, file].join(' -> ')}`)
159
+ if (completed.has(file)) return completed.get(file)
160
+ const data = await expand(file, [], [...chain, file], file)
161
+ const remoteData = options.localImages && options.interactiveLinks ? await expand(file, [], [...chain, file], file, true) : data
162
+ const key = hash(context + JSON.stringify([data, remoteData]))
163
+ const cached = cache.get('diagrams', key)
164
+ let result
165
+ if (cached && typeof cached.encodedData === 'string' && validUrl(cached.url)) {
166
+ result = { data, ...cached }
167
+ cache.set('diagrams', key, cached)
168
+ reused++
169
+ } else {
170
+ rebuilt++
171
+ const encodedData = encoder.encode(data)
172
+ const url = fullUrl(options, 'svg', remoteData === data ? encodedData : encoder.encode(remoteData))
173
+ const needsShortening = options.shouldShortenLinks && (!options.localImages || options.interactiveLinks)
174
+ const readOnly = options.check || options.dryRun
175
+ if (needsShortening && readOnly) unresolved++
176
+ const resolved = needsShortening && !readOnly ? await shorten(url, options) : { url, cacheable: !needsShortening }
177
+ result = { data, encodedData, url: resolved.url }
178
+ if (resolved.cacheable) cache.set('diagrams', key, { encodedData, url: resolved.url })
179
+ }
180
+ completed.set(file, result)
181
+ return result
182
+ }
183
+ for (const file of files) await build(file)
184
+ for (const [file, diagram] of completed) diagram.includeOnly = includedFiles.has(file) && !linkedFiles.has(file)
185
+ completed.stats = { rebuilt, reused, unresolved }
186
+ return completed
91
187
  }
92
188
 
93
- const saveDiagram = async ({rootDirectory, distDirectory, pumlPath, imgFormat, encodedData, pumlServerUrl}) => {
94
- const outputPath = path.join(
95
- distDirectory, pumlPath.replace(rootDirectory, '').replace(/\.puml$/, `.${imgFormat}`)
96
- )
97
-
98
- mkdirIfDoesntExist(path.dirname(outputPath))
99
- const imgUrl = getFullPumlUrl({ imgFormat, encodedData, pumlServerUrl })
100
- try {
101
- await downloadImg(outputPath, imgUrl)
102
- } catch (e) {
103
- console.warn(`\nWARN: Failed to save ${imgUrl} to ${outputPath}\n`)
104
- if (e.statusCode !== 400) throw e
189
+ // Retain source bytes, including CRLF, while protecting CommonMark code blocks and spans.
190
+ function replaceOutsideCode(source, replace) {
191
+ const offsets = [0]
192
+ for (const match of source.matchAll(/\n/g)) offsets.push(match.index + 1)
193
+ const ranges = markdown.parse(source, {}).filter(token => ['fence', 'code_block'].includes(token.type))
194
+ .map(token => [offsets[token.map[0]], offsets[token.map[1]] ?? source.length])
195
+ function inline(text) {
196
+ const runs = [...text.matchAll(/`+/g)]
197
+ let output = '', cursor = 0
198
+ for (let i = 0; i < runs.length; i++) {
199
+ const open = runs[i]
200
+ let close = i + 1
201
+ while (close < runs.length && runs[close][0].length !== open[0].length) close++
202
+ if (close === runs.length) continue
203
+ const end = runs[close].index + runs[close][0].length
204
+ output += replace(text.slice(cursor, open.index)) + text.slice(open.index, end)
205
+ cursor = end
206
+ i = close
105
207
  }
208
+ return output + replace(text.slice(cursor))
209
+ }
210
+ let output = '', cursor = 0
211
+ for (const [start, end] of ranges) {
212
+ output += inline(source.slice(cursor, start)) + source.slice(start, end)
213
+ cursor = end
214
+ }
215
+ return output + inline(source.slice(cursor))
106
216
  }
107
217
 
108
- const saveDiagrams = async ({rootDirectory, distDirectory, imageFormats, pumlLinks, pumlServerUrl}) => {
109
- fs.rmSync(distDirectory, {recursive: true, force: true});
110
- for (let [pumlPath, {encodedData}] of pumlLinks) {
111
- for (let imgFormat of imageFormats) {
112
- await saveDiagram({
113
- distDirectory, rootDirectory, pumlPath, imgFormat, encodedData, pumlServerUrl
114
- })
115
- }
218
+ function renderMarkdown(file, diagrams, options) {
219
+ const original = fs.readFileSync(file, 'utf8')
220
+ const updated = replaceOutsideCode(original, text => {
221
+ const marker = /<!--[ \t]*(!?\[((?:\\.|[^\]\\\r\n])*)\])\(([^)\r\n]+\.(?:puml|plantuml))\)[ \t]*-->/gi
222
+ let output = '', cursor = 0
223
+ for (const match of text.matchAll(marker)) {
224
+ const [comment, label, , ref] = match
225
+ const diagram = diagrams.get(resolveReference(file, ref))
226
+ if (!diagram) throw new Error(`Could not find diagram "${ref}" referenced by ${file}`)
227
+ diagram.includeOnly = false
228
+ let prefix = text.slice(cursor, match.index)
229
+ // Only remove one immediately adjacent generated link, never earlier links on the line.
230
+ prefix = prefix.replace(/(?:\[!\[(?:\\.|[^\]\\\r\n])*\]\([^()\r\n]*\)\]\([^()\r\n]*\)|!?\[(?:\\.|[^\[\]\\\r\n])*\]\([^()\r\n]*\))$/, '')
231
+ const target = options.localImages ? relativeUrl(file, imagePath(resolveReference(file, ref), localFormat(options), options)) : diagram.url
232
+ const link = label.startsWith('!') && (!options.localImages || options.interactiveLinks)
233
+ ? `[${label}(${target})](${diagram.url})` : `${label}(${target})`
234
+ output += prefix + link + comment
235
+ cursor = match.index + comment.length
116
236
  }
237
+ return output + text.slice(cursor)
238
+ })
239
+ return { file, original, updated }
117
240
  }
118
241
 
119
- // PROCESSORS
120
-
121
- /**
122
- * Add the whole relative includes file into the puml instead of a reference
123
- * @returns {string} The puml data where relative includes are replaced with the data from referenced file
124
- */
125
- const processIncludes = (pumlPath, data) => {
126
- return data.replace(/!include (.*)/g, (fullMatch, url) => {
127
- const fullUrl = path.resolve(path.dirname(pumlPath), url)
128
- return fs.existsSync(fullUrl) ? fs.readFileSync(fullUrl, 'utf8') : fullMatch
129
- })
242
+ function checkDestination(destination, options) {
243
+ const boundary = isWithin(options.rootDirectory, options.distDirectory) ? options.rootDirectory : options.distDirectory
244
+ let current = destination
245
+ while (true) {
246
+ if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
247
+ throw new Error(`Refusing to export through symbolic link: ${current}`)
248
+ }
249
+ if (current === boundary) break
250
+ const parent = path.dirname(current)
251
+ if (parent === current) break
252
+ current = parent
253
+ }
130
254
  }
131
255
 
256
+ async function saveDiagrams(diagrams, options, cache) {
257
+ const outputs = new Map()
258
+ for (const [file, diagram] of diagrams) {
259
+ // Includes containing only settings are not standalone diagrams.
260
+ if (diagram.includeOnly || !/^\s*@start\w+/m.test(diagram.data)) continue
261
+ if (!isWithin(options.rootDirectory, file)) throw new Error(`Cannot export a diagram outside root-directory: ${file}`)
262
+ for (const format of options.imageFormats) {
263
+ const destination = imagePath(file, format, options)
264
+ checkDestination(destination, options)
265
+ if (outputs.has(destination)) throw new Error(`Multiple diagrams would overwrite ${destination}`)
266
+ outputs.set(destination, { diagram, format })
267
+ }
268
+ }
269
+ const changed = []
270
+ for (const [destination, { diagram, format }] of outputs) {
271
+ const key = hash(JSON.stringify([posix(path.relative(options.rootDirectory, destination)), options.pumlServerUrl, format, diagram.encodedData]))
272
+ const cached = cache.get('images', key)
273
+ if (cached && fs.existsSync(destination) && cached.digest === hash(fs.readFileSync(destination))) {
274
+ cache.set('images', key, cached)
275
+ continue
276
+ }
277
+ changed.push(destination)
278
+ if (options.check || options.dryRun) continue
279
+ let buffer
280
+ try {
281
+ buffer = Buffer.from(await (await request(fullUrl(options, format, diagram.encodedData))).arrayBuffer())
282
+ } catch (error) {
283
+ throw new Error(`Failed to export ${destination}: ${error.message}`)
284
+ }
285
+ const valid = format === 'png'
286
+ ? buffer.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))
287
+ : /<svg[\s>]/i.test(buffer.toString('utf8'))
288
+ if (!valid) throw new Error(`PlantUML server did not return a valid ${format} image for ${destination}`)
289
+ // Never remove the output directory: it may contain unrelated user files.
290
+ fs.mkdirSync(path.dirname(destination), { recursive: true })
291
+ checkDestination(destination, options)
292
+ const mode = fs.existsSync(destination) ? fs.statSync(destination).mode & 0o777 : 0o666
293
+ writeAtomic(destination, buffer, mode)
294
+ cache.set('images', key, { digest: hash(buffer) })
295
+ }
296
+ return changed
132
297
 
133
- /**
134
- * Walk through each PUML file recursively so we can encode any linked files first since the files they link
135
- * from are dependent
136
- * @returns {Promise<string> | Promise<undefined>} Returns either processed puml file link, or original link
137
- */
138
- const processPumlFile = async (pumlPath, pumlLinks) => {
139
- // Base Cases
140
- if (!pumlLinks.has(pumlPath)) return pumlPath
141
- if (pumlLinks.get(pumlPath)) return pumlLinks.get(pumlPath)
142
-
143
- let data = fs.readFileSync(pumlPath, 'utf8')
144
- await Promise.all(mapUniqMatches(data, /\$link=["']([^"']+)['"]/gm, async ([pumlLink, pumlLinkPath]) => {
145
- // console.debug(pumlLink, pumlLinkPath)
146
- const newLink = await processPumlFile(path.resolve(path.dirname(pumlPath), pumlLinkPath), pumlLinks)
147
- data = data.replaceAll(pumlLink, `$link="${newLink.url}"`)
148
- }))
149
-
150
- data = processIncludes(pumlPath, data)
151
- await pumlLinks.set(pumlPath, data)
152
- return pumlLinks.get(pumlPath)
153
298
  }
154
299
 
155
- /**
156
- * Update any markdown links which reference PUML diagram so they link directly to a puml server svg link
157
- * Output them as new markdown in the docs directory
158
- */
159
- const processMdFile = async (mdPath, pumlLinks) => {
160
- console.info(`Processing md file at ${mdPath}`)
161
- let originalMdStr = fs.readFileSync(mdPath, 'utf8')
162
- const findMdPumlLinksRE = new RegExp(
163
- `(\\[.*\]\\([^)]+\\))?<!\-\-(!?\\[[^\\]]+\\])\\(([^)]+\\.puml)\\)\-\->`, 'g'
164
- )
165
- // Add puml server tinyurl link for puml links indicated in markdown comments
166
- const mdWithUpdatedPumlLinks = replaceMdIgnoringCode(originalMdStr, (str) => {
167
- return str.replace(findMdPumlLinksRE, (match, _, linkText, mdPumlLinkPath) => {
168
- const pumlPath = path.resolve(path.dirname(mdPath), mdPumlLinkPath)
169
- const pumlLink = pumlLinks.get(pumlPath)
170
- if (!pumlLink) {
171
- throw Error(`Could not find puml for md link path = ${mdPumlLinkPath}, absolute path = ${pumlPath}`)
172
- }
173
- let replacement
174
- // If the puml link is a markdown image
175
- if (linkText[0] === '!') {
176
- replacement = `[${linkText}(${pumlLink.url})](${pumlLink.url})<!--${linkText}(${mdPumlLinkPath})-->`
177
- } else {
178
- // If the puml link is a markdown hyperlink
179
- replacement = `${linkText}(${pumlLink.url})<!--${linkText}(${mdPumlLinkPath})-->`
180
- }
181
-
182
- // console.debug({match, linkText, mdPumlLinkPath, replacement})
183
- return replacement
184
- })
185
- })
186
-
187
-
188
- fs.writeFileSync(mdPath, mdWithUpdatedPumlLinks)
300
+ async function runOnce(options) {
301
+ const cache = loadCache(options)
302
+ const mdPaths = discover(options.markdownDirectory, /\.md$/i, options)
303
+ const pumlPaths = discover(options.pumlDirectory, diagramExtension, options)
304
+ const diagrams = await buildDiagrams(pumlPaths, options, cache)
305
+ // Validate all documents before writing images or Markdown.
306
+ const updates = mdPaths.map(file => renderMarkdown(file, diagrams, options)).filter(item => item.original !== item.updated)
307
+ const imageChanges = options.outputImages ? await saveDiagrams(diagrams, options, cache) : []
308
+ const readOnly = options.check || options.dryRun
309
+ for (const { file, updated } of updates) {
310
+ if (!readOnly) fs.writeFileSync(file, updated)
311
+ else console.info(`Would update Markdown: ${posix(path.relative(options.rootDirectory, file))}`)
312
+ }
313
+ if (readOnly) {
314
+ for (const file of imageChanges) console.info(`Would render image: ${posix(path.relative(options.rootDirectory, file))}`)
315
+ if (diagrams.stats.unresolved) console.info(`${diagrams.stats.unresolved} shortened URLs need generation; no network requests were made.`)
316
+ }
317
+ cache.save()
318
+ const stale = updates.length + imageChanges.length + diagrams.stats.unresolved > 0
319
+ console.info(`Processed ${pumlPaths.length} diagrams and ${mdPaths.length} Markdown files; ${readOnly ? 'would update' : 'updated'} ${updates.length}. Rebuilt ${diagrams.stats.rebuilt}; reused ${diagrams.stats.reused}.`)
320
+ if (options.check) console.info(stale ? 'Documentation is out of date.' : 'Documentation is up to date.')
321
+ return { stale, changed: updates.length, images: imageChanges.length, ...diagrams.stats }
189
322
  }
190
323
 
191
- const runOnce = async (
192
- {
193
- pumlServerUrl,
194
- rootDirectory,
195
- markdownDirectory,
196
- pumlDirectory,
197
- distDirectory,
198
- outputImages,
199
- imageFormats,
200
- respectGitignore,
201
- gitignorePath,
202
- shouldShortenLinks,
203
- }
204
- ) => {
205
- assert(fs.existsSync(markdownDirectory), "That's an invalid md folder path")
206
- assert(fs.existsSync(pumlDirectory), "That's an invalid puml folder path")
207
-
208
- const ignore = respectGitignore ? parseGitIgnore(gitignorePath) : []
209
- const mdPaths = glob.sync(`${markdownDirectory}/**/*.md`, {ignore, nodir: true})
210
- const pumlPaths = glob.sync(`${pumlDirectory}/**/*.puml`, {ignore, nodir: true})
211
- const pumlLinks = new PumlLinks({
212
- pumlPaths, shouldShortenLinks, pumlServerUrl
213
- })
214
-
215
- for (let p of pumlPaths) await processPumlFile(p, pumlLinks)
216
- for (let p of mdPaths) await processMdFile(p, pumlLinks)
217
-
218
- if (outputImages) {
219
- await saveDiagrams({
220
- rootDirectory, distDirectory, imageFormats, pumlLinks, pumlServerUrl
221
- })
222
- }
324
+ module.exports = async function run(input = {}) {
325
+ const options = normalizeOptions(input)
326
+ if (!options.hotReload) return runOnce(options)
327
+ do {
328
+ try { await runOnce(options) } catch (error) { console.error(`ERROR: ${error.message}`) }
329
+ await delay(options.intervalSeconds * 1000)
330
+ } while (true)
223
331
  }
224
-
225
- const reloadRun = ({intervalSeconds, ...rest}) =>
226
- Promise.delay(intervalSeconds * 1000, rest)
227
- .then(run).catch(console.error)
228
- .finally(() => reloadRun({intervalSeconds, ...rest}))
229
-
230
- const run = ({hotReload, ...rest}) => hotReload ? reloadRun(rest) : runOnce(rest)
231
-
232
- module.exports = run
package/lib/cache.js ADDED
@@ -0,0 +1,45 @@
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+ const { createHash, randomUUID } = require('node:crypto')
4
+ const hash = value => createHash('sha256').update(value).digest('hex')
5
+
6
+ function writeAtomic(file, data, mode = 0o600) {
7
+ const temporary = `${file}.${randomUUID()}.tmp`
8
+ let created = false
9
+ try {
10
+ fs.writeFileSync(temporary, data, { flag: 'wx', mode })
11
+ created = true
12
+ fs.renameSync(temporary, file)
13
+ } finally {
14
+ if (created && fs.existsSync(temporary)) fs.unlinkSync(temporary)
15
+ }
16
+ }
17
+
18
+ function loadCache(options) {
19
+ const filename = path.join(options.rootDirectory, '.puml-cache.json')
20
+ let data = { version: 1, diagrams: {}, images: {} }
21
+ if (options.cache !== false && fs.existsSync(filename)) {
22
+ if (fs.lstatSync(filename).isSymbolicLink()) throw new Error('Refusing to read a symlinked .puml-cache.json')
23
+ try {
24
+ const parsed = JSON.parse(fs.readFileSync(filename, 'utf8'))
25
+ if (parsed.version === 1 && parsed.diagrams && parsed.images) data = parsed
26
+ } catch {
27
+ console.warn('WARN: Ignoring unreadable diagram cache; run generation to rebuild it.')
28
+ }
29
+ }
30
+ const used = { version: 1, diagrams: {}, images: {} }
31
+ return {
32
+ get(kind, key) { return options.cache === false ? undefined : (used[kind][key] || data[kind][key]) },
33
+ set(kind, key, value) { used[kind][key] = value },
34
+ save() {
35
+ if (options.cache === false || options.check || options.dryRun) return
36
+ const serialized = JSON.stringify(used, null, 2) + '\n'
37
+ if (fs.existsSync(filename)) {
38
+ if (fs.lstatSync(filename).isSymbolicLink()) throw new Error('Refusing to overwrite a symlinked .puml-cache.json')
39
+ if (fs.readFileSync(filename, 'utf8') === serialized) return
40
+ }
41
+ writeAtomic(filename, serialized)
42
+ },
43
+ }
44
+ }
45
+ module.exports = { hash, writeAtomic, loadCache }
package/lib/network.js ADDED
@@ -0,0 +1,38 @@
1
+ async function request(url, init = {}) {
2
+ const response = await fetch(url, { ...init, signal: AbortSignal.timeout(30000) })
3
+ if (!response.ok) {
4
+ await response.body?.cancel()
5
+ const message = response.headers.get('x-plantuml-diagram-error')
6
+ throw new Error(`HTTP ${response.status} from ${new URL(url).origin}${message ? ': ' + message : ''}`)
7
+ }
8
+ return response
9
+ }
10
+
11
+ function validUrl(value) {
12
+ try {
13
+ const parsed = new URL(value)
14
+ return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password && !/[\s<>]/.test(value)
15
+ } catch { return false }
16
+ }
17
+
18
+ async function shorten(url, options) {
19
+ try {
20
+ let result
21
+ if (options.polrUrl) {
22
+ const body = new URLSearchParams({ url, key: process.env.POLR_APIKEY || '', response_type: 'json' })
23
+ const response = await request(`${options.polrUrl}/api/v2/action/shorten`, { method: 'POST', body, redirect: 'error' })
24
+ const text = (await response.text()).trim()
25
+ try { result = JSON.parse(text).result } catch { result = text }
26
+ } else {
27
+ result = (await (await request(`https://tinyurl.com/api-create.php?url=${encodeURIComponent(url)}`)).text()).trim()
28
+ }
29
+ if (typeof result !== 'string' || !validUrl(result)) throw new Error('Invalid shortener response')
30
+ return { url: result, cacheable: true }
31
+ } catch (error) {
32
+ // A private provider must never fail over to a public shortener or leak its response body/key.
33
+ const message = options.polrUrl ? 'Polr request failed' : error.message
34
+ console.warn(`WARN: Link shortening failed (${message}); using the full diagram URL.`)
35
+ return { url, cacheable: false }
36
+ }
37
+ }
38
+ module.exports = { request, shorten, validUrl }