@oak-digital/types-4-strapi-2 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 ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Oak Digital
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.
22
+
package/bin/index.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require("../lib/index.js")
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@oak-digital/types-4-strapi-2",
3
+ "version": "0.1.0",
4
+ "description": "Typescript interface generator for Strapi 4 models",
5
+ "bin": {
6
+ "t4s": "./bin/index.js"
7
+ },
8
+ "scripts": {
9
+ "build": "tsc -p .",
10
+ "testtypes": "node ./bin/index.js --out src/testtypes",
11
+ "t4s": "node ./bin/index.js"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/Oak-Digital/types-4-strapi-2.git"
16
+ },
17
+ "keywords": [
18
+ "strapi",
19
+ "typescript",
20
+ "types",
21
+ "generator"
22
+ ],
23
+ "author": "Oak digital",
24
+ "license": "MIT",
25
+ "bugs": {
26
+ "url": "https://github.com/Oak-Digital/types-4-strapi-2/issues"
27
+ },
28
+ "homepage": "https://github.com/Oak-Digital/types-4-strapi-2",
29
+ "devDependencies": {
30
+ "@types/node": "^18.7.2",
31
+ "typescript": "^4.7.4"
32
+ },
33
+ "dependencies": {
34
+ "commander": "^9.4.0"
35
+ }
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { program } from 'commander'
2
+ import * as fs from 'node:fs/promises'
3
+ import InterfaceManager from './interface/InterfaceManager'
4
+
5
+ program
6
+ .name("t4s")
7
+
8
+ program
9
+ .option('-i, --in <dir>', 'The src directory for strapi', './src')
10
+ .option('-o, --out <dir>', 'The output directory to output the types to', './types')
11
+
12
+ program.parse()
13
+ const options = program.opts()
14
+ const {
15
+ in: input,
16
+ out,
17
+ } = options
18
+
19
+ const manager = new InterfaceManager(out, input)
20
+ manager.run().catch((err) => {
21
+ console.error(err)
22
+ });
@@ -0,0 +1,115 @@
1
+ import Interface from "./Interface";
2
+
3
+ export default class Attributes {
4
+ Attrs: Record<string, Record<string, any>>;
5
+ private RelationNames: Record<string, [string, Interface]> = {};
6
+
7
+ constructor(attr: Record<string, Record<string, any>>, relationNames: Record<string, [string, Interface]>) {
8
+ this.Attrs = attr;
9
+ this.RelationNames = relationNames;
10
+ }
11
+
12
+ isAttributeOptional(attr: any) {
13
+ // If it is a component / relation / dynamiczone it is always optional due to population
14
+ switch (attr.type) {
15
+ case "component":
16
+ case "dynamiczone":
17
+ case "relation":
18
+ return true;
19
+ default:
20
+ break;
21
+ }
22
+
23
+ return !attr.required;
24
+ }
25
+
26
+ getDependencies(strapiName: string) {
27
+ const dependencies = [];
28
+ for (const attrName in this.Attrs) {
29
+ const attr = this.Attrs[attrName];
30
+ let dependencyName : string;
31
+ switch (attr.type) {
32
+ case "relation":
33
+ dependencyName = attr.target;
34
+ break;
35
+ case "component":
36
+ dependencyName = attr.component;
37
+ break;
38
+ default:
39
+ continue;
40
+ }
41
+ // // If the current dependency is the interface itself, do not report it as a dependency
42
+ // if (dependencyName === strapiName) {
43
+ // continue;
44
+ // }
45
+ dependencies.push(dependencyName);
46
+ }
47
+ return dependencies;
48
+ }
49
+
50
+ attributeToString(attrName: string, attr: any) {
51
+ let optionalString = this.isAttributeOptional(attr) ? '?' : '';
52
+ let str = ` ${attrName}${optionalString}: `
53
+ let isArray : boolean = false;
54
+ switch (attr.type) {
55
+ case "relation":
56
+ const apiName = attr.target;
57
+ // console.log(attrName)
58
+ // console.log(this.RelationNames, apiName)
59
+ const dependencyName = this.RelationNames[apiName][0];
60
+ isArray = attr.relation.endsWith("ToMany");
61
+ str += dependencyName;
62
+ break;
63
+ case "component":
64
+ const componentName = attr.component;
65
+ const dependencyComponentName = this.RelationNames[componentName][0];
66
+ isArray = attr.repeatable ?? false;
67
+ str += dependencyComponentName;
68
+ break;
69
+ case "password":
70
+ return null;
71
+ case "string":
72
+ case "text":
73
+ case "richtext":
74
+ case "email":
75
+ case "uid":
76
+ str += "string";
77
+ break;
78
+ case "integer":
79
+ case "biginteger":
80
+ case "decimal":
81
+ case "float":
82
+ str += "number";
83
+ break;
84
+ case "date":
85
+ case "datetime":
86
+ case "time":
87
+ str += "Date";
88
+ break;
89
+ case "boolean":
90
+ str += attr.type;
91
+ break;
92
+ case "json":
93
+ default:
94
+ str += "any";
95
+ break;
96
+ }
97
+ const isArrayString = isArray ? '[]' : ''
98
+ str += `${isArrayString};`;
99
+ return str;
100
+ }
101
+
102
+ toString() : string {
103
+ const strings = [ "{" ];
104
+ for (const attrName in this.Attrs) {
105
+ const attr = this.Attrs[attrName];
106
+ const attrString = this.attributeToString(attrName, attr);
107
+ if (attrString === null) {
108
+ continue;
109
+ }
110
+ strings.push(attrString)
111
+ }
112
+ strings.push(" }")
113
+ return strings.join("\n");
114
+ }
115
+ }
@@ -0,0 +1,16 @@
1
+ import { prefixDotSlash } from "../utils";
2
+ import Interface from "./Interface";
3
+
4
+ export default class ComponentInterface extends Interface {
5
+ protected Category: string;
6
+
7
+ constructor(baseName: string, attributes: any, relativeDirectoryPath: string, category: string, prefix: string = "") {
8
+ super(baseName, attributes, relativeDirectoryPath, prefix);
9
+ this.Category = category;
10
+ this.updateStrapiName();
11
+ }
12
+
13
+ updateStrapiName() {
14
+ this.StrapiName = `${this.Category}.${this.getBaseName()}`
15
+ }
16
+ }
@@ -0,0 +1,119 @@
1
+ import { dirname, join, parse, relative } from "path";
2
+ import { pascalCase, prefixDotSlash } from "../utils";
3
+ import Attributes from "./Attributes";
4
+
5
+ export default class Interface {
6
+ protected BaseName: string;
7
+ private Relations: Interface[] = []; // Components and relations
8
+ private RelationNames: Record<string, [string, Interface]> = {};
9
+ private RelationNamesCounter: Record<string, number> = {};
10
+ private NamePrefix: string = "";
11
+ private Attributes: any;
12
+ private RelativeDirectoryPath: string;
13
+ protected StrapiName: string;
14
+
15
+ constructor(baseName: string, attributes: any, relativeDirectoryPath: string, prefix: string = "") {
16
+ this.BaseName = baseName;
17
+ this.updateStrapiName()
18
+ this.NamePrefix = prefix;
19
+ this.Attributes = attributes;
20
+ this.RelativeDirectoryPath = relativeDirectoryPath;
21
+ }
22
+
23
+ protected updateStrapiName() {
24
+ this.StrapiName = `api::${this.BaseName}.${this.BaseName}`;
25
+ }
26
+
27
+ getBaseName() {
28
+ return this.BaseName;
29
+ }
30
+
31
+ getStrapiName() {
32
+ return this.StrapiName;
33
+ }
34
+
35
+ getDependencies() {
36
+ const attrs = new Attributes(this.Attributes, this.RelationNames);
37
+ return attrs.getDependencies(this.getStrapiName());
38
+ }
39
+
40
+ getFullInterfaceName() {
41
+ const pascalName = pascalCase(this.BaseName);
42
+ return `${this.NamePrefix}${pascalName}`;
43
+ }
44
+
45
+ // For typescript import from index file
46
+ getRelativeRootPath() {
47
+ const path = join(this.RelativeDirectoryPath, this.getBaseName());
48
+ return prefixDotSlash(path);
49
+ }
50
+
51
+ getRelativeRootDir() {
52
+ const path = dirname(this.getRelativeRootPathFile());
53
+ return path;
54
+ }
55
+
56
+ getRelativeRootPathFile() {
57
+ return `${this.getRelativeRootPath()}.ts`
58
+ }
59
+
60
+ setRelations(relations: Interface[]) {
61
+ this.Relations = relations;
62
+ this.RelationNames = {};
63
+ this.Relations.forEach((inter: Interface) => {
64
+ let name = inter.getFullInterfaceName();
65
+ // FIXME: clean up this mess...
66
+ if (inter.getStrapiName() === this.getStrapiName()) {
67
+ } else {
68
+ // Avoid duplicate names
69
+ if (name in this.RelationNamesCounter) {
70
+ name += ++this.RelationNamesCounter[name];
71
+ } else {
72
+ this.RelationNamesCounter[name] = 0;
73
+ }
74
+ }
75
+ this.RelationNames[inter.getStrapiName()] = [name, inter];
76
+ })
77
+ }
78
+
79
+ private getTsImports() {
80
+ return Object.keys(this.RelationNames).map((strapiName: string) => {
81
+ if (strapiName === this.getStrapiName()) {
82
+ return "";
83
+ }
84
+ const relationName = this.RelationNames[strapiName][0];
85
+ const inter = this.RelationNames[strapiName][1];
86
+ const importPath = prefixDotSlash(relative(this.getRelativeRootDir(), inter.getRelativeRootPath()));
87
+ const fullName = inter.getFullInterfaceName();
88
+ const importNameString = fullName === relationName ? fullName : `${fullName} as ${relationName}`;
89
+ return `import { ${importNameString} } from '${importPath}';`;
90
+ }).filter(s => s).join("\n");
91
+ }
92
+
93
+ attributesToString() {
94
+ const attrs = new Attributes(this.Attributes, this.RelationNames);
95
+ return attrs.toString();
96
+ }
97
+
98
+ getInerfaceString() {
99
+ let str = `export interface ${this.getFullInterfaceName()} {\n`;
100
+ str += this.getInterfaceFieldsString();
101
+ str += `}`
102
+ return str;
103
+ }
104
+
105
+ getInterfaceFieldsString() {
106
+ let str = '';
107
+ str += ` id: number;\n`;
108
+ str += ` attributes: ${this.attributesToString()}\n`;
109
+ return str;
110
+ }
111
+
112
+ toString() {
113
+ const strings = [
114
+ this.getTsImports(),
115
+ this.getInerfaceString()
116
+ ];
117
+ return strings.join("\n")
118
+ }
119
+ }
@@ -0,0 +1,116 @@
1
+ import { existsSync } from "fs";
2
+ import { mkdir, rm, writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import { pascalCase } from "../utils";
5
+ import ComponentInterface from "./ComponentInterface";
6
+ import Interface from "./Interface";
7
+ import { getApiSchemas, getComponentCategoryFolders, getComponentSchemas } from "./schemaReader";
8
+
9
+ export default class InterfaceManager {
10
+ private Interfaces: Record<string, Interface> = {}; // string = strapi name
11
+ private OutRoot: string;
12
+ private StrapiSrcRoot: string;
13
+ private Options: any;
14
+
15
+ static BaseOptions = {
16
+ prefix: "I",
17
+ useCategoryPrefix: true,
18
+ componentPrefix: "",
19
+ componentPrefixOverridesPrefix: false,
20
+ };
21
+
22
+ constructor(outRoot: string, strapiSrcRoot: string, options: any = {}) {
23
+ this.OutRoot = outRoot;
24
+ this.StrapiSrcRoot = strapiSrcRoot;
25
+ this.Options = Object.assign({}, InterfaceManager.BaseOptions, options);
26
+ }
27
+
28
+ async createInterfaces() {
29
+ const apiSchemasPromise = getApiSchemas(this.StrapiSrcRoot);
30
+ const componentSchemasPromise = getComponentSchemas(this.StrapiSrcRoot);
31
+ const apiSchemas = await apiSchemasPromise;
32
+ apiSchemas.forEach((schema) => {
33
+ const { name, attributes } = schema;
34
+ const strapiName = `api::${name}.${name}`;
35
+ const inter = new Interface(name, attributes, "./", this.Options.prefix);
36
+ this.Interfaces[strapiName] = inter;
37
+ })
38
+
39
+ const componentSchemas = await componentSchemasPromise;
40
+ componentSchemas.forEach((category) => {
41
+ const categoryName : string = category.category;
42
+ category.schemas.forEach((schema) => {
43
+ const componentName = schema.name;
44
+ const strapiName = `${categoryName}.${schema.name}`
45
+ const componentPrefix = `${this.Options.componentPrefix}${this.Options.useCategoryPrefix ? pascalCase(categoryName) : ""}`;
46
+ const prefix = this.Options.componentPrefixOverridesPrefix ? componentPrefix : this.Options.prefix + componentPrefix;
47
+ // TODO: make component interface
48
+ const inter = new ComponentInterface(componentName, schema.attributes, `./${categoryName}`, categoryName, prefix);
49
+ this.Interfaces[strapiName] = inter;
50
+ })
51
+ })
52
+ }
53
+
54
+ // Inject dependencies into all interfaces
55
+ injectDependencies() {
56
+ // console.log("Injecting dependencies")
57
+ Object.keys(this.Interfaces).forEach((strapiName: string) => {
58
+ const inter = this.Interfaces[strapiName];
59
+ const dependencies = inter.getDependencies();
60
+ // console.log(`Interfaces for ${inter.getStrapiName()} are`)
61
+ const interfacesToInject = dependencies.map((dependencyStrapiName: string) => {
62
+ return this.Interfaces[dependencyStrapiName];
63
+ }).filter((inter) => inter);
64
+ inter.setRelations(interfacesToInject);
65
+ })
66
+ }
67
+
68
+ async makeFolders() {
69
+ const componentCategories = await getComponentCategoryFolders(this.StrapiSrcRoot);
70
+ if (!existsSync(this.OutRoot)) {
71
+ await mkdir(this.OutRoot, {
72
+ recursive: true,
73
+ });
74
+ }
75
+ await Promise.all(componentCategories.map(async (category) => {
76
+ const path = join(this.OutRoot, category);
77
+ if (existsSync(path)) {
78
+ return;
79
+ }
80
+ await mkdir(path);
81
+ }));
82
+ }
83
+
84
+ async writeInterfaces() {
85
+ const writePromises = Object.keys(this.Interfaces).map(async (strapiName) => {
86
+ const inter = this.Interfaces[strapiName];
87
+ const fileData = inter.toString();
88
+ const filePath = join(this.OutRoot, inter.getRelativeRootPathFile());
89
+ await writeFile(filePath, fileData)
90
+ });
91
+ await Promise.all(writePromises);
92
+ }
93
+
94
+ async writeIndexFile() {
95
+ const strings = Object.keys(this.Interfaces).map((strapiName: string) => {
96
+ const inter = this.Interfaces[strapiName];
97
+ return `export * from '${inter.getRelativeRootPath()}'`;
98
+ });
99
+ const fileData = strings.join("\n");
100
+ const filePath = join(this.OutRoot, "index.ts");
101
+ await writeFile(filePath, fileData);
102
+ }
103
+
104
+ async run() {
105
+ try {
106
+ const createInterfacesPromise = this.createInterfaces();
107
+ const makeFoldersPromise = this.makeFolders();
108
+ await createInterfacesPromise;
109
+ this.injectDependencies();
110
+ await makeFoldersPromise;
111
+ await Promise.all([this.writeInterfaces(), this.writeIndexFile()])
112
+ } catch (err) {
113
+ console.error(err);
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,7 @@
1
+ // import Interface from "./Interface";
2
+ //
3
+ // export default async function createInterface(schemaPath: string, baseName: string, prefix: string = "") {
4
+ // const schema = await readSchema(schemaPath);
5
+ // const inter = new Interface(baseName, schema, prefix);
6
+ // return inter;
7
+ // }
@@ -0,0 +1,5 @@
1
+ import type Interface from "./Interface"
2
+
3
+ export default async function writeInterface(inter: Interface) {
4
+
5
+ }
@@ -0,0 +1,51 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readDirFiltered } from '../utils';
4
+
5
+ export async function readSchema(schemaPath: string) {
6
+ try {
7
+ const schemaData = await readFile(schemaPath);
8
+ return JSON.parse(schemaData.toString()).attributes;
9
+ } catch (e) {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ export async function getApiFolders(strapiSrcRoot: string) {
15
+ const path = join(strapiSrcRoot, 'api');
16
+ return await readDirFiltered(path)
17
+ }
18
+
19
+ export async function getComponentCategoryFolders(strapiSrcRoot: string) {
20
+ const path = join(strapiSrcRoot, "components")
21
+ return await readDirFiltered(path)
22
+ }
23
+
24
+ export async function getComponentSchemas(strapiSrcRoot: string) {
25
+ const categories = await getComponentCategoryFolders(strapiSrcRoot);
26
+ const nestedSchemasPromises = categories.map(async (category: string) => {
27
+ const schemaFilesPath = join(strapiSrcRoot, "components", category);
28
+ const schemaFiles = await readDirFiltered(schemaFilesPath);
29
+ const schemaNamesWithAttributesPromises = schemaFiles.map(async (file: string) => {
30
+ const schemaPath = join(schemaFilesPath, file);
31
+ const attributes = await readSchema(schemaPath);
32
+ const name = file.split(".")[0];
33
+ return { name, attributes }
34
+ })
35
+ const schemaNamesWithAttributes = await Promise.all(schemaNamesWithAttributesPromises);
36
+ return { category, schemas: schemaNamesWithAttributes };
37
+ });
38
+ const nestedSchemasArr = await Promise.all(nestedSchemasPromises);
39
+ return nestedSchemasArr;
40
+ }
41
+
42
+ export async function getApiSchemas(strapiSrcRoot: string) {
43
+ const apiFolders = await getApiFolders(strapiSrcRoot);
44
+ const schemasWithAttributesPromises = apiFolders.map(async (folder: string) => {
45
+ const schemaPath = join(strapiSrcRoot, "api", folder, "content-types", folder, "schema.json")
46
+ const attributes = await readSchema(schemaPath);
47
+ return { name: folder, attributes };
48
+ })
49
+ const schemasWithAttributes = Promise.all(schemasWithAttributesPromises);
50
+ return schemasWithAttributes;
51
+ }
@@ -0,0 +1,19 @@
1
+ import { readdir } from "fs/promises";
2
+
3
+ export function pascalCase(name: string) {
4
+ const words = name.match(/[a-z]+/gi);
5
+ const pascalName = words.map((word: string) => {
6
+ return word.charAt(0).toUpperCase() + word.substring(1).toLowerCase();
7
+ }).join("");
8
+ // console.log(pascalName)
9
+ return pascalName;
10
+ }
11
+
12
+ export async function readDirFiltered(dir: string) {
13
+ const folders = await readdir(dir);
14
+ return folders.filter((folder: string) => !folder.startsWith("."));
15
+ }
16
+
17
+ export function prefixDotSlash(path) {
18
+ return (/^\.?\.\//).test(path) ? path : "./" + path;
19
+ }
package/tsconfig.jsonc ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "incremental": true, /* Enable incremental compilation */
4
+ "target": "es5", /* Specify ECMAScript target version: */
5
+ "module": "commonjs", /* 'none', 'commonjs', 'amd', 'system', etc */
6
+ "declaration": true, /* Concatenate & emit output to single file.*/
7
+ "outDir": "lib", /* Redirect output to the directory. */
8
+ "types": [ "node" ],
9
+ "resolveJsonModule": true,
10
+ "esModuleInterop": true, /* Enables intero between CommonJS and ES */
11
+ "skipLibCheck": true /* Skip type checking of declaration files. */
12
+ },
13
+ "include": [ "src/**/*" ],
14
+ "exclude": [ "types/**/*", "src/testtypes/**/*" ]
15
+ }