@cntrl-site/sdk 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.local +1 -0
- package/cntrl-site-sdk-1.1.0.tgz +0 -0
- package/cntrl.scss +66 -0
- package/lib/Client/Client.js +48 -30
- package/lib/cli.js +55 -0
- package/package.json +10 -2
- package/resources/template.scss.ejs +50 -0
- package/src/Client/Client.test.ts +66 -151
- package/src/Client/Client.ts +63 -35
- package/src/Client/__mock__/projectMock.ts +24 -10
- package/src/cli.ts +67 -0
- package/cntrl-site-sdk-1.0.0.tgz +0 -0
package/.env.local
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
CNTRL_API_URL=https://01GJ2SPNXG3V5P35ZA35YM1JTW:68e1bacad1b3adb6cb8be99ca7167c2b@preview.cntrl.site
|
|
Binary file
|
package/cntrl.scss
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// CAUTION: THIS FILE IS AUTO-GENERATED BASED ON
|
|
2
|
+
// LAYOUT CONFIGURATION IN YOUR CNTRL PROJECT
|
|
3
|
+
// WE HIGHLY ADVICE YOU TO NOT CHANGE IT MANUALLY
|
|
4
|
+
@use "sass:map";
|
|
5
|
+
|
|
6
|
+
$__CNTRL_LAYOUT_WIDTH__: 375;
|
|
7
|
+
|
|
8
|
+
$layout: (
|
|
9
|
+
|
|
10
|
+
mobile: (
|
|
11
|
+
start: 0,
|
|
12
|
+
end: 749,
|
|
13
|
+
exemplary: 375,
|
|
14
|
+
isFirst: true,
|
|
15
|
+
isLast: false
|
|
16
|
+
),
|
|
17
|
+
|
|
18
|
+
tablet: (
|
|
19
|
+
start: 750,
|
|
20
|
+
end: 1023,
|
|
21
|
+
exemplary: 768,
|
|
22
|
+
isFirst: false,
|
|
23
|
+
isLast: false
|
|
24
|
+
),
|
|
25
|
+
|
|
26
|
+
desktop: (
|
|
27
|
+
start: 1024,
|
|
28
|
+
end: 9007199254740991,
|
|
29
|
+
exemplary: 1440,
|
|
30
|
+
isFirst: false,
|
|
31
|
+
isLast: true
|
|
32
|
+
),
|
|
33
|
+
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
@function size($value) {
|
|
37
|
+
@return #{$value/$__CNTRL_LAYOUT_WIDTH__*100}vw;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@mixin for($name) {
|
|
41
|
+
$start: map.get(map.get($layout, $name), "start");
|
|
42
|
+
$end: map.get(map.get($layout, $name), "end");
|
|
43
|
+
$isFirst: map.get(map.get($layout, $name), "isFirst");
|
|
44
|
+
$isLast: map.get(map.get($layout, $name), "isLast");
|
|
45
|
+
$exemplary: map.get(map.get($layout, $name), "exemplary");
|
|
46
|
+
$__CNTRL_LAYOUT_WIDTH__: $exemplary !global;
|
|
47
|
+
|
|
48
|
+
@if $isFirst == true and $isLast == true {
|
|
49
|
+
@content;
|
|
50
|
+
} @else if $isFirst == true {
|
|
51
|
+
@media (max-width: #{$end}px) {
|
|
52
|
+
@content;
|
|
53
|
+
}
|
|
54
|
+
} @else if $isLast == true {
|
|
55
|
+
@media (min-width: #{$start}px) {
|
|
56
|
+
@content;
|
|
57
|
+
}
|
|
58
|
+
} @else {
|
|
59
|
+
@media (min-width: #{$start}px) and (max-width: #{$end}px) {
|
|
60
|
+
@content;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// reset global variable back to first layout's exemplary (mobile-first)
|
|
65
|
+
$__CNTRL_LAYOUT_WIDTH__: 375 !global;
|
|
66
|
+
}
|
package/lib/Client/Client.js
CHANGED
|
@@ -18,28 +18,50 @@ class Client {
|
|
|
18
18
|
throw new Error('API key is missing in the URL.');
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
static getPageMeta(projectMeta, pageMeta) {
|
|
22
|
+
return pageMeta.enabled ? {
|
|
23
|
+
title: pageMeta.title ? pageMeta.title : projectMeta.title,
|
|
24
|
+
description: pageMeta.description ? pageMeta.description : projectMeta.description,
|
|
25
|
+
keywords: pageMeta.keywords ? pageMeta.keywords : projectMeta.keywords,
|
|
26
|
+
opengraphThumbnail: pageMeta.opengraphThumbnail ? pageMeta.opengraphThumbnail : projectMeta.opengraphThumbnail,
|
|
27
|
+
favicon: projectMeta.favicon
|
|
28
|
+
} : projectMeta;
|
|
29
|
+
}
|
|
30
|
+
async getPageData(pageSlug) {
|
|
22
31
|
try {
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
32
|
+
const project = await this.fetchProject();
|
|
33
|
+
const articleId = this.findArticleIdByPageSlug(pageSlug, project.pages);
|
|
34
|
+
const [{ article, keyframes }, typePresets] = await Promise.all([
|
|
35
|
+
this.fetchArticle(articleId),
|
|
36
|
+
this.fetchTypePresets()
|
|
37
|
+
]);
|
|
38
|
+
const page = project.pages.find(page => page.slug === pageSlug);
|
|
39
|
+
const meta = Client.getPageMeta(project.meta, page?.meta);
|
|
40
|
+
return {
|
|
41
|
+
project,
|
|
42
|
+
typePresets,
|
|
43
|
+
article,
|
|
44
|
+
keyframes,
|
|
45
|
+
meta
|
|
46
|
+
};
|
|
27
47
|
}
|
|
28
48
|
catch (e) {
|
|
29
49
|
throw e;
|
|
30
50
|
}
|
|
31
51
|
}
|
|
32
|
-
async
|
|
52
|
+
async getProjectPagesPaths() {
|
|
33
53
|
try {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
const { pages } = await this.fetchProject();
|
|
55
|
+
return pages.map(p => p.slug);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async getLayouts() {
|
|
62
|
+
try {
|
|
63
|
+
const { layouts } = await this.fetchProject();
|
|
64
|
+
return layouts;
|
|
43
65
|
}
|
|
44
66
|
catch (e) {
|
|
45
67
|
throw e;
|
|
@@ -47,18 +69,7 @@ class Client {
|
|
|
47
69
|
}
|
|
48
70
|
async getTypePresets() {
|
|
49
71
|
const response = await this.fetchTypePresets();
|
|
50
|
-
|
|
51
|
-
const typePresets = core_1.TypePresetsSchema.parse(data);
|
|
52
|
-
return typePresets;
|
|
53
|
-
}
|
|
54
|
-
static getPageMeta(projectMeta, pageMeta) {
|
|
55
|
-
return pageMeta.enabled ? {
|
|
56
|
-
title: pageMeta.title ? pageMeta.title : projectMeta.title,
|
|
57
|
-
description: pageMeta.description ? pageMeta.description : projectMeta.description,
|
|
58
|
-
keywords: pageMeta.keywords ? pageMeta.keywords : projectMeta.keywords,
|
|
59
|
-
opengraphThumbnail: pageMeta.opengraphThumbnail ? pageMeta.opengraphThumbnail : projectMeta.opengraphThumbnail,
|
|
60
|
-
favicon: projectMeta.favicon
|
|
61
|
-
} : projectMeta;
|
|
72
|
+
return response;
|
|
62
73
|
}
|
|
63
74
|
async fetchProject() {
|
|
64
75
|
const { username: projectId, password: apiKey, origin } = this.url;
|
|
@@ -71,7 +82,9 @@ class Client {
|
|
|
71
82
|
if (!response.ok) {
|
|
72
83
|
throw new Error(`Failed to fetch project with id #${projectId}: ${response.statusText}`);
|
|
73
84
|
}
|
|
74
|
-
|
|
85
|
+
const data = await response.json();
|
|
86
|
+
const project = core_1.ProjectSchema.parse(data);
|
|
87
|
+
return project;
|
|
75
88
|
}
|
|
76
89
|
async fetchArticle(articleId) {
|
|
77
90
|
const { username: projectId, password: apiKey, origin } = this.url;
|
|
@@ -84,7 +97,10 @@ class Client {
|
|
|
84
97
|
if (!response.ok) {
|
|
85
98
|
throw new Error(`Failed to fetch article with id #${articleId}: ${response.statusText}`);
|
|
86
99
|
}
|
|
87
|
-
|
|
100
|
+
const data = await response.json();
|
|
101
|
+
const article = core_1.ArticleSchema.parse(data.article);
|
|
102
|
+
const keyframes = core_1.KeyframesSchema.parse(data.keyframes);
|
|
103
|
+
return { article, keyframes };
|
|
88
104
|
}
|
|
89
105
|
async fetchTypePresets() {
|
|
90
106
|
const { username: projectId, password: apiKey, origin } = this.url;
|
|
@@ -97,7 +113,9 @@ class Client {
|
|
|
97
113
|
if (!response.ok) {
|
|
98
114
|
throw new Error(`Failed to fetch type presets for the project with id #${projectId}: ${response.statusText}`);
|
|
99
115
|
}
|
|
100
|
-
|
|
116
|
+
const data = await response.json();
|
|
117
|
+
const typePresets = core_1.TypePresetsSchema.parse(data);
|
|
118
|
+
return typePresets;
|
|
101
119
|
}
|
|
102
120
|
findArticleIdByPageSlug(slug, pages) {
|
|
103
121
|
const { username: projectId } = this.url;
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const ejs_1 = __importDefault(require("ejs"));
|
|
10
|
+
const dotenv_1 = require("dotenv");
|
|
11
|
+
const commander_1 = require("commander");
|
|
12
|
+
const Client_1 = require("./Client/Client");
|
|
13
|
+
commander_1.program
|
|
14
|
+
.command('generate-layouts')
|
|
15
|
+
.option('-o, --output <outputFilePath>', 'Output file path', 'cntrl.scss')
|
|
16
|
+
.option('-e, --env <envFilename>', 'Name of the .env file', '.env.local')
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
try {
|
|
19
|
+
(0, dotenv_1.config)({ path: options.env });
|
|
20
|
+
const templateFilePath = path_1.default.resolve(__dirname, '../resources/template.scss.ejs');
|
|
21
|
+
const scssTemplate = fs_1.default.readFileSync(templateFilePath, 'utf-8');
|
|
22
|
+
const apiUrl = process.env.CNTRL_API_URL;
|
|
23
|
+
if (!apiUrl) {
|
|
24
|
+
throw new Error('Environment variable "CNTRL_API_URL" must be set.');
|
|
25
|
+
}
|
|
26
|
+
const client = new Client_1.Client(apiUrl);
|
|
27
|
+
const layouts = await client.getLayouts();
|
|
28
|
+
const ranges = convertLayouts(layouts);
|
|
29
|
+
const compiledTemplate = ejs_1.default.compile(scssTemplate);
|
|
30
|
+
const renderedTemplate = compiledTemplate({ ranges });
|
|
31
|
+
const outputFilePath = path_1.default.resolve(process.cwd(), options.output);
|
|
32
|
+
fs_1.default.writeFileSync(outputFilePath, renderedTemplate);
|
|
33
|
+
console.log(`Generated .scss file at ${outputFilePath}`);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
console.error('An error occurred:', error);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
function convertLayouts(layouts, maxLayoutWidth = Number.MAX_SAFE_INTEGER) {
|
|
41
|
+
const sorted = layouts.slice().sort((la, lb) => la.startsWith - lb.startsWith);
|
|
42
|
+
const mapped = sorted.map((layout, i, ls) => {
|
|
43
|
+
const next = ls[i + 1];
|
|
44
|
+
return {
|
|
45
|
+
start: layout.startsWith,
|
|
46
|
+
end: next ? next.startsWith - 1 : maxLayoutWidth,
|
|
47
|
+
exemplary: layout.exemplary,
|
|
48
|
+
name: layout.title,
|
|
49
|
+
isFirst: i === 0,
|
|
50
|
+
isLast: !next
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
return mapped;
|
|
54
|
+
}
|
|
55
|
+
commander_1.program.parse(process.argv);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cntrl-site/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Generic SDK for use in public websites.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"build": "tsc --project tsconfig.build.json",
|
|
11
11
|
"prepublishOnly": "NODE_ENV=production npm run build"
|
|
12
12
|
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"cntrl-sdk": "lib/cli.js"
|
|
15
|
+
},
|
|
13
16
|
"repository": {
|
|
14
17
|
"type": "git",
|
|
15
18
|
"url": "git+https://github.com/cntrl-site/sdk.git"
|
|
@@ -24,9 +27,14 @@
|
|
|
24
27
|
"lib": "lib"
|
|
25
28
|
},
|
|
26
29
|
"dependencies": {
|
|
27
|
-
"@cntrl-site/core": "^1.22.
|
|
30
|
+
"@cntrl-site/core": "^1.22.3",
|
|
31
|
+
"@types/ejs": "^3.1.2",
|
|
28
32
|
"@types/isomorphic-fetch": "^0.0.36",
|
|
33
|
+
"commander": "^10.0.1",
|
|
34
|
+
"dotenv": "^16.1.3",
|
|
35
|
+
"ejs": "^3.1.9",
|
|
29
36
|
"isomorphic-fetch": "^3.0.0",
|
|
37
|
+
"ts-node": "^10.9.1",
|
|
30
38
|
"url": "^0.11.0"
|
|
31
39
|
},
|
|
32
40
|
"devDependencies": {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// CAUTION: THIS FILE IS AUTO-GENERATED BASED ON
|
|
2
|
+
// LAYOUT CONFIGURATION IN YOUR CNTRL PROJECT
|
|
3
|
+
// WE HIGHLY ADVICE YOU TO NOT CHANGE IT MANUALLY
|
|
4
|
+
@use "sass:map";
|
|
5
|
+
|
|
6
|
+
$__CNTRL_LAYOUT_WIDTH__: <%= ranges[0].exemplary %>;
|
|
7
|
+
|
|
8
|
+
$layout: (
|
|
9
|
+
<% ranges.forEach(function(range) { %>
|
|
10
|
+
<%= range.name %>: (
|
|
11
|
+
start: <%= range.start %>,
|
|
12
|
+
end: <%= range.end %>,
|
|
13
|
+
exemplary: <%= range.exemplary %>,
|
|
14
|
+
isFirst: <%= range.isFirst %>,
|
|
15
|
+
isLast: <%= range.isLast %>
|
|
16
|
+
),
|
|
17
|
+
<% }); %>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
@function size($value) {
|
|
21
|
+
@return #{$value/$__CNTRL_LAYOUT_WIDTH__*100}vw;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@mixin for($name) {
|
|
25
|
+
$start: map.get(map.get($layout, $name), "start");
|
|
26
|
+
$end: map.get(map.get($layout, $name), "end");
|
|
27
|
+
$isFirst: map.get(map.get($layout, $name), "isFirst");
|
|
28
|
+
$isLast: map.get(map.get($layout, $name), "isLast");
|
|
29
|
+
$exemplary: map.get(map.get($layout, $name), "exemplary");
|
|
30
|
+
$__CNTRL_LAYOUT_WIDTH__: $exemplary !global;
|
|
31
|
+
|
|
32
|
+
@if $isFirst == true and $isLast == true {
|
|
33
|
+
@content;
|
|
34
|
+
} @else if $isFirst == true {
|
|
35
|
+
@media (max-width: #{$end}px) {
|
|
36
|
+
@content;
|
|
37
|
+
}
|
|
38
|
+
} @else if $isLast == true {
|
|
39
|
+
@media (min-width: #{$start}px) {
|
|
40
|
+
@content;
|
|
41
|
+
}
|
|
42
|
+
} @else {
|
|
43
|
+
@media (min-width: #{$start}px) and (max-width: #{$end}px) {
|
|
44
|
+
@content;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// reset global variable back to first layout's exemplary (mobile-first)
|
|
49
|
+
$__CNTRL_LAYOUT_WIDTH__: <%= ranges[0].exemplary %> !global;
|
|
50
|
+
}
|
|
@@ -2,72 +2,97 @@ import { Client } from './Client';
|
|
|
2
2
|
import { projectMock } from './__mock__/projectMock';
|
|
3
3
|
import { articleMock } from './__mock__/articleMock';
|
|
4
4
|
import { typePresetsMock } from './__mock__/typePresetsMock';
|
|
5
|
-
import { TMeta, TPageMeta } from '@cntrl-site/core';
|
|
6
5
|
import { keyframesMock } from './__mock__/keyframesMock';
|
|
7
6
|
|
|
8
7
|
describe('Client', () => {
|
|
9
|
-
it('
|
|
10
|
-
const projectId = '
|
|
8
|
+
it('throws an error when no project ID passed to the connect URL', async () => {
|
|
9
|
+
const projectId = '';
|
|
11
10
|
const apiKey = 'MY_API_KEY';
|
|
12
|
-
let fetchCalledTimes = 0;
|
|
13
11
|
const apiUrl = `https://${projectId}:${apiKey}@api.cntrl.site/`;
|
|
12
|
+
expect(() => new Client(apiUrl)).toThrow(new Error('Project ID is missing in the URL.'));
|
|
13
|
+
expect(() => new Client('https://api.cntrl.site'))
|
|
14
|
+
.toThrow(new Error('Project ID is missing in the URL.'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('throws an error when no API key passed to the connect URL', async () => {
|
|
18
|
+
const projectId = 'whatever';
|
|
19
|
+
const apiKey = '';
|
|
20
|
+
const apiUrl = `https://${projectId}:${apiKey}@api.cntrl.site/`;
|
|
21
|
+
expect(() => new Client(apiUrl)).toThrow(new Error('API key is missing in the URL.'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns page data', async () => {
|
|
25
|
+
const projectId = 'projectId';
|
|
26
|
+
const API_BASE_URL = 'api-test.cntrl.site';
|
|
27
|
+
const fetchesMap = {
|
|
28
|
+
[`https://${API_BASE_URL}/projects/${projectId}`]: projectMock,
|
|
29
|
+
[`https://${API_BASE_URL}/projects/${projectId}/type-presets`]: typePresetsMock,
|
|
30
|
+
[`https://${API_BASE_URL}/projects/${projectId}/articles/articleId`]: {
|
|
31
|
+
article: articleMock,
|
|
32
|
+
keyframes: keyframesMock
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const apiKey = 'MY_API_KEY';
|
|
36
|
+
let fetchCalledTimes = 0;
|
|
37
|
+
const apiUrl = `https://${projectId}:${apiKey}@${API_BASE_URL}/`;
|
|
14
38
|
const fetch = async (url: string) => {
|
|
15
39
|
fetchCalledTimes += 1;
|
|
16
|
-
expect(url).toBe(`https://api.cntrl.site/projects/${projectId}`);
|
|
17
40
|
return Promise.resolve({
|
|
18
41
|
ok: true,
|
|
19
|
-
json: () => Promise.resolve(
|
|
42
|
+
json: () => Promise.resolve(fetchesMap[url]),
|
|
20
43
|
statusText: ''
|
|
21
44
|
});
|
|
22
45
|
};
|
|
23
46
|
const client = new Client(apiUrl, fetch);
|
|
24
|
-
const
|
|
25
|
-
expect(fetchCalledTimes).toBe(
|
|
26
|
-
expect(project).toEqual(projectMock);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
json: () => Promise.resolve()
|
|
47
|
+
const pageData = await client.getPageData('/');
|
|
48
|
+
expect(fetchCalledTimes).toBe(3);
|
|
49
|
+
expect(pageData.project).toEqual(projectMock);
|
|
50
|
+
expect(pageData.article).toEqual(articleMock);
|
|
51
|
+
expect(pageData.typePresets).toEqual(typePresetsMock);
|
|
52
|
+
expect(pageData.keyframes).toEqual(keyframesMock);
|
|
53
|
+
expect(pageData.meta).toEqual({
|
|
54
|
+
description: 'page description',
|
|
55
|
+
favicon: 'project favicon',
|
|
56
|
+
keywords: 'page keywords',
|
|
57
|
+
opengraphThumbnail: 'page thumbnail',
|
|
58
|
+
title: 'page title'
|
|
37
59
|
});
|
|
38
|
-
const client = new Client(apiUrl, fetch);
|
|
39
|
-
await expect(client.getProject()).rejects.toEqual(new Error('Failed to fetch project with id #MY_PROJECT_ID: reason'));
|
|
40
60
|
});
|
|
41
61
|
|
|
42
|
-
it('
|
|
43
|
-
|
|
44
|
-
const
|
|
62
|
+
it('ignores page meta if it is not enabled and uses project meta instead', async () => {
|
|
63
|
+
const projectId = 'projectId';
|
|
64
|
+
const API_BASE_URL = 'api-test.cntrl.site';
|
|
65
|
+
const fetchesMap = {
|
|
66
|
+
[`https://${API_BASE_URL}/projects/${projectId}`]: projectMock,
|
|
67
|
+
[`https://${API_BASE_URL}/projects/${projectId}/type-presets`]: typePresetsMock,
|
|
68
|
+
[`https://${API_BASE_URL}/projects/${projectId}/articles/articleId2`]: {
|
|
69
|
+
article: articleMock,
|
|
70
|
+
keyframes: keyframesMock
|
|
71
|
+
}
|
|
72
|
+
};
|
|
45
73
|
const apiKey = 'MY_API_KEY';
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const articleApiUrl = `https://api.cntrl.site/projects/${projectId}/articles/articleId`;
|
|
74
|
+
let fetchCalledTimes = 0;
|
|
75
|
+
const apiUrl = `https://${projectId}:${apiKey}@${API_BASE_URL}/`;
|
|
49
76
|
const fetch = async (url: string) => {
|
|
50
77
|
fetchCalledTimes += 1;
|
|
51
|
-
if (fetchCalledTimes === 1) {
|
|
52
|
-
expect(url).toBe(projectApiUrl);
|
|
53
|
-
}
|
|
54
|
-
if (fetchCalledTimes === 2) {
|
|
55
|
-
expect(url).toBe(articleApiUrl);
|
|
56
|
-
}
|
|
57
78
|
return Promise.resolve({
|
|
58
79
|
ok: true,
|
|
59
|
-
json: () => Promise.resolve(url
|
|
80
|
+
json: () => Promise.resolve(fetchesMap[url]),
|
|
60
81
|
statusText: ''
|
|
61
82
|
});
|
|
62
83
|
};
|
|
63
84
|
const client = new Client(apiUrl, fetch);
|
|
64
|
-
const
|
|
65
|
-
expect(
|
|
66
|
-
|
|
67
|
-
|
|
85
|
+
const pageData = await client.getPageData('/2');
|
|
86
|
+
expect(pageData.meta).toEqual({
|
|
87
|
+
description: 'project description',
|
|
88
|
+
favicon: 'project favicon',
|
|
89
|
+
keywords: 'project keywords',
|
|
90
|
+
opengraphThumbnail: 'project opengraph',
|
|
91
|
+
title: 'project title'
|
|
92
|
+
});
|
|
68
93
|
});
|
|
69
94
|
|
|
70
|
-
it('throws an error upon
|
|
95
|
+
it('throws an error upon page data fetch failure', async () => {
|
|
71
96
|
const projectId = 'MY_PROJECT_ID';
|
|
72
97
|
const apiKey = 'MY_API_KEY';
|
|
73
98
|
const apiUrl = `https://${projectId}:${apiKey}@api.cntrl.site/`;
|
|
@@ -77,25 +102,7 @@ describe('Client', () => {
|
|
|
77
102
|
json: () => Promise.resolve()
|
|
78
103
|
});
|
|
79
104
|
const client = new Client(apiUrl, fetch);
|
|
80
|
-
await expect(client.
|
|
81
|
-
.rejects.toEqual(new Error(`Failed to fetch project with id #${projectId}: reason`));
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('throws an error upon article fetch failure when trying to get article by slug', async () => {
|
|
85
|
-
const projectId = 'MY_PROJECT_ID';
|
|
86
|
-
const apiKey = 'MY_API_KEY';
|
|
87
|
-
const apiUrl = `https://${projectId}:${apiKey}@api.cntrl.site/`;
|
|
88
|
-
const projectApiUrl = `https://api.cntrl.site/projects/${projectId}`;
|
|
89
|
-
const fetch = (url: string) => {
|
|
90
|
-
return Promise.resolve({
|
|
91
|
-
ok: url === projectApiUrl,
|
|
92
|
-
json: () => Promise.resolve(projectMock),
|
|
93
|
-
statusText: 'reason'
|
|
94
|
-
});
|
|
95
|
-
};
|
|
96
|
-
const client = new Client(apiUrl, fetch);
|
|
97
|
-
await expect(client.getPageArticle('/'))
|
|
98
|
-
.rejects.toEqual(new Error('Failed to fetch article with id #articleId: reason'));
|
|
105
|
+
await expect(client.getPageData('/')).rejects.toEqual(new Error('Failed to fetch project with id #MY_PROJECT_ID: reason'));
|
|
99
106
|
});
|
|
100
107
|
|
|
101
108
|
it('throws an error when trying to fetch article by nonexistent slug', async () => {
|
|
@@ -112,99 +119,7 @@ describe('Client', () => {
|
|
|
112
119
|
});
|
|
113
120
|
};
|
|
114
121
|
const client = new Client(apiUrl, fetch);
|
|
115
|
-
await expect(client.
|
|
122
|
+
await expect(client.getPageData(slug))
|
|
116
123
|
.rejects.toEqual(new Error(`Page with a slug ${slug} was not found in project with id #${projectId}`));
|
|
117
124
|
});
|
|
118
|
-
|
|
119
|
-
it('returns type presets by project id', async () => {
|
|
120
|
-
let fetchCalledTimes = 0;
|
|
121
|
-
const projectId = 'MY_PROJECT_ID';
|
|
122
|
-
const apiKey = 'MY_API_KEY';
|
|
123
|
-
const apiUrl = `https://${projectId}:${apiKey}@api.cntrl.site/`;
|
|
124
|
-
const fetch = (url: string) => {
|
|
125
|
-
fetchCalledTimes += 1;
|
|
126
|
-
expect(url).toBe(`https://api.cntrl.site/projects/${projectId}/type-presets`);
|
|
127
|
-
return Promise.resolve({
|
|
128
|
-
ok: true,
|
|
129
|
-
json: () => Promise.resolve(typePresetsMock),
|
|
130
|
-
statusText: ''
|
|
131
|
-
});
|
|
132
|
-
};
|
|
133
|
-
const client = new Client(apiUrl, fetch);
|
|
134
|
-
const presets = await client.getTypePresets();
|
|
135
|
-
expect(presets).toEqual(typePresetsMock);
|
|
136
|
-
expect(fetchCalledTimes).toEqual(1);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('throws an error upon type presets fetch failure', async () => {
|
|
140
|
-
const projectId = 'MY_PROJECT_ID';
|
|
141
|
-
const apiKey = 'MY_API_KEY';
|
|
142
|
-
const apiUrl = `https://${projectId}:${apiKey}@api.cntrl.site/`;
|
|
143
|
-
const fetch = () => {
|
|
144
|
-
return Promise.resolve({
|
|
145
|
-
ok: false,
|
|
146
|
-
json: () => Promise.resolve(),
|
|
147
|
-
statusText: 'reason'
|
|
148
|
-
});
|
|
149
|
-
};
|
|
150
|
-
const client = new Client(apiUrl, fetch);
|
|
151
|
-
await expect(client.getTypePresets()).rejects.toEqual(
|
|
152
|
-
new Error(`Failed to fetch type presets for the project with id #${projectId}: reason`)
|
|
153
|
-
);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('merges two meta objects into one with priority of page-based over project-based', () => {
|
|
157
|
-
const pageMeta: TPageMeta = {
|
|
158
|
-
enabled: true,
|
|
159
|
-
description: 'page-desc',
|
|
160
|
-
title: 'page-title'
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
const projectMeta: TMeta = {
|
|
164
|
-
opengraphThumbnail: 'proj-og',
|
|
165
|
-
description: 'proj-desc',
|
|
166
|
-
title: 'proj-title',
|
|
167
|
-
keywords: 'project, keywords'
|
|
168
|
-
};
|
|
169
|
-
const meta = Client.getPageMeta(projectMeta, pageMeta);
|
|
170
|
-
expect(meta.keywords).toBe(projectMeta.keywords);
|
|
171
|
-
expect(meta.opengraphThumbnail).toBe(projectMeta.opengraphThumbnail);
|
|
172
|
-
expect(meta.description).toBe(pageMeta.description);
|
|
173
|
-
expect(meta.title).toBe(pageMeta.title);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('ignores page meta when `enabled` is set to `false` and uses only generic project meta', () => {
|
|
177
|
-
const pageMeta: TPageMeta = {
|
|
178
|
-
enabled: false,
|
|
179
|
-
description: 'page-desc',
|
|
180
|
-
title: 'page-title'
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
const projectMeta: TMeta = {
|
|
184
|
-
opengraphThumbnail: 'proj-og',
|
|
185
|
-
keywords: 'project, keywords'
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
const meta = Client.getPageMeta(projectMeta, pageMeta);
|
|
189
|
-
expect(meta.keywords).toBe(projectMeta.keywords);
|
|
190
|
-
expect(meta.opengraphThumbnail).toBe(projectMeta.opengraphThumbnail);
|
|
191
|
-
expect(meta.description).toBeUndefined();
|
|
192
|
-
expect(meta.title).toBeUndefined();
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('throws an error when no project ID passed to the connect URL', async () => {
|
|
196
|
-
const projectId = '';
|
|
197
|
-
const apiKey = 'MY_API_KEY';
|
|
198
|
-
const apiUrl = `https://${projectId}:${apiKey}@api.cntrl.site/`;
|
|
199
|
-
expect(() => new Client(apiUrl)).toThrow(new Error('Project ID is missing in the URL.'));
|
|
200
|
-
expect(() => new Client('https://api.cntrl.site'))
|
|
201
|
-
.toThrow(new Error('Project ID is missing in the URL.'));
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('throws an error when no API key passed to the connect URL', async () => {
|
|
205
|
-
const projectId = 'whatever';
|
|
206
|
-
const apiKey = '';
|
|
207
|
-
const apiUrl = `https://${projectId}:${apiKey}@api.cntrl.site/`;
|
|
208
|
-
expect(() => new Client(apiUrl)).toThrow(new Error('API key is missing in the URL.'));
|
|
209
|
-
});
|
|
210
125
|
});
|
package/src/Client/Client.ts
CHANGED
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
TypePresetsSchema,
|
|
10
10
|
TPage,
|
|
11
11
|
TKeyframeAny,
|
|
12
|
-
KeyframesSchema
|
|
12
|
+
KeyframesSchema,
|
|
13
|
+
TLayout
|
|
13
14
|
} from '@cntrl-site/core';
|
|
14
15
|
import fetch from 'isomorphic-fetch';
|
|
15
16
|
import { URL } from 'url';
|
|
@@ -29,51 +30,62 @@ export class Client {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
private static getPageMeta(projectMeta: TMeta, pageMeta: TPageMeta): TMeta {
|
|
34
|
+
return pageMeta.enabled ? {
|
|
35
|
+
title: pageMeta.title ? pageMeta.title : projectMeta.title,
|
|
36
|
+
description: pageMeta.description ? pageMeta.description : projectMeta.description,
|
|
37
|
+
keywords: pageMeta.keywords ? pageMeta.keywords : projectMeta.keywords,
|
|
38
|
+
opengraphThumbnail: pageMeta.opengraphThumbnail ? pageMeta.opengraphThumbnail : projectMeta.opengraphThumbnail,
|
|
39
|
+
favicon: projectMeta.favicon
|
|
40
|
+
} : projectMeta;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getPageData(pageSlug: string): Promise<CntrlPageData> {
|
|
33
44
|
try {
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
45
|
+
const project = await this.fetchProject();
|
|
46
|
+
const articleId = this.findArticleIdByPageSlug(pageSlug, project.pages);
|
|
47
|
+
const [{ article, keyframes }, typePresets] = await Promise.all([
|
|
48
|
+
this.fetchArticle(articleId),
|
|
49
|
+
this.fetchTypePresets()
|
|
50
|
+
]);
|
|
51
|
+
const page = project.pages.find(page => page.slug === pageSlug)!;
|
|
52
|
+
const meta = Client.getPageMeta(project.meta, page?.meta!);
|
|
53
|
+
return {
|
|
54
|
+
project,
|
|
55
|
+
typePresets,
|
|
56
|
+
article,
|
|
57
|
+
keyframes,
|
|
58
|
+
meta
|
|
59
|
+
};
|
|
38
60
|
} catch (e) {
|
|
39
61
|
throw e;
|
|
40
62
|
}
|
|
41
63
|
}
|
|
42
64
|
|
|
43
|
-
async
|
|
65
|
+
async getProjectPagesPaths(): Promise<string[]> {
|
|
44
66
|
try {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
const project = ProjectSchema.parse(data);
|
|
48
|
-
const articleId = this.findArticleIdByPageSlug(pageSlug, project.pages);
|
|
49
|
-
const articleResponse = await this.fetchArticle(articleId);
|
|
50
|
-
const articleData = await articleResponse.json();
|
|
51
|
-
const article = ArticleSchema.parse(articleData.article);
|
|
52
|
-
const keyframes = KeyframesSchema.parse(articleData.keyframes);
|
|
53
|
-
return { article, keyframes };
|
|
67
|
+
const { pages } = await this.fetchProject();
|
|
68
|
+
return pages.map(p => p.slug);
|
|
54
69
|
} catch (e) {
|
|
55
70
|
throw e;
|
|
56
71
|
}
|
|
57
72
|
}
|
|
58
73
|
|
|
59
|
-
async
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
async getLayouts(): Promise<TLayout[]> {
|
|
75
|
+
try {
|
|
76
|
+
const { layouts } = await this.fetchProject();
|
|
77
|
+
return layouts;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
64
81
|
}
|
|
65
82
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
description: pageMeta.description ? pageMeta.description : projectMeta.description,
|
|
70
|
-
keywords: pageMeta.keywords ? pageMeta.keywords : projectMeta.keywords,
|
|
71
|
-
opengraphThumbnail: pageMeta.opengraphThumbnail ? pageMeta.opengraphThumbnail : projectMeta.opengraphThumbnail,
|
|
72
|
-
favicon: projectMeta.favicon
|
|
73
|
-
} : projectMeta;
|
|
83
|
+
async getTypePresets(): Promise<TTypePresets> {
|
|
84
|
+
const response = await this.fetchTypePresets();
|
|
85
|
+
return response;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
|
-
private async fetchProject(): Promise<
|
|
88
|
+
private async fetchProject(): Promise<TProject> {
|
|
77
89
|
const { username: projectId, password: apiKey, origin } = this.url;
|
|
78
90
|
const url = new URL(`/projects/${projectId}`, origin);
|
|
79
91
|
const response = await this.fetchImpl(url.href, {
|
|
@@ -84,10 +96,12 @@ export class Client {
|
|
|
84
96
|
if (!response.ok) {
|
|
85
97
|
throw new Error(`Failed to fetch project with id #${projectId}: ${response.statusText}`);
|
|
86
98
|
}
|
|
87
|
-
|
|
99
|
+
const data = await response.json();
|
|
100
|
+
const project = ProjectSchema.parse(data);
|
|
101
|
+
return project;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
|
-
private async fetchArticle(articleId: string): Promise<
|
|
104
|
+
private async fetchArticle(articleId: string): Promise<ArticleData> {
|
|
91
105
|
const { username: projectId, password: apiKey, origin } = this.url;
|
|
92
106
|
const url = new URL(`/projects/${projectId}/articles/${articleId}`, origin);
|
|
93
107
|
const response = await this.fetchImpl(url.href, {
|
|
@@ -98,10 +112,13 @@ export class Client {
|
|
|
98
112
|
if (!response.ok) {
|
|
99
113
|
throw new Error(`Failed to fetch article with id #${articleId}: ${response.statusText}`);
|
|
100
114
|
}
|
|
101
|
-
|
|
115
|
+
const data = await response.json();
|
|
116
|
+
const article = ArticleSchema.parse(data.article);
|
|
117
|
+
const keyframes = KeyframesSchema.parse(data.keyframes);
|
|
118
|
+
return { article, keyframes };
|
|
102
119
|
}
|
|
103
120
|
|
|
104
|
-
private async fetchTypePresets(): Promise<
|
|
121
|
+
private async fetchTypePresets(): Promise<TTypePresets> {
|
|
105
122
|
const { username: projectId, password: apiKey, origin } = this.url;
|
|
106
123
|
const url = new URL(`/projects/${projectId}/type-presets`, origin);
|
|
107
124
|
const response = await this.fetchImpl(url.href, {
|
|
@@ -114,7 +131,9 @@ export class Client {
|
|
|
114
131
|
`Failed to fetch type presets for the project with id #${projectId}: ${response.statusText}`
|
|
115
132
|
);
|
|
116
133
|
}
|
|
117
|
-
|
|
134
|
+
const data = await response.json();
|
|
135
|
+
const typePresets = TypePresetsSchema.parse(data);
|
|
136
|
+
return typePresets;
|
|
118
137
|
}
|
|
119
138
|
|
|
120
139
|
private findArticleIdByPageSlug(slug: string, pages: TPage[]): string {
|
|
@@ -134,3 +153,12 @@ interface FetchImplResponse {
|
|
|
134
153
|
}
|
|
135
154
|
|
|
136
155
|
type FetchImpl = (url: string, init?: RequestInit) => Promise<FetchImplResponse>;
|
|
156
|
+
interface ArticleData {
|
|
157
|
+
article: TArticle;
|
|
158
|
+
keyframes: TKeyframeAny[];
|
|
159
|
+
}
|
|
160
|
+
interface CntrlPageData extends ArticleData {
|
|
161
|
+
project: TProject;
|
|
162
|
+
typePresets: TTypePresets;
|
|
163
|
+
meta: TMeta;
|
|
164
|
+
}
|
|
@@ -14,11 +14,11 @@ export const projectMock: TProject = {
|
|
|
14
14
|
head: ''
|
|
15
15
|
},
|
|
16
16
|
meta: {
|
|
17
|
-
favicon:
|
|
18
|
-
title:
|
|
19
|
-
opengraphThumbnail:
|
|
20
|
-
keywords:
|
|
21
|
-
description:
|
|
17
|
+
favicon: 'project favicon',
|
|
18
|
+
title: 'project title',
|
|
19
|
+
opengraphThumbnail: 'project opengraph',
|
|
20
|
+
keywords: 'project keywords',
|
|
21
|
+
description: 'project description'
|
|
22
22
|
},
|
|
23
23
|
grid: {
|
|
24
24
|
color: 'rgba(0, 0, 0, 1)'
|
|
@@ -30,11 +30,25 @@ export const projectMock: TProject = {
|
|
|
30
30
|
slug: '/',
|
|
31
31
|
isPublished: true,
|
|
32
32
|
meta: {
|
|
33
|
-
opengraphThumbnail: 'page
|
|
34
|
-
title: 'page
|
|
35
|
-
description: 'page
|
|
33
|
+
opengraphThumbnail: 'page thumbnail',
|
|
34
|
+
title: 'page title',
|
|
35
|
+
description: 'page description',
|
|
36
36
|
enabled: true,
|
|
37
|
-
keywords: 'page
|
|
37
|
+
keywords: 'page keywords'
|
|
38
38
|
}
|
|
39
|
-
}
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'pageId2',
|
|
42
|
+
title: 'Page 2',
|
|
43
|
+
articleId: 'articleId2',
|
|
44
|
+
slug: '/2',
|
|
45
|
+
isPublished: true,
|
|
46
|
+
meta: {
|
|
47
|
+
opengraphThumbnail: 'page thumbnail',
|
|
48
|
+
title: 'page title',
|
|
49
|
+
description: 'page description',
|
|
50
|
+
enabled: false,
|
|
51
|
+
keywords: 'page keywords'
|
|
52
|
+
}
|
|
53
|
+
}]
|
|
40
54
|
};
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import ejs from 'ejs';
|
|
6
|
+
import { config } from 'dotenv';
|
|
7
|
+
import { program } from 'commander';
|
|
8
|
+
import { TLayout } from '@cntrl-site/core';
|
|
9
|
+
import { Client } from './Client/Client';
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.command('generate-layouts')
|
|
13
|
+
.option('-o, --output <outputFilePath>', 'Output file path', 'cntrl.scss')
|
|
14
|
+
.option('-e, --env <envFilename>', 'Name of the .env file', '.env.local')
|
|
15
|
+
.action(async (options) => {
|
|
16
|
+
try {
|
|
17
|
+
config({ path: options.env });
|
|
18
|
+
const templateFilePath = path.resolve(__dirname, '../resources/template.scss.ejs');
|
|
19
|
+
const scssTemplate = fs.readFileSync(templateFilePath, 'utf-8');
|
|
20
|
+
const apiUrl = process.env.CNTRL_API_URL;
|
|
21
|
+
if (!apiUrl) {
|
|
22
|
+
throw new Error('Environment variable "CNTRL_API_URL" must be set.');
|
|
23
|
+
}
|
|
24
|
+
const client = new Client(apiUrl);
|
|
25
|
+
const layouts = await client.getLayouts();
|
|
26
|
+
const ranges = convertLayouts(layouts);
|
|
27
|
+
|
|
28
|
+
const compiledTemplate = ejs.compile(scssTemplate);
|
|
29
|
+
const renderedTemplate = compiledTemplate({ ranges });
|
|
30
|
+
|
|
31
|
+
const outputFilePath = path.resolve(process.cwd(), options.output);
|
|
32
|
+
fs.writeFileSync(outputFilePath, renderedTemplate);
|
|
33
|
+
|
|
34
|
+
console.log(`Generated .scss file at ${outputFilePath}`);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('An error occurred:', error);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function convertLayouts(layouts: TLayout[], maxLayoutWidth: number = Number.MAX_SAFE_INTEGER): LayoutRange[] {
|
|
42
|
+
const sorted = layouts.slice().sort((la, lb) => la.startsWith - lb.startsWith);
|
|
43
|
+
const mapped = sorted.map<LayoutRange>((layout, i, ls) => {
|
|
44
|
+
const next = ls[i + 1];
|
|
45
|
+
return {
|
|
46
|
+
start: layout.startsWith,
|
|
47
|
+
end: next ? next.startsWith - 1 : maxLayoutWidth,
|
|
48
|
+
exemplary: layout.exemplary,
|
|
49
|
+
name: layout.title,
|
|
50
|
+
isFirst: i === 0,
|
|
51
|
+
isLast: !next
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
return mapped;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LayoutRange {
|
|
58
|
+
/** closed range [start, end] */
|
|
59
|
+
start: number;
|
|
60
|
+
end: number;
|
|
61
|
+
exemplary: number;
|
|
62
|
+
name: string;
|
|
63
|
+
isFirst: boolean;
|
|
64
|
+
isLast: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
program.parse(process.argv);
|
package/cntrl-site-sdk-1.0.0.tgz
DELETED
|
Binary file
|