mosaic-headless 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ja.md +94 -0
- package/README.md +133 -0
- package/README.zh-CN.md +87 -0
- package/README.zh-TW.md +87 -0
- package/SKILL.md +353 -0
- package/assets/templates/platforms/claude-ai.json +34 -0
- package/assets/templates/platforms/claude-code.json +28 -0
- package/assets/templates/platforms/codex-cli.json +33 -0
- package/assets/templates/platforms/continue.json +31 -0
- package/assets/templates/platforms/copilot.json +46 -0
- package/assets/templates/platforms/cursor.json +36 -0
- package/assets/templates/platforms/gemini-cli.json +33 -0
- package/assets/templates/platforms/windsurf.json +34 -0
- package/bin/check-release.mjs +143 -0
- package/bin/install.mjs +452 -0
- package/bin/sync-version.mjs +63 -0
- package/data/animatable-properties.csv +23 -0
- package/data/condition-comparators.csv +13 -0
- package/data/condition-subjects.csv +60 -0
- package/data/db-columns.csv +207 -0
- package/data/default-children.csv +11 -0
- package/data/dynamic-variables.csv +75 -0
- package/data/element-classes.csv +152 -0
- package/data/evaluator-functions.csv +20 -0
- package/data/interaction-types.csv +13 -0
- package/data/node-properties.csv +182 -0
- package/data/node-property-verification.csv +182 -0
- package/data/node-types.csv +123 -0
- package/data/node-verification.csv +123 -0
- package/data/placement-rules.csv +123 -0
- package/data/pluggables.csv +208 -0
- package/data/property-verification.csv +171 -0
- package/data/rest-routes.csv +115 -0
- package/data/rwd-verification.csv +570 -0
- package/data/style-properties.csv +99 -0
- package/data/style-states.csv +54 -0
- package/data/style-value-shapes.csv +23 -0
- package/data/style-verification.csv +99 -0
- package/package.json +59 -0
- package/references/data-model.md +95 -0
- package/references/design-system.md +118 -0
- package/references/dynamic-content.md +113 -0
- package/references/failure-modes.md +182 -0
- package/references/interactions.md +126 -0
- package/references/placement.md +117 -0
- package/references/responsive.md +174 -0
- package/references/styling.md +172 -0
- package/references/templates-and-conditions.md +122 -0
- package/references/vs-elementor-gutenberg.md +73 -0
- package/references/write-protocol.md +79 -0
- package/sites/_moksa.py +1165 -0
- package/sites/moksa.json +8685 -0
- package/tools/bootstrap_probe_theme.php +68 -0
- package/tools/build_all.py +55 -0
- package/tools/build_page.py +352 -0
- package/tools/build_report.py +221 -0
- package/tools/build_site.py +174 -0
- package/tools/capture_live.py +130 -0
- package/tools/check_placement_predicts.py +72 -0
- package/tools/copy_styles.py +204 -0
- package/tools/extract_default_children.py +94 -0
- package/tools/extract_dynamic_variables.py +104 -0
- package/tools/extract_interactions.py +98 -0
- package/tools/extract_node_types.py +165 -0
- package/tools/extract_placement.py +135 -0
- package/tools/extract_pluggables.py +90 -0
- package/tools/extract_style_properties.py +163 -0
- package/tools/mint_session.php +52 -0
- package/tools/probe.py +144 -0
- package/tools/sweep_node_properties.py +271 -0
- package/tools/sweep_node_types.py +318 -0
- package/tools/sweep_properties.py +215 -0
- package/tools/sweep_style_properties.py +254 -0
- package/tools/theme_export.php +91 -0
- package/tools/theme_import.php +113 -0
- package/tools/verify_rwd.py +278 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Refuse to publish a package that does not hold together.
|
|
4
|
+
*
|
|
5
|
+
* node bin/check-release.mjs
|
|
6
|
+
*
|
|
7
|
+
* Runs as `preversion` and again as `prepublishOnly`, so a release cannot be cut or
|
|
8
|
+
* pushed while any of this is false. Exits non-zero with the reason.
|
|
9
|
+
*
|
|
10
|
+
* The version number exists in three places - package.json, the SKILL.md
|
|
11
|
+
* frontmatter an agent reads, and the frontmatter each platform template writes on
|
|
12
|
+
* install. Nothing keeps them together on its own, and a skill that reports a
|
|
13
|
+
* version it is not is worse than one that reports none. `npm version` runs
|
|
14
|
+
* sync-version.mjs to write all three; this asserts it worked.
|
|
15
|
+
*
|
|
16
|
+
* It also checks that every path in package.json `files` exists, that the data files
|
|
17
|
+
* the skill's own claims rest on are present and non-trivial, and that the
|
|
18
|
+
* verification CSVs still say what SKILL.md says they say. The last one is the point:
|
|
19
|
+
* the headline numbers in the documentation are counted from the data at release
|
|
20
|
+
* time rather than trusted.
|
|
21
|
+
*/
|
|
22
|
+
import { execSync } from "node:child_process";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
|
+
const read = (p) => fs.readFileSync(path.join(ROOT, p), "utf8");
|
|
29
|
+
const problems = [];
|
|
30
|
+
const fail = (m) => problems.push(m);
|
|
31
|
+
|
|
32
|
+
// ---------- versions agree --------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const pkg = JSON.parse(read("package.json"));
|
|
35
|
+
const want = pkg.version;
|
|
36
|
+
|
|
37
|
+
const skill = read("SKILL.md");
|
|
38
|
+
const fmVersion = /^version:\s*"?([^"\n]+)"?\s*$/m.exec(skill.split("---")[1] || "");
|
|
39
|
+
if (!fmVersion) fail("SKILL.md frontmatter has no version:");
|
|
40
|
+
else if (fmVersion[1].trim() !== want)
|
|
41
|
+
fail(`SKILL.md version ${fmVersion[1].trim()} != package.json ${want}`);
|
|
42
|
+
|
|
43
|
+
const platDir = path.join(ROOT, "assets", "templates", "platforms");
|
|
44
|
+
for (const f of fs.readdirSync(platDir).filter((x) => x.endsWith(".json"))) {
|
|
45
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(platDir, f), "utf8"));
|
|
46
|
+
const v = cfg.frontmatter?.version;
|
|
47
|
+
if (v !== undefined && v !== want) fail(`${f} frontmatter version ${v} != ${want}`);
|
|
48
|
+
if (JSON.stringify(cfg).includes("gutenberg-headless"))
|
|
49
|
+
fail(`${f} still names another skill`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------- everything the package claims to ship actually exists -----------
|
|
53
|
+
|
|
54
|
+
// `files` entries are globs, not paths - a directory shipped whole is the mistake
|
|
55
|
+
// this list exists to avoid, so the check has to understand `data/*.csv`.
|
|
56
|
+
for (const entry of pkg.files) {
|
|
57
|
+
const star = entry.indexOf("*");
|
|
58
|
+
if (star === -1) {
|
|
59
|
+
if (!fs.existsSync(path.join(ROOT, entry)))
|
|
60
|
+
fail(`package.json files lists ${entry}, which does not exist`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const dir = entry.slice(0, entry.lastIndexOf("/"));
|
|
64
|
+
const pattern = entry.slice(entry.lastIndexOf("/") + 1);
|
|
65
|
+
const quote = (x) => x.replace(/[-.+?^${}()|[\]\\]/g, "\\$&");
|
|
66
|
+
const re = new RegExp("^" + pattern.split("*").map(quote).join(".*") + "$");
|
|
67
|
+
const abs = path.join(ROOT, dir);
|
|
68
|
+
if (!fs.existsSync(abs) || !fs.readdirSync(abs).some((f) => re.test(f)))
|
|
69
|
+
fail(`package.json files lists ${entry}, which matches nothing`);
|
|
70
|
+
}
|
|
71
|
+
if (!fs.existsSync(path.join(ROOT, pkg.bin["mosaic-headless"])))
|
|
72
|
+
fail(`bin points at ${pkg.bin["mosaic-headless"]}, which does not exist`);
|
|
73
|
+
|
|
74
|
+
// ---------- the numbers in the docs are counted, not trusted ----------------
|
|
75
|
+
|
|
76
|
+
const rows = (p) => read(p).trim().split("\n").length - 1; // minus the header
|
|
77
|
+
const counts = {
|
|
78
|
+
"data/node-verification.csv": 122,
|
|
79
|
+
"data/style-verification.csv": 98,
|
|
80
|
+
"data/node-property-verification.csv": 181,
|
|
81
|
+
"data/rwd-verification.csv": 569,
|
|
82
|
+
};
|
|
83
|
+
for (const [file, expected] of Object.entries(counts)) {
|
|
84
|
+
if (!fs.existsSync(path.join(ROOT, file))) { fail(`${file} missing`); continue; }
|
|
85
|
+
const n = rows(file);
|
|
86
|
+
if (n !== expected) fail(`${file} has ${n} rows, SKILL.md claims ${expected}`);
|
|
87
|
+
if (!skill.includes(String(expected)))
|
|
88
|
+
fail(`SKILL.md never mentions ${expected}, but ${file} has that many rows`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// a sweep whose own blind spots were folded into a pass rate is the failure this
|
|
92
|
+
// skill argues against, so the labels have to survive into the shipped data
|
|
93
|
+
const style = read("data/style-verification.csv");
|
|
94
|
+
if (!style.includes("SKIPPED")) fail("style-verification.csv has no SKIPPED rows - "
|
|
95
|
+
+ "blind spots should be labelled, not dropped");
|
|
96
|
+
|
|
97
|
+
// ---------- nothing private ships -------------------------------------------
|
|
98
|
+
|
|
99
|
+
// npm's `files` allowlist OVERRIDES .gitignore: naming a directory there ships
|
|
100
|
+
// everything inside it, ignored or not. Listing `sites/` once put a real client's
|
|
101
|
+
// page generator and content into the tarball - gitignored, and published anyway.
|
|
102
|
+
// So the tarball is inspected rather than the intent trusted.
|
|
103
|
+
let packed = "";
|
|
104
|
+
try {
|
|
105
|
+
// execSync with one fixed command string: on Windows `npm` is npm.cmd, which
|
|
106
|
+
// execFileSync will not resolve, and passing an args array with shell:true is
|
|
107
|
+
// deprecated. Nothing here is interpolated, so there is nothing to escape.
|
|
108
|
+
packed = execSync("npm pack --dry-run --json",
|
|
109
|
+
{ cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
110
|
+
} catch {
|
|
111
|
+
fail("could not run `npm pack --dry-run` to inspect the tarball");
|
|
112
|
+
}
|
|
113
|
+
if (packed) {
|
|
114
|
+
let files = [];
|
|
115
|
+
try {
|
|
116
|
+
files = (JSON.parse(packed)[0]?.files || []).map((f) => f.path.split("\\").join("/"));
|
|
117
|
+
} catch { fail("could not parse `npm pack --json` output"); }
|
|
118
|
+
|
|
119
|
+
const forbidden = [
|
|
120
|
+
[/^sites\/_zidanna/, "a real client's page generator"],
|
|
121
|
+
[/^sites\/zidanna\./, "a real client's content"],
|
|
122
|
+
[/^data\/raw\//, "raw capture dumps"],
|
|
123
|
+
[/__pycache__/, "python build artefacts"],
|
|
124
|
+
[/^shots\//, "screenshots"],
|
|
125
|
+
[/\.env|secret|credential/i, "something that looks like a secret"],
|
|
126
|
+
];
|
|
127
|
+
for (const [re, what] of forbidden) {
|
|
128
|
+
const hit = files.filter((f) => re.test(f));
|
|
129
|
+
if (hit.length) fail(`tarball would ship ${what}: ${hit.slice(0, 3).join(", ")}`);
|
|
130
|
+
}
|
|
131
|
+
if (files.length === 0) fail("npm pack reported no files");
|
|
132
|
+
console.log(`tarball inspected: ${files.length} files, nothing forbidden`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------- report ----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
if (problems.length) {
|
|
138
|
+
console.error(`release check failed (${problems.length}):`);
|
|
139
|
+
for (const p of problems) console.error(" - " + p);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
console.log(`release check passed: v${want}, ${pkg.files.length} shipped paths, `
|
|
143
|
+
+ `${Object.keys(counts).length} verification tables counted`);
|
package/bin/install.mjs
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Install the mosaic-headless skill into any of 8 supported AI platforms.
|
|
4
|
+
*
|
|
5
|
+
* npx mosaic-headless # interactive (arrow keys, zh-TW)
|
|
6
|
+
* npx mosaic-headless --list
|
|
7
|
+
* npx mosaic-headless claude-code --global
|
|
8
|
+
* npx mosaic-headless cursor --to /path/to/project
|
|
9
|
+
* npx mosaic-headless --dry-run claude-code
|
|
10
|
+
*
|
|
11
|
+
* Zero dependencies - colors, prompts and progress are hand-rolled so npx
|
|
12
|
+
* starts instantly. Python is NOT needed to install, only to run the skill's
|
|
13
|
+
* query/validation tools afterwards.
|
|
14
|
+
*
|
|
15
|
+
* Install types:
|
|
16
|
+
* full - SKILL.md + references/ + tools/ + data/ + sites/
|
|
17
|
+
* rule - single .md/.mdc rule file with embedded references
|
|
18
|
+
* instructions-append - fenced section appended to copilot-instructions.md
|
|
19
|
+
* zip-upload - zip bundle for manual upload (Claude.ai)
|
|
20
|
+
*
|
|
21
|
+
* Exit codes: 0 = installed, 1 = failed, 2 = invocation error
|
|
22
|
+
*/
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import readline from "node:readline";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
31
|
+
const PLATFORMS = path.join(ROOT, "assets", "templates", "platforms");
|
|
32
|
+
|
|
33
|
+
// ---------- colors (hand-rolled chalk) -------------------------------------
|
|
34
|
+
|
|
35
|
+
const TTY = process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
36
|
+
const esc = (n) => (s) => (TTY ? `\x1b[${n}m${s}\x1b[0m` : s);
|
|
37
|
+
const bold = esc("1"), dim = esc("2");
|
|
38
|
+
const red = esc("1;31"), yellow = esc("1;33"), green = esc("1;32");
|
|
39
|
+
const cyan = esc("1;36"), blue = esc("1;34"), magenta = esc("1;35");
|
|
40
|
+
const gcyan = esc("36"), ggreen = esc("32");
|
|
41
|
+
|
|
42
|
+
const die = (msg, code = 1) => { console.error(red("error"), msg); process.exit(code); };
|
|
43
|
+
const info = (m) => console.log(blue("info"), m);
|
|
44
|
+
const warn = (m) => console.log(yellow("warn"), m);
|
|
45
|
+
const expand = (p) => (p && p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p);
|
|
46
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
47
|
+
|
|
48
|
+
// ---------- the MOKSA banner ------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const LETTERS = {
|
|
51
|
+
M: ["███╗ ███╗", "████╗ ████║", "██╔████╔██║", "██║╚██╔╝██║", "██║ ╚═╝ ██║", "╚═╝ ╚═╝"],
|
|
52
|
+
O: [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
|
|
53
|
+
K: ["██╗ ██╗", "██║ ██╔╝", "█████╔╝ ", "██╔═██╗ ", "██║ ██╗", "╚═╝ ╚═╝"],
|
|
54
|
+
S: ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
|
|
55
|
+
A: [" █████╗ ", "██╔══██╗", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
|
|
56
|
+
};
|
|
57
|
+
const PALETTE = [red, yellow, green, cyan, magenta];
|
|
58
|
+
|
|
59
|
+
function banner(subtitle) {
|
|
60
|
+
console.log();
|
|
61
|
+
for (let row = 0; row < 6; row++) {
|
|
62
|
+
console.log(" " + [..."MOKSA"].map((ch, i) => PALETTE[i % PALETTE.length](LETTERS[ch][row])).join(" "));
|
|
63
|
+
}
|
|
64
|
+
console.log();
|
|
65
|
+
console.log(bold(gcyan(" mosaic-headless · Mosaic 無頭建站技能包")));
|
|
66
|
+
console.log(bold(yellow(` ${subtitle}`)));
|
|
67
|
+
console.log();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------- the 完成 box ----------------------------------------------------
|
|
71
|
+
|
|
72
|
+
const visualWidth = (str) => {
|
|
73
|
+
let w = 0;
|
|
74
|
+
for (const ch of str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")) {
|
|
75
|
+
const c = ch.codePointAt(0);
|
|
76
|
+
w += (c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3000 && c <= 0x303f) ||
|
|
77
|
+
(c >= 0xff00 && c <= 0xffef) || (c >= 0xac00 && c <= 0xd7af) ? 2 : 1;
|
|
78
|
+
}
|
|
79
|
+
return w;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function box(lines, header = " 完成 ") {
|
|
83
|
+
const width = Math.max(visualWidth(header), ...lines.map(visualWidth)) + 2;
|
|
84
|
+
console.log();
|
|
85
|
+
console.log(ggreen("╭" + "─".repeat(width) + "╮"));
|
|
86
|
+
console.log(ggreen("│") + green(header) + " ".repeat(width - visualWidth(header)) + ggreen("│"));
|
|
87
|
+
console.log(ggreen("├" + "─".repeat(width) + "┤"));
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
console.log(ggreen("│ ") + line + " ".repeat(Math.max(0, width - visualWidth(line) - 2)) + ggreen(" │"));
|
|
90
|
+
}
|
|
91
|
+
console.log(ggreen("╰" + "─".repeat(width) + "╯"));
|
|
92
|
+
console.log();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------- animated progress ----------------------------------------------
|
|
96
|
+
|
|
97
|
+
async function step(label, fn) {
|
|
98
|
+
if (!TTY) { const out = await fn(); console.log(green("✔"), label); return out; }
|
|
99
|
+
process.stdout.write(cyan("◐") + " " + label);
|
|
100
|
+
const t0 = Date.now();
|
|
101
|
+
const out = await fn();
|
|
102
|
+
await sleep(Math.max(0, 140 - (Date.now() - t0)));
|
|
103
|
+
process.stdout.write("\r" + green("✔") + " " + label + "\n");
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------- interactive prompts (hand-rolled, zh-TW) ------------------------
|
|
108
|
+
|
|
109
|
+
function select(message, choices) {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
let idx = 0;
|
|
112
|
+
const render = (first) => {
|
|
113
|
+
if (!first) process.stdout.write(`\x1b[${choices.length + 1}A\x1b[J`);
|
|
114
|
+
console.log(bold(message) + dim("(↑↓ 選擇,Enter 確定)"));
|
|
115
|
+
choices.forEach((c, i) => {
|
|
116
|
+
console.log(i === idx ? cyan(` ❯ ${c.title}`) : ` ${c.title}`);
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
render(true);
|
|
120
|
+
readline.emitKeypressEvents(process.stdin);
|
|
121
|
+
process.stdin.setRawMode(true);
|
|
122
|
+
process.stdin.resume();
|
|
123
|
+
const onKey = (_s, key) => {
|
|
124
|
+
if (key.name === "up") idx = (idx - 1 + choices.length) % choices.length;
|
|
125
|
+
else if (key.name === "down") idx = (idx + 1) % choices.length;
|
|
126
|
+
else if (key.name === "return") {
|
|
127
|
+
process.stdin.setRawMode(false);
|
|
128
|
+
process.stdin.removeListener("keypress", onKey);
|
|
129
|
+
process.stdin.pause();
|
|
130
|
+
return resolve(choices[idx].value);
|
|
131
|
+
} else if (key.name === "c" && key.ctrl) { process.stdin.setRawMode(false); process.exit(130); }
|
|
132
|
+
else return;
|
|
133
|
+
render(false);
|
|
134
|
+
};
|
|
135
|
+
process.stdin.on("keypress", onKey);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toggle(message, initial = false) {
|
|
140
|
+
const yes = { title: initial ? "要(預設)" : "要", value: true };
|
|
141
|
+
const no = { title: initial ? "不要" : "不要(預設)", value: false };
|
|
142
|
+
return select(message, initial ? [yes, no] : [no, yes]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------- platform config ------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function listPlatforms() {
|
|
148
|
+
return fs.readdirSync(PLATFORMS).filter((f) => f.endsWith(".json")).sort()
|
|
149
|
+
.map((f) => JSON.parse(fs.readFileSync(path.join(PLATFORMS, f), "utf8")));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function loadPlatform(name) {
|
|
153
|
+
const p = path.join(PLATFORMS, `${name}.json`);
|
|
154
|
+
if (!fs.existsSync(p)) die(`Unknown platform: ${name} — run --list to see options.`, 2);
|
|
155
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function detectPlatforms() {
|
|
159
|
+
const cwd = process.cwd(), home = os.homedir();
|
|
160
|
+
const hits = [];
|
|
161
|
+
const probe = (dir, name) => { if (fs.existsSync(dir)) hits.push(name); };
|
|
162
|
+
probe(path.join(cwd, ".claude"), "claude-code");
|
|
163
|
+
probe(path.join(home, ".claude"), "claude-code");
|
|
164
|
+
probe(path.join(cwd, ".cursor"), "cursor");
|
|
165
|
+
probe(path.join(home, ".cursor"), "cursor");
|
|
166
|
+
probe(path.join(cwd, ".github"), "copilot");
|
|
167
|
+
probe(path.join(home, ".codex"), "codex-cli");
|
|
168
|
+
probe(path.join(home, ".gemini"), "gemini-cli");
|
|
169
|
+
probe(path.join(home, ".windsurf"), "windsurf");
|
|
170
|
+
return [...new Set(hits)];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------- content assembly ------------------------------------------------
|
|
174
|
+
|
|
175
|
+
function buildFrontmatter(fm) {
|
|
176
|
+
if (!fm) return "";
|
|
177
|
+
const lines = ["---"];
|
|
178
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
179
|
+
if (v === null || v === undefined) continue;
|
|
180
|
+
if (Array.isArray(v)) lines.push(`${k}: [${v.map((x) => JSON.stringify(x)).join(", ")}]`);
|
|
181
|
+
else if (typeof v === "boolean") lines.push(`${k}: ${v}`);
|
|
182
|
+
else if (typeof v === "string" && (v.includes("\n") || v.length > 100)) {
|
|
183
|
+
lines.push(`${k}: |`);
|
|
184
|
+
for (const sub of v.split("\n")) lines.push(` ${sub}`);
|
|
185
|
+
} else lines.push(`${k}: ${JSON.stringify(v)}`);
|
|
186
|
+
}
|
|
187
|
+
lines.push("---");
|
|
188
|
+
return lines.join("\n") + "\n\n";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readSkillBody() {
|
|
192
|
+
let raw = fs.readFileSync(path.join(ROOT, "SKILL.md"), "utf8");
|
|
193
|
+
if (raw.startsWith("---")) {
|
|
194
|
+
const end = raw.indexOf("\n---", 3);
|
|
195
|
+
if (end !== -1) raw = raw.slice(end + 4).replace(/^\n+/, "");
|
|
196
|
+
}
|
|
197
|
+
return raw;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function embedReferences(names) {
|
|
201
|
+
// A missing file is a hard error, not a skip - silently shipping a rule file
|
|
202
|
+
// short one document is the silent-failure class this skill exists to kill.
|
|
203
|
+
const parts = [];
|
|
204
|
+
for (const n of names) {
|
|
205
|
+
const p = path.join(ROOT, "references", n);
|
|
206
|
+
if (!fs.existsSync(p)) {
|
|
207
|
+
const avail = fs.readdirSync(path.join(ROOT, "references")).filter((q) => q.endsWith(".md")).join(", ");
|
|
208
|
+
die(`Platform config asks to embed references/${n}, which does not exist.\nAvailable: ${avail}`);
|
|
209
|
+
}
|
|
210
|
+
parts.push(`\n\n---\n\n## Reference: ${n}\n\n${fs.readFileSync(p, "utf8")}\n`);
|
|
211
|
+
}
|
|
212
|
+
return parts.join("");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function walkFiles(dir) {
|
|
216
|
+
const out = [];
|
|
217
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
218
|
+
const p = path.join(dir, e.name);
|
|
219
|
+
if (e.name === "__pycache__") continue;
|
|
220
|
+
if (e.isDirectory()) out.push(...walkFiles(p));
|
|
221
|
+
else out.push(p);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function copySection(sec, dstRoot, force) {
|
|
227
|
+
// Upgrades MUST delete what the previous version left behind: a stale, wrong
|
|
228
|
+
// dataset sitting next to the right one is worse than no installer.
|
|
229
|
+
const srcPath = path.join(ROOT, sec);
|
|
230
|
+
if (!fs.existsSync(srcPath)) return [0, 0];
|
|
231
|
+
let copied = 0;
|
|
232
|
+
const keep = new Set();
|
|
233
|
+
for (const f of walkFiles(srcPath)) {
|
|
234
|
+
const rel = path.relative(srcPath, f);
|
|
235
|
+
const target = path.join(dstRoot, sec, rel);
|
|
236
|
+
keep.add(path.resolve(target));
|
|
237
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
238
|
+
if (fs.existsSync(target) && !force) continue;
|
|
239
|
+
fs.copyFileSync(f, target);
|
|
240
|
+
copied++;
|
|
241
|
+
}
|
|
242
|
+
let pruned = 0;
|
|
243
|
+
const secDir = path.join(dstRoot, sec);
|
|
244
|
+
if (fs.existsSync(secDir)) {
|
|
245
|
+
for (const f of walkFiles(secDir)) {
|
|
246
|
+
if (!keep.has(path.resolve(f))) { fs.unlinkSync(f); pruned++; }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return [copied, pruned];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------- strategies -----------------------------------------------------
|
|
253
|
+
|
|
254
|
+
async function installFull(cfg, root, force, dry) {
|
|
255
|
+
const fsr = cfg.folderStructure;
|
|
256
|
+
const dir = path.join(root, fsr.skillPath);
|
|
257
|
+
const file = path.join(dir, fsr.filename);
|
|
258
|
+
const content = buildFrontmatter(cfg.frontmatter) + readSkillBody();
|
|
259
|
+
const plan = { file, size: content.length, sections: [] };
|
|
260
|
+
if (dry) {
|
|
261
|
+
for (const s of ["references", "tools", "data", "sites"]) if (cfg.sections?.[s]) plan.sections.push(s);
|
|
262
|
+
return plan;
|
|
263
|
+
}
|
|
264
|
+
await step("寫入 SKILL.md", () => {
|
|
265
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
266
|
+
if (fs.existsSync(file) && !force) die(`Refusing to overwrite ${file} - pass --force.`);
|
|
267
|
+
fs.writeFileSync(file, content);
|
|
268
|
+
});
|
|
269
|
+
for (const s of ["references", "tools", "data", "sites"]) {
|
|
270
|
+
if (!cfg.sections?.[s]) continue;
|
|
271
|
+
await step(`複製 ${s}/`, () => {
|
|
272
|
+
const [n, pruned] = copySection(s, dir, force);
|
|
273
|
+
plan.sections.push(`${s} (${n} files${pruned ? `, ${pruned} stale removed` : ""})`);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
await step("驗證安裝", () => {
|
|
277
|
+
if (!fs.existsSync(file)) die("install verification failed: SKILL.md missing");
|
|
278
|
+
if (cfg.sections?.data && !fs.existsSync(path.join(dir, "data", "node-verification.csv")))
|
|
279
|
+
die("install verification failed: data/node-verification.csv missing");
|
|
280
|
+
});
|
|
281
|
+
return plan;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function installRule(cfg, root, force, dry) {
|
|
285
|
+
const fsr = cfg.folderStructure;
|
|
286
|
+
const file = path.join(root, fsr.skillPath, fsr.filename);
|
|
287
|
+
let body = readSkillBody();
|
|
288
|
+
if (cfg.embedReferences) body += embedReferences(cfg.embedReferences);
|
|
289
|
+
const content = buildFrontmatter(cfg.frontmatter) + body;
|
|
290
|
+
const plan = { file, size: content.length, embedded: cfg.embedReferences || [] };
|
|
291
|
+
if (dry) return plan;
|
|
292
|
+
await step("寫入 rule 檔(內嵌參考文件)", () => {
|
|
293
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
294
|
+
if (fs.existsSync(file) && !force) die(`Refusing to overwrite ${file} - pass --force.`);
|
|
295
|
+
fs.writeFileSync(file, content);
|
|
296
|
+
});
|
|
297
|
+
return plan;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function installAppend(cfg, root, force, dry) {
|
|
301
|
+
const fsr = cfg.folderStructure;
|
|
302
|
+
const target = path.join(root, fsr.projectRoot, fsr.filename);
|
|
303
|
+
const { appendMarker: begin, appendMarkerEnd: end } = cfg;
|
|
304
|
+
let body = readSkillBody();
|
|
305
|
+
if (cfg.embedReferences) body += embedReferences(cfg.embedReferences);
|
|
306
|
+
const section = `\n\n${begin}\n## Headless Mosaic Skill\n\n${body}\n${end}\n`;
|
|
307
|
+
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
|
|
308
|
+
let content;
|
|
309
|
+
if (existing.includes(begin) && existing.includes(end)) {
|
|
310
|
+
const before = existing.split(begin)[0].replace(/\s+$/, "");
|
|
311
|
+
const after = existing.split(end).slice(1).join(end).replace(/^\s+/, "");
|
|
312
|
+
content = (before + "\n" + after).trim() + section;
|
|
313
|
+
} else {
|
|
314
|
+
content = (existing.replace(/\s+$/, "") + section).replace(/^\n+/, "");
|
|
315
|
+
}
|
|
316
|
+
const plan = { file: target, size: content.length, appended: true };
|
|
317
|
+
if (dry) return plan;
|
|
318
|
+
await step("附加到 copilot-instructions.md", () => {
|
|
319
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
320
|
+
fs.writeFileSync(target, content);
|
|
321
|
+
});
|
|
322
|
+
return plan;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function installZip(cfg, root, force, dry) {
|
|
326
|
+
// Node has no built-in zip. Stage the folder, then use whatever archiver the
|
|
327
|
+
// OS has; if none, the staged folder + instructions are the result.
|
|
328
|
+
const fsr = cfg.folderStructure;
|
|
329
|
+
const outZip = path.join(root, fsr.skillPath);
|
|
330
|
+
const stage = path.join(root, "mosaic-headless-stage", "mosaic-headless");
|
|
331
|
+
const plan = { file: outZip, size: 0, entries: [] };
|
|
332
|
+
if (dry) { plan.entries.push("SKILL.md + sections (staged, then zipped)"); return plan; }
|
|
333
|
+
await step("打包 zip(Claude.ai 手動上傳用)", () => {
|
|
334
|
+
fs.rmSync(path.dirname(stage), { recursive: true, force: true });
|
|
335
|
+
fs.mkdirSync(stage, { recursive: true });
|
|
336
|
+
fs.writeFileSync(path.join(stage, "SKILL.md"), buildFrontmatter(cfg.frontmatter) + readSkillBody());
|
|
337
|
+
for (const s of ["references", "data", "sites"]) {
|
|
338
|
+
if (cfg.sections?.[s]) copySection(s, stage, true);
|
|
339
|
+
}
|
|
340
|
+
if (fs.existsSync(outZip) && !force) die(`Refusing to overwrite ${outZip} - pass --force.`);
|
|
341
|
+
fs.mkdirSync(path.dirname(outZip), { recursive: true });
|
|
342
|
+
try {
|
|
343
|
+
if (process.platform === "win32") {
|
|
344
|
+
execFileSync("powershell", ["-NoProfile", "-Command",
|
|
345
|
+
`Compress-Archive -Path '${stage}' -DestinationPath '${outZip}' -Force`]);
|
|
346
|
+
} else {
|
|
347
|
+
execFileSync("zip", ["-qr", outZip, "mosaic-headless"], { cwd: path.dirname(stage) });
|
|
348
|
+
}
|
|
349
|
+
fs.rmSync(path.dirname(stage), { recursive: true, force: true });
|
|
350
|
+
plan.size = fs.statSync(outZip).size;
|
|
351
|
+
} catch {
|
|
352
|
+
plan.file = path.dirname(stage);
|
|
353
|
+
plan.note = `no zip tool found - folder staged at ${path.dirname(stage)}; zip it manually`;
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
return plan;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const STRATEGIES = {
|
|
360
|
+
full: installFull, rule: installRule,
|
|
361
|
+
"instructions-append": installAppend, "zip-upload": installZip,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// ---------- CLI ------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
function cmdList() {
|
|
367
|
+
const rows = listPlatforms();
|
|
368
|
+
console.log(`${"platform".padEnd(14)} ${"install type".padEnd(22)} verified as-of display name`);
|
|
369
|
+
console.log("-".repeat(90));
|
|
370
|
+
for (const c of rows) {
|
|
371
|
+
console.log(`${c.platform.padEnd(14)} ${c.installType.padEnd(22)} ${(c.verified ? "yes" : "no ").padEnd(8)} ${(c.verifiedAsOf || "?").padEnd(12)} ${c.displayName}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function cmdInfo(name) {
|
|
376
|
+
const c = loadPlatform(name);
|
|
377
|
+
const f = c.folderStructure;
|
|
378
|
+
console.log(`Platform: ${c.platform} (${c.displayName})`);
|
|
379
|
+
console.log(`Install type: ${c.installType}`);
|
|
380
|
+
console.log(`Project path: ${[f.projectRoot, f.skillPath, f.filename].filter(Boolean).join("/")}`);
|
|
381
|
+
if (f.globalRoot) console.log(`Global path: ${[f.globalRoot, f.skillPath, f.filename].filter(Boolean).join("/")}`);
|
|
382
|
+
console.log(`Verified: ${!!c.verified} (as of ${c.verifiedAsOf || "unknown"})`);
|
|
383
|
+
console.log(`\n${c.loaderBehaviour}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function main() {
|
|
387
|
+
const argv = process.argv.slice(2);
|
|
388
|
+
const flags = new Set(argv.filter((a) => a.startsWith("--") || a === "-f"));
|
|
389
|
+
const pos = argv.filter((a) => !a.startsWith("-") && argv[argv.indexOf(a) - 1] !== "--to" && argv[argv.indexOf(a) - 1] !== "--info");
|
|
390
|
+
const getOpt = (name) => { const i = argv.indexOf(name); return i !== -1 ? argv[i + 1] : null; };
|
|
391
|
+
|
|
392
|
+
if (flags.has("--list")) return cmdList();
|
|
393
|
+
if (getOpt("--info")) return cmdInfo(getOpt("--info"));
|
|
394
|
+
|
|
395
|
+
let name = pos[0];
|
|
396
|
+
let useGlobal = flags.has("--global");
|
|
397
|
+
let force = flags.has("--force") || flags.has("-f");
|
|
398
|
+
const dry = flags.has("--dry-run");
|
|
399
|
+
|
|
400
|
+
// Interactive mode: no platform named, and a real terminal to ask in.
|
|
401
|
+
if (!name && process.stdin.isTTY && process.stdout.isTTY) {
|
|
402
|
+
banner("Skill Installer");
|
|
403
|
+
const detected = detectPlatforms();
|
|
404
|
+
if (detected.length) info(`偵測到:${detected.map((d) => cyan(d)).join("、")}`);
|
|
405
|
+
const plats = listPlatforms();
|
|
406
|
+
name = await select("你想裝在哪個 AI 助手?", plats.map((c) => ({
|
|
407
|
+
title: `${c.displayName}${detected.includes(c.platform) ? green(" ●") : ""}`,
|
|
408
|
+
value: c.platform,
|
|
409
|
+
})));
|
|
410
|
+
const cfgPeek = loadPlatform(name);
|
|
411
|
+
if (cfgPeek.folderStructure.globalRoot && cfgPeek.installType !== "zip-upload") {
|
|
412
|
+
useGlobal = await toggle("要全域安裝嗎?(所有專案都能用)", detected.includes(name));
|
|
413
|
+
}
|
|
414
|
+
force = await toggle("檔案如果已經存在要覆蓋嗎?", true);
|
|
415
|
+
} else if (!name) {
|
|
416
|
+
cmdList();
|
|
417
|
+
console.log("\nUsage: npx mosaic-headless <platform> [--global] [--to DIR] [--force] [--dry-run]");
|
|
418
|
+
return;
|
|
419
|
+
} else {
|
|
420
|
+
banner("Skill Installer");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const cfg = loadPlatform(name);
|
|
424
|
+
const fsr = cfg.folderStructure;
|
|
425
|
+
let root = getOpt("--to") ? path.resolve(expand(getOpt("--to"))) : null;
|
|
426
|
+
if (!root) {
|
|
427
|
+
if (useGlobal && fsr.globalRoot) root = expand(fsr.globalRoot);
|
|
428
|
+
else if (cfg.installType === "instructions-append") root = process.cwd();
|
|
429
|
+
else root = path.join(process.cwd(), fsr.projectRoot || "");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
info(`Installing mosaic-headless → ${cyan(cfg.displayName)}${useGlobal ? dim("(全域)") : ""}${dry ? yellow("(DRY RUN)") : ""}`);
|
|
433
|
+
info(`Target: ${dim(root)}`);
|
|
434
|
+
console.log();
|
|
435
|
+
const plan = await STRATEGIES[cfg.installType](cfg, root, force, dry);
|
|
436
|
+
|
|
437
|
+
const lines = [
|
|
438
|
+
`${bold("平台")} ${cfg.displayName}`,
|
|
439
|
+
`${bold("檔案")} ${plan.file}`,
|
|
440
|
+
`${bold("大小")} ${plan.size.toLocaleString()} bytes`,
|
|
441
|
+
];
|
|
442
|
+
if (plan.sections?.length) lines.push(`${bold("內容")} ${plan.sections.join(", ")}`);
|
|
443
|
+
if (plan.embedded?.length) lines.push(`${bold("內嵌")} ${plan.embedded.join(", ")}`);
|
|
444
|
+
if (plan.note) lines.push(yellow(`NOTE ${plan.note}`));
|
|
445
|
+
lines.push("");
|
|
446
|
+
lines.push(dim("下一步:先讀 references/failure-modes.md,再讓 AI 依 data/ 查證後動手。"));
|
|
447
|
+
box(lines, dry ? " DRY RUN " : " 完成 ");
|
|
448
|
+
if (dry) console.log(dim("(dry run; nothing written.)"));
|
|
449
|
+
console.log(dim(cfg.loaderBehaviour));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
main();
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Write package.json's version into every other place that carries one.
|
|
4
|
+
*
|
|
5
|
+
* node bin/sync-version.mjs
|
|
6
|
+
*
|
|
7
|
+
* Runs from the `version` npm lifecycle script, after npm has bumped package.json
|
|
8
|
+
* and before it makes the commit and the tag - so the tagged commit already has all
|
|
9
|
+
* three versions agreeing, and `npm version patch` is the only command anyone has to
|
|
10
|
+
* remember.
|
|
11
|
+
*
|
|
12
|
+
* Targets:
|
|
13
|
+
* SKILL.md frontmatter `version:`
|
|
14
|
+
* assets/templates/platforms/*.json frontmatter.version, where present
|
|
15
|
+
*
|
|
16
|
+
* check-release.mjs asserts afterwards that this worked, so a silent miss here
|
|
17
|
+
* cannot reach npm.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
25
|
+
const v = pkg.version;
|
|
26
|
+
const touched = [];
|
|
27
|
+
|
|
28
|
+
// SKILL.md - only inside the frontmatter block, never in the prose below it
|
|
29
|
+
const skillPath = path.join(ROOT, "SKILL.md");
|
|
30
|
+
let skill = fs.readFileSync(skillPath, "utf8");
|
|
31
|
+
const end = skill.indexOf("\n---", 4);
|
|
32
|
+
if (end === -1) {
|
|
33
|
+
console.error("SKILL.md has no frontmatter block");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const head = skill.slice(0, end);
|
|
37
|
+
const rest = skill.slice(end);
|
|
38
|
+
// "already correct" and "line missing" are different answers, and conflating them
|
|
39
|
+
// made this exit 1 on a release where the version happened to match already.
|
|
40
|
+
const VERSION_LINE = /^version:\s*"?[^"\n]*"?\s*$/m;
|
|
41
|
+
if (!VERSION_LINE.test(head)) {
|
|
42
|
+
console.error("SKILL.md frontmatter has no version: line to update");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
const next = head.replace(VERSION_LINE, `version: "${v}"`);
|
|
46
|
+
if (next !== head) {
|
|
47
|
+
fs.writeFileSync(skillPath, next + rest);
|
|
48
|
+
touched.push("SKILL.md");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// platform templates
|
|
52
|
+
const platDir = path.join(ROOT, "assets", "templates", "platforms");
|
|
53
|
+
for (const f of fs.readdirSync(platDir).filter((x) => x.endsWith(".json"))) {
|
|
54
|
+
const p = path.join(platDir, f);
|
|
55
|
+
const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
56
|
+
if (cfg.frontmatter && "version" in cfg.frontmatter && cfg.frontmatter.version !== v) {
|
|
57
|
+
cfg.frontmatter.version = v;
|
|
58
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
|
|
59
|
+
touched.push(`assets/templates/platforms/${f}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`synced v${v} into ${touched.length} file(s): ${touched.join(", ") || "none"}`);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
property,kind,animates_via
|
|
2
|
+
transform,Transform,(the CSS property)
|
|
3
|
+
translateX,Move,--mosaic-translate-x
|
|
4
|
+
translateY,Move,--mosaic-translate-y
|
|
5
|
+
translateZ,Move,--mosaic-translate-z
|
|
6
|
+
scale,Scale,--mosaic-scale
|
|
7
|
+
scaleX,Scale,--mosaic-scale-x
|
|
8
|
+
scaleY,Scale,--mosaic-scale-y
|
|
9
|
+
scaleZ,Scale,--mosaic-scale-z
|
|
10
|
+
rotateX,Rotate,--mosaic-rotate-x
|
|
11
|
+
rotateY,Rotate,--mosaic-rotate-y
|
|
12
|
+
rotateZ,Rotate,--mosaic-rotate-z
|
|
13
|
+
opacity,Opacity,(the CSS property)
|
|
14
|
+
color,Color,(the CSS property)
|
|
15
|
+
backgroundColor,Color,(the CSS property)
|
|
16
|
+
borderColor,Color,(the CSS property)
|
|
17
|
+
boxShadow,BoxShadow,(the CSS property)
|
|
18
|
+
textShadow,TextShadow,(the CSS property)
|
|
19
|
+
width,Size,(the CSS property)
|
|
20
|
+
height,Size,(the CSS property)
|
|
21
|
+
filter,Filter,(the CSS property)
|
|
22
|
+
backdropFilter,Filter,(the CSS property)
|
|
23
|
+
transformOrigin,TransformOrigin,(the CSS property)
|