diff --git a/components.d.ts b/components.d.ts
index e31119b3..320ffeb8 100644
--- a/components.d.ts
+++ b/components.d.ts
@@ -78,6 +78,7 @@ declare module '@vue/runtime-core' {
Encryption: typeof import('./src/tools/encryption/encryption.vue')['default']
EtaCalculator: typeof import('./src/tools/eta-calculator/eta-calculator.vue')['default']
FavoriteButton: typeof import('./src/components/FavoriteButton.vue')['default']
+ FolderStructureDiagram: typeof import('./src/tools/folder-structure-diagram/folder-structure-diagram.vue')['default']
FormatTransformer: typeof import('./src/components/FormatTransformer.vue')['default']
GitMemo: typeof import('./src/tools/git-memo/git-memo.vue')['default']
'GitMemo.content': typeof import('./src/tools/git-memo/git-memo.content.md')['default']
@@ -159,6 +160,7 @@ declare module '@vue/runtime-core' {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
RsaKeyPairGenerator: typeof import('./src/tools/rsa-key-pair-generator/rsa-key-pair-generator.vue')['default']
+ SafelinkDecoder: typeof import('./src/tools/safelink-decoder/safelink-decoder.vue')['default']
SlugifyString: typeof import('./src/tools/slugify-string/slugify-string.vue')['default']
SpanCopyable: typeof import('./src/components/SpanCopyable.vue')['default']
SqlPrettify: typeof import('./src/tools/sql-prettify/sql-prettify.vue')['default']
diff --git a/package.json b/package.json
index fd6c02e6..3135138c 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,9 @@
"@tiptap/starter-kit": "2.1.6",
"@tiptap/vue-3": "2.0.3",
"@types/figlet": "^1.5.8",
+ "@types/lodash.defaultsdeep": "^4.6.9",
+ "@types/lodash.flattendeep": "^4.4.9",
+ "@types/lodash.last": "^3.0.9",
"@vicons/material": "^0.12.0",
"@vicons/tabler": "^0.12.0",
"@vueuse/core": "^10.3.0",
@@ -68,6 +71,9 @@
"jwt-decode": "^3.1.2",
"libphonenumber-js": "^1.10.28",
"lodash": "^4.17.21",
+ "lodash.defaultsdeep": "^4.6.1",
+ "lodash.flattendeep": "^4.4.0",
+ "lodash.last": "^3.0.0",
"marked": "^10.0.0",
"mathjs": "^11.9.1",
"mime-types": "^2.1.35",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bd6c38c9..a6f3ab31 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,15 @@ dependencies:
'@types/figlet':
specifier: ^1.5.8
version: 1.5.8
+ '@types/lodash.defaultsdeep':
+ specifier: ^4.6.9
+ version: 4.6.9
+ '@types/lodash.flattendeep':
+ specifier: ^4.4.9
+ version: 4.4.9
+ '@types/lodash.last':
+ specifier: ^3.0.9
+ version: 3.0.9
'@vicons/material':
specifier: ^0.12.0
version: 0.12.0
@@ -104,6 +113,15 @@ dependencies:
lodash:
specifier: ^4.17.21
version: 4.17.21
+ lodash.defaultsdeep:
+ specifier: ^4.6.1
+ version: 4.6.1
+ lodash.flattendeep:
+ specifier: ^4.4.0
+ version: 4.4.0
+ lodash.last:
+ specifier: ^3.0.0
+ version: 3.0.0
marked:
specifier: ^10.0.0
version: 10.0.0
@@ -2951,6 +2969,24 @@ packages:
'@types/lodash': 4.14.192
dev: false
+ /@types/lodash.defaultsdeep@4.6.9:
+ resolution: {integrity: sha512-pLtCFK0YkHfGtGLYLNMTbFB5/G5+RsmQCIbbHH8GOAXjv+gDkVilY98kILfe8JH2Kev0OCReYxp1AjxEjP8ixA==}
+ dependencies:
+ '@types/lodash': 4.14.200
+ dev: false
+
+ /@types/lodash.flattendeep@4.4.9:
+ resolution: {integrity: sha512-Oacs/ZMuMvVWkhMqvj+Spad457Beln5pnkauif+6s65fE2cSL7J7NoMfwkxjuQsOsr4DUCDH/iDbmuZo81Nypw==}
+ dependencies:
+ '@types/lodash': 4.14.200
+ dev: false
+
+ /@types/lodash.last@3.0.9:
+ resolution: {integrity: sha512-HMIEyk2LJWDumrHsuSh7ZokUs+xyXa0nZO/s3paaKjOvwrjBNoQHsM0UqcpmzEt7CJ29a16XNe3vr8buvHeMCg==}
+ dependencies:
+ '@types/lodash': 4.14.200
+ dev: false
+
/@types/lodash@4.14.192:
resolution: {integrity: sha512-km+Vyn3BYm5ytMO13k9KTp27O75rbQ0NFw+U//g+PX7VZyjCioXaRFisqSIJRECljcTv73G3i6BpglNGHgUQ5A==}
@@ -3351,7 +3387,7 @@ packages:
dependencies:
'@unhead/dom': 0.5.1
'@unhead/schema': 0.5.1
- '@vueuse/shared': 10.7.2(vue@3.3.4)
+ '@vueuse/shared': 10.9.0(vue@3.3.4)
unhead: 0.5.1
vue: 3.3.4
transitivePeerDependencies:
@@ -3993,10 +4029,10 @@ packages:
- vue
dev: false
- /@vueuse/shared@10.7.2(vue@3.3.4):
- resolution: {integrity: sha512-qFbXoxS44pi2FkgFjPvF4h7c9oMDutpyBdcJdMYIMg9XyXli2meFMuaKn+UMgsClo//Th6+beeCgqweT/79BVA==}
+ /@vueuse/shared@10.9.0(vue@3.3.4):
+ resolution: {integrity: sha512-Uud2IWncmAfJvRaFYzv5OHDli+FbOzxiVEQdLCKQKLyhz94PIyFC3CHcH7EDMwIn8NPtD06+PNbC/PiO0LGLtw==}
dependencies:
- vue-demi: 0.14.6(vue@3.3.4)
+ vue-demi: 0.14.7(vue@3.3.4)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
@@ -6694,6 +6730,18 @@ packages:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
dev: true
+ /lodash.defaultsdeep@4.6.1:
+ resolution: {integrity: sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==}
+ dev: false
+
+ /lodash.flattendeep@4.4.0:
+ resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==}
+ dev: false
+
+ /lodash.last@3.0.0:
+ resolution: {integrity: sha512-14mq7rSkCxG4XMy9lF2FbIOqqgF0aH0NfPuQ3LPR3vIh0kHnUvIYP70dqa1Hf47zyXfQ8FzAg0MYOQeSuE1R7A==}
+ dev: false
+
/lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
@@ -9151,8 +9199,8 @@ packages:
vue: 3.3.4
dev: false
- /vue-demi@0.14.6(vue@3.3.4):
- resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==}
+ /vue-demi@0.14.7(vue@3.3.4):
+ resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==}
engines: {node: '>=12'}
hasBin: true
requiresBuild: true
@@ -9442,6 +9490,7 @@ packages:
/workbox-google-analytics@7.0.0:
resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==}
+ deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained
dependencies:
workbox-background-sync: 7.0.0
workbox-core: 7.0.0
diff --git a/src/tools/folder-structure-diagram/folder-structure-diagram.vue b/src/tools/folder-structure-diagram/folder-structure-diagram.vue
new file mode 100644
index 00000000..c453d5ca
--- /dev/null
+++ b/src/tools/folder-structure-diagram/folder-structure-diagram.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/tools/folder-structure-diagram/index.ts b/src/tools/folder-structure-diagram/index.ts
new file mode 100644
index 00000000..ddf8fcc1
--- /dev/null
+++ b/src/tools/folder-structure-diagram/index.ts
@@ -0,0 +1,12 @@
+import { Folder } from '@vicons/tabler';
+import { defineTool } from '../tool';
+
+export const tool = defineTool({
+ name: 'Folder Structure Diagram',
+ path: '/folder-structure-diagram',
+ description: 'tree-like utility for generating ASCII folder structure diagrams',
+ keywords: ['folder', 'structure', 'diagram', 'tree', 'ascii'],
+ component: () => import('./folder-structure-diagram.vue'),
+ icon: Folder,
+ createdAt: new Date('2024-04-20'),
+});
diff --git a/src/tools/folder-structure-diagram/lib/FileStructure.ts b/src/tools/folder-structure-diagram/lib/FileStructure.ts
new file mode 100644
index 00000000..aee589a5
--- /dev/null
+++ b/src/tools/folder-structure-diagram/lib/FileStructure.ts
@@ -0,0 +1,20 @@
+/**
+ * Represents a single item in a file system
+ * (i.e. a file or a folder)
+ */
+export interface FileStructure {
+ /** The name of the file or folder */
+ name: string
+
+ /** If a folder, the contents of the folder */
+ children: FileStructure[]
+
+ /**
+ * The number of spaces in front of the name
+ * in the original source string
+ */
+ indentCount: number
+
+ /** The parent directory of this file or folder */
+ parent: FileStructure | null
+}
diff --git a/src/tools/folder-structure-diagram/lib/generate-tree.test.ts b/src/tools/folder-structure-diagram/lib/generate-tree.test.ts
new file mode 100644
index 00000000..8ab591a0
--- /dev/null
+++ b/src/tools/folder-structure-diagram/lib/generate-tree.test.ts
@@ -0,0 +1,165 @@
+import { describe, expect, it } from 'vitest';
+import { generateTree } from './generate-tree';
+import { mockInput } from './mock-input';
+import { parseInput } from './parse-input';
+
+describe('generateTree', () => {
+ it('returns an UTF-8 representation of the provided FileStructure object', () => {
+ const actual = generateTree(parseInput(mockInput));
+
+ const expected = `
+.
+├── my-app
+│ ├── src
+│ │ ├── index.html
+│ │ ├── main.ts
+│ │ └── main.scss
+│ ├── build
+│ │ ├── index.html
+│ │ ├── main.js
+│ │ └── main.css
+│ ├── .prettierrc.json
+│ ├── .gitlab-ci.yml
+│ └── README.md
+└── empty dir
+
+ `.trim();
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('returns an ASCII representation of the provided FileStructure object', () => {
+ const actual = generateTree(parseInput(mockInput), { charset: 'ascii' });
+
+ const expected = `
+.
+|-- my-app
+| |-- src
+| | |-- index.html
+| | |-- main.ts
+| | \`-- main.scss
+| |-- build
+| | |-- index.html
+| | |-- main.js
+| | \`-- main.css
+| |-- .prettierrc.json
+| |-- .gitlab-ci.yml
+| \`-- README.md
+\`-- empty dir
+
+ `.trim();
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('does not render lines for parent directories that have already printed all of their children', () => {
+ const input = `
+
+grandparent
+ parent
+ child
+ parent
+ child
+ grandchild
+
+ `;
+
+ const actual = generateTree(parseInput(input));
+
+ const expected = `
+.
+└── grandparent
+ ├── parent
+ │ └── child
+ └── parent
+ └── child
+ └── grandchild
+
+ `.trim();
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('appends a trailing slash to directories if trailingDirSlash === true', () => {
+ const input = `
+
+grandparent
+ parent/
+ child
+ parent//
+ child
+ grandchild
+
+ `;
+
+ const actual = generateTree(parseInput(input), { trailingDirSlash: true });
+
+ const expected = `
+.
+└── grandparent/
+ ├── parent/
+ │ └── child
+ └── parent//
+ └── child/
+ └── grandchild
+
+ `.trim();
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('prints each items\' full path if fullPath === true', () => {
+ const input = `
+
+grandparent
+ parent/
+ child
+ parent//
+ child
+ grandchild
+
+ `;
+
+ const actual = generateTree(parseInput(input), { fullPath: true });
+
+ const expected = `
+.
+└── ./grandparent
+ ├── ./grandparent/parent/
+ │ └── ./grandparent/parent/child
+ └── ./grandparent/parent//
+ └── ./grandparent/parent//child
+ └── ./grandparent/parent//child/grandchild
+
+ `.trim();
+
+ expect(actual).toEqual(expected);
+ });
+
+ it('does not render the root dot if rootDot === false', () => {
+ const input = `
+
+grandparent
+ parent
+ child
+ parent
+ child
+ grandchild
+
+ `;
+
+ const actual = generateTree(parseInput(input), { rootDot: false });
+
+ const expected = `
+grandparent
+├── parent
+│ └── child
+└── parent
+ └── child
+ └── grandchild
+
+ `.trim();
+
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/src/tools/folder-structure-diagram/lib/generate-tree.ts b/src/tools/folder-structure-diagram/lib/generate-tree.ts
new file mode 100644
index 00000000..59f530cd
--- /dev/null
+++ b/src/tools/folder-structure-diagram/lib/generate-tree.ts
@@ -0,0 +1,138 @@
+import type { RecursiveArray } from 'lodash';
+import defaultsDeep from 'lodash.defaultsdeep';
+import flattenDeep from 'lodash.flattendeep';
+import last from 'lodash.last';
+import type { FileStructure } from './FileStructure';
+import { LINE_STRINGS } from './line-strings';
+
+/**
+ * Represents all rendering options available
+ * when calling `generateTree`
+ */
+interface GenerateTreeOptions {
+ /**
+ * Which set of characters to use when
+ * rendering directory lines
+ */
+ charset?: 'ascii' | 'utf-8'
+
+ /**
+ * Whether or not to append trailing slashes
+ * to directories. Items that already include a
+ * trailing slash will not have another appended.
+ */
+ trailingDirSlash?: boolean
+
+ /**
+ * Whether or not to print the full
+ * path of the item
+ */
+ fullPath?: boolean
+
+ /**
+ * Whether or not to render a dot as the root of the tree
+ */
+ rootDot?: boolean
+}
+
+/** The default options if no options are provided */
+const defaultOptions: GenerateTreeOptions = {
+ charset: 'utf-8',
+ trailingDirSlash: false,
+ fullPath: false,
+ rootDot: true,
+};
+
+/**
+ * Generates an ASCII tree diagram, given a FileStructure
+ * @param structure The FileStructure object to convert into ASCII
+ * @param options The rendering options
+ */
+export function generateTree(structure: FileStructure,
+ options?: GenerateTreeOptions): string {
+ return flattenDeep([
+ getAsciiLine(structure, defaultsDeep({}, options, defaultOptions)),
+ structure.children.map(c => generateTree(c, options)) as RecursiveArray<
+ string
+ >,
+ ])
+ // Remove null entries. Should only occur for the very first node
+ // when `options.rootDot === false`
+ .filter(line => line != null)
+ .join('\n');
+}
+
+/**
+ * Returns a line of ASCII that represents
+ * a single FileStructure object
+ * @param structure The file to render
+ * @param options The rendering options
+ */
+function getAsciiLine(structure: FileStructure,
+ options: GenerateTreeOptions): string | null {
+ const lines = LINE_STRINGS[options.charset as string];
+
+ // Special case for the root element
+ if (!structure.parent) {
+ return options.rootDot ? structure.name : null;
+ }
+
+ const chunks = [
+ isLastChild(structure) ? lines.LAST_CHILD : lines.CHILD,
+ getName(structure, options),
+ ];
+
+ let current = structure.parent;
+ while (current && current.parent) {
+ chunks.unshift(isLastChild(current) ? lines.EMPTY : lines.DIRECTORY);
+ current = current.parent;
+ }
+
+ // Join all the chunks together to create the final line.
+ // If we're not rendering the root `.`, chop off the first 4 characters.
+ return chunks.join('').substring(options.rootDot ? 0 : lines.CHILD.length);
+}
+
+/**
+ * Returns the name of a file or folder according to the
+ * rules specified by the rendering rules
+ * @param structure The file or folder to get the name of
+ * @param options The rendering options
+ */
+function getName(structure: FileStructure,
+ options: GenerateTreeOptions): string {
+ const nameChunks = [structure.name];
+
+ // Optionally append a trailing slash
+ if (
+ // if the trailing slash option is enabled
+ options.trailingDirSlash
+ // and if the item has at least one child
+ && structure.children.length > 0
+ // and if the item doesn't already have a trailing slash
+ && !/\/\s*$/.test(structure.name)
+ ) {
+ nameChunks.push('/');
+ }
+
+ // Optionally prefix the name with its full path
+ if (options.fullPath && structure.parent && structure.parent) {
+ nameChunks.unshift(
+ getName(
+ structure.parent,
+ defaultsDeep({}, { trailingDirSlash: true }, options),
+ ),
+ );
+ }
+
+ return nameChunks.join('');
+}
+
+/**
+ * A utility function do determine if a file or folder
+ * is the last child of its parent
+ * @param structure The file or folder to test
+ */
+function isLastChild(structure: FileStructure): boolean {
+ return Boolean(structure.parent && last(structure.parent.children) === structure);
+}
diff --git a/src/tools/folder-structure-diagram/lib/line-strings.ts b/src/tools/folder-structure-diagram/lib/line-strings.ts
new file mode 100644
index 00000000..8177233a
--- /dev/null
+++ b/src/tools/folder-structure-diagram/lib/line-strings.ts
@@ -0,0 +1,33 @@
+/**
+ * Represents an object that contains the
+ * actual strings used to render the tree
+ */
+export interface LineStringSet {
+ /** The string to render immediately before non-last children */
+ CHILD: string
+
+ /** The string to render immediately before the last child */
+ LAST_CHILD: string
+
+ /** The string to render for parent directories */
+ DIRECTORY: string
+
+ /** The string to render for empty space */
+ EMPTY: string
+}
+
+/** Contains all strings for tree rendering */
+export const LINE_STRINGS: { [charset: string]: LineStringSet } = {
+ 'ascii': {
+ CHILD: '|-- ',
+ LAST_CHILD: '`-- ',
+ DIRECTORY: '| ',
+ EMPTY: ' ',
+ },
+ 'utf-8': {
+ CHILD: '├── ',
+ LAST_CHILD: '└── ',
+ DIRECTORY: '│ ',
+ EMPTY: ' ',
+ },
+};
diff --git a/src/tools/folder-structure-diagram/lib/mock-input.ts b/src/tools/folder-structure-diagram/lib/mock-input.ts
new file mode 100644
index 00000000..427051cb
--- /dev/null
+++ b/src/tools/folder-structure-diagram/lib/mock-input.ts
@@ -0,0 +1,19 @@
+// not using a template string in order
+// to have more control over whitespace
+export const mockInput = [
+ 'my-app',
+ ' src',
+ ' index.html',
+ ' main.ts',
+ ' main.scss',
+ ' - build',
+ ' - index.html',
+ ' main.js',
+ ' main.css',
+ '',
+ ' ',
+ ' .prettierrc.json',
+ ' .gitlab-ci.yml',
+ ' README.md',
+ 'empty dir',
+].join('\n');
diff --git a/src/tools/folder-structure-diagram/lib/parse-input.test.ts b/src/tools/folder-structure-diagram/lib/parse-input.test.ts
new file mode 100644
index 00000000..b9a5286b
--- /dev/null
+++ b/src/tools/folder-structure-diagram/lib/parse-input.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, it } from 'vitest';
+import type { FileStructure } from './FileStructure';
+import { mockInput } from './mock-input';
+import { parseInput, splitInput } from './parse-input';
+
+describe('parse-input', () => {
+ it('parses plain text input into a FileStructure object', () => {
+ const actual = parseInput(mockInput);
+
+ const root: FileStructure = {
+ name: '.',
+ children: [],
+ indentCount: -1,
+ parent: null,
+ };
+
+ const myApp: FileStructure = {
+ name: 'my-app',
+ children: [],
+ indentCount: 0,
+ parent: root,
+ };
+ root.children.push(myApp);
+
+ const src: FileStructure = {
+ name: 'src',
+ children: [],
+ indentCount: 2,
+ parent: myApp,
+ };
+ myApp.children.push(src);
+
+ const srcIndexHtml: FileStructure = {
+ name: 'index.html',
+ children: [],
+ indentCount: 4,
+ parent: src,
+ };
+ src.children.push(srcIndexHtml);
+
+ const mainTs: FileStructure = {
+ name: 'main.ts',
+ children: [],
+ indentCount: 4,
+ parent: src,
+ };
+ src.children.push(mainTs);
+
+ const mainScss: FileStructure = {
+ name: 'main.scss',
+ children: [],
+ indentCount: 3,
+ parent: src,
+ };
+ src.children.push(mainScss);
+
+ const build: FileStructure = {
+ name: 'build',
+ children: [],
+ indentCount: 2,
+ parent: myApp,
+ };
+ myApp.children.push(build);
+
+ const buildIndexHtml: FileStructure = {
+ name: 'index.html',
+ children: [],
+ indentCount: 4,
+ parent: build,
+ };
+ build.children.push(buildIndexHtml);
+
+ const mainJs: FileStructure = {
+ name: 'main.js',
+ children: [],
+ indentCount: 4,
+ parent: build,
+ };
+ build.children.push(mainJs);
+
+ const mainCss: FileStructure = {
+ name: 'main.css',
+ children: [],
+ indentCount: 4,
+ parent: build,
+ };
+ build.children.push(mainCss);
+
+ const prettierRcJson: FileStructure = {
+ name: '.prettierrc.json',
+ children: [],
+ indentCount: 2,
+ parent: myApp,
+ };
+ myApp.children.push(prettierRcJson);
+
+ const gitlabCiYml: FileStructure = {
+ name: '.gitlab-ci.yml',
+ children: [],
+ indentCount: 2,
+ parent: myApp,
+ };
+ myApp.children.push(gitlabCiYml);
+
+ const readmeMd: FileStructure = {
+ name: 'README.md',
+ children: [],
+ indentCount: 2,
+ parent: myApp,
+ };
+ myApp.children.push(readmeMd);
+
+ const emptyDir: FileStructure = {
+ name: 'empty dir',
+ children: [],
+ indentCount: 0,
+ parent: root,
+ };
+ root.children.push(emptyDir);
+
+ expect(actual).toEqual(root);
+ });
+});
+
+describe('splitInput', () => {
+ it('splits plain text into an array of File Structure objects', () => {
+ const actual = splitInput(mockInput);
+
+ const expected = [
+ { name: 'my-app', children: [], indentCount: 0, parent: null },
+ { name: 'src', children: [], indentCount: 2, parent: null },
+ { name: 'index.html', children: [], indentCount: 4, parent: null },
+ { name: 'main.ts', children: [], indentCount: 4, parent: null },
+ { name: 'main.scss', children: [], indentCount: 3, parent: null },
+ { name: 'build', children: [], indentCount: 2, parent: null },
+ { name: 'index.html', children: [], indentCount: 4, parent: null },
+ { name: 'main.js', children: [], indentCount: 4, parent: null },
+ { name: 'main.css', children: [], indentCount: 4, parent: null },
+ { name: '.prettierrc.json', children: [], indentCount: 2, parent: null },
+ { name: '.gitlab-ci.yml', children: [], indentCount: 2, parent: null },
+ { name: 'README.md', children: [], indentCount: 2, parent: null },
+ { name: 'empty dir', children: [], indentCount: 0, parent: null },
+ ];
+
+ expect(actual).toEqual(expected);
+ });
+});
diff --git a/src/tools/folder-structure-diagram/lib/parse-input.ts b/src/tools/folder-structure-diagram/lib/parse-input.ts
new file mode 100644
index 00000000..4bf2c068
--- /dev/null
+++ b/src/tools/folder-structure-diagram/lib/parse-input.ts
@@ -0,0 +1,81 @@
+import last from 'lodash.last';
+import type { FileStructure } from './FileStructure';
+
+/**
+ * Matches the whitespace in front of a file name.
+ * Also will match a markdown bullet point if included.
+ * For example, testing against " - hello" will return
+ * a positive match with the first capturing group
+ * with " - " and a second with " "
+ */
+const leadingWhitespaceAndBulletRegex = /^((\s*)(?:-\s)?)/;
+
+/** Matches lines that only contain whitespace */
+const onlyWhitespaceRegex = /^\s*$/;
+
+/** Used to split a block of text into individual lines */
+const newlineSplitterRegex = /[^\r\n]+/g;
+
+/**
+ * Translates a block of user-created text into
+ * a nested FileStructure structure
+ * @param input The plain-text input from the user
+ */
+export function parseInput(input: string): FileStructure {
+ const structures = splitInput(input);
+
+ const root: FileStructure = {
+ name: '.',
+ children: [],
+ indentCount: -1,
+ parent: null,
+ };
+
+ const path = [root];
+ for (const s of structures) {
+ while (last(path)!.indentCount >= s.indentCount) {
+ path.pop();
+ }
+
+ const parent = last(path) as FileStructure;
+ parent.children.push(s);
+ s.parent = parent;
+
+ path.push(s);
+ }
+
+ return root;
+}
+
+/**
+ * Splits a block of user-created text into
+ * individual, un-nested FileStructure objects.
+ * Used internally as part of `parseInput`.
+ * @param input The plain-text input from the user
+ */
+export function splitInput(input: string): FileStructure[] {
+ let lines = input.match(newlineSplitterRegex)?.map(m => m) || [];
+
+ // filter out empty lines
+ lines = lines.filter(l => !onlyWhitespaceRegex.test(l));
+
+ return lines.map((l) => {
+ const matchResult = leadingWhitespaceAndBulletRegex.exec(l);
+
+ if (!matchResult) {
+ throw new Error(
+ `Unable to execute leadingWhitespaceAndBulletRegex against string: "${l}"`,
+ );
+ }
+
+ const name = l.replace(matchResult[1], '');
+ const indentCount = matchResult[2].length;
+
+ return {
+ name,
+ children: [],
+ indentCount,
+ parent: null,
+ };
+ });
+}
diff --git a/src/tools/index.ts b/src/tools/index.ts
index aa861c93..a43ef985 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -6,6 +6,7 @@ import { tool as asciiTextDrawer } from './ascii-text-drawer';
import { tool as textToUnicode } from './text-to-unicode';
import { tool as safelinkDecoder } from './safelink-decoder';
+import { tool as folderStructureDiagram } from './folder-structure-diagram';
import { tool as pdfSignatureChecker } from './pdf-signature-checker';
import { tool as numeronymGenerator } from './numeronym-generator';
import { tool as macAddressGenerator } from './mac-address-generator';
@@ -128,6 +129,7 @@ export const toolsByCategory: ToolCategory[] = [
httpStatusCodes,
jsonDiff,
safelinkDecoder,
+ folderStructureDiagram,
],
},
{