@dfosco/storyboard 0.7.1 → 0.9.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.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +14128 -10953
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/dist/tiny-canvas.css +1 -1
- package/package.json +138 -128
- package/scaffold/AGENTS.md +9 -7
- package/scaffold/README.md +18 -0
- package/scaffold/deploy.yml +21 -0
- package/scaffold/gitignore +8 -14
- package/scaffold/preview.yml +40 -0
- package/scaffold/{manifest.json → scaffold.config.json} +73 -3
- package/scaffold/scripts/aggregate-artifacts.mjs +257 -0
- package/scaffold/skills/canvas/SKILL.md +1 -1
- package/scaffold/skills/migrate-0.5.0/SKILL.md +1 -1
- package/scaffold/skills/ship/SKILL.md +2 -2
- package/scaffold/skills/worktree/SKILL.md +1 -1
- package/scaffold/src/_prototype.jsx +17 -0
- package/scaffold/src/_story.jsx +13 -0
- package/scaffold/src/library/README.md +34 -0
- package/scaffold/src/library/_app.jsx +41 -0
- package/scaffold/src/library/_app.module.css +2 -0
- package/scaffold/src/library/create.jsx +10 -0
- package/scaffold/src/library/index.jsx +14 -0
- package/scaffold/src/library/mount.jsx +51 -0
- package/scaffold/src/library/prototypes-entry.jsx +57 -0
- package/scaffold/src/library/routes.jsx +205 -0
- package/scaffold/src/library/viewfinder.jsx +13 -0
- package/scaffold/src/library/workspace.jsx +17 -0
- package/scripts/aggregate-artifacts.mjs +257 -0
- package/src/canvas/style.css +16 -16
- package/src/core/artifact/operations.js +54 -15
- package/src/core/artifact/schemas/canvas.schema.json +7 -1
- package/src/core/autosync/server.test.js +1 -1
- package/src/core/canvas/hot-pool.js +21 -33
- package/src/core/canvas/materializer.js +39 -13
- package/src/core/canvas/selectedWidgets.js +1 -1
- package/src/core/canvas/server.js +376 -100
- package/src/core/canvas/server.test.js +472 -2
- package/src/core/canvas/terminal-server.js +144 -57
- package/src/core/canvas/undoRedo.js +174 -0
- package/src/core/canvas/undoRedo.test.js +250 -0
- package/src/core/cli/artifact.js +2 -0
- package/src/core/cli/buildManifest.js +34 -0
- package/src/core/cli/canvasRedo.js +44 -0
- package/src/core/cli/canvasUndo.js +40 -0
- package/src/core/cli/create.js +151 -23
- package/src/core/cli/dev.js +23 -35
- package/src/core/cli/index.js +21 -0
- package/src/core/cli/schemas.js +8 -4
- package/src/core/cli/serverUrl.js +24 -0
- package/src/core/cli/setup.js +14 -25
- package/src/core/cli/terminal-welcome.js +12 -6
- package/src/core/cli/updateVersion.js +42 -2
- package/src/core/cli/workleaf.js +140 -0
- package/src/core/comments/api.js +1 -1
- package/src/core/comments/queries.js +3 -0
- package/src/core/comments/ui/CommentWindow.jsx +3 -2
- package/src/core/comments/ui/comment-layout.css +14 -14
- package/src/core/data/artifactIndex.js +235 -0
- package/src/core/data/artifactIndex.test.js +203 -0
- package/src/core/data/artifactManifest.js +290 -0
- package/src/core/data/artifactManifest.test.js +207 -0
- package/src/core/data/viewfinder.js +47 -7
- package/src/core/index.js +24 -1
- package/src/core/inspector/highlighter.js +1 -1
- package/src/core/inspector/mouseMode.js +2 -2
- package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +2 -2
- package/src/core/lib/components/ui/avatar/avatar-image.jsx +2 -2
- package/src/core/lib/components/ui/avatar/avatar.jsx +2 -2
- package/src/core/lib/components/ui/checkbox/checkbox.jsx +12 -9
- package/src/core/lib/components/ui/dialog/dialog-content.jsx +45 -31
- package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +9 -5
- package/src/core/lib/components/ui/dialog/dialog.jsx +12 -3
- package/src/core/lib/components/ui/dialog/dialog.module.css +118 -0
- package/src/core/lib/components/ui/dialog/index.js +4 -34
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +3 -3
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +19 -9
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +8 -8
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +15 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +14 -7
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +8 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +4 -4
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +2 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +8 -2
- package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +2 -2
- package/src/core/lib/components/ui/label/label.jsx +1 -2
- package/src/core/lib/components/ui/panel/panel-close.jsx +7 -4
- package/src/core/lib/components/ui/panel/panel-content.jsx +7 -9
- package/src/core/lib/components/ui/panel/panel-title.jsx +3 -3
- package/src/core/lib/components/ui/panel/panel.jsx +11 -2
- package/src/core/lib/components/ui/separator/separator.jsx +6 -4
- package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +7 -6
- package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +13 -6
- package/src/core/lib/components/ui/tooltip/tooltip.jsx +5 -5
- package/src/core/modes/modes.css +4 -4
- package/src/core/mountStoryboardCore.js +287 -83
- package/src/core/mountStoryboardCore.test.js +198 -0
- package/src/core/rename-watcher/config.json +1 -1
- package/src/core/rename-watcher/watcher.js +330 -37
- package/src/core/rename-watcher/watcher.test.js +253 -0
- package/src/core/scaffold/fragments.js +287 -0
- package/src/core/scaffold/fragments.test.js +401 -0
- package/src/core/scaffold/index.js +291 -0
- package/src/core/scaffold/migrateLegacyGitignore.js +157 -0
- package/src/core/scaffold/scaffold-integration.test.js +247 -0
- package/src/core/scaffold.js +4 -97
- package/src/core/stores/canvasInteractionStore.js +120 -0
- package/src/core/stores/canvasInteractionStore.test.js +126 -0
- package/src/core/stores/configSchema.js +64 -8
- package/src/core/stores/configSchema.test.js +3 -0
- package/src/core/stores/customerModeConfig.js +24 -3
- package/src/core/stores/customerModeConfig.test.js +46 -0
- package/src/core/stores/presentationStore.js +118 -0
- package/src/core/stores/presentationStore.test.js +114 -0
- package/src/core/stores/recentArtifacts.js +49 -2
- package/src/core/stores/recentArtifacts.test.js +59 -6
- package/src/core/stores/themeBootstrap.js +264 -0
- package/src/core/stores/themeBootstrap.test.js +211 -0
- package/src/core/stores/themeStore.test.js +154 -0
- package/src/core/stores/themeStore.ts +235 -220
- package/src/core/stores/uiConfig.js +10 -2
- package/src/core/styles/generateThemesCss.js +63 -0
- package/src/core/styles/tailwind.css +150 -94
- package/src/core/styles/workspace-chrome.css +13 -0
- package/src/core/tools/handlers/paletteTheme.js +11 -8
- package/src/core/ui/ActionMenuButton.jsx +2 -1
- package/src/core/ui/AutosyncMenuButton.css +7 -7
- package/src/core/ui/BranchSelect.module.css +5 -5
- package/src/core/ui/CanvasCreateMenu.jsx +1 -19
- package/src/core/ui/ChromeSlot.jsx +92 -0
- package/src/core/ui/ChromeSlot.test.jsx +140 -0
- package/src/core/ui/CoreUIBar.css +5 -5
- package/src/core/ui/CoreUIBar.jsx +170 -142
- package/src/core/ui/InspectorPanel.css +5 -5
- package/src/core/ui/InspectorPanel.jsx +9 -9
- package/src/core/ui/PwaInstallBanner.css +5 -5
- package/src/core/ui/PwaInstallBanner.jsx +5 -5
- package/src/core/ui/ThemeMenuButton.jsx +45 -30
- package/src/core/ui/core-ui-colors.css +28 -66
- package/src/core/ui/sidepanel.css +12 -12
- package/src/core/vite/server-plugin.js +13 -0
- package/src/core/workleaf/cleanup.js +102 -0
- package/src/core/workleaf/create.js +199 -0
- package/src/core/workleaf/delete.js +101 -0
- package/src/core/workleaf/list.js +57 -0
- package/src/core/workleaf/merge.js +238 -0
- package/src/core/workleaf/paths.js +146 -0
- package/src/core/workleaf/paths.test.js +230 -0
- package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +130 -34
- package/src/core/workshop/features/createFlow/server.js +5 -1
- package/src/core/workshop/features/createPrototype/server.js +5 -1
- package/src/core/workshop/features/templateIndex.js +28 -1
- package/src/core/worktree/port.js +48 -5
- package/src/core/worktree/port.test.js +42 -0
- package/src/internals/ArtifactForm/ArtifactForm.jsx +173 -112
- package/src/internals/ArtifactForm/ArtifactForm.module.css +51 -69
- package/src/internals/ArtifactForm/artifactSchemas.js +40 -5
- package/src/internals/AuthModal/AuthModal.module.css +34 -34
- package/src/internals/BaseUiForm/BaseUiForm.jsx +293 -0
- package/src/internals/BaseUiForm/BaseUiForm.module.css +542 -0
- package/src/internals/BranchBar/BranchBar.module.css +4 -4
- package/src/internals/BranchesDropdown/BranchesDropdown.jsx +69 -0
- package/src/internals/BranchesDropdown/BranchesDropdown.module.css +84 -0
- package/src/internals/CommandPalette/CommandPalette.jsx +41 -29
- package/src/internals/CommandPalette/CreateArtifactForm.jsx +150 -0
- package/src/internals/CommandPalette/CreateDialog.jsx +20 -110
- package/src/internals/CommandPalette/WidgetArtifactDialog.css +16 -16
- package/src/internals/CommandPalette/WidgetArtifactDialog.jsx +1 -1
- package/src/internals/CommandPalette/command-palette.css +14 -14
- package/src/internals/CommandPalette/createHelpers.js +30 -0
- package/src/internals/CreatePage/CreatePage.jsx +57 -0
- package/src/internals/CreatePage/CreatePage.module.css +34 -0
- package/src/internals/Dialog.jsx +51 -0
- package/src/internals/FlowError.module.css +5 -5
- package/src/internals/PrototypeErrorBoundary.module.css +15 -15
- package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +534 -0
- package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +323 -0
- package/src/internals/Tooltip.jsx +39 -0
- package/src/internals/Viewfinder.jsx +229 -64
- package/src/internals/Viewfinder.module.css +253 -182
- package/src/internals/canvas/CanvasControls.module.css +11 -11
- package/src/internals/canvas/CanvasPage.bridge.test.jsx +11 -37
- package/src/internals/canvas/CanvasPage.jsx +358 -217
- package/src/internals/canvas/CanvasPage.module.css +20 -19
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +39 -26
- package/src/internals/canvas/CanvasToolbar.module.css +11 -11
- package/src/internals/canvas/ConnectorLayer.jsx +39 -2
- package/src/internals/canvas/ConnectorLayer.module.css +10 -10
- package/src/internals/canvas/PageSelector.jsx +15 -1
- package/src/internals/canvas/PageSelector.module.css +41 -27
- package/src/internals/canvas/canvasApi.js +40 -5
- package/src/internals/canvas/canvasTheme.js +22 -111
- package/src/internals/canvas/isolation/createIsolationMiddleware.js +68 -0
- package/src/internals/canvas/isolation/mountIsolationApp.jsx +88 -0
- package/src/internals/canvas/isolation/prototypeRoutes.jsx +140 -0
- package/src/internals/canvas/isolation/prototypesEntry.jsx +28 -0
- package/src/internals/canvas/isolation/storiesEntry.jsx +20 -0
- package/src/internals/canvas/isolation/storyRoutes.jsx +55 -0
- package/src/internals/canvas/useUndoRedo.js +75 -60
- package/src/internals/canvas/useUndoRedo.test.js +46 -178
- package/src/internals/canvas/widgets/CodePenEmbed.jsx +16 -36
- package/src/internals/canvas/widgets/CodePenEmbed.module.css +12 -12
- package/src/internals/canvas/widgets/CropOverlay.module.css +23 -23
- package/src/internals/canvas/widgets/ExpandedPane.module.css +8 -8
- package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +1 -1
- package/src/internals/canvas/widgets/FigmaEmbed.jsx +15 -36
- package/src/internals/canvas/widgets/FigmaEmbed.module.css +15 -15
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +30 -21
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.module.css +5 -5
- package/src/internals/canvas/widgets/ImageWidget.jsx +7 -2
- package/src/internals/canvas/widgets/ImageWidget.module.css +3 -3
- package/src/internals/canvas/widgets/LinkPreview.module.css +79 -80
- package/src/internals/canvas/widgets/MarkdownBlock.module.css +31 -31
- package/src/internals/canvas/widgets/PromptWidget.jsx +2 -16
- package/src/internals/canvas/widgets/PromptWidget.module.css +33 -33
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +92 -17
- package/src/internals/canvas/widgets/PrototypeEmbed.module.css +58 -56
- package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +118 -4
- package/src/internals/canvas/widgets/ResizeHandle.module.css +4 -4
- package/src/internals/canvas/widgets/StorySetWidget.jsx +53 -25
- package/src/internals/canvas/widgets/StoryWidget.jsx +71 -56
- package/src/internals/canvas/widgets/StoryWidget.module.css +22 -20
- package/src/internals/canvas/widgets/TerminalReadWidget.jsx +13 -15
- package/src/internals/canvas/widgets/TerminalReadWidget.module.css +4 -4
- package/src/internals/canvas/widgets/TerminalWidget.jsx +56 -45
- package/src/internals/canvas/widgets/TerminalWidget.module.css +10 -10
- package/src/internals/canvas/widgets/TilesWidget.module.css +17 -17
- package/src/internals/canvas/widgets/WidgetChrome.jsx +1 -1
- package/src/internals/canvas/widgets/WidgetChrome.module.css +76 -61
- package/src/internals/canvas/widgets/WidgetWrapper.module.css +2 -2
- package/src/internals/canvas/widgets/embedOverlay.module.css +14 -4
- package/src/internals/canvas/widgets/embedTheme.js +12 -104
- package/src/internals/canvas/widgets/expandUtils.js +19 -19
- package/src/internals/canvas/widgets/normalizeLegacyEmbedSrc.js +74 -0
- package/src/internals/canvas/widgets/widgetConfig.js +5 -5
- package/src/internals/context.jsx +22 -1
- package/src/internals/context.test.jsx +47 -0
- package/src/internals/hooks/useArtifactIndex.js +31 -0
- package/src/internals/hooks/useArtifactIndex.test.js +45 -0
- package/src/internals/hooks/usePrototypeReloadGuard.js +14 -8
- package/src/internals/index.js +6 -0
- package/src/internals/loadConsumerWrapper.js +60 -0
- package/src/internals/story/ComponentSetPage.jsx +75 -34
- package/src/internals/story/ComponentSetPage.module.css +14 -14
- package/src/internals/story/StoryPage.jsx +16 -25
- package/src/internals/story/StoryPage.module.css +2 -2
- package/src/internals/vite/data-plugin.js +317 -149
- package/src/internals/vite/data-plugin.test.js +11 -11
- package/{scaffold/storyboard.config.json → storyboard.config.json} +25 -0
- package/terminal.config.json +1 -1
- package/src/core/lib/components/ui/card/card-action.jsx +0 -11
- package/src/core/lib/components/ui/card/card-content.jsx +0 -11
- package/src/core/lib/components/ui/card/card-description.jsx +0 -11
- package/src/core/lib/components/ui/card/card-footer.jsx +0 -11
- package/src/core/lib/components/ui/card/card-header.jsx +0 -19
- package/src/core/lib/components/ui/card/card-title.jsx +0 -11
- package/src/core/lib/components/ui/card/card.jsx +0 -17
- package/src/core/lib/components/ui/card/index.js +0 -25
- package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/collapsible.jsx +0 -7
- package/src/core/lib/components/ui/collapsible/index.js +0 -13
- package/src/core/lib/components/ui/dialog/dialog-close.jsx +0 -7
- package/src/core/lib/components/ui/dialog/dialog-description.jsx +0 -15
- package/src/core/lib/components/ui/dialog/dialog-footer.jsx +0 -23
- package/src/core/lib/components/ui/dialog/dialog-header.jsx +0 -11
- package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +0 -15
- package/src/core/lib/components/ui/dialog/dialog-portal.jsx +0 -4
- package/src/core/lib/components/ui/dialog/dialog-title.jsx +0 -15
- package/src/core/lib/components/ui/popover/index.js +0 -28
- package/src/core/lib/components/ui/popover/popover-close.jsx +0 -7
- package/src/core/lib/components/ui/popover/popover-content.jsx +0 -22
- package/src/core/lib/components/ui/popover/popover-description.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-header.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-portal.jsx +0 -4
- package/src/core/lib/components/ui/popover/popover-title.jsx +0 -11
- package/src/core/lib/components/ui/popover/popover-trigger.jsx +0 -8
- package/src/core/lib/components/ui/popover/popover.jsx +0 -4
- package/src/core/lib/components/ui/select/index.js +0 -37
- package/src/core/lib/components/ui/select/select-content.jsx +0 -30
- package/src/core/lib/components/ui/select/select-group-heading.jsx +0 -17
- package/src/core/lib/components/ui/select/select-group.jsx +0 -15
- package/src/core/lib/components/ui/select/select-item.jsx +0 -26
- package/src/core/lib/components/ui/select/select-label.jsx +0 -11
- package/src/core/lib/components/ui/select/select-portal.jsx +0 -4
- package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +0 -18
- package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +0 -18
- package/src/core/lib/components/ui/select/select-separator.jsx +0 -15
- package/src/core/lib/components/ui/select/select-trigger.jsx +0 -25
- package/src/core/lib/components/ui/select/select.jsx +0 -4
- package/src/core/lib/components/ui/sheet/index.js +0 -34
- package/src/core/lib/components/ui/sheet/sheet-close.jsx +0 -7
- package/src/core/lib/components/ui/sheet/sheet-content.jsx +0 -35
- package/src/core/lib/components/ui/sheet/sheet-description.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-footer.jsx +0 -11
- package/src/core/lib/components/ui/sheet/sheet-header.jsx +0 -11
- package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-portal.jsx +0 -4
- package/src/core/lib/components/ui/sheet/sheet-title.jsx +0 -15
- package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +0 -7
- package/src/core/lib/components/ui/sheet/sheet.jsx +0 -4
- package/src/core/lib/components/ui/toggle/index.js +0 -8
- package/src/core/lib/components/ui/toggle/toggle.jsx +0 -36
- package/src/core/lib/components/ui/toggle-group/index.js +0 -10
- package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +0 -29
- package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +0 -43
- package/src/internals/canvas/StoryErrorBoundary.jsx +0 -47
- package/src/internals/canvas/WebGLContextPool.jsx +0 -291
- package/src/internals/canvas/WebGLContextPool.test.jsx +0 -165
- package/src/internals/canvas/componentIsolate.jsx +0 -144
- package/src/internals/canvas/componentSetIsolate.jsx +0 -300
- package/src/internals/canvas/widgets/ComponentSetWidget.module.css +0 -89
- package/src/internals/canvas/widgets/InlineStoryRenderer.jsx +0 -129
- package/src/internals/canvas/widgets/InlineStoryRenderer.test.jsx +0 -68
- package/src/internals/canvas/widgets/StorySetWidget.module.css +0 -89
- package/src/primer/ThemeSync.jsx +0 -73
- package/src/primer/index.js +0 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
6
|
+
import { __test } from './watcher.js'
|
|
7
|
+
|
|
8
|
+
const { detectDraftsFlips, processChange, scanDirectory } = __test
|
|
9
|
+
|
|
10
|
+
const TEST_CONFIG = {
|
|
11
|
+
watch: [
|
|
12
|
+
{ path: 'src/prototypes', extensions: ['.jsx', '.tsx', '.mdx'], type: 'prototype' },
|
|
13
|
+
{ path: 'src/canvas', extensions: ['.canvas.jsonl'], type: 'canvas' },
|
|
14
|
+
],
|
|
15
|
+
exclude: {
|
|
16
|
+
filePrefixes: ['_'],
|
|
17
|
+
directories: ['node_modules', '.git', 'dist', 'images', '.worktrees'],
|
|
18
|
+
},
|
|
19
|
+
debounceMs: 0,
|
|
20
|
+
autocommit: { enabled: false, prefix: '[test-autofix]' },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Initialise a tmp dir as a git repo so the watcher's `git add`/`git rm`
|
|
25
|
+
* autocommit path doesn't blow up. We disable autocommit by default in
|
|
26
|
+
* tests via the config override; this is just defensive.
|
|
27
|
+
*/
|
|
28
|
+
function makeRepoRoot() {
|
|
29
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-watcher-test-'))
|
|
30
|
+
fs.mkdirSync(path.join(root, 'src', 'canvas'), { recursive: true })
|
|
31
|
+
fs.mkdirSync(path.join(root, 'src', 'prototypes'), { recursive: true })
|
|
32
|
+
fs.mkdirSync(path.join(root, 'assets', 'canvas', 'images'), { recursive: true })
|
|
33
|
+
// Init git so autocommit can run if needed
|
|
34
|
+
try {
|
|
35
|
+
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: root, stdio: 'pipe' })
|
|
36
|
+
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root })
|
|
37
|
+
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: root })
|
|
38
|
+
execFileSync('git', ['config', 'commit.gpgsign', 'false'], { cwd: root })
|
|
39
|
+
} catch { /* git may be missing in CI sandbox — flips work without */ }
|
|
40
|
+
return root
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeCanvasFile(absPath, widgets) {
|
|
44
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true })
|
|
45
|
+
const events = [
|
|
46
|
+
{ event: 'canvas_created', timestamp: '2026-01-01T00:00:00Z', title: 'X', widgets: [] },
|
|
47
|
+
]
|
|
48
|
+
if (widgets && widgets.length > 0) {
|
|
49
|
+
events.push({ event: 'widgets_replaced', timestamp: '2026-01-01T00:00:01Z', widgets })
|
|
50
|
+
}
|
|
51
|
+
fs.writeFileSync(absPath, events.map((e) => JSON.stringify(e)).join('\n') + '\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function imageWidget(id, src) {
|
|
55
|
+
return { id, type: 'image', props: { src, width: 100, height: 100 }, position: { x: 0, y: 0 } }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('rename-watcher: detectDraftsFlips', () => {
|
|
59
|
+
it('detects a top-level canvas moving into drafts/', () => {
|
|
60
|
+
const oldSet = new Set(['foo.canvas.jsonl'])
|
|
61
|
+
const newSet = new Set(['drafts/foo.canvas.jsonl'])
|
|
62
|
+
const flips = detectDraftsFlips(oldSet, newSet)
|
|
63
|
+
expect(flips).toHaveLength(1)
|
|
64
|
+
expect(flips[0]).toMatchObject({
|
|
65
|
+
oldPath: 'foo.canvas.jsonl',
|
|
66
|
+
newPath: 'drafts/foo.canvas.jsonl',
|
|
67
|
+
oldDraft: false,
|
|
68
|
+
newDraft: true,
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('detects a drafts canvas moving back out to top-level', () => {
|
|
73
|
+
const oldSet = new Set(['drafts/foo.canvas.jsonl'])
|
|
74
|
+
const newSet = new Set(['foo.canvas.jsonl'])
|
|
75
|
+
const flips = detectDraftsFlips(oldSet, newSet)
|
|
76
|
+
expect(flips).toHaveLength(1)
|
|
77
|
+
expect(flips[0].oldDraft).toBe(true)
|
|
78
|
+
expect(flips[0].newDraft).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('detects flips inside a subdirectory (widget/drafts/v6 ↔ widget/v6)', () => {
|
|
82
|
+
const oldSet = new Set(['widget/v6.canvas.jsonl'])
|
|
83
|
+
const newSet = new Set(['widget/drafts/v6.canvas.jsonl'])
|
|
84
|
+
const flips = detectDraftsFlips(oldSet, newSet)
|
|
85
|
+
expect(flips).toHaveLength(1)
|
|
86
|
+
expect(flips[0].newDraft).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('returns nothing when the move is purely a rename (no draft change)', () => {
|
|
90
|
+
const oldSet = new Set(['foo.canvas.jsonl'])
|
|
91
|
+
const newSet = new Set(['bar.canvas.jsonl'])
|
|
92
|
+
expect(detectDraftsFlips(oldSet, newSet)).toEqual([])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('returns nothing when both sides are inside drafts/', () => {
|
|
96
|
+
const oldSet = new Set(['drafts/foo.canvas.jsonl'])
|
|
97
|
+
const newSet = new Set(['drafts/bar.canvas.jsonl'])
|
|
98
|
+
expect(detectDraftsFlips(oldSet, newSet)).toEqual([])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('skips ambiguous flips (multiple files with the same stripped path)', () => {
|
|
102
|
+
const oldSet = new Set(['foo.canvas.jsonl', 'drafts/foo.canvas.jsonl'])
|
|
103
|
+
const newSet = new Set(['drafts/foo.canvas.jsonl', 'foo.canvas.jsonl'])
|
|
104
|
+
// No actual changes here, but even if there were, multi-match would skip.
|
|
105
|
+
expect(detectDraftsFlips(oldSet, newSet)).toEqual([])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('ignores non-canvas files', () => {
|
|
109
|
+
const oldSet = new Set(['foo.jsx', 'bar.md'])
|
|
110
|
+
const newSet = new Set(['drafts/foo.jsx', 'drafts/bar.md'])
|
|
111
|
+
expect(detectDraftsFlips(oldSet, newSet)).toEqual([])
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('rename-watcher: drafts-flip image movement (end-to-end via processChange)', () => {
|
|
116
|
+
let root
|
|
117
|
+
let config
|
|
118
|
+
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
root = makeRepoRoot()
|
|
121
|
+
config = TEST_CONFIG
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
try { fs.rmSync(root, { recursive: true, force: true }) } catch { /* */ }
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('moves a public image into images/drafts/ when its canvas moves into drafts', () => {
|
|
129
|
+
const canvasAbs = path.join(root, 'src', 'canvas', 'foo.canvas.jsonl')
|
|
130
|
+
const imagesDir = path.join(root, 'assets', 'canvas', 'images')
|
|
131
|
+
fs.writeFileSync(path.join(imagesDir, 'pic.png'), Buffer.from([1, 2, 3]))
|
|
132
|
+
writeCanvasFile(canvasAbs, [imageWidget('img-1', 'pic.png')])
|
|
133
|
+
|
|
134
|
+
// Seed snapshots, then move the canvas into drafts.
|
|
135
|
+
const snapshots = new Map()
|
|
136
|
+
for (const entry of config.watch) snapshots.set(entry.path, scanDirectory(root, entry, config))
|
|
137
|
+
|
|
138
|
+
const draftAbs = path.join(root, 'src', 'canvas', 'drafts', 'foo.canvas.jsonl')
|
|
139
|
+
fs.mkdirSync(path.dirname(draftAbs), { recursive: true })
|
|
140
|
+
fs.renameSync(canvasAbs, draftAbs)
|
|
141
|
+
|
|
142
|
+
processChange(root, snapshots, config)
|
|
143
|
+
|
|
144
|
+
expect(fs.existsSync(path.join(imagesDir, 'pic.png'))).toBe(false)
|
|
145
|
+
expect(fs.existsSync(path.join(imagesDir, 'drafts', 'pic.png'))).toBe(true)
|
|
146
|
+
|
|
147
|
+
// Canvas file should have a widgets_replaced event with the new src
|
|
148
|
+
const text = fs.readFileSync(draftAbs, 'utf-8')
|
|
149
|
+
expect(text).toContain('"src":"drafts/pic.png"')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('moves a drafts image back to images/ when its canvas moves out of drafts', () => {
|
|
153
|
+
const draftAbs = path.join(root, 'src', 'canvas', 'drafts', 'foo.canvas.jsonl')
|
|
154
|
+
const imagesDir = path.join(root, 'assets', 'canvas', 'images')
|
|
155
|
+
fs.mkdirSync(path.join(imagesDir, 'drafts'), { recursive: true })
|
|
156
|
+
fs.writeFileSync(path.join(imagesDir, 'drafts', 'pic.png'), Buffer.from([1, 2, 3]))
|
|
157
|
+
writeCanvasFile(draftAbs, [imageWidget('img-1', 'drafts/pic.png')])
|
|
158
|
+
|
|
159
|
+
const snapshots = new Map()
|
|
160
|
+
for (const entry of config.watch) snapshots.set(entry.path, scanDirectory(root, entry, config))
|
|
161
|
+
|
|
162
|
+
const canvasAbs = path.join(root, 'src', 'canvas', 'foo.canvas.jsonl')
|
|
163
|
+
fs.renameSync(draftAbs, canvasAbs)
|
|
164
|
+
|
|
165
|
+
processChange(root, snapshots, config)
|
|
166
|
+
|
|
167
|
+
expect(fs.existsSync(path.join(imagesDir, 'drafts', 'pic.png'))).toBe(false)
|
|
168
|
+
expect(fs.existsSync(path.join(imagesDir, 'pic.png'))).toBe(true)
|
|
169
|
+
|
|
170
|
+
const text = fs.readFileSync(canvasAbs, 'utf-8')
|
|
171
|
+
// The new src is the bare basename (no drafts/ prefix)
|
|
172
|
+
expect(text).toMatch(/"src":"pic\.png"/)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('copies (not renames) a shared image when another canvas still references it', () => {
|
|
176
|
+
const movedCanvas = path.join(root, 'src', 'canvas', 'foo.canvas.jsonl')
|
|
177
|
+
const otherCanvas = path.join(root, 'src', 'canvas', 'other.canvas.jsonl')
|
|
178
|
+
const imagesDir = path.join(root, 'assets', 'canvas', 'images')
|
|
179
|
+
fs.writeFileSync(path.join(imagesDir, 'shared.png'), Buffer.from([9]))
|
|
180
|
+
writeCanvasFile(movedCanvas, [imageWidget('a', 'shared.png')])
|
|
181
|
+
writeCanvasFile(otherCanvas, [imageWidget('b', 'shared.png')])
|
|
182
|
+
|
|
183
|
+
const snapshots = new Map()
|
|
184
|
+
for (const entry of config.watch) snapshots.set(entry.path, scanDirectory(root, entry, config))
|
|
185
|
+
|
|
186
|
+
const draftAbs = path.join(root, 'src', 'canvas', 'drafts', 'foo.canvas.jsonl')
|
|
187
|
+
fs.mkdirSync(path.dirname(draftAbs), { recursive: true })
|
|
188
|
+
fs.renameSync(movedCanvas, draftAbs)
|
|
189
|
+
|
|
190
|
+
processChange(root, snapshots, config)
|
|
191
|
+
|
|
192
|
+
// Source preserved (other canvas still owns it)
|
|
193
|
+
expect(fs.existsSync(path.join(imagesDir, 'shared.png'))).toBe(true)
|
|
194
|
+
// Copied into drafts dir for the flipping canvas
|
|
195
|
+
expect(fs.existsSync(path.join(imagesDir, 'drafts', 'shared.png'))).toBe(true)
|
|
196
|
+
// Only the flipping canvas's widget src updated
|
|
197
|
+
const flippedText = fs.readFileSync(draftAbs, 'utf-8')
|
|
198
|
+
expect(flippedText).toContain('"src":"drafts/shared.png"')
|
|
199
|
+
const otherText = fs.readFileSync(otherCanvas, 'utf-8')
|
|
200
|
+
expect(otherText).toContain('"src":"shared.png"')
|
|
201
|
+
expect(otherText).not.toContain('"src":"drafts/shared.png"')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('picks a unique basename when the target directory already has the same name', () => {
|
|
205
|
+
const movedCanvas = path.join(root, 'src', 'canvas', 'foo.canvas.jsonl')
|
|
206
|
+
const imagesDir = path.join(root, 'assets', 'canvas', 'images')
|
|
207
|
+
fs.writeFileSync(path.join(imagesDir, 'pic.png'), Buffer.from([1]))
|
|
208
|
+
// Pre-existing collision in target dir
|
|
209
|
+
fs.mkdirSync(path.join(imagesDir, 'drafts'), { recursive: true })
|
|
210
|
+
fs.writeFileSync(path.join(imagesDir, 'drafts', 'pic.png'), Buffer.from([2]))
|
|
211
|
+
writeCanvasFile(movedCanvas, [imageWidget('a', 'pic.png')])
|
|
212
|
+
|
|
213
|
+
const snapshots = new Map()
|
|
214
|
+
for (const entry of config.watch) snapshots.set(entry.path, scanDirectory(root, entry, config))
|
|
215
|
+
|
|
216
|
+
const draftAbs = path.join(root, 'src', 'canvas', 'drafts', 'foo.canvas.jsonl')
|
|
217
|
+
fs.mkdirSync(path.dirname(draftAbs), { recursive: true })
|
|
218
|
+
fs.renameSync(movedCanvas, draftAbs)
|
|
219
|
+
|
|
220
|
+
processChange(root, snapshots, config)
|
|
221
|
+
|
|
222
|
+
expect(fs.existsSync(path.join(imagesDir, 'drafts', 'pic-2.png'))).toBe(true)
|
|
223
|
+
// Original pic.png in drafts/ untouched (different content from pre-existing file)
|
|
224
|
+
expect(fs.readFileSync(path.join(imagesDir, 'drafts', 'pic.png'))).toEqual(Buffer.from([2]))
|
|
225
|
+
|
|
226
|
+
const flippedText = fs.readFileSync(draftAbs, 'utf-8')
|
|
227
|
+
expect(flippedText).toContain('"src":"drafts/pic-2.png"')
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('is a no-op for widgets whose images are already in the correct directory', () => {
|
|
231
|
+
const canvasAbs = path.join(root, 'src', 'canvas', 'foo.canvas.jsonl')
|
|
232
|
+
const imagesDir = path.join(root, 'assets', 'canvas', 'images')
|
|
233
|
+
fs.mkdirSync(path.join(imagesDir, 'drafts'), { recursive: true })
|
|
234
|
+
// Image lives in drafts/ even though the (about-to-move-in) canvas is public
|
|
235
|
+
fs.writeFileSync(path.join(imagesDir, 'drafts', 'already.png'), Buffer.from([1]))
|
|
236
|
+
writeCanvasFile(canvasAbs, [imageWidget('a', 'drafts/already.png')])
|
|
237
|
+
|
|
238
|
+
const snapshots = new Map()
|
|
239
|
+
for (const entry of config.watch) snapshots.set(entry.path, scanDirectory(root, entry, config))
|
|
240
|
+
|
|
241
|
+
const draftAbs = path.join(root, 'src', 'canvas', 'drafts', 'foo.canvas.jsonl')
|
|
242
|
+
fs.mkdirSync(path.dirname(draftAbs), { recursive: true })
|
|
243
|
+
fs.renameSync(canvasAbs, draftAbs)
|
|
244
|
+
|
|
245
|
+
processChange(root, snapshots, config)
|
|
246
|
+
|
|
247
|
+
// File didn't move, no new widgets_replaced event with a different src
|
|
248
|
+
expect(fs.existsSync(path.join(imagesDir, 'drafts', 'already.png'))).toBe(true)
|
|
249
|
+
const text = fs.readFileSync(draftAbs, 'utf-8')
|
|
250
|
+
// Original src is preserved
|
|
251
|
+
expect(text).toContain('"src":"drafts/already.png"')
|
|
252
|
+
})
|
|
253
|
+
})
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffold fragment templating — comment-style-agnostic block markers used by
|
|
3
|
+
* `storyboard-scaffold` to keep slices of client-owned files in sync with the
|
|
4
|
+
* library.
|
|
5
|
+
*
|
|
6
|
+
* Two marker schemes:
|
|
7
|
+
*
|
|
8
|
+
* Client-side ("namespaced"):
|
|
9
|
+
* # <!-- storyboard:<source-path>:<fragment-id> --start-->
|
|
10
|
+
* ...content the scaffolder rewrites...
|
|
11
|
+
* # <!-- storyboard:<source-path>:<fragment-id> --end-->
|
|
12
|
+
*
|
|
13
|
+
* The leading `#` (or `//`, or whatever comment prefix the host file uses)
|
|
14
|
+
* is ignored by the parser — we match on the `storyboard:` namespace
|
|
15
|
+
* substring only, so the same scheme works in .gitignore, .yml, .json,
|
|
16
|
+
* .js, .md, etc.
|
|
17
|
+
*
|
|
18
|
+
* Library-side ("bare"):
|
|
19
|
+
* # <!-- <fragment-id> --start-->
|
|
20
|
+
* ...fragment body...
|
|
21
|
+
* # <!-- <fragment-id> --end-->
|
|
22
|
+
*
|
|
23
|
+
* Bare markers live in `packages/storyboard/scaffold/<source-path>`. The
|
|
24
|
+
* scaffolder reads them via `extractFragment()`. When a library file is
|
|
25
|
+
* copied wholesale into a client (no client-side markers present), the
|
|
26
|
+
* scaffolder strips the bare markers first via `stripLibraryMarkers()`
|
|
27
|
+
* so the client gets clean content.
|
|
28
|
+
*
|
|
29
|
+
* Whitespace rules:
|
|
30
|
+
* - Fragment body = the bytes BETWEEN the start-marker line and the
|
|
31
|
+
* end-marker line, exclusive of both. Leading/trailing newlines on the
|
|
32
|
+
* body are preserved exactly so round-trips are stable.
|
|
33
|
+
* - The comment prefix on the start line is repeated on the end line by
|
|
34
|
+
* convention but not enforced — the parser only requires that both
|
|
35
|
+
* markers appear on their own line.
|
|
36
|
+
*
|
|
37
|
+
* Failure modes (all throw):
|
|
38
|
+
* - Nested markers with the same id.
|
|
39
|
+
* - Unbalanced markers (start without end, or vice versa).
|
|
40
|
+
* - Unknown source path or fragment id during apply.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const NAMESPACE = 'storyboard'
|
|
44
|
+
|
|
45
|
+
// Captures `storyboard:<src>:<id> --start` or `--end` inside an HTML comment.
|
|
46
|
+
// The wrapping comment chars (`#`, `//`, etc.) AROUND the `<!--` are matched
|
|
47
|
+
// loosely — we only require the `<!--` HTML-comment open/close brackets and
|
|
48
|
+
// the `storyboard:` namespace substring.
|
|
49
|
+
const NAMESPACED_MARKER_RE = new RegExp(
|
|
50
|
+
`<!--\\s*${NAMESPACE}:([^\\s:]+):([A-Za-z0-9][A-Za-z0-9-]*)\\s+--(start|end)\\s*-->`
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// Library-side bare markers: `<!-- <id> --start-->` or `<!-- <id> --end-->`.
|
|
54
|
+
// `<!--` and `-->` are required so we don't accidentally match prose comments.
|
|
55
|
+
const BARE_MARKER_RE = /<!--\s*([A-Za-z0-9][A-Za-z0-9-]*)\s+--(start|end)\s*-->/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {object} NamespacedFragment
|
|
59
|
+
* @property {string} sourcePath - e.g. "scaffold/gitignore"
|
|
60
|
+
* @property {string} id - e.g. "runtime-state"
|
|
61
|
+
* @property {number} startLine - 0-indexed line number of the start marker
|
|
62
|
+
* @property {number} endLine - 0-indexed line number of the end marker
|
|
63
|
+
* @property {string} body - content between markers (exclusive)
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a client-side file for `storyboard:<src>:<id>` fragment markers.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} text
|
|
70
|
+
* @returns {NamespacedFragment[]}
|
|
71
|
+
* @throws if markers are nested, unbalanced, or duplicated.
|
|
72
|
+
*/
|
|
73
|
+
export function parseFragments(text) {
|
|
74
|
+
const lines = splitLinesKeepingEol(text)
|
|
75
|
+
const fragments = []
|
|
76
|
+
const open = new Map() // key=`${src}:${id}` → { sourcePath, id, startLine }
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < lines.length; i++) {
|
|
79
|
+
const m = lines[i].match(NAMESPACED_MARKER_RE)
|
|
80
|
+
if (!m) continue
|
|
81
|
+
const [, sourcePath, id, side] = m
|
|
82
|
+
const key = `${sourcePath}:${id}`
|
|
83
|
+
|
|
84
|
+
if (side === 'start') {
|
|
85
|
+
if (open.has(key)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Duplicate or nested storyboard:${key} --start-- marker at line ${i + 1}`
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
open.set(key, { sourcePath, id, startLine: i })
|
|
91
|
+
} else {
|
|
92
|
+
const opened = open.get(key)
|
|
93
|
+
if (!opened) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Stray storyboard:${key} --end-- marker at line ${i + 1} with no matching --start--`
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
const bodyLines = lines.slice(opened.startLine + 1, i)
|
|
99
|
+
fragments.push({
|
|
100
|
+
sourcePath: opened.sourcePath,
|
|
101
|
+
id: opened.id,
|
|
102
|
+
startLine: opened.startLine,
|
|
103
|
+
endLine: i,
|
|
104
|
+
body: bodyLines.join(''),
|
|
105
|
+
})
|
|
106
|
+
open.delete(key)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (open.size > 0) {
|
|
111
|
+
const first = [...open.values()][0]
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Unclosed storyboard:${first.sourcePath}:${first.id} fragment (start at line ${first.startLine + 1})`
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return fragments
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Extract a bare-marker fragment body from a library source file.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} srcText
|
|
124
|
+
* @param {string} id
|
|
125
|
+
* @returns {string|null} the fragment body (between markers, exclusive), or
|
|
126
|
+
* null if no matching fragment exists.
|
|
127
|
+
* @throws if markers are nested, unbalanced, or duplicated for the same id.
|
|
128
|
+
*/
|
|
129
|
+
export function extractFragment(srcText, id) {
|
|
130
|
+
const lines = splitLinesKeepingEol(srcText)
|
|
131
|
+
let startLine = -1
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < lines.length; i++) {
|
|
134
|
+
const m = lines[i].match(BARE_MARKER_RE)
|
|
135
|
+
if (!m || m[1] !== id) continue
|
|
136
|
+
const side = m[2]
|
|
137
|
+
if (side === 'start') {
|
|
138
|
+
if (startLine !== -1) {
|
|
139
|
+
throw new Error(`Duplicate --start-- marker for fragment "${id}" at line ${i + 1}`)
|
|
140
|
+
}
|
|
141
|
+
startLine = i
|
|
142
|
+
} else {
|
|
143
|
+
if (startLine === -1) {
|
|
144
|
+
throw new Error(`Stray --end-- marker for fragment "${id}" at line ${i + 1}`)
|
|
145
|
+
}
|
|
146
|
+
const bodyLines = lines.slice(startLine + 1, i)
|
|
147
|
+
return bodyLines.join('')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (startLine !== -1) {
|
|
152
|
+
throw new Error(`Unclosed --start-- marker for fragment "${id}" at line ${startLine + 1}`)
|
|
153
|
+
}
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Rewrite the bodies of every `storyboard:<src>:<id>` fragment in a client
|
|
159
|
+
* file by looking up the matching library fragment via `lookup(src, id)`.
|
|
160
|
+
*
|
|
161
|
+
* The lookup callback is responsible for resolving the source file and
|
|
162
|
+
* extracting the fragment body — typically:
|
|
163
|
+
*
|
|
164
|
+
* (src, id) => extractFragment(fs.readFileSync(src, 'utf-8'), id)
|
|
165
|
+
*
|
|
166
|
+
* Returns `{ text, replaced, unchanged }` so the caller can report counts and
|
|
167
|
+
* skip writes when nothing changed.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} clientText
|
|
170
|
+
* @param {(sourcePath: string, fragmentId: string) => string|null} lookup
|
|
171
|
+
* @returns {{ text: string, replaced: number, unchanged: number }}
|
|
172
|
+
* @throws if a fragment marker references a source/id the lookup can't resolve.
|
|
173
|
+
*/
|
|
174
|
+
export function applyFragments(clientText, lookup) {
|
|
175
|
+
const fragments = parseFragments(clientText)
|
|
176
|
+
if (fragments.length === 0) {
|
|
177
|
+
return { text: clientText, replaced: 0, unchanged: 0 }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const lines = splitLinesKeepingEol(clientText)
|
|
181
|
+
let replaced = 0
|
|
182
|
+
let unchanged = 0
|
|
183
|
+
|
|
184
|
+
// Walk fragments in reverse so earlier indices don't shift after splicing.
|
|
185
|
+
for (let i = fragments.length - 1; i >= 0; i--) {
|
|
186
|
+
const frag = fragments[i]
|
|
187
|
+
const newBody = lookup(frag.sourcePath, frag.id)
|
|
188
|
+
if (newBody == null) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`No fragment "${frag.id}" found in ${frag.sourcePath} (referenced from client line ${frag.startLine + 1})`
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const newBodyLines = splitLinesKeepingEol(newBody)
|
|
195
|
+
const oldBody = lines.slice(frag.startLine + 1, frag.endLine).join('')
|
|
196
|
+
if (oldBody === newBody) {
|
|
197
|
+
unchanged++
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
lines.splice(frag.startLine + 1, frag.endLine - frag.startLine - 1, ...newBodyLines)
|
|
202
|
+
replaced++
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { text: lines.join(''), replaced, unchanged }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Strip library-side bare markers from a text. Used by the whole-file copy
|
|
210
|
+
* pass so that when a library file is copied into a client, the markers
|
|
211
|
+
* don't leak through.
|
|
212
|
+
*
|
|
213
|
+
* Defensive behaviour:
|
|
214
|
+
* - Removes ONLY bare-marker lines (`<!-- <id> --start-->` / `--end-->`).
|
|
215
|
+
* - Leaves `storyboard:<src>:<id>` namespaced markers in place — those
|
|
216
|
+
* don't belong in library sources, but if a contributor adds one we
|
|
217
|
+
* shouldn't silently delete it.
|
|
218
|
+
* - Collapses any blank lines left behind so the output stays clean.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} text
|
|
221
|
+
* @returns {{ text: string, stripped: number }} number of marker LINES removed.
|
|
222
|
+
*/
|
|
223
|
+
export function stripLibraryMarkers(text) {
|
|
224
|
+
const lines = splitLinesKeepingEol(text)
|
|
225
|
+
let stripped = 0
|
|
226
|
+
|
|
227
|
+
const filtered = lines.filter((line) => {
|
|
228
|
+
if (NAMESPACED_MARKER_RE.test(line)) return true
|
|
229
|
+
|
|
230
|
+
const m = line.match(BARE_MARKER_RE)
|
|
231
|
+
if (!m) return true
|
|
232
|
+
|
|
233
|
+
stripped++
|
|
234
|
+
return false
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// Collapse runs of >2 consecutive blank lines that may now exist where
|
|
238
|
+
// a marker used to live.
|
|
239
|
+
const collapsed = []
|
|
240
|
+
let blankRun = 0
|
|
241
|
+
for (const line of filtered) {
|
|
242
|
+
const isBlank = /^\s*$/.test(stripEol(line))
|
|
243
|
+
if (isBlank) {
|
|
244
|
+
blankRun++
|
|
245
|
+
if (blankRun > 1) continue
|
|
246
|
+
} else {
|
|
247
|
+
blankRun = 0
|
|
248
|
+
}
|
|
249
|
+
collapsed.push(line)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { text: collapsed.join(''), stripped }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Internals
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Split text into an array of lines that, when joined, reproduce the input
|
|
261
|
+
* byte-for-byte. Each element retains its trailing `\n` / `\r\n` (or no EOL
|
|
262
|
+
* for a trailing non-terminated line).
|
|
263
|
+
*
|
|
264
|
+
* This is the only safe way to splice line ranges back together without
|
|
265
|
+
* mangling line endings — a naive `split('\n').join('\n')` corrupts CRLF
|
|
266
|
+
* files and drops the trailing newline.
|
|
267
|
+
*/
|
|
268
|
+
function splitLinesKeepingEol(text) {
|
|
269
|
+
if (text === '') return []
|
|
270
|
+
const out = []
|
|
271
|
+
let start = 0
|
|
272
|
+
for (let i = 0; i < text.length; i++) {
|
|
273
|
+
const ch = text[i]
|
|
274
|
+
if (ch === '\n') {
|
|
275
|
+
out.push(text.slice(start, i + 1))
|
|
276
|
+
start = i + 1
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (start < text.length) {
|
|
280
|
+
out.push(text.slice(start))
|
|
281
|
+
}
|
|
282
|
+
return out
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function stripEol(line) {
|
|
286
|
+
return line.replace(/\r?\n$/, '')
|
|
287
|
+
}
|