@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.
@@ -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
+ }