@threadlabs/looma 0.32.1 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/bin/looma-docs.mjs +3 -0
- package/components/shared/icons.js +1 -0
- package/components/shared/overlay.js +1 -1
- package/components/ui-action-bar/ui-action-bar.html +11 -11
- package/components/ui-affordance-scope/ui-affordance-scope.html +2 -2
- package/components/ui-avatar/ui-avatar.html +8 -8
- package/components/ui-avatar-group/ui-avatar-group.html +14 -14
- package/components/ui-avatar-group/ui-avatar-group.js +3 -2
- package/components/ui-badge/ui-badge.html +43 -43
- package/components/ui-breadcrumb-item/ui-breadcrumb-item.html +13 -13
- package/components/ui-breadcrumbs/ui-breadcrumbs.html +8 -8
- package/components/ui-button/ui-button.html +32 -29
- package/components/ui-callout/ui-callout.html +9 -9
- package/components/ui-card/ui-card.html +11 -11
- package/components/ui-checkbox/ui-checkbox.html +52 -33
- package/components/ui-cluster/ui-cluster.html +21 -21
- package/components/ui-color-picker/ui-color-picker.html +135 -0
- package/components/ui-color-picker/ui-color-picker.js +76 -0
- package/components/ui-combobox/ui-combobox.html +43 -43
- package/components/ui-container/ui-container.html +7 -7
- package/components/ui-context-menu/ui-context-menu.html +9 -9
- package/components/ui-cover/ui-cover.html +8 -8
- package/components/ui-description-item/ui-description-item.html +7 -7
- package/components/ui-description-list/ui-description-list.html +4 -4
- package/components/ui-dialog/ui-dialog.html +26 -26
- package/components/ui-disclosure/ui-disclosure.html +23 -23
- package/components/ui-editable/ui-editable.html +16 -16
- package/components/ui-editor-insert-table-grid/ui-editor-insert-table-grid.html +9 -9
- package/components/ui-editor-link-form/ui-editor-link-form.html +1 -2
- package/components/ui-editor-mention-menu/ui-editor-mention-menu.html +23 -23
- package/components/ui-editor-mention-menu-item/ui-editor-mention-menu-item.html +10 -10
- package/components/ui-editor-slash-menu/ui-editor-slash-menu.html +29 -29
- package/components/ui-editor-slash-menu-group/ui-editor-slash-menu-group.html +5 -5
- package/components/ui-editor-slash-menu-item/ui-editor-slash-menu-item.html +11 -11
- package/components/ui-editor-surface/ui-editor-surface.html +2 -2
- package/components/ui-editor-table-context-menu/ui-editor-table-context-menu.html +25 -25
- package/components/ui-editor-table-overlay/ui-editor-table-overlay.html +13 -13
- package/components/ui-editor-table-toolbar/ui-editor-table-toolbar.html +31 -31
- package/components/ui-editor-toolbar/ui-editor-toolbar.html +13 -13
- package/components/ui-floating-action-button/ui-floating-action-button.html +8 -8
- package/components/ui-form-field/ui-form-field.html +15 -15
- package/components/ui-form-field/ui-form-field.js +12 -4
- package/components/ui-grid/ui-grid.html +4 -4
- package/components/ui-icon/ui-icon.html +3 -2
- package/components/ui-icon-button/ui-icon-button.html +15 -14
- package/components/ui-image/ui-image.html +14 -14
- package/components/ui-input/ui-input.html +8 -8
- package/components/ui-input-group/ui-input-group.html +18 -18
- package/components/ui-list/ui-list.html +11 -11
- package/components/ui-list-item/ui-list-item.html +18 -18
- package/components/ui-listbox/ui-listbox.html +10 -10
- package/components/ui-menu/ui-menu.html +18 -17
- package/components/ui-menu-group/ui-menu-group.html +4 -4
- package/components/ui-menu-item/ui-menu-item.html +20 -20
- package/components/ui-meter/ui-meter.html +5 -5
- package/components/ui-nav-item/ui-nav-item.html +20 -20
- package/components/ui-page-header/ui-page-header.html +18 -17
- package/components/ui-popover/ui-popover.html +8 -8
- package/components/ui-radio/ui-radio.html +25 -17
- package/components/ui-radio-group/ui-radio-group.html +10 -10
- package/components/ui-reel/ui-reel.html +5 -5
- package/components/ui-scroll-area/ui-scroll-area.html +10 -10
- package/components/ui-search-result-row/ui-search-result-row.html +19 -19
- package/components/ui-search-shell/ui-search-shell.html +22 -22
- package/components/ui-section/ui-section.html +24 -23
- package/components/ui-select/ui-select.html +10 -10
- package/components/ui-separator/ui-separator.html +2 -2
- package/components/ui-sidebar/ui-sidebar.html +8 -8
- package/components/ui-spinner/ui-spinner.html +6 -6
- package/components/ui-stack/ui-stack.html +12 -12
- package/components/ui-status-message/ui-status-message.html +17 -17
- package/components/ui-swatch/ui-swatch.html +2 -2
- package/components/ui-switch/ui-switch.html +36 -26
- package/components/ui-switcher/ui-switcher.html +7 -7
- package/components/ui-table/ui-table.html +6 -6
- package/components/ui-tabs/ui-tabs.html +16 -15
- package/components/ui-text/ui-text.html +3 -2
- package/components/ui-textarea/ui-textarea.html +10 -10
- package/components/ui-toast/ui-toast.html +9 -9
- package/components/ui-toast-region/ui-toast-region.html +12 -12
- package/components/ui-tooltip/ui-tooltip.html +15 -15
- package/components/ui-top-bar/ui-top-bar.html +11 -11
- package/components/ui-tree/ui-tree.html +5 -5
- package/components/ui-tree-item/ui-tree-item.html +36 -36
- package/dist/index.js +46 -45
- package/docs/README.md +116 -0
- package/docs/card.html +63 -0
- package/docs/card.js +32 -0
- package/docs/catalog-view.js +18 -0
- package/docs/catalog-view.mjs +11 -0
- package/docs/catalog.json +22974 -0
- package/docs/catalog.mjs +131 -0
- package/docs/cli.mjs +26 -0
- package/docs/commands.mjs +94 -0
- package/docs/customizer-render.mjs +40 -0
- package/docs/customizer.js +118 -0
- package/docs/documentation.css +8 -0
- package/docs/examples/ui-action-bar/examples/01-form-footer.behavior.js +7 -0
- package/docs/examples/ui-button/examples/08-loading.behavior.js +21 -0
- package/docs/examples/ui-checkbox/examples/02-checked-indeterminate-and-disabled.behavior.js +30 -0
- package/docs/examples/ui-combobox/examples/06-selected-values.behavior.js +11 -0
- package/docs/examples/ui-search-shell/examples/02-label-dismissible-status-and-footer.behavior.js +34 -0
- package/docs/examples/ui-toast/examples/01-authored.behavior.js +15 -0
- package/docs/examples/ui-toast/examples/02-danger.behavior.js +2 -0
- package/docs/examples/ui-tree/examples/03-max-depth-2-and-hover-expand-delay-300.behavior.js +30 -0
- package/docs/examples/ui-tree/examples/06-move-without-dragging.behavior.js +2 -0
- package/docs/examples/ui-tree/examples/07-multiple-selection.behavior.js +16 -0
- package/docs/examples/ui-tree-item/examples/02-container.behavior.js +1 -0
- package/docs/index.d.ts +54 -0
- package/docs/index.mjs +4 -0
- package/docs/local-server.mjs +68 -0
- package/docs/preview.js +20 -0
- package/docs/reference.html +1 -0
- package/docs/reference.js +36 -0
- package/docs/render.mjs +84 -0
- package/docs/site.mjs +198 -0
- package/docs/store.mjs +92 -0
- package/docs/theme.mjs +100 -0
- package/docs/workspace.mjs +32 -0
- package/package.json +28 -3
- package/styles/ui-action-bar.css +11 -11
- package/styles/ui-affordance-scope.css +2 -2
- package/styles/ui-avatar-group.css +14 -14
- package/styles/ui-avatar.css +8 -8
- package/styles/ui-badge.css +41 -41
- package/styles/ui-breadcrumb-item.css +13 -13
- package/styles/ui-breadcrumbs.css +8 -8
- package/styles/ui-button.css +30 -28
- package/styles/ui-callout.css +9 -9
- package/styles/ui-card.css +11 -11
- package/styles/ui-checkbox.css +52 -33
- package/styles/ui-cluster.css +21 -21
- package/styles/ui-color-picker.css +120 -0
- package/styles/ui-combobox.css +86 -86
- package/styles/ui-container.css +7 -7
- package/styles/ui-context-menu.css +9 -9
- package/styles/ui-cover.css +8 -8
- package/styles/ui-description-item.css +7 -7
- package/styles/ui-description-list.css +4 -4
- package/styles/ui-dialog.css +52 -52
- package/styles/ui-disclosure.css +23 -23
- package/styles/ui-editable.css +16 -16
- package/styles/ui-editor-insert-table-grid.css +9 -9
- package/styles/ui-editor-mention-menu-item.css +10 -10
- package/styles/ui-editor-mention-menu.css +23 -23
- package/styles/ui-editor-slash-menu-group.css +5 -5
- package/styles/ui-editor-slash-menu-item.css +11 -11
- package/styles/ui-editor-slash-menu.css +29 -29
- package/styles/ui-editor-surface.css +2 -2
- package/styles/ui-editor-table-context-menu.css +25 -25
- package/styles/ui-editor-table-overlay.css +13 -13
- package/styles/ui-editor-table-toolbar.css +31 -31
- package/styles/ui-editor-toolbar.css +13 -13
- package/styles/ui-floating-action-button.css +8 -8
- package/styles/ui-form-field.css +13 -13
- package/styles/ui-grid.css +4 -4
- package/styles/ui-icon-button.css +13 -13
- package/styles/ui-icon.css +2 -2
- package/styles/ui-image.css +14 -14
- package/styles/ui-input-group.css +18 -18
- package/styles/ui-input.css +8 -8
- package/styles/ui-list-item.css +18 -18
- package/styles/ui-list.css +11 -11
- package/styles/ui-listbox.css +20 -20
- package/styles/ui-menu-group.css +4 -4
- package/styles/ui-menu-item.css +20 -20
- package/styles/ui-menu.css +16 -16
- package/styles/ui-meter.css +5 -5
- package/styles/ui-nav-item.css +20 -20
- package/styles/ui-page-header.css +18 -17
- package/styles/ui-popover.css +8 -8
- package/styles/ui-radio-group.css +10 -10
- package/styles/ui-radio.css +26 -18
- package/styles/ui-reel.css +5 -5
- package/styles/ui-scroll-area.css +10 -10
- package/styles/ui-search-result-row.css +19 -19
- package/styles/ui-search-shell.css +22 -22
- package/styles/ui-section.css +24 -23
- package/styles/ui-select.css +10 -10
- package/styles/ui-separator.css +2 -2
- package/styles/ui-sidebar.css +8 -8
- package/styles/ui-spinner.css +6 -6
- package/styles/ui-stack.css +12 -12
- package/styles/ui-status-message.css +17 -17
- package/styles/ui-swatch.css +2 -2
- package/styles/ui-switch.css +36 -28
- package/styles/ui-switcher.css +7 -7
- package/styles/ui-table.css +6 -6
- package/styles/ui-tabs.css +11 -11
- package/styles/ui-text.css +3 -2
- package/styles/ui-textarea.css +10 -10
- package/styles/ui-toast-region.css +24 -24
- package/styles/ui-toast.css +18 -18
- package/styles/ui-tooltip.css +15 -15
- package/styles/ui-top-bar.css +11 -11
- package/styles/ui-tree-item.css +71 -71
- package/styles/ui-tree.css +5 -5
- package/theme-system.css +80 -0
- package/tokens.css +14 -3
- package/vanilla/UiColorPicker.d.ts +30 -0
- package/vanilla/UiColorPicker.js +77 -0
- package/vanilla/UiIcon.js +1 -1
- package/vanilla/index.js +1 -0
- package/vue/UiActionBar.vue +11 -11
- package/vue/UiAffordanceScope.vue +2 -2
- package/vue/UiAvatar.vue +8 -8
- package/vue/UiAvatarGroup.vue +14 -14
- package/vue/UiBadge.vue +43 -42
- package/vue/UiBreadcrumbItem.vue +13 -13
- package/vue/UiBreadcrumbs.vue +8 -8
- package/vue/UiButton.vue +34 -29
- package/vue/UiCallout.vue +9 -9
- package/vue/UiCard.vue +11 -11
- package/vue/UiCheckbox.vue +57 -34
- package/vue/UiCluster.vue +21 -21
- package/vue/UiColorPicker.d.ts +65 -0
- package/vue/UiColorPicker.js +2 -0
- package/vue/UiColorPicker.vue +422 -0
- package/vue/UiCombobox.vue +86 -86
- package/vue/UiContainer.vue +7 -7
- package/vue/UiContextMenu.vue +9 -9
- package/vue/UiCover.vue +8 -8
- package/vue/UiDescriptionItem.vue +7 -7
- package/vue/UiDescriptionList.vue +4 -4
- package/vue/UiDialog.vue +52 -52
- package/vue/UiDisclosure.vue +23 -23
- package/vue/UiEditable.vue +16 -16
- package/vue/UiEditorInsertTableGrid.vue +9 -9
- package/vue/UiEditorMentionMenu.vue +23 -23
- package/vue/UiEditorMentionMenuItem.vue +10 -10
- package/vue/UiEditorSlashMenu.vue +29 -29
- package/vue/UiEditorSlashMenuGroup.vue +5 -5
- package/vue/UiEditorSlashMenuItem.vue +11 -11
- package/vue/UiEditorSurface.vue +2 -2
- package/vue/UiEditorTableContextMenu.vue +25 -25
- package/vue/UiEditorTableOverlay.vue +13 -13
- package/vue/UiEditorTableToolbar.vue +31 -31
- package/vue/UiEditorToolbar.vue +13 -13
- package/vue/UiFloatingActionButton.vue +8 -8
- package/vue/UiFormField.vue +14 -14
- package/vue/UiGrid.vue +4 -4
- package/vue/UiIcon.vue +6 -2
- package/vue/UiIconButton.vue +14 -14
- package/vue/UiImage.vue +14 -14
- package/vue/UiInput.vue +9 -8
- package/vue/UiInputGroup.vue +18 -18
- package/vue/UiList.vue +11 -11
- package/vue/UiListItem.vue +18 -18
- package/vue/UiListbox.vue +20 -20
- package/vue/UiMenu.vue +16 -16
- package/vue/UiMenuGroup.vue +4 -4
- package/vue/UiMenuItem.vue +20 -20
- package/vue/UiMeter.vue +5 -5
- package/vue/UiNavItem.vue +20 -20
- package/vue/UiPageHeader.vue +18 -17
- package/vue/UiPopover.vue +8 -8
- package/vue/UiRadio.vue +28 -17
- package/vue/UiRadioGroup.vue +10 -10
- package/vue/UiReel.vue +5 -5
- package/vue/UiScrollArea.vue +10 -10
- package/vue/UiSearchResultRow.vue +19 -19
- package/vue/UiSearchShell.vue +22 -22
- package/vue/UiSection.vue +24 -23
- package/vue/UiSelect.vue +11 -10
- package/vue/UiSeparator.vue +2 -2
- package/vue/UiSidebar.vue +8 -8
- package/vue/UiSpinner.vue +6 -6
- package/vue/UiStack.vue +12 -12
- package/vue/UiStatusMessage.vue +17 -17
- package/vue/UiSwatch.vue +2 -2
- package/vue/UiSwitch.vue +38 -25
- package/vue/UiSwitcher.vue +7 -7
- package/vue/UiTable.vue +6 -6
- package/vue/UiTabs.vue +13 -13
- package/vue/UiText.vue +3 -2
- package/vue/UiTextarea.vue +10 -10
- package/vue/UiToast.vue +18 -18
- package/vue/UiToastRegion.vue +24 -24
- package/vue/UiTooltip.vue +15 -15
- package/vue/UiTopBar.vue +11 -11
- package/vue/UiTree.vue +5 -5
- package/vue/UiTreeItem.vue +73 -71
- package/vue/chunks/UiActionBar.js +1 -1
- package/vue/chunks/UiAffordanceScope.js +1 -1
- package/vue/chunks/UiAvatar.js +1 -1
- package/vue/chunks/UiAvatarGroup.js +1 -1
- package/vue/chunks/UiBadge.js +2 -2
- package/vue/chunks/UiBreadcrumbItem.js +1 -1
- package/vue/chunks/UiBreadcrumbs.js +1 -1
- package/vue/chunks/UiButton.js +2 -2
- package/vue/chunks/UiCallout.js +1 -1
- package/vue/chunks/UiCard.js +1 -1
- package/vue/chunks/UiCheckbox.js +1 -1
- package/vue/chunks/UiCluster.js +1 -1
- package/vue/chunks/UiColorPicker.js +326 -0
- package/vue/chunks/UiCombobox.js +1 -1
- package/vue/chunks/UiContainer.js +1 -1
- package/vue/chunks/UiContextMenu.js +1 -1
- package/vue/chunks/UiCover.js +1 -1
- package/vue/chunks/UiDescriptionItem.js +1 -1
- package/vue/chunks/UiDescriptionList.js +1 -1
- package/vue/chunks/UiDialog.js +1 -1
- package/vue/chunks/UiDisclosure.js +1 -1
- package/vue/chunks/UiEditable.js +1 -1
- package/vue/chunks/UiEditorImageToolbar.js +1 -1
- package/vue/chunks/UiEditorInsertTableGrid.js +1 -1
- package/vue/chunks/UiEditorLinkForm.js +1 -1
- package/vue/chunks/UiEditorMentionMenu.js +1 -1
- package/vue/chunks/UiEditorMentionMenuItem.js +1 -1
- package/vue/chunks/UiEditorSlashMenu.js +1 -1
- package/vue/chunks/UiEditorSlashMenuGroup.js +1 -1
- package/vue/chunks/UiEditorSlashMenuItem.js +1 -1
- package/vue/chunks/UiEditorSurface.js +1 -1
- package/vue/chunks/UiEditorTableContextMenu.js +1 -1
- package/vue/chunks/UiEditorTableOverlay.js +1 -1
- package/vue/chunks/UiEditorTableToolbar.js +1 -1
- package/vue/chunks/UiEditorToolbar.js +1 -1
- package/vue/chunks/UiFloatingActionButton.js +1 -1
- package/vue/chunks/UiFormField.js +2 -2
- package/vue/chunks/UiGrid.js +1 -1
- package/vue/chunks/UiIcon.js +5 -1
- package/vue/chunks/UiIconButton.js +2 -2
- package/vue/chunks/UiImage.js +1 -1
- package/vue/chunks/UiInput.js +1 -1
- package/vue/chunks/UiInputGroup.js +1 -1
- package/vue/chunks/UiList.js +1 -1
- package/vue/chunks/UiListItem.js +1 -1
- package/vue/chunks/UiListbox.js +1 -1
- package/vue/chunks/UiMenu.js +1 -1
- package/vue/chunks/UiMenuGroup.js +1 -1
- package/vue/chunks/UiMenuItem.js +1 -1
- package/vue/chunks/UiMeter.js +1 -1
- package/vue/chunks/UiNavItem.js +1 -1
- package/vue/chunks/UiPageHeader.js +1 -1
- package/vue/chunks/UiPopover.js +1 -1
- package/vue/chunks/UiRadio.js +1 -1
- package/vue/chunks/UiRadioGroup.js +1 -1
- package/vue/chunks/UiReel.js +1 -1
- package/vue/chunks/UiScrollArea.js +1 -1
- package/vue/chunks/UiSearchResultRow.js +1 -1
- package/vue/chunks/UiSearchShell.js +1 -1
- package/vue/chunks/UiSection.js +1 -1
- package/vue/chunks/UiSelect.js +1 -1
- package/vue/chunks/UiSeparator.js +1 -1
- package/vue/chunks/UiSidebar.js +1 -1
- package/vue/chunks/UiSpinner.js +1 -1
- package/vue/chunks/UiStack.js +1 -1
- package/vue/chunks/UiStatusMessage.js +1 -1
- package/vue/chunks/UiSwatch.js +1 -1
- package/vue/chunks/UiSwitch.js +1 -1
- package/vue/chunks/UiSwitcher.js +1 -1
- package/vue/chunks/UiTable.js +1 -1
- package/vue/chunks/UiTabs.js +3 -3
- package/vue/chunks/UiText.js +1 -1
- package/vue/chunks/UiTextarea.js +1 -1
- package/vue/chunks/UiToast.js +1 -1
- package/vue/chunks/UiToastRegion.js +1 -1
- package/vue/chunks/UiTooltip.js +1 -1
- package/vue/chunks/UiTopBar.js +1 -1
- package/vue/chunks/UiTree.js +1 -1
- package/vue/chunks/UiTreeItem.js +1 -1
- package/vue/components.css +1857 -1654
- package/vue/editor/index.js +28 -11
- package/vue/index.d.ts +1 -0
- package/vue/index.js +3 -2
package/docs/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface DocumentationGroup { id: string; label: string }
|
|
2
|
+
export interface ExampleCode { language: string; code: string }
|
|
3
|
+
export interface DocumentationExample {
|
|
4
|
+
id: string; name: string; title: string; description: string; markup: string;
|
|
5
|
+
resizable: boolean; frameworks: Record<string, ExampleCode>; behavior?: string; bindings?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DocumentationComponent {
|
|
8
|
+
id: string; package: string; tag: string; title: string; description: string; group: string;
|
|
9
|
+
root: string; resource: string; source?: string; navigationParent?: string;
|
|
10
|
+
targets: string[]; examples: DocumentationExample[];
|
|
11
|
+
attributes: Record<string, unknown>[]; properties: Record<string, unknown>[];
|
|
12
|
+
events: Record<string, unknown>[]; slots: Record<string, unknown>[];
|
|
13
|
+
designTokens: { sources?: string[]; component: Record<string, unknown>[]; shared: Record<string, unknown>[] };
|
|
14
|
+
}
|
|
15
|
+
export interface DocumentationCatalog {
|
|
16
|
+
schemaVersion: 1; package?: string; version?: string; groups: DocumentationGroup[];
|
|
17
|
+
resources?: { tag: string; package: string; resource: string }[]; components: DocumentationComponent[];
|
|
18
|
+
}
|
|
19
|
+
export interface DiscoveryRoot { directory: string; packageName: string; groups?: DocumentationGroup[] }
|
|
20
|
+
export interface ThemeRecipe { schemaVersion: 1; values: Record<string, string> }
|
|
21
|
+
export interface ThemeField { family: string; id: string; label: string; property: string; type: string; defaultValue: string; description: string }
|
|
22
|
+
export interface DocumentationConfiguration {
|
|
23
|
+
title?: string; base?: string; origin?: string; outDir?: string; content?: string;
|
|
24
|
+
logo?: string | { light: string; dark?: string }; nav?: { label: string; href: string }[];
|
|
25
|
+
components?: DiscoveryRoot[]; catalogs?: string[]; categories?: Record<string, string>;
|
|
26
|
+
theme?: { recipe?: string; css?: string }; overrides?: string[];
|
|
27
|
+
recipe?: ThemeRecipe;
|
|
28
|
+
}
|
|
29
|
+
export interface DocumentationInput { root?: string; config?: DocumentationConfiguration; catalog?: DocumentationCatalog }
|
|
30
|
+
export interface DocumentationSite {
|
|
31
|
+
root: string; config: DocumentationConfiguration; base: string; catalog: DocumentationCatalog;
|
|
32
|
+
examples: (DocumentationExample & { file: string; component: string; path: string })[];
|
|
33
|
+
generated: string; content: string; previews: string; redirects: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
export interface DocumentationBuild { outDir: string; routes: readonly string[]; browserInputs: readonly string[]; site: DocumentationSite }
|
|
36
|
+
export interface DocumentationServer { url: string; close(): Promise<void> }
|
|
37
|
+
export function readCatalog(specifier?: string | URL): Promise<DocumentationCatalog>;
|
|
38
|
+
export function discoverComponents(input: DiscoveryRoot & { root: string }): Promise<DocumentationCatalog>;
|
|
39
|
+
export function mergeCatalogs(catalogs: DocumentationCatalog[], options?: { categories?: Record<string, string> }): DocumentationCatalog;
|
|
40
|
+
export function prepareDocumentation(input: DocumentationInput & { root: string }): Promise<DocumentationSite>;
|
|
41
|
+
export function buildDocumentation(input?: DocumentationInput): Promise<DocumentationBuild>;
|
|
42
|
+
/** Optional HTMLKit integration; server tooling peers are loaded only when invoked. */
|
|
43
|
+
export function documentationOptions(site: DocumentationSite): Promise<import('@nextwebwg/htmlkit').ApplicationOptions>;
|
|
44
|
+
export function documentationPlugin(site: DocumentationSite): import('@nextwebwg/htmlkit').HtmlKitPlugin;
|
|
45
|
+
export function initializeDocumentation(input?: { root?: string; title?: string }): Promise<{ config: DocumentationConfiguration; files: string[]; applicationImport: string }>;
|
|
46
|
+
export function readConfiguration(root?: string): Promise<{ root: string; config: DocumentationConfiguration }>;
|
|
47
|
+
export function buildProjectDocumentation(input?: DocumentationInput): Promise<DocumentationBuild>;
|
|
48
|
+
export function devProjectDocumentation(input?: DocumentationInput & { port?: number; onError?: (error: unknown) => void }): Promise<DocumentationServer>;
|
|
49
|
+
export function previewProjectDocumentation(input?: DocumentationInput & { port?: number }): Promise<DocumentationServer>;
|
|
50
|
+
export const themeFields: readonly ThemeField[];
|
|
51
|
+
export const startingPoints: Record<string, ThemeRecipe>;
|
|
52
|
+
export function defaultRecipe(): ThemeRecipe;
|
|
53
|
+
export function validateRecipe(input: unknown): ThemeRecipe;
|
|
54
|
+
export function themeCSS(input: ThemeRecipe, systemCSS?: string): string;
|
package/docs/index.mjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { readCatalog, discoverComponents, mergeCatalogs } from './catalog.mjs';
|
|
2
|
+
export { prepareDocumentation, documentationOptions, documentationPlugin, buildDocumentation } from './site.mjs';
|
|
3
|
+
export { defaultRecipe, startingPoints, themeFields, validateRecipe, themeCSS } from './theme.mjs';
|
|
4
|
+
export { initializeDocumentation, buildProjectDocumentation, devProjectDocumentation, previewProjectDocumentation, readConfiguration } from './commands.mjs';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer, request as proxyRequest } from 'node:http';
|
|
3
|
+
|
|
4
|
+
const rejected = (message, status = 403) => Object.assign(new Error(message), { status });
|
|
5
|
+
const secretMatches = (value, secret) => typeof value === 'string' && Buffer.byteLength(value) === secret.length && timingSafeEqual(Buffer.from(value), Buffer.from(secret));
|
|
6
|
+
async function body(request) {
|
|
7
|
+
const chunks = []; let size = 0;
|
|
8
|
+
for await (const chunk of request) {
|
|
9
|
+
size += chunk.length;
|
|
10
|
+
if (size > 64 * 1024) throw rejected('Theme request is too large.', 413);
|
|
11
|
+
chunks.push(chunk);
|
|
12
|
+
}
|
|
13
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
|
|
14
|
+
catch { throw rejected('Expected a JSON theme recipe and revision.', 400); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Local capabilities stay in the parent editor; opaque example frames receive only theme CSS. */
|
|
18
|
+
export async function serveLocalDocumentation({ upstream, store, base = '/', port = 0, onSaved = async () => {} }) {
|
|
19
|
+
const target = new URL(upstream);
|
|
20
|
+
if (target.protocol !== 'http:' || target.hostname !== '127.0.0.1') throw new Error('The documentation upstream must be a loopback HTMLKit server.');
|
|
21
|
+
const capability = randomBytes(32).toString('hex');
|
|
22
|
+
let origin;
|
|
23
|
+
const json = (response, status, data) => {
|
|
24
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
25
|
+
response.end(JSON.stringify(data));
|
|
26
|
+
};
|
|
27
|
+
const sameOrigin = request => {
|
|
28
|
+
if (request.headers.origin) return request.headers.origin === origin;
|
|
29
|
+
return request.headers['sec-fetch-site'] === 'same-origin';
|
|
30
|
+
};
|
|
31
|
+
const server = createServer((request, response) => {
|
|
32
|
+
void (async () => {
|
|
33
|
+
if (request.headers.host !== new URL(origin).host) throw rejected('Unexpected local documentation host.');
|
|
34
|
+
const url = new URL(request.url, origin);
|
|
35
|
+
const api = base + '_looma/';
|
|
36
|
+
if (url.pathname === api + 'session' || url.pathname === api + 'theme') {
|
|
37
|
+
if (!sameOrigin(request)) throw rejected('Open this editor from its local documentation address.');
|
|
38
|
+
if (url.pathname.endsWith('/session')) {
|
|
39
|
+
if (request.method !== 'GET') throw rejected('Use GET for the local session.', 405);
|
|
40
|
+
json(response, 200, { ...await store.read(), capability }); return;
|
|
41
|
+
}
|
|
42
|
+
if (request.method !== 'POST') throw rejected('Use POST to save a theme.', 405);
|
|
43
|
+
if (request.headers['content-type']?.split(';')[0].trim() !== 'application/json') throw rejected('Use application/json to save a theme.', 415);
|
|
44
|
+
if (!secretMatches(request.headers['x-looma-capability'], capability)) throw rejected('The local editing session expired. Reload before saving.');
|
|
45
|
+
const result = await store.save(await body(request));
|
|
46
|
+
try { await onSaved(result); }
|
|
47
|
+
catch (error) { throw rejected(`Theme files were saved, but the documentation could not rebuild: ${error.message}`, 500); }
|
|
48
|
+
json(response, 200, result); return;
|
|
49
|
+
}
|
|
50
|
+
if (!['GET', 'HEAD'].includes(request.method)) throw rejected('Documentation supports GET and HEAD.', 405);
|
|
51
|
+
const headers = { ...request.headers, host: target.host };
|
|
52
|
+
delete headers['x-looma-capability'];
|
|
53
|
+
const proxy = proxyRequest(target.origin + request.url, { method: request.method, headers }, remote => {
|
|
54
|
+
const responseHeaders = { ...remote.headers };
|
|
55
|
+
if (url.pathname.startsWith(api + 'preview/')) {
|
|
56
|
+
const existing = responseHeaders['content-security-policy'];
|
|
57
|
+
responseHeaders['content-security-policy'] = [...(existing ? [existing].flat() : []), 'sandbox allow-scripts'];
|
|
58
|
+
}
|
|
59
|
+
response.writeHead(remote.statusCode, responseHeaders); remote.pipe(response);
|
|
60
|
+
});
|
|
61
|
+
proxy.on('error', () => { if (!response.headersSent) json(response, 502, { error: 'The documentation renderer is unavailable.' }); else response.destroy(); });
|
|
62
|
+
request.on('aborted', () => proxy.destroy()); proxy.end();
|
|
63
|
+
})().catch(error => { if (!response.headersSent) json(response, error.status ?? 500, { error: error.status ? error.message : 'Could not process this local documentation request.' }); else response.destroy(); });
|
|
64
|
+
});
|
|
65
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
66
|
+
origin = `http://127.0.0.1:${server.address().port}`;
|
|
67
|
+
return { url: origin + base, close: () => new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); server.closeIdleConnections(); }) };
|
|
68
|
+
}
|
package/docs/preview.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** An opaque sandbox cannot read its parent; it accepts only theme CSS from that parent. */
|
|
2
|
+
export function connect(host) {
|
|
3
|
+
const document = host.element.ownerDocument;
|
|
4
|
+
const view = document.defaultView;
|
|
5
|
+
const theme = document.createElement('style');
|
|
6
|
+
theme.dataset.loomaTheme = '';
|
|
7
|
+
document.head.append(theme);
|
|
8
|
+
const height = () => view.parent.postMessage({ type: 'looma-preview-height', height: Math.max(host.element.getBoundingClientRect().height, host.element.scrollHeight) }, '*');
|
|
9
|
+
const receive = event => {
|
|
10
|
+
if (event.source !== view.parent || event.data?.type !== 'looma-theme' || typeof event.data.css !== 'string') return;
|
|
11
|
+
theme.textContent = event.data.css;
|
|
12
|
+
height();
|
|
13
|
+
};
|
|
14
|
+
view.addEventListener('message', receive);
|
|
15
|
+
const observer = new ResizeObserver(height);
|
|
16
|
+
observer.observe(host.element);
|
|
17
|
+
view.parent.postMessage({ type: 'looma-preview-ready' }, '*');
|
|
18
|
+
height();
|
|
19
|
+
return () => { observer.disconnect(); view.removeEventListener('message', receive); theme.remove(); };
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<template component="looma-reference" module="./reference.js"><defs></defs><div><slot></slot></div></template>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** A deep link opens its containing tab through the tab's native, accessible activation. */
|
|
2
|
+
export function connect(host) {
|
|
3
|
+
const root = host.element;
|
|
4
|
+
const view = root.ownerDocument.defaultView;
|
|
5
|
+
let currentHash = view.location.hash;
|
|
6
|
+
const open = () => {
|
|
7
|
+
let id;
|
|
8
|
+
try { id = decodeURIComponent(currentHash.slice(1)); } catch { return; }
|
|
9
|
+
const target = root.ownerDocument.getElementById(id);
|
|
10
|
+
if (!target || !root.contains(target)) { observer.disconnect(); return; }
|
|
11
|
+
const panel = target.closest('[role="tabpanel"]');
|
|
12
|
+
const tab = panel && [...root.querySelectorAll('[role="tab"]')].find(button => button.getAttribute('aria-controls') === panel.id);
|
|
13
|
+
if (!tab) return;
|
|
14
|
+
if (panel.hidden) tab.click();
|
|
15
|
+
if (!panel.hidden) { target.scrollIntoView({ block: 'start' }); observer.disconnect(); }
|
|
16
|
+
};
|
|
17
|
+
const observer = new MutationObserver(open);
|
|
18
|
+
const activate = value => {
|
|
19
|
+
currentHash = value;
|
|
20
|
+
observer.disconnect();
|
|
21
|
+
if (!currentHash) return;
|
|
22
|
+
observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['role', 'id', 'hidden'] });
|
|
23
|
+
open();
|
|
24
|
+
};
|
|
25
|
+
const hash = () => activate(view.location.hash);
|
|
26
|
+
const click = event => {
|
|
27
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
28
|
+
const link = event.target.closest?.('a[href]');
|
|
29
|
+
if (!link || link.target && link.target !== '_self') return;
|
|
30
|
+
const target = new URL(link.href, view.location.href);
|
|
31
|
+
if (target.origin === view.location.origin && target.pathname === view.location.pathname && target.hash) activate(target.hash);
|
|
32
|
+
};
|
|
33
|
+
root.addEventListener('click', click);
|
|
34
|
+
view.addEventListener('hashchange', hash); hash();
|
|
35
|
+
return () => { observer.disconnect(); root.removeEventListener('click', click); view.removeEventListener('hashchange', hash); };
|
|
36
|
+
}
|
package/docs/render.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { escapeHtml as escape } from './catalog.mjs';
|
|
3
|
+
|
|
4
|
+
const literal = value => escape(value).replaceAll('{', '\\{');
|
|
5
|
+
const code = value => `<code>${literal(value ?? '—')}</code>`;
|
|
6
|
+
const table = (head, rows) => `<table><thead><tr>${head.map(h => `<th>${literal(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(value => `<td>${value}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
|
7
|
+
export const exampleKey = id => createHash('sha256').update(id).digest('hex').slice(0, 20);
|
|
8
|
+
export const componentPath = component => `/components/${component.navigationParent ?? component.tag}/` + (component.navigationParent ? `#${component.tag}-api` : '');
|
|
9
|
+
export const previewPath = example => `/_looma/preview/${exampleKey(example.id)}/`;
|
|
10
|
+
|
|
11
|
+
export function apiBody(api, prefix = '') {
|
|
12
|
+
const attributes = api.attributes ?? [];
|
|
13
|
+
const properties = api.properties ?? [];
|
|
14
|
+
const events = api.events ?? [];
|
|
15
|
+
const slots = api.slots ?? [];
|
|
16
|
+
const tokens = api.designTokens ?? { component: [], shared: [] };
|
|
17
|
+
const section = (name, items, headings, render) => `<h2 id="${prefix}${name.toLowerCase().replaceAll(' ', '-')}">${name}</h2>${items.length ? table(headings, items.map(render)) : `<p>No ${name.toLowerCase()}.</p>`}`;
|
|
18
|
+
const fields = value => value?.length ? `<ul>${value.map(field => `<li>${code(field.path)}: ${code(field.type)}${field.required ? ' (required)' : ''}</li>`).join('')}</ul>` : '';
|
|
19
|
+
return `<p><strong>Package:</strong> ${code(api.package)} · <strong>Native root:</strong> ${code(api.root)}</p>`
|
|
20
|
+
+ section('Attributes', attributes, ['Name', 'Description', 'Property', 'Type', 'Default', 'Allowed values'], p => [code(p.name), literal(p.description ?? ''), code(p.property), code(p.type) + fields(p.fields), code(p.default), code(p.options?.join(', '))])
|
|
21
|
+
+ (api.targets?.includes('vue') ? section('Framework props', properties, ['Name', 'Type', 'Default', 'Allowed values'], p => [code(p.name), code(p.type) + fields(p.fields), code(p.default), code(p.options?.join(', '))]) : '')
|
|
22
|
+
+ section('Events', events, ['Name', 'Detail', 'Description'], e => [code(e.name), code(e.detailSchema ?? e.detailType) + fields(e.fields), literal(e.detailDocs ?? '')])
|
|
23
|
+
+ section('Slots', slots, ['Name', 'Purpose'], s => [code(s.name), literal(s.description ?? '')])
|
|
24
|
+
+ `<h2 id="${prefix}design-tokens">Design tokens</h2><p>Set shared theme properties on a theme boundary. Component hooks apply only to their owning component.</p>`
|
|
25
|
+
+ section('Component tokens', tokens.component ?? [], ['Token', 'Declared values', 'Fallback'], t => [code(t.name), code(t.declarations?.join(' | ')), code(t.fallbacks?.join(' | '))])
|
|
26
|
+
+ section('Shared tokens', tokens.shared ?? [], ['Token', 'Fallback'], t => [code(t.name), code(t.fallbacks?.join(' | '))]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function structuredBindings(example) {
|
|
30
|
+
let markup = example.markup;
|
|
31
|
+
const assignments = [];
|
|
32
|
+
for (const pair of (example.bindings ?? '').split(/\s+/).filter(Boolean)) {
|
|
33
|
+
const [attribute, variable] = pair.split('=');
|
|
34
|
+
if (!/^[a-zA-Z_$][\w$]*$/.test(variable ?? '') || !/^[\w-]+$/.test(attribute)) throw new Error(`Invalid example binding ${pair} in ${example.id}.`);
|
|
35
|
+
const match = new RegExp(`<[^>]*\\s${attribute}='([^']*)'[^>]*>`).exec(markup);
|
|
36
|
+
const id = match && /\sid="([^"]+)"/.exec(match[0])?.[1];
|
|
37
|
+
if (!match || !id) throw new Error(`Example binding ${pair} in ${example.id} needs an element with an id.`);
|
|
38
|
+
const value = JSON.parse(match[1].replaceAll(''', "'").replaceAll('"', '"').replaceAll('&', '&'));
|
|
39
|
+
assignments.push({ id, variable, property: attribute.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()), value });
|
|
40
|
+
markup = markup.replace(match[0], match[0].replace(` ${attribute}='${match[1]}'`, ''));
|
|
41
|
+
}
|
|
42
|
+
return { markup, assignments };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function exampleCodes(example, component, components) {
|
|
46
|
+
const tags = [...new Set([...example.markup.matchAll(/<([a-z][\w]*-[\w-]+)\b/g)].map(match => match[1]))];
|
|
47
|
+
const owners = [...new Set(tags.map(tag => components.find(c => c.tag === tag)?.package).filter(Boolean))];
|
|
48
|
+
const html = `<script type="module">\n${owners.map(owner => `import ${JSON.stringify(owner)};`).join('\n')}\n</script>\n\n${example.markup}`;
|
|
49
|
+
const codes = { 'html-next': { language: 'html', code: html } };
|
|
50
|
+
if (component.targets?.includes('vue')) {
|
|
51
|
+
const toName = tag => tag.replace(/^ui-/, '').split('-').map(word => word[0].toUpperCase() + word.slice(1)).join('');
|
|
52
|
+
const groups = new Map();
|
|
53
|
+
for (const tag of tags) {
|
|
54
|
+
const entry = components.find(c => c.tag === tag);
|
|
55
|
+
if (!entry?.targets?.includes('vue')) continue;
|
|
56
|
+
const owner = entry.vueImport ?? `${entry.package}/vue${tag.startsWith('ui-editor-') ? '/editor' : ''}`;
|
|
57
|
+
groups.set(owner, [...groups.get(owner) ?? [], toName(tag)]);
|
|
58
|
+
}
|
|
59
|
+
const binding = structuredBindings(example);
|
|
60
|
+
let markup = binding.markup;
|
|
61
|
+
for (const tag of tags) if (components.find(c => c.tag === tag)?.targets?.includes('vue')) markup = markup.replace(new RegExp(`(</?)${tag}(?=[\\s/>])`, 'g'), (_, start) => start + toName(tag));
|
|
62
|
+
for (const assignment of binding.assignments) markup = markup.replace(`id="${assignment.id}"`, `id="${assignment.id}" :${assignment.property}="${assignment.variable}"`);
|
|
63
|
+
const declarations = binding.assignments.map(assignment => `const ${assignment.variable} = ${JSON.stringify(assignment.value, null, 2)};`).join('\n');
|
|
64
|
+
codes.vue = { language: 'vue', code: `<script setup>\n${[...groups].map(([owner, names]) => `import { ${names.sort().join(', ')} } from ${JSON.stringify(owner)};`).join('\n')}\n${declarations}\n</script>\n\n<template>\n${markup}\n</template>` };
|
|
65
|
+
}
|
|
66
|
+
return { ...codes, ...example.frameworks };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The same card invocation is embedded in reference pages and rendered by the customizer. */
|
|
70
|
+
export function cardMarkup(example, base = '/', component = {}) {
|
|
71
|
+
return `<looma-example-card identity="${escape(example.id)}" title="${escape(component.title ?? example.title)}" description="${escape(component.description ?? example.description)}" example-title="${escape(example.title)}" category="${escape(component.group ?? 'forms')}" tag="${escape(component.tag ?? '')}" package-name="${escape(component.package ?? '')}" native-root="${escape(component.root ? `<${component.root}>` : '')}" href="${component.tag ? base + componentPath(component).slice(1) : ''}" src="${base}${previewPath(example).slice(1)}"${example.resizable ? ' resizable' : ''}></looma-example-card>`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function referencePage(component, catalog, cardResource, base) {
|
|
75
|
+
const parts = catalog.components.filter(c => c.navigationParent === component.tag);
|
|
76
|
+
const examples = [...component.examples, ...parts.flatMap(part => part.examples)];
|
|
77
|
+
const codes = example => {
|
|
78
|
+
const owner = catalog.components.find(c => c.examples.some(e => e.id === example.id)) ?? component;
|
|
79
|
+
const tabs = Object.entries(exampleCodes(example, owner, catalog.components)).map(([mode, value]) => `<section aria-label="${mode === 'vue' ? 'Vue' : 'HTML Next'}"><pre><code class="language-${escape(value.language)}">${literal(value.code)}</code></pre></section>`).join('');
|
|
80
|
+
return `<ui-disclosure summary="Example code"><ui-tabs label="Example syntax" group="syntax">${tabs}</ui-tabs></ui-disclosure>`;
|
|
81
|
+
};
|
|
82
|
+
const body = `<ui-stack gap="xl">${examples.map(example => `<section>${cardMarkup(example, base, catalog.components.find(c => c.examples.some(e => e.id === example.id)) ?? component)}${codes(example)}</section>`).join('')}</ui-stack>`;
|
|
83
|
+
return `---\ntitle: ${JSON.stringify(component.title)}\ndescription: ${JSON.stringify(component.description || `${component.title} component reference.`)}\nid: ${JSON.stringify(component.id)}\n---\n\n# ${escape(component.title)}\n\n${escape(component.description)}\n\n<link rel="component" href="${cardResource}">\n<link rel="component" href="${cardResource.replace(/card\.html$/, 'reference.html')}">\n<link rel="component" href="@threadlabs/looma/components/ui-tabs/ui-tabs.html">\n<link rel="component" href="@threadlabs/looma/components/ui-disclosure/ui-disclosure.html">\n<link rel="component" href="@threadlabs/looma/components/ui-stack/ui-stack.html">\n\n<looma-reference>\n\n:::: tabs\n== Examples\n\n${body}\n\n== API\n\n<h2 id="api">API</h2>\n${apiBody(component)}\n${parts.map(part => `<h2 id="${part.tag}-api">${literal(part.title)} API</h2><p>${literal(part.description)}</p>${apiBody(part, part.tag + '-')}`).join('\n')}\n::::\n\n</looma-reference>\n`;
|
|
84
|
+
}
|
package/docs/site.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { cp, mkdir, readFile, readdir, realpath, rename, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { discoverComponents, escapeHtml, mergeCatalogs, readCatalog, readJson } from './catalog.mjs';
|
|
7
|
+
import { cardMarkup, componentPath, exampleKey, previewPath, referencePage } from './render.mjs';
|
|
8
|
+
import { customizerResource } from './customizer-render.mjs';
|
|
9
|
+
import { catalogResource } from './catalog-view.mjs';
|
|
10
|
+
import { cacheDirectory, rejectSymlink, withBuildLock } from './workspace.mjs';
|
|
11
|
+
|
|
12
|
+
const own = name => fileURLToPath(new URL(name, import.meta.url));
|
|
13
|
+
/** Resolve import-only optional tooling peers in the consumer's ESM package scope. */
|
|
14
|
+
export async function tool(name, root) {
|
|
15
|
+
if (!['markupress', '@nextwebwg/htmlkit', 'vite'].includes(name)) throw new Error(`Unknown documentation tool ${name}.`);
|
|
16
|
+
const directory = await cacheDirectory(root, 'tooling');
|
|
17
|
+
const file = path.join(directory, name.replaceAll('/', '-').replace('@', '') + '.mjs');
|
|
18
|
+
await rejectSymlink(file);
|
|
19
|
+
const temporary = file + '.' + randomUUID() + '.tmp';
|
|
20
|
+
try { await writeFile(temporary, `export * from ${JSON.stringify(name)};\n`, { flag: 'wx' }); await rename(temporary, file); }
|
|
21
|
+
finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
|
|
22
|
+
return import(pathToFileURL(file).href);
|
|
23
|
+
}
|
|
24
|
+
const normalizeBase = base => '/' + String(base ?? '/').replace(/^\/+|\/+$/g, '') + (String(base ?? '/').replaceAll('/', '') ? '/' : '');
|
|
25
|
+
const relativeHref = (from, to) => path.relative(path.dirname(from), to).split(path.sep).join('/');
|
|
26
|
+
|
|
27
|
+
async function authoredFiles(directory) {
|
|
28
|
+
const files = [];
|
|
29
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
30
|
+
if (entry.name.startsWith('.')) continue;
|
|
31
|
+
const file = path.join(directory, entry.name);
|
|
32
|
+
if (entry.isDirectory()) files.push(...await authoredFiles(file)); else files.push(file);
|
|
33
|
+
}
|
|
34
|
+
return files;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Prepare a disposable documentation tree from published catalogs and opted-in project roots. */
|
|
38
|
+
export async function prepareDocumentation({ root, config = {}, catalog }) {
|
|
39
|
+
root = await realpath(path.resolve(root));
|
|
40
|
+
const base = normalizeBase(config.base);
|
|
41
|
+
const catalogs = [catalog ?? await readCatalog()];
|
|
42
|
+
for (const entry of config.catalogs ?? []) {
|
|
43
|
+
const file = entry.startsWith('.') || path.isAbsolute(entry) ? path.resolve(root, entry) : createRequire(path.join(root, 'package.json')).resolve(entry);
|
|
44
|
+
catalogs.push(await readCatalog(file));
|
|
45
|
+
}
|
|
46
|
+
for (const entry of config.components ?? []) catalogs.push(await discoverComponents({ root, ...entry }));
|
|
47
|
+
const combined = mergeCatalogs(catalogs, { categories: config.categories });
|
|
48
|
+
for (const component of combined.components) {
|
|
49
|
+
component.examples = component.examples.length ? component.examples : [{ id: `${component.id}/default`, name: 'default', title: component.title, description: component.description, markup: `<${component.tag}></${component.tag}>`, frameworks: {}, resizable: false }];
|
|
50
|
+
}
|
|
51
|
+
const generated = await cacheDirectory(root, 'generated');
|
|
52
|
+
const output = path.resolve(root, config.outDir ?? '.looma/site');
|
|
53
|
+
if (output === generated || output.startsWith(generated + path.sep)) throw new Error('Choose an output directory outside .looma/generated, which owns disposable source files.');
|
|
54
|
+
await rm(generated, { recursive: true, force: true });
|
|
55
|
+
await mkdir(generated, { recursive: true });
|
|
56
|
+
let content = path.join(generated, 'docs');
|
|
57
|
+
if (config.content) {
|
|
58
|
+
const relative = path.relative(root, path.resolve(root, config.content));
|
|
59
|
+
if (!relative || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) || relative.split(path.sep).includes('.looma')) throw new Error('Authored content must be a directory inside the project, outside .looma.');
|
|
60
|
+
const mirror = path.join(generated, 'project');
|
|
61
|
+
await rm(mirror, { recursive: true, force: true });
|
|
62
|
+
// Preserve every relative dependency path without copying the project or rewriting code examples.
|
|
63
|
+
const mirrorParents = async (source, target, parts) => {
|
|
64
|
+
await mkdir(target, { recursive: true });
|
|
65
|
+
for (const entry of await readdir(source, { withFileTypes: true })) {
|
|
66
|
+
if (['.looma', '.git', 'node_modules', 'package.json'].includes(entry.name)) continue;
|
|
67
|
+
const original = path.join(source, entry.name), mirrored = path.join(target, entry.name);
|
|
68
|
+
if (entry.name === parts[0]) {
|
|
69
|
+
if (parts.length > 1) await mirrorParents(original, mirrored, parts.slice(1));
|
|
70
|
+
} else await symlink(original, mirrored, entry.isDirectory() ? 'dir' : 'file');
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
await mirrorParents(root, mirror, relative.split(path.sep));
|
|
74
|
+
content = path.join(mirror, relative);
|
|
75
|
+
}
|
|
76
|
+
const previews = path.join(generated, 'previews');
|
|
77
|
+
await rm(content, { recursive: true, force: true });
|
|
78
|
+
await rm(previews, { recursive: true, force: true });
|
|
79
|
+
await mkdir(path.join(content, 'components'), { recursive: true });
|
|
80
|
+
await mkdir(previews, { recursive: true });
|
|
81
|
+
const card = path.join(generated, 'card.html');
|
|
82
|
+
await cp(own('card.html'), card);
|
|
83
|
+
await cp(own('card.js'), path.join(generated, 'card.js'));
|
|
84
|
+
await writeFile(path.join(generated, 'catalog-view.html'), catalogResource(combined.groups));
|
|
85
|
+
await cp(own('catalog-view.js'), path.join(generated, 'catalog-view.js'));
|
|
86
|
+
await cp(own('preview.js'), path.join(generated, 'preview.js'));
|
|
87
|
+
for (const file of ['reference.html', 'reference.js']) await cp(own(file), path.join(generated, file));
|
|
88
|
+
for (const file of ['customizer.js', 'theme.mjs']) await cp(own(file), path.join(generated, file));
|
|
89
|
+
const write = async (file, source) => { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, source); };
|
|
90
|
+
const examples = [];
|
|
91
|
+
const resourceByTag = new Map(combined.resources.map(resource => [resource.tag, resource]));
|
|
92
|
+
for (const component of combined.components) {
|
|
93
|
+
if (!component.navigationParent) {
|
|
94
|
+
const page = path.join(content, 'components', component.tag + '.md');
|
|
95
|
+
await write(page, referencePage(component, combined, relativeHref(page, card), base));
|
|
96
|
+
}
|
|
97
|
+
for (const example of component.examples) {
|
|
98
|
+
const key = exampleKey(example.id);
|
|
99
|
+
const file = path.join(previews, key + '.html');
|
|
100
|
+
const tags = [...new Set([...example.markup.matchAll(/<([a-z][\w]*-[\w-]+)\b/g)].map(match => match[1]))];
|
|
101
|
+
const links = tags.filter(tag => resourceByTag.has(tag)).map(tag => `<link rel="component" href="${escapeHtml(resourceByTag.get(tag).resource)}">`).join('\n');
|
|
102
|
+
// Navigation specimens name destinations supplied by their host application. Give those
|
|
103
|
+
// fragments local stand-ins inside the isolated stage; keep real documentation links checked.
|
|
104
|
+
const declaredIds = new Set([...example.markup.matchAll(/\sid=["']([^"']+)["']/g)].map(match => match[1]));
|
|
105
|
+
const destinations = [...new Set([...example.markup.matchAll(/\bhref=["']#([^"']+)["']/g)].map(match => match[1]))].filter(id => !declaredIds.has(id));
|
|
106
|
+
const anchors = destinations.map(id => `<span id="${escapeHtml(id)}" hidden></span>`).join('');
|
|
107
|
+
const setup = path.join(previews, key + '.js');
|
|
108
|
+
const behavior = example.behavior;
|
|
109
|
+
await write(setup, `import { connect as connectPreview } from '../preview.js';\n${behavior ? `import setupExample from ${JSON.stringify(behavior)};\n` : ''}export function connect(host) { const stopPreview = connectPreview(host); ${behavior ? 'const stopExample = setupExample(host.element);' : ''} return () => { ${behavior ? 'stopExample?.();' : ''} stopPreview(); }; }\n`);
|
|
110
|
+
await write(file, ` ${links}\n<template component="looma-preview-${key}" module="./${key}.js"><defs><meta name="hk:navigation" content="hidden"><title>${escapeHtml(example.title)}</title></defs><div data-preview="${escapeHtml(example.id)}">${example.markup}${anchors}</div></template>\n`);
|
|
111
|
+
examples.push({ ...example, file, component: component.id, path: base + previewPath(example).slice(1) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const gallery = combined.groups.map(group => {
|
|
115
|
+
const components = combined.components.filter(c => c.group === group.id && !c.navigationParent);
|
|
116
|
+
return components.length ? `<section data-catalog-group="${escapeHtml(group.id)}">\n\n## ${escapeHtml(group.label)}\n\n<ui-grid columns="3" min="sm">${components.map(component => `<div data-category="${escapeHtml(component.group)}" data-catalog-search="${escapeHtml(`${component.tag} ${component.title} ${component.description}`)}">${cardMarkup(component.examples[0], base, component)}</div>`).join('')}</ui-grid>\n\n</section>` : '';
|
|
117
|
+
}).join('\n\n');
|
|
118
|
+
await write(path.join(content, 'components/index.md'), `---\ntitle: Components\n---\n\n# Components\n\nExplore the live examples that also power your theme customizer.\n\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), card)}">\n<link rel="component" href="@threadlabs/looma/components/ui-grid/ui-grid.html">\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), path.join(generated, 'catalog-view.html'))}">\n\n<looma-catalog>\n\n${gallery}\n\n</looma-catalog>\n`);
|
|
119
|
+
await write(path.join(content, 'index.md'), `---\ntitle: ${JSON.stringify(config.title ?? 'Your design system')}\n---\n\n# ${config.title ?? 'Your design system'}\n\nWeave your design system from Looma and your own HTML Next components.\n\n[Explore components](/components/) · [Customize your theme](/customize/)\n`);
|
|
120
|
+
await write(path.join(content, 'design-system.md'), await readFile(own('README.md'), 'utf8'));
|
|
121
|
+
if (config.content) {
|
|
122
|
+
const authored = path.resolve(root, config.content);
|
|
123
|
+
for (const source of await authoredFiles(authored)) {
|
|
124
|
+
const target = path.join(content, path.relative(authored, source));
|
|
125
|
+
let exists = false;
|
|
126
|
+
try { await readFile(target); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
127
|
+
// A project's landing page and footer are branding/content, never API overrides.
|
|
128
|
+
if (exists && !['index.md', '_footer.md'].includes(path.relative(content, target))) throw new Error(`Authored/generated documentation collision: ${source} would replace ${target}. Add guidance under another path.`);
|
|
129
|
+
await mkdir(path.dirname(target), { recursive: true }); await cp(source, target);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const customizer = path.join(generated, 'customizer.html');
|
|
133
|
+
await write(customizer, customizerResource({ catalog: combined, config, base }, config.recipe));
|
|
134
|
+
const customizerPage = path.join(content, 'customize.md');
|
|
135
|
+
await write(customizerPage, `---\ntitle: Customize\n---\n\n# Weave your design system\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
|
|
136
|
+
await write(path.join(generated, 'catalog.json'), JSON.stringify(combined));
|
|
137
|
+
const redirects = {};
|
|
138
|
+
for (const component of combined.components) {
|
|
139
|
+
const target = componentPath(component);
|
|
140
|
+
redirects[`/components/${component.group}/${component.tag}/`] = target;
|
|
141
|
+
if (component.group === 'editor') redirects[`/editor/${component.tag}/`] = target;
|
|
142
|
+
if (component.navigationParent) redirects[`/parts/${component.tag}/`] = target;
|
|
143
|
+
}
|
|
144
|
+
return { root, config, base, catalog: combined, examples, generated, content, previews, redirects };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One plugin powers both the public consumer and local repositories. */
|
|
148
|
+
export function documentationPlugin(site) {
|
|
149
|
+
return {
|
|
150
|
+
name: 'looma-design-system',
|
|
151
|
+
buildEnd: context => isolatePreviewScripts(site, context),
|
|
152
|
+
generate: async () => ({ '/_looma/theme-system.css': await readFile(fileURLToPath(import.meta.resolve('@threadlabs/looma/theme-system.css')), 'utf8') }),
|
|
153
|
+
config: () => ({ pages: [{ dir: path.relative(site.root, site.previews), prefix: '/_looma/preview/', layout: false }], css: [own('documentation.css')] }),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function documentationOptions(site) {
|
|
158
|
+
const { siteOptions } = await tool('markupress', site.root);
|
|
159
|
+
const styles = ['tokens.css', 'theme-light.css', 'theme-dark.css', 'theme-system.css'].map(file => fileURLToPath(import.meta.resolve(`@threadlabs/looma/${file}`)));
|
|
160
|
+
const options = siteOptions({ root: site.root, contentDir: path.relative(site.root, site.content), title: site.config.title ?? 'Your design system', base: site.base, outDir: site.config.outDir ?? 'site', ...(site.config.origin ? { origin: site.config.origin } : {}), ...(site.config.logo ? { logo: site.config.logo } : {}), ...(site.config.nav ? { nav: site.config.nav } : {}) }, { plugins: [documentationPlugin(site)], css: styles, redirects: site.redirects });
|
|
161
|
+
let savedTheme;
|
|
162
|
+
if (site.config.themeCSSContents !== undefined) {
|
|
163
|
+
savedTheme = path.join(site.generated, 'saved-theme.css');
|
|
164
|
+
await writeFile(savedTheme, site.config.themeCSSContents);
|
|
165
|
+
}
|
|
166
|
+
const themeStyles = [savedTheme, ...(site.config.overrides ?? []).map(file => path.resolve(site.root, file))].filter(Boolean);
|
|
167
|
+
return themeStyles.length ? { ...options, plugins: [...options.plugins, { name: 'looma-saved-theme', config: () => ({ css: themeStyles }) }] } : options;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Build through HTMLKit, then make opaque preview scripts portable to static hosts without CORS. */
|
|
171
|
+
export async function buildDocumentation({ root = process.cwd(), config = {}, catalog } = {}) {
|
|
172
|
+
return withBuildLock(root, async () => {
|
|
173
|
+
const site = await prepareDocumentation({ root, config, catalog });
|
|
174
|
+
const { buildApplication } = await tool('@nextwebwg/htmlkit', site.root);
|
|
175
|
+
const built = await buildApplication(await documentationOptions(site));
|
|
176
|
+
return { ...built, site };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function isolatePreviewScripts(site, { outDir, writeFile: writeOutput }) {
|
|
181
|
+
const { build } = await tool('vite', site.root);
|
|
182
|
+
for (const example of site.examples) {
|
|
183
|
+
const htmlFile = path.join(outDir, '_looma/preview', exampleKey(example.id), 'index.html');
|
|
184
|
+
let html = await readFile(htmlFile, 'utf8');
|
|
185
|
+
const scripts = [...html.matchAll(/<script\b[^>]*type="module"[^>]*src="([^"]+)"[^>]*><\/script>/g)];
|
|
186
|
+
if (!scripts.length) throw new Error(`No browser module found for isolated preview ${example.id}.`);
|
|
187
|
+
for (const [tag, url] of scripts) {
|
|
188
|
+
const input = path.join(outDir, url.slice(site.base.length));
|
|
189
|
+
const bundle = await build({ configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry: input, name: 'LoomaPreview', formats: ['iife'] }, rollupOptions: { output: { codeSplitting: false } } } });
|
|
190
|
+
const code = (Array.isArray(bundle) ? bundle[0] : bundle).output.find(output => output.type === 'chunk').code;
|
|
191
|
+
const name = `${exampleKey(example.id)}.js`;
|
|
192
|
+
await writeOutput('_looma/runtime/' + name, code);
|
|
193
|
+
html = html.replace(tag, `<script src="${site.base}_looma/runtime/${name}" defer></script>`);
|
|
194
|
+
}
|
|
195
|
+
html = html.replace(/<link\b[^>]*rel="modulepreload"[^>]*>/g, '');
|
|
196
|
+
await writeFile(htmlFile, html);
|
|
197
|
+
}
|
|
198
|
+
}
|
package/docs/store.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, open, readFile, realpath, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { defaultRecipe, themeCSS, validateRecipe } from './theme.mjs';
|
|
6
|
+
|
|
7
|
+
const missing = error => error.code === 'ENOENT';
|
|
8
|
+
const failure = (message, status = 409) => Object.assign(new Error(message), { status });
|
|
9
|
+
const digest = values => createHash('sha256').update(JSON.stringify(values)).digest('hex');
|
|
10
|
+
const contained = (root, file) => file.startsWith(root + path.sep);
|
|
11
|
+
|
|
12
|
+
/** A local writer owns only two explicitly configured theme files. */
|
|
13
|
+
export async function createThemeStore({ root, recipe = 'looma.theme.json', css = 'looma.theme.css' }) {
|
|
14
|
+
root = await realpath(path.resolve(root));
|
|
15
|
+
const destination = value => {
|
|
16
|
+
if (typeof value !== 'string' || !value || path.isAbsolute(value)) throw failure('Theme paths must be relative paths inside the repository.', 400);
|
|
17
|
+
const file = path.resolve(root, value);
|
|
18
|
+
if (!contained(root, file)) throw failure('Theme paths must stay inside the repository.', 400);
|
|
19
|
+
return file;
|
|
20
|
+
};
|
|
21
|
+
for (const value of [recipe, css]) {
|
|
22
|
+
if (typeof value === 'string' && value.split(/[\\/]/).some(part => ['.looma', '.git', 'node_modules'].includes(part))) throw failure('Theme outputs must stay outside generated files, Git metadata and node_modules.', 400);
|
|
23
|
+
}
|
|
24
|
+
const recipeFile = destination(recipe), cssFile = destination(css);
|
|
25
|
+
if (recipeFile === cssFile || !recipeFile.endsWith('.json') || !cssFile.endsWith('.css')) throw failure('Choose separate .json recipe and .css theme files.', 400);
|
|
26
|
+
const lockFile = destination('.looma/theme-write.lock');
|
|
27
|
+
const safe = async file => {
|
|
28
|
+
const pieces = path.relative(root, file).split(path.sep);
|
|
29
|
+
let cursor = root;
|
|
30
|
+
for (const piece of pieces) {
|
|
31
|
+
cursor = path.join(cursor, piece);
|
|
32
|
+
try { if ((await lstat(cursor)).isSymbolicLink()) throw failure(`Theme paths cannot traverse a symlink: ${path.relative(root, cursor)}.`, 400); }
|
|
33
|
+
catch (error) { if (!missing(error)) throw error; }
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
for (const file of [recipeFile, cssFile, lockFile]) await safe(file);
|
|
37
|
+
const bytes = async file => { await safe(file); try { return await readFile(file, 'utf8'); } catch (error) { if (missing(error)) return null; throw error; } };
|
|
38
|
+
const snapshot = async () => {
|
|
39
|
+
const [json, css] = await Promise.all([bytes(recipeFile), bytes(cssFile)]);
|
|
40
|
+
if (css !== null && !css.startsWith('/* Looma shared semantic theme. */')) throw failure('The theme CSS is not a generated Looma file. Choose a new output path and keep manual CSS in an overrides file.');
|
|
41
|
+
return { json, css, recipe: json === null ? defaultRecipe() : validateRecipe(JSON.parse(json)), revision: digest([json, css]) };
|
|
42
|
+
};
|
|
43
|
+
const read = async ({ includeCSS = false } = {}) => {
|
|
44
|
+
await safe(lockFile); await mkdir(path.dirname(lockFile), { recursive: true }); await safe(lockFile);
|
|
45
|
+
let lock;
|
|
46
|
+
try { lock = await open(lockFile, 'wx', 0o600); }
|
|
47
|
+
catch (error) { if (error.code === 'EEXIST') throw failure('A theme editor holds .looma/theme-write.lock. Retry after its save. If the old editor stopped, close it before removing that lock and restarting.'); throw error; }
|
|
48
|
+
try { const { recipe, revision, css } = await snapshot(); return { recipe, revision, ...(includeCSS ? { css } : {}) }; }
|
|
49
|
+
finally { try { await lock.close(); } finally { await unlink(lockFile); } }
|
|
50
|
+
};
|
|
51
|
+
const stage = async (file, data) => {
|
|
52
|
+
await safe(file); await mkdir(path.dirname(file), { recursive: true }); await safe(file);
|
|
53
|
+
const temp = file + '.' + randomUUID() + '.tmp';
|
|
54
|
+
const handle = await open(temp, 'wx', 0o644);
|
|
55
|
+
try { await handle.writeFile(data); await handle.sync(); } catch (error) { await unlink(temp); throw error; } finally { await handle.close(); }
|
|
56
|
+
return temp;
|
|
57
|
+
};
|
|
58
|
+
const save = async input => {
|
|
59
|
+
const next = validateRecipe(input?.recipe);
|
|
60
|
+
if (typeof input?.revision !== 'string') throw failure('A theme revision is required. Reload before saving.', 400);
|
|
61
|
+
await safe(lockFile); await mkdir(path.dirname(lockFile), { recursive: true }); await safe(lockFile);
|
|
62
|
+
let lock;
|
|
63
|
+
try { lock = await open(lockFile, 'wx', 0o600); }
|
|
64
|
+
catch (error) { if (error.code === 'EEXIST') throw failure('Another theme save is in progress. Reload before saving again.'); throw error; }
|
|
65
|
+
const temporary = new Set(); let original; let committedRecipe = false;
|
|
66
|
+
try {
|
|
67
|
+
original = await snapshot();
|
|
68
|
+
if (original.revision !== input.revision) throw failure('Theme files changed outside this editor. Reload before saving; your unsaved preview is still here.');
|
|
69
|
+
const systemCSS = await readFile(fileURLToPath(import.meta.resolve('@threadlabs/looma/theme-system.css')), 'utf8');
|
|
70
|
+
const nextJson = JSON.stringify(next, null, 2) + '\n';
|
|
71
|
+
const nextCSS = themeCSS(next, systemCSS);
|
|
72
|
+
const stagedRecipe = await stage(recipeFile, nextJson); temporary.add(stagedRecipe);
|
|
73
|
+
const stagedCSS = await stage(cssFile, nextCSS); temporary.add(stagedCSS);
|
|
74
|
+
if ((await snapshot()).revision !== original.revision) throw failure('Theme files changed while saving. Reload before saving again.');
|
|
75
|
+
await safe(recipeFile); await safe(cssFile);
|
|
76
|
+
await rename(stagedRecipe, recipeFile); temporary.delete(stagedRecipe); committedRecipe = true;
|
|
77
|
+
// CSS is the visible commit point: applications consume this complete file.
|
|
78
|
+
await rename(stagedCSS, cssFile); temporary.delete(stagedCSS);
|
|
79
|
+
return { recipe: next, revision: digest([nextJson, nextCSS]) };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (committedRecipe) {
|
|
82
|
+
if (original.json === null) await unlink(recipeFile);
|
|
83
|
+
else { const restore = await stage(recipeFile, original.json); temporary.add(restore); await rename(restore, recipeFile); temporary.delete(restore); }
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
} finally {
|
|
87
|
+
try { await Promise.all([...temporary].map(file => unlink(file).catch(error => { if (!missing(error)) throw error; }))); }
|
|
88
|
+
finally { try { await lock.close(); } finally { await unlink(lockFile); } }
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return { root, recipeFile, cssFile, read, save };
|
|
92
|
+
}
|