@ozdao/scriptorium 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -81,7 +81,8 @@ export default {
81
81
  skipped_names: ['node_modules', 'builds'], // папки, пропускаемые на любой глубине
82
82
  skipped_paths: [], // пути от корня репозитория; documentation/ пропускается всегда
83
83
  nav: [{ text: 'Обзор', link: '/' }],
84
- sidebar: { priority: ['AGENTS.md'], folders_to_bottom: true, collapse_depth: 2, hidden: [] },
84
+ sidebar: { priority: ['AGENTS.md'], folders_to_bottom: true, collapse_depth: 2, hidden: [],
85
+ sort_by_date: [] }, // пути папок: страницы в них по дате, новые сверху (date из frontmatter или ГГГГ-ММ-ДД в конце имени; при равной — по добавлению в git)
85
86
  outline: { level: [2, 3], label: 'На странице' },
86
87
  labels: { menu: 'Меню', theme: 'Тема', top: 'Наверх', prev: 'Назад', next: 'Дальше', not_found: 'Страница не найдена', home: 'На главную' },
87
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ozdao/scriptorium",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Documentation site generator on @ozdao/martyrs: markdown folders to an SSR site",
5
5
  "author": "OZ DAO <hello@ozdao.com>",
6
6
  "license": "GPL-3.0-or-later",
@@ -32,10 +32,14 @@ const control_characters = /[\u0000-\u001f]/g;
32
32
  const special_characters = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
33
33
  const combining_marks = /[̀-ͯ]/g;
34
34
 
35
+ // Диакритика снимается только с латиницы (é → e), как у VitePress. Кириллица
36
+ // остаётся целой: «й» и «ё» — отдельные буквы, а не «и» и «е» с надстрочным
37
+ // знаком; без NFC обратно якорь «#семейства» не совпадал бы с заголовком.
35
38
  function slugify(text) {
36
39
  return text
37
40
  .normalize('NFKD')
38
- .replace(combining_marks, '')
41
+ .replace(new RegExp(`(?<=[A-Za-z])${combining_marks.source}+`, 'g'), '')
42
+ .normalize('NFC')
39
43
  .replace(control_characters, '')
40
44
  .replace(special_characters, '-')
41
45
  .replace(/-{2,}/g, '-')
@@ -25,6 +25,7 @@ export default function pagesLoader() {
25
25
  file: ${JSON.stringify(page.file)},
26
26
  title: ${JSON.stringify(page.title)},
27
27
  frontmatter: ${JSON.stringify(page.frontmatter)},
28
+ added: ${JSON.stringify(page.added ?? null)},
28
29
  load: () => import(${JSON.stringify(`@docs/${page.file}`)}),
29
30
  }`);
30
31
 
@@ -1,10 +1,12 @@
1
1
  // Обход репозитория документации: какие .md становятся страницами и по каким
2
2
  // адресам. Работает в node (loader'ы сборки), в бандл сайта не попадает.
3
+ import { execFileSync } from 'node:child_process';
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import { pathToFileURL } from 'node:url';
6
7
  import matter from 'gray-matter';
7
8
 
9
+ import { insideFolders } from './sidebar.js';
8
10
  import { resolveSiteConfig } from './site-config.js';
9
11
 
10
12
  // CLI запускается из папки documentation/ (как vitepress из папки сайта): она же
@@ -66,14 +68,45 @@ export function listMarkdown(docs_root, config) {
66
68
  return walk(docs_root, '');
67
69
  }
68
70
 
71
+ // Время добавления файлов папок sidebar.sort_by_date, мс: коммит, которым файл
72
+ // появился по этому пути в git — добавлен или перенесён (один вызов git log на все
73
+ // папки); файл вне git или без git — время создания файла.
74
+ function addedTimes(docs_root, files, folders) {
75
+ const added = new Map();
76
+ if (folders.length > 0) {
77
+ try {
78
+ const log = execFileSync('git', ['log', '--diff-filter=AR', '--relative', '--name-only', '--format=@%at', '--', ...folders], {
79
+ cwd: docs_root,
80
+ encoding: 'utf-8',
81
+ maxBuffer: 64 * 1024 * 1024,
82
+ });
83
+ let time = 0;
84
+ for (const line of log.split('\n')) {
85
+ if (line.startsWith('@')) time = Number(line.slice(1)) * 1000;
86
+ else if (line && !added.has(line)) added.set(line, time);
87
+ }
88
+ } catch {
89
+ // git нет или папка не в репозитории — время создания файла ниже
90
+ }
91
+ }
92
+ for (const file of files) {
93
+ if (!added.has(file)) added.set(file, fs.statSync(path.join(docs_root, file)).birthtimeMs);
94
+ }
95
+ return added;
96
+ }
97
+
69
98
  // Страницы сайта: файл, адрес, подпись в сайдбаре (frontmatter.title, иначе имя
70
- // файла) и frontmatter. Два файла на один адрес (AGENTS.md и index.md одной
71
- // папки) — ошибка: какая из страниц настоящая, решает автор, а не порядок обхода.
99
+ // файла), frontmatter и, для папок sidebar.sort_by_date, время добавления (added, мс).
100
+ // Два файла на один адрес (AGENTS.md и index.md одной папки) — ошибка: какая из
101
+ // страниц настоящая, решает автор, а не порядок обхода.
72
102
  export function scanPages(docs_root, config) {
73
103
  const pages = [];
74
104
  const file_of_url = new Map();
105
+ const files = listMarkdown(docs_root, config);
106
+ const sort_by_date = config.sidebar.sort_by_date;
107
+ const added = addedTimes(docs_root, files.filter((file) => insideFolders(file, sort_by_date)), sort_by_date);
75
108
 
76
- for (const file of listMarkdown(docs_root, config)) {
109
+ for (const file of files) {
77
110
  const url = pageUrl(file);
78
111
  if (file_of_url.has(url)) {
79
112
  throw new Error(`[scriptorium] два файла на один адрес ${url}: ${file_of_url.get(url)} и ${file} — оставь один`);
@@ -94,6 +127,7 @@ export function scanPages(docs_root, config) {
94
127
  ? String(frontmatter.title)
95
128
  : path.posix.basename(file, '.md'),
96
129
  frontmatter,
130
+ ...(added.has(file) ? { added: added.get(file) } : {}),
97
131
  });
98
132
  }
99
133
 
@@ -9,7 +9,11 @@
9
9
  // корневой AGENTS.md — «Обзор» в шапке, в сайдбаре его нет;
10
10
  // - папки из sidebar.hidden (по имени, на любой глубине) — страницы сайта, но
11
11
  // не пункты сайдбара;
12
- // - папка глубины collapse_depth и глубже изначально свёрнута.
12
+ // - папка глубины collapse_depth и глубже изначально свёрнута;
13
+ // - в папках sidebar.sort_by_date (пути от корня, на любой глубине) страницы
14
+ // идут по дате, новые сверху: date из frontmatter, иначе дата ГГГГ-ММ-ДД в конце
15
+ // имени файла; при равной дате — по времени добавления (added); без даты —
16
+ // после датированных, по имени.
13
17
  //
14
18
  // Узел: { text, url?, items?, collapsed }. url папки — адрес её AGENTS.md.
15
19
 
@@ -17,6 +21,29 @@ function isFolderIndex(file_name) {
17
21
  return file_name === 'AGENTS.md' || file_name === 'index.md';
18
22
  }
19
23
 
24
+ // файл лежит в одной из папок (пути от корня репозитория) или глубже
25
+ export function insideFolders(file, folders) {
26
+ return folders.some((folder) => file.startsWith(folder.replace(/\/$/, '') + '/'));
27
+ }
28
+
29
+ // дата страницы, мс: date из frontmatter, иначе ГГГГ-ММ-ДД в конце имени файла; нет — NaN
30
+ function pageDate(page) {
31
+ const date = page.frontmatter?.date;
32
+ if (date !== undefined && date !== null && date !== '') return Date.parse(String(date));
33
+ const match = page.file.match(/(\d{4}-\d{2}-\d{2})\.md$/);
34
+ return match ? Date.parse(match[1]) : NaN;
35
+ }
36
+
37
+ // новые сверху: по дате, затем по времени добавления; без даты — в конец, по имени
38
+ function newestFirst(a, b) {
39
+ const date_a = pageDate(a);
40
+ const date_b = pageDate(b);
41
+ if (Number.isNaN(date_a) !== Number.isNaN(date_b)) return Number.isNaN(date_a) ? 1 : -1;
42
+ if (!Number.isNaN(date_a) && date_a !== date_b) return date_b - date_a;
43
+ if ((a.added ?? 0) !== (b.added ?? 0)) return (b.added ?? 0) - (a.added ?? 0);
44
+ return a.file < b.file ? -1 : a.file > b.file ? 1 : 0;
45
+ }
46
+
20
47
  function prioritize(names, priority) {
21
48
  const rank = new Map(priority.map((name, index) => [name, index]));
22
49
  const first = names.filter((name) => rank.has(name)).sort((a, b) => rank.get(a) - rank.get(b));
@@ -44,6 +71,15 @@ function folderTree(pages, hidden) {
44
71
 
45
72
  function folderItems(folder, depth, options) {
46
73
  const names = prioritize([...folder.files.keys(), ...folder.folders.keys()].sort(), options.priority);
74
+ const dated = [...folder.files.values()].filter((page) => insideFolders(page.file, options.sort_by_date)).sort(newestFirst);
75
+ if (dated.length > 0) {
76
+ // датированные страницы встают на места своих имён в порядке по дате
77
+ const slots = names.filter((name) => folder.files.has(name) && dated.includes(folder.files.get(name)));
78
+ let next = 0;
79
+ for (let index = 0; index < names.length; index += 1) {
80
+ if (slots.includes(names[index])) names[index] = dated[next++].file.split('/').pop();
81
+ }
82
+ }
47
83
  const items = [];
48
84
 
49
85
  for (const name of names) {
@@ -77,8 +113,8 @@ function folderItems(folder, depth, options) {
77
113
  }
78
114
 
79
115
  export function buildSidebar(pages, config) {
80
- const { priority, folders_to_bottom, collapse_depth, hidden } = config.sidebar;
81
- return folderItems(folderTree(pages, hidden), 1, { priority, folders_to_bottom, collapse_depth });
116
+ const { priority, folders_to_bottom, collapse_depth, hidden, sort_by_date } = config.sidebar;
117
+ return folderItems(folderTree(pages, hidden), 1, { priority, folders_to_bottom, collapse_depth, sort_by_date });
82
118
  }
83
119
 
84
120
  // Плоский порядок ссылок сайдбара для «Назад / Дальше»: ссылка папки идёт
@@ -79,6 +79,8 @@ export function resolveSiteConfig(raw) {
79
79
  folders_to_bottom: sidebar.folders_to_bottom ?? true,
80
80
  collapse_depth: sidebar.collapse_depth ?? 2,
81
81
  hidden: stringList(sidebar.hidden, 'sidebar.hidden'),
82
+ // пути папок от корня репозитория: файлы в них (на любой глубине) идут по дате, новые сверху
83
+ sort_by_date: stringList(sidebar.sort_by_date, 'sidebar.sort_by_date'),
82
84
  },
83
85
  outline: {
84
86
  level,