@cntrl-site/sdk 0.7.2 → 1.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.
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__: 100;
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 it's initial state
65
+ $__CNTRL_LAYOUT_WIDTH__: 100 !global;
66
+ }
@@ -8,38 +8,60 @@ const core_1 = require("@cntrl-site/core");
8
8
  const isomorphic_fetch_1 = __importDefault(require("isomorphic-fetch"));
9
9
  const url_1 = require("url");
10
10
  class Client {
11
- constructor(projectId, APIUrl, fetchImpl = isomorphic_fetch_1.default) {
12
- this.projectId = projectId;
13
- this.APIUrl = APIUrl;
11
+ constructor(APIUrl, fetchImpl = isomorphic_fetch_1.default) {
14
12
  this.fetchImpl = fetchImpl;
15
- if (projectId.length === 0) {
16
- throw new Error('CNTRL SDK: Project ID is empty. Did you forget to pass it?');
13
+ this.url = new url_1.URL(APIUrl);
14
+ if (!this.url.username) {
15
+ throw new Error('Project ID is missing in the URL.');
17
16
  }
18
- if (APIUrl.length === 0) {
19
- throw new Error('CNTRL SDK: API URL is empty. Did you forget to pass it?');
17
+ if (!this.url.password) {
18
+ throw new Error('API key is missing in the URL.');
20
19
  }
21
20
  }
22
- async getProject() {
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) {
23
31
  try {
24
- const response = await this.fetchProject();
25
- const data = await response.json();
26
- const project = core_1.ProjectSchema.parse(data);
27
- return project;
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
+ };
28
47
  }
29
48
  catch (e) {
30
49
  throw e;
31
50
  }
32
51
  }
33
- async getPageArticle(pageSlug) {
52
+ async getProjectPagesPaths() {
34
53
  try {
35
- const projectResponse = await this.fetchProject();
36
- const data = await projectResponse.json();
37
- const project = core_1.ProjectSchema.parse(data);
38
- const articleId = this.findArticleIdByPageSlug(pageSlug, project.pages);
39
- const articleResponse = await this.fetchArticle(articleId);
40
- const articleData = await articleResponse.json();
41
- const article = core_1.ArticleSchema.parse(articleData);
42
- return article;
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,61 +69,59 @@ class Client {
47
69
  }
48
70
  async getTypePresets() {
49
71
  const response = await this.fetchTypePresets();
50
- const data = await response.json();
51
- const typePresets = core_1.TypePresetsSchema.parse(data);
52
- return typePresets;
53
- }
54
- async getKeyframes(articleId) {
55
- const response = await this.fetchKeyframes(articleId);
56
- const data = await response.json();
57
- const keyframes = core_1.KeyframesSchema.parse(data);
58
- return keyframes;
59
- }
60
- static getPageMeta(projectMeta, pageMeta) {
61
- return pageMeta.enabled ? {
62
- title: pageMeta.title ? pageMeta.title : projectMeta.title,
63
- description: pageMeta.description ? pageMeta.description : projectMeta.description,
64
- keywords: pageMeta.keywords ? pageMeta.keywords : projectMeta.keywords,
65
- opengraphThumbnail: pageMeta.opengraphThumbnail ? pageMeta.opengraphThumbnail : projectMeta.opengraphThumbnail,
66
- favicon: projectMeta.favicon
67
- } : projectMeta;
72
+ return response;
68
73
  }
69
74
  async fetchProject() {
70
- const url = new url_1.URL(`/projects/${this.projectId}`, this.APIUrl);
71
- const response = await this.fetchImpl(url.href);
75
+ const { username: projectId, password: apiKey, origin } = this.url;
76
+ const url = new url_1.URL(`/projects/${projectId}`, origin);
77
+ const response = await this.fetchImpl(url.href, {
78
+ headers: {
79
+ Authorization: `Bearer ${apiKey}`
80
+ }
81
+ });
72
82
  if (!response.ok) {
73
- throw new Error(`Failed to fetch project with id #${this.projectId}: ${response.statusText}`);
83
+ throw new Error(`Failed to fetch project with id #${projectId}: ${response.statusText}`);
74
84
  }
75
- return response;
85
+ const data = await response.json();
86
+ const project = core_1.ProjectSchema.parse(data);
87
+ return project;
76
88
  }
77
89
  async fetchArticle(articleId) {
78
- const url = new url_1.URL(`/articles/${articleId}`, this.APIUrl);
79
- const response = await this.fetchImpl(url.href);
90
+ const { username: projectId, password: apiKey, origin } = this.url;
91
+ const url = new url_1.URL(`/projects/${projectId}/articles/${articleId}`, origin);
92
+ const response = await this.fetchImpl(url.href, {
93
+ headers: {
94
+ Authorization: `Bearer ${apiKey}`
95
+ }
96
+ });
80
97
  if (!response.ok) {
81
98
  throw new Error(`Failed to fetch article with id #${articleId}: ${response.statusText}`);
82
99
  }
83
- return response;
84
- }
85
- async fetchKeyframes(articleId) {
86
- const url = new url_1.URL(`/keyframes/${articleId}`, this.APIUrl);
87
- const response = await this.fetchImpl(url.href);
88
- if (!response.ok) {
89
- throw new Error(`Failed to fetch keyframes for the article with id #${articleId}: ${response.statusText}`);
90
- }
91
- return response;
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 };
92
104
  }
93
105
  async fetchTypePresets() {
94
- const url = new url_1.URL(`/projects/${this.projectId}/type-presets`, this.APIUrl);
95
- const response = await this.fetchImpl(url.href);
106
+ const { username: projectId, password: apiKey, origin } = this.url;
107
+ const url = new url_1.URL(`/projects/${projectId}/type-presets`, origin);
108
+ const response = await this.fetchImpl(url.href, {
109
+ headers: {
110
+ Authorization: `Bearer ${apiKey}`
111
+ }
112
+ });
96
113
  if (!response.ok) {
97
- throw new Error(`Failed to fetch type presets for the project with id #${this.projectId}: ${response.statusText}`);
114
+ throw new Error(`Failed to fetch type presets for the project with id #${projectId}: ${response.statusText}`);
98
115
  }
99
- return response;
116
+ const data = await response.json();
117
+ const typePresets = core_1.TypePresetsSchema.parse(data);
118
+ return typePresets;
100
119
  }
101
120
  findArticleIdByPageSlug(slug, pages) {
121
+ const { username: projectId } = this.url;
102
122
  const page = pages.find((page) => page.slug === slug);
103
123
  if (!page) {
104
- throw new Error(`Page with a slug ${slug} was not found in project with id #${this.projectId}`);
124
+ throw new Error(`Page with a slug ${slug} was not found in project with id #${projectId}`);
105
125
  }
106
126
  return page.articleId;
107
127
  }
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": "0.7.2",
3
+ "version": "1.1.0",
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.0",
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__: 100;
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 it's initial state
49
+ $__CNTRL_LAYOUT_WIDTH__: 100 !global;
50
+ }
@@ -2,100 +2,114 @@ 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('returns project', async () => {
10
- let fetchCalledTimes = 0;
8
+ it('throws an error when no project ID passed to the connect URL', async () => {
9
+ const projectId = '';
10
+ const apiKey = 'MY_API_KEY';
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 () => {
11
25
  const projectId = 'projectId';
12
- const apiUrl = 'https://api.cntrl.site/';
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}/`;
13
38
  const fetch = async (url: string) => {
14
39
  fetchCalledTimes += 1;
15
- expect(url).toBe(`${apiUrl}projects/${projectId}`);
16
40
  return Promise.resolve({
17
41
  ok: true,
18
- json: () => Promise.resolve(projectMock),
42
+ json: () => Promise.resolve(fetchesMap[url]),
19
43
  statusText: ''
20
44
  });
21
45
  };
22
- const client = new Client(projectId, apiUrl, fetch);
23
- const project = await client.getProject();
24
- expect(fetchCalledTimes).toBe(1);
25
- expect(project).toEqual(projectMock);
26
- });
27
-
28
- it('throws an error upon project fetch failure', async () => {
29
- const projectId = 'projectId';
30
- const apiUrl = 'https://api.cntrl.site/';
31
- const fetch = async () => Promise.resolve({
32
- ok: false,
33
- statusText: 'reason',
34
- json: () => Promise.resolve()
46
+ const client = new Client(apiUrl, fetch);
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'
35
59
  });
36
- const client = new Client(projectId, apiUrl, fetch);
37
- await expect(client.getProject()).rejects.toEqual(new Error('Failed to fetch project with id #projectId: reason'));
38
60
  });
39
61
 
40
- it('returns article by page slug', async () => {
41
- let fetchCalledTimes = 0;
62
+ it('ignores page meta if it is not enabled and uses project meta instead', async () => {
42
63
  const projectId = 'projectId';
43
- const apiUrl = 'https://api.cntrl.site/';
44
- const projectApiUrl = `${apiUrl}projects/projectId`;
45
- const articleApiUrl = `${apiUrl}articles/articleId`;
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
+ };
73
+ const apiKey = 'MY_API_KEY';
74
+ let fetchCalledTimes = 0;
75
+ const apiUrl = `https://${projectId}:${apiKey}@${API_BASE_URL}/`;
46
76
  const fetch = async (url: string) => {
47
77
  fetchCalledTimes += 1;
48
- if (fetchCalledTimes === 1) {
49
- expect(url).toBe(projectApiUrl);
50
- }
51
- if (fetchCalledTimes === 2) {
52
- expect(url).toBe(articleApiUrl);
53
- }
54
78
  return Promise.resolve({
55
79
  ok: true,
56
- json: () => Promise.resolve(url === projectApiUrl ? projectMock : articleMock),
80
+ json: () => Promise.resolve(fetchesMap[url]),
57
81
  statusText: ''
58
82
  });
59
83
  };
60
- const client = new Client(projectId, apiUrl, fetch);
61
- const article = await client.getPageArticle('/');
62
- expect(fetchCalledTimes).toBe(2);
63
- expect(article).toEqual(articleMock);
84
+ const client = new Client(apiUrl, fetch);
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
+ });
64
93
  });
65
94
 
66
- it('throws an error upon project fetch failure when trying to get article by slug', async () => {
67
- const projectId = 'projectId';
68
- const apiUrl = 'https://api.cntrl.site/';
95
+ it('throws an error upon page data fetch failure', async () => {
96
+ const projectId = 'MY_PROJECT_ID';
97
+ const apiKey = 'MY_API_KEY';
98
+ const apiUrl = `https://${projectId}:${apiKey}@api.cntrl.site/`;
69
99
  const fetch = async () => Promise.resolve({
70
100
  ok: false,
71
101
  statusText: 'reason',
72
102
  json: () => Promise.resolve()
73
103
  });
74
- const client = new Client(projectId, apiUrl, fetch);
75
- await expect(client.getPageArticle('/'))
76
- .rejects.toEqual(new Error('Failed to fetch project with id #projectId: reason'));
77
- });
78
-
79
- it('throws an error upon article fetch failure when trying to get article by slug', async () => {
80
- const projectId = 'projectId';
81
- const apiUrl = 'https://api.cntrl.site/';
82
- const projectApiUrl = `${apiUrl}projects/projectId`;
83
- const fetch = (url: string) => {
84
- return Promise.resolve({
85
- ok: url === projectApiUrl,
86
- json: () => Promise.resolve(projectMock),
87
- statusText: 'reason'
88
- });
89
- };
90
- const client = new Client(projectId, apiUrl, fetch);
91
- await expect(client.getPageArticle('/'))
92
- .rejects.toEqual(new Error('Failed to fetch article with id #articleId: reason'));
104
+ const client = new Client(apiUrl, fetch);
105
+ await expect(client.getPageData('/')).rejects.toEqual(new Error('Failed to fetch project with id #MY_PROJECT_ID: reason'));
93
106
  });
94
107
 
95
108
  it('throws an error when trying to fetch article by nonexistent slug', async () => {
96
- const projectId = 'projectId';
97
- const apiUrl = 'https://api.cntrl.site/';
98
- const projectApiUrl = `${apiUrl}projects/projectId`;
109
+ const projectId = 'MY_PROJECT_ID';
110
+ const apiKey = 'MY_API_KEY';
111
+ const apiUrl = `https://${projectId}:${apiKey}@api.cntrl.site/`;
112
+ const projectApiUrl = `https://api.cntrl.site/projects/${projectId}`;
99
113
  const slug = '/nonexistent-slug';
100
114
  const fetch = (url: string) => {
101
115
  return Promise.resolve({
@@ -104,119 +118,8 @@ describe('Client', () => {
104
118
  statusText: 'reason'
105
119
  });
106
120
  };
107
- const client = new Client(projectId, apiUrl, fetch);
108
- await expect(client.getPageArticle(slug))
121
+ const client = new Client(apiUrl, fetch);
122
+ await expect(client.getPageData(slug))
109
123
  .rejects.toEqual(new Error(`Page with a slug ${slug} was not found in project with id #${projectId}`));
110
124
  });
111
-
112
- it('returns type presets by project id', async () => {
113
- let fetchCalledTimes = 0;
114
- const projectId = 'projectId';
115
- const apiUrl = 'https://api.cntrl.site/';
116
- const fetch = (url: string) => {
117
- fetchCalledTimes += 1;
118
- expect(url).toBe(`${apiUrl}projects/${projectId}/type-presets`);
119
- return Promise.resolve({
120
- ok: true,
121
- json: () => Promise.resolve(typePresetsMock),
122
- statusText: ''
123
- });
124
- };
125
- const client = new Client(projectId, apiUrl, fetch);
126
- const presets = await client.getTypePresets();
127
- expect(presets).toEqual(typePresetsMock);
128
- expect(fetchCalledTimes).toEqual(1);
129
- });
130
-
131
- it('throws an error upon type presets fetch failure', async () => {
132
- const projectId = 'projectId';
133
- const apiUrl = 'https://api.cntrl.site/';
134
- const fetch = () => {
135
- return Promise.resolve({
136
- ok: false,
137
- json: () => Promise.resolve(),
138
- statusText: 'reason'
139
- });
140
- };
141
- const client = new Client(projectId, apiUrl, fetch);
142
- await expect(client.getTypePresets()).rejects.toEqual(
143
- new Error(`Failed to fetch type presets for the project with id #${projectId}: reason`)
144
- );
145
- });
146
-
147
- it('returns keyframes by article id', async () => {
148
- let fetchCalledTimes = 0;
149
- const projectId = 'projectId';
150
- const articleId = 'articleId';
151
- const apiUrl = 'https://api.cntrl.site/';
152
- const fetch = (url: string) => {
153
- fetchCalledTimes += 1;
154
- expect(url).toBe(`${apiUrl}keyframes/${articleId}`);
155
- return Promise.resolve({
156
- ok: true,
157
- json: () => Promise.resolve(keyframesMock),
158
- statusText: ''
159
- });
160
- };
161
- const client = new Client(projectId, apiUrl, fetch);
162
- const presets = await client.getKeyframes(articleId);
163
- expect(presets).toEqual(keyframesMock);
164
- expect(fetchCalledTimes).toEqual(1);
165
- });
166
-
167
- it('throws an error upon keyframes fetch failure', async () => {
168
- const projectId = 'projectId';
169
- const articleId = 'articleId';
170
- const apiUrl = 'https://api.cntrl.site/';
171
- const fetch = () => {
172
- return Promise.resolve({
173
- ok: false,
174
- json: () => Promise.resolve(),
175
- statusText: 'reason'
176
- });
177
- };
178
- const client = new Client(projectId, apiUrl, fetch);
179
- await expect(client.getKeyframes(articleId)).rejects.toEqual(
180
- new Error(`Failed to fetch keyframes for the article with id #${articleId}: reason`)
181
- );
182
- });
183
-
184
- it('merges two meta objects into one with priority of page-based over project-based', () => {
185
- const pageMeta: TPageMeta = {
186
- enabled: true,
187
- description: 'page-desc',
188
- title: 'page-title'
189
- };
190
-
191
- const projectMeta: TMeta = {
192
- opengraphThumbnail: 'proj-og',
193
- description: 'proj-desc',
194
- title: 'proj-title',
195
- keywords: 'project, keywords'
196
- };
197
- const meta = Client.getPageMeta(projectMeta, pageMeta);
198
- expect(meta.keywords).toBe(projectMeta.keywords);
199
- expect(meta.opengraphThumbnail).toBe(projectMeta.opengraphThumbnail);
200
- expect(meta.description).toBe(pageMeta.description);
201
- expect(meta.title).toBe(pageMeta.title);
202
- });
203
-
204
- it('ignores page meta when `enabled` is set to `false` and uses only generic project meta', () => {
205
- const pageMeta: TPageMeta = {
206
- enabled: false,
207
- description: 'page-desc',
208
- title: 'page-title'
209
- };
210
-
211
- const projectMeta: TMeta = {
212
- opengraphThumbnail: 'proj-og',
213
- keywords: 'project, keywords'
214
- };
215
-
216
- const meta = Client.getPageMeta(projectMeta, pageMeta);
217
- expect(meta.keywords).toBe(projectMeta.keywords);
218
- expect(meta.opengraphThumbnail).toBe(projectMeta.opengraphThumbnail);
219
- expect(meta.description).toBeUndefined();
220
- expect(meta.title).toBeUndefined();
221
- });
222
125
  });
@@ -9,117 +9,138 @@ 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';
16
17
 
17
18
  export class Client {
19
+ private url: URL;
18
20
  constructor(
19
- private projectId: string,
20
- private APIUrl: string,
21
+ APIUrl: string,
21
22
  private fetchImpl: FetchImpl = fetch
22
23
  ) {
23
- if (projectId.length === 0) {
24
- throw new Error('CNTRL SDK: Project ID is empty. Did you forget to pass it?');
24
+ this.url = new URL(APIUrl);
25
+ if (!this.url.username) {
26
+ throw new Error('Project ID is missing in the URL.');
25
27
  }
26
- if (APIUrl.length === 0) {
27
- throw new Error('CNTRL SDK: API URL is empty. Did you forget to pass it?');
28
+ if (!this.url.password) {
29
+ throw new Error('API key is missing in the URL.');
28
30
  }
29
31
  }
30
32
 
31
- async getProject(): Promise<TProject> {
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> {
32
44
  try {
33
- const response = await this.fetchProject();
34
- const data = await response.json();
35
- const project = ProjectSchema.parse(data);
36
- return project;
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
+ };
37
60
  } catch (e) {
38
61
  throw e;
39
62
  }
40
63
  }
41
64
 
42
- async getPageArticle(pageSlug: string): Promise<TArticle> {
65
+ async getProjectPagesPaths(): Promise<string[]> {
43
66
  try {
44
- const projectResponse = await this.fetchProject();
45
- const data = await projectResponse.json();
46
- const project = ProjectSchema.parse(data);
47
- const articleId = this.findArticleIdByPageSlug(pageSlug, project.pages);
48
- const articleResponse = await this.fetchArticle(articleId);
49
- const articleData = await articleResponse.json();
50
- const article = ArticleSchema.parse(articleData);
51
- return article;
67
+ const { pages } = await this.fetchProject();
68
+ return pages.map(p => p.slug);
52
69
  } catch (e) {
53
70
  throw e;
54
71
  }
55
72
  }
56
73
 
57
- async getTypePresets(): Promise<TTypePresets> {
58
- const response = await this.fetchTypePresets();
59
- const data = await response.json();
60
- const typePresets = TypePresetsSchema.parse(data);
61
- return typePresets;
62
- }
63
-
64
- async getKeyframes(articleId: string): Promise<TKeyframeAny[]> {
65
- const response = await this.fetchKeyframes(articleId);
66
- const data = await response.json();
67
- const keyframes = KeyframesSchema.parse(data);
68
- return keyframes;
69
- }
70
-
71
- public static getPageMeta(projectMeta: TMeta, pageMeta: TPageMeta): TMeta {
72
- return pageMeta.enabled ? {
73
- title: pageMeta.title ? pageMeta.title : projectMeta.title,
74
- description: pageMeta.description ? pageMeta.description : projectMeta.description,
75
- keywords: pageMeta.keywords ? pageMeta.keywords : projectMeta.keywords,
76
- opengraphThumbnail: pageMeta.opengraphThumbnail ? pageMeta.opengraphThumbnail : projectMeta.opengraphThumbnail,
77
- favicon: projectMeta.favicon
78
- } : projectMeta;
74
+ async getLayouts(): Promise<TLayout[]> {
75
+ try {
76
+ const { layouts } = await this.fetchProject();
77
+ return layouts;
78
+ } catch (e) {
79
+ throw e;
80
+ }
79
81
  }
80
82
 
81
- private async fetchProject(): Promise<FetchImplResponse> {
82
- const url = new URL(`/projects/${this.projectId}`, this.APIUrl);
83
- const response = await this.fetchImpl(url.href);
84
- if (!response.ok) {
85
- throw new Error(`Failed to fetch project with id #${this.projectId}: ${response.statusText}`);
86
- }
83
+ async getTypePresets(): Promise<TTypePresets> {
84
+ const response = await this.fetchTypePresets();
87
85
  return response;
88
86
  }
89
87
 
90
- private async fetchArticle(articleId: string): Promise<FetchImplResponse> {
91
- const url = new URL(`/articles/${articleId}`, this.APIUrl);
92
- const response = await this.fetchImpl(url.href);
88
+ private async fetchProject(): Promise<TProject> {
89
+ const { username: projectId, password: apiKey, origin } = this.url;
90
+ const url = new URL(`/projects/${projectId}`, origin);
91
+ const response = await this.fetchImpl(url.href, {
92
+ headers: {
93
+ Authorization: `Bearer ${apiKey}`
94
+ }
95
+ });
93
96
  if (!response.ok) {
94
- throw new Error(`Failed to fetch article with id #${articleId}: ${response.statusText}`);
97
+ throw new Error(`Failed to fetch project with id #${projectId}: ${response.statusText}`);
95
98
  }
96
- return response;
99
+ const data = await response.json();
100
+ const project = ProjectSchema.parse(data);
101
+ return project;
97
102
  }
98
103
 
99
- private async fetchKeyframes(articleId: string): Promise<FetchImplResponse> {
100
- const url = new URL(`/keyframes/${articleId}`, this.APIUrl);
101
- const response = await this.fetchImpl(url.href);
104
+ private async fetchArticle(articleId: string): Promise<ArticleData> {
105
+ const { username: projectId, password: apiKey, origin } = this.url;
106
+ const url = new URL(`/projects/${projectId}/articles/${articleId}`, origin);
107
+ const response = await this.fetchImpl(url.href, {
108
+ headers: {
109
+ Authorization: `Bearer ${apiKey}`
110
+ }
111
+ });
102
112
  if (!response.ok) {
103
- throw new Error(`Failed to fetch keyframes for the article with id #${articleId}: ${response.statusText}`);
113
+ throw new Error(`Failed to fetch article with id #${articleId}: ${response.statusText}`);
104
114
  }
105
- return response;
115
+ const data = await response.json();
116
+ const article = ArticleSchema.parse(data.article);
117
+ const keyframes = KeyframesSchema.parse(data.keyframes);
118
+ return { article, keyframes };
106
119
  }
107
120
 
108
- private async fetchTypePresets(): Promise<FetchImplResponse> {
109
- const url = new URL(`/projects/${this.projectId}/type-presets`, this.APIUrl);
110
- const response = await this.fetchImpl(url.href);
121
+ private async fetchTypePresets(): Promise<TTypePresets> {
122
+ const { username: projectId, password: apiKey, origin } = this.url;
123
+ const url = new URL(`/projects/${projectId}/type-presets`, origin);
124
+ const response = await this.fetchImpl(url.href, {
125
+ headers: {
126
+ Authorization: `Bearer ${apiKey}`
127
+ }
128
+ });
111
129
  if (!response.ok) {
112
130
  throw new Error(
113
- `Failed to fetch type presets for the project with id #${this.projectId}: ${response.statusText}`
131
+ `Failed to fetch type presets for the project with id #${projectId}: ${response.statusText}`
114
132
  );
115
133
  }
116
- return response;
134
+ const data = await response.json();
135
+ const typePresets = TypePresetsSchema.parse(data);
136
+ return typePresets;
117
137
  }
118
138
 
119
139
  private findArticleIdByPageSlug(slug: string, pages: TPage[]): string {
140
+ const { username: projectId } = this.url;
120
141
  const page = pages.find((page) => page.slug === slug);
121
142
  if (!page) {
122
- throw new Error(`Page with a slug ${slug} was not found in project with id #${this.projectId}`);
143
+ throw new Error(`Page with a slug ${slug} was not found in project with id #${projectId}`);
123
144
  }
124
145
  return page.articleId;
125
146
  }
@@ -131,4 +152,13 @@ interface FetchImplResponse {
131
152
  statusText: string;
132
153
  }
133
154
 
134
- type FetchImpl = (url: string) => Promise<FetchImplResponse>;
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: undefined,
18
- title: undefined,
19
- opengraphThumbnail: undefined,
20
- keywords: undefined,
21
- description: undefined
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-thumbnail',
34
- title: 'page-title',
35
- description: 'page-description',
33
+ opengraphThumbnail: 'page thumbnail',
34
+ title: 'page title',
35
+ description: 'page description',
36
36
  enabled: true,
37
- keywords: 'page-keywords'
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);
Binary file