Skip to content

Commit

Permalink
feat: initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
scarf005 committed Feb 13, 2024
0 parents commit 813946d
Show file tree
Hide file tree
Showing 16 changed files with 1,241 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
assets
dist
_site
8 changes: 8 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"editor.defaultFormatter": "denoland.vscode-deno",
"[javascript][typescript][typescriptreact][markdown][json][jsonc]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"deno.config": "./deno.jsonc",
"deno.enable": true
}
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# StackGraph

**StackGraph**는 JSX 컴포넌트 관계도와 메타데이터를 정적 분석해 시각화해요.

## 시작하기

### 데이터 준비하기

다음 형태의 JSON 파일을 준비해주세요.

```ts
export type LabelOption = {
flows: Record<string, {
type: "push" | "replace"
to: string
}[]>
clicks: Record<string, string[]>
shows: Record<string, string[]>
}
```
### 렌더링하기
```sh
$ git clone https://github.com/daangn/stackgraph
$ cd stackgraph

$ deno run -A render/build.ts <data.json 파일 경로>

$ deno task serve
```
8 changes: 8 additions & 0 deletions _config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import lume from "lume/mod.ts"

const site = lume({ src: "render" })

site.copy("index.html", "index.html")
site.copy("assets", "assets")

export default site
24 changes: 24 additions & 0 deletions deno.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"exclude": ["render/assets", "_site"],
"tasks": {
"lume": "echo \"import 'lume/cli.ts'\" | deno run --unstable -A -",
"build": "deno task lume",
"serve": "deno task lume -s"
},
"fmt": {
"semiColons": false,
"useTabs": true,
"proseWrap": "never"
},
"compilerOptions": {
"allowJs": false,
"exactOptionalPropertyTypes": true,
"lib": ["deno.window", "dom"],
"jsx": "precompile",
"jsxImportSource": "npm:preact",
"types": ["./main.d.ts", "lume/types.ts"]
},
"imports": {
"lume/": "https://deno.land/x/[email protected]/"
}
}
617 changes: 617 additions & 0 deletions deno.lock

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions graph/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { RealFileSystemHost } from "https://deno.land/x/[email protected]/common/mod.ts"

export class FilteredFSHost extends RealFileSystemHost {
constructor(readonly ignore: (path: string) => boolean) {
super()
}

override fileExists(filePath: string): Promise<boolean> {
if (this.ignore(filePath)) return Promise.resolve(false)
return super.fileExists(filePath)
}
override fileExistsSync(filePath: string): boolean {
if (this.ignore(filePath)) return false
return super.fileExistsSync(filePath)
}
override directoryExists(dirPath: string): Promise<boolean> {
if (this.ignore(dirPath)) return Promise.resolve(false)
return super.directoryExists(dirPath)
}
override directoryExistsSync(dirPath: string): boolean {
if (this.ignore(dirPath)) return false
return super.directoryExistsSync(dirPath)
}
}
59 changes: 59 additions & 0 deletions graph/graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
ReferencedSymbol,
ReferencedSymbolEntry,
SyntaxKind,
VariableDeclaration,
} from "https://deno.land/x/[email protected]/mod.ts"

type Optional<T> = [T] | []
const toOptional = <T>(x: T | undefined): Optional<T> => (x ? [x] : [])

export type Graph = Map<VariableDeclaration, VariableDeclaration[]>

const getTopmostVarDecl = (
ref: ReferencedSymbolEntry,
): Optional<VariableDeclaration> => {
const decl = ref
.getNode()
.getAncestors()
.findLast((x) => x.getKindName() === "VariableDeclaration")
?.asKind(SyntaxKind.VariableDeclaration)

return toOptional(decl)
}

export const getGraph = (vardecl: VariableDeclaration): Graph => {
const graph: Graph = new Map()

const getReferencedVarDecls = (
node: VariableDeclaration,
): VariableDeclaration[] => {
const file = node.getSourceFile()

// TODO: use isDefinition()?
const getActualReferences = (
symbol: ReferencedSymbol,
): ReferencedSymbolEntry[] =>
symbol
.getReferences()
.filter((ref) =>
ref.getSourceFile().getFilePath() !== file.getFilePath()
)
.filter((ref) =>
ref.getNode().getParent()?.getKindName() !== "ImportClause"
)

const varDecls = node.findReferences()
.flatMap(getActualReferences)
.flatMap(getTopmostVarDecl)

graph.set(node, varDecls)
return varDecls
}

let decls = [vardecl]
while (decls.length > 0) {
decls = decls.flatMap(getReferencedVarDecls)
}
return graph
}
60 changes: 60 additions & 0 deletions graph/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { distinct } from "https://deno.land/[email protected]/collections/distinct.ts"
import {
SourceFile,
VariableDeclaration,
} from "https://deno.land/x/[email protected]/mod.ts"
import { getGraph } from "./graph.ts"
import type { Graph } from "./graph.ts"

export type Relations<A, B> = Record<string, Omit<LinkedVarDecl<A, B>, "decl">>
export type LinkedVarDecl<A, B> = {
decl: VariableDeclaration
links: A
metas: B
}

export type Metadata = {
logShowEvents: string[]
logClickEvents: string[]
}

export type Dependencies = Record<string, string[]>

export type Rels = Record<string, Rel>
export type Rel = {
links: Record<"push" | "replace", string[]>
meta: Metadata
}

/**
* creates a reference graph
*/
export const serialize = (graph: Graph) =>
Array.from(graph.entries()).map(([decl, refs]) =>
[decl.getName(), distinct(refs.map((x) => x.getName()))] as const
)

type Option<A, B> = {
/**
* Filter and map links, metadatas, and variable declaration
*/
getLink: (node: VariableDeclaration) => LinkedVarDecl<A, B> | undefined
}

export const generateGraph = <A, B>({ getLink }: Option<A, B>) =>
(
files: SourceFile[],
): { dependencies: Dependencies; relations: Relations<A, B> } => {
const links = files.flatMap((file) =>
file.getVariableDeclarations().flatMap((decl) => getLink(decl) ?? [])
)

const relations = Object.fromEntries(
links.map(({ decl, ...relations }) => [decl.getName(), relations]),
)
const dependencies = Object.fromEntries(
links.flatMap(({ decl }) => serialize(getGraph(decl))),
)

return { dependencies, relations }
}
10 changes: 10 additions & 0 deletions render/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { transpile } from "https://deno.land/x/[email protected]/mod.ts"
import { toFileUrl } from "https://deno.land/[email protected]/path/to_file_url.ts"

if (import.meta.main) {
const path = toFileUrl(import.meta.dirname + "/main.ts")

const code = await transpile(path).then((res) => res.get(path.href)!)
console.log(code.length)
await Deno.writeTextFile(import.meta.dirname + "/assets/main.js", code)
}
27 changes: 27 additions & 0 deletions render/colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { crypto } from "https://deno.land/[email protected]/crypto/mod.ts"

const toYIQ = ({ r, g, b }: Record<"r" | "g" | "b", number>) =>
0.299 * r + 0.587 * g + 0.114 * b

const encoder = new TextEncoder()
const hashInner = (str: string) => {
const hash = crypto.subtle.digestSync("SHA-1", encoder.encode(str))
return new Uint8Array(hash)
}

/** hashes string with SHA-1 */
export const hash = (str: string): string => {
const hash = hashInner(str)
return Array.from(hash).map((n) => n.toString(16).padStart(2, "0")).join("")
}

export const hashRGB = (str: string) => {
const [r, g, b] = hashInner(str).slice(0, 3)
return { r, g, b }
}

export const colors = ({ r, g, b }: Record<"r" | "g" | "b", number>) =>
({
color: `rgba(${r}, ${g}, ${b}, 0.8)`,
textColor: toYIQ({ r, g, b }) > 128 ? "black" : "white",
}) as const
114 changes: 114 additions & 0 deletions render/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { GraphElement } from "https://deno.land/x/[email protected]/graph/custom/common/link.ts"
import type { GraphData } from "https://esm.sh/[email protected]"
import type { Opaque } from "https://raw.githubusercontent.com/sindresorhus/type-fest/main/source/opaque.d.ts"

import { ArrowGraphHashed } from "https://deno.land/x/[email protected]/graph/mod.ts"
import { Stream } from "https://deno.land/x/[email protected]/stream/mod.ts"
import { HashSet } from "https://deno.land/x/[email protected]/hashed/mod.ts"

import { colors, hashRGB } from "./colors.ts"
import { type LabelOption, mkToLabel } from "./label.ts"

export type Flows = Record<string, { type: "push" | "replace"; to: string }[]>

export type Id = Opaque<string, Node>

export type Node = {
id: Id
name: string
textColor: "white" | "black"
color: string
label: string
pathsInto: Id[]
pathsFrom: Id[]
}
export type Link = {
type: "push" | "replace"
source: Id
target: Id
}

export type Data = {
links: Link[]
nodes: Node[]
}

export const mkAllPathFrom =
<T>(graph: ArrowGraphHashed<T>) => (id: T): HashSet<T> => {
const visited = HashSet.builder<T>()
const rec = (id: T) => {
const paths = graph.getConnectionStreamFrom(id)
.map(([, to]) => to)
.filter((to) => !visited.has(to))
.toArray()

visited.addAll(paths)
paths.forEach(rec)
}
rec(id)
return visited.build()
}

export const mkAllPathInto =
<T>(graph: ArrowGraphHashed<T>) => (id: T): HashSet<T> => {
const visited = HashSet.builder<T>()
const rec = (id: T) => {
const paths = graph.getConnectionStreamTo(id)
.map(([from]) => from)
.filter((from) => !visited.has(from))
.toArray()

visited.addAll(paths)
paths.forEach(rec)
}
rec(id)
return visited.build()
}

if (import.meta.main) {
const [datapath] = Deno.args
const data = JSON.parse(await Deno.readTextFile(datapath)) as LabelOption
const flows = data.flows
const toLabel = mkToLabel(data)

const links: Link[] = Object.entries(flows)
.flatMap(([source, targets]) =>
targets.map(({ type, to: target }) => ({
source: source as Id,
target: target as Id,
type,
}))
)

const graph = Stream.fromObject(flows)
.flatMap(([source, targets]) =>
Stream.from(targets).map(({ to }) => [source, to] as GraphElement<Id>)
)
.reduce(ArrowGraphHashed.reducer())

const allPathFrom = mkAllPathFrom(graph)
const allPathInto = mkAllPathInto(graph)

const nodes: Node[] = Object.keys(flows)
.map((name) => {
const id = name as Id
const pathsInto = allPathInto(id).toArray()
const pathsFrom = allPathFrom(id).toArray()
const label = toLabel(name)
const color = colors(hashRGB(name))

return { id, name, ...color, pathsInto, pathsFrom, label }
})

await Deno.writeTextFile(
import.meta.dirname + "/assets/data.json",
JSON.stringify(
{ links, nodes } satisfies GraphData satisfies {
links: Link[]
nodes: Node[]
},
null,
2,
),
)
}
Loading

0 comments on commit 813946d

Please sign in to comment.