From 9f19c23ba8500d89bfccc680e662839be0c81d5d Mon Sep 17 00:00:00 2001 From: olaru Date: Fri, 25 Sep 2026 17:55:14 +0300 Subject: [PATCH 01/22] contact form backend --- composer.json | 1 + config/autoload/mail.global.php | 39 +++ config/config.php | 1 + public/css/contact.css | 2 +- public/js/app.js | 2 +- src/App/assets/js/components/_contact.js | 39 +-- src/App/assets/scss/components/_contact.scss | 99 ++---- src/App/src/ConfigProvider.php | 6 + src/App/src/Factory/ContactServiceFactory.php | 28 ++ .../PostContactCreateHandlerFactory.php | 32 ++ .../src/Handler/PostContactCreateHandler.php | 50 ++++ src/App/src/RoutesDelegator.php | 3 + src/App/src/Service/ContactService.php | 169 +++++++++++ src/Blog/templates/page/contact.html.twig | 52 ++-- src/Page/src/Handler/GetPageViewHandler.php | 8 +- .../Handler/PostContactCreateHandlerTest.php | 168 +++++++++++ test/Unit/App/RoutesDelegatorTest.php | 9 + test/Unit/App/Service/ContactServiceTest.php | 283 ++++++++++++++++++ .../Page/Handler/GetPageViewHandlerTest.php | 27 +- 19 files changed, 880 insertions(+), 138 deletions(-) create mode 100644 config/autoload/mail.global.php create mode 100644 src/App/src/Factory/ContactServiceFactory.php create mode 100644 src/App/src/Factory/PostContactCreateHandlerFactory.php create mode 100644 src/App/src/Handler/PostContactCreateHandler.php create mode 100644 src/App/src/Service/ContactService.php create mode 100644 test/Unit/App/Handler/PostContactCreateHandlerTest.php create mode 100644 test/Unit/App/Service/ContactServiceTest.php diff --git a/composer.json b/composer.json index 2216e27e..ceb62d93 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "doctrine/doctrine-fixtures-bundle": "^4.3", "dotkernel/dot-cache": "^4.0", "dotkernel/dot-errorhandler": "^5.0.0", + "dotkernel/dot-mail": "^5.4", "dotkernel/dot-response-header": "^3.6", "laminas/laminas-component-installer": "^3.5.0", "laminas/laminas-config-aggregator": "^1.17.0", diff --git a/config/autoload/mail.global.php b/config/autoload/mail.global.php new file mode 100644 index 00000000..02ea4af4 --- /dev/null +++ b/config/autoload/mail.global.php @@ -0,0 +1,39 @@ + [ + 'contact' => [ + 'message_options' => [ + 'from' => $contactEmail, + 'from_name' => 'Dotkernel', + 'to' => [$contactEmail], + 'subject' => 'New contact form submission', + 'body' => [ + 'content' => '', + 'charset' => 'utf-8', + ], + 'attachments' => [ + 'files' => [], + 'dir' => [ + 'iterate' => false, + 'path' => 'data/mail/attachments', + 'recursive' => false, + ], + ], + ], + 'transport' => 'sendmail', + ], + 'log' => [ + 'sent' => getcwd() . '/log/mail-sent.log', + ], + ], + 'twig' => [ + 'globals' => [ + 'contact_email' => $contactEmail, + ], + ], +]; diff --git a/config/config.php b/config/config.php index 7120efe4..1fe12615 100644 --- a/config/config.php +++ b/config/config.php @@ -27,6 +27,7 @@ // Dotkernel packages \Dot\ErrorHandler\ConfigProvider::class, \Dot\Log\ConfigProvider::class, + \Dot\Mail\ConfigProvider::class, \Dot\Cache\ConfigProvider::class, \Dot\ResponseHeader\ConfigProvider::class, diff --git a/public/css/contact.css b/public/css/contact.css index a0ef2ec2..ba4a22a1 100644 --- a/public/css/contact.css +++ b/public/css/contact.css @@ -1 +1 @@ -html[data-theme=light] .contact-page{--field-border:#8a8478}html[data-theme=dark] .contact-page{--field-border:#7a746d}.contact-page code{font-family:var(--mono);background:var(--teal-dim);color:var(--teal);border-radius:4px;padding:1px 6px;font-size:.92em}.contact-page :focus-visible{outline:2px solid var(--amber);outline-offset:2px}.contact-label{font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted);font-size:11.5px}.contact-hero{padding:88px 0 64px}.contact-hero .wrap{flex-direction:column;gap:20px;display:flex}.contact-hero h1{letter-spacing:-.02em;font-size:44px;line-height:1.15}.contact-hero p{max-width:700px;font-size:18px;line-height:1.6}.contact-hero strong{color:var(--teal);font-weight:600}.contact-main{padding:0 0 88px}.contact-layout{grid-template-columns:minmax(0,1fr) minmax(0,1.45fr);align-items:start;gap:64px;display:grid}.contact-channels{flex-direction:column;gap:18px;display:flex}.contact-channel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);gap:16px;padding:22px 24px;display:flex}.contact-channel:hover{border-color:var(--teal)}.contact-channel>svg{fill:none;width:20px;height:20px;stroke:var(--teal);stroke-width:1.6px;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0;margin-top:2px}.contact-channel p{font-size:14.5px;line-height:1.65}.contact-channel-body{flex-direction:column;gap:4px;display:flex}.contact-channel-kicker{font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted);font-size:10.5px}.contact-channel-title{font-family:var(--mono);letter-spacing:-.01em;font-size:17px;font-weight:600}.contact-support{background:var(--surface-2);border:1px solid var(--border-soft);border-radius:var(--radius);flex-direction:column;padding:22px 24px;display:flex}.contact-support .contact-label{padding-bottom:12px}.contact-support a{border-top:1px solid var(--border-soft);min-height:44px;color:var(--teal);justify-content:space-between;align-items:center;font-size:14.5px;display:flex}.contact-support a:hover{color:var(--amber)}.contact-form-card{background:var(--surface);border:1px solid var(--border);border-top:3px solid var(--amber);border-radius:var(--radius);padding:34px 36px 36px}.contact-form{flex-direction:column;gap:26px;display:flex}.contact-form[hidden]{display:none}.contact-form-head{flex-direction:column;gap:6px;display:flex}.contact-form-head h2{font-size:20px;line-height:1.2}.contact-form-head p{font-size:14.5px;line-height:1.65}.contact-topics{border:0;margin:0;padding:0}.contact-topics legend{float:none;width:auto;margin-bottom:12px;padding:0}.contact-pills{flex-wrap:wrap;gap:8px;display:flex}.contact-pill{min-height:44px;font-family:var(--mono);color:var(--text-muted);background:var(--surface);border:1px solid var(--field-border);cursor:pointer;border-radius:999px;padding:0 16px;font-size:13.5px;font-weight:500}.contact-pill:hover{border-color:var(--teal)}.contact-pill[aria-pressed=true]{color:var(--teal);background:var(--teal-dim);border-color:var(--teal)}.contact-fields{grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;display:grid}.contact-field{flex-direction:column;gap:7px;display:flex}.contact-field label{font-family:var(--mono);color:var(--text);font-size:13px;font-weight:500}.contact-field textarea{resize:vertical;min-height:160px}.contact-input{width:100%;font-family:var(--sans);color:var(--text);background:var(--surface);border:1px solid var(--field-border);border-radius:8px;padding:11px 14px;font-size:15px;line-height:1.5}.contact-input::placeholder{color:var(--text-dim)}.contact-input:hover{border-color:var(--text-muted)}.contact-input:focus-visible{border-color:var(--amber)}.contact-consent{color:var(--text-muted);cursor:pointer;align-items:flex-start;gap:10px;font-size:14px;line-height:1.55;display:flex}.contact-consent input{width:18px;height:18px;accent-color:var(--amber);flex-shrink:0;margin:2px 0 0}.contact-form-foot{border-top:1px solid var(--border-soft);justify-content:space-between;align-items:center;gap:18px;padding-top:22px;display:flex}.contact-form-foot p{max-width:330px;font-size:13.5px;line-height:1.6}.contact-form-foot .btn{flex-shrink:0;height:46px;padding:0 22px}.contact-sent{flex-direction:column;justify-content:center;gap:18px;min-height:560px;display:flex}.contact-sent[hidden]{display:none}.contact-sent h2{font-size:28px;line-height:1.2}.contact-sent p{max-width:460px;font-size:16.5px;line-height:1.7}.contact-sent strong{color:var(--text);font-weight:600}.contact-sent .btn{height:44px;color:var(--text-muted);padding:0 18px}.contact-sent .btn:hover{border-color:var(--amber);color:var(--text)}.contact-sent-icon{border-radius:var(--radius);background:var(--teal-dim);width:48px;height:48px;color:var(--teal);justify-content:center;align-items:center;display:flex}.contact-sent-icon svg{width:22px;height:22px}.contact-faq-layout{grid-template-columns:minmax(0,1fr) minmax(0,1.6fr);align-items:start;gap:64px;display:grid}.contact-faq-head{flex-direction:column;gap:14px;display:flex}.contact-faq-head h2{font-size:30px;line-height:1.2}.contact-faq-list{border-top:1px solid var(--border-soft);flex-direction:column;display:flex}.contact-faq-list details.acc{padding:20px 0}.contact-faq-list details.acc summary{letter-spacing:-.01em;gap:18px;padding:0;font-size:17px;font-weight:600}.contact-faq-list details.acc summary .chev{width:18px;height:18px;color:var(--text-muted);flex-shrink:0}.contact-faq-list details.acc[open] summary .chev{transform:rotate(180deg)}.contact-faq-list details.acc p{max-width:620px;margin-top:12px;font-size:15px;line-height:1.7}.contact-faq-list details.acc a{color:var(--teal)}.contact-faq-list details.acc a:hover{color:var(--amber)}@media (width<=991.98px){.contact-layout,.contact-faq-layout{grid-template-columns:minmax(0,1fr);gap:40px}.contact-layout .contact-form-card{order:-1}}@media (width<=700px){.contact-hero{padding:56px 0 40px}.contact-hero h1{font-size:32px}.contact-hero p{font-size:16.5px}.contact-hero .wrap,.contact-layout,.contact-faq-layout{padding:0 16px}.contact-form-card{padding:26px 20px 24px}.contact-fields{grid-template-columns:minmax(0,1fr)}.contact-form-foot{flex-direction:column;align-items:stretch}.contact-form-foot .btn{justify-content:center}.contact-sent{min-height:360px}} +html[data-theme=light] .contact-page{--field-border:#8a8478}html[data-theme=dark] .contact-page{--field-border:#7a746d}.contact-page code{font-family:var(--mono);background:var(--teal-dim);color:var(--teal);border-radius:4px;padding:1px 6px;font-size:.92em}.contact-page :focus-visible{outline:2px solid var(--amber);outline-offset:2px}.contact-label{font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted);font-size:11.5px}.contact-hero{padding:88px 0 64px}.contact-hero .wrap{flex-direction:column;gap:20px;display:flex}.contact-hero h1{letter-spacing:-.02em;font-size:44px;line-height:1.15}.contact-hero p{max-width:700px;font-size:18px;line-height:1.6}.contact-hero strong{color:var(--teal);font-weight:600}.contact-main{padding:0 0 88px}.contact-layout{grid-template-columns:minmax(0,1fr) minmax(0,1.45fr);align-items:start;gap:64px;display:grid}.contact-channels{flex-direction:column;gap:18px;display:flex}.contact-channel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);gap:16px;padding:22px 24px;display:flex}.contact-channel:hover{border-color:var(--teal)}.contact-channel>svg{fill:none;width:20px;height:20px;stroke:var(--teal);stroke-width:1.6px;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0;margin-top:2px}.contact-channel p{font-size:14.5px;line-height:1.65}.contact-channel-body{flex-direction:column;gap:4px;display:flex}.contact-channel-kicker{font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted);font-size:10.5px}.contact-channel-title{font-family:var(--mono);letter-spacing:-.01em;font-size:17px;font-weight:600}.contact-support{background:var(--surface-2);border:1px solid var(--border-soft);border-radius:var(--radius);flex-direction:column;padding:22px 24px;display:flex}.contact-support .contact-label{padding-bottom:12px}.contact-support a{border-top:1px solid var(--border-soft);min-height:44px;color:var(--teal);justify-content:space-between;align-items:center;font-size:14.5px;display:flex}.contact-support a:hover{color:var(--amber)}.contact-form-card{background:var(--surface);border:1px solid var(--border);border-top:3px solid var(--amber);border-radius:var(--radius);padding:34px 36px 36px}.contact-form{flex-direction:column;gap:26px;display:flex}.contact-form[hidden]{display:none}.contact-form-head{flex-direction:column;gap:6px;display:flex}.contact-form-head h2{font-size:20px;line-height:1.2}.contact-form-head p{font-size:14.5px;line-height:1.65}.contact-topics{border:0;margin:0;padding:0}.contact-topics legend{float:none;width:auto;margin-bottom:12px;padding:0}.contact-pills{flex-wrap:wrap;gap:8px;display:flex}.contact-pill{min-height:44px;font-family:var(--mono);color:var(--text-muted);background:var(--surface);border:1px solid var(--field-border);cursor:pointer;border-radius:999px;padding:0 16px;font-size:13.5px;font-weight:500}.contact-pill:hover{border-color:var(--teal)}.contact-pill[aria-pressed=true]{color:var(--teal);background:var(--teal-dim);border-color:var(--teal)}.contact-fields{grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;display:grid}.contact-field{flex-direction:column;gap:7px;display:flex}.contact-field label{font-family:var(--mono);color:var(--text);font-size:13px;font-weight:500}.contact-field textarea{resize:vertical;min-height:160px}.contact-input{width:100%;font-family:var(--sans);color:var(--text);background:var(--surface);border:1px solid var(--field-border);border-radius:8px;padding:11px 14px;font-size:15px;line-height:1.5}.contact-input::placeholder{color:var(--text-dim)}.contact-input:hover{border-color:var(--text-muted)}.contact-input:focus-visible{border-color:var(--amber)}.contact-error{color:var(--rose);margin:0;font-size:13px}.contact-alert{border:1px solid;border-radius:8px;margin:0;padding:14px 16px;font-size:14.5px;line-height:1.55}.contact-alert a{color:inherit;text-decoration:underline}.contact-alert-success{color:var(--teal);background:var(--teal-dim);border-color:var(--teal)}.contact-alert-error{color:var(--rose);background:var(--rose-dim);border-color:var(--rose)}.contact-form-foot{border-top:1px solid var(--border-soft);justify-content:space-between;align-items:center;gap:18px;padding-top:22px;display:flex}.contact-form-foot p{max-width:330px;font-size:13.5px;line-height:1.6}.contact-form-foot .btn{flex-shrink:0;height:46px;padding:0 22px}.contact-faq-layout{grid-template-columns:minmax(0,1fr) minmax(0,1.6fr);align-items:start;gap:64px;display:grid}.contact-faq-head{flex-direction:column;gap:14px;display:flex}.contact-faq-head h2{font-size:30px;line-height:1.2}.contact-faq-list{border-top:1px solid var(--border-soft);flex-direction:column;display:flex}.contact-faq-list details.acc{padding:20px 0}.contact-faq-list details.acc summary{letter-spacing:-.01em;gap:18px;padding:0;font-size:17px;font-weight:600}.contact-faq-list details.acc summary .chev{width:18px;height:18px;color:var(--text-muted);flex-shrink:0}.contact-faq-list details.acc[open] summary .chev{transform:rotate(180deg)}.contact-faq-list details.acc p{max-width:620px;margin-top:12px;font-size:15px;line-height:1.7}.contact-faq-list details.acc a{color:var(--teal)}.contact-faq-list details.acc a:hover{color:var(--amber)}@media (width<=991.98px){.contact-layout,.contact-faq-layout{grid-template-columns:minmax(0,1fr);gap:40px}.contact-layout .contact-form-card{order:-1}}@media (width<=700px){.contact-hero{padding:56px 0 40px}.contact-hero h1{font-size:32px}.contact-hero p{font-size:16.5px}.contact-hero .wrap,.contact-layout,.contact-faq-layout{padding:0 16px}.contact-form-card{padding:26px 20px 24px}.contact-fields{grid-template-columns:minmax(0,1fr)}.contact-form-foot{flex-direction:column;align-items:stretch}.contact-form-foot .btn{justify-content:center}} diff --git a/public/js/app.js b/public/js/app.js index 49a4f5f9..95f1ebb5 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1 +1 @@ -/* empty css */((e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports))(((e,t)=>{(function(n,r){typeof e==`object`&&t!==void 0?t.exports=r():typeof define==`function`&&define.amd?define(r):(n=typeof globalThis<`u`?globalThis:n||self,n.bootstrap=r())})(e,(function(){let e=new Map,t={set(t,n,r){e.has(t)||e.set(t,new Map);let i=e.get(t);if(!i.has(n)&&i.size!==0){console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(i.keys())[0]}.`);return}i.set(n,r)},get(t,n){return e.has(t)&&e.get(t).get(n)||null},remove(t,n){if(!e.has(t))return;let r=e.get(t);r.delete(n),r.size===0&&e.delete(t)}},n=`transitionend`,r=e=>(e&&window.CSS&&window.CSS.escape&&(e=e.replace(/#([^\s"#']+)/g,(e,t)=>`#${CSS.escape(t)}`)),e),i=e=>e==null?`${e}`:Object.prototype.toString.call(e).match(/\s([a-z]+)/i)[1].toLowerCase(),a=e=>{do e+=Math.floor(Math.random()*1e6);while(document.getElementById(e));return e},o=e=>{if(!e)return 0;let{transitionDuration:t,transitionDelay:n}=window.getComputedStyle(e);return!Number.parseFloat(t)&&!Number.parseFloat(n)?0:(t=t.split(`,`)[0],n=n.split(`,`)[0],(Number.parseFloat(t)+Number.parseFloat(n))*1e3)},s=e=>{e.dispatchEvent(new Event(n))},c=e=>!e||typeof e!=`object`?!1:(e.jquery!==void 0&&(e=e[0]),e.nodeType!==void 0),l=e=>c(e)?e.jquery?e[0]:e:typeof e==`string`&&e.length>0?document.querySelector(r(e)):null,u=e=>{if(!c(e)||e.getClientRects().length===0)return!1;let t=getComputedStyle(e).getPropertyValue(`visibility`)===`visible`,n=e.closest(`details:not([open])`);if(!n)return t;if(n!==e){let t=e.closest(`summary`);if(t&&t.parentNode!==n||t===null)return!1}return t},d=e=>!e||e.nodeType!==Node.ELEMENT_NODE||e.classList.contains(`disabled`)?!0:e.disabled===void 0?e.hasAttribute(`disabled`)&&e.getAttribute(`disabled`)!==`false`:e.disabled,f=e=>{if(!document.documentElement.attachShadow)return null;if(typeof e.getRootNode==`function`){let t=e.getRootNode();return t instanceof ShadowRoot?t:null}return e instanceof ShadowRoot?e:e.parentNode?f(e.parentNode):null},p=()=>{},m=e=>{e.offsetHeight},h=()=>window.jQuery&&!document.body.hasAttribute(`data-bs-no-jquery`)?window.jQuery:null,g=[],_=e=>{document.readyState===`loading`?(g.length||document.addEventListener(`DOMContentLoaded`,()=>{for(let e of g)e()}),g.push(e)):e()},v=()=>document.documentElement.dir===`rtl`,y=e=>{_(()=>{let t=h();if(t){let n=e.NAME,r=t.fn[n];t.fn[n]=e.jQueryInterface,t.fn[n].Constructor=e,t.fn[n].noConflict=()=>(t.fn[n]=r,e.jQueryInterface)}})},b=(e,t=[],n=e)=>typeof e==`function`?e.call(...t):n,x=(e,t,r=!0)=>{if(!r){b(e);return}let i=o(t)+5,a=!1,c=({target:r})=>{r===t&&(a=!0,t.removeEventListener(n,c),b(e))};t.addEventListener(n,c),setTimeout(()=>{a||s(t)},i)},S=(e,t,n,r)=>{let i=e.length,a=e.indexOf(t);return a===-1?!n&&r?e[i-1]:e[0]:(a+=n?1:-1,r&&(a=(a+i)%i),e[Math.max(0,Math.min(a,i-1))])},C=/[^.]*(?=\..*)\.|.*/,w=/\..*/,T=/::\d+$/,E={},D=1,O={mouseenter:`mouseover`,mouseleave:`mouseout`},k=new Set(`click.dblclick.mouseup.mousedown.contextmenu.mousewheel.DOMMouseScroll.mouseover.mouseout.mousemove.selectstart.selectend.keydown.keypress.keyup.orientationchange.touchstart.touchmove.touchend.touchcancel.pointerdown.pointermove.pointerup.pointerleave.pointercancel.gesturestart.gesturechange.gestureend.focus.blur.change.reset.select.submit.focusin.focusout.load.unload.beforeunload.resize.move.DOMContentLoaded.readystatechange.error.abort.scroll`.split(`.`));function ee(e,t){return t&&`${t}::${D++}`||e.uidEvent||D++}function A(e){let t=ee(e);return e.uidEvent=t,E[t]=E[t]||{},E[t]}function j(e,t){return function n(r){return ce(r,{delegateTarget:e}),n.oneOff&&M.off(e,r.type,t),t.apply(e,[r])}}function te(e,t,n){return function r(i){let a=e.querySelectorAll(t);for(let{target:o}=i;o&&o!==this;o=o.parentNode)for(let s of a)if(s===o)return ce(i,{delegateTarget:o}),r.oneOff&&M.off(e,i.type,t,n),n.apply(o,[i])}}function ne(e,t,n=null){return Object.values(e).find(e=>e.callable===t&&e.delegationSelector===n)}function re(e,t,n){let r=typeof t==`string`,i=r?n:t||n,a=se(e);return k.has(a)||(a=e),[r,i,a]}function ie(e,t,n,r,i){if(typeof t!=`string`||!e)return;let[a,o,s]=re(t,n,r);t in O&&(o=(e=>function(t){if(!t.relatedTarget||t.relatedTarget!==t.delegateTarget&&!t.delegateTarget.contains(t.relatedTarget))return e.call(this,t)})(o));let c=A(e),l=c[s]||(c[s]={}),u=ne(l,o,a?n:null);if(u){u.oneOff=u.oneOff&&i;return}let d=ee(o,t.replace(C,``)),f=a?te(e,n,o):j(e,o);f.delegationSelector=a?n:null,f.callable=o,f.oneOff=i,f.uidEvent=d,l[d]=f,e.addEventListener(s,f,a)}function ae(e,t,n,r,i){let a=ne(t[n],r,i);a&&(e.removeEventListener(n,a,!!i),delete t[n][a.uidEvent])}function oe(e,t,n,r){let i=t[n]||{};for(let[a,o]of Object.entries(i))a.includes(r)&&ae(e,t,n,o.callable,o.delegationSelector)}function se(e){return e=e.replace(w,``),O[e]||e}let M={on(e,t,n,r){ie(e,t,n,r,!1)},one(e,t,n,r){ie(e,t,n,r,!0)},off(e,t,n,r){if(typeof t!=`string`||!e)return;let[i,a,o]=re(t,n,r),s=o!==t,c=A(e),l=c[o]||{},u=t.startsWith(`.`);if(a!==void 0){if(!Object.keys(l).length)return;ae(e,c,o,a,i?n:null);return}if(u)for(let n of Object.keys(c))oe(e,c,n,t.slice(1));for(let[n,r]of Object.entries(l)){let i=n.replace(T,``);(!s||t.includes(i))&&ae(e,c,o,r.callable,r.delegationSelector)}},trigger(e,t,n){if(typeof t!=`string`||!e)return null;let r=h(),i=t!==se(t),a=null,o=!0,s=!0,c=!1;i&&r&&(a=r.Event(t,n),r(e).trigger(a),o=!a.isPropagationStopped(),s=!a.isImmediatePropagationStopped(),c=a.isDefaultPrevented());let l=ce(new Event(t,{bubbles:o,cancelable:!0}),n);return c&&l.preventDefault(),s&&e.dispatchEvent(l),l.defaultPrevented&&a&&a.preventDefault(),l}};function ce(e,t={}){for(let[n,r]of Object.entries(t))try{e[n]=r}catch{Object.defineProperty(e,n,{configurable:!0,get(){return r}})}return e}function le(e){if(e===`true`)return!0;if(e===`false`)return!1;if(e===Number(e).toString())return Number(e);if(e===``||e===`null`)return null;if(typeof e!=`string`)return e;try{return JSON.parse(decodeURIComponent(e))}catch{return e}}function ue(e){return e.replace(/[A-Z]/g,e=>`-${e.toLowerCase()}`)}let N={setDataAttribute(e,t,n){e.setAttribute(`data-bs-${ue(t)}`,n)},removeDataAttribute(e,t){e.removeAttribute(`data-bs-${ue(t)}`)},getDataAttributes(e){if(!e)return{};let t={},n=Object.keys(e.dataset).filter(e=>e.startsWith(`bs`)&&!e.startsWith(`bsConfig`));for(let r of n){let n=r.replace(/^bs/,``);n=n.charAt(0).toLowerCase()+n.slice(1),t[n]=le(e.dataset[r])}return t},getDataAttribute(e,t){return le(e.getAttribute(`data-bs-${ue(t)}`))}};class de{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw Error(`You have to implement the static method "NAME", for each component!`)}_getConfig(e){return e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e}_mergeConfigObj(e,t){let n=c(t)?N.getDataAttribute(t,`config`):{};return{...this.constructor.Default,...typeof n==`object`?n:{},...c(t)?N.getDataAttributes(t):{},...typeof e==`object`?e:{}}}_typeCheckConfig(e,t=this.constructor.DefaultType){for(let[n,r]of Object.entries(t)){let t=e[n],a=c(t)?`element`:i(t);if(!new RegExp(r).test(a))throw TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${a}" but expected type "${r}".`)}}}class P extends de{constructor(e,n){super(),e=l(e),e&&(this._element=e,this._config=this._getConfig(n),t.set(this._element,this.constructor.DATA_KEY,this))}dispose(){t.remove(this._element,this.constructor.DATA_KEY),M.off(this._element,this.constructor.EVENT_KEY);for(let e of Object.getOwnPropertyNames(this))this[e]=null}_queueCallback(e,t,n=!0){x(e,t,n)}_getConfig(e){return e=this._mergeConfigObj(e,this._element),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}static getInstance(e){return t.get(l(e),this.DATA_KEY)}static getOrCreateInstance(e,t={}){return this.getInstance(e)||new this(e,typeof t==`object`?t:null)}static get VERSION(){return`5.3.8`}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(e){return`${e}${this.EVENT_KEY}`}}let fe=e=>{let t=e.getAttribute(`data-bs-target`);if(!t||t===`#`){let n=e.getAttribute(`href`);if(!n||!n.includes(`#`)&&!n.startsWith(`.`))return null;n.includes(`#`)&&!n.startsWith(`#`)&&(n=`#${n.split(`#`)[1]}`),t=n&&n!==`#`?n.trim():null}return t?t.split(`,`).map(e=>r(e)).join(`,`):null},F={find(e,t=document.documentElement){return[].concat(...Element.prototype.querySelectorAll.call(t,e))},findOne(e,t=document.documentElement){return Element.prototype.querySelector.call(t,e)},children(e,t){return[].concat(...e.children).filter(e=>e.matches(t))},parents(e,t){let n=[],r=e.parentNode.closest(t);for(;r;)n.push(r),r=r.parentNode.closest(t);return n},prev(e,t){let n=e.previousElementSibling;for(;n;){if(n.matches(t))return[n];n=n.previousElementSibling}return[]},next(e,t){let n=e.nextElementSibling;for(;n;){if(n.matches(t))return[n];n=n.nextElementSibling}return[]},focusableChildren(e){let t=[`a`,`button`,`input`,`textarea`,`select`,`details`,`[tabindex]`,`[contenteditable="true"]`].map(e=>`${e}:not([tabindex^="-"])`).join(`,`);return this.find(t,e).filter(e=>!d(e)&&u(e))},getSelectorFromElement(e){let t=fe(e);return t&&F.findOne(t)?t:null},getElementFromSelector(e){let t=fe(e);return t?F.findOne(t):null},getMultipleElementsFromSelector(e){let t=fe(e);return t?F.find(t):[]}},pe=(e,t=`hide`)=>{let n=`click.dismiss${e.EVENT_KEY}`,r=e.NAME;M.on(document,n,`[data-bs-dismiss="${r}"]`,function(n){if([`A`,`AREA`].includes(this.tagName)&&n.preventDefault(),d(this))return;let i=F.getElementFromSelector(this)||this.closest(`.${r}`);e.getOrCreateInstance(i)[t]()})},me=`.bs.alert`,he=`close${me}`,ge=`closed${me}`;class _e extends P{static get NAME(){return`alert`}close(){if(M.trigger(this._element,he).defaultPrevented)return;this._element.classList.remove(`show`);let e=this._element.classList.contains(`fade`);this._queueCallback(()=>this._destroyElement(),this._element,e)}_destroyElement(){this._element.remove(),M.trigger(this._element,ge),this.dispose()}static jQueryInterface(e){return this.each(function(){let t=_e.getOrCreateInstance(this);if(typeof e==`string`){if(t[e]===void 0||e.startsWith(`_`)||e===`constructor`)throw TypeError(`No method named "${e}"`);t[e](this)}})}}pe(_e,`close`),y(_e);let I=`[data-bs-toggle="button"]`;class L extends P{static get NAME(){return`button`}toggle(){this._element.setAttribute(`aria-pressed`,this._element.classList.toggle(`active`))}static jQueryInterface(e){return this.each(function(){let t=L.getOrCreateInstance(this);e===`toggle`&&t[e]()})}}M.on(document,`click.bs.button.data-api`,I,e=>{e.preventDefault();let t=e.target.closest(I);L.getOrCreateInstance(t).toggle()}),y(L);let R=`.bs.swipe`,ve=`touchstart${R}`,ye=`touchmove${R}`,be=`touchend${R}`,xe=`pointerdown${R}`,Se=`pointerup${R}`,Ce={endCallback:null,leftCallback:null,rightCallback:null},we={endCallback:`(function|null)`,leftCallback:`(function|null)`,rightCallback:`(function|null)`};class Te extends de{constructor(e,t){super(),this._element=e,!(!e||!Te.isSupported())&&(this._config=this._getConfig(t),this._deltaX=0,this._supportPointerEvents=!!window.PointerEvent,this._initEvents())}static get Default(){return Ce}static get DefaultType(){return we}static get NAME(){return`swipe`}dispose(){M.off(this._element,R)}_start(e){if(!this._supportPointerEvents){this._deltaX=e.touches[0].clientX;return}this._eventIsPointerPenTouch(e)&&(this._deltaX=e.clientX)}_end(e){this._eventIsPointerPenTouch(e)&&(this._deltaX=e.clientX-this._deltaX),this._handleSwipe(),b(this._config.endCallback)}_move(e){this._deltaX=e.touches&&e.touches.length>1?0:e.touches[0].clientX-this._deltaX}_handleSwipe(){let e=Math.abs(this._deltaX);if(e<=40)return;let t=e/this._deltaX;this._deltaX=0,t&&b(t>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(M.on(this._element,xe,e=>this._start(e)),M.on(this._element,Se,e=>this._end(e)),this._element.classList.add(`pointer-event`)):(M.on(this._element,ve,e=>this._start(e)),M.on(this._element,ye,e=>this._move(e)),M.on(this._element,be,e=>this._end(e)))}_eventIsPointerPenTouch(e){return this._supportPointerEvents&&(e.pointerType===`pen`||e.pointerType===`touch`)}static isSupported(){return`ontouchstart`in document.documentElement||navigator.maxTouchPoints>0}}let z=`.bs.carousel`,Ee=`.data-api`,De=`next`,Oe=`prev`,ke=`left`,Ae=`right`,je=`slide${z}`,Me=`slid${z}`,Ne=`keydown${z}`,Pe=`mouseenter${z}`,Fe=`mouseleave${z}`,Ie=`dragstart${z}`,Le=`load${z}${Ee}`,Re=`click${z}${Ee}`,ze=`carousel`,Be=`active`,Ve={ArrowLeft:Ae,ArrowRight:ke},He={interval:5e3,keyboard:!0,pause:`hover`,ride:!1,touch:!0,wrap:!0},Ue={interval:`(number|boolean)`,keyboard:`boolean`,pause:`(string|boolean)`,ride:`(boolean|string)`,touch:`boolean`,wrap:`boolean`};class We extends P{constructor(e,t){super(e,t),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=F.findOne(`.carousel-indicators`,this._element),this._addEventListeners(),this._config.ride===ze&&this.cycle()}static get Default(){return He}static get DefaultType(){return Ue}static get NAME(){return`carousel`}next(){this._slide(De)}nextWhenVisible(){!document.hidden&&u(this._element)&&this.next()}prev(){this._slide(Oe)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval(()=>this.nextWhenVisible(),this._config.interval)}_maybeEnableCycle(){if(this._config.ride){if(this._isSliding){M.one(this._element,Me,()=>this.cycle());return}this.cycle()}}to(e){let t=this._getItems();if(e>t.length-1||e<0)return;if(this._isSliding){M.one(this._element,Me,()=>this.to(e));return}let n=this._getItemIndex(this._getActive());if(n===e)return;let r=e>n?De:Oe;this._slide(r,t[e])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(e){return e.defaultInterval=e.interval,e}_addEventListeners(){this._config.keyboard&&M.on(this._element,Ne,e=>this._keydown(e)),this._config.pause===`hover`&&(M.on(this._element,Pe,()=>this.pause()),M.on(this._element,Fe,()=>this._maybeEnableCycle())),this._config.touch&&Te.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(let e of F.find(`.carousel-item img`,this._element))M.on(e,Ie,e=>e.preventDefault());let e={leftCallback:()=>this._slide(this._directionToOrder(ke)),rightCallback:()=>this._slide(this._directionToOrder(Ae)),endCallback:()=>{this._config.pause===`hover`&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout(()=>this._maybeEnableCycle(),500+this._config.interval))}};this._swipeHelper=new Te(this._element,e)}_keydown(e){if(/input|textarea/i.test(e.target.tagName))return;let t=Ve[e.key];t&&(e.preventDefault(),this._slide(this._directionToOrder(t)))}_getItemIndex(e){return this._getItems().indexOf(e)}_setActiveIndicatorElement(e){if(!this._indicatorsElement)return;let t=F.findOne(`.active`,this._indicatorsElement);t.classList.remove(Be),t.removeAttribute(`aria-current`);let n=F.findOne(`[data-bs-slide-to="${e}"]`,this._indicatorsElement);n&&(n.classList.add(Be),n.setAttribute(`aria-current`,`true`))}_updateInterval(){let e=this._activeElement||this._getActive();if(!e)return;let t=Number.parseInt(e.getAttribute(`data-bs-interval`),10);this._config.interval=t||this._config.defaultInterval}_slide(e,t=null){if(this._isSliding)return;let n=this._getActive(),r=e===De,i=t||S(this._getItems(),n,r,this._config.wrap);if(i===n)return;let a=this._getItemIndex(i),o=t=>M.trigger(this._element,t,{relatedTarget:i,direction:this._orderToDirection(e),from:this._getItemIndex(n),to:a});if(o(je).defaultPrevented||!n||!i)return;let s=!!this._interval;this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(a),this._activeElement=i;let c=r?`carousel-item-start`:`carousel-item-end`,l=r?`carousel-item-next`:`carousel-item-prev`;i.classList.add(l),m(i),n.classList.add(c),i.classList.add(c),this._queueCallback(()=>{i.classList.remove(c,l),i.classList.add(Be),n.classList.remove(Be,l,c),this._isSliding=!1,o(Me)},n,this._isAnimated()),s&&this.cycle()}_isAnimated(){return this._element.classList.contains(`slide`)}_getActive(){return F.findOne(`.active.carousel-item`,this._element)}_getItems(){return F.find(`.carousel-item`,this._element)}_clearInterval(){this._interval&&=(clearInterval(this._interval),null)}_directionToOrder(e){return v()?e===ke?Oe:De:e===ke?De:Oe}_orderToDirection(e){return v()?e===Oe?ke:Ae:e===Oe?Ae:ke}static jQueryInterface(e){return this.each(function(){let t=We.getOrCreateInstance(this,e);if(typeof e==`number`){t.to(e);return}if(typeof e==`string`){if(t[e]===void 0||e.startsWith(`_`)||e===`constructor`)throw TypeError(`No method named "${e}"`);t[e]()}})}}M.on(document,Re,`[data-bs-slide], [data-bs-slide-to]`,function(e){let t=F.getElementFromSelector(this);if(!t||!t.classList.contains(ze))return;e.preventDefault();let n=We.getOrCreateInstance(t),r=this.getAttribute(`data-bs-slide-to`);if(r){n.to(r),n._maybeEnableCycle();return}if(N.getDataAttribute(this,`slide`)===`next`){n.next(),n._maybeEnableCycle();return}n.prev(),n._maybeEnableCycle()}),M.on(window,Le,()=>{let e=F.find(`[data-bs-ride="carousel"]`);for(let t of e)We.getOrCreateInstance(t)}),y(We);let Ge=`.bs.collapse`,Ke=`show${Ge}`,qe=`shown${Ge}`,Je=`hide${Ge}`,Ye=`hidden${Ge}`,Xe=`click${Ge}.data-api`,Ze=`show`,Qe=`collapse`,$e=`collapsing`,et=`:scope .${Qe} .${Qe}`,tt=`[data-bs-toggle="collapse"]`,nt={parent:null,toggle:!0},rt={parent:`(null|element)`,toggle:`boolean`};class it extends P{constructor(e,t){super(e,t),this._isTransitioning=!1,this._triggerArray=[];let n=F.find(tt);for(let e of n){let t=F.getSelectorFromElement(e),n=F.find(t).filter(e=>e===this._element);t!==null&&n.length&&this._triggerArray.push(e)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return nt}static get DefaultType(){return rt}static get NAME(){return`collapse`}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let e=[];if(this._config.parent&&(e=this._getFirstLevelChildren(`.collapse.show, .collapse.collapsing`).filter(e=>e!==this._element).map(e=>it.getOrCreateInstance(e,{toggle:!1}))),e.length&&e[0]._isTransitioning||M.trigger(this._element,Ke).defaultPrevented)return;for(let t of e)t.hide();let t=this._getDimension();this._element.classList.remove(Qe),this._element.classList.add($e),this._element.style[t]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;let n=()=>{this._isTransitioning=!1,this._element.classList.remove($e),this._element.classList.add(Qe,Ze),this._element.style[t]=``,M.trigger(this._element,qe)},r=`scroll${t[0].toUpperCase()+t.slice(1)}`;this._queueCallback(n,this._element,!0),this._element.style[t]=`${this._element[r]}px`}hide(){if(this._isTransitioning||!this._isShown()||M.trigger(this._element,Je).defaultPrevented)return;let e=this._getDimension();this._element.style[e]=`${this._element.getBoundingClientRect()[e]}px`,m(this._element),this._element.classList.add($e),this._element.classList.remove(Qe,Ze);for(let e of this._triggerArray){let t=F.getElementFromSelector(e);t&&!this._isShown(t)&&this._addAriaAndCollapsedClass([e],!1)}this._isTransitioning=!0;let t=()=>{this._isTransitioning=!1,this._element.classList.remove($e),this._element.classList.add(Qe),M.trigger(this._element,Ye)};this._element.style[e]=``,this._queueCallback(t,this._element,!0)}_isShown(e=this._element){return e.classList.contains(Ze)}_configAfterMerge(e){return e.toggle=!!e.toggle,e.parent=l(e.parent),e}_getDimension(){return this._element.classList.contains(`collapse-horizontal`)?`width`:`height`}_initializeChildren(){if(!this._config.parent)return;let e=this._getFirstLevelChildren(tt);for(let t of e){let e=F.getElementFromSelector(t);e&&this._addAriaAndCollapsedClass([t],this._isShown(e))}}_getFirstLevelChildren(e){let t=F.find(et,this._config.parent);return F.find(e,this._config.parent).filter(e=>!t.includes(e))}_addAriaAndCollapsedClass(e,t){if(e.length)for(let n of e)n.classList.toggle(`collapsed`,!t),n.setAttribute(`aria-expanded`,t)}static jQueryInterface(e){let t={};return typeof e==`string`&&/show|hide/.test(e)&&(t.toggle=!1),this.each(function(){let n=it.getOrCreateInstance(this,t);if(typeof e==`string`){if(n[e]===void 0)throw TypeError(`No method named "${e}"`);n[e]()}})}}M.on(document,Xe,tt,function(e){(e.target.tagName===`A`||e.delegateTarget&&e.delegateTarget.tagName===`A`)&&e.preventDefault();for(let e of F.getMultipleElementsFromSelector(this))it.getOrCreateInstance(e,{toggle:!1}).toggle()}),y(it);var B=`top`,V=`bottom`,H=`right`,U=`left`,at=`auto`,ot=[B,V,H,U],st=`start`,ct=`end`,lt=`clippingParents`,ut=`viewport`,dt=`popper`,ft=`reference`,pt=ot.reduce(function(e,t){return e.concat([t+`-`+st,t+`-`+ct])},[]),mt=[].concat(ot,[at]).reduce(function(e,t){return e.concat([t,t+`-`+st,t+`-`+ct])},[]),ht=`beforeRead`,gt=`read`,_t=`afterRead`,vt=`beforeMain`,yt=`main`,bt=`afterMain`,xt=`beforeWrite`,St=`write`,Ct=`afterWrite`,wt=[ht,gt,_t,vt,yt,bt,xt,St,Ct];function W(e){return e?(e.nodeName||``).toLowerCase():null}function G(e){if(e==null)return window;if(e.toString()!==`[object Window]`){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function Tt(e){return e instanceof G(e).Element||e instanceof Element}function K(e){return e instanceof G(e).HTMLElement||e instanceof HTMLElement}function Et(e){return typeof ShadowRoot>`u`?!1:e instanceof G(e).ShadowRoot||e instanceof ShadowRoot}function Dt(e){var t=e.state;Object.keys(t.elements).forEach(function(e){var n=t.styles[e]||{},r=t.attributes[e]||{},i=t.elements[e];!K(i)||!W(i)||(Object.assign(i.style,n),Object.keys(r).forEach(function(e){var t=r[e];t===!1?i.removeAttribute(e):i.setAttribute(e,t===!0?``:t)}))})}function Ot(e){var t=e.state,n={popper:{position:t.options.strategy,left:`0`,top:`0`,margin:`0`},arrow:{position:`absolute`},reference:{}};return Object.assign(t.elements.popper.style,n.popper),t.styles=n,t.elements.arrow&&Object.assign(t.elements.arrow.style,n.arrow),function(){Object.keys(t.elements).forEach(function(e){var r=t.elements[e],i=t.attributes[e]||{},a=Object.keys(t.styles.hasOwnProperty(e)?t.styles[e]:n[e]).reduce(function(e,t){return e[t]=``,e},{});!K(r)||!W(r)||(Object.assign(r.style,a),Object.keys(i).forEach(function(e){r.removeAttribute(e)}))})}}let kt={name:`applyStyles`,enabled:!0,phase:`write`,fn:Dt,effect:Ot,requires:[`computeStyles`]};function q(e){return e.split(`-`)[0]}var At=Math.max,jt=Math.min,Mt=Math.round;function Nt(){var e=navigator.userAgentData;return e!=null&&e.brands&&Array.isArray(e.brands)?e.brands.map(function(e){return e.brand+`/`+e.version}).join(` `):navigator.userAgent}function Pt(){return!/^((?!chrome|android).)*safari/i.test(Nt())}function Ft(e,t,n){t===void 0&&(t=!1),n===void 0&&(n=!1);var r=e.getBoundingClientRect(),i=1,a=1;t&&K(e)&&(i=e.offsetWidth>0&&Mt(r.width)/e.offsetWidth||1,a=e.offsetHeight>0&&Mt(r.height)/e.offsetHeight||1);var o=(Tt(e)?G(e):window).visualViewport,s=!Pt()&&n,c=(r.left+(s&&o?o.offsetLeft:0))/i,l=(r.top+(s&&o?o.offsetTop:0))/a,u=r.width/i,d=r.height/a;return{width:u,height:d,top:l,right:c+u,bottom:l+d,left:c,x:c,y:l}}function It(e){var t=Ft(e),n=e.offsetWidth,r=e.offsetHeight;return Math.abs(t.width-n)<=1&&(n=t.width),Math.abs(t.height-r)<=1&&(r=t.height),{x:e.offsetLeft,y:e.offsetTop,width:n,height:r}}function Lt(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&Et(n)){var r=t;do{if(r&&e.isSameNode(r))return!0;r=r.parentNode||r.host}while(r)}return!1}function J(e){return G(e).getComputedStyle(e)}function Rt(e){return[`table`,`td`,`th`].indexOf(W(e))>=0}function Y(e){return((Tt(e)?e.ownerDocument:e.document)||window.document).documentElement}function zt(e){return W(e)===`html`?e:e.assignedSlot||e.parentNode||(Et(e)?e.host:null)||Y(e)}function Bt(e){return!K(e)||J(e).position===`fixed`?null:e.offsetParent}function Vt(e){var t=/firefox/i.test(Nt());if(/Trident/i.test(Nt())&&K(e)&&J(e).position===`fixed`)return null;var n=zt(e);for(Et(n)&&(n=n.host);K(n)&&[`html`,`body`].indexOf(W(n))<0;){var r=J(n);if(r.transform!==`none`||r.perspective!==`none`||r.contain===`paint`||[`transform`,`perspective`].indexOf(r.willChange)!==-1||t&&r.willChange===`filter`||t&&r.filter&&r.filter!==`none`)return n;n=n.parentNode}return null}function Ht(e){for(var t=G(e),n=Bt(e);n&&Rt(n)&&J(n).position===`static`;)n=Bt(n);return n&&(W(n)===`html`||W(n)===`body`&&J(n).position===`static`)?t:n||Vt(e)||t}function Ut(e){return[`top`,`bottom`].indexOf(e)>=0?`x`:`y`}function Wt(e,t,n){return At(e,jt(t,n))}function Gt(e,t,n){var r=Wt(e,t,n);return r>n?n:r}function Kt(){return{top:0,right:0,bottom:0,left:0}}function qt(e){return Object.assign({},Kt(),e)}function Jt(e,t){return t.reduce(function(t,n){return t[n]=e,t},{})}var Yt=function(e,t){return e=typeof e==`function`?e(Object.assign({},t.rects,{placement:t.placement})):e,qt(typeof e==`number`?Jt(e,ot):e)};function Xt(e){var t,n=e.state,r=e.name,i=e.options,a=n.elements.arrow,o=n.modifiersData.popperOffsets,s=q(n.placement),c=Ut(s),l=[U,H].indexOf(s)>=0?`height`:`width`;if(!(!a||!o)){var u=Yt(i.padding,n),d=It(a),f=c===`y`?B:U,p=c===`y`?V:H,m=n.rects.reference[l]+n.rects.reference[c]-o[c]-n.rects.popper[l],h=o[c]-n.rects.reference[c],g=Ht(a),_=g?c===`y`?g.clientHeight||0:g.clientWidth||0:0,v=m/2-h/2,y=u[f],b=_-d[l]-u[p],x=_/2-d[l]/2+v,S=Wt(y,x,b),C=c;n.modifiersData[r]=(t={},t[C]=S,t.centerOffset=S-x,t)}}function Zt(e){var t=e.state,n=e.options.element,r=n===void 0?`[data-popper-arrow]`:n;r!=null&&(typeof r==`string`&&(r=t.elements.popper.querySelector(r),!r)||Lt(t.elements.popper,r)&&(t.elements.arrow=r))}let Qt={name:`arrow`,enabled:!0,phase:`main`,fn:Xt,effect:Zt,requires:[`popperOffsets`],requiresIfExists:[`preventOverflow`]};function $t(e){return e.split(`-`)[1]}var en={top:`auto`,right:`auto`,bottom:`auto`,left:`auto`};function tn(e,t){var n=e.x,r=e.y,i=t.devicePixelRatio||1;return{x:Mt(n*i)/i||0,y:Mt(r*i)/i||0}}function nn(e){var t,n=e.popper,r=e.popperRect,i=e.placement,a=e.variation,o=e.offsets,s=e.position,c=e.gpuAcceleration,l=e.adaptive,u=e.roundOffsets,d=e.isFixed,f=o.x,p=f===void 0?0:f,m=o.y,h=m===void 0?0:m,g=typeof u==`function`?u({x:p,y:h}):{x:p,y:h};p=g.x,h=g.y;var _=o.hasOwnProperty(`x`),v=o.hasOwnProperty(`y`),y=U,b=B,x=window;if(l){var S=Ht(n),C=`clientHeight`,w=`clientWidth`;if(S===G(n)&&(S=Y(n),J(S).position!==`static`&&s===`absolute`&&(C=`scrollHeight`,w=`scrollWidth`)),S=S,i===B||(i===U||i===H)&&a===ct){b=V;var T=d&&S===x&&x.visualViewport?x.visualViewport.height:S[C];h-=T-r.height,h*=c?1:-1}if(i===U||(i===B||i===V)&&a===ct){y=H;var E=d&&S===x&&x.visualViewport?x.visualViewport.width:S[w];p-=E-r.width,p*=c?1:-1}}var D=Object.assign({position:s},l&&en),O=u===!0?tn({x:p,y:h},G(n)):{x:p,y:h};if(p=O.x,h=O.y,c){var k;return Object.assign({},D,(k={},k[b]=v?`0`:``,k[y]=_?`0`:``,k.transform=(x.devicePixelRatio||1)<=1?`translate(`+p+`px, `+h+`px)`:`translate3d(`+p+`px, `+h+`px, 0)`,k))}return Object.assign({},D,(t={},t[b]=v?h+`px`:``,t[y]=_?p+`px`:``,t.transform=``,t))}function rn(e){var t=e.state,n=e.options,r=n.gpuAcceleration,i=r===void 0?!0:r,a=n.adaptive,o=a===void 0?!0:a,s=n.roundOffsets,c=s===void 0?!0:s,l={placement:q(t.placement),variation:$t(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:i,isFixed:t.options.strategy===`fixed`};t.modifiersData.popperOffsets!=null&&(t.styles.popper=Object.assign({},t.styles.popper,nn(Object.assign({},l,{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:o,roundOffsets:c})))),t.modifiersData.arrow!=null&&(t.styles.arrow=Object.assign({},t.styles.arrow,nn(Object.assign({},l,{offsets:t.modifiersData.arrow,position:`absolute`,adaptive:!1,roundOffsets:c})))),t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-placement":t.placement})}let an={name:`computeStyles`,enabled:!0,phase:`beforeWrite`,fn:rn,data:{}};var on={passive:!0};function sn(e){var t=e.state,n=e.instance,r=e.options,i=r.scroll,a=i===void 0?!0:i,o=r.resize,s=o===void 0?!0:o,c=G(t.elements.popper),l=[].concat(t.scrollParents.reference,t.scrollParents.popper);return a&&l.forEach(function(e){e.addEventListener(`scroll`,n.update,on)}),s&&c.addEventListener(`resize`,n.update,on),function(){a&&l.forEach(function(e){e.removeEventListener(`scroll`,n.update,on)}),s&&c.removeEventListener(`resize`,n.update,on)}}let cn={name:`eventListeners`,enabled:!0,phase:`write`,fn:function(){},effect:sn,data:{}};var ln={left:`right`,right:`left`,bottom:`top`,top:`bottom`};function un(e){return e.replace(/left|right|bottom|top/g,function(e){return ln[e]})}var dn={start:`end`,end:`start`};function fn(e){return e.replace(/start|end/g,function(e){return dn[e]})}function pn(e){var t=G(e);return{scrollLeft:t.pageXOffset,scrollTop:t.pageYOffset}}function mn(e){return Ft(Y(e)).left+pn(e).scrollLeft}function hn(e,t){var n=G(e),r=Y(e),i=n.visualViewport,a=r.clientWidth,o=r.clientHeight,s=0,c=0;if(i){a=i.width,o=i.height;var l=Pt();(l||!l&&t===`fixed`)&&(s=i.offsetLeft,c=i.offsetTop)}return{width:a,height:o,x:s+mn(e),y:c}}function gn(e){var t=Y(e),n=pn(e),r=e.ownerDocument?.body,i=At(t.scrollWidth,t.clientWidth,r?r.scrollWidth:0,r?r.clientWidth:0),a=At(t.scrollHeight,t.clientHeight,r?r.scrollHeight:0,r?r.clientHeight:0),o=-n.scrollLeft+mn(e),s=-n.scrollTop;return J(r||t).direction===`rtl`&&(o+=At(t.clientWidth,r?r.clientWidth:0)-i),{width:i,height:a,x:o,y:s}}function _n(e){var t=J(e),n=t.overflow,r=t.overflowX,i=t.overflowY;return/auto|scroll|overlay|hidden/.test(n+i+r)}function vn(e){return[`html`,`body`,`#document`].indexOf(W(e))>=0?e.ownerDocument.body:K(e)&&_n(e)?e:vn(zt(e))}function yn(e,t){t===void 0&&(t=[]);var n=vn(e),r=n===e.ownerDocument?.body,i=G(n),a=r?[i].concat(i.visualViewport||[],_n(n)?n:[]):n,o=t.concat(a);return r?o:o.concat(yn(zt(a)))}function bn(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function xn(e,t){var n=Ft(e,!1,t===`fixed`);return n.top+=e.clientTop,n.left+=e.clientLeft,n.bottom=n.top+e.clientHeight,n.right=n.left+e.clientWidth,n.width=e.clientWidth,n.height=e.clientHeight,n.x=n.left,n.y=n.top,n}function Sn(e,t,n){return t===ut?bn(hn(e,n)):Tt(t)?xn(t,n):bn(gn(Y(e)))}function Cn(e){var t=yn(zt(e)),n=[`absolute`,`fixed`].indexOf(J(e).position)>=0&&K(e)?Ht(e):e;return Tt(n)?t.filter(function(e){return Tt(e)&&Lt(e,n)&&W(e)!==`body`}):[]}function wn(e,t,n,r){var i=t===`clippingParents`?Cn(e):[].concat(t),a=[].concat(i,[n]),o=a[0],s=a.reduce(function(t,n){var i=Sn(e,n,r);return t.top=At(i.top,t.top),t.right=jt(i.right,t.right),t.bottom=jt(i.bottom,t.bottom),t.left=At(i.left,t.left),t},Sn(e,o,r));return s.width=s.right-s.left,s.height=s.bottom-s.top,s.x=s.left,s.y=s.top,s}function Tn(e){var t=e.reference,n=e.element,r=e.placement,i=r?q(r):null,a=r?$t(r):null,o=t.x+t.width/2-n.width/2,s=t.y+t.height/2-n.height/2,c;switch(i){case B:c={x:o,y:t.y-n.height};break;case V:c={x:o,y:t.y+t.height};break;case H:c={x:t.x+t.width,y:s};break;case U:c={x:t.x-n.width,y:s};break;default:c={x:t.x,y:t.y}}var l=i?Ut(i):null;if(l!=null){var u=l===`y`?`height`:`width`;switch(a){case st:c[l]=c[l]-(t[u]/2-n[u]/2);break;case ct:c[l]=c[l]+(t[u]/2-n[u]/2);break}}return c}function En(e,t){t===void 0&&(t={});var n=t,r=n.placement,i=r===void 0?e.placement:r,a=n.strategy,o=a===void 0?e.strategy:a,s=n.boundary,c=s===void 0?lt:s,l=n.rootBoundary,u=l===void 0?ut:l,d=n.elementContext,f=d===void 0?dt:d,p=n.altBoundary,m=p===void 0?!1:p,h=n.padding,g=h===void 0?0:h,_=qt(typeof g==`number`?Jt(g,ot):g),v=f===dt?ft:dt,y=e.rects.popper,b=e.elements[m?v:f],x=wn(Tt(b)?b:b.contextElement||Y(e.elements.popper),c,u,o),S=Ft(e.elements.reference),C=Tn({reference:S,element:y,placement:i}),w=bn(Object.assign({},y,C)),T=f===dt?w:S,E={top:x.top-T.top+_.top,bottom:T.bottom-x.bottom+_.bottom,left:x.left-T.left+_.left,right:T.right-x.right+_.right},D=e.modifiersData.offset;if(f===dt&&D){var O=D[i];Object.keys(E).forEach(function(e){var t=[H,V].indexOf(e)>=0?1:-1,n=[B,V].indexOf(e)>=0?`y`:`x`;E[e]+=O[n]*t})}return E}function Dn(e,t){t===void 0&&(t={});var n=t,r=n.placement,i=n.boundary,a=n.rootBoundary,o=n.padding,s=n.flipVariations,c=n.allowedAutoPlacements,l=c===void 0?mt:c,u=$t(r),d=u?s?pt:pt.filter(function(e){return $t(e)===u}):ot,f=d.filter(function(e){return l.indexOf(e)>=0});f.length===0&&(f=d);var p=f.reduce(function(t,n){return t[n]=En(e,{placement:n,boundary:i,rootBoundary:a,padding:o})[q(n)],t},{});return Object.keys(p).sort(function(e,t){return p[e]-p[t]})}function On(e){if(q(e)===at)return[];var t=un(e);return[fn(e),t,fn(t)]}function kn(e){var t=e.state,n=e.options,r=e.name;if(!t.modifiersData[r]._skip){for(var i=n.mainAxis,a=i===void 0?!0:i,o=n.altAxis,s=o===void 0?!0:o,c=n.fallbackPlacements,l=n.padding,u=n.boundary,d=n.rootBoundary,f=n.altBoundary,p=n.flipVariations,m=p===void 0?!0:p,h=n.allowedAutoPlacements,g=t.options.placement,_=q(g)===g,v=c||(_||!m?[un(g)]:On(g)),y=[g].concat(v).reduce(function(e,n){return e.concat(q(n)===at?Dn(t,{placement:n,boundary:u,rootBoundary:d,padding:l,flipVariations:m,allowedAutoPlacements:h}):n)},[]),b=t.rects.reference,x=t.rects.popper,S=new Map,C=!0,w=y[0],T=0;T=0,ee=k?`width`:`height`,A=En(t,{placement:E,boundary:u,rootBoundary:d,altBoundary:f,padding:l}),j=k?O?H:U:O?V:B;b[ee]>x[ee]&&(j=un(j));var te=un(j),ne=[];if(a&&ne.push(A[D]<=0),s&&ne.push(A[j]<=0,A[te]<=0),ne.every(function(e){return e})){w=E,C=!1;break}S.set(E,ne)}if(C)for(var re=m?3:1,ie=function(e){var t=y.find(function(t){var n=S.get(t);if(n)return n.slice(0,e).every(function(e){return e})});if(t)return w=t,`break`},ae=re;ae>0&&ie(ae)!==`break`;ae--);t.placement!==w&&(t.modifiersData[r]._skip=!0,t.placement=w,t.reset=!0)}}let An={name:`flip`,enabled:!0,phase:`main`,fn:kn,requiresIfExists:[`offset`],data:{_skip:!1}};function jn(e,t,n){return n===void 0&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function Mn(e){return[B,H,V,U].some(function(t){return e[t]>=0})}function Nn(e){var t=e.state,n=e.name,r=t.rects.reference,i=t.rects.popper,a=t.modifiersData.preventOverflow,o=En(t,{elementContext:`reference`}),s=En(t,{altBoundary:!0}),c=jn(o,r),l=jn(s,i,a),u=Mn(c),d=Mn(l);t.modifiersData[n]={referenceClippingOffsets:c,popperEscapeOffsets:l,isReferenceHidden:u,hasPopperEscaped:d},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":u,"data-popper-escaped":d})}let Pn={name:`hide`,enabled:!0,phase:`main`,requiresIfExists:[`preventOverflow`],fn:Nn};function Fn(e,t,n){var r=q(e),i=[U,B].indexOf(r)>=0?-1:1,a=typeof n==`function`?n(Object.assign({},t,{placement:e})):n,o=a[0],s=a[1];return o||=0,s=(s||0)*i,[U,H].indexOf(r)>=0?{x:s,y:o}:{x:o,y:s}}function In(e){var t=e.state,n=e.options,r=e.name,i=n.offset,a=i===void 0?[0,0]:i,o=mt.reduce(function(e,n){return e[n]=Fn(n,t.rects,a),e},{}),s=o[t.placement],c=s.x,l=s.y;t.modifiersData.popperOffsets!=null&&(t.modifiersData.popperOffsets.x+=c,t.modifiersData.popperOffsets.y+=l),t.modifiersData[r]=o}let Ln={name:`offset`,enabled:!0,phase:`main`,requires:[`popperOffsets`],fn:In};function Rn(e){var t=e.state,n=e.name;t.modifiersData[n]=Tn({reference:t.rects.reference,element:t.rects.popper,placement:t.placement})}let zn={name:`popperOffsets`,enabled:!0,phase:`read`,fn:Rn,data:{}};function Bn(e){return e===`x`?`y`:`x`}function Vn(e){var t=e.state,n=e.options,r=e.name,i=n.mainAxis,a=i===void 0?!0:i,o=n.altAxis,s=o===void 0?!1:o,c=n.boundary,l=n.rootBoundary,u=n.altBoundary,d=n.padding,f=n.tether,p=f===void 0?!0:f,m=n.tetherOffset,h=m===void 0?0:m,g=En(t,{boundary:c,rootBoundary:l,padding:d,altBoundary:u}),_=q(t.placement),v=$t(t.placement),y=!v,b=Ut(_),x=Bn(b),S=t.modifiersData.popperOffsets,C=t.rects.reference,w=t.rects.popper,T=typeof h==`function`?h(Object.assign({},t.rects,{placement:t.placement})):h,E=typeof T==`number`?{mainAxis:T,altAxis:T}:Object.assign({mainAxis:0,altAxis:0},T),D=t.modifiersData.offset?t.modifiersData.offset[t.placement]:null,O={x:0,y:0};if(S){if(a){var k=b===`y`?B:U,ee=b===`y`?V:H,A=b===`y`?`height`:`width`,j=S[b],te=j+g[k],ne=j-g[ee],re=p?-w[A]/2:0,ie=v===st?C[A]:w[A],ae=v===st?-w[A]:-C[A],oe=t.elements.arrow,se=p&&oe?It(oe):{width:0,height:0},M=t.modifiersData[`arrow#persistent`]?t.modifiersData[`arrow#persistent`].padding:Kt(),ce=M[k],le=M[ee],ue=Wt(0,C[A],se[A]),N=y?C[A]/2-re-ue-ce-E.mainAxis:ie-ue-ce-E.mainAxis,de=y?-C[A]/2+re+ue+le+E.mainAxis:ae+ue+le+E.mainAxis,P=t.elements.arrow&&Ht(t.elements.arrow),fe=P?b===`y`?P.clientTop||0:P.clientLeft||0:0,F=D?.[b]??0,pe=j+N-F-fe,me=j+de-F,he=Wt(p?jt(te,pe):te,j,p?At(ne,me):ne);S[b]=he,O[b]=he-j}if(s){var ge=b===`x`?B:U,_e=b===`x`?V:H,I=S[x],L=x===`y`?`height`:`width`,R=I+g[ge],ve=I-g[_e],ye=[B,U].indexOf(_)!==-1,be=D?.[x]??0,xe=ye?R:I-C[L]-w[L]-be+E.altAxis,Se=ye?I+C[L]+w[L]-be-E.altAxis:ve,Ce=p&&ye?Gt(xe,I,Se):Wt(p?xe:R,I,p?Se:ve);S[x]=Ce,O[x]=Ce-I}t.modifiersData[r]=O}}let Hn={name:`preventOverflow`,enabled:!0,phase:`main`,fn:Vn,requiresIfExists:[`offset`]};function Un(e){return{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}}function Wn(e){return e===G(e)||!K(e)?pn(e):Un(e)}function Gn(e){var t=e.getBoundingClientRect(),n=Mt(t.width)/e.offsetWidth||1,r=Mt(t.height)/e.offsetHeight||1;return n!==1||r!==1}function Kn(e,t,n){n===void 0&&(n=!1);var r=K(t),i=K(t)&&Gn(t),a=Y(t),o=Ft(e,i,n),s={scrollLeft:0,scrollTop:0},c={x:0,y:0};return(r||!r&&!n)&&((W(t)!==`body`||_n(a))&&(s=Wn(t)),K(t)?(c=Ft(t,!0),c.x+=t.clientLeft,c.y+=t.clientTop):a&&(c.x=mn(a))),{x:o.left+s.scrollLeft-c.x,y:o.top+s.scrollTop-c.y,width:o.width,height:o.height}}function qn(e){var t=new Map,n=new Set,r=[];e.forEach(function(e){t.set(e.name,e)});function i(e){n.add(e.name),[].concat(e.requires||[],e.requiresIfExists||[]).forEach(function(e){if(!n.has(e)){var r=t.get(e);r&&i(r)}}),r.push(e)}return e.forEach(function(e){n.has(e.name)||i(e)}),r}function Jn(e){var t=qn(e);return wt.reduce(function(e,n){return e.concat(t.filter(function(e){return e.phase===n}))},[])}function Yn(e){var t;return function(){return t||=new Promise(function(n){Promise.resolve().then(function(){t=void 0,n(e())})}),t}}function Xn(e){var t=e.reduce(function(e,t){var n=e[t.name];return e[t.name]=n?Object.assign({},n,t,{options:Object.assign({},n.options,t.options),data:Object.assign({},n.data,t.data)}):t,e},{});return Object.keys(t).map(function(e){return t[e]})}var Zn={placement:`bottom`,modifiers:[],strategy:`absolute`};function Qn(){return![...arguments].some(function(e){return!(e&&typeof e.getBoundingClientRect==`function`)})}function $n(e){e===void 0&&(e={});var t=e,n=t.defaultModifiers,r=n===void 0?[]:n,i=t.defaultOptions,a=i===void 0?Zn:i;return function(e,t,n){n===void 0&&(n=a);var i={placement:`bottom`,orderedModifiers:[],options:Object.assign({},Zn,a),modifiersData:{},elements:{reference:e,popper:t},attributes:{},styles:{}},o=[],s=!1,c={state:i,setOptions:function(n){var o=typeof n==`function`?n(i.options):n;u(),i.options=Object.assign({},a,i.options,o),i.scrollParents={reference:Tt(e)?yn(e):e.contextElement?yn(e.contextElement):[],popper:yn(t)};var s=Jn(Xn([].concat(r,i.options.modifiers)));return i.orderedModifiers=s.filter(function(e){return e.enabled}),l(),c.update()},forceUpdate:function(){if(!s){var e=i.elements,t=e.reference,n=e.popper;if(Qn(t,n)){i.rects={reference:Kn(t,Ht(n),i.options.strategy===`fixed`),popper:It(n)},i.reset=!1,i.placement=i.options.placement,i.orderedModifiers.forEach(function(e){return i.modifiersData[e.name]=Object.assign({},e.data)});for(var r=0;rNumber.parseInt(e,10)):typeof e==`function`?t=>e(t,this._element):e}_getPopperConfig(){let e={placement:this._getPlacement(),modifiers:[{name:`preventOverflow`,options:{boundary:this._config.boundary}},{name:`offset`,options:{offset:this._getOffset()}}]};return(this._inNavbar||this._config.display===`static`)&&(N.setDataAttribute(this._menu,`popper`,`static`),e.modifiers=[{name:`applyStyles`,enabled:!1}]),{...e,...b(this._config.popperConfig,[void 0,e])}}_selectMenuItem({key:e,target:t}){let n=F.find(`.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)`,this._menu).filter(e=>u(e));n.length&&S(n,t,e===or,!n.includes(t)).focus()}static jQueryInterface(e){return this.each(function(){let t=X.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0)throw TypeError(`No method named "${e}"`);t[e]()}})}static clearMenus(e){if(e.button===2||e.type===`keyup`&&e.key!==`Tab`)return;let t=F.find(gr);for(let n of t){let t=X.getInstance(n);if(!t||t._config.autoClose===!1)continue;let r=e.composedPath(),i=r.includes(t._menu);if(r.includes(t._element)||t._config.autoClose===`inside`&&!i||t._config.autoClose===`outside`&&i||t._menu.contains(e.target)&&(e.type===`keyup`&&e.key===`Tab`||/input|select|option|textarea|form/i.test(e.target.tagName)))continue;let a={relatedTarget:t._element};e.type===`click`&&(a.clickEvent=e),t._completeHide(a)}}static dataApiKeydownHandler(e){let t=/input|textarea/i.test(e.target.tagName),n=e.key===`Escape`,r=[`ArrowUp`,or].includes(e.key);if(!r&&!n||t&&!n)return;e.preventDefault();let i=this.matches(hr)?this:F.prev(this,hr)[0]||F.next(this,hr)[0]||F.findOne(hr,e.delegateTarget.parentNode),a=X.getOrCreateInstance(i);if(r){e.stopPropagation(),a.show(),a._selectMenuItem(e);return}a._isShown()&&(e.stopPropagation(),a.hide(),i.focus())}}M.on(document,fr,hr,X.dataApiKeydownHandler),M.on(document,fr,_r,X.dataApiKeydownHandler),M.on(document,dr,X.clearMenus),M.on(document,pr,X.clearMenus),M.on(document,dr,hr,function(e){e.preventDefault(),X.getOrCreateInstance(this).toggle()}),y(X);let Er=`backdrop`,Dr=`show`,Or=`mousedown.bs.${Er}`,kr={className:`modal-backdrop`,clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:`body`},Ar={className:`string`,clickCallback:`(function|null)`,isAnimated:`boolean`,isVisible:`boolean`,rootElement:`(element|string)`};class jr extends de{constructor(e){super(),this._config=this._getConfig(e),this._isAppended=!1,this._element=null}static get Default(){return kr}static get DefaultType(){return Ar}static get NAME(){return Er}show(e){if(!this._config.isVisible){b(e);return}this._append();let t=this._getElement();this._config.isAnimated&&m(t),t.classList.add(Dr),this._emulateAnimation(()=>{b(e)})}hide(e){if(!this._config.isVisible){b(e);return}this._getElement().classList.remove(Dr),this._emulateAnimation(()=>{this.dispose(),b(e)})}dispose(){this._isAppended&&=(M.off(this._element,Or),this._element.remove(),!1)}_getElement(){if(!this._element){let e=document.createElement(`div`);e.className=this._config.className,this._config.isAnimated&&e.classList.add(`fade`),this._element=e}return this._element}_configAfterMerge(e){return e.rootElement=l(e.rootElement),e}_append(){if(this._isAppended)return;let e=this._getElement();this._config.rootElement.append(e),M.on(e,Or,()=>{b(this._config.clickCallback)}),this._isAppended=!0}_emulateAnimation(e){x(e,this._getElement(),this._config.isAnimated)}}let Mr=`.bs.focustrap`,Nr=`focusin${Mr}`,Pr=`keydown.tab${Mr}`,Fr=`backward`,Ir={autofocus:!0,trapElement:null},Lr={autofocus:`boolean`,trapElement:`element`};class Rr extends de{constructor(e){super(),this._config=this._getConfig(e),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return Ir}static get DefaultType(){return Lr}static get NAME(){return`focustrap`}activate(){this._isActive||=(this._config.autofocus&&this._config.trapElement.focus(),M.off(document,Mr),M.on(document,Nr,e=>this._handleFocusin(e)),M.on(document,Pr,e=>this._handleKeydown(e)),!0)}deactivate(){this._isActive&&(this._isActive=!1,M.off(document,Mr))}_handleFocusin(e){let{trapElement:t}=this._config;if(e.target===document||e.target===t||t.contains(e.target))return;let n=F.focusableChildren(t);n.length===0?t.focus():this._lastTabNavDirection===Fr?n[n.length-1].focus():n[0].focus()}_handleKeydown(e){e.key===`Tab`&&(this._lastTabNavDirection=e.shiftKey?Fr:`forward`)}}let zr=`.fixed-top, .fixed-bottom, .is-fixed, .sticky-top`,Br=`.sticky-top`,Vr=`padding-right`,Hr=`margin-right`;class Ur{constructor(){this._element=document.body}getWidth(){let e=document.documentElement.clientWidth;return Math.abs(window.innerWidth-e)}hide(){let e=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,Vr,t=>t+e),this._setElementAttributes(zr,Vr,t=>t+e),this._setElementAttributes(Br,Hr,t=>t-e)}reset(){this._resetElementAttributes(this._element,`overflow`),this._resetElementAttributes(this._element,Vr),this._resetElementAttributes(zr,Vr),this._resetElementAttributes(Br,Hr)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,`overflow`),this._element.style.overflow=`hidden`}_setElementAttributes(e,t,n){let r=this.getWidth();this._applyManipulationCallback(e,e=>{if(e!==this._element&&window.innerWidth>e.clientWidth+r)return;this._saveInitialAttribute(e,t);let i=window.getComputedStyle(e).getPropertyValue(t);e.style.setProperty(t,`${n(Number.parseFloat(i))}px`)})}_saveInitialAttribute(e,t){let n=e.style.getPropertyValue(t);n&&N.setDataAttribute(e,t,n)}_resetElementAttributes(e,t){this._applyManipulationCallback(e,e=>{let n=N.getDataAttribute(e,t);if(n===null){e.style.removeProperty(t);return}N.removeDataAttribute(e,t),e.style.setProperty(t,n)})}_applyManipulationCallback(e,t){if(c(e)){t(e);return}for(let n of F.find(e,this._element))t(n)}}let Z=`.bs.modal`,Wr=`hide${Z}`,Gr=`hidePrevented${Z}`,Kr=`hidden${Z}`,qr=`show${Z}`,Jr=`shown${Z}`,Yr=`resize${Z}`,Xr=`click.dismiss${Z}`,Zr=`mousedown.dismiss${Z}`,Qr=`keydown.dismiss${Z}`,$r=`click${Z}.data-api`,ei=`modal-open`,ti=`show`,ni=`modal-static`,ri={backdrop:!0,focus:!0,keyboard:!0},ii={backdrop:`(boolean|string)`,focus:`boolean`,keyboard:`boolean`};class ai extends P{constructor(e,t){super(e,t),this._dialog=F.findOne(`.modal-dialog`,this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new Ur,this._addEventListeners()}static get Default(){return ri}static get DefaultType(){return ii}static get NAME(){return`modal`}toggle(e){return this._isShown?this.hide():this.show(e)}show(e){this._isShown||this._isTransitioning||M.trigger(this._element,qr,{relatedTarget:e}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(ei),this._adjustDialog(),this._backdrop.show(()=>this._showElement(e)))}hide(){!this._isShown||this._isTransitioning||M.trigger(this._element,Wr).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(ti),this._queueCallback(()=>this._hideModal(),this._element,this._isAnimated()))}dispose(){M.off(window,Z),M.off(this._dialog,Z),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new jr({isVisible:!!this._config.backdrop,isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Rr({trapElement:this._element})}_showElement(e){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display=`block`,this._element.removeAttribute(`aria-hidden`),this._element.setAttribute(`aria-modal`,!0),this._element.setAttribute(`role`,`dialog`),this._element.scrollTop=0;let t=F.findOne(`.modal-body`,this._dialog);t&&(t.scrollTop=0),m(this._element),this._element.classList.add(ti),this._queueCallback(()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,M.trigger(this._element,Jr,{relatedTarget:e})},this._dialog,this._isAnimated())}_addEventListeners(){M.on(this._element,Qr,e=>{if(e.key===`Escape`){if(this._config.keyboard){this.hide();return}this._triggerBackdropTransition()}}),M.on(window,Yr,()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()}),M.on(this._element,Zr,e=>{M.one(this._element,Xr,t=>{if(!(this._element!==e.target||this._element!==t.target)){if(this._config.backdrop===`static`){this._triggerBackdropTransition();return}this._config.backdrop&&this.hide()}})})}_hideModal(){this._element.style.display=`none`,this._element.setAttribute(`aria-hidden`,!0),this._element.removeAttribute(`aria-modal`),this._element.removeAttribute(`role`),this._isTransitioning=!1,this._backdrop.hide(()=>{document.body.classList.remove(ei),this._resetAdjustments(),this._scrollBar.reset(),M.trigger(this._element,Kr)})}_isAnimated(){return this._element.classList.contains(`fade`)}_triggerBackdropTransition(){if(M.trigger(this._element,Gr).defaultPrevented)return;let e=this._element.scrollHeight>document.documentElement.clientHeight,t=this._element.style.overflowY;t===`hidden`||this._element.classList.contains(ni)||(e||(this._element.style.overflowY=`hidden`),this._element.classList.add(ni),this._queueCallback(()=>{this._element.classList.remove(ni),this._queueCallback(()=>{this._element.style.overflowY=t},this._dialog)},this._dialog),this._element.focus())}_adjustDialog(){let e=this._element.scrollHeight>document.documentElement.clientHeight,t=this._scrollBar.getWidth(),n=t>0;if(n&&!e){let e=v()?`paddingLeft`:`paddingRight`;this._element.style[e]=`${t}px`}if(!n&&e){let e=v()?`paddingRight`:`paddingLeft`;this._element.style[e]=`${t}px`}}_resetAdjustments(){this._element.style.paddingLeft=``,this._element.style.paddingRight=``}static jQueryInterface(e,t){return this.each(function(){let n=ai.getOrCreateInstance(this,e);if(typeof e==`string`){if(n[e]===void 0)throw TypeError(`No method named "${e}"`);n[e](t)}})}}M.on(document,$r,`[data-bs-toggle="modal"]`,function(e){let t=F.getElementFromSelector(this);[`A`,`AREA`].includes(this.tagName)&&e.preventDefault(),M.one(t,qr,e=>{e.defaultPrevented||M.one(t,Kr,()=>{u(this)&&this.focus()})});let n=F.findOne(`.modal.show`);n&&ai.getInstance(n).hide(),ai.getOrCreateInstance(t).toggle(this)}),pe(ai),y(ai);let Q=`.bs.offcanvas`,oi=`.data-api`,si=`load${Q}${oi}`,ci=`show`,li=`showing`,ui=`hiding`,di=`.offcanvas.show`,fi=`show${Q}`,pi=`shown${Q}`,mi=`hide${Q}`,hi=`hidePrevented${Q}`,gi=`hidden${Q}`,_i=`resize${Q}`,vi=`click${Q}${oi}`,yi=`keydown.dismiss${Q}`,bi={backdrop:!0,keyboard:!0,scroll:!1},xi={backdrop:`(boolean|string)`,keyboard:`boolean`,scroll:`boolean`};class Si extends P{constructor(e,t){super(e,t),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return bi}static get DefaultType(){return xi}static get NAME(){return`offcanvas`}toggle(e){return this._isShown?this.hide():this.show(e)}show(e){this._isShown||M.trigger(this._element,fi,{relatedTarget:e}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||new Ur().hide(),this._element.setAttribute(`aria-modal`,!0),this._element.setAttribute(`role`,`dialog`),this._element.classList.add(li),this._queueCallback(()=>{(!this._config.scroll||this._config.backdrop)&&this._focustrap.activate(),this._element.classList.add(ci),this._element.classList.remove(li),M.trigger(this._element,pi,{relatedTarget:e})},this._element,!0))}hide(){!this._isShown||M.trigger(this._element,mi).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(ui),this._backdrop.hide(),this._queueCallback(()=>{this._element.classList.remove(ci,ui),this._element.removeAttribute(`aria-modal`),this._element.removeAttribute(`role`),this._config.scroll||new Ur().reset(),M.trigger(this._element,gi)},this._element,!0))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){let e=()=>{if(this._config.backdrop===`static`){M.trigger(this._element,hi);return}this.hide()},t=!!this._config.backdrop;return new jr({className:`offcanvas-backdrop`,isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?e:null})}_initializeFocusTrap(){return new Rr({trapElement:this._element})}_addEventListeners(){M.on(this._element,yi,e=>{if(e.key===`Escape`){if(this._config.keyboard){this.hide();return}M.trigger(this._element,hi)}})}static jQueryInterface(e){return this.each(function(){let t=Si.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0||e.startsWith(`_`)||e===`constructor`)throw TypeError(`No method named "${e}"`);t[e](this)}})}}M.on(document,vi,`[data-bs-toggle="offcanvas"]`,function(e){let t=F.getElementFromSelector(this);if([`A`,`AREA`].includes(this.tagName)&&e.preventDefault(),d(this))return;M.one(t,gi,()=>{u(this)&&this.focus()});let n=F.findOne(di);n&&n!==t&&Si.getInstance(n).hide(),Si.getOrCreateInstance(t).toggle(this)}),M.on(window,si,()=>{for(let e of F.find(di))Si.getOrCreateInstance(e).show()}),M.on(window,_i,()=>{for(let e of F.find(`[aria-modal][class*=show][class*=offcanvas-]`))getComputedStyle(e).position!==`fixed`&&Si.getOrCreateInstance(e).hide()}),pe(Si),y(Si);let Ci={"*":[`class`,`dir`,`id`,`lang`,`role`,/^aria-[\w-]*$/i],a:[`target`,`href`,`title`,`rel`],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:[`src`,`srcset`,`alt`,`title`,`width`,`height`],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},wi=new Set([`background`,`cite`,`href`,`itemtype`,`longdesc`,`poster`,`src`,`xlink:href`]),Ti=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Ei=(e,t)=>{let n=e.nodeName.toLowerCase();return t.includes(n)?wi.has(n)?!!Ti.test(e.nodeValue):!0:t.filter(e=>e instanceof RegExp).some(e=>e.test(n))};function Di(e,t,n){if(!e.length)return e;if(n&&typeof n==`function`)return n(e);let r=new window.DOMParser().parseFromString(e,`text/html`),i=[].concat(...r.body.querySelectorAll(`*`));for(let e of i){let n=e.nodeName.toLowerCase();if(!Object.keys(t).includes(n)){e.remove();continue}let r=[].concat(...e.attributes),i=[].concat(t[`*`]||[],t[n]||[]);for(let t of r)Ei(t,i)||e.removeAttribute(t.nodeName)}return r.body.innerHTML}let Oi={allowList:Ci,content:{},extraClass:``,html:!1,sanitize:!0,sanitizeFn:null,template:`
`},ki={allowList:`object`,content:`object`,extraClass:`(string|function)`,html:`boolean`,sanitize:`boolean`,sanitizeFn:`(null|function)`,template:`string`},Ai={entry:`(string|element|function|null)`,selector:`(string|element)`};class ji extends de{constructor(e){super(),this._config=this._getConfig(e)}static get Default(){return Oi}static get DefaultType(){return ki}static get NAME(){return`TemplateFactory`}getContent(){return Object.values(this._config.content).map(e=>this._resolvePossibleFunction(e)).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(e){return this._checkContent(e),this._config.content={...this._config.content,...e},this}toHtml(){let e=document.createElement(`div`);e.innerHTML=this._maybeSanitize(this._config.template);for(let[t,n]of Object.entries(this._config.content))this._setContent(e,n,t);let t=e.children[0],n=this._resolvePossibleFunction(this._config.extraClass);return n&&t.classList.add(...n.split(` `)),t}_typeCheckConfig(e){super._typeCheckConfig(e),this._checkContent(e.content)}_checkContent(e){for(let[t,n]of Object.entries(e))super._typeCheckConfig({selector:t,entry:n},Ai)}_setContent(e,t,n){let r=F.findOne(n,e);if(r){if(t=this._resolvePossibleFunction(t),!t){r.remove();return}if(c(t)){this._putElementInTemplate(l(t),r);return}if(this._config.html){r.innerHTML=this._maybeSanitize(t);return}r.textContent=t}}_maybeSanitize(e){return this._config.sanitize?Di(e,this._config.allowList,this._config.sanitizeFn):e}_resolvePossibleFunction(e){return b(e,[void 0,this])}_putElementInTemplate(e,t){if(this._config.html){t.innerHTML=``,t.append(e);return}t.textContent=e.textContent}}let Mi=new Set([`sanitize`,`allowList`,`sanitizeFn`]),Ni=`fade`,Pi=`show`,Fi=`.modal`,Ii=`hide.bs.modal`,Li=`hover`,Ri=`focus`,zi=`click`,Bi={AUTO:`auto`,TOP:`top`,RIGHT:v()?`left`:`right`,BOTTOM:`bottom`,LEFT:v()?`right`:`left`},Vi={allowList:Ci,animation:!0,boundary:`clippingParents`,container:!1,customClass:``,delay:0,fallbackPlacements:[`top`,`right`,`bottom`,`left`],html:!1,offset:[0,6],placement:`top`,popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:``,title:``,trigger:`hover focus`},Hi={allowList:`object`,animation:`boolean`,boundary:`(string|element)`,container:`(string|element|boolean)`,customClass:`(string|function)`,delay:`(number|object)`,fallbackPlacements:`array`,html:`boolean`,offset:`(array|string|function)`,placement:`(string|function)`,popperConfig:`(null|object|function)`,sanitize:`boolean`,sanitizeFn:`(null|function)`,selector:`(string|boolean)`,template:`string`,title:`(string|element|function)`,trigger:`string`};class Ui extends P{constructor(e,t){if(rr===void 0)throw TypeError(`Bootstrap's tooltips require Popper (https://popper.js.org/docs/v2/)`);super(e,t),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Vi}static get DefaultType(){return Hi}static get NAME(){return`tooltip`}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){if(this._isEnabled){if(this._isShown()){this._leave();return}this._enter()}}dispose(){clearTimeout(this._timeout),M.off(this._element.closest(Fi),Ii,this._hideModalHandler),this._element.getAttribute(`data-bs-original-title`)&&this._element.setAttribute(`title`,this._element.getAttribute(`data-bs-original-title`)),this._disposePopper(),super.dispose()}show(){if(this._element.style.display===`none`)throw Error(`Please use show on visible elements`);if(!(this._isWithContent()&&this._isEnabled))return;let e=M.trigger(this._element,this.constructor.eventName(`show`)),t=(f(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(e.defaultPrevented||!t)return;this._disposePopper();let n=this._getTipElement();this._element.setAttribute(`aria-describedby`,n.getAttribute(`id`));let{container:r}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(r.append(n),M.trigger(this._element,this.constructor.eventName(`inserted`))),this._popper=this._createPopper(n),n.classList.add(Pi),`ontouchstart`in document.documentElement)for(let e of[].concat(...document.body.children))M.on(e,`mouseover`,p);this._queueCallback(()=>{M.trigger(this._element,this.constructor.eventName(`shown`)),this._isHovered===!1&&this._leave(),this._isHovered=!1},this.tip,this._isAnimated())}hide(){if(!(!this._isShown()||M.trigger(this._element,this.constructor.eventName(`hide`)).defaultPrevented)){if(this._getTipElement().classList.remove(Pi),`ontouchstart`in document.documentElement)for(let e of[].concat(...document.body.children))M.off(e,`mouseover`,p);this._activeTrigger[zi]=!1,this._activeTrigger[Ri]=!1,this._activeTrigger[Li]=!1,this._isHovered=null,this._queueCallback(()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute(`aria-describedby`),M.trigger(this._element,this.constructor.eventName(`hidden`)))},this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return!!this._getTitle()}_getTipElement(){return this.tip||=this._createTipElement(this._newContent||this._getContentForTemplate()),this.tip}_createTipElement(e){let t=this._getTemplateFactory(e).toHtml();if(!t)return null;t.classList.remove(Ni,Pi),t.classList.add(`bs-${this.constructor.NAME}-auto`);let n=a(this.constructor.NAME).toString();return t.setAttribute(`id`,n),this._isAnimated()&&t.classList.add(Ni),t}setContent(e){this._newContent=e,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(e){return this._templateFactory?this._templateFactory.changeContent(e):this._templateFactory=new ji({...this._config,content:e,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute(`data-bs-original-title`)}_initializeOnDelegatedTarget(e){return this.constructor.getOrCreateInstance(e.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ni)}_isShown(){return this.tip&&this.tip.classList.contains(Pi)}_createPopper(e){let t=b(this._config.placement,[this,e,this._element]),n=Bi[t.toUpperCase()];return nr(this._element,e,this._getPopperConfig(n))}_getOffset(){let{offset:e}=this._config;return typeof e==`string`?e.split(`,`).map(e=>Number.parseInt(e,10)):typeof e==`function`?t=>e(t,this._element):e}_resolvePossibleFunction(e){return b(e,[this._element,this._element])}_getPopperConfig(e){let t={placement:e,modifiers:[{name:`flip`,options:{fallbackPlacements:this._config.fallbackPlacements}},{name:`offset`,options:{offset:this._getOffset()}},{name:`preventOverflow`,options:{boundary:this._config.boundary}},{name:`arrow`,options:{element:`.${this.constructor.NAME}-arrow`}},{name:`preSetPlacement`,enabled:!0,phase:`beforeMain`,fn:e=>{this._getTipElement().setAttribute(`data-popper-placement`,e.state.placement)}}]};return{...t,...b(this._config.popperConfig,[void 0,t])}}_setListeners(){let e=this._config.trigger.split(` `);for(let t of e)if(t===`click`)M.on(this._element,this.constructor.eventName(`click`),this._config.selector,e=>{let t=this._initializeOnDelegatedTarget(e);t._activeTrigger[zi]=!(t._isShown()&&t._activeTrigger[zi]),t.toggle()});else if(t!==`manual`){let e=t===Li?this.constructor.eventName(`mouseenter`):this.constructor.eventName(`focusin`),n=t===Li?this.constructor.eventName(`mouseleave`):this.constructor.eventName(`focusout`);M.on(this._element,e,this._config.selector,e=>{let t=this._initializeOnDelegatedTarget(e);t._activeTrigger[e.type===`focusin`?Ri:Li]=!0,t._enter()}),M.on(this._element,n,this._config.selector,e=>{let t=this._initializeOnDelegatedTarget(e);t._activeTrigger[e.type===`focusout`?Ri:Li]=t._element.contains(e.relatedTarget),t._leave()})}this._hideModalHandler=()=>{this._element&&this.hide()},M.on(this._element.closest(Fi),Ii,this._hideModalHandler)}_fixTitle(){let e=this._element.getAttribute(`title`);e&&(!this._element.getAttribute(`aria-label`)&&!this._element.textContent.trim()&&this._element.setAttribute(`aria-label`,e),this._element.setAttribute(`data-bs-original-title`,e),this._element.removeAttribute(`title`))}_enter(){if(this._isShown()||this._isHovered){this._isHovered=!0;return}this._isHovered=!0,this._setTimeout(()=>{this._isHovered&&this.show()},this._config.delay.show)}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout(()=>{this._isHovered||this.hide()},this._config.delay.hide))}_setTimeout(e,t){clearTimeout(this._timeout),this._timeout=setTimeout(e,t)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(e){let t=N.getDataAttributes(this._element);for(let e of Object.keys(t))Mi.has(e)&&delete t[e];return e={...t,...typeof e==`object`&&e?e:{}},e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e.container=e.container===!1?document.body:l(e.container),typeof e.delay==`number`&&(e.delay={show:e.delay,hide:e.delay}),typeof e.title==`number`&&(e.title=e.title.toString()),typeof e.content==`number`&&(e.content=e.content.toString()),e}_getDelegateConfig(){let e={};for(let[t,n]of Object.entries(this._config))this.constructor.Default[t]!==n&&(e[t]=n);return e.selector=!1,e.trigger=`manual`,e}_disposePopper(){this._popper&&=(this._popper.destroy(),null),this.tip&&=(this.tip.remove(),null)}static jQueryInterface(e){return this.each(function(){let t=Ui.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0)throw TypeError(`No method named "${e}"`);t[e]()}})}}y(Ui);let Wi={...Ui.Default,content:``,offset:[0,8],placement:`right`,template:``,trigger:`click`},Gi={...Ui.DefaultType,content:`(null|string|element|function)`};class Ki extends Ui{static get Default(){return Wi}static get DefaultType(){return Gi}static get NAME(){return`popover`}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(e){return this.each(function(){let t=Ki.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0)throw TypeError(`No method named "${e}"`);t[e]()}})}}y(Ki);let qi=`.bs.scrollspy`,Ji=`activate${qi}`,Yi=`click${qi}`,Xi=`load${qi}.data-api`,Zi=`active`,Qi=`[href]`,$i=`.nav-link`,ea=`${$i}, .nav-item > ${$i}, .list-group-item`,ta={offset:null,rootMargin:`0px 0px -25%`,smoothScroll:!1,target:null,threshold:[.1,.5,1]},na={offset:`(number|null)`,rootMargin:`string`,smoothScroll:`boolean`,target:`element`,threshold:`array`};class ra extends P{constructor(e,t){super(e,t),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement=getComputedStyle(this._element).overflowY===`visible`?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return ta}static get DefaultType(){return na}static get NAME(){return`scrollspy`}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(let e of this._observableSections.values())this._observer.observe(e)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(e){return e.target=l(e.target)||document.body,e.rootMargin=e.offset?`${e.offset}px 0px -30%`:e.rootMargin,typeof e.threshold==`string`&&(e.threshold=e.threshold.split(`,`).map(e=>Number.parseFloat(e))),e}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(M.off(this._config.target,Yi),M.on(this._config.target,Yi,Qi,e=>{let t=this._observableSections.get(e.target.hash);if(t){e.preventDefault();let n=this._rootElement||window,r=t.offsetTop-this._element.offsetTop;if(n.scrollTo){n.scrollTo({top:r,behavior:`smooth`});return}n.scrollTop=r}}))}_getNewObserver(){let e={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver(e=>this._observerCallback(e),e)}_observerCallback(e){let t=e=>this._targetLinks.get(`#${e.target.id}`),n=e=>{this._previousScrollData.visibleEntryTop=e.target.offsetTop,this._process(t(e))},r=(this._rootElement||document.documentElement).scrollTop,i=r>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=r;for(let a of e){if(!a.isIntersecting){this._activeTarget=null,this._clearActiveClass(t(a));continue}let e=a.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(i&&e){if(n(a),!r)return;continue}!i&&!e&&n(a)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;let e=F.find(Qi,this._config.target);for(let t of e){if(!t.hash||d(t))continue;let e=F.findOne(decodeURI(t.hash),this._element);u(e)&&(this._targetLinks.set(decodeURI(t.hash),t),this._observableSections.set(t.hash,e))}}_process(e){this._activeTarget!==e&&(this._clearActiveClass(this._config.target),this._activeTarget=e,e.classList.add(Zi),this._activateParents(e),M.trigger(this._element,Ji,{relatedTarget:e}))}_activateParents(e){if(e.classList.contains(`dropdown-item`)){F.findOne(`.dropdown-toggle`,e.closest(`.dropdown`)).classList.add(Zi);return}for(let t of F.parents(e,`.nav, .list-group`))for(let e of F.prev(t,ea))e.classList.add(Zi)}_clearActiveClass(e){e.classList.remove(Zi);let t=F.find(`${Qi}.${Zi}`,e);for(let e of t)e.classList.remove(Zi)}static jQueryInterface(e){return this.each(function(){let t=ra.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0||e.startsWith(`_`)||e===`constructor`)throw TypeError(`No method named "${e}"`);t[e]()}})}}M.on(window,Xi,()=>{for(let e of F.find(`[data-bs-spy="scroll"]`))ra.getOrCreateInstance(e)}),y(ra);let ia=`.bs.tab`,aa=`hide${ia}`,oa=`hidden${ia}`,sa=`show${ia}`,ca=`shown${ia}`,la=`click${ia}`,ua=`keydown${ia}`,da=`load${ia}`,fa=`ArrowRight`,pa=`ArrowDown`,ma=`Home`,ha=`active`,ga=`fade`,_a=`show`,va=`.dropdown-toggle`,ya=`:not(${va})`,ba=`.nav-link${ya}, .list-group-item${ya}, [role="tab"]${ya}`,xa=`[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]`,Sa=`${ba}, ${xa}`,Ca=`.${ha}[data-bs-toggle="tab"], .${ha}[data-bs-toggle="pill"], .${ha}[data-bs-toggle="list"]`;class wa extends P{constructor(e){super(e),this._parent=this._element.closest(`.list-group, .nav, [role="tablist"]`),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),M.on(this._element,ua,e=>this._keydown(e)))}static get NAME(){return`tab`}show(){let e=this._element;if(this._elemIsActive(e))return;let t=this._getActiveElem(),n=t?M.trigger(t,aa,{relatedTarget:e}):null;M.trigger(e,sa,{relatedTarget:t}).defaultPrevented||n&&n.defaultPrevented||(this._deactivate(t,e),this._activate(e,t))}_activate(e,t){e&&(e.classList.add(ha),this._activate(F.getElementFromSelector(e)),this._queueCallback(()=>{if(e.getAttribute(`role`)!==`tab`){e.classList.add(_a);return}e.removeAttribute(`tabindex`),e.setAttribute(`aria-selected`,!0),this._toggleDropDown(e,!0),M.trigger(e,ca,{relatedTarget:t})},e,e.classList.contains(ga)))}_deactivate(e,t){e&&(e.classList.remove(ha),e.blur(),this._deactivate(F.getElementFromSelector(e)),this._queueCallback(()=>{if(e.getAttribute(`role`)!==`tab`){e.classList.remove(_a);return}e.setAttribute(`aria-selected`,!1),e.setAttribute(`tabindex`,`-1`),this._toggleDropDown(e,!1),M.trigger(e,oa,{relatedTarget:t})},e,e.classList.contains(ga)))}_keydown(e){if(![`ArrowLeft`,fa,`ArrowUp`,pa,ma,`End`].includes(e.key))return;e.stopPropagation(),e.preventDefault();let t=this._getChildren().filter(e=>!d(e)),n;if([ma,`End`].includes(e.key))n=t[e.key===ma?0:t.length-1];else{let r=[fa,pa].includes(e.key);n=S(t,e.target,r,!0)}n&&(n.focus({preventScroll:!0}),wa.getOrCreateInstance(n).show())}_getChildren(){return F.find(Sa,this._parent)}_getActiveElem(){return this._getChildren().find(e=>this._elemIsActive(e))||null}_setInitialAttributes(e,t){this._setAttributeIfNotExists(e,`role`,`tablist`);for(let e of t)this._setInitialAttributesOnChild(e)}_setInitialAttributesOnChild(e){e=this._getInnerElement(e);let t=this._elemIsActive(e),n=this._getOuterElement(e);e.setAttribute(`aria-selected`,t),n!==e&&this._setAttributeIfNotExists(n,`role`,`presentation`),t||e.setAttribute(`tabindex`,`-1`),this._setAttributeIfNotExists(e,`role`,`tab`),this._setInitialAttributesOnTargetPanel(e)}_setInitialAttributesOnTargetPanel(e){let t=F.getElementFromSelector(e);t&&(this._setAttributeIfNotExists(t,`role`,`tabpanel`),e.id&&this._setAttributeIfNotExists(t,`aria-labelledby`,`${e.id}`))}_toggleDropDown(e,t){let n=this._getOuterElement(e);if(!n.classList.contains(`dropdown`))return;let r=(e,r)=>{let i=F.findOne(e,n);i&&i.classList.toggle(r,t)};r(va,ha),r(`.dropdown-menu`,_a),n.setAttribute(`aria-expanded`,t)}_setAttributeIfNotExists(e,t,n){e.hasAttribute(t)||e.setAttribute(t,n)}_elemIsActive(e){return e.classList.contains(ha)}_getInnerElement(e){return e.matches(Sa)?e:F.findOne(Sa,e)}_getOuterElement(e){return e.closest(`.nav-item, .list-group-item`)||e}static jQueryInterface(e){return this.each(function(){let t=wa.getOrCreateInstance(this);if(typeof e==`string`){if(t[e]===void 0||e.startsWith(`_`)||e===`constructor`)throw TypeError(`No method named "${e}"`);t[e]()}})}}M.on(document,la,xa,function(e){[`A`,`AREA`].includes(this.tagName)&&e.preventDefault(),!d(this)&&wa.getOrCreateInstance(this).show()}),M.on(window,da,()=>{for(let e of F.find(Ca))wa.getOrCreateInstance(e)}),y(wa);let $=`.bs.toast`,Ta=`mouseover${$}`,Ea=`mouseout${$}`,Da=`focusin${$}`,Oa=`focusout${$}`,ka=`hide${$}`,Aa=`hidden${$}`,ja=`show${$}`,Ma=`shown${$}`,Na=`hide`,Pa=`show`,Fa=`showing`,Ia={animation:`boolean`,autohide:`boolean`,delay:`number`},La={animation:!0,autohide:!0,delay:5e3};class Ra extends P{constructor(e,t){super(e,t),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return La}static get DefaultType(){return Ia}static get NAME(){return`toast`}show(){M.trigger(this._element,ja).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add(`fade`),this._element.classList.remove(Na),m(this._element),this._element.classList.add(Pa,Fa),this._queueCallback(()=>{this._element.classList.remove(Fa),M.trigger(this._element,Ma),this._maybeScheduleHide()},this._element,this._config.animation))}hide(){!this.isShown()||M.trigger(this._element,ka).defaultPrevented||(this._element.classList.add(Fa),this._queueCallback(()=>{this._element.classList.add(Na),this._element.classList.remove(Fa,Pa),M.trigger(this._element,Aa)},this._element,this._config.animation))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(Pa),super.dispose()}isShown(){return this._element.classList.contains(Pa)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout(()=>{this.hide()},this._config.delay)))}_onInteraction(e,t){switch(e.type){case`mouseover`:case`mouseout`:this._hasMouseInteraction=t;break;case`focusin`:case`focusout`:this._hasKeyboardInteraction=t;break}if(t){this._clearTimeout();return}let n=e.relatedTarget;this._element===n||this._element.contains(n)||this._maybeScheduleHide()}_setListeners(){M.on(this._element,Ta,e=>this._onInteraction(e,!0)),M.on(this._element,Ea,e=>this._onInteraction(e,!1)),M.on(this._element,Da,e=>this._onInteraction(e,!0)),M.on(this._element,Oa,e=>this._onInteraction(e,!1))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(e){return this.each(function(){let t=Ra.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0)throw TypeError(`No method named "${e}"`);t[e](this)}})}}return pe(Ra),y(Ra),{Alert:_e,Button:L,Carousel:We,Collapse:it,Dropdown:X,Modal:ai,Offcanvas:Si,Popover:Ki,ScrollSpy:ra,Tab:wa,Toast:Ra,Tooltip:Ui}}))}))();function e(e){document.documentElement.setAttribute(`data-theme`,e)}function t(){var t=document.documentElement.getAttribute(`data-theme`)===`light`?`dark`:`light`;e(t),localStorage.setItem(`theme`,t)}window.toggleTheme=t;function n(e){document.querySelectorAll(`.md-actions.open`).forEach(function(t){t!==e&&(t.classList.remove(`open`),t.querySelector(`.md-actions-toggle`).setAttribute(`aria-expanded`,`false`))})}function r(e){var t=e.closest(`.md-actions`),r=t.classList.contains(`open`);n(t),t.classList.toggle(`open`,!r),e.setAttribute(`aria-expanded`,String(!r))}window.toggleMdActionsMenu=r,document.addEventListener(`click`,function(e){e.target.closest(`.md-actions`)||n()}),document.addEventListener(`keydown`,function(e){e.key===`Escape`&&n()}),document.addEventListener(`click`,function(e){var t=e.target.closest(`[data-copy-url]`);if(t){e.preventDefault();var r=t.getAttribute(`data-copy-url`),i=t.querySelector(`.label`),a=i.textContent;fetch(r).then(function(e){return e.text()}).then(function(e){return navigator.clipboard.writeText(e)}).then(function(){i.textContent=`Copied!`,setTimeout(function(){i.textContent=a},1500)}).catch(function(){navigator.clipboard.writeText(r)}),n()}}),(function(){var e=document.querySelector(`[data-contact-form]`);if(!e)return;var t=e.querySelector(`[data-contact-topic]`),n=e.querySelectorAll(`[data-topic]`),r=document.querySelector(`[data-contact-sent]`),i=r.querySelector(`[data-contact-sent-topic]`),a=r.querySelector(`[data-contact-reset]`);function o(){return e.querySelector(`[data-topic][aria-pressed="true"]`)}function s(e){n.forEach(function(t){t.setAttribute(`aria-pressed`,String(t.getAttribute(`data-topic`)===e))}),t.value=e}n.forEach(function(e){e.addEventListener(`click`,function(){s(e.getAttribute(`data-topic`))})}),e.addEventListener(`submit`,function(t){t.preventDefault();var n=o();i.textContent=n?n.textContent:``,e.hidden=!0,r.hidden=!1,r.querySelector(`h2`).focus({preventScroll:!0})}),a.addEventListener(`click`,function(){e.reset(),s(n[0].getAttribute(`data-topic`)),r.hidden=!0,e.hidden=!1,e.querySelector(`input:not([type="hidden"])`).focus()})})(); \ No newline at end of file +/* empty css */((e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports))(((e,t)=>{(function(n,r){typeof e==`object`&&t!==void 0?t.exports=r():typeof define==`function`&&define.amd?define(r):(n=typeof globalThis<`u`?globalThis:n||self,n.bootstrap=r())})(e,(function(){let e=new Map,t={set(t,n,r){e.has(t)||e.set(t,new Map);let i=e.get(t);if(!i.has(n)&&i.size!==0){console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(i.keys())[0]}.`);return}i.set(n,r)},get(t,n){return e.has(t)&&e.get(t).get(n)||null},remove(t,n){if(!e.has(t))return;let r=e.get(t);r.delete(n),r.size===0&&e.delete(t)}},n=`transitionend`,r=e=>(e&&window.CSS&&window.CSS.escape&&(e=e.replace(/#([^\s"#']+)/g,(e,t)=>`#${CSS.escape(t)}`)),e),i=e=>e==null?`${e}`:Object.prototype.toString.call(e).match(/\s([a-z]+)/i)[1].toLowerCase(),a=e=>{do e+=Math.floor(Math.random()*1e6);while(document.getElementById(e));return e},o=e=>{if(!e)return 0;let{transitionDuration:t,transitionDelay:n}=window.getComputedStyle(e);return!Number.parseFloat(t)&&!Number.parseFloat(n)?0:(t=t.split(`,`)[0],n=n.split(`,`)[0],(Number.parseFloat(t)+Number.parseFloat(n))*1e3)},s=e=>{e.dispatchEvent(new Event(n))},c=e=>!e||typeof e!=`object`?!1:(e.jquery!==void 0&&(e=e[0]),e.nodeType!==void 0),l=e=>c(e)?e.jquery?e[0]:e:typeof e==`string`&&e.length>0?document.querySelector(r(e)):null,u=e=>{if(!c(e)||e.getClientRects().length===0)return!1;let t=getComputedStyle(e).getPropertyValue(`visibility`)===`visible`,n=e.closest(`details:not([open])`);if(!n)return t;if(n!==e){let t=e.closest(`summary`);if(t&&t.parentNode!==n||t===null)return!1}return t},d=e=>!e||e.nodeType!==Node.ELEMENT_NODE||e.classList.contains(`disabled`)?!0:e.disabled===void 0?e.hasAttribute(`disabled`)&&e.getAttribute(`disabled`)!==`false`:e.disabled,f=e=>{if(!document.documentElement.attachShadow)return null;if(typeof e.getRootNode==`function`){let t=e.getRootNode();return t instanceof ShadowRoot?t:null}return e instanceof ShadowRoot?e:e.parentNode?f(e.parentNode):null},p=()=>{},m=e=>{e.offsetHeight},h=()=>window.jQuery&&!document.body.hasAttribute(`data-bs-no-jquery`)?window.jQuery:null,g=[],_=e=>{document.readyState===`loading`?(g.length||document.addEventListener(`DOMContentLoaded`,()=>{for(let e of g)e()}),g.push(e)):e()},v=()=>document.documentElement.dir===`rtl`,y=e=>{_(()=>{let t=h();if(t){let n=e.NAME,r=t.fn[n];t.fn[n]=e.jQueryInterface,t.fn[n].Constructor=e,t.fn[n].noConflict=()=>(t.fn[n]=r,e.jQueryInterface)}})},b=(e,t=[],n=e)=>typeof e==`function`?e.call(...t):n,x=(e,t,r=!0)=>{if(!r){b(e);return}let i=o(t)+5,a=!1,c=({target:r})=>{r===t&&(a=!0,t.removeEventListener(n,c),b(e))};t.addEventListener(n,c),setTimeout(()=>{a||s(t)},i)},S=(e,t,n,r)=>{let i=e.length,a=e.indexOf(t);return a===-1?!n&&r?e[i-1]:e[0]:(a+=n?1:-1,r&&(a=(a+i)%i),e[Math.max(0,Math.min(a,i-1))])},C=/[^.]*(?=\..*)\.|.*/,w=/\..*/,T=/::\d+$/,E={},D=1,O={mouseenter:`mouseover`,mouseleave:`mouseout`},k=new Set(`click.dblclick.mouseup.mousedown.contextmenu.mousewheel.DOMMouseScroll.mouseover.mouseout.mousemove.selectstart.selectend.keydown.keypress.keyup.orientationchange.touchstart.touchmove.touchend.touchcancel.pointerdown.pointermove.pointerup.pointerleave.pointercancel.gesturestart.gesturechange.gestureend.focus.blur.change.reset.select.submit.focusin.focusout.load.unload.beforeunload.resize.move.DOMContentLoaded.readystatechange.error.abort.scroll`.split(`.`));function ee(e,t){return t&&`${t}::${D++}`||e.uidEvent||D++}function A(e){let t=ee(e);return e.uidEvent=t,E[t]=E[t]||{},E[t]}function j(e,t){return function n(r){return ce(r,{delegateTarget:e}),n.oneOff&&M.off(e,r.type,t),t.apply(e,[r])}}function te(e,t,n){return function r(i){let a=e.querySelectorAll(t);for(let{target:o}=i;o&&o!==this;o=o.parentNode)for(let s of a)if(s===o)return ce(i,{delegateTarget:o}),r.oneOff&&M.off(e,i.type,t,n),n.apply(o,[i])}}function ne(e,t,n=null){return Object.values(e).find(e=>e.callable===t&&e.delegationSelector===n)}function re(e,t,n){let r=typeof t==`string`,i=r?n:t||n,a=se(e);return k.has(a)||(a=e),[r,i,a]}function ie(e,t,n,r,i){if(typeof t!=`string`||!e)return;let[a,o,s]=re(t,n,r);t in O&&(o=(e=>function(t){if(!t.relatedTarget||t.relatedTarget!==t.delegateTarget&&!t.delegateTarget.contains(t.relatedTarget))return e.call(this,t)})(o));let c=A(e),l=c[s]||(c[s]={}),u=ne(l,o,a?n:null);if(u){u.oneOff=u.oneOff&&i;return}let d=ee(o,t.replace(C,``)),f=a?te(e,n,o):j(e,o);f.delegationSelector=a?n:null,f.callable=o,f.oneOff=i,f.uidEvent=d,l[d]=f,e.addEventListener(s,f,a)}function ae(e,t,n,r,i){let a=ne(t[n],r,i);a&&(e.removeEventListener(n,a,!!i),delete t[n][a.uidEvent])}function oe(e,t,n,r){let i=t[n]||{};for(let[a,o]of Object.entries(i))a.includes(r)&&ae(e,t,n,o.callable,o.delegationSelector)}function se(e){return e=e.replace(w,``),O[e]||e}let M={on(e,t,n,r){ie(e,t,n,r,!1)},one(e,t,n,r){ie(e,t,n,r,!0)},off(e,t,n,r){if(typeof t!=`string`||!e)return;let[i,a,o]=re(t,n,r),s=o!==t,c=A(e),l=c[o]||{},u=t.startsWith(`.`);if(a!==void 0){if(!Object.keys(l).length)return;ae(e,c,o,a,i?n:null);return}if(u)for(let n of Object.keys(c))oe(e,c,n,t.slice(1));for(let[n,r]of Object.entries(l)){let i=n.replace(T,``);(!s||t.includes(i))&&ae(e,c,o,r.callable,r.delegationSelector)}},trigger(e,t,n){if(typeof t!=`string`||!e)return null;let r=h(),i=t!==se(t),a=null,o=!0,s=!0,c=!1;i&&r&&(a=r.Event(t,n),r(e).trigger(a),o=!a.isPropagationStopped(),s=!a.isImmediatePropagationStopped(),c=a.isDefaultPrevented());let l=ce(new Event(t,{bubbles:o,cancelable:!0}),n);return c&&l.preventDefault(),s&&e.dispatchEvent(l),l.defaultPrevented&&a&&a.preventDefault(),l}};function ce(e,t={}){for(let[n,r]of Object.entries(t))try{e[n]=r}catch{Object.defineProperty(e,n,{configurable:!0,get(){return r}})}return e}function le(e){if(e===`true`)return!0;if(e===`false`)return!1;if(e===Number(e).toString())return Number(e);if(e===``||e===`null`)return null;if(typeof e!=`string`)return e;try{return JSON.parse(decodeURIComponent(e))}catch{return e}}function ue(e){return e.replace(/[A-Z]/g,e=>`-${e.toLowerCase()}`)}let N={setDataAttribute(e,t,n){e.setAttribute(`data-bs-${ue(t)}`,n)},removeDataAttribute(e,t){e.removeAttribute(`data-bs-${ue(t)}`)},getDataAttributes(e){if(!e)return{};let t={},n=Object.keys(e.dataset).filter(e=>e.startsWith(`bs`)&&!e.startsWith(`bsConfig`));for(let r of n){let n=r.replace(/^bs/,``);n=n.charAt(0).toLowerCase()+n.slice(1),t[n]=le(e.dataset[r])}return t},getDataAttribute(e,t){return le(e.getAttribute(`data-bs-${ue(t)}`))}};class de{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw Error(`You have to implement the static method "NAME", for each component!`)}_getConfig(e){return e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e}_mergeConfigObj(e,t){let n=c(t)?N.getDataAttribute(t,`config`):{};return{...this.constructor.Default,...typeof n==`object`?n:{},...c(t)?N.getDataAttributes(t):{},...typeof e==`object`?e:{}}}_typeCheckConfig(e,t=this.constructor.DefaultType){for(let[n,r]of Object.entries(t)){let t=e[n],a=c(t)?`element`:i(t);if(!new RegExp(r).test(a))throw TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${a}" but expected type "${r}".`)}}}class P extends de{constructor(e,n){super(),e=l(e),e&&(this._element=e,this._config=this._getConfig(n),t.set(this._element,this.constructor.DATA_KEY,this))}dispose(){t.remove(this._element,this.constructor.DATA_KEY),M.off(this._element,this.constructor.EVENT_KEY);for(let e of Object.getOwnPropertyNames(this))this[e]=null}_queueCallback(e,t,n=!0){x(e,t,n)}_getConfig(e){return e=this._mergeConfigObj(e,this._element),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}static getInstance(e){return t.get(l(e),this.DATA_KEY)}static getOrCreateInstance(e,t={}){return this.getInstance(e)||new this(e,typeof t==`object`?t:null)}static get VERSION(){return`5.3.8`}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(e){return`${e}${this.EVENT_KEY}`}}let fe=e=>{let t=e.getAttribute(`data-bs-target`);if(!t||t===`#`){let n=e.getAttribute(`href`);if(!n||!n.includes(`#`)&&!n.startsWith(`.`))return null;n.includes(`#`)&&!n.startsWith(`#`)&&(n=`#${n.split(`#`)[1]}`),t=n&&n!==`#`?n.trim():null}return t?t.split(`,`).map(e=>r(e)).join(`,`):null},F={find(e,t=document.documentElement){return[].concat(...Element.prototype.querySelectorAll.call(t,e))},findOne(e,t=document.documentElement){return Element.prototype.querySelector.call(t,e)},children(e,t){return[].concat(...e.children).filter(e=>e.matches(t))},parents(e,t){let n=[],r=e.parentNode.closest(t);for(;r;)n.push(r),r=r.parentNode.closest(t);return n},prev(e,t){let n=e.previousElementSibling;for(;n;){if(n.matches(t))return[n];n=n.previousElementSibling}return[]},next(e,t){let n=e.nextElementSibling;for(;n;){if(n.matches(t))return[n];n=n.nextElementSibling}return[]},focusableChildren(e){let t=[`a`,`button`,`input`,`textarea`,`select`,`details`,`[tabindex]`,`[contenteditable="true"]`].map(e=>`${e}:not([tabindex^="-"])`).join(`,`);return this.find(t,e).filter(e=>!d(e)&&u(e))},getSelectorFromElement(e){let t=fe(e);return t&&F.findOne(t)?t:null},getElementFromSelector(e){let t=fe(e);return t?F.findOne(t):null},getMultipleElementsFromSelector(e){let t=fe(e);return t?F.find(t):[]}},pe=(e,t=`hide`)=>{let n=`click.dismiss${e.EVENT_KEY}`,r=e.NAME;M.on(document,n,`[data-bs-dismiss="${r}"]`,function(n){if([`A`,`AREA`].includes(this.tagName)&&n.preventDefault(),d(this))return;let i=F.getElementFromSelector(this)||this.closest(`.${r}`);e.getOrCreateInstance(i)[t]()})},me=`.bs.alert`,he=`close${me}`,ge=`closed${me}`;class _e extends P{static get NAME(){return`alert`}close(){if(M.trigger(this._element,he).defaultPrevented)return;this._element.classList.remove(`show`);let e=this._element.classList.contains(`fade`);this._queueCallback(()=>this._destroyElement(),this._element,e)}_destroyElement(){this._element.remove(),M.trigger(this._element,ge),this.dispose()}static jQueryInterface(e){return this.each(function(){let t=_e.getOrCreateInstance(this);if(typeof e==`string`){if(t[e]===void 0||e.startsWith(`_`)||e===`constructor`)throw TypeError(`No method named "${e}"`);t[e](this)}})}}pe(_e,`close`),y(_e);let I=`[data-bs-toggle="button"]`;class L extends P{static get NAME(){return`button`}toggle(){this._element.setAttribute(`aria-pressed`,this._element.classList.toggle(`active`))}static jQueryInterface(e){return this.each(function(){let t=L.getOrCreateInstance(this);e===`toggle`&&t[e]()})}}M.on(document,`click.bs.button.data-api`,I,e=>{e.preventDefault();let t=e.target.closest(I);L.getOrCreateInstance(t).toggle()}),y(L);let R=`.bs.swipe`,ve=`touchstart${R}`,ye=`touchmove${R}`,be=`touchend${R}`,xe=`pointerdown${R}`,Se=`pointerup${R}`,Ce={endCallback:null,leftCallback:null,rightCallback:null},we={endCallback:`(function|null)`,leftCallback:`(function|null)`,rightCallback:`(function|null)`};class Te extends de{constructor(e,t){super(),this._element=e,!(!e||!Te.isSupported())&&(this._config=this._getConfig(t),this._deltaX=0,this._supportPointerEvents=!!window.PointerEvent,this._initEvents())}static get Default(){return Ce}static get DefaultType(){return we}static get NAME(){return`swipe`}dispose(){M.off(this._element,R)}_start(e){if(!this._supportPointerEvents){this._deltaX=e.touches[0].clientX;return}this._eventIsPointerPenTouch(e)&&(this._deltaX=e.clientX)}_end(e){this._eventIsPointerPenTouch(e)&&(this._deltaX=e.clientX-this._deltaX),this._handleSwipe(),b(this._config.endCallback)}_move(e){this._deltaX=e.touches&&e.touches.length>1?0:e.touches[0].clientX-this._deltaX}_handleSwipe(){let e=Math.abs(this._deltaX);if(e<=40)return;let t=e/this._deltaX;this._deltaX=0,t&&b(t>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(M.on(this._element,xe,e=>this._start(e)),M.on(this._element,Se,e=>this._end(e)),this._element.classList.add(`pointer-event`)):(M.on(this._element,ve,e=>this._start(e)),M.on(this._element,ye,e=>this._move(e)),M.on(this._element,be,e=>this._end(e)))}_eventIsPointerPenTouch(e){return this._supportPointerEvents&&(e.pointerType===`pen`||e.pointerType===`touch`)}static isSupported(){return`ontouchstart`in document.documentElement||navigator.maxTouchPoints>0}}let z=`.bs.carousel`,Ee=`.data-api`,De=`next`,Oe=`prev`,ke=`left`,Ae=`right`,je=`slide${z}`,Me=`slid${z}`,Ne=`keydown${z}`,Pe=`mouseenter${z}`,Fe=`mouseleave${z}`,Ie=`dragstart${z}`,Le=`load${z}${Ee}`,Re=`click${z}${Ee}`,ze=`carousel`,Be=`active`,Ve={ArrowLeft:Ae,ArrowRight:ke},He={interval:5e3,keyboard:!0,pause:`hover`,ride:!1,touch:!0,wrap:!0},Ue={interval:`(number|boolean)`,keyboard:`boolean`,pause:`(string|boolean)`,ride:`(boolean|string)`,touch:`boolean`,wrap:`boolean`};class We extends P{constructor(e,t){super(e,t),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=F.findOne(`.carousel-indicators`,this._element),this._addEventListeners(),this._config.ride===ze&&this.cycle()}static get Default(){return He}static get DefaultType(){return Ue}static get NAME(){return`carousel`}next(){this._slide(De)}nextWhenVisible(){!document.hidden&&u(this._element)&&this.next()}prev(){this._slide(Oe)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval(()=>this.nextWhenVisible(),this._config.interval)}_maybeEnableCycle(){if(this._config.ride){if(this._isSliding){M.one(this._element,Me,()=>this.cycle());return}this.cycle()}}to(e){let t=this._getItems();if(e>t.length-1||e<0)return;if(this._isSliding){M.one(this._element,Me,()=>this.to(e));return}let n=this._getItemIndex(this._getActive());if(n===e)return;let r=e>n?De:Oe;this._slide(r,t[e])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(e){return e.defaultInterval=e.interval,e}_addEventListeners(){this._config.keyboard&&M.on(this._element,Ne,e=>this._keydown(e)),this._config.pause===`hover`&&(M.on(this._element,Pe,()=>this.pause()),M.on(this._element,Fe,()=>this._maybeEnableCycle())),this._config.touch&&Te.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(let e of F.find(`.carousel-item img`,this._element))M.on(e,Ie,e=>e.preventDefault());let e={leftCallback:()=>this._slide(this._directionToOrder(ke)),rightCallback:()=>this._slide(this._directionToOrder(Ae)),endCallback:()=>{this._config.pause===`hover`&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout(()=>this._maybeEnableCycle(),500+this._config.interval))}};this._swipeHelper=new Te(this._element,e)}_keydown(e){if(/input|textarea/i.test(e.target.tagName))return;let t=Ve[e.key];t&&(e.preventDefault(),this._slide(this._directionToOrder(t)))}_getItemIndex(e){return this._getItems().indexOf(e)}_setActiveIndicatorElement(e){if(!this._indicatorsElement)return;let t=F.findOne(`.active`,this._indicatorsElement);t.classList.remove(Be),t.removeAttribute(`aria-current`);let n=F.findOne(`[data-bs-slide-to="${e}"]`,this._indicatorsElement);n&&(n.classList.add(Be),n.setAttribute(`aria-current`,`true`))}_updateInterval(){let e=this._activeElement||this._getActive();if(!e)return;let t=Number.parseInt(e.getAttribute(`data-bs-interval`),10);this._config.interval=t||this._config.defaultInterval}_slide(e,t=null){if(this._isSliding)return;let n=this._getActive(),r=e===De,i=t||S(this._getItems(),n,r,this._config.wrap);if(i===n)return;let a=this._getItemIndex(i),o=t=>M.trigger(this._element,t,{relatedTarget:i,direction:this._orderToDirection(e),from:this._getItemIndex(n),to:a});if(o(je).defaultPrevented||!n||!i)return;let s=!!this._interval;this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(a),this._activeElement=i;let c=r?`carousel-item-start`:`carousel-item-end`,l=r?`carousel-item-next`:`carousel-item-prev`;i.classList.add(l),m(i),n.classList.add(c),i.classList.add(c),this._queueCallback(()=>{i.classList.remove(c,l),i.classList.add(Be),n.classList.remove(Be,l,c),this._isSliding=!1,o(Me)},n,this._isAnimated()),s&&this.cycle()}_isAnimated(){return this._element.classList.contains(`slide`)}_getActive(){return F.findOne(`.active.carousel-item`,this._element)}_getItems(){return F.find(`.carousel-item`,this._element)}_clearInterval(){this._interval&&=(clearInterval(this._interval),null)}_directionToOrder(e){return v()?e===ke?Oe:De:e===ke?De:Oe}_orderToDirection(e){return v()?e===Oe?ke:Ae:e===Oe?Ae:ke}static jQueryInterface(e){return this.each(function(){let t=We.getOrCreateInstance(this,e);if(typeof e==`number`){t.to(e);return}if(typeof e==`string`){if(t[e]===void 0||e.startsWith(`_`)||e===`constructor`)throw TypeError(`No method named "${e}"`);t[e]()}})}}M.on(document,Re,`[data-bs-slide], [data-bs-slide-to]`,function(e){let t=F.getElementFromSelector(this);if(!t||!t.classList.contains(ze))return;e.preventDefault();let n=We.getOrCreateInstance(t),r=this.getAttribute(`data-bs-slide-to`);if(r){n.to(r),n._maybeEnableCycle();return}if(N.getDataAttribute(this,`slide`)===`next`){n.next(),n._maybeEnableCycle();return}n.prev(),n._maybeEnableCycle()}),M.on(window,Le,()=>{let e=F.find(`[data-bs-ride="carousel"]`);for(let t of e)We.getOrCreateInstance(t)}),y(We);let Ge=`.bs.collapse`,Ke=`show${Ge}`,qe=`shown${Ge}`,Je=`hide${Ge}`,Ye=`hidden${Ge}`,Xe=`click${Ge}.data-api`,Ze=`show`,Qe=`collapse`,$e=`collapsing`,et=`:scope .${Qe} .${Qe}`,tt=`[data-bs-toggle="collapse"]`,nt={parent:null,toggle:!0},rt={parent:`(null|element)`,toggle:`boolean`};class it extends P{constructor(e,t){super(e,t),this._isTransitioning=!1,this._triggerArray=[];let n=F.find(tt);for(let e of n){let t=F.getSelectorFromElement(e),n=F.find(t).filter(e=>e===this._element);t!==null&&n.length&&this._triggerArray.push(e)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return nt}static get DefaultType(){return rt}static get NAME(){return`collapse`}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let e=[];if(this._config.parent&&(e=this._getFirstLevelChildren(`.collapse.show, .collapse.collapsing`).filter(e=>e!==this._element).map(e=>it.getOrCreateInstance(e,{toggle:!1}))),e.length&&e[0]._isTransitioning||M.trigger(this._element,Ke).defaultPrevented)return;for(let t of e)t.hide();let t=this._getDimension();this._element.classList.remove(Qe),this._element.classList.add($e),this._element.style[t]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;let n=()=>{this._isTransitioning=!1,this._element.classList.remove($e),this._element.classList.add(Qe,Ze),this._element.style[t]=``,M.trigger(this._element,qe)},r=`scroll${t[0].toUpperCase()+t.slice(1)}`;this._queueCallback(n,this._element,!0),this._element.style[t]=`${this._element[r]}px`}hide(){if(this._isTransitioning||!this._isShown()||M.trigger(this._element,Je).defaultPrevented)return;let e=this._getDimension();this._element.style[e]=`${this._element.getBoundingClientRect()[e]}px`,m(this._element),this._element.classList.add($e),this._element.classList.remove(Qe,Ze);for(let e of this._triggerArray){let t=F.getElementFromSelector(e);t&&!this._isShown(t)&&this._addAriaAndCollapsedClass([e],!1)}this._isTransitioning=!0;let t=()=>{this._isTransitioning=!1,this._element.classList.remove($e),this._element.classList.add(Qe),M.trigger(this._element,Ye)};this._element.style[e]=``,this._queueCallback(t,this._element,!0)}_isShown(e=this._element){return e.classList.contains(Ze)}_configAfterMerge(e){return e.toggle=!!e.toggle,e.parent=l(e.parent),e}_getDimension(){return this._element.classList.contains(`collapse-horizontal`)?`width`:`height`}_initializeChildren(){if(!this._config.parent)return;let e=this._getFirstLevelChildren(tt);for(let t of e){let e=F.getElementFromSelector(t);e&&this._addAriaAndCollapsedClass([t],this._isShown(e))}}_getFirstLevelChildren(e){let t=F.find(et,this._config.parent);return F.find(e,this._config.parent).filter(e=>!t.includes(e))}_addAriaAndCollapsedClass(e,t){if(e.length)for(let n of e)n.classList.toggle(`collapsed`,!t),n.setAttribute(`aria-expanded`,t)}static jQueryInterface(e){let t={};return typeof e==`string`&&/show|hide/.test(e)&&(t.toggle=!1),this.each(function(){let n=it.getOrCreateInstance(this,t);if(typeof e==`string`){if(n[e]===void 0)throw TypeError(`No method named "${e}"`);n[e]()}})}}M.on(document,Xe,tt,function(e){(e.target.tagName===`A`||e.delegateTarget&&e.delegateTarget.tagName===`A`)&&e.preventDefault();for(let e of F.getMultipleElementsFromSelector(this))it.getOrCreateInstance(e,{toggle:!1}).toggle()}),y(it);var B=`top`,V=`bottom`,H=`right`,U=`left`,at=`auto`,ot=[B,V,H,U],st=`start`,ct=`end`,lt=`clippingParents`,ut=`viewport`,dt=`popper`,ft=`reference`,pt=ot.reduce(function(e,t){return e.concat([t+`-`+st,t+`-`+ct])},[]),mt=[].concat(ot,[at]).reduce(function(e,t){return e.concat([t,t+`-`+st,t+`-`+ct])},[]),ht=`beforeRead`,gt=`read`,_t=`afterRead`,vt=`beforeMain`,yt=`main`,bt=`afterMain`,xt=`beforeWrite`,St=`write`,Ct=`afterWrite`,wt=[ht,gt,_t,vt,yt,bt,xt,St,Ct];function W(e){return e?(e.nodeName||``).toLowerCase():null}function G(e){if(e==null)return window;if(e.toString()!==`[object Window]`){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function Tt(e){return e instanceof G(e).Element||e instanceof Element}function K(e){return e instanceof G(e).HTMLElement||e instanceof HTMLElement}function Et(e){return typeof ShadowRoot>`u`?!1:e instanceof G(e).ShadowRoot||e instanceof ShadowRoot}function Dt(e){var t=e.state;Object.keys(t.elements).forEach(function(e){var n=t.styles[e]||{},r=t.attributes[e]||{},i=t.elements[e];!K(i)||!W(i)||(Object.assign(i.style,n),Object.keys(r).forEach(function(e){var t=r[e];t===!1?i.removeAttribute(e):i.setAttribute(e,t===!0?``:t)}))})}function Ot(e){var t=e.state,n={popper:{position:t.options.strategy,left:`0`,top:`0`,margin:`0`},arrow:{position:`absolute`},reference:{}};return Object.assign(t.elements.popper.style,n.popper),t.styles=n,t.elements.arrow&&Object.assign(t.elements.arrow.style,n.arrow),function(){Object.keys(t.elements).forEach(function(e){var r=t.elements[e],i=t.attributes[e]||{},a=Object.keys(t.styles.hasOwnProperty(e)?t.styles[e]:n[e]).reduce(function(e,t){return e[t]=``,e},{});!K(r)||!W(r)||(Object.assign(r.style,a),Object.keys(i).forEach(function(e){r.removeAttribute(e)}))})}}let kt={name:`applyStyles`,enabled:!0,phase:`write`,fn:Dt,effect:Ot,requires:[`computeStyles`]};function q(e){return e.split(`-`)[0]}var At=Math.max,jt=Math.min,Mt=Math.round;function Nt(){var e=navigator.userAgentData;return e!=null&&e.brands&&Array.isArray(e.brands)?e.brands.map(function(e){return e.brand+`/`+e.version}).join(` `):navigator.userAgent}function Pt(){return!/^((?!chrome|android).)*safari/i.test(Nt())}function Ft(e,t,n){t===void 0&&(t=!1),n===void 0&&(n=!1);var r=e.getBoundingClientRect(),i=1,a=1;t&&K(e)&&(i=e.offsetWidth>0&&Mt(r.width)/e.offsetWidth||1,a=e.offsetHeight>0&&Mt(r.height)/e.offsetHeight||1);var o=(Tt(e)?G(e):window).visualViewport,s=!Pt()&&n,c=(r.left+(s&&o?o.offsetLeft:0))/i,l=(r.top+(s&&o?o.offsetTop:0))/a,u=r.width/i,d=r.height/a;return{width:u,height:d,top:l,right:c+u,bottom:l+d,left:c,x:c,y:l}}function It(e){var t=Ft(e),n=e.offsetWidth,r=e.offsetHeight;return Math.abs(t.width-n)<=1&&(n=t.width),Math.abs(t.height-r)<=1&&(r=t.height),{x:e.offsetLeft,y:e.offsetTop,width:n,height:r}}function Lt(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&Et(n)){var r=t;do{if(r&&e.isSameNode(r))return!0;r=r.parentNode||r.host}while(r)}return!1}function J(e){return G(e).getComputedStyle(e)}function Rt(e){return[`table`,`td`,`th`].indexOf(W(e))>=0}function Y(e){return((Tt(e)?e.ownerDocument:e.document)||window.document).documentElement}function zt(e){return W(e)===`html`?e:e.assignedSlot||e.parentNode||(Et(e)?e.host:null)||Y(e)}function Bt(e){return!K(e)||J(e).position===`fixed`?null:e.offsetParent}function Vt(e){var t=/firefox/i.test(Nt());if(/Trident/i.test(Nt())&&K(e)&&J(e).position===`fixed`)return null;var n=zt(e);for(Et(n)&&(n=n.host);K(n)&&[`html`,`body`].indexOf(W(n))<0;){var r=J(n);if(r.transform!==`none`||r.perspective!==`none`||r.contain===`paint`||[`transform`,`perspective`].indexOf(r.willChange)!==-1||t&&r.willChange===`filter`||t&&r.filter&&r.filter!==`none`)return n;n=n.parentNode}return null}function Ht(e){for(var t=G(e),n=Bt(e);n&&Rt(n)&&J(n).position===`static`;)n=Bt(n);return n&&(W(n)===`html`||W(n)===`body`&&J(n).position===`static`)?t:n||Vt(e)||t}function Ut(e){return[`top`,`bottom`].indexOf(e)>=0?`x`:`y`}function Wt(e,t,n){return At(e,jt(t,n))}function Gt(e,t,n){var r=Wt(e,t,n);return r>n?n:r}function Kt(){return{top:0,right:0,bottom:0,left:0}}function qt(e){return Object.assign({},Kt(),e)}function Jt(e,t){return t.reduce(function(t,n){return t[n]=e,t},{})}var Yt=function(e,t){return e=typeof e==`function`?e(Object.assign({},t.rects,{placement:t.placement})):e,qt(typeof e==`number`?Jt(e,ot):e)};function Xt(e){var t,n=e.state,r=e.name,i=e.options,a=n.elements.arrow,o=n.modifiersData.popperOffsets,s=q(n.placement),c=Ut(s),l=[U,H].indexOf(s)>=0?`height`:`width`;if(!(!a||!o)){var u=Yt(i.padding,n),d=It(a),f=c===`y`?B:U,p=c===`y`?V:H,m=n.rects.reference[l]+n.rects.reference[c]-o[c]-n.rects.popper[l],h=o[c]-n.rects.reference[c],g=Ht(a),_=g?c===`y`?g.clientHeight||0:g.clientWidth||0:0,v=m/2-h/2,y=u[f],b=_-d[l]-u[p],x=_/2-d[l]/2+v,S=Wt(y,x,b),C=c;n.modifiersData[r]=(t={},t[C]=S,t.centerOffset=S-x,t)}}function Zt(e){var t=e.state,n=e.options.element,r=n===void 0?`[data-popper-arrow]`:n;r!=null&&(typeof r==`string`&&(r=t.elements.popper.querySelector(r),!r)||Lt(t.elements.popper,r)&&(t.elements.arrow=r))}let Qt={name:`arrow`,enabled:!0,phase:`main`,fn:Xt,effect:Zt,requires:[`popperOffsets`],requiresIfExists:[`preventOverflow`]};function $t(e){return e.split(`-`)[1]}var en={top:`auto`,right:`auto`,bottom:`auto`,left:`auto`};function tn(e,t){var n=e.x,r=e.y,i=t.devicePixelRatio||1;return{x:Mt(n*i)/i||0,y:Mt(r*i)/i||0}}function nn(e){var t,n=e.popper,r=e.popperRect,i=e.placement,a=e.variation,o=e.offsets,s=e.position,c=e.gpuAcceleration,l=e.adaptive,u=e.roundOffsets,d=e.isFixed,f=o.x,p=f===void 0?0:f,m=o.y,h=m===void 0?0:m,g=typeof u==`function`?u({x:p,y:h}):{x:p,y:h};p=g.x,h=g.y;var _=o.hasOwnProperty(`x`),v=o.hasOwnProperty(`y`),y=U,b=B,x=window;if(l){var S=Ht(n),C=`clientHeight`,w=`clientWidth`;if(S===G(n)&&(S=Y(n),J(S).position!==`static`&&s===`absolute`&&(C=`scrollHeight`,w=`scrollWidth`)),S=S,i===B||(i===U||i===H)&&a===ct){b=V;var T=d&&S===x&&x.visualViewport?x.visualViewport.height:S[C];h-=T-r.height,h*=c?1:-1}if(i===U||(i===B||i===V)&&a===ct){y=H;var E=d&&S===x&&x.visualViewport?x.visualViewport.width:S[w];p-=E-r.width,p*=c?1:-1}}var D=Object.assign({position:s},l&&en),O=u===!0?tn({x:p,y:h},G(n)):{x:p,y:h};if(p=O.x,h=O.y,c){var k;return Object.assign({},D,(k={},k[b]=v?`0`:``,k[y]=_?`0`:``,k.transform=(x.devicePixelRatio||1)<=1?`translate(`+p+`px, `+h+`px)`:`translate3d(`+p+`px, `+h+`px, 0)`,k))}return Object.assign({},D,(t={},t[b]=v?h+`px`:``,t[y]=_?p+`px`:``,t.transform=``,t))}function rn(e){var t=e.state,n=e.options,r=n.gpuAcceleration,i=r===void 0?!0:r,a=n.adaptive,o=a===void 0?!0:a,s=n.roundOffsets,c=s===void 0?!0:s,l={placement:q(t.placement),variation:$t(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:i,isFixed:t.options.strategy===`fixed`};t.modifiersData.popperOffsets!=null&&(t.styles.popper=Object.assign({},t.styles.popper,nn(Object.assign({},l,{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:o,roundOffsets:c})))),t.modifiersData.arrow!=null&&(t.styles.arrow=Object.assign({},t.styles.arrow,nn(Object.assign({},l,{offsets:t.modifiersData.arrow,position:`absolute`,adaptive:!1,roundOffsets:c})))),t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-placement":t.placement})}let an={name:`computeStyles`,enabled:!0,phase:`beforeWrite`,fn:rn,data:{}};var on={passive:!0};function sn(e){var t=e.state,n=e.instance,r=e.options,i=r.scroll,a=i===void 0?!0:i,o=r.resize,s=o===void 0?!0:o,c=G(t.elements.popper),l=[].concat(t.scrollParents.reference,t.scrollParents.popper);return a&&l.forEach(function(e){e.addEventListener(`scroll`,n.update,on)}),s&&c.addEventListener(`resize`,n.update,on),function(){a&&l.forEach(function(e){e.removeEventListener(`scroll`,n.update,on)}),s&&c.removeEventListener(`resize`,n.update,on)}}let cn={name:`eventListeners`,enabled:!0,phase:`write`,fn:function(){},effect:sn,data:{}};var ln={left:`right`,right:`left`,bottom:`top`,top:`bottom`};function un(e){return e.replace(/left|right|bottom|top/g,function(e){return ln[e]})}var dn={start:`end`,end:`start`};function fn(e){return e.replace(/start|end/g,function(e){return dn[e]})}function pn(e){var t=G(e);return{scrollLeft:t.pageXOffset,scrollTop:t.pageYOffset}}function mn(e){return Ft(Y(e)).left+pn(e).scrollLeft}function hn(e,t){var n=G(e),r=Y(e),i=n.visualViewport,a=r.clientWidth,o=r.clientHeight,s=0,c=0;if(i){a=i.width,o=i.height;var l=Pt();(l||!l&&t===`fixed`)&&(s=i.offsetLeft,c=i.offsetTop)}return{width:a,height:o,x:s+mn(e),y:c}}function gn(e){var t=Y(e),n=pn(e),r=e.ownerDocument?.body,i=At(t.scrollWidth,t.clientWidth,r?r.scrollWidth:0,r?r.clientWidth:0),a=At(t.scrollHeight,t.clientHeight,r?r.scrollHeight:0,r?r.clientHeight:0),o=-n.scrollLeft+mn(e),s=-n.scrollTop;return J(r||t).direction===`rtl`&&(o+=At(t.clientWidth,r?r.clientWidth:0)-i),{width:i,height:a,x:o,y:s}}function _n(e){var t=J(e),n=t.overflow,r=t.overflowX,i=t.overflowY;return/auto|scroll|overlay|hidden/.test(n+i+r)}function vn(e){return[`html`,`body`,`#document`].indexOf(W(e))>=0?e.ownerDocument.body:K(e)&&_n(e)?e:vn(zt(e))}function yn(e,t){t===void 0&&(t=[]);var n=vn(e),r=n===e.ownerDocument?.body,i=G(n),a=r?[i].concat(i.visualViewport||[],_n(n)?n:[]):n,o=t.concat(a);return r?o:o.concat(yn(zt(a)))}function bn(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function xn(e,t){var n=Ft(e,!1,t===`fixed`);return n.top+=e.clientTop,n.left+=e.clientLeft,n.bottom=n.top+e.clientHeight,n.right=n.left+e.clientWidth,n.width=e.clientWidth,n.height=e.clientHeight,n.x=n.left,n.y=n.top,n}function Sn(e,t,n){return t===ut?bn(hn(e,n)):Tt(t)?xn(t,n):bn(gn(Y(e)))}function Cn(e){var t=yn(zt(e)),n=[`absolute`,`fixed`].indexOf(J(e).position)>=0&&K(e)?Ht(e):e;return Tt(n)?t.filter(function(e){return Tt(e)&&Lt(e,n)&&W(e)!==`body`}):[]}function wn(e,t,n,r){var i=t===`clippingParents`?Cn(e):[].concat(t),a=[].concat(i,[n]),o=a[0],s=a.reduce(function(t,n){var i=Sn(e,n,r);return t.top=At(i.top,t.top),t.right=jt(i.right,t.right),t.bottom=jt(i.bottom,t.bottom),t.left=At(i.left,t.left),t},Sn(e,o,r));return s.width=s.right-s.left,s.height=s.bottom-s.top,s.x=s.left,s.y=s.top,s}function Tn(e){var t=e.reference,n=e.element,r=e.placement,i=r?q(r):null,a=r?$t(r):null,o=t.x+t.width/2-n.width/2,s=t.y+t.height/2-n.height/2,c;switch(i){case B:c={x:o,y:t.y-n.height};break;case V:c={x:o,y:t.y+t.height};break;case H:c={x:t.x+t.width,y:s};break;case U:c={x:t.x-n.width,y:s};break;default:c={x:t.x,y:t.y}}var l=i?Ut(i):null;if(l!=null){var u=l===`y`?`height`:`width`;switch(a){case st:c[l]=c[l]-(t[u]/2-n[u]/2);break;case ct:c[l]=c[l]+(t[u]/2-n[u]/2);break}}return c}function En(e,t){t===void 0&&(t={});var n=t,r=n.placement,i=r===void 0?e.placement:r,a=n.strategy,o=a===void 0?e.strategy:a,s=n.boundary,c=s===void 0?lt:s,l=n.rootBoundary,u=l===void 0?ut:l,d=n.elementContext,f=d===void 0?dt:d,p=n.altBoundary,m=p===void 0?!1:p,h=n.padding,g=h===void 0?0:h,_=qt(typeof g==`number`?Jt(g,ot):g),v=f===dt?ft:dt,y=e.rects.popper,b=e.elements[m?v:f],x=wn(Tt(b)?b:b.contextElement||Y(e.elements.popper),c,u,o),S=Ft(e.elements.reference),C=Tn({reference:S,element:y,placement:i}),w=bn(Object.assign({},y,C)),T=f===dt?w:S,E={top:x.top-T.top+_.top,bottom:T.bottom-x.bottom+_.bottom,left:x.left-T.left+_.left,right:T.right-x.right+_.right},D=e.modifiersData.offset;if(f===dt&&D){var O=D[i];Object.keys(E).forEach(function(e){var t=[H,V].indexOf(e)>=0?1:-1,n=[B,V].indexOf(e)>=0?`y`:`x`;E[e]+=O[n]*t})}return E}function Dn(e,t){t===void 0&&(t={});var n=t,r=n.placement,i=n.boundary,a=n.rootBoundary,o=n.padding,s=n.flipVariations,c=n.allowedAutoPlacements,l=c===void 0?mt:c,u=$t(r),d=u?s?pt:pt.filter(function(e){return $t(e)===u}):ot,f=d.filter(function(e){return l.indexOf(e)>=0});f.length===0&&(f=d);var p=f.reduce(function(t,n){return t[n]=En(e,{placement:n,boundary:i,rootBoundary:a,padding:o})[q(n)],t},{});return Object.keys(p).sort(function(e,t){return p[e]-p[t]})}function On(e){if(q(e)===at)return[];var t=un(e);return[fn(e),t,fn(t)]}function kn(e){var t=e.state,n=e.options,r=e.name;if(!t.modifiersData[r]._skip){for(var i=n.mainAxis,a=i===void 0?!0:i,o=n.altAxis,s=o===void 0?!0:o,c=n.fallbackPlacements,l=n.padding,u=n.boundary,d=n.rootBoundary,f=n.altBoundary,p=n.flipVariations,m=p===void 0?!0:p,h=n.allowedAutoPlacements,g=t.options.placement,_=q(g)===g,v=c||(_||!m?[un(g)]:On(g)),y=[g].concat(v).reduce(function(e,n){return e.concat(q(n)===at?Dn(t,{placement:n,boundary:u,rootBoundary:d,padding:l,flipVariations:m,allowedAutoPlacements:h}):n)},[]),b=t.rects.reference,x=t.rects.popper,S=new Map,C=!0,w=y[0],T=0;T=0,ee=k?`width`:`height`,A=En(t,{placement:E,boundary:u,rootBoundary:d,altBoundary:f,padding:l}),j=k?O?H:U:O?V:B;b[ee]>x[ee]&&(j=un(j));var te=un(j),ne=[];if(a&&ne.push(A[D]<=0),s&&ne.push(A[j]<=0,A[te]<=0),ne.every(function(e){return e})){w=E,C=!1;break}S.set(E,ne)}if(C)for(var re=m?3:1,ie=function(e){var t=y.find(function(t){var n=S.get(t);if(n)return n.slice(0,e).every(function(e){return e})});if(t)return w=t,`break`},ae=re;ae>0&&ie(ae)!==`break`;ae--);t.placement!==w&&(t.modifiersData[r]._skip=!0,t.placement=w,t.reset=!0)}}let An={name:`flip`,enabled:!0,phase:`main`,fn:kn,requiresIfExists:[`offset`],data:{_skip:!1}};function jn(e,t,n){return n===void 0&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function Mn(e){return[B,H,V,U].some(function(t){return e[t]>=0})}function Nn(e){var t=e.state,n=e.name,r=t.rects.reference,i=t.rects.popper,a=t.modifiersData.preventOverflow,o=En(t,{elementContext:`reference`}),s=En(t,{altBoundary:!0}),c=jn(o,r),l=jn(s,i,a),u=Mn(c),d=Mn(l);t.modifiersData[n]={referenceClippingOffsets:c,popperEscapeOffsets:l,isReferenceHidden:u,hasPopperEscaped:d},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":u,"data-popper-escaped":d})}let Pn={name:`hide`,enabled:!0,phase:`main`,requiresIfExists:[`preventOverflow`],fn:Nn};function Fn(e,t,n){var r=q(e),i=[U,B].indexOf(r)>=0?-1:1,a=typeof n==`function`?n(Object.assign({},t,{placement:e})):n,o=a[0],s=a[1];return o||=0,s=(s||0)*i,[U,H].indexOf(r)>=0?{x:s,y:o}:{x:o,y:s}}function In(e){var t=e.state,n=e.options,r=e.name,i=n.offset,a=i===void 0?[0,0]:i,o=mt.reduce(function(e,n){return e[n]=Fn(n,t.rects,a),e},{}),s=o[t.placement],c=s.x,l=s.y;t.modifiersData.popperOffsets!=null&&(t.modifiersData.popperOffsets.x+=c,t.modifiersData.popperOffsets.y+=l),t.modifiersData[r]=o}let Ln={name:`offset`,enabled:!0,phase:`main`,requires:[`popperOffsets`],fn:In};function Rn(e){var t=e.state,n=e.name;t.modifiersData[n]=Tn({reference:t.rects.reference,element:t.rects.popper,placement:t.placement})}let zn={name:`popperOffsets`,enabled:!0,phase:`read`,fn:Rn,data:{}};function Bn(e){return e===`x`?`y`:`x`}function Vn(e){var t=e.state,n=e.options,r=e.name,i=n.mainAxis,a=i===void 0?!0:i,o=n.altAxis,s=o===void 0?!1:o,c=n.boundary,l=n.rootBoundary,u=n.altBoundary,d=n.padding,f=n.tether,p=f===void 0?!0:f,m=n.tetherOffset,h=m===void 0?0:m,g=En(t,{boundary:c,rootBoundary:l,padding:d,altBoundary:u}),_=q(t.placement),v=$t(t.placement),y=!v,b=Ut(_),x=Bn(b),S=t.modifiersData.popperOffsets,C=t.rects.reference,w=t.rects.popper,T=typeof h==`function`?h(Object.assign({},t.rects,{placement:t.placement})):h,E=typeof T==`number`?{mainAxis:T,altAxis:T}:Object.assign({mainAxis:0,altAxis:0},T),D=t.modifiersData.offset?t.modifiersData.offset[t.placement]:null,O={x:0,y:0};if(S){if(a){var k=b===`y`?B:U,ee=b===`y`?V:H,A=b===`y`?`height`:`width`,j=S[b],te=j+g[k],ne=j-g[ee],re=p?-w[A]/2:0,ie=v===st?C[A]:w[A],ae=v===st?-w[A]:-C[A],oe=t.elements.arrow,se=p&&oe?It(oe):{width:0,height:0},M=t.modifiersData[`arrow#persistent`]?t.modifiersData[`arrow#persistent`].padding:Kt(),ce=M[k],le=M[ee],ue=Wt(0,C[A],se[A]),N=y?C[A]/2-re-ue-ce-E.mainAxis:ie-ue-ce-E.mainAxis,de=y?-C[A]/2+re+ue+le+E.mainAxis:ae+ue+le+E.mainAxis,P=t.elements.arrow&&Ht(t.elements.arrow),fe=P?b===`y`?P.clientTop||0:P.clientLeft||0:0,F=D?.[b]??0,pe=j+N-F-fe,me=j+de-F,he=Wt(p?jt(te,pe):te,j,p?At(ne,me):ne);S[b]=he,O[b]=he-j}if(s){var ge=b===`x`?B:U,_e=b===`x`?V:H,I=S[x],L=x===`y`?`height`:`width`,R=I+g[ge],ve=I-g[_e],ye=[B,U].indexOf(_)!==-1,be=D?.[x]??0,xe=ye?R:I-C[L]-w[L]-be+E.altAxis,Se=ye?I+C[L]+w[L]-be-E.altAxis:ve,Ce=p&&ye?Gt(xe,I,Se):Wt(p?xe:R,I,p?Se:ve);S[x]=Ce,O[x]=Ce-I}t.modifiersData[r]=O}}let Hn={name:`preventOverflow`,enabled:!0,phase:`main`,fn:Vn,requiresIfExists:[`offset`]};function Un(e){return{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}}function Wn(e){return e===G(e)||!K(e)?pn(e):Un(e)}function Gn(e){var t=e.getBoundingClientRect(),n=Mt(t.width)/e.offsetWidth||1,r=Mt(t.height)/e.offsetHeight||1;return n!==1||r!==1}function Kn(e,t,n){n===void 0&&(n=!1);var r=K(t),i=K(t)&&Gn(t),a=Y(t),o=Ft(e,i,n),s={scrollLeft:0,scrollTop:0},c={x:0,y:0};return(r||!r&&!n)&&((W(t)!==`body`||_n(a))&&(s=Wn(t)),K(t)?(c=Ft(t,!0),c.x+=t.clientLeft,c.y+=t.clientTop):a&&(c.x=mn(a))),{x:o.left+s.scrollLeft-c.x,y:o.top+s.scrollTop-c.y,width:o.width,height:o.height}}function qn(e){var t=new Map,n=new Set,r=[];e.forEach(function(e){t.set(e.name,e)});function i(e){n.add(e.name),[].concat(e.requires||[],e.requiresIfExists||[]).forEach(function(e){if(!n.has(e)){var r=t.get(e);r&&i(r)}}),r.push(e)}return e.forEach(function(e){n.has(e.name)||i(e)}),r}function Jn(e){var t=qn(e);return wt.reduce(function(e,n){return e.concat(t.filter(function(e){return e.phase===n}))},[])}function Yn(e){var t;return function(){return t||=new Promise(function(n){Promise.resolve().then(function(){t=void 0,n(e())})}),t}}function Xn(e){var t=e.reduce(function(e,t){var n=e[t.name];return e[t.name]=n?Object.assign({},n,t,{options:Object.assign({},n.options,t.options),data:Object.assign({},n.data,t.data)}):t,e},{});return Object.keys(t).map(function(e){return t[e]})}var Zn={placement:`bottom`,modifiers:[],strategy:`absolute`};function Qn(){return![...arguments].some(function(e){return!(e&&typeof e.getBoundingClientRect==`function`)})}function $n(e){e===void 0&&(e={});var t=e,n=t.defaultModifiers,r=n===void 0?[]:n,i=t.defaultOptions,a=i===void 0?Zn:i;return function(e,t,n){n===void 0&&(n=a);var i={placement:`bottom`,orderedModifiers:[],options:Object.assign({},Zn,a),modifiersData:{},elements:{reference:e,popper:t},attributes:{},styles:{}},o=[],s=!1,c={state:i,setOptions:function(n){var o=typeof n==`function`?n(i.options):n;u(),i.options=Object.assign({},a,i.options,o),i.scrollParents={reference:Tt(e)?yn(e):e.contextElement?yn(e.contextElement):[],popper:yn(t)};var s=Jn(Xn([].concat(r,i.options.modifiers)));return i.orderedModifiers=s.filter(function(e){return e.enabled}),l(),c.update()},forceUpdate:function(){if(!s){var e=i.elements,t=e.reference,n=e.popper;if(Qn(t,n)){i.rects={reference:Kn(t,Ht(n),i.options.strategy===`fixed`),popper:It(n)},i.reset=!1,i.placement=i.options.placement,i.orderedModifiers.forEach(function(e){return i.modifiersData[e.name]=Object.assign({},e.data)});for(var r=0;rNumber.parseInt(e,10)):typeof e==`function`?t=>e(t,this._element):e}_getPopperConfig(){let e={placement:this._getPlacement(),modifiers:[{name:`preventOverflow`,options:{boundary:this._config.boundary}},{name:`offset`,options:{offset:this._getOffset()}}]};return(this._inNavbar||this._config.display===`static`)&&(N.setDataAttribute(this._menu,`popper`,`static`),e.modifiers=[{name:`applyStyles`,enabled:!1}]),{...e,...b(this._config.popperConfig,[void 0,e])}}_selectMenuItem({key:e,target:t}){let n=F.find(`.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)`,this._menu).filter(e=>u(e));n.length&&S(n,t,e===or,!n.includes(t)).focus()}static jQueryInterface(e){return this.each(function(){let t=X.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0)throw TypeError(`No method named "${e}"`);t[e]()}})}static clearMenus(e){if(e.button===2||e.type===`keyup`&&e.key!==`Tab`)return;let t=F.find(gr);for(let n of t){let t=X.getInstance(n);if(!t||t._config.autoClose===!1)continue;let r=e.composedPath(),i=r.includes(t._menu);if(r.includes(t._element)||t._config.autoClose===`inside`&&!i||t._config.autoClose===`outside`&&i||t._menu.contains(e.target)&&(e.type===`keyup`&&e.key===`Tab`||/input|select|option|textarea|form/i.test(e.target.tagName)))continue;let a={relatedTarget:t._element};e.type===`click`&&(a.clickEvent=e),t._completeHide(a)}}static dataApiKeydownHandler(e){let t=/input|textarea/i.test(e.target.tagName),n=e.key===`Escape`,r=[`ArrowUp`,or].includes(e.key);if(!r&&!n||t&&!n)return;e.preventDefault();let i=this.matches(hr)?this:F.prev(this,hr)[0]||F.next(this,hr)[0]||F.findOne(hr,e.delegateTarget.parentNode),a=X.getOrCreateInstance(i);if(r){e.stopPropagation(),a.show(),a._selectMenuItem(e);return}a._isShown()&&(e.stopPropagation(),a.hide(),i.focus())}}M.on(document,fr,hr,X.dataApiKeydownHandler),M.on(document,fr,_r,X.dataApiKeydownHandler),M.on(document,dr,X.clearMenus),M.on(document,pr,X.clearMenus),M.on(document,dr,hr,function(e){e.preventDefault(),X.getOrCreateInstance(this).toggle()}),y(X);let Er=`backdrop`,Dr=`show`,Or=`mousedown.bs.${Er}`,kr={className:`modal-backdrop`,clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:`body`},Ar={className:`string`,clickCallback:`(function|null)`,isAnimated:`boolean`,isVisible:`boolean`,rootElement:`(element|string)`};class jr extends de{constructor(e){super(),this._config=this._getConfig(e),this._isAppended=!1,this._element=null}static get Default(){return kr}static get DefaultType(){return Ar}static get NAME(){return Er}show(e){if(!this._config.isVisible){b(e);return}this._append();let t=this._getElement();this._config.isAnimated&&m(t),t.classList.add(Dr),this._emulateAnimation(()=>{b(e)})}hide(e){if(!this._config.isVisible){b(e);return}this._getElement().classList.remove(Dr),this._emulateAnimation(()=>{this.dispose(),b(e)})}dispose(){this._isAppended&&=(M.off(this._element,Or),this._element.remove(),!1)}_getElement(){if(!this._element){let e=document.createElement(`div`);e.className=this._config.className,this._config.isAnimated&&e.classList.add(`fade`),this._element=e}return this._element}_configAfterMerge(e){return e.rootElement=l(e.rootElement),e}_append(){if(this._isAppended)return;let e=this._getElement();this._config.rootElement.append(e),M.on(e,Or,()=>{b(this._config.clickCallback)}),this._isAppended=!0}_emulateAnimation(e){x(e,this._getElement(),this._config.isAnimated)}}let Mr=`.bs.focustrap`,Nr=`focusin${Mr}`,Pr=`keydown.tab${Mr}`,Fr=`backward`,Ir={autofocus:!0,trapElement:null},Lr={autofocus:`boolean`,trapElement:`element`};class Rr extends de{constructor(e){super(),this._config=this._getConfig(e),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return Ir}static get DefaultType(){return Lr}static get NAME(){return`focustrap`}activate(){this._isActive||=(this._config.autofocus&&this._config.trapElement.focus(),M.off(document,Mr),M.on(document,Nr,e=>this._handleFocusin(e)),M.on(document,Pr,e=>this._handleKeydown(e)),!0)}deactivate(){this._isActive&&(this._isActive=!1,M.off(document,Mr))}_handleFocusin(e){let{trapElement:t}=this._config;if(e.target===document||e.target===t||t.contains(e.target))return;let n=F.focusableChildren(t);n.length===0?t.focus():this._lastTabNavDirection===Fr?n[n.length-1].focus():n[0].focus()}_handleKeydown(e){e.key===`Tab`&&(this._lastTabNavDirection=e.shiftKey?Fr:`forward`)}}let zr=`.fixed-top, .fixed-bottom, .is-fixed, .sticky-top`,Br=`.sticky-top`,Vr=`padding-right`,Hr=`margin-right`;class Ur{constructor(){this._element=document.body}getWidth(){let e=document.documentElement.clientWidth;return Math.abs(window.innerWidth-e)}hide(){let e=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,Vr,t=>t+e),this._setElementAttributes(zr,Vr,t=>t+e),this._setElementAttributes(Br,Hr,t=>t-e)}reset(){this._resetElementAttributes(this._element,`overflow`),this._resetElementAttributes(this._element,Vr),this._resetElementAttributes(zr,Vr),this._resetElementAttributes(Br,Hr)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,`overflow`),this._element.style.overflow=`hidden`}_setElementAttributes(e,t,n){let r=this.getWidth();this._applyManipulationCallback(e,e=>{if(e!==this._element&&window.innerWidth>e.clientWidth+r)return;this._saveInitialAttribute(e,t);let i=window.getComputedStyle(e).getPropertyValue(t);e.style.setProperty(t,`${n(Number.parseFloat(i))}px`)})}_saveInitialAttribute(e,t){let n=e.style.getPropertyValue(t);n&&N.setDataAttribute(e,t,n)}_resetElementAttributes(e,t){this._applyManipulationCallback(e,e=>{let n=N.getDataAttribute(e,t);if(n===null){e.style.removeProperty(t);return}N.removeDataAttribute(e,t),e.style.setProperty(t,n)})}_applyManipulationCallback(e,t){if(c(e)){t(e);return}for(let n of F.find(e,this._element))t(n)}}let Z=`.bs.modal`,Wr=`hide${Z}`,Gr=`hidePrevented${Z}`,Kr=`hidden${Z}`,qr=`show${Z}`,Jr=`shown${Z}`,Yr=`resize${Z}`,Xr=`click.dismiss${Z}`,Zr=`mousedown.dismiss${Z}`,Qr=`keydown.dismiss${Z}`,$r=`click${Z}.data-api`,ei=`modal-open`,ti=`show`,ni=`modal-static`,ri={backdrop:!0,focus:!0,keyboard:!0},ii={backdrop:`(boolean|string)`,focus:`boolean`,keyboard:`boolean`};class ai extends P{constructor(e,t){super(e,t),this._dialog=F.findOne(`.modal-dialog`,this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new Ur,this._addEventListeners()}static get Default(){return ri}static get DefaultType(){return ii}static get NAME(){return`modal`}toggle(e){return this._isShown?this.hide():this.show(e)}show(e){this._isShown||this._isTransitioning||M.trigger(this._element,qr,{relatedTarget:e}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(ei),this._adjustDialog(),this._backdrop.show(()=>this._showElement(e)))}hide(){!this._isShown||this._isTransitioning||M.trigger(this._element,Wr).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(ti),this._queueCallback(()=>this._hideModal(),this._element,this._isAnimated()))}dispose(){M.off(window,Z),M.off(this._dialog,Z),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new jr({isVisible:!!this._config.backdrop,isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Rr({trapElement:this._element})}_showElement(e){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display=`block`,this._element.removeAttribute(`aria-hidden`),this._element.setAttribute(`aria-modal`,!0),this._element.setAttribute(`role`,`dialog`),this._element.scrollTop=0;let t=F.findOne(`.modal-body`,this._dialog);t&&(t.scrollTop=0),m(this._element),this._element.classList.add(ti),this._queueCallback(()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,M.trigger(this._element,Jr,{relatedTarget:e})},this._dialog,this._isAnimated())}_addEventListeners(){M.on(this._element,Qr,e=>{if(e.key===`Escape`){if(this._config.keyboard){this.hide();return}this._triggerBackdropTransition()}}),M.on(window,Yr,()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()}),M.on(this._element,Zr,e=>{M.one(this._element,Xr,t=>{if(!(this._element!==e.target||this._element!==t.target)){if(this._config.backdrop===`static`){this._triggerBackdropTransition();return}this._config.backdrop&&this.hide()}})})}_hideModal(){this._element.style.display=`none`,this._element.setAttribute(`aria-hidden`,!0),this._element.removeAttribute(`aria-modal`),this._element.removeAttribute(`role`),this._isTransitioning=!1,this._backdrop.hide(()=>{document.body.classList.remove(ei),this._resetAdjustments(),this._scrollBar.reset(),M.trigger(this._element,Kr)})}_isAnimated(){return this._element.classList.contains(`fade`)}_triggerBackdropTransition(){if(M.trigger(this._element,Gr).defaultPrevented)return;let e=this._element.scrollHeight>document.documentElement.clientHeight,t=this._element.style.overflowY;t===`hidden`||this._element.classList.contains(ni)||(e||(this._element.style.overflowY=`hidden`),this._element.classList.add(ni),this._queueCallback(()=>{this._element.classList.remove(ni),this._queueCallback(()=>{this._element.style.overflowY=t},this._dialog)},this._dialog),this._element.focus())}_adjustDialog(){let e=this._element.scrollHeight>document.documentElement.clientHeight,t=this._scrollBar.getWidth(),n=t>0;if(n&&!e){let e=v()?`paddingLeft`:`paddingRight`;this._element.style[e]=`${t}px`}if(!n&&e){let e=v()?`paddingRight`:`paddingLeft`;this._element.style[e]=`${t}px`}}_resetAdjustments(){this._element.style.paddingLeft=``,this._element.style.paddingRight=``}static jQueryInterface(e,t){return this.each(function(){let n=ai.getOrCreateInstance(this,e);if(typeof e==`string`){if(n[e]===void 0)throw TypeError(`No method named "${e}"`);n[e](t)}})}}M.on(document,$r,`[data-bs-toggle="modal"]`,function(e){let t=F.getElementFromSelector(this);[`A`,`AREA`].includes(this.tagName)&&e.preventDefault(),M.one(t,qr,e=>{e.defaultPrevented||M.one(t,Kr,()=>{u(this)&&this.focus()})});let n=F.findOne(`.modal.show`);n&&ai.getInstance(n).hide(),ai.getOrCreateInstance(t).toggle(this)}),pe(ai),y(ai);let Q=`.bs.offcanvas`,oi=`.data-api`,si=`load${Q}${oi}`,ci=`show`,li=`showing`,ui=`hiding`,di=`.offcanvas.show`,fi=`show${Q}`,pi=`shown${Q}`,mi=`hide${Q}`,hi=`hidePrevented${Q}`,gi=`hidden${Q}`,_i=`resize${Q}`,vi=`click${Q}${oi}`,yi=`keydown.dismiss${Q}`,bi={backdrop:!0,keyboard:!0,scroll:!1},xi={backdrop:`(boolean|string)`,keyboard:`boolean`,scroll:`boolean`};class Si extends P{constructor(e,t){super(e,t),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return bi}static get DefaultType(){return xi}static get NAME(){return`offcanvas`}toggle(e){return this._isShown?this.hide():this.show(e)}show(e){this._isShown||M.trigger(this._element,fi,{relatedTarget:e}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||new Ur().hide(),this._element.setAttribute(`aria-modal`,!0),this._element.setAttribute(`role`,`dialog`),this._element.classList.add(li),this._queueCallback(()=>{(!this._config.scroll||this._config.backdrop)&&this._focustrap.activate(),this._element.classList.add(ci),this._element.classList.remove(li),M.trigger(this._element,pi,{relatedTarget:e})},this._element,!0))}hide(){!this._isShown||M.trigger(this._element,mi).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(ui),this._backdrop.hide(),this._queueCallback(()=>{this._element.classList.remove(ci,ui),this._element.removeAttribute(`aria-modal`),this._element.removeAttribute(`role`),this._config.scroll||new Ur().reset(),M.trigger(this._element,gi)},this._element,!0))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){let e=()=>{if(this._config.backdrop===`static`){M.trigger(this._element,hi);return}this.hide()},t=!!this._config.backdrop;return new jr({className:`offcanvas-backdrop`,isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?e:null})}_initializeFocusTrap(){return new Rr({trapElement:this._element})}_addEventListeners(){M.on(this._element,yi,e=>{if(e.key===`Escape`){if(this._config.keyboard){this.hide();return}M.trigger(this._element,hi)}})}static jQueryInterface(e){return this.each(function(){let t=Si.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0||e.startsWith(`_`)||e===`constructor`)throw TypeError(`No method named "${e}"`);t[e](this)}})}}M.on(document,vi,`[data-bs-toggle="offcanvas"]`,function(e){let t=F.getElementFromSelector(this);if([`A`,`AREA`].includes(this.tagName)&&e.preventDefault(),d(this))return;M.one(t,gi,()=>{u(this)&&this.focus()});let n=F.findOne(di);n&&n!==t&&Si.getInstance(n).hide(),Si.getOrCreateInstance(t).toggle(this)}),M.on(window,si,()=>{for(let e of F.find(di))Si.getOrCreateInstance(e).show()}),M.on(window,_i,()=>{for(let e of F.find(`[aria-modal][class*=show][class*=offcanvas-]`))getComputedStyle(e).position!==`fixed`&&Si.getOrCreateInstance(e).hide()}),pe(Si),y(Si);let Ci={"*":[`class`,`dir`,`id`,`lang`,`role`,/^aria-[\w-]*$/i],a:[`target`,`href`,`title`,`rel`],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:[`src`,`srcset`,`alt`,`title`,`width`,`height`],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},wi=new Set([`background`,`cite`,`href`,`itemtype`,`longdesc`,`poster`,`src`,`xlink:href`]),Ti=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Ei=(e,t)=>{let n=e.nodeName.toLowerCase();return t.includes(n)?wi.has(n)?!!Ti.test(e.nodeValue):!0:t.filter(e=>e instanceof RegExp).some(e=>e.test(n))};function Di(e,t,n){if(!e.length)return e;if(n&&typeof n==`function`)return n(e);let r=new window.DOMParser().parseFromString(e,`text/html`),i=[].concat(...r.body.querySelectorAll(`*`));for(let e of i){let n=e.nodeName.toLowerCase();if(!Object.keys(t).includes(n)){e.remove();continue}let r=[].concat(...e.attributes),i=[].concat(t[`*`]||[],t[n]||[]);for(let t of r)Ei(t,i)||e.removeAttribute(t.nodeName)}return r.body.innerHTML}let Oi={allowList:Ci,content:{},extraClass:``,html:!1,sanitize:!0,sanitizeFn:null,template:`
`},ki={allowList:`object`,content:`object`,extraClass:`(string|function)`,html:`boolean`,sanitize:`boolean`,sanitizeFn:`(null|function)`,template:`string`},Ai={entry:`(string|element|function|null)`,selector:`(string|element)`};class ji extends de{constructor(e){super(),this._config=this._getConfig(e)}static get Default(){return Oi}static get DefaultType(){return ki}static get NAME(){return`TemplateFactory`}getContent(){return Object.values(this._config.content).map(e=>this._resolvePossibleFunction(e)).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(e){return this._checkContent(e),this._config.content={...this._config.content,...e},this}toHtml(){let e=document.createElement(`div`);e.innerHTML=this._maybeSanitize(this._config.template);for(let[t,n]of Object.entries(this._config.content))this._setContent(e,n,t);let t=e.children[0],n=this._resolvePossibleFunction(this._config.extraClass);return n&&t.classList.add(...n.split(` `)),t}_typeCheckConfig(e){super._typeCheckConfig(e),this._checkContent(e.content)}_checkContent(e){for(let[t,n]of Object.entries(e))super._typeCheckConfig({selector:t,entry:n},Ai)}_setContent(e,t,n){let r=F.findOne(n,e);if(r){if(t=this._resolvePossibleFunction(t),!t){r.remove();return}if(c(t)){this._putElementInTemplate(l(t),r);return}if(this._config.html){r.innerHTML=this._maybeSanitize(t);return}r.textContent=t}}_maybeSanitize(e){return this._config.sanitize?Di(e,this._config.allowList,this._config.sanitizeFn):e}_resolvePossibleFunction(e){return b(e,[void 0,this])}_putElementInTemplate(e,t){if(this._config.html){t.innerHTML=``,t.append(e);return}t.textContent=e.textContent}}let Mi=new Set([`sanitize`,`allowList`,`sanitizeFn`]),Ni=`fade`,Pi=`show`,Fi=`.modal`,Ii=`hide.bs.modal`,Li=`hover`,Ri=`focus`,zi=`click`,Bi={AUTO:`auto`,TOP:`top`,RIGHT:v()?`left`:`right`,BOTTOM:`bottom`,LEFT:v()?`right`:`left`},Vi={allowList:Ci,animation:!0,boundary:`clippingParents`,container:!1,customClass:``,delay:0,fallbackPlacements:[`top`,`right`,`bottom`,`left`],html:!1,offset:[0,6],placement:`top`,popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:``,title:``,trigger:`hover focus`},Hi={allowList:`object`,animation:`boolean`,boundary:`(string|element)`,container:`(string|element|boolean)`,customClass:`(string|function)`,delay:`(number|object)`,fallbackPlacements:`array`,html:`boolean`,offset:`(array|string|function)`,placement:`(string|function)`,popperConfig:`(null|object|function)`,sanitize:`boolean`,sanitizeFn:`(null|function)`,selector:`(string|boolean)`,template:`string`,title:`(string|element|function)`,trigger:`string`};class Ui extends P{constructor(e,t){if(rr===void 0)throw TypeError(`Bootstrap's tooltips require Popper (https://popper.js.org/docs/v2/)`);super(e,t),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Vi}static get DefaultType(){return Hi}static get NAME(){return`tooltip`}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){if(this._isEnabled){if(this._isShown()){this._leave();return}this._enter()}}dispose(){clearTimeout(this._timeout),M.off(this._element.closest(Fi),Ii,this._hideModalHandler),this._element.getAttribute(`data-bs-original-title`)&&this._element.setAttribute(`title`,this._element.getAttribute(`data-bs-original-title`)),this._disposePopper(),super.dispose()}show(){if(this._element.style.display===`none`)throw Error(`Please use show on visible elements`);if(!(this._isWithContent()&&this._isEnabled))return;let e=M.trigger(this._element,this.constructor.eventName(`show`)),t=(f(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(e.defaultPrevented||!t)return;this._disposePopper();let n=this._getTipElement();this._element.setAttribute(`aria-describedby`,n.getAttribute(`id`));let{container:r}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(r.append(n),M.trigger(this._element,this.constructor.eventName(`inserted`))),this._popper=this._createPopper(n),n.classList.add(Pi),`ontouchstart`in document.documentElement)for(let e of[].concat(...document.body.children))M.on(e,`mouseover`,p);this._queueCallback(()=>{M.trigger(this._element,this.constructor.eventName(`shown`)),this._isHovered===!1&&this._leave(),this._isHovered=!1},this.tip,this._isAnimated())}hide(){if(!(!this._isShown()||M.trigger(this._element,this.constructor.eventName(`hide`)).defaultPrevented)){if(this._getTipElement().classList.remove(Pi),`ontouchstart`in document.documentElement)for(let e of[].concat(...document.body.children))M.off(e,`mouseover`,p);this._activeTrigger[zi]=!1,this._activeTrigger[Ri]=!1,this._activeTrigger[Li]=!1,this._isHovered=null,this._queueCallback(()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute(`aria-describedby`),M.trigger(this._element,this.constructor.eventName(`hidden`)))},this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return!!this._getTitle()}_getTipElement(){return this.tip||=this._createTipElement(this._newContent||this._getContentForTemplate()),this.tip}_createTipElement(e){let t=this._getTemplateFactory(e).toHtml();if(!t)return null;t.classList.remove(Ni,Pi),t.classList.add(`bs-${this.constructor.NAME}-auto`);let n=a(this.constructor.NAME).toString();return t.setAttribute(`id`,n),this._isAnimated()&&t.classList.add(Ni),t}setContent(e){this._newContent=e,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(e){return this._templateFactory?this._templateFactory.changeContent(e):this._templateFactory=new ji({...this._config,content:e,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute(`data-bs-original-title`)}_initializeOnDelegatedTarget(e){return this.constructor.getOrCreateInstance(e.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ni)}_isShown(){return this.tip&&this.tip.classList.contains(Pi)}_createPopper(e){let t=b(this._config.placement,[this,e,this._element]),n=Bi[t.toUpperCase()];return nr(this._element,e,this._getPopperConfig(n))}_getOffset(){let{offset:e}=this._config;return typeof e==`string`?e.split(`,`).map(e=>Number.parseInt(e,10)):typeof e==`function`?t=>e(t,this._element):e}_resolvePossibleFunction(e){return b(e,[this._element,this._element])}_getPopperConfig(e){let t={placement:e,modifiers:[{name:`flip`,options:{fallbackPlacements:this._config.fallbackPlacements}},{name:`offset`,options:{offset:this._getOffset()}},{name:`preventOverflow`,options:{boundary:this._config.boundary}},{name:`arrow`,options:{element:`.${this.constructor.NAME}-arrow`}},{name:`preSetPlacement`,enabled:!0,phase:`beforeMain`,fn:e=>{this._getTipElement().setAttribute(`data-popper-placement`,e.state.placement)}}]};return{...t,...b(this._config.popperConfig,[void 0,t])}}_setListeners(){let e=this._config.trigger.split(` `);for(let t of e)if(t===`click`)M.on(this._element,this.constructor.eventName(`click`),this._config.selector,e=>{let t=this._initializeOnDelegatedTarget(e);t._activeTrigger[zi]=!(t._isShown()&&t._activeTrigger[zi]),t.toggle()});else if(t!==`manual`){let e=t===Li?this.constructor.eventName(`mouseenter`):this.constructor.eventName(`focusin`),n=t===Li?this.constructor.eventName(`mouseleave`):this.constructor.eventName(`focusout`);M.on(this._element,e,this._config.selector,e=>{let t=this._initializeOnDelegatedTarget(e);t._activeTrigger[e.type===`focusin`?Ri:Li]=!0,t._enter()}),M.on(this._element,n,this._config.selector,e=>{let t=this._initializeOnDelegatedTarget(e);t._activeTrigger[e.type===`focusout`?Ri:Li]=t._element.contains(e.relatedTarget),t._leave()})}this._hideModalHandler=()=>{this._element&&this.hide()},M.on(this._element.closest(Fi),Ii,this._hideModalHandler)}_fixTitle(){let e=this._element.getAttribute(`title`);e&&(!this._element.getAttribute(`aria-label`)&&!this._element.textContent.trim()&&this._element.setAttribute(`aria-label`,e),this._element.setAttribute(`data-bs-original-title`,e),this._element.removeAttribute(`title`))}_enter(){if(this._isShown()||this._isHovered){this._isHovered=!0;return}this._isHovered=!0,this._setTimeout(()=>{this._isHovered&&this.show()},this._config.delay.show)}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout(()=>{this._isHovered||this.hide()},this._config.delay.hide))}_setTimeout(e,t){clearTimeout(this._timeout),this._timeout=setTimeout(e,t)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(e){let t=N.getDataAttributes(this._element);for(let e of Object.keys(t))Mi.has(e)&&delete t[e];return e={...t,...typeof e==`object`&&e?e:{}},e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e.container=e.container===!1?document.body:l(e.container),typeof e.delay==`number`&&(e.delay={show:e.delay,hide:e.delay}),typeof e.title==`number`&&(e.title=e.title.toString()),typeof e.content==`number`&&(e.content=e.content.toString()),e}_getDelegateConfig(){let e={};for(let[t,n]of Object.entries(this._config))this.constructor.Default[t]!==n&&(e[t]=n);return e.selector=!1,e.trigger=`manual`,e}_disposePopper(){this._popper&&=(this._popper.destroy(),null),this.tip&&=(this.tip.remove(),null)}static jQueryInterface(e){return this.each(function(){let t=Ui.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0)throw TypeError(`No method named "${e}"`);t[e]()}})}}y(Ui);let Wi={...Ui.Default,content:``,offset:[0,8],placement:`right`,template:``,trigger:`click`},Gi={...Ui.DefaultType,content:`(null|string|element|function)`};class Ki extends Ui{static get Default(){return Wi}static get DefaultType(){return Gi}static get NAME(){return`popover`}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(e){return this.each(function(){let t=Ki.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0)throw TypeError(`No method named "${e}"`);t[e]()}})}}y(Ki);let qi=`.bs.scrollspy`,Ji=`activate${qi}`,Yi=`click${qi}`,Xi=`load${qi}.data-api`,Zi=`active`,Qi=`[href]`,$i=`.nav-link`,ea=`${$i}, .nav-item > ${$i}, .list-group-item`,ta={offset:null,rootMargin:`0px 0px -25%`,smoothScroll:!1,target:null,threshold:[.1,.5,1]},na={offset:`(number|null)`,rootMargin:`string`,smoothScroll:`boolean`,target:`element`,threshold:`array`};class ra extends P{constructor(e,t){super(e,t),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement=getComputedStyle(this._element).overflowY===`visible`?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return ta}static get DefaultType(){return na}static get NAME(){return`scrollspy`}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(let e of this._observableSections.values())this._observer.observe(e)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(e){return e.target=l(e.target)||document.body,e.rootMargin=e.offset?`${e.offset}px 0px -30%`:e.rootMargin,typeof e.threshold==`string`&&(e.threshold=e.threshold.split(`,`).map(e=>Number.parseFloat(e))),e}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(M.off(this._config.target,Yi),M.on(this._config.target,Yi,Qi,e=>{let t=this._observableSections.get(e.target.hash);if(t){e.preventDefault();let n=this._rootElement||window,r=t.offsetTop-this._element.offsetTop;if(n.scrollTo){n.scrollTo({top:r,behavior:`smooth`});return}n.scrollTop=r}}))}_getNewObserver(){let e={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver(e=>this._observerCallback(e),e)}_observerCallback(e){let t=e=>this._targetLinks.get(`#${e.target.id}`),n=e=>{this._previousScrollData.visibleEntryTop=e.target.offsetTop,this._process(t(e))},r=(this._rootElement||document.documentElement).scrollTop,i=r>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=r;for(let a of e){if(!a.isIntersecting){this._activeTarget=null,this._clearActiveClass(t(a));continue}let e=a.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(i&&e){if(n(a),!r)return;continue}!i&&!e&&n(a)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;let e=F.find(Qi,this._config.target);for(let t of e){if(!t.hash||d(t))continue;let e=F.findOne(decodeURI(t.hash),this._element);u(e)&&(this._targetLinks.set(decodeURI(t.hash),t),this._observableSections.set(t.hash,e))}}_process(e){this._activeTarget!==e&&(this._clearActiveClass(this._config.target),this._activeTarget=e,e.classList.add(Zi),this._activateParents(e),M.trigger(this._element,Ji,{relatedTarget:e}))}_activateParents(e){if(e.classList.contains(`dropdown-item`)){F.findOne(`.dropdown-toggle`,e.closest(`.dropdown`)).classList.add(Zi);return}for(let t of F.parents(e,`.nav, .list-group`))for(let e of F.prev(t,ea))e.classList.add(Zi)}_clearActiveClass(e){e.classList.remove(Zi);let t=F.find(`${Qi}.${Zi}`,e);for(let e of t)e.classList.remove(Zi)}static jQueryInterface(e){return this.each(function(){let t=ra.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0||e.startsWith(`_`)||e===`constructor`)throw TypeError(`No method named "${e}"`);t[e]()}})}}M.on(window,Xi,()=>{for(let e of F.find(`[data-bs-spy="scroll"]`))ra.getOrCreateInstance(e)}),y(ra);let ia=`.bs.tab`,aa=`hide${ia}`,oa=`hidden${ia}`,sa=`show${ia}`,ca=`shown${ia}`,la=`click${ia}`,ua=`keydown${ia}`,da=`load${ia}`,fa=`ArrowRight`,pa=`ArrowDown`,ma=`Home`,ha=`active`,ga=`fade`,_a=`show`,va=`.dropdown-toggle`,ya=`:not(${va})`,ba=`.nav-link${ya}, .list-group-item${ya}, [role="tab"]${ya}`,xa=`[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]`,Sa=`${ba}, ${xa}`,Ca=`.${ha}[data-bs-toggle="tab"], .${ha}[data-bs-toggle="pill"], .${ha}[data-bs-toggle="list"]`;class wa extends P{constructor(e){super(e),this._parent=this._element.closest(`.list-group, .nav, [role="tablist"]`),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),M.on(this._element,ua,e=>this._keydown(e)))}static get NAME(){return`tab`}show(){let e=this._element;if(this._elemIsActive(e))return;let t=this._getActiveElem(),n=t?M.trigger(t,aa,{relatedTarget:e}):null;M.trigger(e,sa,{relatedTarget:t}).defaultPrevented||n&&n.defaultPrevented||(this._deactivate(t,e),this._activate(e,t))}_activate(e,t){e&&(e.classList.add(ha),this._activate(F.getElementFromSelector(e)),this._queueCallback(()=>{if(e.getAttribute(`role`)!==`tab`){e.classList.add(_a);return}e.removeAttribute(`tabindex`),e.setAttribute(`aria-selected`,!0),this._toggleDropDown(e,!0),M.trigger(e,ca,{relatedTarget:t})},e,e.classList.contains(ga)))}_deactivate(e,t){e&&(e.classList.remove(ha),e.blur(),this._deactivate(F.getElementFromSelector(e)),this._queueCallback(()=>{if(e.getAttribute(`role`)!==`tab`){e.classList.remove(_a);return}e.setAttribute(`aria-selected`,!1),e.setAttribute(`tabindex`,`-1`),this._toggleDropDown(e,!1),M.trigger(e,oa,{relatedTarget:t})},e,e.classList.contains(ga)))}_keydown(e){if(![`ArrowLeft`,fa,`ArrowUp`,pa,ma,`End`].includes(e.key))return;e.stopPropagation(),e.preventDefault();let t=this._getChildren().filter(e=>!d(e)),n;if([ma,`End`].includes(e.key))n=t[e.key===ma?0:t.length-1];else{let r=[fa,pa].includes(e.key);n=S(t,e.target,r,!0)}n&&(n.focus({preventScroll:!0}),wa.getOrCreateInstance(n).show())}_getChildren(){return F.find(Sa,this._parent)}_getActiveElem(){return this._getChildren().find(e=>this._elemIsActive(e))||null}_setInitialAttributes(e,t){this._setAttributeIfNotExists(e,`role`,`tablist`);for(let e of t)this._setInitialAttributesOnChild(e)}_setInitialAttributesOnChild(e){e=this._getInnerElement(e);let t=this._elemIsActive(e),n=this._getOuterElement(e);e.setAttribute(`aria-selected`,t),n!==e&&this._setAttributeIfNotExists(n,`role`,`presentation`),t||e.setAttribute(`tabindex`,`-1`),this._setAttributeIfNotExists(e,`role`,`tab`),this._setInitialAttributesOnTargetPanel(e)}_setInitialAttributesOnTargetPanel(e){let t=F.getElementFromSelector(e);t&&(this._setAttributeIfNotExists(t,`role`,`tabpanel`),e.id&&this._setAttributeIfNotExists(t,`aria-labelledby`,`${e.id}`))}_toggleDropDown(e,t){let n=this._getOuterElement(e);if(!n.classList.contains(`dropdown`))return;let r=(e,r)=>{let i=F.findOne(e,n);i&&i.classList.toggle(r,t)};r(va,ha),r(`.dropdown-menu`,_a),n.setAttribute(`aria-expanded`,t)}_setAttributeIfNotExists(e,t,n){e.hasAttribute(t)||e.setAttribute(t,n)}_elemIsActive(e){return e.classList.contains(ha)}_getInnerElement(e){return e.matches(Sa)?e:F.findOne(Sa,e)}_getOuterElement(e){return e.closest(`.nav-item, .list-group-item`)||e}static jQueryInterface(e){return this.each(function(){let t=wa.getOrCreateInstance(this);if(typeof e==`string`){if(t[e]===void 0||e.startsWith(`_`)||e===`constructor`)throw TypeError(`No method named "${e}"`);t[e]()}})}}M.on(document,la,xa,function(e){[`A`,`AREA`].includes(this.tagName)&&e.preventDefault(),!d(this)&&wa.getOrCreateInstance(this).show()}),M.on(window,da,()=>{for(let e of F.find(Ca))wa.getOrCreateInstance(e)}),y(wa);let $=`.bs.toast`,Ta=`mouseover${$}`,Ea=`mouseout${$}`,Da=`focusin${$}`,Oa=`focusout${$}`,ka=`hide${$}`,Aa=`hidden${$}`,ja=`show${$}`,Ma=`shown${$}`,Na=`hide`,Pa=`show`,Fa=`showing`,Ia={animation:`boolean`,autohide:`boolean`,delay:`number`},La={animation:!0,autohide:!0,delay:5e3};class Ra extends P{constructor(e,t){super(e,t),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return La}static get DefaultType(){return Ia}static get NAME(){return`toast`}show(){M.trigger(this._element,ja).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add(`fade`),this._element.classList.remove(Na),m(this._element),this._element.classList.add(Pa,Fa),this._queueCallback(()=>{this._element.classList.remove(Fa),M.trigger(this._element,Ma),this._maybeScheduleHide()},this._element,this._config.animation))}hide(){!this.isShown()||M.trigger(this._element,ka).defaultPrevented||(this._element.classList.add(Fa),this._queueCallback(()=>{this._element.classList.add(Na),this._element.classList.remove(Fa,Pa),M.trigger(this._element,Aa)},this._element,this._config.animation))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(Pa),super.dispose()}isShown(){return this._element.classList.contains(Pa)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout(()=>{this.hide()},this._config.delay)))}_onInteraction(e,t){switch(e.type){case`mouseover`:case`mouseout`:this._hasMouseInteraction=t;break;case`focusin`:case`focusout`:this._hasKeyboardInteraction=t;break}if(t){this._clearTimeout();return}let n=e.relatedTarget;this._element===n||this._element.contains(n)||this._maybeScheduleHide()}_setListeners(){M.on(this._element,Ta,e=>this._onInteraction(e,!0)),M.on(this._element,Ea,e=>this._onInteraction(e,!1)),M.on(this._element,Da,e=>this._onInteraction(e,!0)),M.on(this._element,Oa,e=>this._onInteraction(e,!1))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(e){return this.each(function(){let t=Ra.getOrCreateInstance(this,e);if(typeof e==`string`){if(t[e]===void 0)throw TypeError(`No method named "${e}"`);t[e](this)}})}}return pe(Ra),y(Ra),{Alert:_e,Button:L,Carousel:We,Collapse:it,Dropdown:X,Modal:ai,Offcanvas:Si,Popover:Ki,ScrollSpy:ra,Tab:wa,Toast:Ra,Tooltip:Ui}}))}))();function e(e){document.documentElement.setAttribute(`data-theme`,e)}function t(){var t=document.documentElement.getAttribute(`data-theme`)===`light`?`dark`:`light`;e(t),localStorage.setItem(`theme`,t)}window.toggleTheme=t;function n(e){document.querySelectorAll(`.md-actions.open`).forEach(function(t){t!==e&&(t.classList.remove(`open`),t.querySelector(`.md-actions-toggle`).setAttribute(`aria-expanded`,`false`))})}function r(e){var t=e.closest(`.md-actions`),r=t.classList.contains(`open`);n(t),t.classList.toggle(`open`,!r),e.setAttribute(`aria-expanded`,String(!r))}window.toggleMdActionsMenu=r,document.addEventListener(`click`,function(e){e.target.closest(`.md-actions`)||n()}),document.addEventListener(`keydown`,function(e){e.key===`Escape`&&n()}),document.addEventListener(`click`,function(e){var t=e.target.closest(`[data-copy-url]`);if(t){e.preventDefault();var r=t.getAttribute(`data-copy-url`),i=t.querySelector(`.label`),a=i.textContent;fetch(r).then(function(e){return e.text()}).then(function(e){return navigator.clipboard.writeText(e)}).then(function(){i.textContent=`Copied!`,setTimeout(function(){i.textContent=a},1500)}).catch(function(){navigator.clipboard.writeText(r)}),n()}}),(function(){var e=document.querySelector(`[data-contact-form]`);if(e){var t=e.querySelector(`[data-contact-topic]`),n=e.querySelectorAll(`[data-topic]`);n.forEach(function(e){e.addEventListener(`click`,function(){var r=e.getAttribute(`data-topic`);n.forEach(function(t){t.setAttribute(`aria-pressed`,String(t===e))}),t.value=r})})}})(); \ No newline at end of file diff --git a/src/App/assets/js/components/_contact.js b/src/App/assets/js/components/_contact.js index 3c2db3a8..d00b0b99 100644 --- a/src/App/assets/js/components/_contact.js +++ b/src/App/assets/js/components/_contact.js @@ -1,5 +1,3 @@ -// Contact page: topic pills and the client-side "message sent" state. -// The form is not wired to a backend yet, so submit only swaps the panels. (function () { var form = document.querySelector('[data-contact-form]'); if (!form) { @@ -8,41 +6,14 @@ var topicInput = form.querySelector('[data-contact-topic]'); var pills = form.querySelectorAll('[data-topic]'); - var sent = document.querySelector('[data-contact-sent]'); - var sentTopic = sent.querySelector('[data-contact-sent-topic]'); - var reset = sent.querySelector('[data-contact-reset]'); - - function currentPill() { - return form.querySelector('[data-topic][aria-pressed="true"]'); - } - - function selectTopic(id) { - pills.forEach(function (pill) { - pill.setAttribute('aria-pressed', String(pill.getAttribute('data-topic') === id)); - }); - topicInput.value = id; - } pills.forEach(function (pill) { pill.addEventListener('click', function () { - selectTopic(pill.getAttribute('data-topic')); + var id = pill.getAttribute('data-topic'); + pills.forEach(function (other) { + other.setAttribute('aria-pressed', String(other === pill)); + }); + topicInput.value = id; }); }); - - form.addEventListener('submit', function (event) { - event.preventDefault(); - var pill = currentPill(); - sentTopic.textContent = pill ? pill.textContent : ''; - form.hidden = true; - sent.hidden = false; - sent.querySelector('h2').focus({ preventScroll: true }); - }); - - reset.addEventListener('click', function () { - form.reset(); - selectTopic(pills[0].getAttribute('data-topic')); - sent.hidden = true; - form.hidden = false; - form.querySelector('input:not([type="hidden"])').focus(); - }); })(); diff --git a/src/App/assets/scss/components/_contact.scss b/src/App/assets/scss/components/_contact.scss index 30d7922b..f6d01523 100644 --- a/src/App/assets/scss/components/_contact.scss +++ b/src/App/assets/scss/components/_contact.scss @@ -284,24 +284,38 @@ html[data-theme="dark"] .contact-page { } } -.contact-consent { - display: flex; - align-items: flex-start; - gap: 10px; - font-size: 14px; +.contact-error { + margin: 0; + font-size: 13px; + color: var(--rose); +} + +.contact-alert { + margin: 0; + padding: 14px 16px; + border-radius: 8px; + border: 1px solid; + font-size: 14.5px; line-height: 1.55; - color: var(--text-muted); - cursor: pointer; - input { - width: 18px; - height: 18px; - margin: 2px 0 0; - flex-shrink: 0; - accent-color: var(--amber); + a { + color: inherit; + text-decoration: underline; } } +.contact-alert-success { + color: var(--teal); + background: var(--teal-dim); + border-color: var(--teal); +} + +.contact-alert-error { + color: var(--rose); + background: var(--rose-dim); + border-color: var(--rose); +} + .contact-form-foot { display: flex; align-items: center; @@ -323,61 +337,6 @@ html[data-theme="dark"] .contact-page { } } -.contact-sent { - min-height: 560px; - display: flex; - flex-direction: column; - justify-content: center; - gap: 18px; - - &[hidden] { - display: none; - } - - h2 { - font-size: 28px; - line-height: 1.2; - } - - p { - max-width: 460px; - font-size: 16.5px; - line-height: 1.7; - } - - strong { - color: var(--text); - font-weight: 600; - } - - .btn { - height: 44px; - padding: 0 18px; - color: var(--text-muted); - - &:hover { - border-color: var(--amber); - color: var(--text); - } - } -} - -.contact-sent-icon { - width: 48px; - height: 48px; - border-radius: var(--radius); - display: flex; - align-items: center; - justify-content: center; - background: var(--teal-dim); - color: var(--teal); - - svg { - width: 22px; - height: 22px; - } -} - /* FAQ */ .contact-faq-layout { display: grid; @@ -488,8 +447,4 @@ html[data-theme="dark"] .contact-page { justify-content: center; } } - - .contact-sent { - min-height: 360px; - } } diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php index 965448c3..43b1b2b3 100644 --- a/src/App/src/ConfigProvider.php +++ b/src/App/src/ConfigProvider.php @@ -11,6 +11,7 @@ use Dot\Cache\Adapter\ArrayAdapter; use Dot\Cache\Adapter\FilesystemAdapter; use Light\App\DBAL\Types\UuidType; +use Light\App\Factory\ContactServiceFactory; use Light\App\Factory\EntityListenerResolverFactory; use Light\App\Factory\FeedGeneratorFactory; use Light\App\Factory\GetFeedViewHandlerFactory; @@ -24,13 +25,16 @@ use Light\App\Factory\MarkdownExtensionFactory; use Light\App\Factory\MarkdownRuntimeLoaderFactory; use Light\App\Factory\PackageGeneratorFactory; +use Light\App\Factory\PostContactCreateHandlerFactory; use Light\App\Factory\SitemapGeneratorFactory; use Light\App\Handler\GetFeedViewHandler; use Light\App\Handler\GetIndexViewHandler; use Light\App\Handler\GetMarkdownArticleHandler; use Light\App\Handler\GetPackagesViewHandler; use Light\App\Handler\GetSitemapViewHandler; +use Light\App\Handler\PostContactCreateHandler; use Light\App\Resolver\EntityListenerResolver; +use Light\App\Service\ContactService; use Light\App\Service\FeedGenerator; use Light\App\Service\GitHubClient; use Light\App\Service\GitHubClientInterface; @@ -142,6 +146,8 @@ public function getDependencies(): array GetMarkdownArticleHandler::class => GetMarkdownArticleHandlerFactory::class, GetSitemapViewHandler::class => GetSitemapViewHandlerFactory::class, GetPackagesViewHandler::class => GetPackagesViewHandlerFactory::class, + PostContactCreateHandler::class => PostContactCreateHandlerFactory::class, + ContactService::class => ContactServiceFactory::class, FeedGenerator::class => FeedGeneratorFactory::class, SitemapGenerator::class => SitemapGeneratorFactory::class, GitHubClient::class => GitHubClientFactory::class, diff --git a/src/App/src/Factory/ContactServiceFactory.php b/src/App/src/Factory/ContactServiceFactory.php new file mode 100644 index 00000000..25c45b68 --- /dev/null +++ b/src/App/src/Factory/ContactServiceFactory.php @@ -0,0 +1,28 @@ +get('dot-mail.service.contact'); + assert($mailService instanceof MailServiceInterface); + + return new ContactService($mailService); + } +} diff --git a/src/App/src/Factory/PostContactCreateHandlerFactory.php b/src/App/src/Factory/PostContactCreateHandlerFactory.php new file mode 100644 index 00000000..40a54291 --- /dev/null +++ b/src/App/src/Factory/PostContactCreateHandlerFactory.php @@ -0,0 +1,32 @@ +get(TemplateRendererInterface::class); + assert($template instanceof TemplateRendererInterface); + + $contactService = $container->get(ContactService::class); + assert($contactService instanceof ContactService); + + return new PostContactCreateHandler($template, $contactService); + } +} diff --git a/src/App/src/Handler/PostContactCreateHandler.php b/src/App/src/Handler/PostContactCreateHandler.php new file mode 100644 index 00000000..1bab4f3c --- /dev/null +++ b/src/App/src/Handler/PostContactCreateHandler.php @@ -0,0 +1,50 @@ +contactService->normalize($request->getParsedBody()); + $errors = $this->contactService->validate($data); + $mailFailed = false; + + if ($errors === [] && ! $this->contactService->send($data)) { + $mailFailed = true; + } + + if ($errors !== [] || $mailFailed) { + $rawBody = $request->getParsedBody(); + + return new HtmlResponse($this->template->render(self::TEMPLATE, [ + 'contact_errors' => $errors, + 'contact_values' => $data, + 'contact_mail_failed' => $mailFailed, + 'query' => ContactService::extractQuery(is_array($rawBody) ? $rawBody : []), + ])); + } + + return new RedirectResponse('/contact/?contact=sent#contact-form', 303); + } +} diff --git a/src/App/src/RoutesDelegator.php b/src/App/src/RoutesDelegator.php index 26efd8e7..10289e27 100644 --- a/src/App/src/RoutesDelegator.php +++ b/src/App/src/RoutesDelegator.php @@ -9,6 +9,7 @@ use Light\App\Handler\GetMarkdownArticleHandler; use Light\App\Handler\GetPackagesViewHandler; use Light\App\Handler\GetSitemapViewHandler; +use Light\App\Handler\PostContactCreateHandler; use Mezzio\Application; use Psr\Container\ContainerInterface; @@ -25,6 +26,8 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal $app->get('/sitemap/', [GetSitemapViewHandler::class], 'app::sitemap'); $app->get('/{categorySlug}/{slug}.md', [GetMarkdownArticleHandler::class], 'app::markdown-article'); + $app->post('/contact/', [PostContactCreateHandler::class], 'app::create-contact'); + // Route name kept as `page::…` because `@layout/default.html.twig` links it by name. // The matching entry must stay out of `routes.page` in local.php to avoid a duplicate. $app->get( diff --git a/src/App/src/Service/ContactService.php b/src/App/src/Service/ContactService.php new file mode 100644 index 00000000..a2ea7085 --- /dev/null +++ b/src/App/src/Service/ContactService.php @@ -0,0 +1,169 @@ + 'Migration', + 'project' => 'Project work', + 'oss' => 'Open source', + 'other' => 'Something else', + ]; + + private const array REQUIRED_FIELDS = [ + 'name' => 'Name', + 'email' => 'Work email', + 'message' => 'Message', + ]; + + private const array RESERVED_FIELDS = ['topic', 'name', 'email', 'company', 'stack', 'message', 'contact']; + + public function __construct(private readonly MailServiceInterface $mailService) + { + } + + /** + * @param array $source + * @return array + */ + public static function extractQuery(array $source): array + { + $params = []; + foreach ($source as $param => $value) { + if (is_string($param) && ! in_array($param, self::RESERVED_FIELDS, true) && is_scalar($value)) { + $params[$param] = (string) $value; + } + } + + return $params; + } + + /** + * @return ContactData + */ + public function normalize(mixed $parsedBody): array + { + $raw = is_array($parsedBody) ? $parsedBody : []; + $topic = $this->stringValue($raw, 'topic'); + + return [ + 'topic' => isset(self::TOPICS[$topic]) ? $topic : '', + 'name' => $this->stringValue($raw, 'name'), + 'email' => $this->stringValue($raw, 'email'), + 'company' => $this->stringValue($raw, 'company'), + 'stack' => $this->stringValue($raw, 'stack'), + 'message' => $this->stringValue($raw, 'message'), + 'query' => http_build_query(self::extractQuery($raw)), + ]; + } + + /** + * @param ContactData $data + * @return array + */ + public function validate(array $data): array + { + $errors = []; + $notEmpty = new NotEmpty(); + + foreach (self::REQUIRED_FIELDS as $field => $label) { + if (! $notEmpty->isValid($data[$field])) { + $errors[$field] = $label . ' is required.'; + } + } + + if ($data['email'] !== '' && ! (new EmailAddress())->isValid($data['email'])) { + $errors['email'] = 'Enter a valid email address.'; + } + + return $errors; + } + + /** + * @param ContactData $data + */ + public function send(array $data): bool + { + $this->mailService->getMessage()->setReplyTo($data['email'], $data['name']); + $this->mailService->setSubject('New contact form submission on dotkernel.com'); + $this->mailService->setBody($this->buildBody($data)); + + try { + return $this->mailService->send()->isValid(); + } catch (Throwable) { + return false; + } + } + + /** + * @param array $raw + */ + private function stringValue(array $raw, string $key): string + { + $value = $raw[$key] ?? ''; + + return is_string($value) ? trim($value) : ''; + } + + /** + * @param ContactData $data + */ + private function buildBody(array $data): string + { + $rows = [ + 'Topic' => self::TOPICS[$data['topic']] ?? '', + 'Name' => $data['name'], + 'Work email' => $data['email'], + 'Company' => $data['company'], + 'Current stack' => $data['stack'], + 'Message' => $data['message'], + 'Query params' => $data['query'], + ]; + + $html = ''; + foreach ($rows as $label => $value) { + if ($value === '') { + continue; + } + + $html .= sprintf( + '

%s:
%s

', + htmlspecialchars($label, ENT_QUOTES), + nl2br(htmlspecialchars($value, ENT_QUOTES)) + ); + } + + return $html; + } +} diff --git a/src/Blog/templates/page/contact.html.twig b/src/Blog/templates/page/contact.html.twig index a6ba2979..ee6f43d9 100644 --- a/src/Blog/templates/page/contact.html.twig +++ b/src/Blog/templates/page/contact.html.twig @@ -75,20 +75,30 @@ -
-
+
+ {% set values = contact_values|default({}) %} + {% set errors = contact_errors|default({}) %} + {% set selected_topic = values.topic|default('') ?: topics[0].id %} +

Send a message

Fields marked with an asterisk are required.

+ {% if contact_success|default(false) %} +

Thanks - your message is on its way. We'll reply within one working day.

+ {% endif %} + {% if contact_mail_failed|default(false) %} + + {% endif %} +
What is it about - +
{% for topic in topics %} + aria-pressed="{{ topic.id == selected_topic ? 'true' : 'false' }}">{{ topic.label }} {% endfor %}
@@ -96,32 +106,34 @@
- + + {% if errors.name is defined %}

{{ errors.name }}

{% endif %}
- + + {% if errors.email is defined %}

{{ errors.email }}

{% endif %}
- +
- +
+ placeholder="What you are building, where it is stuck, and any deadline.">{{ values.message|default('') }} + {% if errors.message is defined %}

{{ errors.message }}

{% endif %}
- + {% for param_name, param_value in query|default({}) %} + + {% endfor %}

Bug reports and feature requests go to the package's GitHub issues, where the fix is tracked in public.

@@ -131,20 +143,6 @@
- -
diff --git a/src/Page/src/Handler/GetPageViewHandler.php b/src/Page/src/Handler/GetPageViewHandler.php index 3c531894..357a7107 100644 --- a/src/Page/src/Handler/GetPageViewHandler.php +++ b/src/Page/src/Handler/GetPageViewHandler.php @@ -7,6 +7,7 @@ use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response\HtmlResponse; use Laminas\Diactoros\Response\TextResponse; +use Light\App\Service\ContactService; use Light\Blog\Repository\CategoryRepository; use Light\Blog\Repository\PostRepository; use Light\Page\Service\PageServiceInterface; @@ -46,10 +47,13 @@ public function handle(ServerRequestInterface $request): ResponseInterface $posts = $this->postRepository->getRecentPosts(3); $categories = $this->categoryRepository->getCategories(); + $query = $request->getQueryParams(); return new HtmlResponse( $this->template->render($template, [ - 'posts' => $posts, - 'categories' => $categories, + 'posts' => $posts, + 'categories' => $categories, + 'contact_success' => ($query['contact'] ?? null) === 'sent', + 'query' => ContactService::extractQuery($query), ]) ); } diff --git a/test/Unit/App/Handler/PostContactCreateHandlerTest.php b/test/Unit/App/Handler/PostContactCreateHandlerTest.php new file mode 100644 index 00000000..ef430714 --- /dev/null +++ b/test/Unit/App/Handler/PostContactCreateHandlerTest.php @@ -0,0 +1,168 @@ + + */ + private function validData(): array + { + return [ + 'topic' => 'migration', + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'company' => 'Acme Inc', + 'stack' => 'ZF3, PHP 7.4', + 'message' => 'We would like to talk about migrating our platform.', + ]; + } + + private function service(MailResult|Throwable $sendResult): ContactService + { + $message = $this->createStub(Email::class); + + $mailService = $this->createStub(MailServiceInterface::class); + $mailService->method('getMessage')->willReturn($message); + if ($sendResult instanceof Throwable) { + $mailService->method('send')->willThrowException($sendResult); + } else { + $mailService->method('send')->willReturn($sendResult); + } + + return new ContactService($mailService); + } + + /** + * @throws Exception + */ + public function testValidSubmissionRedirectsOnSuccess(): void + { + $request = $this->createStub(ServerRequestInterface::class); + $request->method('getParsedBody')->willReturn($this->validData()); + + $template = $this->createMock(TemplateRendererInterface::class); + $template->expects($this->never())->method('render'); + + $handler = new PostContactCreateHandler($template, $this->service(new MailResult(true))); + $response = $handler->handle($request); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame(303, $response->getStatusCode()); + $this->assertSame('/contact/?contact=sent#contact-form', $response->getHeaderLine('Location')); + } + + /** + * @throws Exception + */ + public function testMissingRequiredFieldsRerendersWithErrors(): void + { + $data = $this->validData(); + $data['name'] = ''; + $data['message'] = ''; + + $request = $this->createStub(ServerRequestInterface::class); + $request->method('getParsedBody')->willReturn($data); + + $template = $this->createMock(TemplateRendererInterface::class); + $template + ->expects($this->once()) + ->method('render') + ->with( + 'page::contact', + $this->callback(function (array $params): bool { + return $params['contact_mail_failed'] === false + && array_key_exists('name', $params['contact_errors']) + && array_key_exists('message', $params['contact_errors']) + && $params['contact_values']['email'] === 'jane@example.com'; + }) + ) + ->willReturn(''); + + $handler = new PostContactCreateHandler($template, $this->service(new MailResult(true))); + $response = $handler->handle($request); + + $this->assertInstanceOf(HtmlResponse::class, $response); + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * @throws Exception + */ + public function testRerenderKeepsAnyExtraQueryParamsAsDynamicHiddenFields(): void + { + $data = $this->validData(); + $data['name'] = ''; + $data['utm_source'] = 'google'; + $data['ref'] = 'partner'; + + $request = $this->createStub(ServerRequestInterface::class); + $request->method('getParsedBody')->willReturn($data); + + $template = $this->createMock(TemplateRendererInterface::class); + $template + ->expects($this->once()) + ->method('render') + ->with( + 'page::contact', + $this->callback(fn (array $params): bool => $params['query'] === [ + 'utm_source' => 'google', + 'ref' => 'partner', + ]) + ) + ->willReturn(''); + + $handler = new PostContactCreateHandler($template, $this->service(new MailResult(true))); + $handler->handle($request); + } + + /** + * @throws Exception + */ + public function testMailTransportFailureRerendersWithMailFailedFlag(): void + { + $request = $this->createStub(ServerRequestInterface::class); + $request->method('getParsedBody')->willReturn($this->validData()); + + $template = $this->createMock(TemplateRendererInterface::class); + $template + ->expects($this->once()) + ->method('render') + ->with( + 'page::contact', + $this->callback(function (array $params): bool { + return $params['contact_errors'] === [] + && $params['contact_mail_failed'] === true; + }) + ) + ->willReturn(''); + + $handler = new PostContactCreateHandler( + $template, + $this->service(new RuntimeException('SMTP connection refused')) + ); + $response = $handler->handle($request); + + $this->assertInstanceOf(HtmlResponse::class, $response); + $this->assertSame(200, $response->getStatusCode()); + } +} diff --git a/test/Unit/App/RoutesDelegatorTest.php b/test/Unit/App/RoutesDelegatorTest.php index 4781a2fc..7d169200 100644 --- a/test/Unit/App/RoutesDelegatorTest.php +++ b/test/Unit/App/RoutesDelegatorTest.php @@ -9,6 +9,7 @@ use Light\App\Handler\GetMarkdownArticleHandler; use Light\App\Handler\GetPackagesViewHandler; use Light\App\Handler\GetSitemapViewHandler; +use Light\App\Handler\PostContactCreateHandler; use Light\App\RoutesDelegator; use LightTest\Unit\UnitTest; use Mezzio\Application; @@ -38,6 +39,11 @@ public function testWillRegisterAllRoutes(): void return $this->createStub(Route::class); }); + $app->method('post')->willReturnCallback(function (string $uri, mixed $handler, ?string $name = null) { + $this->registeredRoutes['POST ' . $uri] = ['handler' => $handler, 'name' => $name]; + + return $this->createStub(Route::class); + }); $application = (new RoutesDelegator())($container, '', fn () => $app); @@ -66,5 +72,8 @@ public function testWillRegisterAllRoutes(): void GetPackagesViewHandler::TEMPLATE, $this->registeredRoutes['/dotkernel-packages-oss-lifecycle/']['name'] ); + + $this->assertSame([PostContactCreateHandler::class], $this->registeredRoutes['POST /contact/']['handler']); + $this->assertSame('app::create-contact', $this->registeredRoutes['POST /contact/']['name']); } } diff --git a/test/Unit/App/Service/ContactServiceTest.php b/test/Unit/App/Service/ContactServiceTest.php new file mode 100644 index 00000000..39499891 --- /dev/null +++ b/test/Unit/App/Service/ContactServiceTest.php @@ -0,0 +1,283 @@ + 'migration', + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'company' => 'Acme Inc', + 'stack' => 'ZF3, PHP 7.4', + 'message' => 'We would like to talk about migrating our platform.', + 'query' => '', + ]; + } + + private function service(): ContactService + { + return new ContactService($this->createStub(MailServiceInterface::class)); + } + + public function testNormalizeExtractsAndTrimsKnownFields(): void + { + $data = $this->service()->normalize([ + 'name' => ' Jane Doe ', + 'email' => 'jane@example.com', + 'unknown' => 'ignored', + ]); + + $this->assertSame('Jane Doe', $data['name']); + $this->assertSame('jane@example.com', $data['email']); + $this->assertSame('', $data['company']); + $this->assertArrayNotHasKey('unknown', $data); + } + + public function testNormalizeDropsAnUnknownTopic(): void + { + $data = $this->service()->normalize(['topic' => 'not-a-topic']); + + $this->assertSame('', $data['topic']); + } + + public function testNormalizeKeepsAKnownTopic(): void + { + $data = $this->service()->normalize(['topic' => 'oss']); + + $this->assertSame('oss', $data['topic']); + } + + public function testNormalizeReturnsAllEmptyWhenNotAnArray(): void + { + $data = $this->service()->normalize('not-an-array'); + + $this->assertSame( + [ + 'topic' => '', + 'name' => '', + 'email' => '', + 'company' => '', + 'stack' => '', + 'message' => '', + 'query' => '', + ], + $data + ); + } + + public function testNormalizeBuildsQueryStringFromAnyExtraField(): void + { + $data = $this->service()->normalize([ + 'utm_source' => 'google', + 'ref' => 'partner', + ]); + + $this->assertSame('utm_source=google&ref=partner', $data['query']); + } + + public function testNormalizeExcludesKnownContactFieldsFromQueryString(): void + { + $data = $this->service()->normalize([ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'contact' => 'sent', + 'utm_source' => 'google', + ]); + + $this->assertSame('utm_source=google', $data['query']); + } + + public function testExtractQueryReturnsAnyNonReservedParam(): void + { + $query = ContactService::extractQuery([ + 'utm_source' => 'newsletter', + 'ref' => 'partner site', + ]); + + $this->assertSame(['utm_source' => 'newsletter', 'ref' => 'partner site'], $query); + } + + public function testExtractQueryExcludesReservedFormFields(): void + { + $query = ContactService::extractQuery([ + 'name' => 'Jane Doe', + 'contact' => 'sent', + 'utm_source' => 'google', + ]); + + $this->assertSame(['utm_source' => 'google'], $query); + } + + public function testExtractQueryReturnsEmptyArrayWhenNoParams(): void + { + $this->assertSame([], ContactService::extractQuery([])); + } + + public function testNormalizeIgnoresNonStringValues(): void + { + $data = $this->service()->normalize(['name' => ['not', 'a', 'string']]); + + $this->assertSame('', $data['name']); + } + + public function testValidSubmissionHasNoErrors(): void + { + $errors = $this->service()->validate($this->validData()); + + $this->assertSame([], $errors); + } + + public function testMissingRequiredFieldsProduceErrors(): void + { + $data = $this->validData(); + $data['name'] = ''; + $data['email'] = ''; + $data['message'] = ''; + + $errors = $this->service()->validate($data); + + $this->assertArrayHasKey('name', $errors); + $this->assertArrayHasKey('email', $errors); + $this->assertArrayHasKey('message', $errors); + $this->assertArrayNotHasKey('company', $errors); + $this->assertArrayNotHasKey('stack', $errors); + } + + public function testInvalidEmailFormatProducesError(): void + { + $data = $this->validData(); + $data['email'] = 'not-an-email'; + + $errors = $this->service()->validate($data); + + $this->assertArrayHasKey('email', $errors); + } + + /** + * @throws Exception + */ + public function testSendReturnsTrueOnSuccess(): void + { + $message = $this->createMock(Email::class); + $message->expects($this->once())->method('setReplyTo')->with('jane@example.com', 'Jane Doe'); + + $mailService = $this->createMock(MailServiceInterface::class); + $mailService->method('getMessage')->willReturn($message); + $mailService + ->expects($this->once()) + ->method('setSubject') + ->with('New contact form submission on dotkernel.com'); + $mailService->expects($this->once())->method('setBody'); + $mailService->method('send')->willReturn(new MailResult(true)); + + $service = new ContactService($mailService); + + $this->assertTrue($service->send($this->validData())); + } + + public function testSendReturnsFalseWhenTransportThrows(): void + { + $message = $this->createStub(Email::class); + + $mailService = $this->createStub(MailServiceInterface::class); + $mailService->method('getMessage')->willReturn($message); + $mailService->method('send')->willThrowException(new RuntimeException('SMTP connection refused')); + + $service = new ContactService($mailService); + + $this->assertFalse($service->send($this->validData())); + } + + public function testSendReturnsFalseWhenResultIsInvalid(): void + { + $message = $this->createStub(Email::class); + + $mailService = $this->createStub(MailServiceInterface::class); + $mailService->method('getMessage')->willReturn($message); + $mailService->method('send')->willReturn(new MailResult(false, 'Invalid message')); + + $service = new ContactService($mailService); + + $this->assertFalse($service->send($this->validData())); + } + + public function testSendIncludesQueryParamsInBodyWhenPresent(): void + { + $message = $this->createStub(Email::class); + + $mailService = $this->createMock(MailServiceInterface::class); + $mailService->method('getMessage')->willReturn($message); + $mailService + ->expects($this->once()) + ->method('setBody') + ->with($this->callback( + fn (string $body): bool => str_contains($body, 'Query params') + && str_contains($body, 'utm_source=google&ref=partner') + )); + $mailService->method('send')->willReturn(new MailResult(true)); + + $service = new ContactService($mailService); + + $data = $this->validData(); + $data['query'] = 'utm_source=google&ref=partner'; + + $service->send($data); + } + + public function testSendOmitsQueryRowWhenAbsent(): void + { + $message = $this->createStub(Email::class); + + $mailService = $this->createMock(MailServiceInterface::class); + $mailService->method('getMessage')->willReturn($message); + $mailService + ->expects($this->once()) + ->method('setBody') + ->with($this->callback( + fn (string $body): bool => ! str_contains($body, 'Query params') + )); + $mailService->method('send')->willReturn(new MailResult(true)); + + $service = new ContactService($mailService); + + $service->send($this->validData()); + } + + public function testSendUsesTheTopicLabelInBody(): void + { + $message = $this->createStub(Email::class); + + $mailService = $this->createMock(MailServiceInterface::class); + $mailService->method('getMessage')->willReturn($message); + $mailService + ->expects($this->once()) + ->method('setBody') + ->with($this->callback( + fn (string $body): bool => str_contains($body, 'Topic:
Migration') + )); + $mailService->method('send')->willReturn(new MailResult(true)); + + (new ContactService($mailService))->send($this->validData()); + } +} diff --git a/test/Unit/Page/Handler/GetPageViewHandlerTest.php b/test/Unit/Page/Handler/GetPageViewHandlerTest.php index cf91de01..d9915991 100644 --- a/test/Unit/Page/Handler/GetPageViewHandlerTest.php +++ b/test/Unit/Page/Handler/GetPageViewHandlerTest.php @@ -180,14 +180,39 @@ private function createHandler( /** * @throws Exception */ - private function createRequest(string $routeName, string $accept = ''): ServerRequestInterface + public function testHandlePassesTheContactStateToTheTemplate(): void { + $template = $this->createMock(TemplateRendererInterface::class); + $template + ->expects($this->once()) + ->method('render') + ->with('page::contact', $this->callback( + fn (array $params): bool => $params['contact_success'] === true + && $params['query'] === ['utm_source' => 'newsletter'] + )) + ->willReturn(''); + + $this->createHandler($template)->handle( + $this->createRequest('page::contact', query: ['contact' => 'sent', 'utm_source' => 'newsletter']) + ); + } + + /** + * @param array $query + * @throws Exception + */ + private function createRequest( + string $routeName, + string $accept = '', + array $query = [], + ): ServerRequestInterface { $routeResult = $this->createStub(RouteResult::class); $routeResult->method('getMatchedRouteName')->willReturn($routeName); $request = $this->createStub(ServerRequestInterface::class); $request->method('getAttribute')->willReturn($routeResult); $request->method('getHeaderLine')->willReturn($accept); + $request->method('getQueryParams')->willReturn($query); return $request; } From 2953fbec9bb7a4a3989a1b6bc1d419a8e7c9fe1c Mon Sep 17 00:00:00 2001 From: olaru Date: Fri, 25 Sep 2026 18:16:55 +0300 Subject: [PATCH 02/22] php test: update query --- test/Unit/Page/Handler/PageHandlerTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/Unit/Page/Handler/PageHandlerTest.php b/test/Unit/Page/Handler/PageHandlerTest.php index c8d52929..906c6dad 100644 --- a/test/Unit/Page/Handler/PageHandlerTest.php +++ b/test/Unit/Page/Handler/PageHandlerTest.php @@ -60,6 +60,10 @@ public function testHandle(): void ->method('getHeaderLine') ->willReturn(''); + $request + ->method('getQueryParams') + ->willReturn([]); + $template ->method('render') ->willReturn('

' . $routeName . '

'); From 7c5358a0793bc7e09b61d0e7c7818bd6b20baba0 Mon Sep 17 00:00:00 2001 From: olaru Date: Mon, 28 Sep 2026 17:32:18 +0300 Subject: [PATCH 03/22] Contact form inject method --- composer.json | 1 + config/config.php | 1 + src/App/src/ConfigProvider.php | 7 ++-- src/App/src/Factory/ContactServiceFactory.php | 28 ---------------- .../PostContactCreateHandlerFactory.php | 32 ------------------- .../src/Handler/PostContactCreateHandler.php | 5 +++ src/App/src/Service/ContactService.php | 2 ++ 7 files changed, 12 insertions(+), 64 deletions(-) delete mode 100644 src/App/src/Factory/ContactServiceFactory.php delete mode 100644 src/App/src/Factory/PostContactCreateHandlerFactory.php diff --git a/composer.json b/composer.json index ceb62d93..a6fe63dc 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "doctrine/data-fixtures": "^2.2", "doctrine/doctrine-fixtures-bundle": "^4.3", "dotkernel/dot-cache": "^4.0", + "dotkernel/dot-dependency-injection": "^1.4", "dotkernel/dot-errorhandler": "^5.0.0", "dotkernel/dot-mail": "^5.4", "dotkernel/dot-response-header": "^3.6", diff --git a/config/config.php b/config/config.php index 1fe12615..147edc17 100644 --- a/config/config.php +++ b/config/config.php @@ -25,6 +25,7 @@ \Mezzio\Twig\ConfigProvider::class, // Dotkernel packages + \Dot\DependencyInjection\ConfigProvider::class, \Dot\ErrorHandler\ConfigProvider::class, \Dot\Log\ConfigProvider::class, \Dot\Mail\ConfigProvider::class, diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php index 43b1b2b3..8646a556 100644 --- a/src/App/src/ConfigProvider.php +++ b/src/App/src/ConfigProvider.php @@ -10,8 +10,8 @@ use Doctrine\Persistence\Mapping\Driver\MappingDriverChain; use Dot\Cache\Adapter\ArrayAdapter; use Dot\Cache\Adapter\FilesystemAdapter; +use Dot\DependencyInjection\Factory\AttributedServiceFactory; use Light\App\DBAL\Types\UuidType; -use Light\App\Factory\ContactServiceFactory; use Light\App\Factory\EntityListenerResolverFactory; use Light\App\Factory\FeedGeneratorFactory; use Light\App\Factory\GetFeedViewHandlerFactory; @@ -25,7 +25,6 @@ use Light\App\Factory\MarkdownExtensionFactory; use Light\App\Factory\MarkdownRuntimeLoaderFactory; use Light\App\Factory\PackageGeneratorFactory; -use Light\App\Factory\PostContactCreateHandlerFactory; use Light\App\Factory\SitemapGeneratorFactory; use Light\App\Handler\GetFeedViewHandler; use Light\App\Handler\GetIndexViewHandler; @@ -146,8 +145,8 @@ public function getDependencies(): array GetMarkdownArticleHandler::class => GetMarkdownArticleHandlerFactory::class, GetSitemapViewHandler::class => GetSitemapViewHandlerFactory::class, GetPackagesViewHandler::class => GetPackagesViewHandlerFactory::class, - PostContactCreateHandler::class => PostContactCreateHandlerFactory::class, - ContactService::class => ContactServiceFactory::class, + PostContactCreateHandler::class => AttributedServiceFactory::class, + ContactService::class => AttributedServiceFactory::class, FeedGenerator::class => FeedGeneratorFactory::class, SitemapGenerator::class => SitemapGeneratorFactory::class, GitHubClient::class => GitHubClientFactory::class, diff --git a/src/App/src/Factory/ContactServiceFactory.php b/src/App/src/Factory/ContactServiceFactory.php deleted file mode 100644 index 25c45b68..00000000 --- a/src/App/src/Factory/ContactServiceFactory.php +++ /dev/null @@ -1,28 +0,0 @@ -get('dot-mail.service.contact'); - assert($mailService instanceof MailServiceInterface); - - return new ContactService($mailService); - } -} diff --git a/src/App/src/Factory/PostContactCreateHandlerFactory.php b/src/App/src/Factory/PostContactCreateHandlerFactory.php deleted file mode 100644 index 40a54291..00000000 --- a/src/App/src/Factory/PostContactCreateHandlerFactory.php +++ /dev/null @@ -1,32 +0,0 @@ -get(TemplateRendererInterface::class); - assert($template instanceof TemplateRendererInterface); - - $contactService = $container->get(ContactService::class); - assert($contactService instanceof ContactService); - - return new PostContactCreateHandler($template, $contactService); - } -} diff --git a/src/App/src/Handler/PostContactCreateHandler.php b/src/App/src/Handler/PostContactCreateHandler.php index 1bab4f3c..3b298b6e 100644 --- a/src/App/src/Handler/PostContactCreateHandler.php +++ b/src/App/src/Handler/PostContactCreateHandler.php @@ -4,6 +4,7 @@ namespace Light\App\Handler; +use Dot\DependencyInjection\Attribute\Inject; use Laminas\Diactoros\Response\HtmlResponse; use Laminas\Diactoros\Response\RedirectResponse; use Light\App\Service\ContactService; @@ -18,6 +19,10 @@ class PostContactCreateHandler implements RequestHandlerInterface { public const string TEMPLATE = 'page::contact'; + #[Inject( + TemplateRendererInterface::class, + ContactService::class, + )] public function __construct( protected TemplateRendererInterface $template, protected ContactService $contactService, diff --git a/src/App/src/Service/ContactService.php b/src/App/src/Service/ContactService.php index a2ea7085..becccc66 100644 --- a/src/App/src/Service/ContactService.php +++ b/src/App/src/Service/ContactService.php @@ -4,6 +4,7 @@ namespace Light\App\Service; +use Dot\DependencyInjection\Attribute\Inject; use Dot\Mail\Service\MailServiceInterface; use Laminas\Validator\EmailAddress; use Laminas\Validator\NotEmpty; @@ -49,6 +50,7 @@ final class ContactService private const array RESERVED_FIELDS = ['topic', 'name', 'email', 'company', 'stack', 'message', 'contact']; + #[Inject('dot-mail.service.contact')] public function __construct(private readonly MailServiceInterface $mailService) { } From b5dcd29e65dfa07062c008e57257bf7906855f38 Mon Sep 17 00:00:00 2001 From: olaru Date: Tue, 29 Sep 2026 10:00:30 +0300 Subject: [PATCH 04/22] Added recommended contact form handling and template rendering --- src/App/src/ConfigProvider.php | 2 + src/App/src/Enum/ContactTopicEnum.php | 23 +++++++++++ .../Handler/GetContactCreateFormHandler.php | 38 +++++++++++++++++++ .../src/Handler/PostContactCreateHandler.php | 20 ++++++++-- src/App/src/RoutesDelegator.php | 2 + src/App/src/Service/ContactService.php | 15 ++------ src/App/src/Service/SitemapGenerator.php | 3 ++ src/Blog/templates/page/contact.html.twig | 13 ++----- src/Page/src/Handler/GetPageViewHandler.php | 8 +--- .../Handler/PostContactCreateHandlerTest.php | 30 ++++++++++++--- test/Unit/App/RoutesDelegatorTest.php | 4 ++ .../Unit/App/Service/SitemapGeneratorTest.php | 7 ++-- .../Page/Handler/GetPageViewHandlerTest.php | 27 +------------ test/Unit/Page/Handler/PageHandlerTest.php | 4 -- 14 files changed, 127 insertions(+), 69 deletions(-) create mode 100644 src/App/src/Enum/ContactTopicEnum.php create mode 100644 src/App/src/Handler/GetContactCreateFormHandler.php diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php index 8646a556..f2d12a85 100644 --- a/src/App/src/ConfigProvider.php +++ b/src/App/src/ConfigProvider.php @@ -26,6 +26,7 @@ use Light\App\Factory\MarkdownRuntimeLoaderFactory; use Light\App\Factory\PackageGeneratorFactory; use Light\App\Factory\SitemapGeneratorFactory; +use Light\App\Handler\GetContactCreateFormHandler; use Light\App\Handler\GetFeedViewHandler; use Light\App\Handler\GetIndexViewHandler; use Light\App\Handler\GetMarkdownArticleHandler; @@ -145,6 +146,7 @@ public function getDependencies(): array GetMarkdownArticleHandler::class => GetMarkdownArticleHandlerFactory::class, GetSitemapViewHandler::class => GetSitemapViewHandlerFactory::class, GetPackagesViewHandler::class => GetPackagesViewHandlerFactory::class, + GetContactCreateFormHandler::class => AttributedServiceFactory::class, PostContactCreateHandler::class => AttributedServiceFactory::class, ContactService::class => AttributedServiceFactory::class, FeedGenerator::class => FeedGeneratorFactory::class, diff --git a/src/App/src/Enum/ContactTopicEnum.php b/src/App/src/Enum/ContactTopicEnum.php new file mode 100644 index 00000000..4e8857e7 --- /dev/null +++ b/src/App/src/Enum/ContactTopicEnum.php @@ -0,0 +1,23 @@ + 'Migration', + self::Project => 'Project work', + self::Oss => 'Open source', + self::Other => 'Something else', + }; + } +} diff --git a/src/App/src/Handler/GetContactCreateFormHandler.php b/src/App/src/Handler/GetContactCreateFormHandler.php new file mode 100644 index 00000000..48b93fff --- /dev/null +++ b/src/App/src/Handler/GetContactCreateFormHandler.php @@ -0,0 +1,38 @@ +getQueryParams(); + + return new HtmlResponse($this->template->render(self::TEMPLATE, [ + 'topics' => ContactTopicEnum::cases(), + 'contact_success' => ($query['contact'] ?? null) === 'sent', + 'query' => ContactService::extractQuery($query), + ])); + } +} diff --git a/src/App/src/Handler/PostContactCreateHandler.php b/src/App/src/Handler/PostContactCreateHandler.php index 3b298b6e..a7e87aee 100644 --- a/src/App/src/Handler/PostContactCreateHandler.php +++ b/src/App/src/Handler/PostContactCreateHandler.php @@ -5,9 +5,12 @@ namespace Light\App\Handler; use Dot\DependencyInjection\Attribute\Inject; +use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response\HtmlResponse; use Laminas\Diactoros\Response\RedirectResponse; +use Light\App\Enum\ContactTopicEnum; use Light\App\Service\ContactService; +use Mezzio\Helper\UrlHelper; use Mezzio\Template\TemplateRendererInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -17,14 +20,14 @@ class PostContactCreateHandler implements RequestHandlerInterface { - public const string TEMPLATE = 'page::contact'; - #[Inject( TemplateRendererInterface::class, + UrlHelper::class, ContactService::class, )] public function __construct( protected TemplateRendererInterface $template, + protected UrlHelper $urlHelper, protected ContactService $contactService, ) { } @@ -42,7 +45,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface if ($errors !== [] || $mailFailed) { $rawBody = $request->getParsedBody(); - return new HtmlResponse($this->template->render(self::TEMPLATE, [ + return new HtmlResponse($this->template->render(GetContactCreateFormHandler::TEMPLATE, [ + 'topics' => ContactTopicEnum::cases(), 'contact_errors' => $errors, 'contact_values' => $data, 'contact_mail_failed' => $mailFailed, @@ -50,6 +54,14 @@ public function handle(ServerRequestInterface $request): ResponseInterface ])); } - return new RedirectResponse('/contact/?contact=sent#contact-form', 303); + return new RedirectResponse( + $this->urlHelper->generate( + GetContactCreateFormHandler::TEMPLATE, + [], + ['contact' => 'sent'], + 'contact-form' + ), + StatusCodeInterface::STATUS_SEE_OTHER + ); } } diff --git a/src/App/src/RoutesDelegator.php b/src/App/src/RoutesDelegator.php index 10289e27..d9b7659f 100644 --- a/src/App/src/RoutesDelegator.php +++ b/src/App/src/RoutesDelegator.php @@ -4,6 +4,7 @@ namespace Light\App; +use Light\App\Handler\GetContactCreateFormHandler; use Light\App\Handler\GetFeedViewHandler; use Light\App\Handler\GetIndexViewHandler; use Light\App\Handler\GetMarkdownArticleHandler; @@ -26,6 +27,7 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal $app->get('/sitemap/', [GetSitemapViewHandler::class], 'app::sitemap'); $app->get('/{categorySlug}/{slug}.md', [GetMarkdownArticleHandler::class], 'app::markdown-article'); + $app->get('/contact/', [GetContactCreateFormHandler::class], GetContactCreateFormHandler::TEMPLATE); $app->post('/contact/', [PostContactCreateHandler::class], 'app::create-contact'); // Route name kept as `page::…` because `@layout/default.html.twig` links it by name. diff --git a/src/App/src/Service/ContactService.php b/src/App/src/Service/ContactService.php index becccc66..febe3881 100644 --- a/src/App/src/Service/ContactService.php +++ b/src/App/src/Service/ContactService.php @@ -8,6 +8,7 @@ use Dot\Mail\Service\MailServiceInterface; use Laminas\Validator\EmailAddress; use Laminas\Validator\NotEmpty; +use Light\App\Enum\ContactTopicEnum; use Throwable; use function htmlspecialchars; @@ -35,13 +36,6 @@ */ final class ContactService { - public const array TOPICS = [ - 'migration' => 'Migration', - 'project' => 'Project work', - 'oss' => 'Open source', - 'other' => 'Something else', - ]; - private const array REQUIRED_FIELDS = [ 'name' => 'Name', 'email' => 'Work email', @@ -76,11 +70,10 @@ public static function extractQuery(array $source): array */ public function normalize(mixed $parsedBody): array { - $raw = is_array($parsedBody) ? $parsedBody : []; - $topic = $this->stringValue($raw, 'topic'); + $raw = is_array($parsedBody) ? $parsedBody : []; return [ - 'topic' => isset(self::TOPICS[$topic]) ? $topic : '', + 'topic' => ContactTopicEnum::tryFrom($this->stringValue($raw, 'topic'))->value ?? '', 'name' => $this->stringValue($raw, 'name'), 'email' => $this->stringValue($raw, 'email'), 'company' => $this->stringValue($raw, 'company'), @@ -144,7 +137,7 @@ private function stringValue(array $raw, string $key): string private function buildBody(array $data): string { $rows = [ - 'Topic' => self::TOPICS[$data['topic']] ?? '', + 'Topic' => ContactTopicEnum::tryFrom($data['topic'])?->label() ?? '', 'Name' => $data['name'], 'Work email' => $data['email'], 'Company' => $data['company'], diff --git a/src/App/src/Service/SitemapGenerator.php b/src/App/src/Service/SitemapGenerator.php index 8afc921e..5f898beb 100644 --- a/src/App/src/Service/SitemapGenerator.php +++ b/src/App/src/Service/SitemapGenerator.php @@ -71,6 +71,9 @@ public function write(): int $this->appendUrl($dom, $urlset, $this->baseUrl . '/dotkernel-packages-oss-lifecycle/'); $count++; + $this->appendUrl($dom, $urlset, $this->baseUrl . '/contact/'); + $count++; + foreach ($this->pageRoutes as $routeUri) { $this->appendUrl($dom, $urlset, sprintf('%s/%s/', $this->baseUrl, $routeUri)); $count++; diff --git a/src/Blog/templates/page/contact.html.twig b/src/Blog/templates/page/contact.html.twig index ee6f43d9..4c39ada8 100644 --- a/src/Blog/templates/page/contact.html.twig +++ b/src/Blog/templates/page/contact.html.twig @@ -5,13 +5,6 @@ {% block stylesheets %}{% endblock %} -{% set topics = [ - {id: 'migration', label: 'Migration'}, - {id: 'project', label: 'Project work'}, - {id: 'oss', label: 'Open source'}, - {id: 'other', label: 'Something else'}, -] %} - {% set support_links = [ {label: 'Support documentation', href: 'https://github.com/dotkernel/.github/blob/main/SUPPORT.md'}, {label: 'Security policy', href: 'https://github.com/dotkernel/.github/blob/main/SECURITY.md'}, @@ -78,7 +71,7 @@
{% set values = contact_values|default({}) %} {% set errors = contact_errors|default({}) %} - {% set selected_topic = values.topic|default('') ?: topics[0].id %} + {% set selected_topic = values.topic|default('') ?: (topics|first).value %}

Send a message

@@ -97,8 +90,8 @@
{% for topic in topics %} - + {% endfor %}
diff --git a/src/Page/src/Handler/GetPageViewHandler.php b/src/Page/src/Handler/GetPageViewHandler.php index 357a7107..3c531894 100644 --- a/src/Page/src/Handler/GetPageViewHandler.php +++ b/src/Page/src/Handler/GetPageViewHandler.php @@ -7,7 +7,6 @@ use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response\HtmlResponse; use Laminas\Diactoros\Response\TextResponse; -use Light\App\Service\ContactService; use Light\Blog\Repository\CategoryRepository; use Light\Blog\Repository\PostRepository; use Light\Page\Service\PageServiceInterface; @@ -47,13 +46,10 @@ public function handle(ServerRequestInterface $request): ResponseInterface $posts = $this->postRepository->getRecentPosts(3); $categories = $this->categoryRepository->getCategories(); - $query = $request->getQueryParams(); return new HtmlResponse( $this->template->render($template, [ - 'posts' => $posts, - 'categories' => $categories, - 'contact_success' => ($query['contact'] ?? null) === 'sent', - 'query' => ContactService::extractQuery($query), + 'posts' => $posts, + 'categories' => $categories, ]) ); } diff --git a/test/Unit/App/Handler/PostContactCreateHandlerTest.php b/test/Unit/App/Handler/PostContactCreateHandlerTest.php index ef430714..7719d709 100644 --- a/test/Unit/App/Handler/PostContactCreateHandlerTest.php +++ b/test/Unit/App/Handler/PostContactCreateHandlerTest.php @@ -7,10 +7,13 @@ use Dot\Mail\Email; use Dot\Mail\Result\MailResult; use Dot\Mail\Service\MailServiceInterface; +use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response\HtmlResponse; use Laminas\Diactoros\Response\RedirectResponse; +use Light\App\Enum\ContactTopicEnum; use Light\App\Handler\PostContactCreateHandler; use Light\App\Service\ContactService; +use Mezzio\Helper\UrlHelper; use Mezzio\Template\TemplateRendererInterface; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; @@ -63,11 +66,18 @@ public function testValidSubmissionRedirectsOnSuccess(): void $template = $this->createMock(TemplateRendererInterface::class); $template->expects($this->never())->method('render'); - $handler = new PostContactCreateHandler($template, $this->service(new MailResult(true))); + $urlHelper = $this->createMock(UrlHelper::class); + $urlHelper + ->expects($this->once()) + ->method('generate') + ->with('page::contact', [], ['contact' => 'sent'], 'contact-form') + ->willReturn('/contact/?contact=sent#contact-form'); + + $handler = new PostContactCreateHandler($template, $urlHelper, $this->service(new MailResult(true))); $response = $handler->handle($request); $this->assertInstanceOf(RedirectResponse::class, $response); - $this->assertSame(303, $response->getStatusCode()); + $this->assertSame(StatusCodeInterface::STATUS_SEE_OTHER, $response->getStatusCode()); $this->assertSame('/contact/?contact=sent#contact-form', $response->getHeaderLine('Location')); } @@ -90,7 +100,8 @@ public function testMissingRequiredFieldsRerendersWithErrors(): void ->with( 'page::contact', $this->callback(function (array $params): bool { - return $params['contact_mail_failed'] === false + return $params['topics'] === ContactTopicEnum::cases() + && $params['contact_mail_failed'] === false && array_key_exists('name', $params['contact_errors']) && array_key_exists('message', $params['contact_errors']) && $params['contact_values']['email'] === 'jane@example.com'; @@ -98,7 +109,11 @@ public function testMissingRequiredFieldsRerendersWithErrors(): void ) ->willReturn(''); - $handler = new PostContactCreateHandler($template, $this->service(new MailResult(true))); + $handler = new PostContactCreateHandler( + $template, + $this->createStub(UrlHelper::class), + $this->service(new MailResult(true)) + ); $response = $handler->handle($request); $this->assertInstanceOf(HtmlResponse::class, $response); @@ -131,7 +146,11 @@ public function testRerenderKeepsAnyExtraQueryParamsAsDynamicHiddenFields(): voi ) ->willReturn(''); - $handler = new PostContactCreateHandler($template, $this->service(new MailResult(true))); + $handler = new PostContactCreateHandler( + $template, + $this->createStub(UrlHelper::class), + $this->service(new MailResult(true)) + ); $handler->handle($request); } @@ -158,6 +177,7 @@ public function testMailTransportFailureRerendersWithMailFailedFlag(): void $handler = new PostContactCreateHandler( $template, + $this->createStub(UrlHelper::class), $this->service(new RuntimeException('SMTP connection refused')) ); $response = $handler->handle($request); diff --git a/test/Unit/App/RoutesDelegatorTest.php b/test/Unit/App/RoutesDelegatorTest.php index 7d169200..50c59b5a 100644 --- a/test/Unit/App/RoutesDelegatorTest.php +++ b/test/Unit/App/RoutesDelegatorTest.php @@ -4,6 +4,7 @@ namespace LightTest\Unit\App; +use Light\App\Handler\GetContactCreateFormHandler; use Light\App\Handler\GetFeedViewHandler; use Light\App\Handler\GetIndexViewHandler; use Light\App\Handler\GetMarkdownArticleHandler; @@ -73,6 +74,9 @@ public function testWillRegisterAllRoutes(): void $this->registeredRoutes['/dotkernel-packages-oss-lifecycle/']['name'] ); + $this->assertSame([GetContactCreateFormHandler::class], $this->registeredRoutes['/contact/']['handler']); + $this->assertSame('page::contact', $this->registeredRoutes['/contact/']['name']); + $this->assertSame([PostContactCreateHandler::class], $this->registeredRoutes['POST /contact/']['handler']); $this->assertSame('app::create-contact', $this->registeredRoutes['POST /contact/']['name']); } diff --git a/test/Unit/App/Service/SitemapGeneratorTest.php b/test/Unit/App/Service/SitemapGeneratorTest.php index fbba7c98..68d3cba6 100644 --- a/test/Unit/App/Service/SitemapGeneratorTest.php +++ b/test/Unit/App/Service/SitemapGeneratorTest.php @@ -35,7 +35,7 @@ class SitemapGeneratorTest extends UnitTest { /** Homepage, /blog/, /categories/, /authors/ and the packages-lifecycle page are always present. */ - private const FIXED_URL_COUNT = 5; + private const FIXED_URL_COUNT = 6; private string $sitemapFile; @@ -92,6 +92,7 @@ public function testWriteAlwaysIncludesTheFixedPagesEvenWithoutAnyContent(): voi 'https://example.test/dotkernel-packages-oss-lifecycle/', (string) $urls[4]->loc ); + $this->assertSame('https://example.test/contact/', (string) $urls[5]->loc); $this->assertCount(0, $urls[0]->lastmod); } @@ -100,12 +101,12 @@ public function testWriteAlwaysIncludesTheFixedPagesEvenWithoutAnyContent(): voi */ public function testWriteAddsOneUrlEntryPerConfiguredStaticPage(): void { - $generator = $this->createGenerator(pageRoutes: ['contact']); + $generator = $this->createGenerator(pageRoutes: ['about']); $this->assertSame(self::FIXED_URL_COUNT + 1, $generator->write()); $urls = $this->loadSitemap()->url; - $this->assertSame('https://example.test/contact/', (string) $urls[self::FIXED_URL_COUNT]->loc); + $this->assertSame('https://example.test/about/', (string) $urls[self::FIXED_URL_COUNT]->loc); $this->assertCount(0, $urls[self::FIXED_URL_COUNT]->lastmod); } diff --git a/test/Unit/Page/Handler/GetPageViewHandlerTest.php b/test/Unit/Page/Handler/GetPageViewHandlerTest.php index d9915991..cf91de01 100644 --- a/test/Unit/Page/Handler/GetPageViewHandlerTest.php +++ b/test/Unit/Page/Handler/GetPageViewHandlerTest.php @@ -180,39 +180,14 @@ private function createHandler( /** * @throws Exception */ - public function testHandlePassesTheContactStateToTheTemplate(): void + private function createRequest(string $routeName, string $accept = ''): ServerRequestInterface { - $template = $this->createMock(TemplateRendererInterface::class); - $template - ->expects($this->once()) - ->method('render') - ->with('page::contact', $this->callback( - fn (array $params): bool => $params['contact_success'] === true - && $params['query'] === ['utm_source' => 'newsletter'] - )) - ->willReturn(''); - - $this->createHandler($template)->handle( - $this->createRequest('page::contact', query: ['contact' => 'sent', 'utm_source' => 'newsletter']) - ); - } - - /** - * @param array $query - * @throws Exception - */ - private function createRequest( - string $routeName, - string $accept = '', - array $query = [], - ): ServerRequestInterface { $routeResult = $this->createStub(RouteResult::class); $routeResult->method('getMatchedRouteName')->willReturn($routeName); $request = $this->createStub(ServerRequestInterface::class); $request->method('getAttribute')->willReturn($routeResult); $request->method('getHeaderLine')->willReturn($accept); - $request->method('getQueryParams')->willReturn($query); return $request; } diff --git a/test/Unit/Page/Handler/PageHandlerTest.php b/test/Unit/Page/Handler/PageHandlerTest.php index 906c6dad..c8d52929 100644 --- a/test/Unit/Page/Handler/PageHandlerTest.php +++ b/test/Unit/Page/Handler/PageHandlerTest.php @@ -60,10 +60,6 @@ public function testHandle(): void ->method('getHeaderLine') ->willReturn(''); - $request - ->method('getQueryParams') - ->willReturn([]); - $template ->method('render') ->willReturn('

' . $routeName . '

'); From 5cc0b945131c3d7c6bcf8893606d184e86883583 Mon Sep 17 00:00:00 2001 From: olaru Date: Tue, 29 Sep 2026 10:01:55 +0300 Subject: [PATCH 05/22] Add tests --- test/Unit/App/Enum/ContactTopicEnumTest.php | 29 ++++++++ .../GetContactCreateFormHandlerTest.php | 72 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 test/Unit/App/Enum/ContactTopicEnumTest.php create mode 100644 test/Unit/App/Handler/GetContactCreateFormHandlerTest.php diff --git a/test/Unit/App/Enum/ContactTopicEnumTest.php b/test/Unit/App/Enum/ContactTopicEnumTest.php new file mode 100644 index 00000000..a1ae587e --- /dev/null +++ b/test/Unit/App/Enum/ContactTopicEnumTest.php @@ -0,0 +1,29 @@ +assertSame( + ['migration', 'project', 'oss', 'other'], + array_map(fn (ContactTopicEnum $topic): string => $topic->value, ContactTopicEnum::cases()) + ); + } + + public function testLabel(): void + { + $this->assertSame('Migration', ContactTopicEnum::Migration->label()); + $this->assertSame('Project work', ContactTopicEnum::Project->label()); + $this->assertSame('Open source', ContactTopicEnum::Oss->label()); + $this->assertSame('Something else', ContactTopicEnum::Other->label()); + } +} diff --git a/test/Unit/App/Handler/GetContactCreateFormHandlerTest.php b/test/Unit/App/Handler/GetContactCreateFormHandlerTest.php new file mode 100644 index 00000000..ade887fe --- /dev/null +++ b/test/Unit/App/Handler/GetContactCreateFormHandlerTest.php @@ -0,0 +1,72 @@ +createMock(TemplateRendererInterface::class); + $template + ->expects($this->once()) + ->method('render') + ->with('page::contact', $this->callback( + fn (array $params): bool => $params['topics'] === ContactTopicEnum::cases() + && $params['contact_success'] === false + && $params['query'] === [] + )) + ->willReturn(''); + + $response = (new GetContactCreateFormHandler($template))->handle($this->createRequest([])); + + $this->assertInstanceOf(HtmlResponse::class, $response); + $this->assertSame(StatusCodeInterface::STATUS_OK, $response->getStatusCode()); + $this->assertSame('
', (string) $response->getBody()); + } + + /** + * @throws Exception + */ + public function testHandleFlagsSuccessAndKeepsExtraQueryParams(): void + { + $template = $this->createMock(TemplateRendererInterface::class); + $template + ->expects($this->once()) + ->method('render') + ->with('page::contact', $this->callback( + fn (array $params): bool => $params['contact_success'] === true + && $params['query'] === ['utm_source' => 'newsletter'] + )) + ->willReturn(''); + + (new GetContactCreateFormHandler($template))->handle( + $this->createRequest(['contact' => 'sent', 'utm_source' => 'newsletter']) + ); + } + + /** + * @param array $query + * @throws Exception + */ + private function createRequest(array $query): ServerRequestInterface + { + $request = $this->createStub(ServerRequestInterface::class); + $request->method('getQueryParams')->willReturn($query); + + return $request; + } +} From 51c995c3f8377b3786996cc7b93b90b4f020753a Mon Sep 17 00:00:00 2001 From: olaru Date: Tue, 29 Sep 2026 11:27:40 +0300 Subject: [PATCH 06/22] update handler names and routes --- src/App/src/ConfigProvider.php | 8 ++--- ...er.php => GetCreateContactFormHandler.php} | 2 +- ...ndler.php => PostCreateContactHandler.php} | 13 ++------ src/App/src/RoutesDelegator.php | 8 ++--- src/App/src/Service/ContactService.php | 31 ++++++++++++++++--- src/App/templates/app/index.html.twig | 2 +- src/App/templates/layout/default.html.twig | 4 +-- .../page/JSON-LD/contact.jsonld.twig | 4 +-- src/Page/templates/page/about.html.twig | 6 ++-- src/Page/templates/page/admin.html.twig | 2 +- src/Page/templates/page/api.html.twig | 2 +- .../templates/page/architecture.html.twig | 2 +- src/Page/templates/page/frontend.html.twig | 2 +- src/Page/templates/page/light.html.twig | 2 +- src/Page/templates/page/queue.html.twig | 2 +- src/Page/templates/page/wsl2.html.twig | 2 +- ...hp => GetCreateContactFormHandlerTest.php} | 8 ++--- ...t.php => PostCreateContactHandlerTest.php} | 19 ++++++------ test/Unit/App/RoutesDelegatorTest.php | 10 +++--- test/Unit/App/Service/ContactServiceTest.php | 31 ++++++++++++++----- 20 files changed, 96 insertions(+), 64 deletions(-) rename src/App/src/Handler/{GetContactCreateFormHandler.php => GetCreateContactFormHandler.php} (94%) rename src/App/src/Handler/{PostContactCreateHandler.php => PostCreateContactHandler.php} (81%) rename test/Unit/App/Handler/{GetContactCreateFormHandlerTest.php => GetCreateContactFormHandlerTest.php} (90%) rename test/Unit/App/Handler/{PostContactCreateHandlerTest.php => PostCreateContactHandlerTest.php} (91%) diff --git a/src/App/src/ConfigProvider.php b/src/App/src/ConfigProvider.php index f2d12a85..53a7dd6c 100644 --- a/src/App/src/ConfigProvider.php +++ b/src/App/src/ConfigProvider.php @@ -26,13 +26,13 @@ use Light\App\Factory\MarkdownRuntimeLoaderFactory; use Light\App\Factory\PackageGeneratorFactory; use Light\App\Factory\SitemapGeneratorFactory; -use Light\App\Handler\GetContactCreateFormHandler; +use Light\App\Handler\GetCreateContactFormHandler; use Light\App\Handler\GetFeedViewHandler; use Light\App\Handler\GetIndexViewHandler; use Light\App\Handler\GetMarkdownArticleHandler; use Light\App\Handler\GetPackagesViewHandler; use Light\App\Handler\GetSitemapViewHandler; -use Light\App\Handler\PostContactCreateHandler; +use Light\App\Handler\PostCreateContactHandler; use Light\App\Resolver\EntityListenerResolver; use Light\App\Service\ContactService; use Light\App\Service\FeedGenerator; @@ -146,8 +146,8 @@ public function getDependencies(): array GetMarkdownArticleHandler::class => GetMarkdownArticleHandlerFactory::class, GetSitemapViewHandler::class => GetSitemapViewHandlerFactory::class, GetPackagesViewHandler::class => GetPackagesViewHandlerFactory::class, - GetContactCreateFormHandler::class => AttributedServiceFactory::class, - PostContactCreateHandler::class => AttributedServiceFactory::class, + GetCreateContactFormHandler::class => AttributedServiceFactory::class, + PostCreateContactHandler::class => AttributedServiceFactory::class, ContactService::class => AttributedServiceFactory::class, FeedGenerator::class => FeedGeneratorFactory::class, SitemapGenerator::class => SitemapGeneratorFactory::class, diff --git a/src/App/src/Handler/GetContactCreateFormHandler.php b/src/App/src/Handler/GetCreateContactFormHandler.php similarity index 94% rename from src/App/src/Handler/GetContactCreateFormHandler.php rename to src/App/src/Handler/GetCreateContactFormHandler.php index 48b93fff..193872c1 100644 --- a/src/App/src/Handler/GetContactCreateFormHandler.php +++ b/src/App/src/Handler/GetCreateContactFormHandler.php @@ -13,7 +13,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; -class GetContactCreateFormHandler implements RequestHandlerInterface +class GetCreateContactFormHandler implements RequestHandlerInterface { public const string TEMPLATE = 'page::contact'; diff --git a/src/App/src/Handler/PostContactCreateHandler.php b/src/App/src/Handler/PostCreateContactHandler.php similarity index 81% rename from src/App/src/Handler/PostContactCreateHandler.php rename to src/App/src/Handler/PostCreateContactHandler.php index a7e87aee..b7d80a88 100644 --- a/src/App/src/Handler/PostContactCreateHandler.php +++ b/src/App/src/Handler/PostCreateContactHandler.php @@ -5,7 +5,6 @@ namespace Light\App\Handler; use Dot\DependencyInjection\Attribute\Inject; -use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response\HtmlResponse; use Laminas\Diactoros\Response\RedirectResponse; use Light\App\Enum\ContactTopicEnum; @@ -18,7 +17,7 @@ use function is_array; -class PostContactCreateHandler implements RequestHandlerInterface +class PostCreateContactHandler implements RequestHandlerInterface { #[Inject( TemplateRendererInterface::class, @@ -45,7 +44,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface if ($errors !== [] || $mailFailed) { $rawBody = $request->getParsedBody(); - return new HtmlResponse($this->template->render(GetContactCreateFormHandler::TEMPLATE, [ + return new HtmlResponse($this->template->render(GetCreateContactFormHandler::TEMPLATE, [ 'topics' => ContactTopicEnum::cases(), 'contact_errors' => $errors, 'contact_values' => $data, @@ -55,13 +54,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface } return new RedirectResponse( - $this->urlHelper->generate( - GetContactCreateFormHandler::TEMPLATE, - [], - ['contact' => 'sent'], - 'contact-form' - ), - StatusCodeInterface::STATUS_SEE_OTHER + $this->urlHelper->generate('app::create-contact-form', [], ['contact' => 'sent'], 'contact-form') ); } } diff --git a/src/App/src/RoutesDelegator.php b/src/App/src/RoutesDelegator.php index d9b7659f..be6e8c3e 100644 --- a/src/App/src/RoutesDelegator.php +++ b/src/App/src/RoutesDelegator.php @@ -4,13 +4,13 @@ namespace Light\App; -use Light\App\Handler\GetContactCreateFormHandler; +use Light\App\Handler\GetCreateContactFormHandler; use Light\App\Handler\GetFeedViewHandler; use Light\App\Handler\GetIndexViewHandler; use Light\App\Handler\GetMarkdownArticleHandler; use Light\App\Handler\GetPackagesViewHandler; use Light\App\Handler\GetSitemapViewHandler; -use Light\App\Handler\PostContactCreateHandler; +use Light\App\Handler\PostCreateContactHandler; use Mezzio\Application; use Psr\Container\ContainerInterface; @@ -27,8 +27,8 @@ public function __invoke(ContainerInterface $container, string $serviceName, cal $app->get('/sitemap/', [GetSitemapViewHandler::class], 'app::sitemap'); $app->get('/{categorySlug}/{slug}.md', [GetMarkdownArticleHandler::class], 'app::markdown-article'); - $app->get('/contact/', [GetContactCreateFormHandler::class], GetContactCreateFormHandler::TEMPLATE); - $app->post('/contact/', [PostContactCreateHandler::class], 'app::create-contact'); + $app->get('/contact/', [GetCreateContactFormHandler::class], 'app::create-contact-form'); + $app->post('/contact/', [PostCreateContactHandler::class], 'app::create-contact'); // Route name kept as `page::…` because `@layout/default.html.twig` links it by name. // The matching entry must stay out of `routes.page` in local.php to avoid a duplicate. diff --git a/src/App/src/Service/ContactService.php b/src/App/src/Service/ContactService.php index febe3881..41e194e3 100644 --- a/src/App/src/Service/ContactService.php +++ b/src/App/src/Service/ContactService.php @@ -9,6 +9,7 @@ use Laminas\Validator\EmailAddress; use Laminas\Validator\NotEmpty; use Light\App\Enum\ContactTopicEnum; +use Psr\Log\LoggerInterface; use Throwable; use function htmlspecialchars; @@ -44,9 +45,14 @@ final class ContactService private const array RESERVED_FIELDS = ['topic', 'name', 'email', 'company', 'stack', 'message', 'contact']; - #[Inject('dot-mail.service.contact')] - public function __construct(private readonly MailServiceInterface $mailService) - { + #[Inject( + 'dot-mail.service.contact', + 'dot-log.default_logger', + )] + public function __construct( + private readonly MailServiceInterface $mailService, + private readonly LoggerInterface $logger, + ) { } /** @@ -115,10 +121,25 @@ public function send(array $data): bool $this->mailService->setBody($this->buildBody($data)); try { - return $this->mailService->send()->isValid(); - } catch (Throwable) { + $result = $this->mailService->send(); + } catch (Throwable $exception) { + $this->logger->error('Contact form email could not be sent: {message}', [ + 'message' => $exception->getMessage(), + 'exception' => $exception, + ]); + return false; } + + if (! $result->isValid()) { + $this->logger->error('Contact form email could not be sent: {message}', [ + 'message' => $result->getMessage(), + ]); + + return false; + } + + return true; } /** diff --git a/src/App/templates/app/index.html.twig b/src/App/templates/app/index.html.twig index 8842f0de..74933678 100644 --- a/src/App/templates/app/index.html.twig +++ b/src/App/templates/app/index.html.twig @@ -393,7 +393,7 @@

Dotkernel is an open-source project created and led by the dev team at Apidemia - built first as an internal tool for handling complex architectures, now freely shared as our way of giving back to the community.

diff --git a/src/App/templates/layout/default.html.twig b/src/App/templates/layout/default.html.twig index 112286c0..874e1ed6 100644 --- a/src/App/templates/layout/default.html.twig +++ b/src/App/templates/layout/default.html.twig @@ -59,7 +59,7 @@ - +
@@ -109,7 +109,7 @@
  • Blog
  • Discussion
  • About
  • -
  • Contact us
  • +
  • Contact us
  • diff --git a/src/Blog/templates/page/JSON-LD/contact.jsonld.twig b/src/Blog/templates/page/JSON-LD/contact.jsonld.twig index dad51248..8b58c796 100644 --- a/src/Blog/templates/page/JSON-LD/contact.jsonld.twig +++ b/src/Blog/templates/page/JSON-LD/contact.jsonld.twig @@ -2,8 +2,8 @@ { "@context": "https://schema.org", "@type": "ContactPage", - "@id": "{{ app.url ~ path('page::contact') }}#webpage", - "url": "{{ app.url ~ path('page::contact') }}", + "@id": "{{ app.url ~ path('app::create-contact-form') }}#webpage", + "url": "{{ app.url ~ path('app::create-contact-form') }}", "name": "Contact", "isPartOf": { "@id": "{{ app.url }}#website" } } diff --git a/src/Page/templates/page/about.html.twig b/src/Page/templates/page/about.html.twig index b597c1eb..61501216 100644 --- a/src/Page/templates/page/about.html.twig +++ b/src/Page/templates/page/about.html.twig @@ -39,7 +39,7 @@ on. Everything here is made by a team that has to live with its own decisions.

    @@ -311,7 +311,7 @@ a platform, hiring a team, or just want to argue about middleware, we would rather have the conversation than the brochure.

    diff --git a/src/Page/templates/page/admin.html.twig b/src/Page/templates/page/admin.html.twig index 4feadf4c..dcec8785 100644 --- a/src/Page/templates/page/admin.html.twig +++ b/src/Page/templates/page/admin.html.twig @@ -655,7 +655,7 @@ the interface before you install anything. Dotkernel Admin is developed and led by the dev team at Apidemia, and released as open source for the community.

    diff --git a/src/Page/templates/page/api.html.twig b/src/Page/templates/page/api.html.twig index 2b73003f..7c195002 100644 --- a/src/Page/templates/page/api.html.twig +++ b/src/Page/templates/page/api.html.twig @@ -692,7 +692,7 @@ internal tool for handling complex architectures, released under MIT as our way of giving back to the community.

    diff --git a/src/Page/templates/page/architecture.html.twig b/src/Page/templates/page/architecture.html.twig index f4962616..e2747dc6 100644 --- a/src/Page/templates/page/architecture.html.twig +++ b/src/Page/templates/page/architecture.html.twig @@ -618,7 +618,7 @@ internal tool for handling complex architectures, released under MIT as our way of giving back to the community.

    diff --git a/src/Page/templates/page/frontend.html.twig b/src/Page/templates/page/frontend.html.twig index cf8ca336..3edda148 100644 --- a/src/Page/templates/page/frontend.html.twig +++ b/src/Page/templates/page/frontend.html.twig @@ -625,7 +625,7 @@ as open source for the community. Try the demo to see the account flows before you install anything.

    diff --git a/src/Page/templates/page/light.html.twig b/src/Page/templates/page/light.html.twig index c2014e2a..8f21cb10 100644 --- a/src/Page/templates/page/light.html.twig +++ b/src/Page/templates/page/light.html.twig @@ -585,7 +585,7 @@ open source for the community. If you want to see the whole application before you commit to it, that is the entire promise of this skeleton.

    diff --git a/src/Page/templates/page/queue.html.twig b/src/Page/templates/page/queue.html.twig index 62c7fecf..b7ef1389 100644 --- a/src/Page/templates/page/queue.html.twig +++ b/src/Page/templates/page/queue.html.twig @@ -636,7 +636,7 @@

    Dotkernel Queue is developed and led by the dev team at Apidemia - built to keep real platforms responsive under real load, and released as open source for the community.

    diff --git a/src/Page/templates/page/wsl2.html.twig b/src/Page/templates/page/wsl2.html.twig index 749525b1..3d87d94d 100644 --- a/src/Page/templates/page/wsl2.html.twig +++ b/src/Page/templates/page/wsl2.html.twig @@ -367,7 +367,7 @@ Docker approximation of it.

    diff --git a/test/Unit/App/Handler/GetContactCreateFormHandlerTest.php b/test/Unit/App/Handler/GetCreateContactFormHandlerTest.php similarity index 90% rename from test/Unit/App/Handler/GetContactCreateFormHandlerTest.php rename to test/Unit/App/Handler/GetCreateContactFormHandlerTest.php index ade887fe..4acd2cea 100644 --- a/test/Unit/App/Handler/GetContactCreateFormHandlerTest.php +++ b/test/Unit/App/Handler/GetCreateContactFormHandlerTest.php @@ -7,13 +7,13 @@ use Fig\Http\Message\StatusCodeInterface; use Laminas\Diactoros\Response\HtmlResponse; use Light\App\Enum\ContactTopicEnum; -use Light\App\Handler\GetContactCreateFormHandler; +use Light\App\Handler\GetCreateContactFormHandler; use LightTest\Unit\UnitTest; use Mezzio\Template\TemplateRendererInterface; use PHPUnit\Framework\MockObject\Exception; use Psr\Http\Message\ServerRequestInterface; -class GetContactCreateFormHandlerTest extends UnitTest +class GetCreateContactFormHandlerTest extends UnitTest { /** * @throws Exception @@ -31,7 +31,7 @@ public function testHandleRendersTheContactFormWithTheTopics(): void )) ->willReturn('
    '); - $response = (new GetContactCreateFormHandler($template))->handle($this->createRequest([])); + $response = (new GetCreateContactFormHandler($template))->handle($this->createRequest([])); $this->assertInstanceOf(HtmlResponse::class, $response); $this->assertSame(StatusCodeInterface::STATUS_OK, $response->getStatusCode()); @@ -53,7 +53,7 @@ public function testHandleFlagsSuccessAndKeepsExtraQueryParams(): void )) ->willReturn(''); - (new GetContactCreateFormHandler($template))->handle( + (new GetCreateContactFormHandler($template))->handle( $this->createRequest(['contact' => 'sent', 'utm_source' => 'newsletter']) ); } diff --git a/test/Unit/App/Handler/PostContactCreateHandlerTest.php b/test/Unit/App/Handler/PostCreateContactHandlerTest.php similarity index 91% rename from test/Unit/App/Handler/PostContactCreateHandlerTest.php rename to test/Unit/App/Handler/PostCreateContactHandlerTest.php index 7719d709..a866cb2c 100644 --- a/test/Unit/App/Handler/PostContactCreateHandlerTest.php +++ b/test/Unit/App/Handler/PostCreateContactHandlerTest.php @@ -11,19 +11,20 @@ use Laminas\Diactoros\Response\HtmlResponse; use Laminas\Diactoros\Response\RedirectResponse; use Light\App\Enum\ContactTopicEnum; -use Light\App\Handler\PostContactCreateHandler; +use Light\App\Handler\PostCreateContactHandler; use Light\App\Service\ContactService; use Mezzio\Helper\UrlHelper; use Mezzio\Template\TemplateRendererInterface; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; +use Psr\Log\NullLogger; use RuntimeException; use Throwable; use function array_key_exists; -class PostContactCreateHandlerTest extends TestCase +class PostCreateContactHandlerTest extends TestCase { /** * @return array @@ -52,7 +53,7 @@ private function service(MailResult|Throwable $sendResult): ContactService $mailService->method('send')->willReturn($sendResult); } - return new ContactService($mailService); + return new ContactService($mailService, new NullLogger()); } /** @@ -70,14 +71,14 @@ public function testValidSubmissionRedirectsOnSuccess(): void $urlHelper ->expects($this->once()) ->method('generate') - ->with('page::contact', [], ['contact' => 'sent'], 'contact-form') + ->with('app::create-contact-form', [], ['contact' => 'sent'], 'contact-form') ->willReturn('/contact/?contact=sent#contact-form'); - $handler = new PostContactCreateHandler($template, $urlHelper, $this->service(new MailResult(true))); + $handler = new PostCreateContactHandler($template, $urlHelper, $this->service(new MailResult(true))); $response = $handler->handle($request); $this->assertInstanceOf(RedirectResponse::class, $response); - $this->assertSame(StatusCodeInterface::STATUS_SEE_OTHER, $response->getStatusCode()); + $this->assertSame(StatusCodeInterface::STATUS_FOUND, $response->getStatusCode()); $this->assertSame('/contact/?contact=sent#contact-form', $response->getHeaderLine('Location')); } @@ -109,7 +110,7 @@ public function testMissingRequiredFieldsRerendersWithErrors(): void ) ->willReturn(''); - $handler = new PostContactCreateHandler( + $handler = new PostCreateContactHandler( $template, $this->createStub(UrlHelper::class), $this->service(new MailResult(true)) @@ -146,7 +147,7 @@ public function testRerenderKeepsAnyExtraQueryParamsAsDynamicHiddenFields(): voi ) ->willReturn(''); - $handler = new PostContactCreateHandler( + $handler = new PostCreateContactHandler( $template, $this->createStub(UrlHelper::class), $this->service(new MailResult(true)) @@ -175,7 +176,7 @@ public function testMailTransportFailureRerendersWithMailFailedFlag(): void ) ->willReturn(''); - $handler = new PostContactCreateHandler( + $handler = new PostCreateContactHandler( $template, $this->createStub(UrlHelper::class), $this->service(new RuntimeException('SMTP connection refused')) diff --git a/test/Unit/App/RoutesDelegatorTest.php b/test/Unit/App/RoutesDelegatorTest.php index 50c59b5a..51b96bac 100644 --- a/test/Unit/App/RoutesDelegatorTest.php +++ b/test/Unit/App/RoutesDelegatorTest.php @@ -4,13 +4,13 @@ namespace LightTest\Unit\App; -use Light\App\Handler\GetContactCreateFormHandler; +use Light\App\Handler\GetCreateContactFormHandler; use Light\App\Handler\GetFeedViewHandler; use Light\App\Handler\GetIndexViewHandler; use Light\App\Handler\GetMarkdownArticleHandler; use Light\App\Handler\GetPackagesViewHandler; use Light\App\Handler\GetSitemapViewHandler; -use Light\App\Handler\PostContactCreateHandler; +use Light\App\Handler\PostCreateContactHandler; use Light\App\RoutesDelegator; use LightTest\Unit\UnitTest; use Mezzio\Application; @@ -74,10 +74,10 @@ public function testWillRegisterAllRoutes(): void $this->registeredRoutes['/dotkernel-packages-oss-lifecycle/']['name'] ); - $this->assertSame([GetContactCreateFormHandler::class], $this->registeredRoutes['/contact/']['handler']); - $this->assertSame('page::contact', $this->registeredRoutes['/contact/']['name']); + $this->assertSame([GetCreateContactFormHandler::class], $this->registeredRoutes['/contact/']['handler']); + $this->assertSame('app::create-contact-form', $this->registeredRoutes['/contact/']['name']); - $this->assertSame([PostContactCreateHandler::class], $this->registeredRoutes['POST /contact/']['handler']); + $this->assertSame([PostCreateContactHandler::class], $this->registeredRoutes['POST /contact/']['handler']); $this->assertSame('app::create-contact', $this->registeredRoutes['POST /contact/']['name']); } } diff --git a/test/Unit/App/Service/ContactServiceTest.php b/test/Unit/App/Service/ContactServiceTest.php index 39499891..4a97ca78 100644 --- a/test/Unit/App/Service/ContactServiceTest.php +++ b/test/Unit/App/Service/ContactServiceTest.php @@ -10,6 +10,8 @@ use Light\App\Service\ContactService; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use RuntimeException; use function str_contains; @@ -37,7 +39,7 @@ private function validData(): array private function service(): ContactService { - return new ContactService($this->createStub(MailServiceInterface::class)); + return new ContactService($this->createStub(MailServiceInterface::class), new NullLogger()); } public function testNormalizeExtractsAndTrimsKnownFields(): void @@ -191,7 +193,7 @@ public function testSendReturnsTrueOnSuccess(): void $mailService->expects($this->once())->method('setBody'); $mailService->method('send')->willReturn(new MailResult(true)); - $service = new ContactService($mailService); + $service = new ContactService($mailService, new NullLogger()); $this->assertTrue($service->send($this->validData())); } @@ -204,7 +206,16 @@ public function testSendReturnsFalseWhenTransportThrows(): void $mailService->method('getMessage')->willReturn($message); $mailService->method('send')->willThrowException(new RuntimeException('SMTP connection refused')); - $service = new ContactService($mailService); + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('error') + ->with($this->anything(), $this->callback( + fn (array $context): bool => $context['message'] === 'SMTP connection refused' + && $context['exception'] instanceof RuntimeException + )); + + $service = new ContactService($mailService, $logger); $this->assertFalse($service->send($this->validData())); } @@ -217,7 +228,13 @@ public function testSendReturnsFalseWhenResultIsInvalid(): void $mailService->method('getMessage')->willReturn($message); $mailService->method('send')->willReturn(new MailResult(false, 'Invalid message')); - $service = new ContactService($mailService); + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('error') + ->with($this->anything(), ['message' => 'Invalid message']); + + $service = new ContactService($mailService, $logger); $this->assertFalse($service->send($this->validData())); } @@ -237,7 +254,7 @@ public function testSendIncludesQueryParamsInBodyWhenPresent(): void )); $mailService->method('send')->willReturn(new MailResult(true)); - $service = new ContactService($mailService); + $service = new ContactService($mailService, new NullLogger()); $data = $this->validData(); $data['query'] = 'utm_source=google&ref=partner'; @@ -259,7 +276,7 @@ public function testSendOmitsQueryRowWhenAbsent(): void )); $mailService->method('send')->willReturn(new MailResult(true)); - $service = new ContactService($mailService); + $service = new ContactService($mailService, new NullLogger()); $service->send($this->validData()); } @@ -278,6 +295,6 @@ public function testSendUsesTheTopicLabelInBody(): void )); $mailService->method('send')->willReturn(new MailResult(true)); - (new ContactService($mailService))->send($this->validData()); + (new ContactService($mailService, new NullLogger()))->send($this->validData()); } } From c4c081bfe4e5647a06c269f58ce0716a33dd38d3 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Tue, 29 Sep 2026 12:16:09 +0300 Subject: [PATCH 07/22] update handler names and routes --- config/autoload/local.php.dist | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/config/autoload/local.php.dist b/config/autoload/local.php.dist index 3cd2b2e1..d6b209b2 100644 --- a/config/autoload/local.php.dist +++ b/config/autoload/local.php.dist @@ -83,13 +83,12 @@ return [ 'sitemap' => [ 'path' => realpath(__DIR__ . '/../../public/sitemap.xml'), ], - // `dotkernel-packages-oss-lifecycle` is intentionally absent: it has a dedicated handler - // and is routed in Light\App\RoutesDelegator. Re-adding it here registers the path twice. + // `dotkernel-packages-oss-lifecycle` and `contact` are intentionally absent: they have dedicated + // handlers and are routed in Light\App\RoutesDelegator. Re-adding them here registers the path twice. 'routes' => [ 'page' => [ // Linked from the footer on every page, so this route must stay registered. 'about' => 'about', - 'contact' => 'contact', 'api' => 'api', 'admin' => 'admin', 'queue' => 'queue', From 55069b4118e53e545210aca5499905df947a040a Mon Sep 17 00:00:00 2001 From: bidi47 Date: Tue, 29 Sep 2026 17:18:20 +0300 Subject: [PATCH 08/22] updated wsl2 - node version to 24 Signed-off-by: bidi47 --- public/wsl2.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/public/wsl2.md b/public/wsl2.md index a6438c11..b3b49c85 100644 --- a/public/wsl2.md +++ b/public/wsl2.md @@ -84,12 +84,12 @@ After setup, [Editor Integration](https://docs.dotkernel.org/development/v2/edit `install.yml` reads `config.yml` once and provisions every one of these - safe to re-run if a step fails partway through. -| Component | What you get | -| --- | --- | -| Web server | Apache, with virtualhosts routed automatically under `*.localhost`. | -| Database | MariaDB 12.3 from the MariaDB repository, plus phpMyAdmin for browsing it. | -| PHP | 8.5 by default via the Remi repository; `php81` … `php85` aliases switch versions. | -| Node.js | 22 by default via NodeSource; `node18` … `node24` aliases switch versions. | +| Component | What you get | +|----------------|----------------------------------------------------------------------------------------------------------------------------| +| Web server | Apache, with virtualhosts routed automatically under `*.localhost`. | +| Database | MariaDB 12.3 from the MariaDB repository, plus phpMyAdmin for browsing it. | +| PHP | 8.5 by default via the Remi repository; `php81` … `php85` aliases switch versions. | +| Node.js | 24 by default via NodeSource; `node18` … `node24` aliases switch versions. | | Git & Composer | Your Git identity from `config.yml`, and the latest Composer at install time; update it later with `composer self-update`. | ## Every project, its own subdomain From ebfcaf789f49533e0e35578c91c09759b5ae1145 Mon Sep 17 00:00:00 2001 From: bidi47 Date: Tue, 29 Sep 2026 17:33:23 +0300 Subject: [PATCH 09/22] updated wsl2 page Signed-off-by: bidi47 --- src/Page/templates/page/wsl2.html.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Page/templates/page/wsl2.html.twig b/src/Page/templates/page/wsl2.html.twig index 80df6b89..171df7a6 100644 --- a/src/Page/templates/page/wsl2.html.twig +++ b/src/Page/templates/page/wsl2.html.twig @@ -247,7 +247,7 @@ Node.js - 22 by default via NodeSource; node18 … node24 aliases switch versions. + 24 by default via NodeSource; node18 … node24 aliases switch versions. Git & Composer From 5417fe2402281a4c9f11276e3c0adb2881b6f572 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Tue, 29 Sep 2026 18:06:49 +0300 Subject: [PATCH 10/22] Remove or fix dead-link --- .../basic-security-in-dotkernel-headless-platform.md | 2 +- .../best-practice/using-like-wildcards-with-zend-db.md | 2 +- .../autologin-using-cookie-remember-me-in-dotkernel.md | 2 +- .../sunsetting-pear-channel-for-zend-framework-1.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/md-articles/best-practice/basic-security-in-dotkernel-headless-platform.md b/public/md-articles/best-practice/basic-security-in-dotkernel-headless-platform.md index 871c516b..cb0f6db8 100644 --- a/public/md-articles/best-practice/basic-security-in-dotkernel-headless-platform.md +++ b/public/md-articles/best-practice/basic-security-in-dotkernel-headless-platform.md @@ -151,7 +151,7 @@ The GitHub Action [Laminas Continuous Integration](https://github.com/laminas/la - [Content Negotiation in Dotkernel REST API](https://www.dotkernel.com/dotkernel-api/content-negotiation-in-dotkernel-rest-api/) - [laminas-form Documentation](https://docs.laminas.dev/laminas-form/v3/intro/) - [CORS in Dotkernel API](https://docs.dotkernel.org/api-documentation/v6/tutorials/cors/) -- [CORS Policy Setup in Dotkernel](https://www.dotkernel.com/how-to/mezzio-cors-implementation-in-dotkernel/) +- [CORS Policy Setup in Dotkernel](https://www.dotkernel.com/how-to/cors-policy-setup-in-dotkernel-using-mezzio-cors/) - [Error Reporting Endpoint](https://docs.dotkernel.org/api-documentation/v6/core-features/error-reporting/) - [OpenAPI Documentation](https://docs.dotkernel.org/api-documentation/v6/openapi/introduction/) - [mezzio/mezzio-authentication-oauth2 Configuration](https://docs.mezzio.dev/mezzio-authentication-oauth2/v1/intro/#configuration) diff --git a/public/md-articles/best-practice/using-like-wildcards-with-zend-db.md b/public/md-articles/best-practice/using-like-wildcards-with-zend-db.md index 6785a335..11edcded 100644 --- a/public/md-articles/best-practice/using-like-wildcards-with-zend-db.md +++ b/public/md-articles/best-practice/using-like-wildcards-with-zend-db.md @@ -16,7 +16,7 @@ The LIKE condition allows pattern matching in the WHERE clause of SELECT, INSERT The `_` wildcard matches a single character, and `%` matches any string of any length (including zero). This article shows how to use LIKE and NOT LIKE with both wildcards in Zend_Db. -Continuing the Zend_Db article [series](http://www.dotkernel.com/dotkernel/sql-queries-using-zend-db-select/), let's discuss the LIKE condition. +Continuing the Zend_Db article series (http://www.dotkernel.com/dotkernel/sql-queries-using-zend-db-select/), let's discuss the LIKE condition. The **LIKE** condition allows you to use wildcards in the *WHERE* clause of an SQL statement. This allows pattern matching. It can be used in any valid SQL statement (*SELECT*, *INSERT*, *UPDATE* or *DELETE*). diff --git a/public/md-articles/dotkernel/autologin-using-cookie-remember-me-in-dotkernel.md b/public/md-articles/dotkernel/autologin-using-cookie-remember-me-in-dotkernel.md index 800652cd..3c9842ee 100644 --- a/public/md-articles/dotkernel/autologin-using-cookie-remember-me-in-dotkernel.md +++ b/public/md-articles/dotkernel/autologin-using-cookie-remember-me-in-dotkernel.md @@ -84,7 +84,7 @@ npm run prod ```shell vendor/bin/phinx create --configuration=config/migrations.php RememberUserSchema ``` -3. Modify the generated migration file as in [user_remember_schema](https://www.dotkernel.com/dotkernel/autologin-cookie-remember-me-feature/), then run it against the database: +3. Modify the generated migration file as in [user_remember_schema](https://github.com/dotkernel/frontend/blob/b736eeaa7092c1c50d57db9242c938d8d5f83bd0/data/database/migrations/20220621062142_remember_user_schema.php), then run it against the database: ```shell vendor/bin/phinx migrate --configuration=config/migrations.php ``` diff --git a/public/md-articles/zend-framework/sunsetting-pear-channel-for-zend-framework-1.md b/public/md-articles/zend-framework/sunsetting-pear-channel-for-zend-framework-1.md index d5cdd1e8..55a4543c 100644 --- a/public/md-articles/zend-framework/sunsetting-pear-channel-for-zend-framework-1.md +++ b/public/md-articles/zend-framework/sunsetting-pear-channel-for-zend-framework-1.md @@ -16,7 +16,7 @@ Dotkernel is sunsetting its unofficial PEAR channel for Zend Framework 1, which The main reasons are that upgrading PEAR to work with PHP 8 is too painful, and the channel currently runs on an LXC container with CentOS 7, which doesn't work on the latest Proxmox version, making the upgrade to AlmaLinux not worth the effort. The post closes by thanking PEAR for its historical contribution to the PHP ecosystem. -The unofficial PEAR channel for Zend Framework 1 was created in [2016](https://www.dotkernel.com/dotkernel/migration-of-zend-framework-1-pear-channel/), at the time when [PEAR](https://pear.php.net/) was still used a lot. +The unofficial PEAR channel for Zend Framework 1 was created in 2016, at the time when [PEAR](https://pear.php.net/) was still used a lot. Due to the fact that it is a pain to upgrade PEAR to work with PHP 8, we must sunset the channel. From ee9709925754290047d3632269d02f7d92c16ec1 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Wed, 30 Sep 2026 12:32:09 +0300 Subject: [PATCH 11/22] Update post slug to Autologin using Cookie post and and posibility to update/add post_slug using fixtures --- src/App/src/Fixture/PostLoader.php | 25 ++++++++++++++++------- src/App/src/Fixture/articles_cleaned.json | 1 + 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/App/src/Fixture/PostLoader.php b/src/App/src/Fixture/PostLoader.php index 96474a44..6c08dd98 100644 --- a/src/App/src/Fixture/PostLoader.php +++ b/src/App/src/Fixture/PostLoader.php @@ -55,17 +55,20 @@ public function load(ObjectManager $manager): void } /** @var Author $author */ - $author = $this->getReference('author_' . $authorSlug, Author::class); - $title = html_entity_decode($articleData['post_title'], ENT_QUOTES, 'UTF-8'); - $slug = $this->slugify($title); + $author = $this->getReference('author_' . $authorSlug, Author::class); + $title = html_entity_decode($articleData['post_title'], ENT_QUOTES, 'UTF-8'); + $autoSlug = $this->slugify($title); - if (isset($usedSlugs[$slug])) { - $usedSlugs[$slug]++; - $slug .= '-' . $usedSlugs[$slug]; + if (isset($usedSlugs[$autoSlug])) { + $usedSlugs[$autoSlug]++; + $autoSlug .= '-' . $usedSlugs[$autoSlug]; } else { - $usedSlugs[$slug] = 1; + $usedSlugs[$autoSlug] = 1; } + $customSlug = trim((string) ($articleData['post_slug'] ?? '')); + $slug = $customSlug !== '' ? $customSlug : $autoSlug; + $status = match ($articleData['post_status']) { 'published' => PostStatusEnum::Published, 'private' => PostStatusEnum::Private, @@ -85,6 +88,10 @@ public function load(ObjectManager $manager): void $openGraphImg = $articleData['opengraph_img'] ?? null; $article = $repository->findOneBy(['slug' => $slug]); + if ($article === null && $slug !== $autoSlug) { + // post was imported before post_slug was set: find it by the title-based slug + $article = $repository->findOneBy(['slug' => $autoSlug]); + } if ($article === null) { $article = new Post(); @@ -105,6 +112,10 @@ public function load(ObjectManager $manager): void } else { $changed = false; + if ($article->getSlug() !== $slug) { + $article->setSlug($slug); + $changed = true; + } if ($article->getTitle() !== $title) { $article->setTitle($title); $changed = true; diff --git a/src/App/src/Fixture/articles_cleaned.json b/src/App/src/Fixture/articles_cleaned.json index ff1fb05b..a98c1527 100644 --- a/src/App/src/Fixture/articles_cleaned.json +++ b/src/App/src/Fixture/articles_cleaned.json @@ -936,6 +936,7 @@ }, { "post_title": "Autologin using Cookie / Remember Me in Dotkernel", + "post_slug": "autologin-cookie-remember-me-feature", "post_date": "2022-07-18 09:12:59", "post_status": "published", "author": { From 21db1e42536f4359703972c0f603f0c1823d703b Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Wed, 30 Sep 2026 12:38:40 +0300 Subject: [PATCH 12/22] add test --- test/Unit/App/Fixture/PostLoaderTest.php | 372 +++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 test/Unit/App/Fixture/PostLoaderTest.php diff --git a/test/Unit/App/Fixture/PostLoaderTest.php b/test/Unit/App/Fixture/PostLoaderTest.php new file mode 100644 index 00000000..a6800582 --- /dev/null +++ b/test/Unit/App/Fixture/PostLoaderTest.php @@ -0,0 +1,372 @@ +jsonFile = sprintf( + '%s%slight-post-loader-%s%sarticles_cleaned.json', + sys_get_temp_dir(), + DIRECTORY_SEPARATOR, + bin2hex(random_bytes(8)), + DIRECTORY_SEPARATOR + ); + + mkdir(dirname($this->jsonFile), 0775, true); + + $this->author = new Author(); + $this->category = new Category(); + } + + protected function tearDown(): void + { + if (is_file($this->jsonFile)) { + unlink($this->jsonFile); + } + + $directory = dirname($this->jsonFile); + if (is_dir($directory)) { + rmdir($directory); + } + + parent::tearDown(); + } + + /** + * @throws Exception + */ + public function testLoadCreatesPostWithCustomSlugWhenNothingMatchesInDb(): void + { + $this->writeArticles([ + $this->article('A post', ['post_slug' => ' custom-slug ']), + ]); + + $lookups = []; + $manager = $this->createEntityManager($this->createRepository([], $lookups)); + + $persisted = null; + $manager->expects($this->once())->method('persist') + ->willReturnCallback(function (Post $post) use (&$persisted): void { + $persisted = $post; + }); + $manager->expects($this->once())->method('flush'); + + $this->expectOutputString("CREATE: A post\n"); + $this->runLoader($manager); + + $this->assertInstanceOf(Post::class, $persisted); + $this->assertSame('custom-slug', $persisted->getSlug()); + $this->assertSame(['custom-slug', 'a-post'], $lookups); + } + + /** + * @throws Exception + */ + public function testLoadUpdatesExistingPostFoundByCustomSlug(): void + { + $this->writeArticles([ + $this->article('A post', ['post_slug' => 'custom-slug']), + ]); + + $existing = $this->existingPost('custom-slug', 'A post'); + + $lookups = []; + $manager = $this->createEntityManager($this->createRepository(['custom-slug' => $existing], $lookups)); + $manager->expects($this->never())->method('persist'); + + $this->expectOutputString("UNCHANGED: A post\n"); + $this->runLoader($manager); + + $this->assertSame('custom-slug', $existing->getSlug()); + $this->assertSame(['custom-slug'], $lookups); + } + + /** + * @throws Exception + */ + public function testLoadRenamesExistingPostFoundByTitleSlugToCustomSlug(): void + { + $this->writeArticles([ + $this->article('A post', ['post_slug' => 'custom-slug']), + ]); + + // Imported before post_slug was added to the JSON. + $existing = $this->existingPost('a-post', 'A post'); + + $lookups = []; + $manager = $this->createEntityManager($this->createRepository(['a-post' => $existing], $lookups)); + $manager->expects($this->never())->method('persist'); + + $this->expectOutputString("UPDATE: A post\n"); + $this->runLoader($manager); + + $this->assertSame('custom-slug', $existing->getSlug()); + $this->assertSame(['custom-slug', 'a-post'], $lookups); + } + + /** + * @throws Exception + */ + public function testLoadFallsBackToTitleSlugWhenPostSlugIsMissingOrEmpty(): void + { + $this->writeArticles([ + $this->article('A post'), + $this->article('A post', ['post_slug' => '']), + ]); + + $lookups = []; + $manager = $this->createEntityManager($this->createRepository([], $lookups)); + $manager->expects($this->exactly(2))->method('persist'); + + $this->expectOutputString("CREATE: A post\nCREATE: A post\n"); + $this->runLoader($manager); + + $this->assertSame(['a-post', 'a-post-2'], $lookups); + } + + /** + * @throws Exception + */ + public function testLoadUpdatesEveryChangedFieldOnExistingPost(): void + { + $this->writeArticles([ + $this->article('A post', [ + 'post_status' => 'archived', + 'post_date' => '0000-00-00 00:00:00', + 'excerpt' => 'New excerpt', + 'tl_dr' => 'New TL;DR', + 'isObsolete' => true, + 'isTwig' => true, + 'opengraph_img' => 'og.png', + ]), + ]); + + $existing = $this->existingPost('a-post', 'Old title'); + $existing->setCategory(new Category()); + $existing->setAuthor(new Author()); + + $lookups = []; + $manager = $this->createEntityManager($this->createRepository(['a-post' => $existing], $lookups)); + $manager->expects($this->never())->method('persist'); + + $this->expectOutputString("UPDATE: A post\n"); + $this->runLoader($manager); + + $this->assertSame('A post', $existing->getTitle()); + $this->assertSame(PostStatusEnum::Archived, $existing->getStatus()); + $this->assertNotSame('2022-07-18 09:12:59', $existing->getPostDate()->format('Y-m-d H:i:s')); + $this->assertSame($this->category, $existing->getCategory()); + $this->assertSame($this->author, $existing->getAuthor()); + $this->assertSame('New excerpt', $existing->getExcerpt()); + $this->assertSame('New TL;DR', $existing->getTlDr()); + $this->assertTrue($existing->isObsolete()); + $this->assertTrue($existing->isTwig()); + $this->assertSame('og.png', $existing->getOpenGraphImage()); + } + + /** + * @throws Exception + */ + public function testLoadMapsPostStatuses(): void + { + $this->writeArticles([ + $this->article('Private post', ['post_status' => 'private']), + $this->article('Draft post', ['post_status' => 'unknown']), + ]); + + $lookups = []; + $manager = $this->createEntityManager($this->createRepository([], $lookups)); + + $statuses = []; + $manager->expects($this->exactly(2))->method('persist') + ->willReturnCallback(function (Post $post) use (&$statuses): void { + $statuses[] = $post->getStatus(); + }); + + $this->expectOutputString("CREATE: Private post\nCREATE: Draft post\n"); + $this->runLoader($manager); + + $this->assertSame([PostStatusEnum::Private, PostStatusEnum::Draft], $statuses); + } + + /** + * @throws Exception + */ + public function testLoadSkipsArticleWithUnknownAuthor(): void + { + $this->writeArticles([ + $this->article('A post', ['author' => ['display_name' => 'nobody']]), + ]); + + $lookups = []; + $manager = $this->createEntityManager($this->createRepository([], $lookups)); + $manager->expects($this->never())->method('persist'); + + $this->expectOutputString("SKIP (no author): A post\n"); + $this->runLoader($manager); + + $this->assertSame([], $lookups); + } + + /** + * @throws Exception + */ + public function testLoadThrowsWhenJsonFileCannotBeRead(): void + { + $manager = $this->createStub(EntityManagerInterface::class); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to read file'); + + @$this->runLoader($manager); + } + + public function testDependenciesAndOrder(): void + { + $loader = new PostLoader($this->jsonFile); + + $this->assertSame([AuthorLoader::class, CategoryLoader::class], $loader->getDependencies()); + $this->assertSame(3, $loader->getOrder()); + } + + /** + * @param array $extra + * @return array + */ + private function article(string $title, array $extra = []): array + { + return $extra + [ + 'post_title' => $title, + 'post_date' => '2022-07-18 09:12:59', + 'post_status' => 'published', + 'author' => ['display_name' => 'arhimede'], + 'excerpt' => 'Excerpt', + 'tl_dr' => 'TL;DR', + 'tags' => [], + ]; + } + + private function existingPost(string $slug, string $title): Post + { + $post = new Post(); + $post->setSlug($slug); + $post->setTitle($title); + $post->setPostDate(new DateTimeImmutable('2022-07-18 09:12:59')); + $post->setStatus(PostStatusEnum::Published); + $post->setCategory($this->category); + $post->setAuthor($this->author); + $post->setExcerpt('Excerpt'); + $post->setTldr('TL;DR'); + $post->setObsolete(false); + $post->setTwig(false); + $post->setOpenGraphImage(null); + + return $post; + } + + /** + * @param list> $articles + */ + private function writeArticles(array $articles): void + { + file_put_contents($this->jsonFile, json_encode([ + ['slug' => 'category', 'articles' => $articles], + ])); + } + + /** + * @param array $postsBySlug + * @param list $lookups + * @return EntityRepository + * @throws Exception + */ + private function createRepository(array $postsBySlug, array &$lookups): EntityRepository + { + $repository = $this->createStub(EntityRepository::class); + $repository->method('findOneBy')->willReturnCallback( + function (array $criteria) use ($postsBySlug, &$lookups): ?Post { + $lookups[] = $criteria['slug']; + return $postsBySlug[$criteria['slug']] ?? null; + } + ); + + return $repository; + } + + private function runLoader(EntityManagerInterface $manager): void + { + $referenceRepository = new ReferenceRepository($manager); + $referenceRepository->addReference('category_category', $this->category); + $referenceRepository->addReference('author_arhimede', $this->author); + + $loader = new PostLoader($this->jsonFile); + $loader->setReferenceRepository($referenceRepository); + $loader->load($manager); + } + + /** + * @param EntityRepository $repository + * @throws Exception + */ + private function createEntityManager(EntityRepository $repository): EntityManagerInterface&MockObject + { + $manager = $this->createMock(EntityManagerInterface::class); + $manager->method('getRepository')->willReturn($repository); + $manager->method('contains')->willReturn(true); + + $unitOfWork = $this->createStub(UnitOfWork::class); + $unitOfWork->method('isInIdentityMap')->willReturn(false); + $manager->method('getUnitOfWork')->willReturn($unitOfWork); + + $manager->method('getClassMetadata')->willReturnCallback(function (string $class) { + $metadata = $this->createStub(ClassMetadata::class); + $metadata->method('getName')->willReturn($class); + return $metadata; + }); + + return $manager; + } +} From dc1c07c15df0d7e1cf9223fa8b0e9513d2b6625c Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Wed, 30 Sep 2026 12:40:15 +0300 Subject: [PATCH 13/22] update test --- test/Unit/App/Fixture/PostLoaderTest.php | 316 ++++++----------------- 1 file changed, 73 insertions(+), 243 deletions(-) diff --git a/test/Unit/App/Fixture/PostLoaderTest.php b/test/Unit/App/Fixture/PostLoaderTest.php index a6800582..f5bf88dc 100644 --- a/test/Unit/App/Fixture/PostLoaderTest.php +++ b/test/Unit/App/Fixture/PostLoaderTest.php @@ -16,307 +16,113 @@ use Light\Blog\Entity\Author; use Light\Blog\Entity\Category; use Light\Blog\Entity\Post; -use Light\Blog\Enum\PostStatusEnum; use LightTest\Unit\UnitTest; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\MockObject\MockObject; -use RuntimeException; -use function bin2hex; +use function array_count_values; use function dirname; -use function file_put_contents; -use function is_dir; -use function is_file; -use function json_encode; -use function mkdir; -use function random_bytes; -use function rmdir; -use function sprintf; -use function sys_get_temp_dir; -use function unlink; - -use const DIRECTORY_SEPARATOR; - +use function file_get_contents; +use function json_decode; +use function preg_quote; +use function preg_replace; +use function strtolower; +use function trim; + +/** + * Runs PostLoader against the real articles_cleaned.json, using the + * "Autologin using Cookie / Remember Me in Dotkernel" article that sets post_slug. + */ class PostLoaderTest extends UnitTest { - private string $jsonFile; - private Author $author; - private Category $category; - - protected function setUp(): void - { - parent::setUp(); - - $this->jsonFile = sprintf( - '%s%slight-post-loader-%s%sarticles_cleaned.json', - sys_get_temp_dir(), - DIRECTORY_SEPARATOR, - bin2hex(random_bytes(8)), - DIRECTORY_SEPARATOR - ); - - mkdir(dirname($this->jsonFile), 0775, true); - - $this->author = new Author(); - $this->category = new Category(); - } - - protected function tearDown(): void - { - if (is_file($this->jsonFile)) { - unlink($this->jsonFile); - } - - $directory = dirname($this->jsonFile); - if (is_dir($directory)) { - rmdir($directory); - } - - parent::tearDown(); - } + private const string TITLE = 'Autologin using Cookie / Remember Me in Dotkernel'; + private const string CUSTOM_SLUG = 'autologin-cookie-remember-me-feature'; + private const string TITLE_SLUG = 'autologin-using-cookie-remember-me-in-dotkernel'; /** * @throws Exception */ public function testLoadCreatesPostWithCustomSlugWhenNothingMatchesInDb(): void { - $this->writeArticles([ - $this->article('A post', ['post_slug' => ' custom-slug ']), - ]); - $lookups = []; $manager = $this->createEntityManager($this->createRepository([], $lookups)); - $persisted = null; - $manager->expects($this->once())->method('persist') - ->willReturnCallback(function (Post $post) use (&$persisted): void { - $persisted = $post; + $persistedSlugs = []; + $manager->expects($this->atLeastOnce())->method('persist') + ->willReturnCallback(function (Post $post) use (&$persistedSlugs): void { + $persistedSlugs[$post->getTitle()] = $post->getSlug(); }); - $manager->expects($this->once())->method('flush'); - - $this->expectOutputString("CREATE: A post\n"); - $this->runLoader($manager); - - $this->assertInstanceOf(Post::class, $persisted); - $this->assertSame('custom-slug', $persisted->getSlug()); - $this->assertSame(['custom-slug', 'a-post'], $lookups); - } - - /** - * @throws Exception - */ - public function testLoadUpdatesExistingPostFoundByCustomSlug(): void - { - $this->writeArticles([ - $this->article('A post', ['post_slug' => 'custom-slug']), - ]); - - $existing = $this->existingPost('custom-slug', 'A post'); - - $lookups = []; - $manager = $this->createEntityManager($this->createRepository(['custom-slug' => $existing], $lookups)); - $manager->expects($this->never())->method('persist'); - - $this->expectOutputString("UNCHANGED: A post\n"); - $this->runLoader($manager); - - $this->assertSame('custom-slug', $existing->getSlug()); - $this->assertSame(['custom-slug'], $lookups); - } - - /** - * @throws Exception - */ - public function testLoadRenamesExistingPostFoundByTitleSlugToCustomSlug(): void - { - $this->writeArticles([ - $this->article('A post', ['post_slug' => 'custom-slug']), - ]); - - // Imported before post_slug was added to the JSON. - $existing = $this->existingPost('a-post', 'A post'); - - $lookups = []; - $manager = $this->createEntityManager($this->createRepository(['a-post' => $existing], $lookups)); - $manager->expects($this->never())->method('persist'); - - $this->expectOutputString("UPDATE: A post\n"); - $this->runLoader($manager); - - $this->assertSame('custom-slug', $existing->getSlug()); - $this->assertSame(['custom-slug', 'a-post'], $lookups); - } - - /** - * @throws Exception - */ - public function testLoadFallsBackToTitleSlugWhenPostSlugIsMissingOrEmpty(): void - { - $this->writeArticles([ - $this->article('A post'), - $this->article('A post', ['post_slug' => '']), - ]); - - $lookups = []; - $manager = $this->createEntityManager($this->createRepository([], $lookups)); - $manager->expects($this->exactly(2))->method('persist'); - $this->expectOutputString("CREATE: A post\nCREATE: A post\n"); + $this->expectOutputRegex('/CREATE: ' . preg_quote(self::TITLE, '/') . '\n/'); $this->runLoader($manager); - $this->assertSame(['a-post', 'a-post-2'], $lookups); - } - - /** - * @throws Exception - */ - public function testLoadUpdatesEveryChangedFieldOnExistingPost(): void - { - $this->writeArticles([ - $this->article('A post', [ - 'post_status' => 'archived', - 'post_date' => '0000-00-00 00:00:00', - 'excerpt' => 'New excerpt', - 'tl_dr' => 'New TL;DR', - 'isObsolete' => true, - 'isTwig' => true, - 'opengraph_img' => 'og.png', - ]), - ]); - - $existing = $this->existingPost('a-post', 'Old title'); - $existing->setCategory(new Category()); - $existing->setAuthor(new Author()); - - $lookups = []; - $manager = $this->createEntityManager($this->createRepository(['a-post' => $existing], $lookups)); - $manager->expects($this->never())->method('persist'); - - $this->expectOutputString("UPDATE: A post\n"); - $this->runLoader($manager); + $this->assertSame(self::CUSTOM_SLUG, $persistedSlugs[self::TITLE]); + $this->assertNotContains(self::TITLE_SLUG, $persistedSlugs); - $this->assertSame('A post', $existing->getTitle()); - $this->assertSame(PostStatusEnum::Archived, $existing->getStatus()); - $this->assertNotSame('2022-07-18 09:12:59', $existing->getPostDate()->format('Y-m-d H:i:s')); - $this->assertSame($this->category, $existing->getCategory()); - $this->assertSame($this->author, $existing->getAuthor()); - $this->assertSame('New excerpt', $existing->getExcerpt()); - $this->assertSame('New TL;DR', $existing->getTlDr()); - $this->assertTrue($existing->isObsolete()); - $this->assertTrue($existing->isTwig()); - $this->assertSame('og.png', $existing->getOpenGraphImage()); + // Not found by post_slug, so the title-based slug is tried before creating. + $counts = array_count_values($lookups); + $this->assertSame(1, $counts[self::CUSTOM_SLUG]); + $this->assertSame(1, $counts[self::TITLE_SLUG]); } /** * @throws Exception */ - public function testLoadMapsPostStatuses(): void + public function testLoadUpdatesPostFoundByCustomSlug(): void { - $this->writeArticles([ - $this->article('Private post', ['post_status' => 'private']), - $this->article('Draft post', ['post_status' => 'unknown']), - ]); + $existing = $this->existingPost(self::CUSTOM_SLUG, 'Old title'); $lookups = []; - $manager = $this->createEntityManager($this->createRepository([], $lookups)); - - $statuses = []; - $manager->expects($this->exactly(2))->method('persist') - ->willReturnCallback(function (Post $post) use (&$statuses): void { - $statuses[] = $post->getStatus(); - }); + $manager = $this->createEntityManager($this->createRepository([self::CUSTOM_SLUG => $existing], $lookups)); + $manager->expects($this->atLeastOnce())->method('flush'); - $this->expectOutputString("CREATE: Private post\nCREATE: Draft post\n"); + $this->expectOutputRegex('/UPDATE: ' . preg_quote(self::TITLE, '/') . '\n/'); $this->runLoader($manager); - $this->assertSame([PostStatusEnum::Private, PostStatusEnum::Draft], $statuses); + $this->assertSame(self::CUSTOM_SLUG, $existing->getSlug()); + $this->assertSame(self::TITLE, $existing->getTitle()); + $this->assertNotContains(self::TITLE_SLUG, $lookups); } /** * @throws Exception */ - public function testLoadSkipsArticleWithUnknownAuthor(): void + public function testLoadRenamesPostImportedUnderTitleSlugToCustomSlug(): void { - $this->writeArticles([ - $this->article('A post', ['author' => ['display_name' => 'nobody']]), - ]); + // Imported before post_slug was added to the JSON. + $existing = $this->existingPost(self::TITLE_SLUG, self::TITLE); $lookups = []; - $manager = $this->createEntityManager($this->createRepository([], $lookups)); - $manager->expects($this->never())->method('persist'); + $manager = $this->createEntityManager($this->createRepository([self::TITLE_SLUG => $existing], $lookups)); + $manager->expects($this->atLeastOnce())->method('flush'); - $this->expectOutputString("SKIP (no author): A post\n"); + $this->expectOutputRegex('/UPDATE: ' . preg_quote(self::TITLE, '/') . '\n/'); $this->runLoader($manager); - $this->assertSame([], $lookups); - } - - /** - * @throws Exception - */ - public function testLoadThrowsWhenJsonFileCannotBeRead(): void - { - $manager = $this->createStub(EntityManagerInterface::class); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to read file'); - - @$this->runLoader($manager); + $this->assertSame(self::CUSTOM_SLUG, $existing->getSlug()); } public function testDependenciesAndOrder(): void { - $loader = new PostLoader($this->jsonFile); + $loader = new PostLoader(); $this->assertSame([AuthorLoader::class, CategoryLoader::class], $loader->getDependencies()); $this->assertSame(3, $loader->getOrder()); } - /** - * @param array $extra - * @return array - */ - private function article(string $title, array $extra = []): array - { - return $extra + [ - 'post_title' => $title, - 'post_date' => '2022-07-18 09:12:59', - 'post_status' => 'published', - 'author' => ['display_name' => 'arhimede'], - 'excerpt' => 'Excerpt', - 'tl_dr' => 'TL;DR', - 'tags' => [], - ]; - } - private function existingPost(string $slug, string $title): Post { $post = new Post(); $post->setSlug($slug); $post->setTitle($title); - $post->setPostDate(new DateTimeImmutable('2022-07-18 09:12:59')); - $post->setStatus(PostStatusEnum::Published); - $post->setCategory($this->category); - $post->setAuthor($this->author); - $post->setExcerpt('Excerpt'); - $post->setTldr('TL;DR'); - $post->setObsolete(false); - $post->setTwig(false); - $post->setOpenGraphImage(null); + $post->setPostDate(new DateTimeImmutable()); + $post->setCategory(new Category()); + $post->setAuthor(new Author()); + $post->setExcerpt(''); return $post; } - /** - * @param list> $articles - */ - private function writeArticles(array $articles): void - { - file_put_contents($this->jsonFile, json_encode([ - ['slug' => 'category', 'articles' => $articles], - ])); - } - /** * @param array $postsBySlug * @param list $lookups @@ -336,17 +142,41 @@ function (array $criteria) use ($postsBySlug, &$lookups): ?Post { return $repository; } + /** + * Registers the category and author references the real articles_cleaned.json needs. + */ private function runLoader(EntityManagerInterface $manager): void { + $contents = file_get_contents(dirname(__DIR__, 4) . '/src/App/src/Fixture/articles_cleaned.json'); + $this->assertIsString($contents); + + /** @var list}> $categories */ + $categories = json_decode($contents, true); + $referenceRepository = new ReferenceRepository($manager); - $referenceRepository->addReference('category_category', $this->category); - $referenceRepository->addReference('author_arhimede', $this->author); + foreach ($categories as $category) { + $referenceRepository->setReference('category_' . $category['slug'], new Category()); + + foreach ($category['articles'] as $article) { + $authorName = $article['author']['display_name'] ?? null; + if ($authorName !== null) { + $referenceRepository->setReference('author_' . $this->slugify($authorName), new Author()); + } + } + } - $loader = new PostLoader($this->jsonFile); + $loader = new PostLoader(); $loader->setReferenceRepository($referenceRepository); $loader->load($manager); } + private function slugify(string $text): string + { + $text = strtolower(trim($text)); + $text = preg_replace('/[^a-z0-9]+/', '-', $text) ?? ''; + return trim($text, '-'); + } + /** * @param EntityRepository $repository * @throws Exception From ad7b091328a879c6a222ad4b323c49dde1eb7c22 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Wed, 30 Sep 2026 13:06:25 +0300 Subject: [PATCH 14/22] fix dead link --- ...n-dotkernel.md => autologin-cookie-remember-me-feature.md} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename public/md-articles/dotkernel/{autologin-using-cookie-remember-me-in-dotkernel.md => autologin-cookie-remember-me-feature.md} (95%) diff --git a/public/md-articles/dotkernel/autologin-using-cookie-remember-me-in-dotkernel.md b/public/md-articles/dotkernel/autologin-cookie-remember-me-feature.md similarity index 95% rename from public/md-articles/dotkernel/autologin-using-cookie-remember-me-in-dotkernel.md rename to public/md-articles/dotkernel/autologin-cookie-remember-me-feature.md index 3c9842ee..e20d984e 100644 --- a/public/md-articles/dotkernel/autologin-using-cookie-remember-me-in-dotkernel.md +++ b/public/md-articles/dotkernel/autologin-cookie-remember-me-feature.md @@ -3,7 +3,7 @@ title: "Autologin using Cookie / Remember Me in Dotkernel" description: "A step-by-step guide to implementing a Remember Me / autologin feature in Dotkernel Frontend." author: "SergiuB" date_published: "2022-07-18" -canonical_url: "https://www.dotkernel.com/dotkernel/autologin-using-cookie-remember-me-in-dotkernel/" +canonical_url: "https://www.dotkernel.com/dotkernel/autologin-cookie-remember-me-feature/" category: "Dotkernel" language: "en" --- @@ -84,7 +84,7 @@ npm run prod ```shell vendor/bin/phinx create --configuration=config/migrations.php RememberUserSchema ``` -3. Modify the generated migration file as in [user_remember_schema](https://github.com/dotkernel/frontend/blob/b736eeaa7092c1c50d57db9242c938d8d5f83bd0/data/database/migrations/20220621062142_remember_user_schema.php), then run it against the database: +3. Modify the generated migration file as in [user_remember_schema](https://www.dotkernel.com/dotkernel/autologin-cookie-remember-me-feature/), then run it against the database: ```shell vendor/bin/phinx migrate --configuration=config/migrations.php ``` From 1ba888f26c8cdbc8ec37f0d6c6ec5a1e318016cc Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Wed, 30 Sep 2026 16:54:07 +0300 Subject: [PATCH 15/22] fix dead link --- .../insert-update-delete-statements-with-zend-db.md | 2 +- public/md-articles/best-practice/subqueries-with-zend-db.md | 2 +- .../best-practice/using-like-wildcards-with-zend-db.md | 2 +- .../what-are-returning-the-fetch-functions-from-zend-db.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md b/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md index 79e45302..41071e0c 100644 --- a/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md +++ b/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md @@ -15,7 +15,7 @@ language: "en" DML (Data Manipulation Language) statements change data values in database tables. This article, continuing the Zend_Db series, shows how the three primary DML statements - INSERT, UPDATE, and DELETE - are written in raw SQL and translated into Zend_Db method calls. -Continuing the Zend_DB article [series](http://www.dotkernel.com/dotkernel/sql-queries-using-zend-db-select/), we are stopping now at DML statements. DML (Data Manipulation Language) statements are statements that change data values in database tables. There are 3 primary DML statements: +Continuing the Zend_DB article [series](http://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/), we are stopping now at DML statements. DML (Data Manipulation Language) statements are statements that change data values in database tables. There are 3 primary DML statements: - INSERT - Inserting new rows into database tables. - UPDATE - Updating existing rows in database tables. diff --git a/public/md-articles/best-practice/subqueries-with-zend-db.md b/public/md-articles/best-practice/subqueries-with-zend-db.md index f3a0f7ae..e84918a3 100644 --- a/public/md-articles/best-practice/subqueries-with-zend-db.md +++ b/public/md-articles/best-practice/subqueries-with-zend-db.md @@ -14,7 +14,7 @@ language: "en" Continuing the Zend_Db series, this article shows a more complex query - combining COUNT(), LEFT JOIN, and GROUP BY across 3 tables, with a count taken from 2 different tables - and how to build it, including a nested subquery, using Zend_Db. -Continuing the Zend_DB article [series](http://www.dotkernel.com/dotkernel/sql-queries-using-zend-db-select/), we are stopping now at subqueries. +Continuing the Zend_DB article [series](http://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/), we are stopping now at subqueries. As you note, the below is a complicate query, with *COUNT()*, *LEFT JOIN()*, *GROUP BY* - select from 3 tables, and make a count from 2 different tables: diff --git a/public/md-articles/best-practice/using-like-wildcards-with-zend-db.md b/public/md-articles/best-practice/using-like-wildcards-with-zend-db.md index 11edcded..3d22d36f 100644 --- a/public/md-articles/best-practice/using-like-wildcards-with-zend-db.md +++ b/public/md-articles/best-practice/using-like-wildcards-with-zend-db.md @@ -16,7 +16,7 @@ The LIKE condition allows pattern matching in the WHERE clause of SELECT, INSERT The `_` wildcard matches a single character, and `%` matches any string of any length (including zero). This article shows how to use LIKE and NOT LIKE with both wildcards in Zend_Db. -Continuing the Zend_Db article series (http://www.dotkernel.com/dotkernel/sql-queries-using-zend-db-select/), let's discuss the LIKE condition. +Continuing the Zend_Db article [series](https://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/), let's discuss the LIKE condition. The **LIKE** condition allows you to use wildcards in the *WHERE* clause of an SQL statement. This allows pattern matching. It can be used in any valid SQL statement (*SELECT*, *INSERT*, *UPDATE* or *DELETE*). diff --git a/public/md-articles/best-practice/what-are-returning-the-fetch-functions-from-zend-db.md b/public/md-articles/best-practice/what-are-returning-the-fetch-functions-from-zend-db.md index 5fcb03d3..4dcd978d 100644 --- a/public/md-articles/best-practice/what-are-returning-the-fetch-functions-from-zend-db.md +++ b/public/md-articles/best-practice/what-are-returning-the-fetch-functions-from-zend-db.md @@ -15,7 +15,7 @@ language: "en" Continuing the Zend_Db article series, this article walks through the FETCH methods available on Zend_Db_Adapter_Abstract: fetchAll, fetchAssoc, fetchCol, fetchOne, fetchPairs, and fetchRow. Each method is shown next to the equivalent old-style code built on query(), next_record(), and f(), so the two approaches can be compared side by side. -Continuing the Zend_DB article [series](http://www.dotkernel.com/dotkernel/sql-queries-using-zend-db-select/), we are stopping now at *FETCH* methods that are in [Zend_Db_Adapter_Abstract](https://docs.laminas.dev/laminas-db/adapter/): +Continuing the Zend_DB article [series](http://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/), we are stopping now at *FETCH* methods that are in [Zend_Db_Adapter_Abstract](https://docs.laminas.dev/laminas-db/adapter/): ```php array fetchAll (string|Zend_Db_Select $sql, [mixed $bind = array()]) From 2387bac5f09048a27f05dd2d52de87264c9d1467 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Wed, 30 Sep 2026 17:01:11 +0300 Subject: [PATCH 16/22] fix dead link --- .../insert-update-delete-statements-with-zend-db.md | 2 +- public/md-articles/best-practice/subqueries-with-zend-db.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md b/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md index 41071e0c..0ea0a7fa 100644 --- a/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md +++ b/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md @@ -94,4 +94,4 @@ A: Call $db->delete('user', 'id = '.$id), which is equivalent to the SQL stateme ## Resources -- [Zend_Db series](http://www.dotkernel.com/dotkernel/sql-select-zend-db/) +- [Zend_Db series](https://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/) diff --git a/public/md-articles/best-practice/subqueries-with-zend-db.md b/public/md-articles/best-practice/subqueries-with-zend-db.md index e84918a3..4c5658c6 100644 --- a/public/md-articles/best-practice/subqueries-with-zend-db.md +++ b/public/md-articles/best-practice/subqueries-with-zend-db.md @@ -69,4 +69,4 @@ A: Use ->joinLeft(array('b'=>'track_courses'), 'a.id = b.track_id', array('count ## Resources -- [Zend_Db series](http://www.dotkernel.com/dotkernel/sql-select-zend-db/) +- [Zend_Db series](https://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/) From 9fbba1bd0e13717462fa32ec4ee723cba5902894 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Wed, 30 Sep 2026 17:55:18 +0300 Subject: [PATCH 17/22] add https to links in .md files --- ...rt-update-delete-statements-with-zend-db.md | 2 +- .../sql-queries-using-zend-db-select.md | 6 +++--- .../best-practice/subqueries-with-zend-db.md | 2 +- ...turning-the-fetch-functions-from-zend-db.md | 2 +- ...mestamp-on-a-field-that-record-date-time.md | 2 +- ...ing-dotkernel-with-composer-dependencies.md | 10 +++++----- .../how-to/what-is-psr-7-and-how-to-use-it.md | 18 +++++++++--------- .../md-articles/javascript/intro-to-jquery.md | 6 +++--- ...l-php-apache-mariadb-composer-phpmyadmin.md | 2 +- ...point-arithmetic-why-is-int-0-7-0-1-10-7.md | 8 ++++---- ...vironment-development-staging-production.md | 2 +- ...tion-using-pdo-and-zend-framework-part-2.md | 2 +- ...is-the-intl-php-extension-problem-solved.md | 4 ++-- 13 files changed, 33 insertions(+), 33 deletions(-) diff --git a/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md b/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md index 0ea0a7fa..a880c189 100644 --- a/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md +++ b/public/md-articles/best-practice/insert-update-delete-statements-with-zend-db.md @@ -15,7 +15,7 @@ language: "en" DML (Data Manipulation Language) statements change data values in database tables. This article, continuing the Zend_Db series, shows how the three primary DML statements - INSERT, UPDATE, and DELETE - are written in raw SQL and translated into Zend_Db method calls. -Continuing the Zend_DB article [series](http://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/), we are stopping now at DML statements. DML (Data Manipulation Language) statements are statements that change data values in database tables. There are 3 primary DML statements: +Continuing the Zend_DB article [series](https://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/), we are stopping now at DML statements. DML (Data Manipulation Language) statements are statements that change data values in database tables. There are 3 primary DML statements: - INSERT - Inserting new rows into database tables. - UPDATE - Updating existing rows in database tables. diff --git a/public/md-articles/best-practice/sql-queries-using-zend-db-select.md b/public/md-articles/best-practice/sql-queries-using-zend-db-select.md index f68ff064..a3e75d72 100644 --- a/public/md-articles/best-practice/sql-queries-using-zend-db-select.md +++ b/public/md-articles/best-practice/sql-queries-using-zend-db-select.md @@ -126,6 +126,6 @@ A: Before fetching it, echo the query to visualize it: echo $select->__toString( ## Resources - [Zend_Db](https://docs.laminas.dev/laminas-db/adapter/) -- [What are returning the FETCH functions from Zend_Db](http://www.dotkernel.com/best-practice/what-are-returning-the-fetch-functions-from-zend-db/) -- [Subqueries with Zend_Db](http://www.dotkernel.com/best-practice/subqueries-with-zend-db/) -- [INSERT, UPDATE, DELETE statements with Zend_Db](http://www.dotkernel.com/best-practice/insert-update-delete-statements-with-zend-db/) +- [What are returning the FETCH functions from Zend_Db](https://www.dotkernel.com/best-practice/what-are-returning-the-fetch-functions-from-zend-db/) +- [Subqueries with Zend_Db](https://www.dotkernel.com/best-practice/subqueries-with-zend-db/) +- [INSERT, UPDATE, DELETE statements with Zend_Db](https://www.dotkernel.com/best-practice/insert-update-delete-statements-with-zend-db/) diff --git a/public/md-articles/best-practice/subqueries-with-zend-db.md b/public/md-articles/best-practice/subqueries-with-zend-db.md index 4c5658c6..fe873f1e 100644 --- a/public/md-articles/best-practice/subqueries-with-zend-db.md +++ b/public/md-articles/best-practice/subqueries-with-zend-db.md @@ -14,7 +14,7 @@ language: "en" Continuing the Zend_Db series, this article shows a more complex query - combining COUNT(), LEFT JOIN, and GROUP BY across 3 tables, with a count taken from 2 different tables - and how to build it, including a nested subquery, using Zend_Db. -Continuing the Zend_DB article [series](http://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/), we are stopping now at subqueries. +Continuing the Zend_DB article [series](https://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/), we are stopping now at subqueries. As you note, the below is a complicate query, with *COUNT()*, *LEFT JOIN()*, *GROUP BY* - select from 3 tables, and make a count from 2 different tables: diff --git a/public/md-articles/best-practice/what-are-returning-the-fetch-functions-from-zend-db.md b/public/md-articles/best-practice/what-are-returning-the-fetch-functions-from-zend-db.md index 4dcd978d..d8a2daf3 100644 --- a/public/md-articles/best-practice/what-are-returning-the-fetch-functions-from-zend-db.md +++ b/public/md-articles/best-practice/what-are-returning-the-fetch-functions-from-zend-db.md @@ -15,7 +15,7 @@ language: "en" Continuing the Zend_Db article series, this article walks through the FETCH methods available on Zend_Db_Adapter_Abstract: fetchAll, fetchAssoc, fetchCol, fetchOne, fetchPairs, and fetchRow. Each method is shown next to the equivalent old-style code built on query(), next_record(), and f(), so the two approaches can be compared side by side. -Continuing the Zend_DB article [series](http://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/), we are stopping now at *FETCH* methods that are in [Zend_Db_Adapter_Abstract](https://docs.laminas.dev/laminas-db/adapter/): +Continuing the Zend_DB article [series](https://www.dotkernel.com/best-practice/sql-queries-using-zend-db-select/), we are stopping now at *FETCH* methods that are in [Zend_Db_Adapter_Abstract](https://docs.laminas.dev/laminas-db/adapter/): ```php array fetchAll (string|Zend_Db_Select $sql, [mixed $bind = array()]) diff --git a/public/md-articles/best-practice/why-use-current-timestamp-on-a-field-that-record-date-time.md b/public/md-articles/best-practice/why-use-current-timestamp-on-a-field-that-record-date-time.md index 07110f6d..f8d4010d 100644 --- a/public/md-articles/best-practice/why-use-current-timestamp-on-a-field-that-record-date-time.md +++ b/public/md-articles/best-practice/why-use-current-timestamp-on-a-field-that-record-date-time.md @@ -16,7 +16,7 @@ On a TIMESTAMP field that records date and time when inserting a new record, it' This removes the need to set the value manually from PHP or with MySQL's NOW() function, and the ON UPDATE CURRENT_TIMESTAMP clause can additionally keep the field updated automatically on every row update. Only one TIMESTAMP field per table can be DEFAULT CURRENT_TIMESTAMP. -On a *TIMESTAMP field* that records date and time when *inserting* a new record, it is encouraged to use as a *DEFAULT* value, the **CURRENT_TIMESTAMP** constant. **Why?** Because when inserting a new row in the table for the date and time field there is no need to specifically add its value, either by creating it from PHP code with the [Date/ Time functions](http://www.php.net/manual/en/ref.datetime.php) or with MySQL function [NOW()](http://dev.mysql.com/doc/refman/5.0/en/date-and-time-functions.html#function_now) +On a *TIMESTAMP field* that records date and time when *inserting* a new record, it is encouraged to use as a *DEFAULT* value, the **CURRENT_TIMESTAMP** constant. **Why?** Because when inserting a new row in the table for the date and time field there is no need to specifically add its value, either by creating it from PHP code with the [Date/ Time functions](https://www.php.net/manual/en/ref.datetime.php) or with MySQL function [NOW()](https://dev.mysql.com/doc/refman/5.0/en/date-and-time-functions.html#function_now) ```sql ALTER TABLE `user` CHANGE `dateCreated` `dateCreated` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/public/md-articles/dotkernel/using-dotkernel-with-composer-dependencies.md b/public/md-articles/dotkernel/using-dotkernel-with-composer-dependencies.md index a5130f8e..9d29a9bb 100644 --- a/public/md-articles/dotkernel/using-dotkernel-with-composer-dependencies.md +++ b/public/md-articles/dotkernel/using-dotkernel-with-composer-dependencies.md @@ -36,7 +36,7 @@ These dependencies can be used anywhere after the `Dot_Kernel::initialize()` fun ## Using Non-Namespaced Dependencies (Zend Framework 1) -The class is loaded [PSR-0](http://www.php-fig.org/psr/psr-0/) style, meaning the class name looks like `VendorName_PackageName_ClassName`: +The class is loaded [PSR-0](https://www.php-fig.org/psr/psr-0/) style, meaning the class name looks like `VendorName_PackageName_ClassName`: ```php // Only the text to draw is required @@ -54,7 +54,7 @@ Zend_Barcode::factory( ## Using Namespaced Dependencies (Zend Framework 2) -The class is loaded [PSR-4](http://www.php-fig.org/psr/psr-4/) style, meaning the class name looks like `\VendorName\PackageName\ClassName`: +The class is loaded [PSR-4](https://www.php-fig.org/psr/psr-4/) style, meaning the class name looks like `\VendorName\PackageName\ClassName`: ```php use Zend\Barcode\Barcode; @@ -120,10 +120,10 @@ A: This article works for any Dotkernel 1.x version if your server is running PH ## Resources -- [Adding Composer support in your Dotkernel project](http://www.dotkernel.com/dotkernel/adding-composer-support-in-your-dotkernel-project) +- [Adding Composer support in your Dotkernel project](https://www.dotkernel.com/dotkernel/adding-composer-support-in-your-dotkernel-project) - [Zend Framework 1 manual](http://framework.zend.com/manual/1.12/en/manual.html) - [Zend Framework 2 manual](http://framework.zend.com/manual/current/en/index.html) -- [PSR-0](http://www.php-fig.org/psr/psr-0/) -- [PSR-4](http://www.php-fig.org/psr/psr-4/) +- [PSR-0](https://www.php-fig.org/psr/psr-0/) +- [PSR-4](https://www.php-fig.org/psr/psr-4/) - [Zend Framework 1 - Rendering a barcode](http://framework.zend.com/manual/1.12/en/zend.barcode.creation.html#zend.barcode.creation.renderering) - [Zend Framework 2 - Rendering a barcode](http://framework.zend.com/manual/current/en/modules/zend.barcode.creation.html#rendering-a-barcode) diff --git a/public/md-articles/how-to/what-is-psr-7-and-how-to-use-it.md b/public/md-articles/how-to/what-is-psr-7-and-how-to-use-it.md index 8128fccb..eb405965 100644 --- a/public/md-articles/how-to/what-is-psr-7-and-how-to-use-it.md +++ b/public/md-articles/how-to/what-is-psr-7-and-how-to-use-it.md @@ -18,7 +18,7 @@ This article lists the PSR-7 interfaces as a cheatsheet, then walks through prac PSR-7 is a set of common interfaces defined by PHP Framework Interop Group. These interfaces are representing HTTP messages, and URIs for use when communicating trough HTTP. Any web application using this set of interfaces is a PSR-7 application. -More about interfaces and interfaces examples can be found [here](http://php.net/manual/language.oop5.interfaces.php). +More about interfaces and interfaces examples can be found [here](https://php.net/manual/language.oop5.interfaces.php). ## Interfaces @@ -29,13 +29,13 @@ The interfaces defined in PSR-7 are the following: | Class Name | Description | |---|---| -| [`Psr\Http\Message\MessageInterface`](http://www.php-fig.org/psr/psr-7/#psrhttpmessagemessageinterface) | Representation of a HTTP message | -| [`Psr\Http\Message\RequestInterface`](http://www.php-fig.org/psr/psr-7/#psrhttpmessagerequestinterface) | Representation of an outgoing, client-side request. | -| [`Psr\Http\Message\ServerRequestInterface`](http://www.php-fig.org/psr/psr-7/#psrhttpmessageserverrequestinterface) | Representation of an incoming, server-side HTTP request. | -| [`Psr\Http\Message\ResponseInterface`](http://www.php-fig.org/psr/psr-7/#psrhttpmessageresponseinterface) | Representation of an outgoing, server-side response. | -| [`Psr\Http\Message\StreamInterface`](http://www.php-fig.org/psr/psr-7/#psrhttpmessagestreaminterface) | Describes a data stream | -| [`Psr\Http\Message\UriInterface`](http://www.php-fig.org/psr/psr-7/#psrhttpmessageuriinterface) | Value object representing a URI. | -| [`Psr\Http\Message\UploadedFileInterface`](http://www.php-fig.org/psr/psr-7/#psrhttpmessageuploadedfileinterface) | Value object representing a file uploaded through an HTTP request. | +| [`Psr\Http\Message\MessageInterface`](https://www.php-fig.org/psr/psr-7/#psrhttpmessagemessageinterface) | Representation of a HTTP message | +| [`Psr\Http\Message\RequestInterface`](https://www.php-fig.org/psr/psr-7/#psrhttpmessagerequestinterface) | Representation of an outgoing, client-side request. | +| [`Psr\Http\Message\ServerRequestInterface`](https://www.php-fig.org/psr/psr-7/#psrhttpmessageserverrequestinterface) | Representation of an incoming, server-side HTTP request. | +| [`Psr\Http\Message\ResponseInterface`](https://www.php-fig.org/psr/psr-7/#psrhttpmessageresponseinterface) | Representation of an outgoing, server-side response. | +| [`Psr\Http\Message\StreamInterface`](https://www.php-fig.org/psr/psr-7/#psrhttpmessagestreaminterface) | Describes a data stream | +| [`Psr\Http\Message\UriInterface`](https://www.php-fig.org/psr/psr-7/#psrhttpmessageuriinterface) | Value object representing a URI. | +| [`Psr\Http\Message\UploadedFileInterface`](https://www.php-fig.org/psr/psr-7/#psrhttpmessageuploadedfileinterface) | Value object representing a file uploaded through an HTTP request. | ## Working with PSR-7 @@ -202,7 +202,7 @@ $bodyText = $body->getContends(); More information can be found in the [PSR-7 article](https://www.php-fig.org/psr/psr-7/) in Dotkernel3 documentation portal. -Sources: [PSR-7: HTTP messages](http://www.php-fig.org/psr/psr-7/), [zend-diactoros](https://zendframework.github.io/zend-diactoros/) +Sources: [PSR-7: HTTP messages](https://www.php-fig.org/psr/psr-7/), [zend-diactoros](https://zendframework.github.io/zend-diactoros/) ## FAQ diff --git a/public/md-articles/javascript/intro-to-jquery.md b/public/md-articles/javascript/intro-to-jquery.md index da48e4bc..01fa7179 100644 --- a/public/md-articles/javascript/intro-to-jquery.md +++ b/public/md-articles/javascript/intro-to-jquery.md @@ -16,13 +16,13 @@ Starting with Dotkernel's 1.5 release, the framework switched from Dojo to jQuer It covers the jQuery (`$`) object and CSS-style selectors, chaining methods to manipulate matched elements, binding events like click, and making Ajax calls with `$.get()` and `$.getJSON()`. Starting with the 1.5 release, Dotkernel will make the switch from Dojo to jQuery. -On jQuery's website, there's an excellent "[Getting started with jQuery tutorial](http://docs.jquery.com/Tutorials:Getting_Started_with_jQuery)", as well as a extensive [documentation](http://api.jquery.com/) for the framework, so I'll just go over a few basic concepts and common examples. +On jQuery's website, there's an excellent "[Getting started with jQuery tutorial](https://docs.jquery.com/Tutorials:Getting_Started_with_jQuery)", as well as a extensive [documentation](https://api.jquery.com/) for the framework, so I'll just go over a few basic concepts and common examples. ## The jQuery Object jQuery is the global object that contains all of jQuery's functionality. You will probably want to use $ instead which is a synonim of the same object, only faster to type. -It contains a few useful methods such as [jQuery.map](http://api.jquery.com/jQuery.map/), but it's mostly used with a selector parameter to retrieve a set of matched elements. +It contains a few useful methods such as [jQuery.map](https://api.jquery.com/jQuery.map/), but it's mostly used with a selector parameter to retrieve a set of matched elements. ## Selectors @@ -70,7 +70,7 @@ $("#helpButton").click(function(){ ## Ajax -jQuery has [many ajax helper functions](http://api.jquery.com/category/ajax/), here is a simple example that will replace the contents of an element with data loaded from the server: +jQuery has [many ajax helper functions](https://api.jquery.com/category/ajax/), here is a simple example that will replace the contents of an element with data loaded from the server: ```javascript $.get("get-news.php", function(result){ diff --git a/public/md-articles/php-development/almalinux-9-in-wsl2-install-php-apache-mariadb-composer-phpmyadmin.md b/public/md-articles/php-development/almalinux-9-in-wsl2-install-php-apache-mariadb-composer-phpmyadmin.md index e29942cd..9e56037c 100644 --- a/public/md-articles/php-development/almalinux-9-in-wsl2-install-php-apache-mariadb-composer-phpmyadmin.md +++ b/public/md-articles/php-development/almalinux-9-in-wsl2-install-php-apache-mariadb-composer-phpmyadmin.md @@ -54,7 +54,7 @@ While still in the AlmaLinux 9 terminal, start executing the following commands. Install required packages: ```bash -sudo dnf install epel-release dnf-utils http://rpms.remirepo.net/enterprise/remi-release-9.rpm -y +sudo dnf install epel-release dnf-utils https://rpms.remirepo.net/enterprise/remi-release-9.rpm -y ``` Update installed packages: diff --git a/public/md-articles/php-development/floating-point-arithmetic-why-is-int-0-7-0-1-10-7.md b/public/md-articles/php-development/floating-point-arithmetic-why-is-int-0-7-0-1-10-7.md index f5da37cc..d31d912c 100644 --- a/public/md-articles/php-development/floating-point-arithmetic-why-is-int-0-7-0-1-10-7.md +++ b/public/md-articles/php-development/floating-point-arithmetic-why-is-int-0-7-0-1-10-7.md @@ -103,7 +103,7 @@ A: If precise values matter for your project, the article recommends using the B ## Resources -- [Floating point tutorial (kipirvine.com)](http://kipirvine.com/asm/workbook/floating_tut.htm) -- [IEEE 754 converter (h-schmidt.net)](http://www.h-schmidt.net/FloatConverter/IEEE754.html) -- [BCMath PHP Extension documentation](http://php.net/manual/en/book.bc.php) -- [What Every Computer Scientist Should Know About Floating-Point Arithmetic](http://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html) +- [Floating point tutorial (kipirvine.com)](https://kipirvine.com/asm/workbook/floating_tut.htm) +- [IEEE 754 converter (h-schmidt.net)](https://www.h-schmidt.net/FloatConverter/IEEE754.html) +- [BCMath PHP Extension documentation](https://php.net/manual/en/book.bc.php) +- [What Every Computer Scientist Should Know About Floating-Point Arithmetic](https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html) diff --git a/public/md-articles/php-development/php-environment-development-staging-production.md b/public/md-articles/php-development/php-environment-development-staging-production.md index 1100c3d2..0f2db7a9 100644 --- a/public/md-articles/php-development/php-environment-development-staging-production.md +++ b/public/md-articles/php-development/php-environment-development-staging-production.md @@ -55,4 +55,4 @@ A: The article recommends using Subversion source code to switch from one enviro ## Resources -- [Software development practice](http://dltj.org/article/software-development-practice/) +- [Software development practice](https://dltj.org/article/software-development-practice/) diff --git a/public/md-articles/php-development/protection-against-sql-injection-using-pdo-and-zend-framework-part-2.md b/public/md-articles/php-development/protection-against-sql-injection-using-pdo-and-zend-framework-part-2.md index a293d151..0a05271d 100644 --- a/public/md-articles/php-development/protection-against-sql-injection-using-pdo-and-zend-framework-part-2.md +++ b/public/md-articles/php-development/protection-against-sql-injection-using-pdo-and-zend-framework-part-2.md @@ -63,6 +63,6 @@ A short tip mentioned is to use type casting to avoid SQL Injection in a WHERE c ## Resources -- [Protection against SQL Injection using PDO and Zend Framework (part 1)](http://www.dotkernel.com/php-development/protection-against-sql-injection-using-pdo-and-zend-framework/) +- [Protection against SQL Injection using PDO and Zend Framework (part 1)](https://www.dotkernel.com/php-development/protection-against-sql-injection-using-pdo-and-zend-framework/) - [Secure Programming with the Zend Framework (Stefan Esser slides)](http://www.suspekt.org/downloads/DPC_Secure_Programming_With_The_Zend_Framework.pdf) - [PHP Type Casting](https://www.w3schools.com/php/php_casting.asp) diff --git a/public/md-articles/php-troubleshooting/where-is-the-intl-php-extension-problem-solved.md b/public/md-articles/php-troubleshooting/where-is-the-intl-php-extension-problem-solved.md index 00907b85..8fa160d6 100644 --- a/public/md-articles/php-troubleshooting/where-is-the-intl-php-extension-problem-solved.md +++ b/public/md-articles/php-troubleshooting/where-is-the-intl-php-extension-problem-solved.md @@ -36,7 +36,7 @@ Source: [PHP Documentation](https://www.php.net/manual/en/book.intl.php) > This extension may be installed using the bundled version as of PHP 5.3.0, or as a PECL extension as of PHP 5.2.0. In other words, there are two methods to install the intl extension. -Source: [PHP Documentation](http://php.net/manual/en/intl.installation.php) +Source: [PHP Documentation](https://php.net/manual/en/intl.installation.php) ## Cause @@ -75,7 +75,7 @@ For projects hosted on a shared hosting platform, you must ask your hosting prov - Check if the extension is enabled using `phpinfo()`. - If the file doesn't exist: - Check your PHP version by running the `php -v` command. - - Download the PHP version that corresponds to yours from the [PHP Downloads Page](http://windows.php.net/download/) (TS/NTS, x86/x64). + - Download the PHP version that corresponds to yours from the [PHP Downloads Page](https://windows.php.net/download/) (TS/NTS, x86/x64). To find thread safety for your PHP, run `php -i | findstr "Thread"`. - Search for the php_intl.dll file in the ext folder of that downloaded version and copy it into your php\ext folder. - Repeat the steps for the case in which the file exists. From 8800651b6386ffba0304b077073b9ad5c1279d59 Mon Sep 17 00:00:00 2001 From: OStefan2001 Date: Thu, 1 Oct 2026 18:35:27 +0300 Subject: [PATCH 18/22] Update http to https --- ...vers-in-the-same-app-for-the-same-action.md | 2 +- .../best-practice/aptana-set-svn-keywords.md | 4 ++-- .../golden-rules-of-professional-php-coding.md | 10 +++++----- .../htaccess-301-redirect-non-www-to-www.md | 8 ++++---- .../svn-export-in-a-virtual-host.md | 2 +- ...vn-keywords-setup-in-php-ide-zend-studio.md | 2 +- ...-browser-detection-in-dotkernel-projects.md | 6 +++--- ...aching-in-dotkernel-using-zend-framework.md | 6 +++--- ...amelcase-table-names-in-mysql-on-windows.md | 2 +- ...zend-certified-engineers-zce-in-our-team.md | 4 ++-- .../configuring-the-cache-in-dotkernel.md | 4 ++-- ...sable-wurfl-redirect-for-mobile-browsers.md | 4 ++-- .../dotkernel/dotkernel-1-2-0-release.md | 6 +++--- .../dotkernel/dotkernel-1-2-2-release.md | 4 ++-- .../dotkernel/dotkernel-1-3-0-release.md | 4 ++-- .../dotkernel/dotkernel-1-3-2-release.md | 2 +- .../dotkernel/dotkernel-1-5-0-released.md | 6 +++--- .../dotkernel/dotkernel-1-8-0-lts-released.md | 10 +++++----- ...kernel-1-8-1-upgrade-from-1-8-0-released.md | 6 +++--- .../dotkernel/dotkernel-coding-standard.md | 16 ++++++++-------- ...el-database-naming-conventions-for-mysql.md | 2 +- .../dotkernel/dotkernel-on-nginx.md | 10 +++++----- ...rnel-reserved-variable-names-for-caching.md | 8 ++++---- .../dotkernel-version-1-0-in-action.md | 6 +++--- .../geoip-ip-address-location-in-dotkernel.md | 8 ++++---- .../how-to-use-alerts-in-dotkernel.md | 2 +- ...rd-hashing-api-from-php-5-5-in-dotkernel.md | 16 ++++++++-------- ...-extension-in-zend-server-5-6-on-windows.md | 6 +++--- ...ip-extension-in-zend-server-6-on-windows.md | 8 ++++---- .../new-features-in-zend-framework-1-12.md | 10 +++++----- ...atter-and-templates-for-zend-studio-10-1.md | 8 ++++---- ...wurfl-cloud-php-library-to-dotkernel-1-6.md | 6 +++--- ...ing-dotkernel-with-composer-dependencies.md | 8 ++++---- ...rfl-cloud-integration-in-dotkernel-1-6-0.md | 2 +- ...-api-license-incompatible-with-dotkernel.md | 12 ++++++------ ...end-framework-integration-into-dotkernel.md | 18 +++++++++--------- ...uth-and-zend-acl-integrated-in-dotkernel.md | 6 +++--- .../zend-registry-usage-in-dotkernel.md | 6 +++--- ...-dotkernel-refactor-of-dot-session-class.md | 12 ++++++------ ...atter-file-for-dotkernel-coding-standard.md | 2 +- ...-free-php-html-css-javascript-editor-ide.md | 6 +++--- .../aptana-php-installation-in-aptana-2-x.md | 4 ++-- ...ter-unicode-support-in-mysql-5-5-utf8mb4.md | 6 +++--- .../end-of-support-for-php-5-2-x-branch.md | 4 ++-- .../how-to-upgrade-wamp-to-php-5-3-4.md | 4 ++-- ...eased-no-upgrade-possible-for-wampserver.md | 2 +- .../php-support-back-in-aptana-3-0.md | 2 +- ...tion-using-pdo-and-zend-framework-part-2.md | 2 +- ...a-to-connect-to-dotkernel-tracker-mantis.md | 12 ++++++------ .../using-php-7-express-in-zend-studio-13.md | 2 +- ...zend-certified-engineer-in-dotboost-team.md | 4 ++-- .../zend-server-5-5-quick-setup-on-windows.md | 2 +- ...-debug-bar-a-very-helpfull-zf-debug-tool.md | 2 +- ...work-1-12-4-released-with-security-fixes.md | 8 ++++---- .../zend-framework-1-7-0-released.md | 4 ++-- ...ar-accessible-repository-on-plesk-server.md | 2 +- ...security-fixes-in-zend-framework-1-12-12.md | 4 ++-- 57 files changed, 167 insertions(+), 167 deletions(-) diff --git a/public/md-articles/android/multiple-broadcast-receivers-in-the-same-app-for-the-same-action.md b/public/md-articles/android/multiple-broadcast-receivers-in-the-same-app-for-the-same-action.md index 692bc596..12511c6d 100644 --- a/public/md-articles/android/multiple-broadcast-receivers-in-the-same-app-for-the-same-action.md +++ b/public/md-articles/android/multiple-broadcast-receivers-in-the-same-app-for-the-same-action.md @@ -29,4 +29,4 @@ A: This can happen when you use 3rd party libraries that already define their ow ## Resources -- [Admob App Download Tracking documentation](http://developer.admob.com/wiki/Android_App_Download_Tracking) +- [Admob App Download Tracking documentation](https://developer.admob.com/wiki/Android_App_Download_Tracking) diff --git a/public/md-articles/best-practice/aptana-set-svn-keywords.md b/public/md-articles/best-practice/aptana-set-svn-keywords.md index 64317cd6..9059c682 100644 --- a/public/md-articles/best-practice/aptana-set-svn-keywords.md +++ b/public/md-articles/best-practice/aptana-set-svn-keywords.md @@ -10,7 +10,7 @@ language: "en" # Aptana - set SVN keywords -In Aptana it's very simple to set the [svn:keywords](http://svnbook.red-bean.com/en/1.4/svn.advanced.props.special.keywords.html) property for a file. +In Aptana it's very simple to set the [svn:keywords](https://svnbook.red-bean.com/en/1.4/svn.advanced.props.special.keywords.html) property for a file. For example if you want to set the svn keyword property ***Id***: @@ -44,4 +44,4 @@ A: After the SVN commit of the file, the $Id$ keyword is replaced with text cont ## Resources -- [svn:keywords property documentation](http://svnbook.red-bean.com/en/1.4/svn.advanced.props.special.keywords.html) +- [svn:keywords property documentation](https://svnbook.red-bean.com/en/1.4/svn.advanced.props.special.keywords.html) diff --git a/public/md-articles/best-practice/golden-rules-of-professional-php-coding.md b/public/md-articles/best-practice/golden-rules-of-professional-php-coding.md index a371ec2e..f7a64756 100644 --- a/public/md-articles/best-practice/golden-rules-of-professional-php-coding.md +++ b/public/md-articles/best-practice/golden-rules-of-professional-php-coding.md @@ -31,7 +31,7 @@ ini_set('display_errors', 1); 6. Use a version control system. **SVN is NOT dead.** -7. Use an **[IDE](http://en.wikipedia.org/wiki/Integrated_development_environment)**. [Aptana 2](http://www.aptana.com/products/studio2/download), [Aptana 3](http://www.aptana.com/products/studio3/download), Eclipse, even [Zend Studio](http://www.zend.com/en/products/studio/). +7. Use an **[IDE](https://en.wikipedia.org/wiki/Integrated_development_environment)**. [Aptana 2](https://www.aptana.com/products/studio2/download), [Aptana 3](https://www.aptana.com/products/studio3/download), Eclipse, even [Zend Studio](https://www.zend.com/en/products/studio/). 8. Know your **IDE**: code snippets, code assist, integration with Zend Framework, SVN integration, bug tracker integration, and so on @@ -55,7 +55,7 @@ A: It recommends using a version control system (noting that SVN is not dead) an ## Resources -- [Integrated development environment (Wikipedia)](http://en.wikipedia.org/wiki/Integrated_development_environment) -- [Aptana 2 download](http://www.aptana.com/products/studio2/download) -- [Aptana 3 download](http://www.aptana.com/products/studio3/download) -- [Zend Studio](http://www.zend.com/en/products/studio/) +- [Integrated development environment (Wikipedia)](https://en.wikipedia.org/wiki/Integrated_development_environment) +- [Aptana 2 download](https://www.aptana.com/products/studio2/download) +- [Aptana 3 download](https://www.aptana.com/products/studio3/download) +- [Zend Studio](https://www.zend.com/en/products/studio/) diff --git a/public/md-articles/best-practice/htaccess-301-redirect-non-www-to-www.md b/public/md-articles/best-practice/htaccess-301-redirect-non-www-to-www.md index aa164852..dab499e3 100644 --- a/public/md-articles/best-practice/htaccess-301-redirect-non-www-to-www.md +++ b/public/md-articles/best-practice/htaccess-301-redirect-non-www-to-www.md @@ -10,14 +10,14 @@ language: "en" # htaccess 301 redirect non-www to www -To always redirect users to the www site (for example: http://dotboost.com to http://www.dotboost.com), add the following lines to .htaccess, right after **RewriteEngine On**: +To always redirect users to the www site (for example: https://dotboost.com to https://www.dotboost.com), add the following lines to .htaccess, right after **RewriteEngine On**: ```shell RewriteCond %{HTTP_HOST} ^dotboost.com RewriteRule ^(.*)$ http://www.dotboost.com/$1 [L,R=301] ``` -If, on the other hand, you want to redirect http://www.dotboost.com to http://dotboost.com, add the following lines instead: +If, on the other hand, you want to redirect https://www.dotboost.com to https://dotboost.com, add the following lines instead: ```shell RewriteCond %{HTTP_HOST} ^www.dotboost.com @@ -29,7 +29,7 @@ Replace dotboost.com with your site's domain. ## FAQ **Q: How do I redirect a non-www domain to www using .htaccess?** -A: Add RewriteCond %{HTTP_HOST} ^dotboost.com and RewriteRule ^(.*)$ http://www.dotboost.com/$1 to your .htaccess file, right after RewriteEngine On, replacing dotboost.com with your own domain. +A: Add RewriteCond %{HTTP_HOST} ^dotboost.com and RewriteRule ^(.*)$ https://www.dotboost.com/$1 to your .htaccess file, right after RewriteEngine On, replacing dotboost.com with your own domain. **Q: How do I redirect a www domain to non-www instead?** -A: Add RewriteCond %{HTTP_HOST} ^www.dotboost.com and RewriteRule ^(.*)$ http://dotboost.com/$1 instead, again replacing dotboost.com with your own domain. +A: Add RewriteCond %{HTTP_HOST} ^www.dotboost.com and RewriteRule ^(.*)$ https://dotboost.com/$1 instead, again replacing dotboost.com with your own domain. diff --git a/public/md-articles/best-practice/svn-export-in-a-virtual-host.md b/public/md-articles/best-practice/svn-export-in-a-virtual-host.md index 0286abf1..f811658b 100644 --- a/public/md-articles/best-practice/svn-export-in-a-virtual-host.md +++ b/public/md-articles/best-practice/svn-export-in-a-virtual-host.md @@ -38,7 +38,7 @@ where: | Parameter | Meaning | |---|---| | `-r revisionNumber` | Optional. Exports a specific revision. By default, the latest revision is used. | -| `repositoryUrl` | The repository URL (e.g. `http://example.com/repos/project-name/trunk/`). Remember to add `/trunk/`, or change it appropriately for a branch or tag. | +| `repositoryUrl` | The repository URL (e.g. `https://example.com/repos/project-name/trunk/`). Remember to add `/trunk/`, or change it appropriately for a branch or tag. | | `targetDirectory` - `./` | The current directory. | | `targetDirectory` - `./project-name` | Exports to the `project-name` subdirectory. | | `targetDirectory` - `/var/www/vhosts/example.com/httpdocs` | Exports to an absolute path. | diff --git a/public/md-articles/best-practice/svn-keywords-setup-in-php-ide-zend-studio.md b/public/md-articles/best-practice/svn-keywords-setup-in-php-ide-zend-studio.md index aa8f1358..09667981 100644 --- a/public/md-articles/best-practice/svn-keywords-setup-in-php-ide-zend-studio.md +++ b/public/md-articles/best-practice/svn-keywords-setup-in-php-ide-zend-studio.md @@ -73,7 +73,7 @@ A: They provide better integration of SVN, your PHP IDE (Zend Studio), and a bug A: It tells SVN to ignore local settings files such as *.project, *.prefs, .project, cache, .settings, .buildpath, and *.ini, since you don't want to commit your local settings to the main repository. **Q: How do you set up basic bug tracker integration?** -A: Set the bugtracq:label property to "Tracker ID:" and bugtraq:message; if you have a public bug tracker such as Mantis, also set bugtraq:url to a URL pattern like http://www.dotkernel.net/view.php?id=%BUGID%. +A: Set the bugtracq:label property to "Tracker ID:" and bugtraq:message; if you have a public bug tracker such as Mantis, also set bugtraq:url to a URL pattern like https://www.dotkernel.net/view.php?id=%BUGID%. **Q: Should these properties be applied recursively?** A: No. For the properties above, apply them only to the project folder, not recursively. diff --git a/public/md-articles/dotkernel/adding-windows-10-os-and-browser-detection-in-dotkernel-projects.md b/public/md-articles/dotkernel/adding-windows-10-os-and-browser-detection-in-dotkernel-projects.md index dfab7532..b4c7aee7 100644 --- a/public/md-articles/dotkernel/adding-windows-10-os-and-browser-detection-in-dotkernel-projects.md +++ b/public/md-articles/dotkernel/adding-windows-10-os-and-browser-detection-in-dotkernel-projects.md @@ -20,7 +20,7 @@ This article is the upgrade guide for applying that icon patch. *The new Icons listed in User and Admin Logins* 1. Make sure your project is running version **1.5.0** or **newer**. -2. Download the [patch](http://www.dotkernel.com/download/?did=46). +2. Download the [patch](https://www.dotkernel.com/download/?did=46). 3. Extract the archive into a folder, e.g. `icons_patch`. 4. Create a backup of your project before continuing (recommended). 5. Copy all the files in the `icons_patch` folder into your Dotkernel project. @@ -55,5 +55,5 @@ A: You'll be prompted to replace 2 files (browser.xml and os.xml) and should agr ## Resources -- [Icon patch download](http://www.dotkernel.com/download/?did=46) -- [Dotkernel Reserved Variable Names for Caching](http://www.dotkernel.com/dotkernel/dotkernel-reserved-variable-names-for-caching) +- [Icon patch download](https://www.dotkernel.com/download/?did=46) +- [Dotkernel Reserved Variable Names for Caching](https://www.dotkernel.com/dotkernel/dotkernel-reserved-variable-names-for-caching) diff --git a/public/md-articles/dotkernel/caching-in-dotkernel-using-zend-framework.md b/public/md-articles/dotkernel/caching-in-dotkernel-using-zend-framework.md index ab301054..d8d38ad9 100644 --- a/public/md-articles/dotkernel/caching-in-dotkernel-using-zend-framework.md +++ b/public/md-articles/dotkernel/caching-in-dotkernel-using-zend-framework.md @@ -18,7 +18,7 @@ Dotkernel 1.8 implements a cache layer for router, acl_role, menu, options (incl The configuration is set from `/configs/application.ini`: whether caching is enabled, how long the cache stays valid, the cache namespace, and the storage provider (File or APC). The article recommends disabling the cache in development mode. -See [Configuring the Cache in Dotkernel](http://www.dotkernel.com/dotkernel/configuring-the-cache-in-dotkernel/) for more details. +See [Configuring the Cache in Dotkernel](https://www.dotkernel.com/dotkernel/configuring-the-cache-in-dotkernel/) for more details. ## 2. Using the cache @@ -74,5 +74,5 @@ A: Yes, the article shows an example of saving and loading a stdClass object usi ## Resources -- [Dotkernel Reserved Variable Names for Caching](http://www.dotkernel.com/dotkernel/dotkernel-reserved-variable-names-for-caching) -- [Configuring the Cache in Dotkernel](http://www.dotkernel.com/dotkernel/configuring-the-cache-in-dotkernel/) +- [Dotkernel Reserved Variable Names for Caching](https://www.dotkernel.com/dotkernel/dotkernel-reserved-variable-names-for-caching) +- [Configuring the Cache in Dotkernel](https://www.dotkernel.com/dotkernel/configuring-the-cache-in-dotkernel/) diff --git a/public/md-articles/dotkernel/camelcase-table-names-in-mysql-on-windows.md b/public/md-articles/dotkernel/camelcase-table-names-in-mysql-on-windows.md index 5c545ac5..8fc9b9eb 100644 --- a/public/md-articles/dotkernel/camelcase-table-names-in-mysql-on-windows.md +++ b/public/md-articles/dotkernel/camelcase-table-names-in-mysql-on-windows.md @@ -30,4 +30,4 @@ A: Add the line `lower_case_table_names=2` to your my.cnf file and restart MySQL ## Resources -- [MySQL lower_case_table_names documentation](http://dev.mysql.com/doc/refman/4.1/en/server-system-variables.html#sysvar_lower_case_table_names) +- [MySQL lower_case_table_names documentation](https://dev.mysql.com/doc/refman/4.1/en/server-system-variables.html#sysvar_lower_case_table_names) diff --git a/public/md-articles/dotkernel/commitment-to-php-new-zend-certified-engineers-zce-in-our-team.md b/public/md-articles/dotkernel/commitment-to-php-new-zend-certified-engineers-zce-in-our-team.md index 2b43175d..ec387302 100644 --- a/public/md-articles/dotkernel/commitment-to-php-new-zend-certified-engineers-zce-in-our-team.md +++ b/public/md-articles/dotkernel/commitment-to-php-new-zend-certified-engineers-zce-in-our-team.md @@ -14,7 +14,7 @@ Another 2 of our team members passed the ZCE exam. Now we are 5. That means we are really taking PHP into serious consideration, and at the very least we have good technical skills. -See the [Zend Yellow Pages](http://www.zend.com/store/education/certification/yellow-pages.php#list-cid=0&sid=&certtype_zf=1&certtype_php=1&certtype=&firstname=&lastname=&company=Dotboost%20Technologies&ClientCandidateID=). +See the [Zend Yellow Pages](https://www.zend.com/store/education/certification/yellow-pages.php#list-cid=0&sid=&certtype_zf=1&certtype_php=1&certtype=&firstname=&lastname=&company=Dotboost%20Technologies&ClientCandidateID=). ## FAQ @@ -23,4 +23,4 @@ A: According to the article, 2 more team members passed the ZCE exam, bringing t ## Resources -- [Zend Yellow Pages listing](http://www.zend.com/store/education/certification/yellow-pages.php#list-cid=0&sid=&certtype_zf=1&certtype_php=1&certtype=&firstname=&lastname=&company=Dotboost%20Technologies&ClientCandidateID=) +- [Zend Yellow Pages listing](https://www.zend.com/store/education/certification/yellow-pages.php#list-cid=0&sid=&certtype_zf=1&certtype_php=1&certtype=&firstname=&lastname=&company=Dotboost%20Technologies&ClientCandidateID=) diff --git a/public/md-articles/dotkernel/configuring-the-cache-in-dotkernel.md b/public/md-articles/dotkernel/configuring-the-cache-in-dotkernel.md index ccbd0279..f591782f 100644 --- a/public/md-articles/dotkernel/configuring-the-cache-in-dotkernel.md +++ b/public/md-articles/dotkernel/configuring-the-cache-in-dotkernel.md @@ -19,8 +19,8 @@ Optional backend-specific settings (like the file cache directory) are recommend This article contains the Dotkernel cache layer configuration guide. The Dotkernel Caching Layer is based on Zend Framework Cache; more configuration options can be found at the following links: -- [Zend Framework Cache Frontends](http://framework.zend.com/manual/1.12/en/zend.cache.frontends.html) -- [Zend Framework Cache Backends](http://framework.zend.com/manual/1.12/en/zend.cache.backends.html) +- [Zend Framework Cache Frontends](https://framework.zend.com/manual/1.12/en/zend.cache.frontends.html) +- [Zend Framework Cache Backends](https://framework.zend.com/manual/1.12/en/zend.cache.backends.html) ## Main Cache Settings (Cache Frontend) diff --git a/public/md-articles/dotkernel/disable-wurfl-redirect-for-mobile-browsers.md b/public/md-articles/dotkernel/disable-wurfl-redirect-for-mobile-browsers.md index 8dab81ae..1e60eeec 100644 --- a/public/md-articles/dotkernel/disable-wurfl-redirect-for-mobile-browsers.md +++ b/public/md-articles/dotkernel/disable-wurfl-redirect-for-mobile-browsers.md @@ -16,8 +16,8 @@ Dotkernel's example mobile site normally relies on Wurfl to detect mobile browse As of revision 408, this behavior is controlled by a single `resources.useragent.wurflapi.redirect` setting in application.ini. The article shows that setting along with the matching condition in `IndexController.php` that checks it before registering and redirecting a visit. -Dotkernel has an example mobile site at [http://v1.dotkernel.net/mobile](http://v1.dotkernel.net/mobile) that uses [jQuery Mobile](http://jquerymobile.com/). -Wurfl is also used to detect mobile browsers (as discussed in a [previous blog post](http://www.dotkernel.com/dotkernel/wurfl-zend-framework-integration-into-dotkernel/)) and automatically redirect them to the mobile site the first time they view the homepage. +Dotkernel has an example mobile site at [https://v1.dotkernel.net/mobile](https://v1.dotkernel.net/mobile) that uses [jQuery Mobile](https://jquerymobile.com/). +Wurfl is also used to detect mobile browsers (as discussed in a [previous blog post](https://www.dotkernel.com/dotkernel/wurfl-zend-framework-integration-into-dotkernel/)) and automatically redirect them to the mobile site the first time they view the homepage. Sometimes this behavior isn't desired (for example when you don't have a mobile site, or you don't plan on using Wurfl at all). Starting with revision 408, there's an option in application.ini to disable the automatic redirect (by default the redirect is disabled): diff --git a/public/md-articles/dotkernel/dotkernel-1-2-0-release.md b/public/md-articles/dotkernel/dotkernel-1-2-0-release.md index 85d3f2e9..9d339ea3 100644 --- a/public/md-articles/dotkernel/dotkernel-1-2-0-release.md +++ b/public/md-articles/dotkernel/dotkernel-1-2-0-release.md @@ -19,7 +19,7 @@ The release also adds new library classes (Dot_Geoip, Dot_Seo), updates existing ## Database Naming Conventions On database, we changed the names and structure of tables to respect database naming convention. -See [http://www.dotkernel.com/dotkernel/dotkernel-database-naming-conventions-for-mysql/](http://www.dotkernel.com/dotkernel/dotkernel-database-naming-conventions-for-mysql/) for details. +See [https://www.dotkernel.com/dotkernel/dotkernel-database-naming-conventions-for-mysql/](https://www.dotkernel.com/dotkernel/dotkernel-database-naming-conventions-for-mysql/) for details. ## The "Dots" Concept @@ -41,9 +41,9 @@ New library classes have been implemented: Dot_Geoip and Dot_Seo, and some of th ## SQL Prepared Statements In Dotkernel, all SQL queries are written as prepared statements. -We strongly encourage this practice: [http://www.dotkernel.com/php-development/protection-against-sql-injection-using-pdo-and-zend-framework/](http://www.dotkernel.com/php-development/protection-against-sql-injection-using-pdo-and-zend-framework/) +We strongly encourage this practice: [https://www.dotkernel.com/php-development/protection-against-sql-injection-using-pdo-and-zend-framework/](https://www.dotkernel.com/php-development/protection-against-sql-injection-using-pdo-and-zend-framework/) -For more details, see [ChangeLog 1.2.0](http://www.dotkernel.com/changelog/1-2-0/). +For more details, see [ChangeLog 1.2.0](https://www.dotkernel.com/changelog/1-2-0/). ## FAQ diff --git a/public/md-articles/dotkernel/dotkernel-1-2-2-release.md b/public/md-articles/dotkernel/dotkernel-1-2-2-release.md index 95c3fa76..c4ec930c 100644 --- a/public/md-articles/dotkernel/dotkernel-1-2-2-release.md +++ b/public/md-articles/dotkernel/dotkernel-1-2-2-release.md @@ -47,7 +47,7 @@ A: You can download just the changed files from 1.2.1 to 1.2.2 using the upgrade - ChangeLog 1.2.2 (linked in the original post as `../changelog/1-2-2/`) - Upgrade package for 1.2.2 (linked in the original post as `../download/?did=17`) -- Dotkernel Tracker: http://www.dotkernel.net/ -- Dotkernel WebSVN: http://websvn.dotkernel.net/listing.php?repname=Dotkernel+ver.+1 +- Dotkernel Tracker: https://www.dotkernel.net/ +- Dotkernel WebSVN: https://websvn.dotkernel.net/listing.php?repname=Dotkernel+ver.+1 - ChangeLog 1.2.1 (linked in the original post as `../changelog/1-2-1/`) - Upgrade package for 1.2.1 (linked in the original post as `../download/?did=14`) diff --git a/public/md-articles/dotkernel/dotkernel-1-3-0-release.md b/public/md-articles/dotkernel/dotkernel-1-3-0-release.md index 10389c78..7fdc2037 100644 --- a/public/md-articles/dotkernel/dotkernel-1-3-0-release.md +++ b/public/md-articles/dotkernel/dotkernel-1-3-0-release.md @@ -63,5 +63,5 @@ A: No. Because of the XML file reorganization (bug 69), 1.3.0 is not compatible - ChangeLog 1.3.0 (linked in the original post as `../changelog/1-3-0/`) - route.xml documentation (linked in the original post as `../docs/router-xml/`) - dots.xml documentation (linked in the original post as `../docs/dots-xml/`) -- Dotkernel Tracker: http://www.dotkernel.net/ -- Dotkernel WebSVN: http://websvn.dotkernel.net/listing.php?repname=Dotkernel +- Dotkernel Tracker: https://www.dotkernel.net/ +- Dotkernel WebSVN: https://websvn.dotkernel.net/listing.php?repname=Dotkernel diff --git a/public/md-articles/dotkernel/dotkernel-1-3-2-release.md b/public/md-articles/dotkernel/dotkernel-1-3-2-release.md index 77aca70a..3eb91ecf 100644 --- a/public/md-articles/dotkernel/dotkernel-1-3-2-release.md +++ b/public/md-articles/dotkernel/dotkernel-1-3-2-release.md @@ -49,4 +49,4 @@ Refactoring covered Zend_Paginator and added a dojo dijit theme to Dotkernel. ## Resources -- Dotkernel 1.3.2 download: http://www.dotkernel.com/download/?did=27 +- Dotkernel 1.3.2 download: https://www.dotkernel.com/download/?did=27 diff --git a/public/md-articles/dotkernel/dotkernel-1-5-0-released.md b/public/md-articles/dotkernel/dotkernel-1-5-0-released.md index db62c3f6..6725e089 100644 --- a/public/md-articles/dotkernel/dotkernel-1-5-0-released.md +++ b/public/md-articles/dotkernel/dotkernel-1-5-0-released.md @@ -70,6 +70,6 @@ A: About 250 commits went into the SVN repository since the previous release, so ## Resources -- Intro to jQuery: http://www.dotkernel.com/javascript/intro-to-jquery/ -- Zend Registry usage in Dotkernel: http://www.dotkernel.com/dotkernel/zend-registry-usage-in-dotkernel/ -- Dotkernel 1.5.0 download: http://www.dotkernel.com/download/?did=33 +- Intro to jQuery: https://www.dotkernel.com/javascript/intro-to-jquery/ +- Zend Registry usage in Dotkernel: https://www.dotkernel.com/dotkernel/zend-registry-usage-in-dotkernel/ +- Dotkernel 1.5.0 download: https://www.dotkernel.com/download/?did=33 diff --git a/public/md-articles/dotkernel/dotkernel-1-8-0-lts-released.md b/public/md-articles/dotkernel/dotkernel-1-8-0-lts-released.md index ee76cf66..1cf732a5 100644 --- a/public/md-articles/dotkernel/dotkernel-1-8-0-lts-released.md +++ b/public/md-articles/dotkernel/dotkernel-1-8-0-lts-released.md @@ -84,8 +84,8 @@ A: A security scan in the Admin Dashboard shows recommended settings, admin fail ## Resources -- What is LTS: http://www.dotkernel.com/long-term-support -- Caching in Dotkernel using Zend Framework: http://www.dotkernel.com/dotkernel/caching-in-dotkernel-using-zend-framework/ -- Dotkernel reserved variable names for caching: http://www.dotkernel.com/dotkernel/dotkernel-reserved-variable-names-for-caching/ -- How to use alerts in Dotkernel: http://www.dotkernel.com/dotkernel/how-to-use-alerts-in-dotkernel/ -- Dotkernel 1.8.0 (LTS) download: http://www.dotkernel.com/download/?did=41 +- What is LTS: https://www.dotkernel.com/long-term-support +- Caching in Dotkernel using Zend Framework: https://www.dotkernel.com/dotkernel/caching-in-dotkernel-using-zend-framework/ +- Dotkernel reserved variable names for caching: https://www.dotkernel.com/dotkernel/dotkernel-reserved-variable-names-for-caching/ +- How to use alerts in Dotkernel: https://www.dotkernel.com/dotkernel/how-to-use-alerts-in-dotkernel/ +- Dotkernel 1.8.0 (LTS) download: https://www.dotkernel.com/download/?did=41 diff --git a/public/md-articles/dotkernel/dotkernel-1-8-1-upgrade-from-1-8-0-released.md b/public/md-articles/dotkernel/dotkernel-1-8-1-upgrade-from-1-8-0-released.md index 2b3beb03..c2f81800 100644 --- a/public/md-articles/dotkernel/dotkernel-1-8-1-upgrade-from-1-8-0-released.md +++ b/public/md-articles/dotkernel/dotkernel-1-8-1-upgrade-from-1-8-0-released.md @@ -35,6 +35,6 @@ A: The post provides a dedicated "Upgrade from Dotkernel 1.8.0" download link, s ## Resources -- Dotkernel 1.8.1: http://www.dotkernel.com/download/?did=42 -- Upgrade from Dotkernel 1.8.0: http://www.dotkernel.com/download/?did=43 -- Dotkernel 1.8.0 (LTS): http://www.dotkernel.com/download/?did=41 +- Dotkernel 1.8.1: https://www.dotkernel.com/download/?did=42 +- Upgrade from Dotkernel 1.8.0: https://www.dotkernel.com/download/?did=43 +- Dotkernel 1.8.0 (LTS): https://www.dotkernel.com/download/?did=41 diff --git a/public/md-articles/dotkernel/dotkernel-coding-standard.md b/public/md-articles/dotkernel/dotkernel-coding-standard.md index 648b295e..88d09afc 100644 --- a/public/md-articles/dotkernel/dotkernel-coding-standard.md +++ b/public/md-articles/dotkernel/dotkernel-coding-standard.md @@ -68,11 +68,11 @@ A: Every opening curly brace starts on its own new line after the statement, and ## Resources -- Zend Framework: http://framework.zend.com/ -- ZF Coding Standard: http://framework.zend.com/manual/en/coding-standard.php-file-formatting.html -- ZF Coding Standard - Indentation: http://framework.zend.com/manual/en/coding-standard.php-file-formatting.html#coding-standard.php-file-formatting.indentation -- ZF Coding Standard - Naming Conventions: http://framework.zend.com/manual/en/coding-standard.naming-conventions.html -- ZF Coding Standard - Classes: http://framework.zend.com/manual/en/coding-standard.naming-conventions.html#coding-standard.naming-conventions.classes -- ZF Coding Standard - Interfaces: http://framework.zend.com/manual/en/coding-standard.naming-conventions.html#coding-standard.naming-conventions.interfaces -- ZF Coding Standard - Filenames: http://framework.zend.com/manual/en/coding-standard.naming-conventions.html#coding-standard.naming-conventions.filenames -- ZF Coding Standard - Control Statements: http://framework.zend.com/manual/en/coding-standard.coding-style.html#coding-standard.coding-style.control-statements +- Zend Framework: https://framework.zend.com/ +- ZF Coding Standard: https://framework.zend.com/manual/en/coding-standard.php-file-formatting.html +- ZF Coding Standard - Indentation: https://framework.zend.com/manual/en/coding-standard.php-file-formatting.html#coding-standard.php-file-formatting.indentation +- ZF Coding Standard - Naming Conventions: https://framework.zend.com/manual/en/coding-standard.naming-conventions.html +- ZF Coding Standard - Classes: https://framework.zend.com/manual/en/coding-standard.naming-conventions.html#coding-standard.naming-conventions.classes +- ZF Coding Standard - Interfaces: https://framework.zend.com/manual/en/coding-standard.naming-conventions.html#coding-standard.naming-conventions.interfaces +- ZF Coding Standard - Filenames: https://framework.zend.com/manual/en/coding-standard.naming-conventions.html#coding-standard.naming-conventions.filenames +- ZF Coding Standard - Control Statements: https://framework.zend.com/manual/en/coding-standard.coding-style.html#coding-standard.coding-style.control-statements diff --git a/public/md-articles/dotkernel/dotkernel-database-naming-conventions-for-mysql.md b/public/md-articles/dotkernel/dotkernel-database-naming-conventions-for-mysql.md index 6fc2141f..18f82820 100644 --- a/public/md-articles/dotkernel/dotkernel-database-naming-conventions-for-mysql.md +++ b/public/md-articles/dotkernel/dotkernel-database-naming-conventions-for-mysql.md @@ -76,4 +76,4 @@ A: Table and column names must follow camelLetter naming conventions, while SQL ## Resources -- FaZend: Rules of naming of database tables and columns: http://fazend.com/a/2009-11-DataNaming.html +- FaZend: Rules of naming of database tables and columns: https://fazend.com/a/2009-11-DataNaming.html diff --git a/public/md-articles/dotkernel/dotkernel-on-nginx.md b/public/md-articles/dotkernel/dotkernel-on-nginx.md index 71f47aea..fe6d4e5e 100644 --- a/public/md-articles/dotkernel/dotkernel-on-nginx.md +++ b/public/md-articles/dotkernel/dotkernel-on-nginx.md @@ -18,7 +18,7 @@ Since Nginx was becoming the buzzword instead of Apache, this article tests Dotk 1. Installed on a clean CentOS 6.3 VPS: Nginx 1.4.1, PHP 5.4.16, PHP-FPM. 2. Installed PHP modules: APC, GeoIP, and others. -3. Installed PEAR and Zend Framework from the PEAR ZF channel (`http://code.google.com/p/zend/`). +3. Installed PEAR and Zend Framework from the PEAR ZF channel (`https://code.google.com/p/zend/`). 4. Fine-tuned `php.ini`: date, default charset, include path, etc. 5. Created a MySQL database on a remote server and allowed permissions from the Nginx server's IP to connect to it. 6. Edited the Nginx config (`/etc/nginx/conf.d/default.conf`): @@ -73,10 +73,10 @@ A: The directive `try_files $uri $uri/ /index.php;`, which sends all requests to A: By adding a location block matching `^/configs/` that returns `deny all;`. **Q: How was the Dotkernel codebase deployed onto the server?** -A: It was exported directly from SVN using `svn export --force http://v1.dotkernel.net/svn/trunk/ /var/www/html`. +A: It was exported directly from SVN using `svn export --force https://v1.dotkernel.net/svn/trunk/ /var/www/html`. ## Resources -- [PEAR ZF Channel](http://code.google.com/p/zend/) -- [application.ini example](http://nginx.dotkernel.net/configs/application.ini) -- [Live Nginx demo](http://nginx.dotkernel.net/) +- [PEAR ZF Channel](https://code.google.com/p/zend/) +- [application.ini example](https://nginx.dotkernel.net/configs/application.ini) +- [Live Nginx demo](https://nginx.dotkernel.net/) diff --git a/public/md-articles/dotkernel/dotkernel-reserved-variable-names-for-caching.md b/public/md-articles/dotkernel/dotkernel-reserved-variable-names-for-caching.md index 66f8270c..433c985f 100644 --- a/public/md-articles/dotkernel/dotkernel-reserved-variable-names-for-caching.md +++ b/public/md-articles/dotkernel/dotkernel-reserved-variable-names-for-caching.md @@ -47,7 +47,7 @@ A: The change won't take effect on its own, since values like browser_xml and os ## Resources -- [Caching in Dotkernel Using Zend Framework Cache](http://www.dotkernel.com/dotkernel/caching-in-dotkernel-using-zend-framework) -- [router.xml documentation](http://www.dotkernel.com/docs/router-xml/) -- [Module Structure](http://www.dotkernel.com/docs/module-structure/) -- [menu.xml documentation](http://www.dotkernel.com/docs/menu-xml/) +- [Caching in Dotkernel Using Zend Framework Cache](https://www.dotkernel.com/dotkernel/caching-in-dotkernel-using-zend-framework) +- [router.xml documentation](https://www.dotkernel.com/docs/router-xml/) +- [Module Structure](https://www.dotkernel.com/docs/module-structure/) +- [menu.xml documentation](https://www.dotkernel.com/docs/menu-xml/) diff --git a/public/md-articles/dotkernel/dotkernel-version-1-0-in-action.md b/public/md-articles/dotkernel/dotkernel-version-1-0-in-action.md index 12d595f5..78ae2272 100644 --- a/public/md-articles/dotkernel/dotkernel-version-1-0-in-action.md +++ b/public/md-articles/dotkernel/dotkernel-version-1-0-in-action.md @@ -48,6 +48,6 @@ Zend_Registry is a container for storing objects and values in the application s ## Resources -- [DotBoost](http://www.dotboost.com) -- [Open Software License (OSL 3.0)](http://opensource.org/licenses/osl-3.0.php) -- [Zend Framework](http://framework.zend.com/) +- [DotBoost](https://www.dotboost.com) +- [Open Software License (OSL 3.0)](https://opensource.org/licenses/osl-3.0.php) +- [Zend Framework](https://framework.zend.com/) diff --git a/public/md-articles/dotkernel/geoip-ip-address-location-in-dotkernel.md b/public/md-articles/dotkernel/geoip-ip-address-location-in-dotkernel.md index 54ba62f7..1d9ad108 100644 --- a/public/md-articles/dotkernel/geoip-ip-address-location-in-dotkernel.md +++ b/public/md-articles/dotkernel/geoip-ip-address-location-in-dotkernel.md @@ -109,7 +109,7 @@ If neither .dat file exists, it behaves the same as when mod_geoip isn't install ## Resources -- [MaxMind's IP geolocation](http://www.maxmind.com/app/ip-location) -- [library/Dot/Geoip.php source](http://websvn.dotkernel.net/filedetails.php?repname=Dotkernel&path=%2Ftrunk%2Flibrary%2FDot%2FGeoip.php) -- [GeoLite Country database](http://www.maxmind.com/app/geolitecountry) -- [PHP GeoIP functions](http://php.net/manual/en/book.geoip.php) +- [MaxMind's IP geolocation](https://www.maxmind.com/app/ip-location) +- [library/Dot/Geoip.php source](https://websvn.dotkernel.net/filedetails.php?repname=Dotkernel&path=%2Ftrunk%2Flibrary%2FDot%2FGeoip.php) +- [GeoLite Country database](https://www.maxmind.com/app/geolitecountry) +- [PHP GeoIP functions](https://php.net/manual/en/book.geoip.php) diff --git a/public/md-articles/dotkernel/how-to-use-alerts-in-dotkernel.md b/public/md-articles/dotkernel/how-to-use-alerts-in-dotkernel.md index 82199396..91714ed7 100644 --- a/public/md-articles/dotkernel/how-to-use-alerts-in-dotkernel.md +++ b/public/md-articles/dotkernel/how-to-use-alerts-in-dotkernel.md @@ -118,4 +118,4 @@ A: Create a new Dot_Alert(), add headers such as From, Reply-To and X-Mailer, th ## Resources -- [Understanding dots.xml](http://www.dotkernel.com/docs/dots-xml/) +- [Understanding dots.xml](https://www.dotkernel.com/docs/dots-xml/) diff --git a/public/md-articles/dotkernel/implementing-the-new-password-hashing-api-from-php-5-5-in-dotkernel.md b/public/md-articles/dotkernel/implementing-the-new-password-hashing-api-from-php-5-5-in-dotkernel.md index 17851597..1f0de231 100644 --- a/public/md-articles/dotkernel/implementing-the-new-password-hashing-api-from-php-5-5-in-dotkernel.md +++ b/public/md-articles/dotkernel/implementing-the-new-password-hashing-api-from-php-5-5-in-dotkernel.md @@ -11,12 +11,12 @@ language: "en" # Implementing the new Password Hashing API from PHP 5.5 in Dotkernel ## TL;DR -To use the new [Password Hashing](http://www.php.net/manual/en/book.password.php) functions introduced in PHP 5.5 and unify password-related functions for both admin and users, Dotkernel's codebase was refactored in version 1.8.0 (starting from revision 799). +To use the new [Password Hashing](https://www.php.net/manual/en/book.password.php) functions introduced in PHP 5.5 and unify password-related functions for both admin and users, Dotkernel's codebase was refactored in version 1.8.0 (starting from revision 799). Because those functions require PHP 5.5+, the [Password Compat library](https://github.com/ircmaxell/password_compat) is used for compatibility, and the minimum PHP version to run Dotkernel was raised to 5.3.8. ## Background -See more on the reasoning behind this change [here](http://www.brandonsavage.net/please-stop-hashing-passwords-yourself/). +See more on the reasoning behind this change [here](https://www.brandonsavage.net/please-stop-hashing-passwords-yourself/). ## Applying this refactor to older Dotkernel systems @@ -28,8 +28,8 @@ See more on the reasoning behind this change [here](http://www.brandonsavage.net ``` 2. Remove the `settings.admin.salt = 5F6WQ9U3YT` variable from `application.ini`. -3. Apply the [patch](http://www.dotkernel.com/download/?did=40). -4. If you have trouble applying the patch, compare the files and see the log/diff in [websvn](http://websvn.dotkernel.net/comp.php?repname=Dotkernel&compare[]=/@796&compare[]=/@797). +3. Apply the [patch](https://www.dotkernel.com/download/?did=40). +4. If you have trouble applying the patch, compare the files and see the log/diff in [websvn](https://websvn.dotkernel.net/comp.php?repname=Dotkernel&compare[]=/@796&compare[]=/@797). 5. Run the conversion script - details are in the file `Console/Controller.php`, at line 47. 6. Admin passwords cannot be converted, so they need to be recreated manually. @@ -52,8 +52,8 @@ A: Admin passwords cannot be converted automatically, so they need to be recreat ## Resources -- [PHP Password Hashing manual](http://www.php.net/manual/en/book.password.php) -- [Please stop hashing passwords yourself](http://www.brandonsavage.net/please-stop-hashing-passwords-yourself/) +- [PHP Password Hashing manual](https://www.php.net/manual/en/book.password.php) +- [Please stop hashing passwords yourself](https://www.brandonsavage.net/please-stop-hashing-passwords-yourself/) - [Password Compatibility library](https://github.com/ircmaxell/password_compat) -- [Patch download](http://www.dotkernel.com/download/?did=40) -- [websvn diff (revision 796 vs 797)](http://websvn.dotkernel.net/comp.php?repname=Dotkernel&compare[]=/@796&compare[]=/@797) +- [Patch download](https://www.dotkernel.com/download/?did=40) +- [websvn diff (revision 796 vs 797)](https://websvn.dotkernel.net/comp.php?repname=Dotkernel&compare[]=/@796&compare[]=/@797) diff --git a/public/md-articles/dotkernel/installing-geoip-extension-in-zend-server-5-6-on-windows.md b/public/md-articles/dotkernel/installing-geoip-extension-in-zend-server-5-6-on-windows.md index 21567f8a..c9194754 100644 --- a/public/md-articles/dotkernel/installing-geoip-extension-in-zend-server-5-6-on-windows.md +++ b/public/md-articles/dotkernel/installing-geoip-extension-in-zend-server-5-6-on-windows.md @@ -24,7 +24,7 @@ Test whether php_geoip is already available, and if not, download the correct ph This outputs `true` if the extension is available, or `false` if not. -2. If the output is `false`, download a php_geoip.dll file correctly compiled for your Zend Server version from [http://windows.php.net/downloads/pecl/releases/geoip/1.0.8/](http://windows.php.net/downloads/pecl/releases/geoip/1.0.8/). +2. If the output is `false`, download a php_geoip.dll file correctly compiled for your Zend Server version from [https://windows.php.net/downloads/pecl/releases/geoip/1.0.8/](https://windows.php.net/downloads/pecl/releases/geoip/1.0.8/). There are several files for different architectures and compilers. 3. Find your PHP's architecture and compiler by running `phpinfo();`. @@ -68,7 +68,7 @@ A: Create a PHP file with `var_dump(function_exists('geoip_database_info'));`. It outputs true if the extension is available, or false if it isn't. **Q: Where can you download the php_geoip extension for Zend Server 5.6?** -A: From http://windows.php.net/downloads/pecl/releases/geoip/1.0.8/, where several php_geoip.dll files are available for different architectures and compilers. +A: From https://windows.php.net/downloads/pecl/releases/geoip/1.0.8/, where several php_geoip.dll files are available for different architectures and compilers. **Q: How do you know which php_geoip.dll file to download?** A: You can find your PHP's architecture and the compiler it was built with by running phpinfo(). @@ -85,4 +85,4 @@ A: You also need to download geoip databases from MaxMind, such as geoIP.dat, ge ## Resources -- [php_geoip 1.0.8 downloads](http://windows.php.net/downloads/pecl/releases/geoip/1.0.8/) +- [php_geoip 1.0.8 downloads](https://windows.php.net/downloads/pecl/releases/geoip/1.0.8/) diff --git a/public/md-articles/dotkernel/installing-geoip-extension-in-zend-server-6-on-windows.md b/public/md-articles/dotkernel/installing-geoip-extension-in-zend-server-6-on-windows.md index 75a405ca..6ee9da14 100644 --- a/public/md-articles/dotkernel/installing-geoip-extension-in-zend-server-6-on-windows.md +++ b/public/md-articles/dotkernel/installing-geoip-extension-in-zend-server-6-on-windows.md @@ -15,7 +15,7 @@ As an update to [Installing GeoIP extension in Zend Server 5.6 on Windows](https ## Steps -1. Download the `php_geoip-1.0.8-5.4-nts-vc9-x86.zip` file from [http://windows.php.net/downloads/pecl/releases/geoip/1.0.8/](http://windows.php.net/downloads/pecl/releases/geoip/1.0.8/). +1. Download the `php_geoip-1.0.8-5.4-nts-vc9-x86.zip` file from [https://windows.php.net/downloads/pecl/releases/geoip/1.0.8/](https://windows.php.net/downloads/pecl/releases/geoip/1.0.8/). 2. From the archive, copy `php_geoip.dll` to `ZEND_PATH\Zend\ZendServer\lib\phpext\`. 3. Open the `php.ini` file, located at `ZEND_PATH\Zend\ZendServer\etc\php.ini`. 4. Add the following line at the end of the file: @@ -30,7 +30,7 @@ As an update to [Installing GeoIP extension in Zend Server 5.6 on Windows](https ## FAQ **Q: Which php_geoip file should be downloaded for Zend Server 6.1?** -A: Download the php_geoip-1.0.8-5.4-nts-vc9-x86.zip file from http://windows.php.net/downloads/pecl/releases/geoip/1.0.8/. +A: Download the php_geoip-1.0.8-5.4-nts-vc9-x86.zip file from https://windows.php.net/downloads/pecl/releases/geoip/1.0.8/. **Q: Where does the php_geoip.dll go on Zend Server 6.1?** A: Copy the php_geoip.dll file from the archive to ZEND_PATH\Zend\ZendServer\lib\phpext\. @@ -43,5 +43,5 @@ A: Follow the same steps described in the previous article (Installing GeoIP ext ## Resources -- [Installing GeoIP extension in Zend Server 5.6 on Windows](http://www.dotkernel.com/dotkernel/installing-geoip-extension-in-zend-server-5-6-on-windows/) -- [php_geoip 1.0.8 downloads](http://windows.php.net/downloads/pecl/releases/geoip/1.0.8/) +- [Installing GeoIP extension in Zend Server 5.6 on Windows](https://www.dotkernel.com/dotkernel/installing-geoip-extension-in-zend-server-5-6-on-windows/) +- [php_geoip 1.0.8 downloads](https://windows.php.net/downloads/pecl/releases/geoip/1.0.8/) diff --git a/public/md-articles/dotkernel/new-features-in-zend-framework-1-12.md b/public/md-articles/dotkernel/new-features-in-zend-framework-1-12.md index d68b3ed4..231d0462 100644 --- a/public/md-articles/dotkernel/new-features-in-zend-framework-1-12.md +++ b/public/md-articles/dotkernel/new-features-in-zend-framework-1-12.md @@ -54,8 +54,8 @@ A: Over 200 reported issues in the tracker were fixed, with particular thanks cr ## Resources -- [Matthew Weier O'Phinney's announcement](http://devzone.zend.com/2366/zend-framework-1-12-series-1-12-0rc1-now-available/) -- [Browscap project](http://browsers.garykeith.com/) -- [php_browscap.ini download](http://browsers.garykeith.com/stream.asp?PHP_BrowsCapINI) -- [Prior WurflApi removal announcement](http://www.dotkernel.com/dotkernel/zend-framework-dropped-integration-of-wurfl-adapter/) -- [Complete issue tracker list](http://framework.zend.com/issues/secure/IssueNavigator.jspa?requestId=12877) +- [Matthew Weier O'Phinney's announcement](https://devzone.zend.com/2366/zend-framework-1-12-series-1-12-0rc1-now-available/) +- [Browscap project](https://browsers.garykeith.com/) +- [php_browscap.ini download](https://browsers.garykeith.com/stream.asp?PHP_BrowsCapINI) +- [Prior WurflApi removal announcement](https://www.dotkernel.com/dotkernel/zend-framework-dropped-integration-of-wurfl-adapter/) +- [Complete issue tracker list](https://framework.zend.com/issues/secure/IssueNavigator.jspa?requestId=12877) diff --git a/public/md-articles/dotkernel/php-formatter-and-templates-for-zend-studio-10-1.md b/public/md-articles/dotkernel/php-formatter-and-templates-for-zend-studio-10-1.md index b8ab7eed..c7fa3fa3 100644 --- a/public/md-articles/dotkernel/php-formatter-and-templates-for-zend-studio-10-1.md +++ b/public/md-articles/dotkernel/php-formatter-and-templates-for-zend-studio-10-1.md @@ -25,7 +25,7 @@ A: The updated PHP Formatter file for Zend Studio 10.1 and a templates file buil ## Resources -- [Dotkernel PHP Formatter for Zend Studio 10.1](http://www.dotkernel.com/download/?did=37) -- [About PHP Formatter (Zend Studio docs)](http://files.zend.com/help/Zend-Studio-10/zend-studio.htm#formatter.htm) -- [Templates for Zend Studio](http://www.dotkernel.com/download/?did=38) -- [About Templates in Zend Studio and how to import/export (Zend Studio docs)](http://files.zend.com/help/Zend-Studio-10/zend-studio.htm#templates.htm) +- [Dotkernel PHP Formatter for Zend Studio 10.1](https://www.dotkernel.com/download/?did=37) +- [About PHP Formatter (Zend Studio docs)](https://files.zend.com/help/Zend-Studio-10/zend-studio.htm#formatter.htm) +- [Templates for Zend Studio](https://www.dotkernel.com/download/?did=38) +- [About Templates in Zend Studio and how to import/export (Zend Studio docs)](https://files.zend.com/help/Zend-Studio-10/zend-studio.htm#templates.htm) diff --git a/public/md-articles/dotkernel/scientia-mobile-licensed-its-wurfl-cloud-php-library-to-dotkernel-1-6.md b/public/md-articles/dotkernel/scientia-mobile-licensed-its-wurfl-cloud-php-library-to-dotkernel-1-6.md index 1f703ac7..e755b347 100644 --- a/public/md-articles/dotkernel/scientia-mobile-licensed-its-wurfl-cloud-php-library-to-dotkernel-1-6.md +++ b/public/md-articles/dotkernel/scientia-mobile-licensed-its-wurfl-cloud-php-library-to-dotkernel-1-6.md @@ -44,6 +44,6 @@ It does not allow making derivative works, even when source code is provided or ## Resources -- [Scientia Mobile](http://www.scientiamobile.com/) -- [Detecting Mobile Devices in Dotkernel 1.6](http://www.dotkernel.com/dotkernel/detecting-mobile-devices-in-dotkernel-1-6/) -- [dotkernel/library/WurflCloud/LICENSE.txt](http://websvn.dotkernel.net/filedetails.php?repname=Dotkernel&path=%2Ftrunk%2Flibrary%2FWurflCloud%2FLICENSE.txt) +- [Scientia Mobile](https://www.scientiamobile.com/) +- [Detecting Mobile Devices in Dotkernel 1.6](https://www.dotkernel.com/dotkernel/detecting-mobile-devices-in-dotkernel-1-6/) +- [dotkernel/library/WurflCloud/LICENSE.txt](https://websvn.dotkernel.net/filedetails.php?repname=Dotkernel&path=%2Ftrunk%2Flibrary%2FWurflCloud%2FLICENSE.txt) diff --git a/public/md-articles/dotkernel/using-dotkernel-with-composer-dependencies.md b/public/md-articles/dotkernel/using-dotkernel-with-composer-dependencies.md index 9d29a9bb..859da93d 100644 --- a/public/md-articles/dotkernel/using-dotkernel-with-composer-dependencies.md +++ b/public/md-articles/dotkernel/using-dotkernel-with-composer-dependencies.md @@ -121,9 +121,9 @@ A: This article works for any Dotkernel 1.x version if your server is running PH ## Resources - [Adding Composer support in your Dotkernel project](https://www.dotkernel.com/dotkernel/adding-composer-support-in-your-dotkernel-project) -- [Zend Framework 1 manual](http://framework.zend.com/manual/1.12/en/manual.html) -- [Zend Framework 2 manual](http://framework.zend.com/manual/current/en/index.html) +- [Zend Framework 1 manual](https://framework.zend.com/manual/1.12/en/manual.html) +- [Zend Framework 2 manual](https://framework.zend.com/manual/current/en/index.html) - [PSR-0](https://www.php-fig.org/psr/psr-0/) - [PSR-4](https://www.php-fig.org/psr/psr-4/) -- [Zend Framework 1 - Rendering a barcode](http://framework.zend.com/manual/1.12/en/zend.barcode.creation.html#zend.barcode.creation.renderering) -- [Zend Framework 2 - Rendering a barcode](http://framework.zend.com/manual/current/en/modules/zend.barcode.creation.html#rendering-a-barcode) +- [Zend Framework 1 - Rendering a barcode](https://framework.zend.com/manual/1.12/en/zend.barcode.creation.html#zend.barcode.creation.renderering) +- [Zend Framework 2 - Rendering a barcode](https://framework.zend.com/manual/current/en/modules/zend.barcode.creation.html#rendering-a-barcode) diff --git a/public/md-articles/dotkernel/wurfl-cloud-integration-in-dotkernel-1-6-0.md b/public/md-articles/dotkernel/wurfl-cloud-integration-in-dotkernel-1-6-0.md index b05c5c9a..dd23075f 100644 --- a/public/md-articles/dotkernel/wurfl-cloud-integration-in-dotkernel-1-6-0.md +++ b/public/md-articles/dotkernel/wurfl-cloud-integration-in-dotkernel-1-6-0.md @@ -51,4 +51,4 @@ A: Change the user agent in your browser to a mobile device and access your proj ## Resources - Register for a Wurfl Cloud account: https://www.scientiamobile.com/register -- Related post - Detecting Mobile Devices in Dotkernel 1.6.0: http://www.dotkernel.com/?p=1465 +- Related post - Detecting Mobile Devices in Dotkernel 1.6.0: https://www.dotkernel.com/?p=1465 diff --git a/public/md-articles/dotkernel/wurfl-php-api-license-incompatible-with-dotkernel.md b/public/md-articles/dotkernel/wurfl-php-api-license-incompatible-with-dotkernel.md index 6810e1ad..e1eff4c5 100644 --- a/public/md-articles/dotkernel/wurfl-php-api-license-incompatible-with-dotkernel.md +++ b/public/md-articles/dotkernel/wurfl-php-api-license-incompatible-with-dotkernel.md @@ -22,11 +22,11 @@ We integrated the WURFL PHP API into the Dotkernel code base a long time ago. At that time its license was GNU/GPL, perfectly compatible with the Zend Framework license (new BSD) and Dotkernel (OSL 3.0). On June 6th, 2011, the WURFL PHP API library was updated to version 1.3.0, and Dotkernel followed suit in its 1.5.0 release candidate, without noticing that the license had changed from GNU/GPL to AGPL - suddenly turning it into a "trial only" library for "Product evaluation" only. -See the [official announcement](http://www.scientiamobile.com/site/page/view/products#licenses). +See the [official announcement](https://www.scientiamobile.com/site/page/view/products#licenses). ## What Dotkernel plans to do -We respect the work of Luca Passani and Steve Kamerman, but we must keep [Dotkernel Application Framework](http://www.dotkernel.com) free, without such license limitations. +We respect the work of Luca Passani and Steve Kamerman, but we must keep [Dotkernel Application Framework](https://www.dotkernel.com) free, without such license limitations. The only things to do at this stage are: - Remove WURFL PHP API library version 1.3.0 from the Dotkernel code base. @@ -35,7 +35,7 @@ The only things to do at this stage are: - Consider forking WURFL PHP API 1.2.1 to a new library. - Consider changing the library's name to avoid trademark issues. -*Later edit: the correct link to the announcement is [here](http://tech.groups.yahoo.com/group/wmlprogramming/message/34031).* +*Later edit: the correct link to the announcement is [here](https://tech.groups.yahoo.com/group/wmlprogramming/message/34031).* ## FAQ @@ -53,6 +53,6 @@ It also intended to keep contributing to the free WURFL XML data, and considered ## Resources -- Official announcement of the license change: http://www.scientiamobile.com/site/page/view/products#licenses -- Corrected announcement link: http://tech.groups.yahoo.com/group/wmlprogramming/message/34031 -- Dotkernel Application Framework: http://www.dotkernel.com +- Official announcement of the license change: https://www.scientiamobile.com/site/page/view/products#licenses +- Corrected announcement link: https://tech.groups.yahoo.com/group/wmlprogramming/message/34031 +- Dotkernel Application Framework: https://www.dotkernel.com diff --git a/public/md-articles/dotkernel/wurfl-zend-framework-integration-into-dotkernel.md b/public/md-articles/dotkernel/wurfl-zend-framework-integration-into-dotkernel.md index ddbc155d..63255a12 100644 --- a/public/md-articles/dotkernel/wurfl-zend-framework-integration-into-dotkernel.md +++ b/public/md-articles/dotkernel/wurfl-zend-framework-integration-into-dotkernel.md @@ -12,12 +12,12 @@ language: "en" ## TL;DR -[WURFL](http://wurfl.sourceforge.net/) is integrated into Dotkernel using the [Zend_Http_UserAgent](http://framework.zend.com/manual/1.11/en/zend.http.user-agent.html) class from [ZF 1.11.0rc1](http://framework.zend.com/download/latest) (the beta release at the time of the post). +[WURFL](https://wurfl.sourceforge.net/) is integrated into Dotkernel using the [Zend_Http_UserAgent](https://framework.zend.com/manual/1.11/en/zend.http.user-agent.html) class from [ZF 1.11.0rc1](https://framework.zend.com/download/latest) (the beta release at the time of the post). This post walks through the required folders, config files, and code to wire it up. ## Installation steps -1. Download the [WURFL PHP API](http://sourceforge.net/projects/wurfl/files/WURFL%20PHP/1.1/wurfl-php-1.1.tar.gz/download) and unzip it into a folder named `wurfl-php-1.1`. +1. Download the [WURFL PHP API](https://sourceforge.net/projects/wurfl/files/WURFL%20PHP/1.1/wurfl-php-1.1.tar.gz/download) and unzip it into a folder named `wurfl-php-1.1`. 2. Create the following folders and make them writable by the web server: - `cache\wurfl\FILE_CACHE_PROVIDER` - `cache\wurfl\FILE_PERSISTENCE_PROVIDER` @@ -34,12 +34,12 @@ resources.useragent.wurflapi.wurfl_config_file = APPLICATION_PATH "/configs/wurf 5. Create the folder `externals\wurfl`, and copy the following files into it: - `wurfl-php-1.1\examples\resources\web_browsers_patch.xml` - - `wurfl-php-1.1\examples\resources\wurfl-regression.zip`, renamed to `wurfl.zip` (or download the [latest wurfl zip](http://sourceforge.net/projects/wurfl/files/WURFL/) database and rename it `wurfl.zip`). + - `wurfl-php-1.1\examples\resources\wurfl-regression.zip`, renamed to `wurfl.zip` (or download the [latest wurfl zip](https://sourceforge.net/projects/wurfl/files/WURFL/) database and rename it `wurfl.zip`). 6. Copy the contents of the folder `wurfl-php-1.1\WURFL` to `library\Wurfl`. ## Using WURFL in Dotkernel -[WURFL](http://wurfl.sourceforge.net/) is integrated into Dotkernel in the mobile module. +[WURFL](https://wurfl.sourceforge.net/) is integrated into Dotkernel in the mobile module. To access WURFL configuration: ```php @@ -68,8 +68,8 @@ A: Instantiate a Zend_Http_UserAgent with the useragent config, then call getDev ## Resources -- WURFL: http://wurfl.sourceforge.net/ -- Zend_Http_UserAgent manual: http://framework.zend.com/manual/1.11/en/zend.http.user-agent.html -- Zend Framework latest download: http://framework.zend.com/download/latest -- WURFL PHP API 1.1 download: http://sourceforge.net/projects/wurfl/files/WURFL%20PHP/1.1/wurfl-php-1.1.tar.gz/download -- Latest WURFL zip database: http://sourceforge.net/projects/wurfl/files/WURFL/ +- WURFL: https://wurfl.sourceforge.net/ +- Zend_Http_UserAgent manual: https://framework.zend.com/manual/1.11/en/zend.http.user-agent.html +- Zend Framework latest download: https://framework.zend.com/download/latest +- WURFL PHP API 1.1 download: https://sourceforge.net/projects/wurfl/files/WURFL%20PHP/1.1/wurfl-php-1.1.tar.gz/download +- Latest WURFL zip database: https://sourceforge.net/projects/wurfl/files/WURFL/ diff --git a/public/md-articles/dotkernel/zend-auth-and-zend-acl-integrated-in-dotkernel.md b/public/md-articles/dotkernel/zend-auth-and-zend-acl-integrated-in-dotkernel.md index 1de42fcf..0ba8d4d6 100644 --- a/public/md-articles/dotkernel/zend-auth-and-zend-acl-integrated-in-dotkernel.md +++ b/public/md-articles/dotkernel/zend-auth-and-zend-acl-integrated-in-dotkernel.md @@ -12,7 +12,7 @@ language: "en" ## TL;DR -[Zend_Auth](http://framework.zend.com/manual/en/zend.auth.html) and [Zend_Acl](http://framework.zend.com/manual/en/zend.acl.html) have been integrated into Dotkernel starting with version 1.5.0. +[Zend_Auth](https://framework.zend.com/manual/en/zend.auth.html) and [Zend_Acl](https://framework.zend.com/manual/en/zend.acl.html) have been integrated into Dotkernel starting with version 1.5.0. The User and Admin models were completely refactored using the new `Dot_Auth` and `Dot_Acl` classes for authentication and access control. ## Dot_Auth @@ -95,5 +95,5 @@ Dot_Auth calls the isAllowed method from Dot_Acl to authenticate the user. ## Resources -- Zend_Auth manual: http://framework.zend.com/manual/en/zend.auth.html -- Zend_Acl manual: http://framework.zend.com/manual/en/zend.acl.html +- Zend_Auth manual: https://framework.zend.com/manual/en/zend.auth.html +- Zend_Acl manual: https://framework.zend.com/manual/en/zend.acl.html diff --git a/public/md-articles/dotkernel/zend-registry-usage-in-dotkernel.md b/public/md-articles/dotkernel/zend-registry-usage-in-dotkernel.md index bee393bc..27f4ca32 100644 --- a/public/md-articles/dotkernel/zend-registry-usage-in-dotkernel.md +++ b/public/md-articles/dotkernel/zend-registry-usage-in-dotkernel.md @@ -18,7 +18,7 @@ In Dotkernel, `Zend_Registry` holds a fixed set of request-scoped variables - fr | Variable | Contents | |---|---| -| `startTime` | The result of [microtime()](http://php.net/manual/en/function.microtime.php) at the beginning of the request | +| `startTime` | The result of [microtime()](https://php.net/manual/en/function.microtime.php) at the beginning of the request | | `configuration` | The configuration options loaded from `configs/application.ini` | | `router` | Routing settings loaded from `configs/router.xml` | | `database` | The database adapter | @@ -63,5 +63,5 @@ A: It contains seo information loaded from configs/dots/seo.xml, such as site na ## Resources -- microtime() PHP manual: http://php.net/manual/en/function.microtime.php -- Zend Framework Documentation on Zend_Registry: http://framework.zend.com/manual/en/zend.registry.using.html +- microtime() PHP manual: https://php.net/manual/en/function.microtime.php +- Zend Framework Documentation on Zend_Registry: https://framework.zend.com/manual/en/zend.registry.using.html diff --git a/public/md-articles/dotkernel/zend-session-usage-in-dotkernel-refactor-of-dot-session-class.md b/public/md-articles/dotkernel/zend-session-usage-in-dotkernel-refactor-of-dot-session-class.md index a2207868..2350f2de 100644 --- a/public/md-articles/dotkernel/zend-session-usage-in-dotkernel-refactor-of-dot-session-class.md +++ b/public/md-articles/dotkernel/zend-session-usage-in-dotkernel-refactor-of-dot-session-class.md @@ -18,17 +18,17 @@ The fix, shipped in Dotkernel 1.5.1, removed the `regenerateID()` call and added ## The bug -We found a strange behaviour of sessions in one of our projects, running Dotkernel version 1.5.0 - similar to [one described here](http://trac.elgg.org/ticket/2677). +We found a strange behaviour of sessions in one of our projects, running Dotkernel version 1.5.0 - similar to [one described here](https://trac.elgg.org/ticket/2677). In unknown circumstances, and only in IE 8 and IE9, the session cookie is not saved on the client machine, and the user needs to log in over and over again. It was reproduced once on the staging server, and the only way to fix it at the time was to open a new tab with the same page. ## The investigation Investigating the `Dot_Session` class showed that the session cookie is generated **3 times**. -See this [bug report](http://www.dotkernel.net/view.php?id=184). +See this [bug report](https://www.dotkernel.net/view.php?id=184). The code used both **regenerateID()** and **rememberMe()** methods of Zend_Session, which is **not necessary**. -Quote from the [ZF documentation](http://framework.zend.com/manual/1.11/en/zend.session.global_session_management.html): +Quote from the [ZF documentation](https://framework.zend.com/manual/1.11/en/zend.session.global_session_management.html): > If you call the rememberMe() function, then don't use regenerateId(), since the former calls the latter. > If a user has successfully logged into your website, use rememberMe() instead of regenerateId(). @@ -65,6 +65,6 @@ A: If you encounter the same issue in IE8 and IE9, the tip given is to deactivat ## Resources -- Similar issue described on Elgg: http://trac.elgg.org/ticket/2677 -- Dotkernel bug report: http://www.dotkernel.net/view.php?id=184 -- Zend Framework documentation on global session management: http://framework.zend.com/manual/1.11/en/zend.session.global_session_management.html +- Similar issue described on Elgg: https://trac.elgg.org/ticket/2677 +- Dotkernel bug report: https://www.dotkernel.net/view.php?id=184 +- Zend Framework documentation on global session management: https://framework.zend.com/manual/1.11/en/zend.session.global_session_management.html diff --git a/public/md-articles/dotkernel/zend-studio-php-formatter-file-for-dotkernel-coding-standard.md b/public/md-articles/dotkernel/zend-studio-php-formatter-file-for-dotkernel-coding-standard.md index 93272c54..0a30d0e5 100644 --- a/public/md-articles/dotkernel/zend-studio-php-formatter-file-for-dotkernel-coding-standard.md +++ b/public/md-articles/dotkernel/zend-studio-php-formatter-file-for-dotkernel-coding-standard.md @@ -29,4 +29,4 @@ A: Unzip the downloaded file, then in Zend Studio go to Preferences -> PHP -> Co ## Resources -- [Dotkernel PHP Formatter download](http://www.dotkernel.com/download/?did=34) +- [Dotkernel PHP Formatter download](https://www.dotkernel.com/download/?did=34) diff --git a/public/md-articles/javascript/codelobster-php-edition-free-php-html-css-javascript-editor-ide.md b/public/md-articles/javascript/codelobster-php-edition-free-php-html-css-javascript-editor-ide.md index 58fd7825..cb0a5b41 100644 --- a/public/md-articles/javascript/codelobster-php-edition-free-php-html-css-javascript-editor-ide.md +++ b/public/md-articles/javascript/codelobster-php-edition-free-php-html-css-javascript-editor-ide.md @@ -18,10 +18,10 @@ On top of that, it ships plugins for popular CMS platforms and PHP frameworks su ## Free PHP, HTML, CSS, JavaScript Editor (IDE) - Codelobster PHP Edition -![Free PHP, HTML, CSS, JavaScript editor (IDE) - Codelobster PHP Edition](http://www.codelobster.com/images/clphped.gif) +![Free PHP, HTML, CSS, JavaScript editor (IDE) - Codelobster PHP Edition](https://www.codelobster.com/images/clphped.gif) For valuable work on creation of sites you need a good comfortable editor necessarily. -There are many requiring paid products for this purpose, but we would like to select free of charge very functional and at the same time of simple in the use editor - [Codelobster PHP Edition](http://www.codelobster.com/). +There are many requiring paid products for this purpose, but we would like to select free of charge very functional and at the same time of simple in the use editor - [Codelobster PHP Edition](https://www.codelobster.com/). Let us consider some important possibilities and advantages of this program: @@ -47,7 +47,7 @@ Highlighting and autocompletion works for SQL files also. - WordPress blogging engine - Smarty template engine -[http://www.codelobster.com](http://www.codelobster.com) +[https://www.codelobster.com](https://www.codelobster.com) ## FAQ diff --git a/public/md-articles/php-development/aptana-php-installation-in-aptana-2-x.md b/public/md-articles/php-development/aptana-php-installation-in-aptana-2-x.md index ffc076fe..cfad9b76 100644 --- a/public/md-articles/php-development/aptana-php-installation-in-aptana-2-x.md +++ b/public/md-articles/php-development/aptana-php-installation-in-aptana-2-x.md @@ -15,12 +15,12 @@ language: "en" Aptana discontinued its bundled Aptana PHP plugin in Aptana 2.x in favor of PDT, but PDT is missing major features needed for professional PHP development. This article shows how to manually reinstall the Aptana PHP plugin through Aptana's update site, and how to add SVN support via Subclipse if it isn't already installed. -As all [aptana](http://www.aptana.org/) fans know, [Aptana PHP](http://www.aptana.org/php) plugin was discontinued in Aptana 2.x, in favor of PDT. +As all [aptana](https://www.aptana.org/) fans know, [Aptana PHP](https://www.aptana.org/php) plugin was discontinued in Aptana 2.x, in favor of PDT. But PDT is a joke, not suitable for professional PHP development, major features are missing. So if you want to continue using Aptana PHP, that's what need to be done: 1. Aptana -> Help -> Install New Software -2. Add http://update.aptana.com/install/php +2. Add https://update.aptana.com/install/php 3. Then select Aptana PHP and install it. In case you don't have yet a SVN plugin, go to diff --git a/public/md-articles/php-development/better-unicode-support-in-mysql-5-5-utf8mb4.md b/public/md-articles/php-development/better-unicode-support-in-mysql-5-5-utf8mb4.md index 1b14f2fa..ac8be30e 100644 --- a/public/md-articles/php-development/better-unicode-support-in-mysql-5-5-utf8mb4.md +++ b/public/md-articles/php-development/better-unicode-support-in-mysql-5-5-utf8mb4.md @@ -16,9 +16,9 @@ MySQL 5.5 introduced the utf8mb4 character set for fuller Unicode support, and D Switching to utf8mb4 means VARCHAR(255) columns can hit MySQL's 767-byte max key length error, so VARCHAR(150) is used instead, and the connection charset must be updated in both the application config and my.cnf. Beginning with version 5.5 of MySQL, utf8mb4 character set was introduced, in order to better support Unicode. -Further reading: [MySQL and Unicode](http://mzsanford.com/blog/mysql-and-unicode/), also directly related to [PHP](http://www.phptherightway.com/#php_and_utf8). +Further reading: [MySQL and Unicode](https://mzsanford.com/blog/mysql-and-unicode/), also directly related to [PHP](https://www.phptherightway.com/#php_and_utf8). -Sample dk.sql file, part of Dotkernel framework, was updated in [revision 793](http://websvn.dotkernel.net/filedetails.php?repname=Dotkernel&path=%2Ftrunk%2Fdot_kernel.sql&rev=793&peg=793). +Sample dk.sql file, part of Dotkernel framework, was updated in [revision 793](https://websvn.dotkernel.net/filedetails.php?repname=Dotkernel&path=%2Ftrunk%2Fdot_kernel.sql&rev=793&peg=793). Only one downside, is not possible to use anymore VARCHAR(255), as you will get the error: @@ -26,7 +26,7 @@ Only one downside, is not possible to use anymore VARCHAR(255), as you will get #1071 - Specified key was too long; max key length is 767 bytes ``` -Explanation [here](http://stackoverflow.com/questions/1814532/1071-specified-key-was-too-long-max-key-length-is-767-bytes); so we use instead VARCHAR(150). +Explanation [here](https://stackoverflow.com/questions/1814532/1071-specified-key-was-too-long-max-key-length-is-767-bytes); so we use instead VARCHAR(150). Now we should change the connection charset. In config/application.ini file, edit the below line: diff --git a/public/md-articles/php-development/end-of-support-for-php-5-2-x-branch.md b/public/md-articles/php-development/end-of-support-for-php-5-2-x-branch.md index 51819e1f..aa488a34 100644 --- a/public/md-articles/php-development/end-of-support-for-php-5-2-x-branch.md +++ b/public/md-articles/php-development/end-of-support-for-php-5-2-x-branch.md @@ -40,5 +40,5 @@ A: PHP 5.3.3 was just released, so it is time to upgrade every project to the PH ## Resources -- [PHP 5.2.14 changelog](http://www.php.net/ChangeLog-5.php#5.2.14) -- [PHP 5.3.3 changelog](http://www.php.net/ChangeLog-5.php#5.3.3) +- [PHP 5.2.14 changelog](https://www.php.net/ChangeLog-5.php#5.2.14) +- [PHP 5.3.3 changelog](https://www.php.net/ChangeLog-5.php#5.3.3) diff --git a/public/md-articles/php-development/how-to-upgrade-wamp-to-php-5-3-4.md b/public/md-articles/php-development/how-to-upgrade-wamp-to-php-5-3-4.md index 7b485373..aca5b1fa 100644 --- a/public/md-articles/php-development/how-to-upgrade-wamp-to-php-5-3-4.md +++ b/public/md-articles/php-development/how-to-upgrade-wamp-to-php-5-3-4.md @@ -17,7 +17,7 @@ A step-by-step guide to manually upgrading the PHP version used by a WAMP server ## Steps 1. Stop the WAMP server. -2. Go to [windows.php.net](http://windows.php.net/download/) and download the latest ZIPPED package for PHP 5.3.4. +2. Go to [windows.php.net](https://windows.php.net/download/) and download the latest ZIPPED package for PHP 5.3.4. Make sure it is the **VC6 Thread Safe build** - do not download the installer. 3. Create a folder `php5.3.4` inside `wamp/bin/php`. 4. Extract the downloaded zip into the newly created `php5.3.4` folder. @@ -55,4 +55,4 @@ Also check that the PEAR path is correct in php.ini and modify it accordingly, t ## Resources -- [windows.php.net downloads](http://windows.php.net/download/) +- [windows.php.net downloads](https://windows.php.net/download/) diff --git a/public/md-articles/php-development/php-5-3-6-released-no-upgrade-possible-for-wampserver.md b/public/md-articles/php-development/php-5-3-6-released-no-upgrade-possible-for-wampserver.md index 72500d20..6ec30bbe 100644 --- a/public/md-articles/php-development/php-5-3-6-released-no-upgrade-possible-for-wampserver.md +++ b/public/md-articles/php-development/php-5-3-6-released-no-upgrade-possible-for-wampserver.md @@ -28,4 +28,4 @@ A: More details are available on the WampServer forum, linked in this article. ## Resources -- [WampServer forum discussion](http://www.wampserver.com/phorum/read.php?2,72243) +- [WampServer forum discussion](https://www.wampserver.com/phorum/read.php?2,72243) diff --git a/public/md-articles/php-development/php-support-back-in-aptana-3-0.md b/public/md-articles/php-development/php-support-back-in-aptana-3-0.md index a44b0417..bf354162 100644 --- a/public/md-articles/php-development/php-support-back-in-aptana-3-0.md +++ b/public/md-articles/php-development/php-support-back-in-aptana-3-0.md @@ -47,5 +47,5 @@ The plan is to provide the PHP debugger as a separate set of plugins you can gra ## Resources -- [Aptana Studio 3 official product page](http://www.aptana.com/products/studio3) +- [Aptana Studio 3 official product page](https://www.aptana.com/products/studio3) - [Aptana forum announcement thread](https://aptanastudio.tenderapp.com/discussions/questions/419-php-in-studio-3) diff --git a/public/md-articles/php-development/protection-against-sql-injection-using-pdo-and-zend-framework-part-2.md b/public/md-articles/php-development/protection-against-sql-injection-using-pdo-and-zend-framework-part-2.md index 0a05271d..b6064951 100644 --- a/public/md-articles/php-development/protection-against-sql-injection-using-pdo-and-zend-framework-part-2.md +++ b/public/md-articles/php-development/protection-against-sql-injection-using-pdo-and-zend-framework-part-2.md @@ -64,5 +64,5 @@ A short tip mentioned is to use type casting to avoid SQL Injection in a WHERE c ## Resources - [Protection against SQL Injection using PDO and Zend Framework (part 1)](https://www.dotkernel.com/php-development/protection-against-sql-injection-using-pdo-and-zend-framework/) -- [Secure Programming with the Zend Framework (Stefan Esser slides)](http://www.suspekt.org/downloads/DPC_Secure_Programming_With_The_Zend_Framework.pdf) +- [Secure Programming with the Zend Framework (Stefan Esser slides)](https://www.suspekt.org/downloads/DPC_Secure_Programming_With_The_Zend_Framework.pdf) - [PHP Type Casting](https://www.w3schools.com/php/php_casting.asp) diff --git a/public/md-articles/php-development/using-aptana-to-connect-to-dotkernel-tracker-mantis.md b/public/md-articles/php-development/using-aptana-to-connect-to-dotkernel-tracker-mantis.md index 303553c0..794b980b 100644 --- a/public/md-articles/php-development/using-aptana-to-connect-to-dotkernel-tracker-mantis.md +++ b/public/md-articles/php-development/using-aptana-to-connect-to-dotkernel-tracker-mantis.md @@ -23,18 +23,18 @@ Screenshots for the steps below are available in the Downloads section, under [U ## Steps -1. Install Aptana 2.0.5 by following the steps at [http://www.dotkernel.com/php-development/aptana-php-installation-in-aptana-2-x/](https://www.dotkernel.com/php-development/aptana-php-installation-in-aptana-2-x/). +1. Install Aptana 2.0.5 by following the steps at [https://www.dotkernel.com/php-development/aptana-php-installation-in-aptana-2-x/](https://www.dotkernel.com/php-development/aptana-php-installation-in-aptana-2-x/). 2. Install the Mylyn plugin. Go to Aptana -> Help -> Software Update. -Add the update site: [http://download.eclipse.org/tools/mylyn/update/e3.4/](http://download.eclipse.org/tools/mylyn/update/e3.4/). +Add the update site: [https://download.eclipse.org/tools/mylyn/update/e3.4/](https://download.eclipse.org/tools/mylyn/update/e3.4/). 3. In Aptana, go to Windows -> Show View -> Other -> Task Repository. 4. Right click and select Add Task Repository -> Install More Connectors. 5. Select Mantis, then Finish. 6. Install the Mylyn Connector for Mantis. 7. From the Task Repository window, right click and select Add Task Repository -> Mantis -> Next. -Server: [http://www.dotkernel.net/api/soap/mantisconnect.php](http://www.dotkernel.net/api/soap/mantisconnect.php). +Server: [https://www.dotkernel.net/api/soap/mantisconnect.php](https://www.dotkernel.net/api/soap/mantisconnect.php). Label: Dotkernel Tracker (or a custom string). -User ID: your username from [http://www.dotkernel.net/](http://www.dotkernel.net/) - if you don't have an account, [sign up here](http://www.dotkernel.net/signup_page.php). +User ID: your username from [https://www.dotkernel.net/](https://www.dotkernel.net/) - if you don't have an account, [sign up here](https://www.dotkernel.net/signup_page.php). 8. To check if your connection works, click Validate Settings, then Finish. 9. A popup will ask if you want to create a query; click Yes. 10. Select project: Dotkernel. @@ -51,7 +51,7 @@ A: Dotkernel Tracker is a Mantis-based, web-based bugtracking system used by the A: Aptana, the web development IDE used for Dotkernel development, is integrated with Dotkernel Tracker via the Mylyn plugin's Mantis connector, so tickets can be managed directly from within the IDE. **Q: What plugin do you need to install in Aptana before connecting to Mantis?** -A: You need to install the Mylyn plugin via Aptana -> Help -> Software Update, adding the update site http://download.eclipse.org/tools/mylyn/update/e3.4/, and then install the Mylyn Connector for Mantis. +A: You need to install the Mylyn plugin via Aptana -> Help -> Software Update, adding the update site https://download.eclipse.org/tools/mylyn/update/e3.4/, and then install the Mylyn Connector for Mantis. **Q: What information is needed to add Dotkernel Tracker as a task repository?** -A: You need the server URL (http://www.dotkernel.net/api/soap/mantisconnect.php), a label of your choosing (e.g. "Dotkernel Tracker"), and your User ID, which is your username from dotkernel.net (you can sign up if you don't already have an account). +A: You need the server URL (https://www.dotkernel.net/api/soap/mantisconnect.php), a label of your choosing (e.g. "Dotkernel Tracker"), and your User ID, which is your username from dotkernel.net (you can sign up if you don't already have an account). diff --git a/public/md-articles/php-development/using-php-7-express-in-zend-studio-13.md b/public/md-articles/php-development/using-php-7-express-in-zend-studio-13.md index 70b7b46d..7a111326 100644 --- a/public/md-articles/php-development/using-php-7-express-in-zend-studio-13.md +++ b/public/md-articles/php-development/using-php-7-express-in-zend-studio-13.md @@ -49,7 +49,7 @@ As an example, a local project named zend-framework-test is used, and the latest - Right click on the project. - Create a new folder named library. -- Download the latest Zend Framework 1 version from [here](http://framework.zend.com/downloads/latest#ZF1), making sure to choose the Full option, not the Full Package one. +- Download the latest Zend Framework 1 version from [here](https://framework.zend.com/downloads/latest#ZF1), making sure to choose the Full option, not the Full Package one. - Extract the archive and browse the library folder. - Copy the Zend folder into your project's library folder. - If the drag and drop didn't work and you had to manually copy the folder into your project, click the project folder in Zend Studio and press F5 (Refresh). diff --git a/public/md-articles/php-development/welcome-to-the-10th-zend-certified-engineer-in-dotboost-team.md b/public/md-articles/php-development/welcome-to-the-10th-zend-certified-engineer-in-dotboost-team.md index 71b449db..e5c2027b 100644 --- a/public/md-articles/php-development/welcome-to-the-10th-zend-certified-engineer-in-dotboost-team.md +++ b/public/md-articles/php-development/welcome-to-the-10th-zend-certified-engineer-in-dotboost-team.md @@ -17,9 +17,9 @@ Next up: adopting Zend Framework 2 best practices, pursuing the Zend Framework 2 ## Milestone Announcement -Today is a major milestone for [Dotboost Technologies Inc.](http://www.dotboost.com) +Today is a major milestone for [Dotboost Technologies Inc.](https://www.dotboost.com) The company is announcing that the 10th member of the team has passed the Zend Certified Engineer exam. -See the [Zend Yellow Pages](http://bit.ly/dotboost-zce) listing. +See the [Zend Yellow Pages](https://bit.ly/dotboost-zce) listing. This is part of the company's commitment to ensure that clients have access to top level PHP development as standard practice, and it allows the team to follow a strong quality assurance program. The next step is to implement Zend Framework 2 best practices and to take the upcoming Zend Framework 2 Certified Architect exam. diff --git a/public/md-articles/php-development/zend-server-5-5-quick-setup-on-windows.md b/public/md-articles/php-development/zend-server-5-5-quick-setup-on-windows.md index aced37fb..57e704e2 100644 --- a/public/md-articles/php-development/zend-server-5-5-quick-setup-on-windows.md +++ b/public/md-articles/php-development/zend-server-5-5-quick-setup-on-windows.md @@ -27,7 +27,7 @@ In order to make a fresh installation of Zend Server 5.5.0 on Windows 7 usable a - Set error reporting: display_errors to on, and error_reporting to -1. 3. Enable APC. - APC is listed as enabled in the Zend Server admin interface, but the app dll file is missing, so APC isn't actually working. - - Download the latest php_apc dll file from [here](http://windows.php.net/downloads/pecl/releases/apc/) - at the time of writing, php_apc-3.1.5-5.3-nts-vc9-x86.zip was the latest non-thread-safe VC9 build available. + - Download the latest php_apc dll file from [here](https://windows.php.net/downloads/pecl/releases/apc/) - at the time of writing, php_apc-3.1.5-5.3-nts-vc9-x86.zip was the latest non-thread-safe VC9 build available. - Extract php_apc.dll to ZEND_INSTALATION_PATH/ZendServer/lib/phpext/. - Open the php.ini file from ZEND_INSTALATION_PATH/ZendServer/etc/php.ini and add the line extension=php_apc.dll. - Restart the Apache service. diff --git a/public/md-articles/zend-framework/scienta-zf-debug-bar-a-very-helpfull-zf-debug-tool.md b/public/md-articles/zend-framework/scienta-zf-debug-bar-a-very-helpfull-zf-debug-tool.md index 80487470..ffeee4c4 100644 --- a/public/md-articles/zend-framework/scienta-zf-debug-bar-a-very-helpfull-zf-debug-tool.md +++ b/public/md-articles/zend-framework/scienta-zf-debug-bar-a-very-helpfull-zf-debug-tool.md @@ -16,7 +16,7 @@ The author came across the Scienta ZF Debug Bar, a debugging tool for Zend Frame Dotkernel had been relying on its own basic debug bar, which only showed queries, time spent, and memory used. Finding Scienta far more complex and polished than their internal tool, the Dotkernel team decided to switch to it and integrate it into the Dotkernel code base. -Just found today a very interesting and helpful debug tool: [Scienta](http://jokke.dk/software/scientadebugbar). +Just found today a very interesting and helpful debug tool: [Scienta](https://jokke.dk/software/scientadebugbar). We at Dotkernel used some very basic debug bar: queries, time spent, memory used. But this Scienta is way more complex and nicer than our internal code, so we switch to it and integrate it in Dotkernel code base. diff --git a/public/md-articles/zend-framework/zend-framework-1-12-4-released-with-security-fixes.md b/public/md-articles/zend-framework/zend-framework-1-12-4-released-with-security-fixes.md index 139749c6..374939e0 100644 --- a/public/md-articles/zend-framework/zend-framework-1-12-4-released-with-security-fixes.md +++ b/public/md-articles/zend-framework/zend-framework-1-12-4-released-with-security-fixes.md @@ -15,19 +15,19 @@ language: "en" Matthew Weier O'Phinney announced the release of Zend Framework 1.12.4, along with 2.1.6 and 2.2.6, all containing security updates, and the ZF PEAR channel was updated to the latest 1.12.4 release. A March 7, 2014 edit notes that Zend Framework 1.12.5 was subsequently released to fix a backward compatibility issue introduced in the 1.12.4 release. -[Matthew Weier O'Phinney](http://mwop.net/) just announced the release of ZF 1.12.4, 2.1.6, and 2.2.6 with Security Updates. +[Matthew Weier O'Phinney](https://mwop.net/) just announced the release of ZF 1.12.4, 2.1.6, and 2.2.6 with Security Updates. -For more information, please read the official release announcement: [Zend Framework Released](http://framework.zend.com/blog/zend-framework-1-12-4-2-1-6-and-2-2-6-released.html) +For more information, please read the official release announcement: [Zend Framework Released](https://framework.zend.com/blog/zend-framework-1-12-4-2-1-6-and-2-2-6-released.html) Also, the ZF PEAR channel was updated to latest 1.12.4 release. -[pear upgrade zend/zend](http://code.google.com/p/zend/) +[pear upgrade zend/zend](https://code.google.com/p/zend/) March 7, 2014 EDIT: Zend Framework 1.12.5 was released, in order to fix a backward compatibility issue introduced in 1.12.4 release. -[Release Announcement](http://framework.zend.com/blog/zend-framework-1-12-5-released.html) +[Release Announcement](https://framework.zend.com/blog/zend-framework-1-12-5-released.html) ## FAQ diff --git a/public/md-articles/zend-framework/zend-framework-1-7-0-released.md b/public/md-articles/zend-framework/zend-framework-1-7-0-released.md index 029f9b39..88111479 100644 --- a/public/md-articles/zend-framework/zend-framework-1-7-0-released.md +++ b/public/md-articles/zend-framework/zend-framework-1-7-0-released.md @@ -16,8 +16,8 @@ Zend Framework 1.7.0 has been released, and its headline feature is support for The release is available directly from the official Zend Framework download page. At first glance, the biggest news is AMF support. -This brings [Adobe's Action Message Format protocol to your PHP 5 application](http://framework.zend.com/manual/1.12/en/zend.amf.server.html). -You can [download the latest ZF](http://framework.zend.com/download/latest) release directly. +This brings [Adobe's Action Message Format protocol to your PHP 5 application](https://framework.zend.com/manual/1.12/en/zend.amf.server.html). +You can [download the latest ZF](https://framework.zend.com/download/latest) release directly. ## FAQ diff --git a/public/md-articles/zend-framework/zend-framework-as-pear-accessible-repository-on-plesk-server.md b/public/md-articles/zend-framework/zend-framework-as-pear-accessible-repository-on-plesk-server.md index d436ccb0..158bd8a0 100644 --- a/public/md-articles/zend-framework/zend-framework-as-pear-accessible-repository-on-plesk-server.md +++ b/public/md-articles/zend-framework/zend-framework-as-pear-accessible-repository-on-plesk-server.md @@ -22,7 +22,7 @@ Also that way we can forget about the need to update ZF at latest versions, and Of course, backward compatibility can be an issue in future ZF releases (like the 2.0 branch for PHP > 5.3). 1. Install PEAR if it is not installed already. -2. Follow the instructions for [ZF PEAR](http://code.google.com/p/zend/). +2. Follow the instructions for [ZF PEAR](https://code.google.com/p/zend/). Then simply use: `pear install zend/zend` 3. Create a vhosts.conf file in /var/www/vhosts/dotkernel.com/conf or wherever your vhost configuration folder is located. 4. In that file, remove the open_basedir: diff --git a/public/md-articles/zend-framework/zend-mail-and-zend-http-security-fixes-in-zend-framework-1-12-12.md b/public/md-articles/zend-framework/zend-mail-and-zend-http-security-fixes-in-zend-framework-1-12-12.md index 48a41320..8099f39f 100644 --- a/public/md-articles/zend-framework/zend-mail-and-zend-http-security-fixes-in-zend-framework-1-12-12.md +++ b/public/md-articles/zend-framework/zend-mail-and-zend-http-security-fixes-in-zend-framework-1-12-12.md @@ -17,7 +17,7 @@ Consumers of these components, including Dotkernel which relies heavily on Zend_ A follow-up release, 1.12.13, was issued shortly after to fix a regression introduced in 1.12.12. The release of ZF 1.12.12 was just announced, with security updates especially on the Zend_Mail and Zend_Http components. -For more information, please read the official release announcement: [Zend Framework 1.12.12 Released](http://bit.ly/1Hr0K6e). +For more information, please read the official release announcement: [Zend Framework 1.12.12 Released](https://bit.ly/1Hr0K6e). Also, the ZF PEAR channel was updated to the latest 1.12.12 release. @@ -31,7 +31,7 @@ If you cannot, you can download the patch separately and apply it to your ZF ins May 20, 2015 EDIT: Zend Framework 1.12.13 was released, in order to fix a regression issue introduced in the 1.12.12 release. -See the [Release Announcement](http://framework.zend.com/blog/zend-framework-1-12-13-released.html). +See the [Release Announcement](https://framework.zend.com/blog/zend-framework-1-12-13-released.html). ## FAQ From 2ebd469714b320514982e99463ddbaef2484df8f Mon Sep 17 00:00:00 2001 From: bidi47 Date: Thu, 1 Oct 2026 22:10:03 +0300 Subject: [PATCH 19/22] updated wsl2 page - added comparison table Signed-off-by: bidi47 --- public/images/app/stack/ddev.svg | 1 + public/images/app/stack/docker.svg | 1 + public/images/app/stack/laragon.svg | 1 + public/images/app/stack/wsl2.svg | 1 + public/images/app/stack/xampp.svg | 1 + public/wsl2.md | 25 +++++-- src/App/assets/images/stack/ddev.svg | 1 + src/App/assets/images/stack/docker.svg | 1 + src/App/assets/images/stack/laragon.svg | 1 + src/App/assets/images/stack/wsl2.svg | 1 + src/App/assets/images/stack/xampp.svg | 1 + src/Page/templates/page/wsl2.html.twig | 98 +++++++++++++++++++++++++ 12 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 public/images/app/stack/ddev.svg create mode 100644 public/images/app/stack/docker.svg create mode 100644 public/images/app/stack/laragon.svg create mode 100644 public/images/app/stack/wsl2.svg create mode 100644 public/images/app/stack/xampp.svg create mode 100644 src/App/assets/images/stack/ddev.svg create mode 100644 src/App/assets/images/stack/docker.svg create mode 100644 src/App/assets/images/stack/laragon.svg create mode 100644 src/App/assets/images/stack/wsl2.svg create mode 100644 src/App/assets/images/stack/xampp.svg diff --git a/public/images/app/stack/ddev.svg b/public/images/app/stack/ddev.svg new file mode 100644 index 00000000..03abca10 --- /dev/null +++ b/public/images/app/stack/ddev.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/app/stack/docker.svg b/public/images/app/stack/docker.svg new file mode 100644 index 00000000..e68a6613 --- /dev/null +++ b/public/images/app/stack/docker.svg @@ -0,0 +1 @@ +Docker \ No newline at end of file diff --git a/public/images/app/stack/laragon.svg b/public/images/app/stack/laragon.svg new file mode 100644 index 00000000..92f1b00c --- /dev/null +++ b/public/images/app/stack/laragon.svg @@ -0,0 +1 @@ +Laragon \ No newline at end of file diff --git a/public/images/app/stack/wsl2.svg b/public/images/app/stack/wsl2.svg new file mode 100644 index 00000000..9387b90c --- /dev/null +++ b/public/images/app/stack/wsl2.svg @@ -0,0 +1 @@ +Windows \ No newline at end of file diff --git a/public/images/app/stack/xampp.svg b/public/images/app/stack/xampp.svg new file mode 100644 index 00000000..d240e007 --- /dev/null +++ b/public/images/app/stack/xampp.svg @@ -0,0 +1 @@ +XAMPP \ No newline at end of file diff --git a/public/wsl2.md b/public/wsl2.md index b3b49c85..860c4b69 100644 --- a/public/wsl2.md +++ b/public/wsl2.md @@ -15,16 +15,31 @@ One Ansible playbook installs PHP, Apache, MariaDB, Composer, Node.js and phpMyA - [Read the docs](https://docs.dotkernel.org/development/v2/terminal/) - [View on GitHub](https://github.com/dotkernel/development/tree/alma-linux-10) -| | | -| --- | --- | -| Distro | AlmaLinux 10 | -| Provisioning | Ansible | -| Host | WSL 2 or bare metal | +| | | +|--------------|---------------------| +| Distro | AlmaLinux 10 | +| Provisioning | Ansible | +| Host | WSL 2 or bare metal | ## Terminal to running stack Terminal -> WSL 2 -> AlmaLinux -> Ansible -> Ready. +## Comparison with Popular Stacks + +Here is a direct comparison between WSL2, XAMPP, Laragon, DDEV, and Docker to help you choose the best development environment for your workflow. + +| Feature | WSL2 | XAMPP | Laragon | DDEV | Docker | +|---------------------------|------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Primary Use Case** | Running a real Linux environment on Windows via a managed VM. | Quick all-in-one Apache/MariaDB/PHP/Perl stack for local development. | Fast, portable PHP/Node development stack on Windows. | Per-project PHP/CMS (and Node.js) development environments built on Docker. | General-purpose containerization for any tech stack. | +| **Core Architecture** | Real Linux kernel in a lightweight utility VM; distros run as isolated containers inside it. | Native executables run as host processes (optionally as services). | Portable native Windows binaries; doesn't use Windows Services. | Orchestration layer that generates and runs Docker containers per project, plus a shared router. | Containers sharing the host kernel on Linux; on Windows/macOS, Docker Desktop runs the engine inside a Linux VM (WSL2/Hyper-V or Apple Virtualization). | +| **Depends On** | Windows virtualization support. | Nothing. | Nothing. | A Docker provider (Docker CE, Docker Desktop, OrbStack, Rancher, etc.); on Windows, usually WSL2. | Native on Linux; WSL2 or Hyper-V on Windows; a VM on macOS. | +| **OS Support** | Windows 10 (1903+, build 18362+), Windows 11, Windows Server. | Windows and Linux (PHP up to 8.2.12); macOS build lags (PHP up to 8.2.4); XAMPP-VM unsupported on Apple Silicon. | Windows only. | macOS, Windows (WSL2 or traditional), Linux, GitHub Codespaces. | Windows, macOS, Linux. | +| **Resource Usage** | Low to Medium (small footprint at idle; memory grows dynamically, capped by default at a share of host RAM). | Low (runs directly on host processes). | Very Low (lightweight, no background services). | Medium to High (Docker overhead plus several containers per project; varies by provider). | Low on native Linux; Medium to High with Docker Desktop's VM. | +| **Environment Isolation** | Partial (separate Linux filesystem and kernel, but deliberately integrated with Windows files, networking, and executables). | None (shared global install, ports, and PHP version). | Self-contained install, no per-project isolation (projects share services and the active PHP version). | Container-level per project (own web and DB containers), with a shared router on ports 80/443. | Container-level (process, filesystem, and network namespaces; shared kernel, so weaker than a VM). | +| **Production Parity** | Depends on what you install (it's a Linux OS, not a stack). | Low (Windows paths and config differ from production; vendor states it's not for production). | Low (Windows paths differ from Linux servers). | High if versions are matched (standardized Linux containers, not your production images). | High; identical only if the exact same image is deployed to production. | +| **License / Cost** | Free (part of Windows). | Free, open source. | Laragon 6 free; versions 7+ require a paid license. | Free, open source. | Docker Engine free and open source; Docker Desktop requires a paid subscription for larger businesses. | + ## The same OS family your servers run AlmaLinux is a RHEL-compatible distribution, so the packages, package manager and service conventions you use locally - `dnf`, `systemd` and the rest - are the ones you will see again in staging and production. diff --git a/src/App/assets/images/stack/ddev.svg b/src/App/assets/images/stack/ddev.svg new file mode 100644 index 00000000..03abca10 --- /dev/null +++ b/src/App/assets/images/stack/ddev.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/App/assets/images/stack/docker.svg b/src/App/assets/images/stack/docker.svg new file mode 100644 index 00000000..e68a6613 --- /dev/null +++ b/src/App/assets/images/stack/docker.svg @@ -0,0 +1 @@ +Docker \ No newline at end of file diff --git a/src/App/assets/images/stack/laragon.svg b/src/App/assets/images/stack/laragon.svg new file mode 100644 index 00000000..92f1b00c --- /dev/null +++ b/src/App/assets/images/stack/laragon.svg @@ -0,0 +1 @@ +Laragon \ No newline at end of file diff --git a/src/App/assets/images/stack/wsl2.svg b/src/App/assets/images/stack/wsl2.svg new file mode 100644 index 00000000..9387b90c --- /dev/null +++ b/src/App/assets/images/stack/wsl2.svg @@ -0,0 +1 @@ +Windows \ No newline at end of file diff --git a/src/App/assets/images/stack/xampp.svg b/src/App/assets/images/stack/xampp.svg new file mode 100644 index 00000000..d240e007 --- /dev/null +++ b/src/App/assets/images/stack/xampp.svg @@ -0,0 +1 @@ +XAMPP \ No newline at end of file diff --git a/src/Page/templates/page/wsl2.html.twig b/src/Page/templates/page/wsl2.html.twig index 171df7a6..473ea2dc 100644 --- a/src/Page/templates/page/wsl2.html.twig +++ b/src/Page/templates/page/wsl2.html.twig @@ -22,6 +22,12 @@ .spec-table tbody tr:last-child th, .spec-table tbody tr:last-child td { border-bottom: 0; } .spec-table tbody th { font-weight: 500; color: var(--text-muted); white-space: nowrap; } .spec-table td { color: var(--text); } + .spec-table--compare { min-width: 1100px; } + .spec-table--compare tbody th { white-space: normal; min-width: 150px; position: sticky; left: 0; background: var(--surface-2); } + .spec-table--compare thead th:first-child { position: sticky; left: 0; } + .spec-table--compare thead th:nth-child(2), .spec-table--compare tbody td:nth-child(2) { background: var(--page-accent-dim, var(--sage-dim)); } + .stack-name { display: inline-flex; align-items: center; gap: 8px; } + .stack-logo { width: 20px; height: 20px; object-fit: contain; flex: none; } .spec-note { margin-top: 14px; font-size: 13.5px; color: var(--text-dim); } .step-cmd { display: block; margin-top: 12px; font-family: var(--mono); font-size: 12.5px; color: var(--teal); overflow-x: auto; } pre.step-cmd { margin-bottom: 0; white-space: pre; } @@ -120,6 +126,98 @@ +
    +
    +
    + Comparison +

    Comparison with Popular Stacks

    +

    Here is a direct comparison between WSL2, XAMPP, Laragon, DDEV, and Docker to help you choose the best + development environment for your workflow.

    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FeatureWSL2XAMPPLaragonDDEVDocker
    Primary Use CaseRunning a real Linux environment on Windows via a managed VM.Quick all-in-one Apache/MariaDB/PHP/Perl stack for local development.Fast, portable PHP/Node development stack on Windows.Per-project PHP/CMS (and Node.js) development environments built on Docker.General-purpose containerization for any tech stack.
    Core ArchitectureReal Linux kernel in a lightweight utility VM; distros run as isolated containers inside it.Native executables run as host processes (optionally as services).Portable native Windows binaries; doesn't use Windows Services.Orchestration layer that generates and runs Docker containers per project, plus a shared router.Containers sharing the host kernel on Linux; on Windows/macOS, Docker Desktop runs the engine inside a Linux VM (WSL2/Hyper-V or Apple Virtualization).
    Depends OnWindows virtualization support.Nothing.Nothing.A Docker provider (Docker CE, Docker Desktop, OrbStack, Rancher, etc.); on Windows, usually WSL2.Native on Linux; WSL2 or Hyper-V on Windows; a VM on macOS.
    OS SupportWindows 10 (1903+, build 18362+), Windows 11, Windows Server.Windows and Linux (PHP up to 8.2.12); macOS build lags (PHP up to 8.2.4); XAMPP-VM unsupported on Apple Silicon.Windows only.macOS, Windows (WSL2 or traditional), Linux, GitHub Codespaces.Windows, macOS, Linux.
    Resource UsageLow to Medium (small footprint at idle; memory grows dynamically, capped by default at a share of host RAM).Low (runs directly on host processes).Very Low (lightweight, no background services).Medium to High (Docker overhead plus several containers per project; varies by provider).Low on native Linux; Medium to High with Docker Desktop's VM.
    Environment IsolationPartial (separate Linux filesystem and kernel, but deliberately integrated with Windows files, networking, and executables).None (shared global install, ports, and PHP version).Self-contained install, no per-project isolation (projects share services and the active PHP version).Container-level per project (own web and DB containers), with a shared router on ports 80/443.Container-level (process, filesystem, and network namespaces; shared kernel, so weaker than a VM).
    Production ParityDepends on what you install (it's a Linux OS, not a stack).Low (Windows paths and config differ from production; vendor states it's not for production).Low (Windows paths differ from Linux servers).High if versions are matched (standardized Linux containers, not your production images).High; identical only if the exact same image is deployed to production.
    License / CostFree (part of Windows).Free, open source.Laragon 6 free; versions 7+ require a paid license.Free, open source.Docker Engine free and open source; Docker Desktop requires a paid subscription for larger businesses.
    +
    +
    +
    +
    From d78591924d2dd1dd879f2e222a44ceb80bbb9951 Mon Sep 17 00:00:00 2001 From: bidi47 Date: Fri, 2 Oct 2026 17:13:54 +0300 Subject: [PATCH 20/22] updated wsl2 page - comparison table Signed-off-by: bidi47 --- public/wsl2.md | 26 ++++++++-------- src/Page/templates/page/wsl2.html.twig | 42 ++++++++++++++++++-------- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/public/wsl2.md b/public/wsl2.md index 860c4b69..ab9f5e0b 100644 --- a/public/wsl2.md +++ b/public/wsl2.md @@ -27,18 +27,20 @@ Terminal -> WSL 2 -> AlmaLinux -> Ansible -> Ready. ## Comparison with Popular Stacks -Here is a direct comparison between WSL2, XAMPP, Laragon, DDEV, and Docker to help you choose the best development environment for your workflow. - -| Feature | WSL2 | XAMPP | Laragon | DDEV | Docker | -|---------------------------|------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Primary Use Case** | Running a real Linux environment on Windows via a managed VM. | Quick all-in-one Apache/MariaDB/PHP/Perl stack for local development. | Fast, portable PHP/Node development stack on Windows. | Per-project PHP/CMS (and Node.js) development environments built on Docker. | General-purpose containerization for any tech stack. | -| **Core Architecture** | Real Linux kernel in a lightweight utility VM; distros run as isolated containers inside it. | Native executables run as host processes (optionally as services). | Portable native Windows binaries; doesn't use Windows Services. | Orchestration layer that generates and runs Docker containers per project, plus a shared router. | Containers sharing the host kernel on Linux; on Windows/macOS, Docker Desktop runs the engine inside a Linux VM (WSL2/Hyper-V or Apple Virtualization). | -| **Depends On** | Windows virtualization support. | Nothing. | Nothing. | A Docker provider (Docker CE, Docker Desktop, OrbStack, Rancher, etc.); on Windows, usually WSL2. | Native on Linux; WSL2 or Hyper-V on Windows; a VM on macOS. | -| **OS Support** | Windows 10 (1903+, build 18362+), Windows 11, Windows Server. | Windows and Linux (PHP up to 8.2.12); macOS build lags (PHP up to 8.2.4); XAMPP-VM unsupported on Apple Silicon. | Windows only. | macOS, Windows (WSL2 or traditional), Linux, GitHub Codespaces. | Windows, macOS, Linux. | -| **Resource Usage** | Low to Medium (small footprint at idle; memory grows dynamically, capped by default at a share of host RAM). | Low (runs directly on host processes). | Very Low (lightweight, no background services). | Medium to High (Docker overhead plus several containers per project; varies by provider). | Low on native Linux; Medium to High with Docker Desktop's VM. | -| **Environment Isolation** | Partial (separate Linux filesystem and kernel, but deliberately integrated with Windows files, networking, and executables). | None (shared global install, ports, and PHP version). | Self-contained install, no per-project isolation (projects share services and the active PHP version). | Container-level per project (own web and DB containers), with a shared router on ports 80/443. | Container-level (process, filesystem, and network namespaces; shared kernel, so weaker than a VM). | -| **Production Parity** | Depends on what you install (it's a Linux OS, not a stack). | Low (Windows paths and config differ from production; vendor states it's not for production). | Low (Windows paths differ from Linux servers). | High if versions are matched (standardized Linux containers, not your production images). | High; identical only if the exact same image is deployed to production. | -| **License / Cost** | Free (part of Windows). | Free, open source. | Laragon 6 free; versions 7+ require a paid license. | Free, open source. | Docker Engine free and open source; Docker Desktop requires a paid subscription for larger businesses. | +Here is a direct comparison between the Dotkernel WSL2 stack, XAMPP, Laragon, DDEV, and Docker to help you choose the best development environment for your workflow. + +| Feature | Dotkernel WSL2 (AlmaLinux 10) | XAMPP | Laragon | DDEV | Docker | +|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Primary Use Case** | RHEL-compatible PHP stack on Windows, provisioned by one Ansible playbook. | Quick all-in-one Apache/MariaDB/PHP/Perl stack for local development. | Fast, portable PHP/Node development stack on Windows. | Per-project PHP/CMS (and Node.js) development environments built on Docker. | General-purpose containerization for any tech stack. | +| **Core Architecture** | AlmaLinux 10 running in WSL 2's lightweight utility VM (real Linux kernel), or directly on bare metal. Services run natively inside the distro. | Native executables run as host processes (optionally as services). | Portable native Windows binaries; doesn't use Windows Services. | Orchestration layer that generates and runs Docker containers per project, plus a shared router. | Containers sharing the host kernel on Linux; on Windows/macOS, Docker Desktop runs the engine inside a Linux VM (WSL2/Hyper-V or Apple Virtualization). | +| **Depends On** | Windows virtualization support (WSL 2); nothing extra on bare AlmaLinux 10. | Nothing. | Nothing. | A Docker provider (Docker CE, Docker Desktop, OrbStack, Rancher, etc.); on Windows, usually WSL2. | Native on Linux; WSL2 or Hyper-V on Windows; a VM on macOS. | +| **OS Support** | Windows 10 1903+ (x64) or 2004+ (ARM64), Windows 11, Windows Server 2022/2025; bare-metal AlmaLinux 10. | Windows and Linux (PHP up to 8.2.12); macOS build lags (PHP up to 8.2.4). | Windows only. | macOS, Windows (WSL2 or traditional), Linux, GitHub Codespaces. | Windows, macOS, Linux. | +| **Resource Usage** | Low to Medium (small idle footprint; WSL 2 memory grows dynamically, capped by default at a share of host RAM). | Low (runs directly on host processes). | Very Low (lightweight, no background services). | Medium to High (Docker overhead plus several containers per project; varies by provider). | Low on native Linux; Medium to High with Docker Desktop's VM. | +| **Environment Isolation** | Separate Linux filesystem and kernel from Windows; projects share one Apache/PHP/MariaDB install, each with its own `*.localhost` virtualhost. | None (shared global install, ports, and PHP version). | Self-contained install, no per-project isolation (projects share services and the active PHP version). | Container-level per project (own web and DB containers), with a shared router on ports 80/443. | Container-level (process, filesystem, and network namespaces; shared kernel, so weaker than a VM). | +| **Production Parity** | High for RHEL-family servers (same `dnf`, `systemd`, and package sources as AlmaLinux/Rocky/RHEL production); not image-identical. | Low (Windows paths and config differ from production; vendor states it's not for production). | Low (Windows paths differ from Linux servers). | High if versions are matched (standardized Linux containers, not your production images). | High; identical only if the exact same image is deployed to production. | +| **PHP Version Switching** | `php81` … `php85` aliases (Remi modules). | One PHP version per install; switching means a different installer. | Switch between installed PHP versions from the menu. | Per-project `php_version` in config. | Per image / per container. | +| **Default Versions** | PHP 8.5, MariaDB 12.3 LTS, Node.js 24. | PHP 8.2.12, MariaDB 10.4.32 (Windows build). | User-selectable. | Configurable per project. | Defined by the image. | +| **License / Cost** | Free, open source (MIT). | Free, open source. | Laragon 6 free; 7+ free for non-commercial use (with license reminders), paid license for commercial use. | Free, open source. | Docker Engine free and open source; Docker Desktop requires a paid subscription for orgs with 250+ employees or $10M+ revenue. | ## The same OS family your servers run diff --git a/src/Page/templates/page/wsl2.html.twig b/src/Page/templates/page/wsl2.html.twig index 473ea2dc..681ef1f9 100644 --- a/src/Page/templates/page/wsl2.html.twig +++ b/src/Page/templates/page/wsl2.html.twig @@ -131,7 +131,7 @@
    Comparison

    Comparison with Popular Stacks

    -

    Here is a direct comparison between WSL2, XAMPP, Laragon, DDEV, and Docker to help you choose the best +

    Here is a direct comparison between the Dotkernel WSL2 stack, XAMPP, Laragon, DDEV, and Docker to help you choose the best development environment for your workflow.

    @@ -140,7 +140,7 @@ Feature - WSL2 + Dotkernel WSL2 (AlmaLinux 10) XAMPP Laragon DDEV @@ -150,7 +150,7 @@ Primary Use Case - Running a real Linux environment on Windows via a managed VM. + RHEL-compatible PHP stack on Windows, provisioned by one Ansible playbook. Quick all-in-one Apache/MariaDB/PHP/Perl stack for local development. Fast, portable PHP/Node development stack on Windows. Per-project PHP/CMS (and Node.js) development environments built on Docker. @@ -158,7 +158,7 @@ Core Architecture - Real Linux kernel in a lightweight utility VM; distros run as isolated containers inside it. + AlmaLinux 10 running in WSL 2's lightweight utility VM (real Linux kernel), or directly on bare metal. Services run natively inside the distro. Native executables run as host processes (optionally as services). Portable native Windows binaries; doesn't use Windows Services. Orchestration layer that generates and runs Docker containers per project, plus a shared router. @@ -166,7 +166,7 @@ Depends On - Windows virtualization support. + Windows virtualization support (WSL 2); nothing extra on bare AlmaLinux 10. Nothing. Nothing. A Docker provider (Docker CE, Docker Desktop, OrbStack, Rancher, etc.); on Windows, usually WSL2. @@ -174,15 +174,15 @@ OS Support - Windows 10 (1903+, build 18362+), Windows 11, Windows Server. - Windows and Linux (PHP up to 8.2.12); macOS build lags (PHP up to 8.2.4); XAMPP-VM unsupported on Apple Silicon. + Windows 10 1903+ (x64) or 2004+ (ARM64), Windows 11, Windows Server 2022/2025; bare-metal AlmaLinux 10. + Windows and Linux (PHP up to 8.2.12); macOS build lags (PHP up to 8.2.4). Windows only. macOS, Windows (WSL2 or traditional), Linux, GitHub Codespaces. Windows, macOS, Linux. Resource Usage - Low to Medium (small footprint at idle; memory grows dynamically, capped by default at a share of host RAM). + Low to Medium (small idle footprint; WSL 2 memory grows dynamically, capped by default at a share of host RAM). Low (runs directly on host processes). Very Low (lightweight, no background services). Medium to High (Docker overhead plus several containers per project; varies by provider). @@ -190,7 +190,7 @@ Environment Isolation - Partial (separate Linux filesystem and kernel, but deliberately integrated with Windows files, networking, and executables). + Separate Linux filesystem and kernel from Windows; projects share one Apache/PHP/MariaDB install, each with its own *.localhost virtualhost. None (shared global install, ports, and PHP version). Self-contained install, no per-project isolation (projects share services and the active PHP version). Container-level per project (own web and DB containers), with a shared router on ports 80/443. @@ -198,19 +198,35 @@ Production Parity - Depends on what you install (it's a Linux OS, not a stack). + High for RHEL-family servers (same dnf, systemd, and package sources as AlmaLinux/Rocky/RHEL production); not image-identical. Low (Windows paths and config differ from production; vendor states it's not for production). Low (Windows paths differ from Linux servers). High if versions are matched (standardized Linux containers, not your production images). High; identical only if the exact same image is deployed to production. + + PHP Version Switching + php81 … php85 aliases (Remi modules). + One PHP version per install; switching means a different installer. + Switch between installed PHP versions from the menu. + Per-project php_version in config. + Per image / per container. + + + Default Versions + PHP 8.5, MariaDB 12.3 LTS, Node.js 24. + PHP 8.2.12, MariaDB 10.4.32 (Windows build). + User-selectable. + Configurable per project. + Defined by the image. + License / Cost - Free (part of Windows). + Free, open source (MIT). Free, open source. - Laragon 6 free; versions 7+ require a paid license. + Laragon 6 free; 7+ free for non-commercial use (with license reminders), paid license for commercial use. Free, open source. - Docker Engine free and open source; Docker Desktop requires a paid subscription for larger businesses. + Docker Engine free and open source; Docker Desktop requires a paid subscription for orgs with 250+ employees or $10M+ revenue. From f0f6c5e8550f15b7eefbb7c1ed06ab4f707c4686 Mon Sep 17 00:00:00 2001 From: bidi47 Date: Fri, 2 Oct 2026 18:03:12 +0300 Subject: [PATCH 21/22] added star button Signed-off-by: bidi47 --- src/Page/templates/page/wsl2.html.twig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Page/templates/page/wsl2.html.twig b/src/Page/templates/page/wsl2.html.twig index 681ef1f9..6fbf33e3 100644 --- a/src/Page/templates/page/wsl2.html.twig +++ b/src/Page/templates/page/wsl2.html.twig @@ -10,7 +10,11 @@ {% block title %}Development Environment{% endblock %} -{% block head_links %}{% endblock %} +{% block head_links %} + + + +{% endblock %} {% block stylesheets %}