proscenium 0.1.0.alpha3-arm64-darwin → 0.1.1-arm64-darwin

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4a4874116abf93273709324c8eacc88a42436df83ed6398c7425b6a622bd6d0
4
- data.tar.gz: e1a88bd41ea897d31977b58912520921fc3a7c3ef57c2df319b0f127cbeeaf6d
3
+ metadata.gz: f264b667a89d847b0a8a0e753afe90c9b5c92959e538d1d35cb896a6f8d5ab50
4
+ data.tar.gz: bdbd4343046e0f1f774ca12b09f3771eb5200960ca0edf66a1ecf807c0e67cd0
5
5
  SHA512:
6
- metadata.gz: d57f3bd3991f94dc2f6b3d0d7ad839cdf7d75b23135cab57794f6bb48b8593a3cb003a4f75dc8b182d2e6c42d3db065d61d5b675fbf769e938b1dcd56b9bd40b
7
- data.tar.gz: 18db0151f4a42b0bb5c53c4fb5c034bdd2c900beb5d576d0ddf3d490e079c495c9c4063b1a7be6d767d408bbf766be6496726ff6ac6435915c724dd751bee334
6
+ metadata.gz: f783ed5d51723589855542ffa0a10b61f85ae0ae97e61c245f66a792f6f24e86d573ce27744899a02840383412bd625c2707efe3dd3f6836c4e81a5d68b44af0
7
+ data.tar.gz: 15d99be1cbcb8474be5425372804a3ad7f67fe4489cab40f011e125e075b2885696c1becb069ba54667e5b2d9809b81bcc50f24cfbb239db28f12da44ad81512
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 = isProd || isTest ? false : '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()
@@ -109,7 +142,8 @@ async function main(paths = [], options = {}) {
109
142
  if (write) {
110
143
  return new TextEncoder().encode(JSON.stringify(result))
111
144
  } else {
112
- return result.outputFiles[0].contents
145
+ const fileIndex = params.sourcemap === 'linked' ? 1 : 0
146
+ return result.outputFiles[fileIndex].contents
113
147
  }
114
148
  }
115
149
 
@@ -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.1'
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.1
5
5
  platform: arm64-darwin
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
@@ -186,9 +200,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
186
200
  version: 2.7.0
187
201
  required_rubygems_version: !ruby/object:Gem::Requirement
188
202
  requirements:
189
- - - ">"
203
+ - - ">="
190
204
  - !ruby/object:Gem::Version
191
- version: 1.3.1
205
+ version: '0'
192
206
  requirements: []
193
207
  rubygems_version: 3.3.7
194
208
  signing_key:
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
- }