Skip to content

Commit

Permalink
feat(cuttings): Add first draft of the cuttings package
Browse files Browse the repository at this point in the history
  • Loading branch information
Sidnioulz committed Sep 14, 2024
1 parent 474e6f7 commit 83627ce
Show file tree
Hide file tree
Showing 18 changed files with 740 additions and 2,020 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ coverage
*cutting.figmarine.json

# Debug files for local testing
packages/cuttings/src/debug.ts
packages/rest/src/debug.ts
82 changes: 82 additions & 0 deletions packages/cuttings/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@

<div align="center">
<h1>@figmarine/cuttings</h1>

<p>
A Node.js library to download Figma files and their metadata, and to store them as JSON files.
</p>


<p>
<img src="https://img.shields.io/badge/status-TODO-red" alt="Status: to do" />
<a href="https://github.com/Sidnioulz/figmarine/commits"><img src="https://img.shields.io/github/commit-activity/m/Sidnioulz/figmarine" alt="commit activity" /></a>
<a href="https://github.com/Sidnioulz/figmarine/commits"><img src="https://img.shields.io/github/last-commit/Sidnioulz/figmarine" alt="last commit" /></a>
<a href="https://github.com/Sidnioulz/figmarine/issues?q=is%3Aopen+is%3Aissue+label%3Apkg-cuttings"><img src="https://img.shields.io/github/issues-search?query=repo%3ASidnioulz%2Ffigmarine%20is%3Aopen%20is%3Aissue%20label%3Apkg-cuttings&label=issues" alt="open issues" /></a>
<a href="https://github.com/Sidnioulz/figmarine/actions/workflows/github-code-scanning/codeql"><img src="https://github.com/Sidnioulz/figmarine/actions/workflows/github-code-scanning/codeql/badge.svg?branch=main" alt="CodeQL status" /></a>
<a href="https://github.com/Sidnioulz/figmarine/actions/workflows/continuous-integration.yml"><img src="https://github.com/Sidnioulz/figmarine/actions/workflows/continuous-integration.yml/badge.svg?branch=main" alt="CI status" /></a>
<a href="https://codecov.io/gh/Sidnioulz/figmarine"><img src="https://codecov.io/gh/Sidnioulz/figmarine/graph/badge.svg?token=4SX3N57XH3" alt="code coverage" /></a>
<a href="https://github.com/Sidnioulz/figmarine/graphs/contributors"><img src="https://img.shields.io/github/contributors/Sidnioulz/figmarine" alt="contributors" /></a>
<a href="https://github.com/Sidnioulz/figmarine/blob/main/CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="code of conduct: contributor covenant 2.1" /></a>
<a href="https://github.com/Sidnioulz/figmarine/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Sidnioulz/figmarine.svg" alt="license" /></a>
<a href="https://github.com/Sidnioulz/figmarine/network/members"><img src="https://img.shields.io/github/forks/Sidnioulz/figmarine" alt="forks" /></a>
<a href="https://github.com/Sidnioulz/figmarine/stargazers"><img src="https://img.shields.io/github/stars/Sidnioulz/figmarine" alt="stars" /></a>
<a href="https://github.com/sponsors/Sidnioulz"><img src="https://img.shields.io/badge/sponsor-30363D?logo=GitHub-Sponsors&logoColor=#EA4AAA" alt="sponsor this project" /></a>
</p>

<h4>
<a href="https://github.com/Sidnioulz/figmarine/packages/cuttings">📗 Documentation</a>
<span> · </span>
<a href="https://github.com/Sidnioulz/figmarine/issues/new?labels=bug,pkg-cuttings">🐛 Report a Bug</a>
<span> · </span>
<a href="https://github.com/Sidnioulz/figmarine/issues/new?labels=enhancement,pkg-cuttings">💡 Request Feature</a>
</h4>
</div>

<br />

## :notebook_with_decorative_cover: Table of Contents

<!-- no toc -->
- [Package Details](#star2-package-details)
- [Roadmap](#dart-roadmap)
- [Contributing](#wave-contributing)
- [License](#warning-license)
- [Support](#sos-support)

## :star2: Package Details

> [!CAUTION]
> Development on this package has not yet started.

## :dart: Roadmap

- [ ] Finalise cutting format (core file, facets)
- [ ] Optimise data overlap between some facets
- [x] Write FS middleware to store and retrieve cuttings
- [ ] Use REST API client to download cuttings
- [ ] Create hydration helpers
- [ ] Use zod for schema validation on FS-loaded files
- [ ] Improve zod parse error printing
- [ ] Add ability to name/describe cutting files and use the name in logs
- [ ] Export Node.js library
- [ ] Add unit tests
- [ ] Document the library
- [ ] Document the `figcutting.json` file format
- [ ] Automate NPM releases


## :wave: Contributing

See [how to contribute](https://github.com/Sidnioulz/figmarine/tree/main?tab=readme-ov-file#package-contributing).

## :warning: License

Distributed under the [MIT License](https://github.com/Sidnioulz/figmarine/tree/main?tab=MIT-1-ov-file).

## :sos: Support

Please open a conversation in the [discussion space](https://github.com/Sidnioulz/figmarine/discussions) to ask a question.

Please [open an issue](https://github.com/Sidnioulz/figmarine/issues/new?labels=pkg-cuttings) for bug reports or code suggestions.

4 changes: 4 additions & 0 deletions packages/cuttings/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// /** @type {import("eslint").Linter.Config} */
import { library } from '@figmarine/config-eslint';

export default library;
58 changes: 58 additions & 0 deletions packages/cuttings/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{
"name": "@figmarine/cuttings",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"description": "Library to cache arbitrary data to disk for arbitrarily long",
"repository": {
"type": "git",
"url": "https://github.com/Sidnioulz/figmarine",
"directory": "packages/cuttings"
},
"bugs": {
"url": "https://github.com/Sidnioulz/figmarine/issues"
},
"keywords": [
"figma",
"figmarine",
"nursery",
"cuttings"
],
"author": "Steve Dodier-Lazaro",
"homepage": "https://github.com/Sidnioulz/figmarine",
"scripts": {
"build": "tsc && tsup --env.NODE_ENV production",
"dev": "tsc-watch --onSuccess \"tsup --env.NODE_ENV production\"",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:coverage": "vitest --coverage",
"test:dev": "vitest dev",
"typecheck": "tsc --noEmit"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist/**"
],
"devDependencies": {
"@figmarine/config-eslint": "workspace:*",
"@figmarine/config-prettier": "workspace:*",
"@figmarine/config-tsup": "workspace:*",
"@figmarine/config-typescript": "workspace:*",
"@figmarine/config-vitest": "workspace:*",
"@types/node": "^22.5.0",
"@vitest/coverage-v8": "^2.1.1",
"axios": "^1.7.4",
"tsc-watch": "^6.2.0",
"tsup": "^8.2.4",
"typescript": "latest",
"vitest": "^2.1.1"
},
"dependencies": {
"@figmarine/logger": "workspace:*",
"@figmarine/rest": "workspace:*",
"zod": "^3.23.8"
}
}
3 changes: 3 additions & 0 deletions packages/cuttings/prettier.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import prettier from '@figmarine/config-prettier';

export default prettier;
3 changes: 3 additions & 0 deletions packages/cuttings/src/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
describe('@figmarine/cuttings', () => {
it.only('', () => { });
});
68 changes: 68 additions & 0 deletions packages/cuttings/src/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import fs from 'node:fs';
import path from 'node:path';

import { log } from '@figmarine/logger';

import { type Cutting, isCutting } from './schemas/cutting';

function stringifyCutting(cutting: Cutting): string {
return JSON.stringify(cutting);
}

function parseCuttingString(str: string): Cutting {
return JSON.parse(str);
}

/**
* Saves a cutting to disk.
* @param cutting The cutting to save to disk.
* @param location The path where to save the cutting.
*/
export function plantCutting(cutting: Cutting, location: string): void {
log(`Cuttings::plantCutting: Saving cutting to ${location}`);
const dir = path.dirname(location);
if (!fs.existsSync(dir)) {
log(`Cuttings::plantCutting: directory '${dir}' does not exist, attempting to create.`);
fs.mkdirSync(dir, { recursive: true });
}

if (fs.existsSync(location)) {
log(`Cuttings::plantCutting: overwriting existing file at '${location}'.`);
}

fs.writeFileSync(
location,
stringifyCutting({
...cutting,
meta: {
...cutting.meta,
lastKnownFilePath: undefined,
lastStored: Date.now(),
},
}),
'utf-8',
);
log(`Cuttings::plantCutting: Successfully saved file.`);
}

/**
* Loads a cutting from disk.
* @param location The path where to load the cutting.
* @returns The loaded cutting.
*/
export function digCutting(location: string): Cutting {
log(`Cuttings::digCutting: Digging location ${location}`);

const str = fs.readFileSync(location, 'utf-8');
log(`Cuttings::digCutting: Successfully loaded file.`);

const cutting = parseCuttingString(str);
if (!isCutting(cutting)) {
throw new Error(`Cuttings::digCutting: File did not match expected format: ${location}`);
}

log(`Cuttings::digCutting: Successfully parsed file.`);
cutting.meta.lastKnownFilePath = location;

return cutting;
}
4 changes: 4 additions & 0 deletions packages/cuttings/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './schemas/cutting';
export * from './schemas/facet';
export * from './fs';
export * from './take';
42 changes: 42 additions & 0 deletions packages/cuttings/src/logHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ZodError } from 'zod';

import type { Cutting } from './schemas/cutting';
import type { Facet } from './schemas/facet';

/**
* Returns a string representation of a cutting, using its label,
* or last known file path if loaded from disk.
* @param cutting The cutting.
* @returns The string representation.
*/
export function printCutting(cutting: Cutting): string {
return cutting.meta.label ?? cutting.meta.lastKnownFilePath ?? '<unnamed>';
}

/**
* Returns a string representation of a facet, using its endpoint and id.
* @param facet The facet.
* @returns The string representation.
*/
export function printFacet(facet: Facet): string {
return `${facet.endpoint}:${facet.id}`;
}

/**
* Returns a string representation of a facet, using its endpoint and id.
* @param facet The facet.
* @returns The string representation.
*/
export function printFacets(cutting: Cutting): string {
return cutting.facets.map(printFacet).join('; ');
}

/**
* Turns a Zod parsing error into a string, with one line per issue found.
* @param error The Zod parsing error.
* @returns A multi-line string with the error messages.
*/
export function printZodError(error: ZodError<unknown>): string {
// TODO: implement.
return JSON.stringify(error);
}
117 changes: 117 additions & 0 deletions packages/cuttings/src/schemas/cutting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type {
File,
LocalVariable,
LocalVariableCollection,
Project,
PublishedComponent,
PublishedComponentSet,
PublishedStyle,
PublishedVariable,
PublishedVariableCollection,
} from '@figmarine/rest';
import { log } from '@figmarine/logger';
import { z } from 'zod';

import { printCutting, printZodError } from '../logHelpers';
import { FacetSchema } from './facet';

export const CuttingSchema = z.object({
meta: z.object({
/**
* An optional descriptive label for human use.
*/
label: z.string().optional(),

/**
* The last time this data was stored to disk, if ever. Defaults to zero when never saved.
*/
lastStored: z.number(),

/**
* If the Cutting was just loaded from disk, set to the file path it was loaded from.
*/
lastKnownFilePath: z.string().optional(),

/**
* Major version number of the Figmarine API used to create a Cutting.
*/
figmarineVersion: z.number().gte(0).lte(0),
}),

/**
* Endpoints that must be called to fetch or re-hydrate this Cutting's data.
*/
facets: z.array(FacetSchema),

/**
* Data stored in the Cutting.
*/
data: z.object({
components: z.record(z.string(), z.object({}).passthrough()),
componentSets: z.record(z.string(), z.object({}).passthrough()),
files: z.record(z.string(), z.object({}).passthrough()),
localVariables: z.record(z.string(), z.object({}).passthrough()),
localVariableCollections: z.record(z.string(), z.object({}).passthrough()),
projects: z.record(z.string(), z.object({}).passthrough()),
publishedVariables: z.record(z.string(), z.object({}).passthrough()),
publishedVariableCollections: z.record(z.string(), z.object({}).passthrough()),
styles: z.record(z.string(), z.object({}).passthrough()),
}),
});

/**
* A Cutting is a collection of Figma data fetched over the REST API.
* It contains an array of `facets`, which record a data point fetched
* over the API, its type and when it was last fetched. It also contains
* a `data` object, with components, component sets, files, projects,
* styles and variables, which are filled by the Cutting when it calls
* facet endpoints. Each of these data objects is a dictionary where
* keys are ids (e.g. `fileKey` for `data.files`) and values are the
* item being represented. All types come from Figma except for File
* types, which are simplified from `GetFile` response bodies to exclude
* irrelevant data.
*/
export type Cutting = {
/**
* The Cutting's metadata.
*/
meta: z.infer<typeof CuttingSchema.shape.meta>;

/**
* The Cutting's array of facets.
*/
facets: z.infer<typeof CuttingSchema.shape.facets>;

/**
* The Cutting's stored data.
*/
data: {
components: Record<string, PublishedComponent>;
componentSets: Record<string, PublishedComponentSet>;
files: Record<string, File>;
localVariables: Record<string, LocalVariable>;
localVariableCollections: Record<string, LocalVariableCollection>;
projects: Record<string, Project>;
publishedVariables: Record<string, PublishedVariable>;
publishedVariableCollections: Record<string, PublishedVariableCollection>;
styles: Record<string, PublishedStyle>;
};
};

/**
* Checks if a data blob is a valid Cutting.
* @param blob The data to check.
* @returns Whether it is a valid Cutting.
*/
export function isCutting(blob: unknown): blob is Cutting {
log(`Cutting::isCutting: Checking out the following blob: ${JSON.stringify(blob)}`);
const outcome = CuttingSchema.safeParse(blob);

if (outcome.success) {
log(`Cutting::isCutting: Valid cutting ${printCutting(blob as Cutting)}.`);
return true;
} else {
log(`Cutting::isCutting: Invalid cutting.\n${printZodError(outcome.error)}`);
return false;
}
}
Loading

0 comments on commit 83627ce

Please sign in to comment.