@ozdao/scriptorium 0.0.0-stage → 0.1.0

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.
Files changed (39) hide show
  1. package/README.md +119 -2
  2. package/bin/scriptorium.js +180 -0
  3. package/package.json +40 -4
  4. package/public/favicon/favicon.svg +1 -0
  5. package/src/client.js +29 -0
  6. package/src/components/Badge.vue +29 -0
  7. package/src/components/HeaderNav.vue +46 -0
  8. package/src/components/Layout.vue +34 -0
  9. package/src/components/Outline.vue +74 -0
  10. package/src/components/Page.vue +33 -0
  11. package/src/components/PrevNext.vue +37 -0
  12. package/src/components/SearchBox.vue +381 -0
  13. package/src/components/SearchButton.vue +36 -0
  14. package/src/components/SidebarTree.vue +114 -0
  15. package/src/components/SiteTitle.vue +11 -0
  16. package/src/components/ThemeToggle.vue +31 -0
  17. package/src/configs/client.config.js +38 -0
  18. package/src/configs/site.config.js +8 -0
  19. package/src/content/current-page.js +54 -0
  20. package/src/content/markdown.js +182 -0
  21. package/src/content/markdown.loader.js +95 -0
  22. package/src/content/pages.js +5 -0
  23. package/src/content/pages.loader.js +33 -0
  24. package/src/content/scan.js +101 -0
  25. package/src/content/search-index.js +7 -0
  26. package/src/content/search-index.loader.js +144 -0
  27. package/src/content/search-options.js +16 -0
  28. package/src/content/sidebar.js +99 -0
  29. package/src/content/site-config.js +95 -0
  30. package/src/content/use-docs.js +21 -0
  31. package/src/design-system/doc.scss +257 -0
  32. package/src/design-system/index.js +19 -0
  33. package/src/design-system/layout.scss +416 -0
  34. package/src/design-system/theme.scss +45 -0
  35. package/src/localization/index.js +27 -0
  36. package/src/main.js +8 -0
  37. package/src/router/index.js +101 -0
  38. package/src/router/links.js +56 -0
  39. package/src/server.js +59 -0
@@ -0,0 +1,99 @@
1
+ // Дерево сайдбара из списка страниц — правила прежнего сайта (vitepress-sidebar
2
+ // 1.40.0 + arrangeSidebar из .vitepress/config.mts):
3
+ // - папка = узел с подписью-именем папки, страница = пункт с подписью title;
4
+ // - порядок внутри папки: имена по возрастанию (обычный sort по кодам символов,
5
+ // как у vitepress-sidebar: заглавные раньше строчных), затем sidebar.priority
6
+ // выносит перечисленные имена вперёд в своём порядке; при folders_to_bottom
7
+ // папки идут после файлов;
8
+ // - AGENTS.md папки отдаёт ей свою ссылку и отдельным пунктом не показывается;
9
+ // корневой AGENTS.md — «Обзор» в шапке, в сайдбаре его нет;
10
+ // - папки из sidebar.hidden (по имени, на любой глубине) — страницы сайта, но
11
+ // не пункты сайдбара;
12
+ // - папка глубины collapse_depth и глубже изначально свёрнута.
13
+ //
14
+ // Узел: { text, url?, items?, collapsed }. url папки — адрес её AGENTS.md.
15
+
16
+ function isFolderIndex(file_name) {
17
+ return file_name === 'AGENTS.md' || file_name === 'index.md';
18
+ }
19
+
20
+ function prioritize(names, priority) {
21
+ const rank = new Map(priority.map((name, index) => [name, index]));
22
+ const first = names.filter((name) => rank.has(name)).sort((a, b) => rank.get(a) - rank.get(b));
23
+ return [...first, ...names.filter((name) => !rank.has(name))];
24
+ }
25
+
26
+ function folderTree(pages, hidden) {
27
+ const root = { folders: new Map(), files: new Map() };
28
+
29
+ for (const page of pages) {
30
+ const parts = page.file.split('/');
31
+ const file_name = parts.pop();
32
+ if (parts.some((part) => hidden.includes(part))) continue;
33
+
34
+ let folder = root;
35
+ for (const part of parts) {
36
+ if (!folder.folders.has(part)) folder.folders.set(part, { folders: new Map(), files: new Map() });
37
+ folder = folder.folders.get(part);
38
+ }
39
+ folder.files.set(file_name, page);
40
+ }
41
+
42
+ return root;
43
+ }
44
+
45
+ function folderItems(folder, depth, options) {
46
+ const names = prioritize([...folder.files.keys(), ...folder.folders.keys()].sort(), options.priority);
47
+ const items = [];
48
+
49
+ for (const name of names) {
50
+ if (folder.files.has(name)) {
51
+ if (isFolderIndex(name)) continue;
52
+ const page = folder.files.get(name);
53
+ items.push({ text: page.title, url: page.url });
54
+ continue;
55
+ }
56
+
57
+ const sub_folder = folder.folders.get(name);
58
+ const index_page = sub_folder.files.get('AGENTS.md') || sub_folder.files.get('index.md');
59
+ const children = folderItems(sub_folder, depth + 1, options);
60
+
61
+ if (!index_page && children.length === 0) continue;
62
+
63
+ items.push({
64
+ text: name,
65
+ folder: true,
66
+ ...(index_page ? { url: index_page.url } : {}),
67
+ ...(children.length ? { items: children, collapsed: depth >= options.collapse_depth } : {}),
68
+ });
69
+ }
70
+
71
+ if (!options.folders_to_bottom) return items.map(({ folder, ...item }) => item);
72
+
73
+ return [
74
+ ...items.filter((item) => !item.folder),
75
+ ...items.filter((item) => item.folder),
76
+ ].map(({ folder, ...item }) => item);
77
+ }
78
+
79
+ 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 });
82
+ }
83
+
84
+ // Плоский порядок ссылок сайдбара для «Назад / Дальше»: ссылка папки идёт
85
+ // перед её содержимым.
86
+ export function flattenSidebar(items) {
87
+ const links = [];
88
+ for (const item of items) {
89
+ if (item.url) links.push({ text: item.text, url: item.url });
90
+ if (item.items) links.push(...flattenSidebar(item.items));
91
+ }
92
+ return links;
93
+ }
94
+
95
+ // Есть ли среди потомков узла текущая страница — такие папки раскрыты.
96
+ export function containsUrl(item, url) {
97
+ if (item.url === url) return true;
98
+ return Boolean(item.items?.some((child) => containsUrl(child, url)));
99
+ }
@@ -0,0 +1,95 @@
1
+ // Конфиг сайта потребителя (documentation/config.js) → полный конфиг
2
+ // с умолчаниями. Чистая функция без node-импортов: её зовут и сборка (loader'ы
3
+ // в node), и бандл сайта (configs/site.config.js), и умолчания обязаны совпадать,
4
+ // иначе сайдбар и список страниц разойдутся.
5
+
6
+ const default_labels = {
7
+ menu: 'Menu',
8
+ theme: 'Appearance',
9
+ top: 'Return to top',
10
+ prev: 'Previous page',
11
+ next: 'Next page',
12
+ // Страница 404 — подписи как у VitePress.
13
+ not_found: 'Page not found',
14
+ home: 'Take me home',
15
+ // Поиск (SearchButton, SearchBox) — подписи как у локального поиска VitePress.
16
+ search: {
17
+ button: 'Search',
18
+ placeholder: 'Search docs',
19
+ no_results: 'No results for',
20
+ reset: 'Reset search',
21
+ select: 'to select',
22
+ navigate: 'to navigate',
23
+ close: 'to close',
24
+ },
25
+ };
26
+
27
+ const appearances = ['dark', 'light', 'auto'];
28
+
29
+ function isObject(value) {
30
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
31
+ }
32
+
33
+ function stringList(value, field) {
34
+ if (value === undefined) return [];
35
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
36
+ throw new TypeError(`[scriptorium] documentation/config.js: ${field} — ожидается массив строк`);
37
+ }
38
+ return value;
39
+ }
40
+
41
+ export function resolveSiteConfig(raw) {
42
+ if (!isObject(raw)) {
43
+ throw new TypeError('[scriptorium] documentation/config.js должен отдавать объект (export default { ... })');
44
+ }
45
+
46
+ const appearance = raw.appearance ?? 'dark';
47
+ if (!appearances.includes(appearance)) {
48
+ throw new TypeError(`[scriptorium] documentation/config.js: appearance "${appearance}" — допустимо ${appearances.join(', ')}`);
49
+ }
50
+
51
+ const nav = raw.nav ?? [];
52
+ if (!Array.isArray(nav) || nav.some((item) => !isObject(item) || typeof item.text !== 'string' || typeof item.link !== 'string')) {
53
+ throw new TypeError('[scriptorium] documentation/config.js: nav — ожидается массив { text, link }');
54
+ }
55
+
56
+ const sidebar = isObject(raw.sidebar) ? raw.sidebar : {};
57
+ const outline = isObject(raw.outline) ? raw.outline : {};
58
+ const level = outline.level ?? [2, 3];
59
+ if (!Array.isArray(level) || level.length !== 2 || level.some((entry) => !Number.isInteger(entry))) {
60
+ throw new TypeError('[scriptorium] documentation/config.js: outline.level — ожидается [от, до], например [2, 3]');
61
+ }
62
+
63
+ const labels = isObject(raw.labels) ? raw.labels : {};
64
+
65
+ const lang = raw.lang ?? 'en-US';
66
+
67
+ return {
68
+ title: raw.title ?? 'Documentation',
69
+ description: raw.description ?? '',
70
+ lang,
71
+ // Язык интерфейса martyrs (vue-i18n) — первая часть кода: 'ru-RU' → 'ru'.
72
+ locale: lang.split('-')[0].toLowerCase(),
73
+ appearance,
74
+ skipped_names: stringList(raw.skipped_names, 'skipped_names'),
75
+ skipped_paths: stringList(raw.skipped_paths, 'skipped_paths'),
76
+ nav,
77
+ sidebar: {
78
+ priority: stringList(sidebar.priority, 'sidebar.priority'),
79
+ folders_to_bottom: sidebar.folders_to_bottom ?? true,
80
+ collapse_depth: sidebar.collapse_depth ?? 2,
81
+ hidden: stringList(sidebar.hidden, 'sidebar.hidden'),
82
+ },
83
+ outline: {
84
+ level,
85
+ label: outline.label ?? 'On this page',
86
+ },
87
+ // labels.search — вложенный объект: сливается отдельно, иначе частичный
88
+ // блок потребителя затёр бы остальные подписи поиска.
89
+ labels: {
90
+ ...default_labels,
91
+ ...labels,
92
+ search: { ...default_labels.search, ...(isObject(labels.search) ? labels.search : {}) },
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,21 @@
1
+ // Данные сайта для компонентов темы потребителя (аналог useData у VitePress):
2
+ // page — открытая страница { url, file, title, frontmatter, headers } или null (404);
3
+ // frontmatter — её frontmatter ({} на 404);
4
+ // config — конфиг сайта с умолчаниями;
5
+ // pages — все страницы сайта { url, file, title, frontmatter }.
6
+ // Открытая страница живёт в сторе запроса (store.core.state.docs): её ставит
7
+ // гард роутера, сервер отдаёт в data-state, клиент гидрирует. config и pages —
8
+ // константы сборки, в стор их класть незачем.
9
+ import { computed } from 'vue';
10
+ import { useStore } from '@ozdao/martyrs/modules/core/client';
11
+
12
+ import site_config from '../configs/site.config.js';
13
+ import { pages } from './pages.js';
14
+
15
+ export function useDocs() {
16
+ const store = useStore();
17
+ const page = computed(() => store.core.state.docs ?? null);
18
+ const frontmatter = computed(() => page.value?.frontmatter ?? {});
19
+
20
+ return { page, frontmatter, config: site_config, pages };
21
+ }
@@ -0,0 +1,257 @@
1
+ // ============================================================================
2
+ // Содержимое страницы документации (.doc) — HTML из markdown.loader.js
3
+ // ============================================================================
4
+ // Reset martyrs снимает браузерные отступы и маркеры списков — здесь они
5
+ // возвращаются для текста документации. Цвета — токены martyrs, отступы —
6
+ // шкала martyrs, кегли — --fs-* и --doc-h*.
7
+
8
+ .doc {
9
+ font-size: var(--fs-regular);
10
+ line-height: 1.75;
11
+ color: rgb(var(--text-primary));
12
+ overflow-wrap: break-word;
13
+
14
+ > :first-child {
15
+ margin-top: 0;
16
+ }
17
+
18
+ // ---- Заголовки ----
19
+
20
+ h1, h2, h3, h4, h5, h6 {
21
+ position: relative;
22
+ font-family: var(--font-main);
23
+ line-height: 1.3;
24
+ letter-spacing: 0;
25
+ scroll-margin-top: var(--medium);
26
+ }
27
+
28
+ h1 {
29
+ font-size: var(--doc-h1);
30
+ margin-bottom: var(--medium);
31
+ }
32
+
33
+ h2 {
34
+ font-size: var(--doc-h2);
35
+ margin-top: var(--big);
36
+ margin-bottom: var(--medium);
37
+ padding-top: var(--medium);
38
+ border-top: 1px solid var(--border-subtle);
39
+ }
40
+
41
+ h3 {
42
+ font-size: var(--doc-h3);
43
+ margin-top: var(--semi);
44
+ margin-bottom: var(--regular);
45
+ }
46
+
47
+ h4, h5, h6 {
48
+ font-size: var(--doc-h4);
49
+ margin-top: var(--medium);
50
+ margin-bottom: var(--small);
51
+ }
52
+
53
+ // Якорь заголовка: виден при наведении, стоит слева от текста, как у VitePress.
54
+ .header-anchor {
55
+ position: absolute;
56
+ left: 0;
57
+ margin-left: -0.87em;
58
+ color: rgb(var(--doc-link));
59
+ opacity: 0;
60
+ text-decoration: none;
61
+ transition: opacity 0.2s ease;
62
+ }
63
+
64
+ h1:hover .header-anchor,
65
+ h2:hover .header-anchor,
66
+ h3:hover .header-anchor,
67
+ h4:hover .header-anchor,
68
+ h5:hover .header-anchor,
69
+ h6:hover .header-anchor,
70
+ .header-anchor:focus {
71
+ opacity: 1;
72
+ }
73
+
74
+ // ---- Текст ----
75
+
76
+ p, ul, ol, blockquote, table, pre, details, .custom-block {
77
+ margin: var(--regular) 0;
78
+ }
79
+
80
+ p {
81
+ font-size: inherit;
82
+ line-height: inherit;
83
+ }
84
+
85
+ a {
86
+ color: rgb(var(--doc-link));
87
+ text-decoration: underline;
88
+ text-underline-offset: 2px;
89
+ transition: opacity 0.2s ease;
90
+
91
+ &:hover {
92
+ opacity: 0.8;
93
+ }
94
+ }
95
+
96
+ strong {
97
+ color: rgb(var(--text-primary));
98
+ }
99
+
100
+ hr {
101
+ margin: var(--semi) 0;
102
+ border: none;
103
+ border-top: 1px solid var(--border-subtle);
104
+ }
105
+
106
+ img {
107
+ max-width: 100%;
108
+ height: auto;
109
+ }
110
+
111
+ // ---- Списки ----
112
+
113
+ ul, ol {
114
+ padding-left: var(--medium);
115
+ }
116
+
117
+ ul {
118
+ list-style: disc;
119
+ }
120
+
121
+ ol {
122
+ list-style: decimal;
123
+ }
124
+
125
+ // reset martyrs ставит list-style: none на сам li — маркер берём у списка.
126
+ li {
127
+ list-style: inherit;
128
+ }
129
+
130
+ li + li {
131
+ margin-top: var(--thin);
132
+ }
133
+
134
+ li > ul, li > ol {
135
+ margin: var(--thin) 0 0;
136
+ }
137
+
138
+ // ---- Цитата ----
139
+
140
+ blockquote {
141
+ padding-left: var(--regular);
142
+ border-left: 2px solid var(--border-strong);
143
+ color: rgb(var(--text-secondary));
144
+ }
145
+
146
+ // ---- Таблицы (блок .table martyrs + прокрутка широких) ----
147
+
148
+ table {
149
+ display: block;
150
+ width: 100%;
151
+ overflow-x: auto;
152
+ border-collapse: collapse;
153
+ font-size: var(--fs-small);
154
+ }
155
+
156
+ th, td {
157
+ padding: var(--thin) var(--regular);
158
+ border: 1px solid var(--border-subtle);
159
+ text-align: left;
160
+ vertical-align: top;
161
+ }
162
+
163
+ th {
164
+ background: rgb(var(--bg-card));
165
+ }
166
+
167
+ tr:nth-child(2n) td {
168
+ background: rgba(var(--bg-card), 0.5);
169
+ }
170
+
171
+ // ---- Код ----
172
+
173
+ code {
174
+ font-family: var(--font-mono);
175
+ font-size: 0.875em;
176
+ }
177
+
178
+ :not(pre) > code {
179
+ padding: 0.15em 0.4em;
180
+ border-radius: var(--thin);
181
+ background: rgb(var(--bg-card));
182
+ color: rgb(var(--text-primary));
183
+ }
184
+
185
+ a > code {
186
+ color: inherit;
187
+ }
188
+
189
+ pre {
190
+ padding: var(--regular) var(--medium);
191
+ border-radius: var(--small);
192
+ background: rgb(var(--bg-card));
193
+ overflow-x: auto;
194
+ line-height: 1.6;
195
+ font-size: var(--fs-small);
196
+
197
+ code {
198
+ font-size: inherit;
199
+ }
200
+ }
201
+
202
+ // ---- Блоки ::: tip / info / warning / danger / details ----
203
+
204
+ .custom-block {
205
+ --block-rgb: var(--text-muted);
206
+ padding: var(--small) var(--regular);
207
+ border-radius: var(--small);
208
+ background: rgba(var(--block-rgb), 0.12);
209
+ border: 1px solid rgba(var(--block-rgb), 0.3);
210
+
211
+ > :first-child { margin-top: 0; }
212
+ > :last-child { margin-bottom: 0; }
213
+
214
+ p { margin: var(--thin) 0; }
215
+ }
216
+
217
+ .custom-block-title {
218
+ color: rgb(var(--block-rgb));
219
+ }
220
+
221
+ .info { --block-rgb: var(--text-muted); }
222
+ .tip { --block-rgb: var(--color-accent-secondary); }
223
+ .warning { --block-rgb: var(--color-warning); }
224
+ .danger { --block-rgb: var(--color-negative); }
225
+
226
+ details.custom-block summary {
227
+ cursor: pointer;
228
+ color: rgb(var(--text-primary));
229
+ }
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Подсветка кода shiki: две темы в CSS-переменных (--shiki-light / --shiki-dark),
234
+ // фон — поверхность martyrs, а не фон темы подсветки. Тёмная — по data-theme
235
+ // на :root и по системной теме, пока выбора не было (как тема martyrs).
236
+ // ---------------------------------------------------------------------------
237
+
238
+ .shiki,
239
+ .shiki span {
240
+ color: var(--shiki-light);
241
+ }
242
+
243
+ :root[data-theme='dark'] {
244
+ .shiki,
245
+ .shiki span {
246
+ color: var(--shiki-dark);
247
+ }
248
+ }
249
+
250
+ @media (prefers-color-scheme: dark) {
251
+ :root:not([data-theme='light']) {
252
+ .shiki,
253
+ .shiki span {
254
+ color: var(--shiki-dark);
255
+ }
256
+ }
257
+ }
@@ -0,0 +1,19 @@
1
+ // ============================================================================
2
+ // Дизайн-система сайта документации — единая точка подключения
3
+ // ============================================================================
4
+ // client.js импортирует только эту папку.
5
+ //
6
+ // Порядок важен: база дизайн-системы martyrs (слои @layer) → JIT-утилиты
7
+ // (unlayered, бьют слои) → тема сайта (unlayered, последняя — бьёт всё).
8
+ // Тема потребителя (documentation/theme/style.scss) подключается позже, через
9
+ // роутер, и перекрывает эту.
10
+ //
11
+ // Состав папки:
12
+ // theme.scss — токены сайта: шрифты, ширины колонок, кегли содержимого
13
+ // layout.scss — шапка, сайдбар, оглавление, «Назад / Дальше»
14
+ // doc.scss — типографика содержимого страницы (.doc) и подсветка кода
15
+ import '@ozdao/martyrs/design-system';
16
+ import 'martyrs-jit.css';
17
+ import './theme.scss';
18
+ import './layout.scss';
19
+ import './doc.scss';