@manta-eu/codegen 0.1.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.md +63 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +68 -0
- package/dist/device-login.d.ts +45 -0
- package/dist/device-login.d.ts.map +1 -0
- package/dist/device-login.js +127 -0
- package/dist/fetch-shapes.d.ts +12 -0
- package/dist/fetch-shapes.d.ts.map +1 -0
- package/dist/fetch-shapes.js +17 -0
- package/dist/generate.d.ts +14 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +77 -0
- package/dist/http.d.ts +7 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +36 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/shapes.d.ts +19 -0
- package/dist/shapes.d.ts.map +1 -0
- package/dist/shapes.js +78 -0
- package/dist/sort-schemas.d.ts +11 -0
- package/dist/sort-schemas.d.ts.map +1 -0
- package/dist/sort-schemas.js +33 -0
- package/dist/token.d.ts +17 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +63 -0
- package/dist/turtle.d.ts +13 -0
- package/dist/turtle.d.ts.map +1 -0
- package/dist/turtle.js +54 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Manta AI AS
|
|
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
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @manta-eu/codegen
|
|
2
|
+
|
|
3
|
+
A CLI that generates LDkit schemas for one realm, from the SHACL shapes that realm's datastore
|
|
4
|
+
holds. `@manta-eu/ldo` builds the same types from local files in this repository. An app outside this
|
|
5
|
+
repository has neither the files nor Nix, so this package reads the shapes over HTTP instead.
|
|
6
|
+
|
|
7
|
+
## Use
|
|
8
|
+
|
|
9
|
+
```shell
|
|
10
|
+
pnpm manta-codegen generate \
|
|
11
|
+
--api-url https://app.example \
|
|
12
|
+
--datastore salthouse \
|
|
13
|
+
--keycloak-url https://keycloak.example \
|
|
14
|
+
--keycloak-realm your-realm \
|
|
15
|
+
--client-id your-realm-app \
|
|
16
|
+
--out ./.ldkit
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The run writes one `.ts` file per namespace (`manta.ts`, `meta.ts`, `google.ts` and so on),
|
|
20
|
+
`namespaces.ts` for the vocabulary constants, and an `index.ts` that re-exports every file. It writes
|
|
21
|
+
nothing else: the directory holds generated code, and the CLI keeps no copy of either graph it read.
|
|
22
|
+
|
|
23
|
+
A run removes the `.ts` files of the run before it, so a class the shapes no longer describe loses
|
|
24
|
+
its file. It leaves every other file in the directory alone.
|
|
25
|
+
|
|
26
|
+
Only `namespaces.ts` holds the realm IRIs.
|
|
27
|
+
|
|
28
|
+
## Log in
|
|
29
|
+
|
|
30
|
+
`MANTA_CLIENT_SECRET` decides the path.
|
|
31
|
+
|
|
32
|
+
**No secret — a person.** The CLI starts the OAuth device flow with PKCE. It prints a link and a
|
|
33
|
+
code, then waits for the browser. Keycloak needs a public client that carries
|
|
34
|
+
`oauth2.device.authorization.grant.enabled`.
|
|
35
|
+
|
|
36
|
+
**A secret — a machine.** The CLI asks for a service account token. Keycloak needs a confidential
|
|
37
|
+
client with service account roles. Add `--scope` to ask for a narrow token.
|
|
38
|
+
|
|
39
|
+
```shell
|
|
40
|
+
export MANTA_CLIENT_SECRET=... # never a flag: an argument list is public on the machine
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Two traps:
|
|
44
|
+
|
|
45
|
+
- `--client-id` must name the realm's **app** client. The API compares the token's `azp` against its
|
|
46
|
+
own `VITE_KEYCLOAK_CLIENT_ID`, and answers 401 for any other client. That check is off today
|
|
47
|
+
(`authorizedPartyCheckEnabled`), which is the only reason the `manta-codegen` service client works
|
|
48
|
+
from CI. Turning it on 401s this caller.
|
|
49
|
+
- The API grants a datastore to a person, or to a service account holding the `operator` realm role.
|
|
50
|
+
Without that role a service account token is refused the datastore, so a local run needs either the
|
|
51
|
+
device login or a client shaped like `manta-codegen`, which has no device flow and needs
|
|
52
|
+
`MANTA_CLIENT_SECRET`.
|
|
53
|
+
|
|
54
|
+
## What the datastore does not hold
|
|
55
|
+
|
|
56
|
+
`observed-entities.ttl` reaches no graph. It describes 35 classes, among them `meta:Body`,
|
|
57
|
+
`meta:Title` and `google:Headline`. A consumer generates without them.
|
|
58
|
+
|
|
59
|
+
This is a decision, not an oversight. `kgc/kgc/realms/lib/term_structure.py` writes that file from
|
|
60
|
+
the instance data of a live datastore. A new realm therefore has none, and the shapes move with the
|
|
61
|
+
data. See WEB-1536.
|
|
62
|
+
|
|
63
|
+
The CLI prints the class count for each namespace, so a thin result is visible at once.
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, Option } from "commander";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fetchRealmShapes, sparqlEndpoint } from "./fetch-shapes.js";
|
|
5
|
+
import { pollForDeviceToken, startDeviceLogin } from "./device-login.js";
|
|
6
|
+
import { describeError } from "./http.js";
|
|
7
|
+
import { generatePackage } from "./generate.js";
|
|
8
|
+
import { cachedToken, mintWithClientCredentials } from "./token.js";
|
|
9
|
+
// The secret stays out of the flags: an argument list is readable to every process on the machine.
|
|
10
|
+
function tokenSourceFor(options) {
|
|
11
|
+
const client = { baseUrl: options.keycloakUrl, realm: options.keycloakRealm, clientId: options.clientId };
|
|
12
|
+
const secret = process.env.MANTA_CLIENT_SECRET;
|
|
13
|
+
if (secret) {
|
|
14
|
+
return cachedToken(() => mintWithClientCredentials({ ...client, clientSecret: secret, scope: options.scope }));
|
|
15
|
+
}
|
|
16
|
+
return cachedToken(async () => {
|
|
17
|
+
const login = await startDeviceLogin(client);
|
|
18
|
+
console.error(`manta-codegen: open ${login.verificationUri} and enter the code ${login.userCode}`);
|
|
19
|
+
return pollForDeviceToken(client, login);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function reportTargetClasses(targetClasses) {
|
|
23
|
+
const total = Object.values(targetClasses).reduce((sum, count) => sum + count, 0);
|
|
24
|
+
console.error(`manta-codegen: the datastore describes ${total} class(es):`);
|
|
25
|
+
for (const [prefix, count] of Object.entries(targetClasses))
|
|
26
|
+
console.error(` ${prefix}: ${count}`);
|
|
27
|
+
if (total === 0)
|
|
28
|
+
console.error("manta-codegen: the datastore describes no class. Check the datastore id.");
|
|
29
|
+
}
|
|
30
|
+
async function readShapes(options) {
|
|
31
|
+
const token = tokenSourceFor(options);
|
|
32
|
+
const endpoint = sparqlEndpoint(options.apiUrl, options.datastore);
|
|
33
|
+
console.error(`manta-codegen: reading the shapes from ${endpoint}`);
|
|
34
|
+
const shapes = await fetchRealmShapes(endpoint, token);
|
|
35
|
+
console.error(`manta-codegen: the realm vocabulary base is ${shapes.vocabBase}`);
|
|
36
|
+
reportTargetClasses(shapes.targetClasses);
|
|
37
|
+
return shapes;
|
|
38
|
+
}
|
|
39
|
+
// The CLI keeps no copy of either graph: the directory holds generated code and nothing else.
|
|
40
|
+
async function generate(options) {
|
|
41
|
+
const shapes = await readShapes(options);
|
|
42
|
+
const outDir = resolve(options.out);
|
|
43
|
+
await generatePackage(shapes, outDir);
|
|
44
|
+
console.error(`manta-codegen: wrote the LDkit schemas to ${outDir}`);
|
|
45
|
+
}
|
|
46
|
+
const program = new Command()
|
|
47
|
+
.name("manta-codegen")
|
|
48
|
+
.description("Generate LDkit schemas from a realm's SHACL shapes, read from its datastore.");
|
|
49
|
+
program
|
|
50
|
+
.command("generate")
|
|
51
|
+
.description("read the realm's shapes and generate the LDkit schemas, one file per namespace")
|
|
52
|
+
.requiredOption("--api-url <url>", "Manta API base URL")
|
|
53
|
+
.requiredOption("--datastore <id>", "datastore to read")
|
|
54
|
+
.requiredOption("--keycloak-url <url>", "Keycloak base URL")
|
|
55
|
+
.requiredOption("--keycloak-realm <realm>", "Keycloak realm")
|
|
56
|
+
.requiredOption("--client-id <id>", "Keycloak client id")
|
|
57
|
+
.option("--scope <scope>", "OAuth scope to ask for")
|
|
58
|
+
.addOption(new Option("--out <dir>", "directory to write into").default("./.ldkit"))
|
|
59
|
+
.action(async (options) => {
|
|
60
|
+
try {
|
|
61
|
+
await generate(options);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.error(describeError(error));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
program.parse();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type FetchLike } from "./http.js";
|
|
2
|
+
import { type TokenSource } from "./token.js";
|
|
3
|
+
export type PublicClient = {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
realm: string;
|
|
6
|
+
clientId: string;
|
|
7
|
+
};
|
|
8
|
+
export type DeviceLogin = {
|
|
9
|
+
deviceCode: string;
|
|
10
|
+
userCode: string;
|
|
11
|
+
verificationUri: string;
|
|
12
|
+
intervalMs: number;
|
|
13
|
+
expiresInMs: number;
|
|
14
|
+
codeVerifier: string;
|
|
15
|
+
};
|
|
16
|
+
export type PkcePair = {
|
|
17
|
+
verifier: string;
|
|
18
|
+
challenge: string;
|
|
19
|
+
};
|
|
20
|
+
export type Sleep = (ms: number) => Promise<void>;
|
|
21
|
+
export declare function deviceAuthEndpoint(baseUrl: string, realm: string): string;
|
|
22
|
+
export declare function createPkcePair(): PkcePair;
|
|
23
|
+
export declare function startDeviceLogin(client: PublicClient, fetchImpl?: FetchLike, pkce?: PkcePair): Promise<DeviceLogin>;
|
|
24
|
+
export type DeviceTokens = {
|
|
25
|
+
accessToken: string;
|
|
26
|
+
refreshToken?: string;
|
|
27
|
+
};
|
|
28
|
+
export declare function pollForDeviceToken(...args: Parameters<typeof pollForDeviceTokens>): Promise<string>;
|
|
29
|
+
export declare function pollForDeviceTokens(client: PublicClient, { deviceCode, codeVerifier, intervalMs, expiresInMs, }: Pick<DeviceLogin, "deviceCode" | "codeVerifier"> & {
|
|
30
|
+
intervalMs?: number;
|
|
31
|
+
expiresInMs?: number;
|
|
32
|
+
}, { fetchImpl, sleep, clock, }?: {
|
|
33
|
+
fetchImpl?: FetchLike;
|
|
34
|
+
sleep?: Sleep;
|
|
35
|
+
clock?: () => number;
|
|
36
|
+
}): Promise<DeviceTokens>;
|
|
37
|
+
export declare function refreshAccessToken(client: PublicClient, refreshToken: string, fetchImpl?: FetchLike): Promise<DeviceTokens | undefined>;
|
|
38
|
+
export declare function deviceLoginTokenSource(client: PublicClient, prompt: (login: DeviceLogin) => void, { fetchImpl, login, poll, }?: {
|
|
39
|
+
fetchImpl?: FetchLike;
|
|
40
|
+
login?: (client: PublicClient, fetchImpl: FetchLike) => Promise<DeviceLogin>;
|
|
41
|
+
poll?: (client: PublicClient, login: DeviceLogin, options: {
|
|
42
|
+
fetchImpl: FetchLike;
|
|
43
|
+
}) => Promise<DeviceTokens>;
|
|
44
|
+
}): TokenSource;
|
|
45
|
+
//# sourceMappingURL=device-login.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-login.d.ts","sourceRoot":"","sources":["../src/device-login.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAAoD,MAAM,WAAW,CAAC;AAC7F,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AAE7D,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/D,MAAM,MAAM,KAAK,GAAG,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAclD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEzE;AAGD,wBAAgB,cAAc,IAAI,QAAQ,CAIzC;AA+BD,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,YAAY,EACpB,SAAS,GAAE,SAAiB,EAC5B,IAAI,GAAE,QAA2B,GAChC,OAAO,CAAC,WAAW,CAAC,CAiBtB;AAyBD,MAAM,MAAM,YAAY,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1E,wBAAsB,kBAAkB,CAAC,GAAG,IAAI,EAAE,UAAU,CAAC,OAAO,mBAAmB,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAEzG;AAED,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,YAAY,EACpB,EACE,UAAU,EACV,YAAY,EACZ,UAA8B,EAC9B,WAAgC,GACjC,EAAE,IAAI,CAAC,WAAW,EAAE,YAAY,GAAG,cAAc,CAAC,GAAG;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,EACnG,EACE,SAAiB,EACjB,KAAgB,EAChB,KAAgB,GACjB,GAAE;IAAE,SAAS,CAAC,EAAE,SAAS,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAA;CAAO,GACrE,OAAO,CAAC,YAAY,CAAC,CAwBvB;AAGD,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,YAAY,EACpB,YAAY,EAAE,MAAM,EACpB,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAiBnC;AAGD,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,EACpC,EACE,SAAiB,EACjB,KAAwB,EACxB,IAA0B,GAC3B,GAAE;IACD,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,YAAY,EAAE,SAAS,EAAE,SAAS,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC7E,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE;QAAE,SAAS,EAAE,SAAS,CAAA;KAAE,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;CAC1G,GACL,WAAW,CAkBb"}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { formRequest, isTransientStatus, postTo, readJson } from "./http.js";
|
|
3
|
+
import { tokenEndpoint } from "./token.js";
|
|
4
|
+
const deviceCodeGrant = "urn:ietf:params:oauth:grant-type:device_code";
|
|
5
|
+
// RFC 8628 says to add this much whenever the server answers slow_down.
|
|
6
|
+
const slowDownStepMs = 5_000;
|
|
7
|
+
const defaultIntervalMs = 5_000;
|
|
8
|
+
// RFC 8628 makes expires_in optional, and Keycloak sends ten minutes.
|
|
9
|
+
const defaultExpiresInMs = 600_000;
|
|
10
|
+
const sleepFor = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
11
|
+
export function deviceAuthEndpoint(baseUrl, realm) {
|
|
12
|
+
return `${baseUrl}/realms/${realm}/protocol/openid-connect/auth/device`;
|
|
13
|
+
}
|
|
14
|
+
// A public client holds no secret, so PKCE is what ties the token request back to this process.
|
|
15
|
+
export function createPkcePair() {
|
|
16
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
17
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
18
|
+
}
|
|
19
|
+
function readDeviceLogin(body, endpoint, codeVerifier) {
|
|
20
|
+
const deviceCode = body.device_code;
|
|
21
|
+
const userCode = body.user_code;
|
|
22
|
+
const verificationUri = body.verification_uri_complete ?? body.verification_uri;
|
|
23
|
+
if (!deviceCode || !userCode || !verificationUri) {
|
|
24
|
+
throw new Error(`manta-codegen: the device login response from ${endpoint} is incomplete.`);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
deviceCode,
|
|
28
|
+
userCode,
|
|
29
|
+
verificationUri,
|
|
30
|
+
intervalMs: body.interval ? body.interval * 1000 : defaultIntervalMs,
|
|
31
|
+
expiresInMs: body.expires_in ? body.expires_in * 1000 : defaultExpiresInMs,
|
|
32
|
+
codeVerifier,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// No secret lives on the machine: a developer needs nothing beyond a browser login.
|
|
36
|
+
export async function startDeviceLogin(client, fetchImpl = fetch, pkce = createPkcePair()) {
|
|
37
|
+
const endpoint = deviceAuthEndpoint(client.baseUrl, client.realm);
|
|
38
|
+
const request = formRequest({
|
|
39
|
+
client_id: client.clientId,
|
|
40
|
+
code_challenge: pkce.challenge,
|
|
41
|
+
code_challenge_method: "S256",
|
|
42
|
+
});
|
|
43
|
+
const response = await postTo(endpoint, request, fetchImpl);
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error(`manta-codegen: ${endpoint} refused the device login with status ${response.status}. ` +
|
|
46
|
+
`Check that the client ${client.clientId} is public and allows the OAuth device flow.`);
|
|
47
|
+
}
|
|
48
|
+
return readDeviceLogin(await readJson(response, endpoint), endpoint, pkce.verifier);
|
|
49
|
+
}
|
|
50
|
+
function deviceLoginFailure(error, status) {
|
|
51
|
+
switch (error) {
|
|
52
|
+
case "access_denied":
|
|
53
|
+
return new Error("manta-codegen: the login was refused in the browser.");
|
|
54
|
+
case "expired_token":
|
|
55
|
+
return new Error("manta-codegen: the login code expired. Run the command again.");
|
|
56
|
+
case "authorization_pending":
|
|
57
|
+
case "slow_down":
|
|
58
|
+
return undefined;
|
|
59
|
+
default:
|
|
60
|
+
return new Error(`manta-codegen: the device login failed with "${error ?? status}".`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function deviceTokenRequest(client, deviceCode, codeVerifier) {
|
|
64
|
+
return formRequest({
|
|
65
|
+
grant_type: deviceCodeGrant,
|
|
66
|
+
device_code: deviceCode,
|
|
67
|
+
client_id: client.clientId,
|
|
68
|
+
code_verifier: codeVerifier,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export async function pollForDeviceToken(...args) {
|
|
72
|
+
return (await pollForDeviceTokens(...args)).accessToken;
|
|
73
|
+
}
|
|
74
|
+
export async function pollForDeviceTokens(client, { deviceCode, codeVerifier, intervalMs = defaultIntervalMs, expiresInMs = defaultExpiresInMs, }, { fetchImpl = fetch, sleep = sleepFor, clock = Date.now, } = {}) {
|
|
75
|
+
const endpoint = tokenEndpoint(client.baseUrl, client.realm);
|
|
76
|
+
const request = deviceTokenRequest(client, deviceCode, codeVerifier);
|
|
77
|
+
const deadline = clock() + expiresInMs;
|
|
78
|
+
let wait = intervalMs;
|
|
79
|
+
for (;;) {
|
|
80
|
+
// Keycloak answers authorization_pending for ever if nobody opens the link.
|
|
81
|
+
if (clock() >= deadline)
|
|
82
|
+
throw new Error("manta-codegen: nobody approved the login in time. Run it again.");
|
|
83
|
+
const response = await postTo(endpoint, request, fetchImpl);
|
|
84
|
+
const body = await readJson(response, endpoint);
|
|
85
|
+
if (response.ok && body.access_token) {
|
|
86
|
+
return { accessToken: body.access_token, ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}) };
|
|
87
|
+
}
|
|
88
|
+
const failure = deviceLoginFailure(body.error, response.status);
|
|
89
|
+
if (failure)
|
|
90
|
+
throw failure;
|
|
91
|
+
if (body.error === "slow_down")
|
|
92
|
+
wait += slowDownStepMs;
|
|
93
|
+
await sleep(wait);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// A refresh token outlives many access tokens, so a long run renews without a second browser login.
|
|
97
|
+
export async function refreshAccessToken(client, refreshToken, fetchImpl = fetch) {
|
|
98
|
+
const endpoint = tokenEndpoint(client.baseUrl, client.realm);
|
|
99
|
+
const request = formRequest({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: client.clientId });
|
|
100
|
+
const response = await postTo(endpoint, request, fetchImpl);
|
|
101
|
+
// A refused refresh token is terminal and earns a new login; an overloaded Keycloak must not cost one.
|
|
102
|
+
if (isTransientStatus(response.status)) {
|
|
103
|
+
throw new Error(`manta-codegen: ${endpoint} could not renew the token (status ${response.status}).`);
|
|
104
|
+
}
|
|
105
|
+
if (!response.ok)
|
|
106
|
+
return undefined;
|
|
107
|
+
const body = await readJson(response, endpoint);
|
|
108
|
+
if (!body.access_token)
|
|
109
|
+
return undefined;
|
|
110
|
+
return { accessToken: body.access_token, refreshToken: body.refresh_token ?? refreshToken };
|
|
111
|
+
}
|
|
112
|
+
// One device login, then silent refreshes until the refresh token itself is refused.
|
|
113
|
+
export function deviceLoginTokenSource(client, prompt, { fetchImpl = fetch, login = startDeviceLogin, poll = pollForDeviceTokens, } = {}) {
|
|
114
|
+
let refreshToken;
|
|
115
|
+
return async () => {
|
|
116
|
+
const refreshed = refreshToken ? await refreshAccessToken(client, refreshToken, fetchImpl) : undefined;
|
|
117
|
+
if (refreshed) {
|
|
118
|
+
refreshToken = refreshed.refreshToken;
|
|
119
|
+
return refreshed.accessToken;
|
|
120
|
+
}
|
|
121
|
+
const started = await login(client, fetchImpl);
|
|
122
|
+
prompt(started);
|
|
123
|
+
const tokens = await poll(client, started, { fetchImpl });
|
|
124
|
+
refreshToken = tokens.refreshToken;
|
|
125
|
+
return tokens.accessToken;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FetchLike } from "./http.js";
|
|
2
|
+
import type { TokenSource } from "./token.js";
|
|
3
|
+
export type RealmShapes = {
|
|
4
|
+
vocabBase: string;
|
|
5
|
+
ontologyGraphIri: string;
|
|
6
|
+
shacl: string;
|
|
7
|
+
ontology: string;
|
|
8
|
+
targetClasses: Record<string, number>;
|
|
9
|
+
};
|
|
10
|
+
export declare function sparqlEndpoint(apiUrl: string, datastoreId: string): string;
|
|
11
|
+
export declare function fetchRealmShapes(endpoint: string, token: TokenSource, fetchImpl?: FetchLike): Promise<RealmShapes>;
|
|
12
|
+
//# sourceMappingURL=fetch-shapes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetch-shapes.d.ts","sourceRoot":"","sources":["../src/fetch-shapes.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,MAAM,MAAM,WAAW,GAAG;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC,CAAC;AAEF,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAE1E;AAED,wBAAsB,gBAAgB,CACpC,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,WAAW,EAClB,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,WAAW,CAAC,CActB"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { countTargetClasses, fetchGraphList, fetchGraphTurtle, findOntologyGraph, shaclGraphIri, vocabBaseFromOntologyGraph, } from "./shapes.js";
|
|
2
|
+
export function sparqlEndpoint(apiUrl, datastoreId) {
|
|
3
|
+
return `${apiUrl.replace(/\/$/, "")}/d/${encodeURIComponent(datastoreId)}/sparql`;
|
|
4
|
+
}
|
|
5
|
+
export async function fetchRealmShapes(endpoint, token, fetchImpl = fetch) {
|
|
6
|
+
const graphs = await fetchGraphList({ endpoint, token: await token() }, fetchImpl);
|
|
7
|
+
const ontologyGraphIri = findOntologyGraph(graphs);
|
|
8
|
+
const shacl = await fetchGraphTurtle({ endpoint, graphIri: shaclGraphIri, token: await token() }, fetchImpl);
|
|
9
|
+
const ontology = await fetchGraphTurtle({ endpoint, graphIri: ontologyGraphIri, token: await token() }, fetchImpl);
|
|
10
|
+
return {
|
|
11
|
+
vocabBase: vocabBaseFromOntologyGraph(ontologyGraphIri),
|
|
12
|
+
ontologyGraphIri,
|
|
13
|
+
shacl,
|
|
14
|
+
ontology,
|
|
15
|
+
targetClasses: countTargetClasses(`${shacl}\n${ontology}`),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function ldkitCliPath(entry?: string): string;
|
|
2
|
+
export declare function quoteInvalidKeys(source: string): string;
|
|
3
|
+
export declare function formatTypeScript(source: string, path: string): Promise<string>;
|
|
4
|
+
export declare function removeGeneratedFiles(outDir: string): void;
|
|
5
|
+
export declare function normalizeGeneratedFiles(outDir: string): Promise<void>;
|
|
6
|
+
export declare function failOnUnknownNamespace(outDir: string): void;
|
|
7
|
+
export declare function runLdkit(shaclPath: string, outDir: string): void;
|
|
8
|
+
export type ShapeSources = {
|
|
9
|
+
shacl: string;
|
|
10
|
+
ontology: string;
|
|
11
|
+
vocabBase: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function generatePackage({ shacl, ontology, vocabBase }: ShapeSources, outDir: string): Promise<void>;
|
|
14
|
+
//# sourceMappingURL=generate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generate.d.ts","sourceRoot":"","sources":["../src/generate.ts"],"names":[],"mappings":"AAYA,wBAAgB,YAAY,CAAC,KAAK,GAAE,MAAqB,GAAG,MAAM,CAOjE;AAOD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEvD;AAGD,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAEpF;AAOD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAEzD;AAED,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ3E;AAMD,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAU3D;AAGD,wBAAgB,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAchE;AAED,MAAM,MAAM,YAAY,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAElF,wBAAsB,eAAe,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAWjH"}
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { format, resolveConfig } from "prettier";
|
|
7
|
+
import { sortSchemas, splitDeclarations } from "./sort-schemas.js";
|
|
8
|
+
import { mergeShapes, realmPrefix } from "./turtle.js";
|
|
9
|
+
const ldkitEntry = () => createRequire(import.meta.url).resolve("ldkit");
|
|
10
|
+
// LDkit exports no entry for its own CLI, so the run addresses the file beside the package entry.
|
|
11
|
+
export function ldkitCliPath(entry = ldkitEntry()) {
|
|
12
|
+
for (let dir = dirname(entry); dir !== dirname(dir); dir = dirname(dir)) {
|
|
13
|
+
const candidate = join(dir, "esm", "cli.js");
|
|
14
|
+
if (existsSync(candidate))
|
|
15
|
+
return candidate;
|
|
16
|
+
}
|
|
17
|
+
throw new Error("manta-codegen: cannot find the LDkit CLI beside the resolved ldkit package.");
|
|
18
|
+
}
|
|
19
|
+
// LDkit writes a property name as a bare object key, and Meta sends terms such as `1dClick`.
|
|
20
|
+
const objectKey = /(?<=^\s+)[^\s"':]+(?=:\s)/gm;
|
|
21
|
+
const validName = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
22
|
+
export function quoteInvalidKeys(source) {
|
|
23
|
+
return source.replace(objectKey, (key) => (validName.test(key) ? key : `"${key}"`));
|
|
24
|
+
}
|
|
25
|
+
// The settings come from the repository the file lands in, so the code matches its neighbors.
|
|
26
|
+
export async function formatTypeScript(source, path) {
|
|
27
|
+
return format(source, { ...(await resolveConfig(path)), filepath: path });
|
|
28
|
+
}
|
|
29
|
+
function generatedFiles(outDir) {
|
|
30
|
+
return existsSync(outDir) ? readdirSync(outDir).filter((name) => name.endsWith(".ts")) : [];
|
|
31
|
+
}
|
|
32
|
+
// A schema the shapes no longer describe would keep its file, and its re-export, for ever.
|
|
33
|
+
export function removeGeneratedFiles(outDir) {
|
|
34
|
+
for (const file of generatedFiles(outDir))
|
|
35
|
+
rmSync(join(outDir, file));
|
|
36
|
+
}
|
|
37
|
+
export async function normalizeGeneratedFiles(outDir) {
|
|
38
|
+
for (const file of generatedFiles(outDir)) {
|
|
39
|
+
const path = join(outDir, file);
|
|
40
|
+
const source = readFileSync(path, "utf8");
|
|
41
|
+
const normalized = await formatTypeScript(sortSchemas(quoteInvalidKeys(source)), path);
|
|
42
|
+
if (normalized !== source)
|
|
43
|
+
writeFileSync(path, normalized);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const unknownFile = "_unknown.ts";
|
|
47
|
+
// LDkit cannot name a term whose prefix the endpoint left out, so it writes the IRI in full.
|
|
48
|
+
// That schema then addresses one realm for ever, whatever realm the next run reads.
|
|
49
|
+
export function failOnUnknownNamespace(outDir) {
|
|
50
|
+
const path = join(outDir, unknownFile);
|
|
51
|
+
if (!existsSync(path))
|
|
52
|
+
return;
|
|
53
|
+
const names = splitDeclarations(readFileSync(path, "utf8")).declarations.map((each) => each.name);
|
|
54
|
+
throw new Error(`manta-codegen: the endpoint named no prefix for some terms, so LDkit wrote ${unknownFile}: ${names.join(", ")}.`);
|
|
55
|
+
}
|
|
56
|
+
// The LDkit CLI reports a failure on stderr and still exits 0, so an empty directory is the signal.
|
|
57
|
+
export function runLdkit(shaclPath, outDir) {
|
|
58
|
+
removeGeneratedFiles(outDir);
|
|
59
|
+
mkdirSync(outDir, { recursive: true });
|
|
60
|
+
execFileSync(process.execPath, [ldkitCliPath(), "shacl-to-package", "--prefix-alias", `${realmPrefix}=Manta`, "file", shaclPath, outDir], { stdio: "inherit" });
|
|
61
|
+
if (!existsSync(join(outDir, "index.ts"))) {
|
|
62
|
+
throw new Error(`manta-codegen: LDkit wrote no schema into ${outDir}. Read the error above.`);
|
|
63
|
+
}
|
|
64
|
+
failOnUnknownNamespace(outDir);
|
|
65
|
+
}
|
|
66
|
+
export async function generatePackage({ shacl, ontology, vocabBase }, outDir) {
|
|
67
|
+
const stageDir = mkdtempSync(join(tmpdir(), "manta-codegen-"));
|
|
68
|
+
try {
|
|
69
|
+
const merged = join(stageDir, "shapes.ttl");
|
|
70
|
+
writeFileSync(merged, await mergeShapes(shacl, ontology, vocabBase));
|
|
71
|
+
runLdkit(merged, outDir);
|
|
72
|
+
await normalizeGeneratedFiles(outDir);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
rmSync(stageDir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
2
|
+
export declare function formRequest(fields: Record<string, string>): RequestInit;
|
|
3
|
+
export declare function isTransientStatus(status: number): boolean;
|
|
4
|
+
export declare function readJson<T>(response: Response, endpoint: string, label?: string): Promise<T>;
|
|
5
|
+
export declare function postTo(endpoint: string, init: RequestInit, fetchImpl?: FetchLike): Promise<Response>;
|
|
6
|
+
export declare function describeError(error: unknown): string;
|
|
7
|
+
//# sourceMappingURL=http.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE9E,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,WAAW,CAMvE;AAGD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD;AAGD,wBAAsB,QAAQ,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,SAAkB,GAAG,OAAO,CAAC,CAAC,CAAC,CAQ3G;AAGD,wBAAsB,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,SAAS,GAAE,SAAiB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAMjH;AAGD,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAIpD"}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function formRequest(fields) {
|
|
2
|
+
return {
|
|
3
|
+
method: "POST",
|
|
4
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
5
|
+
body: new URLSearchParams(fields).toString(),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
// Overload and timeout, which are worth repeating; every other refusal is the server's final word.
|
|
9
|
+
export function isTransientStatus(status) {
|
|
10
|
+
return status >= 500 || status === 429 || status === 408;
|
|
11
|
+
}
|
|
12
|
+
// A proxy answers an HTML page, and response.json() then throws a bare SyntaxError.
|
|
13
|
+
export async function readJson(response, endpoint, label = "manta-codegen") {
|
|
14
|
+
const body = await response.text();
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(body);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error(`${label}: ${endpoint} answered status ${response.status} with text, not JSON: ${body}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// A thrown fetch says only "fetch failed", which names neither the host nor the step.
|
|
23
|
+
export async function postTo(endpoint, init, fetchImpl = fetch) {
|
|
24
|
+
try {
|
|
25
|
+
return await fetchImpl(endpoint, init);
|
|
26
|
+
}
|
|
27
|
+
catch (cause) {
|
|
28
|
+
throw new Error(`manta-codegen: cannot reach ${endpoint}.`, { cause });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// postTo puts the real failure in the cause, and the message alone names only the host.
|
|
32
|
+
export function describeError(error) {
|
|
33
|
+
if (!(error instanceof Error))
|
|
34
|
+
return String(error);
|
|
35
|
+
return error.cause instanceof Error ? `${error.message}\n caused by: ${error.cause.message}` : error.message;
|
|
36
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { deviceLoginTokenSource, pollForDeviceToken, pollForDeviceTokens, refreshAccessToken, startDeviceLogin, type DeviceLogin, type DeviceTokens, type PkcePair, type PublicClient, type Sleep, } from "./device-login.js";
|
|
2
|
+
export { fetchRealmShapes, sparqlEndpoint, type RealmShapes } from "./fetch-shapes.js";
|
|
3
|
+
export { failOnUnknownNamespace, generatePackage, removeGeneratedFiles, type ShapeSources } from "./generate.js";
|
|
4
|
+
export { sortSchemas } from "./sort-schemas.js";
|
|
5
|
+
export { describeError, isTransientStatus, readJson, type FetchLike } from "./http.js";
|
|
6
|
+
export { countTargetClasses } from "./shapes.js";
|
|
7
|
+
export { cachedToken, mintWithClientCredentials, type ClientCredentials, type TokenSource } from "./token.js";
|
|
8
|
+
export { mergeShapes, realmPrefix, sortQuads } from "./turtle.js";
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,KAAK,GACX,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,KAAK,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACvF,OAAO,EAAE,sBAAsB,EAAE,eAAe,EAAE,oBAAoB,EAAE,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AACjH,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,QAAQ,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AACvF,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,yBAAyB,EAAE,KAAK,iBAAiB,EAAE,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9G,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { deviceLoginTokenSource, pollForDeviceToken, pollForDeviceTokens, refreshAccessToken, startDeviceLogin, } from "./device-login.js";
|
|
2
|
+
export { fetchRealmShapes, sparqlEndpoint } from "./fetch-shapes.js";
|
|
3
|
+
export { failOnUnknownNamespace, generatePackage, removeGeneratedFiles } from "./generate.js";
|
|
4
|
+
export { sortSchemas } from "./sort-schemas.js";
|
|
5
|
+
export { describeError, isTransientStatus, readJson } from "./http.js";
|
|
6
|
+
export { countTargetClasses } from "./shapes.js";
|
|
7
|
+
export { cachedToken, mintWithClientCredentials } from "./token.js";
|
|
8
|
+
export { mergeShapes, realmPrefix, sortQuads } from "./turtle.js";
|
package/dist/shapes.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type FetchLike } from "./http.js";
|
|
2
|
+
export declare const shaclGraphIri = "https://rdfox.com/default-base-iri/shacl";
|
|
3
|
+
export declare function constructGraphQuery(graphIri: string): string;
|
|
4
|
+
export declare function graphListQuery(): string;
|
|
5
|
+
export declare function readGraphList(results: unknown): string[];
|
|
6
|
+
export declare function findOntologyGraph(graphIris: readonly string[]): string;
|
|
7
|
+
export declare function vocabBaseFromOntologyGraph(ontologyGraphIri: string): string;
|
|
8
|
+
export type DatastoreAccess = {
|
|
9
|
+
endpoint: string;
|
|
10
|
+
token: string;
|
|
11
|
+
};
|
|
12
|
+
export type GraphRequest = DatastoreAccess & {
|
|
13
|
+
graphIri: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function fetchGraphTurtle(request: GraphRequest, fetchImpl?: FetchLike): Promise<string>;
|
|
16
|
+
export declare function fetchGraphList(access: DatastoreAccess, fetchImpl?: FetchLike): Promise<string[]>;
|
|
17
|
+
export declare const realmGroup = "(realm)";
|
|
18
|
+
export declare function countTargetClasses(turtle: string): Record<string, number>;
|
|
19
|
+
//# sourceMappingURL=shapes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shapes.d.ts","sourceRoot":"","sources":["../src/shapes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAU,MAAM,WAAW,CAAC;AAGnD,eAAO,MAAM,aAAa,6CAA6C,CAAC;AAMxE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAID,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,EAAE,CAIxD;AAID,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAiBtE;AAED,wBAAgB,0BAA0B,CAAC,gBAAgB,EAAE,MAAM,GAAG,MAAM,CAE3E;AAED,MAAM,MAAM,eAAe,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAElE,MAAM,MAAM,YAAY,GAAG,eAAe,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAiClE,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,GAAE,SAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ3G;AAED,wBAAsB,cAAc,CAAC,MAAM,EAAE,eAAe,EAAE,SAAS,GAAE,SAAiB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAI7G;AAMD,eAAO,MAAM,UAAU,YAAY,CAAC;AAGpC,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAazE"}
|
package/dist/shapes.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { postTo } from "./http.js";
|
|
2
|
+
// The store puts the shapes here for every realm, so this one carries no realm identifier.
|
|
3
|
+
export const shaclGraphIri = "https://rdfox.com/default-base-iri/shacl";
|
|
4
|
+
const storeDefaultBaseIri = "https://rdfox.com/default-base-iri/";
|
|
5
|
+
const ontologyGraphSuffix = "ontology";
|
|
6
|
+
export function constructGraphQuery(graphIri) {
|
|
7
|
+
if (graphIri.includes(">")) {
|
|
8
|
+
throw new Error(`manta-codegen: the graph IRI ${graphIri} carries a ">", which cannot go into a query.`);
|
|
9
|
+
}
|
|
10
|
+
return `CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <${graphIri}> { ?s ?p ?o } }`;
|
|
11
|
+
}
|
|
12
|
+
export function graphListQuery() {
|
|
13
|
+
return "SELECT DISTINCT ?g WHERE { GRAPH ?g { ?s ?p ?o } }";
|
|
14
|
+
}
|
|
15
|
+
export function readGraphList(results) {
|
|
16
|
+
const bindings = results.results?.bindings ?? [];
|
|
17
|
+
return bindings.map((binding) => binding.g?.value).filter((value) => typeof value === "string");
|
|
18
|
+
}
|
|
19
|
+
// The realm ontology graph is `<vocab base>ontology`, so finding it also yields the vocab base. The
|
|
20
|
+
// CLI therefore needs no realm name and ships no realm list.
|
|
21
|
+
export function findOntologyGraph(graphIris) {
|
|
22
|
+
const candidates = graphIris.filter((iri) => iri.endsWith(ontologyGraphSuffix) && !iri.startsWith(storeDefaultBaseIri));
|
|
23
|
+
if (candidates.length === 0) {
|
|
24
|
+
throw new Error(`manta-codegen: the datastore holds no ontology graph. Looked for an IRI that ends with ` +
|
|
25
|
+
`"${ontologyGraphSuffix}" among: ${graphIris.join(", ")}.`);
|
|
26
|
+
}
|
|
27
|
+
if (candidates.length > 1) {
|
|
28
|
+
throw new Error(`manta-codegen: the datastore holds more than one ontology graph: ${candidates.join(", ")}.`);
|
|
29
|
+
}
|
|
30
|
+
return candidates[0];
|
|
31
|
+
}
|
|
32
|
+
export function vocabBaseFromOntologyGraph(ontologyGraphIri) {
|
|
33
|
+
return ontologyGraphIri.slice(0, -ontologyGraphSuffix.length);
|
|
34
|
+
}
|
|
35
|
+
async function runSparql({ endpoint, token }, { query, accept, action }, fetchImpl) {
|
|
36
|
+
const response = await postTo(endpoint, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/sparql-query", Accept: accept },
|
|
39
|
+
body: query,
|
|
40
|
+
}, fetchImpl);
|
|
41
|
+
if (!response.ok)
|
|
42
|
+
throw refusal({ endpoint, action }, response, await response.text());
|
|
43
|
+
return response;
|
|
44
|
+
}
|
|
45
|
+
function refusal(call, response, body) {
|
|
46
|
+
const rejectsToken = response.status === 401 || response.status === 403;
|
|
47
|
+
const hint = rejectsToken ? " Check that the token carries access to this datastore." : "";
|
|
48
|
+
return new Error(`manta-codegen: ${call.endpoint} refused ${call.action} with status ${response.status}.${hint} ${body.trim()}`);
|
|
49
|
+
}
|
|
50
|
+
export async function fetchGraphTurtle(request, fetchImpl = fetch) {
|
|
51
|
+
const call = {
|
|
52
|
+
query: constructGraphQuery(request.graphIri),
|
|
53
|
+
accept: "text/turtle",
|
|
54
|
+
action: `the read of <${request.graphIri}>`,
|
|
55
|
+
};
|
|
56
|
+
return (await runSparql(request, call, fetchImpl)).text();
|
|
57
|
+
}
|
|
58
|
+
export async function fetchGraphList(access, fetchImpl = fetch) {
|
|
59
|
+
const call = { query: graphListQuery(), accept: "application/sparql-results+json", action: "the graph list" };
|
|
60
|
+
return readGraphList(await (await runSparql(access, call, fetchImpl)).json());
|
|
61
|
+
}
|
|
62
|
+
// The datastore serves the realm's own classes on the empty prefix, so the prefix group is optional.
|
|
63
|
+
const targetClass = /sh:targetClass\s+([A-Za-z][\w-]*)?:([A-Za-z][\w-]*)/g;
|
|
64
|
+
// The empty prefix reads as nothing in a report, and the reader needs a name for that group.
|
|
65
|
+
export const realmGroup = "(realm)";
|
|
66
|
+
// A thin result must be visible at once, rather than found later at query time.
|
|
67
|
+
export function countTargetClasses(turtle) {
|
|
68
|
+
const seen = new Map();
|
|
69
|
+
for (const [, prefix, localName] of turtle.matchAll(targetClass)) {
|
|
70
|
+
if (!localName)
|
|
71
|
+
continue;
|
|
72
|
+
const group = prefix ?? realmGroup;
|
|
73
|
+
const classes = seen.get(group) ?? new Set();
|
|
74
|
+
classes.add(localName);
|
|
75
|
+
seen.set(group, classes);
|
|
76
|
+
}
|
|
77
|
+
return Object.fromEntries([...seen].map(([group, classes]) => [group, classes.size]).sort());
|
|
78
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type Declaration = {
|
|
2
|
+
name: string;
|
|
3
|
+
text: string;
|
|
4
|
+
};
|
|
5
|
+
export type SplitSource = {
|
|
6
|
+
header: string[];
|
|
7
|
+
declarations: Declaration[];
|
|
8
|
+
};
|
|
9
|
+
export declare function splitDeclarations(source: string): SplitSource;
|
|
10
|
+
export declare function sortSchemas(source: string): string;
|
|
11
|
+
//# sourceMappingURL=sort-schemas.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sort-schemas.d.ts","sourceRoot":"","sources":["../src/sort-schemas.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,WAAW,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzD,MAAM,MAAM,WAAW,GAAG;IAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,YAAY,EAAE,WAAW,EAAE,CAAA;CAAE,CAAC;AAiB5E,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAO7D;AASD,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAQlD"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const declaration = /^export const (\w+)/;
|
|
2
|
+
function groupByDeclaration(lines) {
|
|
3
|
+
const blocks = [];
|
|
4
|
+
for (const line of lines) {
|
|
5
|
+
if (declaration.test(line))
|
|
6
|
+
blocks.push([]);
|
|
7
|
+
blocks[blocks.length - 1]?.push(line);
|
|
8
|
+
}
|
|
9
|
+
return blocks;
|
|
10
|
+
}
|
|
11
|
+
function toDeclaration(block) {
|
|
12
|
+
return { name: declaration.exec(block[0] ?? "")?.[1] ?? "", text: block.join("\n").trimEnd() };
|
|
13
|
+
}
|
|
14
|
+
export function splitDeclarations(source) {
|
|
15
|
+
const lines = source.split("\n");
|
|
16
|
+
const start = lines.findIndex((line) => declaration.test(line));
|
|
17
|
+
if (start < 0)
|
|
18
|
+
return { header: lines, declarations: [] };
|
|
19
|
+
return { header: lines.slice(0, start), declarations: groupByDeclaration(lines.slice(start)).map(toDeclaration) };
|
|
20
|
+
}
|
|
21
|
+
// A code point order, never localeCompare: the order must not follow the machine that ran the build.
|
|
22
|
+
function byName(left, right) {
|
|
23
|
+
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
|
|
24
|
+
}
|
|
25
|
+
// LDkit writes each schema in the order its shape arrives, so one new shape moves whole blocks.
|
|
26
|
+
// packages/ldo/scripts/sort-schemas.mjs holds the same rule, so the two outputs compare.
|
|
27
|
+
export function sortSchemas(source) {
|
|
28
|
+
const { header, declarations } = splitDeclarations(source);
|
|
29
|
+
if (declarations.length === 0)
|
|
30
|
+
return source;
|
|
31
|
+
const sorted = [...declarations].sort(byName).map((each) => each.text);
|
|
32
|
+
return `${[...header, sorted.join("\n\n")].join("\n")}\n`;
|
|
33
|
+
}
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type FetchLike } from "./http.js";
|
|
2
|
+
export type ClientCredentials = {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
realm: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
clientSecret: string;
|
|
7
|
+
scope?: string;
|
|
8
|
+
};
|
|
9
|
+
export type TokenSource = () => Promise<string>;
|
|
10
|
+
export declare const tokenExpiryBufferMs = 60000;
|
|
11
|
+
export declare const unparsableTokenTtlMs = 30000;
|
|
12
|
+
export declare function tokenEndpoint(baseUrl: string, realm: string): string;
|
|
13
|
+
export declare function accessTokenExpiresAt(accessToken: string, now: number): number;
|
|
14
|
+
export declare function isFresh(expiresAt: number, now: number): boolean;
|
|
15
|
+
export declare function mintWithClientCredentials(credentials: ClientCredentials, fetchImpl?: FetchLike): Promise<string>;
|
|
16
|
+
export declare function cachedToken(mint: TokenSource, clock?: () => number): TokenSource;
|
|
17
|
+
//# sourceMappingURL=token.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAuB,MAAM,WAAW,CAAC;AAEhE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;AAGhD,eAAO,MAAM,mBAAmB,QAAS,CAAC;AAG1C,eAAO,MAAM,oBAAoB,QAAS,CAAC;AAE3C,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEpE;AAGD,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAc7E;AAED,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAE/D;AAqBD,wBAAsB,yBAAyB,CAC7C,WAAW,EAAE,iBAAiB,EAC9B,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,MAAM,CAAC,CAajB;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,EAAE,KAAK,GAAE,MAAM,MAAiB,GAAG,WAAW,CAc1F"}
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { formRequest, postTo } from "./http.js";
|
|
2
|
+
// Re-mint before Keycloak's own exp, so an in-flight request never races an expired token.
|
|
3
|
+
export const tokenExpiryBufferMs = 60_000;
|
|
4
|
+
// The exp claim did not parse, so the token gets a short life rather than none.
|
|
5
|
+
export const unparsableTokenTtlMs = 30_000;
|
|
6
|
+
export function tokenEndpoint(baseUrl, realm) {
|
|
7
|
+
return `${baseUrl}/realms/${realm}/protocol/openid-connect/token`;
|
|
8
|
+
}
|
|
9
|
+
// Reads exp without checking the signature: the caller only decides when to re-mint.
|
|
10
|
+
export function accessTokenExpiresAt(accessToken, now) {
|
|
11
|
+
const fallback = now + unparsableTokenTtlMs;
|
|
12
|
+
const payload = accessToken.split(".")[1];
|
|
13
|
+
if (!payload)
|
|
14
|
+
return fallback;
|
|
15
|
+
try {
|
|
16
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
17
|
+
const exp = claims.exp;
|
|
18
|
+
return typeof exp === "number" ? exp * 1000 - tokenExpiryBufferMs : fallback;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function isFresh(expiresAt, now) {
|
|
25
|
+
return expiresAt > now;
|
|
26
|
+
}
|
|
27
|
+
function clientCredentialsRequest({ clientId, clientSecret, scope }) {
|
|
28
|
+
return formRequest({
|
|
29
|
+
grant_type: "client_credentials",
|
|
30
|
+
client_id: clientId,
|
|
31
|
+
client_secret: clientSecret,
|
|
32
|
+
...(scope ? { scope } : {}),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function readAccessToken(body, realm) {
|
|
36
|
+
const accessToken = body.access_token;
|
|
37
|
+
if (typeof accessToken !== "string") {
|
|
38
|
+
throw new Error(`manta-codegen: the Keycloak response for realm ${realm} carries no access_token.`);
|
|
39
|
+
}
|
|
40
|
+
return accessToken;
|
|
41
|
+
}
|
|
42
|
+
export async function mintWithClientCredentials(credentials, fetchImpl = fetch) {
|
|
43
|
+
const { baseUrl, realm } = credentials;
|
|
44
|
+
const endpoint = tokenEndpoint(baseUrl, realm);
|
|
45
|
+
const response = await postTo(endpoint, clientCredentialsRequest(credentials), fetchImpl);
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new Error(`manta-codegen: Keycloak refused the client credentials for realm ${realm} with status ${response.status}. ` +
|
|
48
|
+
`Check the client id and the secret against ${endpoint}.`);
|
|
49
|
+
}
|
|
50
|
+
return readAccessToken(await response.json(), realm);
|
|
51
|
+
}
|
|
52
|
+
export function cachedToken(mint, clock = Date.now) {
|
|
53
|
+
let held;
|
|
54
|
+
return async () => {
|
|
55
|
+
const now = clock();
|
|
56
|
+
if (held && isFresh(held.expiresAt, now))
|
|
57
|
+
return held.token;
|
|
58
|
+
const token = await mint();
|
|
59
|
+
// A device login blocks until the browser answers, so `now` is already stale here.
|
|
60
|
+
held = { token, expiresAt: accessTokenExpiresAt(token, clock()) };
|
|
61
|
+
return token;
|
|
62
|
+
};
|
|
63
|
+
}
|
package/dist/turtle.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Quad } from "n3";
|
|
2
|
+
export declare const realmPrefix = "m";
|
|
3
|
+
export type ParsedTurtle = {
|
|
4
|
+
quads: Quad[];
|
|
5
|
+
prefixes: Record<string, string>;
|
|
6
|
+
};
|
|
7
|
+
export declare function parseTurtle(turtle: string): ParsedTurtle;
|
|
8
|
+
export declare function writeTurtle({ quads, prefixes }: ParsedTurtle): Promise<string>;
|
|
9
|
+
export declare function namePrefixes(prefixes: Record<string, string>, vocabBase: string): Record<string, string>;
|
|
10
|
+
export declare function quadKey(quad: Quad): string;
|
|
11
|
+
export declare function sortQuads(quads: readonly Quad[]): Quad[];
|
|
12
|
+
export declare function mergeShapes(shacl: string, ontology: string, vocabBase: string): Promise<string>;
|
|
13
|
+
//# sourceMappingURL=turtle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"turtle.d.ts","sourceRoot":"","sources":["../src/turtle.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAQ,MAAM,IAAI,CAAC;AAIrC,eAAO,MAAM,WAAW,MAAM,CAAC;AAE/B,MAAM,MAAM,YAAY,GAAG;IAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC;AAE/E,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAOxD;AAED,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAO9E;AAGD,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQxG;AAUD,wBAAgB,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAE1C;AAID,wBAAgB,SAAS,CAAC,KAAK,EAAE,SAAS,IAAI,EAAE,GAAG,IAAI,EAAE,CAKxD;AAGD,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ/F"}
|
package/dist/turtle.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Parser, Writer } from "n3";
|
|
2
|
+
// LDkit names each generated file after the prefix of the term it holds. The realm vocabulary
|
|
3
|
+
// reaches us on the empty prefix, which LDkit sends to `_unknown.ts`, so it needs a name here.
|
|
4
|
+
export const realmPrefix = "m";
|
|
5
|
+
export function parseTurtle(turtle) {
|
|
6
|
+
const prefixes = {};
|
|
7
|
+
const quads = new Parser().parse(turtle, null, (prefix, iri) => {
|
|
8
|
+
prefixes[prefix] = iri.value;
|
|
9
|
+
});
|
|
10
|
+
return { quads, prefixes };
|
|
11
|
+
}
|
|
12
|
+
export function writeTurtle({ quads, prefixes }) {
|
|
13
|
+
const writer = new Writer({ prefixes });
|
|
14
|
+
writer.addQuads(quads);
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
writer.end((error, result) => (error ? reject(error) : resolve(result)));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
// A second prefix on the same IRI would let the writer pick either one, so the realm keeps only `m`.
|
|
20
|
+
export function namePrefixes(prefixes, vocabBase) {
|
|
21
|
+
const named = { [realmPrefix]: vocabBase };
|
|
22
|
+
for (const [prefix, iri] of Object.entries(prefixes)) {
|
|
23
|
+
if (prefix !== "" && iri !== vocabBase)
|
|
24
|
+
named[prefix] = iri;
|
|
25
|
+
}
|
|
26
|
+
return named;
|
|
27
|
+
}
|
|
28
|
+
function termParts(term) {
|
|
29
|
+
if (term.termType === "Literal")
|
|
30
|
+
return ["2", term.value, term.language, term.datatype.value];
|
|
31
|
+
if (term.termType === "BlankNode")
|
|
32
|
+
return ["1", term.value];
|
|
33
|
+
return ["0", term.value];
|
|
34
|
+
}
|
|
35
|
+
// A literal holds any text, so a separator character could give one key to two different quads.
|
|
36
|
+
export function quadKey(quad) {
|
|
37
|
+
return JSON.stringify([quad.subject, quad.predicate, quad.object, quad.graph].map(termParts));
|
|
38
|
+
}
|
|
39
|
+
// A SPARQL CONSTRUCT promises no order, and LDkit reads the shapes in the order they arrive: it
|
|
40
|
+
// resolves an ambiguous term to whichever namespace it met last. One order gives one answer.
|
|
41
|
+
export function sortQuads(quads) {
|
|
42
|
+
const keyed = quads.map((quad) => ({ key: quadKey(quad), quad }));
|
|
43
|
+
keyed.sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0));
|
|
44
|
+
return keyed.map((each) => each.quad);
|
|
45
|
+
}
|
|
46
|
+
// LDkit reads one document.
|
|
47
|
+
export function mergeShapes(shacl, ontology, vocabBase) {
|
|
48
|
+
const shapes = parseTurtle(shacl);
|
|
49
|
+
const subclasses = parseTurtle(ontology);
|
|
50
|
+
return writeTurtle({
|
|
51
|
+
quads: sortQuads([...shapes.quads, ...subclasses.quads]),
|
|
52
|
+
prefixes: namePrefixes({ ...shapes.prefixes, ...subclasses.prefixes }, vocabBase),
|
|
53
|
+
});
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@manta-eu/codegen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI that generates LDkit schemas from a realm's SHACL shapes, read from its datastore",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"manta-codegen": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"author": "",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@types/n3": "^1.26.0",
|
|
19
|
+
"commander": "^15.0.0",
|
|
20
|
+
"ldkit": "2.8.0",
|
|
21
|
+
"n3": "^2.7.12",
|
|
22
|
+
"prettier": "^3.8.1"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^24.13.3",
|
|
26
|
+
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
|
27
|
+
"@typescript-eslint/parser": "^8.56.1",
|
|
28
|
+
"eslint": "^9.39.2",
|
|
29
|
+
"tsx": "^4.20.6",
|
|
30
|
+
"typescript": "^5.0.0",
|
|
31
|
+
"vitest": "^3.0.0"
|
|
32
|
+
},
|
|
33
|
+
"main": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"files": [
|
|
36
|
+
"dist"
|
|
37
|
+
],
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc -p tsconfig.build.json",
|
|
43
|
+
"build:lib": "pnpm run build",
|
|
44
|
+
"test": "vitest --run",
|
|
45
|
+
"types": "tsc --noEmit"
|
|
46
|
+
}
|
|
47
|
+
}
|