@conduction/nextcloud-vue 2.2.0-vue3.13 → 2.2.0-vue3.14
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/package.json +2 -1
- package/scripts/.jsdoc-baselines.json +254 -0
- package/scripts/build-validators.js +174 -0
- package/scripts/check-css-entry.js +118 -0
- package/scripts/check-docs.js +724 -0
- package/scripts/check-integration-build.js +180 -0
- package/scripts/check-integration-parity.js +540 -0
- package/scripts/check-jsdoc.js +230 -0
- package/scripts/check-peer-consistency.js +161 -0
- package/scripts/generate-nl-icons.mjs +98 -0
- package/scripts/precommit-regenerate-features.sh +33 -0
- package/scripts/precommit-regenerate-partials.sh +68 -0
- package/scripts/sync-l10n-ellipsis.js +46 -0
- package/scripts/update-fleet-manifest-fixtures.js +116 -0
- package/scripts/vue3-compile-sweep.cjs +77 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
/* eslint-disable no-console, n/no-process-exit */
|
|
2
|
+
/**
|
|
3
|
+
* Verifies that every public export of @conduction/nextcloud-vue has
|
|
4
|
+
* corresponding documentation under docs/.
|
|
5
|
+
*
|
|
6
|
+
* The source of truth for "public / user-reachable" is src/index.js.
|
|
7
|
+
* Every named export there is classified and checked against docs/:
|
|
8
|
+
*
|
|
9
|
+
* - Components (Cn*) → docs/components/<kebab>.md
|
|
10
|
+
* - Composables (use*) → docs/utilities/composables/<kebab>.md
|
|
11
|
+
* - Store factories → docs/store/<stem>.md
|
|
12
|
+
* useObjectStore, createObjectStore → object-store.md
|
|
13
|
+
* createCrudStore → crud-store.md
|
|
14
|
+
* createSubResourcePlugin → sub-resource-plugin.md
|
|
15
|
+
* - Store plugins (*Plugin) → docs/store/plugins/<kebab>.md
|
|
16
|
+
* - Store constants/helpers → mentioned by name in an existing docs/store/*.md
|
|
17
|
+
* SEARCH_TYPE, emptyPaginated, getRegisterApiUrl, getSchemaApiUrl
|
|
18
|
+
* - Utilities (everything else from utils) → docs/utilities/<kebab>.md
|
|
19
|
+
*
|
|
20
|
+
* Exempt exports are allow-listed in EXEMPT (e.g. registerIcons).
|
|
21
|
+
*
|
|
22
|
+
* Phase 1 — existence: exits 1 when any export lacks a doc file.
|
|
23
|
+
* Phase 2 — accuracy: for every Component export, verifies that each prop
|
|
24
|
+
* name and each static named-slot from the SFC appears somewhere in the
|
|
25
|
+
* component's doc file. Exits 1 when any are missing.
|
|
26
|
+
*
|
|
27
|
+
* All categories are reported in a single run so CI surfaces every gap at once.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('fs')
|
|
31
|
+
const path = require('path')
|
|
32
|
+
|
|
33
|
+
const ROOT = path.resolve(__dirname, '..')
|
|
34
|
+
const INDEX_FILE = path.join(ROOT, 'src', 'index.js')
|
|
35
|
+
const DOCS_DIR = path.join(ROOT, 'docs')
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Exports that don't need a dedicated doc page. classify() short-circuits
|
|
39
|
+
* to `category: 'Exempt'` for anything listed here, and the main loop
|
|
40
|
+
* skips exempt items entirely (not counted toward any category's totals).
|
|
41
|
+
*
|
|
42
|
+
* Use sparingly — reserved for lifecycle/bootstrap helpers that are
|
|
43
|
+
* covered by another doc (e.g. `registerIcons` is described in
|
|
44
|
+
* docs/getting-started.md as part of the install flow, not as a
|
|
45
|
+
* standalone API).
|
|
46
|
+
*/
|
|
47
|
+
const EXEMPT = new Set([
|
|
48
|
+
'registerIcons',
|
|
49
|
+
'registerTranslations',
|
|
50
|
+
// Override (shadow) of @nextcloud/vue's own NcSelectTags — fixes the
|
|
51
|
+
// systemtags fetch and `:options` handling without changing the public
|
|
52
|
+
// API. The component contract is upstream's; its docs live in
|
|
53
|
+
// @nextcloud/vue, so there is no standalone Conduction page to require.
|
|
54
|
+
'NcSelectTags',
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Store factory/helper identifiers → their expected doc filename stem
|
|
59
|
+
* under docs/store/. Needed because the default kebab-case rule would
|
|
60
|
+
* produce e.g. 'use-object-store', but the established convention strips
|
|
61
|
+
* the leading verb (`use`/`create`) so the docs read as 'object-store',
|
|
62
|
+
* 'crud-store', etc.
|
|
63
|
+
*
|
|
64
|
+
* Multiple identifiers may map to the same stem when they're part of
|
|
65
|
+
* one API surface — `useObjectStore` and `createObjectStore` both point
|
|
66
|
+
* at docs/store/object-store.md because a single page documents the
|
|
67
|
+
* singleton hook and its factory together.
|
|
68
|
+
*/
|
|
69
|
+
const STORE_FACTORY_STEMS = {
|
|
70
|
+
useObjectStore: 'object-store',
|
|
71
|
+
createObjectStore: 'object-store',
|
|
72
|
+
createCrudStore: 'crud-store',
|
|
73
|
+
createSubResourcePlugin: 'sub-resource-plugin',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Exports that don't warrant their own .md file but MUST be mentioned
|
|
78
|
+
* by name in an existing doc. Keys are the export names; values are
|
|
79
|
+
* paths (relative to docs/store/) of the file that's expected to
|
|
80
|
+
* reference them.
|
|
81
|
+
*
|
|
82
|
+
* Used for constants and small helpers that belong with a larger API:
|
|
83
|
+
* `SEARCH_TYPE` only makes sense alongside `searchPlugin`, so it's
|
|
84
|
+
* checked inside plugins/search.md rather than getting a standalone
|
|
85
|
+
* 'search-type.md'. Coverage passes when the file exists and a plain
|
|
86
|
+
* substring match for the symbol name is found.
|
|
87
|
+
*/
|
|
88
|
+
const STORE_MENTION_ONLY = {
|
|
89
|
+
SEARCH_TYPE: 'plugins/search.md',
|
|
90
|
+
emptyPaginated: 'sub-resource-plugin.md',
|
|
91
|
+
getRegisterApiUrl: 'plugins.md',
|
|
92
|
+
getSchemaApiUrl: 'plugins.md',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// JS source helpers (used by Phase 2 accuracy check — non-component exports)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param names that should be skipped during the accuracy check because they
|
|
101
|
+
* are either generic containers ('options', 'params') or too short to carry
|
|
102
|
+
* meaningful signal ('e', 'err'). The check would produce too many false
|
|
103
|
+
* positives if it required every doc to spell out these placeholder names.
|
|
104
|
+
*/
|
|
105
|
+
const SKIP_PARAMS = new Set(['options', 'params', 'e', 'err', 'error', 'cb', 'fn', 'response'])
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Find the JS source file that contains the definition of a given export.
|
|
109
|
+
* Returns null when the file cannot be determined (e.g. store constants that
|
|
110
|
+
* live in the same file as a plugin — those are covered by mention-only checks
|
|
111
|
+
* in Phase 1 and don't need an accuracy check).
|
|
112
|
+
* @param {string} exportName identifier from src/index.js
|
|
113
|
+
* @param {string} category classification returned by classify()
|
|
114
|
+
* @return {string|null} absolute path to the source file, or null
|
|
115
|
+
*/
|
|
116
|
+
function findSourceFile(exportName, category) {
|
|
117
|
+
if (category === 'Composables') {
|
|
118
|
+
return path.join(ROOT, 'src', 'composables', `${exportName}.js`)
|
|
119
|
+
}
|
|
120
|
+
if (category === 'Store plugins') {
|
|
121
|
+
// e.g. 'auditTrailsPlugin' → 'auditTrails.js'
|
|
122
|
+
const stem = exportName.slice(0, -'Plugin'.length)
|
|
123
|
+
return path.join(ROOT, 'src', 'store', 'plugins', `${stem}.js`)
|
|
124
|
+
}
|
|
125
|
+
if (category === 'Store factories') {
|
|
126
|
+
const map = {
|
|
127
|
+
useObjectStore: path.join(ROOT, 'src', 'store', 'index.js'),
|
|
128
|
+
createObjectStore: path.join(ROOT, 'src', 'store', 'index.js'),
|
|
129
|
+
createCrudStore: path.join(ROOT, 'src', 'store', 'createCrudStore.js'),
|
|
130
|
+
createSubResourcePlugin: path.join(ROOT, 'src', 'store', 'createSubResourcePlugin.js'),
|
|
131
|
+
}
|
|
132
|
+
return map[exportName] || null
|
|
133
|
+
}
|
|
134
|
+
if (category === 'Utilities') {
|
|
135
|
+
// Search every utils/*.js file (excluding the barrel) for this export
|
|
136
|
+
const utilDir = path.join(ROOT, 'src', 'utils')
|
|
137
|
+
for (const file of fs.readdirSync(utilDir)) {
|
|
138
|
+
if (!file.endsWith('.js') || file === 'index.js') continue
|
|
139
|
+
const filePath = path.join(utilDir, file)
|
|
140
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
141
|
+
const funcRe = new RegExp(`export\\s+(?:async\\s+)?function\\s+${exportName}\\b`)
|
|
142
|
+
const constRe = new RegExp(`export\\s+const\\s+${exportName}\\b`)
|
|
143
|
+
if (funcRe.test(content) || constRe.test(content)) return filePath
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract @param names from the JSDoc block immediately preceding the named
|
|
152
|
+
* export declaration in a JS file. Only looks at the JSDoc for that specific
|
|
153
|
+
* function so params from sibling exports don't pollute results.
|
|
154
|
+
* @param {string} filePath absolute path to the .js source file
|
|
155
|
+
* @param {string} exportName identifier to look up (function or const name)
|
|
156
|
+
* @return {string[]} @param identifiers (may include 'options.subKey' dotted forms)
|
|
157
|
+
*/
|
|
158
|
+
function extractFunctionParams(filePath, exportName) {
|
|
159
|
+
if (!filePath || !fs.existsSync(filePath)) return []
|
|
160
|
+
const source = fs.readFileSync(filePath, 'utf8')
|
|
161
|
+
|
|
162
|
+
// Locate the export declaration for this specific function
|
|
163
|
+
const funcRe = new RegExp(
|
|
164
|
+
`export\\s+(?:async\\s+)?function\\s+${exportName}\\b|export\\s+const\\s+${exportName}\\s*=`,
|
|
165
|
+
)
|
|
166
|
+
const funcMatch = funcRe.exec(source)
|
|
167
|
+
if (!funcMatch) return []
|
|
168
|
+
|
|
169
|
+
// Find the last /** ... */ JSDoc block that sits before this declaration
|
|
170
|
+
const before = source.slice(0, funcMatch.index)
|
|
171
|
+
const jsdocRe = /\/\*\*[\s\S]*?\*\//g
|
|
172
|
+
let lastJsdoc = null
|
|
173
|
+
let jsdocMatch
|
|
174
|
+
while ((jsdocMatch = jsdocRe.exec(before)) !== null) {
|
|
175
|
+
lastJsdoc = jsdocMatch[0]
|
|
176
|
+
}
|
|
177
|
+
if (!lastJsdoc) return []
|
|
178
|
+
|
|
179
|
+
// Pull out every @param identifier (handles both plain and [optional] forms)
|
|
180
|
+
const paramRe = /@param\s+\{[^}]+\}\s+\[?([a-zA-Z][a-zA-Z0-9._]*)\]?/g
|
|
181
|
+
const params = new Set()
|
|
182
|
+
let m
|
|
183
|
+
while ((m = paramRe.exec(lastJsdoc)) !== null) {
|
|
184
|
+
params.add(m[1])
|
|
185
|
+
}
|
|
186
|
+
return [...params]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Accuracy check for a non-component JS export: verifies that every
|
|
191
|
+
* meaningful @param name from the JSDoc appears in the export's doc file
|
|
192
|
+
* (by exact match or, for `options.subKey` dotted paths, by the leaf name).
|
|
193
|
+
* @param {string} exportName identifier from src/index.js
|
|
194
|
+
* @param {string|null} srcPath absolute path to the source file
|
|
195
|
+
* @param {string} docPath absolute path to the expected .md doc file
|
|
196
|
+
* @return {string[]} plain-English issue strings (empty when all covered)
|
|
197
|
+
*/
|
|
198
|
+
function checkJsAccuracy(exportName, srcPath, docPath) {
|
|
199
|
+
if (!srcPath || !fs.existsSync(srcPath) || !fs.existsSync(docPath)) return []
|
|
200
|
+
|
|
201
|
+
const params = extractFunctionParams(srcPath, exportName)
|
|
202
|
+
if (params.length === 0) return []
|
|
203
|
+
|
|
204
|
+
const docContent = fs.readFileSync(docPath, 'utf8')
|
|
205
|
+
const issues = []
|
|
206
|
+
|
|
207
|
+
for (const param of params) {
|
|
208
|
+
const parts = param.split('.')
|
|
209
|
+
const leaf = parts[parts.length - 1]
|
|
210
|
+
|
|
211
|
+
// Skip generic placeholder names
|
|
212
|
+
if (SKIP_PARAMS.has(leaf) || SKIP_PARAMS.has(param)) continue
|
|
213
|
+
// Skip single-character params and dual-form implementation names
|
|
214
|
+
if (leaf.length <= 1 || param.endsWith('OrOptions') || param.endsWith('OrString')) continue
|
|
215
|
+
|
|
216
|
+
// Check: the exact param name OR (for dotted paths) just the leaf must appear in the doc
|
|
217
|
+
if (!docContent.includes(param) && !docContent.includes(leaf)) {
|
|
218
|
+
issues.push(`param \`${param}\` not mentioned in doc`)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return issues
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// SFC parsing helpers (used by Phase 2 accuracy check — Component exports)
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Extract prop names from a Vue SFC's script-block `props:` definition.
|
|
231
|
+
* Handles both object form (`props: { name: { ... } }`) and array form
|
|
232
|
+
* (`props: ['name1', 'name2']`). Uses brace-depth tracking so prop option
|
|
233
|
+
* keys (type, default, required, validator) are not mistaken for prop names.
|
|
234
|
+
* @param {string} sfcPath absolute path to the .vue file
|
|
235
|
+
* @return {string[]} camelCase prop names, or empty array when none found
|
|
236
|
+
*/
|
|
237
|
+
function extractSfcProps(sfcPath) {
|
|
238
|
+
if (!fs.existsSync(sfcPath)) return []
|
|
239
|
+
const source = fs.readFileSync(sfcPath, 'utf8')
|
|
240
|
+
// Locate the SFC <script> block by hand instead of a single regex so
|
|
241
|
+
// CodeQL (js/bad-tag-filter) can't construct adversarial close-tag
|
|
242
|
+
// variants like `</script\t bar>`. The open tag still uses a tight
|
|
243
|
+
// regex (no `[^>]` ambiguity beyond the `>` itself); the close tag is
|
|
244
|
+
// found by case-insensitive string search after the opener.
|
|
245
|
+
const openMatch = source.match(/<script\b[^>]*>/i)
|
|
246
|
+
if (!openMatch) return []
|
|
247
|
+
const openEnd = openMatch.index + openMatch[0].length
|
|
248
|
+
const closeIdx = source.toLowerCase().indexOf('</script', openEnd)
|
|
249
|
+
if (closeIdx === -1) return []
|
|
250
|
+
const script = source.slice(openEnd, closeIdx)
|
|
251
|
+
|
|
252
|
+
const propsIdx = script.search(/\bprops\s*:/)
|
|
253
|
+
if (propsIdx === -1) return []
|
|
254
|
+
const afterProps = script.slice(propsIdx)
|
|
255
|
+
|
|
256
|
+
// Array form: props: ['a', 'b']
|
|
257
|
+
const arrMatch = afterProps.match(/^props\s*:\s*\[([^\]]*)\]/)
|
|
258
|
+
if (arrMatch) {
|
|
259
|
+
const names = []
|
|
260
|
+
const re = /['"]([^'"]+)['"]/g
|
|
261
|
+
let m
|
|
262
|
+
while ((m = re.exec(arrMatch[1])) !== null) names.push(m[1])
|
|
263
|
+
return names
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Object form: walk characters tracking brace depth.
|
|
267
|
+
// Keys are captured at depth 1 (direct children of the props object):
|
|
268
|
+
// - Right before a `{` opens a sub-object (e.g. `title: {`)
|
|
269
|
+
// - At end of line for flat props (e.g. `name: String,`)
|
|
270
|
+
const braceIdx = afterProps.indexOf('{')
|
|
271
|
+
if (braceIdx === -1) return []
|
|
272
|
+
|
|
273
|
+
const propNames = []
|
|
274
|
+
let depth = 0
|
|
275
|
+
let lineText = ''
|
|
276
|
+
|
|
277
|
+
for (let i = braceIdx; i < afterProps.length; i++) {
|
|
278
|
+
const ch = afterProps[i]
|
|
279
|
+
if (ch === '{') {
|
|
280
|
+
// Capture key immediately before its options object opens
|
|
281
|
+
if (depth === 1) {
|
|
282
|
+
const key = lineText.match(/^\s+([a-zA-Z][a-zA-Z0-9]*)\s*:/)
|
|
283
|
+
if (key) propNames.push(key[1])
|
|
284
|
+
}
|
|
285
|
+
depth++
|
|
286
|
+
} else if (ch === '}') {
|
|
287
|
+
depth--
|
|
288
|
+
if (depth === 0) break
|
|
289
|
+
} else if (ch === '\n') {
|
|
290
|
+
// Capture flat prop (no sub-object) at end of its line
|
|
291
|
+
if (depth === 1) {
|
|
292
|
+
const key = lineText.match(/^\s+([a-zA-Z][a-zA-Z0-9]*)\s*:/)
|
|
293
|
+
if (key) propNames.push(key[1])
|
|
294
|
+
}
|
|
295
|
+
lineText = ''
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
lineText += ch
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return [...new Set(propNames)]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Extract names of all statically-named `<slot>` elements from a Vue SFC
|
|
306
|
+
* template. Dynamic `:name="..."` bindings are intentionally skipped since
|
|
307
|
+
* the slot name is only known at runtime and cannot be literally checked.
|
|
308
|
+
* @param {string} sfcPath absolute path to the .vue file
|
|
309
|
+
* @return {string[]} static slot names (the implicit default slot is excluded)
|
|
310
|
+
*/
|
|
311
|
+
function extractSfcNamedSlots(sfcPath) {
|
|
312
|
+
if (!fs.existsSync(sfcPath)) return []
|
|
313
|
+
const source = fs.readFileSync(sfcPath, 'utf8')
|
|
314
|
+
const templateMatch = source.match(/<template\b[^>]*>([\s\S]*?)<\/template>/m)
|
|
315
|
+
if (!templateMatch) return []
|
|
316
|
+
|
|
317
|
+
const slots = new Set()
|
|
318
|
+
const slotRe = /<slot\b([^>]*?)(?:\s*\/?>)/g
|
|
319
|
+
let m
|
|
320
|
+
while ((m = slotRe.exec(templateMatch[1])) !== null) {
|
|
321
|
+
const attrs = m[1]
|
|
322
|
+
if (attrs.includes(':name=')) continue // skip dynamic slot names
|
|
323
|
+
const nameM = attrs.match(/\bname="([^"]+)"/)
|
|
324
|
+
if (nameM) slots.add(nameM[1])
|
|
325
|
+
}
|
|
326
|
+
return [...slots]
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Accuracy check for one Component export: verifies that every prop name and
|
|
331
|
+
* every static named slot defined in the SFC is mentioned at least once in
|
|
332
|
+
* the component's doc file (by camelCase or kebab-case name).
|
|
333
|
+
* @param {string} componentName PascalCase name (e.g. 'CnWidgetWrapper')
|
|
334
|
+
* @param {string} docPath absolute path to the component's .md file
|
|
335
|
+
* @return {string[]} plain-English issue strings (empty when all covered)
|
|
336
|
+
*/
|
|
337
|
+
function checkComponentDetail(componentName, docPath) {
|
|
338
|
+
const sfcPath = path.join(ROOT, 'src', 'components', componentName, `${componentName}.vue`)
|
|
339
|
+
if (!fs.existsSync(sfcPath) || !fs.existsSync(docPath)) return []
|
|
340
|
+
|
|
341
|
+
const docContent = fs.readFileSync(docPath, 'utf8')
|
|
342
|
+
const issues = []
|
|
343
|
+
|
|
344
|
+
for (const prop of extractSfcProps(sfcPath)) {
|
|
345
|
+
const kebab = toKebab(prop)
|
|
346
|
+
if (!docContent.includes(prop) && !docContent.includes(kebab)) {
|
|
347
|
+
issues.push(`prop \`${prop}\` (${kebab}) not mentioned in doc`)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const slot of extractSfcNamedSlots(sfcPath)) {
|
|
352
|
+
if (!docContent.includes(slot)) {
|
|
353
|
+
issues.push(`slot \`${slot}\` not mentioned in doc`)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return issues
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// Shared utilities
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Convert PascalCase, camelCase, or SCREAMING_SNAKE_CASE to kebab-case.
|
|
366
|
+
*
|
|
367
|
+
* SCREAMING_SNAKE_CASE detection — when the name contains an underscore we
|
|
368
|
+
* treat it as snake-cased and just lowercase + replace `_` with `-`. Without
|
|
369
|
+
* this branch, `SAFE_MARKDOWN_DOMPURIFY_CONFIG` would become
|
|
370
|
+
* `s-a-f-e_-m-a-r-k-d-o-w-n_...` because the per-uppercase-character regex
|
|
371
|
+
* would insert a dash before every capital. Constants like
|
|
372
|
+
* `SAFE_MARKDOWN_DOMPURIFY_CONFIG` and `ROADMAP_LABEL_BLOCKLIST` map cleanly
|
|
373
|
+
* to `safe-markdown-dompurify-config.md` / `roadmap-label-blocklist.md` this
|
|
374
|
+
* way.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} name identifier to convert
|
|
377
|
+
* @return {string} kebab-cased form
|
|
378
|
+
*/
|
|
379
|
+
function toKebab(name) {
|
|
380
|
+
if (name.includes('_')) {
|
|
381
|
+
return name.toLowerCase().replace(/_/g, '-')
|
|
382
|
+
}
|
|
383
|
+
return name.replace(/([A-Z])/g, (m, l, i) => (i === 0 ? '' : '-') + l.toLowerCase())
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Collect all .md file stems under a directory. By default recurses into
|
|
388
|
+
* subdirectories; pass { recursive: false } to limit to the immediate dir.
|
|
389
|
+
* @param {string} dir absolute directory to walk
|
|
390
|
+
* @param {object} [options] options bag
|
|
391
|
+
* @param {boolean} [options.recursive] when false, only read the top level
|
|
392
|
+
* @return {Set<string>} set of markdown file stems (filename without extension)
|
|
393
|
+
*/
|
|
394
|
+
function collectDocStems(dir, { recursive = true } = {}) {
|
|
395
|
+
const stems = new Set()
|
|
396
|
+
if (!fs.existsSync(dir)) {
|
|
397
|
+
return stems
|
|
398
|
+
}
|
|
399
|
+
function walk(current) {
|
|
400
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
401
|
+
const full = path.join(current, entry.name)
|
|
402
|
+
if (entry.isDirectory()) {
|
|
403
|
+
if (recursive) {
|
|
404
|
+
walk(full)
|
|
405
|
+
}
|
|
406
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
407
|
+
stems.add(path.basename(entry.name, '.md'))
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
walk(dir)
|
|
412
|
+
return stems
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Extract all identifiers re-exported from src/index.js. Handles both
|
|
417
|
+
* export { a, b } from '...'
|
|
418
|
+
* export { a } from '...'
|
|
419
|
+
* forms (with optional trailing commas and newlines inside the braces).
|
|
420
|
+
* @param {string} filePath absolute path to the barrel file
|
|
421
|
+
* @return {string[]} deduped list of re-exported identifiers
|
|
422
|
+
*/
|
|
423
|
+
function parsePublicExports(filePath) {
|
|
424
|
+
const source = fs.readFileSync(filePath, 'utf8')
|
|
425
|
+
const names = new Set()
|
|
426
|
+
const re = /export\s*\{([^}]+)\}\s*from\s*['"][^'"]+['"]/g
|
|
427
|
+
let match
|
|
428
|
+
while ((match = re.exec(source)) !== null) {
|
|
429
|
+
for (const raw of match[1].split(',')) {
|
|
430
|
+
// `foo as bar` → the *exported* name is `bar`; `foo` → `foo`.
|
|
431
|
+
const parts = raw.trim().split(/\s+as\s+/)
|
|
432
|
+
const name = (parts[1] || parts[0]).trim()
|
|
433
|
+
if (name) {
|
|
434
|
+
names.add(name)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return [...names]
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Categorize a public export name and return how to satisfy its doc requirement.
|
|
443
|
+
* @param {string} name identifier from src/index.js
|
|
444
|
+
* @return {object} category descriptor used by isCovered()
|
|
445
|
+
*/
|
|
446
|
+
function classify(name) {
|
|
447
|
+
if (EXEMPT.has(name)) {
|
|
448
|
+
return { category: 'Exempt' }
|
|
449
|
+
}
|
|
450
|
+
if (name in STORE_FACTORY_STEMS) {
|
|
451
|
+
const stem = STORE_FACTORY_STEMS[name]
|
|
452
|
+
return {
|
|
453
|
+
category: 'Store factories',
|
|
454
|
+
expectedStem: stem,
|
|
455
|
+
searchDir: path.join(DOCS_DIR, 'store'),
|
|
456
|
+
expectedPath: `docs/store/${stem}.md`,
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (name in STORE_MENTION_ONLY) {
|
|
460
|
+
const file = STORE_MENTION_ONLY[name]
|
|
461
|
+
return {
|
|
462
|
+
category: 'Store constants',
|
|
463
|
+
mentionFile: path.join(DOCS_DIR, 'store', file),
|
|
464
|
+
mentionSymbol: name,
|
|
465
|
+
expectedPath: `docs/store/${file} (must mention \`${name}\`)`,
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// Built-in integration descriptors (`flowIntegration`, `deckIntegration`, …).
|
|
469
|
+
//
|
|
470
|
+
// These are data, not API: each is a frozen `{ id, label, icon, tab, widget,
|
|
471
|
+
// … }` object with no parameters to document, and there are 26 of them. A
|
|
472
|
+
// page each would be 26 near-identical stubs, and the requirement to write
|
|
473
|
+
// one is precisely what kept 24 of them unexported from the barrel for
|
|
474
|
+
// months (see scripts/check-integration-parity.js). One table in
|
|
475
|
+
// builtin-integrations.md is the useful documentation; this rule requires
|
|
476
|
+
// every descriptor to appear in it by name, exactly like STORE_MENTION_ONLY.
|
|
477
|
+
//
|
|
478
|
+
// `registerBuiltinIntegrations` / `registerLeafIntegrations` are functions,
|
|
479
|
+
// not descriptors, and keep their own pages — hence the `register` guard.
|
|
480
|
+
if (/Integration$/.test(name) && !name.startsWith('register')) {
|
|
481
|
+
return {
|
|
482
|
+
category: 'Integration descriptors',
|
|
483
|
+
mentionFile: path.join(DOCS_DIR, 'utilities', 'builtin-integrations.md'),
|
|
484
|
+
mentionSymbol: name,
|
|
485
|
+
expectedPath: `docs/utilities/builtin-integrations.md (must mention \`${name}\`)`,
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (/^Cn[A-Z]/.test(name)) {
|
|
489
|
+
const stem = toKebab(name)
|
|
490
|
+
return {
|
|
491
|
+
category: 'Components',
|
|
492
|
+
expectedStem: stem,
|
|
493
|
+
searchDir: path.join(DOCS_DIR, 'components'),
|
|
494
|
+
expectedPath: `docs/components/${stem}.md`,
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (name.endsWith('Plugin')) {
|
|
498
|
+
const stem = toKebab(name.slice(0, -'Plugin'.length))
|
|
499
|
+
return {
|
|
500
|
+
category: 'Store plugins',
|
|
501
|
+
expectedStem: stem,
|
|
502
|
+
searchDir: path.join(DOCS_DIR, 'store', 'plugins'),
|
|
503
|
+
expectedPath: `docs/store/plugins/${stem}.md`,
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (/^use[A-Z]/.test(name)) {
|
|
507
|
+
const stem = toKebab(name)
|
|
508
|
+
return {
|
|
509
|
+
category: 'Composables',
|
|
510
|
+
expectedStem: stem,
|
|
511
|
+
searchDir: path.join(DOCS_DIR, 'utilities', 'composables'),
|
|
512
|
+
expectedPath: `docs/utilities/composables/${stem}.md`,
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const stem = toKebab(name)
|
|
516
|
+
return {
|
|
517
|
+
category: 'Utilities',
|
|
518
|
+
expectedStem: stem,
|
|
519
|
+
searchDir: path.join(DOCS_DIR, 'utilities'),
|
|
520
|
+
expectedPath: `docs/utilities/${stem}.md`,
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Verify coverage for a single classified export. Returns true when covered.
|
|
526
|
+
* @param {object} info classify() result
|
|
527
|
+
* @return {boolean} whether the export's documentation requirement is satisfied
|
|
528
|
+
*/
|
|
529
|
+
function isCovered(info) {
|
|
530
|
+
if (info.category === 'Exempt') {
|
|
531
|
+
return true
|
|
532
|
+
}
|
|
533
|
+
if (info.mentionFile) {
|
|
534
|
+
if (!fs.existsSync(info.mentionFile)) {
|
|
535
|
+
return false
|
|
536
|
+
}
|
|
537
|
+
const contents = fs.readFileSync(info.mentionFile, 'utf8')
|
|
538
|
+
return contents.includes(info.mentionSymbol)
|
|
539
|
+
}
|
|
540
|
+
const stems = collectDocStems(info.searchDir)
|
|
541
|
+
return stems.has(info.expectedStem)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// Phase 1: existence check
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
const exports_ = parsePublicExports(INDEX_FILE)
|
|
549
|
+
|
|
550
|
+
const categories = new Map()
|
|
551
|
+
const componentExports = [] // collected for Phase 2a (SFC prop/slot check)
|
|
552
|
+
|
|
553
|
+
// Categories that get a Phase 2b @param accuracy check (must have their own
|
|
554
|
+
// doc file — Store constants use mention-only and are skipped).
|
|
555
|
+
const JS_ACCURACY_CATEGORIES = new Set(['Composables', 'Utilities', 'Store factories', 'Store plugins'])
|
|
556
|
+
const jsExports = [] // { name, category, docPath } collected for Phase 2b
|
|
557
|
+
|
|
558
|
+
for (const name of exports_) {
|
|
559
|
+
const info = classify(name)
|
|
560
|
+
if (info.category === 'Exempt') {
|
|
561
|
+
continue
|
|
562
|
+
}
|
|
563
|
+
if (info.category === 'Components') {
|
|
564
|
+
componentExports.push(name)
|
|
565
|
+
}
|
|
566
|
+
// Collect JS exports that have a dedicated doc file for the @param check
|
|
567
|
+
if (JS_ACCURACY_CATEGORIES.has(info.category) && !info.mentionFile && info.searchDir && info.expectedStem) {
|
|
568
|
+
const docPath = path.join(info.searchDir, `${info.expectedStem}.md`)
|
|
569
|
+
jsExports.push({ name, category: info.category, docPath })
|
|
570
|
+
}
|
|
571
|
+
if (!categories.has(info.category)) {
|
|
572
|
+
categories.set(info.category, { checked: 0, missing: [] })
|
|
573
|
+
}
|
|
574
|
+
const bucket = categories.get(info.category)
|
|
575
|
+
bucket.checked += 1
|
|
576
|
+
if (!isCovered(info)) {
|
|
577
|
+
bucket.missing.push({ name, expectedPath: info.expectedPath })
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const ORDER = ['Components', 'Composables', 'Store factories', 'Store plugins', 'Store constants', 'Utilities']
|
|
582
|
+
const ordered = [...categories.entries()].sort(([a], [b]) => ORDER.indexOf(a) - ORDER.indexOf(b))
|
|
583
|
+
|
|
584
|
+
let totalChecked = 0
|
|
585
|
+
let totalMissing = 0
|
|
586
|
+
|
|
587
|
+
console.log('Documentation coverage by category:\n')
|
|
588
|
+
for (const [category, { checked, missing }] of ordered) {
|
|
589
|
+
totalChecked += checked
|
|
590
|
+
totalMissing += missing.length
|
|
591
|
+
const covered = checked - missing.length
|
|
592
|
+
const mark = missing.length === 0 ? '✓' : '✗'
|
|
593
|
+
console.log(` ${mark} ${category}: ${covered}/${checked}`)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (totalMissing > 0) {
|
|
597
|
+
console.error(`\n✗ ${totalMissing} export(s) are missing documentation:\n`)
|
|
598
|
+
for (const [category, { missing }] of ordered) {
|
|
599
|
+
if (missing.length === 0) {
|
|
600
|
+
continue
|
|
601
|
+
}
|
|
602
|
+
console.error(`${category}:`)
|
|
603
|
+
for (const { name, expectedPath } of missing) {
|
|
604
|
+
console.error(` - ${name} → ${expectedPath}`)
|
|
605
|
+
}
|
|
606
|
+
console.error('')
|
|
607
|
+
}
|
|
608
|
+
console.error('Create the missing files (or add the missing mention) so every public export is documented.')
|
|
609
|
+
process.exit(1)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
console.log(`\n✓ All ${totalChecked} public exports are documented.`)
|
|
613
|
+
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// Phase 2a: accuracy check — every SFC prop and named slot must appear in doc
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
console.log('\nChecking component doc accuracy (props and slots):\n')
|
|
619
|
+
|
|
620
|
+
const detailFailures = []
|
|
621
|
+
|
|
622
|
+
for (const name of componentExports) {
|
|
623
|
+
const kebab = toKebab(name)
|
|
624
|
+
const fileIssues = []
|
|
625
|
+
|
|
626
|
+
// docs/components/ reference doc (formal props/slots tables)
|
|
627
|
+
const docsPath = path.join(DOCS_DIR, 'components', `${kebab}.md`)
|
|
628
|
+
const docsIssues = checkComponentDetail(name, docsPath)
|
|
629
|
+
if (docsIssues.length > 0) {
|
|
630
|
+
fileIssues.push({ file: `docs/components/${kebab}.md`, issues: docsIssues })
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// co-located styleguide example (takes priority in the styleguide renderer)
|
|
634
|
+
const colocatedPath = path.join(ROOT, 'src', 'components', name, `${name}.md`)
|
|
635
|
+
const colocatedIssues = checkComponentDetail(name, colocatedPath)
|
|
636
|
+
if (colocatedIssues.length > 0) {
|
|
637
|
+
fileIssues.push({ file: `src/components/${name}/${name}.md`, issues: colocatedIssues })
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (fileIssues.length > 0) {
|
|
641
|
+
detailFailures.push({ name, fileIssues })
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const detailChecked = componentExports.length
|
|
646
|
+
const detailFailed = detailFailures.length
|
|
647
|
+
|
|
648
|
+
if (detailFailed === 0) {
|
|
649
|
+
console.log(` ✓ All ${detailChecked} component docs cover their props and slots.`)
|
|
650
|
+
} else {
|
|
651
|
+
console.log(` ✓ ${detailChecked - detailFailed}/${detailChecked} component docs cover their props and slots.`)
|
|
652
|
+
console.error(`\n✗ ${detailFailed} component doc(s) are missing prop or slot coverage:\n`)
|
|
653
|
+
for (const { name, fileIssues } of detailFailures) {
|
|
654
|
+
console.error(`${name}:`)
|
|
655
|
+
for (const { file, issues } of fileIssues) {
|
|
656
|
+
console.error(` ${file}:`)
|
|
657
|
+
for (const issue of issues) {
|
|
658
|
+
console.error(` - ${issue}`)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
console.error('')
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// Phase 2b: accuracy check — every JSDoc @param must appear in the doc
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
|
|
669
|
+
console.log('\nChecking JS export doc accuracy (JSDoc @param names):\n')
|
|
670
|
+
|
|
671
|
+
// Accumulate results per category so we can print a Phase-1-style summary
|
|
672
|
+
const jsDetailByCategory = new Map()
|
|
673
|
+
for (const cat of JS_ACCURACY_CATEGORIES) {
|
|
674
|
+
jsDetailByCategory.set(cat, { checked: 0, failures: [] })
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
for (const { name, category, docPath } of jsExports) {
|
|
678
|
+
const bucket = jsDetailByCategory.get(category)
|
|
679
|
+
bucket.checked += 1
|
|
680
|
+
const srcPath = findSourceFile(name, category)
|
|
681
|
+
const issues = checkJsAccuracy(name, srcPath, docPath)
|
|
682
|
+
if (issues.length > 0) {
|
|
683
|
+
bucket.failures.push({ name, issues, docPath })
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const JS_ORDER = ['Composables', 'Store factories', 'Store plugins', 'Utilities']
|
|
688
|
+
let jsDetailFailed = 0
|
|
689
|
+
|
|
690
|
+
for (const cat of JS_ORDER) {
|
|
691
|
+
const { checked, failures } = jsDetailByCategory.get(cat)
|
|
692
|
+
const covered = checked - failures.length
|
|
693
|
+
const mark = failures.length === 0 ? '✓' : '✗'
|
|
694
|
+
console.log(` ${mark} ${cat}: ${covered}/${checked}`)
|
|
695
|
+
jsDetailFailed += failures.length
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (jsDetailFailed > 0) {
|
|
699
|
+
console.error(`\n✗ ${jsDetailFailed} JS export doc(s) are missing @param coverage:\n`)
|
|
700
|
+
for (const cat of JS_ORDER) {
|
|
701
|
+
const { failures } = jsDetailByCategory.get(cat)
|
|
702
|
+
if (failures.length === 0) continue
|
|
703
|
+
console.error(`${cat}:`)
|
|
704
|
+
for (const { name, issues, docPath } of failures) {
|
|
705
|
+
const rel = path.relative(ROOT, docPath).replace(/\\/g, '/')
|
|
706
|
+
console.error(` ${name} (${rel}):`)
|
|
707
|
+
for (const issue of issues) {
|
|
708
|
+
console.error(` - ${issue}`)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
console.error('')
|
|
712
|
+
}
|
|
713
|
+
console.error('Add the missing @param names to the doc so every JSDoc parameter is covered.')
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
// Final exit
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
if (detailFailed > 0 || jsDetailFailed > 0) {
|
|
721
|
+
process.exit(1)
|
|
722
|
+
}
|
|
723
|
+
console.log('\n✓ All accuracy checks passed.')
|
|
724
|
+
process.exit(0)
|