@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,230 @@
|
|
|
1
|
+
/* eslint-disable no-console, n/no-process-exit */
|
|
2
|
+
/**
|
|
3
|
+
* JSDoc completeness ratchet (G2 of the auto-update guarantee).
|
|
4
|
+
*
|
|
5
|
+
* For every Cn* SFC, scores the JSDoc coverage on its props, events, and
|
|
6
|
+
* named slots, then compares against a per-component baseline committed
|
|
7
|
+
* at scripts/.jsdoc-baselines.json. CI fails when any component's score
|
|
8
|
+
* regresses below baseline; new components (no baseline entry) must
|
|
9
|
+
* score 100%.
|
|
10
|
+
*
|
|
11
|
+
* Spec: openspec/changes/unify-component-docs/specs/component-reference/
|
|
12
|
+
* spec.md "Requirement: JSDoc completeness ratchet"
|
|
13
|
+
*
|
|
14
|
+
* Modes:
|
|
15
|
+
* node scripts/check-jsdoc.js Verify against baseline (CI mode)
|
|
16
|
+
* node scripts/check-jsdoc.js --update Regenerate baseline file
|
|
17
|
+
* node scripts/check-jsdoc.js --json Emit raw scores as JSON
|
|
18
|
+
*
|
|
19
|
+
* Why we resolve vue-docgen-api from docusaurus/node_modules:
|
|
20
|
+
* The package is already installed there as a transitive dep of
|
|
21
|
+
* vue-docgen-cli. Adding it as a separate root devdep would duplicate
|
|
22
|
+
* the install and complicate the lockfile. The script is a developer
|
|
23
|
+
* tool, not part of the published library bundle.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('fs')
|
|
27
|
+
const path = require('path')
|
|
28
|
+
|
|
29
|
+
const ROOT = path.resolve(__dirname, '..')
|
|
30
|
+
const COMPONENTS_DIR = path.join(ROOT, 'src/components')
|
|
31
|
+
const BASELINE_FILE = path.join(__dirname, '.jsdoc-baselines.json')
|
|
32
|
+
const DOCGEN_API_PATH = path.join(ROOT, 'docusaurus/node_modules/vue-docgen-api')
|
|
33
|
+
|
|
34
|
+
// Resolve vue-docgen-api from the docusaurus install. If it's missing, the
|
|
35
|
+
// dev hasn't run `cd docusaurus && npm install` yet — give a clear hint.
|
|
36
|
+
let parse
|
|
37
|
+
try {
|
|
38
|
+
parse = require(DOCGEN_API_PATH).parse
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// `npm ci` — NOT `npm install --legacy-peer-deps`, which this hint used to
|
|
41
|
+
// recommend and which CLAUDE.md forbids outright. The lockfile resolves
|
|
42
|
+
// cleanly and CI installs these deps with a plain `npm ci`
|
|
43
|
+
// (code-quality.yml: `frontend-setup-command: cd docusaurus && npm ci`), so
|
|
44
|
+
// the flag was never needed. A hint pointing at a banned workaround makes
|
|
45
|
+
// the gate look unrunnable, which gets it skipped rather than fixed.
|
|
46
|
+
console.error(
|
|
47
|
+
'[check-jsdoc] Failed to load vue-docgen-api from docusaurus/node_modules.\n'
|
|
48
|
+
+ ' Run `cd docusaurus && npm ci` first.',
|
|
49
|
+
)
|
|
50
|
+
process.exit(2)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const args = process.argv.slice(2)
|
|
54
|
+
const UPDATE_MODE = args.includes('--update')
|
|
55
|
+
const JSON_MODE = args.includes('--json')
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @returns {string[]} Absolute paths to every `CnFoo/CnFoo.vue` SFC.
|
|
59
|
+
*/
|
|
60
|
+
function findCnSfcs() {
|
|
61
|
+
const dirs = fs.readdirSync(COMPONENTS_DIR, { withFileTypes: true })
|
|
62
|
+
.filter(d => d.isDirectory() && d.name.startsWith('Cn'))
|
|
63
|
+
return dirs
|
|
64
|
+
.map(d => path.join(COMPONENTS_DIR, d.name, `${d.name}.vue`))
|
|
65
|
+
.filter(p => fs.existsSync(p))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Score one component. A documented item counts 1; an undocumented item
|
|
70
|
+
* counts 0. The score is documented / total. Components with no
|
|
71
|
+
* props/events/slots score 1.0 (vacuously documented).
|
|
72
|
+
*
|
|
73
|
+
* Documentation rules (per spec):
|
|
74
|
+
* - Prop: non-empty description.
|
|
75
|
+
* - Event: non-empty description (incl. JSDoc on $emit site).
|
|
76
|
+
* - Slot: non-empty description.
|
|
77
|
+
*
|
|
78
|
+
* @returns {{component, score, total, documented, missing}}
|
|
79
|
+
*/
|
|
80
|
+
async function scoreSfc(sfcPath) {
|
|
81
|
+
const componentName = path.basename(sfcPath, '.vue')
|
|
82
|
+
const doc = await parse(sfcPath)
|
|
83
|
+
|
|
84
|
+
const items = []
|
|
85
|
+
|
|
86
|
+
for (const p of doc.props || []) {
|
|
87
|
+
items.push({
|
|
88
|
+
kind: 'prop',
|
|
89
|
+
name: p.name,
|
|
90
|
+
documented: Boolean((p.description || '').trim()),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// vue-docgen-api emits each $emit site separately AND any @event JSDoc
|
|
95
|
+
// tag, so the same logical event can appear twice. Dedupe by name and
|
|
96
|
+
// take the richest description. The character class allows `:` (e.g.
|
|
97
|
+
// `update:mode`, `update:expanded-ids`) and `.` (e.g. `field.changed`)
|
|
98
|
+
// in event names — the original `[\w-]+` regex left those events
|
|
99
|
+
// permanently flagged as undocumented because the colon couldn't be
|
|
100
|
+
// captured, see ncv#333.
|
|
101
|
+
const eventByName = new Map()
|
|
102
|
+
for (const e of doc.events || []) {
|
|
103
|
+
const m = e.name.match(/^([\w:.-]+)\s+(.+)$/s)
|
|
104
|
+
const cleanName = m ? m[1] : e.name
|
|
105
|
+
const inferredDesc = m ? m[2].trim() : ''
|
|
106
|
+
const desc = ((e.description || '').trim() || inferredDesc).trim()
|
|
107
|
+
const existing = eventByName.get(cleanName)
|
|
108
|
+
if (!existing || desc.length > existing.length) {
|
|
109
|
+
eventByName.set(cleanName, desc)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const [name, desc] of eventByName) {
|
|
113
|
+
items.push({ kind: 'event', name, documented: Boolean(desc) })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const s of doc.slots || []) {
|
|
117
|
+
items.push({
|
|
118
|
+
kind: 'slot',
|
|
119
|
+
name: s.name,
|
|
120
|
+
documented: Boolean((s.description || '').trim()),
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const total = items.length
|
|
125
|
+
const documented = items.filter(i => i.documented).length
|
|
126
|
+
const score = total === 0 ? 1.0 : documented / total
|
|
127
|
+
const missing = items.filter(i => !i.documented).map(i => `${i.kind}:${i.name}`)
|
|
128
|
+
|
|
129
|
+
return { component: componentName, score, total, documented, missing }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Round to 2 decimals so baseline diffs are stable across reruns.
|
|
134
|
+
*/
|
|
135
|
+
function round(n) {
|
|
136
|
+
return Math.round(n * 100) / 100
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function main() {
|
|
140
|
+
const sfcs = findCnSfcs()
|
|
141
|
+
const results = []
|
|
142
|
+
for (const sfc of sfcs) {
|
|
143
|
+
try {
|
|
144
|
+
results.push(await scoreSfc(sfc))
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error(`[check-jsdoc] Failed to parse ${path.relative(ROOT, sfc)}: ${err.message}`)
|
|
147
|
+
process.exit(2)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
results.sort((a, b) => a.component.localeCompare(b.component))
|
|
151
|
+
|
|
152
|
+
if (JSON_MODE) {
|
|
153
|
+
console.log(JSON.stringify(results, null, 2))
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (UPDATE_MODE) {
|
|
158
|
+
const baseline = {}
|
|
159
|
+
for (const r of results) baseline[r.component] = round(r.score)
|
|
160
|
+
baseline.__schema__ = {
|
|
161
|
+
generatedBy: 'scripts/check-jsdoc.js --update',
|
|
162
|
+
rule: 'CI fails if any component score < baseline. New components require 1.0.',
|
|
163
|
+
}
|
|
164
|
+
fs.writeFileSync(BASELINE_FILE, JSON.stringify(baseline, null, 2) + '\n')
|
|
165
|
+
console.log(`[check-jsdoc] Wrote baseline for ${results.length} components → ${path.relative(ROOT, BASELINE_FILE)}`)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let baseline = {}
|
|
170
|
+
if (fs.existsSync(BASELINE_FILE)) {
|
|
171
|
+
baseline = JSON.parse(fs.readFileSync(BASELINE_FILE, 'utf8'))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const failures = []
|
|
175
|
+
for (const r of results) {
|
|
176
|
+
const key = r.component
|
|
177
|
+
const observed = round(r.score)
|
|
178
|
+
const expected = key in baseline ? baseline[key] : 1.0
|
|
179
|
+
const isNew = !(key in baseline)
|
|
180
|
+
if (observed < expected) {
|
|
181
|
+
failures.push({ ...r, observed, expected, isNew })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Pretty print: per-component score table.
|
|
186
|
+
const longestName = Math.max(0, ...results.map(r => r.component.length))
|
|
187
|
+
console.log('\nJSDoc completeness scores (Cn* components):')
|
|
188
|
+
console.log('─'.repeat(longestName + 30))
|
|
189
|
+
for (const r of results) {
|
|
190
|
+
const pct = (r.score * 100).toFixed(0).padStart(3) + '%'
|
|
191
|
+
const baselineMark = r.component in baseline
|
|
192
|
+
? `(baseline ${(baseline[r.component] * 100).toFixed(0)}%)`
|
|
193
|
+
: '(NEW — must be 100%)'
|
|
194
|
+
console.log(` ${r.component.padEnd(longestName)} ${pct} ${r.documented}/${r.total} ${baselineMark}`)
|
|
195
|
+
}
|
|
196
|
+
console.log('─'.repeat(longestName + 30))
|
|
197
|
+
|
|
198
|
+
if (failures.length === 0) {
|
|
199
|
+
console.log(`✓ All ${results.length} components meet their JSDoc baseline.`)
|
|
200
|
+
console.log(' Tip: improve coverage in a PR, then `npm run jsdoc-baselines:update` to bump the bar.\n')
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.error('\n✗ JSDoc completeness regression(s):\n')
|
|
205
|
+
for (const f of failures) {
|
|
206
|
+
const expectedPct = (f.expected * 100).toFixed(0)
|
|
207
|
+
const observedPct = (f.observed * 100).toFixed(0)
|
|
208
|
+
const lead = f.isNew
|
|
209
|
+
? `${f.component} is a new component and MUST score 100%`
|
|
210
|
+
: `${f.component} score dropped from baseline ${expectedPct}% to ${observedPct}%`
|
|
211
|
+
console.error(` • ${lead}`)
|
|
212
|
+
console.error(` File: src/components/${f.component}/${f.component}.vue`)
|
|
213
|
+
console.error(` Missing JSDoc on:`)
|
|
214
|
+
for (const m of f.missing) {
|
|
215
|
+
console.error(` - ${m}`)
|
|
216
|
+
}
|
|
217
|
+
console.error('')
|
|
218
|
+
}
|
|
219
|
+
console.error('How to fix:')
|
|
220
|
+
console.error(' 1. Add the missing JSDoc to the SFC (see CLAUDE.md "Documenting components").')
|
|
221
|
+
console.error(' 2. Re-run `npm run check:jsdoc` to confirm.')
|
|
222
|
+
console.error(' 3. If you intentionally improved coverage, run `npm run jsdoc-baselines:update`')
|
|
223
|
+
console.error(' and commit the bumped baseline.\n')
|
|
224
|
+
process.exit(1)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
main().catch(err => {
|
|
228
|
+
console.error(err)
|
|
229
|
+
process.exit(2)
|
|
230
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Peer-dependency consistency gate.
|
|
5
|
+
*
|
|
6
|
+
* A `peerDependencies` block can be internally CONTRADICTORY: package A is
|
|
7
|
+
* declared at a range whose versions all require package B at a range that
|
|
8
|
+
* shares no version with the range we declare for B. npm 7+ resolves peers
|
|
9
|
+
* strictly, so every correctly-behaving consumer then hits ERESOLVE and has to
|
|
10
|
+
* add an `overrides` entry — or, worse, installs with `--legacy-peer-deps` and
|
|
11
|
+
* silently gets an unsupported tree.
|
|
12
|
+
*
|
|
13
|
+
* That is not hypothetical. Shipped in every release up to 2.1.0-vue3.15:
|
|
14
|
+
*
|
|
15
|
+
* "@nextcloud/capabilities": "^1.2.1" ← requires initial-state ^3.0.0
|
|
16
|
+
* "@nextcloud/vue": "^9.0.0" ← requires initial-state ^3.0.0
|
|
17
|
+
* "@nextcloud/initial-state": "^2.2.0" ← no overlap with either
|
|
18
|
+
*
|
|
19
|
+
* openregister had to add an override just to install. Nothing in CI noticed,
|
|
20
|
+
* because the library's own `npm ci` installs its DEV tree — where the peers
|
|
21
|
+
* are resolved from devDependencies and the contradiction never surfaces.
|
|
22
|
+
*
|
|
23
|
+
* OFFLINE BY DESIGN
|
|
24
|
+
* -----------------
|
|
25
|
+
* This gate reads the already-installed `node_modules` rather than querying
|
|
26
|
+
* the registry: a network call in CI is a flake waiting to happen, and the
|
|
27
|
+
* installed tree is what the repo actually builds and tests against. The
|
|
28
|
+
* trade-off is coverage — it checks the resolved version of each peer, not
|
|
29
|
+
* every version the declared range admits. A `--registry` pass would be
|
|
30
|
+
* strictly stronger and is a reasonable follow-up; this catches the class of
|
|
31
|
+
* bug that has actually shipped.
|
|
32
|
+
*
|
|
33
|
+
* Exit codes:
|
|
34
|
+
* 0 — no contradiction found among the installed peers
|
|
35
|
+
* 1 — at least one installed peer requires another peer at a range that
|
|
36
|
+
* cannot be satisfied together with our own declaration
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
'use strict'
|
|
40
|
+
|
|
41
|
+
const fs = require('fs')
|
|
42
|
+
const path = require('path')
|
|
43
|
+
|
|
44
|
+
const ROOT = path.resolve(__dirname, '..')
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read a package.json, returning null when it is absent or unparseable.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} file Absolute path.
|
|
50
|
+
*
|
|
51
|
+
* @return {object|null} Parsed manifest.
|
|
52
|
+
*/
|
|
53
|
+
function readJson(file) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether two semver ranges can be satisfied by a common version.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} semver The `semver` module.
|
|
65
|
+
* @param {string} a First range.
|
|
66
|
+
* @param {string} b Second range.
|
|
67
|
+
*
|
|
68
|
+
* @return {boolean} True when the ranges intersect.
|
|
69
|
+
*/
|
|
70
|
+
function rangesIntersect(semver, a, b) {
|
|
71
|
+
try {
|
|
72
|
+
return semver.intersects(a, b, { includePrerelease: true })
|
|
73
|
+
} catch {
|
|
74
|
+
// A range this resolver cannot parse (a git URL, `*`, a workspace
|
|
75
|
+
// protocol) is not evidence of a conflict. Say nothing rather than
|
|
76
|
+
// manufacture a failure.
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Run the gate.
|
|
83
|
+
*
|
|
84
|
+
* @return {void}
|
|
85
|
+
*/
|
|
86
|
+
function main() {
|
|
87
|
+
const pkg = readJson(path.join(ROOT, 'package.json'))
|
|
88
|
+
const peers = (pkg && pkg.peerDependencies) || {}
|
|
89
|
+
const names = Object.keys(peers)
|
|
90
|
+
|
|
91
|
+
if (names.length === 0) {
|
|
92
|
+
console.log('✓ peer consistency: no peerDependencies declared')
|
|
93
|
+
process.exit(0)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let semver
|
|
97
|
+
try {
|
|
98
|
+
// eslint-disable-next-line global-require
|
|
99
|
+
semver = require('semver')
|
|
100
|
+
} catch {
|
|
101
|
+
console.error(' (peer-consistency gate skipped: `semver` is not installed)')
|
|
102
|
+
process.exit(0)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const failures = []
|
|
106
|
+
let inspected = 0
|
|
107
|
+
|
|
108
|
+
for (const name of names) {
|
|
109
|
+
const manifest = readJson(path.join(ROOT, 'node_modules', name, 'package.json'))
|
|
110
|
+
if (manifest === null) {
|
|
111
|
+
// Not installed — an optional peer the dev tree does not pull in.
|
|
112
|
+
// Absence is not a finding.
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
inspected++
|
|
116
|
+
|
|
117
|
+
// A peer's own requirements on ANOTHER of our peers, whether it lists
|
|
118
|
+
// them as dependencies or as peerDependencies. Both constrain the
|
|
119
|
+
// consumer's tree.
|
|
120
|
+
const constraints = { ...(manifest.peerDependencies || {}), ...(manifest.dependencies || {}) }
|
|
121
|
+
|
|
122
|
+
for (const [other, range] of Object.entries(constraints)) {
|
|
123
|
+
if (!Object.prototype.hasOwnProperty.call(peers, other)) {
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (!rangesIntersect(semver, range, peers[other])) {
|
|
127
|
+
failures.push(
|
|
128
|
+
`${name}@${manifest.version} requires ${other}@${range}, `
|
|
129
|
+
+ `but we declare ${other}@${peers[other]} — no version satisfies both.\n`
|
|
130
|
+
+ ` Widen our ${other} peer (e.g. "${peers[other]} || ${range}") `
|
|
131
|
+
+ 'or lower the peer that demands it.',
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (inspected === 0) {
|
|
138
|
+
// Positive control: a run that inspected nothing must not report a
|
|
139
|
+
// clean bill of health. That shape — a check that cannot match
|
|
140
|
+
// anything, printing a tick — is indistinguishable from a real pass.
|
|
141
|
+
console.error('✗ peer consistency: no declared peer is installed — run `npm ci` first')
|
|
142
|
+
process.exit(1)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (failures.length > 0) {
|
|
146
|
+
console.error('✗ peer consistency gate failed:')
|
|
147
|
+
for (const failure of failures) {
|
|
148
|
+
console.error(` - ${failure}`)
|
|
149
|
+
}
|
|
150
|
+
console.error(
|
|
151
|
+
'\nA contradictory peer block makes `npm install` fail with ERESOLVE for every\n'
|
|
152
|
+
+ 'consumer that does not add an override. See scripts/check-peer-consistency.js.',
|
|
153
|
+
)
|
|
154
|
+
process.exit(1)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.log(`✓ peer consistency: ${inspected} installed peer(s) agree with our declared ranges`)
|
|
158
|
+
process.exit(0)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
main()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-off generator: turn CC0/EUPL government icon SVGs into compact
|
|
3
|
+
* data-URI catalogue modules for @conduction/nextcloud-vue.
|
|
4
|
+
*
|
|
5
|
+
* Each emitted entry is `{ key, label, url }` where `url` is a tiny
|
|
6
|
+
* `data:image/svg+xml,...` URI — consumed via CnIconBrowser's `url-icons`
|
|
7
|
+
* prop and rendered as `<img>`, so multi-path / illustrative icons render
|
|
8
|
+
* faithfully (the single-path catalogue path cannot represent them).
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, readFileSync, writeFileSync, statSync, mkdirSync } from 'node:fs'
|
|
11
|
+
import { join, basename, extname } from 'node:path'
|
|
12
|
+
|
|
13
|
+
const SRC = process.env.SRC_ROOT
|
|
14
|
+
const OUT = process.env.OUT_DIR
|
|
15
|
+
if (!SRC || !OUT) { throw new Error('SRC_ROOT and OUT_DIR required') }
|
|
16
|
+
mkdirSync(OUT, { recursive: true })
|
|
17
|
+
|
|
18
|
+
/** Recursively collect *.svg files under dir. */
|
|
19
|
+
function walk(dir) {
|
|
20
|
+
const out = []
|
|
21
|
+
for (const name of readdirSync(dir)) {
|
|
22
|
+
const p = join(dir, name)
|
|
23
|
+
const st = statSync(p)
|
|
24
|
+
if (st.isDirectory()) { out.push(...walk(p)) } else if (extname(name).toLowerCase() === '.svg') { out.push(p) }
|
|
25
|
+
}
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Humanise a file base name into a label. */
|
|
30
|
+
function humanize(name) {
|
|
31
|
+
return name
|
|
32
|
+
.replace(/\.svg$/i, '')
|
|
33
|
+
.replace(/^.*__/, '') // strip category prefix (denhaag arrows__x)
|
|
34
|
+
.replace(/[-_]+/g, ' ')
|
|
35
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
36
|
+
.trim()
|
|
37
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Slug for a stable key. */
|
|
41
|
+
function slug(s) {
|
|
42
|
+
return s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Mini SVG → data-URI (Taylor Hunt / tigt "mini-svg-data-uri" algorithm):
|
|
47
|
+
* single-quote attrs, collapse whitespace, encodeURIComponent, then decode
|
|
48
|
+
* the handful of hex pairs that are safe & shorter unencoded.
|
|
49
|
+
*/
|
|
50
|
+
function svgToTinyDataUri(svg) {
|
|
51
|
+
const reWs = /\s+/g
|
|
52
|
+
const reHex = /%[\dA-F]{2}/g
|
|
53
|
+
const decode = (m) => ({ '%20': ' ', '%3D': '=', '%3A': ':', '%2F': '/' }[m.toUpperCase()] || m.toLowerCase())
|
|
54
|
+
let s = svg
|
|
55
|
+
.replace(/<\?xml[\s\S]*?\?>/gi, '')
|
|
56
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
57
|
+
.replace(/<!DOCTYPE[\s\S]*?>/gi, '')
|
|
58
|
+
.replace(/<title>[\s\S]*?<\/title>/gi, '')
|
|
59
|
+
.trim()
|
|
60
|
+
.replace(reWs, ' ')
|
|
61
|
+
.replace(/> </g, '><')
|
|
62
|
+
.replace(/"/g, "'")
|
|
63
|
+
s = encodeURIComponent(s).replace(reHex, decode)
|
|
64
|
+
return 'data:image/svg+xml,' + s
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SETS = [
|
|
68
|
+
{ name: 'rvo', exportName: 'rvoIcons', dir: join(SRC, 'nlrvo-assets/package/icons'), keyPrefix: 'rvo' },
|
|
69
|
+
{ name: 'openGemeenten', exportName: 'openGemeentenIcons', dir: join(SRC, 'og/Iconenset-master/Svg/Line'), keyPrefix: 'og' },
|
|
70
|
+
{ name: 'denHaag', exportName: 'denHaagIcons', dir: join(SRC, 'denhaag-svg'), keyPrefix: 'dh' },
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
const summary = []
|
|
74
|
+
for (const set of SETS) {
|
|
75
|
+
const files = walk(set.dir).sort()
|
|
76
|
+
const seen = new Set()
|
|
77
|
+
const entries = []
|
|
78
|
+
let bytes = 0
|
|
79
|
+
for (const f of files) {
|
|
80
|
+
const raw = readFileSync(f, 'utf8')
|
|
81
|
+
if (!raw.includes('<svg')) { continue }
|
|
82
|
+
const label = humanize(basename(f))
|
|
83
|
+
let id = `${set.keyPrefix}-${slug(basename(f).replace(/\.svg$/i, ''))}`
|
|
84
|
+
let n = 2
|
|
85
|
+
while (seen.has(id)) { id = `${set.keyPrefix}-${slug(basename(f).replace(/\.svg$/i, ''))}-${n++}` }
|
|
86
|
+
seen.add(id)
|
|
87
|
+
const url = svgToTinyDataUri(raw)
|
|
88
|
+
bytes += url.length
|
|
89
|
+
entries.push({ id, label, url })
|
|
90
|
+
}
|
|
91
|
+
entries.sort((a, b) => a.label.localeCompare(b.label))
|
|
92
|
+
const header = `/**\n * SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>\n * SPDX-License-Identifier: EUPL-1.2\n *\n * AUTO-GENERATED by scripts/generate-nl-icons.mjs — do not edit by hand.\n * Icon artwork: see icons/ATTRIBUTION.md for upstream sources & licences.\n *\n * ${entries.length} icons, each an { id, label, url } entry whose \`url\` is a\n * self-contained data:image/svg+xml URI (render via CnIconBrowser url-icons).\n */\n`
|
|
93
|
+
const body = `export const ${set.exportName} = ${JSON.stringify(entries, null, '\t')}\n\nexport default ${set.exportName}\n`
|
|
94
|
+
writeFileSync(join(OUT, `${set.name}.js`), header + body)
|
|
95
|
+
summary.push({ set: set.name, icons: entries.length, kb: Math.round(bytes / 1024) })
|
|
96
|
+
}
|
|
97
|
+
writeFileSync(join(OUT, 'index.js'), `/**\n * SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>\n * SPDX-License-Identifier: EUPL-1.2\n *\n * Barrel for the bundled NL-government icon catalogues.\n *\n * Prefer a per-set subpath import (\`@conduction/nextcloud-vue/src/icons/rvo.js\`)\n * so bundlers only pull the set you use. \`NL_DESIGN_ICON_GROUPS\` and the combined\n * \`NL_DESIGN_ICONS\` reference all three sets, so importing them pulls every set.\n *\n * Group shape: { key, label, icons: [{ id, label, url }] } — feed straight to\n * CnIconBrowser's \`url-icon-groups\` prop for a tab per set.\n */\nimport { rvoIcons } from './rvo.js'\nimport { openGemeentenIcons } from './openGemeenten.js'\nimport { denHaagIcons } from './denHaag.js'\n\nexport { rvoIcons, openGemeentenIcons, denHaagIcons }\n\nexport const NL_DESIGN_ICON_GROUPS = [\n\t{ key: 'rvo', label: 'RVO', icons: rvoIcons },\n\t{ key: 'open-gemeenten', label: 'Gemeente', icons: openGemeentenIcons },\n\t{ key: 'den-haag', label: 'Den Haag', icons: denHaagIcons },\n]\n\n/** Flat combined list of every bundled NL-government icon ({ id, label, url }). */\nexport const NL_DESIGN_ICONS = [...rvoIcons, ...openGemeentenIcons, ...denHaagIcons]\n`)
|
|
98
|
+
console.log(JSON.stringify(summary, null, 2))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Regenerates docs/features.json whenever staged changes touch openspec/specs/
|
|
3
|
+
# or the features overlay (see CONVENTIONS.md in ConductionNL/.github,
|
|
4
|
+
# § "features.json is generated at commit time, never by CI").
|
|
5
|
+
#
|
|
6
|
+
# Best-effort by design: any failure only warns and never blocks the commit —
|
|
7
|
+
# the CI gate (features-check/features-extract → Quality Report) enforces.
|
|
8
|
+
|
|
9
|
+
if git diff --cached --name-only | grep -qE "^openspec/(specs/|features\.overlay\.json)"; then
|
|
10
|
+
CACHE=".git/extract-features.py"
|
|
11
|
+
# Fetch the canonical script (single source of truth in ConductionNL/.github);
|
|
12
|
+
# fall back to a previously cached copy when offline.
|
|
13
|
+
curl -sf --max-time 10 \
|
|
14
|
+
https://raw.githubusercontent.com/ConductionNL/.github/main/scripts/extract-features.py \
|
|
15
|
+
-o "$CACHE" 2>/dev/null || true
|
|
16
|
+
|
|
17
|
+
if [ -f "$CACHE" ]; then
|
|
18
|
+
if command -v python3 >/dev/null 2>&1; then PY="python3";
|
|
19
|
+
elif command -v py >/dev/null 2>&1; then PY="py -3";
|
|
20
|
+
else PY="python"; fi
|
|
21
|
+
|
|
22
|
+
if $PY "$CACHE" --app-root . >/dev/null 2>&1; then
|
|
23
|
+
git add docs/features.json
|
|
24
|
+
echo "pre-commit: docs/features.json regenerated from openspec/specs/."
|
|
25
|
+
else
|
|
26
|
+
echo "pre-commit: WARNING — could not regenerate docs/features.json (python or pyyaml missing?). CI features-check will verify." >&2
|
|
27
|
+
fi
|
|
28
|
+
else
|
|
29
|
+
echo "pre-commit: WARNING — could not fetch extract-features.py (offline?). CI features-check will verify." >&2
|
|
30
|
+
fi
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
exit 0
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Pre-commit hook: G3.5 of the auto-update guarantee.
|
|
3
|
+
#
|
|
4
|
+
# When a `src/components/Cn*/Cn*.vue` file is in the staged set,
|
|
5
|
+
# regenerate `docs/components/_generated/<name>.md` via vue-docgen-cli
|
|
6
|
+
# and stage the result so the commit lands the SFC + the auto-doc
|
|
7
|
+
# partial atomically.
|
|
8
|
+
#
|
|
9
|
+
# Without this hook, the freshness gate in `code-quality.yml` (G1)
|
|
10
|
+
# still catches stale partials at CI time, but the author has to
|
|
11
|
+
# remember to run `cd docusaurus && npm run prebuild:docs` manually
|
|
12
|
+
# before pushing. This hook closes that loop locally.
|
|
13
|
+
#
|
|
14
|
+
# Spec: openspec/specs/component-reference/spec.md
|
|
15
|
+
# Requirement: Auto-regeneration on commit (developer ergonomics)
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------
|
|
20
|
+
# Pluggable integration registry parity gate (AD-11/AD-13).
|
|
21
|
+
#
|
|
22
|
+
# When any src/integrations/ file is staged, run the parity check so a
|
|
23
|
+
# descriptor missing its `tab` or `widget` is caught at commit time
|
|
24
|
+
# rather than only at CI (code-quality.yml's "Integration parity gate"
|
|
25
|
+
# step). Cheap — just requires one Node module. Runs before the Cn*
|
|
26
|
+
# SFC early-exit below so it isn't skipped on integrations-only commits.
|
|
27
|
+
# ---------------------------------------------------------------------
|
|
28
|
+
if git diff --cached --name-only --diff-filter=ACMR \
|
|
29
|
+
| grep -qE '^src/integrations/'; then
|
|
30
|
+
echo '[pre-commit] src/integrations/ staged — running integration parity gate'
|
|
31
|
+
node scripts/check-integration-parity.js
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# Bail unless at least one Cn* SFC is staged. The check runs O(staged
|
|
35
|
+
# files), so the cost is trivial when nothing in src/components/ moves.
|
|
36
|
+
if ! git diff --cached --name-only --diff-filter=ACMR \
|
|
37
|
+
| grep -qE '^src/components/Cn[^/]+/Cn[^/]+\.vue$'; then
|
|
38
|
+
exit 0
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# Skip silently if docusaurus deps aren't installed yet. New
|
|
42
|
+
# contributors haven't necessarily run `cd docusaurus && npm install`
|
|
43
|
+
# before their first commit; failing the commit on this would be a
|
|
44
|
+
# bad first-impression. CI's freshness gate still catches the stale
|
|
45
|
+
# partial when they push, with a clear "run this command" hint.
|
|
46
|
+
if [ ! -x docusaurus/node_modules/.bin/vue-docgen ]; then
|
|
47
|
+
cat <<'EOF' >&2
|
|
48
|
+
[pre-commit] Skipping auto-regen: docusaurus/ deps not installed.
|
|
49
|
+
[pre-commit] Install once with `cd docusaurus && npm install --legacy-peer-deps`
|
|
50
|
+
[pre-commit] to enable automatic partial regeneration on commit. Without it,
|
|
51
|
+
[pre-commit] CI's freshness gate will still catch stale _generated/ files,
|
|
52
|
+
[pre-commit] just at push time instead of commit time.
|
|
53
|
+
EOF
|
|
54
|
+
exit 0
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
echo '[pre-commit] Cn* SFC staged — regenerating docs/components/_generated/'
|
|
58
|
+
|
|
59
|
+
# Regenerate. The script is idempotent — if the partials already match
|
|
60
|
+
# the source, the diff is empty and `git add` is a no-op.
|
|
61
|
+
( cd docusaurus && npm run --silent prebuild:docs )
|
|
62
|
+
|
|
63
|
+
# Stage every regenerated partial. Even if the author only touched one
|
|
64
|
+
# component, vue-docgen-cli rewrites column padding across the whole
|
|
65
|
+
# table when widths shift (rare but possible) and any drift would fail
|
|
66
|
+
# the freshness gate at push time. Re-staging the whole _generated/
|
|
67
|
+
# tree keeps things in lockstep.
|
|
68
|
+
git add docs/components/_generated/
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Mirrors the @nextcloud/l10n-enforce-ellipsis lint fix into the l10n
|
|
4
|
+
* translation catalogs. ESLint only touches source files; this rewrites
|
|
5
|
+
* `...` (exactly three ASCII dots, not adjacent to another dot) to `…`
|
|
6
|
+
* in l10n/*.json — keys and values both, since runtime lookups match
|
|
7
|
+
* the exact source string.
|
|
8
|
+
*
|
|
9
|
+
* Implemented as a textual substitution to preserve all unrelated
|
|
10
|
+
* formatting (whitespace, key order, even duplicate keys) so the diff
|
|
11
|
+
* is strictly the ellipsis change.
|
|
12
|
+
*
|
|
13
|
+
* Runs as part of `npm run lint-fix`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs')
|
|
17
|
+
const path = require('path')
|
|
18
|
+
|
|
19
|
+
const ELLIPSIS_RE = /(?<!\.)\.{3}(?!\.)/g
|
|
20
|
+
|
|
21
|
+
function processFile(file) {
|
|
22
|
+
const original = fs.readFileSync(file, 'utf8')
|
|
23
|
+
const rewritten = original.replace(ELLIPSIS_RE, '…')
|
|
24
|
+
if (rewritten === original) return false
|
|
25
|
+
fs.writeFileSync(file, rewritten)
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const l10nDir = path.join(__dirname, '..', 'l10n')
|
|
30
|
+
if (!fs.existsSync(l10nDir)) {
|
|
31
|
+
process.exit(0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let changed = 0
|
|
35
|
+
for (const entry of fs.readdirSync(l10nDir)) {
|
|
36
|
+
if (!entry.endsWith('.json')) continue
|
|
37
|
+
const file = path.join(l10nDir, entry)
|
|
38
|
+
if (processFile(file)) {
|
|
39
|
+
changed++
|
|
40
|
+
console.log(`sync-l10n-ellipsis: rewrote ${path.relative(process.cwd(), file)}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (changed === 0) {
|
|
45
|
+
console.log('sync-l10n-ellipsis: no changes')
|
|
46
|
+
}
|