diff --git a/src/app/about/about.component.html b/src/app/about/about.component.html index eace09b0..290562d8 100644 --- a/src/app/about/about.component.html +++ b/src/app/about/about.component.html @@ -10,5 +10,5 @@

- + diff --git a/src/app/about/about.component.scss b/src/app/about/about.component.scss index a26616cf..b72db1ea 100644 --- a/src/app/about/about.component.scss +++ b/src/app/about/about.component.scss @@ -50,5 +50,9 @@ } } } + + app-description { + padding: paddings.$m; + } } } diff --git a/src/app/about/about.component.ts b/src/app/about/about.component.ts index df9a9f06..1a3d822c 100644 --- a/src/app/about/about.component.ts +++ b/src/app/about/about.component.ts @@ -1,6 +1,7 @@ import { Component, Inject } from '@angular/core'; import { METADATA } from '../common/injection-tokens'; import { Metadata } from '../metadata'; +import { ExtendedDescriptionTreeNode } from './description/extended-description-tree-node'; @Component({ selector: 'app-about', @@ -10,6 +11,9 @@ import { Metadata } from '../metadata'; export class AboutComponent { public realName = this.metadata.realName; public nickname = this.metadata.nickname; + public descriptionTree = ExtendedDescriptionTreeNode.fromNode( + this.metadata.descriptionTree, + ); constructor( @Inject(METADATA) private metadata: Metadata, diff --git a/src/app/about/description/component-tree.ts b/src/app/about/description/component-tree.ts new file mode 100644 index 00000000..429a37dc --- /dev/null +++ b/src/app/about/description/component-tree.ts @@ -0,0 +1,152 @@ +import { DescriptionComponent } from './description.component'; +import { ExtendedDescriptionTreeNode } from './extended-description-tree-node'; + +export class ComponentTreeNode { + public readonly children: ReadonlyArray + public readonly parent: ComponentTreeNode | null + public readonly tree: ComponentTree + private _index?: number | null + + private constructor( + public component: DescriptionComponent, + children: ReadonlyArray, + parent?: ComponentTreeNode, + ) { + this.parent = parent || null + this.tree = parent?.tree || new ComponentTree(this) + this.children = children + .map((component) => + new ComponentTreeNode( + component, + component.children.toArray() as unknown as ReadonlyArray, + this, + ), + ) + } + + public static fromRootComponent(component: DescriptionComponent) { + if(component.parent) { + throw new Error('Just a root component can create a tree node') + } + return new ComponentTreeNode( + component, + component.children.toArray() as unknown as ReadonlyArray, + ) + } + + public get dataNode(): ExtendedDescriptionTreeNode { + return this.component.node + } + + public findNodeByComponent(component: DescriptionComponent): ComponentTreeNode | undefined { + return this.children.find((node) => node.component === component) + } + + public nextFocusable(): DescriptionComponent | undefined { + if (this.dataNode.isBranch && this.component.isExpanded) { + return this.children[0].component + } + + if (this.nextSibling) { + return this.nextSibling?.component + } + + return this.nextNode?.component + } + + private get nextSibling(): ComponentTreeNode | undefined { + if (this.index === null || this.index === this.parent!.children.length - 1) { + return + } + + return this.parent!.children[this.index + 1] + } + + private get index(): number | null { + if (this._index === undefined) { + this._index = this._findIndex() + } + + return this._index + } + + private _findIndex(): number | null { + if (!this.parent) { + return null + } + + const index = this.parent.children + .findIndex((node) => node == this) + if (index == -1) { + throw new Error("Can't find self as parent's child") + } + return index + } + + private get nextNode(): ComponentTreeNode | undefined { + return this.parent?.nextSibling || this.parent?.nextNode + } + + public previousFocusable(): DescriptionComponent | undefined { + if (this.previousSibling?.dataNode.isBranch && this.previousSibling.component.isExpanded) { + return this.lastFocusableChild(this.previousSibling)?.component + } + + return this.previousSibling?.component || this.parent?.component + } + + private get previousSibling(): ComponentTreeNode | undefined { + if (this.index === null || this.index === 0) { + return + } + + return this.parent!.children[this.index - 1] + } + + public lastFocusableChild(node: ComponentTreeNode): ComponentTreeNode | undefined { + if (!node.dataNode.isBranch || !node.component.isExpanded) { + return node + } + for (let i = node.children.length - 1; i >= 0; i--) { + const child = node.children[i] + const lastChildFocusable = this.lastFocusableChild(child) + if (lastChildFocusable) { + return this.lastFocusableChild(child) + } + } + return + } +} + +export class ComponentTree { + constructor( + public root: ComponentTreeNode, + ) { + } + private _firstFocusable!: DescriptionComponent; + + public get firstFocusable(): DescriptionComponent | undefined { + if (!this._firstFocusable) { + const firstFocusable = this._getFirstFocusableComponent(this.root) + if (!firstFocusable) { + throw new Error('No focusable first component found') + } + this._firstFocusable = firstFocusable + } + return this._firstFocusable + } + + private _getFirstFocusableComponent(node: ComponentTreeNode): DescriptionComponent | undefined { + const firstFocusableDataNode = this.root.dataNode.tree.firstFocusable + if(node.dataNode === firstFocusableDataNode) { + return node.component + } + return node.children + .find((child) => child.dataNode == firstFocusableDataNode) + ?.component + } + + public get lastFocusable(): DescriptionComponent | undefined { + return this.root.lastFocusableChild(this.root)?.component + } +} diff --git a/src/app/about/description/description.component.html b/src/app/about/description/description.component.html index ec467e29..461093c5 100644 --- a/src/app/about/description/description.component.html +++ b/src/app/about/description/description.component.html @@ -1,17 +1,84 @@ - - - -
    -
  • -
    - {{ line.symbol }} - -
    - - -
  • -
+ +
+ {{ node.data.symbol }} + +
+ + +
+
+
    + + +
  • + + +
  • +
diff --git a/src/app/about/description/description.component.scss b/src/app/about/description/description.component.scss index 66064615..d16c3d0a 100644 --- a/src/app/about/description/description.component.scss +++ b/src/app/about/description/description.component.scss @@ -4,31 +4,15 @@ :host { display: flex; flex-direction: column; - padding: paddings.$m; + gap: margins.$m; - ul { - display: flex; - flex-direction: column; - gap: margins.$m; - } - - ul > li { - display: flex; - flex-direction: column; - gap: margins.$m; - } - - // Indentation for nested lists - ul ul { - margin-left: margins.$l; - } - - .line { + .data { display: flex; align-items: center; word-break: break-word; font-size: 1.5rem; + // 's come from content, and don't have the Angular's added ng_ attr ::ng-deep a { text-decoration-line: underline; text-decoration-thickness: 1px; @@ -45,6 +29,56 @@ 'GRAD' 200, 'opsz' 24 } + + button { + background: transparent; + color: inherit; + border: none; + cursor: pointer; + vertical-align: -3px; + } + + } + + ul { + display: flex; + flex-direction: column; + gap: margins.$m; + } + + // Indentation for nested lists + ul ul { + margin-left: margins.$l; + } + + + // Collapsible + .data.actionable { + cursor: pointer; + } + + &[aria-expanded="true"] { + > .data button.expand { + display: none; + } + + > .data button.collapse { + display: block; + } + } + + &[aria-expanded="false"] { + > .data button.expand { + display: block; + } + + > .data button.collapse { + display: none; + } + + > ul { + display: none; + } } // Content-specific customizations @@ -53,7 +87,8 @@ text-decoration-color: currentColor; } - > ul > li > ul > li > .line { + // TODO: can be replaced when just those are collapsible + ul[role="tree"] > li > app-description > ul > li > app-description > .data { font-weight: bold; } } diff --git a/src/app/about/description/description.component.spec.ts b/src/app/about/description/description.component.spec.ts index 110c631d..ec5be43d 100644 --- a/src/app/about/description/description.component.spec.ts +++ b/src/app/about/description/description.component.spec.ts @@ -1,41 +1,30 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { MockProvider } from 'ng-mocks'; import { MATERIAL_SYMBOLS_CLASS } from '../../../test/constants'; -import { METADATA } from '../../common/injection-tokens'; -import { Build, Code, History } from '../../material-symbols'; -import { DescriptionLine, Metadata } from '../../metadata'; +import { Code } from '../../material-symbols'; +import { DescriptionTreeNode, DescriptionTreeNodeData } from '../../metadata'; import { DescriptionComponent } from './description.component'; +import { ExtendedDescriptionTreeNode } from './extended-description-tree-node'; describe('DescriptionComponent', () => { let component: DescriptionComponent; let fixture: ComponentFixture; - const fakeDescriptionLines: ReadonlyArray = [ - {symbol: Code, html: 'Line 1 HTML', text: 'Line 1 Text'}, - { - symbol: Build, html: 'Line 2 HTML', text: 'Line 2 Text', lines: [ - {symbol: History, html: 'Line 2.1 HTML', text: 'Line 2.1 Text'}, - ], - }, - ] - const allFakeDescriptionLines = fakeDescriptionLines - .flatMap((line) => line.lines && line.lines.length ? [line, ...line.lines] : line) - const fakeMetadata: Metadata = ({ - descriptionLines: fakeDescriptionLines, - } as Pick) as Metadata; + const fakeTreeNode: DescriptionTreeNode = new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Code, + html: 'Line 1 HTML', + text: 'Line 1 Text', + }), + }); beforeEach(() => { TestBed.configureTestingModule({ - declarations: [ - DescriptionComponent, - ], - providers: [ - MockProvider(METADATA, fakeMetadata), - ], + declarations: [DescriptionComponent], }); fixture = TestBed.createComponent(DescriptionComponent); component = fixture.componentInstance; + component.node = ExtendedDescriptionTreeNode.fromNode(fakeTreeNode) fixture.detectChanges(); }); @@ -43,17 +32,13 @@ describe('DescriptionComponent', () => { expect(component).toBeTruthy(); }); - it('should contain all description lines with its symbol and text', () => { - const lineElements = fixture.debugElement.queryAll(By.css('.line')); - expect(lineElements.length).toBe(allFakeDescriptionLines.length); - lineElements.forEach((lineElement, index) => { - const descriptionLine= allFakeDescriptionLines[index]; + it('should contain description data with its symbol and html', () => { + const dataElement = fixture.debugElement.query(By.css('.data')); - const materialSymbolSpan = lineElement.query(By.css( `.${MATERIAL_SYMBOLS_CLASS}`)) - expect(materialSymbolSpan.nativeElement.textContent).withContext(`item ${index} symbol`).toEqual(descriptionLine.symbol) + const materialSymbolSpan = dataElement.query(By.css(`.${MATERIAL_SYMBOLS_CLASS}`)) + expect(materialSymbolSpan.nativeElement.textContent).withContext(`symbol`).toEqual(fakeTreeNode.data?.symbol) - const textSpan = lineElement.query(By.css('.content')) - expect(textSpan.nativeElement.innerHTML).withContext(`item ${index} html`).toEqual(descriptionLine.html) - }); + const textSpan = dataElement.query(By.css('.content')) + expect(textSpan.nativeElement.innerHTML).withContext(`html`).toEqual(fakeTreeNode.data?.html) }) }); diff --git a/src/app/about/description/description.component.ts b/src/app/about/description/description.component.ts index cc0dab60..943b5524 100644 --- a/src/app/about/description/description.component.ts +++ b/src/app/about/description/description.component.ts @@ -1,7 +1,8 @@ -import { Component, Inject } from '@angular/core'; +import { Component, ElementRef, HostBinding, Input, QueryList, ViewChildren } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; -import { METADATA } from '../../common/injection-tokens'; -import { Metadata } from '../../metadata'; +import { ExpandLess, ExpandMore } from '../../material-symbols'; +import { ComponentTreeNode } from './component-tree'; +import { ExtendedDescriptionTreeNode } from './extended-description-tree-node'; @Component({ selector: 'app-description', @@ -9,9 +10,170 @@ import { Metadata } from '../../metadata'; styleUrls: ['./description.component.scss'], }) export class DescriptionComponent { + @Input({required: true}) node!: ExtendedDescriptionTreeNode; + @Input() parent: DescriptionComponent | undefined; + @ViewChildren("children") children!: QueryList; + + protected readonly MaterialSymbol = { + ExpandMore, + ExpandLess, + } + protected readonly DATA_ELEMENT_CLASS_SELECTOR = '.data' + private DEFAULT_EXPANDED = true; + private _expanded: boolean = this.DEFAULT_EXPANDED; + private _focused?: boolean + constructor( - @Inject(METADATA) protected metadata: Metadata, protected sanitizer: DomSanitizer, + private elementRef: ElementRef, ) { } + + @HostBinding('attr.aria-expanded') + public get isExpanded(): boolean | undefined { + if (this.node.isLeaf) { + return undefined; + } + return this._expanded + } + + public get listRole(): 'tree' | 'group' { + return this.parent ? 'group' : 'tree' + } + + public get listItemRole(): 'treeitem' { + return 'treeitem' + } + + // TODO: Build trees from certain depth onwards + //public INDENT_LEVEL_OF_FIRST_TREE = 1; + + public get tabIndex(): 0 | -1 { + if (this._focused === undefined) { + return this.node.isFirstFocusable ? 0 : -1 + } + + return this._focused ? 0 : -1 + } + + private _componentNode!: ComponentTreeNode; + + protected get componentNode(): ComponentTreeNode { + if (!this._componentNode) { + this._componentNode = this._getComponentNode() + } + return this._componentNode + } + + public onClick() { + this.toggleCollapse() + } + + public onKeydownArrowRight() { + if (this.node.isLeaf) { + return + } + + if (!this._expanded) { + this.expand() + return + } + + this.moveFocusTo(this.componentNode.nextFocusable()) + } + + public onKeydownArrowLeft() { + if (this.node.isBranch && this._expanded) { + this.collapse() + return + } + + // TODO: empty root node case + if (this.node.parent && (this.node.isLeaf || !this._expanded)) { + this.moveFocusTo(this.parent) + } + } + + public onKeydownArrowDown() { + this.moveFocusTo(this.componentNode.nextFocusable()) + } + + public onKeydownArrowUp() { + this.moveFocusTo(this.componentNode.previousFocusable()) + } + + public onKeydownHome() { + this.moveFocusTo(this.componentNode.tree.firstFocusable) + } + + public onKeydownEnd() { + this.moveFocusTo(this.componentNode.tree.lastFocusable) + } + + public onKeydownAsterisk() { + if (this.componentNode.parent) { + this.componentNode.parent.children.forEach((child) => { + child.component.expand() + }) + } + } + + // Extra :P + public onKeydownShiftAsterisk() { + if (this.componentNode.parent) { + this.componentNode.parent.children.forEach((child) => { + child.component.collapse() + }) + } + } + + public focus() { + this.elementRef.nativeElement + .querySelector(this.DATA_ELEMENT_CLASS_SELECTOR).focus() + this._focused = true + } + + private _getComponentNode(): ComponentTreeNode { + if (!this.parent) { + return ComponentTreeNode.fromRootComponent(this) + } + const componentNode = this.parent.componentNode.findNodeByComponent(this) + if (!componentNode) { + throw new Error("Can't find component node from parent") + } + return componentNode + } + + private unfocus() { + this._focused = false + } + + private moveFocusTo(component?: DescriptionComponent) { + if (!component) { + return + } + this.unfocus() + component.focus() + } + + + private toggleCollapse() { + this._expanded ? this.collapse() : this.expand() + } + + private expand() { + if (this.node.isLeaf) { + return + } + + this._expanded = true + } + + private collapse() { + if (this.node.isLeaf) { + return + } + + this._expanded = false + } } diff --git a/src/app/about/description/extended-description-tree-node.ts b/src/app/about/description/extended-description-tree-node.ts new file mode 100644 index 00000000..4d35d4fe --- /dev/null +++ b/src/app/about/description/extended-description-tree-node.ts @@ -0,0 +1,68 @@ +import { DescriptionTreeNode } from '../../metadata'; + +export class ExtendedDescriptionTreeNode extends DescriptionTreeNode { + public override children: ReadonlyArray + public parent?: ExtendedDescriptionTreeNode + public tree: ExtendedDescriptionTree + + private constructor( + {base, parent}: { + base: DescriptionTreeNode, + parent?: ExtendedDescriptionTreeNode, + }) { + super({data: base.data, children: base.children}); + + this.parent = parent + this.tree = parent?.tree ?? new ExtendedDescriptionTree(this) + this.children = base.children + .map( + (base) => new ExtendedDescriptionTreeNode( + {base, parent: this}, + ), + ) + } + + public static fromNode(base: DescriptionTreeNode) { + return new ExtendedDescriptionTreeNode({base: base}) + } + + public get isLeaf(): boolean { + return this.children.length == 0 + } + + public get isBranch(): boolean { + return this.children.length > 0 + } + + public get isFirstFocusable(): boolean { + return this.tree.firstFocusable === this + } +} + +export class ExtendedDescriptionTree { + constructor( + public root: ExtendedDescriptionTreeNode, + ) { + } + private _firstFocusableNode!: ExtendedDescriptionTreeNode; + + public get firstFocusable(): ExtendedDescriptionTreeNode | undefined { + if (!this._firstFocusableNode) { + const firstFocusableNode = this._getFirstFocusableNode(this.root) + if (!firstFocusableNode) { + throw new Error('No focusable first node found') + } + this._firstFocusableNode = firstFocusableNode; + } + return this._firstFocusableNode + } + + private _getFirstFocusableNode(node: ExtendedDescriptionTreeNode): ExtendedDescriptionTreeNode | undefined { + if (node.data) { + return node + } + return node.children.find( + (childrenNode) => this._getFirstFocusableNode(childrenNode), + ) + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d366f98d..5c054afe 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -4,22 +4,24 @@ import { BrowserModule } from '@angular/platform-browser'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { SeoModule } from "@ngaox/seo"; import { environment } from '../environments'; +import { AboutComponent } from './about/about.component'; +import { ContactSocialIconsComponent } from './about/contact-social-icons/contact-social-icons.component'; +import { + ContactTraditionalIconsComponent, +} from './about/contact-traditional-icons/contact-traditional-icons.component'; +import { DescriptionComponent } from './about/description/description.component'; +import { ProfilePictureComponent } from './about/profile-picture/profile-picture.component'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { ContactsComponent } from './contacts/contacts.component'; +import { HeaderComponent } from './header/header.component'; import { JsonldMetadataComponent } from './jsonld-metadata/jsonld-metadata.component'; import { METADATA } from './metadata'; import { NavigationTabsComponent } from './navigation-tabs/navigation-tabs.component'; import { NoScriptComponent } from './no-script/no-script.component'; -import { AboutComponent } from './about/about.component'; import { ReleaseInfoComponent } from './release-info/release-info.component'; import { SocialComponent } from './social/social.component'; -import { HeaderComponent } from './header/header.component'; -import { ProfilePictureComponent } from './about/profile-picture/profile-picture.component'; -import { ContactTraditionalIconsComponent } from './about/contact-traditional-icons/contact-traditional-icons.component'; -import { ContactSocialIconsComponent } from './about/contact-social-icons/contact-social-icons.component'; -import { DescriptionComponent } from './about/description/description.component'; @NgModule({ declarations: [ diff --git a/src/app/metadata.ts b/src/app/metadata.ts index 774bb9d3..dc6ddc60 100644 --- a/src/app/metadata.ts +++ b/src/app/metadata.ts @@ -1,13 +1,16 @@ import { Api, Apps, - Build, Cloud, + Build, + Cloud, Code, - Database, DeployedCode, + Database, + DeployedCode, Dns, History, Login, - Robot2, Security, + Robot2, + Security, Smartphone, Terminal, Web, @@ -28,7 +31,17 @@ const YEARS_OF_EXPERIENCE = Math.abs( new Date(TIMESTAMP_DIFF).getUTCFullYear() - 1970, ) -class DescriptionLineImpl implements DescriptionLine { +export class DescriptionTreeNode { + public data?: DescriptionTreeNodeData + public children: ReadonlyArray + + constructor({data, children}: { data?: DescriptionTreeNodeData, children?: ReadonlyArray }) { + this.data = data; + this.children = children ?? []; + } +} + +export class DescriptionTreeNodeData { public symbol: string; public html: string; public text: string; @@ -37,7 +50,7 @@ class DescriptionLineImpl implements DescriptionLine { symbol: string, html: string, text?: string - }, public lines?: ReadonlyArray) { + }) { this.symbol = symbol; this.html = html; this.text = text ?? @@ -46,98 +59,127 @@ class DescriptionLineImpl implements DescriptionLine { } } -const DESCRIPTION_LINES: ReadonlyArray = [ - new DescriptionLineImpl({ - symbol: Code, - html: 'Senior software engineer', - }, - ), - new DescriptionLineImpl({ - symbol: History, - html: `${YEARS_OF_EXPERIENCE}+ years \ +const DESCRIPTION_TREE_ROOT_NODE: DescriptionTreeNode = { + children: [ + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Code, + html: 'Senior software engineer', + }), + }), + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: History, + html: `${YEARS_OF_EXPERIENCE}+ years \ crafting:`, - }, [ - new DescriptionLineImpl({ - symbol: Apps, - html: 'Frontends', - }, - [ - new DescriptionLineImpl({ - symbol: Smartphone, - html: 'Cross-platform mobile apps', + }), + children: [ + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Apps, + html: 'Frontends', }), - new DescriptionLineImpl({ - symbol: Web, - html: 'Websites and web apps', - }), - ], - ), - new DescriptionLineImpl({ - symbol: Dns, - html: 'Backends', - }, [ - new DescriptionLineImpl({ - symbol: Api, - html: `HTTP REST APIs`, - }), - new DescriptionLineImpl({ - symbol: Database, - html: 'Relational databases', + children: [ + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Smartphone, + html: 'Cross-platform mobile apps', + }), + }), + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Web, + html: 'Websites and web apps', + }), + }), + ], }), - new DescriptionLineImpl({ - symbol: Login, - html: `AuthNZ: \ + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Dns, + html: 'Backends', + }), + children: [ + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Api, + html: `HTTP REST APIs`, + }), + }), + + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Database, + html: 'Relational databases', + }), + }), + + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Login, + html: `AuthNZ: \ OAuth 2 & SSO`, + }), + }), + ], }), - ]), - new DescriptionLineImpl({ - symbol: Build, - html: 'Infrastructure & tooling', - }, [ - new DescriptionLineImpl({ - symbol: Robot2, - html: 'CI/CD pipelines', - }), - new DescriptionLineImpl({ - symbol: Terminal, - html: 'Shell scripting', - }), - new DescriptionLineImpl({ - symbol: DeployedCode, - html: 'Containerization', - }), - new DescriptionLineImpl({ - symbol: Cloud, - html: `Cloud native & \ + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Build, + html: 'Infrastructure & tooling', + }), + children: [ + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Robot2, + html: 'CI/CD pipelines', + }), + }), + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Terminal, + html: 'Shell scripting', + }), + }), + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: DeployedCode, + html: 'Containerization', + }), + }), + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Cloud, + html: `Cloud native & \ IaC`, + }), + }), + ], }), - ]), - ], - ), - new DescriptionLineImpl({ - symbol: Security, - html: `With security in mind`, - }), -] - -export interface DescriptionLine { - symbol: string; - html: string; - text: string, - lines?: ReadonlyArray; + ], + }), + new DescriptionTreeNode({ + data: new DescriptionTreeNodeData({ + symbol: Security, + html: `With security in mind`, + }), + }) + ], } const DOMAIN_NAME = `${NICKNAME}.com`; -const descriptionLinesToText = (lines: ReadonlyArray): string => { +const descriptionTreeToText = (treeRootNode: DescriptionTreeNode): string => { const formatter = new Intl.ListFormat('en') - return lines.flatMap((line, index) => + return treeRootNode.children.flatMap((node, index) => [ - line.text.replace(/:$/, ''), - line.lines && line.lines.length ? ' ' + formatter.format( - line.lines.map((subLine) => subLine.text.toLowerCase()), + node.data?.text.replace(/:$/, '') ?? '', + node.children && node.children.length ? ' ' + formatter.format( + node.children + .map((subNode) => subNode.data?.text.toLowerCase() ?? '') + .filter((text) => !!text), ) : '', - index != lines.length - 1 ? '. ' : '', + index != treeRootNode.children.length - 1 ? '. ' : '', ], ).join('') } @@ -145,8 +187,8 @@ export const METADATA = { nickname: NICKNAME, realName: REAL_NAME, siteName: `${REAL_NAME} 🔗 @${NICKNAME}`, - descriptionLines: DESCRIPTION_LINES, - description: descriptionLinesToText(DESCRIPTION_LINES) + descriptionTree: DESCRIPTION_TREE_ROOT_NODE, + description: descriptionTreeToText(DESCRIPTION_TREE_ROOT_NODE) .concat('. Get to know me more here'), domainName: DOMAIN_NAME, authorUrl: new URL(`https://${DOMAIN_NAME}`), diff --git a/src/humans.txt.liquid b/src/humans.txt.liquid index 4d420320..5425ad60 100644 --- a/src/humans.txt.liquid +++ b/src/humans.txt.liquid @@ -2,7 +2,7 @@ Hi, I'm {{ realName }}. Also known as {{ nickname }} in the cyberspace. I happen Would you have guessed that? Well if you knew I'm a software engineer, maybe you did! Some things about me: {% liquid -for line in descriptionLines +for node in descriptionTree.nodes if forloop.first echo '┌' elsif forloop.last @@ -10,19 +10,19 @@ for line in descriptionLines else echo '├' endif - echo ' ' | append: line.text | append: '\n' - for subline in line.lines + echo ' ' | append: node.data.text | append: '\n' + for subnode in node.nodes echo '│ ' unless forloop.last echo '├' else echo '└' endunless - echo ' ' | append: subline.text | append: '\n' - assign last_subline = forloop.last - for subsubline in subline.lines + echo ' ' | append: subnode.data.text | append: '\n' + assign last_subnode = forloop.last + for subsubnode in subnode.nodes echo '│ ' - if last_subline + if last_subnode echo ' ' else echo '│' @@ -33,7 +33,7 @@ for line in descriptionLines else echo '└' endunless - echo ' ' | append: subsubline.text | append: '\n' + echo ' ' | append: subsubnode.data.text | append: '\n' endfor endfor endfor