Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: node property accessors and html-tree getters #13

Merged
merged 1 commit into from
Mar 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions packages/figma-parser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@
"types": "./dist/index.d.ts",
"module": "./dist/index.js",
"devDependencies": {
"@types/mdast": "^4.0.1",
"@figma/rest-api-spec": "^0.10.0",
"@types/node": "^20.8.0",
"@types/picomatch": "^2.3.2",
"@types/unist": "^3.0.2",
"@typescript-eslint/eslint-plugin": "^6.13.2",
"@typescript-eslint/parser": "^6.13.2",
"typedoc": "^0.25.12",
"typedoc-plugin-markdown": "^3.17.1",
"typescript": "^5.0.2",
Expand All @@ -39,15 +42,8 @@
"wsrun": "^5.2.4"
},
"dependencies": {
"@figma/rest-api-spec": "^0.10.0",
"@typescript-eslint/eslint-plugin": "^6.13.2",
"@typescript-eslint/parser": "^6.13.2",
"flat": "^6.0.1",
"mdast-builder": "^1.1.1",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.1.0",
"hastscript": "^9.0.0",
"picomatch": "^2.3.1",
"remark": "^15.0.1",
"type-fest": "^4.12.0",
"unist-builder": "^4.0.0"
},
Expand Down Expand Up @@ -124,6 +120,16 @@
"types": "./dist/variables.d.cts",
"default": "./dist/variables.cjs"
}
},
"./contents": {
"import": {
"types": "./dist/contents.d.ts",
"default": "./dist/contents.js"
},
"require": {
"types": "./dist/contents.d.cts",
"default": "./dist/contents.cjs"
}
}
}
}
35 changes: 27 additions & 8 deletions packages/figma-parser/src/contents/content-node.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { Node, TextNode } from '@figma/rest-api-spec';
import { isTextNode } from '../document/types';
import { combineSchema } from '../shared/combine-schema.util';
import { isEmptyObject } from '../shared/is-empty-object.util';
import { isObject } from '../shared/is-object.util';
import { AbstractNode } from '../shared/node.abstract';
import { NodeBase } from '../shared/node.abstract';
import { isTextNode } from '../shared/types';
import type { Getter, ParseTreeOptions, TreeNode } from './types';
import { getNodeDecoratedText } from './utils';

export class ContentNode<T extends Node = Node> extends AbstractNode<T> {
export const isFauxNode = (node: TreeNode): node is TreeNode & { children: [TreeNode] } => {
return Object.keys(node).length === 1 && 'children' in node && node.children?.length === 1;
};

export class ContentNode<T extends Node = Node> extends NodeBase<T> {
private defaultGetters: Getter[] = [
{
test: (node) => node.children.length === 0,
get(node) {
return {
type: node.raw.type,
data: node.getRawContents(),
data: node.getRawChildrenText(),
};
},
},
Expand All @@ -37,12 +41,14 @@ export class ContentNode<T extends Node = Node> extends AbstractNode<T> {
async parseTree(options?: Partial<ParseTreeOptions>): Promise<TreeNode>;
async parseTree(getters?: Getter[]): Promise<TreeNode>;
async parseTree(getters?: Getter[], options?: Partial<ParseTreeOptions>): Promise<TreeNode>;
async parseTree(getters?: Getter[], options?: Partial<ParseTreeOptions>): Promise<TreeNode>;
async parseTree(gettersOrOptions?: Getter[] | Partial<ParseTreeOptions>, userOptions: Partial<ParseTreeOptions> = {}): Promise<TreeNode> {
const getters = Array.isArray(gettersOrOptions) ? gettersOrOptions : this.defaultGetters;
const options = isObject(gettersOrOptions) ? (gettersOrOptions as ParseTreeOptions) : userOptions;

const parseOptions: ParseTreeOptions = {
omitEmpty: true,
omitFauxNodes: true,
defaultGetter: () => ({}) as TreeNode,
...options,
};
Expand All @@ -55,7 +61,7 @@ export class ContentNode<T extends Node = Node> extends AbstractNode<T> {
return out as TreeNode;
}

if (out?.children) return out as TreeNode;
if (out?.children && out?.children.length > 0) return out as TreeNode;

if (this.children?.length > 0) {
out.children = await Promise.all(this.children.map(async (childNode) => await childNode.parseTree(getters, options)));
Expand All @@ -69,17 +75,30 @@ export class ContentNode<T extends Node = Node> extends AbstractNode<T> {
delete out.children;
}

if (isFauxNode(out)) {
return out.children[0];
}

return out as TreeNode;
}

/**
* Gets raw text without any formatting
*/
getRawText() {
if (!isTextNode(this)) return;

return this.characters;
}

/**
* Retrieves the concatenated raw text content of the node and its children, excluding any formatting.
* Useful for extracting plain text from a node tree.
*/
getRawContents() {
getRawChildrenText() {
const textNodes = Array.from(this.filterDeep(isTextNode)) as ContentNode<TextNode>[];

const contents = textNodes.map((node) => node.text()).filter(Boolean);
const contents = textNodes.map((node) => node.getRawText()).filter(Boolean);

return contents.join(`\n\n`);
}
Expand All @@ -88,7 +107,7 @@ export class ContentNode<T extends Node = Node> extends AbstractNode<T> {
* Retrieves the text content of the node and its children, formatted according to `getFormattedText()`.
* This method organizes text contents in a markdown-ish format, including basic styles and list formatting.
*/
getFormattedContents() {
getFormattedChildrenText() {
const textNodes = Array.from(this.filterDeep(isTextNode)) as ContentNode<TextNode>[];

const contents = textNodes.map((node) => node.getFormattedText()).filter(Boolean);
Expand Down
67 changes: 67 additions & 0 deletions packages/figma-parser/src/contents/html.tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { h } from 'hastscript';
import { isInstanceNode, isTextNode } from '../shared/types';
import { ContentNode } from './content-node';
import { Getter } from './types';

const getListItems = (node: ContentNode) => {
let items: string[] = [];

if (node.children.length === 1 && isTextNode(node.children[0])) {
const text = node.children[0].getRawText()!;
items = text.split('\n').filter(Boolean);
}

if (node.children.length > 1) {
items = node.children.filter(isTextNode).map((node) => node.getRawText()!);
}

return items;
};

const heading: Getter = {
test(node) {
return isInstanceNode(node) && ['Heading Lv.1', 'Heading Lv.2', 'Heading Lv.3', 'Heading Lv.4', 'Heading Lv.5', 'Heading Lv.6'].includes(node.name);
},
get(node) {
const depth = node.name.at(-1)!;

return h(`h${depth}`, [node.getRawChildrenText()]);
},
};

const unorderedList: Getter = {
test(node) {
return isInstanceNode(node) && node.name === 'UnorderedList';
},
get(node) {
const items = getListItems(node);
return h(
'ul',
items.map((item) => h('li', [item]))
);
},
};

const orderedList: Getter = {
test(node) {
return isInstanceNode(node) && node.name === 'OrderedList';
},
get(node) {
const items = getListItems(node);
return h(
'ol',
items.map((item) => h('li', [item]))
);
},
};

const paragraph: Getter = {
test(node) {
return isInstanceNode(node) && node.name === 'Paragraph';
},
get(node) {
return h('p', [node.getRawChildrenText()]);
},
};

export const htmlGetters = [heading, unorderedList, orderedList, paragraph];
15 changes: 7 additions & 8 deletions packages/figma-parser/src/contents/types.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import { TypeStyle } from '@figma/rest-api-spec';
import { OnPurposeAny } from '../types';
import type { Node } from 'unist';
import { ContentNode } from './content-node';

export interface TypeStyleTable {
[p: string]: TypeStyle;
}

export interface TreeNode {
type: string;
data?: OnPurposeAny;
children?: TreeNode[];
[k: string]: OnPurposeAny;
}
export type TreeNode = Node & { children?: Node[] };

export type GetterTestFn = (node: ContentNode) => boolean;
export type GetterGetFn = (node: ContentNode) => Omit<TreeNode, 'children'> & { children?: TreeNode[] | false };
export type GetterGetFn = (node: ContentNode) => TreeNode & { children?: TreeNode[] | false };

export type Getter = {
/**
Expand Down Expand Up @@ -88,6 +83,10 @@ export interface ParseTreeOptions {
* @default true
*/
omitEmpty: boolean;
/**
* Whether nodes that's only property is `children` should be skipped, or not.
*/
omitFauxNodes: boolean;
/**
* Default getter for nodes that don't pass the test of any of provided getters.
* By default it returns an empty object, which then is ommited, when `omitEmpty` is set to true.
Expand Down
4 changes: 2 additions & 2 deletions packages/figma-parser/src/document/get-document.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GetFileResponse } from '@figma/rest-api-spec';
import { DocumentNode, GetFileResponse } from '@figma/rest-api-spec';
import { FigmaApiInterface } from '../core/api';
import { SingleNode } from './single-node';

Expand All @@ -8,5 +8,5 @@ import { SingleNode } from './single-node';
export async function getDocument(host: FigmaApiInterface, fileId: string) {
const file = await host.request<GetFileResponse>(`files/${fileId}`);

return new SingleNode(file.document);
return new SingleNode<DocumentNode>(file.document);
}
4 changes: 2 additions & 2 deletions packages/figma-parser/src/document/single-node.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Node } from '@figma/rest-api-spec';
import { AbstractNode } from '../shared/node.abstract';
import { NodeBase } from '../shared/node.abstract';
import { PathBreadcrumb } from './types';

export type WalkPredicate = (node: SingleNode, path: PathBreadcrumb[]) => void;

/**
* Represents a single node in a Figma file, providing utilities for navigation, search, and data extraction.
*/
export class SingleNode<T extends Node = Node> extends AbstractNode<T> {}
export class SingleNode<T extends Node = Node> extends NodeBase<T> {}
50 changes: 1 addition & 49 deletions packages/figma-parser/src/document/types.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,4 @@
import type {
BooleanOperationNode,
CanvasNode,
ComponentNode,
ComponentSetNode,
DocumentNode,
EllipseNode,
FrameNode,
GroupNode,
InstanceNode,
LineNode,
Node,
RectangleNode,
RegularPolygonNode,
SliceNode,
StarNode,
TextNode,
VectorNode,
} from '@figma/rest-api-spec';
import { AbstractNode } from '../shared/node.abstract';
import { OnPurposeAny } from '../types';
import { Node } from '@figma/rest-api-spec';
import { SingleNode } from './single-node';

export interface PathBreadcrumb {
Expand All @@ -28,17 +8,6 @@ export interface PathBreadcrumb {

export type FigmaNodeId = `${number}:${number}` | string;

export const hasChildren = (node: Node): node is Node & { children: OnPurposeAny } => !!node && 'children' in node && Array.isArray(node.children) && node.children.length > 0;

export const isFigmaNodeId = (value: string): value is FigmaNodeId => /\d+:\d+/.test(value);

export const nodeTypes = ['DOCUMENT', 'CANVAS', 'FRAME', 'GROUP', 'VECTOR', 'BOOLEAN_OPERATION', 'STAR', 'LINE', 'ELLIPSE', 'REGULAR_POLYGON', 'RECTANGLE', 'TEXT', 'SLICE', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE'];

export const isNode = (value: object): value is Node => {
if (!value) return false;
return 'id' in value && 'name' in value && 'type' in value && nodeTypes.includes(value.type as string);
};

export const isSingleNode = (value: unknown): value is Node => {
if (!value) return false;
return value instanceof SingleNode;
Expand All @@ -48,20 +17,3 @@ export interface GlobSearchNodes {
node: SingleNode;
path: PathBreadcrumb[];
}

export const isDocumentNode = (node: AbstractNode): node is AbstractNode<DocumentNode> => !!node && node.raw.type === 'DOCUMENT';
export const isCanvasNode = (node: AbstractNode): node is AbstractNode<CanvasNode> => !!node && node.raw.type === 'CANVAS';
export const isFrameNode = (node: AbstractNode): node is AbstractNode<FrameNode> => !!node && node.raw.type === 'FRAME';
export const isGroupNode = (node: AbstractNode): node is AbstractNode<GroupNode> => !!node && node.raw.type === 'GROUP';
export const isVectorNode = (node: AbstractNode): node is AbstractNode<VectorNode> => !!node && node.raw.type === 'VECTOR';
export const isBooleanOperationNode = (node: AbstractNode): node is AbstractNode<BooleanOperationNode> => !!node && node.raw.type === 'BOOLEAN_OPERATION';
export const isStarNode = (node: AbstractNode): node is AbstractNode<StarNode> => !!node && node.raw.type === 'STAR';
export const isLineNode = (node: AbstractNode): node is AbstractNode<LineNode> => !!node && node.raw.type === 'LINE';
export const isEllipseNode = (node: AbstractNode): node is AbstractNode<EllipseNode> => !!node && node.raw.type === 'ELLIPSE';
export const isRegularPolygonNode = (node: AbstractNode): node is AbstractNode<RegularPolygonNode> => !!node && node.raw.type === 'REGULAR_POLYGON';
export const isRectangleNode = (node: AbstractNode): node is AbstractNode<RectangleNode> => !!node && node.raw.type === 'RECTANGLE';
export const isTextNode = (node: AbstractNode): node is AbstractNode<TextNode> => !!node && node.raw.type === 'TEXT';
export const isSliceNode = (node: AbstractNode): node is AbstractNode<SliceNode> => !!node && node.raw.type === 'SLICE';
export const isComponentNode = (node: AbstractNode): node is AbstractNode<ComponentNode> => !!node && node.raw.type === 'COMPONENT';
export const isComponentSetNode = (node: AbstractNode): node is AbstractNode<ComponentSetNode> => !!node && node.raw.type === 'COMPONENT_SET';
export const isInstanceNode = (node: AbstractNode): node is AbstractNode<InstanceNode> => !!node && node.raw.type === 'INSTANCE';
Loading
Loading