Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OFFI-15: Lombiq.VueJs plugin support and bug fixes #126

Merged
merged 22 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ wwwroot/
node_modules/
*.user
.pnpm-debug.log
*.orig
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,11 @@ module.exports = function vuePlugin() {
const className = 'VueComponent-' + pascalCaseName;

// Inject name and template properties. The "(?<!\/\/.*)" is a negative lookbehind to ignore comments.
const pattern = /(?<!\/\/.*)export\s+default\s*{/;
const pattern = /(?<!\/\/.*)export\s+default([^{]*){/;
if (!code.match(pattern)) throw new Error("Couldn't match 'export default {' in the source code!");
code = code.replace(
pattern,
`export default { name: '${componentName}', template: document.querySelector('.${className}').innerHTML,` +
`export default$1{ name: '${componentName}', template: document.querySelector('.${className}').innerHTML,` +
// This line is intentionally compressed to simplify the mapping.
' /* eslint-disable-line */' +
// We can't verify this rule, because line breaks added by the code will always be LF even on Windows
Expand Down
24 changes: 22 additions & 2 deletions Lombiq.VueJs/Assets/Scripts/helpers/vue-sfc-compiler-pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,32 @@ const defaultOptions = {
sfcDestinationPath: path.join('wwwroot', 'vue'),
vueJsNodeModulesPath: path.resolve(__dirname, '..', '..', '..', 'node_modules'),
rollupAlias: {},
rollupNodeResolve: { preferBuiltins: true, browser: true, mainFields: ['module', 'jsnext:main'] },
isProduction: false,
};

function processRollupNodeResolve(opts) {
if (!opts.rollupNodeResolve) opts.rollupNodeResolve = {};

if (Array.isArray(opts.rollupNodeResolve.resolveOnlyRules)) {
const rules = opts.rollupNodeResolve.resolveOnlyRules;

opts.rollupNodeResolve.resolveOnly = function resolveOnly(item) {
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
if (rule.regex && item.match(new RegExp(rule.value))) return !!rule.include;
if (!rule.regex && item === rule.value) return !!rule.include;
}

return true;
};
}
}

function compile(options) {
const fileOptions = tryOpenJson('vue-sfc-compiler-pipeline.json');
const opts = options ? { ...defaultOptions, ...fileOptions, ...options } : defaultOptions;
const opts = { ...defaultOptions, ...fileOptions, ...(options ?? { }) };
processRollupNodeResolve(opts);

if (!fs.existsSync(opts.sfcRootPath)) return Promise.resolve([]);
const components = getVueComponents(opts.sfcRootPath);
Expand All @@ -47,7 +67,7 @@ function compile(options) {
vuePlugin(),
json(),
alias(opts.rollupAlias),
nodeResolve({ preferBuiltins: true, browser: true, mainFields: ['module', 'jsnext:main'] }), // #spell-check-ignore-line
nodeResolve(opts.rollupNodeResolve),
replace({
values: {
'process.env.NODE_ENV': JSON.stringify(opts.isProduction ? 'production' : 'development'),
Expand Down
26 changes: 20 additions & 6 deletions Lombiq.VueJs/Assets/Scripts/vue-component-app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,32 @@ window.VueApplications = window.VueApplications ?? { };
function toKebabCase(camelCase) {
return Array
.from(camelCase)
.map((letter) => { const lowerCase = letter.toLowerCase(); return letter === lowerCase ? letter : '-' + lowerCase })
.map((letter) => {
const lowerCase = letter.toLowerCase();
return letter === lowerCase ? letter : '-' + lowerCase;
})
.join('');
}

document.querySelectorAll('.lombiq-vue').forEach(async function initializeVueComponentApp(element) {
const { name, model } = JSON.parse(element.dataset.vue);
const component = (await import(name + '.vue')).default;

const plugins = await Promise.all(element
.dataset
.plugins
.split(',')
.filter((word) => word?.trim())
.map(async (word) => (await import(word.trim())).default));

const hasEmit = Array.isArray(component?.emit);
const vModel = Object
.keys(model)
.map(property => ({ property: property, eventName: 'update:' + toKebabCase(property) }))
.filter(pair => !hasEmit || component.emit.includes(pair.eventName))
.map(pair => ` @${pair.eventName}="viewModel.${pair.property} = $event"`);
.map((property) => ({ property: property, eventName: 'update:' + toKebabCase(property) }))
.filter((pair) => !hasEmit || component.emit.includes(pair.eventName))
.map((pair) => ` @${pair.eventName}="viewModel.${pair.property} = $event"`);

createApp({
const app = createApp({
data: function data() {
return { viewModel: model, root: element };
},
Expand All @@ -30,5 +40,9 @@ document.querySelectorAll('.lombiq-vue').forEach(async function initializeVueCom
window.VueApplications[element.id] = this;
this.$appId = element.id;
},
}).mount(element);
});

plugins.forEach((plugin) => app.use(plugin));

app.mount(element);
});
4 changes: 4 additions & 0 deletions Lombiq.VueJs/TagHelpers/VueComponentAppTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ public class VueComponentAppTagHelper : VueComponentTagHelper
[HtmlAttributeName("model")]
public object Model { get; set; } = new { };

[HtmlAttributeName("plugins")]
public string Plugins { get; set; }

public VueComponentAppTagHelper(
IDisplayHelper displayHelper,
IHttpContextAccessor hca,
Expand All @@ -54,6 +57,7 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu
["id"] = string.IsNullOrWhiteSpace(Id) ? $"{Name}_{Guid.NewGuid():D}" : Id,
["class"] = $"{Class} lombiq-vue".Trim(),
["data-vue"] = JsonSerializer.Serialize(new { Name, Model }, _jsonSerializerOptions),
["data-plugins"] = Plugins ?? string.Empty,
},
TagRenderMode = TagRenderMode.Normal,
});
Expand Down
2 changes: 1 addition & 1 deletion Lombiq.VueJs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"dependencies": {
"axios": "1.6.0",
"source-map": "^0.7.3",
"vue": "3.4.19"
"vue": "3.4.21"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then this should be updated in ResourceManagementOptionsConfiguration as well.

},
"devDependencies": {
"@rollup/plugin-alias": "^3.1.9",
Expand Down
96 changes: 48 additions & 48 deletions Lombiq.VueJs/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 31 additions & 2 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,33 @@ The packages will be automatically installed on build (i.e. `dotnet build`). You

The build script can be configured by placing a JSON file (called _vue-sfc-compiler-pipeline.json_) in the project root. It can contain the same properties you can see in the `defaultOptions` variables in [vue-sfc-compiler-pipeline.js](Lombiq.VueJs/Assets/scripts/helpers/vue-sfc-compiler-pipeline.js). Any property set in the JSON file overrides the default value as the two objects are merged.

When configuring the `rollupNodeResolve` option (for [`@rollup/plugin-node-resolve`](https://www.npmjs.com/package/@rollup/plugin-node-resolve)), normally you could only pass in an array of exact matches due to the limitation of the JSON format. Instead, you can use `rollupNodeResolve.resolveOnlyRules` which is an object array in the following format:

```json
{
"rollupNodeResolve": {
"preferBuiltins": true,
"browser": true,
"mainFields": ["module", "jsnext:main"],
"resolveOnlyRules": [
{
"regex": false,
"include": false,
"value": "vue"
},
{
"regex": true,
" ": false,
"value": "^vuetify"
}
]
},
"isProduction": false
}
```

Here we excluded `vue` and packages starting with `vuetify` (e.g. `vuetify/components`) from the resolution, so they are treated as external. Then you can add `vuetify` using the resource manifest as a script module.

## Using Vue.js Single File Components

The module identifies Single File Components in the _Assets/Scripts/VueComponents_ directory and harvests them as shapes. They have a custom _.vue_ file renderer that displays the content of the `<template>` element after applying localization for the custom `[[ ... ]]` expression that calls `IStringLocalizer`. Besides that, it's pure Vue, yet you can still make use of shape overriding if needed.
Expand Down Expand Up @@ -106,10 +133,12 @@ export default {
In most cases you'll want to place a full app on a page and initialize it with server-side data. You can achieve this by using the tag helper like this:

```razor
<vue-component-app area="My.Module" name="my-article" model="@new { Title = "Hello World!", Date = DateTime.Now }" />
<vue-component-app area="My.Module" name="my-article" model="@new { Title = "Hello World!", Date = DateTime.Now }" plugins="resourceName1, resourceName2" />
```

This will automatically create a new Vue app that only contains the component identified by the `name` attribute (i.e. _/Assets/Scripts/VueComponents/my-article.vue_). The app has the data from the `model` attribute which is bound to the component. (By the way if your .vue and .cshtml files are in the same Orchard Core module, then you can even skip the `area` attribute.) The only other consideration is that if your SFC has child components, those have to be declared in the Orchard Core resource manifest options. It would be like `_manifest.DefineSingleFileComponent("my-article").SetDependencies("my-child-component");`. Components that don't have children don't have to be declared this way.
This will automatically create a new Vue app that only contains the component identified by the `name` attribute (i.e. _/Assets/Scripts/VueComponents/my-article.vue_). The app gets the data from the `model` attribute which is bound to the component. (By the way if your .vue and .cshtml files are in the same Orchard Core module, then you can even skip the `area` attribute.) The only other consideration is that if your SFC has child components, those have to be declared in the Orchard Core resource manifest options. It would be like `_manifest.DefineSingleFileComponent("my-article").SetDependencies("my-child-component");`. Components that don't have children don't have to be declared this way.

The optional `plugins` attribute can contain a comma separated list of resource names. These are `script-module` resources that you can register like `_manifest.DefineScriptModule("resourceName1").SetUrl(...)`. Such modules are expected to `export default` a plugin object that can be dynamically imported and passed to `app.use()` (i.e. `app.use(await import('resourceName1').default)`).

For more details and a demo of the full feature set check out the samples project!

Expand Down