feat: add bazel build utils
This commit is contained in:
parent
f23eb23b96
commit
0a8bb236cf
8
src/bazel/BUILD.bazel
Normal file
8
src/bazel/BUILD.bazel
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
load("@build_bazel_rules_typescript//:defs.bzl", "nodejs_binary")
|
||||||
|
exports_files(["webpack.config.js", "test.js"])
|
||||||
|
|
||||||
|
nodejs_binary(
|
||||||
|
name = "webpack",
|
||||||
|
entry_point = "webpack/bin/webpack",
|
||||||
|
visibility = ["//visibility:public"],
|
||||||
|
)
|
||||||
0
src/bazel/WORKSPACE
Normal file
0
src/bazel/WORKSPACE
Normal file
109
src/bazel/karma.conf.js
Normal file
109
src/bazel/karma.conf.js
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Warning: the testing rule will change.
|
||||||
|
*
|
||||||
|
* Instead of running karma outside of bazel against the bin_dir directory, we will run it as part of the bazel process.
|
||||||
|
*/
|
||||||
|
module.exports = function(config) {
|
||||||
|
const webpackConfig = {
|
||||||
|
resolveLoader: {
|
||||||
|
alias: {
|
||||||
|
"template-loader": '@nrwl/nx/bazel/template-loader'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.component\.js$/,
|
||||||
|
use: [
|
||||||
|
{loader: 'template-loader' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.html$/,
|
||||||
|
use: [
|
||||||
|
{loader: 'raw-loader' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [
|
||||||
|
{loader: 'raw-loader' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
config.set({
|
||||||
|
// base path that will be used to resolve all patterns (eg. files, exclude)
|
||||||
|
basePath: `${config.opts.bin_dir}/${config.opts.app}`,
|
||||||
|
|
||||||
|
// frameworks to use
|
||||||
|
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
|
||||||
|
frameworks: ['jasmine'],
|
||||||
|
|
||||||
|
// list of files / patterns to load in the browser
|
||||||
|
files: [
|
||||||
|
{ pattern: 'test.js', watched: false}
|
||||||
|
],
|
||||||
|
|
||||||
|
// list of files to exclude
|
||||||
|
exclude: [],
|
||||||
|
|
||||||
|
// preprocess matching files before serving them to the browser
|
||||||
|
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
|
||||||
|
preprocessors: {
|
||||||
|
'test.js': ['webpack']
|
||||||
|
},
|
||||||
|
|
||||||
|
// test results reporter to use
|
||||||
|
// possible values: 'dots', 'progress'
|
||||||
|
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
|
||||||
|
reporters: config.opts.reporters ? config.opts.reporters : (config.opts.progress ? ['progress'] : ['dots']),
|
||||||
|
|
||||||
|
webpack: webpackConfig,
|
||||||
|
|
||||||
|
webpackMiddleware: {
|
||||||
|
stats: 'errors-only'
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
require('karma-jasmine'),
|
||||||
|
require('karma-chrome-launcher'),
|
||||||
|
require('karma-jasmine-html-reporter'),
|
||||||
|
require('karma-coverage-istanbul-reporter'),
|
||||||
|
require('karma-webpack')
|
||||||
|
],
|
||||||
|
|
||||||
|
coverageIstanbulReporter: {
|
||||||
|
reports: [ 'html', 'lcovonly' ],
|
||||||
|
fixWebpackSourcePaths: true
|
||||||
|
},
|
||||||
|
|
||||||
|
client: {
|
||||||
|
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||||
|
},
|
||||||
|
|
||||||
|
// web server port
|
||||||
|
port: config.opts.port ? config.opts.port : 9876,
|
||||||
|
|
||||||
|
// enable / disable colors in the output (reporters and logs)
|
||||||
|
colors: config.opts.colors,
|
||||||
|
|
||||||
|
// level of logging
|
||||||
|
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
|
||||||
|
logLevel: config.opts.log ? config.opts.log: config.LOG_INFO,
|
||||||
|
|
||||||
|
// enable / disable watching file and executing tests whenever any file changes
|
||||||
|
autoWatch: true,
|
||||||
|
|
||||||
|
// start these browsers
|
||||||
|
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
|
||||||
|
// browsers: ['PhantomJS'],
|
||||||
|
browsers: ['Chrome'],
|
||||||
|
|
||||||
|
// Concurrency level
|
||||||
|
// how many browser should be started simultaneous
|
||||||
|
concurrency: Infinity
|
||||||
|
});
|
||||||
|
};
|
||||||
30
src/bazel/template-loader.js
Normal file
30
src/bazel/template-loader.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
var templateUrlRegex = /templateUrl\s*:(\s*['"`](.*?)['"`]\s*([,}]))/gm;
|
||||||
|
var stylesRegex = /styleUrls *:(\s*\[[^\]]*?\])/g;
|
||||||
|
var stringRegex = /(['`"])((?:[^\\]\\\1|.)*?)\1/g;
|
||||||
|
|
||||||
|
function replaceStringsWithRequires(string) {
|
||||||
|
return string.replace(stringRegex, function (match, quote, url) {
|
||||||
|
if (url.charAt(0) !== ".") {
|
||||||
|
url = "./" + url;
|
||||||
|
}
|
||||||
|
return "require('" + url + "')";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function(source, sourcemap) {
|
||||||
|
// Not cacheable during unit tests;
|
||||||
|
this.cacheable && this.cacheable();
|
||||||
|
|
||||||
|
var newSource = source.replace(templateUrlRegex, function (match, url) {
|
||||||
|
return "template:" + replaceStringsWithRequires(url);
|
||||||
|
}).replace(stylesRegex, function (match, urls) {
|
||||||
|
return "styles:" + replaceStringsWithRequires(urls);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Support for tests
|
||||||
|
if (this.callback) {
|
||||||
|
this.callback(null, newSource, sourcemap)
|
||||||
|
} else {
|
||||||
|
return newSource;
|
||||||
|
}
|
||||||
|
};
|
||||||
62
src/bazel/webpack.bzl
Normal file
62
src/bazel/webpack.bzl
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
def _collect_es5_sources_impl(target, ctx):
|
||||||
|
result = set()
|
||||||
|
if hasattr(ctx.rule.attr, "srcs"):
|
||||||
|
for dep in ctx.rule.attr.srcs:
|
||||||
|
if hasattr(dep, "es5_sources"):
|
||||||
|
result += dep.es5_sources
|
||||||
|
if hasattr(target, "typescript"):
|
||||||
|
result += target.typescript.es5_sources
|
||||||
|
return struct(es5_sources = result)
|
||||||
|
|
||||||
|
_collect_es5_sources = aspect(
|
||||||
|
_collect_es5_sources_impl,
|
||||||
|
attr_aspects = ["deps", "srcs"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _webpack_bundle_impl(ctx):
|
||||||
|
inputs = set()
|
||||||
|
for s in ctx.attr.srcs:
|
||||||
|
if hasattr(s, "es5_sources"):
|
||||||
|
inputs += s.es5_sources
|
||||||
|
|
||||||
|
config = ctx.files.config
|
||||||
|
|
||||||
|
if ctx.attr.mode == 'prod':
|
||||||
|
main = ctx.new_file('bundles/main.bundle.prod.js')
|
||||||
|
polyfills = ctx.new_file('bundles/polyfills.bundle.prod.js')
|
||||||
|
vendor = ctx.new_file('bundles/vendor.bundle.prod.js')
|
||||||
|
styles = ctx.new_file('bundles/styles.bundle.prod.js')
|
||||||
|
else:
|
||||||
|
main = ctx.new_file('bundles/main.bundle.js')
|
||||||
|
polyfills = ctx.new_file('bundles/polyfills.bundle.js')
|
||||||
|
vendor = ctx.new_file('bundles/vendor.bundle.js')
|
||||||
|
styles = ctx.new_file('bundles/styles.bundle.js')
|
||||||
|
|
||||||
|
inputs += [config]
|
||||||
|
args = []
|
||||||
|
|
||||||
|
if ctx.attr.mode == 'prod':
|
||||||
|
args += ['-p']
|
||||||
|
|
||||||
|
args += ['--config', config.path]
|
||||||
|
args += ['--env.bin_dir', ctx.configuration.bin_dir.path]
|
||||||
|
args += ['--env.package', ctx.label.package]
|
||||||
|
args += ['--env.mode', ctx.attr.mode]
|
||||||
|
|
||||||
|
ctx.action(
|
||||||
|
progress_message = "Webpack bundling %s" % ctx.label,
|
||||||
|
inputs = inputs.to_list(),
|
||||||
|
outputs = [main, polyfills, vendor, styles],
|
||||||
|
executable = ctx.executable._webpack,
|
||||||
|
arguments = args,
|
||||||
|
)
|
||||||
|
return DefaultInfo(files=depset([main, polyfills, vendor, styles]))
|
||||||
|
|
||||||
|
webpack_bundle = rule(implementation = _webpack_bundle_impl,
|
||||||
|
attrs = {
|
||||||
|
"srcs": attr.label_list(allow_files=True, aspects=[_collect_es5_sources]),
|
||||||
|
"config": attr.label(allow_single_file=True, mandatory=True),
|
||||||
|
"mode": attr.string(default="dev"),
|
||||||
|
"_webpack": attr.label(default=Label("@nrwl//:webpack"), executable=True, cfg="host")
|
||||||
|
}
|
||||||
|
)
|
||||||
185
src/bazel/webpack.config.js
Normal file
185
src/bazel/webpack.config.js
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const ProgressPlugin = require('webpack/lib/ProgressPlugin');
|
||||||
|
const CircularDependencyPlugin = require('circular-dependency-plugin');
|
||||||
|
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||||
|
|
||||||
|
const { NoEmitOnErrorsPlugin, SourceMapDevToolPlugin, NamedModulesPlugin } = require('webpack');
|
||||||
|
const { GlobCopyWebpackPlugin, NamedLazyChunksWebpackPlugin, BaseHrefWebpackPlugin } = require('@angular/cli/plugins/webpack');
|
||||||
|
const { CommonsChunkPlugin } = require('webpack').optimize;
|
||||||
|
|
||||||
|
const nodeModules = path.join(process.cwd(), 'node_modules');
|
||||||
|
const realNodeModules = fs.realpathSync(nodeModules);
|
||||||
|
|
||||||
|
const entryPoints = ["inline", "polyfills", "styles", "vendor", "main"];
|
||||||
|
const baseHref = "";
|
||||||
|
|
||||||
|
module.exports = function(env) {
|
||||||
|
const name = path.parse(env.package).name;
|
||||||
|
const apps = JSON.parse(fs.readFileSync(path.join(process.cwd(), '.angular-cli.json'), 'UTF-8')).apps;
|
||||||
|
const appConfig = apps.filter(a => a.name === name)[0];
|
||||||
|
const out = path.join(process.cwd(), env.bin, env.package, 'bundles');
|
||||||
|
const src = path.join(process.cwd(), env.bin, appConfig.root);
|
||||||
|
|
||||||
|
// victor todo: remove it when ng_module rule is fixed
|
||||||
|
const alias = Object.assign({}, {
|
||||||
|
'@angular/core/core': '@angular/core/@angular/core.es5',
|
||||||
|
'@angular/common/common': '@angular/common/@angular/common.es5',
|
||||||
|
'@angular/platform-browser/platform-browser': '@angular/platform-browser/@angular/platform-browser.es5'
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
"resolve": {
|
||||||
|
"extensions": [
|
||||||
|
".js"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"./node_modules"
|
||||||
|
],
|
||||||
|
"symlinks": true,
|
||||||
|
alias
|
||||||
|
},
|
||||||
|
"resolveLoader": {
|
||||||
|
"modules": [
|
||||||
|
"./node_modules"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entry": {
|
||||||
|
"main": [
|
||||||
|
tsToJs(path.join(src, appConfig.main))
|
||||||
|
],
|
||||||
|
"polyfills": [
|
||||||
|
tsToJs(path.join(src, appConfig.polyfills))
|
||||||
|
],
|
||||||
|
"styles": appConfig.styles.map(s => path.join(src, s))
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"path": out,
|
||||||
|
"filename": "[name].bundle.js",
|
||||||
|
"chunkFilename": "[id].chunk.js"
|
||||||
|
},
|
||||||
|
"module": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"enforce": "pre",
|
||||||
|
"test": /\.js$/,
|
||||||
|
"loader": "source-map-loader",
|
||||||
|
"exclude": [
|
||||||
|
/(\\|\/)node_modules(\\|\/)/
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test": /\.html$/,
|
||||||
|
"loader": "raw-loader"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test": /\.(eot|svg|cur)$/,
|
||||||
|
"loader": "file-loader?name=[name].[hash:20].[ext]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"test": /\.(jpg|png|webp|gif|otf|ttf|woff|woff2|ani)$/,
|
||||||
|
"loader": "url-loader?name=[name].[hash:20].[ext]&limit=10000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
new NoEmitOnErrorsPlugin(),
|
||||||
|
new GlobCopyWebpackPlugin({
|
||||||
|
"patterns": [
|
||||||
|
"assets",
|
||||||
|
"favicon.ico"
|
||||||
|
],
|
||||||
|
"globOptions": {
|
||||||
|
"cwd": src,
|
||||||
|
"dot": true,
|
||||||
|
"ignore": "**/.gitkeep"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
new ProgressPlugin(),
|
||||||
|
new CircularDependencyPlugin({
|
||||||
|
"exclude": /(\\|\/)node_modules(\\|\/)/,
|
||||||
|
"failOnError": false
|
||||||
|
}),
|
||||||
|
new NamedLazyChunksWebpackPlugin(),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
"template": path.join(src, 'index.html'),
|
||||||
|
"filename": "./index.html",
|
||||||
|
"hash": false,
|
||||||
|
"inject": true,
|
||||||
|
"compile": true,
|
||||||
|
"favicon": false,
|
||||||
|
"minify": false,
|
||||||
|
"cache": true,
|
||||||
|
"showErrors": true,
|
||||||
|
"chunks": "all",
|
||||||
|
"excludeChunks": [],
|
||||||
|
"xhtml": true,
|
||||||
|
"chunksSortMode": function sort(left, right) {
|
||||||
|
let leftIndex = entryPoints.indexOf(left.names[0]);
|
||||||
|
let rightindex = entryPoints.indexOf(right.names[0]);
|
||||||
|
if (leftIndex > rightindex) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
else if (leftIndex < rightindex) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
new BaseHrefWebpackPlugin({}),
|
||||||
|
new CommonsChunkPlugin({
|
||||||
|
"name": [
|
||||||
|
"inline"
|
||||||
|
],
|
||||||
|
"minChunks": null
|
||||||
|
}),
|
||||||
|
new CommonsChunkPlugin({
|
||||||
|
"name": [
|
||||||
|
"vendor"
|
||||||
|
],
|
||||||
|
"minChunks": (module) => {
|
||||||
|
return module.resource
|
||||||
|
&& (module.resource.startsWith(nodeModules)
|
||||||
|
|| module.resource.startsWith(realNodeModules));
|
||||||
|
},
|
||||||
|
"chunks": [
|
||||||
|
"main"
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
new SourceMapDevToolPlugin({
|
||||||
|
"filename": "[file].map[query]",
|
||||||
|
"moduleFilenameTemplate": "[resource-path]",
|
||||||
|
"fallbackModuleFilenameTemplate": "[resource-path]?[hash]",
|
||||||
|
"sourceRoot": "webpack:///"
|
||||||
|
}),
|
||||||
|
new CommonsChunkPlugin({
|
||||||
|
"name": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"minChunks": 2,
|
||||||
|
"async": "common"
|
||||||
|
}),
|
||||||
|
new NamedModulesPlugin({}),
|
||||||
|
],
|
||||||
|
"node": {
|
||||||
|
"fs": "empty",
|
||||||
|
"global": true,
|
||||||
|
"crypto": "empty",
|
||||||
|
"tls": "empty",
|
||||||
|
"net": "empty",
|
||||||
|
"process": true,
|
||||||
|
"module": false,
|
||||||
|
"clearImmediate": false,
|
||||||
|
"setImmediate": false
|
||||||
|
},
|
||||||
|
"devServer": {
|
||||||
|
"historyApiFallback": true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function tsToJs(s) {
|
||||||
|
return `${s.substring(0, s.length - 3)}.js`;
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package(default_visibility = ["//visibility:public"])
|
package(default_visibility = ["//visibility:public"])
|
||||||
load("@build_bazel_rules_angular//:defs.bzl", "ng_module")
|
load("@build_bazel_rules_angular//:defs.bzl", "ng_module")
|
||||||
load("@build_bazel_rules_nrwl//:webpack.bzl", "webpack_bundle")
|
load("@nrwl//:webpack.bzl", "webpack_bundle")
|
||||||
|
|
||||||
ng_module(
|
ng_module(
|
||||||
name = "compile",
|
name = "compile",
|
||||||
@ -9,6 +9,7 @@ ng_module(
|
|||||||
tsconfig = "//:tsconfig.json"
|
tsconfig = "//:tsconfig.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# temporary work-around to handle static
|
||||||
genrule(
|
genrule(
|
||||||
name = "copy_static",
|
name = "copy_static",
|
||||||
srcs = ["src"] + glob(["src/**/*"], exclude=["**/*.ts"]),
|
srcs = ["src"] + glob(["src/**/*"], exclude=["**/*.ts"]),
|
||||||
@ -31,5 +32,5 @@ filegroup(
|
|||||||
webpack_bundle(
|
webpack_bundle(
|
||||||
name = "<%= name %>",
|
name = "<%= name %>",
|
||||||
srcs = ["compile_and_static"],
|
srcs = ["compile_and_static"],
|
||||||
config = "@build_bazel_rules_nrwl//:webpack.config.js"
|
config = "@nrwl//:webpack.config.js"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {insert} from '../utility/ast-utils';
|
|||||||
function addBootstrap(path: string): Rule {
|
function addBootstrap(path: string): Rule {
|
||||||
return (host: Tree) => {
|
return (host: Tree) => {
|
||||||
const modulePath = `${path}/app/app.module.ts`;
|
const modulePath = `${path}/app/app.module.ts`;
|
||||||
const moduleSource = host.read(modulePath) !.toString('utf-8');
|
const moduleSource = host.read(modulePath)!.toString('utf-8');
|
||||||
const sourceFile = ts.createSourceFile(modulePath, moduleSource, ts.ScriptTarget.Latest, true);
|
const sourceFile = ts.createSourceFile(modulePath, moduleSource, ts.ScriptTarget.Latest, true);
|
||||||
const importChanges = addImportToModule(sourceFile, modulePath, 'BrowserModule', '@angular/platform-browser');
|
const importChanges = addImportToModule(sourceFile, modulePath, 'BrowserModule', '@angular/platform-browser');
|
||||||
const bootstrapChanges = addBootstrapToModule(sourceFile, modulePath, 'AppComponent', './app.component');
|
const bootstrapChanges = addBootstrapToModule(sourceFile, modulePath, 'AppComponent', './app.component');
|
||||||
|
|||||||
@ -3,6 +3,7 @@ exports_files(["tsconfig.json"])
|
|||||||
|
|
||||||
load("@build_bazel_rules_angular//:defs.bzl", "ng_external_libraries")
|
load("@build_bazel_rules_angular//:defs.bzl", "ng_external_libraries")
|
||||||
|
|
||||||
|
# change to glob(["node_modules/**/*.js", "node_modules/**/*.json", "node_modules/**/*.d.ts"])
|
||||||
filegroup(name = "node_modules", srcs = glob([
|
filegroup(name = "node_modules", srcs = glob([
|
||||||
# should not be whitelisted
|
# should not be whitelisted
|
||||||
"node_modules/@angular/**",
|
"node_modules/@angular/**",
|
||||||
@ -20,14 +21,13 @@ filegroup(name = "node_modules", srcs = glob([
|
|||||||
"node_modules/webpack/**"
|
"node_modules/webpack/**"
|
||||||
], exclude=["node_modules/@angular/cli/**"]))
|
], exclude=["node_modules/@angular/cli/**"]))
|
||||||
|
|
||||||
|
# this should go away soon when we do bootstrap codegen in the scripts.postinstall
|
||||||
ng_external_libraries(name = "ng_libs", srcs = glob([
|
ng_external_libraries(name = "ng_libs", srcs = glob([
|
||||||
"node_modules/@angular/**"
|
"node_modules/@angular/**"
|
||||||
], exclude = [
|
], exclude = [
|
||||||
"node_modules/@angular/cli/**",
|
"node_modules/@angular/cli/**",
|
||||||
"node_modules/@angular/platform-browser/animations*",
|
"node_modules/@angular/platform-browser/animations*",
|
||||||
"node_modules/@angular/platform-browser/animations/**",
|
"node_modules/@angular/platform-browser/animations/**",
|
||||||
|
|
||||||
# Alex E?
|
|
||||||
"node_modules/@angular/router*",
|
"node_modules/@angular/router*",
|
||||||
"node_modules/@angular/router/**",
|
"node_modules/@angular/router/**",
|
||||||
]))
|
]))
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# <%= className %>
|
# <%= className %>
|
||||||
|
|
||||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version <%= version %>.
|
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version EXPERIMENTAL BAZEL.
|
||||||
|
|
||||||
## Development server
|
## Development server
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user