```
✨ feat(newsletter): Ajoute l'éditeur de template d'email avec Preact
Crée un nouvel éditeur de template d'email en utilisant Preact et
react-email-editor, et l'intègre au contrôleur et aux vues.
```
This commit is contained in:
@@ -5,6 +5,8 @@ import {ServerCard} from './class/ServerCard'
|
|||||||
import {AutoCustomer} from './class/AutoCustomer'
|
import {AutoCustomer} from './class/AutoCustomer'
|
||||||
import {RepeatLine} from './class/RepeatLine'
|
import {RepeatLine} from './class/RepeatLine'
|
||||||
import {OrderCtrl} from './class/OrderCtrl'
|
import {OrderCtrl} from './class/OrderCtrl'
|
||||||
|
import {MainframeEmailEditor} from './class/MainframeEmailEditor'
|
||||||
|
import preactCustomElement from './functions/preact'
|
||||||
|
|
||||||
|
|
||||||
function script() {
|
function script() {
|
||||||
@@ -13,6 +15,7 @@ function script() {
|
|||||||
customElements.define('auto-customer',AutoCustomer,{extends:'button'})
|
customElements.define('auto-customer',AutoCustomer,{extends:'button'})
|
||||||
customElements.define('repeat-line',RepeatLine,{extends:'div'})
|
customElements.define('repeat-line',RepeatLine,{extends:'div'})
|
||||||
customElements.define('order-ctrl',OrderCtrl,{extends:'div'})
|
customElements.define('order-ctrl',OrderCtrl,{extends:'div'})
|
||||||
|
preactCustomElement("email-builder",MainframeEmailEditor)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,8 @@ input {
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ee {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
38
assets/class/MainframeEmailEditor.jsx
Normal file
38
assets/class/MainframeEmailEditor.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import EmailEditor, { EditorRef, EmailEditorProps } from 'react-email-editor';
|
||||||
|
import { useRef } from 'preact/hooks';
|
||||||
|
|
||||||
|
export function MainframeEmailEditor() {
|
||||||
|
const emailEditorRef = useRef(null);
|
||||||
|
|
||||||
|
const exportHtml = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const unlayer = emailEditorRef.current?.editor;
|
||||||
|
|
||||||
|
unlayer?.exportHtml((data) => {
|
||||||
|
const { design, html } = data;
|
||||||
|
|
||||||
|
let format = JSON.stringify({
|
||||||
|
design,
|
||||||
|
html
|
||||||
|
})
|
||||||
|
document.body.querySelector('#template_content').setAttribute('value',format);
|
||||||
|
document.body.querySelector('#template_content').value = format;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onReady =(unlayer) => {
|
||||||
|
let html = document.body.querySelector('#template_content').getAttribute('value');
|
||||||
|
let data = JSON.parse(html);
|
||||||
|
unlayer.loadDesign(data.design);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<button class="button" onClick={exportHtml}>Sauvegarde le html</button>
|
||||||
|
</div>
|
||||||
|
<EmailEditor locale="fr-FR" ref={emailEditorRef} onReady={onReady} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
assets/functions/preact.js
Normal file
152
assets/functions/preact.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { h, cloneElement, render, hydrate } from 'preact'
|
||||||
|
|
||||||
|
export default function preactCustomElement (tagName, Component, propNames, options) {
|
||||||
|
function PreactElement () {
|
||||||
|
const inst = Reflect.construct(HTMLElement, [], PreactElement)
|
||||||
|
inst._vdomComponent = Component
|
||||||
|
inst._root = options && options.shadow ? inst.attachShadow({ mode: 'open' }) : inst
|
||||||
|
return inst
|
||||||
|
}
|
||||||
|
PreactElement.prototype = Object.create(HTMLElement.prototype)
|
||||||
|
PreactElement.prototype.constructor = PreactElement
|
||||||
|
PreactElement.prototype.connectedCallback = connectedCallback
|
||||||
|
PreactElement.prototype.attributeChangedCallback = attributeChangedCallback
|
||||||
|
PreactElement.prototype.disconnectedCallback = disconnectedCallback
|
||||||
|
|
||||||
|
propNames = propNames || Component.observedAttributes || Object.keys(Component.propTypes || {})
|
||||||
|
PreactElement.observedAttributes = propNames
|
||||||
|
|
||||||
|
// Keep DOM properties and Preact props in sync
|
||||||
|
propNames.forEach(name => {
|
||||||
|
Object.defineProperty(PreactElement.prototype, name, {
|
||||||
|
get () {
|
||||||
|
return this._vdom.props[name]
|
||||||
|
},
|
||||||
|
set (v) {
|
||||||
|
if (this._vdom) {
|
||||||
|
this.attributeChangedCallback(name, null, v)
|
||||||
|
} else {
|
||||||
|
if (!this._props) this._props = {}
|
||||||
|
this._props[name] = v
|
||||||
|
this.connectedCallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflect property changes to attributes if the value is a primitive
|
||||||
|
const type = typeof v
|
||||||
|
if (v == null || type === 'string' || type === 'boolean' || type === 'number') {
|
||||||
|
this.setAttribute(name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return customElements.define(tagName || Component.tagName || Component.displayName || Component.name, PreactElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextProvider (props) {
|
||||||
|
this.getChildContext = () => props.context
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const { context, children, ...rest } = props
|
||||||
|
return cloneElement(children, rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectedCallback () {
|
||||||
|
// Obtain a reference to the previous context by pinging the nearest
|
||||||
|
// higher up node that was rendered with Preact. If one Preact component
|
||||||
|
// higher up receives our ping, it will set the `detail` property of
|
||||||
|
// our custom event. This works because events are dispatched
|
||||||
|
// synchronously.
|
||||||
|
const event = new CustomEvent('_preact', {
|
||||||
|
detail: {},
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
this.dispatchEvent(event)
|
||||||
|
const context = event.detail.context
|
||||||
|
|
||||||
|
this._vdom = h(ContextProvider, { ...this._props, context }, toVdom(this, this._vdomComponent))
|
||||||
|
;(this.hasAttribute('hydrate') ? hydrate : render)(this._vdom, this._root)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCamelCase (str) {
|
||||||
|
return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function attributeChangedCallback (name, oldValue, newValue) {
|
||||||
|
if (!this._vdom) return
|
||||||
|
// Attributes use `null` as an empty value whereas `undefined` is more
|
||||||
|
// common in pure JS components, especially with default parameters.
|
||||||
|
// When calling `node.removeAttribute()` we'll receive `null` as the new
|
||||||
|
// value. See issue #50.
|
||||||
|
newValue = newValue == null ? undefined : newValue
|
||||||
|
const props = {}
|
||||||
|
props[name] = newValue
|
||||||
|
props[toCamelCase(name)] = newValue
|
||||||
|
this._vdom = cloneElement(this._vdom, props)
|
||||||
|
render(this._vdom, this._root)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectedCallback () {
|
||||||
|
render((this._vdom = null), this._root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pass an event listener to each `<slot>` that "forwards" the current
|
||||||
|
* context value to the rendered child. The child will trigger a custom
|
||||||
|
* event, where will add the context value to. Because events work
|
||||||
|
* synchronously, the child can immediately pull of the value right
|
||||||
|
* after having fired the event.
|
||||||
|
*/
|
||||||
|
function Slot (props, context) {
|
||||||
|
const ref = r => {
|
||||||
|
if (!r) {
|
||||||
|
this.ref.removeEventListener('_preact', this._listener)
|
||||||
|
} else {
|
||||||
|
this.ref = r
|
||||||
|
if (!this._listener) {
|
||||||
|
this._listener = event => {
|
||||||
|
event.stopPropagation()
|
||||||
|
event.detail.context = context
|
||||||
|
}
|
||||||
|
r.addEventListener('_preact', this._listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h('slot', { ...props, ref })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVdom (element, nodeName) {
|
||||||
|
if (element.nodeType === Node.TEXT_NODE) {
|
||||||
|
const data = element.data
|
||||||
|
element.data = ''
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
if (element.nodeType !== Node.ELEMENT_NODE) return null
|
||||||
|
const children = []
|
||||||
|
const props = {}
|
||||||
|
let i = 0
|
||||||
|
const a = element.attributes
|
||||||
|
const cn = element.childNodes
|
||||||
|
for (i = a.length; i--; ) {
|
||||||
|
if (a[i].name !== 'slot') {
|
||||||
|
props[a[i].name] = a[i].value
|
||||||
|
props[toCamelCase(a[i].name)] = a[i].value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
props.parent = element
|
||||||
|
|
||||||
|
for (i = cn.length; i--; ) {
|
||||||
|
const vnode = toVdom(cn[i], null)
|
||||||
|
// Move slots correctly
|
||||||
|
const name = cn[i].slot
|
||||||
|
if (name) {
|
||||||
|
props[name] = h(Slot, { name }, vnode)
|
||||||
|
} else {
|
||||||
|
children[i] = vnode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only wrap the topmost node with a slot
|
||||||
|
const wrappedChildren = nodeName ? h(Slot, null, children) : children
|
||||||
|
return h(nodeName || element.nodeName.toLowerCase(), props, wrappedChildren)
|
||||||
|
}
|
||||||
36
migrations/Version20250801135113.php
Normal file
36
migrations/Version20250801135113.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20250801135113 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE template ALTER content TYPE TEXT');
|
||||||
|
$this->addSql('ALTER TABLE template ALTER content DROP NOT NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN template.content IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE SCHEMA public');
|
||||||
|
$this->addSql('ALTER TABLE template ALTER content TYPE TEXT');
|
||||||
|
$this->addSql('ALTER TABLE template ALTER content SET NOT NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN template.content IS \'(DC2Type:array)\'');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,10 +21,15 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grafikart/drop-files-element": "^1.0.9",
|
"@grafikart/drop-files-element": "^1.0.9",
|
||||||
|
"@grapesjs/studio-sdk": "^1.0.55",
|
||||||
|
"@grapesjs/studio-sdk-plugins": "^1.0.27",
|
||||||
"@hotwired/turbo": "^8.0.13",
|
"@hotwired/turbo": "^8.0.13",
|
||||||
|
"@preact/preset-vite": "^2.10.2",
|
||||||
"@sentry/browser": "^9.34.0",
|
"@sentry/browser": "^9.34.0",
|
||||||
"@tailwindcss/vite": "^4.1.10",
|
"@tailwindcss/vite": "^4.1.10",
|
||||||
|
"@usewaypoint/email-builder": "^0.0.8",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"react-email-editor": "^1.7.11",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.6",
|
||||||
"tailwindcss": "^4.1.10"
|
"tailwindcss": "^4.1.10"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Controller\Artemis\Newsletter;
|
namespace App\Controller\Artemis\Newsletter;
|
||||||
|
|
||||||
|
use App\Entity\Newsletter\Template;
|
||||||
|
use App\Form\Artemis\Newsletter\TemplateType;
|
||||||
use App\Repository\Newsletter\TemplateRepository;
|
use App\Repository\Newsletter\TemplateRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
@@ -34,4 +38,36 @@ class TemplateController extends AbstractController
|
|||||||
//button link
|
//button link
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/artemis/newsletter/template/add',name: 'artemis_newsletter_template_add',methods: ['GET', 'POST'])]
|
||||||
|
#[Route(path: '/artemis/newsletter/template/{id}',name: 'artemis_newsletter_template_edit',methods: ['GET', 'POST'])]
|
||||||
|
public function templateEditor(?Template $template,EntityManagerInterface $entityManager,Request $request): Response
|
||||||
|
{
|
||||||
|
if(is_null($template)){
|
||||||
|
$template = new Template();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$form = $this->createForm(TemplateType::class,$template);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
if($form->isSubmitted() && $form->isValid()){
|
||||||
|
$entityManager->persist($template);
|
||||||
|
$entityManager->flush();
|
||||||
|
$this->addFlash("success","Mise à jour effectuée");
|
||||||
|
return $this->redirectToRoute('artemis_newsletter_template_edit',['id'=>$template->getId()]);
|
||||||
|
|
||||||
|
}
|
||||||
|
return $this->render('artemis/newsletter/template/editor.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
'template' => $template,
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/artemis/newsletter/template/{id}/preview',name: 'artemis_newsletter_template_preview',methods: ['GET', 'POST'])]
|
||||||
|
public function templatePreview(TemplateRepository $templateRepository): Response
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ class Template
|
|||||||
#[ORM\Column(length: 255)]
|
#[ORM\Column(length: 255)]
|
||||||
private ?string $name = null;
|
private ?string $name = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::ARRAY)]
|
#[ORM\Column(type: 'text',nullable: true)]
|
||||||
private array $content = [];
|
private string $content = "";
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
@@ -37,12 +37,12 @@ class Template
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getContent(): array
|
public function getContent(): string
|
||||||
{
|
{
|
||||||
return $this->content;
|
return $this->content;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setContent(array $content): static
|
public function setContent(string $content): static
|
||||||
{
|
{
|
||||||
$this->content = $content;
|
$this->content = $content;
|
||||||
|
|
||||||
|
|||||||
29
src/Form/Artemis/Newsletter/TemplateType.php
Normal file
29
src/Form/Artemis/Newsletter/TemplateType.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form\Artemis\Newsletter;
|
||||||
|
|
||||||
|
use App\Entity\Newsletter\Template;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class TemplateType extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('name',TextType::class,[
|
||||||
|
'label'=>'Nom',
|
||||||
|
'required'=>true,
|
||||||
|
])
|
||||||
|
->add('content',HiddenType::class,[
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver)
|
||||||
|
{
|
||||||
|
$resolver->setDefault('data_class',Template::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="col-span-4 text-center text-gray-400 dark:text-gray-500 font-bold">Aucune liste trouvée.</div>
|
<div class="col-span-4 text-center text-gray-400 dark:text-gray-500 font-bold">Aucun template trouvée.</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
38
templates/artemis/newsletter/template/editor.twig
Normal file
38
templates/artemis/newsletter/template/editor.twig
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends 'artemis/base.twig' %}
|
||||||
|
{% block title %}Template - {{ template.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold mb-6">Template - {{ template.name }}</h1>
|
||||||
|
{% if template.id is not null %}
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<a href="{{ path('artemis_newsletter_template_preview',{id:template.id}) }}" class="ml-2 px-4 py-2 bg-blue-600 text-white font-medium rounded-md shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||||
|
Voir le rendu
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ path('artemis_newsletter_template_edit',{id:template.id,delete :1}) }}" class="ml-2 px-4 py-2 bg-red-600 text-white font-medium rounded-md shadow-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||||
|
Supprimer le template
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||||
|
{{ form_start(form, {'attr': {'class': 'w-full'}}) }}
|
||||||
|
<div class="mb-4">
|
||||||
|
{{ form_label(form.name, null, {'label_attr': {'class': 'block mb-1 font-medium text-gray-700 dark:text-gray-300'}}) }}
|
||||||
|
{{ form_widget(form.name, {'attr': {
|
||||||
|
'class': 'w-full p-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white'
|
||||||
|
}}) }}
|
||||||
|
{{ form_errors(form.name) }}
|
||||||
|
</div>
|
||||||
|
<email-builder></email-builder>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition font-semibold w-full">
|
||||||
|
Sauvegarder
|
||||||
|
</button>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -4,6 +4,7 @@ import { defineConfig } from 'vite';
|
|||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import JavaScriptObfuscator from 'rollup-plugin-javascript-obfuscator';
|
import JavaScriptObfuscator from 'rollup-plugin-javascript-obfuscator';
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
// Si vous utilisez un framework comme Vue ou React, importez son plugin ici
|
// Si vous utilisez un framework comme Vue ou React, importez son plugin ici
|
||||||
// import vue from '@vitejs/plugin-vue';
|
// import vue from '@vitejs/plugin-vue';
|
||||||
@@ -82,6 +83,7 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
// Ajoutez ici les plugins de framework (ex: vue(), react())
|
// Ajoutez ici les plugins de framework (ex: vue(), react())
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
preact(),
|
||||||
// --- PLUGIN D'OBSCURCISSEMENT JAVASCRIPT ---
|
// --- PLUGIN D'OBSCURCISSEMENT JAVASCRIPT ---
|
||||||
// Doit être l'un des derniers plugins pour s'appliquer au code final.
|
// Doit être l'un des derniers plugins pour s'appliquer au code final.
|
||||||
// ATTENTION : Ces options sont très agressives et peuvent casser votre code.
|
// ATTENTION : Ces options sont très agressives et peuvent casser votre code.
|
||||||
|
|||||||
Reference in New Issue
Block a user