@webship.co/workspace 0.0.0-stage → 1.0.0-beta1
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/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +466 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1439 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +7923 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +553 -0
package/themes.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The theme system: which palette the dashboard wears.
|
|
3
|
+
*
|
|
4
|
+
* A theme is one file — `public/themes/<name>/theme.css` — that sets the tokens every component
|
|
5
|
+
* asks for, in both halves (light and dark). Nothing else has to know which theme is loaded,
|
|
6
|
+
* because no component names a colour: they all say `var(--brand-primary)` rather than `#0074e0`.
|
|
7
|
+
*
|
|
8
|
+
* `/style.css` is assembled here rather than being a file on disk: the base layer, then every
|
|
9
|
+
* component, then the chosen theme last so it wins. One request, and a component is a file rather
|
|
10
|
+
* than another block appended to a 2,800-line stylesheet — which is also why two branches adding
|
|
11
|
+
* features stop conflicting over the same tail.
|
|
12
|
+
*
|
|
13
|
+
* Adopted from the tooling this dashboard grew from (its issue #64).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const yaml = require('js-yaml');
|
|
19
|
+
const yamlLoad = (text) => (typeof yaml.safeLoad === 'function' ? yaml.safeLoad(text) : yaml.load(text));
|
|
20
|
+
|
|
21
|
+
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
22
|
+
// Where a theme's YAML overrides live, beside the rest of the config people edit.
|
|
23
|
+
const CONFIG_DIR = process.env.WORKSPACE_CONFIG
|
|
24
|
+
|| path.join(process.env.WORKSPACE_ROOT || path.resolve(__dirname, '..', '..'), 'core', 'config');
|
|
25
|
+
const CSS_DIR = path.join(PUBLIC_DIR, 'css');
|
|
26
|
+
const THEMES_DIR = path.join(PUBLIC_DIR, 'themes');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every style on this machine: the ones that ship as a directory with a `theme.css`, and the ones
|
|
30
|
+
* that are nothing but a `core/config/theme.<name>.yml`.
|
|
31
|
+
*
|
|
32
|
+
* A YAML-only style is how somebody adds one without writing into the app's own directory — which
|
|
33
|
+
* is the difference between a style being something a developer creates and a style being something
|
|
34
|
+
* only a release can add. It works because a theme is a palette: with every token in the YAML there
|
|
35
|
+
* is nothing left for a stylesheet to say.
|
|
36
|
+
*/
|
|
37
|
+
const THEME_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
38
|
+
|
|
39
|
+
function listThemes() {
|
|
40
|
+
const names = new Set();
|
|
41
|
+
try {
|
|
42
|
+
for (const e of fs.readdirSync(THEMES_DIR, { withFileTypes: true })) {
|
|
43
|
+
if (e.isDirectory() && fs.existsSync(path.join(THEMES_DIR, e.name, 'theme.css'))) names.add(e.name);
|
|
44
|
+
}
|
|
45
|
+
} catch (_) { /* no themes directory */ }
|
|
46
|
+
try {
|
|
47
|
+
for (const f of fs.readdirSync(CONFIG_DIR)) {
|
|
48
|
+
const m = f.match(/^theme\.([a-z][a-z0-9-]*)\.yml$/);
|
|
49
|
+
// A YAML with no tokens in it is a file somebody started, not a style you can wear.
|
|
50
|
+
if (m && Object.keys(readThemeOverrides(m[1]).tokens).length) names.add(m[1]);
|
|
51
|
+
}
|
|
52
|
+
} catch (_) { /* no config directory */ }
|
|
53
|
+
return [...names].sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Does this style exist only as YAML? Then its tokens are all there is. */
|
|
57
|
+
function isYamlOnlyTheme(name) {
|
|
58
|
+
return Boolean(name) && !fs.existsSync(path.join(THEMES_DIR, name, 'theme.css'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Was this style made on this machine, or did it come with the workspace?
|
|
63
|
+
*
|
|
64
|
+
* Not the same question as `isYamlOnlyTheme`, though the Styles page asked that one for a while and
|
|
65
|
+
* labelled the answer "made here". Five styles that ship in the repository — Dispatch, UPSUN,
|
|
66
|
+
* GitHub, GitLab and Drupal — are YAML and nothing else, because a style is a set of tokens and a
|
|
67
|
+
* stylesheet would be a second place to keep them. They were being credited to whoever opened the
|
|
68
|
+
* page.
|
|
69
|
+
*
|
|
70
|
+
* `createTheme` stamps what it writes, so the marker is on the file rather than inferred from its
|
|
71
|
+
* shape, and a style that ships needs nothing said about it.
|
|
72
|
+
*/
|
|
73
|
+
function isMadeHere(name) {
|
|
74
|
+
if (!name) return false;
|
|
75
|
+
try {
|
|
76
|
+
const doc = yamlLoad(fs.readFileSync(themeOverridesFile(name), 'utf8'));
|
|
77
|
+
return Boolean(doc && doc.made_here);
|
|
78
|
+
} catch (_) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// What a theme calls itself, for the settings form. One line at the top of the file, so naming a
|
|
84
|
+
// theme does not need a second file to parse.
|
|
85
|
+
function themeLabel(name) {
|
|
86
|
+
try {
|
|
87
|
+
const head = fs.readFileSync(path.join(THEMES_DIR, name, 'theme.css'), 'utf8').slice(0, 600);
|
|
88
|
+
const m = head.match(/^\s*\*\s*@name\s+(.+)$/m);
|
|
89
|
+
if (m) return m[1].trim();
|
|
90
|
+
} catch (_) { /* no CSS: a YAML-only style names itself in its own file */ }
|
|
91
|
+
try {
|
|
92
|
+
const doc = yamlLoad(fs.readFileSync(path.join(CONFIG_DIR, `theme.${name}.yml`), 'utf8'));
|
|
93
|
+
if (doc && typeof doc.name === 'string' && doc.name.trim()) return doc.name.trim();
|
|
94
|
+
} catch (_) { /* fall through to the file name */ }
|
|
95
|
+
return name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' ');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A name that is not a theme falls back to the default, so a typo in settings.yml is a dashboard
|
|
99
|
+
// that looks ordinary rather than one with no styling at all.
|
|
100
|
+
function resolveTheme(wanted) {
|
|
101
|
+
const themes = listThemes();
|
|
102
|
+
if (wanted && themes.includes(wanted)) return wanted;
|
|
103
|
+
return themes.includes('default') ? 'default' : themes[0] || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The order components are concatenated in, which is the order they had when they were one file.
|
|
107
|
+
//
|
|
108
|
+
// Not alphabetical: CSS is a cascade, and two rules of equal specificity are decided by which came
|
|
109
|
+
// last. Sorting by name would change which wins. Anything not listed goes last, alphabetically,
|
|
110
|
+
// which is where a newly added component belongs anyway.
|
|
111
|
+
const COMPONENT_ORDER = [
|
|
112
|
+
'hero',
|
|
113
|
+
'workspace-cards',
|
|
114
|
+
'ai-assistant',
|
|
115
|
+
'deep-chat',
|
|
116
|
+
'project-rows',
|
|
117
|
+
'floating-assistant-workspace-pages',
|
|
118
|
+
'working-indicators',
|
|
119
|
+
'top-toolbar-logo-breadcrumb',
|
|
120
|
+
'layout-rhythm-and-spacing-polish',
|
|
121
|
+
'full-height-left-assistant-sidebar-all-pages',
|
|
122
|
+
'full-height-right-actions-rail-pages-that-have-actions',
|
|
123
|
+
// After the sidebar's own rules, because it is what re-sizes and re-places that sidebar.
|
|
124
|
+
'assistant-views',
|
|
125
|
+
// After the layout components, since it re-caps the container they lay out.
|
|
126
|
+
'page-width',
|
|
127
|
+
'mic-on-any-field-that-wants-typing',
|
|
128
|
+
'settings-editor-core-config-yml',
|
|
129
|
+
'responsive-home-and-the-workspace-pages',
|
|
130
|
+
'running-commands-as-toasts',
|
|
131
|
+
'short-results-as-a-toast-at-the-top',
|
|
132
|
+
'reordering-the-workspace-cards',
|
|
133
|
+
// After the card and row components, since it re-lays out what they draw.
|
|
134
|
+
'grid-and-list',
|
|
135
|
+
'test-report-media-one-file-per-row-paged',
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
function listComponents() {
|
|
139
|
+
let files = [];
|
|
140
|
+
try {
|
|
141
|
+
files = fs.readdirSync(path.join(CSS_DIR, 'components'))
|
|
142
|
+
.filter((f) => f.endsWith('.css'))
|
|
143
|
+
.map((f) => f.replace(/\.css$/, ''));
|
|
144
|
+
} catch (_) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
const known = COMPONENT_ORDER.filter((n) => files.includes(n));
|
|
148
|
+
const rest = files.filter((n) => !COMPONENT_ORDER.includes(n)).sort();
|
|
149
|
+
return [...known, ...rest];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const readOrEmpty = (file) => {
|
|
153
|
+
try { return fs.readFileSync(file, 'utf8'); } catch (_) { return ''; }
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
/* ---------------- tokens as YAML ---------------- */
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A theme's tokens, overridable from `core/config/theme.<name>.yml`.
|
|
161
|
+
*
|
|
162
|
+
* A theme file is a palette: it declares custom properties and selects nothing. That makes every
|
|
163
|
+
* value in it a candidate for configuration, and CSS is the wrong place to ask somebody to change
|
|
164
|
+
* one — it means editing a stylesheet in the app's own directory, which is not where this workspace
|
|
165
|
+
* keeps anything else people set.
|
|
166
|
+
*
|
|
167
|
+
* So the same tokens can be written in YAML, in the config directory with `settings.yml` and the
|
|
168
|
+
* workspace files, where the dashboard's own settings editor can reach them:
|
|
169
|
+
*
|
|
170
|
+
* # core/config/theme.press.yml
|
|
171
|
+
* tokens:
|
|
172
|
+
* size-h1: 2.25rem
|
|
173
|
+
* brand-primary: "#0074e0"
|
|
174
|
+
* dark:
|
|
175
|
+
* surface-card: "#1c2136"
|
|
176
|
+
*
|
|
177
|
+
* Names are written without the leading `--`; one is added if it is missing, so either spelling
|
|
178
|
+
* works. `tokens:` applies to both halves; `dark:` applies to the dark half only, exactly like the
|
|
179
|
+
* `html.dark` block in a theme file.
|
|
180
|
+
*
|
|
181
|
+
* The YAML is emitted AFTER the theme's CSS, so it wins — an override is an override. A file that
|
|
182
|
+
* does not exist, or one that is unreadable, leaves the theme exactly as it shipped: appearance is
|
|
183
|
+
* configuration, and a typo in configuration should not cost you a styled dashboard.
|
|
184
|
+
*/
|
|
185
|
+
function themeOverridesFile(name) {
|
|
186
|
+
return path.join(CONFIG_DIR, `theme.${name}.yml`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readThemeOverrides(name) {
|
|
190
|
+
if (!name) return { tokens: {}, dark: {} };
|
|
191
|
+
let doc;
|
|
192
|
+
try {
|
|
193
|
+
doc = yamlLoad(fs.readFileSync(themeOverridesFile(name), 'utf8'));
|
|
194
|
+
} catch (_) {
|
|
195
|
+
return { tokens: {}, dark: {} };
|
|
196
|
+
}
|
|
197
|
+
const clean = (block) => {
|
|
198
|
+
const out = {};
|
|
199
|
+
if (!block || typeof block !== 'object') return out;
|
|
200
|
+
for (const [rawKey, rawValue] of Object.entries(block)) {
|
|
201
|
+
// A token name, and nothing that could close the block and start a rule of its own.
|
|
202
|
+
const key = String(rawKey).trim().replace(/^--/, '');
|
|
203
|
+
if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(key)) continue;
|
|
204
|
+
if (rawValue === null || rawValue === undefined) continue;
|
|
205
|
+
const value = String(rawValue).trim();
|
|
206
|
+
if (!value || /[{};]/.test(value)) continue;
|
|
207
|
+
out[`--${key}`] = value;
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
};
|
|
211
|
+
return { tokens: clean(doc && doc.tokens), dark: clean(doc && doc.dark) };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The override blocks as CSS, or '' when a theme has no YAML of its own. */
|
|
215
|
+
function themeOverridesCss(name) {
|
|
216
|
+
const { tokens, dark } = readThemeOverrides(name);
|
|
217
|
+
const block = (selector, map) => {
|
|
218
|
+
const keys = Object.keys(map);
|
|
219
|
+
if (!keys.length) return '';
|
|
220
|
+
return `${selector} {\n${keys.map((k) => ` ${k}: ${map[k]};`).join('\n')}\n}\n`;
|
|
221
|
+
};
|
|
222
|
+
return [block(':root', tokens), block('html.dark', dark)].filter(Boolean).join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Base, then components, then the theme. Each part is labelled, so "which file is this rule in"
|
|
226
|
+
// is answerable from the browser's own view-source rather than by grepping.
|
|
227
|
+
function assembleCss(wanted) {
|
|
228
|
+
const theme = resolveTheme(wanted);
|
|
229
|
+
const parts = [`/* base */\n${readOrEmpty(path.join(CSS_DIR, 'base.css'))}`];
|
|
230
|
+
for (const name of listComponents()) {
|
|
231
|
+
parts.push(`/* component: ${name} */\n${readOrEmpty(path.join(CSS_DIR, 'components', `${name}.css`))}`);
|
|
232
|
+
}
|
|
233
|
+
// What activated plugins add, after the components: a plugin stylesheet may only add rules.
|
|
234
|
+
for (const f of require('./plugin-loader').pluginCssFiles()) {
|
|
235
|
+
parts.push(`/* plugin: ${path.basename(f)} */\n${readOrEmpty(f)}`);
|
|
236
|
+
}
|
|
237
|
+
// A style that exists only as YAML has no stylesheet to read — its tokens are the whole palette,
|
|
238
|
+
// and they are appended below like any other override.
|
|
239
|
+
if (theme && !isYamlOnlyTheme(theme)) {
|
|
240
|
+
parts.push(`/* theme: ${theme} */\n${readOrEmpty(path.join(THEMES_DIR, theme, 'theme.css'))}`);
|
|
241
|
+
}
|
|
242
|
+
// Last, so a token set in YAML beats the same token in the theme file.
|
|
243
|
+
const overrides = themeOverridesCss(theme);
|
|
244
|
+
if (overrides) parts.push(`/* theme overrides: core/config/theme.${theme}.yml */\n${overrides}`);
|
|
245
|
+
return parts.join('\n\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Create a style from an existing one, as YAML.
|
|
251
|
+
*
|
|
252
|
+
* Every token is copied rather than referenced, so the new style is complete on its own and editing
|
|
253
|
+
* the one it came from cannot change it later. A style that inherited would be a style that moved
|
|
254
|
+
* under somebody, which is not what "based on" means when you are about to change fifteen values.
|
|
255
|
+
*/
|
|
256
|
+
function createTheme({ name, label, from }) {
|
|
257
|
+
const clean = String(name || '').trim().toLowerCase();
|
|
258
|
+
if (!THEME_NAME_RE.test(clean)) {
|
|
259
|
+
return { ok: false, error: 'A style name is lowercase letters, digits and hyphens, starting with a letter.' };
|
|
260
|
+
}
|
|
261
|
+
if (listThemes().includes(clean) || fs.existsSync(path.join(THEMES_DIR, clean))) {
|
|
262
|
+
return { ok: false, error: `A style called ${clean} already exists.` };
|
|
263
|
+
}
|
|
264
|
+
const source = resolveTheme(from);
|
|
265
|
+
if (!source) return { ok: false, error: 'There is no style to base it on.' };
|
|
266
|
+
|
|
267
|
+
// Whatever the source actually renders with: its YAML if it has one, else its stylesheet.
|
|
268
|
+
const fromYaml = readThemeOverrides(source);
|
|
269
|
+
let tokens = fromYaml.tokens;
|
|
270
|
+
let dark = fromYaml.dark;
|
|
271
|
+
if (!Object.keys(tokens).length) {
|
|
272
|
+
const css = readOrEmpty(path.join(THEMES_DIR, source, 'theme.css')).replace(/\/\*[\s\S]*?\*\//g, '');
|
|
273
|
+
const grab = (selector) => {
|
|
274
|
+
const out = {};
|
|
275
|
+
const re = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, 'g');
|
|
276
|
+
let m;
|
|
277
|
+
while ((m = re.exec(css))) {
|
|
278
|
+
for (const d of m[1].matchAll(/(--[a-zA-Z0-9-]+)\s*:\s*([^;]+);/g)) out[d[1]] = d[2].trim();
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
};
|
|
282
|
+
tokens = grab(':root');
|
|
283
|
+
dark = grab('html\\.dark');
|
|
284
|
+
}
|
|
285
|
+
if (!Object.keys(tokens).length) return { ok: false, error: `${source} has no tokens to copy.` };
|
|
286
|
+
|
|
287
|
+
const title = String(label || '').trim() || clean.charAt(0).toUpperCase() + clean.slice(1).replace(/-/g, ' ');
|
|
288
|
+
// Quoted unless it is plainly safe, so a value with a colon or a hash survives the round trip.
|
|
289
|
+
const q = (v) => (/^[A-Za-z0-9._/-]+$/.test(v) ? v : `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
|
|
290
|
+
const rows = (map) => Object.entries(map).map(([k, v]) => ` ${k.replace(/^--/, '')}: ${q(v)}`);
|
|
291
|
+
const lines = [
|
|
292
|
+
`# ${title} — core/config/theme.${clean}.yml`,
|
|
293
|
+
'#',
|
|
294
|
+
`# A style, created from ${source}. Every token was copied, so this file is complete on its own:`,
|
|
295
|
+
'# editing the style it came from will not change it.',
|
|
296
|
+
'#',
|
|
297
|
+
'# Change any value and reload — there is no stylesheet to edit and nothing to restart. Names go',
|
|
298
|
+
'# without the leading dashes; `tokens:` applies to both halves and `dark:` to the dark half only.',
|
|
299
|
+
'',
|
|
300
|
+
`name: ${q(title)}`,
|
|
301
|
+
'',
|
|
302
|
+
'# Made on this machine rather than shipped with the workspace. It is what the Styles page reads',
|
|
303
|
+
'# to tell the two apart — the format cannot, because the styles that ship are YAML too.',
|
|
304
|
+
'made_here: true',
|
|
305
|
+
'',
|
|
306
|
+
'tokens:',
|
|
307
|
+
...rows(tokens),
|
|
308
|
+
];
|
|
309
|
+
if (Object.keys(dark).length) lines.push('', '# The dark half.', 'dark:', ...rows(dark));
|
|
310
|
+
|
|
311
|
+
const file = themeOverridesFile(clean);
|
|
312
|
+
fs.writeFileSync(file, `${lines.join('\n')}\n`);
|
|
313
|
+
return { ok: true, name: clean, label: title, from: source, file, tokens: Object.keys(tokens).length };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The handful of values that let a style be RECOGNISED without wearing it.
|
|
319
|
+
*
|
|
320
|
+
* Choosing a style from a list of names is guessing. A swatch of its own brand colour, its surface,
|
|
321
|
+
* its line and its text — painted in those actual values — is the difference between picking one and
|
|
322
|
+
* finding out what you picked. One level of `var(--x)` is resolved, because a theme's semantic
|
|
323
|
+
* tokens are usually aliases onto its brand primitives and an unresolved alias swatches as nothing.
|
|
324
|
+
*/
|
|
325
|
+
function themePreview(name) {
|
|
326
|
+
const theme = resolveTheme(name);
|
|
327
|
+
if (!theme) return null;
|
|
328
|
+
const from = readThemeOverrides(theme);
|
|
329
|
+
let map = new Map(Object.entries(from.tokens));
|
|
330
|
+
if (!map.size) {
|
|
331
|
+
const css = readOrEmpty(path.join(THEMES_DIR, theme, 'theme.css')).replace(/\/\*[\s\S]*?\*\//g, '');
|
|
332
|
+
const m = /:root\s*\{([^}]*)\}/.exec(css);
|
|
333
|
+
if (m) for (const d of m[1].matchAll(/(--[a-zA-Z0-9-]+)\s*:\s*([^;]+);/g)) map.set(d[1], d[2].trim());
|
|
334
|
+
}
|
|
335
|
+
const raw = (k) => map.get(k.startsWith('--') ? k : `--${k}`) || '';
|
|
336
|
+
const value = (k) => {
|
|
337
|
+
const v = raw(k);
|
|
338
|
+
const alias = /^var\(\s*(--[a-zA-Z0-9-]+)\s*\)$/.exec(v);
|
|
339
|
+
return alias ? (raw(alias[1]) || '') : v;
|
|
340
|
+
};
|
|
341
|
+
return {
|
|
342
|
+
name: theme,
|
|
343
|
+
label: themeLabel(theme),
|
|
344
|
+
yamlOnly: isYamlOnlyTheme(theme),
|
|
345
|
+
madeHere: isMadeHere(theme),
|
|
346
|
+
tokens: map.size,
|
|
347
|
+
brand: value('brand-primary'),
|
|
348
|
+
secondary: value('brand-secondary'),
|
|
349
|
+
accent: value('brand-accent'),
|
|
350
|
+
surface: value('surface-card') || value('surface'),
|
|
351
|
+
line: value('line'),
|
|
352
|
+
text: value('text-default'),
|
|
353
|
+
// What its type actually does, so a style with a different scale reads as one in the list.
|
|
354
|
+
fontPrimary: value('font-primary'),
|
|
355
|
+
fontSecondary: value('font-secondary'),
|
|
356
|
+
sizeBody: value('size-body'),
|
|
357
|
+
sizeH2: value('size-h2'),
|
|
358
|
+
trackingH2: value('tracking-h2') || 'normal',
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// A cache key that changes when any part does. The browser is told to cache `/style.css`, so
|
|
363
|
+
// editing a component with no version change would show yesterday's stylesheet.
|
|
364
|
+
function cssVersion(wanted) {
|
|
365
|
+
const files = [path.join(CSS_DIR, 'base.css'),
|
|
366
|
+
...listComponents().map((n) => path.join(CSS_DIR, 'components', `${n}.css`)),
|
|
367
|
+
...require('./plugin-loader').pluginCssFiles()];
|
|
368
|
+
const theme = resolveTheme(wanted);
|
|
369
|
+
if (theme) files.push(path.join(THEMES_DIR, theme, 'theme.css'), themeOverridesFile(theme));
|
|
370
|
+
let stamp = 0;
|
|
371
|
+
for (const f of files) {
|
|
372
|
+
try { stamp = Math.max(stamp, fs.statSync(f).mtimeMs); } catch (_) { /* skip */ }
|
|
373
|
+
}
|
|
374
|
+
return `${theme || 'none'}-${Math.round(stamp)}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = {
|
|
378
|
+
listThemes, themeLabel, resolveTheme, listComponents, assembleCss, cssVersion,
|
|
379
|
+
themeOverridesFile, readThemeOverrides, themeOverridesCss,
|
|
380
|
+
isYamlOnlyTheme, isMadeHere, createTheme, themePreview, THEME_NAME_RE,
|
|
381
|
+
};
|
package/transcripts.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The voiceover script of a video, shown with it.
|
|
3
|
+
*
|
|
4
|
+
* A recording's script sits in docs/ beside the write-up, named after the video: `<video>-voiceover-script.md`.
|
|
5
|
+
* People who watch the video, or cannot hear it, want the words; this finds the script and draws the spoken
|
|
6
|
+
* lines, section by section, with the section's start time as a button that seeks the player.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const { esc } = require('./html');
|
|
13
|
+
|
|
14
|
+
const SUFFIXES = ['-voiceover-script', '-voiceover', '-transcript', '-script'];
|
|
15
|
+
const HEADING = /^##\s+(\S+)\s+(.+?)\s+\(((?:\d+:)?\d{1,2}:\d{2}(?:\.\d+)?)\s+to\s+((?:\d+:)?\d{1,2}:\d{2}(?:\.\d+)?)\)\s*$/;
|
|
16
|
+
const MAX_BYTES = 200 * 1024;
|
|
17
|
+
|
|
18
|
+
const memo = new Map();
|
|
19
|
+
|
|
20
|
+
/** The markdown files at the top of `dir`, remembered for a moment: a list asks once per row. */
|
|
21
|
+
function scriptNames(dir) {
|
|
22
|
+
const hit = memo.get(dir);
|
|
23
|
+
if (hit && Date.now() - hit.at < 3000) return hit.names;
|
|
24
|
+
let names = new Set();
|
|
25
|
+
try { names = new Set(fs.readdirSync(dir).filter((n) => n.endsWith('.md'))); } catch (_) { /* no such folder */ }
|
|
26
|
+
memo.set(dir, { at: Date.now(), names });
|
|
27
|
+
return names;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The script file of a video stem, or null. It is looked for in docs/ first and then in `also`, a
|
|
32
|
+
* `{ dir, key }` for a second folder (the videos folder, where some scripts were saved beside their video).
|
|
33
|
+
*/
|
|
34
|
+
function findScript(docsDir, stem, also = null) {
|
|
35
|
+
for (const dir of [docsDir, also && also.dir].filter(Boolean)) {
|
|
36
|
+
const names = scriptNames(dir);
|
|
37
|
+
const hit = SUFFIXES.map((s) => `${stem}${s}.md`).find((n) => names.has(n));
|
|
38
|
+
if (hit) return hit;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Which folder holds a script `findScript` found: `{ dir, key }`, docs/ first. */
|
|
44
|
+
function locateScript(docsDir, name, also = null) {
|
|
45
|
+
if (also && !scriptNames(docsDir).has(name) && scriptNames(also.dir).has(name)) return { dir: also.dir, key: also.key };
|
|
46
|
+
return { dir: docsDir, key: 'docs' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Give every recording its script, in the same pass that built the rows. */
|
|
50
|
+
function attachScripts(groups, docsDir, also = null) {
|
|
51
|
+
for (const [stem, g] of groups) {
|
|
52
|
+
if (g.video) g.script = findScript(docsDir, stem, also);
|
|
53
|
+
}
|
|
54
|
+
return groups;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const seconds = (stamp) => stamp.split(':').reduce((t, part) => t * 60 + parseFloat(part), 0);
|
|
58
|
+
|
|
59
|
+
function stamp(sec) {
|
|
60
|
+
const s = Math.max(0, Math.round(sec));
|
|
61
|
+
const h = Math.floor(s / 3600);
|
|
62
|
+
const m = Math.floor((s % 3600) / 60);
|
|
63
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
64
|
+
return h ? `${h}:${pad(m)}:${pad(s % 60)}` : `${pad(m)}:${pad(s % 60)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const spoken = (line) => line.replace(/^>\s?/, '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*`]/g, '').trim();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The script as people read it. A timed script gives sections with their spoken lines; anything else
|
|
71
|
+
* gives its paragraphs. On-screen notes, tables and metadata are not words anyone says, so they stay out.
|
|
72
|
+
*/
|
|
73
|
+
function parseTranscript(text) {
|
|
74
|
+
const lines = String(text).split('\n');
|
|
75
|
+
const title = ((lines.find((l) => /^#\s+/.test(l)) || '').replace(/^#\s+/, '').trim()) || '';
|
|
76
|
+
if (lines.some((l) => HEADING.test(l))) {
|
|
77
|
+
const sections = [];
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const h = line.match(HEADING);
|
|
80
|
+
if (h) { sections.push({ id: h[1], title: h[2], start: seconds(h[3]), end: seconds(h[4]), lines: [] }); continue; }
|
|
81
|
+
if (sections.length && line.startsWith('> ')) {
|
|
82
|
+
const optional = /\*\(optional\)\*/.test(line);
|
|
83
|
+
const t = spoken(line.replace(/\*\(optional\)\*\s*/g, ''));
|
|
84
|
+
if (t) sections[sections.length - 1].lines.push({ text: t, optional });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { title, timed: true, sections: sections.filter((s) => s.lines.length) };
|
|
88
|
+
}
|
|
89
|
+
const quoted = lines.filter((l) => l.startsWith('> ')).map(spoken).filter(Boolean);
|
|
90
|
+
const paragraphs = quoted.length ? quoted
|
|
91
|
+
: String(text).split(/\n\s*\n/).map((b) => b.split('\n').filter((l) => l.trim() && !/^\s*(#|[-*+]\s|\||\d+[.)]\s)/.test(l)).join(' ').trim()).filter(Boolean);
|
|
92
|
+
return { title, timed: false, paragraphs };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const SOUND_TEXT = ['.txt', '.md', '-transcript.md', '-transcript.txt', '-script.md', '-voiceover-script.md'];
|
|
96
|
+
const RECORD_ORDER = [['kind', 'Made as'], ['engine', 'With'], ['voice', 'Voice'], ['model', 'Model'], ['duration', 'Duration (s)'], ['seconds', 'Length (s)'],
|
|
97
|
+
['loop', 'Loop'], ['instrumental', 'Instrumental'], ['created_utc', 'Made']];
|
|
98
|
+
|
|
99
|
+
/** What belongs to a sound, beside it: the text it was made from, and the record of how. */
|
|
100
|
+
function soundBelongings(dir, stem) {
|
|
101
|
+
let text = null;
|
|
102
|
+
for (const suffix of SOUND_TEXT) {
|
|
103
|
+
try {
|
|
104
|
+
const f = path.join(dir, stem + suffix);
|
|
105
|
+
if (fs.statSync(f).size <= MAX_BYTES) { text = { file: stem + suffix, content: fs.readFileSync(f, 'utf8') }; break; }
|
|
106
|
+
} catch (_) { /* not there */ }
|
|
107
|
+
}
|
|
108
|
+
let record = null;
|
|
109
|
+
try { record = JSON.parse(fs.readFileSync(path.join(dir, `${stem}.json`), 'utf8')); } catch (_) { /* none */ }
|
|
110
|
+
return { text, record };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function recordHtml(rec) {
|
|
114
|
+
if (!rec || typeof rec !== 'object') return '';
|
|
115
|
+
const rows = RECORD_ORDER.filter(([k]) => rec[k] !== undefined && rec[k] !== '' && typeof rec[k] !== 'object')
|
|
116
|
+
.map(([k, label]) => `<span class="sound-fact"><span class="uk-text-meta">${label}</span> ${esc(typeof rec[k] === 'boolean' ? (rec[k] ? 'yes' : 'no') : String(rec[k]))}</span>`);
|
|
117
|
+
return rows.length ? `<p class="sound-record">${rows.join('')}</p>` : '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A sound's card: how it was made, and the words. `hrefFor` turns a file beside it into its address. */
|
|
121
|
+
function soundCardHtml(dir, stem, hrefFor) {
|
|
122
|
+
const { text, record } = soundBelongings(dir, stem);
|
|
123
|
+
if (!text && !record) return null;
|
|
124
|
+
const made = record && (record.kind === 'sound' || record.kind === 'music');
|
|
125
|
+
const body = text ? transcriptHtml(parseTranscript(text.content), { file: text.file, href: hrefFor(text.file) })
|
|
126
|
+
.replace('<h4 class="uk-margin-small-bottom">Transcript', `<h4 class="uk-margin-small-bottom">${made ? 'Description' : 'Transcript'}`) : '';
|
|
127
|
+
return `<div class="sound-card">${recordHtml(record)}${body}</div>`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The transcript as markup. `seek` makes each section's time a button the player answers to. */
|
|
131
|
+
function transcriptHtml(parsed, { file, href, seek = false, heading = true } = {}) {
|
|
132
|
+
const from = file ? `<span class="uk-text-meta">from <a href="${esc(href)}" target="_blank">${esc(file)}</a></span>` : '';
|
|
133
|
+
let body;
|
|
134
|
+
if (parsed.timed) {
|
|
135
|
+
body = parsed.sections.map((s) => {
|
|
136
|
+
const time = seek
|
|
137
|
+
? `<button type="button" class="transcript-seek" data-seek="${s.start}" title="Play from ${stamp(s.start)}">${stamp(s.start)}</button>`
|
|
138
|
+
: `<span class="transcript-time">${stamp(s.start)}</span>`;
|
|
139
|
+
return `<section class="transcript-section" data-start="${s.start}" data-end="${s.end}"><h5>${time} ${esc(s.title)}</h5>${s.lines.map((l) => `<p${l.optional ? ' class="is-optional" title="Optional: dropped when the speech runs long"' : ''}>${esc(l.text)}</p>`).join('')}</section>`;
|
|
140
|
+
}).join('');
|
|
141
|
+
} else {
|
|
142
|
+
body = parsed.paragraphs.map((p) => `<p>${esc(p)}</p>`).join('');
|
|
143
|
+
}
|
|
144
|
+
const head = heading ? `<h4 class="uk-margin-small-bottom">Transcript ${from}</h4>` : (from ? `<p class="uk-margin-small-bottom">${from}</p>` : '');
|
|
145
|
+
return `<div class="transcript">${head}<div class="transcript-body">${body || '<p class="uk-text-meta">The script has no spoken lines.</p>'}</div></div>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The sections of a timed script as chapters: where each starts and ends, and what it is called. */
|
|
149
|
+
function chaptersFor(docsDir, scriptName, also = null) {
|
|
150
|
+
if (!scriptName) return [];
|
|
151
|
+
try {
|
|
152
|
+
const parsed = parseTranscript(fs.readFileSync(path.join(locateScript(docsDir, scriptName, also).dir, scriptName), 'utf8'));
|
|
153
|
+
return parsed.timed ? parsed.sections.map((s) => ({ start: s.start, end: s.end, title: `${s.id} ${s.title}` })) : [];
|
|
154
|
+
} catch (_) { return []; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Read and draw a script from docs/, or null when there is none or it cannot be read. */
|
|
158
|
+
function transcriptFor(docsDir, scriptName, { also = null, ...opts } = {}) {
|
|
159
|
+
if (!scriptName) return null;
|
|
160
|
+
const home = locateScript(docsDir, scriptName, also);
|
|
161
|
+
const file = path.join(home.dir, scriptName);
|
|
162
|
+
try {
|
|
163
|
+
if (fs.statSync(file).size > MAX_BYTES) return null;
|
|
164
|
+
return transcriptHtml(parseTranscript(fs.readFileSync(file, 'utf8')), { file: scriptName, href: `/files/${home.key}/${encodeURIComponent(scriptName)}`, ...opts });
|
|
165
|
+
} catch (_) { return null; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { findScript, locateScript, attachScripts, parseTranscript, transcriptHtml, transcriptFor, chaptersFor, soundBelongings, recordHtml, soundCardHtml, stamp, SUFFIXES };
|
package/urls.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's URLs, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Locally the hub is workspace.ddev.site; `hub_domain` in settings.yml points the whole scheme at a
|
|
5
|
+
* public domain instead, which is what lets this run as a remote development hub.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { hubDomain } = require('./workspaces');
|
|
9
|
+
|
|
10
|
+
const HOME_URL = () => `https://${hubDomain()}`;
|
|
11
|
+
const wsUrl = (key, sub = '') => `https://${key}.${hubDomain()}${sub}`;
|
|
12
|
+
|
|
13
|
+
module.exports = { HOME_URL, wsUrl };
|
package/validate.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a name is allowed to be.
|
|
3
|
+
*
|
|
4
|
+
* Every action that takes a project or a script name checks it against these before touching the
|
|
5
|
+
* filesystem — so a crafted name is refused at the door rather than resolved and then found to be
|
|
6
|
+
* outside the tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const NAME_RE = /^[a-zA-Z0-9_\-]+$/;
|
|
10
|
+
// A file item — a doc, a worklog, an agent — is named after its file, and those
|
|
11
|
+
// names carry dots: a version in a report name, a time in a dated worklog. The
|
|
12
|
+
// project rule above refuses them, which made Edit answer 400 on real files. This
|
|
13
|
+
// one allows a dot inside the name while still refusing traversal: nothing may
|
|
14
|
+
// start with a dot, and ".." never appears.
|
|
15
|
+
const ITEM_NAME_RE = /^(?!\.)(?!.*\.\.)[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
16
|
+
const SCRIPT_RE = /^cmd-[a-zA-Z0-9_.-]+\.sh$/;
|
|
17
|
+
|
|
18
|
+
module.exports = { NAME_RE, ITEM_NAME_RE, SCRIPT_RE };
|
package/version-check.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is there a newer release than the one this machine runs?
|
|
3
|
+
*
|
|
4
|
+
* The version in the top bar answers "what am I on". It cannot answer "and is that current", and a
|
|
5
|
+
* number with nothing beside it reads as up to date whether it is or not — which is how a team ends up
|
|
6
|
+
* spread across four releases with nobody aware of it.
|
|
7
|
+
*
|
|
8
|
+
* Three rules, all of them about not lying:
|
|
9
|
+
*
|
|
10
|
+
* - **Unknown is not up to date.** This repository is private, and the dashboard's container has no
|
|
11
|
+
* GitHub token of its own unless one is put in `.ddev/config.yaml` `web_environment` (the same gap
|
|
12
|
+
* the AI-items repo column reports). When the check cannot be made, nothing is shown — never a
|
|
13
|
+
* reassuring silence dressed up as a verdict.
|
|
14
|
+
* - **Only a number that is really greater is newer.** A tag this cannot parse says nothing.
|
|
15
|
+
* - **It never blocks a page.** The answer arrives in a fragment on its own, cached for hours, and a
|
|
16
|
+
* page with no network renders exactly as fast as one with.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { execFile } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const NUMERIC = /^[0-9]+(\.[0-9]+)*$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Is `candidate` a later release than `current`? Segment by segment, numerically. Anything that is not
|
|
25
|
+
* a plain dotted number is not comparable, and not comparable means no claim either way.
|
|
26
|
+
*/
|
|
27
|
+
function isNewer(candidate, current) {
|
|
28
|
+
const a = String(candidate || '').trim();
|
|
29
|
+
const b = String(current || '').trim();
|
|
30
|
+
if (!NUMERIC.test(a) || !NUMERIC.test(b)) return false;
|
|
31
|
+
const as = a.split('.').map(Number);
|
|
32
|
+
const bs = b.split('.').map(Number);
|
|
33
|
+
for (let i = 0; i < Math.max(as.length, bs.length); i += 1) {
|
|
34
|
+
const x = as[i] || 0;
|
|
35
|
+
const y = bs[i] || 0;
|
|
36
|
+
if (x !== y) return x > y;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Hours, because releases are not frequent and a check on every page load would be a network call per
|
|
42
|
+
// page for an answer that changes a few times a month. A failure is remembered too, briefly: a machine
|
|
43
|
+
// with no token must not shell out on every render to be told so again.
|
|
44
|
+
const TTL = 6 * 60 * 60 * 1000;
|
|
45
|
+
const FAIL_TTL = 10 * 60 * 1000;
|
|
46
|
+
let memo = { at: 0, tag: '', error: '' };
|
|
47
|
+
|
|
48
|
+
function ghLatest(repo) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
execFile('gh', ['api', `repos/${repo}/releases/latest`, '--jq', '.tag_name'],
|
|
51
|
+
{ timeout: 15000 }, (err, stdout) => {
|
|
52
|
+
const tag = String(stdout || '').trim();
|
|
53
|
+
if (!err && tag) return resolve({ tag });
|
|
54
|
+
resolve({ error: 'the repository could not be read' });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The newest release of the tooling repository, or why it is not known.
|
|
61
|
+
*
|
|
62
|
+
* `gh` first: it carries whatever auth the machine has, and this repository is private. There is no
|
|
63
|
+
* unauthenticated fallback on purpose — a plain request to a private repository answers 404, which is
|
|
64
|
+
* indistinguishable from "no releases" and would be reported as one.
|
|
65
|
+
*/
|
|
66
|
+
async function latestRelease(repo, { force = false } = {}) {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
const ttl = memo.error ? FAIL_TTL : TTL;
|
|
69
|
+
if (!force && memo.at && now - memo.at < ttl) return memo;
|
|
70
|
+
if (!repo) return { at: now, tag: '', error: 'no repository is configured' };
|
|
71
|
+
const { tag, error } = await ghLatest(repo);
|
|
72
|
+
memo = { at: now, tag: tag || '', error: error || '' };
|
|
73
|
+
return memo;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Forget what was read — for a test, and for a machine that has just been given a token. */
|
|
77
|
+
function forget() { memo = { at: 0, tag: '', error: '' }; }
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What the top bar should say beside the version: nothing at all unless a newer release really exists.
|
|
81
|
+
* Not "up to date", not "could not check" — the version is read in passing, and a line of chrome that
|
|
82
|
+
* says nothing useful on every page of every machine is worse than a blank.
|
|
83
|
+
*/
|
|
84
|
+
async function updateHintHtml({ current, repo, host = 'github.com', esc }) {
|
|
85
|
+
if (!current) return '';
|
|
86
|
+
const { tag } = await latestRelease(repo);
|
|
87
|
+
if (!tag || !isNewer(tag, current)) return '';
|
|
88
|
+
const href = `https://${host}/${repo}/releases/tag/${encodeURIComponent(tag)}`;
|
|
89
|
+
return `<a class="version-new" href="${esc(href)}" target="_blank" rel="noopener"
|
|
90
|
+
title="${esc(`${tag} has been released — this machine runs ${current}. Update with: bash core/workspace-app/scripts/update.sh`)}"
|
|
91
|
+
><span uk-icon="icon: arrow-up; ratio: .7"></span>${esc(tag)}</a>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { isNewer, latestRelease, updateHintHtml, forget, TTL, FAIL_TTL };
|