proscenium 0.1.0.alpha3-x86_64-linux → 0.1.0.alpha4-x86_64-linux

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16d95e62d81df0d7cdef528f9ef167a457b08d47091fa24baecd0543794cb9df
4
- data.tar.gz: 0d3e128c70dd071fd8680bbfe76e70eb810a65a99d7541f9d2f52cb35da0eeff
3
+ metadata.gz: 20618c6ca12b3a4cc686da231f64a86a413768f5898a0a7b256dc0950108460e
4
+ data.tar.gz: 628152d21501c20f926a1996502330f2ff234c9cd61d0a2e8c185d4ebea25868
5
5
  SHA512:
6
- metadata.gz: c81a0c1f16041ce97cb03a880ec1877faaada6ace634edf0840b8548136843cc91e09340c61b00416cf41230d7e641b2d446e51c884487d8ac4fc2dd944d1f0a
7
- data.tar.gz: f0e06b2652fe8403b966c896dc81183a4ce48dc3643559ff0b268f9dbb4a36d5ba8008c87a80eefeabafd6e6b690d8b22b0d874482b62493631213b83594acb2
6
+ metadata.gz: 46ec6c6dc8597d2ab301267709241d2f2df9f9cc48b0266ac4bf33cd6691d1439116561c854ac4251f9757daee33a183f18d8401ece0b008ce90b420530fde5d
7
+ data.tar.gz: 8c7b601a0b99b22f7f569ccc41da348e203983f71a6e873f9a38a2788796b018a30ad188cd12c4a728c97008f64e2c00207323772e2e4ea1a6a27bb3d8952c3b
data/README.md CHANGED
@@ -1,12 +1,29 @@
1
- # Proscenium
1
+ # Proscenium - Modern Client-Side Tooling for Rails
2
2
 
3
- - Serve assets from anywhere within your Rails root.
3
+ Proscenium treats your client-side code as first class citizens of your Rails app, and assumes a
4
+ "fast by default" internet. It compiles your JS, JSX and CSS in real time, and on demand, with no
5
+ configuration at all!
6
+
7
+ - Zero configuration.
8
+ - NO JavaScript rumtime needed - just the browser!
9
+ - Real-time compilation.
10
+ - No additional process or server - Just run Rails!
11
+ - Serve assets from anywhere within your Rails root (/app, /config, /lib).
4
12
  - Automatically side load JS/CSS for your layouts and views.
5
- - Import JS and CSS from node_modules, URL, local (relative, absolute)
6
- - Real-time bundling of JS, JSX and CSS.
7
- - Import Map
8
- - CSS Modules
9
- - Minification
13
+ - Import JS(X) and CSS from node_modules, URL, local (relative, absolute).
14
+ - Optional bundling of JS(X) and CSS.
15
+ - Import Map support for JS and CSS.
16
+ - CSS Modules.
17
+ - CSS Custom Media Queries.
18
+ - CSS mixins.
19
+ - Minification.
20
+ - Auto reload after changes (development only).
21
+
22
+ ## !! EXPERIMENTAL SOFTWARE !!
23
+
24
+ While my goal is to use Proscenium in production, I strongly recommended that you **DO NOT** use
25
+ this in production apps! Right now, this is a play thing, and should only be used for
26
+ development/testing.
10
27
 
11
28
  ## Installation
12
29
 
@@ -34,6 +51,7 @@ Using the examples above...
34
51
  - `app/views/users/index.js` => `https://yourapp.com/app/views/users/index.js`
35
52
  - `app/views/layouts/application.css` => `https://yourapp.com/app/views/layouts/application.css`
36
53
  - `lib/utils.js` => `https://yourapp.com/lib/utils.js`
54
+ - `config/properties.css` => `https://yourapp.com/config/properties.css`
37
55
 
38
56
  ## Importing
39
57
 
@@ -166,10 +184,28 @@ to `false`.
166
184
 
167
185
  ## CSS Modules
168
186
 
169
- Direct access to CSS files are parsed through @parcel/css.
187
+ Give any CSS file a `.module.css` extension, and Proscenium will load it as a CSS Module...
188
+
189
+ ```css
190
+ .header {
191
+ background-color: #00f;
192
+ }
193
+ ```
170
194
 
171
- Importing a CSS file from JS will append the CSS file to the document's head. The results of the
172
- import will be an object of CSS modules.
195
+ The above produces:
196
+
197
+ ```css
198
+ .header5564cdbb {
199
+ background-color: #00f;
200
+ }
201
+ ```
202
+
203
+ Importing a CSS file from JS will automatically append the stylesheet to the document's head. The
204
+ results of the import will be an object of CSS modules.
205
+
206
+ ```js
207
+ import styles from './styles.module.css'
208
+ ```
173
209
 
174
210
  ## Auto Reload
175
211
 
@@ -191,6 +227,25 @@ You can disable auto reload by setting the `config.proscenium.auto_reload` confi
191
227
 
192
228
  Proscenium supports [custom media queries](https://css-tricks.com/can-we-have-custom-media-queries-please/) as per the [spec](https://www.w3.org/TR/mediaqueries-5/#custom-mq). However, because of the way they are parsed, they cannot be imported using `@import`. So if you define your custom media queries in `/config/custom_media_queries.css`, Proscenium will automatically inject them into your CSS, so you can use them anywhere.
193
229
 
230
+ ## CSS Mixins
231
+
232
+ CSS mixins are supported using the `@mixin` at-rule. Simply define your mixins in any number of files ending in `.mixin.css`, and using the `@define-mixin` at-rule...
233
+
234
+ ```css
235
+ // /lib/text.mixin.css
236
+ @define-mixin bigText {
237
+ font-size: 50px;
238
+ }
239
+ ```
240
+
241
+ ```css
242
+ // /app/views/layouts/application.css
243
+ p {
244
+ @mixin bigText;
245
+ color: red;
246
+ }
247
+ ```
248
+
194
249
  ## How It Works
195
250
 
196
251
  Proscenium provides a Rails middleware that proxies requests for your frontend code. By default, it will simply search for a file of the same name in your Rails root. For example, a request for '/app/views/layouts/application.js' or '/lib/hooks.js' will return that exact file relative to your Rails root.
@@ -1,14 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ReactComponent < ApplicationComponent
4
- attr_accessor :props
4
+ attr_accessor :props, :lazy
5
5
 
6
- def initialize(props = {})
6
+ # @param props: [Hash]
7
+ # @param lazy: [Boolean] Lazy load the component using IntersectionObserver. Default: true.
8
+ def initialize(props: {}, lazy: true)
7
9
  @props = props
10
+ @lazy = lazy
11
+
8
12
  super
9
13
  end
10
14
 
11
15
  def call
12
- tag.div data: { component: { path: virtual_path, props: @props } }
16
+ tag.div class: ['componentManagedByProscenium', css_module(:component)],
17
+ data: { component: { path: virtual_path, props: props, lazy: lazy } } do
18
+ tag.div content
19
+ end
13
20
  end
14
21
  end
data/bin/esbuild CHANGED
Binary file
data/bin/lightningcss ADDED
Binary file
@@ -74,11 +74,13 @@ export default function () {
74
74
  padding: 10px 0 0 20px;
75
75
  }
76
76
  .lineText {
77
+ display: block;
77
78
  white-space: pre-wrap;
78
79
  }
79
80
  .lineCursor {
80
81
  white-space: pre;
81
82
  color: blueviolet;
83
+ display: block;
82
84
  }
83
85
  </style>
84
86
  <div class="window">
@@ -0,0 +1,67 @@
1
+ import { expandGlob } from 'std/fs/mod.ts'
2
+ import postcss, { CssSyntaxError } from 'postcss'
3
+
4
+ export default async (root, path) => {
5
+ let tmpFile
6
+ let contents
7
+
8
+ const mixinFiles = []
9
+ for await (const file of expandGlob(`lib/**/*.mixin.css`, { root })) {
10
+ mixinFiles.push(file.path)
11
+ }
12
+
13
+ // Only process mixins with PostCSS if there are any 'lib/**/*.mixin.css' files.
14
+ if (mixinFiles.length > 0) {
15
+ tmpFile = await Deno.makeTempFile()
16
+ contents = await Deno.readTextFile(path)
17
+
18
+ const result = await postcss([mixinsPlugin({ mixinFiles })]).process(contents, { from: path })
19
+ contents = result.css
20
+ }
21
+
22
+ return [tmpFile, contents]
23
+ }
24
+
25
+ const mixinsPlugin = (opts = {}) => {
26
+ return {
27
+ postcssPlugin: 'mixins',
28
+
29
+ prepare() {
30
+ const mixins = {}
31
+
32
+ return {
33
+ async Once(_, helpers) {
34
+ for (const path of opts.mixinFiles) {
35
+ const content = await Deno.readTextFile(path)
36
+ const root = helpers.parse(content, { from: path })
37
+
38
+ root.walkAtRules('define-mixin', atrule => {
39
+ mixins[atrule.params] = atrule
40
+ })
41
+ }
42
+ },
43
+
44
+ AtRule: {
45
+ mixin: (rule, helpers) => {
46
+ const mixin = mixins[rule.params]
47
+
48
+ if (!mixin) {
49
+ throw rule.error(`Undefined mixin '${rule.params}'`)
50
+ }
51
+
52
+ const proxy = new helpers.Root()
53
+ for (let i = 0; i < mixin.nodes.length; i++) {
54
+ const node = mixin.nodes[i].clone()
55
+ delete node.raws.before
56
+ proxy.append(node)
57
+ }
58
+
59
+ rule.parent.insertBefore(rule, proxy)
60
+
61
+ if (rule.parent) rule.remove()
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
@@ -1,11 +1,16 @@
1
1
  import { crypto } from 'std/crypto/mod.ts'
2
- import { join, resolve, dirname, fromFileUrl } from 'std/path/mod.ts'
2
+ import { join, resolve, dirname, basename, fromFileUrl } from 'std/path/mod.ts'
3
3
 
4
+ import { fileExists } from '../../utils.js'
5
+ import postcss from './css/postcss.js'
4
6
  import setup from './setup_plugin.js'
5
7
 
6
8
  export default setup('css', async build => {
7
9
  const cwd = build.initialOptions.absWorkingDir
8
- const parcelBin = resolve(dirname(fromFileUrl(import.meta.url)), '../../../../bin/parcel_css')
10
+ const lightningcssBin = resolve(
11
+ dirname(fromFileUrl(import.meta.url)),
12
+ '../../../../bin/lightningcss'
13
+ )
9
14
 
10
15
  let customMedia
11
16
  try {
@@ -20,24 +25,55 @@ export default setup('css', async build => {
20
25
  filter: /\.css$/,
21
26
  namespace: 'file',
22
27
  async callback(args) {
23
- let path = args.path
28
+ const hash = await digest(args.path.slice(cwd.length))
24
29
  const isCssModule = args.path.endsWith('.module.css')
25
- let cmd = [parcelBin, '--nesting', '--targets', '>= 0.25%']
26
30
 
31
+ // If path is a CSS module, imported from JS, and a side-loaded ViewComponent stylesheet,
32
+ // simply return a JS proxy of the class names. The stylesheet itself will have already been
33
+ // side loaded. This avoids compiling the CSS all over again.
34
+ if (isCssModule && args.pluginData?.importedFromJs && (await isViewComponent(args.path))) {
35
+ return {
36
+ resolveDir: cwd,
37
+ loader: 'js',
38
+ contents: cssModulesProxyTemplate(hash)
39
+ }
40
+ }
41
+
42
+ let cmd = [
43
+ lightningcssBin,
44
+ '--nesting',
45
+ '--error-recovery',
46
+ args.pluginData?.importedFromJs && '--minify',
47
+ '--targets',
48
+ '>= 0.25%'
49
+ ].filter(Boolean)
50
+
51
+ // This will process the CSS with Postcss only if it needs to.
52
+ let [tmpFile, contents] = await postcss(cwd, args.path)
53
+
54
+ // As custom media are defined in their own file, we have to append the file contents to our
55
+ // stylesheet, so that the custom media can be used.
27
56
  if (customMedia) {
28
57
  cmd.push('--custom-media')
29
58
 
30
- path = await Deno.makeTempFile()
31
- await Deno.writeTextFile(path, (await Deno.readTextFile(args.path)) + customMedia)
59
+ if (!tmpFile && !contents) {
60
+ tmpFile = await Deno.makeTempFile()
61
+ contents = await Deno.readTextFile(args.path)
62
+ }
63
+
64
+ contents += customMedia
65
+ }
66
+
67
+ if (tmpFile && contents) {
68
+ await Deno.writeTextFile(tmpFile, contents)
32
69
  }
33
70
 
34
71
  if (isCssModule) {
35
- const hash = await digest(args.path.slice(cwd.length))
36
72
  cmd = cmd.concat(['--css-modules', '--css-modules-pattern', `[local]${hash}`])
37
73
  }
38
74
 
39
75
  const p = Deno.run({
40
- cmd: [...cmd, path],
76
+ cmd: [...cmd, tmpFile || args.path],
41
77
  stdout: 'piped',
42
78
  stderr: 'piped'
43
79
  })
@@ -51,9 +87,48 @@ export default setup('css', async build => {
51
87
  // here.
52
88
  p.close()
53
89
 
90
+ // Success!
54
91
  if (code === 0) {
55
- const contents = new TextDecoder().decode(rawOutput)
56
- return { loader: 'css', contents: isCssModule ? JSON.parse(contents).code : contents }
92
+ let contents = new TextDecoder().decode(rawOutput)
93
+ if (isCssModule) {
94
+ contents = JSON.parse(contents)
95
+ }
96
+
97
+ // If stylesheet is imported from JS, then we return JS code that appends the stylesheet
98
+ // in a <style> in the <head> of the page, and if the stylesheet is a CSS module, it
99
+ // exports a plain object of class names.
100
+ if (args.pluginData?.importedFromJs) {
101
+ const code = isCssModule ? contents.code : contents
102
+ const mod = [
103
+ `let e = document.querySelector('#_${hash}');`,
104
+ 'if (!e) {',
105
+ "e = document.createElement('style');",
106
+ `e.id = '_${hash}';`,
107
+ 'document.head.appendChild(e);',
108
+ `e.appendChild(document.createTextNode(\`${code}\`));`,
109
+ '}'
110
+ ]
111
+
112
+ if (isCssModule) {
113
+ const classes = {}
114
+ for (const key in contents.exports) {
115
+ if (Object.hasOwnProperty.call(contents.exports, key)) {
116
+ classes[key] = contents.exports[key].name
117
+ }
118
+ }
119
+ mod.push(`export default ${JSON.stringify(classes)};`)
120
+ }
121
+
122
+ // We are importing from JS, so return the entire result from LightningCSS via the js
123
+ // loader.
124
+ return {
125
+ resolveDir: cwd,
126
+ loader: 'js',
127
+ contents: mod.join('')
128
+ }
129
+ }
130
+
131
+ return { loader: 'css', contents: isCssModule ? contents.code : contents }
57
132
  } else {
58
133
  const errorString = new TextDecoder().decode(rawError)
59
134
  throw errorString
@@ -74,3 +149,28 @@ async function digest(value) {
74
149
 
75
150
  return hexCodes.slice(0, 8)
76
151
  }
152
+
153
+ async function isViewComponent(path) {
154
+ const fileName = basename(path)
155
+ const dirName = dirname(path)
156
+
157
+ return (
158
+ (fileName === 'component.module.css' && (await fileExists(join(dirName, 'component.rb')))) ||
159
+ (fileName.endsWith('_component.module.css') &&
160
+ (await fileExists(join(dirName, fileName.replace(/\.module\.css$/, '.rb')))))
161
+ )
162
+ }
163
+
164
+ function cssModulesProxyTemplate(hash) {
165
+ return [
166
+ `export default new Proxy( {}, {`,
167
+ ` get(target, prop, receiver) {`,
168
+ ` if (prop in target || typeof prop === 'symbol') {`,
169
+ ` return Reflect.get(target, prop, receiver)`,
170
+ ` } else {`,
171
+ ` return prop + '${hash}'`,
172
+ ` }`,
173
+ ` }`,
174
+ `})`
175
+ ].join('')
176
+ }
@@ -3,13 +3,13 @@ import { resolve as resolveFromImportMap } from 'import-maps/resolve'
3
3
 
4
4
  import setup from './setup_plugin.js'
5
5
 
6
- const baseURL = new URL('file://')
7
6
  const importKinds = ['import-statement', 'dynamic-import', 'require-call', 'import-rule']
8
7
 
9
8
  export default setup('resolve', (build, options) => {
10
9
  const { runtimeDir, importMap } = options
11
10
  const cwd = build.initialOptions.absWorkingDir
12
11
  const runtimeCwdAlias = `${cwd}/proscenium-runtime`
12
+ let bundled = false
13
13
 
14
14
  return [
15
15
  {
@@ -29,18 +29,22 @@ export default setup('resolve', (build, options) => {
29
29
  }
30
30
 
31
31
  // Proscenium runtime
32
- if (args.path.startsWith('@proscenium/')) {
33
- const result = { suffix: args.suffix }
32
+ // if (args.path.startsWith('@proscenium/')) {
33
+ // const result = { suffix: args.suffix }
34
34
 
35
- if (args.queryParams?.has('bundle')) {
36
- result.path = join(runtimeDir, `${args.path.replace(/^@proscenium/, '')}/index.js`)
37
- } else {
38
- result.path = `${args.path.replace(/^@proscenium/, '/proscenium-runtime')}/index.js`
39
- result.external = true
40
- }
35
+ // if (args.queryParams?.has('bundle-all')) {
36
+ // bundled = true
37
+ // }
41
38
 
42
- return result
43
- }
39
+ // if (bundled || args.queryParams?.has('bundle')) {
40
+ // result.path = join(runtimeDir, `${args.path.replace(/^@proscenium/, '')}/index.js`)
41
+ // } else {
42
+ // result.path = `${args.path.replace(/^@proscenium/, '/proscenium-runtime')}/index.js`
43
+ // result.external = true
44
+ // }
45
+
46
+ // return result
47
+ // }
44
48
 
45
49
  if (args.path.startsWith(runtimeCwdAlias)) {
46
50
  return { path: join(runtimeDir, args.path.slice(runtimeCwdAlias.length)) }
@@ -51,32 +55,6 @@ export default setup('resolve', (build, options) => {
51
55
  return await unbundleImport(args)
52
56
  }
53
57
  }
54
- },
55
-
56
- {
57
- type: 'onLoad',
58
- filter: /.*/,
59
- namespace: 'importStylesheet',
60
- callback(args) {
61
- const result = {
62
- resolveDir: cwd,
63
- loader: 'js'
64
- }
65
-
66
- if (args.path.endsWith('.module.css')) {
67
- result.contents = `
68
- import { importCssModule } from '/proscenium-runtime/import_css.js'
69
- export default await importCssModule('${args.path}')
70
- `
71
- } else {
72
- result.contents = `
73
- import { appendStylesheet } from '/proscenium-runtime/import_css.js'
74
- appendStylesheet('${args.path}')
75
- `
76
- }
77
-
78
- return result
79
- }
80
58
  }
81
59
  ]
82
60
 
@@ -90,7 +68,9 @@ export default setup('resolve', (build, options) => {
90
68
  const result = { path: params.path, suffix: params.suffix }
91
69
 
92
70
  if (importMap) {
71
+ const baseURL = new URL(params.importer.slice(cwd.length), 'file://')
93
72
  const { matched, resolvedImport } = resolveFromImportMap(params.path, importMap, baseURL)
73
+
94
74
  if (matched) {
95
75
  if (resolvedImport.protocol === 'file:') {
96
76
  params.path = resolvedImport.pathname
@@ -108,24 +88,39 @@ export default setup('resolve', (build, options) => {
108
88
  // Resolve the path using esbuild's internal resolution. This allows us to import node packages
109
89
  // and extension-less paths without custom code, as esbuild with resolve them for us.
110
90
  const resolveResult = await build.resolve(result.path, {
111
- resolveDir: params.resolveDir,
91
+ // If path is a bare module (node_modules), and resolveDir is the Proscenium runtime dir, then
92
+ // use `cwd` as the `resolveDir`, otherwise pass it through as is. This ensures that nested
93
+ // node_modules are resolved correctly.
94
+ resolveDir:
95
+ isBareModule(result.path) && params.resolveDir.startsWith(runtimeDir)
96
+ ? cwd
97
+ : params.resolveDir,
112
98
  pluginData: {
113
99
  // We use this property later on, as we should ignore this resolution call.
114
100
  isResolvingPath: true
115
101
  }
116
102
  })
117
103
 
118
- if (resolveResult.errors.length > 0) {
119
- // throw `${resolveResult.errors[0].text} (resolveDir: ${cwd})`
104
+ // Simple return the resolved result if we have an error. Usually happens when module is not
105
+ // found.
106
+ if (resolveResult.errors.length > 0) return resolveResult
107
+
108
+ // If 'bundle-all' queryParam is defined, return the resolveResult.
109
+ if (bundled || params.queryParams?.has('bundle-all')) {
110
+ bundled = true
111
+ return { ...resolveResult, suffix: '?bundle-all' }
120
112
  }
121
113
 
122
- // If bundle queryParam is defined, return the resolveResult.
114
+ // If 'bundle' queryParam is defined, return the resolveResult.
123
115
  if (params.queryParams?.has('bundle')) {
124
- return { ...resolveResult, suffix: result.suffix }
116
+ return { ...resolveResult, suffix: '?bundle' }
125
117
  }
126
118
 
127
119
  if (resolveResult.path.startsWith(runtimeDir)) {
128
120
  result.path = '/proscenium-runtime' + resolveResult.path.slice(runtimeDir.length)
121
+ } else if (!resolveResult.path.startsWith(cwd)) {
122
+ // If resolved path does not start with cwd, then it is most likely linked, so bundle it.
123
+ return { ...resolveResult, suffix: '?bundle' }
129
124
  } else {
130
125
  result.path = resolveResult.path.slice(cwd.length)
131
126
  }
@@ -138,7 +133,7 @@ export default setup('resolve', (build, options) => {
138
133
  /\.jsx?$/.test(params.importer)
139
134
  ) {
140
135
  // We're importing a CSS file from JS(X).
141
- result.namespace = 'importStylesheet'
136
+ return { ...resolveResult, pluginData: { importedFromJs: true } }
142
137
  } else {
143
138
  result.external = true
144
139
  }
@@ -146,3 +141,7 @@ export default setup('resolve', (build, options) => {
146
141
  return result
147
142
  }
148
143
  })
144
+
145
+ function isBareModule(mod) {
146
+ return !mod.startsWith('/') && !mod.startsWith('.')
147
+ }
@@ -9,13 +9,22 @@ export default (pluginName, pluginFn) => {
9
9
  build.onResolve({ filter, namespace }, async params => {
10
10
  if (params.pluginData?.isResolvingPath) return
11
11
 
12
- options.debug &&
13
- console.debug(`plugin(${pluginName}):onResolve`, params.path, { params })
14
-
15
- const results = await callback(params)
16
-
17
- options.debug &&
18
- console.debug(`plugin(${pluginName}):onResolve`, params.path, { results })
12
+ let results
13
+
14
+ if (options.debug) {
15
+ console.debug()
16
+ console.group(`plugin(${pluginName}):onResolve`, { filter, namespace })
17
+ console.debug('params:', params)
18
+
19
+ try {
20
+ results = await callback(params)
21
+ console.debug('results:', results)
22
+ } finally {
23
+ console.groupEnd()
24
+ }
25
+ } else {
26
+ results = await callback(params)
27
+ }
19
28
 
20
29
  return results
21
30
  })
@@ -11,14 +11,38 @@ import resolvePlugin from './esbuild/resolve_plugin.js'
11
11
  import ArgumentError from './esbuild/argument_error.js'
12
12
  import throwCompileError from './esbuild/compile_error.js'
13
13
 
14
+ /**
15
+ * Compile the given paths, outputting the result to stdout. This is designed to be called as a CLI:
16
+ *
17
+ * Example with Deno run (dev and test):
18
+ * deno run -A lib/proscenium/compilers/esbuild.js --root ./test/internal lib/foo.js
19
+ * Example with Deno compiled binary:
20
+ * bin/esbuild lib/proscenium/compilers/esbuild.js --root ./test/internal lib/foo.js
21
+ *
22
+ * USAGE:
23
+ * esbuild [OPTIONS] <PATHS_ARG>...
24
+ *
25
+ * ARGS:
26
+ * <PATHS_ARG>... One or more file paths to compile.
27
+ *
28
+ * OPTIONS:
29
+ * --root
30
+ * Relative or absolute path to the root or current working directory when compilation will
31
+ * take place.
32
+ * --import-map
33
+ * Path to an import map, relative to the <root>.
34
+ * --write
35
+ * Write output to the filesystem according to esbuild logic.
36
+ * --debug
37
+ * Debug output,
38
+ */
14
39
  if (import.meta.main) {
15
40
  !Deno.env.get('RAILS_ENV') && Deno.env.set('RAILS_ENV', 'development')
16
41
 
17
42
  const { _: paths, ...options } = parseArgs(Deno.args, {
18
- string: ['root', 'runtime-dir', 'import-map'],
43
+ string: ['root', 'import-map'],
19
44
  boolean: ['write', 'debug'],
20
45
  alias: {
21
- 'runtime-dir': 'runtimeDir',
22
46
  'import-map': 'importMap'
23
47
  }
24
48
  })
@@ -34,10 +58,12 @@ if (import.meta.main) {
34
58
  }
35
59
 
36
60
  async function main(paths = [], options = {}) {
37
- const { root, write, debug } = { write: false, ...options }
61
+ const { write, debug } = { write: false, ...options }
38
62
 
39
63
  if (!Array.isArray(paths) || paths.length < 1) throw new ArgumentError('pathsRequired')
40
- if (!root) throw new ArgumentError('rootRequired')
64
+ if (!options.root) throw new ArgumentError('rootRequired')
65
+
66
+ const root = resolve(options.root)
41
67
 
42
68
  // Make sure that `root` is a valid directory.
43
69
  try {
@@ -84,7 +110,6 @@ async function main(paths = [], options = {}) {
84
110
  absWorkingDir: root,
85
111
  logLevel: 'silent',
86
112
  logLimit: 1,
87
- sourcemap: isTest ? false : isProd ? 'linked' : 'inline',
88
113
  outdir: 'public/assets',
89
114
  outbase: './',
90
115
  format: 'esm',
@@ -97,10 +122,18 @@ async function main(paths = [], options = {}) {
97
122
  write
98
123
  }
99
124
 
125
+ if (!debug) {
126
+ params.sourcemap = isTest ? false : isProd ? 'linked' : 'inline'
127
+ }
128
+
100
129
  let result
101
130
  try {
102
131
  result = await build(params)
103
132
  } catch (error) {
133
+ if (debug) {
134
+ throw error
135
+ }
136
+
104
137
  return { ...error.errors[0] }
105
138
  } finally {
106
139
  stop()
@@ -1,22 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Proscenium::CssModule
4
+ class NotFound < StandardError
5
+ def initialize(pathname)
6
+ @pathname = pathname
7
+ super
8
+ end
9
+
10
+ def message
11
+ "Stylesheet is required, but does not exist: #{@pathname}"
12
+ end
13
+ end
14
+
4
15
  def initialize(path)
5
- @path = "#{path}.module.css"
16
+ @path = path
17
+ @css_module_path = "#{path}.module.css"
18
+ end
6
19
 
7
- return unless Rails.application.config.proscenium.side_load
20
+ # Parses the given `content` for CSS modules names ('class' attributes beginning with '@'), and
21
+ # returns the content with said CSS Modules replaced with the compiled class names.
22
+ #
23
+ # Example:
24
+ # <div class="@my_css_module_name"></div>
25
+ def compile_class_names(content)
26
+ doc = Nokogiri::HTML::DocumentFragment.parse(content)
27
+
28
+ return content if (modules = doc.css('[class*="@"]')).empty?
29
+
30
+ modules.each do |ele|
31
+ classes = ele.classes.map { |cls| cls.starts_with?('@') ? class_names!(cls[1..]) : cls }
32
+ ele['class'] = classes.join(' ')
33
+ end
8
34
 
9
- Proscenium::SideLoad.append! Rails.root.join(@path)
35
+ doc.to_html.html_safe
10
36
  end
11
37
 
12
- # Returns an Array of class names generated from the given CSS module `names`.
38
+ # @returns [Array] of class names generated from the given CSS module `names`.
13
39
  def class_names(*names)
14
- names.flatten.compact.map { |name| "#{name}#{hash}" }
40
+ side_load_css_module
41
+ names.flatten.compact.map { |name| "#{name.to_s.camelize(:lower)}#{hash}" }
42
+ end
43
+
44
+ # Like #class_names, but requires that the stylesheet exists.
45
+ #
46
+ # @raises Proscenium::CssModule::NotFound if stylesheet does not exists.
47
+ def class_names!(...)
48
+ raise NotFound, @css_module_path unless Rails.root.join(@css_module_path).exist?
49
+
50
+ class_names(...)
15
51
  end
16
52
 
17
53
  private
18
54
 
19
55
  def hash
20
- @hash ||= Digest::SHA1.hexdigest("/#{@path}")[..7]
56
+ @hash ||= Digest::SHA1.hexdigest("/#{@css_module_path}")[..7]
57
+ end
58
+
59
+ def side_load_css_module
60
+ return unless Rails.application.config.proscenium.side_load
61
+
62
+ Proscenium::SideLoad.append "#{@path}.module", :css
21
63
  end
22
64
  end
@@ -11,7 +11,9 @@ module Proscenium
11
11
  def side_load_stylesheets
12
12
  return unless Proscenium::Current.loaded
13
13
 
14
- stylesheet_link_tag(*Proscenium::Current.loaded[:css])
14
+ Proscenium::Current.loaded[:css].map do |sheet|
15
+ stylesheet_link_tag(sheet, id: "_#{Digest::SHA1.hexdigest("/#{sheet}")[..7]}")
16
+ end.join("\n").html_safe
15
17
  end
16
18
 
17
19
  def side_load_javascripts(**options)
@@ -31,7 +31,9 @@ module Proscenium
31
31
 
32
32
  def cli
33
33
  if ENV['PROSCENIUM_TEST']
34
- 'deno run -q --import-map import_map.json -A lib/proscenium/compilers/esbuild.js'
34
+ [
35
+ 'deno run -q --import-map import_map.json -A', 'lib/proscenium/compilers/esbuild.js'
36
+ ].join(' ')
35
37
  else
36
38
  Gem.bin_path 'proscenium', 'esbuild'
37
39
  end
@@ -4,9 +4,9 @@ require 'oj'
4
4
 
5
5
  module Proscenium
6
6
  class Middleware
7
- class ParcelCss < Base
7
+ class Lightningcss < Base
8
8
  def attempt
9
- benchmark :parcelcss do
9
+ benchmark :lightningcss do
10
10
  with_custom_media { |path| build path }
11
11
  end
12
12
  end
@@ -41,7 +41,7 @@ module Proscenium
41
41
  end
42
42
 
43
43
  def cli
44
- Gem.bin_path 'proscenium', 'parcel_css'
44
+ Gem.bin_path 'proscenium', 'lightningcss'
45
45
  end
46
46
 
47
47
  def cli_options
@@ -9,15 +9,8 @@ module Proscenium
9
9
 
10
10
  autoload :Base
11
11
  autoload :Esbuild
12
- autoload :ParcelCss
13
12
  autoload :Runtime
14
13
 
15
- MIDDLEWARE_CLASSES = {
16
- esbuild: Esbuild,
17
- parcelcss: ParcelCss,
18
- runtime: Runtime
19
- }.freeze
20
-
21
14
  def initialize(app)
22
15
  @app = app
23
16
  end
@@ -38,23 +31,15 @@ module Proscenium
38
31
  def attempt(request)
39
32
  return unless (type = find_type(request))
40
33
 
41
- file_handler.attempt(request.env) || MIDDLEWARE_CLASSES[type].attempt(request)
34
+ file_handler.attempt(request.env) || type.attempt(request)
42
35
  end
43
36
 
44
37
  # Returns the type of file being requested using Rails.application.config.proscenium.glob_types.
45
38
  def find_type(request)
46
- return :runtime if request.path_info.start_with?('/proscenium-runtime/')
47
-
48
- path = Rails.root.join(request.path[1..])
49
-
50
- type, = glob_types.find do |_, globs|
51
- # TODO: Look for the precompiled file in public/assets first
52
- # globs.any? { |glob| Rails.public_path.join('assets').glob(glob).any?(path) }
53
-
54
- globs.any? { |glob| Rails.root.glob(glob).any?(path) }
55
- end
39
+ path = Pathname.new(request.path)
56
40
 
57
- type
41
+ return Runtime if path.fnmatch?(glob_types[:runtime], File::FNM_EXTGLOB)
42
+ return Esbuild if path.fnmatch?(glob_types[:esbuild], File::FNM_EXTGLOB)
58
43
  end
59
44
 
60
45
  def file_handler
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ class Phlex < ::Phlex::View
5
+ module Sideload
6
+ def template(...)
7
+ Proscenium::SideLoad.append self.class.path if Rails.application.config.proscenium.side_load
8
+
9
+ super
10
+ end
11
+ end
12
+
13
+ class << self
14
+ attr_accessor :path
15
+
16
+ def inherited(child)
17
+ path = caller_locations(1, 1)[0].path
18
+ child.path = path.delete_prefix(::Rails.root.to_s).delete_suffix('.rb')[1..]
19
+
20
+ child.prepend Sideload
21
+
22
+ super
23
+ end
24
+ end
25
+
26
+ def css_module(name)
27
+ cssm.class_names!(name).join ' '
28
+ end
29
+
30
+ private
31
+
32
+ def cssm
33
+ @cssm ||= Proscenium::CssModule.new(self.class.path)
34
+ end
35
+ end
36
+ end
@@ -12,16 +12,8 @@ module Proscenium
12
12
  #
13
13
  # See https://doc.deno.land/https://deno.land/std@0.145.0/path/mod.ts/~/globToRegExp
14
14
  DEFAULT_GLOB_TYPES = {
15
- esbuild: [
16
- 'config/**/*.{js,jsx,css}',
17
- 'lib/**/*.{js,jsx,css}',
18
- 'app/**/*.{js,jsx,css}'
19
- ]
20
- # parcelcss: [
21
- # 'config/**/*.css',
22
- # 'lib/**/*.css',
23
- # 'app/**/*.css'
24
- # ]
15
+ esbuild: '/{config,app,lib,node_modules}/**.{js,mjs,jsx,css}',
16
+ runtime: '/proscenium-runtime/**.{js,jsx}'
25
17
  }.freeze
26
18
 
27
19
  class << self
@@ -7,16 +7,16 @@ export default socketPath => {
7
7
 
8
8
  consumer.subscriptions.create('Proscenium::ReloadChannel', {
9
9
  received: debounce(() => {
10
- console.log('Proscenium files changed; reloading...')
10
+ console.log('[Proscenium] Files changed; reloading...')
11
11
  location.reload()
12
12
  }, 200),
13
13
 
14
14
  connected() {
15
- console.log('Proscenium auto reload websocket connected')
15
+ console.log('[Proscenium] Auto-reload websocket connected')
16
16
  },
17
17
 
18
18
  disconnected() {
19
- console.log('Proscenium auto reload websocket disconnected')
19
+ console.log('[Proscenium] Auto-reload websocket disconnected')
20
20
  }
21
21
  })
22
22
  }
@@ -5,17 +5,6 @@ module Proscenium
5
5
  DEFAULT_EXTENSIONS = %i[js css].freeze
6
6
  EXTENSIONS = %i[js css].freeze
7
7
 
8
- class NotFound < StandardError
9
- def initialize(pathname)
10
- @pathname = pathname
11
- super
12
- end
13
-
14
- def message
15
- "#{@pathname} does not exist"
16
- end
17
- end
18
-
19
8
  module_function
20
9
 
21
10
  # Side load the given asset `path`, by appending it to `Proscenium::Current.loaded`, which is a
@@ -46,40 +35,25 @@ module Proscenium
46
35
  end
47
36
  end
48
37
 
49
- # Like #append, but only accepts a single `path` argument, which must be a Pathname. Raises
50
- # `NotFound` if path does not exist,
51
- def append!(pathname)
52
- Proscenium::Current.loaded ||= EXTENSIONS.to_h { |e| [e, Set[]] }
53
-
54
- unless pathname.is_a?(Pathname)
55
- raise ArgumentError, "Argument `pathname` (#{pathname}) must be a Pathname"
56
- end
57
-
58
- ext = pathname.extname.sub('.', '').to_sym
59
- path = pathname.relative_path_from(Rails.root).to_s
60
-
61
- raise ArgumentError, "unsupported extension: #{ext}" unless EXTENSIONS.include?(ext)
62
-
63
- return if Proscenium::Current.loaded[ext].include?(path)
64
-
65
- raise NotFound, path unless pathname.exist?
66
-
67
- Proscenium::Current.loaded[ext] << path
68
-
69
- Rails.logger.debug "[Proscenium] Side loaded /#{path}"
70
- end
71
-
72
38
  module Monkey
73
39
  module TemplateRenderer
74
40
  private
75
41
 
76
42
  def render_template(view, template, layout_name, locals)
77
- if template.respond_to?(:virtual_path) &&
78
- template.respond_to?(:type) && template.type == :html
79
- if (layout = layout_name && find_layout(layout_name, locals.keys, [formats.first]))
80
- Proscenium::SideLoad.append "app/views/#{layout.virtual_path}" # layout
81
- end
82
-
43
+ layout = find_layout(layout_name, locals.keys, [formats.first])
44
+ renderable = template.instance_variable_get(:@renderable)
45
+
46
+ if template.is_a?(ActionView::Template::Renderable) &&
47
+ renderable.class < ::ViewComponent::Base && renderable.class.format == :html
48
+ # Side load controller rendered ViewComponent
49
+ Proscenium::SideLoad.append "app/views/#{layout.virtual_path}" if layout
50
+ Proscenium::SideLoad.append "app/views/#{renderable.virtual_path}"
51
+ elsif template.respond_to?(:virtual_path) &&
52
+ template.respond_to?(:type) && template.type == :html
53
+ # Side load regular view template.
54
+ Proscenium::SideLoad.append "app/views/#{layout.virtual_path}" if layout
55
+
56
+ # Try side loading the variant template
83
57
  if template.respond_to?(:variant) && template.variant
84
58
  Proscenium::SideLoad.append "app/views/#{template.virtual_path}+#{template.variant}"
85
59
  end
@@ -0,0 +1,8 @@
1
+ export async function fileExists(path) {
2
+ try {
3
+ const fileInfo = await Deno.stat(path)
4
+ return fileInfo.isFile
5
+ } catch {
6
+ return false
7
+ }
8
+ }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Proscenium
4
- VERSION = '0.1.0.alpha3'
4
+ VERSION = '0.1.0.alpha4'
5
5
  end
@@ -5,12 +5,16 @@ module Proscenium::ViewComponent
5
5
 
6
6
  autoload :TagBuilder
7
7
 
8
+ def render_in(...)
9
+ cssm.compile_class_names(super)
10
+ end
11
+
8
12
  def before_render
9
13
  side_load_assets unless self.class < ReactComponent
10
14
  end
11
15
 
12
16
  def css_module(name)
13
- cssm.class_names(name.to_s.camelize(:lower)).join ' '
17
+ cssm.class_names!(name).join ' '
14
18
  end
15
19
 
16
20
  private
data/lib/proscenium.rb CHANGED
@@ -10,6 +10,7 @@ module Proscenium
10
10
  autoload :SideLoad
11
11
  autoload :CssModule
12
12
  autoload :ViewComponent
13
+ autoload :Phlex
13
14
  autoload :Helper
14
15
  autoload :LinkToHelper
15
16
  autoload :Precompile
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: proscenium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.alpha3
4
+ version: 0.1.0.alpha4
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Joel Moss
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-08-10 00:00:00.000000000 Z
11
+ date: 2022-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable
@@ -64,6 +64,20 @@ dependencies:
64
64
  - - "~>"
65
65
  - !ruby/object:Gem::Version
66
66
  version: '3.0'
67
+ - !ruby/object:Gem::Dependency
68
+ name: nokogiri
69
+ requirement: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '1.13'
74
+ type: :runtime
75
+ prerelease: false
76
+ version_requirements: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '1.13'
67
81
  - !ruby/object:Gem::Dependency
68
82
  name: oj
69
83
  requirement: !ruby/object:Gem::Requirement
@@ -117,7 +131,7 @@ email:
117
131
  - joel@developwithstyle.com
118
132
  executables:
119
133
  - esbuild
120
- - parcel_css
134
+ - lightningcss
121
135
  extensions: []
122
136
  extra_rdoc_files: []
123
137
  files:
@@ -129,7 +143,7 @@ files:
129
143
  - app/components/application_component.rb
130
144
  - app/components/react_component.rb
131
145
  - bin/esbuild
132
- - bin/parcel_css
146
+ - bin/lightningcss
133
147
  - config/routes.rb
134
148
  - lib/proscenium.rb
135
149
  - lib/proscenium/compiler.js
@@ -137,6 +151,7 @@ files:
137
151
  - lib/proscenium/compilers/esbuild.js
138
152
  - lib/proscenium/compilers/esbuild/argument_error.js
139
153
  - lib/proscenium/compilers/esbuild/compile_error.js
154
+ - lib/proscenium/compilers/esbuild/css/postcss.js
140
155
  - lib/proscenium/compilers/esbuild/css_plugin.js
141
156
  - lib/proscenium/compilers/esbuild/env_plugin.js
142
157
  - lib/proscenium/compilers/esbuild/http_bundle_plugin.js
@@ -151,18 +166,17 @@ files:
151
166
  - lib/proscenium/middleware.rb
152
167
  - lib/proscenium/middleware/base.rb
153
168
  - lib/proscenium/middleware/esbuild.rb
154
- - lib/proscenium/middleware/parcel_css.rb
169
+ - lib/proscenium/middleware/lightningcss.rb
155
170
  - lib/proscenium/middleware/runtime.rb
156
171
  - lib/proscenium/middleware/static.rb
172
+ - lib/proscenium/phlex.rb
157
173
  - lib/proscenium/precompile.rb
158
174
  - lib/proscenium/railtie.rb
159
175
  - lib/proscenium/runtime/auto_reload.js
160
- - lib/proscenium/runtime/component_manager/index.js
161
- - lib/proscenium/runtime/component_manager/render_component.jsx
162
- - lib/proscenium/runtime/import_css.js
163
176
  - lib/proscenium/runtime/react_shim/index.js
164
177
  - lib/proscenium/runtime/react_shim/package.json
165
178
  - lib/proscenium/side_load.rb
179
+ - lib/proscenium/utils.js
166
180
  - lib/proscenium/version.rb
167
181
  - lib/proscenium/view_component.rb
168
182
  - lib/proscenium/view_component/tag_builder.rb
data/bin/parcel_css DELETED
Binary file
@@ -1,27 +0,0 @@
1
- /* eslint-disable no-console */
2
-
3
- export async function init() {
4
- const elements = document.querySelectorAll('[data-component]')
5
-
6
- if (elements.length < 1) return
7
-
8
- const { default: renderComponent } = await import(`./render_component`)
9
-
10
- Array.from(elements, ele => {
11
- const data = JSON.parse(ele.getAttribute('data-component'))
12
-
13
- let isVisible = false
14
- const observer = new IntersectionObserver(entries => {
15
- entries.forEach(entry => {
16
- if (!isVisible && entry.isIntersecting) {
17
- isVisible = true
18
- observer.unobserve(ele)
19
-
20
- renderComponent(ele, data)
21
- }
22
- })
23
- })
24
-
25
- observer.observe(ele)
26
- })
27
- }
@@ -1,36 +0,0 @@
1
- /* eslint-disable no-console */
2
-
3
- import { RAILS_ENV } from 'env'
4
-
5
- // We don't use JSX, as doing so would auto-inject React. We don't want to do this, as React is lazy
6
- // loaded only when needed.
7
- export default async function (ele, data) {
8
- const { createElement, useEffect, lazy, Suspense } = await import('react')
9
- const { createRoot } = await import('react-dom/client')
10
-
11
- const component = lazy(() => import(`/app/components${data.path}.jsx`))
12
- const contentLoader = data.contentLoader && ele.firstElementChild
13
-
14
- const Fallback = ({ contentLoader }) => {
15
- useEffect(() => {
16
- contentLoader && contentLoader.remove()
17
- }, []) // eslint-disable-line react-hooks/exhaustive-deps
18
-
19
- if (!contentLoader) return null
20
-
21
- return (
22
- <div
23
- style={{ height: '100%' }}
24
- dangerouslySetInnerHTML={{ __html: contentLoader.outerHTML }}
25
- ></div>
26
- )
27
- }
28
-
29
- createRoot(ele).render(
30
- <Suspense fallback={<Fallback contentLoader={contentLoader} />}>
31
- {createElement(component, data.props)}
32
- </Suspense>
33
- )
34
-
35
- RAILS_ENV === 'development' && console.debug(`[REACT]`, `Rendered ${data.path.slice(1)}`)
36
- }
@@ -1,46 +0,0 @@
1
- async function digest(value) {
2
- value = new TextEncoder().encode(value)
3
- const view = new DataView(await crypto.subtle.digest('SHA-1', value))
4
-
5
- let hexCodes = ''
6
- for (let index = 0; index < view.byteLength; index += 4) {
7
- hexCodes += view.getUint32(index).toString(16).padStart(8, '0')
8
- }
9
-
10
- return hexCodes.slice(0, 8)
11
- }
12
-
13
- const proxyCache = {}
14
-
15
- export async function importCssModule(path) {
16
- appendStylesheet(path)
17
-
18
- if (Object.keys(proxyCache).includes(path)) {
19
- return proxyCache[path]
20
- }
21
-
22
- const hashValue = await digest(path)
23
- return (proxyCache[path] = new Proxy(
24
- {},
25
- {
26
- get(target, prop, receiver) {
27
- if (prop in target || typeof prop === 'symbol') {
28
- return Reflect.get(target, prop, receiver)
29
- } else {
30
- return `${prop}${hashValue}`
31
- }
32
- }
33
- }
34
- ))
35
- }
36
-
37
- export function appendStylesheet(path) {
38
- // Make sure we only load the stylesheet once.
39
- if (document.head.querySelector(`link[rel=stylesheet][href='${path}']`)) return
40
-
41
- const ele = document.createElement('link')
42
- ele.setAttribute('rel', 'stylesheet')
43
- ele.setAttribute('media', 'all')
44
- ele.setAttribute('href', path)
45
- document.head.appendChild(ele)
46
- }