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/CHANGELOG.md +24 -0
- package/README.md +181 -29
- package/bin/index.js +88 -71
- package/index.js +305 -206
- package/lib/cache.js +45 -0
- package/lib/network.js +38 -0
- package/package.json +19 -11
- package/.nvmrc +0 -1
- package/CONTRIBUTING.md +0 -1
- package/puml/c4_styles.puml +0 -37
- package/puml/constants.puml +0 -32
- package/puml/level_1_system_view.puml +0 -36
- package/puml/level_2_container_view.puml +0 -55
- package/puml/level_3_component_view_label_retrieval_job.puml +0 -40
- package/puml/level_3_component_view_pipeline.puml +0 -58
- package/puml/level_4_activity_diagram_export_bq_snapshot_job.puml +0 -13
- package/puml/level_4_activity_diagram_sampler_a.puml +0 -79
- package/puml/level_4_activity_diagram_sampler_b.puml +0 -32
- package/puml/level_4_erd.puml +0 -106
- package/puml/styles.puml +0 -22
package/index.js
CHANGED
|
@@ -1,232 +1,331 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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 }
|