使用 Go 實作「索引典生成器」應用程式

前言

本文製作一個可以利用 YAML 配置檔產生靜態網頁的 CLI 工具。

做法

建立一個 example.yaml 範例檔:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
---
title: Thesaurus
subjects:
- terms:
- text: social science concepts
preferred: true
notes:
- text: Concepts related to the study of institutions and functioning of human society and with the interpersonal relationships of individuals as members of society.

- terms:
- text: psychological concepts
preferred: true
- text: 心理學概念
parentRelationships:
- text: social science concepts
preferred: true
notes:
- text: Scientific concepts related to psychology.

- terms:
- text: emotion
preferred: true
- text: emotions
- text: 情緒
parentRelationships:
- text: psychological concepts
preferred: true
notes:
- text: Refers to a complex phenomena and quality of consciousness, featuring the synthesis or combination of subjective experiences and perceptions, expressive physiological and psychological behaviors, and the excitation or stimulation of the nervous system. Among psychological studies, the concept is associated with ideas on personality formation, rational and irrational thinking, and cognitive motivation.
- text: 意指意識所呈現的複雜現象及特性。情感涉及了合成或組合主觀經驗與感知、具表達意義的生理與心理行為、以及神經系統的興奮或刺激。在心理學研究中,情感的概念與個性形成、理性與非理性思維、以及認知動機有關。

建立 GO 結構體。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type Resource struct {
Title string `json:"title" yaml:"title"`
Subjects Subjects `json:"subjects" yaml:"subjects"`
}

type Subjects []*Subject

type Subject struct {
Terms Terms `json:"terms" yaml:"terms"`
ParentRelationships Terms `json:"parentRelationships,omitempty" yaml:"parentRelationships"`
Notes Notes `json:"notes,omitempty" yaml:"notes"`
}

type Terms []*Term

func (t *Terms) FirstPreferred() *Term {
for _, term := range *t {
if term.Preferred {
return term
}
}
return nil
}

type Term struct {
Text string `json:"text" yaml:"text"`
Preferred bool `json:"preferred" yaml:"preferred"`
}

type Notes []*Note

type Note struct {
Text string `json:"text" yaml:"text"`
}

建立 NewResource 方法,將 YAML 檔反序列化到結構中。

1
2
3
4
5
6
7
8
9
10
11
12
13
func NewResource(filename string) (r *Resource, err error) {
var b []byte
b, err = ioutil.ReadFile(filename)
if err != nil {
return
}
go helper.StartPermanentProgress(1200, "1/3", "Unmarshalling thesaurus file...")
defer helper.FinishPermanentProgress()
if err = yaml.Unmarshal(b, &r); err != nil {
return
}
return r, nil
}

建立 NewTree 方法,將列表結構的資料轉換成樹狀結構。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
func NewTree(source *Resource) (thesaurus *Tree, err error) {
helper.InitProgressBar(len(source.Subjects), "2/3", "Building thesaurus tree...")
thesaurus = &Tree{
Title: source.Title,
}
table := make(map[string]*Node, len(source.Subjects))
for i, subject := range source.Subjects {
preferredTerm := subject.Terms.FirstPreferred()
if preferredTerm == nil {
return nil, errors.New(fmt.Sprintf("preferred term missing (subject: #%d)", i+1))
}
if subject.ParentRelationships.FirstPreferred() == nil {
if thesaurus.Root != nil {
return nil, errors.New(fmt.Sprintf("preferred parent missing (subject: \"%s\")", preferredTerm.Text))
}
thesaurus.Root = NewNode(*subject)
table[preferredTerm.Text] = thesaurus.Root
if err := helper.ProgressBar.Add(1); err != nil {
return nil, err
}
continue
}
table[preferredTerm.Text] = nil
}
if thesaurus.Root == nil {
return nil, errors.New("root missing")
}
return thesaurus, buildTree(source.Subjects, table)
}

func buildTree(subjects Subjects, table map[string]*Node) (err error) {
var orphans Subjects
for i, subject := range subjects {
if subject.ParentRelationships.FirstPreferred() == nil {
continue
}
preferredTerm := subject.Terms.FirstPreferred()
if preferredTerm == nil {
return errors.New(fmt.Sprintf("preferred term missing (subject: #%d)", i+1))
}
preferredParent := subject.ParentRelationships.FirstPreferred()
parent, ok := table[preferredParent.Text]
if !ok {
return errors.New(fmt.Sprintf("preferred parent missing (subject: \"%s\")", preferredTerm.Text))
}
if parent != nil {
child := NewNode(*subject)
parent.AppendChild(child)
table[preferredTerm.Text] = child
if err := helper.ProgressBar.Add(1); err != nil {
return err
}
continue
}
orphans = append(orphans, subject)
}
if len(orphans) == len(subjects) {
return
}
return buildTree(orphans, table)
}

建立 Build 方法,將樹狀結構的資料寫至靜態檔案中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (b *Builder) Build(t *Tree) (err error) {
go helper.StartPermanentProgress(1200, "3/3", "Generating thesaurus assets...")
defer helper.FinishPermanentProgress()
b.SetTree(t)
if err = b.makeOutputDir(); err != nil {
return
}
if err = b.writeHTML(); err != nil {
return
}
if err = b.writeCSS(); err != nil {
return
}
if err = b.writeJS(); err != nil {
return
}
if err = b.writeJSON(); err != nil {
return
}
if err = b.writeMD(); err != nil {
return
}
return
}

assets 資料夾新增 index.html 範本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>__TITLE__</title>
<link rel="stylesheet" href="style.css">
<script src="main.js" defer></script>
</head>
<body>
<div id="spinner" class="hidden"></div>
<div id="app">
<h1 id="title"></h1>
<hr>
<form>
<label>
<input type="text" placeholder="Search" id="input" autocomplete="off" autofocus>
</label>
</form>
<ul id="root"></ul>
</div>
<template data-subject-template>
<li class="subject">
<div class="preferred-term"></div>
<div class="notes">
<!-- insert "data-note-template" -->
</div>
<ul class="children hidden">
<!-- insert "data-subject-template" -->
</ul>
</li>
</template>
<template data-note-template>
<div class="note">
<div class="note-text"></div>
</div>
</template>
</body>
</html>

assets 資料夾新增 main.js 範本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
const app = document.querySelector('#app');
const spinner = document.querySelector('#spinner');
const title = document.querySelector('#title');
const input = document.querySelector('#input');
const root = document.querySelector('#root');
const subjectTemplate = document.querySelector('[data-subject-template]');
const noteTemplate = document.querySelector('[data-note-template]');

/**
* @param {HTMLElement} target
* @param {Object} prop
* @param {Object} prop.subject
* @param {Object} prop.subject.terms
* @param {Array} prop.subject.terms[].text
* @param {Array} prop.subject.terms[].preferred
* @param {Object} prop.subject.notes
* @param {string} prop.subject.notes[].text
* @param {Array} prop.children
*/
const render = (target, prop) => {
const [subject] = subjectTemplate.content.cloneNode(true).children;
const [preferredTerm, notes, children] = subject.children;
prop.subject.terms.forEach((item) => {
if (item.preferred) {
preferredTerm.textContent = item.text;
preferredTerm.classList.add(prop?.children?.length ? 'preferred-term-expandable' : 'preferred-term-expanded');
}
});
prop.subject.notes?.forEach((item) => {
const [note] = noteTemplate.content.cloneNode(true).children;
const [text] = note.children;
text.textContent = item.text;
notes.append(note);
});
target.appendChild(subject);
setTimeout(() => prop?.children?.forEach((item) => render(children, item)), 0);
};

const search = (prop, input) => {
const subjects = [];
const terms = prop.subject.terms.filter((item) => {
if (item.text === input) {
return true;
}
return item.text.includes(input);
});
if (terms.length > 0) {
subjects.push(prop);
}
for (let i = 0; i < prop?.children?.length; i++) {
if (subjects.length > 50) {
break;
}
search(prop?.children[i], input).forEach((item) => subjects.push(item));
}
return subjects;
};

const toggleSpinner = async (delay = 0) => {
await new Promise((res) => setTimeout(() => res(), delay));
document.documentElement.classList.toggle('full-height');
spinner.classList.toggle('hidden');
app.classList.toggle('hidden');
};

let data;

(async () => {
await toggleSpinner();
data = await fetch('data.json').then((r) => r.json());
title.textContent = data.title;
render(root, data.root);
await toggleSpinner(1000);
})();

root.addEventListener('click', (e) => {
if (e.target.classList.contains('preferred-term-expandable')) {
e.target.parentElement.querySelector('.children').classList.toggle('hidden');
e.target.classList.toggle('preferred-term-expanded');
}
});

input.addEventListener('keyup', (e) => {
root.innerHTML = '';
if (input.value.trim().length > 1) {
search(data.root, input.value).forEach((item) => render(root, item));
return;
}
render(root, data.root);
});

assets 資料夾新增 style.css 範本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
html.full-height {
height: 100%;
}
html.full-height body {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
margin: 0;
}
.hidden {
display: none;
}
#title {
margin: 12px 24px;
}
form {
display: flex;
justify-content: end;
margin: 20px 24px;
}
ul {
list-style-type: none;
margin: 0;
padding: 0;
}
li {
margin: 20px 12px 20px 24px;
}
.preferred-term::before {
color: black;
content: "\25B6";
display: inline-block;
margin-right: calc(8px * .75);
transform: scale(.75);
}
.preferred-term-expanded::before {
transform: scale(.75) rotate(90deg);
}
.preferred-term-expandable {
cursor: pointer;
}
.note {
margin: 8px 24px;
}
#spinner {
animation: sk-rotateplane 1s infinite ease-in-out;
-webkit-animation: sk-rotateplane 1s infinite ease-in-out;
background-color: #333333;
height: 45px;
width: 45px;
}
@keyframes sk-rotateplane {
0% {
transform: perspective(120px) rotateX(0deg) rotateY(0deg);
-webkit-transform: perspective(120px) rotateX(0deg) rotateY(0deg);
}
50% {
transform: perspective(120px) rotateX(-179.9deg) rotateY(0deg);
-webkit-transform: perspective(120px) rotateX(-179.9deg) rotateY(0deg);
}
100% {
transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg);
-webkit-transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg);
}
}
@-webkit-keyframes sk-rotateplane {
0% {
-webkit-transform: perspective(120px);
}
50% {
-webkit-transform: perspective(120px) rotateY(180deg);
}
100% {
-webkit-transform: perspective(120px) rotateY(180deg) rotateX(180deg);
}
}

執行程式。

1
go run main.go -f example.yaml

程式碼