-
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(cuttings): Add first draft of the cuttings package
- Loading branch information
Showing
18 changed files
with
740 additions
and
2,020 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import prettier from '@figmarine/config-prettier'; | ||
|
||
export default prettier; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
describe('@figmarine/cuttings', () => { | ||
it.only('', () => { }); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
Oops, something went wrong.