Browse Source

init cljs sdk

Tienson Qin 4 days ago
parent
commit
b5f7dba149
6 changed files with 480 additions and 1 deletions
  1. 8 1
      bb.edn
  2. 12 0
      libs/README.md
  3. 2 0
      libs/package.json
  4. 154 0
      libs/scripts/extract-sdk-schema.js
  5. 147 0
      libs/yarn.lock
  6. 157 0
      scripts/src/logseq/libs/sdk_generator.clj

+ 8 - 1
bb.edn

@@ -10,7 +10,9 @@
   logseq/graph-parser
   {:local/root "deps/graph-parser"}
   org.clj-commons/digest
-  {:mvn/version "1.4.100"}}
+  {:mvn/version "1.4.100"}
+  cheshire/cheshire
+  {:mvn/version "5.12.0"}}
  :pods
  {clj-kondo/clj-kondo {:version "2024.09.27"}
   org.babashka/fswatcher {:version "0.0.3"}
@@ -19,6 +21,11 @@
  {dev:desktop-watch
   logseq.tasks.dev.desktop/watch
 
+  libs:generate-cljs-sdk
+  {:doc "Generate CLJS wrappers based on the JS SDK declarations"
+   :requires ([logseq.libs.sdk-generator :as sdk-gen])
+   :task (sdk-gen/run! (sdk-gen/parse-args *command-line-args*))}
+
   dev:open-dev-electron-app
   logseq.tasks.dev.desktop/open-dev-electron-app
 

+ 12 - 0
libs/README.md

@@ -30,3 +30,15 @@ import "@logseq/libs"
 #### Feedback
 If you have any feedback or encounter any issues, feel free to join Logseq's discord group.
 https://discord.gg/KpN4eHY
+
+#### Generate CLJS SDK wrappers
+
+To regenerate the ClojureScript facade from the JS SDK declarations:
+
+```bash
+yarn run generate:schema              # emits dist/logseq-sdk-schema.json
+bb libs:generate-cljs-sdk            # writes per-proxy CLJS under target/generated-cljs
+```
+
+Each interface is emitted to its own namespace (e.g. `logseq.app`, `logseq.editor`).
+Pass `--out-dir` to change the output directory or `--ns-prefix` to use a different namespace root.

+ 2 - 0
libs/package.json

@@ -10,6 +10,7 @@
     "dev:user": "npm run build:user -- --mode development --watch",
     "build:core": "webpack --config webpack.config.core.js --mode production",
     "dev:core": "npm run build:core -- --mode development --watch",
+    "generate:schema": "node scripts/extract-sdk-schema.js",
     "build": "tsc && rm dist/*.js && npm run build:user",
     "lint": "prettier --check \"src/**/*.{ts, js}\"",
     "fix": "prettier --write \"src/**/*.{ts, js}\"",
@@ -27,6 +28,7 @@
     "snake-case": "3.0.4"
   },
   "devDependencies": {
+    "ts-morph": "^22.0.0",
     "@babel/core": "^7.20.2",
     "@babel/preset-env": "^7.20.2",
     "@types/debug": "^4.1.5",

+ 154 - 0
libs/scripts/extract-sdk-schema.js

@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+/**
+ * Extracts metadata about the Logseq JS SDK from the generated *.d.ts files.
+ *
+ * This script uses ts-morph so we can rely on the TypeScript compiler's view of
+ * the declarations. We intentionally read the emitted declaration files in
+ * dist/ so that consumers do not need to depend on the source layout.
+ *
+ * The resulting schema is written to dist/logseq-sdk-schema.json and contains
+ * a simplified representation that downstream tooling (Babashka) can consume.
+ */
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { Project, Node } = require('ts-morph');
+
+const ROOT = path.resolve(__dirname, '..');
+const DIST_DIR = path.join(ROOT, 'dist');
+const OUTPUT_FILE = path.join(DIST_DIR, 'logseq-sdk-schema.json');
+const DECL_FILES = [
+  'LSPlugin.d.ts',
+  'LSPlugin.user.d.ts',
+];
+
+/**
+ * Interfaces whose methods will be turned into CLJS wrappers at runtime.
+ * These correspond to `logseq.<Namespace>` targets in the JS SDK.
+ */
+const TARGET_INTERFACES = [
+  'IAppProxy',
+  'IEditorProxy',
+  'IDBProxy',
+  'IUIProxy',
+  'IUtilsProxy',
+  'IGitProxy',
+  'IAssetsProxy',
+];
+
+/**
+ * Simple heuristics to determine whether a parameter should be converted via
+ * cljs-bean when crossing the JS <-> CLJS boundary.
+ */
+const BEAN_TO_JS_REGEX =
+  /(Record<|Array<|UIOptions|UIContainerAttrs|StyleString|StyleOptions|object|any|unknown|IHookEvent|BlockEntity|PageEntity|Promise<\s*Record)/i;
+
+const project = new Project({
+  compilerOptions: { allowJs: true },
+});
+
+DECL_FILES.forEach((file) => {
+  const full = path.join(DIST_DIR, file);
+  if (fs.existsSync(full)) {
+    project.addSourceFileAtPath(full);
+  }
+});
+
+const schema = {
+  generatedAt: new Date().toISOString(),
+  interfaces: {},
+};
+
+const serializeDoc = (symbol) => {
+  if (!symbol) return undefined;
+  const decl = symbol.getDeclarations()[0];
+  if (!decl) return undefined;
+
+  const docs = decl
+    .getJsDocs()
+    .map((doc) => doc.getComment())
+    .filter(Boolean);
+  return docs.length ? docs.join('\n\n') : undefined;
+};
+
+const serializeParameter = (signature, symbol, memberNode) => {
+  const name = symbol.getName();
+  const declaration = symbol.getDeclarations()[0];
+
+  let typeText;
+  let optional = symbol.isOptional?.() ?? false;
+  let rest = symbol.isRestParameter?.() ?? false;
+
+  if (declaration && Node.isParameterDeclaration(declaration)) {
+    typeText = declaration.getType().getText();
+    optional = declaration.hasQuestionToken?.() ?? false;
+    rest = declaration.isRestParameter?.() ?? false;
+  } else {
+    const location =
+      signature.getDeclaration?.() ??
+      memberNode ??
+      declaration ??
+      symbol.getDeclarations()[0];
+    typeText = symbol.getTypeAtLocation(location).getText();
+  }
+
+  const convertToJs = BEAN_TO_JS_REGEX.test(typeText);
+
+  return {
+    name,
+    type: typeText,
+    optional,
+    rest,
+    beanToJs: convertToJs,
+  };
+};
+
+const serializeSignature = (sig, memberNode) => {
+  const params = sig.getParameters().map((paramSymbol) =>
+    serializeParameter(sig, paramSymbol, memberNode)
+  );
+  const returnType = sig.getReturnType().getText();
+  return {
+    parameters: params,
+    returnType,
+  };
+};
+
+const sourceFiles = project.getSourceFiles();
+sourceFiles.forEach((source) => {
+  source.getInterfaces().forEach((iface) => {
+    const name = iface.getName();
+    if (!TARGET_INTERFACES.includes(name)) {
+      return;
+    }
+
+    const interfaceSymbol = iface.getType().getSymbol();
+    const doc = serializeDoc(interfaceSymbol);
+    const methods = iface.getMembers().map((member) => {
+      const symbol = member.getSymbol();
+      if (!symbol) return null;
+
+      const type = symbol.getTypeAtLocation(member);
+      const callSignatures = type.getCallSignatures();
+      if (!callSignatures.length) {
+        return null;
+      }
+
+      return {
+        name: symbol.getName(),
+        documentation: serializeDoc(symbol),
+        signatures: callSignatures.map((sig) => serializeSignature(sig, member)),
+      };
+    }).filter(Boolean);
+
+    schema.interfaces[name] = {
+      documentation: doc,
+      methods,
+    };
+  });
+});
+
+fs.mkdirSync(DIST_DIR, { recursive: true });
+fs.writeFileSync(OUTPUT_FILE, JSON.stringify(schema, null, 2));
+
+console.log(`Wrote ${OUTPUT_FILE}`);

+ 147 - 0
libs/yarn.lock

@@ -1066,11 +1066,42 @@
     "@jridgewell/resolve-uri" "^3.0.3"
     "@jridgewell/sourcemap-codec" "^1.4.10"
 
+"@nodelib/[email protected]":
+  version "2.1.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
+  integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
+  dependencies:
+    "@nodelib/fs.stat" "2.0.5"
+    run-parallel "^1.1.9"
+
+"@nodelib/[email protected]", "@nodelib/fs.stat@^2.0.2":
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
+  integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
+
+"@nodelib/fs.walk@^1.2.3":
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
+  integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
+  dependencies:
+    "@nodelib/fs.scandir" "2.1.5"
+    fastq "^1.6.0"
+
 "@polka/url@^1.0.0-next.17":
   version "1.0.0-next.17"
   resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.17.tgz#25fdbdfd282c2f86ddf3fcefbd98be99cd2627e2"
   integrity sha512-0p1rCgM3LLbAdwBnc7gqgnvjHg9KpbhcSphergHShlkWz8EdPawoMJ3/VbezI0mGC5eKCDzMaPgF9Yca6cKvrg==
 
+"@ts-morph/common@~0.23.0":
+  version "0.23.0"
+  resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.23.0.tgz#bd4ddbd3f484f29476c8bd985491592ae5fc147e"
+  integrity sha512-m7Lllj9n/S6sOkCkRftpM7L24uvmfXQFedlW/4hENcuJH1HHm9u5EgxZb9uVjQSCGrbBWBkOGgcTxNg36r6ywA==
+  dependencies:
+    fast-glob "^3.3.2"
+    minimatch "^9.0.3"
+    mkdirp "^3.0.1"
+    path-browserify "^1.0.1"
+
 "@types/debug@^4.1.5":
   version "4.1.7"
   resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82"
@@ -1434,6 +1465,13 @@ braces@^3.0.1:
   dependencies:
     fill-range "^7.0.1"
 
+braces@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
+  integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
+  dependencies:
+    fill-range "^7.1.1"
+
 browserslist@^4.14.5:
   version "4.16.8"
   resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.8.tgz#cb868b0b554f137ba6e33de0ecff2eda403c4fb0"
@@ -1501,6 +1539,11 @@ clone-deep@^4.0.1:
     kind-of "^6.0.2"
     shallow-clone "^3.0.0"
 
+code-block-writer@^13.0.1:
+  version "13.0.3"
+  resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-13.0.3.tgz#90f8a84763a5012da7af61319dd638655ae90b5b"
+  integrity sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==
+
 color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@@ -1709,6 +1752,17 @@ [email protected], fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
   integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
+fast-glob@^3.3.2:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
+  integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
+  dependencies:
+    "@nodelib/fs.stat" "^2.0.2"
+    "@nodelib/fs.walk" "^1.2.3"
+    glob-parent "^5.1.2"
+    merge2 "^1.3.0"
+    micromatch "^4.0.8"
+
 fast-json-stable-stringify@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
@@ -1719,6 +1773,13 @@ fastest-levenshtein@^1.0.12:
   resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz#9990f7d3a88cc5a9ffd1f1745745251700d497e2"
   integrity sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow==
 
+fastq@^1.6.0:
+  version "1.19.1"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5"
+  integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==
+  dependencies:
+    reusify "^1.0.4"
+
 fill-range@^7.0.1:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
@@ -1726,6 +1787,13 @@ fill-range@^7.0.1:
   dependencies:
     to-regex-range "^5.0.1"
 
+fill-range@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
+  integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
+  dependencies:
+    to-regex-range "^5.0.1"
+
 find-cache-dir@^3.3.2:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b"
@@ -1763,6 +1831,13 @@ get-stream@^6.0.0:
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
   integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
 
+glob-parent@^5.1.2:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
+  integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
+  dependencies:
+    is-glob "^4.0.1"
+
 glob-to-regexp@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
@@ -1868,6 +1943,18 @@ is-core-module@^2.9.0:
   dependencies:
     has "^1.0.3"
 
+is-extglob@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+is-glob@^4.0.1:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+  integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+  dependencies:
+    is-extglob "^2.1.1"
+
 is-number@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
@@ -2028,6 +2115,11 @@ merge-stream@^2.0.0:
   resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
   integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
 
+merge2@^1.3.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
+  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+
 micromatch@^4.0.0:
   version "4.0.4"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9"
@@ -2036,6 +2128,14 @@ micromatch@^4.0.0:
     braces "^3.0.1"
     picomatch "^2.2.3"
 
+micromatch@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
+  integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
+  dependencies:
+    braces "^3.0.3"
+    picomatch "^2.3.1"
+
 [email protected]:
   version "1.49.0"
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed"
@@ -2065,6 +2165,18 @@ minimatch@^5.0.1, minimatch@^5.1.0:
   dependencies:
     brace-expansion "^2.0.1"
 
+minimatch@^9.0.3:
+  version "9.0.5"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
+  integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
+  dependencies:
+    brace-expansion "^2.0.1"
+
+mkdirp@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
+  integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
+
 [email protected]:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
@@ -2150,6 +2262,11 @@ p-try@^2.0.0:
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
   integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
 
+path-browserify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
+  integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
+
 path-exists@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
@@ -2183,6 +2300,11 @@ picomatch@^2.2.3:
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
   integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
 
+picomatch@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+  integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
 pkg-dir@^4.1.0, pkg-dir@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
@@ -2210,6 +2332,11 @@ punycode@^2.1.0:
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
+queue-microtask@^1.2.2:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
+  integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+
 randombytes@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
@@ -2314,6 +2441,18 @@ resolve@^1.9.0:
     is-core-module "^2.2.0"
     path-parse "^1.0.6"
 
+reusify@^1.0.4:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
+  integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
+
+run-parallel@^1.1.9:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
+  integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
+  dependencies:
+    queue-microtask "^1.2.2"
+
 safe-buffer@^5.1.0:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
@@ -2535,6 +2674,14 @@ [email protected]:
     micromatch "^4.0.0"
     semver "^7.3.4"
 
+ts-morph@^22.0.0:
+  version "22.0.0"
+  resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-22.0.0.tgz#5532c592fb6dddae08846f12c9ab0fc590b1d42e"
+  integrity sha512-M9MqFGZREyeb5fTl6gNHKZLqBQA0TjA1lea+CR48R8EBTDuWrNqW6ccC5QvjNR4s6wDumD3LTCjOFSp9iwlzaw==
+  dependencies:
+    "@ts-morph/common" "~0.23.0"
+    code-block-writer "^13.0.1"
+
 tslib@^2.0.3:
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"

+ 157 - 0
scripts/src/logseq/libs/sdk_generator.clj

@@ -0,0 +1,157 @@
+(ns logseq.libs.sdk-generator
+  (:require [babashka.fs :as fs]
+            [cheshire.core :as json]
+            [clojure.string :as string]))
+
+(def default-schema "libs/dist/logseq-sdk-schema.json")
+(def default-output-dir "target/generated-cljs")
+(def default-ns-prefix "logseq")
+
+(defn parse-args
+  [args]
+  (loop [opts {}
+         tokens args]
+    (if (empty? tokens)
+      opts
+      (let [[flag value & more] tokens]
+        (case flag
+          "--schema" (recur (assoc opts :schema value) more)
+          "--out" (recur (assoc opts :out-dir value) more)
+          "--out-dir" (recur (assoc opts :out-dir value) more)
+          "--ns-prefix" (recur (assoc opts :ns-prefix value) more)
+          (throw (ex-info (str "Unknown flag: " flag) {:flag flag})))))))
+
+(defn camel->kebab [s]
+  (-> s
+      (string/replace #"([a-z0-9])([A-Z])" "$1-$2")
+      (string/replace #"([A-Z]+)([A-Z][a-z])" "$1-$2")
+      (string/lower-case)
+      (string/replace #"[^a-z0-9]+" "-")
+      (string/replace #"(^-|-$)" "")))
+
+(defn interface->target [iface-name]
+  (-> iface-name
+      (string/replace #"^I" "")
+      (string/replace #"Proxy$" "")))
+
+(defn interface->suffix [iface-name]
+  (-> iface-name
+      interface->target
+      camel->kebab))
+
+(defn interface->namespace [iface-name ns-prefix]
+  (prn :debug :iface-name iface-name
+       :ns-prefix ns-prefix
+       :suffix (interface->suffix iface-name))
+  (str ns-prefix "." (interface->suffix iface-name)))
+
+(defn format-docstring [doc]
+  (when (and doc (not (string/blank? doc)))
+    (str "  " (pr-str doc) "\n")))
+
+(defn bean-specs [params]
+  (when (seq params)
+    (vec
+     (map (fn [{:keys [beanToJs rest]}]
+            (let [spec (cond-> {}
+                         beanToJs (assoc :bean-to-js true)
+                         rest (assoc :rest true))]
+              spec))
+          params))))
+
+(defn rest-index [params]
+  (some (fn [[idx {:keys [rest]}]]
+          (when rest idx))
+        (map-indexed vector params)))
+
+(defn select-primary-signature [signatures]
+  (when (seq signatures)
+    (apply max-key #(count (:parameters %)) signatures)))
+
+(defn emit-method
+  [{:keys [name documentation signatures]}
+   iface-name]
+  (let [{:keys [parameters]} (select-primary-signature signatures)
+        specs (bean-specs parameters)
+        rest-idx (rest-index parameters)
+        fn-name (camel->kebab name)
+        owner-prop (interface->target iface-name)
+        js-prop (str ".-" name)]
+    (str "\n"
+         "(defn " fn-name "\n"
+         (or (format-docstring documentation) "")
+         "  [& args]\n"
+         "  (let [owner  (.-" owner-prop " js/logseq)\n"
+         "        method (" js-prop " owner)\n"
+         "        specs  " (pr-str specs) "\n"
+         "        rest-idx " (if (number? rest-idx) rest-idx "nil") "]\n"
+         "    (call-proxy owner method specs rest-idx args)))\n")))
+
+(defn emit-namespace
+  [iface-name iface ns-prefix]
+  (let [ns (interface->namespace iface-name ns-prefix)
+        header (str ";; Auto-generated via `bb libs:generate-cljs-sdk`\n"
+                    "(ns " ns "\n"
+                    "  (:require [cljs-bean.core :as bean]))\n\n"
+                    "(defn- convert-args [specs rest-idx args]\n"
+                    "  (if (seq specs)\n"
+                    "    (map-indexed\n"
+                    "      (fn [idx arg]\n"
+                    "        (let [spec (if (and rest-idx (>= idx rest-idx))\n"
+                    "                       (nth specs rest-idx nil)\n"
+                    "                       (nth specs idx nil))]\n"
+                    "          (if (and spec (:bean-to-js spec))\n"
+                    "            (bean/->js arg)\n"
+                    "            arg)))\n"
+                    "      args)\n"
+                    "    args))\n\n"
+                    "(defn- call-proxy [owner method specs rest-idx args]\n"
+                    "  (when-not method\n"
+                    "    (throw (js/Error. \"Missing method on logseq namespace\")))\n"
+                    "  (let [converted (convert-args specs rest-idx args)]\n"
+                    "    (.apply method owner (to-array (vec converted)))))\n")
+        methods-str (->> (:methods iface)
+                         (map #(emit-method % iface-name))
+                         (apply str))]
+    [ns (str header methods-str)]))
+
+(defn namespace->file
+  [out-dir ns]
+  (let [parts (string/split ns #"\.")
+        dir-parts (butlast parts)
+        file-name (str (last parts) ".cljs")]
+    (apply fs/path out-dir (concat dir-parts [file-name]))))
+
+(defn ensure-schema!
+  [schema-path]
+  (when-not (fs/exists? schema-path)
+    (throw (ex-info (str "Schema not found, run `yarn --cwd libs generate:schema` first: " schema-path)
+                    {:schema schema-path}))))
+
+(defn write-namespaces!
+  [out-dir namespaces]
+  (doseq [[ns content] namespaces]
+    (let [file (namespace->file out-dir ns)]
+      (fs/create-dirs (fs/parent file))
+      (spit (str file) content)
+      (println "Generated" (str file)))))
+
+(defn generate!
+  ([] (generate! {}))
+  ([opts]
+   (let [schema-path (fs/absolutize (or (:schema opts) default-schema))
+         out-dir     (fs/absolutize (or (:out-dir opts) default-output-dir))
+         ns-prefix   (or (:ns-prefix opts) default-ns-prefix)]
+     (ensure-schema! schema-path)
+     (let [schema (json/parse-string (slurp (str schema-path)) true)
+           namespaces (map (fn [[iface-name iface]]
+                             (emit-namespace (name iface-name) iface ns-prefix))
+                           (:interfaces schema))]
+       (fs/create-dirs out-dir)
+       (write-namespaces! out-dir namespaces)
+       out-dir))))
+
+(defn -main
+  [& args]
+  (let [opts (parse-args args)]
+    (generate! opts)))