@rulecad/versions 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +29 -2
- package/package.json +39 -4
- package/versions.d.mts +32 -0
- package/versions.mjs +106 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JoniBach and contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rulecad/versions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Semver for physical parts. For software, a major version means "your code may break". For a printed part it means
|
|
4
|
+
**"an earlier print may no longer fit"**. So the version follows the part's *interface*: the measurements other parts
|
|
5
|
+
depend on, like a keycap's stem slot or a case's screw holes.
|
|
6
|
+
|
|
7
|
+
| Change | Bump |
|
|
8
|
+
|---|---|
|
|
9
|
+
| An interface value changed or went away; a named variant changed | **major**: old prints may not fit |
|
|
10
|
+
| A new interface value, option, variant or tweak range | **minor**: nothing that fitted stops fitting |
|
|
11
|
+
| Anything else: tolerances that aren't interface, the shape's code, a rule's value, print settings | **patch** |
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { compare, bump } from '@rulecad/versions';
|
|
15
|
+
import { resolve } from '@rulecad/variables';
|
|
16
|
+
|
|
17
|
+
const snapshot = (object, js) => ({ object, entries: resolve(object).entries, js });
|
|
18
|
+
const before = snapshot({ variables: { slot: { value: 4.2, interface: true } } }, shapeCode);
|
|
19
|
+
const after = snapshot({ variables: { slot: { value: 4.25, interface: true } } }, shapeCode);
|
|
20
|
+
|
|
21
|
+
compare(before, after) // { kind: 'major', reasons: [{ kind: 'major', text: 'interface slot 4.2 → 4.25' }] }
|
|
22
|
+
bump('1.4.2', 'major') // '2.0.0'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Every reason is a short line you can put straight into a changelog. Where releases are kept is up to you (git tags,
|
|
26
|
+
files, a database): this package only reads and compares.
|
|
27
|
+
|
|
28
|
+
`satisfies(version, range)` handles `^1.2.0`, `~1.2.0` and `1.2.0`, so an assembly can say which versions of a part it
|
|
29
|
+
takes. One difference from npm: `^0.2.0` accepts `0.3.0`, because here a minor release never breaks a fit, even before
|
|
30
|
+
1.0. Also exported: `parse`, `newer` and `bigger`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulecad/versions",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Semver for physical parts: a change to what other parts fit against is major, so you know when an old print stops fitting.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"types": "./versions.d.mts",
|
|
8
|
+
"default": "./versions.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"versions.mjs",
|
|
12
|
+
"versions.d.mts",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"semver",
|
|
19
|
+
"versioning",
|
|
20
|
+
"3d-printing",
|
|
21
|
+
"cad",
|
|
22
|
+
"interface"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/JoniBach/rulecad.git",
|
|
31
|
+
"directory": "packages/versions"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@rulecad/variables": "^0.1.0",
|
|
38
|
+
"@rulecad/keyboard-layout": "^0.1.0"
|
|
39
|
+
},
|
|
40
|
+
"types": "./versions.d.mts"
|
|
41
|
+
}
|
package/versions.d.mts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The next version. @param {string} v '1.2.3' @param {'major' | 'minor' | 'patch'} kind @returns {string} */
|
|
2
|
+
export function bump(v: string, kind: "major" | "minor" | "patch"): string;
|
|
3
|
+
/** Whether a version is in a range (^1.2.0, ~1.2.0 or 1.2.0); ^0.2.0 takes 0.3.0. @param {string} version @param {string} range @returns {boolean} */
|
|
4
|
+
export function satisfies(version: string, range: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* What changed between two snapshots of a design, and the bump that needs.
|
|
7
|
+
* @param {Snapshot} before @param {Snapshot} after
|
|
8
|
+
* @returns {{ kind: 'major' | 'minor' | 'patch' | null, reasons: { kind: 'major' | 'minor' | 'patch', text: string }[] }}
|
|
9
|
+
*/
|
|
10
|
+
export function compare(before: Snapshot, after: Snapshot): {
|
|
11
|
+
kind: "major" | "minor" | "patch" | null;
|
|
12
|
+
reasons: {
|
|
13
|
+
kind: "major" | "minor" | "patch";
|
|
14
|
+
text: string;
|
|
15
|
+
}[];
|
|
16
|
+
};
|
|
17
|
+
export function parse(v: any): number[];
|
|
18
|
+
export function newer(x: any, y: any): boolean;
|
|
19
|
+
export function bigger(k1: any, k2: any): any;
|
|
20
|
+
/**
|
|
21
|
+
* a design: its .json, resolve()'s entries and its code
|
|
22
|
+
*/
|
|
23
|
+
export type Snapshot = {
|
|
24
|
+
object: object;
|
|
25
|
+
entries: {
|
|
26
|
+
name: string;
|
|
27
|
+
value: any;
|
|
28
|
+
}[];
|
|
29
|
+
js: string;
|
|
30
|
+
layouts?: Record<string, object>;
|
|
31
|
+
engine?: string;
|
|
32
|
+
};
|
package/versions.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Versions for physical parts: semver where the interface is what fits. A part's interface variables are the
|
|
2
|
+
// measurements other parts depend on (a keycap's stem slot), so:
|
|
3
|
+
// major: an earlier print may no longer fit: an interface variable changed or went away, a variant changed
|
|
4
|
+
// minor: something added that doesn't break fits: a new interface variable, option, variant or tweak range
|
|
5
|
+
// patch: anything else (print quality, tolerances that aren't interface, the shape's code, the rules it uses)
|
|
6
|
+
// compare(before, after) reads two snapshots of a design and says which, with a reason for each change. Releases can
|
|
7
|
+
// then be git tags, files, anything: this module only reads and compares, it stores nothing. No files, browsers too.
|
|
8
|
+
import { fmt } from '@rulecad/variables';
|
|
9
|
+
import { project } from '@rulecad/keyboard-layout';
|
|
10
|
+
|
|
11
|
+
// ---------- semver ----------
|
|
12
|
+
/** @typedef {{ object: object, entries: { name: string, value: any }[], js: string, layouts?: Record<string, object>, engine?: string }} Snapshot a design: its .json, resolve()'s entries and its code */
|
|
13
|
+
|
|
14
|
+
const KINDS = ['patch', 'minor', 'major'];
|
|
15
|
+
export const parse = (v) => { const m = String(v).match(/^(\d+)\.(\d+)\.(\d+)$/); if (!m) throw new Error(`"${v}" isn't a version like 1.2.0`); return m.slice(1).map(Number); };
|
|
16
|
+
/** The next version. @param {string} v '1.2.3' @param {'major' | 'minor' | 'patch'} kind @returns {string} */
|
|
17
|
+
export function bump(v, kind) {
|
|
18
|
+
const [a, b, c] = parse(v);
|
|
19
|
+
return kind === 'major' ? `${a + 1}.0.0` : kind === 'minor' ? `${a}.${b + 1}.0` : `${a}.${b}.${c + 1}`;
|
|
20
|
+
}
|
|
21
|
+
export const newer = (x, y) => { const [p, q] = [parse(x), parse(y)]; for (let i = 0; i < 3; i++) if (p[i] !== q[i]) return p[i] > q[i]; return false; };
|
|
22
|
+
export const bigger = (k1, k2) => (KINDS.indexOf(k1) >= KINDS.indexOf(k2) ? k1 : k2);
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
// satisfies('0.2.1', '^0.2.0') -> true
|
|
26
|
+
/** Whether a version is in a range (^1.2.0, ~1.2.0 or 1.2.0); ^0.2.0 takes 0.3.0. @param {string} version @param {string} range @returns {boolean} */
|
|
27
|
+
export function satisfies(version, range) {
|
|
28
|
+
const m = String(range).match(/^([~^]?)(\d+\.\d+\.\d+)$/);
|
|
29
|
+
if (!m) throw new Error(`"${range}" isn't a version range like ^1.2.0, ~1.2.0 or 1.2.0`);
|
|
30
|
+
const [a, b, c] = parse(version), [x, y, z] = parse(m[2]), atLeast = a > x || (a === x && (b > y || (b === y && c >= z)));
|
|
31
|
+
if (m[1] === '^') return a === x && atLeast; // same major: a minor release never breaks a fit, 0.x included
|
|
32
|
+
if (m[1] === '~') return a === x && b === y && c >= z;
|
|
33
|
+
return a === x && b === y && c === z;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------- what changed ----------
|
|
37
|
+
// compare(before, after) -> { kind: 'major'|'minor'|'patch'|null, reasons }
|
|
38
|
+
// before/after: { object, entries, js } (entries from resolve(), js the design's source).
|
|
39
|
+
/**
|
|
40
|
+
* What changed between two snapshots of a design, and the bump that needs.
|
|
41
|
+
* @param {Snapshot} before @param {Snapshot} after
|
|
42
|
+
* @returns {{ kind: 'major' | 'minor' | 'patch' | null, reasons: { kind: 'major' | 'minor' | 'patch', text: string }[] }}
|
|
43
|
+
*/
|
|
44
|
+
export function compare(before, after) {
|
|
45
|
+
const reasons = [], add = (kind, text) => reasons.push({ kind, text });
|
|
46
|
+
const val = (r, name) => r.entries.find((e) => e.name === name);
|
|
47
|
+
const v = (r, name) => fmt(val(r, name).value); // for reading: 4.2, not 4.199999999999999
|
|
48
|
+
const vars = (r) => r.object.variables || {};
|
|
49
|
+
const same = (a, b) => (typeof a === 'number' && typeof b === 'number' ? Math.abs(a - b) < 1e-9 : a === b); // numbers, or choices
|
|
50
|
+
|
|
51
|
+
for (const [name, d] of Object.entries(vars(before))) {
|
|
52
|
+
const now = vars(after)[name];
|
|
53
|
+
if (d.interface) {
|
|
54
|
+
if (!now) add('major', `interface ${name} removed`);
|
|
55
|
+
else if (!now.interface) add('major', `${name} is no longer interface`);
|
|
56
|
+
else if (!same(val(before, name).value, val(after, name).value)) add('major', `interface ${name} ${v(before, name)} → ${v(after, name)}`);
|
|
57
|
+
} else if (!now) add('patch', `${name} removed`);
|
|
58
|
+
else if (!same(val(before, name).value, val(after, name).value)) add('patch', `${name} ${v(before, name)} → ${v(after, name)}`);
|
|
59
|
+
}
|
|
60
|
+
for (const [name, d] of Object.entries(vars(after))) {
|
|
61
|
+
const was = vars(before)[name];
|
|
62
|
+
if (d.interface && !was?.interface && was) continue; // reported above as a change of role
|
|
63
|
+
if (d.interface && !was) add('minor', `interface ${name} added (${v(after, name)})`);
|
|
64
|
+
else if (!was) add('patch', `${name} added`);
|
|
65
|
+
if ((d.type === 'text' || d.type === 'anchor') && !was?.type) add('minor', `${name} can now be set (${d.type})`); // a new input
|
|
66
|
+
if (d.choice) { // a choice: options added are new ways to build it; options taken away break what used them
|
|
67
|
+
for (const c of d.choice) if (!was?.choice?.includes(c)) add('minor', `${name} can now be ${c}`);
|
|
68
|
+
for (const c of was?.choice || []) if (!d.choice.includes(c)) add(d.interface ? 'major' : 'patch', `${name} can no longer be ${c}`);
|
|
69
|
+
}
|
|
70
|
+
if (d.tweak && !was?.tweak) add('minor', `${name} can now be set (${d.tweak.min} to ${d.tweak.max})`);
|
|
71
|
+
else if (d.tweak && was?.tweak && (d.tweak.min !== was.tweak.min || d.tweak.max !== was.tweak.max)) add('patch', `${name} range now ${d.tweak.min} to ${d.tweak.max}`);
|
|
72
|
+
}
|
|
73
|
+
// Values the design builds from that changed without any variable changing: a rule's value
|
|
74
|
+
for (const e of after.entries) if (e.name.includes('.')) { const b = val(before, e.name); if (b && !same(b.value, e.value)) add('patch', `rule ${e.name} ${fmt(b.value)} → ${fmt(e.value)}`); }
|
|
75
|
+
// Layouts kept in an object's .json (older designs): moving one into a layout file is no change, if its keys match
|
|
76
|
+
const lay = (r) => r.object.layouts || {}, fields = (r, n) => Object.values(r.object.variables || {}).find((v) => v.type === 'layout' && (v.choice || []).includes(n))?.fields;
|
|
77
|
+
for (const [k, l] of Object.entries(lay(before))) {
|
|
78
|
+
const now = lay(after)[k] ?? after.layouts?.[k];
|
|
79
|
+
if (!now) add('major', `layout ${k} removed`);
|
|
80
|
+
else if (JSON.stringify(project(l, fields(after, k))) !== JSON.stringify(project(now, fields(after, k)))) add('major', `layout ${k} changed`);
|
|
81
|
+
else if (!lay(after)[k]) add('patch', `layout ${k} moved to its own file`);
|
|
82
|
+
}
|
|
83
|
+
for (const k of Object.keys(lay(after))) if (!lay(before)[k]) add('minor', `layout ${k} added`);
|
|
84
|
+
if (before.js.trimEnd() !== after.js.trimEnd()) add('patch', 'shape code changed');
|
|
85
|
+
if (before.engine != null && after.engine != null && before.engine.trimEnd() !== after.engine.trimEnd()) add('patch', 'board engine changed (footprints, routing)');
|
|
86
|
+
for (const k of ['material', 'orientation', 'title']) if (before.object[k] !== after.object[k]) add('patch', `${k} changed`);
|
|
87
|
+
const shown = (o) => JSON.stringify(o.display || o.mirror || {}); // on-screen settings ("display", once "mirror")
|
|
88
|
+
if (shown(before.object) !== shown(after.object)) add('patch', 'display settings changed');
|
|
89
|
+
// Variants: a new one is a new way to build it; changing or removing one changes what was built under that name
|
|
90
|
+
const vb = before.object.variants || {}, va = after.object.variants || {};
|
|
91
|
+
for (const [k, v] of Object.entries(vb)) {
|
|
92
|
+
if (!va[k]) add('major', `variant ${k} removed`);
|
|
93
|
+
else if (JSON.stringify(v) !== JSON.stringify(va[k])) add('major', `variant ${k} changed`);
|
|
94
|
+
}
|
|
95
|
+
for (const k of Object.keys(va)) if (!vb[k]) add('minor', `variant ${k} added`);
|
|
96
|
+
// Layout files: compared only on what this object reads of each key (its layout variable's fields), plus positions
|
|
97
|
+
for (const [name, v] of Object.entries(after.object.variables || {})) if (v.type === 'layout') {
|
|
98
|
+
for (const n of v.choice || [v.value]) {
|
|
99
|
+
const b = before.layouts?.[n], a = after.layouts?.[n];
|
|
100
|
+
if (!a || !b) continue; // new choices are reported above; a layout moved out of the .json just above
|
|
101
|
+
if (JSON.stringify(project(b, v.fields)) !== JSON.stringify(project(a, v.fields))) add('major', `layout ${n} changed (${['positions', ...(v.fields || [])].join(', ')})`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const kind = reasons.reduce((k, r) => (k ? bigger(k, r.kind) : r.kind), null);
|
|
105
|
+
return { kind, reasons };
|
|
106
|
+
}
|