frappe-ui 0.1.113 → 0.1.115

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "0.1.113",
3
+ "version": "0.1.115",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -17,7 +17,7 @@
17
17
  "files": [
18
18
  "src",
19
19
  "scripts",
20
- "vite.js"
20
+ "vite"
21
21
  ],
22
22
  "repository": {
23
23
  "type": "git",
@@ -53,13 +53,17 @@
53
53
  "feather-icons": "^4.28.0",
54
54
  "idb-keyval": "^6.2.0",
55
55
  "lowlight": "^3.3.0",
56
+ "lucide-static": "^0.479.0",
56
57
  "ora": "5.4.1",
57
58
  "prettier": "^3.3.2",
58
59
  "radix-vue": "^1.5.3",
60
+ "reka-ui": "^2.0.2",
59
61
  "showdown": "^2.1.0",
60
62
  "socket.io-client": "^4.5.1",
61
63
  "tippy.js": "^6.3.7",
62
- "typescript": "^5.0.2"
64
+ "typescript": "^5.0.2",
65
+ "unplugin-icons": "^22.1.0",
66
+ "unplugin-vue-components": "^28.4.1"
63
67
  },
64
68
  "peerDependencies": {
65
69
  "vue": ">=3.5.0",
@@ -5,10 +5,28 @@
5
5
  nullable
6
6
  v-slot="{ open: isComboboxOpen }"
7
7
  >
8
- <Popover class="w-full" v-model:show="showOptions" ref="rootRef">
9
- <template #target="{ open: openPopover, togglePopover }">
10
- <slot name="target" v-bind="{ open: openPopover, togglePopover }">
11
- <div class="w-full">
8
+ <Popover
9
+ class="w-full"
10
+ v-model:show="showOptions"
11
+ ref="rootRef"
12
+ :placement="placement"
13
+ >
14
+ <template
15
+ #target="{ open: openPopover, togglePopover, close: closePopover }"
16
+ >
17
+ <slot
18
+ name="target"
19
+ v-bind="{
20
+ open: openPopover,
21
+ close: closePopover,
22
+ togglePopover,
23
+ isOpen: isComboboxOpen,
24
+ }"
25
+ >
26
+ <div class="w-full space-y-1.5">
27
+ <label v-if="props.label" class="block text-xs text-ink-gray-5">
28
+ {{ props.label }}
29
+ </label>
12
30
  <button
13
31
  class="flex h-7 w-full items-center justify-between gap-2 rounded bg-surface-gray-2 px-2 py-1 transition-colors hover:bg-surface-gray-3 border border-transparent focus:border-outline-gray-4 focus:outline-none focus:ring-2 focus:ring-outline-gray-3"
14
32
  :class="{ 'bg-surface-gray-3': isComboboxOpen }"
@@ -25,6 +43,7 @@
25
43
  <span class="text-base leading-5 text-ink-gray-4" v-else>
26
44
  {{ placeholder || '' }}
27
45
  </span>
46
+ <slot name="suffix" />
28
47
  </div>
29
48
  <FeatherIcon
30
49
  name="chevron-down"
@@ -60,12 +79,17 @@
60
79
  autocomplete="off"
61
80
  placeholder="Search"
62
81
  />
63
- <button
82
+ <div
64
83
  class="absolute right-0 inline-flex h-7 w-7 items-center justify-center"
65
- @click="clearAll"
66
84
  >
67
- <FeatherIcon name="x" class="w-4 text-ink-gray-8" />
68
- </button>
85
+ <LoadingIndicator
86
+ v-if="!props.loading"
87
+ class="h-4 w-4 text-ink-gray-5"
88
+ />
89
+ <button v-else @click="clearAll">
90
+ <FeatherIcon name="x" class="w-4 text-ink-gray-8" />
91
+ </button>
92
+ </div>
69
93
  </div>
70
94
  </div>
71
95
  <div
@@ -84,17 +108,21 @@
84
108
  v-for="(option, idx) in group.items.slice(0, 50)"
85
109
  :key="idx"
86
110
  :value="option"
111
+ :disabled="option.disabled"
87
112
  v-slot="{ active, selected }"
88
113
  >
89
114
  <li
90
115
  :class="[
91
116
  'flex cursor-pointer items-center justify-between rounded px-2.5 py-1.5 text-base',
92
- { 'bg-surface-gray-3': active },
117
+ {
118
+ 'bg-surface-gray-3': active,
119
+ 'opacity-50': option.disabled,
120
+ },
93
121
  ]"
94
122
  >
95
123
  <div class="flex flex-1 gap-2 overflow-hidden items-center">
96
124
  <div
97
- v-if="$slots['item-prefix'] || $props.multiple"
125
+ v-if="$slots['item-prefix'] || props.multiple"
98
126
  class="flex flex-shrink-0"
99
127
  >
100
128
  <slot
@@ -141,7 +169,10 @@
141
169
  </li>
142
170
  </ComboboxOptions>
143
171
 
144
- <div v-if="$slots.footer || multiple" class="border-t p-1">
172
+ <div
173
+ v-if="$slots.footer || props.showFooter || multiple"
174
+ class="border-t p-1"
175
+ >
145
176
  <slot name="footer" v-bind="{ togglePopover }">
146
177
  <div v-if="multiple" class="flex items-center justify-end">
147
178
  <Button
@@ -153,8 +184,12 @@
153
184
  v-if="areAllOptionsSelected"
154
185
  label="Clear All"
155
186
  @click.stop="clearAll"
156
- /></div
157
- ></slot>
187
+ />
188
+ </div>
189
+ <div v-else class="flex items-center justify-end">
190
+ <Button label="Clear" @click.stop="clearAll" />
191
+ </div>
192
+ </slot>
158
193
  </div>
159
194
  </div>
160
195
  </div>
@@ -174,6 +209,7 @@ import { computed, nextTick, ref, watch } from 'vue'
174
209
  import Popover from './Popover.vue'
175
210
  import { Button } from './Button'
176
211
  import FeatherIcon from './FeatherIcon.vue'
212
+ import LoadingIndicator from './LoadingIndicator.vue'
177
213
 
178
214
  type Option = {
179
215
  label: string
@@ -195,10 +231,14 @@ type AutocompleteOptionGroup = {
195
231
  type AutocompleteOptions = AutocompleteOption[] | AutocompleteOptionGroup[]
196
232
 
197
233
  type AutocompleteProps = {
234
+ label?: string
198
235
  options: AutocompleteOptions
199
236
  hideSearch?: boolean
200
237
  placeholder?: string
201
238
  bodyClasses?: string | string[]
239
+ loading?: boolean
240
+ placement?: string
241
+ showFooter?: boolean
202
242
  } & (
203
243
  | {
204
244
  multiple: true
@@ -278,13 +318,19 @@ const filterOptions = (options: Option[]) => {
278
318
  const selectedValue = computed({
279
319
  get() {
280
320
  if (!props.multiple) {
281
- return findOption(props.modelValue as AutocompleteOption)
321
+ return (
322
+ findOption(props.modelValue as AutocompleteOption) ||
323
+ // if the modelValue is not found in the option list
324
+ // return the modelValue as is
325
+ makeOption(props.modelValue as AutocompleteOption)
326
+ )
282
327
  }
283
328
  // in case of `multiple`, modelValue is an array of values
284
329
  // if the modelValue is a list of values, convert them to options
285
- let values = props.modelValue as AutocompleteOption[]
286
- if (!values) return []
287
- return isOption(values[0]) ? values : values.map((v) => findOption(v))
330
+ const values = (props.modelValue || []) as AutocompleteOption[]
331
+ return isOption(values[0])
332
+ ? values
333
+ : values.map((v) => findOption(v) || makeOption(v))
288
334
  },
289
335
  set(val) {
290
336
  query.value = ''
@@ -303,6 +349,10 @@ const findOption = (option: AutocompleteOption) => {
303
349
  return allOptions.value.find((o) => o.value === value)
304
350
  }
305
351
 
352
+ const makeOption = (option: AutocompleteOption) => {
353
+ return isOption(option) ? option : { label: option, value: option }
354
+ }
355
+
306
356
  const getLabel = (option: AutocompleteOption) => {
307
357
  if (isOption(option)) {
308
358
  return option?.label || option?.value || 'No label'
@@ -12,7 +12,7 @@
12
12
  >
13
13
  <FeatherIcon
14
14
  name="star"
15
- class="fill-gray-400 text-ink-gray-1 stroke-1 mr-1"
15
+ class="fill-gray-300 text-transparent mr-0.5"
16
16
  :class="iconClasses(index)"
17
17
  @click="markRating(index)"
18
18
  />
@@ -55,9 +55,9 @@ const iconClasses = (index: number) => {
55
55
  ]
56
56
 
57
57
  if (index <= hoveredRating.value && index > rating.value) {
58
- classes.push('fill-yellow-200')
58
+ classes.push('!fill-yellow-200')
59
59
  } else if (index <= rating.value) {
60
- classes.push('fill-yellow-500')
60
+ classes.push('!fill-yellow-500')
61
61
  }
62
62
 
63
63
  if (!props.readonly) {
@@ -2,7 +2,7 @@
2
2
  <node-view-wrapper>
3
3
  <div class="code-block-container">
4
4
  <select
5
- class="language-selector form-select"
5
+ class="language-selector form-select py-0"
6
6
  contenteditable="false"
7
7
  v-model="selectedLanguage"
8
8
  >
@@ -8,7 +8,7 @@ import {
8
8
  TooltipContent,
9
9
  TooltipArrow,
10
10
  type TooltipContentProps,
11
- } from 'radix-vue'
11
+ } from 'reka-ui'
12
12
  import { computed } from 'vue'
13
13
 
14
14
  defineOptions({
package/vite/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # Frappe UI Vite Plugins
2
+
3
+ A collection of Vite plugins for Frappe applications that handle common
4
+ development tasks when building modern frontends for Frappe.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ npm install frappe-ui
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ In your `vite.config.js` file:
15
+
16
+ ```javascript
17
+ import { defineConfig } from 'vite'
18
+ import vue from '@vitejs/plugin-vue'
19
+ import path from 'path'
20
+ import frappeui from 'frappe-ui/vite'
21
+
22
+ export default defineConfig({
23
+ plugins: [
24
+ frappeui({
25
+ frappeProxy: true, // Setup proxy to Frappe backend
26
+ lucideIcons: true, // Configure Lucide icons
27
+ jinjaBootData: true, // Inject server-side boot data
28
+ // Generate TypeScript interfaces from DocTypes
29
+ frappeTypes: {
30
+ input: {
31
+ app_name: ['doctype_1', 'doctype_2'],
32
+ },
33
+ },
34
+ // Production build config for asset paths and HTML output
35
+ buildConfig: {
36
+ indexHtmlPath: '../your_app/www/frontend.html',
37
+ },
38
+ }),
39
+ vue(),
40
+ ],
41
+ })
42
+ ```
43
+
44
+ ## Plugins
45
+
46
+ ### Frappe Proxy Plugin
47
+
48
+ Automatically configures a development server that proxies requests to your
49
+ Frappe backend.
50
+
51
+ #### Features
52
+
53
+ - Sets up a proxy for backend routes (`/api`, `/app`, etc.)
54
+ - Automatically detects Frappe port from `common_site_config.json`
55
+
56
+ #### Configuration
57
+
58
+ ```javascript
59
+ frappeui({
60
+ frappeProxy: {
61
+ port: 8080, // Frontend dev server port
62
+ source: '^/(app|login|api|assets|files|private)', // Routes to proxy
63
+ },
64
+ })
65
+ ```
66
+
67
+ ### Lucide Icons Plugin
68
+
69
+ Integrates [Lucide icons](https://lucide.dev/) with your app, providing access
70
+ to all Lucide icons with some customization.
71
+
72
+ #### Features
73
+
74
+ - Automatically registers all Lucide icons for use in Vue components
75
+ - Configures icons with standardized stroke-width 1.5 according to our design
76
+ system
77
+ - Support auto-import
78
+
79
+ #### Implicit import
80
+
81
+ ```vue
82
+ <template>
83
+ <LucideArrowRight class="size-4" />
84
+ </template>
85
+ ```
86
+
87
+ #### Explicit import
88
+
89
+ ```vue
90
+ <template>
91
+ <LucideArrowRight class="size-4" />
92
+ <LucideBarChart class="size-4" />
93
+ </template>
94
+ <script setup lang="ts">
95
+ import LucideArrowRight from '~icons/lucide/arrow-right'
96
+ import LucideBarChart from '~icons/lucide/bar-chart'
97
+ </script>
98
+ ```
99
+
100
+ ### Frappe Types Plugin
101
+
102
+ Generates TypeScript interfaces for your Frappe DocTypes, providing type safety
103
+ when working with Frappe data.
104
+
105
+ #### Features
106
+
107
+ - Automatically generates TypeScript interfaces from DocType JSON files
108
+ - Creates and updates interfaces only when DocTypes change
109
+
110
+ #### Configuration
111
+
112
+ ```javascript
113
+ frappeui({
114
+ frappeTypes: {
115
+ input: {
116
+ your_app_name: ['doctype1', 'doctype2'],
117
+ },
118
+ output: 'src/types/doctypes.ts', // (optional)
119
+ },
120
+ })
121
+ ```
122
+
123
+ ### Jinja Boot Data Plugin
124
+
125
+ Injects jinja block that reads keys from `boot` context object and sets in
126
+ `window`. Useful to set global values like `csrf_token`, `site_name`, etc.
127
+
128
+ #### Configuration
129
+
130
+ ```javascript
131
+ frappeui({
132
+ jinjaBootData: true,
133
+ })
134
+ ```
135
+
136
+ #### Usage
137
+
138
+ In your Python/Jinja template:
139
+
140
+ ```python
141
+
142
+ def get_context(context):
143
+ context.boot = {
144
+ "csrf_token": "...",
145
+ "user": frappe.session.user,
146
+ "user_info": frappe.session.user_info,
147
+ }
148
+ return context
149
+ ```
150
+
151
+ In your JavaScript code:
152
+
153
+ ```javascript
154
+ // Access injected data
155
+ console.log(window.user)
156
+ console.log(window.user_info)
157
+ ```
158
+
159
+ ### Build Configuration Plugin
160
+
161
+ Handles production builds with proper asset paths and HTML output.
162
+
163
+ #### Features
164
+
165
+ - Configures output directories for build assets
166
+ - Sets up correct asset paths for Frappe's standard directory structure
167
+ - Copies the built index.html to a specified location (typically in www)
168
+
169
+ #### Configuration
170
+
171
+ ```javascript
172
+ frappeui({
173
+ buildConfig: {
174
+ // default: '../app_name/public/frontend', auto-detected if not specified
175
+ outDir: '../app_name/public/frontend',
176
+ // Base URL for assets (auto-detected from outDir if not specified)
177
+ baseUrl: '/assets/app_name/frontend/',
178
+ // required, typically "../app_name/www/app_name.html"
179
+ indexHtmlPath: '../app_name/www/app_name.html',
180
+ emptyOutDir: true, // Clear output directory before build
181
+ sourcemap: true, // Generate sourcemaps
182
+ },
183
+ })
184
+ ```
@@ -0,0 +1,169 @@
1
+ const path = require('path')
2
+ const fs = require('fs')
3
+
4
+ function buildConfig(options = {}) {
5
+ let outDir = options.outDir || findOutputDir()
6
+ if (!outDir) {
7
+ console.error(
8
+ '[frappeui-build-config-plugin] Could not find build output directory automatically. Please specify it manually.',
9
+ )
10
+ return
11
+ }
12
+
13
+ const defaultOptions = {
14
+ outDir,
15
+ emptyOutDir: true,
16
+ sourcemap: true,
17
+ indexHtmlPath: null,
18
+ baseUrl: options.baseUrl || getBaseUrl(outDir),
19
+ }
20
+
21
+ const mergedOptions = { ...defaultOptions, ...options }
22
+
23
+ return {
24
+ name: 'frappeui-build-config-plugin',
25
+ config(config, { command, mode }) {
26
+ const buildConfig = {
27
+ build: {
28
+ outDir: mergedOptions.outDir,
29
+ emptyOutDir: mergedOptions.emptyOutDir,
30
+ commonjsOptions: {
31
+ include: [/tailwind.config.js/, /node_modules/],
32
+ },
33
+ sourcemap: mergedOptions.sourcemap,
34
+ },
35
+ }
36
+
37
+ if (mode === 'production') {
38
+ // Apply baseUrl only in production mode
39
+ buildConfig.base = mergedOptions.baseUrl
40
+
41
+ if (!mergedOptions.indexHtmlPath) {
42
+ throw new Error(
43
+ '[frappeui-build-config-plugin] indexHtmlPath is required in buildConfig options',
44
+ )
45
+ }
46
+ }
47
+
48
+ return buildConfig
49
+ },
50
+ writeBundle(options, bundle) {
51
+ if (mergedOptions.indexHtmlPath) {
52
+ try {
53
+ const sourceHtml = path.join(mergedOptions.outDir, 'index.html')
54
+ if (fs.existsSync(sourceHtml)) {
55
+ // Ensure destination directory exists
56
+ const destDir = path.dirname(mergedOptions.indexHtmlPath)
57
+ if (!fs.existsSync(destDir)) {
58
+ fs.mkdirSync(destDir, { recursive: true })
59
+ }
60
+
61
+ fs.copyFileSync(sourceHtml, mergedOptions.indexHtmlPath)
62
+ console.log(
63
+ `[frappeui-build-config-plugin] Successfully copied index.html to ${mergedOptions.indexHtmlPath}`,
64
+ )
65
+ } else {
66
+ console.error(
67
+ `[frappeui-build-config-plugin] Source index.html not found at ${sourceHtml}`,
68
+ )
69
+ }
70
+ } catch (error) {
71
+ console.error(
72
+ '[frappeui-build-config-plugin] Error copying index.html:',
73
+ error,
74
+ )
75
+ }
76
+ }
77
+ },
78
+ }
79
+ }
80
+
81
+ function findOutputDir() {
82
+ let appDir = findAppDir()
83
+ if (appDir) {
84
+ return path.join(appDir, 'public', 'frontend')
85
+ }
86
+ return null
87
+ }
88
+
89
+ function getBaseUrl(outputDir) {
90
+ try {
91
+ // Parse the output directory path to extract app name
92
+ // Expected pattern: /path/to/apps/app_name/app_name/public/frontend
93
+ if (!outputDir) return '/'
94
+
95
+ const parts = outputDir.split(path.sep)
96
+ const publicIndex = parts.indexOf('public')
97
+
98
+ if (publicIndex > 0) {
99
+ const appName = parts[publicIndex - 1] // Get the app name from path
100
+
101
+ // Check if the app name appears twice in sequence (common in Frappe apps)
102
+ const appsIndex = parts.indexOf('apps')
103
+ if (appsIndex >= 0 && publicIndex > appsIndex + 2) {
104
+ // If in standard Frappe app structure, use the app name from apps/app_name path
105
+ const possibleAppName = parts[appsIndex + 1]
106
+ if (possibleAppName === appName) {
107
+ // Determine the part after public (typically 'frontend')
108
+ const subdir = parts[publicIndex + 1] || ''
109
+ return `/assets/${appName}/${subdir}/`
110
+ }
111
+ }
112
+
113
+ // Fallback: just use the detected app name with standard structure
114
+ const subdir = parts[publicIndex + 1] || ''
115
+ return `/assets/${appName}/${subdir}/`
116
+ }
117
+ // fallback
118
+ return '/'
119
+ } catch (error) {
120
+ console.error(
121
+ '[frappeui-build-config-plugin] Error calculating base URL:',
122
+ error,
123
+ )
124
+ // fallback on error
125
+ return '/'
126
+ }
127
+ }
128
+
129
+ function findAppDir() {
130
+ // currentDir is the vue app directory
131
+ let currentDir = process.cwd()
132
+ // appDir is the frappe app directory
133
+ let appDir = path.resolve(currentDir, '..')
134
+
135
+ try {
136
+ // Read directories in the app directory
137
+ const directories = fs
138
+ .readdirSync(appDir)
139
+ .filter((item) => fs.statSync(path.join(appDir, item)).isDirectory())
140
+
141
+ // Find the first directory that contains folders typical of a Frappe app
142
+ for (const dir of directories) {
143
+ const dirPath = path.join(appDir, dir)
144
+ try {
145
+ const contents = fs.readdirSync(dirPath)
146
+ if (
147
+ contents.includes('public') &&
148
+ contents.includes('patches') &&
149
+ contents.includes('www') &&
150
+ contents.includes('hooks.py')
151
+ ) {
152
+ return dirPath
153
+ }
154
+ } catch (error) {
155
+ // Skip directories that can't be read
156
+ continue
157
+ }
158
+ }
159
+ } catch (error) {
160
+ console.error(
161
+ '[frappeui-build-config-plugin] Error finding app directory:',
162
+ error,
163
+ )
164
+ }
165
+
166
+ return null
167
+ }
168
+
169
+ module.exports = { buildConfig }
@@ -1,8 +1,7 @@
1
1
  const fs = require('fs').promises
2
2
  const path = require('path')
3
- const ora = require('ora')
4
3
 
5
- module.exports = class DocTypeInterfaceGenerator {
4
+ class DocTypeInterfaceGenerator {
6
5
  constructor(appsPath, appDoctypeMap, outputPath) {
7
6
  this.appsPath = appsPath
8
7
  this.appDoctypeMap = appDoctypeMap
@@ -10,8 +9,14 @@ module.exports = class DocTypeInterfaceGenerator {
10
9
  this.processedDoctypes = new Set()
11
10
  this.existingInterfaces = {}
12
11
  this.updatedInterfaces = 0
13
- this.spinner = ora('Generating doctype interfaces...').start()
14
12
  this.jsonFileCache = new Map()
13
+ this.summary = {
14
+ processed: 0,
15
+ updated: 0,
16
+ skipped: 0,
17
+ notFound: 0,
18
+ details: [],
19
+ }
15
20
  }
16
21
 
17
22
  async generate() {
@@ -25,6 +30,8 @@ module.exports = class DocTypeInterfaceGenerator {
25
30
  }
26
31
  await Promise.all(promises)
27
32
 
33
+ this.printSummary()
34
+
28
35
  if (this.updatedInterfaces > 0) {
29
36
  const baseInterfaces = this.generateBaseInterfaces()
30
37
  const interfacesString = [
@@ -34,11 +41,24 @@ module.exports = class DocTypeInterfaceGenerator {
34
41
 
35
42
  await fs.mkdir(path.dirname(this.outputPath), { recursive: true })
36
43
  await fs.writeFile(this.outputPath, interfacesString)
37
- this.spinner.succeed(
38
- `Updated ${this.updatedInterfaces} interface${this.updatedInterfaces === 1 ? '' : 's'}. Output file updated.`,
44
+ }
45
+ }
46
+
47
+ printSummary() {
48
+ console.log('\nFrappe Type Generation Summary:')
49
+ console.log(`- Total processed: ${this.summary.processed} doctypes`)
50
+ console.log(`- Updated: ${this.summary.updated} interfaces`)
51
+ console.log(`- Skipped: ${this.summary.skipped} (no changes)`)
52
+ if (this.summary.notFound > 0) {
53
+ console.log(`- Not found: ${this.summary.notFound}`)
54
+ }
55
+
56
+ if (this.updatedInterfaces > 0) {
57
+ console.log(
58
+ `\nOutput file updated with ${this.updatedInterfaces} interface${this.updatedInterfaces === 1 ? '' : 's'}.`,
39
59
  )
40
60
  } else {
41
- this.spinner.info('No new schema changes.')
61
+ console.log('\nNo new schema changes.')
42
62
  }
43
63
  }
44
64
 
@@ -69,10 +89,12 @@ module.exports = class DocTypeInterfaceGenerator {
69
89
  return
70
90
  }
71
91
  this.processedDoctypes.add(doctypeName)
92
+ this.summary.processed++
72
93
 
73
94
  const jsonFilePath = await this.findJsonFile(appName, doctypeName)
74
95
  if (!jsonFilePath) {
75
- this.spinner.text = `Processing: ${doctypeName} [not found]`
96
+ this.summary.notFound++
97
+ this.summary.details.push(`${doctypeName}: not found`)
76
98
  return
77
99
  }
78
100
  const jsonData = JSON.parse(await fs.readFile(jsonFilePath, 'utf8'))
@@ -84,7 +106,8 @@ module.exports = class DocTypeInterfaceGenerator {
84
106
  existingInterface &&
85
107
  existingInterface.includes(`// Last updated: ${lastModified}`)
86
108
  ) {
87
- this.spinner.text = `Processing: ${doctypeName} [skipped]`
109
+ this.summary.skipped++
110
+ this.summary.details.push(`${doctypeName}: skipped (no changes)`)
88
111
  return
89
112
  }
90
113
 
@@ -166,7 +189,8 @@ module.exports = class DocTypeInterfaceGenerator {
166
189
  interfaceString += `}\n`
167
190
  this.updatedInterfaces++
168
191
  this.existingInterfaces[interfaceName] = interfaceString
169
- this.spinner.text = `Processing: ${doctypeName} [updated]`
192
+ this.summary.updated++
193
+ this.summary.details.push(`${doctypeName}: updated`)
170
194
  }
171
195
 
172
196
  async findJsonFile(appName, doctypeName) {
@@ -204,19 +228,21 @@ module.exports = class DocTypeInterfaceGenerator {
204
228
 
205
229
  generateBaseInterfaces() {
206
230
  return `interface DocType {
207
- name: string;
208
- creation: string;
209
- modified: string;
210
- owner: string;
211
- modified_by: string;
212
- }
231
+ name: string;
232
+ creation: string;
233
+ modified: string;
234
+ owner: string;
235
+ modified_by: string;
236
+ }
213
237
 
214
- interface ChildDocType extends DocType {
215
- parent?: string;
216
- parentfield?: string;
217
- parenttype?: string;
218
- idx?: number;
219
- }
220
- `
238
+ interface ChildDocType extends DocType {
239
+ parent?: string;
240
+ parentfield?: string;
241
+ parenttype?: string;
242
+ idx?: number;
243
+ }
244
+ `
221
245
  }
222
246
  }
247
+
248
+ exports.DocTypeInterfaceGenerator = DocTypeInterfaceGenerator
@@ -0,0 +1,36 @@
1
+ const { getCommonSiteConfig } = require('./utils')
2
+
3
+ function frappeProxy({
4
+ port = 8080,
5
+ source = '^/(app|login|api|assets|files|private)',
6
+ } = {}) {
7
+ const commonSiteConfig = getCommonSiteConfig()
8
+ const webserver_port = commonSiteConfig
9
+ ? commonSiteConfig.webserver_port
10
+ : 8000
11
+ if (!commonSiteConfig) {
12
+ console.log('No common_site_config.json found, using default port 8000')
13
+ }
14
+
15
+ let proxy = {}
16
+ proxy[source] = {
17
+ target: `http://127.0.0.1:${webserver_port}`,
18
+ ws: true,
19
+ router: function (req) {
20
+ const site_name = req.headers.host.split(':')[0]
21
+ return `http://${site_name}:${webserver_port}`
22
+ },
23
+ }
24
+
25
+ return {
26
+ name: 'frappeui-proxy-plugin',
27
+ config: () => ({
28
+ server: {
29
+ port: port,
30
+ proxy: proxy,
31
+ },
32
+ }),
33
+ }
34
+ }
35
+
36
+ exports.frappeProxy = frappeProxy
@@ -0,0 +1,67 @@
1
+ const path = require('path')
2
+ const { spawn } = require('child_process')
3
+
4
+ function frappeTypes(options = {}) {
5
+ let childProcess = null
6
+
7
+ return {
8
+ name: 'frappeui-types-plugin',
9
+ config: (config, { command, mode }) => {
10
+ if (mode === 'development') {
11
+ // Run the type generation in a separate process
12
+ const scriptPath = path.join(__dirname, 'generateTypes.js')
13
+
14
+ // Serialize options as a JSON string to pass to the child process
15
+ const optionsArg = JSON.stringify(options)
16
+
17
+ childProcess = spawn('node', [scriptPath, optionsArg], {
18
+ stdio: ['ignore', 'pipe', 'pipe'], // Ignore stdin, pipe stdout/stderr
19
+ detached: false,
20
+ })
21
+
22
+ // Pipe stdout and stderr
23
+ childProcess.stdout.pipe(process.stdout)
24
+ childProcess.stderr.pipe(process.stderr)
25
+
26
+ // Handle child process errors
27
+ childProcess.on('error', (err) => {
28
+ console.error('Error in type generation process:', err)
29
+ })
30
+
31
+ const cleanup = () => {
32
+ if (childProcess && !childProcess.killed) {
33
+ try {
34
+ // Send SIGTERM signal
35
+ childProcess.kill('SIGTERM')
36
+
37
+ // Force kill if needed after a timeout
38
+ setTimeout(() => {
39
+ if (childProcess && !childProcess.killed) {
40
+ childProcess.kill('SIGKILL')
41
+ }
42
+ }, 500)
43
+ } catch (e) {
44
+ // Ignore errors during cleanup
45
+ }
46
+ }
47
+ }
48
+
49
+ // Register cleanup on exit signals and process exit
50
+ ;['SIGINT', 'SIGTERM', 'exit'].forEach((signal) => {
51
+ process.once(signal, cleanup)
52
+ })
53
+
54
+ // Handle child process exit
55
+ childProcess.on('exit', (code, signal) => {
56
+ childProcess = null
57
+ if (code !== 0 && !signal) {
58
+ console.log(`Type generation process exited with code ${code}`)
59
+ }
60
+ })
61
+ }
62
+ return {}
63
+ },
64
+ }
65
+ }
66
+
67
+ exports.frappeTypes = frappeTypes
@@ -0,0 +1,59 @@
1
+ const path = require('path')
2
+ const { findAppsFolder } = require('./utils')
3
+ const { DocTypeInterfaceGenerator } = require('./doctypeInterfaceGenerator')
4
+
5
+ // Handle termination signals to exit cleanly
6
+ process.on('SIGINT', () => process.exit(0))
7
+ process.on('SIGTERM', () => process.exit(0))
8
+
9
+ async function main() {
10
+ try {
11
+ // Parse options passed from the plugin
12
+ let options = {}
13
+ if (process.argv[2]) {
14
+ try {
15
+ options = JSON.parse(process.argv[2])
16
+ } catch (err) {
17
+ console.error('Error parsing plugin options:', err)
18
+ }
19
+ }
20
+
21
+ // Use only the plugin options for configuration
22
+ if (!(options && options.input)) {
23
+ console.log('No type generation input specified in options')
24
+ return
25
+ }
26
+
27
+ const frontendFolder = process.cwd()
28
+ let outputPath = options.output || 'src/types/doctypes.ts'
29
+ if (!path.isAbsolute(outputPath)) {
30
+ outputPath = path.join(frontendFolder, outputPath)
31
+ }
32
+
33
+ const appsFolder = findAppsFolder()
34
+ if (!appsFolder) {
35
+ console.error('Could not find frappe-bench/apps folder')
36
+ return
37
+ }
38
+
39
+ const generator = new DocTypeInterfaceGenerator(
40
+ appsFolder,
41
+ options.input,
42
+ outputPath,
43
+ )
44
+ await generator.generate()
45
+ } catch (error) {
46
+ console.error('Error generating DocType interfaces:', error)
47
+ process.exit(1)
48
+ }
49
+ }
50
+
51
+ // Execute if run directly
52
+ if (require.main === module) {
53
+ main()
54
+ .then(() => process.exit(0))
55
+ .catch((err) => {
56
+ console.error(err)
57
+ process.exit(1)
58
+ })
59
+ }
package/vite/index.js ADDED
@@ -0,0 +1,35 @@
1
+ const { lucideIcons } = require('./lucideIcons')
2
+ const { frappeProxy } = require('./frappeProxy')
3
+ const { frappeTypes } = require('./frappeTypes')
4
+ const { jinjaBootData } = require('./jinjaBootData')
5
+ const { buildConfig } = require('./buildConfig')
6
+
7
+ function frappeuiPlugin(
8
+ options = {
9
+ lucideIcons: true,
10
+ frappeProxy: true,
11
+ frappeTypes: true,
12
+ jinjaBootData: true,
13
+ buildConfig: true,
14
+ },
15
+ ) {
16
+ let plugins = []
17
+ if (options.lucideIcons) {
18
+ plugins.push(lucideIcons(options.lucideIcons))
19
+ }
20
+ if (options.frappeProxy) {
21
+ plugins.push(frappeProxy(options.frappeProxy))
22
+ }
23
+ if (options.frappeTypes) {
24
+ plugins.push(frappeTypes(options.frappeTypes))
25
+ }
26
+ if (options.jinjaBootData) {
27
+ plugins.push(jinjaBootData(options.jinjaBootData))
28
+ }
29
+ if (options.buildConfig) {
30
+ plugins.push(buildConfig(options.buildConfig))
31
+ }
32
+ return plugins
33
+ }
34
+
35
+ module.exports = frappeuiPlugin
@@ -0,0 +1,25 @@
1
+ function jinjaBootData() {
2
+ return {
3
+ name: 'frappeui-jinja-boot-data-plugin',
4
+ transformIndexHtml(html, context) {
5
+ if (!context.server) {
6
+ // context.server is true in dev mode
7
+ // only inject this in production build
8
+ return html.replace(
9
+ /<\/body>/,
10
+ `
11
+ <script>
12
+ {% for key in boot %}
13
+ window["{{ key }}"] = {{ boot[key] | tojson }};
14
+ {% endfor %}
15
+ </script>
16
+ </body>
17
+ `,
18
+ )
19
+ }
20
+ return html
21
+ },
22
+ }
23
+ }
24
+
25
+ module.exports = { jinjaBootData }
@@ -0,0 +1,67 @@
1
+ const LucideIcons = require('lucide-static')
2
+ const Icons = require('unplugin-icons/vite')
3
+ const Components = require('unplugin-vue-components/vite')
4
+ const IconsResolver = require('unplugin-icons/resolver')
5
+
6
+ function lucideIconsPlugin() {
7
+ return [
8
+ Components.default({
9
+ resolvers: [
10
+ IconsResolver.default({
11
+ prefix: false,
12
+ enabledCollections: ['lucide'],
13
+ }),
14
+ ],
15
+ }),
16
+ Icons.default({
17
+ customCollections: {
18
+ lucide: getIcons(),
19
+ },
20
+ }),
21
+ ]
22
+ }
23
+
24
+ function getIcons() {
25
+ let icons = {}
26
+ for (const icon in LucideIcons) {
27
+ if (icon === 'default') {
28
+ continue
29
+ }
30
+ let iconSvg = LucideIcons[icon]
31
+
32
+ // set stroke-width to 1.5
33
+ if (iconSvg && iconSvg.includes('stroke-width')) {
34
+ iconSvg = iconSvg.replace(/stroke-width="2"/g, 'stroke-width="1.5"')
35
+ }
36
+ icons[icon] = iconSvg
37
+
38
+ let dashKeys = camelToDash(icon)
39
+ for (let dashKey of dashKeys) {
40
+ if (dashKey !== icon) {
41
+ icons[dashKey] = iconSvg
42
+ }
43
+ }
44
+ }
45
+ return icons
46
+ }
47
+
48
+ function camelToDash(key) {
49
+ // barChart2 -> bar-chart-2
50
+ let withNumber = key.replace(/[A-Z0-9]/g, (m) => '-' + m.toLowerCase())
51
+ if (withNumber.startsWith('-')) {
52
+ withNumber = withNumber.substring(1)
53
+ }
54
+ // barChart2 -> bar-chart2
55
+ let withoutNumber = key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())
56
+ if (withoutNumber.startsWith('-')) {
57
+ withoutNumber = withoutNumber.substring(1)
58
+ }
59
+
60
+ if (withNumber !== withoutNumber) {
61
+ // both are required because unplugin icon resolver doesn't put a dash before numbers
62
+ return [withNumber, withoutNumber]
63
+ }
64
+ return [withNumber]
65
+ }
66
+
67
+ exports.lucideIcons = lucideIconsPlugin
package/vite/utils.js ADDED
@@ -0,0 +1,48 @@
1
+ const path = require('path')
2
+ const fs = require('fs')
3
+
4
+ function getConfig() {
5
+ let configPath = path.join(process.cwd(), 'frappeui.json')
6
+ if (fs.existsSync(configPath)) {
7
+ return JSON.parse(fs.readFileSync(configPath))
8
+ }
9
+ }
10
+
11
+ function getCommonSiteConfig() {
12
+ let currentDir = path.resolve('.')
13
+ // traverse up till we find frappe-bench with sites directory
14
+ while (currentDir !== '/') {
15
+ if (
16
+ fs.existsSync(path.join(currentDir, 'sites')) &&
17
+ fs.existsSync(path.join(currentDir, 'apps'))
18
+ ) {
19
+ let configPath = path.join(currentDir, 'sites', 'common_site_config.json')
20
+ if (fs.existsSync(configPath)) {
21
+ return JSON.parse(fs.readFileSync(configPath))
22
+ }
23
+ return null
24
+ }
25
+ currentDir = path.resolve(currentDir, '..')
26
+ }
27
+ return null
28
+ }
29
+
30
+ function findAppsFolder() {
31
+ let currentDir = process.cwd()
32
+ while (currentDir !== '/') {
33
+ if (
34
+ fs.existsSync(path.join(currentDir, 'apps')) &&
35
+ fs.existsSync(path.join(currentDir, 'sites'))
36
+ ) {
37
+ return path.join(currentDir, 'apps')
38
+ }
39
+ currentDir = path.resolve(currentDir, '..')
40
+ }
41
+ return null
42
+ }
43
+
44
+ module.exports = {
45
+ getConfig,
46
+ getCommonSiteConfig,
47
+ findAppsFolder,
48
+ }
package/vite.js DELETED
@@ -1,106 +0,0 @@
1
- const path = require('path')
2
- const fs = require('fs')
3
- const DocTypeInterfaceGenerator = require('./scripts/generateInterface')
4
-
5
- module.exports = function proxyOptions({
6
- port = 8080,
7
- source = '^/(app|login|api|assets|files|private)',
8
- enableDocTypeInterfaces = true,
9
- } = {}) {
10
- const commonSiteConfig = getCommonSiteConfig()
11
- const webserver_port = commonSiteConfig
12
- ? commonSiteConfig.webserver_port
13
- : 8000
14
- if (!commonSiteConfig) {
15
- console.log('No common_site_config.json found, using default port 8000')
16
- }
17
- let proxy = {}
18
- proxy[source] = {
19
- target: `http://127.0.0.1:${webserver_port}`,
20
- ws: true,
21
- router: function (req) {
22
- const site_name = req.headers.host.split(':')[0]
23
- return `http://${site_name}:${webserver_port}`
24
- },
25
- }
26
-
27
- return {
28
- name: 'frappeui-vite-plugin',
29
- config: async () => {
30
- if (enableDocTypeInterfaces) {
31
- await generateDocTypeInterfaces()
32
- }
33
-
34
- return {
35
- server: {
36
- port: port,
37
- proxy: proxy,
38
- },
39
- }
40
- },
41
- }
42
- }
43
-
44
- async function generateDocTypeInterfaces() {
45
- const config = getConfig()
46
- if (!(config && config.typeGeneration && config.typeGeneration.input)) return
47
-
48
- const frontendFolder = process.cwd()
49
- let outputPath = config.typeGeneration.output || 'src/types/doctypes.ts'
50
- if (!path.isAbsolute(outputPath)) {
51
- outputPath = path.join(frontendFolder, outputPath)
52
- }
53
-
54
- const appsFolder = findAppsFolder()
55
- if (!appsFolder) {
56
- console.error('Could not find frappe-bench/apps folder')
57
- return
58
- }
59
-
60
- const generator = new DocTypeInterfaceGenerator(
61
- appsFolder,
62
- config.typeGeneration.input,
63
- outputPath,
64
- )
65
- await generator.generate()
66
- }
67
-
68
- function getCommonSiteConfig() {
69
- let currentDir = path.resolve('.')
70
- // traverse up till we find frappe-bench with sites directory
71
- while (currentDir !== '/') {
72
- if (
73
- fs.existsSync(path.join(currentDir, 'sites')) &&
74
- fs.existsSync(path.join(currentDir, 'apps'))
75
- ) {
76
- let configPath = path.join(currentDir, 'sites', 'common_site_config.json')
77
- if (fs.existsSync(configPath)) {
78
- return JSON.parse(fs.readFileSync(configPath))
79
- }
80
- return null
81
- }
82
- currentDir = path.resolve(currentDir, '..')
83
- }
84
- return null
85
- }
86
-
87
- function findAppsFolder() {
88
- let currentDir = process.cwd()
89
- while (currentDir !== '/') {
90
- if (
91
- fs.existsSync(path.join(currentDir, 'apps')) &&
92
- fs.existsSync(path.join(currentDir, 'sites'))
93
- ) {
94
- return path.join(currentDir, 'apps')
95
- }
96
- currentDir = path.resolve(currentDir, '..')
97
- }
98
- return null
99
- }
100
-
101
- function getConfig() {
102
- let configPath = path.join(process.cwd(), 'frappeui.json')
103
- if (fs.existsSync(configPath)) {
104
- return JSON.parse(fs.readFileSync(configPath))
105
- }
106
- }