diff --git a/packages/cli/package.json b/packages/cli/package.json index 38f9c22..1e8c1c9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,6 +12,8 @@ "scripts": { "prepack": "npm run build", "build": "tsc", + "pretest": "npm run build", + "test": "node --test", "format": "prettier -w .", "dev": "tsc-watch" }, diff --git a/packages/cli/src/bin/index.ts b/packages/cli/src/bin/index.ts index dcf1651..bcce703 100644 --- a/packages/cli/src/bin/index.ts +++ b/packages/cli/src/bin/index.ts @@ -4,16 +4,61 @@ import fs from 'node:fs' import path from 'node:path' import penpotExport from '@penpot-export/core' -const rootProjectPath = fs.realpathSync(process.cwd()) -const configFilePath = path.resolve(rootProjectPath, 'penpot-export.config.js') -const exists = fs.existsSync(configFilePath) +import { parsePenpotUrl } from '../penpot' -if (!exists) { - throw new Error( - 'penpot-export: Config file not found. Check if file penpot-export.config.js exists at root.', - ) +const [, , command, ...params] = process.argv + +switch (command) { + case 'inspect': { + const [url] = params + + if (!url) { + throw new Error( + 'penpot-export: Missing URL to inspect. Provide an URL to the inspect command.', + ) + } + + try { + const parsed = parsePenpotUrl(url) + console.log( + [ + 'The following details are the result of inspecting the provided URL:', + `Penpot instance: ${parsed.instance}`, + `Workspace id: ${parsed.workspaceId}`, + `File id: ${parsed.fileId}`, + `Page id: ${parsed.pageId}`, + ].join('\n\t'), + ) + } catch (e) { + if (e instanceof TypeError) { + throw new Error( + `penpot-export: URL inspection failed with the following error: ${e.message}.`, + ) + } else { + throw new Error( + 'penpot-export: URL inspection failed with an unknown error.', + ) + } + } + + break + } + default: { + const rootProjectPath = fs.realpathSync(process.cwd()) + const configFilePath = path.resolve( + rootProjectPath, + 'penpot-export.config.js', + ) + const exists = fs.existsSync(configFilePath) + + if (!exists) { + throw new Error( + 'penpot-export: Config file not found. Check if file penpot-export.config.js exists at root.', + ) + } + + const config = require(configFilePath) + + penpotExport(config, rootProjectPath) + } } - -const config = require(configFilePath) - -penpotExport(config, rootProjectPath) diff --git a/packages/cli/src/penpot/index.ts b/packages/cli/src/penpot/index.ts new file mode 100644 index 0000000..6eefd0a --- /dev/null +++ b/packages/cli/src/penpot/index.ts @@ -0,0 +1 @@ +export * from './urls' diff --git a/packages/cli/src/penpot/urls.test.ts b/packages/cli/src/penpot/urls.test.ts new file mode 100644 index 0000000..2b2f1d9 --- /dev/null +++ b/packages/cli/src/penpot/urls.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' + +import { parsePenpotUrl } from './urls' + +describe('Penpot URL parser', () => { + describe('Params validation', () => { + describe('when provided an invalid URL', () => { + it('throws an error', () => { + const input = 'foo' + + const expectedException = { + name: 'TypeError', + message: 'Invalid URL', + } + + assert.throws(() => { + parsePenpotUrl(input) + }, expectedException) + }) + }) + + describe('when provided a valid URL not from a Penpot file', () => { + it('throws an error', () => { + { + const input = 'https://design.penpot.app/#/settings/profile' + + const expectedException = { + name: 'TypeError', + message: 'Invalid Penpot file URL', + } + + assert.throws(() => { + parsePenpotUrl(input) + }, expectedException) + } + + { + const input = 'https://design.penpot.app/#/settings/access-tokens' + + const expectedException = { + name: 'TypeError', + message: 'Invalid Penpot file URL', + } + + assert.throws(() => { + parsePenpotUrl(input) + }, expectedException) + } + }) + }) + }) + + describe('Parsing output', () => { + describe('when provided a Penpot SaaS URL', () => { + it('parses it without page-id param', () => { + const input = + 'https://design.penpot.app/#/workspace/4a499800-872e-80e1-8002-fc0b4bbaa4e4/52961d58-0a92-80c2-8003-2e4b5e9a7826' + + const expect = { + instance: 'https://design.penpot.app/', + workspaceId: '4a499800-872e-80e1-8002-fc0b4bbaa4e4', + fileId: '52961d58-0a92-80c2-8003-2e4b5e9a7826', + pageId: undefined, + } + + assert.deepStrictEqual(parsePenpotUrl(input), expect) + }) + + it('parses it with page id', () => { + const input = + 'https://design.penpot.app/#/workspace/4a499800-872e-80e1-8002-fc0b4bbaa4e4/52961d58-0a92-80c2-8003-2e4b5e9a7826?page-id=38f1e350-296d-80f1-8002-fd3314270d8c' + + const expect = { + instance: 'https://design.penpot.app/', + workspaceId: '4a499800-872e-80e1-8002-fc0b4bbaa4e4', + fileId: '52961d58-0a92-80c2-8003-2e4b5e9a7826', + pageId: '38f1e350-296d-80f1-8002-fd3314270d8c', + } + + assert.deepStrictEqual(parsePenpotUrl(input), expect) + }) + }) + + describe('when provided a self-hosted Penpot URL', () => { + it('parses it without page-id param', () => { + const input = + 'https://penpot.mydomain.com/#/workspace/da54e491-6ba3-11eb-9ba1-03f8ac143bbf/c86728dd-89fd-8169-8002-a71575166a74' + + const expect = { + instance: 'https://penpot.mydomain.com/', + workspaceId: 'da54e491-6ba3-11eb-9ba1-03f8ac143bbf', + fileId: 'c86728dd-89fd-8169-8002-a71575166a74', + pageId: undefined, + } + + assert.deepStrictEqual(parsePenpotUrl(input), expect) + }) + + it('parses it with page id', () => { + const input = + 'https://penpot.mydomain.com/#/workspace/da54e491-6ba3-11eb-9ba1-03f8ac143bbf/c86728dd-89fd-8169-8002-a71575166a74?page-id=c86728dd-89fd-8169-8002-a71575166a75' + + const expect = { + instance: 'https://penpot.mydomain.com/', + workspaceId: 'da54e491-6ba3-11eb-9ba1-03f8ac143bbf', + fileId: 'c86728dd-89fd-8169-8002-a71575166a74', + pageId: 'c86728dd-89fd-8169-8002-a71575166a75', + } + + assert.deepStrictEqual(parsePenpotUrl(input), expect) + }) + }) + }) +}) diff --git a/packages/cli/src/penpot/urls.ts b/packages/cli/src/penpot/urls.ts new file mode 100644 index 0000000..c748e5d --- /dev/null +++ b/packages/cli/src/penpot/urls.ts @@ -0,0 +1,26 @@ +// NOTE A Penpot file URL has these forms as of v1.19: +// https:///#/workspace// +// https:///#/workspace//?page-id= +const PENPOT_FILE_URL_HASH_RE = /#\/workspace\/.{36}\/.{36}(?:\?page-id=.{36})?/ + +const UUID_RE = + /^\p{Hex_Digit}{8}-\p{Hex_Digit}{4}-\p{Hex_Digit}{4}-\p{Hex_Digit}{4}-\p{Hex_Digit}{12}$/u + +export function parsePenpotUrl(url: string) { + const parsedUrl = new URL(url) + + if (!PENPOT_FILE_URL_HASH_RE.test(parsedUrl.hash)) { + throw new TypeError('Invalid Penpot file URL') + } + + const [workspaceId, fileId, pageId] = parsedUrl.hash + .split(/[\/?=]/) + .filter((match) => UUID_RE.test(match)) + + return { + instance: parsedUrl.origin + parsedUrl.pathname, + workspaceId, + fileId, + pageId, + } +}