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.
@@ -16,20 +16,20 @@ jobs:
16
16
  steps:
17
17
  -
18
18
  name: Install libreoffice
19
- uses: awalsh128/cache-apt-pkgs-action@v1.4.2
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@v3
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: 8
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@v3
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: Link plugin to etherpad-lite
57
+ - name: Install plugin
69
58
  working-directory: ./etherpad-lite
70
59
  run: |
71
- pnpm link --global $PLUGIN_NAME
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 .. -path "./node_modules/ep_*/static/tests/backend/specs/**" | wc -l)
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
- pnpm run test
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@v3
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: 8
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@v3
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@v3
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: 8
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@v3
38
+ uses: actions/checkout@v4
39
39
  with:
40
40
  fetch-depth: 0
41
41
  -
@@ -0,0 +1,9 @@
1
+ {
2
+ "@metadata": {
3
+ "authors": [
4
+ "Renessaince"
5
+ ]
6
+ },
7
+ "ep_table_of_contents.toc.title": "Паказаць зьмест",
8
+ "ep_table_of_contents.toc": "Паказаць зьмест"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "@metadata": {
3
+ "authors": [
4
+ "Peterleth"
5
+ ]
6
+ },
7
+ "ep_table_of_contents.toc.title": "Vis indholdsfortegnelsen",
8
+ "ep_table_of_contents.toc": "Vis indholdsfortegnelsen"
9
+ }
package/locales/el.json CHANGED
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "@metadata": {
3
3
  "authors": [
4
+ "Jimkats",
4
5
  "Norhorn"
5
6
  ]
6
7
  },
8
+ "ep_table_of_contents.toc.title": "Προβολή πίνακα περιεχομένων",
7
9
  "ep_table_of_contents.toc": "Προβολή του Πίνακα Περιεχομένων"
8
10
  }
@@ -0,0 +1,9 @@
1
+ {
2
+ "@metadata": {
3
+ "authors": [
4
+ "Darafsh"
5
+ ]
6
+ },
7
+ "ep_table_of_contents.toc.title": "نمایش فهرست مطالب",
8
+ "ep_table_of_contents.toc": "نمایش فهرست مطالب"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "@metadata": {
3
+ "authors": [
4
+ "Aindriu80"
5
+ ]
6
+ },
7
+ "ep_table_of_contents.toc.title": "Taispeáin Clár Ábhair",
8
+ "ep_table_of_contents.toc": "Taispeáin Clár Ábhair"
9
+ }
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": "Toon Inhoudsopgave",
6
- "ep_table_of_contents.toc": "Toon Inhoudsopgave"
7
+ "ep_table_of_contents.toc.title": "Inhoudsopgave weergeven",
8
+ "ep_table_of_contents.toc": "Inhoudsopgave weergeven"
7
9
  }
@@ -0,0 +1,9 @@
1
+ {
2
+ "@metadata": {
3
+ "authors": [
4
+ "Usagi.02808"
5
+ ]
6
+ },
7
+ "ep_table_of_contents.toc.title": "Pokaż spis treści",
8
+ "ep_table_of_contents.toc": "Pokaż spis treści"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "@metadata": {
3
+ "authors": [
4
+ "شاه زمان پټان"
5
+ ]
6
+ },
7
+ "ep_table_of_contents.toc.title": "نيوليک ښودل",
8
+ "ep_table_of_contents.toc": "نيوليک ښودل"
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_table_of_contents",
3
- "version": "0.3.89",
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
  }
@@ -17,74 +17,31 @@
17
17
  cursor:pointer;
18
18
  }
19
19
 
20
- .toch1{
20
+ .tocDepth1{
21
21
  margin-left:0px;
22
22
  font-size: 1.5rem;
23
23
  }
24
- .toch2{
24
+ .tocDepth2{
25
25
  margin-left:10px;
26
26
  font-size: 1.2rem;
27
27
  }
28
- .toch3{
28
+ .tocDepth3{
29
29
  margin-left:20px;
30
30
  font-size: 1rem;
31
31
  }
32
- .toch4{
32
+ .tocDepth4{
33
33
  margin-left:30px;
34
34
  font-size:1rem;
35
35
  }
36
- .toch5{
36
+ .tocDepth5{
37
37
  margin-left:30px;
38
38
  font-size:1rem;
39
39
  }
40
- .toch6{
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
- $('#tocButton').click(() => {
4
- $('#toc').toggle();
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 = {}; // The main object we will use
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
- // Create an object of lineNumbers that include the tag
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(toc, (h, v) => { // for each item we should display
119
+ $.each(outlineEntries, (index, entry) => {
120
+ const label = entry.numbering ? `${entry.numbering}. ${entry.text}` : entry.text;
72
121
  const $link = $('<a>', {
73
- text: v.text,
74
- title: v.text,
122
+ text: label,
123
+ title: entry.text,
75
124
  href: '#',
76
- class: `tocItem toc${v.tag}`,
77
- click: () => { tableOfContents.scroll(`${v.y}`); return false; },
125
+ class: `tocItem tocDepth${Math.min(entry.displayDepth, 6)}`,
126
+ click: () => { tableOfContents.scroll(`${entry.y}`); return false; },
78
127
  });
79
- $link.data('class', `toc${v.tag}`);
80
- $link.data('offset', `${v.y}`);
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
- // we might be showing this..
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
+ });