ep_table_of_contents 0.3.89 → 0.3.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/backend-tests.yml +9 -31
- package/.github/workflows/frontend-tests.yml +3 -3
- package/.github/workflows/npmpublish.yml +3 -3
- package/locales/be-tarask.json +9 -0
- package/locales/da.json +9 -0
- package/locales/el.json +2 -0
- package/locales/fa.json +9 -0
- package/locales/ga.json +9 -0
- package/locales/nl.json +5 -3
- package/locales/pl.json +9 -0
- package/locales/ps.json +9 -0
- package/package.json +2 -1
- package/static/css/toc.css +6 -49
- package/static/js/toc.js +80 -37
- package/static/tests/backend/specs/exportHTML.ts +84 -0
- package/static/tests/frontend-new/playwright.config.js +15 -0
- package/static/tests/frontend-new/specs/toc.spec.js +84 -0
- package/tests/toc-numbering.test.js +90 -0
|
@@ -16,20 +16,20 @@ jobs:
|
|
|
16
16
|
steps:
|
|
17
17
|
-
|
|
18
18
|
name: Install libreoffice
|
|
19
|
-
uses: awalsh128/cache-apt-pkgs-action@v1.
|
|
19
|
+
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
|
20
20
|
with:
|
|
21
21
|
packages: libreoffice libreoffice-pdfimport
|
|
22
22
|
version: 1.0
|
|
23
23
|
-
|
|
24
24
|
name: Install etherpad core
|
|
25
|
-
uses: actions/checkout@
|
|
25
|
+
uses: actions/checkout@v4
|
|
26
26
|
with:
|
|
27
27
|
repository: ether/etherpad-lite
|
|
28
28
|
path: etherpad-lite
|
|
29
29
|
- uses: pnpm/action-setup@v3
|
|
30
30
|
name: Install pnpm
|
|
31
31
|
with:
|
|
32
|
-
version:
|
|
32
|
+
version: 10
|
|
33
33
|
run_install: false
|
|
34
34
|
- name: Get pnpm store directory
|
|
35
35
|
shell: bash
|
|
@@ -44,20 +44,9 @@ jobs:
|
|
|
44
44
|
${{ runner.os }}-pnpm-store-
|
|
45
45
|
-
|
|
46
46
|
name: Checkout plugin repository
|
|
47
|
-
uses: actions/checkout@
|
|
47
|
+
uses: actions/checkout@v4
|
|
48
48
|
with:
|
|
49
49
|
path: plugin
|
|
50
|
-
-
|
|
51
|
-
name: Determine plugin name
|
|
52
|
-
id: plugin_name
|
|
53
|
-
working-directory: ./plugin
|
|
54
|
-
run: |
|
|
55
|
-
npx -c 'printf %s\\n "::set-output name=plugin_name::${npm_package_name}"'
|
|
56
|
-
-
|
|
57
|
-
name: Link plugin directory
|
|
58
|
-
working-directory: ./plugin
|
|
59
|
-
run: |
|
|
60
|
-
pnpm link --global
|
|
61
50
|
- name: Remove tests
|
|
62
51
|
working-directory: ./etherpad-lite
|
|
63
52
|
run: rm -rf ./src/tests/backend/specs
|
|
@@ -65,28 +54,17 @@ jobs:
|
|
|
65
54
|
name: Install Etherpad core dependencies
|
|
66
55
|
working-directory: ./etherpad-lite
|
|
67
56
|
run: bin/installDeps.sh
|
|
68
|
-
- name:
|
|
57
|
+
- name: Install plugin
|
|
69
58
|
working-directory: ./etherpad-lite
|
|
70
59
|
run: |
|
|
71
|
-
pnpm
|
|
72
|
-
pnpm run install-plugins --path ../../plugin
|
|
73
|
-
env:
|
|
74
|
-
PLUGIN_NAME: ${{ steps.plugin_name.outputs.plugin_name }}
|
|
75
|
-
- name: Link ep_etherpad-lite
|
|
76
|
-
working-directory: ./etherpad-lite/src
|
|
77
|
-
run: |
|
|
78
|
-
pnpm link --global
|
|
79
|
-
- name: Link etherpad to plugin
|
|
80
|
-
working-directory: ./plugin
|
|
81
|
-
run: |
|
|
82
|
-
pnpm link --global ep_etherpad-lite
|
|
60
|
+
pnpm run plugins i --path ../../plugin
|
|
83
61
|
-
|
|
84
62
|
name: Run the backend tests
|
|
85
|
-
working-directory: ./etherpad-lite
|
|
63
|
+
working-directory: ./etherpad-lite/src
|
|
86
64
|
run: |
|
|
87
|
-
res=$(find
|
|
65
|
+
res=$(find ./plugin_packages -path "*/static/tests/backend/specs/*" 2>/dev/null | wc -l)
|
|
88
66
|
if [ $res -eq 0 ]; then
|
|
89
67
|
echo "No backend tests found"
|
|
90
68
|
else
|
|
91
|
-
|
|
69
|
+
npx cross-env NODE_ENV=production mocha --import=tsx --timeout 120000 --recursive node_modules/ep_*/static/tests/backend/specs/**
|
|
92
70
|
fi
|
|
@@ -12,13 +12,13 @@ jobs:
|
|
|
12
12
|
steps:
|
|
13
13
|
-
|
|
14
14
|
name: Check out Etherpad core
|
|
15
|
-
uses: actions/checkout@
|
|
15
|
+
uses: actions/checkout@v4
|
|
16
16
|
with:
|
|
17
17
|
repository: ether/etherpad-lite
|
|
18
18
|
- uses: pnpm/action-setup@v3
|
|
19
19
|
name: Install pnpm
|
|
20
20
|
with:
|
|
21
|
-
version:
|
|
21
|
+
version: 10
|
|
22
22
|
run_install: false
|
|
23
23
|
- name: Get pnpm store directory
|
|
24
24
|
shell: bash
|
|
@@ -33,7 +33,7 @@ jobs:
|
|
|
33
33
|
${{ runner.os }}-pnpm-store-
|
|
34
34
|
-
|
|
35
35
|
name: Check out the plugin
|
|
36
|
-
uses: actions/checkout@
|
|
36
|
+
uses: actions/checkout@v4
|
|
37
37
|
with:
|
|
38
38
|
path: ./node_modules/__tmp
|
|
39
39
|
-
|
|
@@ -15,13 +15,13 @@ jobs:
|
|
|
15
15
|
node-version: 20
|
|
16
16
|
registry-url: https://registry.npmjs.org/
|
|
17
17
|
- name: Check out Etherpad core
|
|
18
|
-
uses: actions/checkout@
|
|
18
|
+
uses: actions/checkout@v4
|
|
19
19
|
with:
|
|
20
20
|
repository: ether/etherpad-lite
|
|
21
21
|
- uses: pnpm/action-setup@v3
|
|
22
22
|
name: Install pnpm
|
|
23
23
|
with:
|
|
24
|
-
version:
|
|
24
|
+
version: 10
|
|
25
25
|
run_install: false
|
|
26
26
|
- name: Get pnpm store directory
|
|
27
27
|
shell: bash
|
|
@@ -35,7 +35,7 @@ jobs:
|
|
|
35
35
|
restore-keys: |
|
|
36
36
|
${{ runner.os }}-pnpm-store-
|
|
37
37
|
-
|
|
38
|
-
uses: actions/checkout@
|
|
38
|
+
uses: actions/checkout@v4
|
|
39
39
|
with:
|
|
40
40
|
fetch-depth: 0
|
|
41
41
|
-
|
package/locales/da.json
ADDED
package/locales/el.json
CHANGED
package/locales/fa.json
ADDED
package/locales/ga.json
ADDED
package/locales/nl.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"@metadata": {
|
|
3
|
-
"authors": [
|
|
3
|
+
"authors": [
|
|
4
|
+
"McDutchie"
|
|
5
|
+
]
|
|
4
6
|
},
|
|
5
|
-
"ep_table_of_contents.toc.title": "
|
|
6
|
-
"ep_table_of_contents.toc": "
|
|
7
|
+
"ep_table_of_contents.toc.title": "Inhoudsopgave weergeven",
|
|
8
|
+
"ep_table_of_contents.toc": "Inhoudsopgave weergeven"
|
|
7
9
|
}
|
package/locales/pl.json
ADDED
package/locales/ps.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ep_table_of_contents",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.102",
|
|
4
4
|
"description": "View a table of contents for your pad",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "John McLear",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"node": ">=18.0.0"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
+
"test": "node --test tests/*.test.js",
|
|
37
38
|
"lint": "eslint .",
|
|
38
39
|
"lint:fix": "eslint --fix ."
|
|
39
40
|
}
|
package/static/css/toc.css
CHANGED
|
@@ -17,74 +17,31 @@
|
|
|
17
17
|
cursor:pointer;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
.
|
|
20
|
+
.tocDepth1{
|
|
21
21
|
margin-left:0px;
|
|
22
22
|
font-size: 1.5rem;
|
|
23
23
|
}
|
|
24
|
-
.
|
|
24
|
+
.tocDepth2{
|
|
25
25
|
margin-left:10px;
|
|
26
26
|
font-size: 1.2rem;
|
|
27
27
|
}
|
|
28
|
-
.
|
|
28
|
+
.tocDepth3{
|
|
29
29
|
margin-left:20px;
|
|
30
30
|
font-size: 1rem;
|
|
31
31
|
}
|
|
32
|
-
.
|
|
32
|
+
.tocDepth4{
|
|
33
33
|
margin-left:30px;
|
|
34
34
|
font-size:1rem;
|
|
35
35
|
}
|
|
36
|
-
.
|
|
36
|
+
.tocDepth5{
|
|
37
37
|
margin-left:30px;
|
|
38
38
|
font-size:1rem;
|
|
39
39
|
}
|
|
40
|
-
.
|
|
40
|
+
.tocDepth6{
|
|
41
41
|
margin-left:30px;
|
|
42
42
|
font-size:1rem;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
/* Add styling to the first item in a list */
|
|
47
|
-
|
|
48
|
-
#tocItems{counter-reset: first}
|
|
49
|
-
.toch1 { counter-reset: second third fourth fifth sixth seventh;}
|
|
50
|
-
.toch2 { counter-reset: third fourth fifth sixth seventh; }
|
|
51
|
-
.toch3 { counter-reset: fourth fifth sixth seventh; }
|
|
52
|
-
.toch4 { counter-reset: fifth sixth seventh; }
|
|
53
|
-
.toch5 { counter-reset: sixth seventh; }
|
|
54
|
-
.toch6 { counter-reset: seventh; }
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
/* The behavior for incrementing and the prefix */
|
|
58
|
-
.toch1:before {
|
|
59
|
-
content: counter(first) ". " ;
|
|
60
|
-
counter-increment: first;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
.toch2:before {
|
|
64
|
-
content: counter(first) "." counter(second) ". ";
|
|
65
|
-
counter-increment: second;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
.toch3:before {
|
|
69
|
-
content: counter(first) "." counter(second) "." counter(third) ". ";
|
|
70
|
-
counter-increment: third 1;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
.toch4:before {
|
|
74
|
-
content: counter(first) "." counter(second) "." counter(third) "." counter(fourth) ". ";
|
|
75
|
-
counter-increment: fourth 1;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
.toch5:before {
|
|
79
|
-
content: counter(first) "." counter(second) "." counter(third) "." counter(fourth) "." counter(fifth) ". ";
|
|
80
|
-
counter-increment: fifth 1;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
.toch6:before {
|
|
84
|
-
content: counter(first) "." counter(second) "." counter(third) "." counter(fourth) "." counter(fifth) "." counter(sixth) ". ";
|
|
85
|
-
counter-increment: sixth 1;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
45
|
.activeTOC{
|
|
89
46
|
font-weight: 800;
|
|
90
47
|
}
|
package/static/js/toc.js
CHANGED
|
@@ -1,8 +1,67 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
const getHeadingLevel = (tag) => {
|
|
4
|
+
const match = /^h([1-6])$/.exec(tag);
|
|
5
|
+
return match ? Number(match[1]) : null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const getOutlineEntries = (toc) => {
|
|
9
|
+
const stack = [];
|
|
10
|
+
const counters = [];
|
|
11
|
+
|
|
12
|
+
const outlineEntries = toc.map((entry) => {
|
|
13
|
+
const level = getHeadingLevel(entry.tag);
|
|
14
|
+
if (level == null) {
|
|
15
|
+
return {
|
|
16
|
+
...entry,
|
|
17
|
+
displayDepth: 1,
|
|
18
|
+
numbering: '',
|
|
19
|
+
numberParts: [],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
while (stack.length > 0 && stack[stack.length - 1] >= level) {
|
|
24
|
+
stack.pop();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const depth = stack.length + 1;
|
|
28
|
+
counters.length = depth;
|
|
29
|
+
counters[depth - 1] = (counters[depth - 1] || 0) + 1;
|
|
30
|
+
stack.push(level);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
...entry,
|
|
34
|
+
displayDepth: depth,
|
|
35
|
+
numbering: counters.join('.'),
|
|
36
|
+
numberParts: [...counters],
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const topLevelHeadings = outlineEntries.filter((entry) => entry.numberParts.length === 1);
|
|
41
|
+
if (topLevelHeadings.length !== 1) return outlineEntries;
|
|
42
|
+
|
|
43
|
+
return outlineEntries.map((entry) => {
|
|
44
|
+
if (entry.numberParts.length === 0) return entry;
|
|
45
|
+
if (entry.numberParts.length === 1) {
|
|
46
|
+
return {
|
|
47
|
+
...entry,
|
|
48
|
+
numbering: '',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
...entry,
|
|
54
|
+
displayDepth: entry.displayDepth - 1,
|
|
55
|
+
numbering: entry.numberParts.slice(1).join('.'),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (typeof $ !== 'undefined') {
|
|
61
|
+
$('#tocButton').click(() => {
|
|
62
|
+
$('#toc').toggle();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
6
65
|
|
|
7
66
|
const tableOfContents = {
|
|
8
67
|
|
|
@@ -17,9 +76,7 @@ const tableOfContents = {
|
|
|
17
76
|
|
|
18
77
|
// Find Tags
|
|
19
78
|
findTags: () => {
|
|
20
|
-
const toc =
|
|
21
|
-
const tocL = {}; // A per line record of each TOC item
|
|
22
|
-
let count = 0;
|
|
79
|
+
const toc = [];
|
|
23
80
|
let delims = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', '.h1', '.h2', '.h3', '.h4', '.h5', '.h6'];
|
|
24
81
|
if (clientVars.plugins.plugins.ep_context) {
|
|
25
82
|
if (clientVars.plugins.plugins.ep_context.styles) {
|
|
@@ -47,37 +104,29 @@ const tableOfContents = {
|
|
|
47
104
|
linkText = linkText.replace(/\s*#*/, '');
|
|
48
105
|
}
|
|
49
106
|
|
|
50
|
-
|
|
51
|
-
tocL[lineNumber] = tag;
|
|
52
|
-
|
|
53
|
-
// Does the previous line already have this delim?
|
|
54
|
-
// If so do nothing..
|
|
55
|
-
if (tocL[lineNumber - 1]) {
|
|
56
|
-
if (tocL[lineNumber - 1] === tag) return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
toc[count] = {
|
|
107
|
+
toc.push({
|
|
60
108
|
tag,
|
|
61
109
|
y: newY,
|
|
62
110
|
text: linkText,
|
|
63
111
|
focusId,
|
|
64
112
|
lineNumber,
|
|
65
|
-
};
|
|
66
|
-
count++;
|
|
113
|
+
});
|
|
67
114
|
});
|
|
68
115
|
|
|
116
|
+
const outlineEntries = getOutlineEntries(toc);
|
|
69
117
|
clientVars.plugins.plugins.ep_table_of_context = toc;
|
|
70
118
|
$('#tocItems').html('');
|
|
71
|
-
$.each(
|
|
119
|
+
$.each(outlineEntries, (index, entry) => {
|
|
120
|
+
const label = entry.numbering ? `${entry.numbering}. ${entry.text}` : entry.text;
|
|
72
121
|
const $link = $('<a>', {
|
|
73
|
-
text:
|
|
74
|
-
title:
|
|
122
|
+
text: label,
|
|
123
|
+
title: entry.text,
|
|
75
124
|
href: '#',
|
|
76
|
-
class: `tocItem
|
|
77
|
-
click: () => { tableOfContents.scroll(`${
|
|
125
|
+
class: `tocItem tocDepth${Math.min(entry.displayDepth, 6)}`,
|
|
126
|
+
click: () => { tableOfContents.scroll(`${entry.y}`); return false; },
|
|
78
127
|
});
|
|
79
|
-
$link.
|
|
80
|
-
$link.data('offset', `${
|
|
128
|
+
$link.attr('data-toc-index', index);
|
|
129
|
+
$link.data('offset', `${entry.y}`);
|
|
81
130
|
$link.appendTo('#tocItems');
|
|
82
131
|
});
|
|
83
132
|
},
|
|
@@ -98,22 +147,16 @@ const tableOfContents = {
|
|
|
98
147
|
const repLineNumber = rep.selEnd[0]; // line Number
|
|
99
148
|
|
|
100
149
|
// So given a line number of 10 and a toc of [4,8,12] we want to find 8..
|
|
150
|
+
let activeTocIndex = null;
|
|
101
151
|
$.each(toc, (k, line) => {
|
|
102
152
|
if (repLineNumber >= line.lineNumber) {
|
|
103
|
-
|
|
104
|
-
const nextLine = toc[k];
|
|
105
|
-
if (nextLine.lineNumber <= repLineNumber) {
|
|
106
|
-
const activeToc = parseInt(k) + 1;
|
|
107
|
-
|
|
108
|
-
// Seems expensive, we go through each item and remove class
|
|
109
|
-
$('.tocItem').each(function () {
|
|
110
|
-
$(this).removeClass('activeTOC');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
$(`.toch${activeToc}`).addClass('activeTOC');
|
|
114
|
-
}
|
|
153
|
+
activeTocIndex = Number(k);
|
|
115
154
|
}
|
|
116
155
|
});
|
|
156
|
+
|
|
157
|
+
$('.tocItem').removeClass('activeTOC');
|
|
158
|
+
if (activeTocIndex === null) return;
|
|
159
|
+
$(`.tocItem[data-toc-index="${activeTocIndex}"]`).addClass('activeTOC');
|
|
117
160
|
},
|
|
118
161
|
|
|
119
162
|
update: (rep) => {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import {strict as assert} from 'assert';
|
|
4
|
+
import * as common from 'ep_etherpad-lite/tests/backend/common';
|
|
5
|
+
import {randomString} from 'ep_etherpad-lite/static/js/pad_utils';
|
|
6
|
+
|
|
7
|
+
let agent:any;
|
|
8
|
+
const apiVersion = 1;
|
|
9
|
+
|
|
10
|
+
const authGet = async (url: string) =>
|
|
11
|
+
agent.get(url).set('authorization', await common.generateJWTToken());
|
|
12
|
+
|
|
13
|
+
const createPad = async (padId: string) => {
|
|
14
|
+
const res = await authGet(`/api/${apiVersion}/createPad?padID=${padId}`);
|
|
15
|
+
assert.equal(res.body.code, 0, 'Unable to create new pad');
|
|
16
|
+
return padId;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const setHTML = async (padId: string, html: string) => {
|
|
20
|
+
const res = await authGet(`/api/${apiVersion}/setHTML?padID=${padId}&html=${encodeURIComponent(html)}`);
|
|
21
|
+
assert.equal(res.body.code, 0, 'Unable to set pad HTML');
|
|
22
|
+
return padId;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const getHTMLEndpointFor = (padId: string) => `/api/${apiVersion}/getHTML?padID=${padId}`;
|
|
26
|
+
|
|
27
|
+
const buildHTML = (body: string) => `<!doctype html><html><body>${body}</body></html>`;
|
|
28
|
+
|
|
29
|
+
describe('TOC export/import behavior', function () {
|
|
30
|
+
let padId:string;
|
|
31
|
+
|
|
32
|
+
before(async function () {
|
|
33
|
+
agent = await common.init();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
beforeEach(async function () {
|
|
37
|
+
padId = randomString(10);
|
|
38
|
+
await createPad(padId);
|
|
39
|
+
await setHTML(padId, buildHTML('<h1>Title</h1><h2>Section A</h2><h2>Section B</h2>'));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps the import/export HTML free of TOC sidebar markup', async function () {
|
|
43
|
+
const res = await authGet(getHTMLEndpointFor(padId));
|
|
44
|
+
assert.equal(res.status, 200);
|
|
45
|
+
|
|
46
|
+
const html = res.body.data.html;
|
|
47
|
+
assert.match(html, /Title/);
|
|
48
|
+
assert.match(html, /Section A/);
|
|
49
|
+
assert.match(html, /Section B/);
|
|
50
|
+
assert.doesNotMatch(html, /id="toc"/);
|
|
51
|
+
assert.doesNotMatch(html, /id="tocItems"/);
|
|
52
|
+
assert.doesNotMatch(html, /tocItem/);
|
|
53
|
+
assert.doesNotMatch(html, /Table of Contents/);
|
|
54
|
+
assert.doesNotMatch(html, /ep_table_of_contents\/static\/js\/toc\.js/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does not inject TOC sidebar markup into HTML export', async function () {
|
|
58
|
+
const res = await authGet(`/p/${padId}/export/html`);
|
|
59
|
+
assert.equal(res.status, 200);
|
|
60
|
+
|
|
61
|
+
const html = res.text;
|
|
62
|
+
assert.match(html, /Title/);
|
|
63
|
+
assert.match(html, /Section A/);
|
|
64
|
+
assert.match(html, /Section B/);
|
|
65
|
+
assert.doesNotMatch(html, /id="toc"/);
|
|
66
|
+
assert.doesNotMatch(html, /id="tocItems"/);
|
|
67
|
+
assert.doesNotMatch(html, /tocItem/);
|
|
68
|
+
assert.doesNotMatch(html, /Table of Contents/);
|
|
69
|
+
assert.doesNotMatch(html, /ep_table_of_contents\/static\/js\/toc\.js/);
|
|
70
|
+
assert.doesNotMatch(html, /0\.1/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not inject TOC sidebar text into plain text export', async function () {
|
|
74
|
+
const res = await authGet(`/p/${padId}/export/txt`);
|
|
75
|
+
assert.equal(res.status, 200);
|
|
76
|
+
|
|
77
|
+
const text = res.text;
|
|
78
|
+
assert.match(text, /Title/);
|
|
79
|
+
assert.match(text, /Section A/);
|
|
80
|
+
assert.match(text, /Section B/);
|
|
81
|
+
assert.doesNotMatch(text, /Table of Contents/);
|
|
82
|
+
assert.doesNotMatch(text, /0\.1/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
testDir: path.join(__dirname, 'specs'),
|
|
7
|
+
testMatch: '**/*.spec.js',
|
|
8
|
+
timeout: 90 * 1000,
|
|
9
|
+
retries: process.env.CI ? 2 : 0,
|
|
10
|
+
use: {
|
|
11
|
+
baseURL: 'http://127.0.0.1:9001',
|
|
12
|
+
viewport: {width: 1280, height: 3000},
|
|
13
|
+
trace: 'on-first-retry',
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const {expect, test} = require('@playwright/test');
|
|
2
|
+
const {
|
|
3
|
+
clearPadContent,
|
|
4
|
+
getPadBody,
|
|
5
|
+
goToNewPad,
|
|
6
|
+
writeToPad,
|
|
7
|
+
} = require('ep_etherpad-lite/tests/frontend-new/helper/padHelper');
|
|
8
|
+
|
|
9
|
+
const enableToc = async (page) => {
|
|
10
|
+
await page.locator('.buttonicon-settings').click();
|
|
11
|
+
await page.locator('label[for="options-toc"]').click();
|
|
12
|
+
await expect(page.locator('#toc')).toBeVisible();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const writeSections = async (page, count, prefix) => {
|
|
16
|
+
for (let i = 0; i < count; i++) {
|
|
17
|
+
await writeToPad(page, `${prefix} ${i + 1}`);
|
|
18
|
+
if (i < count - 1) await page.keyboard.press('Enter');
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const applyHeading = async (page, lineNumber, headingLevel) => {
|
|
23
|
+
const padBody = await getPadBody(page);
|
|
24
|
+
await padBody.locator('div').nth(lineNumber).selectText();
|
|
25
|
+
await page.evaluate((value) => {
|
|
26
|
+
$('#heading-selection').val(String(value)).trigger('change');
|
|
27
|
+
}, headingLevel - 1);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
test.beforeEach(async ({page}) => {
|
|
31
|
+
await goToNewPad(page);
|
|
32
|
+
await enableToc(page);
|
|
33
|
+
await clearPadContent(page);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test.describe('table of contents numbering', () => {
|
|
37
|
+
test('starts sibling h2 headings at 1 when there is no h1', async ({page}) => {
|
|
38
|
+
await writeToPad(page, 'First section');
|
|
39
|
+
await page.keyboard.press('Enter');
|
|
40
|
+
await writeToPad(page, 'Second section');
|
|
41
|
+
|
|
42
|
+
await applyHeading(page, 0, 2);
|
|
43
|
+
await applyHeading(page, 1, 2);
|
|
44
|
+
|
|
45
|
+
const tocItems = page.locator('#tocItems .tocItem');
|
|
46
|
+
await expect(tocItems).toHaveCount(2);
|
|
47
|
+
await expect(tocItems.nth(0)).toHaveText('1. First section');
|
|
48
|
+
await expect(tocItems.nth(1)).toHaveText('2. Second section');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('keeps a single top-level heading unnumbered and starts children at 1', async ({page}) => {
|
|
52
|
+
await writeToPad(page, 'Document title');
|
|
53
|
+
await page.keyboard.press('Enter');
|
|
54
|
+
await writeToPad(page, 'First section');
|
|
55
|
+
await page.keyboard.press('Enter');
|
|
56
|
+
await writeToPad(page, 'Second section');
|
|
57
|
+
|
|
58
|
+
await applyHeading(page, 0, 1);
|
|
59
|
+
await applyHeading(page, 1, 2);
|
|
60
|
+
await applyHeading(page, 2, 2);
|
|
61
|
+
|
|
62
|
+
const tocItems = page.locator('#tocItems .tocItem');
|
|
63
|
+
await expect(tocItems).toHaveCount(3);
|
|
64
|
+
await expect(tocItems.nth(0)).toHaveText('Document title');
|
|
65
|
+
await expect(tocItems.nth(1)).toHaveText('1. First section');
|
|
66
|
+
await expect(tocItems.nth(2)).toHaveText('2. Second section');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('keeps all TOC entries for larger sets of sibling headings', async ({page}) => {
|
|
70
|
+
const headingCount = 25;
|
|
71
|
+
await writeSections(page, headingCount, 'Section');
|
|
72
|
+
|
|
73
|
+
const tocItems = page.locator('#tocItems .tocItem');
|
|
74
|
+
for (let i = 0; i < headingCount; i++) {
|
|
75
|
+
await applyHeading(page, i, 2);
|
|
76
|
+
await expect(page.locator(`#tocItems .tocItem[title="Section ${i + 1}"]`)).toBeVisible({timeout: 15_000});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await expect(tocItems).toHaveCount(headingCount);
|
|
80
|
+
await expect(tocItems.nth(0)).toHaveText('1. Section 1');
|
|
81
|
+
await expect(tocItems.nth(9)).toHaveText('10. Section 10');
|
|
82
|
+
await expect(tocItems.nth(24)).toHaveText('25. Section 25');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const vm = require('node:vm');
|
|
7
|
+
const test = require('node:test');
|
|
8
|
+
|
|
9
|
+
const loadTocHelpers = () => {
|
|
10
|
+
const tocPath = path.join(__dirname, '..', 'static', 'js', 'toc.js');
|
|
11
|
+
const source = fs.readFileSync(tocPath, 'utf8');
|
|
12
|
+
const sandbox = {
|
|
13
|
+
console,
|
|
14
|
+
URLSearchParams,
|
|
15
|
+
globalThis: {},
|
|
16
|
+
$: () => ({
|
|
17
|
+
click() {},
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
sandbox.globalThis = sandbox;
|
|
21
|
+
vm.runInNewContext(`${source}
|
|
22
|
+
globalThis.__tocTestExports = {getHeadingLevel, getOutlineEntries};`, sandbox, {filename: tocPath});
|
|
23
|
+
return sandbox.__tocTestExports;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const {getOutlineEntries} = loadTocHelpers();
|
|
27
|
+
|
|
28
|
+
const makeEntries = (tags) => tags.map((tag, index) => ({
|
|
29
|
+
tag,
|
|
30
|
+
text: `Heading ${index + 1}`,
|
|
31
|
+
lineNumber: index,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const summarize = (entries) => getOutlineEntries(makeEntries(entries)).map((entry) => ({
|
|
35
|
+
depth: entry.displayDepth,
|
|
36
|
+
numbering: entry.numbering,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
test('starts first visible heading at 1 when h1 is missing', () => {
|
|
40
|
+
assert.deepEqual(summarize(['h2', 'h3', 'h3']), [
|
|
41
|
+
{depth: 1, numbering: ''},
|
|
42
|
+
{depth: 1, numbering: '1'},
|
|
43
|
+
{depth: 1, numbering: '2'},
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('increments sibling headings instead of repeating 0.1', () => {
|
|
48
|
+
assert.deepEqual(summarize(['h2', 'h2', 'h2']), [
|
|
49
|
+
{depth: 1, numbering: '1'},
|
|
50
|
+
{depth: 1, numbering: '2'},
|
|
51
|
+
{depth: 1, numbering: '3'},
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('keeps a single top-level heading unnumbered and starts children at 1', () => {
|
|
56
|
+
assert.deepEqual(summarize(['h1', 'h2', 'h2']), [
|
|
57
|
+
{depth: 1, numbering: ''},
|
|
58
|
+
{depth: 1, numbering: '1'},
|
|
59
|
+
{depth: 1, numbering: '2'},
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('preserves hierarchical numbering when there are multiple top-level sections', () => {
|
|
64
|
+
assert.deepEqual(summarize(['h1', 'h2', 'h1', 'h2']), [
|
|
65
|
+
{depth: 1, numbering: '1'},
|
|
66
|
+
{depth: 2, numbering: '1.1'},
|
|
67
|
+
{depth: 1, numbering: '2'},
|
|
68
|
+
{depth: 2, numbering: '2.1'},
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('counts nested sections correctly across section changes', () => {
|
|
73
|
+
assert.deepEqual(summarize(['h2', 'h3', 'h2', 'h3', 'h4']), [
|
|
74
|
+
{depth: 1, numbering: '1'},
|
|
75
|
+
{depth: 2, numbering: '1.1'},
|
|
76
|
+
{depth: 1, numbering: '2'},
|
|
77
|
+
{depth: 2, numbering: '2.1'},
|
|
78
|
+
{depth: 3, numbering: '2.1.1'},
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('keeps numbering stable across many sibling headings', () => {
|
|
83
|
+
const headingCount = 200;
|
|
84
|
+
const summary = summarize(Array.from({length: headingCount}, () => 'h2'));
|
|
85
|
+
|
|
86
|
+
assert.equal(summary.length, headingCount);
|
|
87
|
+
assert.deepEqual(summary[0], {depth: 1, numbering: '1'});
|
|
88
|
+
assert.deepEqual(summary[99], {depth: 1, numbering: '100'});
|
|
89
|
+
assert.deepEqual(summary[199], {depth: 1, numbering: '200'});
|
|
90
|
+
});
|