commit c22b2b8ccd93234db72de01155e02ee5e7f5baac Author: Ugo Finnendahl Date: Mon Oct 14 20:06:38 2024 +0200 first version diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..31cb096 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +[*.{css,scss,less,js,json,ts,sass,html,hbs,mustache,phtml,html.twig,md,yml}] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +indent_size = 4 +trim_trailing_whitespace = false + +[site/templates/**.php] +indent_size = 2 + +[site/snippets/**.php] +indent_size = 2 + +[package.json,.{babelrc,editorconfig,eslintrc,lintstagedrc,stylelintrc}] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e371e8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# System files +# ------------ + +Icon +.DS_Store + +# Temporary files +# --------------- + +/media/* +!/media/index.html + +# Lock files +# --------------- + +.lock + +# Editors +# (sensitive workspace files) +# --------------------------- +*.sublime-workspace +/.vscode +/.idea + +# -------------SECURITY------------- +# NEVER publish these files via Git! +# -------------SECURITY------------- + +# Cache Files +# --------------- + +/site/cache/* +!/site/cache/index.html + +# Accounts +# --------------- + +/site/accounts/* +!/site/accounts/index.html + +# Sessions +# --------------- + +/site/sessions/* +!/site/sessions/index.html + +# License +# --------------- + +/site/config/.license diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..5fe5c71 --- /dev/null +++ b/.htaccess @@ -0,0 +1,67 @@ +# Kirby .htaccess +# revision 2023-07-22 + +# rewrite rules + + +# enable awesome urls. i.e.: +# http://yourdomain.com/about-us/team +RewriteEngine on + +# make sure to set the RewriteBase correctly +# if you are running the site in a subfolder; +# otherwise links or the entire site will break. +# +# If your homepage is http://yourdomain.com/mysite, +# set the RewriteBase to: +# +# RewriteBase /mysite + +# In some environments it's necessary to +# set the RewriteBase to: +# +# RewriteBase / + +# block files and folders beginning with a dot, such as .git +# except for the .well-known folder, which is used for Let's Encrypt and security.txt +RewriteRule (^|/)\.(?!well-known\/) index.php [L] + +# block all files in the content folder from being accessed directly +RewriteRule ^content/(.*) index.php [L] + +# block all files in the site folder from being accessed directly +RewriteRule ^site/(.*) index.php [L] + +# block direct access to Kirby and the Panel sources +RewriteRule ^kirby/(.*) index.php [L] + +# make site links work +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*) index.php [L] + + + +# pass the Authorization header to PHP +SetEnvIf Authorization "(.+)" HTTP_AUTHORIZATION=$1 + +# compress text file responses + +AddOutputFilterByType DEFLATE text/plain +AddOutputFilterByType DEFLATE text/html +AddOutputFilterByType DEFLATE text/css +AddOutputFilterByType DEFLATE text/javascript +AddOutputFilterByType DEFLATE application/json +AddOutputFilterByType DEFLATE application/javascript +AddOutputFilterByType DEFLATE application/x-javascript + + +# set security headers in all responses + + +# serve files as plain text if the actual content type is not known +# (hardens against attacks from malicious file uploads) +Header set Content-Type "text/plain" "expr=-z %{CONTENT_TYPE}" +Header set X-Content-Type-Options "nosniff" + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..17e99f5 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ + + + +**Kirby: the CMS that adapts to any project, loved by developers and editors alike.** +The Plainkit is a minimal Kirby setup with the basics you need to start a project from scratch. It is the ideal choice if you are already familiar with Kirby and want to start step-by-step. + +You can learn more about Kirby at [getkirby.com](https://getkirby.com). + +### Try Kirby for free +You can try Kirby and the Plainkit on your local machine or on a test server as long as you need to make sure it is the right tool for your next project. … and when you’re convinced, [buy your license](https://getkirby.com/buy). + +### Get going +Read our guide on [how to get started with Kirby](https://getkirby.com/docs/guide/quickstart). + +You can [download the latest version](https://github.com/getkirby/plainkit/archive/main.zip) of the Plainkit. +If you are familiar with Git, you can clone Kirby's Plainkit repository from Github. + + git clone https://github.com/getkirby/plainkit.git + +## What's Kirby? +- **[getkirby.com](https://getkirby.com)** – Get to know the CMS. +- **[Try it](https://getkirby.com/try)** – Take a test ride with our online demo. Or download one of our kits to get started. +- **[Documentation](https://getkirby.com/docs/guide)** – Read the official guide, reference and cookbook recipes. +- **[Issues](https://github.com/getkirby/kirby/issues)** – Report bugs and other problems. +- **[Feedback](https://feedback.getkirby.com)** – You have an idea for Kirby? Share it. +- **[Forum](https://forum.getkirby.com)** – Whenever you get stuck, don't hesitate to reach out for questions and support. +- **[Discord](https://chat.getkirby.com)** – Hang out and meet the community. +- **[Mastodon](https://mastodon.social/@getkirby)** – Spread the word. +- **[Instagram](https://www.instagram.com/getkirby/)** – Share your creations: #madewithkirby. + +--- + +© 2009-2022 Bastian Allgeier +[getkirby.com](https://getkirby.com) · [License agreement](https://getkirby.com/license) diff --git a/assets/css/src/font.scss b/assets/css/src/font.scss new file mode 100644 index 0000000..736f42f --- /dev/null +++ b/assets/css/src/font.scss @@ -0,0 +1,107 @@ +/* open-sans-300 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: normal; + font-weight: 300; + src: url('../fonts/opensans/open-sans-v40-latin-300.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* open-sans-300italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: italic; + font-weight: 300; + src: url('../fonts/opensans/open-sans-v40-latin-300italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* open-sans-regular - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: normal; + font-weight: 400; + src: url('../fonts/opensans/open-sans-v40-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* open-sans-italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: italic; + font-weight: 400; + src: url('../fonts/opensans/open-sans-v40-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* open-sans-500 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: normal; + font-weight: 500; + src: url('../fonts/opensans/open-sans-v40-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* open-sans-500italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: italic; + font-weight: 500; + src: url('../fonts/opensans/open-sans-v40-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* open-sans-600 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: normal; + font-weight: 600; + src: url('../fonts/opensans/open-sans-v40-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* open-sans-600italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: italic; + font-weight: 600; + src: url('../fonts/opensans/open-sans-v40-latin-600italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* open-sans-700 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: normal; + font-weight: 700; + src: url('../fonts/opensans/open-sans-v40-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* open-sans-700italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: italic; + font-weight: 700; + src: url('../fonts/opensans/open-sans-v40-latin-700italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* open-sans-800 - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: normal; + font-weight: 800; + src: url('../fonts/opensans/open-sans-v40-latin-800.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} + +/* open-sans-800italic - latin */ +@font-face { + font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ + font-family: 'Open Sans'; + font-style: italic; + font-weight: 800; + src: url('../fonts/opensans/open-sans-v40-latin-800italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ +} diff --git a/assets/css/src/style.scss b/assets/css/src/style.scss new file mode 100644 index 0000000..9ca8214 --- /dev/null +++ b/assets/css/src/style.scss @@ -0,0 +1,598 @@ +//////////////////////////////////////////////////////////////////////////////// +// SCSS +//////////////////////////////////////////////////////////////////////////////// + +$border: 0.5vh; +$orange: orange; +$shadow: 0.2rem 0.2rem 0.4rem #000; +$text-shadow: 0.1rem 0.1rem 0.2rem rgba(0,0,0,0.8); +$bg: #214d1d; +$darker: rgba(0,0,0,0.3); +$bgcolor: #2f6f2a; +$line: 0.2rem; + +@mixin border { + border-radius: 0.25em; + border: 0.3em black solid; +} + +@mixin button { + @include border; + cursor: pointer; + background-color: $darker; +} + +@mixin button-text{ + color: white; + font-weight: bold; + padding: 0.3em 0.5em; + text-align: center; + display: flex; + align-items: flex-end; + text-shadow: $shadow; + font-family: "Open Sans"; +} +//////////////////////////////////////////////////////////////////////////////// +// Meyerweb Reset +//////////////////////////////////////////////////////////////////////////////// + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + // line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} + +//////////////////////////////////////////////////////////////////////////////// +// General +//////////////////////////////////////////////////////////////////////////////// + +@import "./font"; + +*{ + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root{ + font-size: Min(1.111111111vw, 1.481481481vh); +} + +body{ + margin: 0; + font-family: "Open Sans"; + background-color: black; + // background-color: $bgcolor; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + color: white; + display: flex; + flex-direction: column; + overflow: hidden; +} + +input{ + font-family: "Open Sans"; +} + +main{ + border-left: 0.2rem solid black; + border-right: 0.2rem solid black; + height: 67.5rem; /* 1080"px" */ + width: 90rem; /* 1440"px" */ + background-color: $bgcolor; + display: flex; + flex-direction: column; +} + +h1,h2,h3,h4,h5,h6{ + font-weight: bold; +} +.game .body{ + height:48rem; +} + + +//////////////////////////////////////////////////////////////////////////////// +// Simple class defs +//////////////////////////////////////////////////////////////////////////////// + +.center{ + display: flex; + justify-content: center; +} + +.hidden{ + display: none !important; +} + +.label{ + font-size: 1.8em; + padding: 0.3em 0; + font-weight: bold; + display: block; +} + +//////////////////////////////////////////////////////////////////////////////// +// Content +//////////////////////////////////////////////////////////////////////////////// + +.overlay{ + @include border; + position: absolute; + top: 50%; + left: 50%; + z-index: 1000; + transform: translate(-50%,-50%); + width: 45rem; + max-height: 60rem; + overflow-y: auto; + &::-webkit-scrollbar { + width: 0; /* Safari and Chrome */ + } + background-color: $bgcolor; + padding: 2em 1.7em; +} + +.dialog{ + flex-grow: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: auto; + h2{ + font-size: 2.4em; + margin-bottom: 0.5em; + text-shadow: $shadow; + } + p{ + font-size: 1.4em; + margin-bottom: 1em; + text-shadow: $shadow; + } +} + +body.home{ + header{ + display: flex; + justify-content: center; + align-items: center; + height: 42%; + &>img{ + display: block; + width: 50%; + height: auto; + } + } + h1{ + text-align: center; + color: white; + font-size: 2.6rem; + text-shadow: $shadow; + } + .menu{ + flex-grow: 1; + display: flex; + align-items: center; + padding-bottom: 7.5rem; + + .mainmenu{ + width: 100%; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + h1{ + margin-bottom: 1em; + } + } + + } +} + + +nav.list{ + display: flex; + justify-content: center; + flex-grow: 1; + + &.horizontal{ + flex-direction: row; + justify-content: space-evenly; + & > *+*{ + margin-left: 1em; + } + } + &.vertical{ + flex-direction: column; + & > *+*{ + margin-top: 1em; + } + } +} + + +//////////////////////////////////////////////////////////////////////////////// +// Elements +//////////////////////////////////////////////////////////////////////////////// + + +.element{ + @include border; + border-width: 0.3rem; + background-color: $bg; + cursor: pointer; + + &:focus, &:hover{ + outline: none; + border-color: $orange; + } + + &.input{ + font-size: 2em; + padding: 0.3em; + background-color: white; + color: black; + display: block; + width: 100%; + } + + &.player{ + display: flex; + justify-content: flex-start; + align-items: center; + padding: 0.7em; + + h2{ + font-size: 1.3em; + } + + &>*{ + margin-right: 1em; + } + + img{ + height: 3em; + aspect-ratio: 1/1; + object-position: top; + object-fit: cover; + } + } + + &.plain{ + @include button-text; + padding: 1rem 2rem; + justify-content: center; + align-items: center; + box-shadow: 0.25rem 0.25rem 1rem rgba(0,0,0, 0.3); + font-size: 1.6em; + &:hover,&:focus{ + background-color: rgba(0, 0, 0, 0.2); + border-color: $orange; + outline: none; + } + &.back{ + background-color: #5E716A; + } + &.new{ + background-color: darken($bg, 5%); + } + } + &.player { + height: 4.5em; + } + &.square{ + font-size: 1rem; + width: 20em; + height: 20em; + padding: 2.5em 1.25em; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: $bg; + box-shadow: 0.25em 0.25em 1em rgba(0,0,0, 0.3); + cursor: pointer; + h2{ + @include button-text; + font-size: 2em; + padding: 0.6em 0 0 0; + } + &:hover,&:focus{ + background-color: rgba(0, 0, 0, 0.2); + border-color: $orange; + outline: none; + } + .icon{ + width: 65%; + height: auto; + svg{ + width: 100%; + aspect-ratio: 1/1; + object-fit: cover; + fill: white; + filter: drop-shadow($shadow); + } + img{ + width: 100%; + aspect-ratio: 1/1; + object-fit: cover; + object-position: top; + filter: drop-shadow($shadow); + } + } + } +} + + +//////////////////////////////////////////////////////////////////////////////// +// Game Setup +//////////////////////////////////////////////////////////////////////////////// + +body.xoi{ + main{ + display: flex; + justify-content: center; + .gamesetup{ + width: 65%; + margin: 0 auto; + h1{ + font-size: 3em; + text-align: center; + margin-bottom: 0.5em; + } + .menu{ + & > * { + margin-bottom: 1.3em; + } + } + } + .playerselect{ + & > h2{ + font-size: 1.8em; + margin-bottom: 0.7em; + text-align: center; + } + } + } + main .xoi{ + display: grid; + height: 100%; + grid-template-columns: 3fr 3fr 2fr 3fr 3fr; + grid-template-rows: 11.5rem 52rem 4rem; + grid-template-areas: + "player1 toGo1 score toGo2 player2" + "player1 game game game player2" + "navi navi navi navi navi" + ; + background-color: black; + + .bigToGo{ + margin: $line; + background-color: white; + border: 0.7rem solid black; + color: black; + font-weight: bold; + font-size: 7.5em; + display: flex; + justify-content: center; + align-items: center; + &.active{ + border-color: $orange; + } + &.one{ + grid-area: toGo1; + } + &.two{ + grid-area: toGo2; + } + } + .score{ + margin: $line 0; + grid-area: score; + color: white; + text-shadow: $text-shadow; + background-color: $bgcolor; + display: flex; + flex-direction: column; + justify-content: space-evenly; + &>div{ + /*sets and legs*/ + display: flex; + justify-content: space-evenly; + align-items: center; + h2,h3{ + margin:0; + text-align: center; + font-size: 1.3rem; + } + h3{ + font-size: 1.1rem; + } + &>h2{ + font-size: 3.2rem; + } + h2:nth-child(2){ + order:10; + } + } + } + .player{ + background-color: $bgcolor; + margin-bottom: $line/2; + + &.player1{ + grid-area: player1; + } + &.player2{ + grid-area: player2; + } + img{ + width: 100%; + aspect-ratio: 35/45; + object-fit: cover; + } + h2, h3{ + text-align: center; + margin: 0.2em; + text-shadow: $text-shadow; + } + h2{ + font-size: 1.8em; + } + h3{ + font-size: 1.2em; + } + .stats{ + display: grid; + grid-template-columns: auto 1fr 1fr 1fr; + grid-auto-rows: auto; + margin: 1em 0.6em; + text-shadow: $text-shadow; + + .row{ + display: contents; + font-size: 1.4em; + &.header{ + font-weight: bold; + } + :nth-child(2), :nth-child(3), :nth-child(4){ + text-align: center; + } + } + } + } + .game{ + grid-area: game; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 4.72727272rem 47.272727273rem; + &>div{ + display: grid; + grid-template-columns: 4fr 4fr 2fr 4fr 4fr; + grid-auto-rows: 4.72727272rem; + overflow-y: scroll; + grid-auto-flow: dense; + height: 100%; + &::-webkit-scrollbar { + width: 0; /* Safari and Chrome */ + } + &>div{ + font-size: 2rem; + text-align: center; + background: white; + color: black; + margin: 0 0 $line $line; + width: calc(100% - $line); + height: calc(100% - $line); + border: 0.15em transparent solid; + display: flex; + justify-content: center; + align-items: center; + &.headding{ + background-color: $bgcolor; + color: white; + text-shadow: $text-shadow; + } + &.rounds{ + grid-column: 3; + background-color: $bgcolor; + color: white; + text-shadow: $text-shadow; + } + &.player1.points{ + grid-column: 1; + } + &.player1.toGo{ + grid-column: 2; + font-size: 1.6rem; + } + &.player2.points{ + grid-column: 4; + } + &.player2.toGo{ + grid-column: 5; + margin-right: $line; + // width: calc(100% - 2*$line); + // height: calc(100% - $line); + font-size: 1.6rem; + } + &.input{ + padding: 0; + border: 0.15em orange solid; + input{ + text-align: center; + box-sizing: border-box; + font-family: inherit; + padding: 0.1em; + border: none; + width: 100%; + height: 100%; + font-size: 1em; + &:focus{ + outline: none; + } + } + } + } + &>div.headding{ + &.rounds{ + font-size: 1.4rem; + } + } + } + } + .nav{ + grid-area: navi; + background-color: $bgcolor; + margin-top: $line/2; + display: flex; + justify-content: space-evenly; + align-items: center; + span{ + font-size: 2em; + } + } + } +} diff --git a/assets/css/style.min.css b/assets/css/style.min.css new file mode 100644 index 0000000..b852581 --- /dev/null +++ b/assets/css/style.min.css @@ -0,0 +1 @@ +html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:'';content:none}table{border-collapse:collapse;border-spacing:0}@font-face{font-display:swap;font-family:'Open Sans';font-style:normal;font-weight:300;src:url("../fonts/opensans/open-sans-v40-latin-300.woff2") format("woff2")}@font-face{font-display:swap;font-family:'Open Sans';font-style:italic;font-weight:300;src:url("../fonts/opensans/open-sans-v40-latin-300italic.woff2") format("woff2")}@font-face{font-display:swap;font-family:'Open Sans';font-style:normal;font-weight:400;src:url("../fonts/opensans/open-sans-v40-latin-regular.woff2") format("woff2")}@font-face{font-display:swap;font-family:'Open Sans';font-style:italic;font-weight:400;src:url("../fonts/opensans/open-sans-v40-latin-italic.woff2") format("woff2")}@font-face{font-display:swap;font-family:'Open Sans';font-style:normal;font-weight:500;src:url("../fonts/opensans/open-sans-v40-latin-500.woff2") format("woff2")}@font-face{font-display:swap;font-family:'Open Sans';font-style:italic;font-weight:500;src:url("../fonts/opensans/open-sans-v40-latin-500italic.woff2") format("woff2")}@font-face{font-display:swap;font-family:'Open Sans';font-style:normal;font-weight:600;src:url("../fonts/opensans/open-sans-v40-latin-600.woff2") format("woff2")}@font-face{font-display:swap;font-family:'Open Sans';font-style:italic;font-weight:600;src:url("../fonts/opensans/open-sans-v40-latin-600italic.woff2") format("woff2")}@font-face{font-display:swap;font-family:'Open Sans';font-style:normal;font-weight:700;src:url("../fonts/opensans/open-sans-v40-latin-700.woff2") format("woff2")}@font-face{font-display:swap;font-family:'Open Sans';font-style:italic;font-weight:700;src:url("../fonts/opensans/open-sans-v40-latin-700italic.woff2") format("woff2")}@font-face{font-display:swap;font-family:'Open Sans';font-style:normal;font-weight:800;src:url("../fonts/opensans/open-sans-v40-latin-800.woff2") format("woff2")}@font-face{font-display:swap;font-family:'Open Sans';font-style:italic;font-weight:800;src:url("../fonts/opensans/open-sans-v40-latin-800italic.woff2") format("woff2")}*{box-sizing:border-box;margin:0;padding:0}:root{font-size:Min(1.11111vw, 1.48148vh)}body{margin:0;font-family:"Open Sans";background-color:black;display:flex;justify-content:center;align-items:center;height:100vh;color:white;display:flex;flex-direction:column;overflow:hidden}input{font-family:"Open Sans"}main{border-left:0.2rem solid black;border-right:0.2rem solid black;height:67.5rem;width:90rem;background-color:#2f6f2a;display:flex;flex-direction:column}h1,h2,h3,h4,h5,h6{font-weight:bold}.game .body{height:48rem}.center{display:flex;justify-content:center}.hidden{display:none !important}.label{font-size:1.8em;padding:0.3em 0;font-weight:bold;display:block}.overlay{border-radius:0.25em;border:0.3em black solid;position:absolute;top:50%;left:50%;z-index:1000;transform:translate(-50%, -50%);width:45rem;max-height:60rem;overflow-y:auto;background-color:#2f6f2a;padding:2em 1.7em}.overlay::-webkit-scrollbar{width:0}.dialog{flex-grow:1;display:flex;flex-direction:column;justify-content:center;align-items:center;width:auto}.dialog h2{font-size:2.4em;margin-bottom:0.5em;text-shadow:0.2rem 0.2rem 0.4rem #000}.dialog p{font-size:1.4em;margin-bottom:1em;text-shadow:0.2rem 0.2rem 0.4rem #000}body.home header{display:flex;justify-content:center;align-items:center;height:42%}body.home header>img{display:block;width:50%;height:auto}body.home h1{text-align:center;color:white;font-size:2.6rem;text-shadow:0.2rem 0.2rem 0.4rem #000}body.home .menu{flex-grow:1;display:flex;align-items:center;padding-bottom:7.5rem}body.home .menu .mainmenu{width:100%;display:flex;align-items:center;flex-direction:column;justify-content:center}body.home .menu .mainmenu h1{margin-bottom:1em}nav.list{display:flex;justify-content:center;flex-grow:1}nav.list.horizontal{flex-direction:row;justify-content:space-evenly}nav.list.horizontal>*+*{margin-left:1em}nav.list.vertical{flex-direction:column}nav.list.vertical>*+*{margin-top:1em}.element{border-radius:0.25em;border:0.3em black solid;border-width:0.3rem;background-color:#214d1d;cursor:pointer}.element:focus,.element:hover{outline:none;border-color:orange}.element.input{font-size:2em;padding:0.3em;background-color:white;color:black;display:block;width:100%}.element.player{display:flex;justify-content:flex-start;align-items:center;padding:0.7em}.element.player h2{font-size:1.3em}.element.player>*{margin-right:1em}.element.player img{height:3em;aspect-ratio:1/1;object-position:top;object-fit:cover}.element.plain{color:white;font-weight:bold;padding:0.3em 0.5em;text-align:center;display:flex;align-items:flex-end;text-shadow:0.2rem 0.2rem 0.4rem #000;font-family:"Open Sans";padding:1rem 2rem;justify-content:center;align-items:center;box-shadow:0.25rem 0.25rem 1rem rgba(0,0,0,0.3);font-size:1.6em}.element.plain:hover,.element.plain:focus{background-color:rgba(0,0,0,0.2);border-color:orange;outline:none}.element.plain.back{background-color:#5E716A}.element.plain.new{background-color:#193a16}.element.player{height:4.5em}.element.square{font-size:1rem;width:20em;height:20em;padding:2.5em 1.25em;display:flex;flex-direction:column;justify-content:center;align-items:center;background-color:#214d1d;box-shadow:0.25em 0.25em 1em rgba(0,0,0,0.3);cursor:pointer}.element.square h2{color:white;font-weight:bold;padding:0.3em 0.5em;text-align:center;display:flex;align-items:flex-end;text-shadow:0.2rem 0.2rem 0.4rem #000;font-family:"Open Sans";font-size:2em;padding:0.6em 0 0 0}.element.square:hover,.element.square:focus{background-color:rgba(0,0,0,0.2);border-color:orange;outline:none}.element.square .icon{width:65%;height:auto}.element.square .icon svg{width:100%;aspect-ratio:1/1;object-fit:cover;fill:white;filter:drop-shadow(0.2rem 0.2rem 0.4rem #000)}.element.square .icon img{width:100%;aspect-ratio:1/1;object-fit:cover;object-position:top;filter:drop-shadow(0.2rem 0.2rem 0.4rem #000)}body.xoi main{display:flex;justify-content:center}body.xoi main .gamesetup{width:65%;margin:0 auto}body.xoi main .gamesetup h1{font-size:3em;text-align:center;margin-bottom:0.5em}body.xoi main .gamesetup .menu>*{margin-bottom:1.3em}body.xoi main .playerselect>h2{font-size:1.8em;margin-bottom:0.7em;text-align:center}body.xoi main .xoi{display:grid;height:100%;grid-template-columns:3fr 3fr 2fr 3fr 3fr;grid-template-rows:11.5rem 52rem 4rem;grid-template-areas:"player1 toGo1 score toGo2 player2" "player1 game game game player2" "navi navi navi navi navi";background-color:black}body.xoi main .xoi .bigToGo{margin:.2rem;background-color:white;border:0.7rem solid black;color:black;font-weight:bold;font-size:7.5em;display:flex;justify-content:center;align-items:center}body.xoi main .xoi .bigToGo.active{border-color:orange}body.xoi main .xoi .bigToGo.one{grid-area:toGo1}body.xoi main .xoi .bigToGo.two{grid-area:toGo2}body.xoi main .xoi .score{margin:.2rem 0;grid-area:score;color:white;text-shadow:0.1rem 0.1rem 0.2rem rgba(0,0,0,0.8);background-color:#2f6f2a;display:flex;flex-direction:column;justify-content:space-evenly}body.xoi main .xoi .score>div{display:flex;justify-content:space-evenly;align-items:center}body.xoi main .xoi .score>div h2,body.xoi main .xoi .score>div h3{margin:0;text-align:center;font-size:1.3rem}body.xoi main .xoi .score>div h3{font-size:1.1rem}body.xoi main .xoi .score>div>h2{font-size:3.2rem}body.xoi main .xoi .score>div h2:nth-child(2){order:10}body.xoi main .xoi .player{background-color:#2f6f2a;margin-bottom:.1rem}body.xoi main .xoi .player.player1{grid-area:player1}body.xoi main .xoi .player.player2{grid-area:player2}body.xoi main .xoi .player img{width:100%;aspect-ratio:35/45;object-fit:cover}body.xoi main .xoi .player h2,body.xoi main .xoi .player h3{text-align:center;margin:0.2em;text-shadow:0.1rem 0.1rem 0.2rem rgba(0,0,0,0.8)}body.xoi main .xoi .player h2{font-size:1.8em}body.xoi main .xoi .player h3{font-size:1.2em}body.xoi main .xoi .player .stats{display:grid;grid-template-columns:auto 1fr 1fr 1fr;grid-auto-rows:auto;margin:1em 0.6em;text-shadow:0.1rem 0.1rem 0.2rem rgba(0,0,0,0.8)}body.xoi main .xoi .player .stats .row{display:contents;font-size:1.4em}body.xoi main .xoi .player .stats .row.header{font-weight:bold}body.xoi main .xoi .player .stats .row :nth-child(2),body.xoi main .xoi .player .stats .row :nth-child(3),body.xoi main .xoi .player .stats .row :nth-child(4){text-align:center}body.xoi main .xoi .game{grid-area:game;display:grid;grid-template-columns:1fr;grid-template-rows:4.72727272rem 47.272727273rem}body.xoi main .xoi .game>div{display:grid;grid-template-columns:4fr 4fr 2fr 4fr 4fr;grid-auto-rows:4.72727272rem;overflow-y:scroll;grid-auto-flow:dense;height:100%}body.xoi main .xoi .game>div::-webkit-scrollbar{width:0}body.xoi main .xoi .game>div>div{font-size:2rem;text-align:center;background:white;color:black;margin:0 0 .2rem .2rem;width:calc(100% - $line);height:calc(100% - $line);border:0.15em transparent solid;display:flex;justify-content:center;align-items:center}body.xoi main .xoi .game>div>div.headding{background-color:#2f6f2a;color:white;text-shadow:0.1rem 0.1rem 0.2rem rgba(0,0,0,0.8)}body.xoi main .xoi .game>div>div.rounds{grid-column:3;background-color:#2f6f2a;color:white;text-shadow:0.1rem 0.1rem 0.2rem rgba(0,0,0,0.8)}body.xoi main .xoi .game>div>div.player1.points{grid-column:1}body.xoi main .xoi .game>div>div.player1.toGo{grid-column:2;font-size:1.6rem}body.xoi main .xoi .game>div>div.player2.points{grid-column:4}body.xoi main .xoi .game>div>div.player2.toGo{grid-column:5;margin-right:.2rem;font-size:1.6rem}body.xoi main .xoi .game>div>div.input{padding:0;border:0.15em orange solid}body.xoi main .xoi .game>div>div.input input{text-align:center;box-sizing:border-box;font-family:inherit;padding:0.1em;border:none;width:100%;height:100%;font-size:1em}body.xoi main .xoi .game>div>div.input input:focus{outline:none}body.xoi main .xoi .game>div>div.headding.rounds{font-size:1.4rem}body.xoi main .xoi .nav{grid-area:navi;background-color:#2f6f2a;margin-top:.1rem;display:flex;justify-content:space-evenly;align-items:center}body.xoi main .xoi .nav span{font-size:2em} diff --git a/assets/fonts/opensans/open-sans-v40-latin-300.woff2 b/assets/fonts/opensans/open-sans-v40-latin-300.woff2 new file mode 100644 index 0000000..e000fcb Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-300.woff2 differ diff --git a/assets/fonts/opensans/open-sans-v40-latin-300italic.woff2 b/assets/fonts/opensans/open-sans-v40-latin-300italic.woff2 new file mode 100644 index 0000000..5167821 Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-300italic.woff2 differ diff --git a/assets/fonts/opensans/open-sans-v40-latin-500.woff2 b/assets/fonts/opensans/open-sans-v40-latin-500.woff2 new file mode 100644 index 0000000..a35be30 Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-500.woff2 differ diff --git a/assets/fonts/opensans/open-sans-v40-latin-500italic.woff2 b/assets/fonts/opensans/open-sans-v40-latin-500italic.woff2 new file mode 100644 index 0000000..039e72f Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-500italic.woff2 differ diff --git a/assets/fonts/opensans/open-sans-v40-latin-600.woff2 b/assets/fonts/opensans/open-sans-v40-latin-600.woff2 new file mode 100644 index 0000000..f67ef00 Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-600.woff2 differ diff --git a/assets/fonts/opensans/open-sans-v40-latin-600italic.woff2 b/assets/fonts/opensans/open-sans-v40-latin-600italic.woff2 new file mode 100644 index 0000000..bd6a4d1 Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-600italic.woff2 differ diff --git a/assets/fonts/opensans/open-sans-v40-latin-700.woff2 b/assets/fonts/opensans/open-sans-v40-latin-700.woff2 new file mode 100644 index 0000000..7e3b8b0 Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-700.woff2 differ diff --git a/assets/fonts/opensans/open-sans-v40-latin-700italic.woff2 b/assets/fonts/opensans/open-sans-v40-latin-700italic.woff2 new file mode 100644 index 0000000..2c96334 Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-700italic.woff2 differ diff --git a/assets/fonts/opensans/open-sans-v40-latin-800.woff2 b/assets/fonts/opensans/open-sans-v40-latin-800.woff2 new file mode 100644 index 0000000..cf65114 Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-800.woff2 differ diff --git a/assets/fonts/opensans/open-sans-v40-latin-800italic.woff2 b/assets/fonts/opensans/open-sans-v40-latin-800italic.woff2 new file mode 100644 index 0000000..17bc073 Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-800italic.woff2 differ diff --git a/assets/fonts/opensans/open-sans-v40-latin-italic.woff2 b/assets/fonts/opensans/open-sans-v40-latin-italic.woff2 new file mode 100644 index 0000000..84ee197 Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-italic.woff2 differ diff --git a/assets/fonts/opensans/open-sans-v40-latin-regular.woff2 b/assets/fonts/opensans/open-sans-v40-latin-regular.woff2 new file mode 100644 index 0000000..eaae942 Binary files /dev/null and b/assets/fonts/opensans/open-sans-v40-latin-regular.woff2 differ diff --git a/assets/img/YGDC.png b/assets/img/YGDC.png new file mode 100644 index 0000000..afd9190 Binary files /dev/null and b/assets/img/YGDC.png differ diff --git a/assets/img/dart-board.svg b/assets/img/dart-board.svg new file mode 100644 index 0000000..b334e9a --- /dev/null +++ b/assets/img/dart-board.svg @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/assets/img/dart.svg b/assets/img/dart.svg new file mode 100644 index 0000000..458fc07 --- /dev/null +++ b/assets/img/dart.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/assets/img/placeholder_person.png b/assets/img/placeholder_person.png new file mode 100644 index 0000000..e7bdbef Binary files /dev/null and b/assets/img/placeholder_person.png differ diff --git a/assets/img/watch.svg b/assets/img/watch.svg new file mode 100644 index 0000000..7033f33 --- /dev/null +++ b/assets/img/watch.svg @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/assets/js/componentPromise.js b/assets/js/componentPromise.js new file mode 100644 index 0000000..30cdcc6 --- /dev/null +++ b/assets/js/componentPromise.js @@ -0,0 +1,105 @@ + +const html = (v) => { return v[0] }; + +export const renderer = { + props: ['stack'], + setup(props) { + + }, + template: html` + + + + ` +} + +function reGet(stack) { + return safePromise(new Promise(function(resolve, reject) { + const lastState = stack[stack.length-1]; + lastState[lastState.length-1].resolve = resolve; + lastState[lastState.length-1].reject = reject; + })); +} + +export function popLastElem(stack) { + const elem = stack[stack.length-1].pop(); + if (stack[stack.length-1].length == 0) { + stack.pop(); + } + return elem; +} + +function safePromise(promise) { + return promise.then(data => [ data, undefined ]).catch(error => [ null, error != undefined ? error : -1 ]); +} + +export function overlayAndGet(component, properties, stack, reGetFlag=false) { + if (reGetFlag) return reGet(stack); + properties["stack"] = stack; + return safePromise(new Promise(function(resolve, reject) { + stack.push([{ + "component": component, + "properties": properties, + "resolve": resolve, + "reject": reject + }]) + })); +} + +export function replaceAndGet(component, properties, stack, reGetFlag=false) { + if (reGetFlag) return reGet(stack); + properties["stack"] = stack; + return safePromise(new Promise(function(resolve, reject) { + stack[stack.length-1].push({ + "component": component, + "properties": properties, + "resolve": resolve, + "reject": reject + }) + })); +} + + +export async function overlayAndPop(component, properties, stack){ + const ret = await overlayAndGet(component, properties, stack); + popLastElem(stack); + return ret; +} + +export async function replaceAndPop(component, properties, stack){ + const ret = await replaceAndGet(component, properties, stack); + popLastElem(stack); + return ret; +} + + +export const powerStateMachine = async (stateMachine, stack, initState=0, initInput=undefined) => { + let stateHistory = [initState]; + let resultHistory = [initInput]; + let reGet = false; + let newState, newResult; + while (stateHistory[stateHistory.length-1] != undefined) { + const state = stateHistory[stateHistory.length-1]; + const result = resultHistory[resultHistory.length-1]; + [newState, newResult] = await stateMachine[state](result, reGet); + if (newState == -1){ + reGet = true; + stateHistory.pop(); + resultHistory.pop(); + if (newResult != undefined) { + resultHistory[resultHistory.length-1] = newResult; + } + } else if (newState == state){ + reGet = true; + resultHistory[resultHistory.length-1] = newResult; + } else if (newState == undefined) { + return newResult; + } else { + reGet = false; + stateHistory.push(newState); + resultHistory.push(newResult); + } + } +} diff --git a/assets/js/components/dialog.js b/assets/js/components/dialog.js new file mode 100644 index 0000000..569e43d --- /dev/null +++ b/assets/js/components/dialog.js @@ -0,0 +1,25 @@ +import { ref, computed } from "vue"; +import { getId } from "../handlers.js"; +import { handleActive, ArrowVerticalKeyHandler, ArrowHorizontalKeyHandler, NumberKeyHandler } from "../handlers.js"; + +const html = (v) => { return v[0] }; + +export default { + props: ['active', 'title', 'text', 'buttons', "withshortkey", "stack"], + setup(props, { emit }) { + const handlers = [ArrowHorizontalKeyHandler]; + if (props.withshortkey) { + handlers.push(NumberKeyHandler); + } + handleActive(props, handlers); + const elements = computed(() => props.buttons.map((i) => { i.onClick = () => emit('resolve', i.result ); return i; }) ); + return { elements } + }, + template: html` +
+

{{ title }}

+

{{ text }}

+ +
+ ` +} diff --git a/assets/js/components/init.js b/assets/js/components/init.js new file mode 100644 index 0000000..dd4e3bc --- /dev/null +++ b/assets/js/components/init.js @@ -0,0 +1,22 @@ +import list from "./list.js"; + +import playerElem from "./playerElem.js"; +import plainElem from "./plainElem.js"; +import squareElem from "./squareElem.js"; +import inputElem from "./inputElem.js"; + +import playerselect from "./playerselect.js"; +// import overlay from "./overlay.js"; +import dialog from "./dialog.js"; + +export default (app) => { + app.component("d-list", list); + + app.component("d-playerElem", playerElem); + app.component("d-plainElem", plainElem); + app.component("d-squareElem", squareElem); + app.component("d-inputElem", inputElem); + + app.component("d-playerselect", playerselect); + app.component("d-dialog", dialog); +} diff --git a/assets/js/components/inputElem.js b/assets/js/components/inputElem.js new file mode 100644 index 0000000..790361a --- /dev/null +++ b/assets/js/components/inputElem.js @@ -0,0 +1,17 @@ +import { onMounted, ref, computed, watch, nextTick } from "vue"; + +const html = (v) => { return v[0] }; + +export default { + props: ['modelValue'], + setup(props) { + + return { window } + }, + template: html` + + ` +} diff --git a/assets/js/components/list.js b/assets/js/components/list.js new file mode 100644 index 0000000..e5589ad --- /dev/null +++ b/assets/js/components/list.js @@ -0,0 +1,17 @@ +import { ref, computed } from "vue"; +import { getId } from "../handlers.js"; + +const html = (v) => { return v[0] }; + +export default { + props: ['elements', 'type', "withshortkey"], + setup(props) { + return { } + }, + template: html` + + ` +} diff --git a/assets/js/components/overlay.js b/assets/js/components/overlay.js new file mode 100644 index 0000000..683e4fd --- /dev/null +++ b/assets/js/components/overlay.js @@ -0,0 +1,14 @@ +const html = (v) => { return v[0] }; + +// Not needed anymore +export default { + props: ['component', "props", "active", "stack"], + setup(props) { + return { } + }, + template: html` +
+ +
+ ` +} diff --git a/assets/js/components/plainElem.js b/assets/js/components/plainElem.js new file mode 100644 index 0000000..c50787a --- /dev/null +++ b/assets/js/components/plainElem.js @@ -0,0 +1,16 @@ +import { computed, ref } from "vue"; +import { getId } from "../handlers.js"; + +const html = (v) => { return v[0] }; + +export default { + props: ['text', 'withshortkey', 'autofocus'], + setup(props) { + const el = ref(null); + const suffix = computed(() => props.withshortkey ? ` (${getId(el.value)})`: ""); + return { suffix, el } + }, + template: html` +
{{ text }}{{ suffix }}
+ ` +} diff --git a/assets/js/components/playerElem.js b/assets/js/components/playerElem.js new file mode 100644 index 0000000..10a037e --- /dev/null +++ b/assets/js/components/playerElem.js @@ -0,0 +1,23 @@ +import { onMounted, ref, computed, watch, nextTick } from "vue"; + +const html = (v) => { return v[0] }; + +export default { + props: ['player', 'id'], + setup(props) { + return { } + }, + template: html` +
+ + +
+ ` +} diff --git a/assets/js/components/playerselect.js b/assets/js/components/playerselect.js new file mode 100644 index 0000000..627ed0f --- /dev/null +++ b/assets/js/components/playerselect.js @@ -0,0 +1,29 @@ +import { nextTick, reactive, ref, computed, onMounted, onUnmounted } from "vue"; +import { handleActive, ArrowVerticalKeyHandler, ArrowHorizontalKeyHandler, NumberKeyHandler } from "../handlers.js"; + +const html = (v) => { return v[0] }; + +export default { + props: ['players', 'stack', 'active', 'input'], + setup(props, context) { + handleActive(props, [ArrowVerticalKeyHandler]); + const children = computed(() => { + return props.players.map((p) => { + return { + component: "d-playerElem", + props: {"player": p}, + onClick: () => { + context.emit('resolve', p); + } + } + }) + }); + return { children } + }, + template: html` +
+ + +
+ ` +} diff --git a/assets/js/components/squareElem.js b/assets/js/components/squareElem.js new file mode 100644 index 0000000..982c088 --- /dev/null +++ b/assets/js/components/squareElem.js @@ -0,0 +1,22 @@ +import { computed, ref } from "vue"; +import { getId } from "../handlers.js"; + +const html = (v) => { return v[0] }; + +export default { + props: ["icon", "text", "withshortkey"], + setup(props) { + const el = ref(null); + const suffix = computed(() => props.withshortkey ? ` (${getId(el.value)})`: ""); + return { suffix, el } + }, + template: html` +
+
+ + +
+

{{ text }}{{ suffix }}

+
+ ` +} diff --git a/assets/js/handlers.js b/assets/js/handlers.js new file mode 100644 index 0000000..423de92 --- /dev/null +++ b/assets/js/handlers.js @@ -0,0 +1,155 @@ +import { ref, watch, nextTick, onBeforeUnmount } from 'vue'; +import { state } from "./stateMgr.js"; + +function getNumberFromKeyEvent(event) { + if (event.keyCode >= 96 && event.keyCode <= 105) { + return event.keyCode - 96; + } else if (event.keyCode >= 48 && event.keyCode <= 57) { + return event.keyCode - 48; + } + return null; +} +export const ArrowHorizontalKeyHandler = (event) => { + if (event.key == "ArrowRight") { + const idx = state.sortedElements.indexOf(String(state.activeIndex)); + state.activeIndex = state.sortedElements[(idx+1)%state.sortedElements.length]; + } else if (event.key == "ArrowLeft") { + const idx = state.sortedElements.indexOf(String(state.activeIndex)) + state.activeIndex = state.sortedElements[(idx+state.sortedElements.length-1)%state.sortedElements.length]; + } +} +export const ArrowVerticalKeyHandler = (event) => { + if (event.key == "ArrowDown") { + const idx = state.sortedElements.indexOf(String(state.activeIndex)) + state.activeIndex = state.sortedElements[(idx+1)%state.sortedElements.length]; + event.preventDefault(); + } else if (event.key == "ArrowUp") { + const idx = state.sortedElements.indexOf(String(state.activeIndex)) + state.activeIndex = state.sortedElements[(idx+state.sortedElements.length-1)%state.sortedElements.length]; + event.preventDefault(); + } +} +export const NumberKeyHandler = (event) => { + let i; + if ((i = getNumberFromKeyEvent(event)) != null) { + if (state.elements[i] != undefined) { + event.stopPropagation(); + event.preventDefault(); + state.activeIndex = i; + state.elements[i].focus(); + state.elements[i].click(); + } + } +} + + +export const EnterHandler = (event) => { + if (event.key == "Enter"){ + if (state.hasOwnProperty("activeElement") && state.activeElement) { + event.stopPropagation(); + if (state.activeElement.tagName != "BUTTON"){ + event.preventDefault(); + } + state.activeElement.click(); + } + } +} + + +export const handleActive = (props, keydownHandlers) => { + const ownCopy = keydownHandlers.map((f) => (e) => f(e)); + const currActive = ref(1); + state.activeIndex = 1; + const handle = () => { + if (props.active){ + state.activeIndex = currActive.value; + for (var i = 0; i < keydownHandlers.length; i++) { + document.addEventListener("keydown", ownCopy[i]); + } + } else { + currActive.value = state.activeIndex; + for (var i = 0; i < ownCopy.length; i++) { + document.removeEventListener("keydown", ownCopy[i]); + } + } + } + onBeforeUnmount(() => { + // Cleanup + for (var i = 0; i < ownCopy.length; i++) { + document.removeEventListener("keydown", ownCopy[i]); + } + }); + watch(() => props.active, handle); + handle(); +} + +export const vAutofocus = { + mounted(el, binding, vnode, prevVnode) { + if (binding.value == undefined || binding.value) { + state.activeIndex = getId(el); + } + } +} + +export function getId(el) { + for (var key in state.elements) { + if (state.elements[key] == el){ + return key; + } + } +} + + + +const add = (el) => { + const focus = () => { + if (state.activeElement !== el) { + state.activeIndex = val; + state.activeElement = el; + } + } + + const blur = (e) => { + if (e.relatedTarget === null) { + state.activeElement = undefined; + state.activeIndex = undefined; + } + } + let val; + if (el.dataset["tabindex"]) { + val = el.dataset["tabindex"]; + } else { + val = Object.keys(state.elements).length+1; + } + el.tabIndex = val; + state.elements[val] = el; + el.addEventListener("focus", focus); + el.addEventListener("blur", blur); + el.blurcb = blur; + el.focuscb = focus; +}; + +const remove = (el) => { + delete el.removeAttribute("tabindex"); + delete state.elements[getId(el)] + // el.removeEventListener("blur", el.blurfn); + // el.removeEventListener("focus", el.focusfn); +} + +export const vIndex = { + updated(el, binding, vnode, prevVnode) { + if (binding.oldValue != binding.value){ + if (binding.oldValue) { + remove(el); + } else { + add(el); + } + } + }, + mounted(el, binding, vnode, prevVnode) { + add(el); + }, + beforeUnmount(el, binding, vnode, prevVnode) { + remove(el); + } +} diff --git a/assets/js/kirby.js b/assets/js/kirby.js new file mode 100644 index 0000000..051225d --- /dev/null +++ b/assets/js/kirby.js @@ -0,0 +1,152 @@ +import { ref, reactive, watch, computed } from "vue"; + + +function isIterable(obj) { + // checks for null and undefined + if (obj == null) { + return false; + } + return typeof obj[Symbol.iterator] === 'function'; +} + +export async function homeAction(options = {}) { + return await ( await fetch("/", { + method: "POST", + headers: { + Accept: "application/json", + }, + body: JSON.stringify(options), + })).json(); +} + +export async function getQuery(query, options = {}) { + return (await ( await fetch("/api/query", { + method: "POST", + headers: { + Accept: "application/json", + // completely unsafe + // Authorization: "Basic "+btoa("api@api.de:H]RcScp];76!-PB") + }, + body: JSON.stringify({ + query: query, + ...options + }), + })).json()).result; +} + + +async function getKirby(endpoint) { + return (await (await fetch("/api"+endpoint, { + method: "GET", + headers: { + // completely unsafe + Authorization: "Basic "+btoa("api@api.de:H]RcScp];76!-PB") + } + })).json()).data; + // .catch(error => { + // console.log("Error:", error); + // }); +} + +export async function setKirby(endpoint, data) { + const response = await fetch(`/${endpoint}`, { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + action: "update", + data: data + }), + }); + const ret = await response.json(); + return ret; +} + +function updateContent(endpoint, data) { + return fetch("/api"+endpoint, { + method: "PATCH", + headers: { + // "X-CSRF" : g_csrf, + // completely unsafe + "Authorization": "Basic "+btoa("api@api.de:H]RcScp];76!-PB") + }, + body: JSON.stringify(data) + }) + .then(response => response.json()) + .then(response => { + // console.log(response.data); + }) + .catch(error => { + console.log("Error:", error); + }); +} + + +export const convertToPages = async (obj) => { + for(var key in obj){ + if (obj[key].hasOwnProperty("link")) { + obj[key] = await kirbyPage(obj[key].link); + } else if (Array.isArray(obj[key]) || typeof obj[key] === 'object' ) { + obj[key] = convertToPages(obj[key]); + } + } + return obj; +} + + +export const kirbyPage = async (endpoint) => { + if (!endpoint.startsWith("/pages/")){ + endpoint = "/pages/"+endpoint.replaceAll("/","+"); + } + + let patch = false; + const page = new Proxy(reactive({}), { + async set(target, key, value) { + if(value.hasOwnProperty("uuid") && value.uuid.startsWith("page://")){ + value = await kirbyPage(value.link); + } + if (Array.isArray(value) || typeof value === 'object' ) { + for(var i in value){ + if (value[i].hasOwnProperty("uuid") && value[i].uuid.startsWith("page://")) { + value[i] = await kirbyPage(value[i].link); + } + } + } + target[key] = value; + if (patch) { + const data = {}; + if (value.isPage) { + data[key] = "page://"+value.uuid; + } else if (Array.isArray(value) || typeof value === 'object' ) { + data[key] = []; + for (var i in value) { + if (value[i].isPage) { + data[key].push("page://"+value[i].uuid); + } else { + data[key].push(value[i]); + } + } + } else { + data[key] = value; + } + updateContent(endpoint, data); + } + return true; + }, + get(target, prop, receiver) { + if (prop == "isPage") { + return true; + } + return target[prop]; + } + } + ); + const lastData = ref({}); + const kData = await getKirby(endpoint); + Object.assign(page, kData.content); + + patch = true; + return page; +} diff --git a/assets/js/stateMgr.js b/assets/js/stateMgr.js new file mode 100644 index 0000000..446f7c3 --- /dev/null +++ b/assets/js/stateMgr.js @@ -0,0 +1,46 @@ +import { reactive, nextTick, watch, computed } from "vue"; + +export const state = reactive({ + _activeIndex: undefined, + get activeIndex(){ + return state._activeIndex; + }, + set activeIndex(val){ + // if (val === state._activeIndex) return; + state._activeIndex = val; + if (val === undefined) return; + state._activeElement = document.querySelector(`[tabindex="${val}"]`); + nextTick(() => { + nextTick(() => { + state._activeElement?.focus(); + }) + }) + }, + _activeElement: undefined, + get activeElement(){ + return state._activeElement; + }, + set activeElement(val){ + // if (val === state._activeElement) return; + state._activeElement = val; + if (val === undefined) return; + state.activeIndex = val.tabIndex; + }, + elements: {}, + sortedElements: computed(() => Object.keys(state.elements)), + init: true, + back: [], + backActive: [] +}); + +watch(() => state.sortedElements, () => { + if (state._activeIndex !== undefined && state.sortedElements.length > 0){ + state.activeIndex = state._activeIndex; + } +}) + +watch(() => state.activeIndex, () => { + if (state.activeIndex === undefined && state.sortedElements.length > 0){ + state.activeIndex = state.sortedElements[0]; + } +}) diff --git a/assets/js/views/home.js b/assets/js/views/home.js new file mode 100644 index 0000000..d791f66 --- /dev/null +++ b/assets/js/views/home.js @@ -0,0 +1,229 @@ +import { nextTick, watch, reactive, ref, computed, onMounted, onUnmounted } from "vue"; +import { getQuery, homeAction } from "../kirby.js"; +import { handleActive, ArrowVerticalKeyHandler, ArrowHorizontalKeyHandler, NumberKeyHandler } from "../handlers.js"; +import { overlayAndGet, powerStateMachine, popLastElem } from "../componentPromise.js"; + +const html = (v) => { return v[0] }; + + +//////////////////////////////////////////////////////////////////////////////// +// Components +//////////////////////////////////////////////////////////////////////////////// + +export const selectMode = { + props: ['active', 'stack'], + setup(props, { emit }) { + handleActive(props, [NumberKeyHandler, ArrowHorizontalKeyHandler]); + + const children = reactive([ + { + // component: "d-squareElem", + // props:{ + // text: "Create Game", + // icon: "/assets/img/dart-board.svg", + // }, + // onClick: () => { + // emit("resolve", "create"); + // } + // }, { + component: "d-squareElem", + props:{ + text: "Play Game", + icon: "/assets/img/dart.svg", + }, + onClick: () => { + emit("resolve", "play"); + } + } + ]); + const canvas = ref([]); + return { children } + }, + template: html` + + ` +} + + +export const selectTournament = { + props: ['tournaments', 'active', 'stack'], + setup(props, { emit }) { + handleActive(props, [NumberKeyHandler, ArrowVerticalKeyHandler]); + + const children = computed(() => { + const items = []; + for (let i in props.tournaments){ + const tournament = props.tournaments[i]; + items.push({ + component: "d-plainElem", + props: { + text: tournament.title, + }, + onClick: () => { + emit("resolve", tournament.id); + } + }) + } + items.push({ + component: "d-plainElem", + props: { + text: "Back", + class: "back" + }, + onClick: () => { + emit("reject"); + } + }); + return items; + }); + return { children } + }, + template: html` + + ` +} + + +export const selectGame = { + props: ['games', 'active', 'stack'], + setup(props, { emit }) { + handleActive(props, [NumberKeyHandler, ArrowVerticalKeyHandler]); + + const children = computed(() => { + const items = []; + items.push({ + component: "d-plainElem", + props: { + text: "Create Game", + class: "new" + }, + onClick: () => { + emit("resolve", "create"); + } + }); + for (let i in props.games){ + const game = props.games[i]; + items.push({ + component: "d-plainElem", + props: { + text: game.player[0]?.forename + " vs. " + game.player[1]?.forename, + }, + onClick: () => { + emit("resolve", game.id); + } + }) + } + items.push({ + component: "d-plainElem", + props: { + text: "Back", + class: "back" + }, + onClick: () => { + emit("reject"); + } + }); + return items; + }); + return { children } + }, + template: html` + + ` +} + + +//////////////////////////////////////////////////////////////////////////////// +// State Machine +//////////////////////////////////////////////////////////////////////////////// + +const stateMachine = (stack) => { + return { + 0: async (input, reGet) => { + const [result, error] = await overlayAndGet("d-selectMode", { }, stack, reGet); + if (error != undefined) { + return [0, error]; + } + if (result == "play") { + return [1, result]; + } + if (result == "create") { + // TODO: + return [1, result]; + } + }, + 1: async (input, reGet) => { + const tournaments = await getQuery("site.find('seasons').getRunningTournaments", { + select: { + title: "page.title", + id: "page.id", + } + }); + const [result, error] = await overlayAndGet("d-selectTournament", { "tournaments": tournaments}, stack, reGet); + if (error != undefined) { + popLastElem(stack); + return [-1, error]; + } + return [2, result]; + }, + 2: async (input, reGet) => { + const games = await getQuery(`site.find('${input}').getRunningGames`, { + select: { + title: "page.title", + id: "page.id", + uuid: "page.uuid", + player: { + query: "page.players.toPages", + select:{ + forename: "page.forename" + } + } + } + }); + const [result, error] = await overlayAndGet("d-selectGame", {"games": games}, stack, reGet); + if (error != undefined) { + popLastElem(stack); + return [-1, error]; + } + if (result == "create") { + // Create Game and redirect + const ret = await homeAction({ + "action": "createGame", + "tournament": input + }) + if (ret.status == "ok") { + window.setTimeout(() => { + window.location.href = ret.url; + return false; + },1); + } + return [undefined, undefined]; + } + window.setTimeout(() => { + window.location.href = result; + return false; + },1); + return [undefined, undefined]; + } + }; +} + + +//////////////////////////////////////////////////////////////////////////////// +// Exports +//////////////////////////////////////////////////////////////////////////////// + +export const homeHandler = async (stack) => { + const sm = stateMachine(stack); + powerStateMachine(sm, stack); +} + +export const initHomeView = (app) => { + app.component("d-selectMode", selectMode).component("d-selectTournament", selectTournament).component('d-selectGame', selectGame) +} diff --git a/assets/js/views/xoi.js.bak b/assets/js/views/xoi.js.bak new file mode 100644 index 0000000..87236b8 --- /dev/null +++ b/assets/js/views/xoi.js.bak @@ -0,0 +1,499 @@ +import { nextTick, reactive, ref, computed, onMounted, onUnmounted } from "vue"; +import { state } from "../stateMgr.js"; +import { getQuery } from "../kirby.js"; +import { initSubState, ArrowVerticalKeyHandler, ArrowHorizontalKeyHandler, NumberKeyHandler } from "../handlers.js"; + + +const html = (v) => { return v[0] }; + +function initView(view){ + const back = state.view; + state.back.push(() => { state.view = back }); + state.backActive.push(state.activeIndex); + state.view = view; +} + +const togo = { + props: ['togo'], + setup(props) { + + return { } + }, + template: html` +
{{ togo }}
+ ` +} + +const game = { + props: ['players', 'currentleg', 'max', 'sendvisit', 'modelValue', 'overlay'], + setup(props, context) { + const length = computed(() => props.currentleg?.visits.length ); + const visits = computed(() => props.currentleg? props.currentleg.visits:undefined ); + const loop = computed(() => Math.max(length.value?length.value+(length.value?length.value%2:0):0,18) ); + const getPlayerVisits = (uuid) => { + const vs = visits.value.filter((v) => v.player == uuid); + if (vs.length < 9) { + for (var i = vs.length; i < 9; i++) { + vs.push({ "sum": "", "toGo":["",""]}) + } + } else if (vs.length*2 < visits.value.length) { + + vs.push({ "sum": "", "toGo":["",""]}); + } + return vs; + } + const check_remove = (event) => { + if (!event.repeat && event.target.value.length < 1) { + context.emit('removeLastVisit', event.target); + } + } + const setup = ref(false); + const setupEnter = () => { + setup.value = true; + } + const confirmEnter = (evt) => { + if (setup.value) { + context.emit('sendvisit', evt.target) + } + setup.value = false; + } + return { length, visits, loop, getPlayerVisits, check_remove, confirmEnter, setupEnter } + }, + template: html` +
+
+
Points
+
ToGo
+
Round
+
Points
+
ToGo
+
+
+
+
{{ max }}
+
0
+
+
{{ max }}
+ +
+
+ ` +} + +const overlay = { + props: ['data'], + setup(props, context) { + const keyhandler = (event) => { + ArrowHorizontalKeyHandler(event); + NumberKeyHandler(event); + if (event.key == "Escape") { + context.emit("closeOverlay") + } + } + return { keyhandler } + }, + template: html` +
+
+

{{ data.title }}

+

{{ data.text }}

+
+ +
+
+
+ ` +} + +const score = { + props: ['page', 'justlegs', 'currentset', 'currentleg'], + setup(props) { + return { } + }, + template: html` +
+
+

{{ currentset?.points[idx] }}

+
+

Best of {{ page?.sets }}

+

Sets

+
+
+
+

{{ currentleg?.points[idx] }}

+
+

Best of {{ page?.legs }}

+

Legs

+
+
+
+ ` +} + +const player = { + props: ['player', 'stats', "id"], + setup(props) { + const current_set = computed( () => props.stats?.sets[props.stats.sets.length-1]); + const current_leg = computed( () => current_set.value?.legs[current_set.value.legs.length-1]); + const getAverage = (avg) => { + return avg && avg[1] != 0 ? ((3*avg[0])/avg[1]).toFixed(1) : "-"; + } + const getCheckout = (checkout) => { + return checkout && checkout[1] != 0 ? Math.round(1000*checkout[0]/checkout[1])/10 : "- " + } + const getMax = (checkouts) => { + if (checkouts && checkouts.length != 0) { + return Math.max(...checkouts); + } + return "-" + } + return { current_set, current_leg, getAverage, getCheckout, getMax } + }, + template: html` +
+ +

{{ player?.forename }} {{ player?.surname }}

+

{{ player?.nickname }}

+
+
+
Stat
Match
Leg
+
+
+
Average:
{{ getAverage(stats?.stats[id].average) }}
{{ getAverage(current_leg?.stats[id].average) }}
+
+
+
First 9:
{{ getAverage(stats?.stats[id].first9) }}
{{ getAverage(current_leg?.stats[id].first9) }}
+
+
+
60+:
{{ stats?.stats[id]["60+"] }}
{{ current_leg?.stats[id]["60+"] }}
+
+
+
100+:
{{ stats?.stats[id]["100+"] }}
{{ current_leg?.stats[id]["100+"] }}
+
+
+
140+:
{{ stats?.stats[id]["140+"] }}
{{ current_leg?.stats[id]["140+"] }}
+
+
+
180:
{{ stats?.stats[id]["180"] }}
{{ current_leg?.stats[id]["180"] }}
+
+
+
Checkouts:
{{ getCheckout(stats?.stats[id].checkouts) }}%
+
+
+
Best Checkout:
{{ getMax(stats?.stats[id].checkoutPoints) }}
+
+
+
+ ` +} + + +export const xoi = { + components: { + "d-togo": togo, + "d-score": score, + "d-game": game, + "d-player": player, + "d-overlay": overlay + }, + setup(props, context) { + let page = ref(); + const updateGame = (reset) => { + getQuery(`site.find('${state.id}')`, { + select: { + title: "page.title", + id: "page.id", + modus: "page.max", + game: "page.rounds.parseJSON", + stats: "page.stats.parseJSON", + sets: "page.sets", + legs: "page.legs", + players: { + query: "page.players.toPages", + select:{ + forename: "page.forename", + surname: "page.surname", + nickname: "page.nickname", + uuid: "page.uuid", + img: "page.pic.toFile?.url" + } + } + } + }).then((res) => { + page.value = res; + if (reset != undefined) { + current_input.value = reset; + } + if (res.stats.winner){ + console.log(res.stats); + let winner = "Draw"; + if (res.players[0].uuid == res.stats.winner){ + winner = res.players[0].forename; + } else if (res.players[1].uuid == res.stats.winner) { + winner = res.players[1].forename; + } + overlay.value = { + "title": "Game Ended", + "text": `The winner is ${winner}`, + "buttons": [] + }; + } + }); + }; + updateGame(); + + let game = computed(() => page.value ? page.value.game: undefined) + let current_set = computed(() => { + if (game.value != undefined) { + return game.value.sets[game.value.sets.length-1] + } + return undefined; + }) + let current_leg = computed(() => { + if (current_set.value != undefined) { + return current_set.value.legs[current_set.value.legs.length-1] + } + return undefined; + }) + let current_toGo = computed(() => { + if (current_leg.value != undefined) { + if (current_leg.value.visits.length < 2) { + return [page.value.modus, page.value.modus] + } else { + + return current_leg.value.visits[current_leg.value.visits.length-2].toGo; + } + } + return [0,0]; + }) + let current_set_points = computed(() => { + if (current_set.value != undefined) { + return current_set.value.points; + } + return undefined; + }); + let current_leg_points = computed(() => { + if (current_leg.value != undefined) { + return current_leg.value.points; + } + return undefined; + }); + let current_player = computed(() => { + if (current_leg.value != undefined) { + const len = current_leg.value.visits.length; + return current_leg.value.visits[len-1].player == page.value.players[1].uuid; + } + return undefined; + }); + const current_input = ref(""); + const overlay = ref(); + return { page, state, game, current_set, current_leg, current_toGo, current_set_points, current_leg_points, current_player, updateGame, current_input, overlay } + }, + methods: { + async removeLastVisit(){ + let last = this.current_leg.visits.length-2 >= 0 ? this.current_leg.visits[this.current_leg.visits.length-2].throws : [""]; + last = last.join(","); + const response = await fetch(`/${state.id}`, { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + "action": "deleteLastThrow", + "visit": true, + }), + }); + const ret = await response.json(); + if (ret.status == "ok"){ + this.updateGame(last); + } else { + console.log(ret); + } + }, + sum(tr){ + const val = tr.trim();; + if (val == "") { + return 0; + } + if (val == "SB"){ + return 25; + } + if (val == "DB"){ + return 50; + } + if (val[0] == "S" || val[0] == "O" || val[0] == "I"){ + return parseFloat(val.substring(1)); + } + if (val[0] == "D"){ + return 2*parseFloat(val.substring(1)); + } + if (val[0] == "T"){ + return 3*parseFloat(val.substring(1)); + } + if (val[0] == "M"){ + return 0; + } else { + // TODO: Check for Na + return parseFloat(val); + } + }, + verify(sum){ + if (this.current_toGo[this.current_player*1]-sum == 0){ + return 0; + } else if (sum > 180 || [179, 178, 176, 175, 173, 172, 169, 166, 163].indexOf(sum) > -1) { + return -1 + } else if (this.current_toGo[this.current_player*1]-sum <= 50) { + return 1 + } + }, + openOverlay(overlay){ + this.overlay = overlay; + }, + closeOverlay(){ + this.overlay = undefined; + }, + checkout_question(throws){ + const buttons = []; + for (var i = 1; i <= 3; i++) { + const x = i; + buttons.push({ + "type": "input", + "props" : { + "type": "button", + "value": `${x} (${x})` + }, + "onClick": () => { + this.closeOverlay(); + this.checkouttries_question(throws, x, false); + } + }) + } + this.openOverlay({ + "title": "Congratulations!", + "text": `How many darts did you need?` , + "buttons": buttons + }); + }, + checkouttries_question(throws, numDarts, zero=true){ + const buttons = []; + for (var i = 1; i <= numDarts; i++) { + const x = i; + buttons.push({ + "type": "input", + "props" : { + "type": "button", + "value": `${x} (${x})` + }, + "autofocus": (i==1 && !zero), + "onClick": () => { + this.closeOverlay(); + this.send_visit(throws, numDarts, x) + } + }) + } + if (zero) { + buttons.push({ + "type": "input", + "props" : { + "type": "button", + "value": `0 (${numDarts+1})` + }, + "autofocus": true, + "onClick": () => { + this.closeOverlay(); + this.send_visit(throws, numDarts, 0) + } + }) + } + this.openOverlay({ + "title": "Checkout Tries", + "text": `How many tries on a Checkout?` , + "buttons": buttons + }); + }, + async send_visit(throws, numDarts=3, checkoutTries=0){ + const response = await fetch(`/${state.id}`, { + method: "POST", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + "action": "addThrows", + "throws": throws.value.split(","), + "checkoutTries": checkoutTries, + "numDarts": numDarts, + "done": true + }), + }); + const ret = await response.json(); + if (ret.status == "ok"){ + this.updateGame(""); + } else { + console.log(ret); + } + }, + async preprocess_visit(throws){ + const tr = throws.value.split(","); + let sum = 0; + tr.forEach((t, i) => { + sum += this.sum(t); + }); + const res = this.verify(sum); + if (res == 0){ + // Ask for num Darts + this.checkout_question(throws); + return; + } else if (res == 1){ + // Ask for checkoutTries + this.checkouttries_question(throws, 3); + return; + } else if (res == -1){ + this.openOverlay({ + "title": "Impossible", + "text": `A score of ${sum} is not possible`, + "buttons": [{ + "type": "input", + "props" : { + "type": "button", + "value": "ok" + }, + "onClick" : this.closeOverlay + }] + }); + return; + } + this.send_visit(throws); + } + }, + template: html` +
+ + + + + + + + +
+ ` +} + +export const initXoiView = (app) => { + app.component('d-xoi', xoi) +} diff --git a/assets/js/views/xoi/logic.js b/assets/js/views/xoi/logic.js new file mode 100644 index 0000000..59b4a2b --- /dev/null +++ b/assets/js/views/xoi/logic.js @@ -0,0 +1,463 @@ +import { computed } from "vue"; + +function setPoints(page, set){ + let points = [0, 0]; // need to get updated for more player + let ret; + const m = page.game.sets.length-1; + for (const i in page.game.sets) { + const set = page.game.sets[i]; + const points = legPoints(page, set, set.legs[set.legs.length-1]); + const winner = getWinner(points, page.legs); + if (winner > -1) + points[winner] += 1; + else + return points; + + if (set == set){ + return points; + } + } + return points; +} + +function legPoints(page, set, leg){ + let points = [0, 0]; // need to get updated for more player + let ret; + const m = set.legs.length-1; + for (const i in set.legs) { + const l = set.legs[i]; + if (l.visits[l.visits.length-1].toGo === undefined) { + return points; + } + if (l.visits[l.visits.length-1].toGo[0] == 0) + points[0] += 1; + else if (l.visits[l.visits.length-1].toGo[1] == 0) + points[1] += 1; + if (l == leg){ + return points; + } + } + return points; +} + +export const getFinalWinner = (page) => { + const points = setPoints(page, page.sets[page.sets.length-1]); + return getWinner(points, page.sets); +}; + +export function getGameProps(page, current_set, current_leg) { + const ret = {}; + + if (current_set === undefined) { + current_set = computed(() => { + if (page.game != undefined) { + return page.game.sets[page.game.sets.length - 1] + } + return undefined; + }); + } + ret.current_set = current_set; + + + if (current_leg === undefined) { + current_leg = computed(() => { + + if (current_set.value != undefined) { + return current_set.value.legs[current_set.value.legs.length - 1] + } + return undefined; + }); + } + ret.current_leg = current_leg; + + const current_toGo = computed(() => { + if (current_leg.value != undefined) { + if (current_leg.value.visits.length < 2) { + return [page.modus, page.modus] + } else { + const end = current_leg.value.visits[current_leg.value.visits.length - 1].toGo; + if (end) return end; + return current_leg.value.visits[current_leg.value.visits.length - 2].toGo; + } + } + return [0, 0]; // need to get updated for more player + }); + ret.current_toGo = current_toGo; + + + + const current_set_points = computed(() => { + if (current_set.value != undefined) { + return setPoints(page, current_set.value); + } + return undefined; + }); + ret.current_set_points = current_set_points; + + + const current_leg_points = computed(() => { + if (current_set.value != undefined || current_leg.value != undefined) { + return legPoints(page, current_set.value, current_leg.value); + } + return undefined; + }); + ret.current_leg_points = current_leg_points; + + + const current_player = computed(() => { + if (current_leg.value != undefined) { + const len = current_leg.value.visits.length; + return current_leg.value.visits[len - 1].player == page.players[1].uuid; + } + return undefined; + }); + ret.current_player = current_player; + + const getVal = (tr) => { + const val = tr.trim(); + if (val == "") { + return 0; + } + if (val == "SB") { + return 25; + } + if (val == "DB") { + return 50; + } + if (val[0] == "S" || val[0] == "O" || val[0] == "I") { + return parseFloat(val.substring(1)); + } + if (val[0] == "D") { + return 2 * parseFloat(val.substring(1)); + } + if (val[0] == "T") { + return 3 * parseFloat(val.substring(1)); + } + if (val[0] == "M") { + return 0; + } else { + // TODO: Check for Na + return parseFloat(val); + } + } + const verifySum = (sum) => { + if (sum > 180 || [179, 178, 176, 175, 173, 172, 169, 166, 163].indexOf(sum) > -1) { + return -1; + } else if (current_toGo.value[current_player.value * 1] - sum < 0) { + return -2; + } if (current_toGo.value[current_player.value * 1] - sum == 0) { + // bogey numbers + if (page.out == "Double" && (sum > 170 || [169, 168, 166, 165, 163, 162, 159].indexOf(sum) > -1)) { + return -1; + } + return 0; + } else if (current_toGo.value[current_player.value * 1] - sum <= 50) { + return 1; + } + return 2; + } + const checkVisit = (throws) => { + const tr = throws.split(","); + let sum = 0; + tr.forEach((t, i) => { + sum += getVal(t); + }); + const res = verifySum(sum); + return [res, sum]; + } + ret.checkVisit = checkVisit; + + return ret; +} + +function newPlayerStats(player) { + return { + player: player.uuid, + average: [0, 0], + first9: [0, 0], + checkouts: [0, 0], + checkoutPoints: [], + "60+": 0, + "100+": 0, + "140+": 0, + "180": 0 + }; +} + +function addNewSetStats(stats, players) { + stats["sets"].push(newStats(players)); + stats["sets"][0]["legs"] = []; + return stats; +} + +function addNewLegStats(stats, players) { + stats["sets"].at(-1)["legs"].push(newStats(players)); + return stats; +} + +function newStats(players) { + const stats = { + stats: [] + } + players.forEach((player, i) => { + const n = newPlayerStats(player); + stats.stats.push(n); + }); + return stats; +} + +export function initStats(page) { + const stats = newStats(page.players); + stats.sets = []; + addNewSetStats(stats, page.players); + addNewLegStats(stats, page.players); + page.stats = stats; +} + +function newVisit(playerUUID, round) { + return { + player: playerUUID, + throws: [], + visit: round, + checkoutTries: 0, + numDarts: 0, + }; +} + +export function initGame(page) { + page.game = { + sets: [{ + points: new Array(page.players.length).fill(0), + legs: [{ + points: new Array(page.players.length).fill(0), + visits: [newVisit(page.players[0].uuid, 1)] + }] + }] + } +} + +function updateStats(page, visit){ + const playerUUIDs = page.players.map((p) => p.uuid); + const k = playerUUIDs.indexOf(visit.player); + + const todos = [page.stats["stats"][k]]; + todos.push(page.stats["sets"].at(-1)["stats"][k]); + todos.push(page.stats["sets"].at(-1)["legs"].at(-1)["stats"][k]); + + todos.forEach((value, i) => { + todos[i]["average"][0] += visit["sum"]; + todos[i]["average"][1] += visit["numDarts"]; + if (visit["visit"] < 4) { + todos[i]["first9"][0] += visit["sum"]; + todos[i]["first9"][1] += visit["numDarts"]; + } + if (visit["toGo"][k] == 0) { + todos[i]["checkouts"][0] += 1; + todos[i]["checkoutPoints"].push(visit["sum"]); + } + todos[i]["checkouts"][1] += visit["checkoutTries"]; + if (visit["sum"] == 180) { + todos[i]["180"] += 1; + } else if (visit["sum"] >= 140) { + todos[i]["140+"] += 1; + } else if (visit["sum"] >= 100) { + todos[i]["100+"] += 1; + } else if (visit["sum"] >= 60) { + todos[i]["60+"] += 1; + } + }); +} + +function getWinner(points, mode) { + const sorted = [...points].map((e,i) => [e,i]).sort((a, b) => b[0] - a[0]); + const k = points.length; + const sum = points.reduce((a,c) => a+c, 0); + if (sum < mode && sorted[0][0] <= mode/k){ + // not over yet + return -1; + } + if (sorted[0][0] == sorted[1][0]){ + // Draw + return -2; + } + // winner id + return sorted[0][1]; +} + +export const formatDate = (d) => { + return `${d.getFullYear()}-${(d.getMonth()+1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; +}; + +function addNewSet(page, points){ + const playerUUIDs = page.players.map((p) => p.uuid); + const set = page.game.sets[page.game.sets.length-1]; + const leg = set.legs[set.legs.length-1]; + const k = playerUUIDs.indexOf(set.legs[0].visits[0].player); + const p = playerUUIDs[(k+1)%playerUUIDs.length]; + page.game.sets.push({ + // points: [...points], + legs: [{ + points: Array(playerUUIDs.length).fill(0), + visits: [newVisit(p, 1)] + }] + }); +} + +function addNewLeg(page){ + const playerUUIDs = page.players.map((p) => p.uuid); + const set = page.game.sets[page.game.sets.length-1]; + const leg = set.legs[set.legs.length-1]; + const k = playerUUIDs.indexOf(leg.visits[0].player); + const p = playerUUIDs[(k+1)%playerUUIDs.length]; + page.game.sets[page.game.sets.length-1].legs.push({ + // points: [...points], + visits: [newVisit(p, 1)] + }); +} + +function clearLastVisit(page){ + const set = page.game.sets[page.game.sets.length-1]; + const leg = set.legs[set.legs.length-1]; + const visit = leg.visits[leg.visits.length-1]; + const ret = visit["throws"]; + visit["throws"] = []; + visit["checkoutTries"] = 0; + visit["numDarts"] = 0; + delete visit["sum"]; + delete visit["toGo"]; + return ret; +} + +export function recalcStats(page){ + const set = page.game.sets[page.game.sets.length-1]; + // const leg = set.legs[set.legs.length-1]; + // const visit = leg.visits[leg.visits.length-1]; + initStats(page); + const lastset = page.game["sets"].length-1; + page.game["sets"].forEach((set, i) => { + const lastleg = set.legs.length-1; + set.legs.forEach((leg, j) => { + const lastvisit = leg["visits"].length-1; + leg.visits.forEach((visit, k) => { + if (!(k == lastvisit && j == lastleg && i == lastset)) { + updateStats(page, visit); + } + if (k == lastvisit && j != lastleg) { + addNewLegStats(page.stats, page.players); + } + }); + if (j == lastleg && i != lastset) { + addNewSetStats(page.stats, page.players); + } + }); + }); +} + +export function removeLastVisit(page){ + const set = page.game.sets[page.game.sets.length-1]; + const leg = set.legs[set.legs.length-1]; + const visit = leg.visits[leg.visits.length-1]; + + // delete last visit. + leg["visits"].pop(); + if (leg["visits"].length == 0){ + set["legs"].pop(); + if (set["legs"].length == 0){ + page.game["sets"].pop(); + if (page.game["sets"].length == 0){ + initGame(page); + return; + } + } + } + // clearLastVisit + const ret = clearLastVisit(page); + recalcStats(page); + return ret; +} + +export function storeVisit(page, throws, sum, numDarts, tries) { + const set = page.game.sets[page.game.sets.length-1]; + const leg = set.legs[set.legs.length-1]; + const visit = leg.visits[leg.visits.length-1]; + + visit["numDarts"] = numDarts; + visit["throws"] = throws; + visit["checkoutTries"] = tries; + visit["sum"] = sum + + const playerUUIDs = page.players.map((p) => p.uuid); + const k = playerUUIDs.indexOf(visit["player"]); + let toGo; + if (leg["visits"].length-2 < 0) { + toGo = Array(playerUUIDs.length).fill(page.modus); + } else { + toGo = [...leg["visits"][leg["visits"].length-2]["toGo"]]; + } + const rest = toGo[k] - visit["sum"]; + if (rest < 0 || (page.out == "Double" && rest == 1)) { + visit["sum"] = 0; + } else { + toGo[k] = rest; + } + visit["toGo"] = toGo; + // update stats + updateStats(page, visit); + + let update = []; + if (rest != 0) { + // Normal case...next players turn + const nextPlayer = playerUUIDs[(k+1)%playerUUIDs.length]; + const nVisit = newVisit(nextPlayer, visit["visit"]+1*(leg.visits[0]["player"] == nextPlayer)); + leg.visits.push(nVisit); + } + else { + // rest == 0 leg finished + const newlegp = legPoints(page, set, leg); + let winner = getWinner(newlegp, page.legs); + if (winner == -1) { + // new Leg + addNewLeg(page); + addNewLegStats(page.stats, page.players); + } else { + // new set? + const newsetp = setPoints(page, set); + winner = getWinner(newsetp, page.sets); + if (winner == -1) { + // new Set + addNewSet(page); + addNewSetStats(page.stats, page.players); + addNewLegStats(page.stats, page.players); + } else { + // ask for continue or game over + if (winner == -2) { + page.stats["winner"] = "DRAW"; + } else { + page.stats["winner"] = visit["player"]; + } + + page.enddate = formatDate(new Date(Date.now())); + return [newsetp, newlegp]; + } + } + } + return false; +} + +export function endGame(page){ + // const set = page.game.sets[page.game.sets.length-1]; + // const leg = set.legs[set.legs.length-1]; + // const visit = leg.visits[leg.visits.length-1]; + // const winner = getWinner(newsetp, page.sets); + // set["points"] = newsetp; + // leg["points"] = newlegp; +} + +export function extension(page){ + const set = page.game.sets[page.game.sets.length-1]; + const leg = set.legs[set.legs.length-1]; + // leg["points"] = newlegp; + // new Leg + addNewLeg(page); + addNewLegStats(page.stats, page.players); +} diff --git a/assets/js/views/xoi/main.js b/assets/js/views/xoi/main.js new file mode 100644 index 0000000..5b255b9 --- /dev/null +++ b/assets/js/views/xoi/main.js @@ -0,0 +1,698 @@ +import { nextTick, watch, reactive, ref, computed, onMounted, onUnmounted } from "vue"; +import { getQuery, setKirby } from "../../kirby.js"; +import { state } from "../../stateMgr.js"; +import { handleActive, ArrowVerticalKeyHandler, ArrowHorizontalKeyHandler, NumberKeyHandler } from "../../handlers.js"; +import { overlayAndGet, powerStateMachine, overlayAndPop, popLastElem } from "../../componentPromise.js"; + +import { getGameProps, initGame, initStats, storeVisit, formatDate, removeLastVisit, extension, getFinalWinner } from "./logic.js"; + +const html = (v) => { return v[0] }; + +//////////////////////////////////////////////////////////////////////////////// +// Components +//////////////////////////////////////////////////////////////////////////////// + +const pregame = { + props: ['page','active', 'stack'], + setup(props, context) { + handleActive(props, [ArrowVerticalKeyHandler]); + + const selectPlayer = async (i) => { + if (props.active) { + const [result, error] = await overlayAndPop("d-playerselect", { players: props.page.participants, class:"overlay"}, props.stack); + if (error === undefined) { + props.page.players[i] = result; + } + } + } + + return { selectPlayer } + }, + template: html` + + ` +} + +export const bullselect = { + props: ['players','active', 'stack'], + setup(props, { emit }) { + handleActive(props, [ArrowHorizontalKeyHandler, NumberKeyHandler]); + + const children = computed(() => { + const items = []; + for (let i in props.players){ + const player = props.players[i]; + items.push({ + component: "d-squareElem", + props: { + text: player.forename + " " + player.surname, + icon: player.img ? player.img : '/assets/img/placeholder_person.png' + }, + onClick: () => { + emit("resolve", player); + } + }) + } + return items; + }); + + return { children } + }, + template: html` +
+

Who won bull?

+ +
+ ` +} + + + +const score = { + props: ['page', 'justlegs', 'current_set_points', 'current_leg_points'], + setup(props) { + return { } + }, + template: html` +
+
+

{{ current_set_points[idx] }}

+
+

Best of {{ page.sets }}

+

Sets

+
+
+
+

{{ current_leg_points[idx] }}

+
+

Best of {{ page.legs }}

+

Legs

+
+
+
+ ` +} + +const player = { + props: ['page', 'id', 'current_stat'], + setup(props) { + const addStats = (stat1, stat2) => { + return { + average: [stat1.average[0]+stat2.average[0], stat1.average[1]+stat2.average[1]], + first9: [stat1.first9[0]+stat2.first9[0], stat1.first9[1]+stat2.first9[1]], + "60+": stat1["60+"]+stat2["60+"], + "100+": stat1["100+"]+stat2["100+"], + "140+": stat1["140+"]+stat2["140+"], + "180": stat1["180"]+stat2["180"], + "checkouts": [stat1.checkouts[0]+stat2.checkouts[0], stat1.checkouts[1]+stat2.checkouts[1]], + "checkoutPoints": [...stat1.checkoutPoints, ...stat2.checkoutPoints] + } + } + const inspect = !!props.page.enddate; + const tourStats = computed(() => { + if (props.page.tournamentStats.length != 2){ + return [ + props.page.stats.stats[0], + props.page.stats.stats[1] + ]; + } + if (inspect) { + return [ + props.page.tournamentStats[0], + props.page.tournamentStats[1] + ]; + } + return [ + addStats(props.page.tournamentStats[0], props.page.stats.stats[0]), + addStats(props.page.tournamentStats[1], props.page.stats.stats[1]) + ]; + }); + const getAverage = (avg) => { + return avg && avg[1] != 0 ? ((3*avg[0])/avg[1]).toFixed(1) : "-"; + } + const getCheckout = (checkout) => { + return checkout && checkout[1] != 0 ? Math.round(1000*checkout[0]/checkout[1])/10 : "- " + } + const getMax = (checkouts) => { + if (checkouts && checkouts.length != 0) { + return Math.max(...checkouts); + } + return "-" + } + const player = computed(() => props.page.players[props.id]) + return { tourStats, getAverage, getCheckout, getMax, player } + }, + template: html` +
+ +

{{ player.forename }} {{ player.surname }}

+

{{ player.nickname }}

+
+
+
Stat
Tnm
Match
Leg
+
+
+
Avg:
+ {{ getAverage(tourStats[id].average) }} +
+ {{ getAverage(page.stats.stats[id].average) }} +
+ {{ getAverage(current_stat.stats[id].average) }} +
+
+
+
First 9:
+ {{ getAverage(tourStats[id].first9) }} +
+ {{ getAverage(page.stats.stats[id].first9) }} +
+ {{ getAverage(current_stat.stats[id].first9) }} +
+
+
+
60+:
{{ tourStats[id]["60+"] }}
{{ page.stats.stats[id]["60+"] }}
{{ current_stat.stats[id]["60+"] }}
+
+
+
100+:
{{ tourStats[id]["100+"] }}
{{ page.stats.stats[id]["100+"] }}
{{ current_stat.stats[id]["100+"] }}
+
+
+
140+:
{{ tourStats[id]["140+"] }}
{{ page.stats.stats[id]["140+"] }}
{{ current_stat.stats[id]["140+"] }}
+
+
+
180:
{{ tourStats[id]["180"] }}
{{ page.stats.stats[id]["180"] }}
{{ current_stat.stats[id]["180"] }}
+
+
+
Ch. %:
{{ getCheckout(tourStats[id].checkouts) }}%
{{ getCheckout(page.stats.stats[id].checkouts) }}%
+
+
+
Best Ch.:
{{ getMax(tourStats[id].checkoutPoints) }}
{{ getMax(page.stats.stats[id].checkoutPoints) }}
+
+
+
+ ` +} + + +const game = { + props: ['active', 'stack', 'players','current_leg', 'max'], + setup(props, context) { + const visits = computed(() => props.current_leg ? props.current_leg.visits:undefined ); + const getPlayerVisits = (uuid) => { + const vs = visits.value.filter((v) => v.player == uuid); + if (vs.length < 9) { + for (var i = vs.length; i < 9; i++) { + vs.push({ "sum": "", "toGo":["",""]}) + } + } else if (vs.length*2 < visits.value.length) { + + vs.push({ "sum": "", "toGo":["",""]}); + } + return vs; + } + return { getPlayerVisits } + }, + template: html` +
+
+
Points
+
ToGo
+
Round
+
Points
+
ToGo
+
+
+
+
{{ max }}
+
0
+
+
{{ max }}
+ +
+
+ ` +} + +const gameinput = { + props: ['input','active', 'stack'], + setup(props, context) { + handleActive(props, []); + const check_remove = (event) => { + if (!event.repeat && event.target.value.length < 1) { + event.preventDefault(); + context.emit('reject', -2); + } + } + const keyhandler = (e) => { + if (e.key == "F1" || e.keyCode == 112) { + e.preventDefault(); + context.emit('resolve', "60"); + } else if (e.key == "F2" || e.keyCode == 113) { + e.preventDefault(); + context.emit('resolve', "45"); + } else if (e.key == "F3" || e.keyCode == 114) { + e.preventDefault(); + context.emit('resolve', "41"); + } else if (e.key == "F4" || e.keyCode == 115) { + e.preventDefault(); + context.emit('resolve', "26"); + } + + } + return { check_remove, keyhandler } + }, + template: html` + + ` +} + + +const xoi = { + props: ['page', 'active', 'stack', 'inspect'], + components: { + "d-player": player, + "d-score": score, + "d-game": game + }, + setup(props, context) { + const gamestack = reactive([]); + const inspectstack = reactive([]); + let current_set, current_leg; + const set_id = ref(props.page.game.sets.length - 1); + const leg_id = ref(props.page.game.sets[set_id.value].legs.length - 1); + let current_stat; + if (props.inspect) { + current_set = computed(() => props.page.game.sets[set_id.value]); + current_leg = computed(() => current_set.value.legs[leg_id.value]); + current_stat = computed( () => props.page.stats?.sets[set_id.value].legs[leg_id.value]); + } + const computedProps = getGameProps(props.page, current_set, current_leg); + if (!props.inspect) { + current_stat = computed( () => props.page.stats?.sets[props.page.stats.sets.length-1].legs[computedProps.current_set.value.legs.length-1]); + } + + const mounted = onMounted(async () => { + if (props.inspect) { + } else { + const winner = await gameHandler(gamestack, props.stack, props.page, computedProps) + context.emit('resolve', winner); + } + }) + return { ...computedProps, set_id, leg_id, gamestack, inspectstack, current_stat } + }, + template: html` +
+
{{ current_toGo[0] }}
+ +
{{ current_toGo[1] }}
+ + + + + + + +
+ ` +} + +//////////////////////////////////////////////////////////////////////////////// +// Dialogs +//////////////////////////////////////////////////////////////////////////////// + +const numDartsDialog = () => { + const buttons = []; + for (var i = 1; i <= 3; i++) { + buttons.push({ + "component": "d-plainElem", + "props" : { + "text": `${i}` + }, + "result" : i + }) + } + return { + "withshortkey": true, + "title": "Congratulations!", + "text": `How many darts did you need?`, + "buttons": buttons + }; +} + +const numCheckoutTriesDialog = (numDarts, start=1) => { + const buttons = []; + for (var i = start; i <= numDarts; i++) { + buttons.push({ + "component": "d-plainElem", + "props" : { + "text": `${i}`, + "autofocus": i == 0, + "data-tabindex": i + }, + "result" : i + }) + } + return { + "withshortkey": true, + "title": "Checkout Tries", + "text": `How many tries on a Checkout?` , + "buttons": buttons + }; +} + +const impossibleDialog = (sum) => { + return { + "withshortkey": true, + "title": "Impossible", + "text": `A score of ${sum} is not possible`, + "buttons": [{ + "component": "d-plainElem", + "props" : { + "text": "ok" + }, + "result" : "ok" + }]} +} + +const gameOverDialog = (winner, points) => { + return { + "withshortkey": true, + "title": `Game Over`, + "text": `${winner != "DRAW" ? "The winner is": ""} ${winner} with ${points[0]}-${points[1]}`, + "buttons": [ + { + "component": "d-plainElem", + "props" : { + "text": "End Game" + }, + "result" : "end" + },{ + "component": "d-plainElem", + "props" : { + "text": "Continue" + }, + "result" : "continue" + }] + } +} + +//////////////////////////////////////////////////////////////////////////////// +// API +//////////////////////////////////////////////////////////////////////////////// + +function getGame(id){ + return getQuery(`site.find('${id}')`, { + select: { + title: "page.title", + id: "page.id", + modus: "page.max.toInt", + out: "page.out", + game: "page.rounds.parseJSON", + stats: "page.stats.parseJSON", + sets: "page.sets.toInt", + legs: "page.legs.toInt", + startdate: "page.Startdate", + enddate: "page.Enddate", + tournamentStats: "page.tournamentStats", + participants: { + query: "page.parent.participants.toPages.sortBy('forename')", + select:{ + forename: "page.forename", + surname: "page.surname", + nickname: "page.nickname", + uuid: "page.uuid", + img: "page.pic.toFile?.url" + } + }, + players: { + query: "page.players.toPages", + select:{ + forename: "page.forename", + surname: "page.surname", + nickname: "page.nickname", + uuid: "page.uuid", + img: "page.pic.toFile?.thumbnail(350).url" + } + } + } + }) +} + +function savePregame(page){ + return setKirby(page.id, { + sets: page.sets, + legs: page.legs, + players: page.players.map((p) => p.uuid), + startdate: page.startdate, + rounds: page.game ? JSON.stringify(page.game) : "", + stats: page.stats ? JSON.stringify(page.stats) : "" + }); +} + +function saveGame(page){ + return setKirby(page.id, { + rounds: page.game ? JSON.stringify(page.game) : "", + stats: page.stats ? JSON.stringify(page.stats) : "", + enddate: page.enddate, + }); +} + + +//////////////////////////////////////////////////////////////////////////////// +// State Machine +//////////////////////////////////////////////////////////////////////////////// + +// Checkout Pipeline +const checkoutPipeline = (stack) => { + return { + 0: async (sum, reGet) => { + // Ask for num Darts + const [numDarts, error] = await overlayAndGet("d-dialog", numDartsDialog(), stack, reGet); + if (error != undefined) { + popLastElem(stack); + return [undefined, [-1, -1]]; + } + return [1, [numDarts, 1]]; + }, + 1: async ([numDarts, start], reGet) => { + // Ask for checkoutTries + if (numDarts <= start) { + // Store with one checkout try + popLastElem(stack); + return [undefined, [1, 1]]; + } + const [tries, error] = await overlayAndPop("d-dialog", numCheckoutTriesDialog(numDarts, start), stack); + if (error != undefined) { + if (start == 0) { + return [undefined, [-1, -1]]; + } + return [-1, undefined]; + } + if (start == 1) { + popLastElem(stack); + } + return [undefined, [numDarts, tries]]; + } + }; +}; + + +// Game State Machine +const gameStateMachine = (gamestack, stack, page, computedProps) => { + return { + 0: async (input, reGet) => { + // Dispatcher + if (page.stats.winner){ + return [undefined, page.stats.winner]; + } + // Check for Game State + return [1, undefined]; + }, + 1: async (input, reGet) => { + // Normal Game Loop + // Get Game Input + const [visit, error] = await overlayAndPop("d-gameinput", { input: input }, gamestack); + // back/delete last throw + if (error != undefined) { + const val = removeLastVisit(page); + saveGame(page); + if (val == undefined) return [1, val]; + return [1, val.join(",")]; + } + + // Validate throw + let numDarts, tries; + const [ret, sum] = computedProps.checkVisit(visit); + if (ret == -1) { + // Impossible + const [_, error] = await overlayAndPop("d-dialog", impossibleDialog(sum), stack); + return [1, visit]; + } else if (ret == -2) { + // Bust TODO + const [_, error] = await overlayAndPop("d-dialog", impossibleDialog(sum), stack); + return [1, visit]; + } else if (ret == 2) { + // Normal + storeVisit(page, visit.split(","), sum, 3, 0); + saveGame(page); + return [1, undefined] + } + // checkout: + if (ret == 0) { + // Checkout: Ask for num Darts + [numDarts, tries] = await powerStateMachine(checkoutPipeline(stack), stack, /*initState=*/0, /*initInput=*/sum); + } else if (ret == 1) { + // <=50: Ask for checkout tries + [numDarts, tries] = await powerStateMachine(checkoutPipeline(stack), stack, /*initState=*/1, /*initInput=*/[3,0]); + } + + if (numDarts == -1) { + // Error/Back + return [1, visit]; + } else { + const points = storeVisit(page, visit.split(","), sum, numDarts, tries); + if (points){ + // Game Over + const winner = getFinalWinner(page); + let name = "DRAW" + if (winner != -2){ + name = page.players[winner].forename; + } + const [answer, error] = await overlayAndPop("d-dialog", gameOverDialog(name, points[1]), stack); + if (answer == "end") { + saveGame(page); + return [undefined, page.stats.winner]; + } else { + extension(page); + saveGame(page); + } + } else { + saveGame(page); + } + return [1, undefined]; + } + }, + }; +} + +export const gameHandler = async (gamestack, stack, page, computedProps) => { + const sm = gameStateMachine(gamestack, stack, page, computedProps); + return powerStateMachine(sm, gamestack); +} + + +// General State Machine +const stateMachine = (stack, page) => { + return { + 0: async (input, reGet) => { + // Dispatcher + // Check Game State: + if (page.players.length != 2 || page.startdate === undefined || page.startdate === ""){ + // Pre Game + return [1, page]; + } else if (page.enddate === undefined || page.enddate === ""){ + if (page.game === "" || page.game === undefined || page.game === null) { + // Who won Bull? + return [2, page]; + } else { + // In Game + return [3, page]; + } + } else { + // Post Game + return [4, undefined]; + } + }, + 1: async (page, reGet) => { + // Pre Game + const [result, error] = await overlayAndPop("d-pregame", { page: page}, stack); + if (error != undefined) { + var re = /^https?:\/\/[^/]+/i; + window.setTimeout(() => { + window.location.href = re.exec(window.location.href)[0]; + return false; + },1); + return [undefined, undefined]; + } + page.startdate = formatDate(new Date(Date.now())); + // Update game in database + const ret = await savePregame(page); + if (ret.status != "ok") { + console.error("Error save page:", ret.status, ret.error); + } + return [0, page]; + }, + 2: async (page, reGet) => { + // Ask for Bull + const [result, error] = await overlayAndPop("d-bullselect", { players: page.players}, stack); + if (error != undefined) { + return [1, page]; + } + // reorderPlayer + if (result !== page.players[0]){ + page.players = [page.players[1], page.players[0]]; + } + // Setup Game + initGame(page); + initStats(page); + const ret = await savePregame(page); + return [3, page]; + }, + 3: async (page, reGet) => { + // In Game + const [result, error] = await overlayAndPop("d-xoi", { page: page, inspect: false }, stack); + return [4, result]; + }, + 4: async (winnerUUID, reGet) => { + const [res, e] = await overlayAndPop("d-xoi", { page: page, inspect: true }, stack); + var re = /^https?:\/\/[^/]+/i; + window.setTimeout(() => { + window.location.href = re.exec(window.location.href)[0]; + return false; + },1); + return [4, res]; + } + }; +} + + +//////////////////////////////////////////////////////////////////////////////// +// Exports +//////////////////////////////////////////////////////////////////////////////// + +export const initXoiView = (app) => { + app.component('d-pregame', pregame).component('d-xoi', xoi).component('d-bullselect', bullselect).component('d-gameinput', gameinput) +} + +export const xoiHandler = async (stack, id) => { + const page = reactive(await getGame(id)); + const sm = stateMachine(stack, page); + await powerStateMachine(sm, stack); +} diff --git a/assets/js/vue.esm-browser.js b/assets/js/vue.esm-browser.js new file mode 100644 index 0000000..9f4aecc --- /dev/null +++ b/assets/js/vue.esm-browser.js @@ -0,0 +1,15444 @@ +function makeMap(str, expectsLowerCase) { + const map = /* @__PURE__ */ Object.create(null); + const list = str.split(","); + for (let i = 0; i < list.length; i++) { + map[list[i]] = true; + } + return expectsLowerCase ? (val) => !!map[val.toLowerCase()] : (val) => !!map[val]; +} + +const EMPTY_OBJ = Object.freeze({}) ; +const EMPTY_ARR = Object.freeze([]) ; +const NOOP = () => { +}; +const NO = () => false; +const onRE = /^on[^a-z]/; +const isOn = (key) => onRE.test(key); +const isModelListener = (key) => key.startsWith("onUpdate:"); +const extend = Object.assign; +const remove = (arr, el) => { + const i = arr.indexOf(el); + if (i > -1) { + arr.splice(i, 1); + } +}; +const hasOwnProperty$1 = Object.prototype.hasOwnProperty; +const hasOwn = (val, key) => hasOwnProperty$1.call(val, key); +const isArray = Array.isArray; +const isMap = (val) => toTypeString(val) === "[object Map]"; +const isSet = (val) => toTypeString(val) === "[object Set]"; +const isDate = (val) => toTypeString(val) === "[object Date]"; +const isRegExp = (val) => toTypeString(val) === "[object RegExp]"; +const isFunction = (val) => typeof val === "function"; +const isString = (val) => typeof val === "string"; +const isSymbol = (val) => typeof val === "symbol"; +const isObject = (val) => val !== null && typeof val === "object"; +const isPromise = (val) => { + return (isObject(val) || isFunction(val)) && isFunction(val.then) && isFunction(val.catch); +}; +const objectToString = Object.prototype.toString; +const toTypeString = (value) => objectToString.call(value); +const toRawType = (value) => { + return toTypeString(value).slice(8, -1); +}; +const isPlainObject = (val) => toTypeString(val) === "[object Object]"; +const isIntegerKey = (key) => isString(key) && key !== "NaN" && key[0] !== "-" && "" + parseInt(key, 10) === key; +const isReservedProp = /* @__PURE__ */ makeMap( + // the leading comma is intentional so empty string "" is also included + ",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted" +); +const isBuiltInDirective = /* @__PURE__ */ makeMap( + "bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text,memo" +); +const cacheStringFunction = (fn) => { + const cache = /* @__PURE__ */ Object.create(null); + return (str) => { + const hit = cache[str]; + return hit || (cache[str] = fn(str)); + }; +}; +const camelizeRE = /-(\w)/g; +const camelize = cacheStringFunction((str) => { + return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : ""); +}); +const hyphenateRE = /\B([A-Z])/g; +const hyphenate = cacheStringFunction( + (str) => str.replace(hyphenateRE, "-$1").toLowerCase() +); +const capitalize = cacheStringFunction((str) => { + return str.charAt(0).toUpperCase() + str.slice(1); +}); +const toHandlerKey = cacheStringFunction((str) => { + const s = str ? `on${capitalize(str)}` : ``; + return s; +}); +const hasChanged = (value, oldValue) => !Object.is(value, oldValue); +const invokeArrayFns = (fns, arg) => { + for (let i = 0; i < fns.length; i++) { + fns[i](arg); + } +}; +const def = (obj, key, value) => { + Object.defineProperty(obj, key, { + configurable: true, + enumerable: false, + value + }); +}; +const looseToNumber = (val) => { + const n = parseFloat(val); + return isNaN(n) ? val : n; +}; +const toNumber = (val) => { + const n = isString(val) ? Number(val) : NaN; + return isNaN(n) ? val : n; +}; +let _globalThis; +const getGlobalThis = () => { + return _globalThis || (_globalThis = typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {}); +}; + +const PatchFlagNames = { + [1]: `TEXT`, + [2]: `CLASS`, + [4]: `STYLE`, + [8]: `PROPS`, + [16]: `FULL_PROPS`, + [32]: `HYDRATE_EVENTS`, + [64]: `STABLE_FRAGMENT`, + [128]: `KEYED_FRAGMENT`, + [256]: `UNKEYED_FRAGMENT`, + [512]: `NEED_PATCH`, + [1024]: `DYNAMIC_SLOTS`, + [2048]: `DEV_ROOT_FRAGMENT`, + [-1]: `HOISTED`, + [-2]: `BAIL` +}; + +const slotFlagsText = { + [1]: "STABLE", + [2]: "DYNAMIC", + [3]: "FORWARDED" +}; + +const GLOBALS_ALLOWED = "Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console"; +const isGloballyAllowed = /* @__PURE__ */ makeMap(GLOBALS_ALLOWED); + +const range = 2; +function generateCodeFrame(source, start = 0, end = source.length) { + let lines = source.split(/(\r?\n)/); + const newlineSequences = lines.filter((_, idx) => idx % 2 === 1); + lines = lines.filter((_, idx) => idx % 2 === 0); + let count = 0; + const res = []; + for (let i = 0; i < lines.length; i++) { + count += lines[i].length + (newlineSequences[i] && newlineSequences[i].length || 0); + if (count >= start) { + for (let j = i - range; j <= i + range || end > count; j++) { + if (j < 0 || j >= lines.length) + continue; + const line = j + 1; + res.push( + `${line}${" ".repeat(Math.max(3 - String(line).length, 0))}| ${lines[j]}` + ); + const lineLength = lines[j].length; + const newLineSeqLength = newlineSequences[j] && newlineSequences[j].length || 0; + if (j === i) { + const pad = start - (count - (lineLength + newLineSeqLength)); + const length = Math.max( + 1, + end > count ? lineLength - pad : end - start + ); + res.push(` | ` + " ".repeat(pad) + "^".repeat(length)); + } else if (j > i) { + if (end > count) { + const length = Math.max(Math.min(end - count, lineLength), 1); + res.push(` | ` + "^".repeat(length)); + } + count += lineLength + newLineSeqLength; + } + } + break; + } + } + return res.join("\n"); +} + +function normalizeStyle(value) { + if (isArray(value)) { + const res = {}; + for (let i = 0; i < value.length; i++) { + const item = value[i]; + const normalized = isString(item) ? parseStringStyle(item) : normalizeStyle(item); + if (normalized) { + for (const key in normalized) { + res[key] = normalized[key]; + } + } + } + return res; + } else if (isString(value) || isObject(value)) { + return value; + } +} +const listDelimiterRE = /;(?![^(]*\))/g; +const propertyDelimiterRE = /:([^]+)/; +const styleCommentRE = /\/\*[^]*?\*\//g; +function parseStringStyle(cssText) { + const ret = {}; + cssText.replace(styleCommentRE, "").split(listDelimiterRE).forEach((item) => { + if (item) { + const tmp = item.split(propertyDelimiterRE); + tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim()); + } + }); + return ret; +} +function normalizeClass(value) { + let res = ""; + if (isString(value)) { + res = value; + } else if (isArray(value)) { + for (let i = 0; i < value.length; i++) { + const normalized = normalizeClass(value[i]); + if (normalized) { + res += normalized + " "; + } + } + } else if (isObject(value)) { + for (const name in value) { + if (value[name]) { + res += name + " "; + } + } + } + return res.trim(); +} +function normalizeProps(props) { + if (!props) + return null; + let { class: klass, style } = props; + if (klass && !isString(klass)) { + props.class = normalizeClass(klass); + } + if (style) { + props.style = normalizeStyle(style); + } + return props; +} + +const HTML_TAGS = "html,body,base,head,link,meta,style,title,address,article,aside,footer,header,hgroup,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,output,progress,select,textarea,details,dialog,menu,summary,template,blockquote,iframe,tfoot"; +const SVG_TAGS = "svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,feDistantLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,text,textPath,title,tspan,unknown,use,view"; +const VOID_TAGS = "area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr"; +const isHTMLTag = /* @__PURE__ */ makeMap(HTML_TAGS); +const isSVGTag = /* @__PURE__ */ makeMap(SVG_TAGS); +const isVoidTag = /* @__PURE__ */ makeMap(VOID_TAGS); + +const specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`; +const isSpecialBooleanAttr = /* @__PURE__ */ makeMap(specialBooleanAttrs); +function includeBooleanAttr(value) { + return !!value || value === ""; +} + +function looseCompareArrays(a, b) { + if (a.length !== b.length) + return false; + let equal = true; + for (let i = 0; equal && i < a.length; i++) { + equal = looseEqual(a[i], b[i]); + } + return equal; +} +function looseEqual(a, b) { + if (a === b) + return true; + let aValidType = isDate(a); + let bValidType = isDate(b); + if (aValidType || bValidType) { + return aValidType && bValidType ? a.getTime() === b.getTime() : false; + } + aValidType = isSymbol(a); + bValidType = isSymbol(b); + if (aValidType || bValidType) { + return a === b; + } + aValidType = isArray(a); + bValidType = isArray(b); + if (aValidType || bValidType) { + return aValidType && bValidType ? looseCompareArrays(a, b) : false; + } + aValidType = isObject(a); + bValidType = isObject(b); + if (aValidType || bValidType) { + if (!aValidType || !bValidType) { + return false; + } + const aKeysCount = Object.keys(a).length; + const bKeysCount = Object.keys(b).length; + if (aKeysCount !== bKeysCount) { + return false; + } + for (const key in a) { + const aHasKey = a.hasOwnProperty(key); + const bHasKey = b.hasOwnProperty(key); + if (aHasKey && !bHasKey || !aHasKey && bHasKey || !looseEqual(a[key], b[key])) { + return false; + } + } + } + return String(a) === String(b); +} +function looseIndexOf(arr, val) { + return arr.findIndex((item) => looseEqual(item, val)); +} + +const toDisplayString = (val) => { + return isString(val) ? val : val == null ? "" : isArray(val) || isObject(val) && (val.toString === objectToString || !isFunction(val.toString)) ? JSON.stringify(val, replacer, 2) : String(val); +}; +const replacer = (_key, val) => { + if (val && val.__v_isRef) { + return replacer(_key, val.value); + } else if (isMap(val)) { + return { + [`Map(${val.size})`]: [...val.entries()].reduce((entries, [key, val2]) => { + entries[`${key} =>`] = val2; + return entries; + }, {}) + }; + } else if (isSet(val)) { + return { + [`Set(${val.size})`]: [...val.values()] + }; + } else if (isObject(val) && !isArray(val) && !isPlainObject(val)) { + return String(val); + } + return val; +}; + +function warn$1(msg, ...args) { + console.warn(`[Vue warn] ${msg}`, ...args); +} + +let activeEffectScope; +class EffectScope { + constructor(detached = false) { + this.detached = detached; + /** + * @internal + */ + this._active = true; + /** + * @internal + */ + this.effects = []; + /** + * @internal + */ + this.cleanups = []; + this.parent = activeEffectScope; + if (!detached && activeEffectScope) { + this.index = (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push( + this + ) - 1; + } + } + get active() { + return this._active; + } + run(fn) { + if (this._active) { + const currentEffectScope = activeEffectScope; + try { + activeEffectScope = this; + return fn(); + } finally { + activeEffectScope = currentEffectScope; + } + } else { + warn$1(`cannot run an inactive effect scope.`); + } + } + /** + * This should only be called on non-detached scopes + * @internal + */ + on() { + activeEffectScope = this; + } + /** + * This should only be called on non-detached scopes + * @internal + */ + off() { + activeEffectScope = this.parent; + } + stop(fromParent) { + if (this._active) { + let i, l; + for (i = 0, l = this.effects.length; i < l; i++) { + this.effects[i].stop(); + } + for (i = 0, l = this.cleanups.length; i < l; i++) { + this.cleanups[i](); + } + if (this.scopes) { + for (i = 0, l = this.scopes.length; i < l; i++) { + this.scopes[i].stop(true); + } + } + if (!this.detached && this.parent && !fromParent) { + const last = this.parent.scopes.pop(); + if (last && last !== this) { + this.parent.scopes[this.index] = last; + last.index = this.index; + } + } + this.parent = void 0; + this._active = false; + } + } +} +function effectScope(detached) { + return new EffectScope(detached); +} +function recordEffectScope(effect, scope = activeEffectScope) { + if (scope && scope.active) { + scope.effects.push(effect); + } +} +function getCurrentScope() { + return activeEffectScope; +} +function onScopeDispose(fn) { + if (activeEffectScope) { + activeEffectScope.cleanups.push(fn); + } else { + warn$1( + `onScopeDispose() is called when there is no active effect scope to be associated with.` + ); + } +} + +const createDep = (effects) => { + const dep = new Set(effects); + dep.w = 0; + dep.n = 0; + return dep; +}; +const wasTracked = (dep) => (dep.w & trackOpBit) > 0; +const newTracked = (dep) => (dep.n & trackOpBit) > 0; +const initDepMarkers = ({ deps }) => { + if (deps.length) { + for (let i = 0; i < deps.length; i++) { + deps[i].w |= trackOpBit; + } + } +}; +const finalizeDepMarkers = (effect) => { + const { deps } = effect; + if (deps.length) { + let ptr = 0; + for (let i = 0; i < deps.length; i++) { + const dep = deps[i]; + if (wasTracked(dep) && !newTracked(dep)) { + dep.delete(effect); + } else { + deps[ptr++] = dep; + } + dep.w &= ~trackOpBit; + dep.n &= ~trackOpBit; + } + deps.length = ptr; + } +}; + +const targetMap = /* @__PURE__ */ new WeakMap(); +let effectTrackDepth = 0; +let trackOpBit = 1; +const maxMarkerBits = 30; +let activeEffect; +const ITERATE_KEY = Symbol("iterate" ); +const MAP_KEY_ITERATE_KEY = Symbol("Map key iterate" ); +class ReactiveEffect { + constructor(fn, scheduler = null, scope) { + this.fn = fn; + this.scheduler = scheduler; + this.active = true; + this.deps = []; + this.parent = void 0; + recordEffectScope(this, scope); + } + run() { + if (!this.active) { + return this.fn(); + } + let parent = activeEffect; + let lastShouldTrack = shouldTrack; + while (parent) { + if (parent === this) { + return; + } + parent = parent.parent; + } + try { + this.parent = activeEffect; + activeEffect = this; + shouldTrack = true; + trackOpBit = 1 << ++effectTrackDepth; + if (effectTrackDepth <= maxMarkerBits) { + initDepMarkers(this); + } else { + cleanupEffect(this); + } + return this.fn(); + } finally { + if (effectTrackDepth <= maxMarkerBits) { + finalizeDepMarkers(this); + } + trackOpBit = 1 << --effectTrackDepth; + activeEffect = this.parent; + shouldTrack = lastShouldTrack; + this.parent = void 0; + if (this.deferStop) { + this.stop(); + } + } + } + stop() { + if (activeEffect === this) { + this.deferStop = true; + } else if (this.active) { + cleanupEffect(this); + if (this.onStop) { + this.onStop(); + } + this.active = false; + } + } +} +function cleanupEffect(effect2) { + const { deps } = effect2; + if (deps.length) { + for (let i = 0; i < deps.length; i++) { + deps[i].delete(effect2); + } + deps.length = 0; + } +} +function effect(fn, options) { + if (fn.effect instanceof ReactiveEffect) { + fn = fn.effect.fn; + } + const _effect = new ReactiveEffect(fn); + if (options) { + extend(_effect, options); + if (options.scope) + recordEffectScope(_effect, options.scope); + } + if (!options || !options.lazy) { + _effect.run(); + } + const runner = _effect.run.bind(_effect); + runner.effect = _effect; + return runner; +} +function stop(runner) { + runner.effect.stop(); +} +let shouldTrack = true; +const trackStack = []; +function pauseTracking() { + trackStack.push(shouldTrack); + shouldTrack = false; +} +function resetTracking() { + const last = trackStack.pop(); + shouldTrack = last === void 0 ? true : last; +} +function track(target, type, key) { + if (shouldTrack && activeEffect) { + let depsMap = targetMap.get(target); + if (!depsMap) { + targetMap.set(target, depsMap = /* @__PURE__ */ new Map()); + } + let dep = depsMap.get(key); + if (!dep) { + depsMap.set(key, dep = createDep()); + } + const eventInfo = { effect: activeEffect, target, type, key } ; + trackEffects(dep, eventInfo); + } +} +function trackEffects(dep, debuggerEventExtraInfo) { + let shouldTrack2 = false; + if (effectTrackDepth <= maxMarkerBits) { + if (!newTracked(dep)) { + dep.n |= trackOpBit; + shouldTrack2 = !wasTracked(dep); + } + } else { + shouldTrack2 = !dep.has(activeEffect); + } + if (shouldTrack2) { + dep.add(activeEffect); + activeEffect.deps.push(dep); + if (activeEffect.onTrack) { + activeEffect.onTrack( + extend( + { + effect: activeEffect + }, + debuggerEventExtraInfo + ) + ); + } + } +} +function trigger(target, type, key, newValue, oldValue, oldTarget) { + const depsMap = targetMap.get(target); + if (!depsMap) { + return; + } + let deps = []; + if (type === "clear") { + deps = [...depsMap.values()]; + } else if (key === "length" && isArray(target)) { + const newLength = Number(newValue); + depsMap.forEach((dep, key2) => { + if (key2 === "length" || !isSymbol(key2) && key2 >= newLength) { + deps.push(dep); + } + }); + } else { + if (key !== void 0) { + deps.push(depsMap.get(key)); + } + switch (type) { + case "add": + if (!isArray(target)) { + deps.push(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } else if (isIntegerKey(key)) { + deps.push(depsMap.get("length")); + } + break; + case "delete": + if (!isArray(target)) { + deps.push(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } + break; + case "set": + if (isMap(target)) { + deps.push(depsMap.get(ITERATE_KEY)); + } + break; + } + } + const eventInfo = { target, type, key, newValue, oldValue, oldTarget } ; + if (deps.length === 1) { + if (deps[0]) { + { + triggerEffects(deps[0], eventInfo); + } + } + } else { + const effects = []; + for (const dep of deps) { + if (dep) { + effects.push(...dep); + } + } + { + triggerEffects(createDep(effects), eventInfo); + } + } +} +function triggerEffects(dep, debuggerEventExtraInfo) { + const effects = isArray(dep) ? dep : [...dep]; + for (const effect2 of effects) { + if (effect2.computed) { + triggerEffect(effect2, debuggerEventExtraInfo); + } + } + for (const effect2 of effects) { + if (!effect2.computed) { + triggerEffect(effect2, debuggerEventExtraInfo); + } + } +} +function triggerEffect(effect2, debuggerEventExtraInfo) { + if (effect2 !== activeEffect || effect2.allowRecurse) { + if (effect2.onTrigger) { + effect2.onTrigger(extend({ effect: effect2 }, debuggerEventExtraInfo)); + } + if (effect2.scheduler) { + effect2.scheduler(); + } else { + effect2.run(); + } + } +} +function getDepFromReactive(object, key) { + var _a; + return (_a = targetMap.get(object)) == null ? void 0 : _a.get(key); +} + +const isNonTrackableKeys = /* @__PURE__ */ makeMap(`__proto__,__v_isRef,__isVue`); +const builtInSymbols = new Set( + /* @__PURE__ */ Object.getOwnPropertyNames(Symbol).filter((key) => key !== "arguments" && key !== "caller").map((key) => Symbol[key]).filter(isSymbol) +); +const arrayInstrumentations = /* @__PURE__ */ createArrayInstrumentations(); +function createArrayInstrumentations() { + const instrumentations = {}; + ["includes", "indexOf", "lastIndexOf"].forEach((key) => { + instrumentations[key] = function(...args) { + const arr = toRaw(this); + for (let i = 0, l = this.length; i < l; i++) { + track(arr, "get", i + ""); + } + const res = arr[key](...args); + if (res === -1 || res === false) { + return arr[key](...args.map(toRaw)); + } else { + return res; + } + }; + }); + ["push", "pop", "shift", "unshift", "splice"].forEach((key) => { + instrumentations[key] = function(...args) { + pauseTracking(); + const res = toRaw(this)[key].apply(this, args); + resetTracking(); + return res; + }; + }); + return instrumentations; +} +function hasOwnProperty(key) { + const obj = toRaw(this); + track(obj, "has", key); + return obj.hasOwnProperty(key); +} +class BaseReactiveHandler { + constructor(_isReadonly = false, _shallow = false) { + this._isReadonly = _isReadonly; + this._shallow = _shallow; + } + get(target, key, receiver) { + const isReadonly2 = this._isReadonly, shallow = this._shallow; + if (key === "__v_isReactive") { + return !isReadonly2; + } else if (key === "__v_isReadonly") { + return isReadonly2; + } else if (key === "__v_isShallow") { + return shallow; + } else if (key === "__v_raw" && receiver === (isReadonly2 ? shallow ? shallowReadonlyMap : readonlyMap : shallow ? shallowReactiveMap : reactiveMap).get(target)) { + return target; + } + const targetIsArray = isArray(target); + if (!isReadonly2) { + if (targetIsArray && hasOwn(arrayInstrumentations, key)) { + return Reflect.get(arrayInstrumentations, key, receiver); + } + if (key === "hasOwnProperty") { + return hasOwnProperty; + } + } + const res = Reflect.get(target, key, receiver); + if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) { + return res; + } + if (!isReadonly2) { + track(target, "get", key); + } + if (shallow) { + return res; + } + if (isRef(res)) { + return targetIsArray && isIntegerKey(key) ? res : res.value; + } + if (isObject(res)) { + return isReadonly2 ? readonly(res) : reactive(res); + } + return res; + } +} +class MutableReactiveHandler extends BaseReactiveHandler { + constructor(shallow = false) { + super(false, shallow); + } + set(target, key, value, receiver) { + let oldValue = target[key]; + if (isReadonly(oldValue) && isRef(oldValue) && !isRef(value)) { + return false; + } + if (!this._shallow) { + if (!isShallow(value) && !isReadonly(value)) { + oldValue = toRaw(oldValue); + value = toRaw(value); + } + if (!isArray(target) && isRef(oldValue) && !isRef(value)) { + oldValue.value = value; + return true; + } + } + const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key); + const result = Reflect.set(target, key, value, receiver); + if (target === toRaw(receiver)) { + if (!hadKey) { + trigger(target, "add", key, value); + } else if (hasChanged(value, oldValue)) { + trigger(target, "set", key, value, oldValue); + } + } + return result; + } + deleteProperty(target, key) { + const hadKey = hasOwn(target, key); + const oldValue = target[key]; + const result = Reflect.deleteProperty(target, key); + if (result && hadKey) { + trigger(target, "delete", key, void 0, oldValue); + } + return result; + } + has(target, key) { + const result = Reflect.has(target, key); + if (!isSymbol(key) || !builtInSymbols.has(key)) { + track(target, "has", key); + } + return result; + } + ownKeys(target) { + track( + target, + "iterate", + isArray(target) ? "length" : ITERATE_KEY + ); + return Reflect.ownKeys(target); + } +} +class ReadonlyReactiveHandler extends BaseReactiveHandler { + constructor(shallow = false) { + super(true, shallow); + } + set(target, key) { + { + warn$1( + `Set operation on key "${String(key)}" failed: target is readonly.`, + target + ); + } + return true; + } + deleteProperty(target, key) { + { + warn$1( + `Delete operation on key "${String(key)}" failed: target is readonly.`, + target + ); + } + return true; + } +} +const mutableHandlers = /* @__PURE__ */ new MutableReactiveHandler(); +const readonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(); +const shallowReactiveHandlers = /* @__PURE__ */ new MutableReactiveHandler( + true +); +const shallowReadonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(true); + +const toShallow = (value) => value; +const getProto = (v) => Reflect.getPrototypeOf(v); +function get(target, key, isReadonly = false, isShallow = false) { + target = target["__v_raw"]; + const rawTarget = toRaw(target); + const rawKey = toRaw(key); + if (!isReadonly) { + if (hasChanged(key, rawKey)) { + track(rawTarget, "get", key); + } + track(rawTarget, "get", rawKey); + } + const { has: has2 } = getProto(rawTarget); + const wrap = isShallow ? toShallow : isReadonly ? toReadonly : toReactive; + if (has2.call(rawTarget, key)) { + return wrap(target.get(key)); + } else if (has2.call(rawTarget, rawKey)) { + return wrap(target.get(rawKey)); + } else if (target !== rawTarget) { + target.get(key); + } +} +function has(key, isReadonly = false) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const rawKey = toRaw(key); + if (!isReadonly) { + if (hasChanged(key, rawKey)) { + track(rawTarget, "has", key); + } + track(rawTarget, "has", rawKey); + } + return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey); +} +function size(target, isReadonly = false) { + target = target["__v_raw"]; + !isReadonly && track(toRaw(target), "iterate", ITERATE_KEY); + return Reflect.get(target, "size", target); +} +function add(value) { + value = toRaw(value); + const target = toRaw(this); + const proto = getProto(target); + const hadKey = proto.has.call(target, value); + if (!hadKey) { + target.add(value); + trigger(target, "add", value, value); + } + return this; +} +function set(key, value) { + value = toRaw(value); + const target = toRaw(this); + const { has: has2, get: get2 } = getProto(target); + let hadKey = has2.call(target, key); + if (!hadKey) { + key = toRaw(key); + hadKey = has2.call(target, key); + } else { + checkIdentityKeys(target, has2, key); + } + const oldValue = get2.call(target, key); + target.set(key, value); + if (!hadKey) { + trigger(target, "add", key, value); + } else if (hasChanged(value, oldValue)) { + trigger(target, "set", key, value, oldValue); + } + return this; +} +function deleteEntry(key) { + const target = toRaw(this); + const { has: has2, get: get2 } = getProto(target); + let hadKey = has2.call(target, key); + if (!hadKey) { + key = toRaw(key); + hadKey = has2.call(target, key); + } else { + checkIdentityKeys(target, has2, key); + } + const oldValue = get2 ? get2.call(target, key) : void 0; + const result = target.delete(key); + if (hadKey) { + trigger(target, "delete", key, void 0, oldValue); + } + return result; +} +function clear() { + const target = toRaw(this); + const hadItems = target.size !== 0; + const oldTarget = isMap(target) ? new Map(target) : new Set(target) ; + const result = target.clear(); + if (hadItems) { + trigger(target, "clear", void 0, void 0, oldTarget); + } + return result; +} +function createForEach(isReadonly, isShallow) { + return function forEach(callback, thisArg) { + const observed = this; + const target = observed["__v_raw"]; + const rawTarget = toRaw(target); + const wrap = isShallow ? toShallow : isReadonly ? toReadonly : toReactive; + !isReadonly && track(rawTarget, "iterate", ITERATE_KEY); + return target.forEach((value, key) => { + return callback.call(thisArg, wrap(value), wrap(key), observed); + }); + }; +} +function createIterableMethod(method, isReadonly, isShallow) { + return function(...args) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const targetIsMap = isMap(rawTarget); + const isPair = method === "entries" || method === Symbol.iterator && targetIsMap; + const isKeyOnly = method === "keys" && targetIsMap; + const innerIterator = target[method](...args); + const wrap = isShallow ? toShallow : isReadonly ? toReadonly : toReactive; + !isReadonly && track( + rawTarget, + "iterate", + isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY + ); + return { + // iterator protocol + next() { + const { value, done } = innerIterator.next(); + return done ? { value, done } : { + value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), + done + }; + }, + // iterable protocol + [Symbol.iterator]() { + return this; + } + }; + }; +} +function createReadonlyMethod(type) { + return function(...args) { + { + const key = args[0] ? `on key "${args[0]}" ` : ``; + console.warn( + `${capitalize(type)} operation ${key}failed: target is readonly.`, + toRaw(this) + ); + } + return type === "delete" ? false : this; + }; +} +function createInstrumentations() { + const mutableInstrumentations2 = { + get(key) { + return get(this, key); + }, + get size() { + return size(this); + }, + has, + add, + set, + delete: deleteEntry, + clear, + forEach: createForEach(false, false) + }; + const shallowInstrumentations2 = { + get(key) { + return get(this, key, false, true); + }, + get size() { + return size(this); + }, + has, + add, + set, + delete: deleteEntry, + clear, + forEach: createForEach(false, true) + }; + const readonlyInstrumentations2 = { + get(key) { + return get(this, key, true); + }, + get size() { + return size(this, true); + }, + has(key) { + return has.call(this, key, true); + }, + add: createReadonlyMethod("add"), + set: createReadonlyMethod("set"), + delete: createReadonlyMethod("delete"), + clear: createReadonlyMethod("clear"), + forEach: createForEach(true, false) + }; + const shallowReadonlyInstrumentations2 = { + get(key) { + return get(this, key, true, true); + }, + get size() { + return size(this, true); + }, + has(key) { + return has.call(this, key, true); + }, + add: createReadonlyMethod("add"), + set: createReadonlyMethod("set"), + delete: createReadonlyMethod("delete"), + clear: createReadonlyMethod("clear"), + forEach: createForEach(true, true) + }; + const iteratorMethods = ["keys", "values", "entries", Symbol.iterator]; + iteratorMethods.forEach((method) => { + mutableInstrumentations2[method] = createIterableMethod( + method, + false, + false + ); + readonlyInstrumentations2[method] = createIterableMethod( + method, + true, + false + ); + shallowInstrumentations2[method] = createIterableMethod( + method, + false, + true + ); + shallowReadonlyInstrumentations2[method] = createIterableMethod( + method, + true, + true + ); + }); + return [ + mutableInstrumentations2, + readonlyInstrumentations2, + shallowInstrumentations2, + shallowReadonlyInstrumentations2 + ]; +} +const [ + mutableInstrumentations, + readonlyInstrumentations, + shallowInstrumentations, + shallowReadonlyInstrumentations +] = /* @__PURE__ */ createInstrumentations(); +function createInstrumentationGetter(isReadonly, shallow) { + const instrumentations = shallow ? isReadonly ? shallowReadonlyInstrumentations : shallowInstrumentations : isReadonly ? readonlyInstrumentations : mutableInstrumentations; + return (target, key, receiver) => { + if (key === "__v_isReactive") { + return !isReadonly; + } else if (key === "__v_isReadonly") { + return isReadonly; + } else if (key === "__v_raw") { + return target; + } + return Reflect.get( + hasOwn(instrumentations, key) && key in target ? instrumentations : target, + key, + receiver + ); + }; +} +const mutableCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, false) +}; +const shallowCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, true) +}; +const readonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, false) +}; +const shallowReadonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, true) +}; +function checkIdentityKeys(target, has2, key) { + const rawKey = toRaw(key); + if (rawKey !== key && has2.call(target, rawKey)) { + const type = toRawType(target); + console.warn( + `Reactive ${type} contains both the raw and reactive versions of the same object${type === `Map` ? ` as keys` : ``}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.` + ); + } +} + +const reactiveMap = /* @__PURE__ */ new WeakMap(); +const shallowReactiveMap = /* @__PURE__ */ new WeakMap(); +const readonlyMap = /* @__PURE__ */ new WeakMap(); +const shallowReadonlyMap = /* @__PURE__ */ new WeakMap(); +function targetTypeMap(rawType) { + switch (rawType) { + case "Object": + case "Array": + return 1 /* COMMON */; + case "Map": + case "Set": + case "WeakMap": + case "WeakSet": + return 2 /* COLLECTION */; + default: + return 0 /* INVALID */; + } +} +function getTargetType(value) { + return value["__v_skip"] || !Object.isExtensible(value) ? 0 /* INVALID */ : targetTypeMap(toRawType(value)); +} +function reactive(target) { + if (isReadonly(target)) { + return target; + } + return createReactiveObject( + target, + false, + mutableHandlers, + mutableCollectionHandlers, + reactiveMap + ); +} +function shallowReactive(target) { + return createReactiveObject( + target, + false, + shallowReactiveHandlers, + shallowCollectionHandlers, + shallowReactiveMap + ); +} +function readonly(target) { + return createReactiveObject( + target, + true, + readonlyHandlers, + readonlyCollectionHandlers, + readonlyMap + ); +} +function shallowReadonly(target) { + return createReactiveObject( + target, + true, + shallowReadonlyHandlers, + shallowReadonlyCollectionHandlers, + shallowReadonlyMap + ); +} +function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) { + if (!isObject(target)) { + { + console.warn(`value cannot be made reactive: ${String(target)}`); + } + return target; + } + if (target["__v_raw"] && !(isReadonly2 && target["__v_isReactive"])) { + return target; + } + const existingProxy = proxyMap.get(target); + if (existingProxy) { + return existingProxy; + } + const targetType = getTargetType(target); + if (targetType === 0 /* INVALID */) { + return target; + } + const proxy = new Proxy( + target, + targetType === 2 /* COLLECTION */ ? collectionHandlers : baseHandlers + ); + proxyMap.set(target, proxy); + return proxy; +} +function isReactive(value) { + if (isReadonly(value)) { + return isReactive(value["__v_raw"]); + } + return !!(value && value["__v_isReactive"]); +} +function isReadonly(value) { + return !!(value && value["__v_isReadonly"]); +} +function isShallow(value) { + return !!(value && value["__v_isShallow"]); +} +function isProxy(value) { + return isReactive(value) || isReadonly(value); +} +function toRaw(observed) { + const raw = observed && observed["__v_raw"]; + return raw ? toRaw(raw) : observed; +} +function markRaw(value) { + def(value, "__v_skip", true); + return value; +} +const toReactive = (value) => isObject(value) ? reactive(value) : value; +const toReadonly = (value) => isObject(value) ? readonly(value) : value; + +function trackRefValue(ref2) { + if (shouldTrack && activeEffect) { + ref2 = toRaw(ref2); + { + trackEffects(ref2.dep || (ref2.dep = createDep()), { + target: ref2, + type: "get", + key: "value" + }); + } + } +} +function triggerRefValue(ref2, newVal) { + ref2 = toRaw(ref2); + const dep = ref2.dep; + if (dep) { + { + triggerEffects(dep, { + target: ref2, + type: "set", + key: "value", + newValue: newVal + }); + } + } +} +function isRef(r) { + return !!(r && r.__v_isRef === true); +} +function ref(value) { + return createRef(value, false); +} +function shallowRef(value) { + return createRef(value, true); +} +function createRef(rawValue, shallow) { + if (isRef(rawValue)) { + return rawValue; + } + return new RefImpl(rawValue, shallow); +} +class RefImpl { + constructor(value, __v_isShallow) { + this.__v_isShallow = __v_isShallow; + this.dep = void 0; + this.__v_isRef = true; + this._rawValue = __v_isShallow ? value : toRaw(value); + this._value = __v_isShallow ? value : toReactive(value); + } + get value() { + trackRefValue(this); + return this._value; + } + set value(newVal) { + const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal); + newVal = useDirectValue ? newVal : toRaw(newVal); + if (hasChanged(newVal, this._rawValue)) { + this._rawValue = newVal; + this._value = useDirectValue ? newVal : toReactive(newVal); + triggerRefValue(this, newVal); + } + } +} +function triggerRef(ref2) { + triggerRefValue(ref2, ref2.value ); +} +function unref(ref2) { + return isRef(ref2) ? ref2.value : ref2; +} +function toValue(source) { + return isFunction(source) ? source() : unref(source); +} +const shallowUnwrapHandlers = { + get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)), + set: (target, key, value, receiver) => { + const oldValue = target[key]; + if (isRef(oldValue) && !isRef(value)) { + oldValue.value = value; + return true; + } else { + return Reflect.set(target, key, value, receiver); + } + } +}; +function proxyRefs(objectWithRefs) { + return isReactive(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers); +} +class CustomRefImpl { + constructor(factory) { + this.dep = void 0; + this.__v_isRef = true; + const { get, set } = factory( + () => trackRefValue(this), + () => triggerRefValue(this) + ); + this._get = get; + this._set = set; + } + get value() { + return this._get(); + } + set value(newVal) { + this._set(newVal); + } +} +function customRef(factory) { + return new CustomRefImpl(factory); +} +function toRefs(object) { + if (!isProxy(object)) { + console.warn(`toRefs() expects a reactive object but received a plain one.`); + } + const ret = isArray(object) ? new Array(object.length) : {}; + for (const key in object) { + ret[key] = propertyToRef(object, key); + } + return ret; +} +class ObjectRefImpl { + constructor(_object, _key, _defaultValue) { + this._object = _object; + this._key = _key; + this._defaultValue = _defaultValue; + this.__v_isRef = true; + } + get value() { + const val = this._object[this._key]; + return val === void 0 ? this._defaultValue : val; + } + set value(newVal) { + this._object[this._key] = newVal; + } + get dep() { + return getDepFromReactive(toRaw(this._object), this._key); + } +} +class GetterRefImpl { + constructor(_getter) { + this._getter = _getter; + this.__v_isRef = true; + this.__v_isReadonly = true; + } + get value() { + return this._getter(); + } +} +function toRef(source, key, defaultValue) { + if (isRef(source)) { + return source; + } else if (isFunction(source)) { + return new GetterRefImpl(source); + } else if (isObject(source) && arguments.length > 1) { + return propertyToRef(source, key, defaultValue); + } else { + return ref(source); + } +} +function propertyToRef(source, key, defaultValue) { + const val = source[key]; + return isRef(val) ? val : new ObjectRefImpl(source, key, defaultValue); +} + +class ComputedRefImpl { + constructor(getter, _setter, isReadonly, isSSR) { + this._setter = _setter; + this.dep = void 0; + this.__v_isRef = true; + this["__v_isReadonly"] = false; + this._dirty = true; + this.effect = new ReactiveEffect(getter, () => { + if (!this._dirty) { + this._dirty = true; + triggerRefValue(this); + } + }); + this.effect.computed = this; + this.effect.active = this._cacheable = !isSSR; + this["__v_isReadonly"] = isReadonly; + } + get value() { + const self = toRaw(this); + trackRefValue(self); + if (self._dirty || !self._cacheable) { + self._dirty = false; + self._value = self.effect.run(); + } + return self._value; + } + set value(newValue) { + this._setter(newValue); + } +} +function computed$1(getterOrOptions, debugOptions, isSSR = false) { + let getter; + let setter; + const onlyGetter = isFunction(getterOrOptions); + if (onlyGetter) { + getter = getterOrOptions; + setter = () => { + console.warn("Write operation failed: computed value is readonly"); + } ; + } else { + getter = getterOrOptions.get; + setter = getterOrOptions.set; + } + const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR); + if (debugOptions && !isSSR) { + cRef.effect.onTrack = debugOptions.onTrack; + cRef.effect.onTrigger = debugOptions.onTrigger; + } + return cRef; +} + +const stack = []; +function pushWarningContext(vnode) { + stack.push(vnode); +} +function popWarningContext() { + stack.pop(); +} +function warn(msg, ...args) { + pauseTracking(); + const instance = stack.length ? stack[stack.length - 1].component : null; + const appWarnHandler = instance && instance.appContext.config.warnHandler; + const trace = getComponentTrace(); + if (appWarnHandler) { + callWithErrorHandling( + appWarnHandler, + instance, + 11, + [ + msg + args.join(""), + instance && instance.proxy, + trace.map( + ({ vnode }) => `at <${formatComponentName(instance, vnode.type)}>` + ).join("\n"), + trace + ] + ); + } else { + const warnArgs = [`[Vue warn]: ${msg}`, ...args]; + if (trace.length && // avoid spamming console during tests + true) { + warnArgs.push(` +`, ...formatTrace(trace)); + } + console.warn(...warnArgs); + } + resetTracking(); +} +function getComponentTrace() { + let currentVNode = stack[stack.length - 1]; + if (!currentVNode) { + return []; + } + const normalizedStack = []; + while (currentVNode) { + const last = normalizedStack[0]; + if (last && last.vnode === currentVNode) { + last.recurseCount++; + } else { + normalizedStack.push({ + vnode: currentVNode, + recurseCount: 0 + }); + } + const parentInstance = currentVNode.component && currentVNode.component.parent; + currentVNode = parentInstance && parentInstance.vnode; + } + return normalizedStack; +} +function formatTrace(trace) { + const logs = []; + trace.forEach((entry, i) => { + logs.push(...i === 0 ? [] : [` +`], ...formatTraceEntry(entry)); + }); + return logs; +} +function formatTraceEntry({ vnode, recurseCount }) { + const postfix = recurseCount > 0 ? `... (${recurseCount} recursive calls)` : ``; + const isRoot = vnode.component ? vnode.component.parent == null : false; + const open = ` at <${formatComponentName( + vnode.component, + vnode.type, + isRoot + )}`; + const close = `>` + postfix; + return vnode.props ? [open, ...formatProps(vnode.props), close] : [open + close]; +} +function formatProps(props) { + const res = []; + const keys = Object.keys(props); + keys.slice(0, 3).forEach((key) => { + res.push(...formatProp(key, props[key])); + }); + if (keys.length > 3) { + res.push(` ...`); + } + return res; +} +function formatProp(key, value, raw) { + if (isString(value)) { + value = JSON.stringify(value); + return raw ? value : [`${key}=${value}`]; + } else if (typeof value === "number" || typeof value === "boolean" || value == null) { + return raw ? value : [`${key}=${value}`]; + } else if (isRef(value)) { + value = formatProp(key, toRaw(value.value), true); + return raw ? value : [`${key}=Ref<`, value, `>`]; + } else if (isFunction(value)) { + return [`${key}=fn${value.name ? `<${value.name}>` : ``}`]; + } else { + value = toRaw(value); + return raw ? value : [`${key}=`, value]; + } +} +function assertNumber(val, type) { + if (val === void 0) { + return; + } else if (typeof val !== "number") { + warn(`${type} is not a valid number - got ${JSON.stringify(val)}.`); + } else if (isNaN(val)) { + warn(`${type} is NaN - the duration expression might be incorrect.`); + } +} + +const ErrorTypeStrings = { + ["sp"]: "serverPrefetch hook", + ["bc"]: "beforeCreate hook", + ["c"]: "created hook", + ["bm"]: "beforeMount hook", + ["m"]: "mounted hook", + ["bu"]: "beforeUpdate hook", + ["u"]: "updated", + ["bum"]: "beforeUnmount hook", + ["um"]: "unmounted hook", + ["a"]: "activated hook", + ["da"]: "deactivated hook", + ["ec"]: "errorCaptured hook", + ["rtc"]: "renderTracked hook", + ["rtg"]: "renderTriggered hook", + [0]: "setup function", + [1]: "render function", + [2]: "watcher getter", + [3]: "watcher callback", + [4]: "watcher cleanup function", + [5]: "native event handler", + [6]: "component event handler", + [7]: "vnode hook", + [8]: "directive hook", + [9]: "transition hook", + [10]: "app errorHandler", + [11]: "app warnHandler", + [12]: "ref function", + [13]: "async component loader", + [14]: "scheduler flush. This is likely a Vue internals bug. Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/core" +}; +function callWithErrorHandling(fn, instance, type, args) { + let res; + try { + res = args ? fn(...args) : fn(); + } catch (err) { + handleError(err, instance, type); + } + return res; +} +function callWithAsyncErrorHandling(fn, instance, type, args) { + if (isFunction(fn)) { + const res = callWithErrorHandling(fn, instance, type, args); + if (res && isPromise(res)) { + res.catch((err) => { + handleError(err, instance, type); + }); + } + return res; + } + const values = []; + for (let i = 0; i < fn.length; i++) { + values.push(callWithAsyncErrorHandling(fn[i], instance, type, args)); + } + return values; +} +function handleError(err, instance, type, throwInDev = true) { + const contextVNode = instance ? instance.vnode : null; + if (instance) { + let cur = instance.parent; + const exposedInstance = instance.proxy; + const errorInfo = ErrorTypeStrings[type] ; + while (cur) { + const errorCapturedHooks = cur.ec; + if (errorCapturedHooks) { + for (let i = 0; i < errorCapturedHooks.length; i++) { + if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) { + return; + } + } + } + cur = cur.parent; + } + const appErrorHandler = instance.appContext.config.errorHandler; + if (appErrorHandler) { + callWithErrorHandling( + appErrorHandler, + null, + 10, + [err, exposedInstance, errorInfo] + ); + return; + } + } + logError(err, type, contextVNode, throwInDev); +} +function logError(err, type, contextVNode, throwInDev = true) { + { + const info = ErrorTypeStrings[type]; + if (contextVNode) { + pushWarningContext(contextVNode); + } + warn(`Unhandled error${info ? ` during execution of ${info}` : ``}`); + if (contextVNode) { + popWarningContext(); + } + if (throwInDev) { + throw err; + } else { + console.error(err); + } + } +} + +let isFlushing = false; +let isFlushPending = false; +const queue = []; +let flushIndex = 0; +const pendingPostFlushCbs = []; +let activePostFlushCbs = null; +let postFlushIndex = 0; +const resolvedPromise = /* @__PURE__ */ Promise.resolve(); +let currentFlushPromise = null; +const RECURSION_LIMIT = 100; +function nextTick(fn) { + const p = currentFlushPromise || resolvedPromise; + return fn ? p.then(this ? fn.bind(this) : fn) : p; +} +function findInsertionIndex(id) { + let start = flushIndex + 1; + let end = queue.length; + while (start < end) { + const middle = start + end >>> 1; + const middleJob = queue[middle]; + const middleJobId = getId(middleJob); + if (middleJobId < id || middleJobId === id && middleJob.pre) { + start = middle + 1; + } else { + end = middle; + } + } + return start; +} +function queueJob(job) { + if (!queue.length || !queue.includes( + job, + isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex + )) { + if (job.id == null) { + queue.push(job); + } else { + queue.splice(findInsertionIndex(job.id), 0, job); + } + queueFlush(); + } +} +function queueFlush() { + if (!isFlushing && !isFlushPending) { + isFlushPending = true; + currentFlushPromise = resolvedPromise.then(flushJobs); + } +} +function invalidateJob(job) { + const i = queue.indexOf(job); + if (i > flushIndex) { + queue.splice(i, 1); + } +} +function queuePostFlushCb(cb) { + if (!isArray(cb)) { + if (!activePostFlushCbs || !activePostFlushCbs.includes( + cb, + cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex + )) { + pendingPostFlushCbs.push(cb); + } + } else { + pendingPostFlushCbs.push(...cb); + } + queueFlush(); +} +function flushPreFlushCbs(seen, i = isFlushing ? flushIndex + 1 : 0) { + { + seen = seen || /* @__PURE__ */ new Map(); + } + for (; i < queue.length; i++) { + const cb = queue[i]; + if (cb && cb.pre) { + if (checkRecursiveUpdates(seen, cb)) { + continue; + } + queue.splice(i, 1); + i--; + cb(); + } + } +} +function flushPostFlushCbs(seen) { + if (pendingPostFlushCbs.length) { + const deduped = [...new Set(pendingPostFlushCbs)]; + pendingPostFlushCbs.length = 0; + if (activePostFlushCbs) { + activePostFlushCbs.push(...deduped); + return; + } + activePostFlushCbs = deduped; + { + seen = seen || /* @__PURE__ */ new Map(); + } + activePostFlushCbs.sort((a, b) => getId(a) - getId(b)); + for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) { + if (checkRecursiveUpdates(seen, activePostFlushCbs[postFlushIndex])) { + continue; + } + activePostFlushCbs[postFlushIndex](); + } + activePostFlushCbs = null; + postFlushIndex = 0; + } +} +const getId = (job) => job.id == null ? Infinity : job.id; +const comparator = (a, b) => { + const diff = getId(a) - getId(b); + if (diff === 0) { + if (a.pre && !b.pre) + return -1; + if (b.pre && !a.pre) + return 1; + } + return diff; +}; +function flushJobs(seen) { + isFlushPending = false; + isFlushing = true; + { + seen = seen || /* @__PURE__ */ new Map(); + } + queue.sort(comparator); + const check = (job) => checkRecursiveUpdates(seen, job) ; + try { + for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { + const job = queue[flushIndex]; + if (job && job.active !== false) { + if (check(job)) { + continue; + } + callWithErrorHandling(job, null, 14); + } + } + } finally { + flushIndex = 0; + queue.length = 0; + flushPostFlushCbs(seen); + isFlushing = false; + currentFlushPromise = null; + if (queue.length || pendingPostFlushCbs.length) { + flushJobs(seen); + } + } +} +function checkRecursiveUpdates(seen, fn) { + if (!seen.has(fn)) { + seen.set(fn, 1); + } else { + const count = seen.get(fn); + if (count > RECURSION_LIMIT) { + const instance = fn.ownerInstance; + const componentName = instance && getComponentName(instance.type); + warn( + `Maximum recursive updates exceeded${componentName ? ` in component <${componentName}>` : ``}. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.` + ); + return true; + } else { + seen.set(fn, count + 1); + } + } +} + +let isHmrUpdating = false; +const hmrDirtyComponents = /* @__PURE__ */ new Set(); +{ + getGlobalThis().__VUE_HMR_RUNTIME__ = { + createRecord: tryWrap(createRecord), + rerender: tryWrap(rerender), + reload: tryWrap(reload) + }; +} +const map = /* @__PURE__ */ new Map(); +function registerHMR(instance) { + const id = instance.type.__hmrId; + let record = map.get(id); + if (!record) { + createRecord(id, instance.type); + record = map.get(id); + } + record.instances.add(instance); +} +function unregisterHMR(instance) { + map.get(instance.type.__hmrId).instances.delete(instance); +} +function createRecord(id, initialDef) { + if (map.has(id)) { + return false; + } + map.set(id, { + initialDef: normalizeClassComponent(initialDef), + instances: /* @__PURE__ */ new Set() + }); + return true; +} +function normalizeClassComponent(component) { + return isClassComponent(component) ? component.__vccOpts : component; +} +function rerender(id, newRender) { + const record = map.get(id); + if (!record) { + return; + } + record.initialDef.render = newRender; + [...record.instances].forEach((instance) => { + if (newRender) { + instance.render = newRender; + normalizeClassComponent(instance.type).render = newRender; + } + instance.renderCache = []; + isHmrUpdating = true; + instance.update(); + isHmrUpdating = false; + }); +} +function reload(id, newComp) { + const record = map.get(id); + if (!record) + return; + newComp = normalizeClassComponent(newComp); + updateComponentDef(record.initialDef, newComp); + const instances = [...record.instances]; + for (const instance of instances) { + const oldComp = normalizeClassComponent(instance.type); + if (!hmrDirtyComponents.has(oldComp)) { + if (oldComp !== record.initialDef) { + updateComponentDef(oldComp, newComp); + } + hmrDirtyComponents.add(oldComp); + } + instance.appContext.propsCache.delete(instance.type); + instance.appContext.emitsCache.delete(instance.type); + instance.appContext.optionsCache.delete(instance.type); + if (instance.ceReload) { + hmrDirtyComponents.add(oldComp); + instance.ceReload(newComp.styles); + hmrDirtyComponents.delete(oldComp); + } else if (instance.parent) { + queueJob(instance.parent.update); + } else if (instance.appContext.reload) { + instance.appContext.reload(); + } else if (typeof window !== "undefined") { + window.location.reload(); + } else { + console.warn( + "[HMR] Root or manually mounted instance modified. Full reload required." + ); + } + } + queuePostFlushCb(() => { + for (const instance of instances) { + hmrDirtyComponents.delete( + normalizeClassComponent(instance.type) + ); + } + }); +} +function updateComponentDef(oldComp, newComp) { + extend(oldComp, newComp); + for (const key in oldComp) { + if (key !== "__file" && !(key in newComp)) { + delete oldComp[key]; + } + } +} +function tryWrap(fn) { + return (id, arg) => { + try { + return fn(id, arg); + } catch (e) { + console.error(e); + console.warn( + `[HMR] Something went wrong during Vue component hot-reload. Full reload required.` + ); + } + }; +} + +let devtools; +let buffer = []; +let devtoolsNotInstalled = false; +function emit$1(event, ...args) { + if (devtools) { + devtools.emit(event, ...args); + } else if (!devtoolsNotInstalled) { + buffer.push({ event, args }); + } +} +function setDevtoolsHook(hook, target) { + var _a, _b; + devtools = hook; + if (devtools) { + devtools.enabled = true; + buffer.forEach(({ event, args }) => devtools.emit(event, ...args)); + buffer = []; + } else if ( + // handle late devtools injection - only do this if we are in an actual + // browser environment to avoid the timer handle stalling test runner exit + // (#4815) + typeof window !== "undefined" && // some envs mock window but not fully + window.HTMLElement && // also exclude jsdom + !((_b = (_a = window.navigator) == null ? void 0 : _a.userAgent) == null ? void 0 : _b.includes("jsdom")) + ) { + const replay = target.__VUE_DEVTOOLS_HOOK_REPLAY__ = target.__VUE_DEVTOOLS_HOOK_REPLAY__ || []; + replay.push((newHook) => { + setDevtoolsHook(newHook, target); + }); + setTimeout(() => { + if (!devtools) { + target.__VUE_DEVTOOLS_HOOK_REPLAY__ = null; + devtoolsNotInstalled = true; + buffer = []; + } + }, 3e3); + } else { + devtoolsNotInstalled = true; + buffer = []; + } +} +function devtoolsInitApp(app, version) { + emit$1("app:init" /* APP_INIT */, app, version, { + Fragment, + Text, + Comment, + Static + }); +} +function devtoolsUnmountApp(app) { + emit$1("app:unmount" /* APP_UNMOUNT */, app); +} +const devtoolsComponentAdded = /* @__PURE__ */ createDevtoolsComponentHook( + "component:added" /* COMPONENT_ADDED */ +); +const devtoolsComponentUpdated = /* @__PURE__ */ createDevtoolsComponentHook("component:updated" /* COMPONENT_UPDATED */); +const _devtoolsComponentRemoved = /* @__PURE__ */ createDevtoolsComponentHook( + "component:removed" /* COMPONENT_REMOVED */ +); +const devtoolsComponentRemoved = (component) => { + if (devtools && typeof devtools.cleanupBuffer === "function" && // remove the component if it wasn't buffered + !devtools.cleanupBuffer(component)) { + _devtoolsComponentRemoved(component); + } +}; +function createDevtoolsComponentHook(hook) { + return (component) => { + emit$1( + hook, + component.appContext.app, + component.uid, + component.parent ? component.parent.uid : void 0, + component + ); + }; +} +const devtoolsPerfStart = /* @__PURE__ */ createDevtoolsPerformanceHook( + "perf:start" /* PERFORMANCE_START */ +); +const devtoolsPerfEnd = /* @__PURE__ */ createDevtoolsPerformanceHook( + "perf:end" /* PERFORMANCE_END */ +); +function createDevtoolsPerformanceHook(hook) { + return (component, type, time) => { + emit$1(hook, component.appContext.app, component.uid, component, type, time); + }; +} +function devtoolsComponentEmit(component, event, params) { + emit$1( + "component:emit" /* COMPONENT_EMIT */, + component.appContext.app, + component, + event, + params + ); +} + +function emit(instance, event, ...rawArgs) { + if (instance.isUnmounted) + return; + const props = instance.vnode.props || EMPTY_OBJ; + { + const { + emitsOptions, + propsOptions: [propsOptions] + } = instance; + if (emitsOptions) { + if (!(event in emitsOptions) && true) { + if (!propsOptions || !(toHandlerKey(event) in propsOptions)) { + warn( + `Component emitted event "${event}" but it is neither declared in the emits option nor as an "${toHandlerKey(event)}" prop.` + ); + } + } else { + const validator = emitsOptions[event]; + if (isFunction(validator)) { + const isValid = validator(...rawArgs); + if (!isValid) { + warn( + `Invalid event arguments: event validation failed for event "${event}".` + ); + } + } + } + } + } + let args = rawArgs; + const isModelListener = event.startsWith("update:"); + const modelArg = isModelListener && event.slice(7); + if (modelArg && modelArg in props) { + const modifiersKey = `${modelArg === "modelValue" ? "model" : modelArg}Modifiers`; + const { number, trim } = props[modifiersKey] || EMPTY_OBJ; + if (trim) { + args = rawArgs.map((a) => isString(a) ? a.trim() : a); + } + if (number) { + args = rawArgs.map(looseToNumber); + } + } + { + devtoolsComponentEmit(instance, event, args); + } + { + const lowerCaseEvent = event.toLowerCase(); + if (lowerCaseEvent !== event && props[toHandlerKey(lowerCaseEvent)]) { + warn( + `Event "${lowerCaseEvent}" is emitted in component ${formatComponentName( + instance, + instance.type + )} but the handler is registered for "${event}". Note that HTML attributes are case-insensitive and you cannot use v-on to listen to camelCase events when using in-DOM templates. You should probably use "${hyphenate(event)}" instead of "${event}".` + ); + } + } + let handlerName; + let handler = props[handlerName = toHandlerKey(event)] || // also try camelCase event handler (#2249) + props[handlerName = toHandlerKey(camelize(event))]; + if (!handler && isModelListener) { + handler = props[handlerName = toHandlerKey(hyphenate(event))]; + } + if (handler) { + callWithAsyncErrorHandling( + handler, + instance, + 6, + args + ); + } + const onceHandler = props[handlerName + `Once`]; + if (onceHandler) { + if (!instance.emitted) { + instance.emitted = {}; + } else if (instance.emitted[handlerName]) { + return; + } + instance.emitted[handlerName] = true; + callWithAsyncErrorHandling( + onceHandler, + instance, + 6, + args + ); + } +} +function normalizeEmitsOptions(comp, appContext, asMixin = false) { + const cache = appContext.emitsCache; + const cached = cache.get(comp); + if (cached !== void 0) { + return cached; + } + const raw = comp.emits; + let normalized = {}; + let hasExtends = false; + if (!isFunction(comp)) { + const extendEmits = (raw2) => { + const normalizedFromExtend = normalizeEmitsOptions(raw2, appContext, true); + if (normalizedFromExtend) { + hasExtends = true; + extend(normalized, normalizedFromExtend); + } + }; + if (!asMixin && appContext.mixins.length) { + appContext.mixins.forEach(extendEmits); + } + if (comp.extends) { + extendEmits(comp.extends); + } + if (comp.mixins) { + comp.mixins.forEach(extendEmits); + } + } + if (!raw && !hasExtends) { + if (isObject(comp)) { + cache.set(comp, null); + } + return null; + } + if (isArray(raw)) { + raw.forEach((key) => normalized[key] = null); + } else { + extend(normalized, raw); + } + if (isObject(comp)) { + cache.set(comp, normalized); + } + return normalized; +} +function isEmitListener(options, key) { + if (!options || !isOn(key)) { + return false; + } + key = key.slice(2).replace(/Once$/, ""); + return hasOwn(options, key[0].toLowerCase() + key.slice(1)) || hasOwn(options, hyphenate(key)) || hasOwn(options, key); +} + +let currentRenderingInstance = null; +let currentScopeId = null; +function setCurrentRenderingInstance(instance) { + const prev = currentRenderingInstance; + currentRenderingInstance = instance; + currentScopeId = instance && instance.type.__scopeId || null; + return prev; +} +function pushScopeId(id) { + currentScopeId = id; +} +function popScopeId() { + currentScopeId = null; +} +const withScopeId = (_id) => withCtx; +function withCtx(fn, ctx = currentRenderingInstance, isNonScopedSlot) { + if (!ctx) + return fn; + if (fn._n) { + return fn; + } + const renderFnWithContext = (...args) => { + if (renderFnWithContext._d) { + setBlockTracking(-1); + } + const prevInstance = setCurrentRenderingInstance(ctx); + let res; + try { + res = fn(...args); + } finally { + setCurrentRenderingInstance(prevInstance); + if (renderFnWithContext._d) { + setBlockTracking(1); + } + } + { + devtoolsComponentUpdated(ctx); + } + return res; + }; + renderFnWithContext._n = true; + renderFnWithContext._c = true; + renderFnWithContext._d = true; + return renderFnWithContext; +} + +let accessedAttrs = false; +function markAttrsAccessed() { + accessedAttrs = true; +} +function renderComponentRoot(instance) { + const { + type: Component, + vnode, + proxy, + withProxy, + props, + propsOptions: [propsOptions], + slots, + attrs, + emit, + render, + renderCache, + data, + setupState, + ctx, + inheritAttrs + } = instance; + let result; + let fallthroughAttrs; + const prev = setCurrentRenderingInstance(instance); + { + accessedAttrs = false; + } + try { + if (vnode.shapeFlag & 4) { + const proxyToUse = withProxy || proxy; + result = normalizeVNode( + render.call( + proxyToUse, + proxyToUse, + renderCache, + props, + setupState, + data, + ctx + ) + ); + fallthroughAttrs = attrs; + } else { + const render2 = Component; + if (attrs === props) { + markAttrsAccessed(); + } + result = normalizeVNode( + render2.length > 1 ? render2( + props, + true ? { + get attrs() { + markAttrsAccessed(); + return attrs; + }, + slots, + emit + } : { attrs, slots, emit } + ) : render2( + props, + null + /* we know it doesn't need it */ + ) + ); + fallthroughAttrs = Component.props ? attrs : getFunctionalFallthrough(attrs); + } + } catch (err) { + blockStack.length = 0; + handleError(err, instance, 1); + result = createVNode(Comment); + } + let root = result; + let setRoot = void 0; + if (result.patchFlag > 0 && result.patchFlag & 2048) { + [root, setRoot] = getChildRoot(result); + } + if (fallthroughAttrs && inheritAttrs !== false) { + const keys = Object.keys(fallthroughAttrs); + const { shapeFlag } = root; + if (keys.length) { + if (shapeFlag & (1 | 6)) { + if (propsOptions && keys.some(isModelListener)) { + fallthroughAttrs = filterModelListeners( + fallthroughAttrs, + propsOptions + ); + } + root = cloneVNode(root, fallthroughAttrs); + } else if (!accessedAttrs && root.type !== Comment) { + const allAttrs = Object.keys(attrs); + const eventAttrs = []; + const extraAttrs = []; + for (let i = 0, l = allAttrs.length; i < l; i++) { + const key = allAttrs[i]; + if (isOn(key)) { + if (!isModelListener(key)) { + eventAttrs.push(key[2].toLowerCase() + key.slice(3)); + } + } else { + extraAttrs.push(key); + } + } + if (extraAttrs.length) { + warn( + `Extraneous non-props attributes (${extraAttrs.join(", ")}) were passed to component but could not be automatically inherited because component renders fragment or text root nodes.` + ); + } + if (eventAttrs.length) { + warn( + `Extraneous non-emits event listeners (${eventAttrs.join(", ")}) were passed to component but could not be automatically inherited because component renders fragment or text root nodes. If the listener is intended to be a component custom event listener only, declare it using the "emits" option.` + ); + } + } + } + } + if (vnode.dirs) { + if (!isElementRoot(root)) { + warn( + `Runtime directive used on component with non-element root node. The directives will not function as intended.` + ); + } + root = cloneVNode(root); + root.dirs = root.dirs ? root.dirs.concat(vnode.dirs) : vnode.dirs; + } + if (vnode.transition) { + if (!isElementRoot(root)) { + warn( + `Component inside renders non-element root node that cannot be animated.` + ); + } + root.transition = vnode.transition; + } + if (setRoot) { + setRoot(root); + } else { + result = root; + } + setCurrentRenderingInstance(prev); + return result; +} +const getChildRoot = (vnode) => { + const rawChildren = vnode.children; + const dynamicChildren = vnode.dynamicChildren; + const childRoot = filterSingleRoot(rawChildren); + if (!childRoot) { + return [vnode, void 0]; + } + const index = rawChildren.indexOf(childRoot); + const dynamicIndex = dynamicChildren ? dynamicChildren.indexOf(childRoot) : -1; + const setRoot = (updatedRoot) => { + rawChildren[index] = updatedRoot; + if (dynamicChildren) { + if (dynamicIndex > -1) { + dynamicChildren[dynamicIndex] = updatedRoot; + } else if (updatedRoot.patchFlag > 0) { + vnode.dynamicChildren = [...dynamicChildren, updatedRoot]; + } + } + }; + return [normalizeVNode(childRoot), setRoot]; +}; +function filterSingleRoot(children) { + let singleRoot; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (isVNode(child)) { + if (child.type !== Comment || child.children === "v-if") { + if (singleRoot) { + return; + } else { + singleRoot = child; + } + } + } else { + return; + } + } + return singleRoot; +} +const getFunctionalFallthrough = (attrs) => { + let res; + for (const key in attrs) { + if (key === "class" || key === "style" || isOn(key)) { + (res || (res = {}))[key] = attrs[key]; + } + } + return res; +}; +const filterModelListeners = (attrs, props) => { + const res = {}; + for (const key in attrs) { + if (!isModelListener(key) || !(key.slice(9) in props)) { + res[key] = attrs[key]; + } + } + return res; +}; +const isElementRoot = (vnode) => { + return vnode.shapeFlag & (6 | 1) || vnode.type === Comment; +}; +function shouldUpdateComponent(prevVNode, nextVNode, optimized) { + const { props: prevProps, children: prevChildren, component } = prevVNode; + const { props: nextProps, children: nextChildren, patchFlag } = nextVNode; + const emits = component.emitsOptions; + if ((prevChildren || nextChildren) && isHmrUpdating) { + return true; + } + if (nextVNode.dirs || nextVNode.transition) { + return true; + } + if (optimized && patchFlag >= 0) { + if (patchFlag & 1024) { + return true; + } + if (patchFlag & 16) { + if (!prevProps) { + return !!nextProps; + } + return hasPropsChanged(prevProps, nextProps, emits); + } else if (patchFlag & 8) { + const dynamicProps = nextVNode.dynamicProps; + for (let i = 0; i < dynamicProps.length; i++) { + const key = dynamicProps[i]; + if (nextProps[key] !== prevProps[key] && !isEmitListener(emits, key)) { + return true; + } + } + } + } else { + if (prevChildren || nextChildren) { + if (!nextChildren || !nextChildren.$stable) { + return true; + } + } + if (prevProps === nextProps) { + return false; + } + if (!prevProps) { + return !!nextProps; + } + if (!nextProps) { + return true; + } + return hasPropsChanged(prevProps, nextProps, emits); + } + return false; +} +function hasPropsChanged(prevProps, nextProps, emitsOptions) { + const nextKeys = Object.keys(nextProps); + if (nextKeys.length !== Object.keys(prevProps).length) { + return true; + } + for (let i = 0; i < nextKeys.length; i++) { + const key = nextKeys[i]; + if (nextProps[key] !== prevProps[key] && !isEmitListener(emitsOptions, key)) { + return true; + } + } + return false; +} +function updateHOCHostEl({ vnode, parent }, el) { + while (parent && parent.subTree === vnode) { + (vnode = parent.vnode).el = el; + parent = parent.parent; + } +} + +const COMPONENTS = "components"; +const DIRECTIVES = "directives"; +function resolveComponent(name, maybeSelfReference) { + return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name; +} +const NULL_DYNAMIC_COMPONENT = Symbol.for("v-ndc"); +function resolveDynamicComponent(component) { + if (isString(component)) { + return resolveAsset(COMPONENTS, component, false) || component; + } else { + return component || NULL_DYNAMIC_COMPONENT; + } +} +function resolveDirective(name) { + return resolveAsset(DIRECTIVES, name); +} +function resolveAsset(type, name, warnMissing = true, maybeSelfReference = false) { + const instance = currentRenderingInstance || currentInstance; + if (instance) { + const Component = instance.type; + if (type === COMPONENTS) { + const selfName = getComponentName( + Component, + false + /* do not include inferred name to avoid breaking existing code */ + ); + if (selfName && (selfName === name || selfName === camelize(name) || selfName === capitalize(camelize(name)))) { + return Component; + } + } + const res = ( + // local registration + // check instance[type] first which is resolved for options API + resolve(instance[type] || Component[type], name) || // global registration + resolve(instance.appContext[type], name) + ); + if (!res && maybeSelfReference) { + return Component; + } + if (warnMissing && !res) { + const extra = type === COMPONENTS ? ` +If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement.` : ``; + warn(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`); + } + return res; + } else { + warn( + `resolve${capitalize(type.slice(0, -1))} can only be used in render() or setup().` + ); + } +} +function resolve(registry, name) { + return registry && (registry[name] || registry[camelize(name)] || registry[capitalize(camelize(name))]); +} + +const isSuspense = (type) => type.__isSuspense; +const SuspenseImpl = { + name: "Suspense", + // In order to make Suspense tree-shakable, we need to avoid importing it + // directly in the renderer. The renderer checks for the __isSuspense flag + // on a vnode's type and calls the `process` method, passing in renderer + // internals. + __isSuspense: true, + process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, rendererInternals) { + if (n1 == null) { + mountSuspense( + n2, + container, + anchor, + parentComponent, + parentSuspense, + isSVG, + slotScopeIds, + optimized, + rendererInternals + ); + } else { + patchSuspense( + n1, + n2, + container, + anchor, + parentComponent, + isSVG, + slotScopeIds, + optimized, + rendererInternals + ); + } + }, + hydrate: hydrateSuspense, + create: createSuspenseBoundary, + normalize: normalizeSuspenseChildren +}; +const Suspense = SuspenseImpl ; +function triggerEvent(vnode, name) { + const eventListener = vnode.props && vnode.props[name]; + if (isFunction(eventListener)) { + eventListener(); + } +} +function mountSuspense(vnode, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, rendererInternals) { + const { + p: patch, + o: { createElement } + } = rendererInternals; + const hiddenContainer = createElement("div"); + const suspense = vnode.suspense = createSuspenseBoundary( + vnode, + parentSuspense, + parentComponent, + container, + hiddenContainer, + anchor, + isSVG, + slotScopeIds, + optimized, + rendererInternals + ); + patch( + null, + suspense.pendingBranch = vnode.ssContent, + hiddenContainer, + null, + parentComponent, + suspense, + isSVG, + slotScopeIds + ); + if (suspense.deps > 0) { + triggerEvent(vnode, "onPending"); + triggerEvent(vnode, "onFallback"); + patch( + null, + vnode.ssFallback, + container, + anchor, + parentComponent, + null, + // fallback tree will not have suspense context + isSVG, + slotScopeIds + ); + setActiveBranch(suspense, vnode.ssFallback); + } else { + suspense.resolve(false, true); + } +} +function patchSuspense(n1, n2, container, anchor, parentComponent, isSVG, slotScopeIds, optimized, { p: patch, um: unmount, o: { createElement } }) { + const suspense = n2.suspense = n1.suspense; + suspense.vnode = n2; + n2.el = n1.el; + const newBranch = n2.ssContent; + const newFallback = n2.ssFallback; + const { activeBranch, pendingBranch, isInFallback, isHydrating } = suspense; + if (pendingBranch) { + suspense.pendingBranch = newBranch; + if (isSameVNodeType(newBranch, pendingBranch)) { + patch( + pendingBranch, + newBranch, + suspense.hiddenContainer, + null, + parentComponent, + suspense, + isSVG, + slotScopeIds, + optimized + ); + if (suspense.deps <= 0) { + suspense.resolve(); + } else if (isInFallback) { + patch( + activeBranch, + newFallback, + container, + anchor, + parentComponent, + null, + // fallback tree will not have suspense context + isSVG, + slotScopeIds, + optimized + ); + setActiveBranch(suspense, newFallback); + } + } else { + suspense.pendingId++; + if (isHydrating) { + suspense.isHydrating = false; + suspense.activeBranch = pendingBranch; + } else { + unmount(pendingBranch, parentComponent, suspense); + } + suspense.deps = 0; + suspense.effects.length = 0; + suspense.hiddenContainer = createElement("div"); + if (isInFallback) { + patch( + null, + newBranch, + suspense.hiddenContainer, + null, + parentComponent, + suspense, + isSVG, + slotScopeIds, + optimized + ); + if (suspense.deps <= 0) { + suspense.resolve(); + } else { + patch( + activeBranch, + newFallback, + container, + anchor, + parentComponent, + null, + // fallback tree will not have suspense context + isSVG, + slotScopeIds, + optimized + ); + setActiveBranch(suspense, newFallback); + } + } else if (activeBranch && isSameVNodeType(newBranch, activeBranch)) { + patch( + activeBranch, + newBranch, + container, + anchor, + parentComponent, + suspense, + isSVG, + slotScopeIds, + optimized + ); + suspense.resolve(true); + } else { + patch( + null, + newBranch, + suspense.hiddenContainer, + null, + parentComponent, + suspense, + isSVG, + slotScopeIds, + optimized + ); + if (suspense.deps <= 0) { + suspense.resolve(); + } + } + } + } else { + if (activeBranch && isSameVNodeType(newBranch, activeBranch)) { + patch( + activeBranch, + newBranch, + container, + anchor, + parentComponent, + suspense, + isSVG, + slotScopeIds, + optimized + ); + setActiveBranch(suspense, newBranch); + } else { + triggerEvent(n2, "onPending"); + suspense.pendingBranch = newBranch; + suspense.pendingId++; + patch( + null, + newBranch, + suspense.hiddenContainer, + null, + parentComponent, + suspense, + isSVG, + slotScopeIds, + optimized + ); + if (suspense.deps <= 0) { + suspense.resolve(); + } else { + const { timeout, pendingId } = suspense; + if (timeout > 0) { + setTimeout(() => { + if (suspense.pendingId === pendingId) { + suspense.fallback(newFallback); + } + }, timeout); + } else if (timeout === 0) { + suspense.fallback(newFallback); + } + } + } + } +} +let hasWarned = false; +function createSuspenseBoundary(vnode, parentSuspense, parentComponent, container, hiddenContainer, anchor, isSVG, slotScopeIds, optimized, rendererInternals, isHydrating = false) { + if (!hasWarned) { + hasWarned = true; + console[console.info ? "info" : "log"]( + ` is an experimental feature and its API will likely change.` + ); + } + const { + p: patch, + m: move, + um: unmount, + n: next, + o: { parentNode, remove } + } = rendererInternals; + let parentSuspenseId; + const isSuspensible = isVNodeSuspensible(vnode); + if (isSuspensible) { + if (parentSuspense == null ? void 0 : parentSuspense.pendingBranch) { + parentSuspenseId = parentSuspense.pendingId; + parentSuspense.deps++; + } + } + const timeout = vnode.props ? toNumber(vnode.props.timeout) : void 0; + { + assertNumber(timeout, `Suspense timeout`); + } + const suspense = { + vnode, + parent: parentSuspense, + parentComponent, + isSVG, + container, + hiddenContainer, + anchor, + deps: 0, + pendingId: 0, + timeout: typeof timeout === "number" ? timeout : -1, + activeBranch: null, + pendingBranch: null, + isInFallback: true, + isHydrating, + isUnmounted: false, + effects: [], + resolve(resume = false, sync = false) { + { + if (!resume && !suspense.pendingBranch) { + throw new Error( + `suspense.resolve() is called without a pending branch.` + ); + } + if (suspense.isUnmounted) { + throw new Error( + `suspense.resolve() is called on an already unmounted suspense boundary.` + ); + } + } + const { + vnode: vnode2, + activeBranch, + pendingBranch, + pendingId, + effects, + parentComponent: parentComponent2, + container: container2 + } = suspense; + let delayEnter = false; + if (suspense.isHydrating) { + suspense.isHydrating = false; + } else if (!resume) { + delayEnter = activeBranch && pendingBranch.transition && pendingBranch.transition.mode === "out-in"; + if (delayEnter) { + activeBranch.transition.afterLeave = () => { + if (pendingId === suspense.pendingId) { + move(pendingBranch, container2, anchor2, 0); + queuePostFlushCb(effects); + } + }; + } + let { anchor: anchor2 } = suspense; + if (activeBranch) { + anchor2 = next(activeBranch); + unmount(activeBranch, parentComponent2, suspense, true); + } + if (!delayEnter) { + move(pendingBranch, container2, anchor2, 0); + } + } + setActiveBranch(suspense, pendingBranch); + suspense.pendingBranch = null; + suspense.isInFallback = false; + let parent = suspense.parent; + let hasUnresolvedAncestor = false; + while (parent) { + if (parent.pendingBranch) { + parent.effects.push(...effects); + hasUnresolvedAncestor = true; + break; + } + parent = parent.parent; + } + if (!hasUnresolvedAncestor && !delayEnter) { + queuePostFlushCb(effects); + } + suspense.effects = []; + if (isSuspensible) { + if (parentSuspense && parentSuspense.pendingBranch && parentSuspenseId === parentSuspense.pendingId) { + parentSuspense.deps--; + if (parentSuspense.deps === 0 && !sync) { + parentSuspense.resolve(); + } + } + } + triggerEvent(vnode2, "onResolve"); + }, + fallback(fallbackVNode) { + if (!suspense.pendingBranch) { + return; + } + const { vnode: vnode2, activeBranch, parentComponent: parentComponent2, container: container2, isSVG: isSVG2 } = suspense; + triggerEvent(vnode2, "onFallback"); + const anchor2 = next(activeBranch); + const mountFallback = () => { + if (!suspense.isInFallback) { + return; + } + patch( + null, + fallbackVNode, + container2, + anchor2, + parentComponent2, + null, + // fallback tree will not have suspense context + isSVG2, + slotScopeIds, + optimized + ); + setActiveBranch(suspense, fallbackVNode); + }; + const delayEnter = fallbackVNode.transition && fallbackVNode.transition.mode === "out-in"; + if (delayEnter) { + activeBranch.transition.afterLeave = mountFallback; + } + suspense.isInFallback = true; + unmount( + activeBranch, + parentComponent2, + null, + // no suspense so unmount hooks fire now + true + // shouldRemove + ); + if (!delayEnter) { + mountFallback(); + } + }, + move(container2, anchor2, type) { + suspense.activeBranch && move(suspense.activeBranch, container2, anchor2, type); + suspense.container = container2; + }, + next() { + return suspense.activeBranch && next(suspense.activeBranch); + }, + registerDep(instance, setupRenderEffect) { + const isInPendingSuspense = !!suspense.pendingBranch; + if (isInPendingSuspense) { + suspense.deps++; + } + const hydratedEl = instance.vnode.el; + instance.asyncDep.catch((err) => { + handleError(err, instance, 0); + }).then((asyncSetupResult) => { + if (instance.isUnmounted || suspense.isUnmounted || suspense.pendingId !== instance.suspenseId) { + return; + } + instance.asyncResolved = true; + const { vnode: vnode2 } = instance; + { + pushWarningContext(vnode2); + } + handleSetupResult(instance, asyncSetupResult, false); + if (hydratedEl) { + vnode2.el = hydratedEl; + } + const placeholder = !hydratedEl && instance.subTree.el; + setupRenderEffect( + instance, + vnode2, + // component may have been moved before resolve. + // if this is not a hydration, instance.subTree will be the comment + // placeholder. + parentNode(hydratedEl || instance.subTree.el), + // anchor will not be used if this is hydration, so only need to + // consider the comment placeholder case. + hydratedEl ? null : next(instance.subTree), + suspense, + isSVG, + optimized + ); + if (placeholder) { + remove(placeholder); + } + updateHOCHostEl(instance, vnode2.el); + { + popWarningContext(); + } + if (isInPendingSuspense && --suspense.deps === 0) { + suspense.resolve(); + } + }); + }, + unmount(parentSuspense2, doRemove) { + suspense.isUnmounted = true; + if (suspense.activeBranch) { + unmount( + suspense.activeBranch, + parentComponent, + parentSuspense2, + doRemove + ); + } + if (suspense.pendingBranch) { + unmount( + suspense.pendingBranch, + parentComponent, + parentSuspense2, + doRemove + ); + } + } + }; + return suspense; +} +function hydrateSuspense(node, vnode, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, rendererInternals, hydrateNode) { + const suspense = vnode.suspense = createSuspenseBoundary( + vnode, + parentSuspense, + parentComponent, + node.parentNode, + document.createElement("div"), + null, + isSVG, + slotScopeIds, + optimized, + rendererInternals, + true + /* hydrating */ + ); + const result = hydrateNode( + node, + suspense.pendingBranch = vnode.ssContent, + parentComponent, + suspense, + slotScopeIds, + optimized + ); + if (suspense.deps === 0) { + suspense.resolve(false, true); + } + return result; +} +function normalizeSuspenseChildren(vnode) { + const { shapeFlag, children } = vnode; + const isSlotChildren = shapeFlag & 32; + vnode.ssContent = normalizeSuspenseSlot( + isSlotChildren ? children.default : children + ); + vnode.ssFallback = isSlotChildren ? normalizeSuspenseSlot(children.fallback) : createVNode(Comment); +} +function normalizeSuspenseSlot(s) { + let block; + if (isFunction(s)) { + const trackBlock = isBlockTreeEnabled && s._c; + if (trackBlock) { + s._d = false; + openBlock(); + } + s = s(); + if (trackBlock) { + s._d = true; + block = currentBlock; + closeBlock(); + } + } + if (isArray(s)) { + const singleChild = filterSingleRoot(s); + if (!singleChild && s.filter((child) => child !== NULL_DYNAMIC_COMPONENT).length > 0) { + warn(` slots expect a single root node.`); + } + s = singleChild; + } + s = normalizeVNode(s); + if (block && !s.dynamicChildren) { + s.dynamicChildren = block.filter((c) => c !== s); + } + return s; +} +function queueEffectWithSuspense(fn, suspense) { + if (suspense && suspense.pendingBranch) { + if (isArray(fn)) { + suspense.effects.push(...fn); + } else { + suspense.effects.push(fn); + } + } else { + queuePostFlushCb(fn); + } +} +function setActiveBranch(suspense, branch) { + suspense.activeBranch = branch; + const { vnode, parentComponent } = suspense; + const el = vnode.el = branch.el; + if (parentComponent && parentComponent.subTree === vnode) { + parentComponent.vnode.el = el; + updateHOCHostEl(parentComponent, el); + } +} +function isVNodeSuspensible(vnode) { + var _a; + return ((_a = vnode.props) == null ? void 0 : _a.suspensible) != null && vnode.props.suspensible !== false; +} + +function watchEffect(effect, options) { + return doWatch(effect, null, options); +} +function watchPostEffect(effect, options) { + return doWatch( + effect, + null, + extend({}, options, { flush: "post" }) + ); +} +function watchSyncEffect(effect, options) { + return doWatch( + effect, + null, + extend({}, options, { flush: "sync" }) + ); +} +const INITIAL_WATCHER_VALUE = {}; +function watch(source, cb, options) { + if (!isFunction(cb)) { + warn( + `\`watch(fn, options?)\` signature has been moved to a separate API. Use \`watchEffect(fn, options?)\` instead. \`watch\` now only supports \`watch(source, cb, options?) signature.` + ); + } + return doWatch(source, cb, options); +} +function doWatch(source, cb, { immediate, deep, flush, onTrack, onTrigger } = EMPTY_OBJ) { + var _a; + if (!cb) { + if (immediate !== void 0) { + warn( + `watch() "immediate" option is only respected when using the watch(source, callback, options?) signature.` + ); + } + if (deep !== void 0) { + warn( + `watch() "deep" option is only respected when using the watch(source, callback, options?) signature.` + ); + } + } + const warnInvalidSource = (s) => { + warn( + `Invalid watch source: `, + s, + `A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.` + ); + }; + const instance = getCurrentScope() === ((_a = currentInstance) == null ? void 0 : _a.scope) ? currentInstance : null; + let getter; + let forceTrigger = false; + let isMultiSource = false; + if (isRef(source)) { + getter = () => source.value; + forceTrigger = isShallow(source); + } else if (isReactive(source)) { + getter = () => source; + deep = true; + } else if (isArray(source)) { + isMultiSource = true; + forceTrigger = source.some((s) => isReactive(s) || isShallow(s)); + getter = () => source.map((s) => { + if (isRef(s)) { + return s.value; + } else if (isReactive(s)) { + return traverse(s); + } else if (isFunction(s)) { + return callWithErrorHandling(s, instance, 2); + } else { + warnInvalidSource(s); + } + }); + } else if (isFunction(source)) { + if (cb) { + getter = () => callWithErrorHandling(source, instance, 2); + } else { + getter = () => { + if (instance && instance.isUnmounted) { + return; + } + if (cleanup) { + cleanup(); + } + return callWithAsyncErrorHandling( + source, + instance, + 3, + [onCleanup] + ); + }; + } + } else { + getter = NOOP; + warnInvalidSource(source); + } + if (cb && deep) { + const baseGetter = getter; + getter = () => traverse(baseGetter()); + } + let cleanup; + let onCleanup = (fn) => { + cleanup = effect.onStop = () => { + callWithErrorHandling(fn, instance, 4); + }; + }; + let oldValue = isMultiSource ? new Array(source.length).fill(INITIAL_WATCHER_VALUE) : INITIAL_WATCHER_VALUE; + const job = () => { + if (!effect.active) { + return; + } + if (cb) { + const newValue = effect.run(); + if (deep || forceTrigger || (isMultiSource ? newValue.some((v, i) => hasChanged(v, oldValue[i])) : hasChanged(newValue, oldValue)) || false) { + if (cleanup) { + cleanup(); + } + callWithAsyncErrorHandling(cb, instance, 3, [ + newValue, + // pass undefined as the old value when it's changed for the first time + oldValue === INITIAL_WATCHER_VALUE ? void 0 : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE ? [] : oldValue, + onCleanup + ]); + oldValue = newValue; + } + } else { + effect.run(); + } + }; + job.allowRecurse = !!cb; + let scheduler; + if (flush === "sync") { + scheduler = job; + } else if (flush === "post") { + scheduler = () => queuePostRenderEffect(job, instance && instance.suspense); + } else { + job.pre = true; + if (instance) + job.id = instance.uid; + scheduler = () => queueJob(job); + } + const effect = new ReactiveEffect(getter, scheduler); + { + effect.onTrack = onTrack; + effect.onTrigger = onTrigger; + } + if (cb) { + if (immediate) { + job(); + } else { + oldValue = effect.run(); + } + } else if (flush === "post") { + queuePostRenderEffect( + effect.run.bind(effect), + instance && instance.suspense + ); + } else { + effect.run(); + } + const unwatch = () => { + effect.stop(); + if (instance && instance.scope) { + remove(instance.scope.effects, effect); + } + }; + return unwatch; +} +function instanceWatch(source, value, options) { + const publicThis = this.proxy; + const getter = isString(source) ? source.includes(".") ? createPathGetter(publicThis, source) : () => publicThis[source] : source.bind(publicThis, publicThis); + let cb; + if (isFunction(value)) { + cb = value; + } else { + cb = value.handler; + options = value; + } + const cur = currentInstance; + setCurrentInstance(this); + const res = doWatch(getter, cb.bind(publicThis), options); + if (cur) { + setCurrentInstance(cur); + } else { + unsetCurrentInstance(); + } + return res; +} +function createPathGetter(ctx, path) { + const segments = path.split("."); + return () => { + let cur = ctx; + for (let i = 0; i < segments.length && cur; i++) { + cur = cur[segments[i]]; + } + return cur; + }; +} +function traverse(value, seen) { + if (!isObject(value) || value["__v_skip"]) { + return value; + } + seen = seen || /* @__PURE__ */ new Set(); + if (seen.has(value)) { + return value; + } + seen.add(value); + if (isRef(value)) { + traverse(value.value, seen); + } else if (isArray(value)) { + for (let i = 0; i < value.length; i++) { + traverse(value[i], seen); + } + } else if (isSet(value) || isMap(value)) { + value.forEach((v) => { + traverse(v, seen); + }); + } else if (isPlainObject(value)) { + for (const key in value) { + traverse(value[key], seen); + } + } + return value; +} + +function validateDirectiveName(name) { + if (isBuiltInDirective(name)) { + warn("Do not use built-in directive ids as custom directive id: " + name); + } +} +function withDirectives(vnode, directives) { + const internalInstance = currentRenderingInstance; + if (internalInstance === null) { + warn(`withDirectives can only be used inside render functions.`); + return vnode; + } + const instance = getExposeProxy(internalInstance) || internalInstance.proxy; + const bindings = vnode.dirs || (vnode.dirs = []); + for (let i = 0; i < directives.length; i++) { + let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]; + if (dir) { + if (isFunction(dir)) { + dir = { + mounted: dir, + updated: dir + }; + } + if (dir.deep) { + traverse(value); + } + bindings.push({ + dir, + instance, + value, + oldValue: void 0, + arg, + modifiers + }); + } + } + return vnode; +} +function invokeDirectiveHook(vnode, prevVNode, instance, name) { + const bindings = vnode.dirs; + const oldBindings = prevVNode && prevVNode.dirs; + for (let i = 0; i < bindings.length; i++) { + const binding = bindings[i]; + if (oldBindings) { + binding.oldValue = oldBindings[i].value; + } + let hook = binding.dir[name]; + if (hook) { + pauseTracking(); + callWithAsyncErrorHandling(hook, instance, 8, [ + vnode.el, + binding, + vnode, + prevVNode + ]); + resetTracking(); + } + } +} + +const leaveCbKey = Symbol("_leaveCb"); +const enterCbKey$1 = Symbol("_enterCb"); +function useTransitionState() { + const state = { + isMounted: false, + isLeaving: false, + isUnmounting: false, + leavingVNodes: /* @__PURE__ */ new Map() + }; + onMounted(() => { + state.isMounted = true; + }); + onBeforeUnmount(() => { + state.isUnmounting = true; + }); + return state; +} +const TransitionHookValidator = [Function, Array]; +const BaseTransitionPropsValidators = { + mode: String, + appear: Boolean, + persisted: Boolean, + // enter + onBeforeEnter: TransitionHookValidator, + onEnter: TransitionHookValidator, + onAfterEnter: TransitionHookValidator, + onEnterCancelled: TransitionHookValidator, + // leave + onBeforeLeave: TransitionHookValidator, + onLeave: TransitionHookValidator, + onAfterLeave: TransitionHookValidator, + onLeaveCancelled: TransitionHookValidator, + // appear + onBeforeAppear: TransitionHookValidator, + onAppear: TransitionHookValidator, + onAfterAppear: TransitionHookValidator, + onAppearCancelled: TransitionHookValidator +}; +const BaseTransitionImpl = { + name: `BaseTransition`, + props: BaseTransitionPropsValidators, + setup(props, { slots }) { + const instance = getCurrentInstance(); + const state = useTransitionState(); + let prevTransitionKey; + return () => { + const children = slots.default && getTransitionRawChildren(slots.default(), true); + if (!children || !children.length) { + return; + } + let child = children[0]; + if (children.length > 1) { + let hasFound = false; + for (const c of children) { + if (c.type !== Comment) { + if (hasFound) { + warn( + " can only be used on a single element or component. Use for lists." + ); + break; + } + child = c; + hasFound = true; + } + } + } + const rawProps = toRaw(props); + const { mode } = rawProps; + if (mode && mode !== "in-out" && mode !== "out-in" && mode !== "default") { + warn(`invalid mode: ${mode}`); + } + if (state.isLeaving) { + return emptyPlaceholder(child); + } + const innerChild = getKeepAliveChild(child); + if (!innerChild) { + return emptyPlaceholder(child); + } + const enterHooks = resolveTransitionHooks( + innerChild, + rawProps, + state, + instance + ); + setTransitionHooks(innerChild, enterHooks); + const oldChild = instance.subTree; + const oldInnerChild = oldChild && getKeepAliveChild(oldChild); + let transitionKeyChanged = false; + const { getTransitionKey } = innerChild.type; + if (getTransitionKey) { + const key = getTransitionKey(); + if (prevTransitionKey === void 0) { + prevTransitionKey = key; + } else if (key !== prevTransitionKey) { + prevTransitionKey = key; + transitionKeyChanged = true; + } + } + if (oldInnerChild && oldInnerChild.type !== Comment && (!isSameVNodeType(innerChild, oldInnerChild) || transitionKeyChanged)) { + const leavingHooks = resolveTransitionHooks( + oldInnerChild, + rawProps, + state, + instance + ); + setTransitionHooks(oldInnerChild, leavingHooks); + if (mode === "out-in") { + state.isLeaving = true; + leavingHooks.afterLeave = () => { + state.isLeaving = false; + if (instance.update.active !== false) { + instance.update(); + } + }; + return emptyPlaceholder(child); + } else if (mode === "in-out" && innerChild.type !== Comment) { + leavingHooks.delayLeave = (el, earlyRemove, delayedLeave) => { + const leavingVNodesCache = getLeavingNodesForType( + state, + oldInnerChild + ); + leavingVNodesCache[String(oldInnerChild.key)] = oldInnerChild; + el[leaveCbKey] = () => { + earlyRemove(); + el[leaveCbKey] = void 0; + delete enterHooks.delayedLeave; + }; + enterHooks.delayedLeave = delayedLeave; + }; + } + } + return child; + }; + } +}; +const BaseTransition = BaseTransitionImpl; +function getLeavingNodesForType(state, vnode) { + const { leavingVNodes } = state; + let leavingVNodesCache = leavingVNodes.get(vnode.type); + if (!leavingVNodesCache) { + leavingVNodesCache = /* @__PURE__ */ Object.create(null); + leavingVNodes.set(vnode.type, leavingVNodesCache); + } + return leavingVNodesCache; +} +function resolveTransitionHooks(vnode, props, state, instance) { + const { + appear, + mode, + persisted = false, + onBeforeEnter, + onEnter, + onAfterEnter, + onEnterCancelled, + onBeforeLeave, + onLeave, + onAfterLeave, + onLeaveCancelled, + onBeforeAppear, + onAppear, + onAfterAppear, + onAppearCancelled + } = props; + const key = String(vnode.key); + const leavingVNodesCache = getLeavingNodesForType(state, vnode); + const callHook = (hook, args) => { + hook && callWithAsyncErrorHandling( + hook, + instance, + 9, + args + ); + }; + const callAsyncHook = (hook, args) => { + const done = args[1]; + callHook(hook, args); + if (isArray(hook)) { + if (hook.every((hook2) => hook2.length <= 1)) + done(); + } else if (hook.length <= 1) { + done(); + } + }; + const hooks = { + mode, + persisted, + beforeEnter(el) { + let hook = onBeforeEnter; + if (!state.isMounted) { + if (appear) { + hook = onBeforeAppear || onBeforeEnter; + } else { + return; + } + } + if (el[leaveCbKey]) { + el[leaveCbKey]( + true + /* cancelled */ + ); + } + const leavingVNode = leavingVNodesCache[key]; + if (leavingVNode && isSameVNodeType(vnode, leavingVNode) && leavingVNode.el[leaveCbKey]) { + leavingVNode.el[leaveCbKey](); + } + callHook(hook, [el]); + }, + enter(el) { + let hook = onEnter; + let afterHook = onAfterEnter; + let cancelHook = onEnterCancelled; + if (!state.isMounted) { + if (appear) { + hook = onAppear || onEnter; + afterHook = onAfterAppear || onAfterEnter; + cancelHook = onAppearCancelled || onEnterCancelled; + } else { + return; + } + } + let called = false; + const done = el[enterCbKey$1] = (cancelled) => { + if (called) + return; + called = true; + if (cancelled) { + callHook(cancelHook, [el]); + } else { + callHook(afterHook, [el]); + } + if (hooks.delayedLeave) { + hooks.delayedLeave(); + } + el[enterCbKey$1] = void 0; + }; + if (hook) { + callAsyncHook(hook, [el, done]); + } else { + done(); + } + }, + leave(el, remove) { + const key2 = String(vnode.key); + if (el[enterCbKey$1]) { + el[enterCbKey$1]( + true + /* cancelled */ + ); + } + if (state.isUnmounting) { + return remove(); + } + callHook(onBeforeLeave, [el]); + let called = false; + const done = el[leaveCbKey] = (cancelled) => { + if (called) + return; + called = true; + remove(); + if (cancelled) { + callHook(onLeaveCancelled, [el]); + } else { + callHook(onAfterLeave, [el]); + } + el[leaveCbKey] = void 0; + if (leavingVNodesCache[key2] === vnode) { + delete leavingVNodesCache[key2]; + } + }; + leavingVNodesCache[key2] = vnode; + if (onLeave) { + callAsyncHook(onLeave, [el, done]); + } else { + done(); + } + }, + clone(vnode2) { + return resolveTransitionHooks(vnode2, props, state, instance); + } + }; + return hooks; +} +function emptyPlaceholder(vnode) { + if (isKeepAlive(vnode)) { + vnode = cloneVNode(vnode); + vnode.children = null; + return vnode; + } +} +function getKeepAliveChild(vnode) { + return isKeepAlive(vnode) ? vnode.children ? vnode.children[0] : void 0 : vnode; +} +function setTransitionHooks(vnode, hooks) { + if (vnode.shapeFlag & 6 && vnode.component) { + setTransitionHooks(vnode.component.subTree, hooks); + } else if (vnode.shapeFlag & 128) { + vnode.ssContent.transition = hooks.clone(vnode.ssContent); + vnode.ssFallback.transition = hooks.clone(vnode.ssFallback); + } else { + vnode.transition = hooks; + } +} +function getTransitionRawChildren(children, keepComment = false, parentKey) { + let ret = []; + let keyedFragmentCount = 0; + for (let i = 0; i < children.length; i++) { + let child = children[i]; + const key = parentKey == null ? child.key : String(parentKey) + String(child.key != null ? child.key : i); + if (child.type === Fragment) { + if (child.patchFlag & 128) + keyedFragmentCount++; + ret = ret.concat( + getTransitionRawChildren(child.children, keepComment, key) + ); + } else if (keepComment || child.type !== Comment) { + ret.push(key != null ? cloneVNode(child, { key }) : child); + } + } + if (keyedFragmentCount > 1) { + for (let i = 0; i < ret.length; i++) { + ret[i].patchFlag = -2; + } + } + return ret; +} + +/*! #__NO_SIDE_EFFECTS__ */ +// @__NO_SIDE_EFFECTS__ +function defineComponent(options, extraOptions) { + return isFunction(options) ? ( + // #8326: extend call and options.name access are considered side-effects + // by Rollup, so we have to wrap it in a pure-annotated IIFE. + /* @__PURE__ */ (() => extend({ name: options.name }, extraOptions, { setup: options }))() + ) : options; +} + +const isAsyncWrapper = (i) => !!i.type.__asyncLoader; +/*! #__NO_SIDE_EFFECTS__ */ +// @__NO_SIDE_EFFECTS__ +function defineAsyncComponent(source) { + if (isFunction(source)) { + source = { loader: source }; + } + const { + loader, + loadingComponent, + errorComponent, + delay = 200, + timeout, + // undefined = never times out + suspensible = true, + onError: userOnError + } = source; + let pendingRequest = null; + let resolvedComp; + let retries = 0; + const retry = () => { + retries++; + pendingRequest = null; + return load(); + }; + const load = () => { + let thisRequest; + return pendingRequest || (thisRequest = pendingRequest = loader().catch((err) => { + err = err instanceof Error ? err : new Error(String(err)); + if (userOnError) { + return new Promise((resolve, reject) => { + const userRetry = () => resolve(retry()); + const userFail = () => reject(err); + userOnError(err, userRetry, userFail, retries + 1); + }); + } else { + throw err; + } + }).then((comp) => { + if (thisRequest !== pendingRequest && pendingRequest) { + return pendingRequest; + } + if (!comp) { + warn( + `Async component loader resolved to undefined. If you are using retry(), make sure to return its return value.` + ); + } + if (comp && (comp.__esModule || comp[Symbol.toStringTag] === "Module")) { + comp = comp.default; + } + if (comp && !isObject(comp) && !isFunction(comp)) { + throw new Error(`Invalid async component load result: ${comp}`); + } + resolvedComp = comp; + return comp; + })); + }; + return defineComponent({ + name: "AsyncComponentWrapper", + __asyncLoader: load, + get __asyncResolved() { + return resolvedComp; + }, + setup() { + const instance = currentInstance; + if (resolvedComp) { + return () => createInnerComp(resolvedComp, instance); + } + const onError = (err) => { + pendingRequest = null; + handleError( + err, + instance, + 13, + !errorComponent + /* do not throw in dev if user provided error component */ + ); + }; + if (suspensible && instance.suspense || false) { + return load().then((comp) => { + return () => createInnerComp(comp, instance); + }).catch((err) => { + onError(err); + return () => errorComponent ? createVNode(errorComponent, { + error: err + }) : null; + }); + } + const loaded = ref(false); + const error = ref(); + const delayed = ref(!!delay); + if (delay) { + setTimeout(() => { + delayed.value = false; + }, delay); + } + if (timeout != null) { + setTimeout(() => { + if (!loaded.value && !error.value) { + const err = new Error( + `Async component timed out after ${timeout}ms.` + ); + onError(err); + error.value = err; + } + }, timeout); + } + load().then(() => { + loaded.value = true; + if (instance.parent && isKeepAlive(instance.parent.vnode)) { + queueJob(instance.parent.update); + } + }).catch((err) => { + onError(err); + error.value = err; + }); + return () => { + if (loaded.value && resolvedComp) { + return createInnerComp(resolvedComp, instance); + } else if (error.value && errorComponent) { + return createVNode(errorComponent, { + error: error.value + }); + } else if (loadingComponent && !delayed.value) { + return createVNode(loadingComponent); + } + }; + } + }); +} +function createInnerComp(comp, parent) { + const { ref: ref2, props, children, ce } = parent.vnode; + const vnode = createVNode(comp, props, children); + vnode.ref = ref2; + vnode.ce = ce; + delete parent.vnode.ce; + return vnode; +} + +const isKeepAlive = (vnode) => vnode.type.__isKeepAlive; +const KeepAliveImpl = { + name: `KeepAlive`, + // Marker for special handling inside the renderer. We are not using a === + // check directly on KeepAlive in the renderer, because importing it directly + // would prevent it from being tree-shaken. + __isKeepAlive: true, + props: { + include: [String, RegExp, Array], + exclude: [String, RegExp, Array], + max: [String, Number] + }, + setup(props, { slots }) { + const instance = getCurrentInstance(); + const sharedContext = instance.ctx; + const cache = /* @__PURE__ */ new Map(); + const keys = /* @__PURE__ */ new Set(); + let current = null; + { + instance.__v_cache = cache; + } + const parentSuspense = instance.suspense; + const { + renderer: { + p: patch, + m: move, + um: _unmount, + o: { createElement } + } + } = sharedContext; + const storageContainer = createElement("div"); + sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => { + const instance2 = vnode.component; + move(vnode, container, anchor, 0, parentSuspense); + patch( + instance2.vnode, + vnode, + container, + anchor, + instance2, + parentSuspense, + isSVG, + vnode.slotScopeIds, + optimized + ); + queuePostRenderEffect(() => { + instance2.isDeactivated = false; + if (instance2.a) { + invokeArrayFns(instance2.a); + } + const vnodeHook = vnode.props && vnode.props.onVnodeMounted; + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance2.parent, vnode); + } + }, parentSuspense); + { + devtoolsComponentAdded(instance2); + } + }; + sharedContext.deactivate = (vnode) => { + const instance2 = vnode.component; + move(vnode, storageContainer, null, 1, parentSuspense); + queuePostRenderEffect(() => { + if (instance2.da) { + invokeArrayFns(instance2.da); + } + const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted; + if (vnodeHook) { + invokeVNodeHook(vnodeHook, instance2.parent, vnode); + } + instance2.isDeactivated = true; + }, parentSuspense); + { + devtoolsComponentAdded(instance2); + } + }; + function unmount(vnode) { + resetShapeFlag(vnode); + _unmount(vnode, instance, parentSuspense, true); + } + function pruneCache(filter) { + cache.forEach((vnode, key) => { + const name = getComponentName(vnode.type); + if (name && (!filter || !filter(name))) { + pruneCacheEntry(key); + } + }); + } + function pruneCacheEntry(key) { + const cached = cache.get(key); + if (!current || !isSameVNodeType(cached, current)) { + unmount(cached); + } else if (current) { + resetShapeFlag(current); + } + cache.delete(key); + keys.delete(key); + } + watch( + () => [props.include, props.exclude], + ([include, exclude]) => { + include && pruneCache((name) => matches(include, name)); + exclude && pruneCache((name) => !matches(exclude, name)); + }, + // prune post-render after `current` has been updated + { flush: "post", deep: true } + ); + let pendingCacheKey = null; + const cacheSubtree = () => { + if (pendingCacheKey != null) { + cache.set(pendingCacheKey, getInnerChild(instance.subTree)); + } + }; + onMounted(cacheSubtree); + onUpdated(cacheSubtree); + onBeforeUnmount(() => { + cache.forEach((cached) => { + const { subTree, suspense } = instance; + const vnode = getInnerChild(subTree); + if (cached.type === vnode.type && cached.key === vnode.key) { + resetShapeFlag(vnode); + const da = vnode.component.da; + da && queuePostRenderEffect(da, suspense); + return; + } + unmount(cached); + }); + }); + return () => { + pendingCacheKey = null; + if (!slots.default) { + return null; + } + const children = slots.default(); + const rawVNode = children[0]; + if (children.length > 1) { + { + warn(`KeepAlive should contain exactly one component child.`); + } + current = null; + return children; + } else if (!isVNode(rawVNode) || !(rawVNode.shapeFlag & 4) && !(rawVNode.shapeFlag & 128)) { + current = null; + return rawVNode; + } + let vnode = getInnerChild(rawVNode); + const comp = vnode.type; + const name = getComponentName( + isAsyncWrapper(vnode) ? vnode.type.__asyncResolved || {} : comp + ); + const { include, exclude, max } = props; + if (include && (!name || !matches(include, name)) || exclude && name && matches(exclude, name)) { + current = vnode; + return rawVNode; + } + const key = vnode.key == null ? comp : vnode.key; + const cachedVNode = cache.get(key); + if (vnode.el) { + vnode = cloneVNode(vnode); + if (rawVNode.shapeFlag & 128) { + rawVNode.ssContent = vnode; + } + } + pendingCacheKey = key; + if (cachedVNode) { + vnode.el = cachedVNode.el; + vnode.component = cachedVNode.component; + if (vnode.transition) { + setTransitionHooks(vnode, vnode.transition); + } + vnode.shapeFlag |= 512; + keys.delete(key); + keys.add(key); + } else { + keys.add(key); + if (max && keys.size > parseInt(max, 10)) { + pruneCacheEntry(keys.values().next().value); + } + } + vnode.shapeFlag |= 256; + current = vnode; + return isSuspense(rawVNode.type) ? rawVNode : vnode; + }; + } +}; +const KeepAlive = KeepAliveImpl; +function matches(pattern, name) { + if (isArray(pattern)) { + return pattern.some((p) => matches(p, name)); + } else if (isString(pattern)) { + return pattern.split(",").includes(name); + } else if (isRegExp(pattern)) { + return pattern.test(name); + } + return false; +} +function onActivated(hook, target) { + registerKeepAliveHook(hook, "a", target); +} +function onDeactivated(hook, target) { + registerKeepAliveHook(hook, "da", target); +} +function registerKeepAliveHook(hook, type, target = currentInstance) { + const wrappedHook = hook.__wdc || (hook.__wdc = () => { + let current = target; + while (current) { + if (current.isDeactivated) { + return; + } + current = current.parent; + } + return hook(); + }); + injectHook(type, wrappedHook, target); + if (target) { + let current = target.parent; + while (current && current.parent) { + if (isKeepAlive(current.parent.vnode)) { + injectToKeepAliveRoot(wrappedHook, type, target, current); + } + current = current.parent; + } + } +} +function injectToKeepAliveRoot(hook, type, target, keepAliveRoot) { + const injected = injectHook( + type, + hook, + keepAliveRoot, + true + /* prepend */ + ); + onUnmounted(() => { + remove(keepAliveRoot[type], injected); + }, target); +} +function resetShapeFlag(vnode) { + vnode.shapeFlag &= ~256; + vnode.shapeFlag &= ~512; +} +function getInnerChild(vnode) { + return vnode.shapeFlag & 128 ? vnode.ssContent : vnode; +} + +function injectHook(type, hook, target = currentInstance, prepend = false) { + if (target) { + const hooks = target[type] || (target[type] = []); + const wrappedHook = hook.__weh || (hook.__weh = (...args) => { + if (target.isUnmounted) { + return; + } + pauseTracking(); + setCurrentInstance(target); + const res = callWithAsyncErrorHandling(hook, target, type, args); + unsetCurrentInstance(); + resetTracking(); + return res; + }); + if (prepend) { + hooks.unshift(wrappedHook); + } else { + hooks.push(wrappedHook); + } + return wrappedHook; + } else { + const apiName = toHandlerKey(ErrorTypeStrings[type].replace(/ hook$/, "")); + warn( + `${apiName} is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup().` + (` If you are using async setup(), make sure to register lifecycle hooks before the first await statement.` ) + ); + } +} +const createHook = (lifecycle) => (hook, target = currentInstance) => ( + // post-create lifecycle registrations are noops during SSR (except for serverPrefetch) + (!isInSSRComponentSetup || lifecycle === "sp") && injectHook(lifecycle, (...args) => hook(...args), target) +); +const onBeforeMount = createHook("bm"); +const onMounted = createHook("m"); +const onBeforeUpdate = createHook("bu"); +const onUpdated = createHook("u"); +const onBeforeUnmount = createHook("bum"); +const onUnmounted = createHook("um"); +const onServerPrefetch = createHook("sp"); +const onRenderTriggered = createHook( + "rtg" +); +const onRenderTracked = createHook( + "rtc" +); +function onErrorCaptured(hook, target = currentInstance) { + injectHook("ec", hook, target); +} + +function renderList(source, renderItem, cache, index) { + let ret; + const cached = cache && cache[index]; + if (isArray(source) || isString(source)) { + ret = new Array(source.length); + for (let i = 0, l = source.length; i < l; i++) { + ret[i] = renderItem(source[i], i, void 0, cached && cached[i]); + } + } else if (typeof source === "number") { + if (!Number.isInteger(source)) { + warn(`The v-for range expect an integer value but got ${source}.`); + } + ret = new Array(source); + for (let i = 0; i < source; i++) { + ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]); + } + } else if (isObject(source)) { + if (source[Symbol.iterator]) { + ret = Array.from( + source, + (item, i) => renderItem(item, i, void 0, cached && cached[i]) + ); + } else { + const keys = Object.keys(source); + ret = new Array(keys.length); + for (let i = 0, l = keys.length; i < l; i++) { + const key = keys[i]; + ret[i] = renderItem(source[key], key, i, cached && cached[i]); + } + } + } else { + ret = []; + } + if (cache) { + cache[index] = ret; + } + return ret; +} + +function createSlots(slots, dynamicSlots) { + for (let i = 0; i < dynamicSlots.length; i++) { + const slot = dynamicSlots[i]; + if (isArray(slot)) { + for (let j = 0; j < slot.length; j++) { + slots[slot[j].name] = slot[j].fn; + } + } else if (slot) { + slots[slot.name] = slot.key ? (...args) => { + const res = slot.fn(...args); + if (res) + res.key = slot.key; + return res; + } : slot.fn; + } + } + return slots; +} + +function renderSlot(slots, name, props = {}, fallback, noSlotted) { + if (currentRenderingInstance.isCE || currentRenderingInstance.parent && isAsyncWrapper(currentRenderingInstance.parent) && currentRenderingInstance.parent.isCE) { + if (name !== "default") + props.name = name; + return createVNode("slot", props, fallback && fallback()); + } + let slot = slots[name]; + if (slot && slot.length > 1) { + warn( + `SSR-optimized slot function detected in a non-SSR-optimized render function. You need to mark this component with $dynamic-slots in the parent template.` + ); + slot = () => []; + } + if (slot && slot._c) { + slot._d = false; + } + openBlock(); + const validSlotContent = slot && ensureValidVNode(slot(props)); + const rendered = createBlock( + Fragment, + { + key: props.key || // slot content array of a dynamic conditional slot may have a branch + // key attached in the `createSlots` helper, respect that + validSlotContent && validSlotContent.key || `_${name}` + }, + validSlotContent || (fallback ? fallback() : []), + validSlotContent && slots._ === 1 ? 64 : -2 + ); + if (!noSlotted && rendered.scopeId) { + rendered.slotScopeIds = [rendered.scopeId + "-s"]; + } + if (slot && slot._c) { + slot._d = true; + } + return rendered; +} +function ensureValidVNode(vnodes) { + return vnodes.some((child) => { + if (!isVNode(child)) + return true; + if (child.type === Comment) + return false; + if (child.type === Fragment && !ensureValidVNode(child.children)) + return false; + return true; + }) ? vnodes : null; +} + +function toHandlers(obj, preserveCaseIfNecessary) { + const ret = {}; + if (!isObject(obj)) { + warn(`v-on with no argument expects an object value.`); + return ret; + } + for (const key in obj) { + ret[preserveCaseIfNecessary && /[A-Z]/.test(key) ? `on:${key}` : toHandlerKey(key)] = obj[key]; + } + return ret; +} + +const getPublicInstance = (i) => { + if (!i) + return null; + if (isStatefulComponent(i)) + return getExposeProxy(i) || i.proxy; + return getPublicInstance(i.parent); +}; +const publicPropertiesMap = ( + // Move PURE marker to new line to workaround compiler discarding it + // due to type annotation + /* @__PURE__ */ extend(/* @__PURE__ */ Object.create(null), { + $: (i) => i, + $el: (i) => i.vnode.el, + $data: (i) => i.data, + $props: (i) => shallowReadonly(i.props) , + $attrs: (i) => shallowReadonly(i.attrs) , + $slots: (i) => shallowReadonly(i.slots) , + $refs: (i) => shallowReadonly(i.refs) , + $parent: (i) => getPublicInstance(i.parent), + $root: (i) => getPublicInstance(i.root), + $emit: (i) => i.emit, + $options: (i) => resolveMergedOptions(i) , + $forceUpdate: (i) => i.f || (i.f = () => queueJob(i.update)), + $nextTick: (i) => i.n || (i.n = nextTick.bind(i.proxy)), + $watch: (i) => instanceWatch.bind(i) + }) +); +const isReservedPrefix = (key) => key === "_" || key === "$"; +const hasSetupBinding = (state, key) => state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key); +const PublicInstanceProxyHandlers = { + get({ _: instance }, key) { + const { ctx, setupState, data, props, accessCache, type, appContext } = instance; + if (key === "__isVue") { + return true; + } + let normalizedProps; + if (key[0] !== "$") { + const n = accessCache[key]; + if (n !== void 0) { + switch (n) { + case 1 /* SETUP */: + return setupState[key]; + case 2 /* DATA */: + return data[key]; + case 4 /* CONTEXT */: + return ctx[key]; + case 3 /* PROPS */: + return props[key]; + } + } else if (hasSetupBinding(setupState, key)) { + accessCache[key] = 1 /* SETUP */; + return setupState[key]; + } else if (data !== EMPTY_OBJ && hasOwn(data, key)) { + accessCache[key] = 2 /* DATA */; + return data[key]; + } else if ( + // only cache other properties when instance has declared (thus stable) + // props + (normalizedProps = instance.propsOptions[0]) && hasOwn(normalizedProps, key) + ) { + accessCache[key] = 3 /* PROPS */; + return props[key]; + } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { + accessCache[key] = 4 /* CONTEXT */; + return ctx[key]; + } else if (shouldCacheAccess) { + accessCache[key] = 0 /* OTHER */; + } + } + const publicGetter = publicPropertiesMap[key]; + let cssModule, globalProperties; + if (publicGetter) { + if (key === "$attrs") { + track(instance, "get", key); + markAttrsAccessed(); + } else if (key === "$slots") { + track(instance, "get", key); + } + return publicGetter(instance); + } else if ( + // css module (injected by vue-loader) + (cssModule = type.__cssModules) && (cssModule = cssModule[key]) + ) { + return cssModule; + } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { + accessCache[key] = 4 /* CONTEXT */; + return ctx[key]; + } else if ( + // global properties + globalProperties = appContext.config.globalProperties, hasOwn(globalProperties, key) + ) { + { + return globalProperties[key]; + } + } else if (currentRenderingInstance && (!isString(key) || // #1091 avoid internal isRef/isVNode checks on component instance leading + // to infinite warning loop + key.indexOf("__v") !== 0)) { + if (data !== EMPTY_OBJ && isReservedPrefix(key[0]) && hasOwn(data, key)) { + warn( + `Property ${JSON.stringify( + key + )} must be accessed via $data because it starts with a reserved character ("$" or "_") and is not proxied on the render context.` + ); + } else if (instance === currentRenderingInstance) { + warn( + `Property ${JSON.stringify(key)} was accessed during render but is not defined on instance.` + ); + } + } + }, + set({ _: instance }, key, value) { + const { data, setupState, ctx } = instance; + if (hasSetupBinding(setupState, key)) { + setupState[key] = value; + return true; + } else if (setupState.__isScriptSetup && hasOwn(setupState, key)) { + warn(`Cannot mutate '; + } + + /** + * Includes an SVG file by absolute or + * relative file path. + * @since 3.7.0 + */ + public static function svg(string|File $file): string|false + { + // support for Kirby's file objects + if ( + $file instanceof File && + $file->extension() === 'svg' + ) { + return $file->read(); + } + + if (is_string($file) === false) { + return false; + } + + $extension = F::extension($file); + + // check for valid svg files + if ($extension !== 'svg') { + return false; + } + + // try to convert relative paths to absolute + if (file_exists($file) === false) { + $root = App::instance()->root(); + $file = realpath($root . '/' . $file); + } + + return F::read($file); + } +} diff --git a/kirby/src/Cms/Ingredients.php b/kirby/src/Cms/Ingredients.php new file mode 100644 index 0000000..f9c1c59 --- /dev/null +++ b/kirby/src/Cms/Ingredients.php @@ -0,0 +1,82 @@ +urls()` and `$kirby->roots()` objects. + * Those are configured in `kirby/config/urls.php` + * and `kirby/config/roots.php` + * + * @package Kirby Cms + * @author Bastian Allgeier + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Ingredients +{ + /** + * @var array + */ + protected $ingredients = []; + + /** + * Creates a new ingredient collection + */ + public function __construct(array $ingredients) + { + $this->ingredients = $ingredients; + } + + /** + * Magic getter for single ingredients + */ + public function __call(string $method, array $args = null): mixed + { + return $this->ingredients[$method] ?? null; + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->ingredients; + } + + /** + * Get a single ingredient by key + */ + public function __get(string $key) + { + return $this->ingredients[$key] ?? null; + } + + /** + * Resolves all ingredient callbacks + * and creates a plain array + * @internal + */ + public static function bake(array $ingredients): static + { + foreach ($ingredients as $name => $ingredient) { + if ($ingredient instanceof Closure) { + $ingredients[$name] = $ingredient($ingredients); + } + } + + return new static($ingredients); + } + + /** + * Returns all ingredients as plain array + */ + public function toArray(): array + { + return $this->ingredients; + } +} diff --git a/kirby/src/Cms/Item.php b/kirby/src/Cms/Item.php new file mode 100644 index 0000000..86dd85a --- /dev/null +++ b/kirby/src/Cms/Item.php @@ -0,0 +1,118 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Item +{ + use HasSiblings; + + public const ITEMS_CLASS = Items::class; + + protected Field|null $field; + + protected string $id; + protected array $params; + protected ModelWithContent $parent; + protected Items $siblings; + + /** + * Creates a new item + */ + public function __construct(array $params = []) + { + $class = static::ITEMS_CLASS; + $this->id = $params['id'] ?? Str::uuid(); + $this->params = $params; + $this->field = $params['field'] ?? null; + $this->parent = $params['parent'] ?? App::instance()->site(); + $this->siblings = $params['siblings'] ?? new $class(); + } + + /** + * Static Item factory + */ + public static function factory(array $params): static + { + return new static($params); + } + + /** + * Returns the parent field if known + */ + public function field(): Field|null + { + return $this->field; + } + + /** + * Returns the unique item id (UUID v4) + */ + public function id(): string + { + return $this->id; + } + + /** + * Compares the item to another one + */ + public function is(Item $item): bool + { + return $this->id() === $item->id(); + } + + /** + * Returns the Kirby instance + */ + public function kirby(): App + { + return $this->parent()->kirby(); + } + + /** + * Returns the parent model + */ + public function parent(): ModelWithContent + { + return $this->parent; + } + + /** + * Returns the sibling collection + * This is required by the HasSiblings trait + * + * @psalm-return self::ITEMS_CLASS + */ + protected function siblingsCollection(): Items + { + return $this->siblings; + } + + /** + * Converts the item to an array + */ + public function toArray(): array + { + return [ + 'id' => $this->id(), + ]; + } +} diff --git a/kirby/src/Cms/Items.php b/kirby/src/Cms/Items.php new file mode 100644 index 0000000..97f8104 --- /dev/null +++ b/kirby/src/Cms/Items.php @@ -0,0 +1,100 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Items extends Collection +{ + public const ITEM_CLASS = Item::class; + + protected Field|null $field; + + /** + * All registered items methods + */ + public static array $methods = []; + + protected array $options; + + /** + * @var \Kirby\Cms\ModelWithContent + */ + protected $parent; + + public function __construct($objects = [], array $options = []) + { + $this->options = $options; + $this->parent = $options['parent'] ?? App::instance()->site(); + $this->field = $options['field'] ?? null; + + parent::__construct($objects, $this->parent); + } + + /** + * Creates a new item collection from a + * an array of item props + */ + public static function factory( + array $items = null, + array $params = [] + ): static { + if (empty($items) === true || is_array($items) === false) { + return new static(); + } + + if (is_array($params) === false) { + throw new InvalidArgumentException('Invalid item options'); + } + + // create a new collection of blocks + $collection = new static([], $params); + + foreach ($items as $item) { + if (is_array($item) === false) { + throw new InvalidArgumentException('Invalid data for ' . static::ITEM_CLASS); + } + + // inject properties from the parent + $item['field'] = $collection->field(); + $item['options'] = $params['options'] ?? []; + $item['parent'] = $collection->parent(); + $item['siblings'] = $collection; + $item['params'] = $item; + + $class = static::ITEM_CLASS; + $item = $class::factory($item); + $collection->append($item->id(), $item); + } + + return $collection; + } + + /** + * Returns the parent field if known + */ + public function field(): Field|null + { + return $this->field; + } + + /** + * Convert the items to an array + */ + public function toArray(Closure $map = null): array + { + return array_values(parent::toArray($map)); + } +} diff --git a/kirby/src/Cms/Language.php b/kirby/src/Cms/Language.php new file mode 100644 index 0000000..5921867 --- /dev/null +++ b/kirby/src/Cms/Language.php @@ -0,0 +1,572 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Language +{ + use HasSiblings; + + /** + * The parent Kirby instance + */ + public static App|null $kirby; + + protected string $code; + protected bool $default; + protected string $direction; + protected array $locale; + protected string $name; + protected array $slugs; + protected array $smartypants; + protected array $translations; + protected string|null $url; + + /** + * Creates a new language object + */ + public function __construct(array $props) + { + if (isset($props['code']) === false) { + throw new InvalidArgumentException('The property "code" is required'); + } + + static::$kirby = $props['kirby'] ?? null; + $this->code = trim($props['code']); + $this->default = ($props['default'] ?? false) === true; + $this->direction = ($props['direction'] ?? null) === 'rtl' ? 'rtl' : 'ltr'; + $this->name = trim($props['name'] ?? $this->code); + $this->slugs = $props['slugs'] ?? []; + $this->smartypants = $props['smartypants'] ?? []; + $this->translations = $props['translations'] ?? []; + $this->url = $props['url'] ?? null; + + if ($locale = $props['locale'] ?? null) { + $this->locale = Locale::normalize($locale); + } else { + $this->locale = [LC_ALL => $this->code]; + } + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Returns the language code + * when the language is converted to a string + */ + public function __toString(): string + { + return $this->code(); + } + + /** + * Returns the base Url for the language + * without the path or other cruft + */ + public function baseUrl(): string + { + $kirbyUrl = $this->kirby()->url(); + $languageUrl = $this->url(); + + if (empty($this->url)) { + return $kirbyUrl; + } + + if (Str::startsWith($languageUrl, $kirbyUrl) === true) { + return $kirbyUrl; + } + + return Url::base($languageUrl) ?? $kirbyUrl; + } + + /** + * Creates an instance with the same + * initial properties. + */ + public function clone(array $props = []): static + { + return new static(array_replace_recursive([ + 'code' => $this->code, + 'default' => $this->default, + 'direction' => $this->direction, + 'locale' => $this->locale, + 'name' => $this->name, + 'slugs' => $this->slugs, + 'smartypants' => $this->smartypants, + 'translations' => $this->translations, + 'url' => $this->url, + ], $props)); + } + + /** + * Returns the language code/id. + * The language code is used in + * text file names as appendix. + */ + public function code(): string + { + return $this->code; + } + + /** + * Creates a new language object + * @internal + */ + public static function create(array $props): static + { + $props['code'] = Str::slug($props['code'] ?? null); + $kirby = App::instance(); + $languages = $kirby->languages(); + + // make the first language the default language + if ($languages->count() === 0) { + $props['default'] = true; + } + + $language = new static($props); + + // trigger before hook + $kirby->trigger( + 'language.create:before', + [ + 'input' => $props, + 'language' => $language + ] + ); + + // validate the new language + LanguageRules::create($language); + + $language->save(); + + if ($languages->count() === 0) { + foreach ($kirby->models() as $model) { + $model->storage()->convertLanguage( + 'default', + $language->code() + ); + } + } + + // update the main languages collection in the app instance + $kirby->languages(false)->append($language->code(), $language); + + // trigger after hook + $kirby->trigger( + 'language.create:after', + [ + 'input' => $props, + 'language' => $language + ] + ); + + return $language; + } + + /** + * Delete the current language and + * all its translation files + * @internal + * + * @throws \Kirby\Exception\Exception + */ + public function delete(): bool + { + $kirby = App::instance(); + $code = $this->code(); + + if ($this->isDeletable() === false) { + throw new Exception('The language cannot be deleted'); + } + + // trigger before hook + $kirby->trigger('language.delete:before', [ + 'language' => $this + ]); + + if (F::remove($this->root()) !== true) { + throw new Exception('The language could not be deleted'); + } + + foreach ($kirby->models() as $model) { + if ($this->isLast() === true) { + $model->storage()->convertLanguage($code, 'default'); + } else { + $model->storage()->deleteLanguage($code); + } + } + + // get the original language collection and remove the current language + $kirby->languages(false)->remove($code); + + // trigger after hook + $kirby->trigger('language.delete:after', [ + 'language' => $this + ]); + + return true; + } + + /** + * Reading direction of this language + */ + public function direction(): string + { + return $this->direction; + } + + /** + * Check if the language file exists + */ + public function exists(): bool + { + return file_exists($this->root()); + } + + /** + * Checks if this is the default language + * for the site. + */ + public function isDefault(): bool + { + return $this->default; + } + + /** + * Checks if the language can be deleted + */ + public function isDeletable(): bool + { + // the default language can only be deleted if it's the last + if ($this->isDefault() === true && $this->isLast() === false) { + return false; + } + + return true; + } + + /** + * Checks if this is the last language + */ + public function isLast(): bool + { + return App::instance()->languages()->count() === 1; + } + + /** + * The id is required for collections + * to work properly. The code is used as id + */ + public function id(): string + { + return $this->code; + } + + /** + * Returns the parent Kirby instance + */ + public function kirby(): App + { + return static::$kirby ??= App::instance(); + } + + /** + * Loads the language rules for provided locale code + */ + public static function loadRules(string $code): array + { + $kirby = App::instance(); + $code = Str::contains($code, '.') ? Str::before($code, '.') : $code; + $file = $kirby->root('i18n:rules') . '/' . $code . '.json'; + + if (F::exists($file) === false) { + $file = $kirby->root('i18n:rules') . '/' . Str::before($code, '_') . '.json'; + } + + try { + return Data::read($file); + } catch (\Exception) { + return []; + } + } + + /** + * Returns the PHP locale setting array + * + * @param int $category If passed, returns the locale for the specified category (e.g. LC_ALL) as string + */ + public function locale(int $category = null): array|string|null + { + if ($category !== null) { + return $this->locale[$category] ?? $this->locale[LC_ALL] ?? null; + } + + return $this->locale; + } + + /** + * Returns the human-readable name + * of the language + */ + public function name(): string + { + return $this->name; + } + + /** + * Returns the URL path for the language + */ + public function path(): string + { + if ($this->url === null) { + return $this->code; + } + + return Url::path($this->url); + } + + /** + * Returns the routing pattern for the language + */ + public function pattern(): string + { + $path = $this->path(); + + if (empty($path) === true) { + return '(:all)'; + } + + return $path . '/(:all?)'; + } + + /** + * Returns the absolute path to the language file + */ + public function root(): string + { + return App::instance()->root('languages') . '/' . $this->code() . '.php'; + } + + /** + * Returns the LanguageRouter instance + * which is used to handle language specific + * routes. + */ + public function router(): LanguageRouter + { + return new LanguageRouter($this); + } + + /** + * Get slug rules for language + * @internal + */ + public function rules(): array + { + $code = $this->locale(LC_CTYPE); + $data = static::loadRules($code); + return array_merge($data, $this->slugs()); + } + + /** + * Saves the language settings in the languages folder + * @internal + * + * @return $this + */ + public function save(): static + { + try { + $existingData = Data::read($this->root()); + } catch (Throwable) { + $existingData = []; + } + + $props = [ + 'code' => $this->code(), + 'default' => $this->isDefault(), + 'direction' => $this->direction(), + 'locale' => Locale::export($this->locale()), + 'name' => $this->name(), + 'translations' => $this->translations(), + 'url' => $this->url, + ]; + + $data = array_merge($existingData, $props); + + ksort($data); + + Data::write($this->root(), $data); + + return $this; + } + + /** + * Private siblings collector + */ + protected function siblingsCollection(): Collection + { + return App::instance()->languages(); + } + + /** + * Returns the custom slug rules for this language + */ + public function slugs(): array + { + return $this->slugs; + } + + /** + * Returns the custom SmartyPants options for this language + */ + public function smartypants(): array + { + return $this->smartypants; + } + + /** + * Returns the most important + * properties as array + */ + public function toArray(): array + { + return [ + 'code' => $this->code(), + 'default' => $this->isDefault(), + 'direction' => $this->direction(), + 'locale' => $this->locale(), + 'name' => $this->name(), + 'rules' => $this->rules(), + 'url' => $this->url() + ]; + } + + /** + * Returns the translation strings for this language + */ + public function translations(): array + { + return $this->translations; + } + + /** + * Returns the absolute Url for the language + */ + public function url(): string + { + $url = $this->url; + $url ??= '/' . $this->code; + return Url::makeAbsolute($url, $this->kirby()->url()); + } + + /** + * Update language properties and save them + * @internal + */ + public function update(array $props = null): static + { + // don't change the language code + unset($props['code']); + + // make sure the slug is nice and clean + $props['slug'] = Str::slug($props['slug'] ?? null); + + $kirby = App::instance(); + $updated = $this->clone($props); + + if (isset($props['translations']) === true) { + $updated->translations = $props['translations']; + } + + // validate the updated language + LanguageRules::update($updated); + + // trigger before hook + $kirby->trigger('language.update:before', [ + 'language' => $this, + 'input' => $props + ]); + + // if language just got promoted to be the new default language… + if ($this->isDefault() === false && $updated->isDefault() === true) { + // convert the current default to a non-default language + $previous = $kirby->defaultLanguage()?->clone(['default' => false])->save(); + $kirby->languages(false)->set($previous->code(), $previous); + + foreach ($kirby->models() as $model) { + $model->storage()->touchLanguage($this); + } + } + + // if language was the default language and got demoted… + if ( + $this->isDefault() === true && + $updated->isDefault() === false && + $kirby->defaultLanguage()->code() === $this->code() + ) { + // ensure another language has already been set as default + throw new LogicException('Please select another language to be the primary language'); + } + + $language = $updated->save(); + + // make sure the language is also updated in the languages collection + $kirby->languages(false)->set($language->code(), $language); + + // trigger after hook + $kirby->trigger('language.update:after', [ + 'newLanguage' => $language, + 'oldLanguage' => $this, + 'input' => $props + ]); + + return $language; + } + + /** + * Returns a language variable object + * for the key in the translations array + */ + public function variable(string $key, bool $decode = false): LanguageVariable + { + // allows decoding if base64-url encoded url is sent + // for compatibility of different environments + if ($decode === true) { + $key = rawurldecode(base64_decode($key)); + } + + return new LanguageVariable($this, $key); + } +} diff --git a/kirby/src/Cms/LanguageRouter.php b/kirby/src/Cms/LanguageRouter.php new file mode 100644 index 0000000..bca5daf --- /dev/null +++ b/kirby/src/Cms/LanguageRouter.php @@ -0,0 +1,125 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class LanguageRouter +{ + protected Router $router; + + /** + * Creates a new language router instance + * for the given language + */ + public function __construct( + protected Language $language + ) { + } + + /** + * Fetches all scoped routes for the + * current language from the Kirby instance + * + * @throws \Kirby\Exception\NotFoundException + */ + public function routes(): array + { + $language = $this->language; + $kirby = $language->kirby(); + $routes = $kirby->routes(); + + // only keep the scoped language routes + $routes = array_values(array_filter($routes, function ($route) use ($language) { + // no language scope + if (empty($route['language']) === true) { + return false; + } + + // wildcard + if ($route['language'] === '*') { + return true; + } + + // get all applicable languages + $languages = Str::split(strtolower($route['language']), '|'); + + // validate the language + return in_array($language->code(), $languages) === true; + })); + + // add the page-scope if necessary + foreach ($routes as $index => $route) { + if ($pageId = ($route['page'] ?? null)) { + if ($page = $kirby->page($pageId)) { + // convert string patterns to arrays + $patterns = A::wrap($route['pattern']); + + // prefix all patterns with the page slug + $patterns = A::map( + $patterns, + fn ($pattern) => $page->uri($language) . '/' . $pattern + ); + + // re-inject the pattern and the full page object + $routes[$index]['pattern'] = $patterns; + $routes[$index]['page'] = $page; + } else { + throw new NotFoundException('The page "' . $pageId . '" does not exist'); + } + } + } + + return $routes; + } + + /** + * Wrapper around the Router::call method + * that injects the Language instance and + * if needed also the Page as arguments. + */ + public function call(string|null $path = null): mixed + { + $language = $this->language; + $kirby = $language->kirby(); + $this->router ??= new Router($this->routes()); + + try { + return $this->router->call($path, $kirby->request()->method(), function ($route) use ($kirby, $language) { + $kirby->setCurrentTranslation($language); + $kirby->setCurrentLanguage($language); + + if ($page = $route->page()) { + return $route->action()->call( + $route, + $language, + $page, + ...$route->arguments() + ); + } + + return $route->action()->call( + $route, + $language, + ...$route->arguments() + ); + }); + } catch (Exception) { + return $kirby->resolve($path, $language->code()); + } + } +} diff --git a/kirby/src/Cms/LanguageRoutes.php b/kirby/src/Cms/LanguageRoutes.php new file mode 100644 index 0000000..68bdf42 --- /dev/null +++ b/kirby/src/Cms/LanguageRoutes.php @@ -0,0 +1,156 @@ +url(); + + foreach ($kirby->languages() as $language) { + // ignore languages with a different base url + if ($language->baseurl() !== $baseurl) { + continue; + } + + $routes[] = [ + 'pattern' => $language->pattern(), + 'method' => 'ALL', + 'env' => 'site', + 'action' => function ($path = null) use ($language) { + $result = $language->router()->call($path); + + // explicitly test for null as $result can + // contain falsy values that should still be returned + if ($result !== null) { + return $result; + } + + // jump through to the fallback if nothing + // can be found for this language + /** @var \Kirby\Http\Route $this */ + $this->next(); + } + ]; + } + + $routes[] = static::fallback($kirby); + + return $routes; + } + + + /** + * Create the fallback route + * for unprefixed default language URLs. + */ + public static function fallback(App $kirby): array + { + return [ + 'pattern' => '(:all)', + 'method' => 'ALL', + 'env' => 'site', + 'action' => function (string $path) use ($kirby) { + // check for content representations or files + $extension = F::extension($path); + + // try to redirect prefixed pages + if ( + empty($extension) === true && + $page = $kirby->page($path) + ) { + $url = $kirby->request()->url([ + 'query' => null, + 'params' => null, + 'fragment' => null + ]); + + if ($url->toString() !== $page->url()) { + // redirect to translated page directly if translation + // is exists and languages detect is enabled + $lang = $kirby->detectedLanguage()->code(); + + if ( + $kirby->option('languages.detect') === true && + $page->translation($lang)->exists() === true + ) { + return $kirby + ->response() + ->redirect($page->url($lang)); + } + + return $kirby + ->response() + ->redirect($page->url()); + } + } + + return $kirby->language()->router()->call($path); + } + ]; + } + + /** + * Create the multi-language home page route + */ + public static function home(App $kirby): array + { + // Multi-language home + return [ + 'pattern' => '', + 'method' => 'ALL', + 'env' => 'site', + 'action' => function () use ($kirby) { + // find all languages with the same base url as the current installation + $languages = $kirby->languages()->filter( + 'baseurl', + $kirby->url() + ); + + // if there's no language with a matching base url, + // redirect to the default language + if ($languages->count() === 0) { + return $kirby + ->response() + ->redirect($kirby->defaultLanguage()->url()); + } + + // if there's just one language, + // we take that to render the home page + if ($languages->count() === 1) { + $currentLanguage = $languages->first(); + } else { + $currentLanguage = $kirby->defaultLanguage(); + } + + // language detection on the home page with / as URL + if ($kirby->url() !== $currentLanguage->url()) { + if ($kirby->option('languages.detect') === true) { + return $kirby + ->response() + ->redirect($kirby->detectedLanguage()->url()); + } + + return $kirby + ->response() + ->redirect($currentLanguage->url()); + } + + // render the home page of the current language + return $currentLanguage->router()->call(); + } + ]; + } +} diff --git a/kirby/src/Cms/LanguageRules.php b/kirby/src/Cms/LanguageRules.php new file mode 100644 index 0000000..6263b90 --- /dev/null +++ b/kirby/src/Cms/LanguageRules.php @@ -0,0 +1,90 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class LanguageRules +{ + /** + * Validates if the language can be created + * + * @throws \Kirby\Exception\DuplicateException If the language already exists + */ + public static function create(Language $language): bool + { + static::validLanguageCode($language); + static::validLanguageName($language); + + if ($language->exists() === true) { + throw new DuplicateException([ + 'key' => 'language.duplicate', + 'data' => [ + 'code' => $language->code() + ] + ]); + } + + return true; + } + + /** + * Validates if the language can be updated + */ + public static function update(Language $language) + { + static::validLanguageCode($language); + static::validLanguageName($language); + } + + /** + * Validates if the language code is formatted correctly + * + * @throws \Kirby\Exception\InvalidArgumentException If the language code is not valid + */ + public static function validLanguageCode(Language $language): bool + { + if (Str::length($language->code()) < 2) { + throw new InvalidArgumentException([ + 'key' => 'language.code', + 'data' => [ + 'code' => $language->code(), + 'name' => $language->name() + ] + ]); + } + + return true; + } + + /** + * Validates if the language name is formatted correctly + * + * @throws \Kirby\Exception\InvalidArgumentException If the language name is invalid + */ + public static function validLanguageName(Language $language): bool + { + if (Str::length($language->name()) < 1) { + throw new InvalidArgumentException([ + 'key' => 'language.name', + 'data' => [ + 'code' => $language->code(), + 'name' => $language->name() + ] + ]); + } + + return true; + } +} diff --git a/kirby/src/Cms/LanguageVariable.php b/kirby/src/Cms/LanguageVariable.php new file mode 100644 index 0000000..b26336e --- /dev/null +++ b/kirby/src/Cms/LanguageVariable.php @@ -0,0 +1,122 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class LanguageVariable +{ + protected App $kirby; + + public function __construct( + protected Language $language, + protected string $key + ) { + $this->kirby = App::instance(); + } + + /** + * Creates a new language variable. This will + * be added to the default language first and + * can then be translated in other languages. + */ + public static function create( + string $key, + string|null $value = null + ): static { + if (is_numeric($key) === true) { + throw new InvalidArgumentException('The variable key must not be numeric'); + } + + if (empty($key) === true) { + throw new InvalidArgumentException('The variable needs a valid key'); + } + + $kirby = App::instance(); + $language = $kirby->defaultLanguage(); + $translations = $language->translations(); + + if ($kirby->translation()->get($key) !== null) { + if (isset($translations[$key]) === true) { + throw new DuplicateException('The variable already exists'); + } + + throw new DuplicateException('The variable is part of the core translation and cannot be overwritten'); + } + + $translations[$key] = trim($value ?? ''); + + $language->update(['translations' => $translations]); + + return $language->variable($key); + } + + /** + * Deletes a language variable from the translations array. + * This will go through all language files and delete the + * key from all translation arrays to keep them clean. + */ + public function delete(): bool + { + // go through all languages and remove the variable + foreach ($this->kirby->languages() as $language) { + $variables = $language->translations(); + + unset($variables[$this->key]); + + $language->update(['translations' => $variables]); + } + + return true; + } + + /** + * Checks if a language variable exists in the default language + */ + public function exists(): bool + { + $language = $this->kirby->defaultLanguage(); + return isset($language->translations()[$this->key]) === true; + } + + /** + * Returns the unique key for the variable + */ + public function key(): string + { + return $this->key; + } + + /** + * Sets a new value for the language variable + */ + public function update(string $value): static + { + $translations = $this->language->translations(); + $translations[$this->key] = $value; + + $language = $this->language->update(['translations' => $translations]); + + return $language->variable($this->key); + } + + /** + * Returns the value if the variable has been translated. + */ + public function value(): string|null + { + return $this->language->translations()[$this->key] ?? null; + } +} diff --git a/kirby/src/Cms/Languages.php b/kirby/src/Cms/Languages.php new file mode 100644 index 0000000..ba631b9 --- /dev/null +++ b/kirby/src/Cms/Languages.php @@ -0,0 +1,94 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Languages extends Collection +{ + /** + * All registered languages methods + */ + public static array $methods = []; + + /** + * Creates a new collection with the given language objects + * + * @param null $parent + * @throws \Kirby\Exception\DuplicateException + */ + public function __construct( + array $objects = [], + $parent = null + ) { + $defaults = array_filter( + $objects, + fn ($language) => $language->isDefault() === true + ); + + if (count($defaults) > 1) { + throw new DuplicateException('You cannot have multiple default languages. Please check your language config files.'); + } + + parent::__construct($objects, null); + } + + /** + * Returns all language codes as array + */ + public function codes(): array + { + return App::instance()->multilang() ? $this->keys() : ['default']; + } + + /** + * Creates a new language with the given props + * @internal + */ + public function create(array $props): Language + { + return Language::create($props); + } + + /** + * Returns the default language + */ + public function default(): Language|null + { + return $this->findBy('isDefault', true) ?? $this->first(); + } + + /** + * Convert all defined languages to a collection + * @internal + */ + public static function load(): static + { + $languages = []; + $files = glob(App::instance()->root('languages') . '/*.php'); + + foreach ($files as $file) { + $props = F::load($file, allowOutput: false); + + if (is_array($props) === true) { + // inject the language code from the filename + // if it does not exist + $props['code'] ??= F::name($file); + + $languages[] = new Language($props); + } + } + + return new static($languages); + } +} diff --git a/kirby/src/Cms/Layout.php b/kirby/src/Cms/Layout.php new file mode 100644 index 0000000..c6920fd --- /dev/null +++ b/kirby/src/Cms/Layout.php @@ -0,0 +1,105 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Layout extends Item +{ + use HasMethods; + + public const ITEMS_CLASS = Layouts::class; + + protected Content $attrs; + protected LayoutColumns $columns; + + /** + * Proxy for attrs + */ + public function __call(string $method, array $args = []): mixed + { + // layout methods + if ($this->hasMethod($method) === true) { + return $this->callMethod($method, $args); + } + + return $this->attrs()->get($method); + } + + /** + * Creates a new Layout object + */ + public function __construct(array $params = []) + { + parent::__construct($params); + + $this->columns = LayoutColumns::factory($params['columns'] ?? [], [ + 'field' => $this->field, + 'parent' => $this->parent + ]); + + // create the attrs object + $this->attrs = new Content($params['attrs'] ?? [], $this->parent); + } + + /** + * Returns the attrs object + */ + public function attrs(): Content + { + return $this->attrs; + } + + /** + * Returns the columns in this layout + */ + public function columns(): LayoutColumns + { + return $this->columns; + } + + /** + * Checks if the layout is empty + * @since 3.5.2 + */ + public function isEmpty(): bool + { + return $this + ->columns() + ->filter('isEmpty', false) + ->count() === 0; + } + + /** + * Checks if the layout is not empty + * @since 3.5.2 + */ + public function isNotEmpty(): bool + { + return $this->isEmpty() === false; + } + + /** + * The result is being sent to the editor + * via the API in the panel + */ + public function toArray(): array + { + return [ + 'attrs' => $this->attrs()->toArray(), + 'columns' => $this->columns()->toArray(), + 'id' => $this->id(), + ]; + } +} diff --git a/kirby/src/Cms/LayoutColumn.php b/kirby/src/Cms/LayoutColumn.php new file mode 100644 index 0000000..11d61d7 --- /dev/null +++ b/kirby/src/Cms/LayoutColumn.php @@ -0,0 +1,120 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class LayoutColumn extends Item +{ + use HasMethods; + + public const ITEMS_CLASS = LayoutColumns::class; + + protected Blocks $blocks; + protected string $width; + + /** + * Creates a new LayoutColumn object + */ + public function __construct(array $params = []) + { + parent::__construct($params); + + $this->blocks = Blocks::factory($params['blocks'] ?? [], [ + 'field' => $this->field, + 'parent' => $this->parent + ]); + + $this->width = $params['width'] ?? '1/1'; + } + + /** + * Magic getter function + */ + public function __call(string $method, mixed $args): mixed + { + // layout column methods + if ($this->hasMethod($method) === true) { + return $this->callMethod($method, $args); + } + } + + /** + * Returns the blocks collection + * + * @param bool $includeHidden Sets whether to include hidden blocks + */ + public function blocks(bool $includeHidden = false): Blocks + { + if ($includeHidden === false) { + return $this->blocks->filter('isHidden', false); + } + + return $this->blocks; + } + + /** + * Checks if the column is empty + * @since 3.5.2 + */ + public function isEmpty(): bool + { + return $this + ->blocks() + ->filter('isHidden', false) + ->count() === 0; + } + + /** + * Checks if the column is not empty + * @since 3.5.2 + */ + public function isNotEmpty(): bool + { + return $this->isEmpty() === false; + } + + /** + * Returns the number of columns this column spans + */ + public function span(int $columns = 12): int + { + $fraction = Str::split($this->width, '/'); + $a = $fraction[0] ?? 1; + $b = $fraction[1] ?? 1; + + return $columns * $a / $b; + } + + /** + * The result is being sent to the editor + * via the API in the panel + */ + public function toArray(): array + { + return [ + 'blocks' => $this->blocks(true)->toArray(), + 'id' => $this->id(), + 'width' => $this->width(), + ]; + } + + /** + * Returns the width of the column + */ + public function width(): string + { + return $this->width; + } +} diff --git a/kirby/src/Cms/LayoutColumns.php b/kirby/src/Cms/LayoutColumns.php new file mode 100644 index 0000000..195e39e --- /dev/null +++ b/kirby/src/Cms/LayoutColumns.php @@ -0,0 +1,23 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class LayoutColumns extends Items +{ + public const ITEM_CLASS = LayoutColumn::class; + + /** + * All registered layout columns methods + */ + public static array $methods = []; +} diff --git a/kirby/src/Cms/Layouts.php b/kirby/src/Cms/Layouts.php new file mode 100644 index 0000000..1888680 --- /dev/null +++ b/kirby/src/Cms/Layouts.php @@ -0,0 +1,120 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Layouts extends Items +{ + public const ITEM_CLASS = Layout::class; + + /** + * All registered layouts methods + */ + public static array $methods = []; + + public static function factory( + array $items = null, + array $params = [] + ): static { + // convert single layout to layouts array + if ( + isset($items['columns']) === true || + isset($items['id']) === true + ) { + $items = [$items]; + } + + $first = $items[0] ?? []; + + // if there are no wrapping layouts for blocks yet … + if ( + isset($first['content']) === true || + isset($first['type']) === true + ) { + $items = [ + [ + 'id' => Str::uuid(), + 'columns' => [ + [ + 'width' => '1/1', + 'blocks' => $items + ] + ] + ] + ]; + } + + return parent::factory($items, $params); + } + + /** + * Checks if a given block type exists in the layouts collection + * @since 3.6.0 + */ + public function hasBlockType(string $type): bool + { + return $this->toBlocks()->hasType($type); + } + + /** + * Parse layouts data + */ + public static function parse(array|string|null $input): array + { + if ( + empty($input) === false && + is_array($input) === false + ) { + try { + $input = Json::decode((string)$input); + } catch (Throwable) { + return []; + } + } + + if (empty($input) === true) { + return []; + } + + return $input; + } + + /** + * Converts layouts to blocks + * @since 3.6.0 + * + * @param bool $includeHidden Sets whether to include hidden blocks + */ + public function toBlocks(bool $includeHidden = false): Blocks + { + $blocks = []; + + if ($this->isNotEmpty() === true) { + foreach ($this->data() as $layout) { + foreach ($layout->columns() as $column) { + foreach ($column->blocks($includeHidden) as $block) { + $blocks[] = $block->toArray(); + } + } + } + } + + return Blocks::factory($blocks, [ + 'field' => $this->field, + 'parent' => $this->parent + ]); + } +} diff --git a/kirby/src/Cms/License.php b/kirby/src/Cms/License.php new file mode 100644 index 0000000..efd2e3d --- /dev/null +++ b/kirby/src/Cms/License.php @@ -0,0 +1,523 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class License +{ + protected const HISTORY = [ + '3' => '2019-02-05', + '4' => '2023-11-28' + ]; + + protected const SALT = 'kwAHMLyLPBnHEskzH9pPbJsBxQhKXZnX'; + + // cache + protected LicenseStatus $status; + protected LicenseType $type; + + public function __construct( + protected string|null $activation = null, + protected string|null $code = null, + protected string|null $domain = null, + protected string|null $email = null, + protected string|null $order = null, + protected string|null $date = null, + protected string|null $signature = null, + ) { + // normalize the email address + $this->email = $this->email === null ? null : $this->normalizeEmail($this->email); + } + + /** + * Returns the activation date if available + */ + public function activation(string|IntlDateFormatter|null $format = null): int|string|null + { + return $this->activation !== null ? Str::date(strtotime($this->activation), $format) : null; + } + + /** + * Returns the license code if available + */ + public function code(bool $obfuscated = false): string|null + { + if ($this->code !== null && $obfuscated === true) { + return Str::substr($this->code, 0, 10) . str_repeat('X', 22); + } + + return $this->code; + } + + /** + * Content for the license file + */ + public function content(): array + { + return [ + 'activation' => $this->activation, + 'code' => $this->code, + 'date' => $this->date, + 'domain' => $this->domain, + 'email' => $this->email, + 'order' => $this->order, + 'signature' => $this->signature, + ]; + } + + /** + * Returns the purchase date if available + */ + public function date(string|IntlDateFormatter|null $format = null): int|string|null + { + return $this->date !== null ? Str::date(strtotime($this->date), $format) : null; + } + + /** + * Returns the activation domain if available + */ + public function domain(): string|null + { + return $this->domain; + } + + /** + * Returns the activation email if available + */ + public function email(): string|null + { + return $this->email; + } + + /** + * Validates the email address of the license + */ + public function hasValidEmailAddress(): bool + { + return V::email($this->email) === true; + } + + /** + * Hub address + */ + public static function hub(): string + { + return App::instance()->option('hub', 'https://hub.getkirby.com'); + } + + /** + * Checks for all required components of a valid license + */ + public function isComplete(): bool + { + if ( + $this->code !== null && + $this->date !== null && + $this->domain !== null && + $this->email !== null && + $this->order !== null && + $this->signature !== null && + $this->hasValidEmailAddress() === true && + $this->type() !== LicenseType::Invalid + ) { + return true; + } + + return false; + } + + /** + * The license is still valid for the currently + * installed version, but it passed the 3 year period. + */ + public function isInactive(): bool + { + return $this->renewal() < time(); + } + + /** + * Checks for licenses beyond their 3 year period + */ + public function isLegacy(): bool + { + if ($this->type() === LicenseType::Legacy) { + return true; + } + + // without an activation date, the license + // renewal cannot be evaluated and the license + // has to be marked as expired + if ($this->activation === null) { + return true; + } + + // get release date of current major version + $major = Str::before(App::instance()->version(), '.'); + $release = strtotime(static::HISTORY[$major] ?? ''); + + // if there's no matching version in the history + // rather throw an exception to avoid further issues + // @codeCoverageIgnoreStart + if ($release === false) { + throw new InvalidArgumentException('The version for your license could not be found'); + } + // @codeCoverageIgnoreEnd + + // If the renewal date is older than the version launch + // date, the license is expired + return $this->renewal() < $release; + } + + /** + * Runs multiple checks to find out if the license is + * installed and verifiable + */ + public function isMissing(): bool + { + return + $this->isComplete() === false || + $this->isOnCorrectDomain() === false || + $this->isSigned() === false; + } + + /** + * Checks if the license is on the correct domain + */ + public function isOnCorrectDomain(): bool + { + if ($this->domain === null) { + return false; + } + + // compare domains + if ($this->normalizeDomain(App::instance()->system()->indexUrl()) !== $this->normalizeDomain($this->domain)) { + return false; + } + + return true; + } + + /** + * Compares the signature with all ingredients + */ + public function isSigned(): bool + { + if ($this->signature === null) { + return false; + } + + // get the public key + $pubKey = F::read(App::instance()->root('kirby') . '/kirby.pub'); + + // verify the license signature + $data = json_encode($this->signatureData()); + $signature = hex2bin($this->signature); + + return openssl_verify($data, $signature, $pubKey, 'RSA-SHA256') === 1; + } + + /** + * Returns a reliable label for the license type + */ + public function label(): string + { + if ($this->status() === LicenseStatus::Missing) { + return LicenseType::Invalid->label(); + } + + return $this->type()->label(); + } + + /** + * Prepares the email address to be make sure it + * does not have trailing spaces and is lowercase. + */ + protected function normalizeEmail(string $email): string + { + return Str::lower(trim($email)); + } + + /** + * Prepares the domain to be comparable + */ + protected function normalizeDomain(string $domain): string + { + // remove common "testing" subdomains as well as www. + // to ensure that installations of the same site have + // the same license URL; only for installations at /, + // subdirectory installations are difficult to normalize + if (Str::contains($domain, '/') === false) { + if (Str::startsWith($domain, 'www.')) { + return substr($domain, 4); + } + + if (Str::startsWith($domain, 'dev.')) { + return substr($domain, 4); + } + + if (Str::startsWith($domain, 'test.')) { + return substr($domain, 5); + } + + if (Str::startsWith($domain, 'staging.')) { + return substr($domain, 8); + } + } + + return $domain; + } + + /** + * Returns the order id if available + */ + public function order(): string|null + { + return $this->order; + } + + /** + * Support the old license file dataset + * from older licenses + */ + public static function polyfill(array $license): array + { + return [ + 'activation' => $license['activation'] ?? null, + 'code' => $license['code'] ?? $license['license'] ?? null, + 'date' => $license['date'] ?? null, + 'domain' => $license['domain'] ?? null, + 'email' => $license['email'] ?? null, + 'order' => $license['order'] ?? null, + 'signature' => $license['signature'] ?? null, + ]; + } + + /** + * Reads the license file in the config folder + * and creates a new license instance for it. + */ + public static function read(): static + { + try { + $license = Json::read(App::instance()->root('license')); + } catch (Throwable) { + return new static(); + } + + return new static(...static::polyfill($license)); + } + + /** + * Sends a request to the hub to register the license + */ + public function register(): static + { + if ($this->type() === LicenseType::Invalid) { + throw new InvalidArgumentException(['key' => 'license.format']); + } + + if ($this->hasValidEmailAddress() === false) { + throw new InvalidArgumentException(['key' => 'license.email']); + } + + if ($this->domain === null) { + throw new InvalidArgumentException(['key' => 'license.domain']); + } + + // @codeCoverageIgnoreStart + $response = $this->request('register', [ + 'license' => $this->code, + 'email' => $this->email, + 'domain' => $this->domain + ]); + + return $this->update($response); + // @codeCoverageIgnoreEnd + } + + /** + * Returns the renewal date + */ + public function renewal(string|IntlDateFormatter|null $format = null): int|string|null + { + if ($this->activation === null) { + return null; + } + + $time = strtotime('+3 years', $this->activation()); + return Str::date($time, $format); + } + + /** + * Sends a hub request + */ + public function request(string $path, array $data): array + { + // @codeCoverageIgnoreStart + $response = Remote::get(static::hub() . '/' . $path, [ + 'data' => $data + ]); + + // handle request errors + if ($response->code() !== 200) { + $message = $response->json()['message'] ?? 'The request failed'; + + throw new LogicException($message, $response->code()); + } + + return $response->json(); + // @codeCoverageIgnoreEnd + } + + /** + * Saves the license in the config folder + */ + public function save(): bool + { + if ($this->status() !== LicenseStatus::Active) { + throw new InvalidArgumentException([ + 'key' => 'license.verification' + ]); + } + + // where to store the license file + $file = App::instance()->root('license'); + + // save the license information + return Json::write($file, $this->content()); + } + + /** + * Returns the signature if available + */ + public function signature(): string|null + { + return $this->signature; + } + + /** + * Creates the signature data array to compare + * with the signature in ::isSigned + */ + public function signatureData(): array + { + if ($this->type() === LicenseType::Legacy) { + return [ + 'license' => $this->code, + 'order' => $this->order, + 'email' => hash('sha256', $this->email . static::SALT), + 'domain' => $this->domain, + 'date' => $this->date, + ]; + } + + return [ + 'activation' => $this->activation, + 'code' => $this->code, + 'date' => $this->date, + 'domain' => $this->domain, + 'email' => hash('sha256', $this->email . static::SALT), + 'order' => $this->order, + ]; + } + + /** + * Returns the license status as string + * This is used to build the proper UI elements + * for the license activation + */ + public function status(): LicenseStatus + { + return $this->status ??= match (true) { + $this->isMissing() === true => LicenseStatus::Missing, + $this->isLegacy() === true => LicenseStatus::Legacy, + $this->isInactive() === true => LicenseStatus::Inactive, + default => LicenseStatus::Active + }; + } + + /** + * Detects the license type if the license key is available + */ + public function type(): LicenseType + { + return $this->type ??= LicenseType::detect($this->code); + } + + /** + * Updates the license file + */ + public function update(array $data): static + { + // decode the response + $data = static::polyfill($data); + + $this->activation = $data['activation']; + $this->code = $data['code']; + $this->date = $data['date']; + $this->order = $data['order']; + $this->signature = $data['signature']; + + // clear the caches + unset($this->status, $this->type); + + // save the new state of the license + $this->save(); + + return $this; + } + + /** + * Sends an upgrade request to the hub in order + * to either redirect to the upgrade form or + * sync the new license state + * + * @codeCoverageIgnore + */ + public function upgrade(): array + { + $response = $this->request('upgrade', [ + 'domain' => $this->domain, + 'email' => $this->email, + 'license' => $this->code, + ]); + + // the license still needs an upgrade + if (empty($response['url']) === false) { + // validate the redirect URL + if (Str::startsWith($response['url'], static::hub()) === false) { + throw new Exception('We couldn’t redirect you to the Hub'); + } + + return [ + 'status' => 'upgrade', + 'url' => $response['url'] + ]; + } + + // the license has already been upgraded + // and can now be replaced + $this->update($response); + + return [ + 'status' => 'complete', + ]; + } +} diff --git a/kirby/src/Cms/LicenseStatus.php b/kirby/src/Cms/LicenseStatus.php new file mode 100644 index 0000000..51ae5da --- /dev/null +++ b/kirby/src/Cms/LicenseStatus.php @@ -0,0 +1,127 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @codeCoverageIgnore + */ +enum LicenseStatus: string +{ + /** + * The license is valid and active + */ + case Active = 'active'; + + /** + * Only used for the demo instance + */ + case Demo = 'demo'; + + /** + * The included updates period of + * the license is over. + */ + case Inactive = 'inactive'; + + /** + * The installation has an old + * license (v1, v2, v3) + */ + case Legacy = 'legacy'; + + /** + * The installation has no license or + * the license cannot be validated + */ + case Missing = 'missing'; + + /** + * Returns the dialog according to the status + */ + public function dialog(): string + { + return match ($this) { + static::Missing => 'registration', + default => 'license' + }; + } + + /** + * Returns the icon according to the status. + * The icon is used for the system view and + * in the license dialog. + */ + public function icon(): string + { + return match ($this) { + static::Missing => 'key', + static::Legacy => 'alert', + static::Inactive => 'clock', + static::Active => 'check', + static::Demo => 'preview', + }; + } + + /** + * The info text is shown in the license dialog + * in the status row. + */ + public function info(string|null $end = null): string + { + return I18n::template('license.status.' . $this->value . '.info', ['date' => $end]); + } + + /** + * Label for the system view + */ + public function label(): string + { + return I18n::translate('license.status.' . $this->value . '.label'); + } + + /** + * Checks if the license can be renewed + * The license dialog will show the renew + * button in this case and redirect to the hub + */ + public function renewable(): bool + { + return match ($this) { + static::Demo => false, + static::Active => false, + default => true + }; + } + + /** + * Returns the theme according to the status + * The theme is used for the label in the system + * view and the status icon in the license dialog. + */ + public function theme(): string + { + return match ($this) { + static::Missing => 'love', + static::Legacy => 'negative', + static::Inactive => 'notice', + static::Active => 'positive', + static::Demo => 'notice', + }; + } + + /** + * Returns the status as string value + */ + public function value(): string + { + return $this->value; + } +} diff --git a/kirby/src/Cms/LicenseType.php b/kirby/src/Cms/LicenseType.php new file mode 100644 index 0000000..6a9fea7 --- /dev/null +++ b/kirby/src/Cms/LicenseType.php @@ -0,0 +1,111 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * + * @codeCoverageIgnore + */ +enum LicenseType: string +{ + /** + * New basic licenses + */ + case Basic = 'basic'; + + /** + * New enterprise licenses + */ + case Enterprise = 'enterprise'; + + /** + * Invalid license codes + */ + case Invalid = 'invalid'; + + /** + * Old Kirby 3 licenses + */ + case Legacy = 'legacy'; + + /** + * Detects the correct LicenseType based on the code + */ + public static function detect(string|null $code): static + { + return match (true) { + static::Basic->isValidCode($code) => static::Basic, + static::Enterprise->isValidCode($code) => static::Enterprise, + static::Legacy->isValidCode($code) => static::Legacy, + default => static::Invalid + }; + } + + /** + * Checks for a valid license code + * by prefix and length. This is just a + * rough validation. + */ + public function isValidCode(string|null $code): bool + { + return + $code !== null && + Str::length($code) === $this->length() && + Str::startsWith($code, $this->prefix()) === true; + } + + /** + * The expected lengths of the license code + */ + public function length(): int + { + return match ($this) { + static::Basic => 38, + static::Enterprise => 38, + static::Legacy => 39, + static::Invalid => 0, + }; + } + + /** + * A human-readable license type label + */ + public function label(): string + { + return match ($this) { + static::Basic => 'Kirby Basic', + static::Enterprise => 'Kirby Enterprise', + static::Legacy => 'Kirby 3', + static::Invalid => I18n::translate('license.unregistered.label'), + }; + } + + /** + * The expected prefix for the license code + */ + public function prefix(): string|null + { + return match ($this) { + static::Basic => 'K-BAS-', + static::Enterprise => 'K-ENT-', + static::Legacy => 'K3-PRO-', + static::Invalid => null, + }; + } + + /** + * Returns the enum value + */ + public function value(): string + { + return $this->value; + } +} diff --git a/kirby/src/Cms/Loader.php b/kirby/src/Cms/Loader.php new file mode 100644 index 0000000..6e71878 --- /dev/null +++ b/kirby/src/Cms/Loader.php @@ -0,0 +1,217 @@ +load()` and the + * `$kirby->core()->load()` methods. + * + * With `$kirby->load()` you get access to core parts + * that might be overwritten by plugins. + * + * With `$kirby->core()->load()` you get access to + * untouched core parts. This is useful if you want to + * reuse or fall back to core features in your plugins. + * + * @package Kirby Cms + * @author Bastian Allgeier + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Loader +{ + /** + * @var \Kirby\Cms\App + */ + protected $kirby; + + /** + * @var bool + */ + protected $withPlugins; + + /** + * @param \Kirby\Cms\App $kirby + * @param bool $withPlugins + */ + public function __construct(App $kirby, bool $withPlugins = true) + { + $this->kirby = $kirby; + $this->withPlugins = $withPlugins; + } + + /** + * Loads the area definition + */ + public function area(string $name): array|null + { + return $this->areas()[$name] ?? null; + } + + /** + * Loads all areas and makes sure that plugins + * are injected properly + */ + public function areas(): array + { + $areas = []; + $extensions = $this->withPlugins === true ? $this->kirby->extensions('areas') : []; + + // load core areas and extend them with elements + // from plugins if they exist + foreach ($this->kirby->core()->areas() as $id => $area) { + $area = $this->resolveArea($area); + + if (isset($extensions[$id]) === true) { + foreach ($extensions[$id] as $areaExtension) { + $extension = $this->resolveArea($areaExtension); + $area = array_replace_recursive($area, $extension); + } + + unset($extensions[$id]); + } + + $areas[$id] = $area; + } + + // add additional areas from plugins + foreach ($extensions as $id => $areaExtensions) { + foreach ($areaExtensions as $areaExtension) { + $areas[$id] = $this->resolve($areaExtension); + } + } + + return $areas; + } + + /** + * Loads a core component closure + */ + public function component(string $name): Closure|null + { + return $this->extension('components', $name); + } + + /** + * Loads all core component closures + */ + public function components(): array + { + return $this->extensions('components'); + } + + /** + * Loads a particular extension + */ + public function extension(string $type, string $name): mixed + { + return $this->extensions($type)[$name] ?? null; + } + + /** + * Loads all defined extensions + */ + public function extensions(string $type): array + { + return $this->withPlugins === false ? $this->kirby->core()->$type() : $this->kirby->extensions($type); + } + + /** + * The resolver takes a string, array or closure. + * + * 1.) a string is supposed to be a path to an existing file. + * The file will either be included when it's a PHP file and + * the array contents will be read. Or it will be parsed with + * the Data class to read yml or json data into an array + * + * 2.) arrays are untouched and returned + * + * 3.) closures will be called and the Kirby instance will be + * passed as first argument + */ + public function resolve(mixed $item): mixed + { + if (is_string($item) === true) { + $item = match (F::extension($item)) { + 'php' => F::load($item, allowOutput: false), + default => Data::read($item) + }; + } + + if (is_callable($item) === true) { + $item = $item($this->kirby); + } + + return $item; + } + + /** + * Calls `static::resolve()` on all items + * in the given array + */ + public function resolveAll(array $items): array + { + $result = []; + + foreach ($items as $key => $value) { + $result[$key] = $this->resolve($value); + } + + return $result; + } + + /** + * Areas need a bit of special treatment + * when they are being loaded + */ + public function resolveArea(string|array|Closure $area): array + { + $area = $this->resolve($area); + $dropdowns = $area['dropdowns'] ?? []; + + // convert closure dropdowns to an array definition + // otherwise they cannot be merged properly later + foreach ($dropdowns as $key => $dropdown) { + if ($dropdown instanceof Closure) { + $area['dropdowns'][$key] = [ + 'options' => $dropdown + ]; + } + } + + return $area; + } + + /** + * Loads a particular section definition + */ + public function section(string $name): array|null + { + return $this->resolve($this->extension('sections', $name)); + } + + /** + * Loads all section defintions + */ + public function sections(): array + { + return $this->resolveAll($this->extensions('sections')); + } + + /** + * Returns the status flag, which shows + * if plugins are loaded as well. + */ + public function withPlugins(): bool + { + return $this->withPlugins; + } +} diff --git a/kirby/src/Cms/Media.php b/kirby/src/Cms/Media.php new file mode 100644 index 0000000..ec44de9 --- /dev/null +++ b/kirby/src/Cms/Media.php @@ -0,0 +1,175 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Media +{ + /** + * Tries to find a file by model and filename + * and to copy it to the media folder. + */ + public static function link( + Page|Site|User $model = null, + string $hash, + string $filename + ): Response|false { + if ($model === null) { + return false; + } + + // fix issues with spaces in filenames + $filename = urldecode($filename); + + // try to find a file by model and filename + // this should work for all original files + if ($file = $model->file($filename)) { + // check if the request contained an outdated media hash + if ($file->mediaHash() !== $hash) { + // if at least the token was correct, redirect + if (Str::startsWith($hash, $file->mediaToken() . '-') === true) { + return Response::redirect($file->mediaUrl(), 307); + } + + // don't leak the correct token, render the error page + return false; + } + + // send the file to the browser + return Response::file($file->publish()->mediaRoot()); + } + + // try to generate a thumb for the file + return static::thumb($model, $hash, $filename); + } + + /** + * Copy the file to the final media folder location + */ + public static function publish(File $file, string $dest): bool + { + // never publish risky files (e.g. HTML, PHP or Apache config files) + FileRules::validFile($file, false); + + $src = $file->root(); + $version = dirname($dest); + $directory = dirname($version); + + // unpublish all files except stuff in the version folder + Media::unpublish($directory, $file, $version); + + // copy/overwrite the file to the dest folder + return F::copy($src, $dest, true); + } + + /** + * Tries to find a job file for the + * given filename and then calls the thumb + * component to create a thumbnail accordingly + */ + public static function thumb( + File|Page|Site|User|string $model, + string $hash, + string $filename + ): Response|false { + $kirby = App::instance(); + + $root = match (true) { + // assets + is_string($model) + => $kirby->root('media') . '/assets/' . $model . '/' . $hash, + // parent files for file model that already included hash + $model instanceof File + => dirname($model->mediaRoot()), + // model files + default + => $model->mediaRoot() . '/' . $hash + }; + + $thumb = $root . '/' . $filename; + $job = $root . '/.jobs/' . $filename . '.json'; + + try { + $options = Data::read($job); + } catch (Throwable) { + // send a customized error message to make clearer what happened here + throw new NotFoundException('The thumbnail configuration could not be found'); + } + + if (empty($options['filename']) === true) { + throw new InvalidArgumentException('Incomplete thumbnail configuration'); + } + + try { + // find the correct source file depending on the model + // this adds support for custom assets + $source = match (true) { + is_string($model) === true + => $kirby->root('index') . '/' . $model . '/' . $options['filename'], + default + => $model->file($options['filename'])->root() + }; + + // generate the thumbnail and save it in the media folder + $kirby->thumb($source, $thumb, $options); + + // remove the job file once the thumbnail has been created + F::remove($job); + + // read the file and send it to the browser + return Response::file($thumb); + } catch (Throwable $e) { + // remove potentially broken thumbnails + F::remove($thumb); + throw $e; + } + } + + /** + * Deletes all versions of the given file + * within the parent directory + */ + public static function unpublish( + string $directory, + File $file, + string|null $ignore = null + ): bool { + if (is_dir($directory) === false) { + return true; + } + + // get both old and new versions (pre and post Kirby 3.4.0) + $versions = array_merge( + glob($directory . '/' . crc32($file->filename()) . '-*', GLOB_ONLYDIR), + glob($directory . '/' . $file->mediaToken() . '-*', GLOB_ONLYDIR) + ); + + // delete all versions of the file + foreach ($versions as $version) { + if ($version === $ignore) { + continue; + } + + Dir::remove($version); + } + + return true; + } +} diff --git a/kirby/src/Cms/Model.php b/kirby/src/Cms/Model.php new file mode 100644 index 0000000..b7010d3 --- /dev/null +++ b/kirby/src/Cms/Model.php @@ -0,0 +1,117 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class Model +{ + use Properties; + + /** + * Each model must define a CLASS_ALIAS + * which will be used in template queries. + * The CLASS_ALIAS is a short human-readable + * version of the class name. I.e. page. + */ + public const CLASS_ALIAS = null; + + /** + * The parent Kirby instance + * + * @var \Kirby\Cms\App + */ + public static $kirby; + + /** + * The parent site instance + * + * @var \Kirby\Cms\Site + */ + protected $site; + + /** + * Makes it possible to convert the entire model + * to a string. Mostly useful for debugging + * + * @return string + */ + public function __toString(): string + { + return $this->id(); + } + + /** + * Each model must return a unique id + * + * @return string|null + */ + public function id() + { + return null; + } + + /** + * Returns the parent Kirby instance + * + * @return \Kirby\Cms\App + */ + public function kirby() + { + return static::$kirby ??= App::instance(); + } + + /** + * Returns the parent Site instance + * + * @return \Kirby\Cms\Site + */ + public function site() + { + return $this->site ??= $this->kirby()->site(); + } + + /** + * Setter for the parent Kirby object + * + * @param \Kirby\Cms\App|null $kirby + * @return $this + */ + protected function setKirby(App $kirby = null) + { + static::$kirby = $kirby; + return $this; + } + + /** + * Setter for the parent site object + * + * @internal + * @param \Kirby\Cms\Site|null $site + * @return $this + */ + public function setSite(Site $site = null) + { + $this->site = $site; + return $this; + } + + /** + * Convert the model to a simple array + * + * @return array + */ + public function toArray(): array + { + return $this->propertiesToArray(); + } +} diff --git a/kirby/src/Cms/ModelPermissions.php b/kirby/src/Cms/ModelPermissions.php new file mode 100644 index 0000000..0b8cb56 --- /dev/null +++ b/kirby/src/Cms/ModelPermissions.php @@ -0,0 +1,116 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class ModelPermissions +{ + protected string $category; + protected ModelWithContent $model; + protected array $options; + protected Permissions $permissions; + protected User $user; + + public function __construct(ModelWithContent $model) + { + $this->model = $model; + $this->options = $model->blueprint()->options(); + $this->user = $model->kirby()->user() ?? User::nobody(); + $this->permissions = $this->user->role()->permissions(); + } + + public function __call(string $method, array $arguments = []): bool + { + return $this->can($method); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + public function can(string $action): bool + { + $user = $this->user->id(); + $role = $this->user->role()->id(); + + // users with the `nobody` role can do nothing + // that needs a permission check + if ($role === 'nobody') { + return false; + } + + // check for a custom `can` method + // which would take priority over any other + // role-based permission rules + if ( + method_exists($this, 'can' . $action) === true && + $this->{'can' . $action}() === false + ) { + return false; + } + + // the almighty `kirby` user can do anything + if ($user === 'kirby' && $role === 'admin') { + return true; + } + + // evaluate the blueprint options block + if (isset($this->options[$action]) === true) { + $options = $this->options[$action]; + + if ($options === false) { + return false; + } + + if ($options === true) { + return true; + } + + if ( + is_array($options) === true && + A::isAssociative($options) === true + ) { + if (isset($options[$role]) === true) { + return $options[$role]; + } + + if (isset($options['*']) === true) { + return $options['*']; + } + } + } + + return $this->permissions->for($this->category, $action); + } + + public function cannot(string $action): bool + { + return $this->can($action) === false; + } + + public function toArray(): array + { + $array = []; + + foreach ($this->options as $key => $value) { + $array[$key] = $this->can($key); + } + + return $array; + } +} diff --git a/kirby/src/Cms/ModelWithContent.php b/kirby/src/Cms/ModelWithContent.php new file mode 100644 index 0000000..6737393 --- /dev/null +++ b/kirby/src/Cms/ModelWithContent.php @@ -0,0 +1,822 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class ModelWithContent implements Identifiable +{ + /** + * Each model must define a CLASS_ALIAS + * which will be used in template queries. + * The CLASS_ALIAS is a short human-readable + * version of the class name, i.e. page. + */ + public const CLASS_ALIAS = null; + + /** + * Cached array of valid blueprints + * that could be used for the model + */ + public array|null $blueprints = null; + + public Content|null $content; + public static App $kirby; + protected Site|null $site; + protected ContentStorage $storage; + public Collection|null $translations; + + /** + * Store values used to initilaize object + */ + protected array $propertyData = []; + + public function __construct(array $props = []) + { + $this->site = $props['site'] ?? null; + + $this->setContent($props['content'] ?? null); + $this->setTranslations($props['translations'] ?? null); + + $this->propertyData = $props; + } + + /** + * Returns the blueprint of the model + */ + abstract public function blueprint(): Blueprint; + + /** + * Returns an array with all blueprints that are available + */ + public function blueprints(string $inSection = null): array + { + // helper function + $toBlueprints = function (array $sections): array { + $blueprints = []; + + foreach ($sections as $section) { + if ($section === null) { + continue; + } + + foreach ((array)$section->blueprints() as $blueprint) { + $blueprints[$blueprint['name']] = $blueprint; + } + } + + return array_values($blueprints); + }; + + $blueprint = $this->blueprint(); + + // no caching for when collecting for specific section + if ($inSection !== null) { + return $toBlueprints([$blueprint->section($inSection)]); + } + + return $this->blueprints ??= $toBlueprints($blueprint->sections()); + } + + /** + * Creates a new instance with the same + * initial properties + * + * @todo eventually refactor without need of propertyData + */ + public function clone(array $props = []): static + { + return new static(array_replace_recursive($this->propertyData, $props)); + } + + /** + * Executes any given model action + */ + abstract protected function commit( + string $action, + array $arguments, + Closure $callback + ): mixed; + + /** + * Returns the content + * + * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist + */ + public function content(string|null $languageCode = null): Content + { + // single language support + if ($this->kirby()->multilang() === false) { + if ($this->content instanceof Content) { + return $this->content; + } + + // don't normalize field keys (already handled by the `Data` class) + return $this->content = new Content($this->readContent(), $this, false); + } + + // get the targeted language + $language = $this->kirby()->language($languageCode); + + // stop if the language does not exist + if ($language === null) { + throw new InvalidArgumentException('Invalid language: ' . $languageCode); + } + + // only fetch from cache for the current language + if ($languageCode === null && $this->content instanceof Content) { + return $this->content; + } + + // get the translation by code + $translation = $this->translation($language->code()); + + // don't normalize field keys (already handled by the `ContentTranslation` class) + $content = new Content($translation->content(), $this, false); + + // only store the content for the current language + if ($languageCode === null) { + $this->content = $content; + } + + return $content; + } + + /** + * Returns the absolute path to the content file; + * NOTE: only supports the published content file + * (use `$model->storage()->contentFile()` for other versions) + * @internal + * @deprecated 4.0.0 + * @todo Remove in v5 + * @codeCoverageIgnore + * + * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist + */ + public function contentFile( + string $languageCode = null, + bool $force = false + ): string { + Helpers::deprecated('The internal $model->contentFile() method has been deprecated. You can use $model->storage()->contentFile() instead, however please note that this method is also internal and may be removed in the future.', 'model-content-file'); + + return $this->storage()->contentFile( + $this->storage()->defaultVersion(), + $languageCode, + $force + ); + } + + /** + * Returns an array with all content files; + * NOTE: only supports the published content file + * (use `$model->storage()->contentFiles()` for other versions) + * @deprecated 4.0.0 + * @todo Remove in v5 + * @codeCoverageIgnore + */ + public function contentFiles(): array + { + Helpers::deprecated('The internal $model->contentFiles() method has been deprecated. You can use $model->storage()->contentFiles() instead, however please note that this method is also internal and may be removed in the future.', 'model-content-file'); + + return $this->storage()->contentFiles( + $this->storage()->defaultVersion() + ); + } + + /** + * Prepares the content that should be written + * to the text file + * @internal + */ + public function contentFileData( + array $data, + string $languageCode = null + ): array { + return $data; + } + + /** + * Returns the absolute path to the + * folder in which the content file is + * located + * @internal + * @deprecated 4.0.0 + * @todo Remove in v5 + * @codeCoverageIgnore + */ + public function contentFileDirectory(): string|null + { + Helpers::deprecated('The internal $model->contentFileDirectory() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); + return $this->root(); + } + + /** + * Returns the extension of the content file + * @internal + * @deprecated 4.0.0 + * @todo Remove in v5 + * @codeCoverageIgnore + */ + public function contentFileExtension(): string + { + Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); + return $this->kirby()->contentExtension(); + } + + /** + * Needs to be declared by the final model + * @internal + * @deprecated 4.0.0 + * @todo Remove in v5 + * @codeCoverageIgnore + */ + abstract public function contentFileName(): string; + + /** + * Converts model to new blueprint + * incl. its content for all translations + */ + protected function convertTo(string $blueprint): static + { + // first close object with new blueprint as template + $new = $this->clone(['template' => $blueprint]); + + // temporary compatibility change (TODO: also convert changes) + $identifier = $this->storage()->defaultVersion(); + + // for multilang, we go through all translations and + // covnert the content for each of them, remove the content file + // to rewrite it with converted content afterwards + if ($this->kirby()->multilang() === true) { + $translations = []; + + foreach ($this->kirby()->languages()->codes() as $code) { + if ($this->translation($code)?->exists() === true) { + $content = $this->content($code)->convertTo($blueprint); + + // delete the old text file + $this->storage()->delete( + $identifier, + $code + ); + + // save to re-create the translation content file + // with the converted/updated content + $new->save($content, $code); + } + + $translations[] = [ + 'code' => $code, + 'content' => $content ?? null + ]; + } + + // cloning the object with the new translations content ensures + // that `propertyData` prop does not hold any old translations + // content that could surface on subsequent cloning + return $new->clone(['translations' => $translations]); + } + + // for single language setups, we do the same, + // just once for the main content + $content = $this->content()->convertTo($blueprint); + + // delete the old text file + $this->storage()->delete($identifier, 'default'); + + return $new->save($content); + } + + /** + * Decrement a given field value + */ + public function decrement( + string $field, + int $by = 1, + int $min = 0 + ): static { + $value = (int)$this->content()->get($field)->value() - $by; + + if ($value < $min) { + $value = $min; + } + + return $this->update([$field => $value]); + } + + /** + * Returns all content validation errors + */ + public function errors(): array + { + $errors = []; + + foreach ($this->blueprint()->sections() as $section) { + $errors = array_merge($errors, $section->errors()); + } + + return $errors; + } + + /** + * Creates a clone and fetches all + * lazy-loaded getters to get a full copy + */ + public function hardcopy(): static + { + $clone = $this->clone(); + + foreach (get_object_vars($clone) as $name => $default) { + if (method_exists($clone, $name) === true) { + $clone->$name(); + } + } + + return $clone; + } + + /** + * Each model must return a unique id + */ + public function id(): string|null + { + return null; + } + + /** + * Increment a given field value + */ + public function increment( + string $field, + int $by = 1, + int $max = null + ): static { + $value = (int)$this->content()->get($field)->value() + $by; + + if ($max && $value > $max) { + $value = $max; + } + + return $this->update([$field => $value]); + } + + /** + * Checks if the model is locked for the current user + */ + public function isLocked(): bool + { + $lock = $this->lock(); + return $lock && $lock->isLocked() === true; + } + + /** + * Checks if the data has any errors + */ + public function isValid(): bool + { + return Form::for($this)->isValid() === true; + } + + /** + * Returns the parent Kirby instance + */ + public function kirby(): App + { + return static::$kirby ??= App::instance(); + } + + /** + * Returns the lock object for this model + * + * Only if a content directory exists, + * virtual pages will need to overwrite this method + */ + public function lock(): ContentLock|null + { + $dir = $this->root(); + + if ($this::CLASS_ALIAS === 'file') { + $dir = dirname($dir); + } + + if ( + $this->kirby()->option('content.locking', true) && + is_string($dir) === true && + file_exists($dir) === true + ) { + return new ContentLock($this); + } + + return null; + } + + /** + * Returns the panel info of the model + * @since 3.6.0 + */ + abstract public function panel(): Model; + + /** + * Must return the permissions object for the model + */ + abstract public function permissions(): ModelPermissions; + + /** + * Clean internal caches + * + * @return $this + */ + public function purge(): static + { + $this->blueprints = null; + $this->content = null; + $this->translations = null; + + return $this; + } + + /** + * Creates a string query, starting from the model + * @internal + */ + public function query( + string $query = null, + string $expect = null + ): mixed { + if ($query === null) { + return null; + } + + try { + $result = Str::query($query, [ + 'kirby' => $this->kirby(), + 'site' => $this instanceof Site ? $this : $this->site(), + 'model' => $this, + static::CLASS_ALIAS => $this + ]); + } catch (Throwable) { + return null; + } + + if ($expect !== null && $result instanceof $expect === false) { + return null; + } + + return $result; + } + + /** + * Read the content from the content file + * @internal + */ + public function readContent(string $languageCode = null): array + { + try { + return $this->storage()->read( + $this->storage()->defaultVersion(), + $languageCode + ); + } catch (NotFoundException) { + // only if the content file really does not exist, it's ok + // to return empty content. Otherwise this could lead to + // content loss in case of file reading issues + return []; + } + } + + /** + * Returns the absolute path to the model + */ + abstract public function root(): string|null; + + /** + * Stores the content on disk + * @internal + */ + public function save( + array|null $data = null, + string|null $languageCode = null, + bool $overwrite = false + ): static { + if ($this->kirby()->multilang() === true) { + return $this->saveTranslation($data, $languageCode, $overwrite); + } + + return $this->saveContent($data, $overwrite); + } + + /** + * Save the single language content + */ + protected function saveContent( + array $data = null, + bool $overwrite = false + ): static { + // create a clone to avoid modifying the original + $clone = $this->clone(); + + // merge the new data with the existing content + $clone->content()->update($data, $overwrite); + + // send the full content array to the writer + $clone->writeContent($clone->content()->toArray()); + + return $clone; + } + + /** + * Save a translation + * + * @throws \Kirby\Exception\InvalidArgumentException If the language for the given code does not exist + */ + protected function saveTranslation( + array $data = null, + string $languageCode = null, + bool $overwrite = false + ): static { + // create a clone to not touch the original + $clone = $this->clone(); + + // fetch the matching translation and update all the strings + $translation = $clone->translation($languageCode); + + if ($translation === null) { + throw new InvalidArgumentException('Invalid language: ' . $languageCode); + } + + // get the content to store + $content = $translation->update($data, $overwrite)->content(); + $kirby = $this->kirby(); + $languageCode = $kirby->languageCode($languageCode); + + // remove all untranslatable fields + if ($languageCode !== $kirby->defaultLanguage()->code()) { + foreach ($this->blueprint()->fields() as $field) { + if (($field['translate'] ?? true) === false) { + $content[strtolower($field['name'])] = null; + } + } + + // remove UUID for non-default languages + if (Uuids::enabled() === true && isset($content['uuid']) === true) { + $content['uuid'] = null; + } + + // merge the translation with the new data + $translation->update($content, true); + } + + // send the full translation array to the writer + $clone->writeContent($translation->content(), $languageCode); + + // reset the content object + $clone->content = null; + + // return the updated model + return $clone; + } + + /** + * Sets the Content object + * + * @return $this + */ + protected function setContent(array $content = null): static + { + if ($content !== null) { + $content = new Content($content, $this); + } + + $this->content = $content; + return $this; + } + + /** + * Create the translations collection from an array + * + * @return $this + */ + protected function setTranslations(array $translations = null): static + { + if ($translations !== null) { + $this->translations = new Collection(); + + foreach ($translations as $props) { + $props['parent'] = $this; + $translation = new ContentTranslation($props); + $this->translations->data[$translation->code()] = $translation; + } + } else { + $this->translations = null; + } + + return $this; + } + + /** + * Returns the parent Site instance + */ + public function site(): Site + { + return $this->site ??= $this->kirby()->site(); + } + + /** + * Returns the content storage handler + * @internal + */ + public function storage(): ContentStorage + { + return $this->storage ??= new ContentStorage( + model: $this, + handler: PlainTextContentStorageHandler::class + ); + } + + /** + * Convert the model to a simple array + */ + public function toArray(): array + { + return [ + 'content' => $this->content()->toArray(), + 'translations' => $this->translations()->toArray() + ]; + } + + /** + * String template builder with automatic HTML escaping + * @since 3.6.0 + * + * @param string|null $template Template string or `null` to use the model ID + * @param string|null $fallback Fallback for tokens in the template that cannot be replaced + * (`null` to keep the original token) + */ + public function toSafeString( + string $template = null, + array $data = [], + string|null $fallback = '' + ): string { + return $this->toString($template, $data, $fallback, 'safeTemplate'); + } + + /** + * String template builder + * + * @param string|null $template Template string or `null` to use the model ID + * @param string|null $fallback Fallback for tokens in the template that cannot be replaced + * (`null` to keep the original token) + * @param string $handler For internal use + */ + public function toString( + string $template = null, + array $data = [], + string|null $fallback = '', + string $handler = 'template' + ): string { + if ($template === null) { + return $this->id() ?? ''; + } + + if ($handler !== 'template' && $handler !== 'safeTemplate') { + throw new InvalidArgumentException('Invalid toString handler'); // @codeCoverageIgnore + } + + $result = Str::$handler($template, array_replace([ + 'kirby' => $this->kirby(), + 'site' => $this instanceof Site ? $this : $this->site(), + 'model' => $this, + static::CLASS_ALIAS => $this, + ], $data), ['fallback' => $fallback]); + + return $result; + } + + /** + * Makes it possible to convert the entire model + * to a string. Mostly useful for debugging + */ + public function __toString(): string + { + return $this->id(); + } + + /** + * Returns a single translation by language code + * If no code is specified the current translation is returned + */ + public function translation( + string $languageCode = null + ): ContentTranslation|null { + if ($language = $this->kirby()->language($languageCode)) { + return $this->translations()->find($language->code()); + } + + return null; + } + + /** + * Returns the translations collection + */ + public function translations(): Collection + { + if ($this->translations !== null) { + return $this->translations; + } + + $this->translations = new Collection(); + + foreach ($this->kirby()->languages() as $language) { + $translation = new ContentTranslation([ + 'parent' => $this, + 'code' => $language->code(), + ]); + + $this->translations->data[$translation->code()] = $translation; + } + + return $this->translations; + } + + /** + * Updates the model data + * + * @throws \Kirby\Exception\InvalidArgumentException If the input array contains invalid values + */ + public function update( + array $input = null, + string $languageCode = null, + bool $validate = false + ): static { + $form = Form::for($this, [ + 'ignoreDisabled' => $validate === false, + 'input' => $input, + 'language' => $languageCode, + ]); + + // validate the input + if ($validate === true && $form->isInvalid() === true) { + throw new InvalidArgumentException([ + 'fallback' => 'Invalid form with errors', + 'details' => $form->errors() + ]); + } + + $arguments = [static::CLASS_ALIAS => $this, 'values' => $form->data(), 'strings' => $form->strings(), 'languageCode' => $languageCode]; + return $this->commit('update', $arguments, function ($model, $values, $strings, $languageCode) { + return $model->save($strings, $languageCode, true); + }); + } + + /** + * Returns the model's UUID + * @since 3.8.0 + */ + public function uuid(): Uuid|null + { + return Uuid::for($this); + } + + /** + * Low level data writer method + * to store the given data on disk or anywhere else + * @internal + */ + public function writeContent(array $data, string $languageCode = null): bool + { + $data = $this->contentFileData($data, $languageCode); + $id = $this->storage()->defaultVersion(); + + try { + // we can only update if the version already exists + $this->storage()->update($id, $languageCode, $data); + } catch (NotFoundException) { + // otherwise create a new version + $this->storage()->create($id, $languageCode, $data); + } + + return true; + } +} diff --git a/kirby/src/Cms/Nest.php b/kirby/src/Cms/Nest.php new file mode 100644 index 0000000..0f8521d --- /dev/null +++ b/kirby/src/Cms/Nest.php @@ -0,0 +1,49 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Nest +{ + public static function create( + $data, + object|null $parent = null + ): NestCollection|NestObject|Field { + if (is_scalar($data) === true) { + return new Field($parent, $data, $data); + } + + $result = []; + + foreach ($data as $key => $value) { + if (is_array($value) === true) { + $result[$key] = static::create($value, $parent); + } elseif (is_scalar($value) === true) { + $result[$key] = new Field($parent, $key, $value); + } + } + + $key = key($data); + + if ($key === null || is_int($key) === true) { + return new NestCollection($result); + } + + return new NestObject($result); + } +} diff --git a/kirby/src/Cms/NestCollection.php b/kirby/src/Cms/NestCollection.php new file mode 100644 index 0000000..129668d --- /dev/null +++ b/kirby/src/Cms/NestCollection.php @@ -0,0 +1,28 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class NestCollection extends BaseCollection +{ + /** + * Converts all objects in the collection + * to an array. This can also take a callback + * function to further modify the array result. + */ + public function toArray(Closure $map = null): array + { + return parent::toArray($map ?? fn ($object) => $object->toArray()); + } +} diff --git a/kirby/src/Cms/NestObject.php b/kirby/src/Cms/NestObject.php new file mode 100644 index 0000000..2466023 --- /dev/null +++ b/kirby/src/Cms/NestObject.php @@ -0,0 +1,45 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class NestObject extends Obj +{ + /** + * Converts the object to an array + */ + public function toArray(): array + { + $result = []; + + foreach ((array)$this as $key => $value) { + if ($value instanceof Field) { + $result[$key] = $value->value(); + continue; + } + + if ( + is_object($value) === true && + method_exists($value, 'toArray') + ) { + $result[$key] = $value->toArray(); + continue; + } + + $result[$key] = $value; + } + + return $result; + } +} diff --git a/kirby/src/Cms/Page.php b/kirby/src/Cms/Page.php new file mode 100644 index 0000000..3fab7b3 --- /dev/null +++ b/kirby/src/Cms/Page.php @@ -0,0 +1,1321 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Page extends ModelWithContent +{ + use HasChildren; + use HasFiles; + use HasMethods; + use HasSiblings; + use PageActions; + use PageSiblings; + + public const CLASS_ALIAS = 'page'; + + /** + * All registered page methods + * @todo Remove when support for PHP 8.2 is dropped + */ + public static array $methods = []; + + /** + * Registry with all Page models + */ + public static array $models = []; + + /** + * The PageBlueprint object + */ + protected PageBlueprint|null $blueprint = null; + + /** + * Nesting level + */ + protected int $depth; + + /** + * Sorting number + slug + */ + protected string|null $dirname; + + /** + * Path of dirnames + */ + protected string|null $diruri = null; + + /** + * Draft status flag + */ + protected bool $isDraft; + + /** + * The Page id + */ + protected string|null $id = null; + + /** + * The template, that should be loaded + * if it exists + */ + protected Template|null $intendedTemplate = null; + + protected array|null $inventory = null; + + /** + * The sorting number + */ + protected int|null $num; + + /** + * The parent page + */ + protected Page|null $parent; + + /** + * Absolute path to the page directory + */ + protected string|null $root; + + /** + * The URL-appendix aka slug + */ + protected string $slug; + + /** + * The intended page template + */ + protected Template|null $template = null; + + /** + * The page url + */ + protected string|null $url; + + /** + * Creates a new page object + */ + public function __construct(array $props) + { + if (isset($props['slug']) === false) { + throw new InvalidArgumentException('The page slug is required'); + } + + parent::__construct($props); + + $this->slug = $props['slug']; + // Sets the dirname manually, which works + // more reliable in connection with the inventory + // than computing the dirname afterwards + $this->dirname = $props['dirname'] ?? null; + $this->isDraft = $props['isDraft'] ?? false; + $this->num = $props['num'] ?? null; + $this->parent = $props['parent'] ?? null; + $this->root = $props['root'] ?? null; + + $this->setBlueprint($props['blueprint'] ?? null); + $this->setChildren($props['children'] ?? null); + $this->setDrafts($props['drafts'] ?? null); + $this->setFiles($props['files'] ?? null); + $this->setTemplate($props['template'] ?? null); + $this->setUrl($props['url'] ?? null); + } + + /** + * Magic caller + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // page methods + if ($this->hasMethod($method)) { + return $this->callMethod($method, $arguments); + } + + // return page content otherwise + return $this->content()->get($method); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return array_merge($this->toArray(), [ + 'content' => $this->content(), + 'children' => $this->children(), + 'siblings' => $this->siblings(), + 'translations' => $this->translations(), + 'files' => $this->files(), + ]); + } + + /** + * Returns the url to the api endpoint + * @internal + */ + public function apiUrl(bool $relative = false): string + { + if ($relative === true) { + return 'pages/' . $this->panel()->id(); + } + + return $this->kirby()->url('api') . '/pages/' . $this->panel()->id(); + } + + /** + * Returns the blueprint object + */ + public function blueprint(): PageBlueprint + { + return $this->blueprint ??= PageBlueprint::factory( + 'pages/' . $this->intendedTemplate(), + 'pages/default', + $this + ); + } + + /** + * Returns an array with all blueprints that are available for the page + */ + public function blueprints(string|null $inSection = null): array + { + if ($inSection !== null) { + return $this->blueprint()->section($inSection)->blueprints(); + } + + if ($this->blueprints !== null) { + return $this->blueprints; + } + + $blueprints = []; + $templates = $this->blueprint()->changeTemplate() ?? $this->blueprint()->options()['changeTemplate'] ?? []; + $currentTemplate = $this->intendedTemplate()->name(); + + if (is_array($templates) === false) { + $templates = []; + } + + // add the current template to the array if it's not already there + if (in_array($currentTemplate, $templates) === false) { + array_unshift($templates, $currentTemplate); + } + + // make sure every template is only included once + $templates = array_unique($templates); + + foreach ($templates as $template) { + try { + $props = Blueprint::load('pages/' . $template); + + $blueprints[] = [ + 'name' => basename($props['name']), + 'title' => $props['title'], + ]; + } catch (Exception) { + // skip invalid blueprints + } + } + + return $this->blueprints = array_values($blueprints); + } + + /** + * Builds the cache id for the page + */ + protected function cacheId(string $contentType): string + { + $cacheId = [$this->id()]; + + if ($this->kirby()->multilang() === true) { + $cacheId[] = $this->kirby()->language()->code(); + } + + $cacheId[] = $contentType; + + return implode('.', $cacheId); + } + + /** + * Prepares the content for the write method + * @internal + */ + public function contentFileData( + array $data, + string|null $languageCode = null + ): array { + return A::prepend($data, [ + 'title' => $data['title'] ?? null, + 'slug' => $data['slug'] ?? null + ]); + } + + /** + * Returns the content text file + * which is found by the inventory method + * @internal + * @deprecated 4.0.0 + * @todo Remove in v5 + * @codeCoverageIgnore + */ + public function contentFileName(string|null $languageCode = null): string + { + Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); + return $this->intendedTemplate()->name(); + } + + /** + * Call the page controller + * @internal + * + * @throws \Kirby\Exception\InvalidArgumentException If the controller returns invalid objects for `kirby`, `site`, `pages` or `page` + */ + public function controller( + array $data = [], + string $contentType = 'html' + ): array { + // create the template data + $data = array_merge($data, [ + 'kirby' => $kirby = $this->kirby(), + 'site' => $site = $this->site(), + 'pages' => new LazyValue(fn () => $site->children()), + 'page' => new LazyValue(fn () => $site->visit($this)) + ]); + + // call the template controller if there's one. + $controllerData = $kirby->controller( + $this->template()->name(), + $data, + $contentType + ); + + // merge controller data with original data safely + // to provide original data to template even if + // it wasn't returned by the controller explicitly + if (empty($controllerData) === false) { + $classes = [ + 'kirby' => App::class, + 'site' => Site::class, + 'pages' => Pages::class, + 'page' => Page::class + ]; + + foreach ($controllerData as $key => $value) { + $data[$key] = match (true) { + // original data wasn't overwritten + array_key_exists($key, $classes) === false => $value, + // original data was overwritten, but matches expected type + $value instanceof $classes[$key] => $value, + // throw error if data was overwritten with wrong type + default => throw new InvalidArgumentException('The returned variable "' . $key . '" from the controller "' . $this->template()->name() . '" is not of the required type "' . $classes[$key] . '"') + }; + } + } + + // unwrap remaining lazy values in data + // (happens if the controller didn't override an original lazy Kirby object) + $data = LazyValue::unwrap($data); + + return $data; + } + + /** + * Returns a number indicating how deep the page + * is nested within the content folder + */ + public function depth(): int + { + return $this->depth ??= (substr_count($this->id(), '/') + 1); + } + + /** + * Sorting number + Slug + */ + public function dirname(): string + { + if ($this->dirname !== null) { + return $this->dirname; + } + + if ($this->num() !== null) { + return $this->dirname = $this->num() . Dir::$numSeparator . $this->uid(); + } + + return $this->dirname = $this->uid(); + } + + /** + * Sorting number + Slug + */ + public function diruri(): string + { + if (is_string($this->diruri) === true) { + return $this->diruri; + } + + if ($this->isDraft() === true) { + $dirname = '_drafts/' . $this->dirname(); + } else { + $dirname = $this->dirname(); + } + + if ($parent = $this->parent()) { + return $this->diruri = $parent->diruri() . '/' . $dirname; + } + + return $this->diruri = $dirname; + } + + /** + * Checks if the page exists on disk + */ + public function exists(): bool + { + return is_dir($this->root()) === true; + } + + /** + * Constructs a Page object and also + * takes page models into account. + * @internal + */ + public static function factory($props): static + { + return static::model($props['model'] ?? 'default', $props); + } + + /** + * Redirects to this page, + * wrapper for the `go()` helper + * + * @since 3.4.0 + * + * @param array $options Options for `Kirby\Http\Uri` to create URL parts + * @param int $code HTTP status code + */ + public function go(array $options = [], int $code = 302): void + { + Response::go($this->url($options), $code); + } + + /** + * Checks if the intended template + * for the page exists. + */ + public function hasTemplate(): bool + { + return $this->intendedTemplate() === $this->template(); + } + + /** + * Returns the Page Id + */ + public function id(): string + { + if ($this->id !== null) { + return $this->id; + } + + // set the id, depending on the parent + if ($parent = $this->parent()) { + return $this->id = $parent->id() . '/' . $this->uid(); + } + + return $this->id = $this->uid(); + } + + /** + * Returns the template that should be + * loaded if it exists. + */ + public function intendedTemplate(): Template + { + if ($this->intendedTemplate !== null) { + return $this->intendedTemplate; + } + + return $this->setTemplate($this->inventory()['template'])->intendedTemplate(); + } + + /** + * Returns the inventory of files + * children and content files + * @internal + */ + public function inventory(): array + { + if ($this->inventory !== null) { + return $this->inventory; + } + + $kirby = $this->kirby(); + + return $this->inventory = Dir::inventory( + $this->root(), + $kirby->contentExtension(), + $kirby->contentIgnore(), + $kirby->multilang() + ); + } + + /** + * Compares the current object with the given page object + * + * @param \Kirby\Cms\Page|string $page + */ + public function is($page): bool + { + if ($page instanceof self === false) { + if (is_string($page) === false) { + return false; + } + + $page = $this->kirby()->page($page); + } + + if ($page instanceof self === false) { + return false; + } + + return $this->id() === $page->id(); + } + + /** + * Checks if the page is accessible that accessible and listable. + * This permission depends on the `read` option until v5 + */ + public function isAccessible(): bool + { + // TODO: remove this check when `read` option deprecated in v5 + if ($this->isReadable() === false) { + return false; + } + + static $accessible = []; + + $template = $this->intendedTemplate()->name(); + + return $accessible[$template] ??= $this->permissions()->can('access'); + } + + /** + * Checks if the page is the current page + */ + public function isActive(): bool + { + return $this->site()->page()?->is($this) === true; + } + + /** + * Checks if the page is a direct or indirect ancestor + * of the given $page object + */ + public function isAncestorOf(Page $child): bool + { + return $child->parents()->has($this->id()) === true; + } + + /** + * Checks if the page can be cached in the + * pages cache. This will also check if one + * of the ignore rules from the config kick in. + */ + public function isCacheable(): bool + { + $kirby = $this->kirby(); + $cache = $kirby->cache('pages'); + $options = $cache->options(); + $ignore = $options['ignore'] ?? null; + + // the pages cache is switched off + if (($options['active'] ?? false) === false) { + return false; + } + + // inspect the current request + $request = $kirby->request(); + + // disable the pages cache for any request types but GET or HEAD + if (in_array($request->method(), ['GET', 'HEAD']) === false) { + return false; + } + + // disable the pages cache when there's request data + if (empty($request->data()) === false) { + return false; + } + + // disable the pages cache when there are any params + if ($request->params()->isNotEmpty()) { + return false; + } + + // check for a custom ignore rule + if ($ignore instanceof Closure) { + if ($ignore($this) === true) { + return false; + } + } + + // ignore pages by id + if (is_array($ignore) === true) { + if (in_array($this->id(), $ignore) === true) { + return false; + } + } + + return true; + } + + /** + * Checks if the page is a child of the given page + * + * @param \Kirby\Cms\Page|string $parent + */ + public function isChildOf($parent): bool + { + return $this->parent()?->is($parent) ?? false; + } + + /** + * Checks if the page is a descendant of the given page + * + * @param \Kirby\Cms\Page|string $parent + */ + public function isDescendantOf($parent): bool + { + if (is_string($parent) === true) { + $parent = $this->site()->find($parent); + } + + if (!$parent) { + return false; + } + + return $this->parents()->has($parent->id()) === true; + } + + /** + * Checks if the page is a descendant of the currently active page + */ + public function isDescendantOfActive(): bool + { + if ($active = $this->site()->page()) { + return $this->isDescendantOf($active); + } + + return false; + } + + /** + * Checks if the current page is a draft + */ + public function isDraft(): bool + { + return $this->isDraft; + } + + /** + * Checks if the page is the error page + */ + public function isErrorPage(): bool + { + return $this->id() === $this->site()->errorPageId(); + } + + /** + * Checks if the page is the home page + */ + public function isHomePage(): bool + { + return $this->id() === $this->site()->homePageId(); + } + + /** + * It's often required to check for the + * home and error page to stop certain + * actions. That's why there's a shortcut. + */ + public function isHomeOrErrorPage(): bool + { + return $this->isHomePage() === true || $this->isErrorPage() === true; + } + + /** + * Check if the page can be listable by the current user + * This permission depends on the `read` option until v5 + */ + public function isListable(): bool + { + // TODO: remove this check when `read` option deprecated in v5 + if ($this->isReadable() === false) { + return false; + } + + // not accessible also means not listable + if ($this->isAccessible() === false) { + return false; + } + + static $listable = []; + + $template = $this->intendedTemplate()->name(); + + return $listable[$template] ??= $this->permissions()->can('list'); + } + + /** + * Checks if the page has a sorting number + */ + public function isListed(): bool + { + return $this->isPublished() && $this->num() !== null; + } + + public function isMovableTo(Page|Site $parent): bool + { + try { + return PageRules::move($this, $parent); + } catch (Throwable) { + return false; + } + } + + /** + * Checks if the page is open. + * Open pages are either the current one + * or descendants of the current one. + */ + public function isOpen(): bool + { + if ($this->isActive() === true) { + return true; + } + + if ($this->site()->page()?->parents()->has($this->id()) === true) { + return true; + } + + return false; + } + + /** + * Checks if the page is not a draft. + */ + public function isPublished(): bool + { + return $this->isDraft() === false; + } + + /** + * Check if the page can be read by the current user + * @todo Deprecate `read` option in v5 and make the necessary changes for `access` and `list` options. + */ + public function isReadable(): bool + { + static $readable = []; + + $template = $this->intendedTemplate()->name(); + + return $readable[$template] ??= $this->permissions()->can('read'); + } + + /** + * Checks if the page is sortable + */ + public function isSortable(): bool + { + return $this->permissions()->can('sort'); + } + + /** + * Checks if the page has no sorting number + */ + public function isUnlisted(): bool + { + return $this->isPublished() && $this->num() === null; + } + + /** + * Checks if the page access is verified. + * This is only used for drafts so far. + * @internal + */ + public function isVerified(string $token = null): bool + { + if ( + $this->isPublished() === true && + $this->parents()->findBy('status', 'draft') === null + ) { + return true; + } + + if ($token === null) { + return false; + } + + return $this->token() === $token; + } + + /** + * Returns the root to the media folder for the page + * @internal + */ + public function mediaRoot(): string + { + return $this->kirby()->root('media') . '/pages/' . $this->id(); + } + + /** + * The page's base URL for any files + * @internal + */ + public function mediaUrl(): string + { + return $this->kirby()->url('media') . '/pages/' . $this->id(); + } + + /** + * Creates a page model if it has been registered + * @internal + */ + public static function model(string $name, array $props = []): static + { + $class = static::$models[$name] ?? null; + $class ??= static::$models['default'] ?? null; + + if ($class !== null) { + $object = new $class($props); + + if ($object instanceof self) { + return $object; + } + } + + return new static($props); + } + + /** + * Returns the last modification date of the page + */ + public function modified( + string|null $format = null, + string|null $handler = null, + string|null $languageCode = null + ): int|string|false|null { + $identifier = $this->isDraft() === true ? 'changes' : 'published'; + + $modified = $this->storage()->modified( + $identifier, + $languageCode + ); + + if ($modified === null) { + return null; + } + + return Str::date($modified, $format, $handler); + } + + /** + * Returns the sorting number + */ + public function num(): int|null + { + return $this->num; + } + + /** + * Returns the panel info object + */ + public function panel(): Panel + { + return new Panel($this); + } + + /** + * Returns the parent Page object + */ + public function parent(): Page|null + { + return $this->parent; + } + + /** + * Returns the parent id, if a parent exists + * @internal + */ + public function parentId(): string|null + { + return $this->parent()?->id(); + } + + /** + * Returns the parent model, + * which can either be another Page + * or the Site + * @internal + */ + public function parentModel(): Page|Site + { + return $this->parent() ?? $this->site(); + } + + /** + * Returns a list of all parents and their parents recursively + */ + public function parents(): Pages + { + $parents = new Pages(); + $page = $this->parent(); + + while ($page !== null) { + $parents->append($page->id(), $page); + $page = $page->parent(); + } + + return $parents; + } + + /** + * Return the permanent URL to the page using its UUID + * @since 3.8.0 + */ + public function permalink(): string|null + { + return $this->uuid()?->url(); + } + + /** + * Returns the permissions object for this page + */ + public function permissions(): PagePermissions + { + return new PagePermissions($this); + } + + /** + * Draft preview Url + * @internal + */ + public function previewUrl(): string|null + { + $preview = $this->blueprint()->preview(); + + if ($preview === false) { + return null; + } + + $url = match ($preview) { + true => $this->url(), + default => $preview + }; + + if ($this->isDraft() === true) { + $uri = new Uri($url); + $uri->query->token = $this->token(); + + $url = $uri->toString(); + } + + return $url; + } + + /** + * Renders the page with the given data. + * + * An optional content type can be passed to + * render a content representation instead of + * the default template. + * + * @param string $contentType + * @throws \Kirby\Exception\NotFoundException If the default template cannot be found + */ + public function render(array $data = [], $contentType = 'html'): string + { + $kirby = $this->kirby(); + $cache = $cacheId = $html = null; + + // try to get the page from cache + if (empty($data) === true && $this->isCacheable() === true) { + $cache = $kirby->cache('pages'); + $cacheId = $this->cacheId($contentType); + $result = $cache->get($cacheId); + $html = $result['html'] ?? null; + $response = $result['response'] ?? []; + $usesAuth = $result['usesAuth'] ?? false; + $usesCookies = $result['usesCookies'] ?? []; + + // if the request contains dynamic data that the cached response + // relied on, don't use the cache to allow dynamic code to run + if (Responder::isPrivate($usesAuth, $usesCookies) === true) { + $html = null; + } + + // reconstruct the response configuration + if (empty($html) === false && empty($response) === false) { + $kirby->response()->fromArray($response); + } + } + + // fetch the page regularly + if ($html === null) { + if ($contentType === 'html') { + $template = $this->template(); + } else { + $template = $this->representation($contentType); + } + + if ($template->exists() === false) { + throw new NotFoundException([ + 'key' => 'template.default.notFound' + ]); + } + + $kirby->data = $this->controller($data, $contentType); + + // trigger before hook and apply for `data` + $kirby->data = $kirby->apply('page.render:before', [ + 'contentType' => $contentType, + 'data' => $kirby->data, + 'page' => $this + ], 'data'); + + // render the page + $html = $template->render($kirby->data); + + // trigger after hook and apply for `html` + $html = $kirby->apply('page.render:after', [ + 'contentType' => $contentType, + 'data' => $kirby->data, + 'html' => $html, + 'page' => $this + ], 'html'); + + // cache the result + $response = $kirby->response(); + if ($cache !== null && $response->cache() === true) { + $cache->set($cacheId, [ + 'html' => $html, + 'response' => $response->toArray(), + 'usesAuth' => $response->usesAuth(), + 'usesCookies' => $response->usesCookies(), + ], $response->expires() ?? 0); + } + } + + return $html; + } + + /** + * @internal + * @throws \Kirby\Exception\NotFoundException If the content representation cannot be found + */ + public function representation(mixed $type): Template + { + $kirby = $this->kirby(); + $template = $this->template(); + $representation = $kirby->template($template->name(), $type); + + if ($representation->exists() === true) { + return $representation; + } + + throw new NotFoundException('The content representation cannot be found'); + } + + /** + * Returns the absolute root to the page directory + * No matter if it exists or not. + */ + public function root(): string + { + return $this->root ??= $this->kirby()->root('content') . '/' . $this->diruri(); + } + + /** + * Returns the PageRules class instance + * which is being used in various methods + * to check for valid actions and input. + */ + protected function rules(): PageRules + { + return new PageRules(); + } + + /** + * Search all pages within the current page + */ + public function search(string|null $query = null, string|array $params = []): Pages + { + return $this->index()->search($query, $params); + } + + /** + * Sets the Blueprint object + * + * @return $this + */ + protected function setBlueprint(array $blueprint = null): static + { + if ($blueprint !== null) { + $blueprint['model'] = $this; + $this->blueprint = new PageBlueprint($blueprint); + } + + return $this; + } + + /** + * Sets the intended template + * + * @return $this + */ + protected function setTemplate(string $template = null): static + { + if ($template !== null) { + $this->intendedTemplate = $this->kirby()->template($template); + } + + return $this; + } + + /** + * Sets the Url + * + * @return $this + */ + protected function setUrl(string $url = null): static + { + if (is_string($url) === true) { + $url = rtrim($url, '/'); + } + + $this->url = $url; + return $this; + } + + /** + * Returns the slug of the page + */ + public function slug(string $languageCode = null): string + { + if ($this->kirby()->multilang() === true) { + $languageCode ??= $this->kirby()->languageCode(); + $defaultLanguageCode = $this->kirby()->defaultLanguage()->code(); + + if ( + $languageCode !== $defaultLanguageCode && + $translation = $this->translations()->find($languageCode) + ) { + return $translation->slug() ?? $this->slug; + } + } + + return $this->slug; + } + + /** + * Returns the page status, which + * can be `draft`, `listed` or `unlisted` + */ + public function status(): string + { + if ($this->isDraft() === true) { + return 'draft'; + } + + if ($this->isUnlisted() === true) { + return 'unlisted'; + } + + return 'listed'; + } + + /** + * Returns the final template + */ + public function template(): Template + { + if ($this->template !== null) { + return $this->template; + } + + $intended = $this->intendedTemplate(); + + if ($intended->exists() === true) { + return $this->template = $intended; + } + + return $this->template = $this->kirby()->template('default'); + } + + /** + * Returns the title field or the slug as fallback + */ + public function title(): Field + { + return $this->content()->get('title')->or($this->slug()); + } + + /** + * Converts the most important + * properties to array + */ + public function toArray(): array + { + return array_merge(parent::toArray(), [ + 'children' => $this->children()->keys(), + 'files' => $this->files()->keys(), + 'id' => $this->id(), + 'mediaUrl' => $this->mediaUrl(), + 'mediaRoot' => $this->mediaRoot(), + 'num' => $this->num(), + 'parent' => $this->parent()?->id(), + 'slug' => $this->slug(), + 'template' => $this->template(), + 'uid' => $this->uid(), + 'uri' => $this->uri(), + 'url' => $this->url() + ]); + } + + /** + * Returns a verification token, which + * is used for the draft authentication + */ + protected function token(): string + { + return $this->kirby()->contentToken( + $this, + $this->id() . $this->template() + ); + } + + /** + * Returns the UID of the page. + * The UID is basically the same as the + * slug, but stays the same on + * multi-language sites. Whereas the slug + * can be translated. + * + * @see self::slug() + */ + public function uid(): string + { + return $this->slug; + } + + /** + * The uri is the same as the id, except + * that it will be translated in multi-language setups + */ + public function uri(string $languageCode = null): string + { + // set the id, depending on the parent + if ($parent = $this->parent()) { + return $parent->uri($languageCode) . '/' . $this->slug($languageCode); + } + + return $this->slug($languageCode); + } + + /** + * Returns the Url + * + * @param array|string|null $options + */ + public function url($options = null): string + { + if ($this->kirby()->multilang() === true) { + if (is_string($options) === true) { + return $this->urlForLanguage($options); + } + + return $this->urlForLanguage(null, $options); + } + + if ($options !== null) { + return Url::to($this->url(), $options); + } + + if (is_string($this->url) === true) { + return $this->url; + } + + if ($this->isHomePage() === true) { + return $this->url = $this->site()->url(); + } + + if ($parent = $this->parent()) { + if ($parent->isHomePage() === true) { + return $this->url = $this->kirby()->url('base') . '/' . $parent->uid() . '/' . $this->uid(); + } + + return $this->url = $this->parent()->url() . '/' . $this->uid(); + } + + return $this->url = $this->kirby()->url('base') . '/' . $this->uid(); + } + + /** + * Builds the Url for a specific language + * + * @internal + * @param string|null $language + */ + public function urlForLanguage( + $language = null, + array $options = null + ): string { + if ($options !== null) { + return Url::to($this->urlForLanguage($language), $options); + } + + if ($this->isHomePage() === true) { + return $this->url = $this->site()->urlForLanguage($language); + } + + if ($parent = $this->parent()) { + if ($parent->isHomePage() === true) { + return $this->url = $this->site()->urlForLanguage($language) . '/' . $parent->slug($language) . '/' . $this->slug($language); + } + + return $this->url = $this->parent()->urlForLanguage($language) . '/' . $this->slug($language); + } + + return $this->url = $this->site()->urlForLanguage($language) . '/' . $this->slug($language); + } +} diff --git a/kirby/src/Cms/PageActions.php b/kirby/src/Cms/PageActions.php new file mode 100644 index 0000000..e1a71c0 --- /dev/null +++ b/kirby/src/Cms/PageActions.php @@ -0,0 +1,989 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait PageActions +{ + /** + * Adapts necessary modifications which page uuid, page slug and files uuid + * of copy objects for single or multilang environments + * @internal + */ + protected function adaptCopy(Page $copy, bool $files = false, bool $children = false): Page + { + if ($this->kirby()->multilang() === true) { + foreach ($this->kirby()->languages() as $language) { + // overwrite with new UUID for the page and files + // for default language (remove old, add new) + if ( + Uuids::enabled() === true && + $language->isDefault() === true + ) { + $copy = $copy->save(['uuid' => Uuid::generate()], $language->code()); + + // regenerate UUIDs of page files + if ($files !== false) { + foreach ($copy->files() as $file) { + $file->save(['uuid' => Uuid::generate()], $language->code()); + } + } + + // regenerate UUIDs of all page children + if ($children !== false) { + foreach ($copy->index(true) as $child) { + // always adapt files of subpages as they are currently always copied; + // but don't adapt children because we already operate on the index + $this->adaptCopy($child, true); + } + } + } + + // remove all translated slugs + if ( + $language->isDefault() === false && + $copy->translation($language)->exists() === true + ) { + $copy = $copy->save(['slug' => null], $language->code()); + } + } + + return $copy; + } + + // overwrite with new UUID for the page and files (remove old, add new) + if (Uuids::enabled() === true) { + $copy = $copy->save(['uuid' => Uuid::generate()]); + + // regenerate UUIDs of page files + if ($files !== false) { + foreach ($copy->files() as $file) { + $file->save(['uuid' => Uuid::generate()]); + } + } + + // regenerate UUIDs of all page children + if ($children !== false) { + foreach ($copy->index(true) as $child) { + // always adapt files of subpages as they are currently always copied; + // but don't adapt children because we already operate on the index + $this->adaptCopy($child, true); + } + } + } + + return $copy; + } + + /** + * Changes the sorting number. + * The sorting number must already be correct + * when the method is called. + * This only affects this page, + * siblings will not be resorted. + * + * @return $this|static + * @throws \Kirby\Exception\LogicException If a draft is being sorted or the directory cannot be moved + */ + public function changeNum(int|null $num = null): static + { + if ($this->isDraft() === true) { + throw new LogicException('Drafts cannot change their sorting number'); + } + + // don't run the action if everything stayed the same + if ($this->num() === $num) { + return $this; + } + + return $this->commit('changeNum', ['page' => $this, 'num' => $num], function ($oldPage, $num) { + $newPage = $oldPage->clone([ + 'num' => $num, + 'dirname' => null, + 'root' => null + ]); + + // actually move the page on disk + if ($oldPage->exists() === true) { + if (Dir::move($oldPage->root(), $newPage->root()) === true) { + // Updates the root path of the old page with the root path + // of the moved new page to use fly actions on old page in loop + $oldPage->root = $newPage->root(); + } else { + throw new LogicException('The page directory cannot be moved'); + } + } + + // overwrite the child in the parent page + static::updateParentCollections($newPage, 'set'); + + return $newPage; + }); + } + + /** + * Changes the slug/uid of the page + * + * @return $this|static + * @throws \Kirby\Exception\LogicException If the directory cannot be moved + */ + public function changeSlug( + string $slug, + string|null $languageCode = null + ): static { + // always sanitize the slug + $slug = Str::slug($slug); + + // in multi-language installations the slug for the non-default + // languages is stored in the text file. The changeSlugForLanguage + // method takes care of that. + if ($this->kirby()->language($languageCode)?->isDefault() === false) { + return $this->changeSlugForLanguage($slug, $languageCode); + } + + // if the slug stays exactly the same, + // nothing needs to be done. + if ($slug === $this->slug()) { + return $this; + } + + $arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => null]; + return $this->commit('changeSlug', $arguments, function ($oldPage, $slug) { + $newPage = $oldPage->clone([ + 'slug' => $slug, + 'dirname' => null, + 'root' => null + ]); + + // clear UUID cache recursively (for children and files as well) + $oldPage->uuid()?->clear(true); + + if ($oldPage->exists() === true) { + // remove the lock of the old page + $oldPage->lock()?->remove(); + + // actually move stuff on disk + if (Dir::move($oldPage->root(), $newPage->root()) !== true) { + throw new LogicException('The page directory cannot be moved'); + } + + // remove from the siblings + static::updateParentCollections($oldPage, 'remove'); + + Dir::remove($oldPage->mediaRoot()); + } + + // overwrite the new page in the parent collection + static::updateParentCollections($newPage, 'set'); + + return $newPage; + }); + } + + /** + * Change the slug for a specific language + * + * @throws \Kirby\Exception\NotFoundException If the language for the given language code cannot be found + * @throws \Kirby\Exception\InvalidArgumentException If the slug for the default language is being changed + */ + protected function changeSlugForLanguage( + string $slug, + string|null $languageCode = null + ): static { + $language = $this->kirby()->language($languageCode); + + if (!$language) { + throw new NotFoundException('The language: "' . $languageCode . '" does not exist'); + } + + if ($language->isDefault() === true) { + throw new InvalidArgumentException('Use the changeSlug method to change the slug for the default language'); + } + + $arguments = ['page' => $this, 'slug' => $slug, 'languageCode' => $language->code()]; + return $this->commit('changeSlug', $arguments, function ($page, $slug, $languageCode) { + // remove the slug if it's the same as the folder name + if ($slug === $page->uid()) { + $slug = null; + } + + $newPage = $page->save(['slug' => $slug], $languageCode); + + // overwrite the updated page in the parent collection + static::updateParentCollections($newPage, 'set'); + + return $newPage; + }); + } + + /** + * Change the status of the current page + * to either draft, listed or unlisted. + * If changing to `listed`, you can pass a position for the + * page in the siblings collection. Siblings will be resorted. + * + * @param string $status "draft", "listed" or "unlisted" + * @param int|null $position Optional sorting number + * @throws \Kirby\Exception\InvalidArgumentException If an invalid status is being passed + */ + public function changeStatus(string $status, int|null $position = null): static + { + return match ($status) { + 'draft' => $this->changeStatusToDraft(), + 'listed' => $this->changeStatusToListed($position), + 'unlisted' => $this->changeStatusToUnlisted(), + default => throw new InvalidArgumentException('Invalid status: ' . $status) + }; + } + + protected function changeStatusToDraft(): static + { + $arguments = ['page' => $this, 'status' => 'draft', 'position' => null]; + $page = $this->commit( + 'changeStatus', + $arguments, + fn ($page) => $page->unpublish() + ); + + return $page; + } + + /** + * @return $this|static + */ + protected function changeStatusToListed(int|null $position = null): static + { + // create a sorting number for the page + $num = $this->createNum($position); + + // don't sort if not necessary + if ($this->status() === 'listed' && $num === $this->num()) { + return $this; + } + + $arguments = ['page' => $this, 'status' => 'listed', 'position' => $num]; + $page = $this->commit('changeStatus', $arguments, function ($page, $status, $position) { + return $page->publish()->changeNum($position); + }); + + if ($this->blueprint()->num() === 'default') { + $page->resortSiblingsAfterListing($num); + } + + return $page; + } + + /** + * @return $this|static + */ + protected function changeStatusToUnlisted(): static + { + if ($this->status() === 'unlisted') { + return $this; + } + + $arguments = ['page' => $this, 'status' => 'unlisted', 'position' => null]; + $page = $this->commit('changeStatus', $arguments, function ($page) { + return $page->publish()->changeNum(null); + }); + + $this->resortSiblingsAfterUnlisting(); + + return $page; + } + + /** + * Change the position of the page in its siblings + * collection. Siblings will be resorted. If the page + * status isn't yet `listed`, it will be changed to it. + * + * @return $this|static + */ + public function changeSort(int|null $position = null): static + { + return $this->changeStatus('listed', $position); + } + + /** + * Changes the page template + * + * @return $this|static + * @throws \Kirby\Exception\LogicException If the textfile cannot be renamed/moved + */ + public function changeTemplate(string $template): static + { + if ($template === $this->intendedTemplate()->name()) { + return $this; + } + + return $this->commit('changeTemplate', ['page' => $this, 'template' => $template], function ($oldPage, $template) { + // convert for new template/blueprint + $page = $oldPage->convertTo($template); + + // update the parent collection + static::updateParentCollections($page, 'set'); + + return $page; + }); + } + + /** + * Change the page title + */ + public function changeTitle( + string $title, + string|null $languageCode = null + ): static { + $arguments = ['page' => $this, 'title' => $title, 'languageCode' => $languageCode]; + return $this->commit('changeTitle', $arguments, function ($page, $title, $languageCode) { + $page = $page->save(['title' => $title], $languageCode); + + // flush the parent cache to get children and drafts right + static::updateParentCollections($page, 'set'); + + return $page; + }); + } + + /** + * Commits a page action, by following these steps + * + * 1. checks the action rules + * 2. sends the before hook + * 3. commits the store action + * 4. sends the after hook + * 5. returns the result + */ + protected function commit( + string $action, + array $arguments, + Closure $callback + ): mixed { + $old = $this->hardcopy(); + $kirby = $this->kirby(); + $argumentValues = array_values($arguments); + + $this->rules()->$action(...$argumentValues); + $kirby->trigger('page.' . $action . ':before', $arguments); + + $result = $callback(...$argumentValues); + + if ($action === 'create') { + $argumentsAfter = ['page' => $result]; + } elseif ($action === 'duplicate') { + $argumentsAfter = ['duplicatePage' => $result, 'originalPage' => $old]; + } elseif ($action === 'delete') { + $argumentsAfter = ['status' => $result, 'page' => $old]; + } else { + $argumentsAfter = ['newPage' => $result, 'oldPage' => $old]; + } + $kirby->trigger('page.' . $action . ':after', $argumentsAfter); + + $kirby->cache('pages')->flush(); + return $result; + } + + /** + * Copies the page to a new parent + * + * @throws \Kirby\Exception\DuplicateException If the page already exists + */ + public function copy(array $options = []): static + { + $slug = $options['slug'] ?? $this->slug(); + $isDraft = $options['isDraft'] ?? $this->isDraft(); + $parent = $options['parent'] ?? null; + $parentModel = $options['parent'] ?? $this->site(); + $num = $options['num'] ?? null; + $children = $options['children'] ?? false; + $files = $options['files'] ?? false; + + // clean up the slug + $slug = Str::slug($slug); + + if ($parentModel->findPageOrDraft($slug)) { + throw new DuplicateException([ + 'key' => 'page.duplicate', + 'data' => [ + 'slug' => $slug + ] + ]); + } + + $tmp = new static([ + 'isDraft' => $isDraft, + 'num' => $num, + 'parent' => $parent, + 'slug' => $slug, + ]); + + $ignore = [ + $this->kirby()->locks()->file($this) + ]; + + // don't copy files + if ($files === false) { + foreach ($this->files() as $file) { + $ignore[] = $file->root(); + + // append all content files + array_push($ignore, ...$file->storage()->contentFiles('published')); + array_push($ignore, ...$file->storage()->contentFiles('changes')); + } + } + + Dir::copy($this->root(), $tmp->root(), $children, $ignore); + + $copy = $parentModel->clone()->findPageOrDraft($slug); + + // normalize copy object + $copy = $this->adaptCopy($copy, $files, $children); + + // add copy to siblings + static::updateParentCollections($copy, 'append', $parentModel); + + return $copy; + } + + /** + * Creates and stores a new page + */ + public static function create(array $props): Page + { + // clean up the slug + $props['slug'] = Str::slug($props['slug'] ?? $props['content']['title'] ?? null); + $props['template'] = $props['model'] = strtolower($props['template'] ?? 'default'); + $props['isDraft'] ??= $props['draft'] ?? true; + + // make sure that a UUID gets generated and + // added to content right away + $props['content'] ??= []; + + if (Uuids::enabled() === true) { + $props['content']['uuid'] ??= Uuid::generate(); + } + + // create a temporary page object + $page = Page::factory($props); + + // always create pages in the default language + if ($page->kirby()->multilang() === true) { + $languageCode = $page->kirby()->defaultLanguage()->code(); + } else { + $languageCode = null; + } + + // create a form for the page + // use always default language to fill form with default values + $form = Form::for( + $page, + [ + 'language' => $languageCode, + 'values' => $props['content'] + ] + ); + + // inject the content + $page = $page->clone(['content' => $form->strings(true)]); + + // run the hooks and creation action + $page = $page->commit( + 'create', + [ + 'page' => $page, + 'input' => $props + ], + function ($page, $props) use ($languageCode) { + // write the content file + $page = $page->save($page->content()->toArray(), $languageCode); + + // flush the parent cache to get children and drafts right + static::updateParentCollections($page, 'append'); + + return $page; + } + ); + + // publish the new page if a number is given + if (isset($props['num']) === true) { + $page = $page->changeStatus('listed', $props['num']); + } + + return $page; + } + + /** + * Creates a child of the current page + */ + public function createChild(array $props): Page + { + $props = array_merge($props, [ + 'url' => null, + 'num' => null, + 'parent' => $this, + 'site' => $this->site(), + ]); + + $modelClass = Page::$models[$props['template'] ?? null] ?? Page::class; + return $modelClass::create($props); + } + + /** + * Create the sorting number for the page + * depending on the blueprint settings + */ + public function createNum(int $num = null): int + { + $mode = $this->blueprint()->num(); + + switch ($mode) { + case 'zero': + return 0; + case 'date': + case 'datetime': + // the $format needs to produce only digits, + // so it can be converted to integer below + $format = $mode === 'date' ? 'Ymd' : 'YmdHi'; + $lang = $this->kirby()->defaultLanguage() ?? null; + $field = $this->content($lang)->get('date'); + $date = $field->isEmpty() ? 'now' : $field; + return (int)date($format, strtotime($date)); + case 'default': + + $max = $this + ->parentModel() + ->children() + ->listed() + ->merge($this) + ->count(); + + // default positioning at the end + $num ??= $max; + + // avoid zeros or negative numbers + if ($num < 1) { + return 1; + } + + // avoid higher numbers than possible + if ($num > $max) { + return $max; + } + + return $num; + default: + // get instance with default language + $app = $this->kirby()->clone([], false); + $app->setCurrentLanguage(); + + $template = Str::template($mode, [ + 'kirby' => $app, + 'page' => $app->page($this->id()), + 'site' => $app->site(), + ], ['fallback' => '']); + + return (int)$template; + } + } + + /** + * Deletes the page + */ + public function delete(bool $force = false): bool + { + return $this->commit('delete', ['page' => $this, 'force' => $force], function ($page, $force) { + // clear UUID cache + $page->uuid()?->clear(); + + // delete all files individually + foreach ($page->files() as $file) { + $file->delete(); + } + + // delete all children individually + foreach ($page->children() as $child) { + $child->delete(true); + } + + // actually remove the page from disc + if ($page->exists() === true) { + // delete all public media files + Dir::remove($page->mediaRoot()); + + // delete the content folder for this page + Dir::remove($page->root()); + + // if the page is a draft and the _drafts folder + // is now empty. clean it up. + if ($page->isDraft() === true) { + $draftsDir = dirname($page->root()); + + if (Dir::isEmpty($draftsDir) === true) { + Dir::remove($draftsDir); + } + } + } + + static::updateParentCollections($page, 'remove'); + + if ($page->isDraft() === false) { + $page->resortSiblingsAfterUnlisting(); + } + + return true; + }); + } + + /** + * Duplicates the page with the given + * slug and optionally copies all files + */ + public function duplicate(string|null $slug = null, array $options = []): static + { + // create the slug for the duplicate + $slug = Str::slug($slug ?? $this->slug() . '-' . Str::slug(I18n::translate('page.duplicate.appendix'))); + + $arguments = [ + 'originalPage' => $this, + 'input' => $slug, + 'options' => $options + ]; + + return $this->commit('duplicate', $arguments, function ($page, $slug, $options) { + $page = $this->copy([ + 'parent' => $this->parent(), + 'slug' => $slug, + 'isDraft' => true, + 'files' => $options['files'] ?? false, + 'children' => $options['children'] ?? false, + ]); + + if (isset($options['title']) === true) { + $page = $page->changeTitle($options['title']); + } + + return $page; + }); + } + + /** + * Moves the page to a new parent if the + * new parent accepts the page type + */ + public function move(Site|Page $parent): Page + { + // nothing to move + if ($this->parentModel()->is($parent) === true) { + return $this; + } + + $arguments = [ + 'page' => $this, + 'parent' => $parent + ]; + + return $this->commit('move', $arguments, function ($page, $parent) { + // remove the uuid cache for this page + $page->uuid()?->clear(true); + + // move drafts into the drafts folder of the parent + if ($page->isDraft() === true) { + $newRoot = $parent->root() . '/_drafts/' . $page->dirname(); + } else { + $newRoot = $parent->root() . '/' . $page->dirname(); + } + + // try to move the page directory on disk + if (Dir::move($page->root(), $newRoot) !== true) { + throw new LogicException([ + 'key' => 'page.move.directory' + ]); + } + + // flush all collection caches to be sure that + // the new child is included afterwards + $parent->purge(); + + // double-check if the new child can actually be found + if (!$newPage = $parent->childrenAndDrafts()->find($page->slug())) { + throw new LogicException([ + 'key' => 'page.move.notFound' + ]); + } + + return $newPage; + }); + } + + /** + * @return $this|static + * @throws \Kirby\Exception\LogicException If the folder cannot be moved + */ + public function publish(): static + { + if ($this->isDraft() === false) { + return $this; + } + + $page = $this->clone([ + 'isDraft' => false, + 'root' => null + ]); + + // actually do it on disk + if ($this->exists() === true) { + if (Dir::move($this->root(), $page->root()) !== true) { + throw new LogicException('The draft folder cannot be moved'); + } + + // Get the draft folder and check if there are any other drafts + // left. Otherwise delete it. + $draftDir = dirname($this->root()); + + if (Dir::isEmpty($draftDir) === true) { + Dir::remove($draftDir); + } + } + + // remove the page from the parent drafts and add it to children + $parentModel = $page->parentModel(); + $parentModel->drafts()->remove($page); + $parentModel->children()->append($page->id(), $page); + + // update the childrenAndDrafts() cache if it is initialized + if ($parentModel->childrenAndDrafts !== null) { + $parentModel->childrenAndDrafts()->set($page->id(), $page); + } + + return $page; + } + + /** + * Clean internal caches + * + * @return $this + */ + public function purge(): static + { + parent::purge(); + + $this->blueprint = null; + $this->children = null; + $this->childrenAndDrafts = null; + $this->drafts = null; + $this->files = null; + $this->inventory = null; + + return $this; + } + + /** + * @throws \Kirby\Exception\LogicException If the page is not included in the siblings collection + */ + protected function resortSiblingsAfterListing(int $position = null): bool + { + // get all siblings including the current page + $siblings = $this + ->parentModel() + ->children() + ->listed() + ->append($this) + ->filter(fn ($page) => $page->blueprint()->num() === 'default'); + + // get a non-associative array of ids + $keys = $siblings->keys(); + $index = array_search($this->id(), $keys); + + // if the page is not included in the siblings something went wrong + if ($index === false) { + throw new LogicException('The page is not included in the sorting index'); + } + + if ($position > count($keys)) { + $position = count($keys); + } + + // move the current page number in the array of keys + // subtract 1 from the num and the position, because of the + // zero-based array keys + $sorted = A::move($keys, $index, $position - 1); + + foreach ($sorted as $key => $id) { + if ($id === $this->id()) { + continue; + } + + $siblings->get($id)?->changeNum($key + 1); + } + + $parent = $this->parentModel(); + $parent->children = $parent->children()->sort('num', 'asc'); + $parent->childrenAndDrafts = null; + + return true; + } + + /** + * @internal + */ + public function resortSiblingsAfterUnlisting(): bool + { + $index = 0; + $parent = $this->parentModel(); + $siblings = $parent + ->children() + ->listed() + ->not($this) + ->filter(fn ($page) => $page->blueprint()->num() === 'default'); + + if ($siblings->count() > 0) { + foreach ($siblings as $sibling) { + $index++; + $sibling->changeNum($index); + } + + $parent->children = $siblings->sort('num', 'asc'); + $parent->childrenAndDrafts = null; + } + + return true; + } + + /** + * Stores the content on disk + * @internal + */ + public function save( + array|null $data = null, + string|null $languageCode = null, + bool $overwrite = false + ): static { + $page = parent::save($data, $languageCode, $overwrite); + + // overwrite the updated page in the parent collection + static::updateParentCollections($page, 'set'); + + return $page; + } + + /** + * Convert a page from listed or + * unlisted to draft. + * + * @return $this|static + * @throws \Kirby\Exception\LogicException If the folder cannot be moved + */ + public function unpublish(): static + { + if ($this->isDraft() === true) { + return $this; + } + + $page = $this->clone([ + 'isDraft' => true, + 'num' => null, + 'dirname' => null, + 'root' => null + ]); + + // actually do it on disk + if ($this->exists() === true) { + if (Dir::move($this->root(), $page->root()) !== true) { + throw new LogicException('The page folder cannot be moved to drafts'); + } + } + + // remove the page from the parent children and add it to drafts + $parentModel = $page->parentModel(); + $parentModel->children()->remove($page); + $parentModel->drafts()->append($page->id(), $page); + + // update the childrenAndDrafts() cache if it is initialized + if ($parentModel->childrenAndDrafts !== null) { + $parentModel->childrenAndDrafts()->set($page->id(), $page); + } + + $page->resortSiblingsAfterUnlisting(); + + return $page; + } + + /** + * Updates the page data + */ + public function update( + array|null $input = null, + string|null $languageCode = null, + bool $validate = false + ): static { + if ($this->isDraft() === true) { + $validate = false; + } + + $page = parent::update($input, $languageCode, $validate); + + // if num is created from page content, update num on content update + if ( + $page->isListed() === true && + in_array($page->blueprint()->num(), ['zero', 'default']) === false + ) { + $page = $page->changeNum($page->createNum()); + } + + // overwrite the updated page in the parent collection + static::updateParentCollections($page, 'set'); + + return $page; + } + + /** + * Updates parent collections with the new page object + * after a page action + * + * @param \Kirby\Cms\Page $page + * @param string $method Method to call on the parent collections + * @param \Kirby\Cms\Page|null $parentMdel + */ + protected static function updateParentCollections( + $page, + string $method, + $parentModel = null + ): void { + $parentModel ??= $page->parentModel(); + + // method arguments depending on the called method + $args = $method === 'remove' ? [$page] : [$page->id(), $page]; + + if ($page->isDraft() === true) { + $parentModel->drafts()->$method(...$args); + } else { + $parentModel->children()->$method(...$args); + } + + // update the childrenAndDrafts() cache if it is initialized + if ($parentModel->childrenAndDrafts !== null) { + $parentModel->childrenAndDrafts()->$method(...$args); + } + } +} diff --git a/kirby/src/Cms/PageBlueprint.php b/kirby/src/Cms/PageBlueprint.php new file mode 100644 index 0000000..79be695 --- /dev/null +++ b/kirby/src/Cms/PageBlueprint.php @@ -0,0 +1,193 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PageBlueprint extends Blueprint +{ + /** + * Creates a new page blueprint object + * with the given props + */ + public function __construct(array $props) + { + parent::__construct($props); + + // normalize all available page options + $this->props['options'] = $this->normalizeOptions( + $this->props['options'] ?? true, + // defaults + [ + 'access' => null, + 'changeSlug' => null, + 'changeStatus' => null, + 'changeTemplate' => null, + 'changeTitle' => null, + 'create' => null, + 'delete' => null, + 'duplicate' => null, + 'list' => null, + 'move' => null, + 'preview' => null, + 'read' => null, + 'sort' => null, + 'update' => null, + ], + // aliases (from v2) + [ + 'status' => 'changeStatus', + 'template' => 'changeTemplate', + 'title' => 'changeTitle', + 'url' => 'changeSlug', + ] + ); + + // normalize the ordering number + $this->props['num'] = $this->normalizeNum($this->props['num'] ?? 'default'); + + // normalize the available status array + $this->props['status'] = $this->normalizeStatus($this->props['status'] ?? null); + } + + /** + * Returns the page numbering mode + */ + public function num(): string + { + return $this->props['num']; + } + + /** + * Normalizes the ordering number + * + * @param mixed $num + */ + protected function normalizeNum($num): string + { + $aliases = [ + '0' => 'zero', + 'sort' => 'default', + ]; + + return $aliases[$num] ?? $num; + } + + /** + * Normalizes the available status options for the page + * + * @param mixed $status + */ + protected function normalizeStatus($status): array + { + $defaults = [ + 'draft' => [ + 'label' => $this->i18n('page.status.draft'), + 'text' => $this->i18n('page.status.draft.description'), + ], + 'unlisted' => [ + 'label' => $this->i18n('page.status.unlisted'), + 'text' => $this->i18n('page.status.unlisted.description'), + ], + 'listed' => [ + 'label' => $this->i18n('page.status.listed'), + 'text' => $this->i18n('page.status.listed.description'), + ] + ]; + + // use the defaults, when the status is not defined + if (empty($status) === true) { + $status = $defaults; + } + + // extend the status definition + $status = $this->extend($status); + + // clean up and translate each status + foreach ($status as $key => $options) { + // skip invalid status definitions + if (in_array($key, ['draft', 'listed', 'unlisted']) === false || $options === false) { + unset($status[$key]); + continue; + } + + if ($options === true) { + $status[$key] = $defaults[$key]; + continue; + } + + // convert everything to a simple array + if (is_array($options) === false) { + $status[$key] = [ + 'label' => $options, + 'text' => null + ]; + } + + // always make sure to have a proper label + if (empty($status[$key]['label']) === true) { + $status[$key]['label'] = $defaults[$key]['label']; + } + + // also make sure to have the text field set + $status[$key]['text'] ??= null; + + // translate text and label if necessary + $status[$key]['label'] = $this->i18n($status[$key]['label'], $status[$key]['label']); + $status[$key]['text'] = $this->i18n($status[$key]['text'], $status[$key]['text']); + } + + // the draft status is required + if (isset($status['draft']) === false) { + $status = ['draft' => $defaults['draft']] + $status; + } + + // remove the draft status for the home and error pages + if ($this->model->isHomeOrErrorPage() === true) { + unset($status['draft']); + } + + return $status; + } + + /** + * Returns the options object + * that handles page options and permissions + */ + public function options(): array + { + return $this->props['options']; + } + + /** + * Returns the preview settings + * The preview setting controls the "Open" + * button in the panel and redirects it to a + * different URL if necessary. + */ + public function preview(): string|bool + { + $preview = $this->props['options']['preview'] ?? true; + + if (is_string($preview) === true) { + return $this->model->toString($preview); + } + + return $preview; + } + + /** + * Returns the status array + */ + public function status(): array + { + return $this->props['status']; + } +} diff --git a/kirby/src/Cms/PagePermissions.php b/kirby/src/Cms/PagePermissions.php new file mode 100644 index 0000000..b4ca118 --- /dev/null +++ b/kirby/src/Cms/PagePermissions.php @@ -0,0 +1,67 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PagePermissions extends ModelPermissions +{ + protected string $category = 'pages'; + + protected function canChangeSlug(): bool + { + return $this->model->isHomeOrErrorPage() !== true; + } + + protected function canChangeStatus(): bool + { + return $this->model->isErrorPage() !== true; + } + + protected function canChangeTemplate(): bool + { + if ($this->model->isErrorPage() === true) { + return false; + } + + if (count($this->model->blueprints()) <= 1) { + return false; + } + + return true; + } + + protected function canDelete(): bool + { + return $this->model->isHomeOrErrorPage() !== true; + } + + protected function canMove(): bool + { + return $this->model->isHomeOrErrorPage() !== true; + } + + protected function canSort(): bool + { + if ($this->model->isErrorPage() === true) { + return false; + } + + if ($this->model->isListed() !== true) { + return false; + } + + if ($this->model->blueprint()->num() !== 'default') { + return false; + } + + return true; + } +} diff --git a/kirby/src/Cms/PagePicker.php b/kirby/src/Cms/PagePicker.php new file mode 100644 index 0000000..f84cc55 --- /dev/null +++ b/kirby/src/Cms/PagePicker.php @@ -0,0 +1,224 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PagePicker extends Picker +{ + // TODO: null only due to our Properties setters, + // remove once our implementation is better + protected Pages|null $items = null; + protected Pages|null $itemsForQuery = null; + protected Page|Site|null $parent; + + /** + * Extends the basic defaults + */ + public function defaults(): array + { + return array_merge(parent::defaults(), [ + // Page ID of the selected parent. Used to navigate + 'parent' => null, + // enable/disable subpage navigation + 'subpages' => true, + ]); + } + + /** + * Returns the parent model object that + * is currently selected in the page picker. + * It normally starts at the site, but can + * also be any subpage. When a query is given + * and subpage navigation is deactivated, + * there will be no model available at all. + */ + public function model(): Page|Site|null + { + // no subpages navigation = no model + if ($this->options['subpages'] === false) { + return null; + } + + // the model for queries is a bit more tricky to find + if (empty($this->options['query']) === false) { + return $this->modelForQuery(); + } + + return $this->parent(); + } + + /** + * Returns a model object for the given + * query, depending on the parent and subpages + * options. + */ + public function modelForQuery(): Page|Site|null + { + if ($this->options['subpages'] === true && empty($this->options['parent']) === false) { + return $this->parent(); + } + + return $this->items()?->parent(); + } + + /** + * Returns basic information about the + * parent model that is currently selected + * in the page picker. + */ + public function modelToArray(Page|Site $model = null): array|null + { + if ($model === null) { + return null; + } + + // the selected model is the site. there's nothing above + if ($model instanceof Site) { + return [ + 'id' => null, + 'parent' => null, + 'title' => $model->title()->value() + ]; + } + + // the top-most page has been reached + // the missing id indicates that there's nothing above + if ($model->id() === $this->start()->id()) { + return [ + 'id' => null, + 'parent' => null, + 'title' => $model->title()->value() + ]; + } + + // the model is a regular page + return [ + 'id' => $model->id(), + 'parent' => $model->parentModel()->id(), + 'title' => $model->title()->value() + ]; + } + + /** + * Search all pages for the picker + */ + public function items(): Pages|null + { + // cache + if ($this->items !== null) { + return $this->items; + } + + // no query? simple parent-based search for pages + if (empty($this->options['query']) === true) { + $items = $this->itemsForParent(); + + // when subpage navigation is enabled, a parent + // might be passed in addition to the query. + // The parent then takes priority. + } elseif ($this->options['subpages'] === true && empty($this->options['parent']) === false) { + $items = $this->itemsForParent(); + + // search by query + } else { + $items = $this->itemsForQuery(); + } + + // filter protected and hidden pages + $items = $items->filter('isListable', true); + + // search + $items = $this->search($items); + + // paginate the result + return $this->items = $this->paginate($items); + } + + /** + * Search for pages by parent + */ + public function itemsForParent(): Pages + { + return $this->parent()->children(); + } + + /** + * Search for pages by query string + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function itemsForQuery(): Pages + { + // cache + if ($this->itemsForQuery !== null) { + return $this->itemsForQuery; + } + + $model = $this->options['model']; + $items = $model->query($this->options['query']); + + // help mitigate some typical query usage issues + // by converting site and page objects to proper + // pages by returning their children + $items = match (true) { + $items instanceof Site, + $items instanceof Page => $items->children(), + $items instanceof Pages => $items, + + default => throw new InvalidArgumentException('Your query must return a set of pages') + }; + + return $this->itemsForQuery = $items; + } + + /** + * Returns the parent model. + * The model will be used to fetch + * subpages unless there's a specific + * query to find pages instead. + */ + public function parent(): Page|Site + { + return $this->parent ??= $this->kirby->page($this->options['parent']) ?? $this->site; + } + + /** + * Calculates the top-most model (page or site) + * that can be accessed when navigating + * through pages. + */ + public function start(): Page|Site + { + if (empty($this->options['query']) === false) { + return $this->itemsForQuery()?->parent() ?? $this->site; + } + + return $this->site; + } + + /** + * Returns an associative array + * with all information for the picker. + * This will be passed directly to the API. + */ + public function toArray(): array + { + $array = parent::toArray(); + $array['model'] = $this->modelToArray($this->model()); + + return $array; + } +} diff --git a/kirby/src/Cms/PageRules.php b/kirby/src/Cms/PageRules.php new file mode 100644 index 0000000..b981058 --- /dev/null +++ b/kirby/src/Cms/PageRules.php @@ -0,0 +1,518 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PageRules +{ + /** + * Validates if the sorting number of the page can be changed + * + * @throws \Kirby\Exception\InvalidArgumentException If the given number is invalid + */ + public static function changeNum(Page $page, int $num = null): bool + { + if ($num !== null && $num < 0) { + throw new InvalidArgumentException(['key' => 'page.num.invalid']); + } + + return true; + } + + /** + * Validates if the slug for the page can be changed + * + * @throws \Kirby\Exception\DuplicateException If a page with this slug already exists + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the slug + */ + public static function changeSlug(Page $page, string $slug): bool + { + if ($page->permissions()->changeSlug() !== true) { + throw new PermissionException([ + 'key' => 'page.changeSlug.permission', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + self::validateSlugLength($slug); + self::validateSlugProtectedPaths($page, $slug); + + $siblings = $page->parentModel()->children(); + $drafts = $page->parentModel()->drafts(); + + if ($siblings->find($slug)?->is($page) === false) { + throw new DuplicateException([ + 'key' => 'page.duplicate', + 'data' => [ + 'slug' => $slug + ] + ]); + } + + if ($drafts->find($slug)?->is($page) === false) { + throw new DuplicateException([ + 'key' => 'page.draft.duplicate', + 'data' => [ + 'slug' => $slug + ] + ]); + } + + return true; + } + + /** + * Validates if the status for the page can be changed + * + * @throws \Kirby\Exception\InvalidArgumentException If the given status is invalid + */ + public static function changeStatus( + Page $page, + string $status, + int $position = null + ): bool { + if (isset($page->blueprint()->status()[$status]) === false) { + throw new InvalidArgumentException(['key' => 'page.status.invalid']); + } + + return match ($status) { + 'draft' => static::changeStatusToDraft($page), + 'listed' => static::changeStatusToListed($page, $position), + 'unlisted' => static::changeStatusToUnlisted($page), + default => throw new InvalidArgumentException(['key' => 'page.status.invalid']) + }; + } + + /** + * Validates if a page can be converted to a draft + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status or the page cannot be converted to a draft + */ + public static function changeStatusToDraft(Page $page): bool + { + if ($page->permissions()->changeStatus() !== true) { + throw new PermissionException([ + 'key' => 'page.changeStatus.permission', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + if ($page->isHomeOrErrorPage() === true) { + throw new PermissionException([ + 'key' => 'page.changeStatus.toDraft.invalid', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + return true; + } + + /** + * Validates if the status of a page can be changed to listed + * + * @throws \Kirby\Exception\InvalidArgumentException If the given position is invalid + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status or the status for the page cannot be changed by any user + */ + public static function changeStatusToListed(Page $page, int $position): bool + { + // no need to check for status changing permissions, + // instead we need to check for sorting permissions + if ($page->isListed() === true) { + if ($page->isSortable() !== true) { + throw new PermissionException([ + 'key' => 'page.sort.permission', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + return true; + } + + static::publish($page); + + if ($position !== null && $position < 0) { + throw new InvalidArgumentException(['key' => 'page.num.invalid']); + } + + return true; + } + + /** + * Validates if the status of a page can be changed to unlisted + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the status + */ + public static function changeStatusToUnlisted(Page $page) + { + static::publish($page); + + return true; + } + + /** + * Validates if the template of the page can be changed + * + * @throws \Kirby\Exception\LogicException If the template of the page cannot be changed at all + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the template + */ + public static function changeTemplate(Page $page, string $template): bool + { + if ($page->permissions()->changeTemplate() !== true) { + throw new PermissionException([ + 'key' => 'page.changeTemplate.permission', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + $blueprints = $page->blueprints(); + + if ( + count($blueprints) <= 1 || + in_array($template, array_column($blueprints, 'name')) === false + ) { + throw new LogicException([ + 'key' => 'page.changeTemplate.invalid', + 'data' => ['slug' => $page->slug()] + ]); + } + + return true; + } + + /** + * Validates if the title of the page can be changed + * + * @throws \Kirby\Exception\InvalidArgumentException If the new title is empty + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the title + */ + public static function changeTitle(Page $page, string $title): bool + { + if ($page->permissions()->changeTitle() !== true) { + throw new PermissionException([ + 'key' => 'page.changeTitle.permission', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + static::validateTitleLength($title); + + return true; + } + + /** + * Validates if the page can be created + * + * @throws \Kirby\Exception\DuplicateException If the same page or a draft already exists + * @throws \Kirby\Exception\InvalidArgumentException If the slug is invalid + * @throws \Kirby\Exception\PermissionException If the user is not allowed to create this page + */ + public static function create(Page $page): bool + { + if ($page->permissions()->create() !== true) { + throw new PermissionException([ + 'key' => 'page.create.permission', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + self::validateSlugLength($page->slug()); + self::validateSlugProtectedPaths($page, $page->slug()); + + if ($page->exists() === true) { + throw new DuplicateException([ + 'key' => 'page.draft.duplicate', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + $siblings = $page->parentModel()->children(); + $drafts = $page->parentModel()->drafts(); + $slug = $page->slug(); + + if ($siblings->find($slug)) { + throw new DuplicateException([ + 'key' => 'page.duplicate', + 'data' => ['slug' => $slug] + ]); + } + + if ($drafts->find($slug)) { + throw new DuplicateException([ + 'key' => 'page.draft.duplicate', + 'data' => ['slug' => $slug] + ]); + } + + return true; + } + + /** + * Validates if the page can be deleted + * + * @throws \Kirby\Exception\LogicException If the page has children and should not be force-deleted + * @throws \Kirby\Exception\PermissionException If the user is not allowed to delete the page + */ + public static function delete(Page $page, bool $force = false): bool + { + if ($page->permissions()->delete() !== true) { + throw new PermissionException([ + 'key' => 'page.delete.permission', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + if (($page->hasChildren() === true || $page->hasDrafts() === true) && $force === false) { + throw new LogicException(['key' => 'page.delete.hasChildren']); + } + + return true; + } + + /** + * Validates if the page can be duplicated + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to duplicate the page + */ + public static function duplicate( + Page $page, + string $slug, + array $options = [] + ): bool { + if ($page->permissions()->duplicate() !== true) { + throw new PermissionException([ + 'key' => 'page.duplicate.permission', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + self::validateSlugLength($slug); + + return true; + } + + /** + * Check if the page can be moved + * to the given parent + */ + public static function move(Page $page, Site|Page $parent): bool + { + // if nothing changes, there's no need for checks + if ($parent->is($page->parent()) === true) { + return true; + } + + if ($page->permissions()->move() !== true) { + throw new PermissionException([ + 'key' => 'page.move.permission', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + // the page cannot be moved into itself + if ($parent instanceof Page && ($page->is($parent) === true || $page->isAncestorOf($parent) === true)) { + throw new LogicException([ + 'key' => 'page.move.ancestor', + ]); + } + + // check for duplicates + if ($parent->childrenAndDrafts()->find($page->slug())) { + throw new DuplicateException([ + 'key' => 'page.move.duplicate', + 'data' => [ + 'slug' => $page->slug(), + ] + ]); + } + + $allowed = []; + + // collect all allowed subpage templates + foreach ($parent->blueprint()->sections() as $section) { + // only take pages sections into consideration + if ($section->type() !== 'pages') { + continue; + } + + // only consider page sections that list pages + // of the targeted new parent page + if ($section->parent() !== $parent) { + continue; + } + + // go through all allowed blueprints and + // add the name to the allow list + foreach ($section->blueprints() as $blueprint) { + $allowed[] = $blueprint['name']; + } + } + + // check if the template of this page is allowed as subpage type + if (in_array($page->intendedTemplate()->name(), $allowed) === false) { + throw new PermissionException([ + 'key' => 'page.move.template', + 'data' => [ + 'template' => $page->intendedTemplate()->name(), + 'parent' => $parent->id() ?? '/', + ] + ]); + } + + return true; + } + + /** + * Check if the page can be published + * (status change from draft to listed or unlisted) + */ + public static function publish(Page $page): bool + { + if ($page->permissions()->changeStatus() !== true) { + throw new PermissionException([ + 'key' => 'page.changeStatus.permission', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + if ($page->isDraft() === true && empty($page->errors()) === false) { + throw new PermissionException([ + 'key' => 'page.changeStatus.incomplete', + 'details' => $page->errors() + ]); + } + + return true; + } + + /** + * Validates if the page can be updated + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to update the page + */ + public static function update(Page $page, array $content = []): bool + { + if ($page->permissions()->update() !== true) { + throw new PermissionException([ + 'key' => 'page.update.permission', + 'data' => [ + 'slug' => $page->slug() + ] + ]); + } + + return true; + } + + /** + * Ensures that the slug is not empty and doesn't exceed the maximum length + * to make sure that the directory name will be accepted by the filesystem + * + * @throws \Kirby\Exception\InvalidArgumentException If the slug is empty or too long + */ + public static function validateSlugLength(string $slug): void + { + $slugLength = Str::length($slug); + + if ($slugLength === 0) { + throw new InvalidArgumentException([ + 'key' => 'page.slug.invalid', + ]); + } + + if ($slugsMaxlength = App::instance()->option('slugs.maxlength', 255)) { + $maxlength = (int)$slugsMaxlength; + + if ($slugLength > $maxlength) { + throw new InvalidArgumentException([ + 'key' => 'page.slug.maxlength', + 'data' => [ + 'length' => $maxlength + ] + ]); + } + } + } + + + /** + * Ensure that a top-level page path does not start with one of + * the reserved URL paths, e.g. for API or the Panel + * + * @throws \Kirby\Exception\InvalidArgumentException If the page ID starts as one of the disallowed paths + */ + protected static function validateSlugProtectedPaths( + Page $page, + string $slug + ): void { + if ($page->parent() === null) { + $paths = A::map( + ['api', 'assets', 'media', 'panel'], + fn ($url) => $page->kirby()->url($url, true)->path()->toString() + ); + + $index = array_search($slug, $paths); + + if ($index !== false) { + throw new InvalidArgumentException([ + 'key' => 'page.changeSlug.reserved', + 'data' => [ + 'path' => $paths[$index] + ] + ]); + } + } + } + + /** + * Ensures that the page title is not empty + * + * @throws \Kirby\Exception\InvalidArgumentException If the title is empty + */ + public static function validateTitleLength(string $title): void + { + if (Str::length($title) === 0) { + throw new InvalidArgumentException([ + 'key' => 'page.changeTitle.empty', + ]); + } + } +} diff --git a/kirby/src/Cms/PageSiblings.php b/kirby/src/Cms/PageSiblings.php new file mode 100644 index 0000000..04f9f63 --- /dev/null +++ b/kirby/src/Cms/PageSiblings.php @@ -0,0 +1,131 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait PageSiblings +{ + /** + * Checks if there's a next listed + * page in the siblings collection + * + * @param \Kirby\Cms\Collection|null $collection + */ + public function hasNextListed($collection = null): bool + { + return $this->nextListed($collection) !== null; + } + + /** + * Checks if there's a next unlisted + * page in the siblings collection + * + * @param \Kirby\Cms\Collection|null $collection + */ + public function hasNextUnlisted($collection = null): bool + { + return $this->nextUnlisted($collection) !== null; + } + + /** + * Checks if there's a previous listed + * page in the siblings collection + * + * @param \Kirby\Cms\Collection|null $collection + */ + public function hasPrevListed($collection = null): bool + { + return $this->prevListed($collection) !== null; + } + + /** + * Checks if there's a previous unlisted + * page in the siblings collection + * + * @param \Kirby\Cms\Collection|null $collection + */ + public function hasPrevUnlisted($collection = null): bool + { + return $this->prevUnlisted($collection) !== null; + } + + /** + * Returns the next listed page if it exists + * + * @param \Kirby\Cms\Collection|null $collection + * + * @return \Kirby\Cms\Page|null + */ + public function nextListed($collection = null) + { + return $this->nextAll($collection)->listed()->first(); + } + + /** + * Returns the next unlisted page if it exists + * + * @param \Kirby\Cms\Collection|null $collection + * + * @return \Kirby\Cms\Page|null + */ + public function nextUnlisted($collection = null) + { + return $this->nextAll($collection)->unlisted()->first(); + } + + /** + * Returns the previous listed page + * + * @param \Kirby\Cms\Collection|null $collection + * + * @return \Kirby\Cms\Page|null + */ + public function prevListed($collection = null) + { + return $this->prevAll($collection)->listed()->last(); + } + + /** + * Returns the previous unlisted page + * + * @param \Kirby\Cms\Collection|null $collection + * + * @return \Kirby\Cms\Page|null + */ + public function prevUnlisted($collection = null) + { + return $this->prevAll($collection)->unlisted()->last(); + } + + /** + * Private siblings collector + * + * @return \Kirby\Cms\Collection + */ + protected function siblingsCollection() + { + if ($this->isDraft() === true) { + return $this->parentModel()->drafts(); + } + + return $this->parentModel()->children(); + } + + /** + * Returns siblings with the same template + * + * @return \Kirby\Cms\Pages + */ + public function templateSiblings(bool $self = true) + { + return $this->siblings($self)->filter('intendedTemplate', $this->intendedTemplate()->name()); + } +} diff --git a/kirby/src/Cms/Pages.php b/kirby/src/Cms/Pages.php new file mode 100644 index 0000000..95ccfc5 --- /dev/null +++ b/kirby/src/Cms/Pages.php @@ -0,0 +1,495 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Pages extends Collection +{ + use HasUuids; + + /** + * Cache for the index only listed and unlisted pages + * + * @var \Kirby\Cms\Pages|null + */ + protected $index = null; + + /** + * Cache for the index all statuses also including drafts + * + * @var \Kirby\Cms\Pages|null + */ + protected $indexWithDrafts = null; + + /** + * All registered pages methods + */ + public static array $methods = []; + + /** + * Adds a single page or + * an entire second collection to the + * current collection + * + * @param \Kirby\Cms\Pages|\Kirby\Cms\Page|string $object + * @return $this + * @throws \Kirby\Exception\InvalidArgumentException When no `Page` or `Pages` object or an ID of an existing page is passed + */ + public function add($object): static + { + $site = App::instance()->site(); + + // add a pages collection + if ($object instanceof self) { + $this->data = array_merge($this->data, $object->data); + + // add a page by id + } elseif ( + is_string($object) === true && + $page = $site->find($object) + ) { + $this->__set($page->id(), $page); + + // add a page object + } elseif ($object instanceof Page) { + $this->__set($object->id(), $object); + + // give a useful error message on invalid input; + // silently ignore "empty" values for compatibility with existing setups + } elseif (in_array($object, [null, false, true], true) !== true) { + throw new InvalidArgumentException('You must pass a Pages or Page object or an ID of an existing page to the Pages collection'); + } + + return $this; + } + + /** + * Returns all audio files of all children + */ + public function audio(): Files + { + return $this->files()->filter('type', 'audio'); + } + + /** + * Returns all children for each page in the array + */ + public function children(): Pages + { + $children = new Pages([]); + + foreach ($this->data as $page) { + foreach ($page->children() as $childKey => $child) { + $children->data[$childKey] = $child; + } + } + + return $children; + } + + /** + * Returns all code files of all children + */ + public function code(): Files + { + return $this->files()->filter('type', 'code'); + } + + /** + * Returns all documents of all children + */ + public function documents(): Files + { + return $this->files()->filter('type', 'document'); + } + + /** + * Fetch all drafts for all pages in the collection + */ + public function drafts(): Pages + { + $drafts = new Pages([]); + + foreach ($this->data as $page) { + foreach ($page->drafts() as $draftKey => $draft) { + $drafts->data[$draftKey] = $draft; + } + } + + return $drafts; + } + + /** + * Creates a pages collection from an array of props + */ + public static function factory( + array $pages, + Page|Site $model = null, + bool $draft = null + ): static { + $model ??= App::instance()->site(); + $children = new static([], $model); + $kirby = $model->kirby(); + + if ($model instanceof Page) { + $parent = $model; + $site = $model->site(); + } else { + $parent = null; + $site = $model; + } + + foreach ($pages as $props) { + $props['kirby'] = $kirby; + $props['parent'] = $parent; + $props['site'] = $site; + $props['isDraft'] = $draft ?? $props['isDraft'] ?? $props['draft'] ?? false; + + $page = Page::factory($props); + + $children->data[$page->id()] = $page; + } + + return $children; + } + + /** + * Returns all files of all children + */ + public function files(): Files + { + $files = new Files([], $this->parent); + + foreach ($this->data as $page) { + foreach ($page->files() as $fileKey => $file) { + $files->data[$fileKey] = $file; + } + } + + return $files; + } + + /** + * Finds a page by its ID or URI + * @internal Use `$pages->find()` instead + */ + public function findByKey(string|null $key = null): Page|null + { + if ($key === null) { + return null; + } + + if ($page = $this->findByUuid($key, 'page')) { + return $page; + } + + // remove trailing or leading slashes + $key = trim($key, '/'); + + // strip extensions from the id + if (strpos($key, '.') !== false) { + $info = pathinfo($key); + + if ($info['dirname'] !== '.') { + $key = $info['dirname'] . '/' . $info['filename']; + } else { + $key = $info['filename']; + } + } + + // try the obvious way + if ($page = $this->get($key)) { + return $page; + } + + $kirby = App::instance(); + $multiLang = $kirby->multilang(); + + // try to find the page by its (translated) URI + // by stepping through the page tree + $start = $this->parent instanceof Page ? $this->parent->id() : ''; + if ($page = $this->findByKeyRecursive($key, $start, $multiLang)) { + return $page; + } + + // for secondary languages, try the full translated URI + // (for collections without parent that won't have a result above) + if ( + $multiLang === true && + $kirby->language()->isDefault() === false && + $page = $this->findBy('uri', $key) + ) { + return $page; + } + + return null; + } + + /** + * Finds a child or child of a child recursively + * + * @return mixed + */ + protected function findByKeyRecursive( + string $id, + string $startAt = null, + bool $multiLang = false + ) { + $path = explode('/', $id); + $item = null; + $query = $startAt; + + foreach ($path as $key) { + $collection = $item?->children() ?? $this; + $query = ltrim($query . '/' . $key, '/'); + $item = $collection->get($query) ?? null; + + if ( + $item === null && + $multiLang === true && + App::instance()->language()->isDefault() === false + ) { + if (count($path) > 1 || $collection->parent()) { + // either the desired path is definitely not a slug, + // or collection is the children of another collection + $item = $collection->findBy('slug', $key); + } else { + // desired path _could_ be a slug or a "top level" uri + $item = $collection->findBy('uri', $key); + } + } + + if ($item === null) { + return null; + } + } + + return $item; + } + + /** + * Finds the currently open page + */ + public function findOpen(): Page|null + { + return $this->findBy('isOpen', true); + } + + /** + * Custom getter that is able to find + * extension pages + * + * @param string $key + * @param mixed $default + * @return \Kirby\Cms\Page|null + */ + public function get($key, $default = null) + { + if ($key === null) { + return null; + } + + if ($item = parent::get($key)) { + return $item; + } + + return App::instance()->extension('pages', $key); + } + + /** + * Returns all images of all children + */ + public function images(): Files + { + return $this->files()->filter('type', 'image'); + } + + /** + * Create a recursive flat index of all + * pages and subpages, etc. + * + * @return \Kirby\Cms\Pages + */ + public function index(bool $drafts = false) + { + // get object property by cache mode + $index = $drafts === true ? $this->indexWithDrafts : $this->index; + + if ($index instanceof self) { + return $index; + } + + $index = new Pages([]); + + foreach ($this->data as $pageKey => $page) { + $index->data[$pageKey] = $page; + $pageIndex = $page->index($drafts); + + if ($pageIndex) { + foreach ($pageIndex as $childKey => $child) { + $index->data[$childKey] = $child; + } + } + } + + if ($drafts === true) { + return $this->indexWithDrafts = $index; + } + + return $this->index = $index; + } + + /** + * Returns all listed pages in the collection + */ + public function listed(): static + { + return $this->filter('isListed', '==', true); + } + + /** + * Returns all unlisted pages in the collection + */ + public function unlisted(): static + { + return $this->filter('isUnlisted', '==', true); + } + + /** + * Include all given items in the collection + * + * @param mixed ...$args + * @return $this|static + */ + public function merge(...$args) + { + // merge multiple arguments at once + if (count($args) > 1) { + $collection = clone $this; + foreach ($args as $arg) { + $collection = $collection->merge($arg); + } + return $collection; + } + + // merge all parent drafts + if ($args[0] === 'drafts') { + if ($parent = $this->parent()) { + return $this->merge($parent->drafts()); + } + + return $this; + } + + // merge an entire collection + if ($args[0] instanceof self) { + $collection = clone $this; + $collection->data = array_merge($collection->data, $args[0]->data); + return $collection; + } + + // append a single page + if ($args[0] instanceof Page) { + $collection = clone $this; + return $collection->set($args[0]->id(), $args[0]); + } + + // merge an array + if (is_array($args[0]) === true) { + $collection = clone $this; + foreach ($args[0] as $arg) { + $collection = $collection->merge($arg); + } + return $collection; + } + + if (is_string($args[0]) === true) { + return $this->merge(App::instance()->site()->find($args[0])); + } + + return $this; + } + + /** + * Filter all pages by excluding the given template + * @since 3.3.0 + * + * @param string|array $templates + * @return \Kirby\Cms\Pages + */ + public function notTemplate($templates) + { + if (empty($templates) === true) { + return $this; + } + + if (is_array($templates) === false) { + $templates = [$templates]; + } + + return $this->filter(function ($page) use ($templates) { + return !in_array($page->intendedTemplate()->name(), $templates); + }); + } + + /** + * Returns an array with all page numbers + */ + public function nums(): array + { + return $this->pluck('num'); + } + + // Returns all listed and unlisted pages in the collection + public function published(): static + { + return $this->filter('isDraft', '==', false); + } + + /** + * Filter all pages by the given template + * + * @param string|array $templates + * @return \Kirby\Cms\Pages + */ + public function template($templates) + { + if (empty($templates) === true) { + return $this; + } + + if (is_array($templates) === false) { + $templates = [$templates]; + } + + return $this->filter(function ($page) use ($templates) { + return in_array($page->intendedTemplate()->name(), $templates); + }); + } + + /** + * Returns all video files of all children + */ + public function videos(): Files + { + return $this->files()->filter('type', 'video'); + } +} diff --git a/kirby/src/Cms/Pagination.php b/kirby/src/Cms/Pagination.php new file mode 100644 index 0000000..fdefddd --- /dev/null +++ b/kirby/src/Cms/Pagination.php @@ -0,0 +1,166 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Pagination extends BasePagination +{ + /** + * Pagination method (param, query, none) + * + * @var string + */ + protected $method; + + /** + * The base URL + * + * @var string + */ + protected $url; + + /** + * Variable name for query strings + * + * @var string + */ + protected $variable; + + /** + * Creates the pagination object. As a new + * property you can now pass the base Url. + * That Url must be the Url of the first + * page of the collection without additional + * pagination information/query parameters in it. + * + * ```php + * $pagination = new Pagination([ + * 'page' => 1, + * 'limit' => 10, + * 'total' => 120, + * 'method' => 'query', + * 'variable' => 'p', + * 'url' => new Uri('https://getkirby.com/blog') + * ]); + * ``` + */ + public function __construct(array $params = []) + { + $kirby = App::instance(); + $config = $kirby->option('pagination', []); + $request = $kirby->request(); + + $params['limit'] ??= $config['limit'] ?? 20; + $params['method'] ??= $config['method'] ?? 'param'; + $params['variable'] ??= $config['variable'] ?? 'page'; + + if (empty($params['url']) === true) { + $params['url'] = new Uri($kirby->url('current'), [ + 'params' => $request->params(), + 'query' => $request->query()->toArray(), + ]); + } + + if ($params['method'] === 'query') { + $params['page'] ??= $params['url']->query()->get($params['variable']); + } elseif ($params['method'] === 'param') { + $params['page'] ??= $params['url']->params()->get($params['variable']); + } + + parent::__construct($params); + + $this->method = $params['method']; + $this->url = $params['url']; + $this->variable = $params['variable']; + } + + /** + * Returns the Url for the first page + */ + public function firstPageUrl(): string|null + { + return $this->pageUrl(1); + } + + /** + * Returns the Url for the last page + */ + public function lastPageUrl(): string|null + { + return $this->pageUrl($this->lastPage()); + } + + /** + * Returns the Url for the next page. + * Returns null if there's no next page. + */ + public function nextPageUrl(): string|null + { + if ($page = $this->nextPage()) { + return $this->pageUrl($page); + } + + return null; + } + + /** + * Returns the URL of the current page. + * If the `$page` variable is set, the URL + * for that page will be returned. + */ + public function pageUrl(int $page = null): string|null + { + if ($page === null) { + return $this->pageUrl($this->page()); + } + + $url = clone $this->url; + $variable = $this->variable; + + if ($this->hasPage($page) === false) { + return null; + } + + $pageValue = $page === 1 ? null : $page; + + if ($this->method === 'query') { + $url->query->$variable = $pageValue; + } elseif ($this->method === 'param') { + $url->params->$variable = $pageValue; + } else { + return null; + } + + return $url->toString(); + } + + /** + * Returns the Url for the previous page. + * Returns null if there's no previous page. + */ + public function prevPageUrl(): string|null + { + if ($page = $this->prevPage()) { + return $this->pageUrl($page); + } + + return null; + } +} diff --git a/kirby/src/Cms/Permissions.php b/kirby/src/Cms/Permissions.php new file mode 100644 index 0000000..20ba4ea --- /dev/null +++ b/kirby/src/Cms/Permissions.php @@ -0,0 +1,212 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Permissions +{ + public static array $extendedActions = []; + + protected array $actions = [ + 'access' => [ + 'account' => true, + 'languages' => true, + 'panel' => true, + 'site' => true, + 'system' => true, + 'users' => true, + ], + 'files' => [ + 'access' => true, + 'changeName' => true, + 'changeTemplate' => true, + 'create' => true, + 'delete' => true, + 'list' => true, + 'read' => true, + 'replace' => true, + 'update' => true + ], + 'languages' => [ + 'create' => true, + 'delete' => true + ], + 'pages' => [ + 'access' => true, + 'changeSlug' => true, + 'changeStatus' => true, + 'changeTemplate' => true, + 'changeTitle' => true, + 'create' => true, + 'delete' => true, + 'duplicate' => true, + 'list' => true, + 'move' => true, + 'preview' => true, + 'read' => true, + 'sort' => true, + 'update' => true + ], + 'site' => [ + 'changeTitle' => true, + 'update' => true + ], + 'users' => [ + 'changeEmail' => true, + 'changeLanguage' => true, + 'changeName' => true, + 'changePassword' => true, + 'changeRole' => true, + 'create' => true, + 'delete' => true, + 'update' => true + ], + 'user' => [ + 'changeEmail' => true, + 'changeLanguage' => true, + 'changeName' => true, + 'changePassword' => true, + 'changeRole' => true, + 'delete' => true, + 'update' => true + ] + ]; + + /** + * Permissions constructor + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function __construct(array|bool|null $settings = []) + { + // dynamically register the extended actions + foreach (static::$extendedActions as $key => $actions) { + if (isset($this->actions[$key]) === true) { + throw new InvalidArgumentException('The action ' . $key . ' is already a core action'); + } + + $this->actions[$key] = $actions; + } + + if (is_array($settings) === true) { + return $this->setCategories($settings); + } + + if (is_bool($settings) === true) { + return $this->setAll($settings); + } + } + + public function for(string $category = null, string $action = null): bool + { + if ($action === null) { + if ($this->hasCategory($category) === false) { + return false; + } + + return $this->actions[$category]; + } + + if ($this->hasAction($category, $action) === false) { + return false; + } + + return $this->actions[$category][$action]; + } + + protected function hasAction(string $category, string $action): bool + { + return + $this->hasCategory($category) === true && + array_key_exists($action, $this->actions[$category]) === true; + } + + protected function hasCategory(string $category): bool + { + return array_key_exists($category, $this->actions) === true; + } + + /** + * @return $this + */ + protected function setAction( + string $category, + string $action, + $setting + ): static { + // wildcard to overwrite the entire category + if ($action === '*') { + return $this->setCategory($category, $setting); + } + + $this->actions[$category][$action] = $setting; + + return $this; + } + + /** + * @return $this + */ + protected function setAll(bool $setting): static + { + foreach ($this->actions as $categoryName => $actions) { + $this->setCategory($categoryName, $setting); + } + + return $this; + } + + /** + * @return $this + */ + protected function setCategories(array $settings): static + { + foreach ($settings as $categoryName => $categoryActions) { + if (is_bool($categoryActions) === true) { + $this->setCategory($categoryName, $categoryActions); + } + + if (is_array($categoryActions) === true) { + foreach ($categoryActions as $actionName => $actionSetting) { + $this->setAction($categoryName, $actionName, $actionSetting); + } + } + } + + return $this; + } + + /** + * @return $this + * @throws \Kirby\Exception\InvalidArgumentException + */ + protected function setCategory(string $category, bool $setting): static + { + if ($this->hasCategory($category) === false) { + throw new InvalidArgumentException('Invalid permissions category'); + } + + foreach ($this->actions[$category] as $actionName => $actionSetting) { + $this->actions[$category][$actionName] = $setting; + } + + return $this; + } + + public function toArray(): array + { + return $this->actions; + } +} diff --git a/kirby/src/Cms/Picker.php b/kirby/src/Cms/Picker.php new file mode 100644 index 0000000..da4b7a5 --- /dev/null +++ b/kirby/src/Cms/Picker.php @@ -0,0 +1,148 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class Picker +{ + protected App $kirby; + protected array $options; + protected Site $site; + + /** + * Creates a new Picker instance + */ + public function __construct(array $params = []) + { + $this->options = array_merge($this->defaults(), $params); + $this->kirby = $this->options['model']->kirby(); + $this->site = $this->kirby->site(); + } + + /** + * Return the array of default values + */ + protected function defaults(): array + { + // default params + return [ + // image settings (ratio, cover, etc.) + 'image' => [], + // query template for the info field + 'info' => false, + // listing style: list, cards, cardlets + 'layout' => 'list', + // number of users displayed per pagination page + 'limit' => 20, + // optional mapping function for the result array + 'map' => null, + // the reference model + 'model' => App::instance()->site(), + // current page when paginating + 'page' => 1, + // a query string to fetch specific items + 'query' => null, + // search query + 'search' => null, + // query template for the text field + 'text' => null + ]; + } + + /** + * Fetches all items for the picker + */ + abstract public function items(): Collection|null; + + /** + * Converts all given items to an associative + * array that is already optimized for the + * panel picker component. + */ + public function itemsToArray(Collection $items = null): array + { + if ($items === null) { + return []; + } + + $result = []; + + foreach ($items as $index => $item) { + if (empty($this->options['map']) === false) { + $result[] = $this->options['map']($item); + } else { + $result[] = $item->panel()->pickerData([ + 'image' => $this->options['image'], + 'info' => $this->options['info'], + 'layout' => $this->options['layout'], + 'model' => $this->options['model'], + 'text' => $this->options['text'], + ]); + } + } + + return $result; + } + + /** + * Apply pagination to the collection + * of items according to the options. + */ + public function paginate(Collection $items): Collection + { + return $items->paginate([ + 'limit' => $this->options['limit'], + 'page' => $this->options['page'] + ]); + } + + /** + * Return the most relevant pagination + * info as array + */ + public function paginationToArray(Pagination $pagination): array + { + return [ + 'limit' => $pagination->limit(), + 'page' => $pagination->page(), + 'total' => $pagination->total() + ]; + } + + /** + * Search through the collection of items + * if not deactivate in the options + */ + public function search(Collection $items): Collection + { + if (empty($this->options['search']) === false) { + return $items->search($this->options['search']); + } + + return $items; + } + + /** + * Returns an associative array + * with all information for the picker. + * This will be passed directly to the API. + */ + public function toArray(): array + { + $items = $this->items(); + + return [ + 'data' => $this->itemsToArray($items), + 'pagination' => $this->paginationToArray($items->pagination()), + ]; + } +} diff --git a/kirby/src/Cms/Plugin.php b/kirby/src/Cms/Plugin.php new file mode 100644 index 0000000..5a69ccf --- /dev/null +++ b/kirby/src/Cms/Plugin.php @@ -0,0 +1,327 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Plugin +{ + protected PluginAssets $assets; + protected array $extends; + protected string $name; + protected string $root; + + // caches + protected array|null $info = null; + protected UpdateStatus|null $updateStatus = null; + + /** + * @param string $name Plugin name within Kirby (`vendor/plugin`) + * @param array $extends Associative array of plugin extensions + * + * @throws \Kirby\Exception\InvalidArgumentException If the plugin name has an invalid format + */ + public function __construct(string $name, array $extends = []) + { + static::validateName($name); + + $this->name = $name; + $this->extends = $extends; + $this->root = $extends['root'] ?? dirname(debug_backtrace()[0]['file']); + $this->info = empty($extends['info']) === false && is_array($extends['info']) ? $extends['info'] : null; + + unset($this->extends['root'], $this->extends['info']); + } + + /** + * Allows access to any composer.json field by method call + */ + public function __call(string $key, array $arguments = null): mixed + { + return $this->info()[$key] ?? null; + } + + /** + * Returns the plugin asset object for a specific asset + */ + public function asset(string $path): PluginAsset|null + { + return $this->assets()->get($path); + } + + /** + * Returns the plugin assets collection + */ + public function assets(): PluginAssets + { + return $this->assets ??= PluginAssets::factory($this); + } + + /** + * Returns the array with author information + * from the composer.json file + */ + public function authors(): array + { + return $this->info()['authors'] ?? []; + } + + /** + * Returns a comma-separated list with all author names + */ + public function authorsNames(): string + { + $names = []; + + foreach ($this->authors() as $author) { + $names[] = $author['name'] ?? null; + } + + return implode(', ', array_filter($names)); + } + + /** + * Returns the associative array of extensions the plugin bundles + */ + public function extends(): array + { + return $this->extends; + } + + /** + * Returns the unique ID for the plugin + * (alias for the plugin name) + */ + public function id(): string + { + return $this->name(); + } + + /** + * Returns the raw data from composer.json + */ + public function info(): array + { + if (is_array($this->info) === true) { + return $this->info; + } + + try { + $info = Data::read($this->manifest()); + } catch (Exception) { + // there is no manifest file or it is invalid + $info = []; + } + + return $this->info = $info; + } + + /** + * Current $kirby instance + */ + public function kirby(): App + { + return App::instance(); + } + + /** + * Returns the link to the plugin homepage + */ + public function link(): string|null + { + $info = $this->info(); + $homepage = $info['homepage'] ?? null; + $docs = $info['support']['docs'] ?? null; + $source = $info['support']['source'] ?? null; + + $link = $homepage ?? $docs ?? $source; + + return V::url($link) ? $link : null; + } + + /** + * Returns the path to the plugin's composer.json + */ + public function manifest(): string + { + return $this->root() . '/composer.json'; + } + + /** + * Returns the root where plugin assets are copied to + */ + public function mediaRoot(): string + { + return App::instance()->root('media') . '/plugins/' . $this->name(); + } + + /** + * Returns the base URL for plugin assets + */ + public function mediaUrl(): string + { + return App::instance()->url('media') . '/plugins/' . $this->name(); + } + + /** + * Returns the plugin name (`vendor/plugin`) + */ + public function name(): string + { + return $this->name; + } + + /** + * Returns a Kirby option value for this plugin + */ + public function option(string $key) + { + return $this->kirby()->option($this->prefix() . '.' . $key); + } + + /** + * Returns the option prefix (`vendor.plugin`) + */ + public function prefix(): string + { + return str_replace('/', '.', $this->name()); + } + + /** + * Returns the root where the plugin files are stored + */ + public function root(): string + { + return $this->root; + } + + /** + * Returns all available plugin metadata + */ + public function toArray(): array + { + return [ + 'authors' => $this->authors(), + 'description' => $this->description(), + 'name' => $this->name(), + 'license' => $this->license(), + 'link' => $this->link(), + 'root' => $this->root(), + 'version' => $this->version() + ]; + } + + /** + * Returns the update status object unless the + * update check has been disabled for the plugin + * @since 3.8.0 + * + * @param array|null $data Custom override for the getkirby.com update data + */ + public function updateStatus(array|null $data = null): UpdateStatus|null + { + if ($this->updateStatus !== null) { + return $this->updateStatus; + } + + $kirby = $this->kirby(); + $option = $kirby->option('updates.plugins'); + + // specific configuration per plugin + if (is_array($option) === true) { + // filter all option values by glob match + $option = A::filter( + $option, + fn ($value, $key) => fnmatch($key, $this->name()) === true + ); + + // sort the matches by key length (with longest key first) + $keys = array_map('strlen', array_keys($option)); + array_multisort($keys, SORT_DESC, $option); + + if (count($option) > 0) { + // use the first and therefore longest key (= most specific match) + $option = reset($option); + } else { + // fallback to the default option value + $option = true; + } + } + + $option ??= $kirby->option('updates') ?? true; + + if ($option !== true) { + return null; + } + + return $this->updateStatus = new UpdateStatus($this, false, $data); + } + + /** + * Checks if the name follows the required pattern + * and throws an exception if not + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function validateName(string $name): void + { + if (preg_match('!^[a-z0-9-]+\/[a-z0-9-]+$!i', $name) !== 1) { + throw new InvalidArgumentException('The plugin name must follow the format "a-z0-9-/a-z0-9-"'); + } + } + + /** + * Returns the normalized version number + * from the composer.json file + */ + public function version(): string|null + { + $composerName = $this->info()['name'] ?? null; + $version = $this->info()['version'] ?? null; + + try { + // if plugin doesn't have version key in composer.json file + // try to get version from "vendor/composer/installed.php" + $version ??= InstalledVersions::getPrettyVersion($composerName); + } catch (Throwable) { + return null; + } + + if ( + is_string($version) !== true || + $version === '' || + Str::endsWith($version, '+no-version-set') + ) { + return null; + } + + // normalize the version number to be without leading `v` + $version = ltrim($version, 'vV'); + + // ensure that the version number now starts with a digit + if (preg_match('/^[0-9]/', $version) !== 1) { + return null; + } + + return $version; + } +} diff --git a/kirby/src/Cms/PluginAsset.php b/kirby/src/Cms/PluginAsset.php new file mode 100644 index 0000000..f5f7f69 --- /dev/null +++ b/kirby/src/Cms/PluginAsset.php @@ -0,0 +1,120 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PluginAsset +{ + public function __construct( + protected string $path, + protected string $root, + protected Plugin $plugin + ) { + } + + public function extension(): string + { + return F::extension($this->path()); + } + + public function filename(): string + { + return F::filename($this->path()); + } + + /** + * Create a unique media hash + */ + public function mediaHash(): string + { + return crc32($this->filename()) . '-' . $this->modified(); + } + + /** + * Absolute path to the asset file in the media folder + */ + public function mediaRoot(): string + { + return $this->plugin()->mediaRoot() . '/' . $this->mediaHash() . '/' . $this->path(); + } + + /** + * Public accessible url path for the asset + */ + public function mediaUrl(): string + { + return $this->plugin()->mediaUrl() . '/' . $this->mediaHash() . '/' . $this->path(); + } + + /** + * Timestamp when asset file was last modified + */ + public function modified(): int|false + { + return F::modified($this->root()); + } + + public function path(): string + { + return $this->path; + } + + public function plugin(): Plugin + { + return $this->plugin; + } + + /** + * Publishes the asset file to the plugin's media folder + * by creating a symlink + */ + public function publish(): void + { + F::link($this->root(), $this->mediaRoot(), 'symlink'); + } + + /** + * @internal + * @since 4.0.0 + * @deprecated 4.0.0 + * @codeCoverageIgnore + */ + public function publishAt(string $path): void + { + $media = $this->plugin()->mediaRoot() . '/' . $path; + F::link($this->root(), $media, 'symlink'); + } + + public function root(): string + { + return $this->root; + } + + /** + * @see ::mediaUrl + */ + public function url(): string + { + return $this->mediaUrl(); + } + + /** + * @see ::url + */ + public function __toString(): string + { + return $this->url(); + } +} diff --git a/kirby/src/Cms/PluginAssets.php b/kirby/src/Cms/PluginAssets.php new file mode 100644 index 0000000..45c415f --- /dev/null +++ b/kirby/src/Cms/PluginAssets.php @@ -0,0 +1,184 @@ + + * @author Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PluginAssets extends Collection +{ + /** + * Clean old/deprecated assets on every resolve + */ + public static function clean(string $pluginName): void + { + if ($plugin = App::instance()->plugin($pluginName)) { + $media = $plugin->mediaRoot(); + $assets = $plugin->assets(); + + // get all media files + $files = Dir::index($media, true); + + // get all active assets' paths from the plugin + $active = $assets->values( + function ($asset) { + $path = $asset->mediaHash() . '/' . $asset->path(); + $paths = []; + $parts = explode('/', $path); + + // collect all path segments + // (e.g. foo/, foo/bar/, foo/bar/baz.css) for the asset + for ($i = 1, $max = count($parts); $i <= $max; $i++) { + $paths[] = implode('/', array_slice($parts, 0, $i)); + + // TODO: remove when media hash is enforced as mandatory + $paths[] = implode('/', array_slice($parts, 1, $i)); + } + + return $paths; + } + ); + + // flatten the array and remove duplicates + $active = array_unique(array_merge(...array_values($active))); + + // get outdated media files by comparing all + // files in the media folder against the set of asset paths + $stale = array_diff($files, $active); + + foreach ($stale as $file) { + $root = $media . '/' . $file; + + if (is_file($root) === true) { + F::remove($root); + } else { + Dir::remove($root); + } + } + } + } + + /** + * Filters assets collection by CSS files + */ + public function css(): static + { + return $this->filter(fn ($asset) => $asset->extension() === 'css'); + } + + /** + * Creates a new collection for the plugin's assets + * by considering the plugin's `asset` extension + * (and `assets` directory as fallback) + */ + public static function factory(Plugin $plugin): static + { + // get assets defined in the plugin extension + if ($assets = $plugin->extends()['assets'] ?? null) { + if ($assets instanceof Closure) { + $assets = $assets(); + } + + // normalize array: use relative path as + // key when no key is defined + foreach ($assets as $key => $root) { + if (is_int($key) === true) { + unset($assets[$key]); + $path = Str::after($root, $plugin->root() . '/'); + $assets[$path] = $root; + } + } + } + + // fallback: if no assets are defined in the plugin extension, + // use all files in the plugin's `assets` directory + if ($assets === null) { + $assets = []; + $root = $plugin->root() . '/assets'; + + foreach (Dir::index($root, true) as $path) { + if (is_file($root . '/' . $path) === true) { + $assets[$path] = $root . '/' . $path; + } + } + } + + $collection = new static([], $plugin); + + foreach ($assets as $path => $root) { + $collection->data[$path] = new PluginAsset($path, $root, $plugin); + } + + return $collection; + } + + /** + * Filters assets collection by JavaScript files + */ + public function js(): static + { + return $this->filter(fn ($asset) => $asset->extension() === 'js'); + } + + public function plugin(): Plugin + { + return $this->parent; + } + + /** + * Create a symlink for a plugin asset and + * return the public URL + */ + public static function resolve( + string $pluginName, + string $hash, + string $path + ): Response|null { + if ($plugin = App::instance()->plugin($pluginName)) { + // do some spring cleaning for older files + static::clean($pluginName); + + // @codeCoverageIgnoreStart + // TODO: deprecated media URL without hash + if (empty($hash) === true) { + $asset = $plugin->asset($path); + $asset->publishAt($path); + return Response::file($asset->root()); + } + + // TODO: deprecated media URL with hash (but path) + if ($asset = $plugin->asset($hash . '/' . $path)) { + $asset->publishAt($hash . '/' . $path); + return Response::file($asset->root()); + } + // @codeCoverageIgnoreEnd + + if ($asset = $plugin->asset($path)) { + if ($asset->mediaHash() === $hash) { + // create a symlink if possible + $asset->publish(); + + // return the file response + return Response::file($asset->root()); + } + } + } + + return null; + } +} diff --git a/kirby/src/Cms/R.php b/kirby/src/Cms/R.php new file mode 100644 index 0000000..312fc2b --- /dev/null +++ b/kirby/src/Cms/R.php @@ -0,0 +1,23 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class R extends Facade +{ + public static function instance(): Request + { + return App::instance()->request(); + } +} diff --git a/kirby/src/Cms/Responder.php b/kirby/src/Cms/Responder.php new file mode 100644 index 0000000..e99ce56 --- /dev/null +++ b/kirby/src/Cms/Responder.php @@ -0,0 +1,406 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Responder +{ + /** + * Timestamp when the response expires + * in Kirby's cache + */ + protected int|null $expires = null; + + /** + * HTTP status code + */ + protected int|null $code = null; + + /** + * Response body + */ + protected string|null $body = null; + + /** + * Flag that defines whether the current + * response can be cached by Kirby's cache + */ + protected bool $cache = true; + + /** + * HTTP headers + */ + protected array $headers = []; + + /** + * Content type + */ + protected string|null $type = null; + + /** + * Flag that defines whether the current + * response uses the HTTP `Authorization` + * request header + */ + protected bool $usesAuth = false; + + /** + * List of cookie names the response + * relies on + */ + protected array $usesCookies = []; + + /** + * Creates and sends the response + */ + public function __toString(): string + { + return (string)$this->send(); + } + + /** + * Setter and getter for the response body + * + * @return $this|string|null + */ + public function body(string $body = null): static|string|null + { + if ($body === null) { + return $this->body; + } + + $this->body = $body; + return $this; + } + + /** + * Setter and getter for the flag that defines + * whether the current response can be cached + * by Kirby's cache + * @since 3.5.5 + * + * @return bool|$this + */ + public function cache(bool|null $cache = null): bool|static + { + if ($cache === null) { + // never ever cache private responses + if (static::isPrivate($this->usesAuth(), $this->usesCookies()) === true) { + return false; + } + + return $this->cache; + } + + $this->cache = $cache; + return $this; + } + + /** + * Setter and getter for the flag that defines + * whether the current response uses the HTTP + * `Authorization` request header + * @since 3.7.0 + * + * @return bool|$this + */ + public function usesAuth(bool|null $usesAuth = null): bool|static + { + if ($usesAuth === null) { + return $this->usesAuth; + } + + $this->usesAuth = $usesAuth; + return $this; + } + + /** + * Setter for a cookie name that is + * used by the response + * @since 3.7.0 + */ + public function usesCookie(string $name): void + { + // only add unique names + if (in_array($name, $this->usesCookies) === false) { + $this->usesCookies[] = $name; + } + } + + /** + * Setter and getter for the list of cookie + * names the response relies on + * @since 3.7.0 + * + * @return array|$this + */ + public function usesCookies(array|null $usesCookies = null) + { + if ($usesCookies === null) { + return $this->usesCookies; + } + + $this->usesCookies = $usesCookies; + return $this; + } + + /** + * Setter and getter for the cache expiry + * timestamp for Kirby's cache + * @since 3.5.5 + * + * @param int|string|null $expires Timestamp, number of minutes or time string to parse + * @param bool $override If `true`, the already defined timestamp will be overridden + * @return int|null|$this + */ + public function expires($expires = null, bool $override = false) + { + // getter + if ($expires === null && $override === false) { + return $this->expires; + } + + // explicit un-setter + if ($expires === null) { + $this->expires = null; + return $this; + } + + // normalize the value to an integer timestamp + if (is_int($expires) === true && $expires < 1000000000) { + // number of minutes + $expires = time() + ($expires * 60); + } elseif (is_int($expires) !== true) { + // time string + $parsedExpires = strtotime($expires); + + if (is_int($parsedExpires) !== true) { + throw new InvalidArgumentException('Invalid time string "' . $expires . '"'); + } + + $expires = $parsedExpires; + } + + // by default only ever *reduce* the cache expiry time + if ( + $override === true || + $this->expires === null || + $expires < $this->expires + ) { + $this->expires = $expires; + } + + return $this; + } + + /** + * Setter and getter for the status code + * + * @return int|$this + */ + public function code(int $code = null) + { + if ($code === null) { + return $this->code; + } + + $this->code = $code; + return $this; + } + + /** + * Construct response from an array + */ + public function fromArray(array $response): void + { + $this->body($response['body'] ?? null); + $this->cache($response['cache'] ?? null); + $this->code($response['code'] ?? null); + $this->expires($response['expires'] ?? null); + $this->headers($response['headers'] ?? null); + $this->type($response['type'] ?? null); + $this->usesAuth($response['usesAuth'] ?? null); + $this->usesCookies($response['usesCookies'] ?? null); + } + + /** + * Setter and getter for a single header + * + * @param string|false|null $value + * @param bool $lazy If `true`, an existing header value is not overridden + * @return string|$this + */ + public function header(string $key, $value = null, bool $lazy = false) + { + if ($value === null) { + return $this->headers()[$key] ?? null; + } + + if ($value === false) { + unset($this->headers[$key]); + return $this; + } + + if ($lazy === true && isset($this->headers[$key]) === true) { + return $this; + } + + $this->headers[$key] = $value; + return $this; + } + + /** + * Setter and getter for all headers + * + * @return array|$this + */ + public function headers(array $headers = null) + { + if ($headers === null) { + $injectedHeaders = []; + + if (static::isPrivate($this->usesAuth(), $this->usesCookies()) === true) { + // never ever cache private responses + $injectedHeaders['Cache-Control'] = 'no-store, private'; + } else { + // the response is public, but it may + // vary based on request headers + $vary = []; + + if ($this->usesAuth() === true) { + $vary[] = 'Authorization'; + } + + if ($this->usesCookies() !== []) { + $vary[] = 'Cookie'; + } + + if ($vary !== []) { + $injectedHeaders['Vary'] = implode(', ', $vary); + } + } + + // lazily inject (never override custom headers) + return array_merge($injectedHeaders, $this->headers); + } + + $this->headers = $headers; + return $this; + } + + /** + * Shortcut to configure a json response + * + * @return string|$this + */ + public function json(array $json = null) + { + if ($json !== null) { + $this->body(json_encode($json)); + } + + return $this->type('application/json'); + } + + /** + * Shortcut to create a redirect response + * + * @return $this + */ + public function redirect( + string|null $location = null, + int|null $code = null + ) { + $location = Url::to($location ?? '/'); + $location = Url::unIdn($location); + + return $this + ->header('Location', (string)$location) + ->code($code ?? 302); + } + + /** + * Creates and returns the response object from the config + */ + public function send(string $body = null): Response + { + if ($body !== null) { + $this->body($body); + } + + return new Response($this->toArray()); + } + + /** + * Converts the response configuration + * to an array + */ + public function toArray(): array + { + // the `cache`, `expires`, `usesAuth` and `usesCookies` + // values are explicitly *not* serialized as they are + // volatile and not to be exported + return [ + 'body' => $this->body(), + 'code' => $this->code(), + 'headers' => $this->headers(), + 'type' => $this->type(), + ]; + } + + /** + * Setter and getter for the content type + * + * @return string|$this + */ + public function type(string $type = null) + { + if ($type === null) { + return $this->type; + } + + if (Str::contains($type, '/') === false) { + $type = Mime::fromExtension($type); + } + + $this->type = $type; + return $this; + } + + /** + * Checks whether the response needs to be exempted from + * all caches due to using dynamic data based on auth + * and/or cookies; the request data only matters if it + * is actually used/relied on by the response + * @since 3.7.0 + * @internal + */ + public static function isPrivate(bool $usesAuth, array $usesCookies): bool + { + $kirby = App::instance(); + + if ($usesAuth === true && $kirby->request()->hasAuth() === true) { + return true; + } + + foreach ($usesCookies as $cookie) { + if (isset($_COOKIE[$cookie]) === true) { + return true; + } + } + + return false; + } +} diff --git a/kirby/src/Cms/Response.php b/kirby/src/Cms/Response.php new file mode 100644 index 0000000..5a21bc6 --- /dev/null +++ b/kirby/src/Cms/Response.php @@ -0,0 +1,28 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Response extends \Kirby\Http\Response +{ + /** + * Adjusted redirect creation which + * parses locations with the Url::to method + * first. + */ + public static function redirect( + string $location = '/', + int $code = 302 + ): static { + return parent::redirect(Url::to($location), $code); + } +} diff --git a/kirby/src/Cms/Role.php b/kirby/src/Cms/Role.php new file mode 100644 index 0000000..3579b62 --- /dev/null +++ b/kirby/src/Cms/Role.php @@ -0,0 +1,149 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Role +{ + protected string|null $description; + protected string $name; + protected Permissions $permissions; + protected string|null $title; + + public function __construct(array $props) + { + $this->name = $props['name']; + $this->permissions = new Permissions($props['permissions'] ?? null); + $title = $props['title'] ?? null; + $this->title = I18n::translate($title) ?? $title; + $description = $props['description'] ?? null; + $this->description = I18n::translate($description) ?? $description; + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + public function __toString(): string + { + return $this->name(); + } + + public static function admin(array $inject = []): static + { + try { + return static::load('admin'); + } catch (Exception) { + return static::factory(static::defaults()['admin'], $inject); + } + } + + protected static function defaults(): array + { + return [ + 'admin' => [ + 'name' => 'admin', + 'description' => I18n::translate('role.admin.description'), + 'title' => I18n::translate('role.admin.title'), + 'permissions' => true, + ], + 'nobody' => [ + 'name' => 'nobody', + 'description' => I18n::translate('role.nobody.description'), + 'title' => I18n::translate('role.nobody.title'), + 'permissions' => false, + ] + ]; + } + + public function description(): string|null + { + return $this->description; + } + + public static function factory(array $props, array $inject = []): static + { + return new static($props + $inject); + } + + public function id(): string + { + return $this->name(); + } + + public function isAdmin(): bool + { + return $this->name() === 'admin'; + } + + public function isNobody(): bool + { + return $this->name() === 'nobody'; + } + + public static function load(string $file, array $inject = []): static + { + $data = Data::read($file); + $data['name'] = F::name($file); + + return static::factory($data, $inject); + } + + public function name(): string + { + return $this->name; + } + + public static function nobody(array $inject = []): static + { + try { + return static::load('nobody'); + } catch (Exception) { + return static::factory(static::defaults()['nobody'], $inject); + } + } + + public function permissions(): Permissions + { + return $this->permissions; + } + + public function title(): string + { + return $this->title ??= ucfirst($this->name()); + } + + /** + * Converts the most important role + * properties to an array + */ + public function toArray(): array + { + return [ + 'description' => $this->description(), + 'id' => $this->id(), + 'name' => $this->name(), + 'permissions' => $this->permissions()->toArray(), + 'title' => $this->title(), + ]; + } +} diff --git a/kirby/src/Cms/Roles.php b/kirby/src/Cms/Roles.php new file mode 100644 index 0000000..11be6c9 --- /dev/null +++ b/kirby/src/Cms/Roles.php @@ -0,0 +1,140 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Roles extends Collection +{ + /** + * All registered roles methods + */ + public static array $methods = []; + + /** + * Returns a filtered list of all + * roles that can be created by the + * current user + * + * @return $this|static + * @throws \Exception + */ + public function canBeChanged(): static + { + if (App::instance()->user()) { + return $this->filter(function ($role) { + $newUser = new User([ + 'email' => 'test@getkirby.com', + 'role' => $role->id() + ]); + + return $newUser->permissions()->can('changeRole'); + }); + } + + return $this; + } + + /** + * Returns a filtered list of all + * roles that can be created by the + * current user + * + * @return $this|static + * @throws \Exception + */ + public function canBeCreated(): static + { + if (App::instance()->user()) { + return $this->filter(function ($role) { + $newUser = new User([ + 'email' => 'test@getkirby.com', + 'role' => $role->id() + ]); + + return $newUser->permissions()->can('create'); + }); + } + + return $this; + } + + public static function factory(array $roles, array $inject = []): static + { + $collection = new static(); + + // read all user blueprints + foreach ($roles as $props) { + $role = Role::factory($props, $inject); + $collection->set($role->id(), $role); + } + + // always include the admin role + if ($collection->find('admin') === null) { + $collection->set('admin', Role::admin()); + } + + // return the collection sorted by name + return $collection->sort('name', 'asc'); + } + + public static function load(string $root = null, array $inject = []): static + { + $kirby = App::instance(); + $roles = new static(); + + // load roles from plugins + foreach ($kirby->extensions('blueprints') as $blueprintName => $blueprint) { + if (substr($blueprintName, 0, 6) !== 'users/') { + continue; + } + + // callback option can be return array or blueprint file path + if (is_callable($blueprint) === true) { + $blueprint = $blueprint($kirby); + } + + if (is_array($blueprint) === true) { + $role = Role::factory($blueprint, $inject); + } else { + $role = Role::load($blueprint, $inject); + } + + $roles->set($role->id(), $role); + } + + // load roles from directory + if ($root !== null) { + foreach (glob($root . '/*.yml') as $file) { + $filename = basename($file); + + if ($filename === 'default.yml') { + continue; + } + + $role = Role::load($file, $inject); + $roles->set($role->id(), $role); + } + } + + // always include the admin role + if ($roles->find('admin') === null) { + $roles->set('admin', Role::admin($inject)); + } + + // return the collection sorted by name + return $roles->sort('name', 'asc'); + } +} diff --git a/kirby/src/Cms/S.php b/kirby/src/Cms/S.php new file mode 100644 index 0000000..260ee30 --- /dev/null +++ b/kirby/src/Cms/S.php @@ -0,0 +1,23 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class S extends Facade +{ + public static function instance(): Session + { + return App::instance()->session(); + } +} diff --git a/kirby/src/Cms/Search.php b/kirby/src/Cms/Search.php new file mode 100644 index 0000000..14add5c --- /dev/null +++ b/kirby/src/Cms/Search.php @@ -0,0 +1,51 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Search +{ + public static function files( + string $query = null, + array $params = [] + ): Files { + return App::instance()->site()->index()->files()->search($query, $params); + } + + /** + * Native search method to search for anything within the collection + */ + public static function collection( + Collection $collection, + string|null $query = null, + string|array $params = [] + ): Collection { + $kirby = App::instance(); + return ($kirby->component('search'))($kirby, $collection, $query, $params); + } + + public static function pages( + string $query = null, + array $params = [] + ): Pages { + return App::instance()->site()->index()->search($query, $params); + } + + public static function users( + string $query = null, + array $params = [] + ): Users { + return App::instance()->users()->search($query, $params); + } +} diff --git a/kirby/src/Cms/Section.php b/kirby/src/Cms/Section.php new file mode 100644 index 0000000..56402be --- /dev/null +++ b/kirby/src/Cms/Section.php @@ -0,0 +1,86 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Section extends Component +{ + /** + * Registry for all component mixins + */ + public static array $mixins = []; + + /** + * Registry for all component types + */ + public static array $types = []; + + /** + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function __construct(string $type, array $attrs = []) + { + if (isset($attrs['model']) === false) { + throw new InvalidArgumentException('Undefined section model'); + } + + if ($attrs['model'] instanceof ModelWithContent === false) { + throw new InvalidArgumentException('Invalid section model'); + } + + // use the type as fallback for the name + $attrs['name'] ??= $type; + $attrs['type'] = $type; + + parent::__construct($type, $attrs); + } + + public function errors(): array + { + if (array_key_exists('errors', $this->methods) === true) { + return $this->methods['errors']->call($this); + } + + return $this->errors ?? []; + } + + public function kirby(): App + { + return $this->model()->kirby(); + } + + public function model(): ModelWithContent + { + return $this->model; + } + + public function toArray(): array + { + $array = parent::toArray(); + + unset($array['model']); + + return $array; + } + + public function toResponse(): array + { + return array_merge([ + 'status' => 'ok', + 'code' => 200, + 'name' => $this->name, + 'type' => $this->type + ], $this->toArray()); + } +} diff --git a/kirby/src/Cms/Site.php b/kirby/src/Cms/Site.php new file mode 100644 index 0000000..cab166d --- /dev/null +++ b/kirby/src/Cms/Site.php @@ -0,0 +1,508 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Site extends ModelWithContent +{ + use HasChildren; + use HasFiles; + use HasMethods; + use SiteActions; + + public const CLASS_ALIAS = 'site'; + + /** + * The SiteBlueprint object + */ + protected SiteBlueprint|null $blueprint = null; + + /** + * The error page object + */ + protected Page|null $errorPage = null; + + /** + * The id of the error page, which is + * fetched in the errorPage method + */ + protected string $errorPageId; + + /** + * The home page object + */ + protected Page|null $homePage = null; + + /** + * The id of the home page, which is + * fetched in the errorPage method + */ + protected string $homePageId; + + /** + * Cache for the inventory array + */ + protected array|null $inventory = null; + + /** + * The current page object + */ + protected Page|null $page; + + /** + * The absolute path to the site directory + */ + protected string $root; + + /** + * The page url + */ + protected string|null $url; + + /** + * Creates a new Site object + */ + public function __construct(array $props = []) + { + parent::__construct($props); + + $this->errorPageId = $props['errorPageId'] ?? 'error'; + $this->homePageId = $props['homePageId'] ?? 'home'; + $this->page = $props['page'] ?? null; + $this->url = $props['url'] ?? null; + + $this->setBlueprint($props['blueprint'] ?? null); + $this->setChildren($props['children'] ?? null); + $this->setDrafts($props['drafts'] ?? null); + $this->setFiles($props['files'] ?? null); + } + + /** + * Modified getter to also return fields + * from the content + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // site methods + if ($this->hasMethod($method)) { + return $this->callMethod($method, $arguments); + } + + // return site content otherwise + return $this->content()->get($method); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return array_merge($this->toArray(), [ + 'content' => $this->content(), + 'children' => $this->children(), + 'files' => $this->files(), + ]); + } + + /** + * Makes it possible to convert the site model + * to a string. Mostly useful for debugging. + */ + public function __toString(): string + { + return $this->url(); + } + + /** + * Returns the url to the api endpoint + * @internal + */ + public function apiUrl(bool $relative = false): string + { + if ($relative === true) { + return 'site'; + } + + return $this->kirby()->url('api') . '/site'; + } + + /** + * Returns the blueprint object + */ + public function blueprint(): SiteBlueprint + { + if ($this->blueprint instanceof SiteBlueprint) { + return $this->blueprint; + } + + return $this->blueprint = SiteBlueprint::factory('site', null, $this); + } + + /** + * Builds a breadcrumb collection + */ + public function breadcrumb(): Pages + { + // get all parents and flip the order + $crumb = $this->page()->parents()->flip(); + + // add the home page + $crumb->prepend($this->homePage()->id(), $this->homePage()); + + // add the active page + $crumb->append($this->page()->id(), $this->page()); + + return $crumb; + } + + /** + * Prepares the content for the write method + * @internal + */ + public function contentFileData( + array $data, + string|null $languageCode = null + ): array { + return A::prepend($data, ['title' => $data['title'] ?? null]); + } + + /** + * Filename for the content file + * @internal + * @deprecated 4.0.0 + * @todo Remove in v5 + * @codeCoverageIgnore + */ + public function contentFileName(): string + { + Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); + return 'site'; + } + + /** + * Returns the error page object + */ + public function errorPage(): Page|null + { + return $this->errorPage ??= $this->find($this->errorPageId()); + } + + /** + * Returns the global error page id + * @internal + */ + public function errorPageId(): string + { + return $this->errorPageId ?? 'error'; + } + + /** + * Checks if the site exists on disk + */ + public function exists(): bool + { + return is_dir($this->root()) === true; + } + + /** + * Returns the home page object + */ + public function homePage(): Page|null + { + return $this->homePage ??= $this->find($this->homePageId()); + } + + /** + * Returns the global home page id + * @internal + */ + public function homePageId(): string + { + return $this->homePageId ?? 'home'; + } + + /** + * Creates an inventory of all files + * and children in the site directory + * @internal + */ + public function inventory(): array + { + if ($this->inventory !== null) { + return $this->inventory; + } + + $kirby = $this->kirby(); + + return $this->inventory = Dir::inventory( + $this->root(), + $kirby->contentExtension(), + $kirby->contentIgnore(), + $kirby->multilang() + ); + } + + /** + * Compares the current object with the given site object + * + * @param mixed $site + */ + public function is($site): bool + { + if ($site instanceof self === false) { + return false; + } + + return $this === $site; + } + + /** + * Returns the root to the media folder for the site + * @internal + */ + public function mediaRoot(): string + { + return $this->kirby()->root('media') . '/site'; + } + + /** + * The site's base url for any files + * @internal + */ + public function mediaUrl(): string + { + return $this->kirby()->url('media') . '/site'; + } + + /** + * Gets the last modification date of all pages + * in the content folder. + */ + public function modified( + string|null $format = null, + string|null $handler = null + ): int|string { + return Dir::modified($this->root(), $format, $handler); + } + + /** + * Returns the current page if `$path` + * is not specified. Otherwise it will try + * to find a page by the given path. + * + * If no current page is set with the page + * prop, the home page will be returned if + * it can be found. (see `Site::homePage()`) + * + * @param string|null $path omit for current page, + * otherwise e.g. `notes/across-the-ocean` + */ + public function page(string|null $path = null): Page|null + { + if ($path !== null) { + return $this->find($path); + } + + if ($this->page instanceof Page) { + return $this->page; + } + + try { + return $this->page = $this->homePage(); + } catch (LogicException) { + return $this->page = null; + } + } + + /** + * Alias for `Site::children()` + */ + public function pages(): Pages + { + return $this->children(); + } + + /** + * Returns the panel info object + */ + public function panel(): Panel + { + return new Panel($this); + } + + /** + * Returns the permissions object for this site + */ + public function permissions(): SitePermissions + { + return new SitePermissions($this); + } + + /** + * Preview Url + * @internal + */ + public function previewUrl(): string|null + { + $preview = $this->blueprint()->preview(); + + if ($preview === false) { + return null; + } + + if ($preview === true) { + $url = $this->url(); + } else { + $url = $preview; + } + + return $url; + } + + /** + * Returns the absolute path to the content directory + */ + public function root(): string + { + return $this->root ??= $this->kirby()->root('content'); + } + + /** + * Returns the SiteRules class instance + * which is being used in various methods + * to check for valid actions and input. + */ + protected function rules(): SiteRules + { + return new SiteRules(); + } + + /** + * Search all pages in the site + */ + public function search(string|null $query = null, string|array $params = []): Pages + { + return $this->index()->search($query, $params); + } + + /** + * Sets the Blueprint object + * + * @return $this + */ + protected function setBlueprint(array|null $blueprint = null): static + { + if ($blueprint !== null) { + $blueprint['model'] = $this; + $this->blueprint = new SiteBlueprint($blueprint); + } + + return $this; + } + + /** + * Converts the most important site + * properties to an array + */ + public function toArray(): array + { + return array_merge(parent::toArray(), [ + 'children' => $this->children()->keys(), + 'errorPage' => $this->errorPage()?->id() ?? false, + 'files' => $this->files()->keys(), + 'homePage' => $this->homePage()?->id() ?? false, + 'page' => $this->page()?->id() ?? false, + 'title' => $this->title()->value(), + 'url' => $this->url(), + ]); + } + + /** + * Returns the Url + */ + public function url(string|null $language = null): string + { + if ($language !== null || $this->kirby()->multilang() === true) { + return $this->urlForLanguage($language); + } + + return $this->url ?? $this->kirby()->url(); + } + + /** + * Returns the translated url + * @internal + */ + public function urlForLanguage( + string|null $languageCode = null, + array|null $options = null + ): string { + if ($language = $this->kirby()->language($languageCode)) { + return $language->url(); + } + + return $this->kirby()->url(); + } + + /** + * Sets the current page by + * id or page object and + * returns the current page + * @internal + */ + public function visit( + string|Page $page, + string|null $languageCode = null + ): Page { + if ($languageCode !== null) { + $this->kirby()->setCurrentTranslation($languageCode); + $this->kirby()->setCurrentLanguage($languageCode); + } + + // convert ids to a Page object + if (is_string($page) === true) { + $page = $this->find($page); + } + + // handle invalid pages + if ($page instanceof Page === false) { + throw new InvalidArgumentException('Invalid page object'); + } + + // set and return the current active page + return $this->page = $page; + } + + /** + * Checks if any content of the site has been + * modified after the given unix timestamp + * This is mainly used to auto-update the cache + */ + public function wasModifiedAfter(int $time): bool + { + return Dir::wasModifiedAfter($this->root(), $time); + } +} diff --git a/kirby/src/Cms/SiteActions.php b/kirby/src/Cms/SiteActions.php new file mode 100644 index 0000000..8f86eb4 --- /dev/null +++ b/kirby/src/Cms/SiteActions.php @@ -0,0 +1,96 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait SiteActions +{ + /** + * Commits a site action, by following these steps + * + * 1. checks the action rules + * 2. sends the before hook + * 3. commits the store action + * 4. sends the after hook + * 5. returns the result + */ + protected function commit( + string $action, + array $arguments, + Closure $callback + ): mixed { + $old = $this->hardcopy(); + $kirby = $this->kirby(); + $argumentValues = array_values($arguments); + + $this->rules()->$action(...$argumentValues); + $kirby->trigger('site.' . $action . ':before', $arguments); + + $result = $callback(...$argumentValues); + + $kirby->trigger('site.' . $action . ':after', ['newSite' => $result, 'oldSite' => $old]); + + $kirby->cache('pages')->flush(); + return $result; + } + + /** + * Change the site title + */ + public function changeTitle( + string $title, + string $languageCode = null + ): static { + $site = $this; + $title = trim($title); + $arguments = compact('site', 'title', 'languageCode'); + + return $this->commit('changeTitle', $arguments, function ($site, $title, $languageCode) { + return $site->save(['title' => $title], $languageCode); + }); + } + + /** + * Creates a main page + */ + public function createChild(array $props): Page + { + $props = array_merge($props, [ + 'url' => null, + 'num' => null, + 'parent' => null, + 'site' => $this, + ]); + + return Page::create($props); + } + + /** + * Clean internal caches + * + * @return $this + */ + public function purge(): static + { + parent::purge(); + + $this->blueprint = null; + $this->children = null; + $this->childrenAndDrafts = null; + $this->drafts = null; + $this->files = null; + $this->inventory = null; + + return $this; + } +} diff --git a/kirby/src/Cms/SiteBlueprint.php b/kirby/src/Cms/SiteBlueprint.php new file mode 100644 index 0000000..5f8b751 --- /dev/null +++ b/kirby/src/Cms/SiteBlueprint.php @@ -0,0 +1,56 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class SiteBlueprint extends Blueprint +{ + /** + * Creates a new page blueprint object + * with the given props + */ + public function __construct(array $props) + { + parent::__construct($props); + + // normalize all available page options + $this->props['options'] = $this->normalizeOptions( + $this->props['options'] ?? true, + // defaults + [ + 'changeTitle' => null, + 'update' => null, + ], + // aliases + [ + 'title' => 'changeTitle', + ] + ); + } + + /** + * Returns the preview settings + * The preview setting controls the "Open" + * button in the panel and redirects it to a + * different URL if necessary. + */ + public function preview(): string|bool + { + $preview = $this->props['options']['preview'] ?? true; + + if (is_string($preview) === true) { + return $this->model->toString($preview); + } + + return $preview; + } +} diff --git a/kirby/src/Cms/SitePermissions.php b/kirby/src/Cms/SitePermissions.php new file mode 100644 index 0000000..8e58415 --- /dev/null +++ b/kirby/src/Cms/SitePermissions.php @@ -0,0 +1,17 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class SitePermissions extends ModelPermissions +{ + protected string $category = 'site'; +} diff --git a/kirby/src/Cms/SiteRules.php b/kirby/src/Cms/SiteRules.php new file mode 100644 index 0000000..08da997 --- /dev/null +++ b/kirby/src/Cms/SiteRules.php @@ -0,0 +1,52 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class SiteRules +{ + /** + * Validates if the site title can be changed + * + * @throws \Kirby\Exception\InvalidArgumentException If the title is empty + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the title + */ + public static function changeTitle(Site $site, string $title): bool + { + if ($site->permissions()->changeTitle() !== true) { + throw new PermissionException(['key' => 'site.changeTitle.permission']); + } + + if (Str::length($title) === 0) { + throw new InvalidArgumentException(['key' => 'site.changeTitle.empty']); + } + + return true; + } + + /** + * Validates if the site can be updated + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to update the site + */ + public static function update(Site $site, array $content = []): bool + { + if ($site->permissions()->update() !== true) { + throw new PermissionException(['key' => 'site.update.permission']); + } + + return true; + } +} diff --git a/kirby/src/Cms/Structure.php b/kirby/src/Cms/Structure.php new file mode 100644 index 0000000..9721869 --- /dev/null +++ b/kirby/src/Cms/Structure.php @@ -0,0 +1,49 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Structure extends Items +{ + public const ITEM_CLASS = StructureObject::class; + + /** + * All registered structure methods + */ + public static array $methods = []; + + /** + * Creates a new structure collection from a + * an array of item props + */ + public static function factory( + array $items = null, + array $params = [] + ): static { + // Bake-in index as ID for all items + // TODO: remove when adding UUID supports to Structures + if (is_array($items) === true) { + $items = array_map(function ($item, $index) { + if (is_array($item) === true) { + $item['id'] ??= $index; + } + return $item; + }, $items, array_keys($items)); + } + + return parent::factory($items, $params); + } +} diff --git a/kirby/src/Cms/StructureObject.php b/kirby/src/Cms/StructureObject.php new file mode 100644 index 0000000..9806b7d --- /dev/null +++ b/kirby/src/Cms/StructureObject.php @@ -0,0 +1,85 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class StructureObject extends Item +{ + use HasMethods; + + public const ITEMS_CLASS = Structure::class; + + protected Content $content; + + /** + * Creates a new StructureObject with the given props + */ + public function __construct(array $params = []) + { + parent::__construct($params); + + $this->content = new Content( + $params['content'] ?? $params['params'] ?? [], + $this->parent + ); + } + + /** + * Modified getter to also return fields + * from the object's content + */ + public function __call(string $method, array $args = []): mixed + { + // structure object methods + if ($this->hasMethod($method) === true) { + return $this->callMethod($method, $args); + } + + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + return $this->content()->get($method); + } + + /** + * Returns the content + */ + public function content(): Content + { + return $this->content; + } + + /** + * Converts all fields in the object to a + * plain associative array. The id is + * injected from the parent into the array + * to make sure it's always present and + * not overloaded by the content. + */ + public function toArray(): array + { + return array_merge( + $this->content()->toArray(), + parent::toArray() + ); + } +} diff --git a/kirby/src/Cms/System.php b/kirby/src/Cms/System.php new file mode 100644 index 0000000..472c756 --- /dev/null +++ b/kirby/src/Cms/System.php @@ -0,0 +1,511 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class System +{ + // cache + protected License|null $license = null; + protected UpdateStatus|null $updateStatus = null; + + public function __construct(protected App $app) + { + // try to create all folders that could be missing + $this->init(); + } + + /** + * Check for a writable accounts folder + */ + public function accounts(): bool + { + return is_writable($this->app->root('accounts')) === true; + } + + /** + * Check for a writable content folder + */ + public function content(): bool + { + return is_writable($this->app->root('content')) === true; + } + + /** + * Check for an existing curl extension + */ + public function curl(): bool + { + return extension_loaded('curl') === true; + } + + /** + * Returns the URL to the file within a system folder + * if the file is located in the document + * root. Otherwise it will return null. + * + * @param string $folder 'git', 'content', 'site', 'kirby' + */ + public function exposedFileUrl(string $folder): string|null + { + if (!$url = $this->folderUrl($folder)) { + return null; + } + + switch ($folder) { + case 'content': + return $url . '/' . basename($this->app->site()->storage()->contentFile( + 'published', + 'default' + )); + case 'git': + return $url . '/config'; + case 'kirby': + return $url . '/composer.json'; + case 'site': + $root = $this->app->root('site'); + $files = glob($root . '/blueprints/*.yml'); + + if (empty($files) === true) { + $files = glob($root . '/templates/*.*'); + } + + if (empty($files) === true) { + $files = glob($root . '/snippets/*.*'); + } + + if (empty($files) === true || empty($files[0]) === true) { + return $url; + } + + $file = $files[0]; + $file = basename(dirname($file)) . '/' . basename($file); + + return $url . '/' . $file; + default: + return null; + } + } + + /** + * Returns the URL to a system folder + * if the folder is located in the document + * root. Otherwise it will return null. + * + * @param string $folder 'git', 'content', 'site', 'kirby' + */ + public function folderUrl(string $folder): string|null + { + $index = $this->app->root('index'); + $root = match ($folder) { + 'git' => $index . '/.git', + default => $this->app->root($folder) + }; + + if ( + $root === null || + is_dir($root) === false || + is_dir($index) === false + ) { + return null; + } + + $root = realpath($root); + $index = realpath($index); + + // windows + $root = str_replace('\\', '/', $root); + $index = str_replace('\\', '/', $index); + + // the folder is not within the document root? + if (Str::startsWith($root, $index) === false) { + return null; + } + + // get the path after the document root + $path = trim(Str::after($root, $index), '/'); + + // build the absolute URL to the folder + return Url::to($path); + } + + /** + * Returns the app's human-readable + * index URL without scheme + */ + public function indexUrl(): string + { + return $this->app->url('index', true) + ->setScheme(null) + ->setSlash(false) + ->toString(); + } + + /** + * Create the most important folders + * if they don't exist yet + * + * @throws \Kirby\Exception\PermissionException + */ + public function init(): void + { + // init /site/accounts + try { + Dir::make($this->app->root('accounts')); + } catch (Throwable) { + throw new PermissionException('The accounts directory could not be created'); + } + + // init /site/sessions + try { + Dir::make($this->app->root('sessions')); + } catch (Throwable) { + throw new PermissionException('The sessions directory could not be created'); + } + + // init /content + try { + Dir::make($this->app->root('content')); + } catch (Throwable) { + throw new PermissionException('The content directory could not be created'); + } + + // init /media + try { + Dir::make($this->app->root('media')); + } catch (Throwable) { + throw new PermissionException('The media directory could not be created'); + } + } + + /** + * Check if the Panel has 2FA activated + */ + public function is2FA(): bool + { + return ($this->loginMethods()['password']['2fa'] ?? null) === true; + } + + /** + * Check if the Panel has 2FA with TOTP activated + */ + public function is2FAWithTOTP(): bool + { + return + $this->is2FA() === true && + in_array('totp', $this->app->auth()->enabledChallenges()) === true; + } + + /** + * Check if the Panel is installable. + * On a public server the panel.install + * option must be explicitly set to true + * to get the installer up and running. + */ + public function isInstallable(): bool + { + return + $this->isLocal() === true || + $this->app->option('panel.install', false) === true; + } + + /** + * Check if Kirby is already installed + */ + public function isInstalled(): bool + { + return $this->app->users()->count() > 0; + } + + /** + * Check if this is a local installation + */ + public function isLocal(): bool + { + return $this->app->environment()->isLocal(); + } + + /** + * Check if all tests pass + */ + public function isOk(): bool + { + return in_array(false, array_values($this->status()), true) === false; + } + + /** + * Loads the license file and returns + * the license information if available + */ + public function license(): License + { + return $this->license ??= License::read(); + } + + /** + * Returns the configured UI modes for the login form + * with their respective options + * + * @throws \Kirby\Exception\InvalidArgumentException If the configuration is invalid + * (only in debug mode) + */ + public function loginMethods(): array + { + $default = ['password' => []]; + $methods = A::wrap($this->app->option('auth.methods', $default)); + + // normalize the syntax variants + $normalized = []; + $uses2fa = false; + foreach ($methods as $key => $value) { + if (is_int($key) === true) { + // ['password'] + $normalized[$value] = []; + } elseif ($value === true) { + // ['password' => true] + $normalized[$key] = []; + } else { + // ['password' => [...]] + $normalized[$key] = $value; + + if (isset($value['2fa']) === true && $value['2fa'] === true) { + $uses2fa = true; + } + } + } + + // 2FA must not be circumvented by code-based modes + foreach (['code', 'password-reset'] as $method) { + if ($uses2fa === true && isset($normalized[$method]) === true) { + unset($normalized[$method]); + + if ($this->app->option('debug') === true) { + $message = 'The "' . $method . '" login method cannot be enabled when 2FA is required'; + throw new InvalidArgumentException($message); + } + } + } + + // only one code-based mode can be active at once + if ( + isset($normalized['code']) === true && + isset($normalized['password-reset']) === true + ) { + unset($normalized['code']); + + if ($this->app->option('debug') === true) { + $message = 'The "code" and "password-reset" login methods cannot be enabled together'; + throw new InvalidArgumentException($message); + } + } + + return $normalized; + } + + /** + * Check for an existing mbstring extension + */ + public function mbString(): bool + { + return extension_loaded('mbstring') === true; + } + + /** + * Check for a writable media folder + */ + public function media(): bool + { + return is_writable($this->app->root('media')) === true; + } + + /** + * Check for a valid PHP version + */ + public function php(): bool + { + return + version_compare(PHP_VERSION, '8.1.0', '>=') === true && + version_compare(PHP_VERSION, '8.4.0', '<') === true; + } + + /** + * Returns a sorted collection of all + * installed plugins + */ + public function plugins(): Collection + { + $plugins = new Collection($this->app->plugins()); + return $plugins->sortBy('name', 'asc'); + } + + /** + * Validates the license key + * and adds it to the .license file in the config + * folder if possible. + * + * @throws \Kirby\Exception\Exception + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function register(string $license = null, string $email = null): bool + { + $license = new License( + code: $license, + domain: $this->indexUrl(), + email: $email, + ); + + $this->license = $license->register(); + return true; + } + + /** + * Check for a valid server environment + */ + public function server(): bool + { + return $this->serverSoftware() !== null; + } + + /** + * Returns the detected server software + */ + public function serverSoftware(): string|null + { + $servers = $this->app->option('servers', [ + 'apache', + 'caddy', + 'litespeed', + 'nginx', + 'php' + ]); + + $software = $this->app->environment()->get('SERVER_SOFTWARE', ''); + + preg_match('!(' . implode('|', A::wrap($servers)) . ')!i', $software, $matches); + + return $matches[0] ?? null; + } + + /** + * Check for a writable sessions folder + */ + public function sessions(): bool + { + return is_writable($this->app->root('sessions')) === true; + } + + /** + * Get an status array of all checks + */ + public function status(): array + { + return [ + 'accounts' => $this->accounts(), + 'content' => $this->content(), + 'curl' => $this->curl(), + 'sessions' => $this->sessions(), + 'mbstring' => $this->mbstring(), + 'media' => $this->media(), + 'php' => $this->php(), + 'server' => $this->server(), + ]; + } + + /** + * Returns the site's title as defined in the + * content file or `site.yml` blueprint + * @since 3.6.0 + */ + public function title(): string + { + $site = $this->app->site(); + + if ($site->title()->isNotEmpty() === true) { + return $site->title()->value(); + } + + return $site->blueprint()->title(); + } + + public function toArray(): array + { + return $this->status(); + } + + /** + * Returns the update status object unless + * the update check for Kirby has been disabled + * @since 3.8.0 + * + * @param array|null $data Custom override for the getkirby.com update data + */ + public function updateStatus(array|null $data = null): UpdateStatus|null + { + if ($this->updateStatus !== null) { + return $this->updateStatus; + } + + $kirby = $this->app; + $option = + $kirby->option('updates.kirby') ?? + $kirby->option('updates', true); + + if ($option === false) { + return null; + } + + return $this->updateStatus = new UpdateStatus( + $kirby, + $option === 'security', + $data + ); + } + + /** + * Upgrade to the new folder separator + */ + public static function upgradeContent(string $root): void + { + $index = Dir::read($root); + + foreach ($index as $dir) { + $oldRoot = $root . '/' . $dir; + $newRoot = preg_replace('!\/([0-9]+)\-!', '/$1_', $oldRoot); + + if (is_dir($oldRoot) === true) { + Dir::move($oldRoot, $newRoot); + static::upgradeContent($newRoot); + } + } + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } +} diff --git a/kirby/src/Cms/System/UpdateStatus.php b/kirby/src/Cms/System/UpdateStatus.php new file mode 100644 index 0000000..0daa25b --- /dev/null +++ b/kirby/src/Cms/System/UpdateStatus.php @@ -0,0 +1,783 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UpdateStatus +{ + /** + * Host to request the update data from + */ + public static string $host = 'https://assets.getkirby.com'; + + /** + * Marker that stores whether a previous remote + * request timed out + */ + protected static bool $timedOut = false; + + // props set in constructor + protected App $app; + protected string|null $currentVersion; + protected array|null $data; + protected string|null $pluginName; + protected bool $securityOnly; + + // props updated throughout the class + protected array $exceptions = []; + protected bool|null $noVulns = null; + + // caches + protected array $messages; + protected array $targetData; + protected array|bool $versionEntry; + protected array $vulnerabilities; + + /** + * @param array|null $data Custom override for the getkirby.com update data + */ + public function __construct( + App|Plugin $package, + bool $securityOnly = false, + array|null $data = null + ) { + if ($package instanceof App) { + $this->app = $package; + $this->pluginName = null; + } else { + $this->app = $package->kirby(); + $this->pluginName = $package->name(); + } + + $this->securityOnly = $securityOnly; + $this->currentVersion = $package->version(); + + $this->data = $data ?? $this->loadData(); + } + + /** + * Returns the currently installed version + */ + public function currentVersion(): string|null + { + return $this->currentVersion; + } + + /** + * Returns the list of exception objects that were + * collected during data fetching and processing + */ + public function exceptions(): array + { + return $this->exceptions; + } + + /** + * Returns the list of exception message strings that + * were collected during data fetching and processing + */ + public function exceptionMessages(): array + { + return array_map(fn ($e) => $e->getMessage(), $this->exceptions()); + } + + /** + * Returns the Panel icon for the status value + * + * @return string 'check'|'alert'|'info' + */ + public function icon(): string + { + return match ($this->status()) { + 'up-to-date', 'not-vulnerable' => 'check', + 'security-update', 'security-upgrade' => 'alert', + 'update', 'upgrade' => 'info', + default => 'question' + }; + } + + /** + * Returns the human-readable and translated label + * for the update status + */ + public function label(): string + { + return I18n::template( + 'system.updateStatus.' . $this->status(), + '?', + ['version' => $this->targetVersion() ?? '?'] + ); + } + + /** + * Returns the latest available version + */ + public function latestVersion(): string|null + { + return $this->data['latest'] ?? null; + } + + /** + * Returns all security messages unless no data + * is available + */ + public function messages(): array|null + { + if (isset($this->messages) === true) { + return $this->messages; + } + + if ( + $this->data === null || + $this->currentVersion === null || + $this->currentVersion === '' + ) { + return null; + } + + $type = $this->pluginName ? 'plugin' : 'kirby'; + + // collect all matching custom messages + $filters = [ + 'kirby' => $this->app->version(), + 'php' => phpversion() + ]; + + if ($type === 'plugin') { + $filters['plugin'] = $this->currentVersion; + } + + $messages = $this->filterArrayByVersion( + $this->data['messages'] ?? [], + $filters, + 'while filtering messages' + ); + + // add a message for each vulnerability + // the current version is affected by + foreach ($this->vulnerabilities() as $vulnerability) { + if ($type === 'plugin') { + $vulnerability['plugin'] = $this->pluginName; + } + + $messages[] = [ + 'text' => I18n::template( + 'system.issues.vulnerability.' . $type, + null, + $vulnerability + ), + 'link' => $vulnerability['link'] ?? null, + 'icon' => 'bug' + ]; + } + + // add special message for end-of-life versions + $versionEntry = $this->versionEntry(); + if (($versionEntry['status'] ?? null) === 'end-of-life') { + $messages[] = [ + 'text' => match ($type) { + 'plugin' => I18n::template( + 'system.issues.eol.plugin', + null, + ['plugin' => $this->pluginName] + ), + default => I18n::translate('system.issues.eol.kirby') + }, + 'link' => $versionEntry['status-link'] ?? 'https://getkirby.com/security/end-of-life', + 'icon' => 'bell' + ]; + } + + // add special message for end-of-life PHP versions + $phpMajor = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; + $phpEol = $this->data['php'][$phpMajor] ?? null; + if (is_string($phpEol) === true && $eolTime = strtotime($phpEol)) { + // the timestamp is available and valid, now check if it is in the past + if ($eolTime < time()) { + $messages[] = [ + 'text' => I18n::template('system.issues.eol.php', null, ['release' => $phpMajor]), + 'link' => 'https://getkirby.com/security/php-end-of-life', + 'icon' => 'bell' + ]; + } + } + + return $this->messages = $messages; + } + + /** + * Returns the raw status value + * + * @return string 'up-to-date'|'not-vulnerable'|'security-update'| + * 'security-upgrade'|'update'|'upgrade'|'unreleased'|'error' + */ + public function status(): string + { + return $this->targetData()['status']; + } + + /** + * Version that is suggested for the update/upgrade + */ + public function targetVersion(): string|null + { + return $this->targetData()['version']; + } + + /** + * Returns the Panel theme for the status value + * + * @return string 'positive'|'negative'|'info'|'notice' + */ + public function theme(): string + { + return match ($this->status()) { + 'up-to-date', 'not-vulnerable' => 'positive', + 'security-update', 'security-upgrade' => 'negative', + 'update', 'upgrade' => 'info', + default => 'notice' + }; + } + + /** + * Returns the most important human-readable + * status information as array + */ + public function toArray(): array + { + return [ + 'currentVersion' => $this->currentVersion() ?? '?', + 'icon' => $this->icon(), + 'label' => $this->label(), + 'latestVersion' => $this->latestVersion() ?? '?', + 'pluginName' => $this->pluginName, + 'theme' => $this->theme(), + 'url' => $this->url(), + ]; + } + + /** + * URL of the target version with fallback + * to the URL of the current version; + * `null` is returned if no URL is known + */ + public function url(): string|null + { + return $this->targetData()['url']; + } + + /** + * Returns all vulnerabilities the current version + * is affected by unless no data is available + */ + public function vulnerabilities(): array|null + { + if (isset($this->vulnerabilities) === true) { + return $this->vulnerabilities; + } + + if ( + $this->data === null || + $this->currentVersion === null || + $this->currentVersion === '' + ) { + return null; + } + + // shortcut for versions without vulnerabilities + $this->versionEntry(); + if ($this->noVulns === true) { + return $this->vulnerabilities = []; + } + + // unstable releases are released before their respective + // stable release and would not be matched by the constraints, + // but they will likely also contain the same vulnerabilities; + // so we strip off any non-numeric version modifiers from the end + preg_match('/^([0-9.]+)/', $this->currentVersion, $matches); + $currentVersion = $matches[1]; + + $vulnerabilities = $this->filterArrayByVersion( + $this->data['incidents'] ?? [], + ['affected' => $currentVersion], + 'while filtering incidents' + ); + + // sort the vulnerabilities by severity (with critical first) + $severities = array_map( + fn ($vulnerability) => match ($vulnerability['severity'] ?? null) { + 'critical' => 4, + 'high' => 3, + 'medium' => 2, + 'low' => 1, + default => 0 + }, + $vulnerabilities + ); + array_multisort($severities, SORT_DESC, $vulnerabilities); + + return $this->vulnerabilities = $vulnerabilities; + } + + /** + * Compares a version against a Composer version constraint + * and returns whether the constraint is satisfied + * + * @param string $reason Suffix for error messages + */ + protected function checkConstraint(string $version, string $constraint, string $reason): bool + { + try { + return Semver::satisfies($version, $constraint); + } catch (Exception $e) { + $package = $this->packageName(); + $message = 'Error comparing version constraint for ' . $package . ' ' . $reason . ': ' . $e->getMessage(); + + $exception = new KirbyException([ + 'fallback' => $message, + 'previous' => $e + ]); + $this->exceptions[] = $exception; + + return false; + } + } + + /** + * Filters a two-level array with one or multiple version constraints + * for each value by one or multiple version filters; + * values that don't contain the filter keys are removed + * + * @param array $array Array that contains associative arrays + * @param array $filters Associative array `field => version` + * @param string $reason Suffix for error messages + */ + protected function filterArrayByVersion(array $array, array $filters, string $reason): array + { + return array_filter($array, function ($item) use ($filters, $reason): bool { + foreach ($filters as $key => $version) { + if (isset($item[$key]) !== true) { + $package = $this->packageName(); + $this->exceptions[] = new KirbyException('Missing constraint ' . $key . ' for ' . $package . ' ' . $reason); + + return false; + } + + if ($this->checkConstraint($version, $item[$key], $reason) !== true) { + return false; + } + } + + return true; + }); + } + + /** + * Finds the minimum possible security update + * to fix all known vulnerabilities + * + * @return string|null Version number of the update or + * `null` if no free update is possible + */ + protected function findMinimumSecurityUpdate(): string|null + { + $versionEntry = $this->versionEntry(); + if ($versionEntry === null || isset($versionEntry['latest']) !== true) { + return null; // @codeCoverageIgnore + } + + $affected = $this->vulnerabilities(); + $incidents = $this->data['incidents'] ?? []; + $maxVersion = $versionEntry['latest']; + + // increase the target version number until there are no vulnerabilities + $version = $this->currentVersion; + $iterations = 0; + while (empty($affected) === false) { + // protect against infinite loops if the + // input data is contradicting itself + $iterations++; + if ($iterations > 10) { + return null; + } + + // if we arrived at the `$maxVersion` but still haven't found + // a version without vulnerabilities, we cannot suggest a version + if ($version === $maxVersion) { + return null; + } + + // find the minimum version that fixes all affected vulnerabilities + foreach ($affected as $incident) { + $incidentVersion = null; + foreach (Str::split($incident['fixed'], ',') as $fixed) { + // skip versions of other major releases + if ( + version_compare($fixed, $this->currentVersion, '<') === true || + version_compare($fixed, $maxVersion, '>') === true + ) { + continue; + } + + // find the minimum version that fixes this specific vulnerability + if ( + $incidentVersion === null || + version_compare($fixed, $incidentVersion, '<') === true + ) { + $incidentVersion = $fixed; + } + } + + // verify that we found at least one possible version; + // otherwise try the `$maxVersion` as a last chance before + // concluding at the top that we cannot solve the task + $incidentVersion ??= $maxVersion; + + // we need a version that fixes all vulnerabilities, so use the + // "largest of the smallest" fixed versions + if (version_compare($incidentVersion, $version, '>') === true) { + $version = $incidentVersion; + } + } + + // run another loop to verify that the suggested version + // doesn't have any known vulnerabilities on its own + $affected = $this->filterArrayByVersion( + $incidents, + ['affected' => $version], + 'while filtering incidents' + ); + } + + return $version; + } + + /** + * Loads the getkirby.com update data + * from cache or via HTTP + */ + protected function loadData(): array|null + { + // try to get the data from cache + $cache = $this->app->cache('updates'); + $key = ( + $this->pluginName ? + 'plugins/' . $this->pluginName : + 'security' + ); + + // try to return from cache; + // invalidate the cache after updates + $data = $cache->get($key); + if ( + is_array($data) === true && + $data['_version'] === $this->currentVersion + ) { + return $data; + } + + // exception message (on previous request error) + if (is_string($data) === true) { + // restore the exception to make it visible when debugging + $this->exceptions[] = new KirbyException($data); + + return null; + } + + // before we request the data, ensure we have a writable cache; + // this reduces strain on the CDN from repeated requests + if ($cache->enabled() === false) { + $this->exceptions[] = new KirbyException('Cannot check for updates without a working "updates" cache'); + + return null; + } + + // first catch every exception; + // we collect it below for debugging + try { + if (static::$timedOut === true) { + throw new Exception('Previous remote request timed out'); // @codeCoverageIgnore + } + + $response = Remote::get( + static::$host . '/' . $key . '.json', + ['timeout' => 2] + ); + + // allow status code HTTP 200 or 0 (e.g. for the file:// protocol) + if (in_array($response->code(), [0, 200], true) !== true) { + throw new Exception('HTTP error ' . $response->code()); // @codeCoverageIgnore + } + + $data = $response->json(); + + if (is_array($data) !== true) { + throw new Exception('Invalid JSON data'); + } + } catch (Exception $e) { + $package = $this->packageName(); + $message = 'Could not load update data for ' . $package . ': ' . $e->getMessage(); + + $exception = new KirbyException([ + 'fallback' => $message, + 'previous' => $e + ]); + $this->exceptions[] = $exception; + + // if the request timed out, prevent additional + // requests for other packages (e.g. plugins) + // to avoid long Panel hangs + if ($e->getCode() === 28) { + static::$timedOut = true; // @codeCoverageIgnore + } elseif (static::$timedOut === false) { + // different error than timeout; + // prevent additional requests in the + // next three days (e.g. if a plugin + // does not have a page on getkirby.com) + // by caching the exception message + // instead of the data array + $cache->set($key, $exception->getMessage(), 3 * 24 * 60); + } + + return null; + } + + // also cache the current version to + // invalidate the cache after updates + // (ensures that the update status is + // fresh directly after the update to + // avoid confusion with outdated info) + $data['_version'] = $this->currentVersion; + + // cache the retrieved data for three days + $cache->set($key, $data, 3 * 24 * 60); + + return $data; + } + + /** + * Returns the human-readable package name for error messages + */ + protected function packageName(): string + { + return $this->pluginName ? 'plugin ' . $this->pluginName : 'Kirby'; + } + + /** + * Performs the update check and returns data for the + * target version (with fallback and error handling) + */ + protected function targetData(): array + { + if (isset($this->targetData) === true) { + return $this->targetData; + } + + // check if we have valid data to compare to + $versionEntry = $this->versionEntry(); + if ($versionEntry === null) { + $version = $this->currentVersion ?? $this->data['latest'] ?? null; + + return $this->targetData = [ + 'status' => 'error', + 'url' => $version ? $this->urlFor($version, 'changes') : null, + 'version' => null + ]; + } + + // check if the current version is the latest available + if (($versionEntry['status'] ?? null) === 'latest') { + return $this->targetData = [ + 'status' => 'up-to-date', + 'url' => $this->urlFor($this->currentVersion, 'changes'), + 'version' => null + ]; + } + + // check if the current version is unreleased + if (($versionEntry['status'] ?? null) === 'unreleased') { + return $this->targetData = [ + 'status' => 'unreleased', + 'url' => null, + 'version' => null + ]; + } + + // check if the installation is vulnerable; + // minimum possible security fixes are preferred + // over all other updates and upgrades + if (count($this->vulnerabilities()) > 0) { + $update = $this->findMinimumSecurityUpdate(); + + if ($update !== null) { + // a free security update was found + return $this->targetData = [ + 'status' => 'security-update', + 'url' => $this->urlFor($update, 'changes'), + 'version' => $update + ]; + } + + // only a paid security upgrade is possible + return $this->targetData = [ + 'status' => 'security-upgrade', + 'url' => $this->urlFor($this->currentVersion, 'upgrade'), + 'version' => $this->data['latest'] ?? null + ]; + } + + // check if the user limited update checking to security updates + if ($this->securityOnly === true) { + return $this->targetData = [ + 'status' => 'not-vulnerable', + 'url' => $this->urlFor($this->currentVersion, 'changes'), + 'version' => null + ]; + } + + // check if free updates are possible from the current version + $latest = $versionEntry['latest'] ?? null; + if (is_string($latest) === true && $latest !== $this->currentVersion) { + return $this->targetData = [ + 'status' => 'update', + 'url' => $this->urlFor($latest, 'changes'), + 'version' => $latest + ]; + } + + // no free update is possible, but we are not on the latest version, + // so the overall latest version must be an upgrade + return $this->targetData = [ + 'status' => 'upgrade', + 'url' => $this->urlFor($this->currentVersion, 'upgrade'), + 'version' => $this->data['latest'] ?? null + ]; + } + + /** + * Returns the URL for a specific version and purpose + */ + protected function urlFor(string $version, string $purpose): string|null + { + if ($this->data === null) { + return null; + } + + // find the first matching entry + $url = null; + foreach ($this->data['urls'] ?? [] as $constraint => $entry) { + // filter out every entry that does not match the version + if ($this->checkConstraint($version, $constraint, 'while finding URL') !== true) { + continue; + } + + // we found a result + $url = $entry[$purpose] ?? null; + if ($url !== null) { + break; + } + } + + if ($url === null) { + $package = $this->packageName(); + $message = 'No matching URL found for ' . $package . '@' . $version; + + $this->exceptions[] = new KirbyException($message); + + return null; + } + + // insert the URL template placeholders + return Str::template($url, [ + 'current' => $this->currentVersion, + 'version' => $version + ]); + } + + /** + * Extracts the first matching version entry from + * the data array unless no data is available + */ + protected function versionEntry(): array|null + { + if (isset($this->versionEntry) === true) { + // no version entry found on last call + if ($this->versionEntry === false) { + return null; + } + + return $this->versionEntry; + } + + if ( + $this->data === null || + $this->currentVersion === null || + $this->currentVersion === '' + ) { + return null; + } + + // special check for unreleased versions + $latest = $this->data['latest'] ?? null; + if ( + $latest !== null && + version_compare($this->currentVersion, $latest, '>') === true + ) { + return [ + 'status' => 'unreleased' + ]; + } + + $versionEntry = null; + foreach ($this->data['versions'] ?? [] as $constraint => $entry) { + // filter out every entry that does not match the current version + if ($this->checkConstraint($this->currentVersion, $constraint, 'while finding version entry') !== true) { + continue; + } + + if (($entry['status'] ?? null) === 'no-vulnerabilities') { + $this->noVulns = true; + + // use the next matching version entry with + // more specific update information + continue; + } + + if (($entry['status'] ?? null) === 'latest') { + $this->noVulns = true; + } + + // we found a result + $versionEntry = $entry; + break; + } + + if ($versionEntry === null) { + $package = $this->packageName(); + $message = 'No matching version entry found for ' . $package . '@' . $this->currentVersion; + + $this->exceptions[] = new KirbyException($message); + } + + $this->versionEntry = $versionEntry ?? false; + return $versionEntry; + } +} diff --git a/kirby/src/Cms/Translation.php b/kirby/src/Cms/Translation.php new file mode 100644 index 0000000..680f212 --- /dev/null +++ b/kirby/src/Cms/Translation.php @@ -0,0 +1,156 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Translation +{ + public function __construct( + protected string $code, + protected array $data + ) { + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Returns the translation author + */ + public function author(): string + { + return $this->get('translation.author', 'Kirby'); + } + + /** + * Returns the official translation code + */ + public function code(): string + { + return $this->code; + } + + /** + * Returns an array with all + * translation strings + */ + public function data(): array + { + return $this->data; + } + + /** + * Returns the translation data and merges + * it with the data from the default translation + */ + public function dataWithFallback(): array + { + if ($this->code === 'en') { + return $this->data; + } + + // get the fallback array + $fallback = App::instance()->translation('en')->data(); + + return array_merge($fallback, $this->data); + } + + /** + * Returns the writing direction + * (ltr or rtl) + */ + public function direction(): string + { + return $this->get('translation.direction', 'ltr'); + } + + /** + * Returns a single translation + * string by key + */ + public function get(string $key, string $default = null): string|null + { + return $this->data[$key] ?? $default; + } + + /** + * Returns the translation id, + * which is also the code + */ + public function id(): string + { + return $this->code; + } + + /** + * Loads the translation from the + * json file in Kirby's translations folder + */ + public static function load( + string $code, + string $root, + array $inject = [] + ): static { + try { + $data = array_merge(Data::read($root), $inject); + } catch (Exception) { + $data = []; + } + + return new static($code, $data); + } + + /** + * Returns the PHP locale of the translation + */ + public function locale(): string + { + $default = $this->code; + if (Str::contains($default, '_') !== true) { + $default .= '_' . strtoupper($this->code); + } + + return $this->get('translation.locale', $default); + } + + /** + * Returns the human-readable translation name. + */ + public function name(): string + { + return $this->get('translation.name', $this->code); + } + + /** + * Converts the most important + * properties to an array + */ + public function toArray(): array + { + return [ + 'code' => $this->code(), + 'data' => $this->data(), + 'name' => $this->name(), + 'author' => $this->author(), + ]; + } +} diff --git a/kirby/src/Cms/Translations.php b/kirby/src/Cms/Translations.php new file mode 100644 index 0000000..40c0b55 --- /dev/null +++ b/kirby/src/Cms/Translations.php @@ -0,0 +1,56 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Translations extends Collection +{ + /** + * All registered translations methods + */ + public static array $methods = []; + + public static function factory(array $translations): static + { + $collection = new static(); + + foreach ($translations as $code => $props) { + $translation = new Translation($code, $props); + $collection->data[$translation->code()] = $translation; + } + + return $collection; + } + + public static function load(string $root, array $inject = []): static + { + $collection = new static(); + + foreach (Dir::read($root) as $filename) { + if (F::extension($filename) !== 'json') { + continue; + } + + $locale = F::name($filename); + $translation = Translation::load($locale, $root . '/' . $filename, $inject[$locale] ?? []); + + $collection->data[$locale] = $translation; + } + + return $collection; + } +} diff --git a/kirby/src/Cms/Url.php b/kirby/src/Cms/Url.php new file mode 100644 index 0000000..bb44139 --- /dev/null +++ b/kirby/src/Cms/Url.php @@ -0,0 +1,63 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Url extends BaseUrl +{ + public static string|null $home = null; + + /** + * Returns the Url to the homepage + */ + public static function home(): string + { + return App::instance()->url(); + } + + /** + * Creates an absolute Url to a template asset if it exists. + * This is used in the `css()` and `js()` helpers + */ + public static function toTemplateAsset( + string $assetPath, + string $extension + ): string|null { + $kirby = App::instance(); + $page = $kirby->site()->page(); + $path = $assetPath . '/' . $page->template() . '.' . $extension; + $file = $kirby->root('assets') . '/' . $path; + $url = $kirby->url('assets') . '/' . $path; + + return file_exists($file) === true ? $url : null; + } + + /** + * Smart resolver for internal and external urls + * + * @param array|string|null $options Either an array of options for the Uri class or a language string + */ + public static function to( + string|null $path = null, + array|string|null $options = null + ): string { + $kirby = App::instance(); + return ($kirby->component('url'))($kirby, $path, $options); + } +} diff --git a/kirby/src/Cms/User.php b/kirby/src/Cms/User.php new file mode 100644 index 0000000..ce5fbb0 --- /dev/null +++ b/kirby/src/Cms/User.php @@ -0,0 +1,765 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class User extends ModelWithContent +{ + use HasFiles; + use HasMethods; + use HasSiblings; + use UserActions; + + public const CLASS_ALIAS = 'user'; + + /** + * All registered user methods + * @todo Remove when support for PHP 8.2 is dropped + */ + public static array $methods = []; + + /** + * Registry with all User models + */ + public static array $models = []; + + protected UserBlueprint|null $blueprint = null; + protected array $credentials; + protected string|null $email; + protected string $hash; + protected string $id; + protected array|null $inventory = null; + protected string|null $language; + protected Field|string|null $name; + protected string|null $password; + protected Role|string|null $role; + + /** + * Creates a new User object + */ + public function __construct(array $props) + { + // helper function to easily edit values (if not null) + // before assigning them to their properties + $set = function (string $key, Closure $callback) use ($props) { + if ($value = $props[$key] ?? null) { + $value = $callback($value); + } + + return $value; + }; + + // if no ID passed, generate one; + // do so before calling parent constructor + // so it also gets stored in propertyData prop + $props['id'] ??= $this->createId(); + + parent::__construct($props); + + $this->id = $props['id']; + $this->email = $set('email', fn ($email) => Str::lower(trim($email))); + $this->language = $set('language', fn ($language) => trim($language)); + $this->name = $set('name', fn ($name) => trim(strip_tags($name))); + $this->password = $props['password'] ?? null; + $this->role = $set('role', fn ($role) => Str::lower(trim($role))); + + $this->setBlueprint($props['blueprint'] ?? null); + $this->setFiles($props['files'] ?? null); + } + + /** + * Modified getter to also return fields + * from the content + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // user methods + if ($this->hasMethod($method)) { + return $this->callMethod($method, $arguments); + } + + // return site content otherwise + return $this->content()->get($method); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return array_merge($this->toArray(), [ + 'avatar' => $this->avatar(), + 'content' => $this->content(), + 'role' => $this->role() + ]); + } + + /** + * Returns the url to the api endpoint + * @internal + */ + public function apiUrl(bool $relative = false): string + { + if ($relative === true) { + return 'users/' . $this->id(); + } + + return $this->kirby()->url('api') . '/users/' . $this->id(); + } + + /** + * Returns the File object for the avatar or null + */ + public function avatar(): File|null + { + return $this->files()->template('avatar')->first(); + } + + /** + * Returns the UserBlueprint object + */ + public function blueprint(): UserBlueprint + { + try { + return $this->blueprint ??= UserBlueprint::factory('users/' . $this->role(), 'users/default', $this); + } catch (Exception) { + return $this->blueprint ??= new UserBlueprint([ + 'model' => $this, + 'name' => 'default', + 'title' => 'Default', + ]); + } + } + + /** + * Prepares the content for the write method + * @internal + * @param string $languageCode|null Not used so far + */ + public function contentFileData( + array $data, + string|null $languageCode = null + ): array { + // remove stuff that has nothing to do in the text files + unset( + $data['email'], + $data['language'], + $data['name'], + $data['password'], + $data['role'] + ); + + return $data; + } + + /** + * Filename for the content file + * + * @internal + * @deprecated 4.0.0 + * @todo Remove in v5 + * @codeCoverageIgnore + */ + public function contentFileName(): string + { + Helpers::deprecated('The internal $model->contentFileName() method has been deprecated. Please let us know via a GitHub issue if you need this method and tell us your use case.', 'model-content-file'); + return 'user'; + } + + protected function credentials(): array + { + return $this->credentials ??= $this->readCredentials(); + } + + /** + * Returns the user email address + */ + public function email(): string|null + { + return $this->email ??= $this->credentials()['email'] ?? null; + } + + /** + * Checks if the user exists + */ + public function exists(): bool + { + return $this->storage()->exists( + 'published', + 'default' + ); + } + + /** + * Constructs a User object and also + * takes User models into account. + * @internal + */ + public static function factory(mixed $props): static + { + if (empty($props['model']) === false) { + return static::model($props['model'], $props); + } + + return new static($props); + } + + /** + * Hashes the user's password unless it is `null`, + * which will leave it as `null` + * @internal + */ + public static function hashPassword( + #[SensitiveParameter] + string $password = null + ): string|null { + if ($password !== null) { + $password = password_hash($password, PASSWORD_DEFAULT); + } + + return $password; + } + + /** + * Returns the user id + */ + public function id(): string + { + return $this->id; + } + + /** + * Returns the inventory of files + * children and content files + */ + public function inventory(): array + { + if ($this->inventory !== null) { + return $this->inventory; + } + + $kirby = $this->kirby(); + + return $this->inventory = Dir::inventory( + $this->root(), + $kirby->contentExtension(), + $kirby->contentIgnore(), + $kirby->multilang() + ); + } + + /** + * Compares the current object with the given user object + */ + public function is(User $user = null): bool + { + if ($user === null) { + return false; + } + + return $this->id() === $user->id(); + } + + /** + * Checks if this user has the admin role + */ + public function isAdmin(): bool + { + return $this->role()->id() === 'admin'; + } + + /** + * Checks if the current user is the virtual + * Kirby user + */ + public function isKirby(): bool + { + return $this->isAdmin() && $this->id() === 'kirby'; + } + + /** + * Checks if the current user is this user + */ + public function isLoggedIn(): bool + { + return $this->is($this->kirby()->user()); + } + + /** + * Checks if the user is the last one + * with the admin role + */ + public function isLastAdmin(): bool + { + return + $this->role()->isAdmin() === true && + $this->kirby()->users()->filter('role', 'admin')->count() <= 1; + } + + /** + * Checks if the user is the last user + */ + public function isLastUser(): bool + { + return $this->kirby()->users()->count() === 1; + } + + /** + * Checks if the current user is the virtual + * Nobody user + */ + public function isNobody(): bool + { + return $this->role()->id() === 'nobody' && $this->id() === 'nobody'; + } + + /** + * Returns the user language + */ + public function language(): string + { + return $this->language ??= + $this->credentials()['language'] ?? + $this->kirby()->panelLanguage(); + } + + /** + * Logs the user in + * + * @param \Kirby\Session\Session|array|null $session Session options or session object to set the user in + */ + public function login( + #[SensitiveParameter] + string $password, + $session = null + ): bool { + $this->validatePassword($password); + $this->loginPasswordless($session); + + return true; + } + + /** + * Logs the user in without checking the password + * + * @param \Kirby\Session\Session|array|null $session Session options or session object to set the user in + */ + public function loginPasswordless( + Session|array|null $session = null + ): void { + if ($this->id() === 'kirby') { + throw new PermissionException('The almighty user "kirby" cannot be used for login, only for raising permissions in code via `$kirby->impersonate()`'); + } + + $kirby = $this->kirby(); + $session = $this->sessionFromOptions($session); + + $kirby->trigger( + 'user.login:before', + ['user' => $this, 'session' => $session] + ); + + $session->regenerateToken(); // privilege change + $session->data()->set('kirby.userId', $this->id()); + + if ($this->passwordTimestamp() !== null) { + $session->data()->set('kirby.loginTimestamp', time()); + } + + $kirby->auth()->setUser($this); + + $kirby->trigger( + 'user.login:after', + ['user' => $this, 'session' => $session] + ); + } + + /** + * Logs the user out + * + * @param \Kirby\Session\Session|array|null $session Session options or session object to unset the user in + */ + public function logout(Session|array|null $session = null): void + { + $kirby = $this->kirby(); + $session = $this->sessionFromOptions($session); + + $kirby->trigger('user.logout:before', ['user' => $this, 'session' => $session]); + + // remove the user from the session for future requests + $session->data()->remove('kirby.userId'); + $session->data()->remove('kirby.loginTimestamp'); + + // clear the cached user object from the app state of the current request + $this->kirby()->auth()->flush(); + + if ($session->data()->get() === []) { + // session is now empty, we might as well destroy it + $session->destroy(); + + $kirby->trigger('user.logout:after', ['user' => $this, 'session' => null]); + } else { + // privilege change + $session->regenerateToken(); + + $kirby->trigger('user.logout:after', ['user' => $this, 'session' => $session]); + } + } + + /** + * Returns the root to the media folder for the user + * @internal + */ + public function mediaRoot(): string + { + return $this->kirby()->root('media') . '/users/' . $this->id(); + } + + /** + * Returns the media url for the user object + * @internal + */ + public function mediaUrl(): string + { + return $this->kirby()->url('media') . '/users/' . $this->id(); + } + + /** + * Creates a user model if it has been registered + * @internal + */ + public static function model(string $name, array $props = []): static + { + if ($class = (static::$models[$name] ?? null)) { + $object = new $class($props); + + if ($object instanceof self) { + return $object; + } + } + + return new static($props); + } + + /** + * Returns the last modification date of the user + */ + public function modified( + string $format = 'U', + string|null $handler = null, + string|null $languageCode = null + ): int|string|false { + $modifiedContent = $this->storage()->modified('published', $languageCode); + $modifiedIndex = F::modified($this->root() . '/index.php'); + $modifiedTotal = max([$modifiedContent, $modifiedIndex]); + + return Str::date($modifiedTotal, $format, $handler); + } + + /** + * Returns the user's name + */ + public function name(): Field + { + if (is_string($this->name) === true) { + return new Field($this, 'name', $this->name); + } + + return $this->name ??= new Field($this, 'name', $this->credentials()['name'] ?? null); + } + + /** + * Returns the user's name or, + * if empty, the email address + */ + public function nameOrEmail(): Field + { + $name = $this->name(); + return $name->isNotEmpty() ? $name : new Field($this, 'email', $this->email()); + } + + /** + * Create a dummy nobody + * @internal + */ + public static function nobody(): static + { + return new static([ + 'email' => 'nobody@getkirby.com', + 'role' => 'nobody' + ]); + } + + /** + * Returns the panel info object + */ + public function panel(): Panel + { + return new Panel($this); + } + + /** + * Returns the encrypted user password + */ + public function password(): string|null + { + return $this->password ??= $this->readPassword(); + } + + /** + * Returns the timestamp when the password + * was last changed + */ + public function passwordTimestamp(): int|null + { + $file = $this->secretsFile(); + + // ensure we have the latest information + // to prevent cache attacks + clearstatcache(); + + // user does not have a password + if (is_file($file) === false) { + return null; + } + + return filemtime($file); + } + + public function permissions(): UserPermissions + { + return new UserPermissions($this); + } + + /** + * Returns the user role + */ + public function role(): Role + { + if ($this->role instanceof Role) { + return $this->role; + } + + $name = $this->role ?? $this->credentials()['role'] ?? 'visitor'; + + return $this->role = $this->kirby()->roles()->find($name) ?? Role::nobody(); + } + + /** + * Returns all available roles + * for this user, that can be selected + * by the authenticated user + */ + public function roles(): Roles + { + $kirby = $this->kirby(); + $roles = $kirby->roles(); + + // a collection with just the one role of the user + $myRole = $roles->filter('id', $this->role()->id()); + + // if there's an authenticated user … + // admin users can select pretty much any role + if ($kirby->user()?->isAdmin() === true) { + // except if the user is the last admin + if ($this->isLastAdmin() === true) { + // in which case they have to stay admin + return $myRole; + } + + // return all roles for mighty admins + return $roles; + } + + // any other user can only keep their role + return $myRole; + } + + /** + * The absolute path to the user directory + */ + public function root(): string + { + return $this->kirby()->root('accounts') . '/' . $this->id(); + } + + /** + * Returns the UserRules class to + * validate any important action. + */ + protected function rules(): UserRules + { + return new UserRules(); + } + + /** + * Reads a specific secret from the user secrets file on disk + * @since 4.0.0 + */ + public function secret(string $key): mixed + { + return $this->readSecrets()[$key] ?? null; + } + + /** + * Sets the Blueprint object + * + * @return $this + */ + protected function setBlueprint(array $blueprint = null): static + { + if ($blueprint !== null) { + $blueprint['model'] = $this; + $this->blueprint = new UserBlueprint($blueprint); + } + + return $this; + } + + /** + * Converts session options into a session object + * + * @param \Kirby\Session\Session|array $session Session options or session object to unset the user in + */ + protected function sessionFromOptions(Session|array|null $session): Session + { + // use passed session options or session object if set + if (is_array($session) === true) { + $session = $this->kirby()->session($session); + } elseif ($session instanceof Session === false) { + $session = $this->kirby()->session(['detect' => true]); + } + + return $session; + } + + /** + * Returns the parent Users collection + */ + protected function siblingsCollection(): Users + { + return $this->kirby()->users(); + } + + /** + * Converts the most important user properties + * to an array + */ + public function toArray(): array + { + return array_merge(parent::toArray(), [ + 'avatar' => $this->avatar()?->toArray(), + 'email' => $this->email(), + 'id' => $this->id(), + 'language' => $this->language(), + 'role' => $this->role()->name(), + 'username' => $this->username() + ]); + } + + /** + * String template builder + * + * @param string|null $fallback Fallback for tokens in the template that cannot be replaced + * (`null` to keep the original token) + */ + public function toString( + string $template = null, + array $data = [], + string|null $fallback = '', + string $handler = 'template' + ): string { + $template ??= $this->email(); + return parent::toString($template, $data, $fallback, $handler); + } + + /** + * Returns the username + * which is the given name or the email + * as a fallback + */ + public function username(): string|null + { + return $this->name()->or($this->email())->value(); + } + + /** + * Compares the given password with the stored one + * + * @throws \Kirby\Exception\NotFoundException If the user has no password + * @throws \Kirby\Exception\InvalidArgumentException If the entered password is not valid + * or does not match the user password + */ + public function validatePassword( + #[SensitiveParameter] + string $password = null + ): bool { + if (empty($this->password()) === true) { + throw new NotFoundException(['key' => 'user.password.undefined']); + } + + // `UserRules` enforces a minimum length of 8 characters, + // so everything below that is a typo + if (Str::length($password) < 8) { + throw new InvalidArgumentException(['key' => 'user.password.invalid']); + } + + // too long passwords can cause DoS attacks + if (Str::length($password) > 1000) { + throw new InvalidArgumentException(['key' => 'user.password.excessive']); + } + + if (password_verify($password, $this->password()) !== true) { + throw new InvalidArgumentException(['key' => 'user.password.wrong', 'httpCode' => 401]); + } + + return true; + } + + /** + * @deprecated 4.0.0 Use `->secretsFile()` instead + * @codeCoverageIgnore + */ + protected function passwordFile(): string + { + return $this->secretsFile(); + } + + /** + * Returns the path to the file containing + * all user secrets, including the password + * @since 4.0.0 + */ + protected function secretsFile(): string + { + return $this->root() . '/.htpasswd'; + } +} diff --git a/kirby/src/Cms/UserActions.php b/kirby/src/Cms/UserActions.php new file mode 100644 index 0000000..728acc1 --- /dev/null +++ b/kirby/src/Cms/UserActions.php @@ -0,0 +1,448 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait UserActions +{ + /** + * Changes the user email address + */ + public function changeEmail(string $email): static + { + $email = trim($email); + + return $this->commit('changeEmail', ['user' => $this, 'email' => Idn::decodeEmail($email)], function ($user, $email) { + $user = $user->clone([ + 'email' => $email + ]); + + $user->updateCredentials([ + 'email' => $email + ]); + + // update the users collection + $user->kirby()->users()->set($user->id(), $user); + + return $user; + }); + } + + /** + * Changes the user language + */ + public function changeLanguage(string $language): static + { + return $this->commit('changeLanguage', ['user' => $this, 'language' => $language], function ($user, $language) { + $user = $user->clone([ + 'language' => $language, + ]); + + $user->updateCredentials([ + 'language' => $language + ]); + + // update the users collection + $user->kirby()->users()->set($user->id(), $user); + + return $user; + }); + } + + /** + * Changes the screen name of the user + */ + public function changeName(string $name): static + { + $name = trim($name); + + return $this->commit('changeName', ['user' => $this, 'name' => $name], function ($user, $name) { + $user = $user->clone([ + 'name' => $name + ]); + + $user->updateCredentials([ + 'name' => $name + ]); + + // update the users collection + $user->kirby()->users()->set($user->id(), $user); + + return $user; + }); + } + + /** + * Changes the user password + */ + public function changePassword( + #[SensitiveParameter] + string $password + ): static { + return $this->commit('changePassword', ['user' => $this, 'password' => $password], function ($user, $password) { + $user = $user->clone([ + 'password' => $password = User::hashPassword($password) + ]); + + $user->writePassword($password); + + // update the users collection + $user->kirby()->users()->set($user->id(), $user); + + // keep the user logged in to the current browser + // if they changed their own password + // (regenerate the session token, update the login timestamp) + if ($user->isLoggedIn() === true) { + $user->loginPasswordless(); + } + + return $user; + }); + } + + /** + * Changes the user role + */ + public function changeRole(string $role): static + { + return $this->commit('changeRole', ['user' => $this, 'role' => $role], function ($user, $role) { + $user = $user->clone([ + 'role' => $role, + ]); + + $user->updateCredentials([ + 'role' => $role + ]); + + // update the users collection + $user->kirby()->users()->set($user->id(), $user); + + return $user; + }); + } + + /** + * Changes the user's TOTP secret + * @since 4.0.0 + */ + public function changeTotp( + #[SensitiveParameter] + string|null $secret + ): static { + return $this->commit('changeTotp', ['user' => $this, 'secret' => $secret], function ($user, $secret) { + $this->writeSecret('totp', $secret); + + // keep the user logged in to the current browser + // if they changed their own TOTP secret + // (regenerate the session token, update the login timestamp) + if ($user->isLoggedIn() === true) { + $user->loginPasswordless(); + } + + return $user; + }); + } + + /** + * Commits a user action, by following these steps + * + * 1. checks the action rules + * 2. sends the before hook + * 3. commits the action + * 4. sends the after hook + * 5. returns the result + * + * @throws \Kirby\Exception\PermissionException + */ + protected function commit( + string $action, + array $arguments, + Closure $callback + ): mixed { + if ($this->isKirby() === true) { + throw new PermissionException('The Kirby user cannot be changed'); + } + + $old = $this->hardcopy(); + $kirby = $this->kirby(); + $argumentValues = array_values($arguments); + + $this->rules()->$action(...$argumentValues); + $kirby->trigger('user.' . $action . ':before', $arguments); + + $result = $callback(...$argumentValues); + + $argumentsAfter = match ($action) { + 'create' => ['user' => $result], + 'delete' => ['status' => $result, 'user' => $old], + default => ['newUser' => $result, 'oldUser' => $old] + }; + + $kirby->trigger('user.' . $action . ':after', $argumentsAfter); + + $kirby->cache('pages')->flush(); + return $result; + } + + /** + * Creates a new User from the given props and returns a new User object + */ + public static function create(array $props = null): User + { + $data = $props; + + if (isset($props['email']) === true) { + $data['email'] = Idn::decodeEmail($props['email']); + } + + if (isset($props['password']) === true) { + $data['password'] = User::hashPassword($props['password']); + } + + $props['role'] = $props['model'] = strtolower($props['role'] ?? 'default'); + + $user = User::factory($data); + + // create a form for the user + $form = Form::for($user, [ + 'values' => $props['content'] ?? [] + ]); + + // inject the content + $user = $user->clone(['content' => $form->strings(true)]); + + // run the hook + return $user->commit('create', ['user' => $user, 'input' => $props], function ($user, $props) { + $user->writeCredentials([ + 'email' => $user->email(), + 'language' => $user->language(), + 'name' => $user->name()->value(), + 'role' => $user->role()->id(), + ]); + + $user->writePassword($user->password()); + + // always create users in the default language + if ($user->kirby()->multilang() === true) { + $languageCode = $user->kirby()->defaultLanguage()->code(); + } else { + $languageCode = null; + } + + // add the user to users collection + $user->kirby()->users()->add($user); + + // write the user data + return $user->save($user->content()->toArray(), $languageCode); + }); + } + + /** + * Returns a random user id + */ + public function createId(): string + { + $length = 8; + + do { + try { + $id = Str::random($length); + if (UserRules::validId($this, $id) === true) { + return $id; + } + + // we can't really test for a random match + // @codeCoverageIgnoreStart + } catch (Throwable) { + $length++; + } + } while (true); + // @codeCoverageIgnoreEnd + } + + /** + * Deletes the user + * + * @throws \Kirby\Exception\LogicException + */ + public function delete(): bool + { + return $this->commit('delete', ['user' => $this], function ($user) { + if ($user->exists() === false) { + return true; + } + + // delete all public assets for this user + Dir::remove($user->mediaRoot()); + + // delete the user directory + if (Dir::remove($user->root()) !== true) { + throw new LogicException('The user directory for "' . $user->email() . '" could not be deleted'); + } + + // remove the user from users collection + $user->kirby()->users()->remove($user); + + return true; + }); + } + + /** + * Read the account information from disk + */ + protected function readCredentials(): array + { + $path = $this->root() . '/index.php'; + + if (is_file($path) === true) { + $credentials = F::load($path, allowOutput: false); + + return is_array($credentials) === false ? [] : $credentials; + } + + return []; + } + + /** + * Reads the user password from disk + */ + protected function readPassword(): string|false + { + return $this->secret('password') ?? false; + } + + /** + * Reads the secrets from the user secrets file on disk + * @since 4.0.0 + */ + protected function readSecrets(): array + { + $file = $this->secretsFile(); + $secrets = []; + + if (is_file($file) === true) { + $lines = explode("\n", file_get_contents($file)); + + if (isset($lines[1]) === true) { + $secrets = Json::decode($lines[1]); + } + + $secrets['password'] = $lines[0]; + } + + // an empty password hash means that no password was set + if (($secrets['password'] ?? null) === '') { + unset($secrets['password']); + } + + return $secrets; + } + + /** + * Updates the user data + */ + public function update( + array $input = null, + string $languageCode = null, + bool $validate = false + ): static { + $user = parent::update($input, $languageCode, $validate); + + // set auth user data only if the current user is this user + if ($user->isLoggedIn() === true) { + $this->kirby()->auth()->setUser($user); + } + + // update the users collection + $user->kirby()->users()->set($user->id(), $user); + + return $user; + } + + /** + * This always merges the existing credentials + * with the given input. + */ + protected function updateCredentials(array $credentials): bool + { + // normalize the email address + if (isset($credentials['email']) === true) { + $credentials['email'] = Str::lower(trim($credentials['email'])); + } + + return $this->writeCredentials(array_merge($this->credentials(), $credentials)); + } + + /** + * Writes the account information to disk + */ + protected function writeCredentials(array $credentials): bool + { + return Data::write($this->root() . '/index.php', $credentials); + } + + /** + * Writes the password to disk + */ + protected function writePassword( + #[SensitiveParameter] + string $password = null + ): bool { + return $this->writeSecret('password', $password); + } + + /** + * Writes a specific secret to the user secrets file on disk; + * `password` is the first line, the rest is stored as JSON + * @since 4.0.0 + */ + protected function writeSecret( + string $key, + #[SensitiveParameter] + mixed $secret + ): bool { + $secrets = $this->readSecrets(); + + if ($secret === null) { + unset($secrets[$key]); + } else { + $secrets[$key] = $secret; + } + + // first line is always the password + $lines = $secrets['password'] ?? ''; + + // everything else is for the second line + $secondLine = Json::encode( + A::without($secrets, 'password') + ); + + if ($secondLine !== '[]') { + $lines .= "\n" . $secondLine; + } + + return F::write($this->secretsFile(), $lines); + } +} diff --git a/kirby/src/Cms/UserBlueprint.php b/kirby/src/Cms/UserBlueprint.php new file mode 100644 index 0000000..d44d852 --- /dev/null +++ b/kirby/src/Cms/UserBlueprint.php @@ -0,0 +1,46 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserBlueprint extends Blueprint +{ + /** + * UserBlueprint constructor. + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function __construct(array $props) + { + // normalize and translate the description + $props['description'] = $this->i18n($props['description'] ?? null); + + // register the other props + parent::__construct($props); + + // normalize all available page options + $this->props['options'] = $this->normalizeOptions( + $this->props['options'] ?? true, + // defaults + [ + 'create' => null, + 'changeEmail' => null, + 'changeLanguage' => null, + 'changeName' => null, + 'changePassword' => null, + 'changeRole' => null, + 'delete' => null, + 'update' => null, + ] + ); + } +} diff --git a/kirby/src/Cms/UserPermissions.php b/kirby/src/Cms/UserPermissions.php new file mode 100644 index 0000000..62cd662 --- /dev/null +++ b/kirby/src/Cms/UserPermissions.php @@ -0,0 +1,51 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserPermissions extends ModelPermissions +{ + protected string $category = 'users'; + + public function __construct(User $model) + { + parent::__construct($model); + + // change the scope of the permissions, + // when the current user is this user + $this->category = $this->user?->is($model) ? 'user' : 'users'; + } + + protected function canChangeRole(): bool + { + return $this->model->roles()->count() > 1; + } + + protected function canCreate(): bool + { + // the admin can always create new users + if ($this->user->isAdmin() === true) { + return true; + } + + // users who are not admins cannot create admins + if ($this->model->isAdmin() === true) { + return false; + } + + return true; + } + + protected function canDelete(): bool + { + return $this->model->isLastAdmin() !== true; + } +} diff --git a/kirby/src/Cms/UserPicker.php b/kirby/src/Cms/UserPicker.php new file mode 100644 index 0000000..0e9ab3e --- /dev/null +++ b/kirby/src/Cms/UserPicker.php @@ -0,0 +1,67 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserPicker extends Picker +{ + /** + * Extends the basic defaults + */ + public function defaults(): array + { + $defaults = parent::defaults(); + $defaults['text'] = '{{ user.username }}'; + + return $defaults; + } + + /** + * Search all users for the picker + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function items(): Users|null + { + $model = $this->options['model']; + + // find the right default query + $query = match (true) { + empty($this->options['query']) === false + => $this->options['query'], + $model instanceof User + => 'user.siblings', + default + => 'kirby.users' + }; + + // fetch all users for the picker + $users = $model->query($query); + + // catch invalid data + if ($users instanceof Users === false) { + throw new InvalidArgumentException('Your query must return a set of users'); + } + + // search + $users = $this->search($users); + + // sort + $users = $users->sort('username', 'asc'); + + // paginate + return $this->paginate($users); + } +} diff --git a/kirby/src/Cms/UserRules.php b/kirby/src/Cms/UserRules.php new file mode 100644 index 0000000..cf32665 --- /dev/null +++ b/kirby/src/Cms/UserRules.php @@ -0,0 +1,384 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserRules +{ + /** + * Validates if the email address can be changed + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the address + */ + public static function changeEmail(User $user, string $email): bool + { + if ($user->permissions()->changeEmail() !== true) { + throw new PermissionException([ + 'key' => 'user.changeEmail.permission', + 'data' => ['name' => $user->username()] + ]); + } + + return static::validEmail($user, $email); + } + + /** + * Validates if the language can be changed + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the language + */ + public static function changeLanguage(User $user, string $language): bool + { + if ($user->permissions()->changeLanguage() !== true) { + throw new PermissionException([ + 'key' => 'user.changeLanguage.permission', + 'data' => ['name' => $user->username()] + ]); + } + + return static::validLanguage($user, $language); + } + + /** + * Validates if the name can be changed + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the name + */ + public static function changeName(User $user, string $name): bool + { + if ($user->permissions()->changeName() !== true) { + throw new PermissionException([ + 'key' => 'user.changeName.permission', + 'data' => ['name' => $user->username()] + ]); + } + + return true; + } + + /** + * Validates if the password can be changed + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the password + */ + public static function changePassword( + User $user, + #[SensitiveParameter] + string $password + ): bool { + if ($user->permissions()->changePassword() !== true) { + throw new PermissionException([ + 'key' => 'user.changePassword.permission', + 'data' => ['name' => $user->username()] + ]); + } + + return static::validPassword($user, $password); + } + + /** + * Validates if the role can be changed + * + * @throws \Kirby\Exception\LogicException If the user is the last admin + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the role + */ + public static function changeRole(User $user, string $role): bool + { + // protect admin from role changes by non-admin + if ( + $user->kirby()->user()->isAdmin() === false && + $user->isAdmin() === true + ) { + throw new PermissionException([ + 'key' => 'user.changeRole.permission', + 'data' => ['name' => $user->username()] + ]); + } + + // prevent non-admins making a user to admin + if ( + $user->kirby()->user()->isAdmin() === false && + $role === 'admin' + ) { + throw new PermissionException([ + 'key' => 'user.changeRole.toAdmin' + ]); + } + + static::validRole($user, $role); + + if ($role !== 'admin' && $user->isLastAdmin() === true) { + throw new LogicException([ + 'key' => 'user.changeRole.lastAdmin', + 'data' => ['name' => $user->username()] + ]); + } + + if ($user->permissions()->changeRole() !== true) { + throw new PermissionException([ + 'key' => 'user.changeRole.permission', + 'data' => ['name' => $user->username()] + ]); + } + + return true; + } + + /** + * Validates if the TOTP can be changed + * @since 4.0.0 + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to change the password + */ + public static function changeTotp( + User $user, + #[SensitiveParameter] + string|null $secret + ): bool { + $currentUser = $user->kirby()->user(); + + if ( + $currentUser->is($user) === false && + $currentUser->isAdmin() === false + ) { + throw new PermissionException('You cannot change the time-based code for ' . $user->email()); + } + + // safety check to avoid accidental insecure secrets; + // throws an exception for secrets of the wrong length + if ($secret !== null) { + new Totp($secret); + } + + return true; + } + + /** + * Validates if the user can be created + * + * @throws \Kirby\Exception\PermissionException If the user is not allowed to create a new user + */ + public static function create(User $user, array $props = []): bool + { + static::validId($user, $user->id()); + static::validEmail($user, $user->email(), true); + static::validLanguage($user, $user->language()); + + // the first user must have a password + if ($user->kirby()->users()->count() === 0 && empty($props['password'])) { + // trigger invalid password error + static::validPassword($user, ' '); + } + + if (empty($props['password']) === false) { + static::validPassword($user, $props['password']); + } + + // get the current user if it exists + $currentUser = $user->kirby()->user(); + + // admins are allowed everything + if ($currentUser?->isAdmin() === true) { + return true; + } + + // only admins are allowed to add admins + $role = $props['role'] ?? null; + + if ($role === 'admin' && $currentUser?->isAdmin() === false) { + throw new PermissionException([ + 'key' => 'user.create.permission' + ]); + } + + // check user permissions (if not on install) + if ( + $user->kirby()->users()->count() > 0 && + $user->permissions()->create() !== true + ) { + throw new PermissionException([ + 'key' => 'user.create.permission' + ]); + } + + return true; + } + + /** + * Validates if the user can be deleted + * + * @throws \Kirby\Exception\LogicException If this is the last user or last admin, which cannot be deleted + * @throws \Kirby\Exception\PermissionException If the user is not allowed to delete this user + */ + public static function delete(User $user): bool + { + if ($user->isLastAdmin() === true) { + throw new LogicException(['key' => 'user.delete.lastAdmin']); + } + + if ($user->isLastUser() === true) { + throw new LogicException([ + 'key' => 'user.delete.lastUser' + ]); + } + + if ($user->permissions()->delete() !== true) { + throw new PermissionException([ + 'key' => 'user.delete.permission', + 'data' => ['name' => $user->username()] + ]); + } + + return true; + } + + /** + * Validates if the user can be updated + * + * @throws \Kirby\Exception\PermissionException If the user it not allowed to update this user + */ + public static function update( + User $user, + array $values = [], + array $strings = [] + ): bool { + if ($user->permissions()->update() !== true) { + throw new PermissionException([ + 'key' => 'user.update.permission', + 'data' => ['name' => $user->username()] + ]); + } + + return true; + } + + /** + * Validates an email address + * + * @throws \Kirby\Exception\DuplicateException If the email address already exists + * @throws \Kirby\Exception\InvalidArgumentException If the email address is invalid + */ + public static function validEmail( + User $user, + string $email, + bool $strict = false + ): bool { + if (V::email($email ?? null) === false) { + throw new InvalidArgumentException([ + 'key' => 'user.email.invalid', + ]); + } + + if ($strict === true) { + $duplicate = $user->kirby()->users()->find($email); + } else { + $duplicate = $user->kirby()->users()->not($user)->find($email); + } + + if ($duplicate) { + throw new DuplicateException([ + 'key' => 'user.duplicate', + 'data' => ['email' => $email] + ]); + } + + return true; + } + + /** + * Validates a user id + * + * @throws \Kirby\Exception\DuplicateException If the user already exists + */ + public static function validId(User $user, string $id): bool + { + if (in_array($id, ['account', 'kirby', 'nobody']) === true) { + throw new InvalidArgumentException('"' . $id . '" is a reserved word and cannot be used as user id'); + } + + if ($user->kirby()->users()->find($id)) { + throw new DuplicateException('A user with this id exists'); + } + + return true; + } + + /** + * Validates a user language code + * + * @throws \Kirby\Exception\InvalidArgumentException If the language does not exist + */ + public static function validLanguage(User $user, string $language): bool + { + if (in_array($language, $user->kirby()->translations()->keys(), true) === false) { + throw new InvalidArgumentException([ + 'key' => 'user.language.invalid', + ]); + } + + return true; + } + + /** + * Validates a password + * + * @throws \Kirby\Exception\InvalidArgumentException If the password is too short + */ + public static function validPassword( + User $user, + #[SensitiveParameter] + string $password + ): bool { + // too short passwords are ineffective + if (Str::length($password ?? null) < 8) { + throw new InvalidArgumentException([ + 'key' => 'user.password.invalid', + ]); + } + + // too long passwords can cause DoS attacks + // and are therefore blocked in the auth system + // (blocked here as well to avoid passwords + // that cannot be used to log in) + if (Str::length($password ?? null) > 1000) { + throw new InvalidArgumentException([ + 'key' => 'user.password.excessive', + ]); + } + + return true; + } + + /** + * Validates a user role + * + * @throws \Kirby\Exception\InvalidArgumentException If the user role does not exist + */ + public static function validRole(User $user, string $role): bool + { + if ($user->kirby()->roles()->find($role) instanceof Role) { + return true; + } + + throw new InvalidArgumentException([ + 'key' => 'user.role.invalid', + ]); + } +} diff --git a/kirby/src/Cms/Users.php b/kirby/src/Cms/Users.php new file mode 100644 index 0000000..df2f6c3 --- /dev/null +++ b/kirby/src/Cms/Users.php @@ -0,0 +1,159 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Users extends Collection +{ + use HasUuids; + + /** + * All registered users methods + */ + public static array $methods = []; + + public function create(array $data): User + { + return User::create($data); + } + + /** + * Adds a single user or + * an entire second collection to the + * current collection + * + * @param \Kirby\Cms\Users|\Kirby\Cms\User|string $object + * @return $this + * @throws \Kirby\Exception\InvalidArgumentException When no `User` or `Users` object or an ID of an existing user is passed + */ + public function add($object): static + { + // add a users collection + if ($object instanceof self) { + $this->data = array_merge($this->data, $object->data); + + // add a user by id + } elseif ( + is_string($object) === true && + $user = App::instance()->user($object) + ) { + $this->__set($user->id(), $user); + + // add a user object + } elseif ($object instanceof User) { + $this->__set($object->id(), $object); + + // give a useful error message on invalid input; + // silently ignore "empty" values for compatibility with existing setups + } elseif (in_array($object, [null, false, true], true) !== true) { + throw new InvalidArgumentException('You must pass a Users or User object or an ID of an existing user to the Users collection'); + } + + return $this; + } + + /** + * Takes an array of user props and creates a nice + * and clean user collection from it + */ + public static function factory(array $users, array $inject = []): static + { + $collection = new static(); + + // read all user blueprints + foreach ($users as $props) { + $user = User::factory($props + $inject); + $collection->set($user->id(), $user); + } + + return $collection; + } + + /** + * Returns all files of all users + */ + public function files(): Files + { + $files = new Files([], $this->parent); + + foreach ($this->data as $user) { + foreach ($user->files() as $fileKey => $file) { + $files->data[$fileKey] = $file; + } + } + + return $files; + } + + /** + * Finds a user in the collection by ID or email address + * @internal Use `$users->find()` instead + */ + public function findByKey(string $key): User|null + { + if ($user = $this->findByUuid($key, 'user')) { + return $user; + } + + if (Str::contains($key, '@') === true) { + return parent::findBy('email', Str::lower($key)); + } + + return parent::findByKey($key); + } + + /** + * Loads a user from disk by passing the absolute path (root) + */ + public static function load(string $root, array $inject = []): static + { + $users = new static(); + + foreach (Dir::read($root) as $userDirectory) { + if (is_dir($root . '/' . $userDirectory) === false) { + continue; + } + + // get role information + $path = $root . '/' . $userDirectory . '/index.php'; + if (is_file($path) === true) { + $credentials = F::load($path, allowOutput: false); + } + + // create user model based on role + $user = User::factory([ + 'id' => $userDirectory, + 'model' => $credentials['role'] ?? null + ] + $inject); + + $users->set($user->id(), $user); + } + + return $users; + } + + /** + * Shortcut for `$users->filter('role', 'admin')` + */ + public function role(string $role): static + { + return $this->filter('role', $role); + } +} diff --git a/kirby/src/Cms/Visitor.php b/kirby/src/Cms/Visitor.php new file mode 100644 index 0000000..511eb8e --- /dev/null +++ b/kirby/src/Cms/Visitor.php @@ -0,0 +1,23 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Visitor extends Facade +{ + public static function instance(): BaseVisitor + { + return App::instance()->visitor(); + } +} diff --git a/kirby/src/Content/Content.php b/kirby/src/Content/Content.php new file mode 100644 index 0000000..978c8d2 --- /dev/null +++ b/kirby/src/Content/Content.php @@ -0,0 +1,248 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Content +{ + /** + * The raw data array + */ + protected array $data = []; + + /** + * Cached field objects + * Once a field is being fetched + * it is added to this array for + * later reuse + */ + protected array $fields = []; + + /** + * A potential parent object. + * Not necessarily needed. Especially + * for testing, but field methods might + * need it. + */ + protected ModelWithContent|null $parent; + + /** + * Magic getter for content fields + */ + public function __call(string $name, array $arguments = []): Field + { + return $this->get($name); + } + + /** + * Creates a new Content object + * + * @param bool $normalize Set to `false` if the input field keys are already lowercase + */ + public function __construct( + array $data = [], + ModelWithContent $parent = null, + bool $normalize = true + ) { + if ($normalize === true) { + $data = array_change_key_case($data, CASE_LOWER); + } + + $this->data = $data; + $this->parent = $parent; + } + + /** + * Same as `self::data()` to improve + * `var_dump` output + * @codeCoverageIgnore + * + * @see self::data() + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Converts the content to a new blueprint + */ + public function convertTo(string $to): array + { + // prepare data + $data = []; + $content = $this; + + // blueprints + $old = $this->parent->blueprint(); + $subfolder = dirname($old->name()); + $new = Blueprint::factory( + $subfolder . '/' . $to, + $subfolder . '/default', + $this->parent + ); + + // forms + $oldForm = new Form([ + 'fields' => $old->fields(), + 'model' => $this->parent + ]); + $newForm = new Form([ + 'fields' => $new->fields(), + 'model' => $this->parent + ]); + + // fields + $oldFields = $oldForm->fields(); + $newFields = $newForm->fields(); + + // go through all fields of new template + foreach ($newFields as $newField) { + $name = $newField->name(); + $oldField = $oldFields->get($name); + + // field name and type matches with old template + if ($oldField?->type() === $newField->type()) { + $data[$name] = $content->get($name)->value(); + } else { + $data[$name] = $newField->default(); + } + } + + // preserve existing fields + return array_merge($this->data, $data); + } + + /** + * Returns the raw data array + */ + public function data(): array + { + return $this->data; + } + + /** + * Returns all registered field objects + */ + public function fields(): array + { + foreach ($this->data as $key => $value) { + $this->get($key); + } + return $this->fields; + } + + /** + * Returns either a single field object + * or all registered fields + */ + public function get(string $key = null): Field|array + { + if ($key === null) { + return $this->fields(); + } + + $key = strtolower($key); + + return $this->fields[$key] ??= new Field( + $this->parent, + $key, + $this->data()[$key] ?? null + ); + } + + /** + * Checks if a content field is set + */ + public function has(string $key): bool + { + return isset($this->data[strtolower($key)]) === true; + } + + /** + * Returns all field keys + */ + public function keys(): array + { + return array_keys($this->data()); + } + + /** + * Returns a clone of the content object + * without the fields, specified by the + * passed key(s) + */ + public function not(string ...$keys): static + { + $copy = clone $this; + $copy->fields = []; + + foreach ($keys as $key) { + unset($copy->data[strtolower($key)]); + } + + return $copy; + } + + /** + * Returns the parent + * Site, Page, File or User object + */ + public function parent(): ModelWithContent|null + { + return $this->parent; + } + + /** + * Set the parent model + * + * @return $this + */ + public function setParent(ModelWithContent $parent): static + { + $this->parent = $parent; + return $this; + } + + /** + * Returns the raw data array + * + * @see self::data() + */ + public function toArray(): array + { + return $this->data(); + } + + /** + * Updates the content and returns + * a cloned object + * + * @return $this + */ + public function update( + array $content = null, + bool $overwrite = false + ): static { + $content = array_change_key_case((array)$content, CASE_LOWER); + $this->data = $overwrite === true ? $content : array_merge($this->data, $content); + + // clear cache of Field objects + $this->fields = []; + + return $this; + } +} diff --git a/kirby/src/Content/ContentStorage.php b/kirby/src/Content/ContentStorage.php new file mode 100644 index 0000000..23eab40 --- /dev/null +++ b/kirby/src/Content/ContentStorage.php @@ -0,0 +1,314 @@ + + * @author Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class ContentStorage +{ + protected ContentStorageHandler $handler; + + public function __construct( + protected ModelWithContent $model, + string $handler = PlainTextContentStorageHandler::class + ) { + $this->handler = new $handler($model); + } + + /** + * Magic caller for handler methods + */ + public function __call(string $name, array $args): mixed + { + return $this->handler->$name(...$args); + } + + /** + * Returns generator for all existing versions-languages combinations + * + * @return Generator + * @todo 4.0.0 consider more descpritive name + */ + public function all(): Generator + { + foreach ($this->model->kirby()->languages()->codes() as $lang) { + foreach ($this->dynamicVersions() as $version) { + if ($this->exists($version, $lang) === true) { + yield $version => $lang; + } + } + } + } + + /** + * Returns the absolute path to the content file + * @internal eventually should only exists in PlainTextContentStorage, + * when not relying anymore on language helper + * + * @param string $lang Code `'default'` in a single-lang installation + * + * @throws \Kirby\Exception\LogicException If the model type doesn't have a known content filename + */ + public function contentFile( + string $version, + string $lang, + bool $force = false + ): string { + $lang = $this->language($lang, $force); + return $this->handler->contentFile($version, $lang); + } + + /** + * Adapts all versions when converting languages + * @internal + */ + public function convertLanguage(string $from, string $to): void + { + $from = $this->language($from, true); + $to = $this->language($to, true); + + foreach ($this->dynamicVersions() as $version) { + $this->handler->move($version, $from, $version, $to); + } + } + + /** + * Creates a new version + * + * @param string|null $lang Code `'default'` in a single-lang installation + * @param array $fields Content fields + */ + public function create( + string $versionType, + string|null $lang, + array $fields + ): void { + $lang = $this->language($lang); + $this->handler->create($versionType, $lang, $fields); + } + + /** + * Returns the default version identifier for the model + * @internal + */ + public function defaultVersion(): string + { + if ( + $this->model instanceof Page === true && + $this->model->isDraft() === true + ) { + return 'changes'; + } + + return 'published'; + } + + /** + * Deletes an existing version in an idempotent way if it was already deleted + * + * @param string $lang Code `'default'` in a single-lang installation + */ + public function delete( + string $version, + string|null $lang = null, + bool $force = false + ): void { + $lang = $this->language($lang, $force); + $this->handler->delete($version, $lang); + } + + /** + * Deletes all versions when deleting a language + * @internal + */ + public function deleteLanguage(string|null $lang): void + { + $lang = $this->language($lang, true); + + foreach ($this->dynamicVersions() as $version) { + $this->handler->delete($version, $lang); + } + } + + /** + * Returns all versions availalbe for the model that can be updated + * @internal + */ + public function dynamicVersions(): array + { + $versions = ['changes']; + + if ( + $this->model instanceof Page === false || + $this->model->isDraft() === false + ) { + $versions[] = 'published'; + } + + return $versions; + } + + /** + * Checks if a version exists + * + * @param string|null $lang Code `'default'` in a single-lang installation; + * checks for "any language" if not provided + */ + public function exists( + string $version, + string|null $lang + ): bool { + if ($lang !== null) { + $lang = $this->language($lang); + } + + return $this->handler->exists($version, $lang); + } + + /** + * Returns the modification timestamp of a version + * if it exists + * + * @param string $lang Code `'default'` in a single-lang installation + */ + public function modified( + string $version, + string|null $lang = null + ): int|null { + $lang = $this->language($lang); + return $this->handler->modified($version, $lang); + } + + /** + * Returns the stored content fields + * + * @param string $lang Code `'default'` in a single-lang installation + * @return array + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function read( + string $version, + string|null $lang = null + ): array { + $lang = $this->language($lang); + $this->ensureExistingVersion($version, $lang); + return $this->handler->read($version, $lang); + } + + /** + * Updates the modification timestamp of an existing version + * + * @param string $lang Code `'default'` in a single-lang installation + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function touch( + string $version, + string|null $lang = null + ): void { + $lang = $this->language($lang); + $this->ensureExistingVersion($version, $lang); + $this->handler->touch($version, $lang); + } + + /** + * Touches all versions of a language + * @internal + */ + public function touchLanguage(string|null $lang): void + { + $lang = $this->language($lang, true); + + foreach ($this->dynamicVersions() as $version) { + if ($this->exists($version, $lang) === true) { + $this->handler->touch($version, $lang); + } + } + } + + /** + * Updates the content fields of an existing version + * + * @param string $lang Code `'default'` in a single-lang installation + * @param array $fields Content fields + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function update( + string $version, + string|null $lang = null, + array $fields = [] + ): void { + $lang = $this->language($lang); + $this->ensureExistingVersion($version, $lang); + $this->handler->update($version, $lang, $fields); + } + + /** + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + protected function ensureExistingVersion( + string $version, + string $lang + ): void { + if ($this->exists($version, $lang) !== true) { + throw new NotFoundException('Version "' . $version . ' (' . $lang . ')" does not already exist'); + } + } + + /** + * Converts a "user-facing" language code to a "raw" language code to be + * used for storage + * + * @param bool $force If set to `true`, the language code is not validated + * @return string Language code + */ + protected function language( + string|null $languageCode = null, + bool $force = false + ): string { + // in force mode, use the provided language code even in single-lang for + // compatibility with the previous behavior in `$model->contentFile()` + if ($force === true) { + return $languageCode ?? 'default'; + } + + // in multi-lang, … + if ($this->model->kirby()->multilang() === true) { + // look up the actual language object if possible + $language = $this->model->kirby()->language($languageCode); + + // validate the language code + if ($language === null) { + throw new InvalidArgumentException('Invalid language: ' . $languageCode); + } + + return $language->code(); + } + + // otherwise use hardcoded "default" code for single lang + return 'default'; + } +} diff --git a/kirby/src/Content/ContentStorageHandler.php b/kirby/src/Content/ContentStorageHandler.php new file mode 100644 index 0000000..6f39d11 --- /dev/null +++ b/kirby/src/Content/ContentStorageHandler.php @@ -0,0 +1,96 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +interface ContentStorageHandler +{ + public function __construct(ModelWithContent $model); + + /** + * Creates a new version + * + * @param string $lang Code `'default'` in a single-lang installation + * @param array $fields Content fields + */ + public function create(string $versionType, string $lang, array $fields): void; + + /** + * Deletes an existing version in an idempotent way if it was already deleted + * + * @param string $lang Code `'default'` in a single-lang installation + */ + public function delete(string $version, string $lang): void; + + /** + * Checks if a version exists + * + * @param string|null $lang Code `'default'` in a single-lang installation; + * checks for "any language" if not provided + */ + public function exists(string $version, string|null $lang): bool; + + /** + * Returns the modification timestamp of a version if it exists + * + * @param string $lang Code `'default'` in a single-lang installation + */ + public function modified(string $version, string $lang): int|null; + + /** + * Moves content from one version-language combination to another + * + * @param string $fromLang Code `'default'` in a single-lang installation + * @param string $toLang Code `'default'` in a single-lang installation + */ + public function move( + string $fromVersion, + string $fromLang, + string $toVersion, + string $toLang + ): void; + + /** + * Returns the stored content fields + * + * @param string $lang Code `'default'` in a single-lang installation + * @return array + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function read(string $version, string $lang): array; + + /** + * Updates the modification timestamp of an existing version + * + * @param string $lang Code `'default'` in a single-lang installation + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function touch(string $version, string $lang): void; + + /** + * Updates the content fields of an existing version + * + * @param string $lang Code `'default'` in a single-lang installation + * @param array $fields Content fields + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function update(string $version, string $lang, array $fields): void; +} diff --git a/kirby/src/Content/ContentTranslation.php b/kirby/src/Content/ContentTranslation.php new file mode 100644 index 0000000..c04ec32 --- /dev/null +++ b/kirby/src/Content/ContentTranslation.php @@ -0,0 +1,173 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class ContentTranslation +{ + protected string $code; + protected array|null $content; + protected string $contentFile; + protected ModelWithContent $parent; + protected string|null $slug; + + /** + * Creates a new translation object + */ + public function __construct(array $props) + { + $this->code = $props['code']; + $this->parent = $props['parent']; + $this->slug = $props['slug'] ?? null; + + if ($content = $props['content'] ?? null) { + $this->content = array_change_key_case($content); + } else { + $this->content = null; + } + } + + /** + * Improve `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Returns the language code of the + * translation + */ + public function code(): string + { + return $this->code; + } + + /** + * Returns the translation content + * as plain array + */ + public function content(): array + { + $parent = $this->parent(); + $content = $this->content ??= $parent->readContent($this->code()); + + // merge with the default content + if ( + $this->isDefault() === false && + $defaultLanguage = $parent->kirby()->defaultLanguage() + ) { + $content = array_merge( + $parent->translation($defaultLanguage->code())?->content() ?? [], + $content + ); + } + + return $content; + } + + /** + * Absolute path to the translation content file + */ + public function contentFile(): string + { + // temporary compatibility change (TODO: take this from the parent `ModelVersion` object) + $identifier = $this->parent::CLASS_ALIAS === 'page' && $this->parent->isDraft() === true ? + 'changes' : + 'published'; + + return $this->contentFile = $this->parent->storage()->contentFile( + $identifier, + $this->code, + true + ); + } + + /** + * Checks if the translation file exists + */ + public function exists(): bool + { + return + empty($this->content) === false || + file_exists($this->contentFile()) === true; + } + + /** + * Returns the translation code as id + */ + public function id(): string + { + return $this->code(); + } + + /** + * Checks if the this is the default translation + * of the model + */ + public function isDefault(): bool + { + return $this->code() === $this->parent->kirby()->defaultLanguage()?->code(); + } + + /** + * Returns the parent page, file or site object + */ + public function parent(): ModelWithContent + { + return $this->parent; + } + + /** + * Returns the custom translation slug + */ + public function slug(): string|null + { + return $this->slug ??= ($this->content()['slug'] ?? null); + } + + /** + * Merge the old and new data + * + * @return $this + */ + public function update(array $data = null, bool $overwrite = false): static + { + $data = array_change_key_case((array)$data); + + $this->content = match ($overwrite) { + true => $data, + default => array_merge($this->content(), $data) + }; + + return $this; + } + + /** + * Converts the most important translation + * props to an array + */ + public function toArray(): array + { + return [ + 'code' => $this->code(), + 'content' => $this->content(), + 'exists' => $this->exists(), + 'slug' => $this->slug(), + ]; + } +} diff --git a/kirby/src/Content/Field.php b/kirby/src/Content/Field.php new file mode 100644 index 0000000..0082063 --- /dev/null +++ b/kirby/src/Content/Field.php @@ -0,0 +1,220 @@ +myField()->lower(); + * ``` + * + * @package Kirby Content + * @author Bastian Allgeier + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Field +{ + /** + * Field method aliases + */ + public static array $aliases = []; + + /** + * The field name + */ + protected string $key; + + /** + * Registered field methods + */ + public static array $methods = []; + + /** + * The parent object if available. + * This will be the page, site, user or file + * to which the content belongs + */ + protected ModelWithContent|null $parent; + + /** + * The value of the field + */ + public mixed $value; + + /** + * Creates a new field object + */ + public function __construct( + ModelWithContent|null $parent, + string $key, + mixed $value + ) { + $this->key = $key; + $this->value = $value; + $this->parent = $parent; + } + + /** + * Magic caller for field methods + */ + public function __call(string $method, array $arguments = []): mixed + { + $method = strtolower($method); + + if (isset(static::$methods[$method]) === true) { + return (static::$methods[$method])(clone $this, ...$arguments); + } + + if (isset(static::$aliases[$method]) === true) { + $method = strtolower(static::$aliases[$method]); + + if (isset(static::$methods[$method]) === true) { + return (static::$methods[$method])(clone $this, ...$arguments); + } + } + + return $this; + } + + /** + * Simplifies the var_dump result + * @codeCoverageIgnore + * + * @see Field::toArray + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Makes it possible to simply echo + * or stringify the entire object + * + * @see Field::toString + */ + public function __toString(): string + { + return $this->toString(); + } + + /** + * Checks if the field exists in the content data array + */ + public function exists(): bool + { + return $this->parent->content()->has($this->key); + } + + /** + * Checks if the field content is empty + */ + public function isEmpty(): bool + { + return + empty($this->value) === true && + in_array($this->value, [0, '0', false], true) === false; + } + + /** + * Checks if the field content is not empty + */ + public function isNotEmpty(): bool + { + return $this->isEmpty() === false; + } + + /** + * Returns the name of the field + */ + public function key(): string + { + return $this->key; + } + + /** + * @see Field::parent() + */ + public function model(): ModelWithContent|null + { + return $this->parent; + } + + /** + * Provides a fallback if the field value is empty + * + * @return $this|static + */ + public function or(mixed $fallback = null): static + { + if ($this->isNotEmpty()) { + return $this; + } + + if ($fallback instanceof self) { + return $fallback; + } + + $field = clone $this; + $field->value = $fallback; + return $field; + } + + /** + * Returns the parent object of the field + */ + public function parent(): ModelWithContent|null + { + return $this->parent; + } + + /** + * Converts the Field object to an array + */ + public function toArray(): array + { + return [$this->key => $this->value]; + } + + /** + * Returns the field value as string + */ + public function toString(): string + { + return (string)$this->value; + } + + /** + * Returns the field content. If a new value is passed, + * the modified field will be returned. Otherwise it + * will return the field value. + */ + public function value(string|Closure $value = null): mixed + { + if ($value === null) { + return $this->value; + } + + if ($value instanceof Closure) { + $value = $value->call($this, $this->value); + } + + $clone = clone $this; + $clone->value = (string)$value; + + return $clone; + } +} diff --git a/kirby/src/Content/PlainTextContentStorageHandler.php b/kirby/src/Content/PlainTextContentStorageHandler.php new file mode 100644 index 0000000..756816a --- /dev/null +++ b/kirby/src/Content/PlainTextContentStorageHandler.php @@ -0,0 +1,253 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PlainTextContentStorageHandler implements ContentStorageHandler +{ + public function __construct(protected ModelWithContent $model) + { + } + + /** + * Creates a new version + * + * @param string $lang Code `'default'` in a single-lang installation + * @param array $fields Content fields + */ + public function create(string $versionType, string $lang, array $fields): void + { + $success = Data::write($this->contentFile($versionType, $lang), $fields); + + // @codeCoverageIgnoreStart + if ($success !== true) { + throw new Exception('Could not write new content file'); + } + // @codeCoverageIgnoreEnd + } + + /** + * Deletes an existing version in an idempotent way if it was already deleted + * + * @param string $lang Code `'default'` in a single-lang installation + */ + public function delete(string $version, string $lang): void + { + $contentFile = $this->contentFile($version, $lang); + $success = F::unlink($contentFile); + + // @codeCoverageIgnoreStart + if ($success !== true) { + throw new Exception('Could not delete content file'); + } + // @codeCoverageIgnoreEnd + + // clean up empty directories + $contentDir = dirname($contentFile); + if ( + Dir::exists($contentDir) === true && + Dir::isEmpty($contentDir) === true + ) { + $success = rmdir($contentDir); + + // @codeCoverageIgnoreStart + if ($success !== true) { + throw new Exception('Could not delete empty content directory'); + } + // @codeCoverageIgnoreEnd + } + } + + /** + * Checks if a version exists + * + * @param string|null $lang Code `'default'` in a single-lang installation; + * checks for "any language" if not provided + */ + public function exists(string $version, string|null $lang): bool + { + if ($lang === null) { + foreach ($this->contentFiles($version) as $file) { + if (is_file($file) === true) { + return true; + } + } + + return false; + } + + return is_file($this->contentFile($version, $lang)) === true; + } + + /** + * Returns the modification timestamp of a version + * if it exists + * + * @param string $lang Code `'default'` in a single-lang installation + */ + public function modified(string $version, string $lang): int|null + { + $modified = F::modified($this->contentFile($version, $lang)); + + if (is_int($modified) === true) { + return $modified; + } + + return null; + } + + /** + * Returns the stored content fields + * + * @param string $lang Code `'default'` in a single-lang installation + * @return array + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function read(string $version, string $lang): array + { + return Data::read($this->contentFile($version, $lang)); + } + + /** + * Updates the modification timestamp of an existing version + * + * @param string $lang Code `'default'` in a single-lang installation + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function touch(string $version, string $lang): void + { + $success = touch($this->contentFile($version, $lang)); + + // @codeCoverageIgnoreStart + if ($success !== true) { + throw new Exception('Could not touch existing content file'); + } + // @codeCoverageIgnoreEnd + } + + /** + * Updates the content fields of an existing version + * + * @param string $lang Code `'default'` in a single-lang installation + * @param array $fields Content fields + * + * @throws \Kirby\Exception\NotFoundException If the version does not exist + */ + public function update(string $version, string $lang, array $fields): void + { + $success = Data::write($this->contentFile($version, $lang), $fields); + + // @codeCoverageIgnoreStart + if ($success !== true) { + throw new Exception('Could not write existing content file'); + } + // @codeCoverageIgnoreEnd + } + + /** + * Returns the absolute path to the content file + * @internal To be made `protected` when the CMS core no longer relies on it + * + * @param string $lang Code `'default'` in a single-lang installation + * + * @throws \Kirby\Exception\LogicException If the model type doesn't have a known content filename + */ + public function contentFile(string $version, string $lang): string + { + if (in_array($version, ['published', 'changes']) !== true) { + throw new InvalidArgumentException('Invalid version identifier "' . $version . '"'); + } + + $extension = $this->model->kirby()->contentExtension(); + $directory = $this->model->root(); + + $directory = match ($this->model::CLASS_ALIAS) { + 'file' => dirname($this->model->root()), + default => $this->model->root() + }; + + $filename = match ($this->model::CLASS_ALIAS) { + 'file' => $this->model->filename(), + 'page' => $this->model->intendedTemplate()->name(), + 'site', + 'user' => $this->model::CLASS_ALIAS, + // @codeCoverageIgnoreStart + default => throw new LogicException('Cannot determine content filename for model type "' . $this->model::CLASS_ALIAS . '"') + // @codeCoverageIgnoreEnd + }; + + if ($this->model::CLASS_ALIAS === 'page' && $this->model->isDraft() === true) { + // changes versions don't need anything extra + // (drafts already have the `_drafts` prefix in their root), + // but a published version is not possible + if ($version === 'published') { + throw new LogicException('Drafts cannot have a published content file'); + } + } elseif ($version === 'changes') { + // other model type or published page that has a changes subfolder + $directory .= '/_changes'; + } + + if ($lang !== 'default') { + return $directory . '/' . $filename . '.' . $lang . '.' . $extension; + } + + return $directory . '/' . $filename . '.' . $extension; + } + + /** + * Returns an array with content files of all languages + * @internal To be made `protected` when the CMS core no longer relies on it + */ + public function contentFiles(string $version): array + { + if ($this->model->kirby()->multilang() === true) { + return $this->model->kirby()->languages()->values( + fn ($lang) => $this->contentFile($version, $lang) + ); + } + + return [ + $this->contentFile($version, 'default') + ]; + } + + /** + * Moves content from one version-language combination to another + * + * @param string $fromLang Code `'default'` in a single-lang installation + * @param string $toLang Code `'default'` in a single-lang installation + */ + public function move( + string $fromVersion, + string $fromLang, + string $toVersion, + string $toLang + ): void { + F::move( + $this->contentFile($fromVersion, $fromLang), + $this->contentFile($toVersion, $toLang) + ); + } +} diff --git a/kirby/src/Data/Data.php b/kirby/src/Data/Data.php new file mode 100644 index 0000000..b6a1cb6 --- /dev/null +++ b/kirby/src/Data/Data.php @@ -0,0 +1,118 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Data +{ + /** + * Handler Type Aliases + */ + public static array $aliases = [ + 'md' => 'txt', + 'mdown' => 'txt', + 'rss' => 'xml', + 'yml' => 'yaml', + ]; + + /** + * All registered handlers + */ + public static array $handlers = [ + 'json' => Json::class, + 'php' => PHP::class, + 'txt' => Txt::class, + 'xml' => Xml::class, + 'yaml' => Yaml::class + ]; + + /** + * Handler getter + */ + public static function handler(string $type): Handler + { + // normalize the type + $type = strtolower($type); + + // find a handler or alias + $handler = static::$handlers[$type] ?? null; + + if ($alias = static::$aliases[$type] ?? null) { + $handler ??= static::$handlers[$alias] ?? null; + } + + if ($handler === null || class_exists($handler) === false) { + throw new Exception('Missing handler for type: "' . $type . '"'); + } + + $handler = new $handler(); + + if ($handler instanceof Handler === false) { + throw new Exception('Handler for type: "' . $type . '" needs to extend Kirby\\Data\\Handler'); + } + + return $handler; + } + + /** + * Decodes data with the specified handler + */ + public static function decode($string, string $type): array + { + return static::handler($type)->decode($string); + } + + /** + * Encodes data with the specified handler + */ + public static function encode($data, string $type): string + { + return static::handler($type)->encode($data); + } + + /** + * Reads data from a file; + * the data handler is automatically chosen by + * the extension if not specified + */ + public static function read(string $file, string|null $type = null): array + { + $type ??= F::extension($file); + $handler = static::handler($type); + return $handler->read($file); + } + + /** + * Writes data to a file; + * the data handler is automatically chosen by + * the extension if not specified + */ + public static function write( + string $file, + $data = [], + string|null $type = null + ): bool { + $type ??= F::extension($file); + $handler = static::handler($type); + return $handler->write($file, $data); + } +} diff --git a/kirby/src/Data/Handler.php b/kirby/src/Data/Handler.php new file mode 100644 index 0000000..95dbad3 --- /dev/null +++ b/kirby/src/Data/Handler.php @@ -0,0 +1,54 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class Handler +{ + /** + * Parses an encoded string and returns a multi-dimensional array + * + * @throws \Exception if the file can't be parsed + */ + abstract public static function decode($string): array; + + /** + * Converts an array to an encoded string + */ + abstract public static function encode($data): string; + + /** + * Reads data from a file + */ + public static function read(string $file): array + { + $contents = F::read($file); + + if ($contents === false) { + throw new Exception('The file "' . $file . '" does not exist or cannot be read'); + } + + return static::decode($contents); + } + + /** + * Writes data to a file + */ + public static function write(string $file, $data = []): bool + { + return F::write($file, static::encode($data)); + } +} diff --git a/kirby/src/Data/Json.php b/kirby/src/Data/Json.php new file mode 100644 index 0000000..35fa867 --- /dev/null +++ b/kirby/src/Data/Json.php @@ -0,0 +1,54 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Json extends Handler +{ + /** + * Converts an array to an encoded JSON string + */ + public static function encode($data): string + { + return json_encode( + $data, + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + ); + } + + /** + * Parses an encoded JSON string and returns a multi-dimensional array + */ + public static function decode($string): array + { + if ($string === null || $string === '') { + return []; + } + + if (is_array($string) === true) { + return $string; + } + + if (is_string($string) === false) { + throw new InvalidArgumentException('Invalid JSON data; please pass a string'); + } + + $result = json_decode($string, true); + + if (is_array($result) === true) { + return $result; + } + + throw new InvalidArgumentException('JSON string is invalid'); + } +} diff --git a/kirby/src/Data/PHP.php b/kirby/src/Data/PHP.php new file mode 100644 index 0000000..b22d38a --- /dev/null +++ b/kirby/src/Data/PHP.php @@ -0,0 +1,82 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class PHP extends Handler +{ + /** + * Converts an array to PHP file content + * + * @param string $indent For internal use only + */ + public static function encode($data, string $indent = ''): string + { + switch (gettype($data)) { + case 'array': + $indexed = array_keys($data) === range(0, count($data) - 1); + $array = []; + + foreach ($data as $key => $value) { + $array[] = "$indent " . ($indexed ? '' : static::encode($key) . ' => ') . static::encode($value, "$indent "); + } + + return "[\n" . implode(",\n", $array) . "\n" . $indent . ']'; + case 'boolean': + return $data ? 'true' : 'false'; + case 'integer': + case 'double': + return (string)$data; + default: + return var_export($data, true); + } + } + + /** + * PHP strings shouldn't be decoded manually + */ + public static function decode($string): array + { + throw new BadMethodCallException('The PHP::decode() method is not implemented'); + } + + /** + * Reads data from a file + */ + public static function read(string $file): array + { + if (is_file($file) !== true) { + throw new Exception('The file "' . $file . '" does not exist'); + } + + return (array)F::load($file, [], allowOutput: false); + } + + /** + * Creates a PHP file with the given data + */ + public static function write(string $file, $data = []): bool + { + $php = static::encode($data); + $php = " + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Txt extends Handler +{ + /** + * Converts an array to an encoded Kirby txt string + */ + public static function encode($data): string + { + $result = []; + + foreach (A::wrap($data) as $key => $value) { + if (empty($key) === true || $value === null) { + continue; + } + + $key = Str::ucfirst(Str::slug($key)); + $value = static::encodeValue($value); + $result[$key] = static::encodeResult($key, $value); + } + + return implode("\n\n----\n\n", $result); + } + + /** + * Helper for converting the value + */ + protected static function encodeValue(array|string|float $value): string + { + // avoid problems with arrays + if (is_array($value) === true) { + $value = Data::encode($value, 'yaml'); + // avoid problems with localized floats + } elseif (is_float($value) === true) { + $value = Str::float($value); + } + + // escape accidental dividers within a field + $value = preg_replace('!(?<=\n|^)----!', '\\----', $value); + + return $value; + } + + /** + * Helper for converting the key and value to the result string + */ + protected static function encodeResult(string $key, string $value): string + { + $value = trim($value); + $result = $key . ':'; + + // multi-line content + $result .= match (preg_match('!\R!', $value)) { + 1 => "\n\n", + default => ' ', + }; + + $result .= $value; + + return $result; + } + + /** + * Parses a Kirby txt string and returns a multi-dimensional array + */ + public static function decode($string): array + { + if ($string === null || $string === '') { + return []; + } + + if (is_array($string) === true) { + return $string; + } + + if (is_string($string) === false) { + throw new InvalidArgumentException('Invalid TXT data; please pass a string'); + } + + // remove Unicode BOM at the beginning of the file + if (Str::startsWith($string, "\xEF\xBB\xBF") === true) { + $string = substr($string, 3); + } + + // explode all fields by the line separator + $fields = preg_split('!\n----\s*\n*!', $string); + + // start the data array + $data = []; + + // loop through all fields and add them to the content + foreach ($fields as $field) { + if ($pos = strpos($field, ':')) { + $key = strtolower(trim(substr($field, 0, $pos))); + $key = str_replace(['-', ' '], '_', $key); + + // Don't add fields with empty keys + if (empty($key) === true) { + continue; + } + + $value = trim(substr($field, $pos + 1)); + + // unescape escaped dividers within a field + $data[$key] = preg_replace( + '!(?<=\n|^)\\\\----!', + '----', + $value + ); + } + } + + return $data; + } +} diff --git a/kirby/src/Data/Xml.php b/kirby/src/Data/Xml.php new file mode 100644 index 0000000..68fa511 --- /dev/null +++ b/kirby/src/Data/Xml.php @@ -0,0 +1,58 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Xml extends Handler +{ + /** + * Converts an array to an encoded XML string + */ + public static function encode($data): string + { + return XmlConverter::create($data, 'data'); + } + + /** + * Parses an encoded XML string and returns a multi-dimensional array + */ + public static function decode($string): array + { + if ($string === null || $string === '') { + return []; + } + + if (is_array($string) === true) { + return $string; + } + + if (is_string($string) === false) { + throw new InvalidArgumentException('Invalid XML data; please pass a string'); + } + + $result = XmlConverter::parse($string); + + if (is_array($result) === true) { + // remove the root's name if it is the default to ensure that + // the decoded data is the same as the input to the encode() method + if ($result['@name'] === 'data') { + unset($result['@name']); + } + + return $result; + } + + throw new InvalidArgumentException('XML string is invalid'); + } +} diff --git a/kirby/src/Data/Yaml.php b/kirby/src/Data/Yaml.php new file mode 100644 index 0000000..efa9c9c --- /dev/null +++ b/kirby/src/Data/Yaml.php @@ -0,0 +1,62 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Yaml extends Handler +{ + /** + * Converts an array to an encoded YAML string + */ + public static function encode($data): string + { + return match (static::handler()) { + 'symfony' => YamlSymfony::encode($data), + default => YamlSpyc::encode($data), + }; + } + + /** + * Parses an encoded YAML string and returns a multi-dimensional array + */ + public static function decode($string): array + { + if ($string === null || $string === '') { + return []; + } + + if (is_array($string) === true) { + return $string; + } + + if (is_string($string) === false) { + throw new InvalidArgumentException('Invalid YAML data; please pass a string'); + } + + return match (static::handler()) { + 'symfony' => YamlSymfony::decode($string), + default => YamlSpyc::decode($string) + }; + } + + /** + * Returns which YAML parser (`spyc` or `symfony`) + * is configured to be used + * @internal + */ + public static function handler(): string + { + return App::instance(null, true)?->option('yaml.handler') ?? 'spyc'; + } +} diff --git a/kirby/src/Data/YamlSpyc.php b/kirby/src/Data/YamlSpyc.php new file mode 100644 index 0000000..a00e92d --- /dev/null +++ b/kirby/src/Data/YamlSpyc.php @@ -0,0 +1,43 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class YamlSpyc +{ + /** + * Converts an array to an encoded YAML string + */ + public static function encode($data): string + { + // $data, $indent, $wordwrap, $no_opening_dashes + return Spyc::YAMLDump($data, false, false, true); + } + + /** + * Parses an encoded YAML string and returns a multi-dimensional array + */ + public static function decode($string): array + { + $result = Spyc::YAMLLoadString($string); + + if (is_array($result) === true) { + return $result; + } + + // apparently Spyc always returns an array, even for invalid YAML syntax + // so this Exception should currently never be thrown + throw new InvalidArgumentException('The YAML data cannot be parsed'); // @codeCoverageIgnore + } +} diff --git a/kirby/src/Data/YamlSymfony.php b/kirby/src/Data/YamlSymfony.php new file mode 100644 index 0000000..013a0d0 --- /dev/null +++ b/kirby/src/Data/YamlSymfony.php @@ -0,0 +1,44 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class YamlSymfony +{ + /** + * Converts an array to an encoded YAML string + */ + public static function encode($data): string + { + $kirby = App::instance(null, true); + + return Symfony::dump( + $data, + $kirby?->option('yaml.params.inline') ?? 9999, + $kirby?->option('yaml.params.indent') ?? 2, + Symfony::DUMP_MULTI_LINE_LITERAL_BLOCK | Symfony::DUMP_EMPTY_ARRAY_AS_SEQUENCE + ); + } + + /** + * Parses an encoded YAML string and returns a multi-dimensional array + */ + public static function decode($string): array + { + $result = Symfony::parse($string); + $result = A::wrap($result); + return $result; + } +} diff --git a/kirby/src/Database/Database.php b/kirby/src/Database/Database.php new file mode 100644 index 0000000..1284017 --- /dev/null +++ b/kirby/src/Database/Database.php @@ -0,0 +1,587 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Database +{ + /** + * The number of affected rows for the last query + */ + protected int|null $affected = null; + + /** + * Whitelist for column names + */ + protected array $columnWhitelist = []; + + /** + * The established connection + */ + protected PDO|null $connection = null; + + /** + * A global array of started connections + */ + public static array $connections = []; + + /** + * Database name + */ + protected string $database; + + protected string $dsn; + + /** + * Set to true to throw exceptions on failed queries + */ + protected bool $fail = false; + + /** + * The connection id + */ + protected string $id; + + /** + * The last error + */ + protected Throwable|null $lastError = null; + + /** + * The last insert id + */ + protected int|null $lastId = null; + + /** + * The last query + */ + protected string $lastQuery; + + /** + * The last result set + */ + protected $lastResult; + + /** + * Optional prefix for table names + */ + protected string|null $prefix = null; + + /** + * The PDO query statement + */ + protected PDOStatement|null $statement = null; + + /** + * List of existing tables in the database + */ + protected array|null $tables = null; + + /** + * An array with all queries which are being made + */ + protected array $trace = []; + + /** + * The database type (mysql, sqlite) + */ + protected string $type; + + public static array $types = []; + + /** + * Creates a new Database instance + */ + public function __construct(array $params = []) + { + $this->connect($params); + } + + /** + * Returns one of the started instances + */ + public static function instance(string|null $id = null): static|null + { + if ($id === null) { + return A::last(static::$connections); + } + + return static::$connections[$id] ?? null; + } + + /** + * Returns all started instances + */ + public static function instances(): array + { + return static::$connections; + } + + /** + * Connects to a database + * + * @param array|null $params This can either be a config key or an array of parameters for the connection + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function connect(array|null $params = null): PDO|null + { + $defaults = [ + 'database' => null, + 'type' => 'mysql', + 'prefix' => null, + 'user' => null, + 'password' => null, + 'id' => uniqid() + ]; + + $options = array_merge($defaults, $params); + + // store the database information + $this->database = $options['database']; + $this->type = $options['type']; + $this->prefix = $options['prefix']; + $this->id = $options['id']; + + if (isset(static::$types[$this->type]) === false) { + throw new InvalidArgumentException('Invalid database type: ' . $this->type); + } + + // fetch the dsn and store it + $this->dsn = (static::$types[$this->type]['dsn'])($options); + + // try to connect + $this->connection = new PDO($this->dsn, $options['user'], $options['password']); + $this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->connection->setAttribute(PDO::ATTR_EMULATE_PREPARES, false); + + // TODO: behavior without this attribute would be preferrable + // (actual types instead of all strings) but would be a breaking change + if ($this->type === 'sqlite') { + $this->connection->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, true); + } + + // store the connection + static::$connections[$this->id] = $this; + + // return the connection + return $this->connection; + } + + /** + * Returns the currently active connection + */ + public function connection(): PDO|null + { + return $this->connection; + } + + /** + * Sets the exception mode + * + * @return $this + */ + public function fail(bool $fail = true): static + { + $this->fail = $fail; + return $this; + } + + /** + * Returns the used database type + */ + public function type(): string + { + return $this->type; + } + + /** + * Returns the used table name prefix + */ + public function prefix(): string|null + { + return $this->prefix; + } + + /** + * Escapes a value to be used for a safe query + * NOTE: Prepared statements using bound parameters are more secure and solid + */ + public function escape(string $value): string + { + return substr($this->connection()->quote($value), 1, -1); + } + + /** + * Adds a value to the db trace and also + * returns the entire trace if nothing is specified + */ + public function trace(array|null $data = null): array + { + // return the full trace + if ($data === null) { + return $this->trace; + } + + // add a new entry to the trace + $this->trace[] = $data; + + return $this->trace; + } + + /** + * Returns the number of affected rows for the last query + */ + public function affected(): int|null + { + return $this->affected; + } + + /** + * Returns the last id if available + */ + public function lastId(): int|null + { + return $this->lastId; + } + + /** + * Returns the last query + */ + public function lastQuery(): string|null + { + return $this->lastQuery; + } + + /** + * Returns the last set of results + */ + public function lastResult() + { + return $this->lastResult; + } + + /** + * Returns the last db error + */ + public function lastError(): Throwable|null + { + return $this->lastError; + } + + /** + * Returns the name of the database + */ + public function name(): string|null + { + return $this->database; + } + + /** + * Private method to execute database queries. + * This is used by the query() and execute() methods + */ + protected function hit(string $query, array $bindings = []): bool + { + // try to prepare and execute the sql + try { + $this->statement = $this->connection->prepare($query); + $this->statement->execute($bindings); + + $this->affected = $this->statement->rowCount(); + $this->lastId = Str::startsWith($query, 'insert ', true) ? $this->connection->lastInsertId() : null; + $this->lastError = null; + + // store the final sql to add it to the trace later + $this->lastQuery = $this->statement->queryString; + } catch (Throwable $e) { + // store the error + $this->affected = 0; + $this->lastError = $e; + $this->lastId = null; + $this->lastQuery = $query; + + // only throw the extension if failing is allowed + if ($this->fail === true) { + throw $e; + } + } + + // add a new entry to the singleton trace array + $this->trace([ + 'query' => $this->lastQuery, + 'bindings' => $bindings, + 'error' => $this->lastError + ]); + + // return true or false on success or failure + return $this->lastError === null; + } + + /** + * Executes a sql query, which is expected to return a set of results + */ + public function query( + string $query, + array $bindings = [], + array $params = [] + ) { + $defaults = [ + 'flag' => null, + 'method' => 'fetchAll', + 'fetch' => Obj::class, + 'iterator' => Collection::class, + ]; + + $options = array_merge($defaults, $params); + + if ($this->hit($query, $bindings) === false) { + return false; + } + + // define the default flag for the fetch method + if ( + $options['fetch'] instanceof Closure || + $options['fetch'] === 'array' + ) { + $flags = PDO::FETCH_ASSOC; + } else { + $flags = PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE; + } + + // add optional flags + if (empty($options['flag']) === false) { + $flags |= $options['flag']; + } + + // set the fetch mode + if ( + $options['fetch'] instanceof Closure || + $options['fetch'] === 'array' + ) { + $this->statement->setFetchMode($flags); + } else { + $this->statement->setFetchMode($flags, $options['fetch']); + } + + // fetch that stuff + $results = $this->statement->{$options['method']}(); + + // apply the fetch closure to all results if given + if ($options['fetch'] instanceof Closure) { + if ($options['method'] === 'fetchAll') { + // fetching multiple records + foreach ($results as $key => $result) { + $results[$key] = $options['fetch']($result, $key); + } + } elseif ($options['method'] === 'fetch' && $results !== false) { + // fetching a single record + $results = $options['fetch']($results, null); + } + } + + if ($options['iterator'] === 'array') { + return $this->lastResult = $results; + } + + return $this->lastResult = new $options['iterator']($results); + } + + /** + * Executes a sql query, which is expected + * to not return a set of results + */ + public function execute(string $query, array $bindings = []): bool + { + return $this->lastResult = $this->hit($query, $bindings); + } + + /** + * Returns the correct Sql generator instance + * for the type of database + */ + public function sql(): Sql + { + $className = static::$types[$this->type]['sql'] ?? 'Sql'; + return new $className($this); + } + + /** + * Sets the current table, which should be queried. Returns a + * Query object, which can be used to build a full query + * for that table + */ + public function table(string $table): Query + { + return new Query($this, $this->prefix() . $table); + } + + /** + * Checks if a table exists in the current database + */ + public function validateTable(string $table): bool + { + if ($this->tables === null) { + // Get the list of tables from the database + $sql = $this->sql()->tables(); + $results = $this->query($sql['query'], $sql['bindings']); + + if ($results) { + $this->tables = $results->pluck('name'); + } else { + return false; + } + } + + return in_array($table, $this->tables) === true; + } + + /** + * Checks if a column exists in a specified table + */ + public function validateColumn(string $table, string $column): bool + { + if (isset($this->columnWhitelist[$table]) === false) { + if ($this->validateTable($table) === false) { + $this->columnWhitelist[$table] = []; + return false; + } + + // Get the column whitelist from the database + $sql = $this->sql()->columns($table); + $results = $this->query($sql['query'], $sql['bindings']); + + if ($results) { + $this->columnWhitelist[$table] = $results->pluck('name'); + } else { + return false; + } + } + + return in_array($column, $this->columnWhitelist[$table]) === true; + } + + /** + * Creates a new table + */ + public function createTable(string $table, array $columns = []): bool + { + $sql = $this->sql()->createTable($table, $columns); + $queries = Str::split($sql['query'], ';'); + + foreach ($queries as $query) { + $query = trim($query); + + if ($this->execute($query, $sql['bindings']) === false) { + return false; + } + } + + // update cache + if (in_array($table, $this->tables ?? []) !== true) { + $this->tables[] = $table; + } + + return true; + } + + /** + * Drops a table + */ + public function dropTable(string $table): bool + { + $sql = $this->sql()->dropTable($table); + if ($this->execute($sql['query'], $sql['bindings']) !== true) { + return false; + } + + // update cache + $key = array_search($table, $this->tables ?? []); + if ($key !== false) { + unset($this->tables[$key]); + } + + return true; + } + + /** + * Magic way to start queries for tables by + * using a method named like the table. + * I.e. $db->users()->all() + */ + public function __call(string $method, mixed $arguments = null): Query + { + return $this->table($method); + } +} + +/** + * MySQL database connector + */ +Database::$types['mysql'] = [ + 'sql' => Mysql::class, + 'dsn' => function (array $params): string { + if (isset($params['host']) === false && isset($params['socket']) === false) { + throw new InvalidArgumentException('The mysql connection requires either a "host" or a "socket" parameter'); + } + + if (isset($params['database']) === false) { + throw new InvalidArgumentException('The mysql connection requires a "database" parameter'); + } + + $parts = []; + + if (empty($params['host']) === false) { + $parts[] = 'host=' . $params['host']; + } + + if (empty($params['port']) === false) { + $parts[] = 'port=' . $params['port']; + } + + if (empty($params['socket']) === false) { + $parts[] = 'unix_socket=' . $params['socket']; + } + + if (empty($params['database']) === false) { + $parts[] = 'dbname=' . $params['database']; + } + + $parts[] = 'charset=' . ($params['charset'] ?? 'utf8'); + + return 'mysql:' . implode(';', $parts); + } +]; + +/** + * SQLite database connector + */ +Database::$types['sqlite'] = [ + 'sql' => Sqlite::class, + 'dsn' => function (array $params): string { + if (isset($params['database']) === false) { + throw new InvalidArgumentException('The sqlite connection requires a "database" parameter'); + } + + return 'sqlite:' . $params['database']; + } +]; diff --git a/kirby/src/Database/Db.php b/kirby/src/Database/Db.php new file mode 100644 index 0000000..3b4648d --- /dev/null +++ b/kirby/src/Database/Db.php @@ -0,0 +1,293 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Db +{ + /** + * Query shortcuts + */ + public static array $queries = []; + + /** + * The singleton Database object + */ + public static Database|null $connection = null; + + /** + * (Re)connect the database + * + * @param array|null $params Pass `[]` to use the default params from the config, + * don't pass any argument to get the current connection + */ + public static function connect(array|null $params = null): Database + { + if ($params === null && static::$connection !== null) { + return static::$connection; + } + + // try to connect with the default + // connection settings if no params are set + $params ??= [ + 'type' => Config::get('db.type', 'mysql'), + 'host' => Config::get('db.host', 'localhost'), + 'user' => Config::get('db.user', 'root'), + 'password' => Config::get('db.password', ''), + 'database' => Config::get('db.database', ''), + 'prefix' => Config::get('db.prefix', ''), + 'port' => Config::get('db.port', '') + ]; + + return static::$connection = new Database($params); + } + + /** + * Returns the current database connection + */ + public static function connection(): Database|null + { + return static::$connection; + } + + /** + * Sets the current table which should be queried. Returns a + * Query object, which can be used to build a full query for + * that table. + */ + public static function table(string $table): Query + { + $db = static::connect(); + return $db->table($table); + } + + /** + * Executes a raw SQL query which expects a set of results + */ + public static function query(string $query, array $bindings = [], array $params = []) + { + $db = static::connect(); + return $db->query($query, $bindings, $params); + } + + /** + * Executes a raw SQL query which expects + * no set of results (i.e. update, insert, delete) + */ + public static function execute(string $query, array $bindings = []): bool + { + $db = static::connect(); + return $db->execute($query, $bindings); + } + + /** + * Magic calls for other static Db methods are + * redirected to either a predefined query or + * the respective method of the Database object + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function __callStatic(string $method, $arguments) + { + if (isset(static::$queries[$method])) { + return (static::$queries[$method])(...$arguments); + } + + if ( + static::$connection !== null && + method_exists(static::$connection, $method) === true + ) { + return call_user_func_array([static::$connection, $method], $arguments); + } + + throw new InvalidArgumentException('Invalid static Db method: ' . $method); + } +} + +// @codeCoverageIgnoreStart + +/** + * Shortcut for SELECT clauses + * + * @param string $table The name of the table which should be queried + * @param mixed $columns Either a string with columns or an array of column names + * @param mixed $where The WHERE clause; can be a string or an array + */ +Db::$queries['select'] = function ( + string $table, + $columns = '*', + $where = null, + string|null $order = null, + int $offset = 0, + int|null $limit = null +) { + return Db::table($table) + ->select($columns) + ->where($where) + ->order($order) + ->offset($offset) + ->limit($limit) + ->all(); +}; + +/** + * Shortcut for selecting a single row in a table + * + * @param string $table The name of the table which should be queried + * @param mixed $columns Either a string with columns or an array of column names + * @param mixed $where The WHERE clause; can be a string or an array + */ +Db::$queries['first'] = Db::$queries['row'] = Db::$queries['one'] = function ( + string $table, + $columns = '*', + $where = null, + string|null $order = null +) { + return Db::table($table) + ->select($columns) + ->where($where) + ->order($order) + ->first(); +}; + +/** + * Returns only values from a single column + * + * @param string $table The name of the table which should be queried + * @param string $column The name of the column to select from + * @param mixed $where The WHERE clause; can be a string or an array + */ +Db::$queries['column'] = function ( + string $table, + string $column, + $where = null, + string|null $order = null, + int $offset = 0, + int|null $limit = null +) { + return Db::table($table) + ->where($where) + ->order($order) + ->offset($offset) + ->limit($limit) + ->column($column); +}; + +/** + * Shortcut for inserting a new row into a table + * + * @param string $table The name of the table which should be queried + * @param array $values An array of values which should be inserted + * @return mixed Returns the last inserted id on success or false + */ +Db::$queries['insert'] = function (string $table, array $values): mixed { + return Db::table($table)->insert($values); +}; + +/** + * Shortcut for updating a row in a table + * + * @param string $table The name of the table which should be queried + * @param array $values An array of values which should be inserted + * @param mixed $where An optional WHERE clause + */ +Db::$queries['update'] = function ( + string $table, + array $values, + $where = null +): bool { + return Db::table($table)->where($where)->update($values); +}; + +/** + * Shortcut for deleting rows in a table + * + * @param string $table The name of the table which should be queried + * @param mixed $where An optional WHERE clause + */ +Db::$queries['delete'] = function (string $table, $where = null): bool { + return Db::table($table)->where($where)->delete(); +}; + +/** + * Shortcut for counting rows in a table + * + * @param string $table The name of the table which should be queried + * @param mixed $where An optional WHERE clause + */ +Db::$queries['count'] = function (string $table, mixed $where = null): int { + return Db::table($table)->where($where)->count(); +}; + +/** + * Shortcut for calculating the minimum value in a column + * + * @param string $table The name of the table which should be queried + * @param string $column The name of the column of which the minimum should be calculated + * @param mixed $where An optional WHERE clause + */ +Db::$queries['min'] = function ( + string $table, + string $column, + $where = null +): float { + return Db::table($table)->where($where)->min($column); +}; + +/** + * Shortcut for calculating the maximum value in a column + * + * @param string $table The name of the table which should be queried + * @param string $column The name of the column of which the maximum should be calculated + * @param mixed $where An optional WHERE clause + */ +Db::$queries['max'] = function ( + string $table, + string $column, + $where = null +): float { + return Db::table($table)->where($where)->max($column); +}; + +/** + * Shortcut for calculating the average value in a column + * + * @param string $table The name of the table which should be queried + * @param string $column The name of the column of which the average should be calculated + * @param mixed $where An optional WHERE clause + */ +Db::$queries['avg'] = function ( + string $table, + string $column, + $where = null +): float { + return Db::table($table)->where($where)->avg($column); +}; + +/** + * Shortcut for calculating the sum of all values in a column + * + * @param string $table The name of the table which should be queried + * @param string $column The name of the column of which the sum should be calculated + * @param mixed $where An optional WHERE clause + */ +Db::$queries['sum'] = function ( + string $table, + string $column, + $where = null +): float { + return Db::table($table)->where($where)->sum($column); +}; + +// @codeCoverageIgnoreEnd diff --git a/kirby/src/Database/Query.php b/kirby/src/Database/Query.php new file mode 100644 index 0000000..f3acd71 --- /dev/null +++ b/kirby/src/Database/Query.php @@ -0,0 +1,942 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Query +{ + public const ERROR_INVALID_QUERY_METHOD = 0; + + /** + * Parent Database object + */ + protected Database|null $database = null; + + /** + * The object which should be fetched for each row + * or function to call for each row + */ + protected string|Closure $fetch = Obj::class; + + /** + * The iterator class, which should be used for result sets + */ + protected string $iterator = Collection::class; + + /** + * An array of bindings for the final query + */ + protected array $bindings = []; + + /** + * The table name + */ + protected string $table; + + /** + * The name of the primary key column + */ + protected string $primaryKeyName = 'id'; + + /** + * An array with additional join parameters + */ + protected array|null $join = null; + + /** + * A list of columns, which should be selected + */ + protected array|string|null $select = null; + + /** + * Boolean for distinct select clauses + */ + protected bool|null $distinct = null; + + /** + * Boolean for if exceptions should be thrown on failing queries + */ + protected bool $fail = false; + + /** + * A list of values for update and insert clauses + */ + protected array|null $values = null; + + /** + * WHERE clause + */ + protected $where = null; + + /** + * GROUP BY clause + */ + protected string|null $group = null; + + /** + * HAVING clause + */ + protected $having = null; + + /** + * ORDER BY clause + */ + protected $order = null; + + /** + * The offset, which should be applied to the select query + */ + protected int $offset = 0; + + /** + * The limit, which should be applied to the select query + */ + protected int|null $limit = null; + + /** + * Boolean to enable query debugging + */ + protected bool $debug = false; + + /** + * Constructor + * + * @param \Kirby\Database\Database $database Database object + * @param string $table Optional name of the table, which should be queried + */ + public function __construct(Database $database, string $table) + { + $this->database = $database; + $this->table($table); + } + + /** + * Reset the query class after each db hit + */ + protected function reset(): void + { + $this->bindings = []; + $this->join = null; + $this->select = null; + $this->distinct = null; + $this->fail = false; + $this->values = null; + $this->where = null; + $this->group = null; + $this->having = null; + $this->order = null; + $this->offset = 0; + $this->limit = null; + $this->debug = false; + } + + /** + * Enables query debugging. + * If enabled, the query will return an array with all important info about + * the query instead of actually executing the query and returning results + * + * @return $this + */ + public function debug(bool $debug = true): static + { + $this->debug = $debug; + return $this; + } + + /** + * Enables distinct select clauses. + * + * @return $this + */ + public function distinct(bool $distinct = true): static + { + $this->distinct = $distinct; + return $this; + } + + /** + * Enables failing queries. + * If enabled queries will no longer fail silently but throw an exception + * + * @return $this + */ + public function fail(bool $fail = true): static + { + $this->fail = $fail; + return $this; + } + + /** + * Sets the object class, which should be fetched; + * set this to `'array'` to get a simple array instead of an object; + * pass a function that receives the `$data` and the `$key` to generate arbitrary data structures + * + * @return $this + */ + public function fetch(string|callable|Closure $fetch): static + { + if (is_callable($fetch) === true) { + $fetch = Closure::fromCallable($fetch); + } + + $this->fetch = $fetch; + return $this; + } + + /** + * Sets the iterator class, which should be used for multiple results + * Set this to array to get a simple array instead of an iterator object + * + * @return $this + */ + public function iterator(string $iterator): static + { + $this->iterator = $iterator; + return $this; + } + + /** + * Sets the name of the table, which should be queried + * + * @return $this + * @throws \Kirby\Exception\InvalidArgumentException if the table does not exist + */ + public function table(string $table): static + { + if ($this->database->validateTable($table) === false) { + throw new InvalidArgumentException('Invalid table: ' . $table); + } + + $this->table = $table; + return $this; + } + + /** + * Sets the name of the primary key column + * + * @return $this + */ + public function primaryKeyName(string $primaryKeyName): static + { + $this->primaryKeyName = $primaryKeyName; + return $this; + } + + /** + * Sets the columns, which should be selected from the table + * By default all columns will be selected + * + * @param array|string|null $select Pass either a string of columns or an array + * @return $this + */ + public function select(array|string|null $select): static + { + $this->select = $select; + return $this; + } + + /** + * Adds a new join clause to the query + * + * @param string $table Name of the table, which should be joined + * @param string $on The on clause for this join + * @param string $type The join type. Uses an inner join by default + * @return $this + */ + public function join( + string $table, + string $on, + string $type = 'JOIN' + ): static { + $join = [ + 'table' => $table, + 'on' => $on, + 'type' => $type + ]; + + $this->join[] = $join; + return $this; + } + + /** + * Shortcut for creating a left join clause + * + * @param string $table Name of the table, which should be joined + * @param string $on The on clause for this join + * @return $this + */ + public function leftJoin(string $table, string $on): static + { + return $this->join($table, $on, 'left join'); + } + + /** + * Shortcut for creating a right join clause + * + * @param string $table Name of the table, which should be joined + * @param string $on The on clause for this join + * @return $this + */ + public function rightJoin(string $table, string $on): static + { + return $this->join($table, $on, 'right join'); + } + + /** + * Shortcut for creating an inner join clause + * + * @param string $table Name of the table, which should be joined + * @param string $on The on clause for this join + * @return $this + */ + public function innerJoin($table, $on): static + { + return $this->join($table, $on, 'inner join'); + } + + /** + * Sets the values which should be used for the update or insert clause + * + * @param mixed $values Can either be a string or an array of values + * @return $this + */ + public function values($values = []): static + { + if ($values !== null) { + $this->values = $values; + } + return $this; + } + + /** + * Attaches additional bindings to the query. + * Also can be used as getter for all attached bindings + * by not passing an argument. + * + * @return array|$this + * @psalm-return ($bindings is array ? $this : array) + */ + public function bindings(array|null $bindings = null): array|static + { + if (is_array($bindings) === true) { + $this->bindings = array_merge($this->bindings, $bindings); + return $this; + } + + return $this->bindings; + } + + /** + * Attaches an additional where clause + * + * All available ways to add where clauses + * + * ->where('username like "myuser"'); (args: 1) + * ->where(['username' => 'myuser']); (args: 1) + * ->where(function($where) { $where->where('id', '=', 1) }) (args: 1) + * ->where('username like ?', 'myuser') (args: 2) + * ->where('username', 'like', 'myuser'); (args: 3) + * + * @return $this + */ + public function where(...$args): static + { + $this->where = $this->filterQuery($args, $this->where); + return $this; + } + + /** + * Shortcut to attach a where clause with an OR operator. + * Check out the where() method docs for additional info. + * + * @return $this + */ + public function orWhere(...$args): static + { + $this->where = $this->filterQuery($args, $this->where, 'OR'); + return $this; + } + + /** + * Shortcut to attach a where clause with an AND operator. + * Check out the where() method docs for additional info. + * + * @return $this + */ + public function andWhere(...$args): static + { + $this->where = $this->filterQuery($args, $this->where, 'AND'); + return $this; + } + + /** + * Attaches a group by clause + * + * @return $this + */ + public function group(string|null $group = null): static + { + $this->group = $group; + return $this; + } + + /** + * Attaches an additional having clause + * + * All available ways to add having clauses + * + * ->having('username like "myuser"'); (args: 1) + * ->having(['username' => 'myuser']); (args: 1) + * ->having(function($having) { $having->having('id', '=', 1) }) (args: 1) + * ->having('username like ?', 'myuser') (args: 2) + * ->having('username', 'like', 'myuser'); (args: 3) + * + * @return $this + */ + public function having(...$args): static + { + $this->having = $this->filterQuery($args, $this->having); + return $this; + } + + /** + * Attaches an order clause + * + * @param string|null $order + * @return $this + */ + public function order(string $order = null) + { + $this->order = $order; + return $this; + } + + /** + * Sets the offset for select clauses + * + * @return $this + */ + public function offset(int $offset): static + { + $this->offset = $offset; + return $this; + } + + /** + * Sets the limit for select clauses + * + * @return $this + */ + public function limit(int|null $limit = null): static + { + $this->limit = $limit; + return $this; + } + + /** + * Builds the different types of SQL queries + * This uses the SQL class to build stuff. + * + * @param string $type (select, update, insert) + * @return array The final query + */ + public function build(string $type): array + { + $sql = $this->database->sql(); + + return match ($type) { + 'select' => $sql->select([ + 'table' => $this->table, + 'columns' => $this->select, + 'join' => $this->join, + 'distinct' => $this->distinct, + 'where' => $this->where, + 'group' => $this->group, + 'having' => $this->having, + 'order' => $this->order, + 'offset' => $this->offset, + 'limit' => $this->limit, + 'bindings' => $this->bindings + ]), + 'update' => $sql->update([ + 'table' => $this->table, + 'where' => $this->where, + 'values' => $this->values, + 'bindings' => $this->bindings + ]), + 'insert' => $sql->insert([ + 'table' => $this->table, + 'values' => $this->values, + 'bindings' => $this->bindings + ]), + 'delete' => $sql->delete([ + 'table' => $this->table, + 'where' => $this->where, + 'bindings' => $this->bindings + ]), + default => null + }; + } + + /** + * Builds a count query + */ + public function count(): int + { + return (int)$this->aggregate('COUNT'); + } + + /** + * Builds a max query + */ + public function max(string $column): float + { + return (float)$this->aggregate('MAX', $column); + } + + /** + * Builds a min query + */ + public function min(string $column): float + { + return (float)$this->aggregate('MIN', $column); + } + + /** + * Builds a sum query + */ + public function sum(string $column): float + { + return (float)$this->aggregate('SUM', $column); + } + + /** + * Builds an average query + */ + public function avg(string $column): float + { + return (float)$this->aggregate('AVG', $column); + } + + /** + * Builds an aggregation query. + * This is used by all the aggregation methods above + * + * @param int $default An optional default value, which should be returned if the query fails + */ + public function aggregate(string $method, string $column = '*', int $default = 0) + { + // reset the sorting to avoid counting issues + $this->order = null; + + // validate column + if ($column !== '*') { + $sql = $this->database->sql(); + $column = $sql->columnName($this->table, $column); + } + + $fetch = $this->fetch; + $row = $this->select($method . '(' . $column . ') as aggregation')->fetch(Obj::class)->first(); + + if ($this->debug === true) { + return $row; + } + + $result = $row?->get('aggregation') ?? $default; + + $this->fetch($fetch); + + return $result; + } + + /** + * Used as an internal shortcut for firing a db query + */ + protected function query(string|array $sql, array $params = []) + { + if (is_string($sql) === true) { + $sql = [ + 'query' => $sql, + 'bindings' => $this->bindings() + ]; + } + + if ($this->debug) { + return [ + 'query' => $sql['query'], + 'bindings' => $this->bindings(), + 'options' => $params + ]; + } + + if ($this->fail) { + $this->database->fail(); + } + + $result = $this->database->query($sql['query'], $sql['bindings'], $params); + + $this->reset(); + + return $result; + } + + /** + * Used as an internal shortcut for executing a db query + */ + protected function execute(string|array $sql, array $params = []) + { + if (is_string($sql) === true) { + $sql = [ + 'query' => $sql, + 'bindings' => $this->bindings() + ]; + } + + if ($this->debug === true) { + return [ + 'query' => $sql['query'], + 'bindings' => $sql['bindings'], + 'options' => $params + ]; + } + + if ($this->fail) { + $this->database->fail(); + } + + $result = $this->database->execute($sql['query'], $sql['bindings']); + + $this->reset(); + + return $result; + } + + /** + * Selects only one row from a table + */ + public function first(): mixed + { + return $this->query($this->offset(0)->limit(1)->build('select'), [ + 'fetch' => $this->fetch, + 'iterator' => 'array', + 'method' => 'fetch', + ]); + } + + /** + * Selects only one row from a table + */ + public function row(): mixed + { + return $this->first(); + } + + /** + * Selects only one row from a table + */ + public function one(): mixed + { + return $this->first(); + } + + /** + * Automatically adds pagination to a query + * + * @param int $limit The number of rows, which should be returned for each page + * @return object Collection iterator with attached pagination object + */ + public function page(int $page, int $limit): object + { + // clone this to create a counter query + $counter = clone $this; + + // count the total number of rows for this query + $count = $counter->debug(false)->count(); + + // pagination + $pagination = new Pagination([ + 'limit' => $limit, + 'page' => $page, + 'total' => $count, + ]); + + // apply it to the dataset and retrieve all rows. make sure to use Collection as the iterator to be able to attach the pagination object + $iterator = $this->iterator; + $collection = $this + ->offset($pagination->offset()) + ->limit($pagination->limit()) + ->iterator(Collection::class) + ->all(); + + $this->iterator($iterator); + + // return debug information if debug mode is active + if ($this->debug) { + $collection['totalcount'] = $count; + return $collection; + } + + // store all pagination vars in a separate object + if ($collection) { + $collection->paginate($pagination); + } + + // return the limited collection + return $collection; + } + + /** + * Returns all matching rows from a table + */ + public function all() + { + return $this->query($this->build('select'), [ + 'fetch' => $this->fetch, + 'iterator' => $this->iterator, + ]); + } + + /** + * Returns only values from a single column + */ + public function column(string $column) + { + // if there isn't already an explicit order, order by the primary key + // instead of the column that was requested (which would be implied otherwise) + if ($this->order === null) { + $sql = $this->database->sql(); + $primaryKey = $sql->combineIdentifier($this->table, $this->primaryKeyName); + + $this->order($primaryKey . ' ASC'); + } + + $results = $this->query($this->select([$column])->build('select'), [ + 'iterator' => 'array', + 'fetch' => 'array', + ]); + + if ($this->debug === true) { + return $results; + } + + $results = array_column($results, $column); + + if ($this->iterator === 'array') { + return $results; + } + + $iterator = $this->iterator; + + return new $iterator($results); + } + + /** + * Find a single row by column and value + */ + public function findBy(string $column, $value) + { + return $this->where([$column => $value])->first(); + } + + /** + * Find a single row by its primary key + */ + public function find($id) + { + return $this->findBy($this->primaryKeyName, $id); + } + + /** + * Fires an insert query + * + * @param mixed $values You can pass values here or set them with ->values() before + * @return mixed Returns the last inserted id on success or false. + */ + public function insert($values = null) + { + $query = $this->execute($this->values($values)->build('insert')); + + if ($this->debug === true) { + return $query; + } + + return $query ? $this->database->lastId() : false; + } + + /** + * Fires an update query + * + * @param mixed $values You can pass values here or set them with ->values() before + * @param mixed $where You can pass a where clause here or set it with ->where() before + */ + public function update($values = null, $where = null): bool + { + return $this->execute($this->values($values)->where($where)->build('update')); + } + + /** + * Fires a delete query + * + * @param mixed $where You can pass a where clause here or set it with ->where() before + */ + public function delete($where = null): bool + { + return $this->execute($this->where($where)->build('delete')); + } + + /** + * Enables magic queries like findByUsername or findByEmail + */ + public function __call(string $method, array $arguments = []) + { + if (preg_match('!^findBy([a-z]+)!i', $method, $match)) { + $column = Str::lower($match[1]); + return $this->findBy($column, $arguments[0]); + } + throw new InvalidArgumentException('Invalid query method: ' . $method, static::ERROR_INVALID_QUERY_METHOD); + } + + /** + * Builder for where and having clauses + * + * @param array $args Arguments, see where() description + * @param mixed $current Current value (like $this->where) + */ + protected function filterQuery(array $args, $current, string $mode = 'AND') + { + $result = ''; + + switch (count($args)) { + case 1: + + if ($args[0] === null) { + return $current; + + // ->where('username like "myuser"'); + } elseif (is_string($args[0]) === true) { + // simply add the entire string to the where clause + // escaping or using bindings has to be done before calling this method + $result = $args[0]; + + // ->where(['username' => 'myuser']); + } elseif (is_array($args[0]) === true) { + // simple array mode (AND operator) + $sql = $this->database->sql()->values($this->table, $args[0], ' AND ', true, true); + + $result = $sql['query']; + + $this->bindings($sql['bindings']); + } elseif (is_callable($args[0]) === true) { + $query = clone $this; + + // since the callback uses its own where condition + // it is necessary to clear/reset the cloned where condition + $query->where = null; + + call_user_func($args[0], $query); + + // copy over the bindings from the nested query + $this->bindings = array_merge($this->bindings, $query->bindings); + + $result = '(' . $query->where . ')'; + } + + break; + case 2: + + // ->where('username like :username', ['username' => 'myuser']) + if (is_string($args[0]) === true && is_array($args[1]) === true) { + // prepared where clause + $result = $args[0]; + + // store the bindings + $this->bindings($args[1]); + + // ->where('username like ?', 'myuser') + } elseif (is_string($args[0]) === true && is_string($args[1]) === true) { + // prepared where clause + $result = $args[0]; + + // store the bindings + $this->bindings([$args[1]]); + } + + break; + case 3: + + // ->where('username', 'like', 'myuser'); + if (is_string($args[0]) === true && is_string($args[1]) === true) { + // validate column + $sql = $this->database->sql(); + $key = $sql->columnName($this->table, $args[0]); + + // ->where('username', 'in', ['myuser', 'myotheruser']); + $predicate = trim(strtoupper($args[1])); + if (is_array($args[2]) === true) { + if (in_array($predicate, ['IN', 'NOT IN']) === false) { + throw new InvalidArgumentException('Invalid predicate ' . $predicate); + } + + // build a list of bound values + $values = []; + $bindings = []; + + foreach ($args[2] as $value) { + $valueBinding = $sql->bindingName('value'); + $bindings[$valueBinding] = $value; + $values[] = $valueBinding; + } + + // add that to the where clause in parenthesis + $result = $key . ' ' . $predicate . ' (' . implode(', ', $values) . ')'; + + // ->where('username', 'like', 'myuser'); + } else { + $predicates = [ + '=', '>=', '>', '<=', '<', '<>', '!=', '<=>', + 'IS', 'IS NOT', + 'BETWEEN', 'NOT BETWEEN', + 'LIKE', 'NOT LIKE', + 'SOUNDS LIKE', + 'REGEXP', 'NOT REGEXP' + ]; + + if (in_array($predicate, $predicates) === false) { + throw new InvalidArgumentException('Invalid predicate/operator ' . $predicate); + } + + $valueBinding = $sql->bindingName('value'); + $bindings[$valueBinding] = $args[2]; + + $result = $key . ' ' . $predicate . ' ' . $valueBinding; + } + $this->bindings($bindings); + } + + break; + } + + // attach the where clause + if (empty($current) === false) { + return $current . ' ' . $mode . ' ' . $result; + } + + return $result; + } +} diff --git a/kirby/src/Database/Sql.php b/kirby/src/Database/Sql.php new file mode 100644 index 0000000..136b394 --- /dev/null +++ b/kirby/src/Database/Sql.php @@ -0,0 +1,879 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class Sql +{ + /** + * List of literals which should not be escaped in queries + */ + public static array $literals = ['NOW()', null]; + + /** + * The parent database connection + */ + protected Database $database; + + /** + * List of used bindings; used to avoid + * duplicate binding names + */ + protected array $bindings = []; + + /** + * Constructor + * @codeCoverageIgnore + */ + public function __construct(Database $database) + { + $this->database = $database; + } + + /** + * Returns a randomly generated binding name + * + * @param string $label String that only contains alphanumeric chars and + * underscores to use as a human-readable identifier + * @return string Binding name that is guaranteed to be unique for this connection + */ + public function bindingName(string $label): string + { + // make sure that the binding name is safe to prevent injections; + // otherwise use a generic label + if (!$label || preg_match('/^[a-zA-Z0-9_]+$/', $label) !== 1) { + $label = 'invalid'; + } + + // generate random bindings until the name is unique + do { + $binding = ':' . $label . '_' . Str::random(8, 'alphaNum'); + } while (in_array($binding, $this->bindings) === true); + + // cache the generated binding name for future invocations + $this->bindings[] = $binding; + return $binding; + } + + /** + * Returns a query to list the columns of a specified table; + * the query needs to return rows with a column `name` + * + * @param string $table Table name + */ + abstract public function columns(string $table): array; + + /** + * Returns a query snippet for a column default value + * + * @param string $name Column name + * @param array $column Column definition array with an optional `default` key + * @return array Array with a `query` string and a `bindings` array + */ + public function columnDefault(string $name, array $column): array + { + if (isset($column['default']) === false) { + return [ + 'query' => null, + 'bindings' => [] + ]; + } + + $binding = $this->bindingName($name . '_default'); + + return [ + 'query' => 'DEFAULT ' . $binding, + 'bindings' => [ + $binding => $column['default'] + ] + ]; + } + + /** + * Returns the cleaned identifier based on the table and column name + * + * @param string $table Table name + * @param string $column Column name + * @param bool $enforceQualified If true, a qualified identifier is returned in all cases + * @return string|null Identifier or null if the table or column is invalid + */ + public function columnName(string $table, string $column, bool $enforceQualified = false): string|null + { + // ensure we have clean $table and $column values without qualified identifiers + list($table, $column) = $this->splitIdentifier($table, $column); + + // combine the identifiers again + if ($this->database->validateColumn($table, $column) === true) { + return $this->combineIdentifier($table, $column, $enforceQualified !== true); + } + + // the table or column does not exist + return null; + } + + /** + * Abstracted column types to simplify table + * creation for multiple database drivers + * @codeCoverageIgnore + */ + public function columnTypes(): array + { + return [ + 'id' => '{{ name }} INT(11) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY', + 'varchar' => '{{ name }} varchar(255) {{ null }} {{ default }} {{ unique }}', + 'text' => '{{ name }} TEXT {{ unique }}', + 'int' => '{{ name }} INT(11) UNSIGNED {{ null }} {{ default }} {{ unique }}', + 'timestamp' => '{{ name }} TIMESTAMP {{ null }} {{ default }} {{ unique }}', + 'bool' => '{{ name }} TINYINT(1) {{ null }} {{ default }} {{ unique }}' + ]; + } + + /** + * Combines an identifier (table and column) + * + * @param $values bool Whether the identifier is going to be used for a VALUES clause; + * only relevant for SQLite + */ + public function combineIdentifier(string $table, string $column, bool $values = false): string + { + return $this->quoteIdentifier($table) . '.' . $this->quoteIdentifier($column); + } + + /** + * Creates the CREATE TABLE syntax for a single column + * + * @param string $name Column name + * @param array $column Column definition array; valid keys: + * - `type` (required): Column template to use + * - `null`: Whether the column may be NULL (boolean) + * - `key`: Index this column is part of; special values `'primary'` for PRIMARY KEY and `true` for automatic naming + * - `unique`: Whether the index (or if not set the column itself) has a UNIQUE constraint + * - `default`: Default value of this column + * @return array Array with `query` and `key` strings, a `unique` boolean and a `bindings` array + * @throws \Kirby\Exception\InvalidArgumentException if no column type is given or the column type is not supported. + */ + public function createColumn(string $name, array $column): array + { + // column type + if (isset($column['type']) === false) { + throw new InvalidArgumentException('No column type given for column ' . $name); + } + $template = $this->columnTypes()[$column['type']] ?? null; + if (!$template) { + throw new InvalidArgumentException('Unsupported column type: ' . $column['type']); + } + + // null option + if (A::get($column, 'null') === false) { + $null = 'NOT NULL'; + } else { + $null = 'NULL'; + } + + // indexes/keys + if (isset($column['key']) === true) { + if (is_string($column['key']) === true) { + $column['key'] = strtolower($column['key']); + } elseif ($column['key'] === true) { + $column['key'] = $name . '_index'; + } + } + + // unique + $uniqueKey = false; + $uniqueColumn = null; + if (isset($column['unique']) === true && $column['unique'] === true) { + if (isset($column['key']) === true) { + // this column is part of an index, make that unique + $uniqueKey = true; + } else { + // make the column itself unique + $uniqueColumn = 'UNIQUE'; + } + } + + // default value + $columnDefault = $this->columnDefault($name, $column); + + $query = trim(Str::template($template, [ + 'name' => $this->quoteIdentifier($name), + 'null' => $null, + 'default' => $columnDefault['query'], + 'unique' => $uniqueColumn + ], ['fallback' => ''])); + + return [ + 'query' => $query, + 'bindings' => $columnDefault['bindings'], + 'key' => $column['key'] ?? null, + 'unique' => $uniqueKey + ]; + } + + /** + * Creates the inner query for the columns in a CREATE TABLE query + * + * @param array $columns Array of column definition arrays, see `Kirby\Database\Sql::createColumn()` + * @return array Array with a `query` string and `bindings`, `keys` and `unique` arrays + */ + public function createTableInner(array $columns): array + { + $query = []; + $bindings = []; + $keys = []; + $unique = []; + + foreach ($columns as $name => $column) { + $sql = $this->createColumn($name, $column); + + // collect query and bindings + $query[] = $sql['query']; + $bindings += $sql['bindings']; + + // make a list of keys per key name + if ($sql['key'] !== null) { + if (isset($keys[$sql['key']]) !== true) { + $keys[$sql['key']] = []; + } + + $keys[$sql['key']][] = $name; + if ($sql['unique'] === true) { + $unique[$sql['key']] = true; + } + } + } + + return [ + 'query' => implode(',' . PHP_EOL, $query), + 'bindings' => $bindings, + 'keys' => $keys, + 'unique' => $unique + ]; + } + + /** + * Creates a CREATE TABLE query + * + * @param string $table Table name + * @param array $columns Array of column definition arrays, see `Kirby\Database\Sql::createColumn()` + * @return array Array with a `query` string and a `bindings` array + */ + public function createTable(string $table, array $columns = []): array + { + $inner = $this->createTableInner($columns); + + // add keys + foreach ($inner['keys'] as $key => $columns) { + // quote each column name and make a list string out of the column names + $columns = implode(', ', array_map( + fn ($name) => $this->quoteIdentifier($name), + $columns + )); + + if ($key === 'primary') { + $key = 'PRIMARY KEY'; + } else { + $unique = isset($inner['unique'][$key]) === true ? 'UNIQUE ' : ''; + $key = $unique . 'INDEX ' . $this->quoteIdentifier($key); + } + + $inner['query'] .= ',' . PHP_EOL . $key . ' (' . $columns . ')'; + } + + return [ + 'query' => 'CREATE TABLE ' . $this->quoteIdentifier($table) . ' (' . PHP_EOL . $inner['query'] . PHP_EOL . ')', + 'bindings' => $inner['bindings'] + ]; + } + + /** + * Builds a DELETE clause + * + * @param array $params List of parameters for the DELETE clause. See defaults for more info. + */ + public function delete(array $params = []): array + { + $defaults = [ + 'table' => '', + 'where' => null, + 'bindings' => [] + ]; + + $options = array_merge($defaults, $params); + $bindings = $options['bindings']; + $query = ['DELETE']; + + // from + $this->extend($query, $bindings, $this->from($options['table'])); + + // where + $this->extend($query, $bindings, $this->where($options['where'])); + + return [ + 'query' => $this->query($query), + 'bindings' => $bindings + ]; + } + + /** + * Creates the sql for dropping a single table + */ + public function dropTable(string $table): array + { + return [ + 'query' => 'DROP TABLE ' . $this->tableName($table), + 'bindings' => [] + ]; + } + + /** + * Extends a given query and bindings + * by reference + */ + public function extend(array &$query, array &$bindings, array $input): void + { + if (empty($input['query']) === false) { + $query[] = $input['query']; + $bindings = array_merge($bindings, $input['bindings']); + } + } + + /** + * Creates the from syntax + */ + public function from(string $table): array + { + return [ + 'query' => 'FROM ' . $this->tableName($table), + 'bindings' => [] + ]; + } + + /** + * Creates the group by syntax + */ + public function group(string|null $group = null): array + { + if (empty($group) === false) { + $query = 'GROUP BY ' . $group; + } + + return [ + 'query' => $query ?? null, + 'bindings' => [] + ]; + } + + /** + * Creates the having syntax + */ + public function having(string|null $having = null): array + { + if (empty($having) === false) { + $query = 'HAVING ' . $having; + } + + return [ + 'query' => $query ?? null, + 'bindings' => [] + ]; + } + + /** + * Creates an insert query + */ + public function insert(array $params = []): array + { + $table = $params['table'] ?? null; + $values = $params['values'] ?? null; + $bindings = $params['bindings']; + $query = ['INSERT INTO ' . $this->tableName($table)]; + + // add the values + $this->extend($query, $bindings, $this->values($table, $values, ', ', false)); + + return [ + 'query' => $this->query($query), + 'bindings' => $bindings + ]; + } + + /** + * Creates a join query + * + * @throws \Kirby\Exception\InvalidArgumentException if an invalid join type is given + */ + public function join(string $type, string $table, string $on): array + { + $types = [ + 'JOIN', + 'INNER JOIN', + 'OUTER JOIN', + 'LEFT OUTER JOIN', + 'LEFT JOIN', + 'RIGHT OUTER JOIN', + 'RIGHT JOIN', + 'FULL OUTER JOIN', + 'FULL JOIN', + 'NATURAL JOIN', + 'CROSS JOIN', + 'SELF JOIN' + ]; + + $type = strtoupper(trim($type)); + + // validate join type + if (in_array($type, $types) === false) { + throw new InvalidArgumentException('Invalid join type ' . $type); + } + + return [ + 'query' => $type . ' ' . $this->tableName($table) . ' ON ' . $on, + 'bindings' => [], + ]; + } + + /** + * Create the syntax for multiple joins + */ + public function joins(array|null $joins = null): array + { + $query = []; + $bindings = []; + + foreach ((array)$joins as $join) { + $this->extend($query, $bindings, $this->join($join['type'] ?? 'JOIN', $join['table'] ?? null, $join['on'] ?? null)); + } + + return [ + 'query' => implode(' ', array_filter($query)), + 'bindings' => [], + ]; + } + + /** + * Creates a limit and offset query instruction + */ + public function limit(int $offset = 0, int|null $limit = null): array + { + // no need to add it to the query + if ($offset === 0 && $limit === null) { + return [ + 'query' => null, + 'bindings' => [] + ]; + } + + $limit ??= '18446744073709551615'; + + $offsetBinding = $this->bindingName('offset'); + $limitBinding = $this->bindingName('limit'); + + return [ + 'query' => 'LIMIT ' . $offsetBinding . ', ' . $limitBinding, + 'bindings' => [ + $limitBinding => $limit, + $offsetBinding => $offset, + ] + ]; + } + + /** + * Creates the order by syntax + */ + public function order(string|null $order = null): array + { + if (empty($order) === false) { + $query = 'ORDER BY ' . $order; + } + + return [ + 'query' => $query ?? null, + 'bindings' => [] + ]; + } + + /** + * Converts a query array into a final string + */ + public function query(array $query, string $separator = ' '): string + { + return implode($separator, array_filter($query)); + } + + /** + * Quotes an identifier (table *or* column) + */ + public function quoteIdentifier(string $identifier): string + { + // * is special, don't quote that + if ($identifier === '*') { + return $identifier; + } + + // escape backticks inside the identifier name + $identifier = str_replace('`', '``', $identifier); + + // wrap in backticks + return '`' . $identifier . '`'; + } + + /** + * Builds a select clause + * + * @param array $params List of parameters for the select clause. Check out the defaults for more info. + * @return array An array with the query and the bindings + */ + public function select(array $params = []): array + { + $defaults = [ + 'table' => '', + 'columns' => '*', + 'join' => null, + 'distinct' => false, + 'where' => null, + 'group' => null, + 'having' => null, + 'order' => null, + 'offset' => 0, + 'limit' => null, + 'bindings' => [] + ]; + + $options = array_merge($defaults, $params); + $bindings = $options['bindings']; + $query = ['SELECT']; + + // select distinct values + if ($options['distinct'] === true) { + $query[] = 'DISTINCT'; + } + + // columns + $query[] = $this->selected($options['table'], $options['columns']); + + // from + $this->extend($query, $bindings, $this->from($options['table'])); + + // joins + $this->extend($query, $bindings, $this->joins($options['join'])); + + // where + $this->extend($query, $bindings, $this->where($options['where'])); + + // group + $this->extend($query, $bindings, $this->group($options['group'])); + + // having + $this->extend($query, $bindings, $this->having($options['having'])); + + // order + $this->extend($query, $bindings, $this->order($options['order'])); + + // offset and limit + $this->extend($query, $bindings, $this->limit($options['offset'], $options['limit'])); + + return [ + 'query' => $this->query($query), + 'bindings' => $bindings + ]; + } + + /** + * Creates a columns definition from string or array + */ + public function selected(string $table, array|string|null $columns = null): string + { + // all columns + if (empty($columns) === true) { + return '*'; + } + + // array of columns + if (is_array($columns) === true) { + // validate columns + $result = []; + + foreach ($columns as $column) { + list($table, $columnPart) = $this->splitIdentifier($table, $column); + + if ($this->validateColumn($table, $columnPart) === true) { + $result[] = $this->combineIdentifier($table, $columnPart); + } + } + + return implode(', ', $result); + } + + return $columns; + } + + /** + * Splits a (qualified) identifier into table and column + * + * @param string $table Default table if the identifier is not qualified + * @throws \Kirby\Exception\InvalidArgumentException if an invalid identifier is given + */ + public function splitIdentifier(string $table, string $identifier): array + { + // split by dot, but only outside of quotes + $parts = preg_split('/(?:`[^`]*`|"[^"]*")(*SKIP)(*F)|\./', $identifier); + + return match (count($parts)) { + // non-qualified identifier + 1 => [$table, $this->unquoteIdentifier($parts[0])], + + // qualified identifier + 2 => [ + $this->unquoteIdentifier($parts[0]), + $this->unquoteIdentifier($parts[1]) + ], + + // every other number is an error + default => throw new InvalidArgumentException('Invalid identifier ' . $identifier) + }; + } + + /** + * Returns a query to list the tables of the current database; + * the query needs to return rows with a column `name` + */ + abstract public function tables(): array; + + /** + * Validates and quotes a table name + * + * @throws \Kirby\Exception\InvalidArgumentException if an invalid table name is given + */ + public function tableName(string $table): string + { + // validate table + if ($this->database->validateTable($table) === false) { + throw new InvalidArgumentException('Invalid table ' . $table); + } + + return $this->quoteIdentifier($table); + } + + /** + * Unquotes an identifier (table *or* column) + */ + public function unquoteIdentifier(string $identifier): string + { + // remove quotes around the identifier + if (in_array(Str::substr($identifier, 0, 1), ['"', '`']) === true) { + $identifier = Str::substr($identifier, 1); + } + + if (in_array(Str::substr($identifier, -1), ['"', '`']) === true) { + $identifier = Str::substr($identifier, 0, -1); + } + + // unescape duplicated quotes + return str_replace(['""', '``'], ['"', '`'], $identifier); + } + + /** + * Builds an update clause + * + * @param array $params List of parameters for the update clause. See defaults for more info. + */ + public function update(array $params = []): array + { + $defaults = [ + 'table' => null, + 'values' => null, + 'where' => null, + 'bindings' => [] + ]; + + $options = array_merge($defaults, $params); + $bindings = $options['bindings']; + + // start the query + $query = ['UPDATE ' . $this->tableName($options['table']) . ' SET']; + + // add the values + $this->extend($query, $bindings, $this->values($options['table'], $options['values'])); + + // add the where clause + $this->extend($query, $bindings, $this->where($options['where'])); + + return [ + 'query' => $this->query($query), + 'bindings' => $bindings + ]; + } + + /** + * Validates a given column name in a table + * + * @throws \Kirby\Exception\InvalidArgumentException If the column is invalid + */ + public function validateColumn(string $table, string $column): bool + { + if ($this->database->validateColumn($table, $column) !== true) { + throw new InvalidArgumentException('Invalid column ' . $column); + } + + return true; + } + + /** + * Builds a safe list of values for insert, select or update queries + * + * @param string $table Table name + * @param mixed $values A value string or array of values + * @param string $separator A separator which should be used to join values + * @param bool $set If true builds a set list of values for update clauses + * @param bool $enforceQualified Always use fully qualified column names + */ + public function values( + string $table, + $values, + string $separator = ', ', + bool $set = true, + bool $enforceQualified = false + ): array { + if (is_array($values) === false) { + return [ + 'query' => $values, + 'bindings' => [] + ]; + } + + if ($set === true) { + return $this->valueSet($table, $values, $separator, $enforceQualified); + } + + return $this->valueList($table, $values, $separator, $enforceQualified); + } + + /** + * Creates a list of fields and values + */ + public function valueList( + string $table, + string|array $values, + string $separator = ',', + bool $enforceQualified = false + ): array { + $fields = []; + $query = []; + $bindings = []; + + foreach ($values as $column => $value) { + $key = $this->columnName($table, $column, $enforceQualified); + + if ($key === null) { + continue; + } + + $fields[] = $key; + + if (in_array($value, static::$literals, true) === true) { + $query[] = $value ?: 'null'; + continue; + } + + if (is_array($value) === true) { + $value = json_encode($value); + } + + // add the binding + $bindings[$bindingName = $this->bindingName('value')] = $value; + + // create the query + $query[] = $bindingName; + } + + return [ + 'query' => '(' . implode($separator, $fields) . ') VALUES (' . implode($separator, $query) . ')', + 'bindings' => $bindings + ]; + } + + /** + * Creates a set of values + */ + public function valueSet( + string $table, + string|array $values, + string $separator = ',', + bool $enforceQualified = false + ): array { + $query = []; + $bindings = []; + + foreach ($values as $column => $value) { + $key = $this->columnName($table, $column, $enforceQualified); + + if ($key === null) { + continue; + } + + if (in_array($value, static::$literals, true) === true) { + $query[] = $key . ' = ' . ($value ?: 'null'); + continue; + } + + if (is_array($value) === true) { + $value = json_encode($value); + } + + // add the binding + $bindings[$bindingName = $this->bindingName('value')] = $value; + + // create the query + $query[] = $key . ' = ' . $bindingName; + } + + return [ + 'query' => implode($separator, $query), + 'bindings' => $bindings + ]; + } + + public function where(string|array|null $where, array $bindings = []): array + { + if (empty($where) === true) { + return [ + 'query' => null, + 'bindings' => [], + ]; + } + + if (is_string($where) === true) { + return [ + 'query' => 'WHERE ' . $where, + 'bindings' => $bindings + ]; + } + + $query = []; + + foreach ($where as $key => $value) { + $binding = $this->bindingName('where_' . $key); + $bindings[$binding] = $value; + + $query[] = $key . ' = ' . $binding; + } + + return [ + 'query' => 'WHERE ' . implode(' AND ', $query), + 'bindings' => $bindings + ]; + } +} diff --git a/kirby/src/Database/Sql/Mysql.php b/kirby/src/Database/Sql/Mysql.php new file mode 100644 index 0000000..02c3939 --- /dev/null +++ b/kirby/src/Database/Sql/Mysql.php @@ -0,0 +1,56 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Mysql extends Sql +{ + /** + * Returns a query to list the columns of a specified table; + * the query needs to return rows with a column `name` + * + * @param string $table Table name + */ + public function columns(string $table): array + { + $databaseBinding = $this->bindingName('database'); + $tableBinding = $this->bindingName('table'); + + $query = 'SELECT COLUMN_NAME AS name FROM INFORMATION_SCHEMA.COLUMNS '; + $query .= 'WHERE TABLE_SCHEMA = ' . $databaseBinding . ' AND TABLE_NAME = ' . $tableBinding; + + return [ + 'query' => $query, + 'bindings' => [ + $databaseBinding => $this->database->name(), + $tableBinding => $table, + ] + ]; + } + + /** + * Returns a query to list the tables of the current database; + * the query needs to return rows with a column `name` + */ + public function tables(): array + { + $binding = $this->bindingName('database'); + + return [ + 'query' => 'SELECT TABLE_NAME AS name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ' . $binding, + 'bindings' => [ + $binding => $this->database->name() + ] + ]; + } +} diff --git a/kirby/src/Database/Sql/Sqlite.php b/kirby/src/Database/Sql/Sqlite.php new file mode 100644 index 0000000..05097a5 --- /dev/null +++ b/kirby/src/Database/Sql/Sqlite.php @@ -0,0 +1,135 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Sqlite extends Sql +{ + /** + * Returns a query to list the columns of a specified table; + * the query needs to return rows with a column `name` + * + * @param string $table Table name + */ + public function columns(string $table): array + { + return [ + 'query' => 'PRAGMA table_info(' . $this->tableName($table) . ')', + 'bindings' => [], + ]; + } + + /** + * Abstracted column types to simplify table + * creation for multiple database drivers + * @codeCoverageIgnore + */ + public function columnTypes(): array + { + return [ + 'id' => '{{ name }} INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL UNIQUE', + 'varchar' => '{{ name }} TEXT {{ null }} {{ default }} {{ unique }}', + 'text' => '{{ name }} TEXT {{ null }} {{ default }} {{ unique }}', + 'int' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}', + 'timestamp' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}', + 'bool' => '{{ name }} INTEGER {{ null }} {{ default }} {{ unique }}' + ]; + } + + /** + * Combines an identifier (table and column) + * + * @param bool $values Whether the identifier is going to be + * used for a VALUES clause; only relevant + * for SQLite + */ + public function combineIdentifier(string $table, string $column, bool $values = false): string + { + // SQLite doesn't support qualified column names for VALUES clauses + if ($values === true) { + return $this->quoteIdentifier($column); + } + + return $this->quoteIdentifier($table) . '.' . $this->quoteIdentifier($column); + } + + /** + * Creates a CREATE TABLE query + * + * @param string $table Table name + * @param array $columns Array of column definition arrays, see `Kirby\Database\Sql::createColumn()` + * @return array Array with a `query` string and a `bindings` array + */ + public function createTable(string $table, array $columns = []): array + { + $inner = $this->createTableInner($columns); + + // add keys + $keys = []; + foreach ($inner['keys'] as $key => $columns) { + // quote each column name and make a list string out of the column names + $columns = implode(', ', array_map( + fn ($name) => $this->quoteIdentifier($name), + $columns + )); + + if ($key === 'primary') { + $inner['query'] .= ',' . PHP_EOL . 'PRIMARY KEY (' . $columns . ')'; + } else { + // SQLite only supports index creation using a separate CREATE INDEX query + $unique = isset($inner['unique'][$key]) === true ? 'UNIQUE ' : ''; + $keys[] = 'CREATE ' . $unique . 'INDEX ' . $this->quoteIdentifier($table . '_index_' . $key) . + ' ON ' . $this->quoteIdentifier($table) . ' (' . $columns . ')'; + } + } + + $query = 'CREATE TABLE ' . $this->quoteIdentifier($table) . ' (' . PHP_EOL . $inner['query'] . PHP_EOL . ')'; + if (empty($keys) === false) { + $query .= ';' . PHP_EOL . implode(';' . PHP_EOL, $keys); + } + + return [ + 'query' => $query, + 'bindings' => $inner['bindings'] + ]; + } + + /** + * Quotes an identifier (table *or* column) + */ + public function quoteIdentifier(string $identifier): string + { + // * is special + if ($identifier === '*') { + return $identifier; + } + + // escape quotes inside the identifier name + $identifier = str_replace('"', '""', $identifier); + + // wrap in quotes + return '"' . $identifier . '"'; + } + + /** + * Returns a query to list the tables of the current database; + * the query needs to return rows with a column `name` + */ + public function tables(): array + { + return [ + 'query' => 'SELECT name FROM sqlite_master WHERE type = "table" OR type = "view"', + 'bindings' => [] + ]; + } +} diff --git a/kirby/src/Email/Body.php b/kirby/src/Email/Body.php new file mode 100644 index 0000000..a25904a --- /dev/null +++ b/kirby/src/Email/Body.php @@ -0,0 +1,71 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Body +{ + protected string|null $html; + protected string|null $text; + + /** + * Email body constructor + */ + public function __construct(array $props = []) + { + $this->html = $props['html'] ?? null; + $this->text = $props['text'] ?? null; + } + + /** + * Creates a new instance while + * merging initial and new properties + * @deprecated 4.0.0 + */ + public function clone(array $props = []): static + { + return new static(array_merge_recursive([ + 'html' => $this->html, + 'text' => $this->text + ], $props)); + } + + /** + * Returns the HTML content of the email body + */ + public function html(): string + { + return $this->html ?? ''; + } + + /** + * Returns the plain text content of the email body + */ + public function text(): string + { + return $this->text ?? ''; + } + + /** + * @since 4.0.0 + */ + public function toArray(): array + { + return [ + 'html' => $this->html(), + 'text' => $this->text() + ]; + } +} diff --git a/kirby/src/Email/Email.php b/kirby/src/Email/Email.php new file mode 100644 index 0000000..2f1b15f --- /dev/null +++ b/kirby/src/Email/Email.php @@ -0,0 +1,296 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Email +{ + /** + * If set to `true`, the debug mode is enabled + * for all emails + */ + public static bool $debug = false; + + /** + * Store for sent emails when `Email::$debug` + * is set to `true` + */ + public static array $emails = []; + + protected bool $isSent = false; + + protected array $attachments; + protected Body $body; + protected array $bcc; + protected Closure|null $beforeSend; + protected array $cc; + protected string $from; + protected string|null $fromName; + protected string $replyTo; + protected string|null $replyToName; + protected string $subject; + protected array $to; + protected array|null $transport; + + /** + * Email constructor + */ + public function __construct(array $props = [], bool $debug = false) + { + foreach (['body', 'from', 'to', 'subject'] as $required) { + if (isset($props[$required]) === false) { + throw new InvalidArgumentException('The property "' . $required . '" is required'); + } + } + + if (is_string($props['body']) === true) { + $props['body'] = ['text' => $props['body']]; + } + + $this->attachments = $props['attachments'] ?? []; + $this->bcc = $this->resolveEmail($props['bcc'] ?? null); + $this->beforeSend = $props['beforeSend'] ?? null; + $this->body = new Body($props['body']); + $this->cc = $this->resolveEmail($props['cc'] ?? null); + $this->from = $this->resolveEmail($props['from'], false); + $this->fromName = $props['fromName'] ?? null; + $this->replyTo = $this->resolveEmail($props['replyTo'] ?? null, false); + $this->replyToName = $props['replyToName'] ?? null; + $this->subject = $props['subject']; + $this->to = $this->resolveEmail($props['to']); + $this->transport = $props['transport'] ?? null; + + // @codeCoverageIgnoreStart + if (static::$debug === false && $debug === false) { + $this->send(); + } elseif (static::$debug === true) { + static::$emails[] = $this; + } + // @codeCoverageIgnoreEnd + } + + /** + * Returns the email attachments + */ + public function attachments(): array + { + return $this->attachments; + } + + /** + * Returns the email body + */ + public function body(): Body|null + { + return $this->body; + } + + /** + * Returns "bcc" recipients + */ + public function bcc(): array + { + return $this->bcc; + } + + /** + * Returns the beforeSend callback closure, + * which has access to the PHPMailer instance + */ + public function beforeSend(): Closure|null + { + return $this->beforeSend; + } + + /** + * Returns "cc" recipients + */ + public function cc(): array + { + return $this->cc; + } + + /** + * Creates a new instance while + * merging initial and new properties + * @deprecated 4.0.0 + */ + public function clone(array $props = []): static + { + return new static(array_merge_recursive([ + 'attachments' => $this->attachments, + 'bcc' => $this->bcc, + 'beforeSend' => $this->beforeSend, + 'body' => $this->body->toArray(), + 'cc' => $this->cc, + 'from' => $this->from, + 'fromName' => $this->fromName, + 'replyTo' => $this->replyTo, + 'replyToName' => $this->replyToName, + 'subject' => $this->subject, + 'to' => $this->to, + 'transport' => $this->transport + ], $props)); + } + + /** + * Returns default transport settings + */ + protected function defaultTransport(): array + { + return [ + 'type' => 'mail' + ]; + } + + /** + * Returns the "from" email address + */ + public function from(): string + { + return $this->from; + } + + /** + * Returns the "from" name + */ + public function fromName(): string|null + { + return $this->fromName; + } + + /** + * Checks if the email has an HTML body + */ + public function isHtml(): bool + { + return empty($this->body()->html()) === false; + } + + /** + * Checks if the email has been sent successfully + */ + public function isSent(): bool + { + return $this->isSent; + } + + /** + * Returns the "reply to" email address + */ + public function replyTo(): string + { + return $this->replyTo; + } + + /** + * Returns the "reply to" name + */ + public function replyToName(): string|null + { + return $this->replyToName; + } + + /** + * Converts single or multiple email addresses to a sanitized format + * + * @throws \Exception + */ + protected function resolveEmail( + string|array|null $email = null, + bool $multiple = true + ): array|string { + if ($email === null) { + return $multiple === true ? [] : ''; + } + + if (is_array($email) === false) { + $email = [$email => null]; + } + + $result = []; + foreach ($email as $address => $name) { + // convert simple email arrays to associative arrays + if (is_int($address) === true) { + // the value is the address, there is no name + $address = $name; + $result[$address] = null; + } else { + $result[$address] = $name; + } + + // ensure that the address is valid + if (V::email($address) === false) { + throw new Exception(sprintf('"%s" is not a valid email address', $address)); + } + } + + return $multiple === true ? $result : array_keys($result)[0]; + } + + /** + * Sends the email + */ + public function send(): bool + { + return $this->isSent = true; + } + + /** + * Returns the email subject + */ + public function subject(): string + { + return $this->subject; + } + + /** + * Returns the email recipients + */ + public function to(): array + { + return $this->to; + } + + /** + * Returns the email transports settings + */ + public function transport(): array + { + return $this->transport ?? $this->defaultTransport(); + } + + /** + * @since 4.0.0 + */ + public function toArray(): array + { + return [ + 'attachments' => $this->attachments(), + 'bcc' => $this->bcc(), + 'body' => $this->body()->toArray(), + 'cc' => $this->cc(), + 'from' => $this->from(), + 'fromName' => $this->fromName(), + 'replyTo' => $this->replyTo(), + 'replyToName' => $this->replyToName(), + 'subject' => $this->subject(), + 'to' => $this->to(), + 'transport' => $this->transport() + ]; + } +} diff --git a/kirby/src/Email/PHPMailer.php b/kirby/src/Email/PHPMailer.php new file mode 100644 index 0000000..ee84a40 --- /dev/null +++ b/kirby/src/Email/PHPMailer.php @@ -0,0 +1,112 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class PHPMailer extends Email +{ + /** + * Sends email via PHPMailer library + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function send(bool $debug = false): bool + { + $mailer = new Mailer(true); + + // set sender's address + $mailer->setFrom($this->from(), $this->fromName() ?? ''); + + // optional reply-to address + if ($replyTo = $this->replyTo()) { + $mailer->addReplyTo($replyTo, $this->replyToName() ?? ''); + } + + // add (multiple) recipient, CC & BCC addresses + foreach ($this->to() as $email => $name) { + $mailer->addAddress($email, $name ?? ''); + } + foreach ($this->cc() as $email => $name) { + $mailer->addCC($email, $name ?? ''); + } + foreach ($this->bcc() as $email => $name) { + $mailer->addBCC($email, $name ?? ''); + } + + $mailer->Subject = $this->subject(); + $mailer->CharSet = 'UTF-8'; + + // set body according to html/text + if ($this->isHtml()) { + $mailer->isHTML(true); + $mailer->Body = $this->body()->html(); + $mailer->AltBody = $this->body()->text(); + } else { + $mailer->Body = $this->body()->text(); + } + + // add attachments + foreach ($this->attachments() as $attachment) { + $mailer->addAttachment($attachment); + } + + // smtp transport settings + if (($this->transport()['type'] ?? 'mail') === 'smtp') { + $mailer->isSMTP(); + $mailer->Host = $this->transport()['host'] ?? null; + $mailer->SMTPAuth = $this->transport()['auth'] ?? false; + $mailer->Username = $this->transport()['username'] ?? null; + $mailer->Password = $this->transport()['password'] ?? null; + $mailer->SMTPSecure = $this->transport()['security'] ?? 'ssl'; + $mailer->Port = $this->transport()['port'] ?? null; + + if ($mailer->SMTPSecure === true) { + switch ($mailer->Port) { + case null: + case 587: + $mailer->SMTPSecure = 'tls'; + $mailer->Port = 587; + break; + case 465: + $mailer->SMTPSecure = 'ssl'; + break; + default: + throw new InvalidArgumentException( + 'Could not automatically detect the "security" protocol from the ' . + '"port" option, please set it explicitly to "tls" or "ssl".' + ); + } + } + } + + // accessible phpMailer instance + $beforeSend = $this->beforeSend(); + + if ($beforeSend instanceof Closure) { + $mailer = $beforeSend->call($this, $mailer) ?? $mailer; + + if ($mailer instanceof Mailer === false) { + throw new InvalidArgumentException('"beforeSend" option return should be instance of PHPMailer\PHPMailer\PHPMailer class'); + } + } + + if ($debug === true) { + return $this->isSent = true; + } + + return $this->isSent = $mailer->send(); // @codeCoverageIgnore + } +} diff --git a/kirby/src/Exception/AuthException.php b/kirby/src/Exception/AuthException.php new file mode 100644 index 0000000..1ea6203 --- /dev/null +++ b/kirby/src/Exception/AuthException.php @@ -0,0 +1,21 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class AuthException extends Exception +{ + protected static string $defaultKey = 'auth'; + protected static string $defaultFallback = 'Unauthenticated'; + protected static int $defaultHttpCode = 401; +} diff --git a/kirby/src/Exception/BadMethodCallException.php b/kirby/src/Exception/BadMethodCallException.php new file mode 100644 index 0000000..f8a1d1b --- /dev/null +++ b/kirby/src/Exception/BadMethodCallException.php @@ -0,0 +1,21 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class BadMethodCallException extends Exception +{ + protected static string $defaultKey = 'invalidMethod'; + protected static string $defaultFallback = 'The method "{ method }" does not exist'; + protected static int $defaultHttpCode = 400; + protected static array $defaultData = ['method' => null]; +} diff --git a/kirby/src/Exception/DuplicateException.php b/kirby/src/Exception/DuplicateException.php new file mode 100644 index 0000000..1bea2bf --- /dev/null +++ b/kirby/src/Exception/DuplicateException.php @@ -0,0 +1,21 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class DuplicateException extends Exception +{ + protected static string $defaultKey = 'duplicate'; + protected static string $defaultFallback = 'The entry exists'; + protected static int $defaultHttpCode = 400; +} diff --git a/kirby/src/Exception/ErrorPageException.php b/kirby/src/Exception/ErrorPageException.php new file mode 100644 index 0000000..bd26c6f --- /dev/null +++ b/kirby/src/Exception/ErrorPageException.php @@ -0,0 +1,21 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class ErrorPageException extends Exception +{ + protected static string $defaultKey = 'errorPage'; + protected static string $defaultFallback = 'Triggered error page'; + protected static int $defaultHttpCode = 404; +} diff --git a/kirby/src/Exception/Exception.php b/kirby/src/Exception/Exception.php new file mode 100644 index 0000000..ae16686 --- /dev/null +++ b/kirby/src/Exception/Exception.php @@ -0,0 +1,207 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Exception extends \Exception +{ + /** + * Data variables that can be used inside the exception message + */ + protected array $data; + + /** + * HTTP code that corresponds with the exception + */ + protected int $httpCode; + + /** + * Additional details that are not included in the exception message + */ + protected array $details; + + /** + * Whether the exception message could be translated + * into the user's language + */ + protected bool $isTranslated = true; + + /** + * Defaults that can be overridden by specific + * exception classes + */ + protected static string $defaultKey = 'general'; + protected static string $defaultFallback = 'An error occurred'; + protected static array $defaultData = []; + protected static int $defaultHttpCode = 500; + protected static array $defaultDetails = []; + + /** + * Prefix for the exception key (e.g. 'error.general') + */ + private static string $prefix = 'error'; + + /** + * Class constructor + * + * @param array|string $args Full option array ('key', 'translate', 'fallback', + * 'data', 'httpCode', 'details' and 'previous') or + * just the message string + */ + public function __construct(array|string $args = []) + { + // set data and httpCode from provided arguments or defaults + $this->data = $args['data'] ?? static::$defaultData; + $this->httpCode = $args['httpCode'] ?? static::$defaultHttpCode; + $this->details = $args['details'] ?? static::$defaultDetails; + + // define the Exception key + $key = $args['key'] ?? static::$defaultKey; + + if (Str::startsWith($key, self::$prefix . '.') === false) { + $key = self::$prefix . '.' . $key; + } + + if (is_string($args) === true) { + $this->isTranslated = false; + parent::__construct($args); + } else { + // define whether message can/should be translated + $translate = + ($args['translate'] ?? true) === true && + class_exists(App::class) === true; + + // fallback waterfall for message string + $message = null; + + if ($translate === true) { + // 1. translation for provided key in current language + // 2. translation for provided key in default language + if (isset($args['key']) === true) { + $message = I18n::translate(self::$prefix . '.' . $args['key']); + $this->isTranslated = true; + } + } + + // 3. provided fallback message + if ($message === null) { + $message = $args['fallback'] ?? null; + $this->isTranslated = false; + } + + if ($translate === true) { + // 4. translation for default key in current language + // 5. translation for default key in default language + if ($message === null) { + $message = I18n::translate(self::$prefix . '.' . static::$defaultKey); + $this->isTranslated = true; + } + } + + // 6. default fallback message + if ($message === null) { + $message = static::$defaultFallback; + $this->isTranslated = false; + } + + // format message with passed data + $message = Str::template($message, $this->data, ['fallback' => '-']); + + // handover to Exception parent class constructor + parent::__construct($message, 0, $args['previous'] ?? null); + } + + // set the Exception code to the key + $this->code = $key; + } + + /** + * Returns the file in which the Exception was created + * relative to the document root + */ + final public function getFileRelative(): string + { + $file = $this->getFile(); + $docRoot = Environment::getGlobally('DOCUMENT_ROOT'); + + if (empty($docRoot) === false) { + $file = ltrim(Str::after($file, $docRoot), '/'); + } + + return $file; + } + + /** + * Returns the data variables from the message + */ + final public function getData(): array + { + return $this->data; + } + + /** + * Returns the additional details that are + * not included in the message + */ + final public function getDetails(): array + { + return $this->details; + } + + /** + * Returns the exception key (error type) + */ + final public function getKey(): string + { + return $this->getCode(); + } + + /** + * Returns the HTTP code that corresponds + * with the exception + */ + final public function getHttpCode(): int + { + return $this->httpCode; + } + + /** + * Returns whether the exception message could + * be translated into the user's language + */ + final public function isTranslated(): bool + { + return $this->isTranslated; + } + + /** + * Converts the object to an array + */ + public function toArray(): array + { + return [ + 'exception' => static::class, + 'message' => $this->getMessage(), + 'key' => $this->getKey(), + 'file' => $this->getFileRelative(), + 'line' => $this->getLine(), + 'details' => $this->getDetails(), + 'code' => $this->getHttpCode() + ]; + } +} diff --git a/kirby/src/Exception/InvalidArgumentException.php b/kirby/src/Exception/InvalidArgumentException.php new file mode 100644 index 0000000..25603b7 --- /dev/null +++ b/kirby/src/Exception/InvalidArgumentException.php @@ -0,0 +1,21 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class InvalidArgumentException extends Exception +{ + protected static string $defaultKey = 'invalidArgument'; + protected static string $defaultFallback = 'Invalid argument "{ argument }" in method "{ method }"'; + protected static int $defaultHttpCode = 400; + protected static array $defaultData = ['argument' => null, 'method' => null]; +} diff --git a/kirby/src/Exception/LogicException.php b/kirby/src/Exception/LogicException.php new file mode 100644 index 0000000..8fac228 --- /dev/null +++ b/kirby/src/Exception/LogicException.php @@ -0,0 +1,20 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class LogicException extends Exception +{ + protected static string $defaultKey = 'logic'; + protected static string $defaultFallback = 'This task cannot be finished'; + protected static int $defaultHttpCode = 400; +} diff --git a/kirby/src/Exception/NotFoundException.php b/kirby/src/Exception/NotFoundException.php new file mode 100644 index 0000000..5c7e284 --- /dev/null +++ b/kirby/src/Exception/NotFoundException.php @@ -0,0 +1,20 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class NotFoundException extends Exception +{ + protected static string $defaultKey = 'notFound'; + protected static string $defaultFallback = 'Not found'; + protected static int $defaultHttpCode = 404; +} diff --git a/kirby/src/Exception/PermissionException.php b/kirby/src/Exception/PermissionException.php new file mode 100644 index 0000000..ae82a66 --- /dev/null +++ b/kirby/src/Exception/PermissionException.php @@ -0,0 +1,21 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class PermissionException extends Exception +{ + protected static string $defaultKey = 'permission'; + protected static string $defaultFallback = 'You are not allowed to do this'; + protected static int $defaultHttpCode = 403; +} diff --git a/kirby/src/Field/FieldOptions.php b/kirby/src/Field/FieldOptions.php new file mode 100644 index 0000000..fb98070 --- /dev/null +++ b/kirby/src/Field/FieldOptions.php @@ -0,0 +1,108 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class FieldOptions extends Node +{ + public function __construct( + /** + * The option source, either a fixed collection or + * a dynamic provider + */ + public Options|OptionsProvider|null $options = null, + + /** + * Whether to escape special HTML characters in + * the option text for safe output in the Panel; + * only set to `false` if the text is later escaped! + */ + public bool $safeMode = true + ) { + } + + public function defaults(): static + { + $this->options ??= new Options(); + + return parent::defaults(); + } + + public static function factory(array $props, bool $safeMode = true): static + { + $options = match ($props['type']) { + 'api' => OptionsApi::factory($props), + 'query' => OptionsQuery::factory($props), + default => Options::factory($props['options'] ?? []) + }; + + return new static($options, $safeMode); + } + + public static function polyfill(array $props = []): array + { + if (is_string($props['options'] ?? null) === true) { + $props['options'] = match ($props['options']) { + 'api' => + ['type' => 'api'] + + OptionsApi::polyfill($props['api'] ?? null), + + 'query' => + ['type' => 'query'] + + OptionsQuery::polyfill($props['query'] ?? null), + + default => + [ 'type' => 'query', 'query' => $props['options']] + }; + } + + unset($props['api'], $props['query']); + + if (($props['options']['type'] ?? null) !== null) { + return $props; + } + + if (($props['options'] ?? null) !== null) { + $props['options'] = [ + 'type' => 'array', + 'options' => $props['options'] + ]; + } + + return $props; + } + + public function resolve(ModelWithContent $model): Options + { + // apply default values + $this->defaults(); + + // already Options, return + if (is_a($this->options, Options::class) === true) { + return $this->options; + } + + // resolve OptionsProvider (OptionsApi or OptionsQuery) to Options + return $this->options = $this->options->resolve($model, $this->safeMode); + } + + public function render(ModelWithContent $model): array + { + return $this->resolve($model)->render($model); + } +} diff --git a/kirby/src/Filesystem/Asset.php b/kirby/src/Filesystem/Asset.php new file mode 100644 index 0000000..021d6c8 --- /dev/null +++ b/kirby/src/Filesystem/Asset.php @@ -0,0 +1,118 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Asset +{ + use IsFile; + use FileModifications; + use HasMethods; + + /** + * Relative file path + */ + protected string|null $path; + + + /** + * Creates a new Asset object for the given path. + */ + public function __construct(string $path) + { + $this->root = $this->kirby()->root('index') . '/' . $path; + $this->url = $this->kirby()->url('base') . '/' . $path; + + $path = dirname($path); + $this->path = $path === '.' ? '' : $path; + } + + /** + * Magic caller for asset methods + * + * @throws \Kirby\Exception\BadMethodCallException + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // asset method proxy + if (method_exists($this->asset(), $method)) { + return $this->asset()->$method(...$arguments); + } + + // asset methods + if ($this->hasMethod($method)) { + return $this->callMethod($method, $arguments); + } + + throw new BadMethodCallException('The method: "' . $method . '" does not exist'); + } + + /** + * Returns a unique id for the asset + */ + public function id(): string + { + return $this->root(); + } + + /** + * Create a unique media hash + */ + public function mediaHash(): string + { + return crc32($this->filename()) . '-' . $this->modified(); + } + + /** + * Returns the relative path starting at the media folder + */ + public function mediaPath(): string + { + return 'assets/' . $this->path() . '/' . $this->mediaHash() . '/' . $this->filename(); + } + + /** + * Returns the absolute path to the file in the public media folder + */ + public function mediaRoot(): string + { + return $this->kirby()->root('media') . '/' . $this->mediaPath(); + } + + /** + * Returns the absolute Url to the file in the public media folder + */ + public function mediaUrl(): string + { + return $this->kirby()->url('media') . '/' . $this->mediaPath(); + } + + /** + * Returns the path of the file from the web root, + * excluding the filename + */ + public function path(): string + { + return $this->path; + } +} diff --git a/kirby/src/Filesystem/Dir.php b/kirby/src/Filesystem/Dir.php new file mode 100644 index 0000000..43360f4 --- /dev/null +++ b/kirby/src/Filesystem/Dir.php @@ -0,0 +1,605 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Dir +{ + /** + * Ignore when scanning directories + */ + public static array $ignore = [ + '.', + '..', + '.DS_Store', + '.gitignore', + '.git', + '.svn', + '.htaccess', + 'Thumb.db', + '@eaDir' + ]; + + public static string $numSeparator = '_'; + + /** + * Copy the directory to a new destination + * + * @param array|false $ignore List of full paths to skip during copying + * or `false` to copy all files, including + * those listed in `Dir::$ignore` + */ + public static function copy( + string $dir, + string $target, + bool $recursive = true, + array|false $ignore = [] + ): bool { + if (is_dir($dir) === false) { + throw new Exception('The directory "' . $dir . '" does not exist'); + } + + if (is_dir($target) === true) { + throw new Exception('The target directory "' . $target . '" exists'); + } + + if (static::make($target) !== true) { + throw new Exception('The target directory "' . $target . '" could not be created'); + } + + foreach (static::read($dir, $ignore === false ? [] : null) as $name) { + $root = $dir . '/' . $name; + + if ( + is_array($ignore) === true && + in_array($root, $ignore) === true + ) { + continue; + } + + if (is_dir($root) === true) { + if ($recursive === true) { + static::copy($root, $target . '/' . $name, true, $ignore); + } + } else { + F::copy($root, $target . '/' . $name); + } + } + + return true; + } + + /** + * Get all subdirectories + */ + public static function dirs( + string $dir, + array|null $ignore = null, + bool $absolute = false + ): array { + $scan = static::read($dir, $ignore, true); + $result = array_values(array_filter($scan, 'is_dir')); + + if ($absolute !== true) { + $result = array_map('basename', $result); + } + + return $result; + } + + /** + * Checks if the directory exists on disk + */ + public static function exists(string $dir): bool + { + return is_dir($dir) === true; + } + + /** + * Get all files + */ + public static function files( + string $dir, + array|null $ignore = null, + bool $absolute = false + ): array { + $scan = static::read($dir, $ignore, true); + $result = array_values(array_filter($scan, 'is_file')); + + if ($absolute !== true) { + $result = array_map('basename', $result); + } + + return $result; + } + + /** + * Read the directory and all subdirectories + * + * @todo Remove support for `$ignore = null` in a major release + * @param array|false|null $ignore Array of absolut file paths; + * `false` to disable `Dir::$ignore` list + * (passing null is deprecated) + */ + public static function index( + string $dir, + bool $recursive = false, + array|false|null $ignore = [], + string $path = null + ): array { + $result = []; + $dir = realpath($dir); + $items = static::read($dir, $ignore === false ? [] : null); + + foreach ($items as $item) { + $root = $dir . '/' . $item; + + if ( + is_array($ignore) === true && + in_array($root, $ignore) === true + ) { + continue; + } + + $entry = $path !== null ? $path . '/' . $item : $item; + $result[] = $entry; + + if ($recursive === true && is_dir($root) === true) { + $result = array_merge($result, static::index($root, true, $ignore, $entry)); + } + } + + return $result; + } + + /** + * Checks if the folder has any contents + */ + public static function isEmpty(string $dir): bool + { + return count(static::read($dir)) === 0; + } + + /** + * Checks if the directory is readable + */ + public static function isReadable(string $dir): bool + { + return is_readable($dir); + } + + /** + * Checks if the directory is writable + */ + public static function isWritable(string $dir): bool + { + return is_writable($dir); + } + + /** + * Scans the directory and analyzes files, + * content, meta info and children. This is used + * in `Kirby\Cms\Page`, `Kirby\Cms\Site` and + * `Kirby\Cms\User` objects to fetch all + * relevant information. + * + * Don't use outside the Cms context. + * + * @internal + */ + public static function inventory( + string $dir, + string $contentExtension = 'txt', + array|null $contentIgnore = null, + bool $multilang = false + ): array { + $dir = realpath($dir); + + $inventory = [ + 'children' => [], + 'files' => [], + 'template' => 'default', + ]; + + if ($dir === false) { + return $inventory; + } + + $items = static::read($dir, $contentIgnore); + + // a temporary store for all content files + $content = []; + + // sort all items naturally to avoid sorting issues later + natsort($items); + + foreach ($items as $item) { + // ignore all items with a leading dot + if (in_array(substr($item, 0, 1), ['.', '_']) === true) { + continue; + } + + $root = $dir . '/' . $item; + + if (is_dir($root) === true) { + // extract the slug and num of the directory + if (preg_match('/^([0-9]+)' . static::$numSeparator . '(.*)$/', $item, $match)) { + $num = (int)$match[1]; + $slug = $match[2]; + } else { + $num = null; + $slug = $item; + } + + $inventory['children'][] = [ + 'dirname' => $item, + 'model' => null, + 'num' => $num, + 'root' => $root, + 'slug' => $slug, + ]; + } else { + $extension = pathinfo($item, PATHINFO_EXTENSION); + + switch ($extension) { + case 'htm': + case 'html': + case 'php': + // don't track those files + break; + case $contentExtension: + $content[] = pathinfo($item, PATHINFO_FILENAME); + break; + default: + $inventory['files'][$item] = [ + 'filename' => $item, + 'extension' => $extension, + 'root' => $root, + ]; + } + } + } + + // remove the language codes from all content filenames + if ($multilang === true) { + foreach ($content as $key => $filename) { + $content[$key] = pathinfo($filename, PATHINFO_FILENAME); + } + + $content = array_unique($content); + } + + $inventory = static::inventoryContent($inventory, $content); + $inventory = static::inventoryModels($inventory, $contentExtension, $multilang); + + return $inventory; + } + + /** + * Take all content files, + * remove those who are meta files and + * detect the main content file + */ + protected static function inventoryContent(array $inventory, array $content): array + { + // filter meta files from the content file + if (empty($content) === true) { + $inventory['template'] = 'default'; + return $inventory; + } + + foreach ($content as $contentName) { + // could be a meta file. i.e. cover.jpg + if (isset($inventory['files'][$contentName]) === true) { + continue; + } + + // it's most likely the template + $inventory['template'] = $contentName; + } + + return $inventory; + } + + /** + * Go through all inventory children + * and inject a model for each + */ + protected static function inventoryModels( + array $inventory, + string $contentExtension, + bool $multilang = false + ): array { + // inject models + if ( + empty($inventory['children']) === false && + empty(Page::$models) === false + ) { + if ($multilang === true) { + $contentExtension = App::instance()->defaultLanguage()->code() . '.' . $contentExtension; + } + + foreach ($inventory['children'] as $key => $child) { + foreach (Page::$models as $modelName => $modelClass) { + if (file_exists($child['root'] . '/' . $modelName . '.' . $contentExtension) === true) { + $inventory['children'][$key]['model'] = $modelName; + break; + } + } + } + } + + return $inventory; + } + + /** + * Create a (symbolic) link to a directory + */ + public static function link(string $source, string $link): bool + { + static::make(dirname($link), true); + + if (is_dir($link) === true) { + return true; + } + + if (is_dir($source) === false) { + throw new Exception(sprintf('The directory "%s" does not exist and cannot be linked', $source)); + } + + try { + return symlink($source, $link) === true; + } catch (Throwable) { + return false; + } + } + + /** + * Creates a new directory + * + * @param string $dir The path for the new directory + * @param bool $recursive Create all parent directories, which don't exist + * @return bool True: the dir has been created, false: creating failed + * @throws \Exception If a file with the provided path already exists or the parent directory is not writable + */ + public static function make(string $dir, bool $recursive = true): bool + { + if (empty($dir) === true) { + return false; + } + + if (is_dir($dir) === true) { + return true; + } + + if (is_file($dir) === true) { + throw new Exception(sprintf('A file with the name "%s" already exists', $dir)); + } + + $parent = dirname($dir); + + if ($recursive === true) { + if (is_dir($parent) === false) { + static::make($parent, true); + } + } + + if (is_writable($parent) === false) { + throw new Exception(sprintf('The directory "%s" cannot be created', $dir)); + } + + return Helpers::handleErrors( + fn (): bool => mkdir($dir), + // if the dir was already created (race condition), + fn (int $errno, string $errstr): bool => Str::endsWith($errstr, 'File exists'), + // consider it a success + true + ); + } + + /** + * Recursively check when the dir and all + * subfolders have been modified for the last time. + * + * @param string $dir The path of the directory + * @param 'date'|'intl'|'strftime'|null $handler Custom date handler or `null` + * for the globally configured one + */ + public static function modified(string $dir, string $format = null, string|null $handler = null): int|string + { + $modified = filemtime($dir); + $items = static::read($dir); + + foreach ($items as $item) { + if (is_file($dir . '/' . $item) === true) { + $newModified = filemtime($dir . '/' . $item); + } else { + $newModified = static::modified($dir . '/' . $item); + } + + $modified = ($newModified > $modified) ? $newModified : $modified; + } + + return Str::date($modified, $format, $handler); + } + + /** + * Moves a directory to a new location + * + * @param string $old The current path of the directory + * @param string $new The desired path where the dir should be moved to + * @return bool true: the directory has been moved, false: moving failed + */ + public static function move(string $old, string $new): bool + { + if ($old === $new) { + return true; + } + + if (is_dir($old) === false || is_dir($new) === true) { + return false; + } + + if (static::make(dirname($new), true) !== true) { + throw new Exception('The parent directory cannot be created'); + } + + return rename($old, $new); + } + + /** + * Returns a nicely formatted size of all the contents of the folder + * + * @param string $dir The path of the directory + * @param string|false|null $locale Locale for number formatting, + * `null` for the current locale, + * `false` to disable number formatting + */ + public static function niceSize( + string $dir, + string|false|null $locale = null + ): string { + return F::niceSize(static::size($dir), $locale); + } + + /** + * Reads all files from a directory and returns them as an array. + * It skips unwanted invisible stuff. + * + * @param string $dir The path of directory + * @param array $ignore Optional array with filenames, which should be ignored + * @param bool $absolute If true, the full path for each item will be returned + * @return array An array of filenames + */ + public static function read( + string $dir, + array|null $ignore = null, + bool $absolute = false + ): array { + if (is_dir($dir) === false) { + return []; + } + + // create the ignore pattern + $ignore ??= static::$ignore; + $ignore = array_merge($ignore, ['.', '..']); + + // scan for all files and dirs + $result = array_values((array)array_diff(scandir($dir), $ignore)); + + // add absolute paths + if ($absolute === true) { + $result = array_map(fn ($item) => $dir . '/' . $item, $result); + } + + return $result; + } + + /** + * Removes a folder including all containing files and folders + */ + public static function remove(string $dir): bool + { + $dir = realpath($dir); + + if (is_dir($dir) === false) { + return true; + } + + if (is_link($dir) === true) { + return F::unlink($dir); + } + + foreach (scandir($dir) as $childName) { + if (in_array($childName, ['.', '..']) === true) { + continue; + } + + $child = $dir . '/' . $childName; + + if (is_dir($child) === true && is_link($child) === false) { + static::remove($child); + } else { + F::unlink($child); + } + } + + return rmdir($dir); + } + + /** + * Gets the size of the directory + * + * @param string $dir The path of the directory + * @param bool $recursive Include all subfolders and their files + */ + public static function size(string $dir, bool $recursive = true): int|false + { + if (is_dir($dir) === false) { + return false; + } + + // Get size for all direct files + $size = F::size(static::files($dir, null, true)); + + // if recursive, add sizes of all subdirectories + if ($recursive === true) { + foreach (static::dirs($dir, null, true) as $subdir) { + $size += static::size($subdir); + } + } + + return $size; + } + + /** + * Checks if the directory or any subdirectory has been + * modified after the given timestamp + */ + public static function wasModifiedAfter(string $dir, int $time): bool + { + if (filemtime($dir) > $time) { + return true; + } + + $content = static::read($dir); + + foreach ($content as $item) { + $subdir = $dir . '/' . $item; + + if (filemtime($subdir) > $time) { + return true; + } + + if (is_dir($subdir) === true && static::wasModifiedAfter($subdir, $time) === true) { + return true; + } + } + + return false; + } +} diff --git a/kirby/src/Filesystem/F.php b/kirby/src/Filesystem/F.php new file mode 100644 index 0000000..7d3b9bc --- /dev/null +++ b/kirby/src/Filesystem/F.php @@ -0,0 +1,931 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class F +{ + public static array $types = [ + 'archive' => [ + 'gz', + 'gzip', + 'tar', + 'tgz', + 'zip', + ], + 'audio' => [ + 'aif', + 'aiff', + 'm4a', + 'midi', + 'mp3', + 'wav', + ], + 'code' => [ + 'css', + 'js', + 'json', + 'java', + 'htm', + 'html', + 'php', + 'rb', + 'py', + 'scss', + 'xml', + 'yaml', + 'yml', + ], + 'document' => [ + 'csv', + 'doc', + 'docx', + 'dotx', + 'indd', + 'md', + 'mdown', + 'pdf', + 'ppt', + 'pptx', + 'rtf', + 'txt', + 'xl', + 'xls', + 'xlsx', + 'xltx', + ], + 'image' => [ + 'ai', + 'avif', + 'bmp', + 'gif', + 'eps', + 'ico', + 'j2k', + 'jp2', + 'jpeg', + 'jpg', + 'jpe', + 'png', + 'ps', + 'psd', + 'svg', + 'tif', + 'tiff', + 'webp' + ], + 'video' => [ + 'avi', + 'flv', + 'm4v', + 'mov', + 'movie', + 'mpe', + 'mpg', + 'mp4', + 'ogg', + 'ogv', + 'swf', + 'webm', + ], + ]; + + public static array $units = [ + 'B', + 'KB', + 'MB', + 'GB', + 'TB', + 'PB', + 'EB', + 'ZB', + 'YB' + ]; + + /** + * Appends new content to an existing file + * + * @param string $file The path for the file + * @param mixed $content Either a string or an array. Arrays will be converted to JSON. + */ + public static function append(string $file, $content): bool + { + return static::write($file, $content, true); + } + + /** + * Returns the file content as base64 encoded string + * + * @param string $file The path for the file + */ + public static function base64(string $file): string + { + return base64_encode(static::read($file)); + } + + /** + * Copy a file to a new location. + */ + public static function copy(string $source, string $target, bool $force = false): bool + { + if (file_exists($source) === false || (file_exists($target) === true && $force === false)) { + return false; + } + + $directory = dirname($target); + + // create the parent directory if it does not exist + if (is_dir($directory) === false) { + Dir::make($directory, true); + } + + return copy($source, $target); + } + + /** + * Just an alternative for dirname() to stay consistent + * + * + * + * $dirname = F::dirname('/var/www/test.txt'); + * // dirname is /var/www + * + * + * + * @param string $file The path + */ + public static function dirname(string $file): string + { + return dirname($file); + } + + /** + * Checks if the file exists on disk + */ + public static function exists(string $file, string|null $in = null): bool + { + try { + static::realpath($file, $in); + return true; + } catch (Exception) { + return false; + } + } + + /** + * Gets the extension of a file + * + * @param string|null $file The filename or path + * @param string|null $extension Set an optional extension to overwrite the current one + */ + public static function extension( + string|null $file = null, + string|null $extension = null + ): string { + // overwrite the current extension + if ($extension !== null) { + return static::name($file) . '.' . $extension; + } + + // return the current extension + return Str::lower(pathinfo($file, PATHINFO_EXTENSION)); + } + + /** + * Converts a file extension to a mime type + */ + public static function extensionToMime(string $extension): string|null + { + return Mime::fromExtension($extension); + } + + /** + * Returns the file type for a passed extension + */ + public static function extensionToType(string $extension): string|false + { + foreach (static::$types as $type => $extensions) { + if (in_array($extension, $extensions) === true) { + return $type; + } + } + + return false; + } + + /** + * Returns all extensions for a certain file type + */ + public static function extensions(string|null $type = null): array + { + if ($type === null) { + return array_keys(Mime::types()); + } + + return static::$types[$type] ?? []; + } + + /** + * Extracts the filename from a file path + * + * + * + * $filename = F::filename('/var/www/test.txt'); + * // filename is test.txt + * + * + * + * @param string $name The path + */ + public static function filename(string $name): string + { + return pathinfo($name, PATHINFO_BASENAME); + } + + /** + * Invalidate opcode cache for file. + * + * @param string $file The path of the file + */ + public static function invalidateOpcodeCache(string $file): bool + { + if ( + function_exists('opcache_invalidate') && + strlen(ini_get('opcache.restrict_api')) === 0 + ) { + return opcache_invalidate($file, true); + } + + return false; + } + + /** + * Checks if a file is of a certain type + * + * @param string $file Full path to the file + * @param string $value An extension or mime type + */ + public static function is(string $file, string $value): bool + { + // check for the extension + if (in_array($value, static::extensions()) === true) { + return static::extension($file) === $value; + } + + // check for the mime type + if (strpos($value, '/') !== false) { + return static::mime($file) === $value; + } + + return false; + } + + /** + * Checks if the file is readable + */ + public static function isReadable(string $file): bool + { + return is_readable($file); + } + + /** + * Checks if the file is writable + */ + public static function isWritable(string $file): bool + { + if (file_exists($file) === false) { + return is_writable(dirname($file)); + } + + return is_writable($file); + } + + /** + * Create a (symbolic) link to a file + */ + public static function link(string $source, string $link, string $method = 'link'): bool + { + Dir::make(dirname($link), true); + + if (is_file($link) === true) { + return true; + } + + if (is_file($source) === false) { + throw new Exception(sprintf('The file "%s" does not exist and cannot be linked', $source)); + } + + try { + return $method($source, $link) === true; + } catch (Throwable) { + return false; + } + } + + /** + * Loads a file and returns the result or `false` if the + * file to load does not exist + * + * @param array $data Optional array of variables to extract in the variable scope + */ + public static function load( + string $file, + mixed $fallback = null, + array $data = [], + bool $allowOutput = true + ) { + if (is_file($file) === false) { + return $fallback; + } + + // we use the loadIsolated() method here to prevent the included + // file from overwriting our $fallback in this variable scope; see + // https://www.php.net/manual/en/function.include.php#example-124 + $callback = fn () => static::loadIsolated($file, $data); + + // if the loaded file should not produce any output, + // call the loaidIsolated method from the Response class + // which checks for unintended ouput and throws an error if detected + if ($allowOutput === false) { + $result = Response::guardAgainstOutput($callback); + } else { + $result = $callback(); + } + + if ( + $fallback !== null && + gettype($result) !== gettype($fallback) + ) { + return $fallback; + } + + return $result; + } + + /** + * A super simple class autoloader + * @since 3.7.0 + */ + public static function loadClasses( + array $classmap, + string|null $base = null + ): void { + // convert all classnames to lowercase + $classmap = array_change_key_case($classmap); + + spl_autoload_register( + fn ($class) => Response::guardAgainstOutput(function () use ($class, $classmap, $base) { + $class = strtolower($class); + + if (isset($classmap[$class]) === false) { + return false; + } + + if ($base) { + include $base . '/' . $classmap[$class]; + } else { + include $classmap[$class]; + } + }) + ); + } + + /** + * Loads a file with as little as possible in the variable scope + * + * @param array $data Optional array of variables to extract in the variable scope + */ + protected static function loadIsolated(string $file, array $data = []) + { + // extract the $data variables in this scope to be accessed by the included file; + // protect $file against being overwritten by a $data variable + $___file___ = $file; + extract($data); + + return include $___file___; + } + + /** + * Loads a file using `include_once()` and + * returns whether loading was successful + */ + public static function loadOnce( + string $file, + bool $allowOutput = true + ): bool { + if (is_file($file) === false) { + return false; + } + + $callback = fn () => include_once $file; + + if ($allowOutput === false) { + Response::guardAgainstOutput($callback); + } else { + $callback(); + } + + return true; + } + + /** + * Returns the mime type of a file + */ + public static function mime(string $file): string|null + { + return Mime::type($file); + } + + /** + * Converts a mime type to a file extension + */ + public static function mimeToExtension(string|null $mime = null): string|false + { + return Mime::toExtension($mime); + } + + /** + * Returns the type for a given mime + */ + public static function mimeToType(string $mime): string|false + { + return static::extensionToType(Mime::toExtension($mime)); + } + + /** + * Get the file's last modification time. + * + * @param 'date'|'intl'|'strftime'|null $handler Custom date handler or `null` + * for the globally configured one + */ + public static function modified( + string $file, + string|IntlDateFormatter|null $format = null, + string|null $handler = null + ): string|int|false { + if (file_exists($file) !== true) { + return false; + } + + $modified = filemtime($file); + + return Str::date($modified, $format, $handler); + } + + /** + * Moves a file to a new location + * + * @param string $oldRoot The current path for the file + * @param string $newRoot The path to the new location + * @param bool $force Force move if the target file exists + */ + public static function move(string $oldRoot, string $newRoot, bool $force = false): bool + { + // check if the file exists + if (file_exists($oldRoot) === false) { + return false; + } + + if (file_exists($newRoot) === true) { + if ($force === false) { + return false; + } + + // delete the existing file + static::remove($newRoot); + } + + $directory = dirname($newRoot); + + // create the parent directory if it does not exist + if (is_dir($directory) === false) { + Dir::make($directory, true); + } + + // atomically moving the file will only work if + // source and target are on the same filesystem + if (stat($oldRoot)['dev'] === stat($directory)['dev']) { + // same filesystem, we can move the file + return rename($oldRoot, $newRoot) === true; + } + + // @codeCoverageIgnoreStart + // not the same filesystem; we need to copy + // the file and unlink the source afterwards + if (copy($oldRoot, $newRoot) === true) { + return unlink($oldRoot) === true; + } + + // copying failed, ensure the new root isn't there + // (e.g. if the file could be created but there's no + // more remaining disk space to write its contents) + static::remove($newRoot); + return false; + // @codeCoverageIgnoreEnd + } + + /** + * Extracts the name from a file path or filename without extension + * + * @param string $name The path or filename + */ + public static function name(string $name): string + { + return pathinfo($name, PATHINFO_FILENAME); + } + + /** + * Converts an integer size into a human readable format + * + * @param int|string|array $size The file size, a file path or array of paths + * @param string|false|null $locale Locale for number formatting, + * `null` for the current locale, + * `false` to disable number formatting + */ + public static function niceSize( + int|string|array $size, + string|false|null $locale = null + ): string { + // file mode + if (is_string($size) === true || is_array($size) === true) { + $size = static::size($size); + } + + // make sure it's an int + $size = (int)$size; + + // avoid errors for invalid sizes + if ($size <= 0) { + return '0 KB'; + } + + // the math magic + $size = round($size / pow(1024, ($unit = floor(log($size, 1024)))), 2); + + // format the number if requested + if ($locale !== false) { + $size = I18n::formatNumber($size, $locale); + } + + return $size . ' ' . static::$units[$unit]; + } + + /** + * Reads the content of a file or requests the + * contents of a remote HTTP or HTTPS URL + * + * @param string $file The path for the file or an absolute URL + */ + public static function read(string $file): string|false + { + if ( + is_readable($file) !== true && + Str::startsWith($file, 'https://') !== true && + Str::startsWith($file, 'http://') !== true + ) { + return false; + } + + return file_get_contents($file); + } + + /** + * Changes the name of the file without + * touching the extension + * + * @param bool $overwrite Force overwrite existing files + */ + public static function rename(string $file, string $newName, bool $overwrite = false): string|false + { + // create the new name + $name = static::safeName(basename($newName)); + + // overwrite the root + $newRoot = rtrim(dirname($file) . '/' . $name . '.' . F::extension($file), '.'); + + // nothing has changed + if ($newRoot === $file) { + return $newRoot; + } + + if (F::move($file, $newRoot, $overwrite) !== true) { + return false; + } + + return $newRoot; + } + + /** + * Returns the absolute path to the file if the file can be found. + */ + public static function realpath(string $file, string|null $in = null): string + { + $realpath = realpath($file); + + if ($realpath === false || is_file($realpath) === false) { + throw new Exception(sprintf('The file does not exist at the given path: "%s"', $file)); + } + + if ($in !== null) { + $parent = realpath($in); + + if ($parent === false || is_dir($parent) === false) { + throw new Exception(sprintf('The parent directory does not exist: "%s"', $in)); + } + + if (substr($realpath, 0, strlen($parent)) !== $parent) { + throw new Exception('The file is not within the parent directory'); + } + } + + return $realpath; + } + + /** + * Returns the relative path of the file + * starting after $in + * + * @SuppressWarnings(PHPMD.CountInLoopExpression) + */ + public static function relativepath(string $file, string|null $in = null): string + { + if (empty($in) === true) { + return basename($file); + } + + // windows + $file = str_replace('\\', '/', $file); + $in = str_replace('\\', '/', $in); + + // trim trailing slashes + $file = rtrim($file, '/'); + $in = rtrim($in, '/'); + + if (Str::contains($file, $in . '/') === false) { + // make the paths relative by stripping what they have + // in common and adding `../` tokens at the start + $fileParts = explode('/', $file); + $inParts = explode('/', $in); + while (count($fileParts) && count($inParts) && ($fileParts[0] === $inParts[0])) { + array_shift($fileParts); + array_shift($inParts); + } + + return str_repeat('../', count($inParts)) . implode('/', $fileParts); + } + + return '/' . Str::after($file, $in . '/'); + } + + /** + * Deletes a file + * + * + * + * $remove = F::remove('test.txt'); + * if($remove) echo 'The file has been removed'; + * + * + * + * @param string $file The path for the file + */ + public static function remove(string $file): bool + { + if (strpos($file, '*') !== false) { + foreach (glob($file) as $f) { + static::remove($f); + } + + return true; + } + + $file = realpath($file); + if (is_string($file) === false) { + return true; + } + + return static::unlink($file); + } + + /** + * Sanitize a file's full name (filename and extension) + * to strip unwanted special characters + * + * + * + * $safe = f::safeName('über genius.txt'); + * // safe will be ueber-genius.txt + * + * + * + * @param string $string The file name + */ + public static function safeName(string $string): string + { + $basename = static::safeBasename($string); + $extension = static::safeExtension($string); + + if (empty($extension) === false) { + $extension = '.' . $extension; + } + + return $basename . $extension; + } + + /** + * Sanitize a file's name (without extension) + * @since 4.0.0 + */ + public static function safeBasename(string $string): string + { + $name = static::name($string); + return Str::slug($name, '-', 'a-z0-9@._-'); + } + + /** + * Sanitize a file's extension + * @since 4.0.0 + */ + public static function safeExtension(string $string): string + { + $extension = static::extension($string); + return Str::slug($extension); + } + + /** + * Tries to find similar or the same file by + * building a glob based on the path + */ + public static function similar(string $path, string $pattern = '*'): array + { + $dir = dirname($path); + $name = static::name($path); + $extension = static::extension($path); + $glob = $dir . '/' . $name . $pattern . '.' . $extension; + return glob($glob); + } + + /** + * Returns the size of a file or an array of files. + * + * @param string|array $file file path or array of paths + */ + public static function size(string|array $file): int + { + if (is_array($file) === true) { + return array_reduce( + $file, + fn ($total, $file) => $total + F::size($file), + 0 + ); + } + + try { + return filesize($file); + } catch (Throwable) { + return 0; + } + } + + /** + * Categorize the file + * + * @param string $file Either the file path or extension + */ + public static function type(string $file): string|null + { + $length = strlen($file); + + if ($length >= 2 && $length <= 4) { + // use the file name as extension + $extension = $file; + } else { + // get the extension from the filename + $extension = pathinfo($file, PATHINFO_EXTENSION); + } + + if (empty($extension) === true) { + // detect the mime type first to get the most reliable extension + $mime = static::mime($file); + $extension = static::mimeToExtension($mime); + } + + // sanitize extension + $extension = strtolower($extension); + + foreach (static::$types as $type => $extensions) { + if (in_array($extension, $extensions) === true) { + return $type; + } + } + + return null; + } + + /** + * Returns all extensions of a given file type + * or `null` if the file type is unknown + */ + public static function typeToExtensions(string $type): array|null + { + return static::$types[$type] ?? null; + } + + /** + * Ensures that a file or link is deleted (with race condition handling) + * @since 3.7.4 + */ + public static function unlink(string $file): bool + { + return Helpers::handleErrors( + fn (): bool => unlink($file), + // if the file or link was already deleted (race condition), + fn (int $errno, string $errstr): bool => Str::endsWith($errstr, 'No such file or directory'), + // consider it a success + true + ); + } + + /** + * Unzips a zip file + */ + public static function unzip(string $file, string $to): bool + { + if (class_exists('ZipArchive') === false) { + throw new Exception('The ZipArchive class is not available'); + } + + $zip = new ZipArchive(); + + if ($zip->open($file) === true) { + $zip->extractTo($to); + $zip->close(); + return true; + } + + return false; + } + + /** + * Returns the file as data uri + * + * @param string $file The path for the file + */ + public static function uri(string $file): string|false + { + if ($mime = static::mime($file)) { + return 'data:' . $mime . ';base64,' . static::base64($file); + } + + return false; + } + + /** + * Creates a new file + * + * @param string $file The path for the new file + * @param mixed $content Either a string, an object or an array. Arrays and objects will be serialized. + * @param bool $append true: append the content to an existing file if available. false: overwrite. + */ + public static function write(string $file, $content, bool $append = false): bool + { + if (is_array($content) === true || is_object($content) === true) { + $content = serialize($content); + } + + $mode = $append === true ? FILE_APPEND | LOCK_EX : LOCK_EX; + + // if the parent directory does not exist, create it + if (is_dir(dirname($file)) === false) { + if (Dir::make(dirname($file)) === false) { + return false; + } + } + + if (static::isWritable($file) === false) { + throw new Exception('The file "' . $file . '" is not writable'); + } + + return file_put_contents($file, $content, $mode) !== false; + } +} diff --git a/kirby/src/Filesystem/File.php b/kirby/src/Filesystem/File.php new file mode 100644 index 0000000..8fa2191 --- /dev/null +++ b/kirby/src/Filesystem/File.php @@ -0,0 +1,557 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class File +{ + /** + * Parent file model + * The model object must use the `\Kirby\Filesystem\IsFile` trait + */ + protected object|null $model; + + /** + * Absolute file path + */ + protected string|null $root; + + /** + * Absolute file URL + */ + protected string|null $url; + + /** + * Validation rules to be used for `::match()` + */ + public static array $validations = [ + 'maxsize' => ['size', 'max'], + 'minsize' => ['size', 'min'] + ]; + + /** + * Constructor sets all file properties + * + * @param array|string|null $props Properties or deprecated `$root` string + * @param string|null $url Deprecated argument, use `$props['url']` instead + * + * @throws \Kirby\Exception\InvalidArgumentException When the model does not use the `Kirby\Filesystem\IsFile` trait + */ + public function __construct( + array|string $props = null, + string $url = null + ) { + // Legacy support for old constructor of + // the `Kirby\Image\Image` class + if (is_array($props) === false) { + $props = [ + 'root' => $props, + 'url' => $url + ]; + } + + $this->root = $props['root'] ?? null; + $this->url = $props['url'] ?? null; + $this->model = $props['model'] ?? null; + + if ( + $this->model !== null && + method_exists($this->model, 'hasIsFileTrait') !== true + ) { + throw new InvalidArgumentException('The model object must use the "Kirby\Filesystem\IsFile" trait'); + } + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Returns the URL for the file object + */ + public function __toString(): string + { + return $this->url() ?? $this->root() ?? ''; + } + + /** + * Returns the file content as base64 encoded string + */ + public function base64(): string + { + return base64_encode($this->read()); + } + + /** + * Copy a file to a new location. + */ + public function copy(string $target, bool $force = false): static + { + if (F::copy($this->root(), $target, $force) !== true) { + throw new Exception('The file "' . $this->root() . '" could not be copied'); + } + + return new static($target); + } + + /** + * Returns the file as data uri + * + * @param bool $base64 Whether the data should be base64 encoded or not + */ + public function dataUri(bool $base64 = true): string + { + if ($base64 === true) { + return 'data:' . $this->mime() . ';base64,' . $this->base64(); + } + + return 'data:' . $this->mime() . ',' . Escape::url($this->read()); + } + + /** + * Deletes the file + */ + public function delete(): bool + { + if (F::remove($this->root()) !== true) { + throw new Exception('The file "' . $this->root() . '" could not be deleted'); + } + + return true; + } + + /* + * Automatically sends all needed headers + * for the file to be downloaded and + * echos the file's content + * + * @param string|null $filename Optional filename for the download + */ + public function download(string|null $filename = null): string + { + return Response::download($this->root(), $filename ?? $this->filename()); + } + + /** + * Checks if the file actually exists + */ + public function exists(): bool + { + return file_exists($this->root()) === true; + } + + /** + * Returns the current lowercase extension (without .) + */ + public function extension(): string + { + return F::extension($this->root()); + } + + /** + * Returns the filename + */ + public function filename(): string + { + return basename($this->root()); + } + + /** + * Returns a md5 hash of the root + */ + public function hash(): string + { + return md5($this->root()); + } + + /** + * Sends an appropriate header for the asset + */ + public function header(bool $send = true): Response|null + { + $response = new Response('', $this->mime()); + + if ($send !== true) { + return $response; + } + + $response->send(); + return null; + } + + /** + * Converts the file to html + */ + public function html(array $attr = []): string + { + return Html::a($this->url() ?? '', $attr); + } + + /** + * Checks if a file is of a certain type + * + * @param string $value An extension or mime type + */ + public function is(string $value): bool + { + return F::is($this->root(), $value); + } + + /** + * Checks if the file is readable + */ + public function isReadable(): bool + { + return is_readable($this->root()) === true; + } + + /** + * Checks if the file is a resizable image + */ + public function isResizable(): bool + { + return false; + } + + /** + * Checks if a preview can be displayed for the file + * in the panel or in the frontend + */ + public function isViewable(): bool + { + return false; + } + + /** + * Checks if the file is writable + */ + public function isWritable(): bool + { + return F::isWritable($this->root()); + } + + /** + * Returns the app instance if it exists + */ + public function kirby(): App|null + { + return App::instance(null, true); + } + + /** + * Runs a set of validations on the file object + * (mainly for images). + * + * @throws \Kirby\Exception\Exception + */ + public function match(array $rules): bool + { + $rules = array_change_key_case($rules); + + if (is_array($rules['mime'] ?? null) === true) { + $mime = $this->mime(); + + // determine if any pattern matches the MIME type; + // once any pattern matches, `$carry` is `true` and the rest is skipped + $matches = array_reduce( + $rules['mime'], + fn ($carry, $pattern) => $carry || Mime::matches($mime, $pattern), + false + ); + + if ($matches !== true) { + throw new Exception([ + 'key' => 'file.mime.invalid', + 'data' => compact('mime') + ]); + } + } + + if (is_array($rules['extension'] ?? null) === true) { + $extension = $this->extension(); + if (in_array($extension, $rules['extension']) !== true) { + throw new Exception([ + 'key' => 'file.extension.invalid', + 'data' => compact('extension') + ]); + } + } + + if (is_array($rules['type'] ?? null) === true) { + $type = $this->type(); + if (in_array($type, $rules['type']) !== true) { + throw new Exception([ + 'key' => 'file.type.invalid', + 'data' => compact('type') + ]); + } + } + + foreach (static::$validations as $key => $arguments) { + $rule = $rules[$key] ?? null; + + if ($rule !== null) { + $property = $arguments[0]; + $validator = $arguments[1]; + + if (V::$validator($this->$property(), $rule) === false) { + throw new Exception([ + 'key' => 'file.' . $key, + 'data' => [$property => $rule] + ]); + } + } + } + + return true; + } + + /** + * Detects the mime type of the file + */ + public function mime(): string|null + { + return Mime::type($this->root()); + } + + /** + * Returns the parent file model, which uses this instance as proxied file asset + */ + public function model(): object|null + { + return $this->model; + } + + /** + * Returns the file's last modification time + * + * @param 'date'|'intl'|'strftime'|null $handler Custom date handler or `null` + * for the globally configured one + */ + public function modified( + string|IntlDateFormatter|null $format = null, + string|null $handler = null + ): string|int|false { + return F::modified($this->root(), $format, $handler); + } + + /** + * Move the file to a new location + * + * @param bool $overwrite Force overwriting any existing files + */ + public function move(string $newRoot, bool $overwrite = false): static + { + if (F::move($this->root(), $newRoot, $overwrite) !== true) { + throw new Exception('The file: "' . $this->root() . '" could not be moved to: "' . $newRoot . '"'); + } + + return new static($newRoot); + } + + /** + * Getter for the name of the file + * without the extension + */ + public function name(): string + { + return pathinfo($this->root(), PATHINFO_FILENAME); + } + + /** + * Returns the file size in a + * human-readable format + * + * @param string|false|null $locale Locale for number formatting, + * `null` for the current locale, + * `false` to disable number formatting + */ + public function niceSize(string|false|null $locale = null): string + { + return F::niceSize($this->root(), $locale); + } + + /** + * Reads the file content and returns it. + */ + public function read(): string|false + { + return F::read($this->root()); + } + + /** + * Returns the absolute path to the file + */ + public function realpath(): string + { + return realpath($this->root()); + } + + /** + * Changes the name of the file without + * touching the extension + * + * @param bool $overwrite Force overwrite existing files + */ + public function rename(string $newName, bool $overwrite = false): static + { + $newRoot = F::rename($this->root(), $newName, $overwrite); + + if ($newRoot === false) { + throw new Exception('The file: "' . $this->root() . '" could not be renamed to: "' . $newName . '"'); + } + + return new static($newRoot); + } + + /** + * Returns the given file path + */ + public function root(): string|null + { + return $this->root ??= $this->model?->root(); + } + + /** + * Returns the absolute url for the file + */ + public function url(): string|null + { + // lazily determine the URL from the model object + // only if it's needed to avoid breaking custom file::url + // components that rely on `$cmsFile->asset()` methods + return $this->url ??= $this->model?->url(); + } + + /** + * Sanitizes the file contents depending on the file type + * by overwriting the file with the sanitized version + * @since 3.6.0 + * + * @param string|bool $typeLazy Explicit sane handler type string, + * `true` for lazy autodetection or + * `false` for normal autodetection + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\LogicException If more than one handler applies + * @throws \Kirby\Exception\NotFoundException If the handler was not found + * @throws \Kirby\Exception\Exception On other errors + */ + public function sanitizeContents(string|bool $typeLazy = false): void + { + Sane::sanitizeFile($this->root(), $typeLazy); + } + + /** + * Returns the sha1 hash of the file + * @since 3.6.0 + */ + public function sha1(): string + { + return sha1_file($this->root()); + } + + /** + * Returns the raw size of the file + */ + public function size(): int + { + return F::size($this->root()); + } + + /** + * Converts the media object to a + * plain PHP array + */ + public function toArray(): array + { + return [ + 'extension' => $this->extension(), + 'filename' => $this->filename(), + 'hash' => $this->hash(), + 'isReadable' => $this->isReadable(), + 'isResizable' => $this->isResizable(), + 'isWritable' => $this->isWritable(), + 'mime' => $this->mime(), + 'modified' => $this->modified('c'), + 'name' => $this->name(), + 'niceSize' => $this->niceSize(), + 'root' => $this->root(), + 'safeName' => F::safeName($this->name()), + 'size' => $this->size(), + 'type' => $this->type(), + 'url' => $this->url() + ]; + } + + /** + * Converts the entire file array into + * a json string + */ + public function toJson(): string + { + return json_encode($this->toArray()); + } + + /** + * Returns the file type. + */ + public function type(): string|null + { + return F::type($this->root()); + } + + /** + * Validates the file contents depending on the file type + * + * @param string|bool $typeLazy Explicit sane handler type string, + * `true` for lazy autodetection or + * `false` for normal autodetection + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\NotFoundException If the handler was not found + * @throws \Kirby\Exception\Exception On other errors + */ + public function validateContents(string|bool $typeLazy = false): void + { + Sane::validateFile($this->root(), $typeLazy); + } + + /** + * Writes content to the file + */ + public function write(string $content): bool + { + if (F::write($this->root(), $content) !== true) { + throw new Exception('The file "' . $this->root() . '" could not be written'); + } + + return true; + } +} diff --git a/kirby/src/Filesystem/Filename.php b/kirby/src/Filesystem/Filename.php new file mode 100644 index 0000000..80f9b8c --- /dev/null +++ b/kirby/src/Filesystem/Filename.php @@ -0,0 +1,258 @@ + 'top left', + * 'width' => 300, + * 'height' => 200 + * 'quality' => 80 + * ]); + * + * echo $filename->toString(); + * // result: some-file-300x200-crop-top-left-q80.jpg + * + * @package Kirby Filesystem + * @author Bastian Allgeier + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Filename +{ + /** + * List of all applicable attributes + */ + protected array $attributes; + + /** + * The sanitized file extension + */ + protected string $extension; + + /** + * The source original filename + */ + protected string $filename; + + /** + * The sanitized file name + */ + protected string $name; + + /** + * The template for the final name + */ + protected string $template; + + /** + * Creates a new Filename object + */ + public function __construct(string $filename, string $template, array $attributes = []) + { + $this->filename = $filename; + $this->template = $template; + $this->attributes = $attributes; + $this->extension = $this->sanitizeExtension( + $attributes['format'] ?? + pathinfo($filename, PATHINFO_EXTENSION) + ); + $this->name = $this->sanitizeName($filename); + } + + /** + * Converts the entire object to a string + */ + public function __toString(): string + { + return $this->toString(); + } + + /** + * Converts all processed attributes + * to an array. The array keys are already + * the shortened versions for the filename + */ + public function attributesToArray(): array + { + $array = [ + 'dimensions' => implode('x', $this->dimensions()), + 'crop' => $this->crop(), + 'blur' => $this->blur(), + 'bw' => $this->grayscale(), + 'q' => $this->quality(), + ]; + + $array = array_filter( + $array, + fn ($item) => $item !== null && $item !== false && $item !== '' + ); + + return $array; + } + + /** + * Converts all processed attributes + * to a string, that can be used in the + * new filename + * + * @param string|null $prefix The prefix will be used in the filename creation + */ + public function attributesToString(string|null $prefix = null): string + { + $array = $this->attributesToArray(); + $result = []; + + foreach ($array as $key => $value) { + if ($value === true) { + $value = ''; + } + + $result[] = match ($key) { + 'dimensions' => $value, + 'crop' => ($value === 'center') ? 'crop' : $key . '-' . $value, + default => $key . $value + }; + } + + $result = array_filter($result); + $attributes = implode('-', $result); + + if (empty($attributes) === true) { + return ''; + } + + return $prefix . $attributes; + } + + /** + * Normalizes the blur option value + */ + public function blur(): int|false + { + $value = $this->attributes['blur'] ?? false; + + if ($value === false) { + return false; + } + + return (int)$value; + } + + /** + * Normalizes the crop option value + */ + public function crop(): string|false + { + // get the crop value + $crop = $this->attributes['crop'] ?? false; + + if ($crop === false) { + return false; + } + + return Str::slug($crop); + } + + /** + * Returns a normalized array + * with width and height values + * if available + */ + public function dimensions(): array + { + if (empty($this->attributes['width']) === true && empty($this->attributes['height']) === true) { + return []; + } + + return [ + 'width' => $this->attributes['width'] ?? null, + 'height' => $this->attributes['height'] ?? null + ]; + } + + /** + * Returns the sanitized extension + */ + public function extension(): string + { + return $this->extension; + } + + /** + * Normalizes the grayscale option value + * and also the available ways to write + * the option. You can use `grayscale`, + * `greyscale` or simply `bw`. The function + * will always return `grayscale` + */ + public function grayscale(): bool + { + // normalize options + $value = $this->attributes['grayscale'] ?? $this->attributes['greyscale'] ?? $this->attributes['bw'] ?? false; + + // turn anything into boolean + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Returns the filename without extension + */ + public function name(): string + { + return $this->name; + } + + /** + * Normalizes the quality option value + */ + public function quality(): int|false + { + $value = $this->attributes['quality'] ?? false; + + if ($value === false || $value === true) { + return false; + } + + return (int)$value; + } + + /** + * Sanitizes the file extension. + * It also replaces `jpeg` with `jpg`. + */ + protected function sanitizeExtension(string $extension): string + { + $extension = F::safeExtension('test.' . $extension); + $extension = str_replace('jpeg', 'jpg', $extension); + return $extension; + } + + /** + * Sanitizes the file name + */ + protected function sanitizeName(string $name): string + { + return F::safeBasename($name); + } + + /** + * Returns the converted filename as string + */ + public function toString(): string + { + return Str::template($this->template, [ + 'name' => $this->name(), + 'attributes' => $this->attributesToString('-'), + 'extension' => $this->extension() + ], ['fallback' => '']); + } +} diff --git a/kirby/src/Filesystem/IsFile.php b/kirby/src/Filesystem/IsFile.php new file mode 100644 index 0000000..e5b1ab1 --- /dev/null +++ b/kirby/src/Filesystem/IsFile.php @@ -0,0 +1,155 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +trait IsFile +{ + /** + * File asset object + */ + protected File|null $asset = null; + + /** + * Absolute file path + */ + protected string|null $root; + + /** + * Absolute file URL + */ + protected string|null $url; + + /** + * Constructor sets all file properties + */ + public function __construct(array $props) + { + $this->root = $props['root'] ?? null; + $this->url = $props['url'] ?? null; + } + + /** + * Magic caller for asset methods + * + * @throws \Kirby\Exception\BadMethodCallException + */ + public function __call(string $method, array $arguments = []): mixed + { + // public property access + if (isset($this->$method) === true) { + return $this->$method; + } + + // asset method proxy + if (method_exists($this->asset(), $method)) { + return $this->asset()->$method(...$arguments); + } + + throw new BadMethodCallException('The method: "' . $method . '" does not exist'); + } + + /** + * Converts the asset to a string + */ + public function __toString(): string + { + return (string)$this->asset(); + } + + /** + * Returns the file asset object + */ + public function asset(array|string|null $props = null): File + { + if ($this->asset !== null) { + return $this->asset; + } + + $props ??= []; + + if (is_string($props) === true) { + $props = ['root' => $props]; + } + + $props['model'] ??= $this; + + return $this->asset = match ($this->type()) { + 'image' => new Image($props), + default => new File($props) + }; + } + + /** + * Checks if the file exists on disk + */ + public function exists(): bool + { + // Important to include this in the trait + // to avoid infinite loops when trying + // to proxy the method from the asset object + return file_exists($this->root()) === true; + } + + /** + * To check the existence of the IsFile trait + * + * @todo Switch to class constant in traits when min PHP version 8.2 required + * @codeCoverageIgnore + */ + protected function hasIsFileTrait(): bool + { + return true; + } + + /** + * Returns the app instance + */ + public function kirby(): App + { + return App::instance(); + } + + /** + * Returns the given file path + */ + public function root(): string|null + { + return $this->root; + } + + /** + * Returns the file type + */ + public function type(): string|null + { + // Important to include this in the trait + // to avoid infinite loops when trying + // to proxy the method from the asset object + return F::type($this->root() ?? $this->url()); + } + + /** + * Returns the absolute url for the file + */ + public function url(): string|null + { + return $this->url; + } +} diff --git a/kirby/src/Filesystem/Mime.php b/kirby/src/Filesystem/Mime.php new file mode 100644 index 0000000..61bf3dc --- /dev/null +++ b/kirby/src/Filesystem/Mime.php @@ -0,0 +1,323 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Mime +{ + /** + * Extension to MIME type map + * + * @var array + */ + public static $types = [ + 'ai' => 'application/postscript', + 'aif' => 'audio/x-aiff', + 'aifc' => 'audio/x-aiff', + 'aiff' => 'audio/x-aiff', + 'avi' => 'video/x-msvideo', + 'avif' => 'image/avif', + 'bmp' => 'image/bmp', + 'css' => 'text/css', + 'csv' => ['text/csv', 'text/x-comma-separated-values', 'text/comma-separated-values', 'application/octet-stream'], + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template', + 'dvi' => 'application/x-dvi', + 'eml' => 'message/rfc822', + 'eps' => 'application/postscript', + 'exe' => ['application/octet-stream', 'application/x-msdownload'], + 'gif' => 'image/gif', + 'gtar' => 'application/x-gtar', + 'gz' => 'application/x-gzip', + 'htm' => 'text/html', + 'html' => 'text/html', + 'ico' => 'image/x-icon', + 'ics' => 'text/calendar', + 'js' => ['application/javascript', 'application/x-javascript'], + 'json' => ['application/json', 'text/json'], + 'j2k' => ['image/jp2'], + 'jp2' => ['image/jp2'], + 'jpg' => ['image/jpeg', 'image/pjpeg'], + 'jpeg' => ['image/jpeg', 'image/pjpeg'], + 'jpe' => ['image/jpeg', 'image/pjpeg'], + 'log' => ['text/plain', 'text/x-log'], + 'm4a' => 'audio/mp4', + 'm4v' => 'video/mp4', + 'mid' => 'audio/midi', + 'midi' => 'audio/midi', + 'mif' => 'application/vnd.mif', + 'mjs' => 'text/javascript', + 'mov' => 'video/quicktime', + 'movie' => 'video/x-sgi-movie', + 'mp2' => 'audio/mpeg', + 'mp3' => ['audio/mpeg', 'audio/mpg', 'audio/mpeg3', 'audio/mp3'], + 'mp4' => 'video/mp4', + 'mpe' => 'video/mpeg', + 'mpeg' => 'video/mpeg', + 'mpg' => 'video/mpeg', + 'mpga' => 'audio/mpeg', + 'odc' => 'application/vnd.oasis.opendocument.chart', + 'odp' => 'application/vnd.oasis.opendocument.presentation', + 'odt' => 'application/vnd.oasis.opendocument.text', + 'pdf' => ['application/pdf', 'application/x-download'], + 'php' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'], + 'php3' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'], + 'phps' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'], + 'pht' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'], + 'phtml' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'], + 'png' => 'image/png', + 'ppt' => ['application/powerpoint', 'application/vnd.ms-powerpoint'], + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template', + 'ps' => 'application/postscript', + 'psd' => 'application/x-photoshop', + 'qt' => 'video/quicktime', + 'rss' => 'application/rss+xml', + 'rtf' => 'text/rtf', + 'rtx' => 'text/richtext', + 'shtml' => 'text/html', + 'svg' => 'image/svg+xml', + 'swf' => 'application/x-shockwave-flash', + 'tar' => 'application/x-tar', + 'text' => 'text/plain', + 'txt' => 'text/plain', + 'tgz' => ['application/x-tar', 'application/x-gzip-compressed'], + 'tif' => 'image/tiff', + 'tiff' => 'image/tiff', + 'wav' => 'audio/x-wav', + 'wbxml' => 'application/wbxml', + 'webm' => 'video/webm', + 'webp' => 'image/webp', + 'word' => ['application/msword', 'application/octet-stream'], + 'xhtml' => 'application/xhtml+xml', + 'xht' => 'application/xhtml+xml', + 'xml' => 'text/xml', + 'xl' => 'application/excel', + 'xls' => ['application/excel', 'application/vnd.ms-excel', 'application/msexcel'], + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template', + 'xsl' => 'text/xml', + 'yaml' => ['application/yaml', 'text/yaml'], + 'yml' => ['application/yaml', 'text/yaml'], + 'zip' => ['application/x-zip', 'application/zip', 'application/x-zip-compressed'], + ]; + + /** + * Fixes an invalid MIME type guess for the given file + */ + public static function fix( + string $file, + string|null $mime = null, + string|null $extension = null + ): string|null { + // fixing map + $map = [ + 'text/html' => [ + 'svg' => [Mime::class, 'fromSvg'], + ], + 'text/plain' => [ + 'css' => 'text/css', + 'json' => 'application/json', + 'mjs' => 'text/javascript', + 'svg' => [Mime::class, 'fromSvg'], + ], + 'text/x-asm' => [ + 'css' => 'text/css' + ], + 'text/x-java' => [ + 'mjs' => 'text/javascript', + ], + 'image/svg' => [ + 'svg' => 'image/svg+xml' + ], + 'application/octet-stream' => [ + 'mjs' => 'text/javascript' + ] + ]; + + if ($mode = ($map[$mime][$extension] ?? null)) { + if (is_callable($mode) === true) { + return $mode($file, $mime, $extension); + } + + if (is_string($mode) === true) { + return $mode; + } + } + + return $mime; + } + + /** + * Guesses a MIME type by extension + */ + public static function fromExtension(string $extension): string|null + { + $mime = static::$types[$extension] ?? null; + return is_array($mime) === true ? array_shift($mime) : $mime; + } + + /** + * Returns the MIME type of a file + */ + public static function fromFileInfo(string $file): string|false + { + if (function_exists('finfo_file') === true && file_exists($file) === true) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file); + finfo_close($finfo); + return $mime; + } + + return false; + } + + /** + * Returns the MIME type of a file + */ + public static function fromMimeContentType(string $file): string|false + { + if ( + function_exists('mime_content_type') === true && + file_exists($file) === true + ) { + return mime_content_type($file); + } + + return false; + } + + /** + * Tries to detect a valid SVG and returns the MIME type accordingly + */ + public static function fromSvg(string $file): string|false + { + if (file_exists($file) === true) { + libxml_use_internal_errors(true); + + $svg = new SimpleXMLElement(file_get_contents($file)); + + if ($svg !== false && $svg->getName() === 'svg') { + return 'image/svg+xml'; + } + } + + return false; + } + + /** + * Tests if a given MIME type is matched by an `Accept` header + * pattern; returns true if the MIME type is contained at all + */ + public static function isAccepted(string $mime, string $pattern): bool + { + $accepted = Str::accepted($pattern); + + foreach ($accepted as $m) { + if (static::matches($mime, $m['value']) === true) { + return true; + } + } + + return false; + } + + /** + * Tests if a MIME wildcard pattern from an `Accept` header + * matches a given type + * @since 3.3.0 + */ + public static function matches(string $test, string $wildcard): bool + { + return fnmatch($wildcard, $test, FNM_PATHNAME) === true; + } + + /** + * Returns the extension for a given MIME type + */ + public static function toExtension(string $mime = null): string|false + { + foreach (static::$types as $key => $value) { + if (is_array($value) === true && in_array($mime, $value) === true) { + return $key; + } + + if ($value === $mime) { + return $key; + } + } + + return false; + } + + /** + * Returns all available extensions for a given MIME type + */ + public static function toExtensions(string $mime = null): array + { + $extensions = []; + + foreach (static::$types as $key => $value) { + if (is_array($value) === true && in_array($mime, $value) === true) { + $extensions[] = $key; + continue; + } + + if ($value === $mime) { + $extensions[] = $key; + } + } + + return $extensions; + } + + /** + * Returns the MIME type of a file + */ + public static function type( + string $file, + string|null $extension = null + ): string|null { + // use the standard finfo extension + $mime = static::fromFileInfo($file); + + // use the mime_content_type function + if ($mime === false) { + $mime = static::fromMimeContentType($file); + } + + // get the extension or extract it from the filename + $extension ??= F::extension($file); + + // try to guess the mime type at least + if ($mime === false) { + $mime = static::fromExtension($extension); + } + + // fix broken mime detection + return static::fix($file, $mime, $extension); + } + + /** + * Returns all detectable MIME types + */ + public static function types(): array + { + return static::$types; + } +} diff --git a/kirby/src/Form/Field.php b/kirby/src/Form/Field.php new file mode 100644 index 0000000..cb95fd4 --- /dev/null +++ b/kirby/src/Form/Field.php @@ -0,0 +1,510 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Field extends Component +{ + /** + * An array of all found errors + */ + protected array|null $errors = null; + + /** + * Parent collection with all fields of the current form + */ + protected Fields|null $formFields; + + /** + * Registry for all component mixins + */ + public static array $mixins = []; + + /** + * Registry for all component types + */ + public static array $types = []; + + /** + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function __construct( + string $type, + array $attrs = [], + Fields|null $formFields = null + ) { + if (isset(static::$types[$type]) === false) { + throw new InvalidArgumentException([ + 'key' => 'field.type.missing', + 'data' => ['name' => $attrs['name'] ?? '-', 'type' => $type] + ]); + } + + if (isset($attrs['model']) === false) { + throw new InvalidArgumentException('Field requires a model'); + } + + $this->formFields = $formFields; + + // use the type as fallback for the name + $attrs['name'] ??= $type; + $attrs['type'] = $type; + + parent::__construct($type, $attrs); + } + + /** + * Returns field api call + */ + public function api(): mixed + { + if ( + isset($this->options['api']) === true && + $this->options['api'] instanceof Closure + ) { + return $this->options['api']->call($this); + } + + return null; + } + + /** + * Returns field data + */ + public function data(bool $default = false): mixed + { + $save = $this->options['save'] ?? true; + + if ($default === true && $this->isEmpty($this->value)) { + $value = $this->default(); + } else { + $value = $this->value; + } + + if ($save === false) { + return null; + } + + if ($save instanceof Closure) { + return $save->call($this, $value); + } + + return $value; + } + + /** + * Default props and computed of the field + */ + public static function defaults(): array + { + return [ + 'props' => [ + /** + * Optional text that will be shown after the input + */ + 'after' => function ($after = null) { + return I18n::translate($after, $after); + }, + /** + * Sets the focus on this field when the form loads. Only the first field with this label gets + */ + 'autofocus' => function (bool $autofocus = null): bool { + return $autofocus ?? false; + }, + /** + * Optional text that will be shown before the input + */ + 'before' => function ($before = null) { + return I18n::translate($before, $before); + }, + /** + * Default value for the field, which will be used when a page/file/user is created + */ + 'default' => function ($default = null) { + return $default; + }, + /** + * If `true`, the field is no longer editable and will not be saved + */ + 'disabled' => function (bool $disabled = null): bool { + return $disabled ?? false; + }, + /** + * Optional help text below the field + */ + 'help' => function ($help = null) { + return I18n::translate($help, $help); + }, + /** + * Optional icon that will be shown at the end of the field + */ + 'icon' => function (string $icon = null) { + return $icon; + }, + /** + * The field label can be set as string or associative array with translations + */ + 'label' => function ($label = null) { + return I18n::translate($label, $label); + }, + /** + * Optional placeholder value that will be shown when the field is empty + */ + 'placeholder' => function ($placeholder = null) { + return I18n::translate($placeholder, $placeholder); + }, + /** + * If `true`, the field has to be filled in correctly to be saved. + */ + 'required' => function (bool $required = null): bool { + return $required ?? false; + }, + /** + * If `false`, the field will be disabled in non-default languages and cannot be translated. This is only relevant in multi-language setups. + */ + 'translate' => function (bool $translate = true): bool { + return $translate; + }, + /** + * Conditions when the field will be shown (since 3.1.0) + */ + 'when' => function ($when = null) { + return $when; + }, + /** + * The width of the field in the field grid. Available widths: `1/1`, `1/2`, `1/3`, `1/4`, `2/3`, `3/4` + */ + 'width' => function (string $width = '1/1') { + return $width; + }, + 'value' => function ($value = null) { + return $value; + } + ], + 'computed' => [ + 'after' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->after !== null) { + return $this->model()->toString($this->after); + } + }, + 'before' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->before !== null) { + return $this->model()->toString($this->before); + } + }, + 'default' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->default === null) { + return; + } + + if (is_string($this->default) === false) { + return $this->default; + } + + return $this->model()->toString($this->default); + }, + 'help' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->help) { + $help = $this->model()->toSafeString($this->help); + $help = $this->kirby()->kirbytext($help); + return $help; + } + }, + 'label' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->label !== null) { + return $this->model()->toString($this->label); + } + }, + 'placeholder' => function () { + /** @var \Kirby\Form\Field $this */ + if ($this->placeholder !== null) { + return $this->model()->toString($this->placeholder); + } + } + ] + ]; + } + + /** + * Returns optional dialog routes for the field + */ + public function dialogs(): array + { + if ( + isset($this->options['dialogs']) === true && + $this->options['dialogs'] instanceof Closure + ) { + return $this->options['dialogs']->call($this); + } + + return []; + } + + /** + * Returns optional drawer routes for the field + */ + public function drawers(): array + { + if ( + isset($this->options['drawers']) === true && + $this->options['drawers'] instanceof Closure + ) { + return $this->options['drawers']->call($this); + } + + return []; + } + + /** + * Creates a new field instance + */ + public static function factory( + string $type, + array $attrs = [], + Fields|null $formFields = null + ): static|FieldClass { + $field = static::$types[$type] ?? null; + + if (is_string($field) && class_exists($field) === true) { + $attrs['siblings'] = $formFields; + return new $field($attrs); + } + + return new static($type, $attrs, $formFields); + } + + /** + * Parent collection with all fields of the current form + */ + public function formFields(): Fields|null + { + return $this->formFields; + } + + /** + * Validates when run for the first time and returns any errors + */ + public function errors(): array + { + if ($this->errors === null) { + $this->validate(); + } + + return $this->errors; + } + + /** + * Checks if the field is empty + */ + public function isEmpty(mixed ...$args): bool + { + $value = match (count($args)) { + 0 => $this->value(), + default => $args[0] + }; + + if ($empty = $this->options['isEmpty'] ?? null) { + return $empty->call($this, $value); + } + + return in_array($value, [null, '', []], true); + } + + /** + * Checks if the field is hidden + */ + public function isHidden(): bool + { + return ($this->options['hidden'] ?? false) === true; + } + + /** + * Checks if the field is invalid + */ + public function isInvalid(): bool + { + return empty($this->errors()) === false; + } + + /** + * Checks if the field is required + */ + public function isRequired(): bool + { + return $this->required ?? false; + } + + /** + * Checks if the field is valid + */ + public function isValid(): bool + { + return empty($this->errors()) === true; + } + + /** + * Returns the Kirby instance + */ + public function kirby(): App + { + return $this->model()->kirby(); + } + + /** + * Returns the parent model + */ + public function model(): mixed + { + return $this->model; + } + + /** + * Checks if the field needs a value before being saved; + * this is the case if all of the following requirements are met: + * - The field is saveable + * - The field is required + * - The field is currently empty + * - The field is not currently inactive because of a `when` rule + */ + protected function needsValue(): bool + { + // check simple conditions first + if ( + $this->save() === false || + $this->isRequired() === false || + $this->isEmpty() === false + ) { + return false; + } + + // check the data of the relevant fields if there is a `when` option + if ( + empty($this->when) === false && + is_array($this->when) === true && + $formFields = $this->formFields() + ) { + foreach ($this->when as $field => $value) { + $field = $formFields->get($field); + $inputValue = $field?->value() ?? ''; + + // if the input data doesn't match the requested `when` value, + // that means that this field is not required and can be saved + // (*all* `when` conditions must be met for this field to be required) + if ($inputValue !== $value) { + return false; + } + } + } + + // either there was no `when` condition or all conditions matched + return true; + } + + /** + * Checks if the field is saveable + */ + public function save(): bool + { + return ($this->options['save'] ?? true) !== false; + } + + /** + * Converts the field to a plain array + */ + public function toArray(): array + { + $array = parent::toArray(); + + unset($array['model']); + + $array['hidden'] = $this->isHidden(); + $array['saveable'] = $this->save(); + $array['signature'] = md5(json_encode($array)); + + ksort($array); + + return array_filter( + $array, + fn ($item) => $item !== null && is_object($item) === false + ); + } + + /** + * Runs the validations defined for the field + */ + protected function validate(): void + { + $validations = $this->options['validations'] ?? []; + $this->errors = []; + + // validate required values + if ($this->needsValue() === true) { + $this->errors['required'] = I18n::translate('error.validation.required'); + } + + foreach ($validations as $key => $validation) { + if (is_int($key) === true) { + // predefined validation + try { + Validations::$validation($this, $this->value()); + } catch (Exception $e) { + $this->errors[$validation] = $e->getMessage(); + } + continue; + } + + if ($validation instanceof Closure) { + try { + $validation->call($this, $this->value()); + } catch (Exception $e) { + $this->errors[$key] = $e->getMessage(); + } + } + } + + if ( + empty($this->validate) === false && + ($this->isEmpty() === false || $this->isRequired() === true) + ) { + $rules = A::wrap($this->validate); + $errors = V::errors($this->value(), $rules); + + if (empty($errors) === false) { + $this->errors = array_merge($this->errors, $errors); + } + } + } + + /** + * Returns the value of the field if saveable + * otherwise it returns null + */ + public function value(): mixed + { + return $this->save() ? $this->value : null; + } +} diff --git a/kirby/src/Form/Field/BlocksField.php b/kirby/src/Form/Field/BlocksField.php new file mode 100644 index 0000000..e37d373 --- /dev/null +++ b/kirby/src/Form/Field/BlocksField.php @@ -0,0 +1,352 @@ +setFieldsets( + $params['fieldsets'] ?? null, + $params['model'] ?? App::instance()->site() + ); + + parent::__construct($params); + + $this->setEmpty($params['empty'] ?? null); + $this->setGroup($params['group'] ?? 'blocks'); + $this->setMax($params['max'] ?? null); + $this->setMin($params['min'] ?? null); + $this->setPretty($params['pretty'] ?? false); + } + + public function blocksToValues( + array $blocks, + string $to = 'values' + ): array { + $result = []; + $fields = []; + + foreach ($blocks as $block) { + try { + $type = $block['type']; + + // get and cache fields at the same time + $fields[$type] ??= $this->fields($block['type']); + + // overwrite the block content with form values + $block['content'] = $this->form( + $fields[$type], + $block['content'] + )->$to(); + + // create id if not exists + $block['id'] ??= Str::uuid(); + } catch (Throwable) { + // skip invalid blocks + } finally { + $result[] = $block; + } + } + + return $result; + } + + public function fields(string $type): array + { + return $this->fieldset($type)->fields(); + } + + public function fieldset(string $type): Fieldset + { + if ($fieldset = $this->fieldsets->find($type)) { + return $fieldset; + } + + throw new NotFoundException( + 'The fieldset ' . $type . ' could not be found' + ); + } + + public function fieldsets(): Fieldsets + { + return $this->fieldsets; + } + + public function fieldsetGroups(): array|null + { + $groups = $this->fieldsets()->groups(); + return empty($groups) === true ? null : $groups; + } + + public function fill(mixed $value = null): void + { + $value = BlocksCollection::parse($value); + $blocks = BlocksCollection::factory($value)->toArray(); + $this->value = $this->blocksToValues($blocks); + } + + public function form(array $fields, array $input = []): Form + { + return new Form([ + 'fields' => $fields, + 'model' => $this->model, + 'strict' => true, + 'values' => $input, + ]); + } + + public function isEmpty(): bool + { + return count($this->value()) === 0; + } + + public function group(): string + { + return $this->group; + } + + public function pretty(): bool + { + return $this->pretty; + } + + /** + * Paste action for blocks: + * - generates new uuids for the blocks + * - filters only supported fieldsets + * - applies max limit if defined + */ + public function pasteBlocks(array $blocks): array + { + $blocks = $this->blocksToValues($blocks); + + foreach ($blocks as $index => &$block) { + $block['id'] = Str::uuid(); + + // remove the block if it's not available + try { + $this->fieldset($block['type']); + } catch (Throwable) { + unset($blocks[$index]); + } + } + + return array_values($blocks); + } + + public function props(): array + { + return [ + 'empty' => $this->empty(), + 'fieldsets' => $this->fieldsets()->toArray(), + 'fieldsetGroups' => $this->fieldsetGroups(), + 'group' => $this->group(), + 'max' => $this->max(), + 'min' => $this->min(), + ] + parent::props(); + } + + public function routes(): array + { + $field = $this; + + return [ + [ + 'pattern' => 'uuid', + 'action' => fn (): array => ['uuid' => Str::uuid()] + ], + [ + 'pattern' => 'paste', + 'method' => 'POST', + 'action' => function () use ($field): array { + $request = App::instance()->request(); + $value = BlocksCollection::parse($request->get('html')); + $blocks = BlocksCollection::factory($value); + + return $field->pasteBlocks($blocks->toArray()); + } + ], + [ + 'pattern' => 'fieldsets/(:any)', + 'method' => 'GET', + 'action' => function ( + string $fieldsetType + ) use ($field): array { + $fields = $field->fields($fieldsetType); + $defaults = $field->form($fields, [])->data(true); + $content = $field->form($fields, $defaults)->values(); + + return Block::factory([ + 'content' => $content, + 'type' => $fieldsetType + ])->toArray(); + } + ], + [ + 'pattern' => 'fieldsets/(:any)/fields/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function ( + string $fieldsetType, + string $fieldName, + string $path = null + ) use ($field) { + $fields = $field->fields($fieldsetType); + $field = $field->form($fields)->field($fieldName); + + $fieldApi = $this->clone([ + 'routes' => $field->api(), + 'data' => array_merge( + $this->data(), + ['field' => $field] + ) + ]); + + return $fieldApi->call( + $path, + $this->requestMethod(), + $this->requestData() + ); + } + ], + ]; + } + + public function store(mixed $value): mixed + { + $blocks = $this->blocksToValues((array)$value, 'content'); + + // returns empty string to avoid storing empty array as string `[]` + // and to consistency work with `$field->isEmpty()` + if (empty($blocks) === true) { + return ''; + } + + return $this->valueToJson($blocks, $this->pretty()); + } + + protected function setDefault(mixed $default = null): void + { + // set id for blocks if not exists + if (is_array($default) === true) { + array_walk($default, function (&$block) { + $block['id'] ??= Str::uuid(); + }); + } + + parent::setDefault($default); + } + + protected function setFieldsets( + string|array|null $fieldsets, + ModelWithContent $model + ): void { + if (is_string($fieldsets) === true) { + $fieldsets = []; + } + + $this->fieldsets = Fieldsets::factory( + $fieldsets, + ['parent' => $model] + ); + } + + protected function setGroup(string $group = null): void + { + $this->group = $group; + } + + protected function setPretty(bool $pretty = false): void + { + $this->pretty = $pretty; + } + + public function validations(): array + { + return [ + 'blocks' => function ($value) { + if ($this->min && count($value) < $this->min) { + throw new InvalidArgumentException([ + 'key' => 'blocks.min.' . ($this->min === 1 ? 'singular' : 'plural'), + 'data' => [ + 'min' => $this->min + ] + ]); + } + + if ($this->max && count($value) > $this->max) { + throw new InvalidArgumentException([ + 'key' => 'blocks.max.' . ($this->max === 1 ? 'singular' : 'plural'), + 'data' => [ + 'max' => $this->max + ] + ]); + } + + $fields = []; + $index = 0; + + foreach ($value as $block) { + $index++; + $type = $block['type']; + + try { + $fieldset = $this->fieldset($type); + $blockFields = $fields[$type] ?? $fieldset->fields() ?? []; + } catch (Throwable) { + // skip invalid blocks + continue; + } + + // store the fields for the next round + $fields[$type] = $blockFields; + + // overwrite the content with the serialized form + $form = $this->form($blockFields, $block['content']); + foreach ($form->fields() as $field) { + $errors = $field->errors(); + + // rough first validation + if (empty($errors) === false) { + throw new InvalidArgumentException([ + 'key' => 'blocks.validation', + 'data' => [ + 'field' => $field->label(), + 'fieldset' => $fieldset->name(), + 'index' => $index + ] + ]); + } + } + } + + return true; + } + ]; + } +} diff --git a/kirby/src/Form/Field/LayoutField.php b/kirby/src/Form/Field/LayoutField.php new file mode 100644 index 0000000..2e595c0 --- /dev/null +++ b/kirby/src/Form/Field/LayoutField.php @@ -0,0 +1,365 @@ +setModel($params['model'] ?? App::instance()->site()); + $this->setLayouts($params['layouts'] ?? ['1/1']); + $this->setSelector($params['selector'] ?? null); + $this->setSettings($params['settings'] ?? null); + + parent::__construct($params); + } + + public function fill(mixed $value = null): void + { + $value = $this->valueFromJson($value); + $layouts = Layouts::factory($value, ['parent' => $this->model])->toArray(); + + foreach ($layouts as $layoutIndex => $layout) { + if ($this->settings !== null) { + $layouts[$layoutIndex]['attrs'] = $this->attrsForm($layout['attrs'])->values(); + } + + foreach ($layout['columns'] as $columnIndex => $column) { + $layouts[$layoutIndex]['columns'][$columnIndex]['blocks'] = $this->blocksToValues($column['blocks']); + } + } + + $this->value = $layouts; + } + + public function attrsForm(array $input = []): Form + { + $settings = $this->settings(); + + return new Form([ + 'fields' => $settings?->fields() ?? [], + 'model' => $this->model, + 'strict' => true, + 'values' => $input, + ]); + } + + public function layouts(): array|null + { + return $this->layouts; + } + + /** + * Creates form values for each layout + */ + public function layoutsToValues(array $layouts): array + { + foreach ($layouts as &$layout) { + $layout['id'] ??= Str::uuid(); + $layout['columns'] ??= []; + + array_walk($layout['columns'], function (&$column) { + $column['id'] ??= Str::uuid(); + $column['blocks'] = $this->blocksToValues($column['blocks'] ?? []); + }); + } + + return $layouts; + } + + /** + * Paste action for layouts: + * - generates new uuids for layout, column and blocks + * - filters only supported layouts + * - filters only supported fieldsets + */ + public function pasteLayouts(array $layouts): array + { + $layouts = $this->layoutsToValues($layouts); + + foreach ($layouts as $layoutIndex => &$layout) { + $layout['id'] = Str::uuid(); + + // remove the row if layout not available for the pasted layout field + $columns = array_column($layout['columns'], 'width'); + if (in_array($columns, $this->layouts()) === false) { + unset($layouts[$layoutIndex]); + continue; + } + + array_walk($layout['columns'], function (&$column) { + $column['id'] = Str::uuid(); + + array_walk($column['blocks'], function (&$block, $index) use ($column) { + $block['id'] = Str::uuid(); + + // remove the block if it's not available + try { + $this->fieldset($block['type']); + } catch (Throwable) { + unset($column['blocks'][$index]); + } + }); + }); + } + + return $layouts; + } + + public function props(): array + { + $settings = $this->settings(); + + return array_merge(parent::props(), [ + 'layouts' => $this->layouts(), + 'selector' => $this->selector(), + 'settings' => $settings?->toArray() + ]); + } + + public function routes(): array + { + $field = $this; + $routes = parent::routes(); + + $routes[] = [ + 'pattern' => 'layout', + 'method' => 'POST', + 'action' => function () use ($field): array { + $request = App::instance()->request(); + + $input = $request->get('attrs') ?? []; + $defaults = $field->attrsForm($input)->data(true); + $attrs = $field->attrsForm($defaults)->values(); + $columns = $request->get('columns') ?? ['1/1']; + + return Layout::factory([ + 'attrs' => $attrs, + 'columns' => array_map(fn ($width) => [ + 'blocks' => [], + 'id' => Str::uuid(), + 'width' => $width, + ], $columns) + ])->toArray(); + }, + ]; + + $routes[] = [ + 'pattern' => 'layout/paste', + 'method' => 'POST', + 'action' => function () use ($field): array { + $request = App::instance()->request(); + $value = Layouts::parse($request->get('json')); + $layouts = Layouts::factory($value); + + return $field->pasteLayouts($layouts->toArray()); + } + ]; + + $routes[] = [ + 'pattern' => 'fields/(:any)/(:all?)', + 'method' => 'ALL', + 'action' => function ( + string $fieldName, + string $path = null + ) use ($field): array { + $form = $field->attrsForm(); + $field = $form->field($fieldName); + + $fieldApi = $this->clone([ + 'routes' => $field->api(), + 'data' => array_merge( + $this->data(), + ['field' => $field] + ) + ]); + + return $fieldApi->call( + $path, + $this->requestMethod(), + $this->requestData() + ); + } + ]; + + return $routes; + } + + public function selector(): array|null + { + return $this->selector; + } + + protected function setDefault(mixed $default = null): void + { + // set id for layouts, columns and blocks within layout if not exists + if (is_array($default) === true) { + array_walk($default, function (&$layout) { + $layout['id'] ??= Str::uuid(); + + // set columns id within layout + if (isset($layout['columns']) === true) { + array_walk($layout['columns'], function (&$column) { + $column['id'] ??= Str::uuid(); + + // set blocks id within column + if (isset($column['blocks']) === true) { + array_walk($column['blocks'], function (&$block) { + $block['id'] ??= Str::uuid(); + }); + } + }); + } + }); + } + + parent::setDefault($default); + } + + protected function setLayouts(array $layouts = []): void + { + $this->layouts = array_map( + fn ($layout) => Str::split($layout), + $layouts + ); + } + + /** + * Layout selector's styles such as size (`small`, `medium`, `large` or `huge`) and columns + */ + protected function setSelector(array|null $selector = null): void + { + $this->selector = $selector; + } + + protected function setSettings(array|string|null $settings = null): void + { + if (empty($settings) === true) { + $this->settings = null; + return; + } + + $settings = Blueprint::extend($settings); + + $settings['icon'] = 'dashboard'; + $settings['type'] = 'layout'; + $settings['parent'] = $this->model(); + + $this->settings = Fieldset::factory($settings); + } + + public function settings(): Fieldset|null + { + return $this->settings; + } + + public function store(mixed $value): mixed + { + $value = Layouts::factory($value, ['parent' => $this->model])->toArray(); + + // returns empty string to avoid storing empty array as string `[]` + // and to consistency work with `$field->isEmpty()` + if (empty($value) === true) { + return ''; + } + + foreach ($value as $layoutIndex => $layout) { + if ($this->settings !== null) { + $value[$layoutIndex]['attrs'] = $this->attrsForm($layout['attrs'])->content(); + } + + foreach ($layout['columns'] as $columnIndex => $column) { + $value[$layoutIndex]['columns'][$columnIndex]['blocks'] = $this->blocksToValues($column['blocks'] ?? [], 'content'); + } + } + + return $this->valueToJson($value, $this->pretty()); + } + + public function validations(): array + { + return [ + 'layout' => function ($value) { + $fields = []; + $layoutIndex = 0; + + foreach ($value as $layout) { + $layoutIndex++; + + // validate settings form + $form = $this->attrsForm($layout['attrs'] ?? []); + + foreach ($form->fields() as $field) { + $errors = $field->errors(); + + if (empty($errors) === false) { + throw new InvalidArgumentException([ + 'key' => 'layout.validation.settings', + 'data' => [ + 'index' => $layoutIndex + ] + ]); + } + } + + // validate blocks in the layout + $blockIndex = 0; + + foreach ($layout['columns'] ?? [] as $column) { + foreach ($column['blocks'] ?? [] as $block) { + $blockIndex++; + $blockType = $block['type']; + + try { + $fieldset = $this->fieldset($blockType); + $blockFields = $fields[$blockType] ?? $this->fields($blockType) ?? []; + } catch (Throwable) { + // skip invalid blocks + continue; + } + + // store the fields for the next round + $fields[$blockType] = $blockFields; + + // overwrite the content with the serialized form + $form = $this->form($blockFields, $block['content']); + + foreach ($form->fields() as $field) { + $errors = $field->errors(); + + // rough first validation + if (empty($errors) === false) { + throw new InvalidArgumentException([ + 'key' => 'layout.validation.block', + 'data' => [ + 'blockIndex' => $blockIndex, + 'field' => $field->label(), + 'fieldset' => $fieldset->name(), + 'layoutIndex' => $layoutIndex + ] + ]); + } + } + } + } + } + + return true; + } + ]; + } +} diff --git a/kirby/src/Form/FieldClass.php b/kirby/src/Form/FieldClass.php new file mode 100644 index 0000000..cc4115e --- /dev/null +++ b/kirby/src/Form/FieldClass.php @@ -0,0 +1,646 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class FieldClass +{ + use HasSiblings; + + protected string|null $after; + protected bool $autofocus; + protected string|null $before; + protected mixed $default; + protected bool $disabled; + protected string|null $help; + protected string|null $icon; + protected string|null $label; + protected ModelWithContent $model; + protected string|null $name; + protected string|null $placeholder; + protected bool $required; + protected Fields $siblings; + protected bool $translate; + protected mixed $value = null; + protected array|null $when; + protected string|null $width; + + public function __construct( + protected array $params = [] + ) { + $this->setAfter($params['after'] ?? null); + $this->setAutofocus($params['autofocus'] ?? false); + $this->setBefore($params['before'] ?? null); + $this->setDefault($params['default'] ?? null); + $this->setDisabled($params['disabled'] ?? false); + $this->setHelp($params['help'] ?? null); + $this->setIcon($params['icon'] ?? null); + $this->setLabel($params['label'] ?? null); + $this->setModel($params['model'] ?? App::instance()->site()); + $this->setName($params['name'] ?? null); + $this->setPlaceholder($params['placeholder'] ?? null); + $this->setRequired($params['required'] ?? false); + $this->setSiblings($params['siblings'] ?? null); + $this->setTranslate($params['translate'] ?? true); + $this->setWhen($params['when'] ?? null); + $this->setWidth($params['width'] ?? null); + + if (array_key_exists('value', $params) === true) { + $this->fill($params['value']); + } + } + + public function __call(string $param, array $args): mixed + { + if (isset($this->$param) === true) { + return $this->$param; + } + + return $this->params[$param] ?? null; + } + + public function after(): string|null + { + return $this->stringTemplate($this->after); + } + + public function api(): array + { + return $this->routes(); + } + + public function autofocus(): bool + { + return $this->autofocus; + } + + public function before(): string|null + { + return $this->stringTemplate($this->before); + } + + /** + * @deprecated 3.5.0 + * @todo remove when the general field class setup has been refactored + * + * Returns the field data + * in a format to be stored + * in Kirby's content fields + */ + public function data(bool $default = false): mixed + { + return $this->store($this->value($default)); + } + + /** + * Returns the default value for the field, + * which will be used when a page/file/user is created + */ + public function default(): mixed + { + if (is_string($this->default) === false) { + return $this->default; + } + + return $this->stringTemplate($this->default); + } + + /** + * Returns optional dialog routes for the field + */ + public function dialogs(): array + { + return []; + } + + /** + * If `true`, the field is no longer editable and will not be saved + */ + public function disabled(): bool + { + return $this->disabled; + } + + /** + * Returns optional drawer routes for the field + */ + public function drawers(): array + { + return []; + } + + /** + * Runs all validations and returns an array of + * error messages + */ + public function errors(): array + { + return $this->validate(); + } + + /** + * Setter for the value + */ + public function fill(mixed $value = null): void + { + $this->value = $value; + } + + /** + * Optional help text below the field + */ + public function help(): string|null + { + if (empty($this->help) === false) { + $help = $this->stringTemplate($this->help); + $help = $this->kirby()->kirbytext($help); + return $help; + } + + return null; + } + + protected function i18n(string|array|null $param = null): string|null + { + return empty($param) === false ? I18n::translate($param, $param) : null; + } + + /** + * Optional icon that will be shown at the end of the field + */ + public function icon(): string|null + { + return $this->icon; + } + + public function id(): string + { + return $this->name(); + } + + public function isDisabled(): bool + { + return $this->disabled; + } + + public function isEmpty(): bool + { + return $this->isEmptyValue($this->value()); + } + + public function isEmptyValue(mixed $value = null): bool + { + return in_array($value, [null, '', []], true); + } + + public function isHidden(): bool + { + return false; + } + + /** + * Checks if the field is invalid + */ + public function isInvalid(): bool + { + return $this->isValid() === false; + } + + public function isRequired(): bool + { + return $this->required; + } + + public function isSaveable(): bool + { + return true; + } + + /** + * Checks if the field is valid + */ + public function isValid(): bool + { + return empty($this->errors()) === true; + } + + /** + * Returns the Kirby instance + */ + public function kirby(): App + { + return $this->model->kirby(); + } + + /** + * The field label can be set as string or associative array with translations + */ + public function label(): string + { + return $this->stringTemplate( + $this->label ?? Str::ucfirst($this->name()) + ); + } + + /** + * Returns the parent model + */ + public function model(): ModelWithContent + { + return $this->model; + } + + /** + * Returns the field name + */ + public function name(): string + { + return $this->name ?? $this->type(); + } + + /** + * Checks if the field needs a value before being saved; + * this is the case if all of the following requirements are met: + * - The field is saveable + * - The field is required + * - The field is currently empty + * - The field is not currently inactive because of a `when` rule + */ + protected function needsValue(): bool + { + // check simple conditions first + if ( + $this->isSaveable() === false || + $this->isRequired() === false || + $this->isEmpty() === false + ) { + return false; + } + + // check the data of the relevant fields if there is a `when` option + if ( + empty($this->when) === false && + is_array($this->when) === true && + $formFields = $this->siblings() + ) { + foreach ($this->when as $field => $value) { + $field = $formFields->get($field); + $inputValue = $field?->value() ?? ''; + + // if the input data doesn't match the requested `when` value, + // that means that this field is not required and can be saved + // (*all* `when` conditions must be met for this field to be required) + if ($inputValue !== $value) { + return false; + } + } + } + + // either there was no `when` condition or all conditions matched + return true; + } + + /** + * Returns all original params for the field + */ + public function params(): array + { + return $this->params; + } + + /** + * Optional placeholder value that will be shown when the field is empty + */ + public function placeholder(): string|null + { + return $this->stringTemplate($this->placeholder); + } + + /** + * Define the props that will be sent to + * the Vue component + */ + public function props(): array + { + return [ + 'after' => $this->after(), + 'autofocus' => $this->autofocus(), + 'before' => $this->before(), + 'default' => $this->default(), + 'disabled' => $this->isDisabled(), + 'help' => $this->help(), + 'hidden' => $this->isHidden(), + 'icon' => $this->icon(), + 'label' => $this->label(), + 'name' => $this->name(), + 'placeholder' => $this->placeholder(), + 'required' => $this->isRequired(), + 'saveable' => $this->isSaveable(), + 'translate' => $this->translate(), + 'type' => $this->type(), + 'when' => $this->when(), + 'width' => $this->width(), + ]; + } + + /** + * If `true`, the field has to be filled in correctly to be saved. + */ + public function required(): bool + { + return $this->required; + } + + /** + * Routes for the field API + */ + public function routes(): array + { + return []; + } + + /** + * @deprecated 3.5.0 + * @todo remove when the general field class setup has been refactored + */ + public function save(): bool + { + return $this->isSaveable(); + } + + protected function setAfter(array|string|null $after = null): void + { + $this->after = $this->i18n($after); + } + + protected function setAutofocus(bool $autofocus = false): void + { + $this->autofocus = $autofocus; + } + + protected function setBefore(array|string|null $before = null): void + { + $this->before = $this->i18n($before); + } + + protected function setDefault(mixed $default = null): void + { + $this->default = $default; + } + + protected function setDisabled(bool $disabled = false): void + { + $this->disabled = $disabled; + } + + protected function setHelp(array|string|null $help = null): void + { + $this->help = $this->i18n($help); + } + + protected function setIcon(string|null $icon = null): void + { + $this->icon = $icon; + } + + protected function setLabel(array|string|null $label = null): void + { + $this->label = $this->i18n($label); + } + + protected function setModel(ModelWithContent $model): void + { + $this->model = $model; + } + + protected function setName(string|null $name = null): void + { + $this->name = $name; + } + + protected function setPlaceholder(array|string|null $placeholder = null): void + { + $this->placeholder = $this->i18n($placeholder); + } + + protected function setRequired(bool $required = false): void + { + $this->required = $required; + } + + protected function setSiblings(Fields|null $siblings = null): void + { + $this->siblings = $siblings ?? new Fields([$this]); + } + + protected function setTranslate(bool $translate = true): void + { + $this->translate = $translate; + } + + /** + * Setter for the when condition + */ + protected function setWhen(array|null $when = null): void + { + $this->when = $when; + } + + /** + * Setter for the field width + */ + protected function setWidth(string|null $width = null): void + { + $this->width = $width; + } + + /** + * Returns all sibling fields + */ + protected function siblingsCollection(): Fields + { + return $this->siblings; + } + + /** + * Parses a string template in the given value + */ + protected function stringTemplate(string|null $string = null): string|null + { + if ($string !== null) { + return $this->model->toString($string); + } + + return null; + } + + /** + * Converts the given value to a value + * that can be stored in the text file + */ + public function store(mixed $value): mixed + { + return $value; + } + + /** + * Should the field be translatable? + */ + public function translate(): bool + { + return $this->translate; + } + + /** + * Converts the field to a plain array + */ + public function toArray(): array + { + $props = $this->props(); + $props['signature'] = md5(json_encode($props)); + + ksort($props); + + return array_filter($props, fn ($item) => $item !== null); + } + + /** + * Returns the field type + */ + public function type(): string + { + return lcfirst(basename(str_replace(['\\', 'Field'], ['/', ''], static::class))); + } + + /** + * Runs the validations defined for the field + */ + protected function validate(): array + { + $validations = $this->validations(); + $value = $this->value(); + $errors = []; + + // validate required values + if ($this->needsValue() === true) { + $errors['required'] = I18n::translate('error.validation.required'); + } + + foreach ($validations as $key => $validation) { + if (is_int($key) === true) { + // predefined validation + try { + Validations::$validation($this, $value); + } catch (Exception $e) { + $errors[$validation] = $e->getMessage(); + } + continue; + } + + if ($validation instanceof Closure) { + try { + $validation->call($this, $value); + } catch (Exception $e) { + $errors[$key] = $e->getMessage(); + } + } + } + + return $errors; + } + + /** + * Defines all validation rules + * @codeCoverageIgnore + */ + protected function validations(): array + { + return []; + } + + /** + * Returns the value of the field if saveable + * otherwise it returns null + */ + public function value(bool $default = false): mixed + { + if ($this->isSaveable() === false) { + return null; + } + + if ($default === true && $this->isEmpty() === true) { + return $this->default(); + } + + return $this->value; + } + + protected function valueFromJson(mixed $value): array + { + try { + return Data::decode($value, 'json'); + } catch (Throwable) { + return []; + } + } + + protected function valueFromYaml(mixed $value): array + { + return Data::decode($value, 'yaml'); + } + + protected function valueToJson( + array $value = null, + bool $pretty = false + ): string { + $constants = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + + if ($pretty === true) { + $constants |= JSON_PRETTY_PRINT; + } + + return json_encode($value, $constants); + } + + protected function valueToYaml(array $value = null): string + { + return Data::encode($value, 'yaml'); + } + + /** + * Conditions when the field will be shown + */ + public function when(): array|null + { + return $this->when; + } + + /** + * Returns the width of the field in + * the Panel grid + */ + public function width(): string + { + return $this->width ?? '1/1'; + } +} diff --git a/kirby/src/Form/Fields.php b/kirby/src/Form/Fields.php new file mode 100644 index 0000000..ef46f2e --- /dev/null +++ b/kirby/src/Form/Fields.php @@ -0,0 +1,52 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Fields extends Collection +{ + /** + * Internal setter for each object in the Collection. + * This takes care of validation and of setting + * the collection prop on each object correctly. + * + * @param object|array $field + */ + public function __set(string $name, $field): void + { + if (is_array($field) === true) { + // use the array key as name if the name is not set + $field['name'] ??= $name; + $field = Field::factory($field['type'], $field, $this); + } + + parent::__set($field->name(), $field); + } + + /** + * Converts the fields collection to an + * array and also does that for every + * included field. + */ + public function toArray(Closure $map = null): array + { + $array = []; + + foreach ($this as $field) { + $array[$field->name()] = $field->toArray(); + } + + return $array; + } +} diff --git a/kirby/src/Form/Form.php b/kirby/src/Form/Form.php new file mode 100644 index 0000000..40714e7 --- /dev/null +++ b/kirby/src/Form/Form.php @@ -0,0 +1,355 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Form +{ + /** + * An array of all found errors + */ + protected array|null $errors = null; + + /** + * Fields in the form + */ + protected Fields|null $fields; + + /** + * All values of form + */ + protected array $values = []; + + /** + * Form constructor + */ + public function __construct(array $props) + { + $fields = $props['fields'] ?? []; + $values = $props['values'] ?? []; + $input = $props['input'] ?? []; + $strict = $props['strict'] ?? false; + $inject = $props; + + // prepare field properties for multilang setups + $fields = static::prepareFieldsForLanguage( + $fields, + $props['language'] ?? null + ); + + // lowercase all value names + $values = array_change_key_case($values); + $input = array_change_key_case($input); + + unset($inject['fields'], $inject['values'], $inject['input']); + + $this->fields = new Fields(); + $this->values = []; + + foreach ($fields as $name => $props) { + // inject stuff from the form constructor (model, etc.) + $props = array_merge($inject, $props); + + // inject the name + $props['name'] = $name = strtolower($name); + + // check if the field is disabled and + // overwrite the field value if not set + $props['value'] = match ($props['disabled'] ?? false) { + true => $values[$name] ?? null, + default => $input[$name] ?? $values[$name] ?? null + }; + + try { + $field = Field::factory($props['type'], $props, $this->fields); + } catch (Throwable $e) { + $field = static::exceptionField($e, $props); + } + + if ($field->save() !== false) { + $this->values[$name] = $field->value(); + } + + $this->fields->append($name, $field); + } + + if ($strict !== true) { + // use all given values, no matter + // if there's a field or not. + $input = array_merge($values, $input); + + foreach ($input as $key => $value) { + $this->values[$key] ??= $value; + } + } + } + + /** + * Returns the data required to write to the content file + * Doesn't include default and null values + */ + public function content(): array + { + return $this->data(false, false); + } + + /** + * Returns data for all fields in the form + * + * @param false $defaults + */ + public function data($defaults = false, bool $includeNulls = true): array + { + $data = $this->values; + + foreach ($this->fields as $field) { + if ($field->save() === false || $field->unset() === true) { + if ($includeNulls === true) { + $data[$field->name()] = null; + } else { + unset($data[$field->name()]); + } + } else { + $data[$field->name()] = $field->data($defaults); + } + } + + return $data; + } + + /** + * An array of all found errors + */ + public function errors(): array + { + if ($this->errors !== null) { + return $this->errors; + } + + $this->errors = []; + + foreach ($this->fields as $field) { + if (empty($field->errors()) === false) { + $this->errors[$field->name()] = [ + 'label' => $field->label(), + 'message' => $field->errors() + ]; + } + } + + return $this->errors; + } + + /** + * Shows the error with the field + */ + public static function exceptionField( + Throwable $exception, + array $props = [] + ): Field { + $message = $exception->getMessage(); + + if (App::instance()->option('debug') === true) { + $message .= ' in file: ' . $exception->getFile(); + $message .= ' line: ' . $exception->getLine(); + } + + $props = array_merge($props, [ + 'label' => 'Error in "' . $props['name'] . '" field.', + 'theme' => 'negative', + 'text' => strip_tags($message), + ]); + + return Field::factory('info', $props); + } + + /** + * Get the field object by name + * and handle nested fields correctly + * + * @throws \Kirby\Exception\NotFoundException + */ + public function field(string $name): Field|FieldClass + { + $form = $this; + $fieldNames = Str::split($name, '+'); + $index = 0; + $count = count($fieldNames); + $field = null; + + foreach ($fieldNames as $fieldName) { + $index++; + + if ($field = $form->fields()->get($fieldName)) { + if ($count !== $index) { + $form = $field->form(); + } + + continue; + } + + throw new NotFoundException('The field "' . $fieldName . '" could not be found'); + } + + // it can get this error only if $name is an empty string as $name = '' + if ($field === null) { + throw new NotFoundException('No field could be loaded'); + } + + return $field; + } + + /** + * Returns form fields + */ + public function fields(): Fields|null + { + return $this->fields; + } + + public static function for( + ModelWithContent $model, + array $props = [] + ): static { + // get the original model data + $original = $model->content($props['language'] ?? null)->toArray(); + $values = $props['values'] ?? []; + + // convert closures to values + foreach ($values as $key => $value) { + if ($value instanceof Closure) { + $values[$key] = $value($original[$key] ?? null); + } + } + + // set a few defaults + $props['values'] = array_merge($original, $values); + $props['fields'] ??= []; + $props['model'] = $model; + + // search for the blueprint + if ( + method_exists($model, 'blueprint') === true && + $blueprint = $model->blueprint() + ) { + $props['fields'] = $blueprint->fields(); + } + + $ignoreDisabled = $props['ignoreDisabled'] ?? false; + + // REFACTOR: this could be more elegant + if ($ignoreDisabled === true) { + $props['fields'] = array_map(function ($field) { + $field['disabled'] = false; + return $field; + }, $props['fields']); + } + + return new static($props); + } + + /** + * Checks if the form is invalid + */ + public function isInvalid(): bool + { + return $this->isValid() === false; + } + + /** + * Checks if the form is valid + */ + public function isValid(): bool + { + return empty($this->errors()) === true; + } + + /** + * Disables fields in secondary languages when + * they are configured to be untranslatable + */ + protected static function prepareFieldsForLanguage( + array $fields, + string|null $language = null + ): array { + $kirby = App::instance(null, true); + + // only modify the fields if we have a valid Kirby multilang instance + if ($kirby?->multilang() === false) { + return $fields; + } + + $language ??= $kirby->language()->code(); + + if ($language !== $kirby->defaultLanguage()->code()) { + foreach ($fields as $fieldName => $fieldProps) { + // switch untranslatable fields to readonly + if (($fieldProps['translate'] ?? true) === false) { + $fields[$fieldName]['unset'] = true; + $fields[$fieldName]['disabled'] = true; + } + } + } + + return $fields; + } + + /** + * Converts the data of fields to strings + * + * @param false $defaults + */ + public function strings($defaults = false): array + { + return A::map( + $this->data($defaults), + fn ($value) => match (true) { + is_array($value) => Data::encode($value, 'yaml'), + default => $value + } + ); + } + + /** + * Converts the form to a plain array + */ + public function toArray(): array + { + $array = [ + 'errors' => $this->errors(), + 'fields' => $this->fields->toArray(fn ($item) => $item->toArray()), + 'invalid' => $this->isInvalid() + ]; + + return $array; + } + + /** + * Returns form values + */ + public function values(): array + { + return $this->values; + } +} diff --git a/kirby/src/Form/Mixin/EmptyState.php b/kirby/src/Form/Mixin/EmptyState.php new file mode 100644 index 0000000..6f7d72a --- /dev/null +++ b/kirby/src/Form/Mixin/EmptyState.php @@ -0,0 +1,18 @@ +empty = $this->i18n($empty); + } + + public function empty(): string|null + { + return $this->stringTemplate($this->empty); + } +} diff --git a/kirby/src/Form/Mixin/Max.php b/kirby/src/Form/Mixin/Max.php new file mode 100644 index 0000000..3141bbe --- /dev/null +++ b/kirby/src/Form/Mixin/Max.php @@ -0,0 +1,18 @@ +max; + } + + protected function setMax(int $max = null) + { + $this->max = $max; + } +} diff --git a/kirby/src/Form/Mixin/Min.php b/kirby/src/Form/Mixin/Min.php new file mode 100644 index 0000000..1b585e1 --- /dev/null +++ b/kirby/src/Form/Mixin/Min.php @@ -0,0 +1,18 @@ +min; + } + + protected function setMin(int $min = null) + { + $this->min = $min; + } +} diff --git a/kirby/src/Form/Validations.php b/kirby/src/Form/Validations.php new file mode 100644 index 0000000..7f0a539 --- /dev/null +++ b/kirby/src/Form/Validations.php @@ -0,0 +1,272 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Validations +{ + /** + * Validates if the field value is boolean + * + * @param \Kirby\Form\Field|\Kirby\Form\FieldClass $field + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function boolean($field, $value): bool + { + if ($field->isEmpty($value) === false) { + if (is_bool($value) === false) { + throw new InvalidArgumentException([ + 'key' => 'validation.boolean' + ]); + } + } + + return true; + } + + /** + * Validates if the field value is valid date + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function date(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmpty($value) === false) { + if (V::date($value) !== true) { + throw new InvalidArgumentException( + V::message('date', $value) + ); + } + } + + return true; + } + + /** + * Validates if the field value is valid email + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function email(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmpty($value) === false) { + if (V::email($value) === false) { + throw new InvalidArgumentException( + V::message('email', $value) + ); + } + } + + return true; + } + + /** + * Validates if the field value is maximum + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function max(Field|FieldClass $field, mixed $value): bool + { + if ( + $field->isEmpty($value) === false && + $field->max() !== null + ) { + if (V::max($value, $field->max()) === false) { + throw new InvalidArgumentException( + V::message('max', $value, $field->max()) + ); + } + } + + return true; + } + + /** + * Validates if the field value is max length + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function maxlength(Field|FieldClass $field, mixed $value): bool + { + if ( + $field->isEmpty($value) === false && + $field->maxlength() !== null + ) { + if (V::maxLength($value, $field->maxlength()) === false) { + throw new InvalidArgumentException( + V::message('maxlength', $value, $field->maxlength()) + ); + } + } + + return true; + } + + /** + * Validates if the field value is minimum + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function min(Field|FieldClass $field, mixed $value): bool + { + if ( + $field->isEmpty($value) === false && + $field->min() !== null + ) { + if (V::min($value, $field->min()) === false) { + throw new InvalidArgumentException( + V::message('min', $value, $field->min()) + ); + } + } + + return true; + } + + /** + * Validates if the field value is min length + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function minlength(Field|FieldClass $field, mixed $value): bool + { + if ( + $field->isEmpty($value) === false && + $field->minlength() !== null + ) { + if (V::minLength($value, $field->minlength()) === false) { + throw new InvalidArgumentException( + V::message('minlength', $value, $field->minlength()) + ); + } + } + + return true; + } + + /** + * Validates if the field value matches defined pattern + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function pattern(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmpty($value) === false && $field->pattern() !== null) { + if (V::match($value, '/' . $field->pattern() . '/i') === false) { + throw new InvalidArgumentException( + V::message('match') + ); + } + } + + return true; + } + + /** + * Validates if the field value is required + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function required(Field|FieldClass $field, mixed $value): bool + { + if ( + $field->isRequired() === true && + $field->save() === true && + $field->isEmpty($value) === true + ) { + throw new InvalidArgumentException([ + 'key' => 'validation.required' + ]); + } + + return true; + } + + /** + * Validates if the field value is in defined options + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function option(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmpty($value) === false) { + $values = array_column($field->options(), 'value'); + + if (in_array($value, $values, true) !== true) { + throw new InvalidArgumentException([ + 'key' => 'validation.option' + ]); + } + } + + return true; + } + + /** + * Validates if the field values is in defined options + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function options(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmpty($value) === false) { + $values = array_column($field->options(), 'value'); + foreach ($value as $val) { + if (in_array($val, $values, true) === false) { + throw new InvalidArgumentException([ + 'key' => 'validation.option' + ]); + } + } + } + + return true; + } + + /** + * Validates if the field value is valid time + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function time(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmpty($value) === false) { + if (V::time($value) !== true) { + throw new InvalidArgumentException( + V::message('time', $value) + ); + } + } + + return true; + } + + /** + * Validates if the field value is valid url + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public static function url(Field|FieldClass $field, mixed $value): bool + { + if ($field->isEmpty($value) === false) { + if (V::url($value) === false) { + throw new InvalidArgumentException( + V::message('url', $value) + ); + } + } + + return true; + } +} diff --git a/kirby/src/Http/Cookie.php b/kirby/src/Http/Cookie.php new file mode 100644 index 0000000..a44b752 --- /dev/null +++ b/kirby/src/Http/Cookie.php @@ -0,0 +1,234 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Cookie +{ + /** + * Key to use for cookie signing + */ + public static string $key = 'KirbyHttpCookieKey'; + + /** + * Set a new cookie + * + * + * + * cookie::set('mycookie', 'hello', ['lifetime' => 60]); + * // expires in 1 hour + * + * + * + * @param string $key The name of the cookie + * @param string $value The cookie content + * @param array $options Array of options: + * lifetime, path, domain, secure, httpOnly, sameSite + * @return bool true: cookie was created, + * false: cookie creation failed + */ + public static function set( + string $key, + string $value, + array $options = [] + ): bool { + // modify CMS caching behavior + static::trackUsage($key); + + // extract options + $expires = static::lifetime($options['lifetime'] ?? 0); + $path = $options['path'] ?? '/'; + $domain = $options['domain'] ?? null; + $secure = $options['secure'] ?? false; + $httponly = $options['httpOnly'] ?? true; + $samesite = $options['sameSite'] ?? 'Lax'; + + // add an HMAC signature of the value + $value = static::hmac($value) . '+' . $value; + + // store that thing in the cookie global + $_COOKIE[$key] = $value; + + // store the cookie + return setcookie( + $key, + $value, + compact('expires', 'path', 'domain', 'secure', 'httponly', 'samesite') + ); + } + + /** + * Calculates the lifetime for a cookie + * + * @param int $minutes Number of minutes or timestamp + */ + public static function lifetime(int $minutes): int + { + // absolute timestamp + if ($minutes > 1000000000) { + return $minutes; + } + + // minutes from now + if ($minutes > 0) { + return time() + ($minutes * 60); + } + + return 0; + } + + /** + * Stores a cookie forever + * + * + * + * cookie::forever('mycookie', 'hello'); + * // never expires + * + * + * + * @param string $key The name of the cookie + * @param string $value The cookie content + * @param array $options Array of options: + * path, domain, secure, httpOnly + * @return bool true: cookie was created, + * false: cookie creation failed + */ + public static function forever( + string $key, + string $value, + array $options = [] + ): bool { + // 9999-12-31 if supported (lower on 32-bit servers) + $options['lifetime'] = min(253402214400, PHP_INT_MAX); + return static::set($key, $value, $options); + } + + /** + * Get a cookie value + * + * + * cookie::get('mycookie', 'peter'); + * // sample output: 'hello' or if the cookie is not set 'peter' + * + * + * @param string|null $key The name of the cookie + * @param string|null $default The default value, which should be returned + * if the cookie has not been found + * @return string|array|null The found value + */ + public static function get( + string|null $key = null, + string|null $default = null + ): string|array|null { + if ($key === null) { + return $_COOKIE; + } + + // modify CMS caching behavior + static::trackUsage($key); + + if ($value = $_COOKIE[$key] ?? null) { + return static::parse($value); + } + + return $default; + } + + /** + * Checks if a cookie exists + */ + public static function exists(string $key): bool + { + return static::get($key) !== null; + } + + /** + * Creates a HMAC for the cookie value + * Used as a cookie signature to prevent easy tampering with cookie data + */ + protected static function hmac(string $value): string + { + return hash_hmac('sha1', $value, static::$key); + } + + /** + * Parses the hashed value from a cookie + * and tries to extract the value + */ + protected static function parse(string $string): string|null + { + // if no hash-value separator is present, we can't parse the value + if (strpos($string, '+') === false) { + return null; + } + + // extract hash and value + $hash = Str::before($string, '+'); + $value = Str::after($string, '+'); + + // if the hash or the value is missing at all return null + // $value can be an empty string, $hash can't be! + if ($hash === '') { + return null; + } + + // compare the extracted hash with the hashed value + // don't accept value if the hash is invalid + if (hash_equals(static::hmac($value), $hash) !== true) { + return null; + } + + return $value; + } + + /** + * Remove a cookie + * + * + * + * cookie::remove('mycookie'); + * // mycookie is now gone + * + * + * + * @param string $key The name of the cookie + * @return bool true: the cookie has been removed, + * false: the cookie could not be removed + */ + public static function remove(string $key): bool + { + if (isset($_COOKIE[$key]) === true) { + unset($_COOKIE[$key]); + return setcookie($key, '', 1, '/') && setcookie($key, false); + } + + return false; + } + + /** + * Tells the CMS responder that the response relies on a cookie and + * its value (even if the cookie isn't set in the current request); + * this ensures that the response is only cached for visitors who don't + * have this cookie set; + * https://github.com/getkirby/kirby/issues/4423#issuecomment-1166300526 + */ + protected static function trackUsage(string $key): void + { + // lazily request the instance for non-CMS use cases + $kirby = App::instance(null, true); + $kirby?->response()->usesCookie($key); + } +} diff --git a/kirby/src/Http/Environment.php b/kirby/src/Http/Environment.php new file mode 100644 index 0000000..8eccc13 --- /dev/null +++ b/kirby/src/Http/Environment.php @@ -0,0 +1,1009 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Environment +{ + /** + * Full base URL object + */ + protected Uri $baseUri; + + /** + * Full base URL + */ + protected string $baseUrl; + + /** + * Whether the request is being served by the CLI + */ + protected bool $cli; + + /** + * Current host name + */ + protected string|null $host; + + /** + * Whether the HTTPS protocol is used + */ + protected bool $https; + + /** + * Sanitized `$_SERVER` data + */ + protected array $info; + + /** + * Current server's IP address + */ + protected string|null $ip; + + /** + * Whether the site is behind a reverse proxy; + * `null` if not known (fixed allowed URL setup) + */ + protected bool|null $isBehindProxy; + + /** + * URI path to the base + */ + protected string $path; + + /** + * Port number in the site URL + */ + protected int|null $port; + + /** + * Intermediary value of the port + * extracted from the host name + */ + protected int|null $portInHost = null; + + /** + * Uri object for the full request URI. + * It is a combination of the base URL and `REQUEST_URI` + */ + protected Uri $requestUri; + + /** + * Full request URL + */ + protected string $requestUrl; + + /** + * Path to the php script within the + * document root without the + * filename of the script + */ + protected string $scriptPath; + + /** + * Class constructor + * + * @param array|null $info Optional override for `$_SERVER` + */ + public function __construct( + array|null $options = null, + array|null $info = null + ) { + $this->detect($options, $info); + } + + /** + * Returns the server's IP address + * @see ::ip + */ + public function address(): string|null + { + return $this->ip(); + } + + /** + * Returns the full base URL object + */ + public function baseUri(): Uri + { + return $this->baseUri; + } + + /** + * Returns the full base URL + */ + public function baseUrl(): string + { + return $this->baseUrl; + } + + /** + * Checks if the request is being served by the CLI + */ + public function cli(): bool + { + return $this->cli; + } + + /** + * Sanitizes the server info and detects + * all relevant parts. This can be called + * again at a later point to overwrite all + * the stored information and re-detect the + * environment if necessary. + * + * @param array|null $info Optional override for `$_SERVER` + */ + public function detect( + array $options = null, + array $info = null + ): array { + $defaults = [ + 'cli' => null, + 'allowed' => null + ]; + + $info ??= $_SERVER; + $options = array_merge($defaults, $options ?? []); + + $this->info = static::sanitize($info); + $this->cli = $this->detectCli($options['cli']); + $this->ip = $this->detectIp(); + $this->host = null; + $this->https = false; + $this->isBehindProxy = null; + $this->scriptPath = $this->detectScriptPath($this->get('SCRIPT_NAME')); + $this->path = $this->detectPath($this->scriptPath); + $this->port = null; + + // insecure auto-detection + if ($options['allowed'] === '*' || $options['allowed'] === ['*']) { + $this->detectAuto(true); + + // fixed environments + } elseif (empty($options['allowed']) === false) { + $this->detectAllowed($options['allowed']); + + // secure auto-detection + } else { + $this->detectAuto(); + } + + // build the URI based on the detected params + $this->detectBaseUri(); + + // build the request URI based on the detected URL + $this->detectRequestUri($this->get('REQUEST_URI')); + + // return the sanitized $_SERVER array + return $this->info; + } + + /** + * Sets the host name, port, path and protocol from the + * fixed list of allowed URLs + */ + protected function detectAllowed(array|string $allowed): void + { + $allowed = A::wrap($allowed); + + // with a single allowed URL, the entire + // environment will be based on that + if (count($allowed) === 1) { + $baseUrl = A::first($allowed); + + if (is_string($baseUrl) === false) { + throw new InvalidArgumentException('Invalid allow list setup for base URLs'); + } + + $uri = new Uri($baseUrl, ['slash' => false]); + + $this->host = $uri->host(); + $this->https = $uri->https(); + $this->port = $uri->port(); + $this->path = $uri->path()->toString(); + return; + } + + // run insecure auto detection to get + // host, port and https from the environment; + // security is achieved by checking against + // the fixed allowlist below + $this->detectAuto(true); + + // build the baseUrl based on the detected environment + // to compare it against what is allowed + $this->detectBaseUri(); + + foreach ($allowed as $url) { + // skip invalid URLs + if (is_string($url) === false) { + continue; + } + + $uri = new Uri($url, ['slash' => false]); + + // the current environment is allowed, + // stop before the exception below is thrown + if ($uri->toString() === $this->baseUrl) { + return; + } + } + + throw new InvalidArgumentException('The environment is not allowed'); + } + + /** + * Sets the host name, port and protocol without configuration + * + * @param bool $insecure Include the `Host`, `Forwarded` and `X-Forwarded-*` headers in the search + */ + protected function detectAuto(bool $insecure = false): void + { + // proxy server setup + if ($insecure === true) { + $forwarded = $this->detectForwarded(); + + $host = $forwarded['host']; + $port = $forwarded['port']; + $https = $forwarded['https']; + + if ($host || $port || $https) { + $this->isBehindProxy = true; + + // if a port or scheme is defined but no host, assume + // that the host is the same as PHP's own hostname + // (which is often the case with reverse proxies) + $this->host = $host ?? $this->detectHost($insecure); + $this->port = $port; + $this->https = $https; + + return; + } + } + + // local server setup + $this->isBehindProxy = false; + + $this->host = $this->detectHost($insecure); + $this->https = $this->detectHttps(); + $this->port = $this->detectPort(); + } + + /** + * Builds the base URL based on the + * given environment params + */ + protected function detectBaseUri(): Uri + { + $this->baseUri = new Uri([ + 'host' => $this->host, + 'path' => $this->path, + 'port' => $this->port, + 'scheme' => $this->https ? 'https' : 'http', + ]); + + $this->baseUrl = $this->baseUri->toString(); + + return $this->baseUri; + } + + /** + * Detects if the request is served by the CLI + * + * @param bool|null $override Set to a boolean to override detection (for testing) + */ + protected function detectCli(bool|null $override = null): bool + { + if (is_bool($override) === true) { + return $override; + } + + if (defined('STDIN') === true) { + return true; + } + + // @codeCoverageIgnoreStart + $term = getenv('TERM'); + + if ( + substr(PHP_SAPI, 0, 3) === 'cgi' && + $term && + $term !== 'unknown' + ) { + return true; + } + + return false; + // @codeCoverageIgnoreEnd + } + + /** + * Detects the host, protocol, port and client IP + * from the `Forwarded` and `X-Forwarded-*` headers + */ + protected function detectForwarded(): array + { + $data = [ + 'for' => null, + 'host' => null, + 'https' => false, + 'port' => null + ]; + + // prefer the standardized `Forwarded` header if defined + if ($forwarded = $this->get('HTTP_FORWARDED')) { + // only use the first (outermost) proxy by using the first set of values + // before the first comma (but only a comma outside of quotes) + if (Str::contains($forwarded, ',') === true) { + $forwarded = preg_split('/"[^"]*"(*SKIP)(*F)|,/', $forwarded)[0]; + } + + // split into separate key=value;key=value fields by semicolon, + // but only split outside of quotes + $rawFields = preg_split('/"[^"]*"(*SKIP)(*F)|;/', $forwarded); + + // split key and value into an associative array + $fields = []; + foreach ($rawFields as $field) { + $key = Str::lower(Str::before($field, '=')); + $value = Str::after($field, '='); + + // trim the surrounding quotes + if (Str::substr($value, 0, 1) === '"') { + $value = Str::substr($value, 1, -1); + } + + $fields[$key] = $value; + } + + // assemble the normalized data + if (isset($fields['host']) === true) { + $parts = $this->detectPortInHost($fields['host']); + $data['host'] = $parts['host']; + $data['port'] = $parts['port']; + } + + if (isset($fields['proto']) === true) { + $data['https'] = $this->detectHttpsProtocol($fields['proto']); + } + + if ($data['https'] === true) { + $data['port'] ??= 443; + } + + $data['for'] = $parts['for'] ?? null; + + return $data; + } + + // no success, try the `X-Forwarded-*` headers + $data['host'] = $this->detectForwardedHost(); + $data['https'] = $this->detectForwardedHttps(); + $data['port'] = $this->detectForwardedPort($data['https']); + $data['for'] = $this->get('HTTP_X_FORWARDED_FOR'); + + return $data; + } + + /** + * Detects the host name of the reverse proxy + * from the `X-Forwarded-Host` header + */ + protected function detectForwardedHost(): string|null + { + $host = $this->get('HTTP_X_FORWARDED_HOST'); + $parts = $this->detectPortInHost($host); + + $this->portInHost = $parts['port']; + + return $parts['host']; + } + + /** + * Detects the protocol of the reverse proxy from the + * `X-Forwarded-SSL` or `X-Forwarded-Proto` header + */ + protected function detectForwardedHttps(): bool + { + if ($this->detectHttpsOn($this->get('HTTP_X_FORWARDED_SSL')) === true) { + return true; + } + + if ($this->detectHttpsProtocol($this->get('HTTP_X_FORWARDED_PROTO')) === true) { + return true; + } + + return false; + } + + /** + * Detects the port of the reverse proxy from the + * `X-Forwarded-Host` or `X-Forwarded-Port` header + * + * @param bool $https Whether HTTPS was detected + */ + protected function detectForwardedPort(bool $https): int|null + { + // based on forwarded port + $port = $this->get('HTTP_X_FORWARDED_PORT'); + + if (is_int($port) === true) { + return $port; + } + + // based on forwarded host + if (is_int($this->portInHost) === true) { + return $this->portInHost; + } + + // based on the detected https state + if ($https === true) { + return 443; + } + + return null; + } + + /** + * Detects the host name from various headers + * + * @param bool $insecure Include the `Host` header in the search + */ + protected function detectHost(bool $insecure = false): string|null + { + $hosts = []; + + if ($insecure === true) { + $hosts[] = $this->get('HTTP_HOST'); + } + + $hosts[] = $this->get('SERVER_NAME'); + $hosts[] = $this->get('SERVER_ADDR'); + + // use the first header that is not empty + $hosts = array_filter($hosts); + $host = A::first($hosts); + + $parts = $this->detectPortInHost($host); + + $this->portInHost = $parts['port']; + + return $parts['host']; + } + + /** + * Detects the HTTPS status + */ + protected function detectHttps(): bool + { + if ($this->detectHttpsOn($this->get('HTTPS')) === true) { + return true; + } + + return false; + } + + /** + * Normalizes the HTTPS status into a boolean + */ + protected function detectHttpsOn(string|int|bool|null $value): bool + { + // off can mean many things :) + $off = ['off', null, '', 0, '0', false, 'false', -1, '-1']; + + return in_array($value, $off, true) === false; + } + + /** + * Detects the HTTPS status from a `X-Forwarded-Proto` string + */ + protected function detectHttpsProtocol(string|null $protocol = null): bool + { + if ($protocol === null) { + return false; + } + + $protocols = ['https', 'https, http']; + + return in_array(strtolower($protocol), $protocols) === true; + } + + /** + * Detects the server's IP address + */ + protected function detectIp(): string|null + { + return $this->get('SERVER_ADDR'); + } + + /** + * Detects the URI path unless in CLI mode + */ + protected function detectPath(string|null $path = null): string + { + if ($this->cli === true) { + return ''; + } + + return $path ?? ''; + } + + /** + * Detects the port from various sources + */ + protected function detectPort(): int|null + { + // based on server port + $port = $this->get('SERVER_PORT'); + + if (is_int($port) === true) { + return $port; + } + + // based on the detected host + if (is_int($this->portInHost) === true) { + return $this->portInHost; + } + + // based on the detected https state + if ($this->https === true) { + return 443; + } + + return null; + } + + /** + * Splits a hostname:port string into its components + */ + protected function detectPortInHost(string|null $host = null): array + { + if (empty($host) === true) { + return [ + 'host' => null, + 'port' => null + ]; + } + + $parts = Str::split($host, ':'); + + return [ + 'host' => $parts[0] ?? null, + 'port' => static::sanitizePort($parts[1] ?? null), + ]; + } + + /** + * Splits any URI into path and query + */ + protected function detectRequestUri(string|null $requestUri = null): Uri + { + // make sure the URL parser works properly when there's a + // colon in the request URI but the URI is relative + if (Url::isAbsolute($requestUri) === false) { + $requestUri = 'https://getkirby.com' . $requestUri; + } + + $uri = new Uri($requestUri); + + // create the URI object as a combination of base uri parts + // and the parts from REQUEST_URI + $this->requestUri = $this->baseUri()->clone([ + 'fragment' => $uri->fragment(), + 'params' => $uri->params(), + 'path' => $uri->path(), + 'query' => $uri->query() + ]); + + // build the full request URL + $this->requestUrl = $this->requestUri->toString(); + + return $this->requestUri; + } + + /** + * Returns the sanitized script path unless in CLI mode + */ + protected function detectScriptPath(string|null $scriptPath = null): string + { + if ($this->cli === true) { + return ''; + } + + return $this->sanitizeScriptPath($scriptPath); + } + + /** + * Gets a value from the server environment array + * + * + * $server->get('document_root'); + * // sample output: /var/www/kirby + * + * $server->get(); + * // returns the whole server array + * + * + * @param string|false|null $key The key to look for. Pass `false` or `null` + * to return the entire server array. + * @param mixed $default Optional default value, which should be + * returned if no element has been found + */ + public function get(string|false|null $key = null, $default = null) + { + if (is_string($key) === false) { + return $this->info; + } + + if (isset($this->info[$key]) === false) { + $key = strtoupper($key); + } + + return $this->info[$key] ?? static::sanitize($key, $default); + } + + /** + * Gets a value from the global server environment array + * of the current app instance; falls back to `$_SERVER` if + * no app instance is running + * + * @param string|false|null $key The key to look for. Pass `false` or `null` + * to return the entire server array. + * @param mixed $default Optional default value, which should be + * returned if no element has been found + */ + public static function getGlobally( + string|false|null $key = null, + $default = null + ) { + // first try the global `Environment` object if the CMS is running + if ($app = App::instance(null, true)) { + return $app->environment()->get($key, $default); + } + + if (is_string($key) === false) { + return static::sanitize($_SERVER); + } + + if (isset($_SERVER[$key]) === false) { + $key = strtoupper($key); + } + + return static::sanitize($key, $_SERVER[$key] ?? $default); + } + + /** + * Returns the current host name + */ + public function host(): string|null + { + return $this->host; + } + + /** + * Returns whether the HTTPS protocol is used + */ + public function https(): bool + { + return $this->https; + } + + /** + * Returns the sanitized `$_SERVER` array + */ + public function info(): array + { + return $this->info; + } + + /** + * Returns the server's IP address + */ + public function ip(): string|null + { + return $this->ip; + } + + /** + * Returns if the server is behind a + * reverse proxy server + */ + public function isBehindProxy(): bool|null + { + return $this->isBehindProxy; + } + + /** + * Checks if this is a local installation; + * returns `false` if in doubt + */ + public function isLocal(): bool + { + // check host + $host = $this->host(); + + if ($host === 'localhost') { + return true; + } + + if (Str::endsWith($host, '.local') === true) { + return true; + } + + if (Str::endsWith($host, '.test') === true) { + return true; + } + + // collect all possible visitor ips + $ips = [ + $this->get('REMOTE_ADDR'), + $this->get('HTTP_X_FORWARDED_FOR'), + $this->get('HTTP_CLIENT_IP') + ]; + + if ($this->get('HTTP_FORWARDED')) { + $ips[] = $this->detectForwarded()['for']; + } + + // remove duplicates and empty ips + $ips = array_unique(array_filter($ips)); + + // no known ip? Better not assume it's local + if (empty($ips) === true) { + return false; + } + + // stop as soon as a non-local ip is found + foreach ($ips as $ip) { + if (in_array($ip, ['::1', '127.0.0.1']) === false) { + return false; + } + } + + return true; + } + + /** + * Loads and returns options from environment-specific + * PHP files (by host name and server IP address or CLI) + * + * @param string $root Root directory to load configs from + */ + public function options(string $root): array + { + $configCli = []; + $configHost = []; + $configAddr = []; + + $host = $this->host(); + $addr = $this->ip(); + + // load the config for the cli + if ($this->cli() === true) { + $configCli = F::load( + file: $root . '/config.cli.php', + fallback: [], + allowOutput: false + ); + } + + // load the config for the host + if (empty($host) === false) { + $configHost = F::load( + file: $root . '/config.' . $host . '.php', + fallback: [], + allowOutput: false + ); + } + + // load the config for the server IP + if (empty($addr) === false) { + $configAddr = F::load( + file: $root . '/config.' . $addr . '.php', + fallback: [], + allowOutput: false + ); + } + + return array_replace_recursive($configCli, $configHost, $configAddr); + } + + /** + * Returns the detected path + */ + public function path(): string|null + { + return $this->path; + } + + /** + * Returns the correct port number + */ + public function port(): int|null + { + return $this->port; + } + + /** + * Returns an URI object for the requested URL + */ + public function requestUri(): Uri + { + return $this->requestUri; + } + + /** + * Returns the current URL, including the request path + * and query + */ + public function requestUrl(): string + { + return $this->requestUrl; + } + + /** + * Sanitizes some `$_SERVER` keys + */ + public static function sanitize( + string|array $key, + $value = null + ) { + if (is_array($key) === true) { + foreach ($key as $k => $v) { + $key[$k] = static::sanitize($k, $v); + } + + return $key; + } + + return match ($key) { + 'SERVER_ADDR', + 'SERVER_NAME', + 'HTTP_HOST', + 'HTTP_X_FORWARDED_HOST' => static::sanitizeHost($value), + + 'SERVER_PORT', + 'HTTP_X_FORWARDED_PORT' => static::sanitizePort($value), + + default => $value + }; + } + + /** + * Sanitizes the given host name + */ + protected static function sanitizeHost( + string|null $host = null + ): string|null { + if (empty($host) === true) { + return null; + } + + $host = Str::lower($host); + $host = strip_tags($host); + $host = basename($host); + $host = preg_replace('![^\w.:-]+!iu', '', $host); + $host = htmlspecialchars($host, ENT_COMPAT); + $host = trim($host, '-'); + $host = trim($host, '.'); + $host = trim($host); + + if ($host === '') { + return null; + } + + return $host; + } + + /** + * Sanitizes the given port number + */ + protected static function sanitizePort( + string|int|false|null $port = null + ): int|null { + // already fine + if (is_int($port) === true) { + return $port; + } + + // no port given + if ($port === null || $port === false || $port === '') { + return null; + } + + // remove any character that is not an integer + $port = preg_replace('![^0-9]+!', '', $port); + + // no port + if ($port === '') { + return null; + } + + // convert to integer + return (int)$port; + } + + /** + * Sanitizes the given script path + */ + protected function sanitizeScriptPath(string|null $scriptPath = null): string + { + $scriptPath ??= ''; + $scriptPath = trim($scriptPath); + + // skip all the sanitizing steps if the path is empty + if ($scriptPath === '') { + return $scriptPath; + } + + // replace Windows backslashes + $scriptPath = str_replace('\\', '/', $scriptPath); + // remove the script + $scriptPath = dirname($scriptPath); + // replace those fucking backslashes again + $scriptPath = str_replace('\\', '/', $scriptPath); + // remove the leading and trailing slashes + $scriptPath = trim($scriptPath, '/'); + + // top-level scripts don't have a path + // and dirname() will return '.' + if ($scriptPath === '.') { + return ''; + } + + return $scriptPath; + } + + /** + * Returns the path to the php script + * within the document root without the + * filename of the script. + * + * i.e. /subfolder/index.php -> subfolder + * + * This can be used to build the base baseUrl + * for subfolder installations + */ + public function scriptPath(): string + { + return $this->scriptPath; + } + + /** + * Returns all environment data as array + */ + public function toArray(): array + { + return [ + 'baseUrl' => $this->baseUrl, + 'host' => $this->host, + 'https' => $this->https, + 'info' => $this->info, + 'ip' => $this->ip, + 'isBehindProxy' => $this->isBehindProxy, + 'path' => $this->path, + 'port' => $this->port, + 'requestUrl' => $this->requestUrl, + 'scriptPath' => $this->scriptPath, + ]; + } +} diff --git a/kirby/src/Http/Exceptions/NextRouteException.php b/kirby/src/Http/Exceptions/NextRouteException.php new file mode 100644 index 0000000..d6bd3f9 --- /dev/null +++ b/kirby/src/Http/Exceptions/NextRouteException.php @@ -0,0 +1,16 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class NextRouteException extends \Exception +{ +} diff --git a/kirby/src/Http/Header.php b/kirby/src/Http/Header.php new file mode 100644 index 0000000..857798c --- /dev/null +++ b/kirby/src/Http/Header.php @@ -0,0 +1,313 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Header +{ + // configuration + public static array $codes = [ + // successful + '_200' => 'OK', + '_201' => 'Created', + '_202' => 'Accepted', + + // redirection + '_300' => 'Multiple Choices', + '_301' => 'Moved Permanently', + '_302' => 'Found', + '_303' => 'See Other', + '_304' => 'Not Modified', + '_307' => 'Temporary Redirect', + '_308' => 'Permanent Redirect', + + // client error + '_400' => 'Bad Request', + '_401' => 'Unauthorized', + '_402' => 'Payment Required', + '_403' => 'Forbidden', + '_404' => 'Not Found', + '_405' => 'Method Not Allowed', + '_406' => 'Not Acceptable', + '_410' => 'Gone', + '_418' => 'I\'m a teapot', + '_451' => 'Unavailable For Legal Reasons', + + // server error + '_500' => 'Internal Server Error', + '_501' => 'Not Implemented', + '_502' => 'Bad Gateway', + '_503' => 'Service Unavailable', + '_504' => 'Gateway Time-out' + ]; + + /** + * Sends a content type header + * + * @return string|void + */ + public static function contentType( + string $mime, + string $charset = 'UTF-8', + bool $send = true + ) { + if ($found = F::extensionToMime($mime)) { + $mime = $found; + } + + $header = 'Content-type: ' . $mime; + + if (empty($charset) === false) { + $header .= '; charset=' . $charset; + } + + if ($send === false) { + return $header; + } + + header($header); + } + + /** + * Creates headers by key and value + */ + public static function create( + string|array $key, + string|null $value = null + ): string { + if (is_array($key) === true) { + $headers = []; + + foreach ($key as $k => $v) { + $headers[] = static::create($k, $v); + } + + return implode("\r\n", $headers); + } + + // prevent header injection by stripping + // any newline characters from single headers + return str_replace(["\r", "\n"], '', $key . ': ' . $value); + } + + /** + * Shortcut for static::contentType() + * + * @return string|void + */ + public static function type( + string $mime, + string $charset = 'UTF-8', + bool $send = true + ) { + return static::contentType($mime, $charset, $send); + } + + /** + * Sends a status header + * + * Checks $code against a list of known status codes. To bypass this check + * and send a custom status code and message, use a $code string formatted + * as 3 digits followed by a space and a message, e.g. '999 Custom Status'. + * + * @param int|string|null $code The HTTP status code + * @param bool $send If set to false the header will be returned instead + * @return string|void + * @psalm-return ($send is false ? string : void) + */ + public static function status( + int|string|null $code = null, + bool $send = true + ) { + $codes = static::$codes; + $protocol = Environment::getGlobally('SERVER_PROTOCOL', 'HTTP/1.1'); + + // allow full control over code and message + if ( + is_string($code) === true && + preg_match('/^\d{3} \w.+$/', $code) === 1 + ) { + $message = substr(rtrim($code), 4); + $code = substr($code, 0, 3); + } else { + if (array_key_exists('_' . $code, $codes) === false) { + $code = 500; + } + + $message = $codes['_' . $code] ?? 'Something went wrong'; + } + + $header = $protocol . ' ' . $code . ' ' . $message; + + if ($send === false) { + return $header; + } + + // try to send the header + header($header); + } + + /** + * Sends a 200 header + * + * @return string|void + */ + public static function success(bool $send = true) + { + return static::status(200, $send); + } + + /** + * Sends a 201 header + * + * @return string|void + */ + public static function created(bool $send = true) + { + return static::status(201, $send); + } + + /** + * Sends a 202 header + * + * @return string|void + */ + public static function accepted(bool $send = true) + { + return static::status(202, $send); + } + + /** + * Sends a 400 header + * + * @return string|void + */ + public static function error(bool $send = true) + { + return static::status(400, $send); + } + + /** + * Sends a 403 header + * + * @return string|void + */ + public static function forbidden(bool $send = true) + { + return static::status(403, $send); + } + + /** + * Sends a 404 header + * + * @return string|void + */ + public static function notfound(bool $send = true) + { + return static::status(404, $send); + } + + /** + * Sends a 404 header + * + * @return string|void + */ + public static function missing(bool $send = true) + { + return static::status(404, $send); + } + + /** + * Sends a 410 header + * + * @return string|void + */ + public static function gone(bool $send = true) + { + return static::status(410, $send); + } + + /** + * Sends a 500 header + * + * @return string|void + */ + public static function panic(bool $send = true) + { + return static::status(500, $send); + } + + /** + * Sends a 503 header + * + * @return string|void + */ + public static function unavailable(bool $send = true) + { + return static::status(503, $send); + } + + /** + * Sends a redirect header + * + * @return string|void + */ + public static function redirect( + string $url, + int $code = 302, + bool $send = true + ) { + $status = static::status($code, false); + $location = 'Location:' . Url::unIdn($url); + + if ($send !== true) { + return $status . "\r\n" . $location; + } + + header($status); + header($location); + exit(); + } + + /** + * Sends download headers for anything that is downloadable + * + * @param array $params Check out the defaults array for available parameters + */ + public static function download(array $params = []): void + { + $defaults = [ + 'name' => 'download', + 'size' => false, + 'mime' => 'application/force-download', + 'modified' => time() + ]; + + $options = array_merge($defaults, $params); + + header('Pragma: public'); + header('Cache-Control: no-cache, no-store, must-revalidate'); + header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $options['modified']) . ' GMT'); + header('Content-Disposition: attachment; filename="' . $options['name'] . '"'); + header('Content-Transfer-Encoding: binary'); + + static::contentType($options['mime']); + + if ($options['size']) { + header('Content-Length: ' . $options['size']); + } + + header('Connection: close'); + } +} diff --git a/kirby/src/Http/Idn.php b/kirby/src/Http/Idn.php new file mode 100644 index 0000000..2ede8b3 --- /dev/null +++ b/kirby/src/Http/Idn.php @@ -0,0 +1,63 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Idn +{ + /** + * Convert domain name from IDNA ASCII to Unicode + */ + public static function decode(string $domain): string|false + { + return idn_to_utf8($domain); + } + + /** + * Convert domain name to IDNA ASCII form + */ + public static function encode(string $domain): string|false + { + return idn_to_ascii($domain); + } + + /** + * Decodes a email address to the Unicode format + */ + public static function decodeEmail(string $email): string + { + if (Str::contains($email, 'xn--') === true) { + $parts = Str::split($email, '@'); + $address = $parts[0]; + $domain = Idn::decode($parts[1] ?? ''); + $email = $address . '@' . $domain; + } + + return $email; + } + + /** + * Encodes a email address to the Punycode format + */ + public static function encodeEmail(string $email): string + { + if (mb_detect_encoding($email, 'ASCII', true) === false) { + $parts = Str::split($email, '@'); + $address = $parts[0]; + $domain = Idn::encode($parts[1] ?? ''); + $email = $address . '@' . $domain; + } + + return $email; + } +} diff --git a/kirby/src/Http/Params.php b/kirby/src/Http/Params.php new file mode 100644 index 0000000..4067a0f --- /dev/null +++ b/kirby/src/Http/Params.php @@ -0,0 +1,156 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Params extends Obj +{ + public static string|null $separator = null; + + /** + * Creates a new params object + */ + public function __construct(array|string|null $params) + { + if (is_string($params) === true) { + $params = static::extract($params)['params']; + } + + parent::__construct($params ?? []); + } + + /** + * Extract the params from a string or array + */ + public static function extract(string|array|null $path = null): array + { + if (empty($path) === true) { + return [ + 'path' => null, + 'params' => null, + 'slash' => false + ]; + } + + $slash = false; + + if (is_string($path) === true) { + $slash = substr($path, -1, 1) === '/'; + $path = Str::split($path, '/'); + } + + if (is_array($path) === true) { + $params = []; + $separator = static::separator(); + + foreach ($path as $index => $p) { + if (strpos($p, $separator) === false) { + continue; + } + + $paramParts = Str::split($p, $separator); + $paramKey = $paramParts[0] ?? null; + $paramValue = $paramParts[1] ?? null; + + if ($paramKey !== null) { + $params[rawurldecode($paramKey)] = $paramValue !== null ? rawurldecode($paramValue) : null; + } + + unset($path[$index]); + } + + return [ + 'path' => $path, + 'params' => $params, + 'slash' => $slash + ]; + } + + return [ + 'path' => null, + 'params' => null, + 'slash' => false + ]; + } + + public function isEmpty(): bool + { + return empty((array)$this) === true; + } + + public function isNotEmpty(): bool + { + return $this->isEmpty() === false; + } + + /** + * Returns the param separator according + * to the operating system. + * + * Unix = ':' + * Windows = ';' + */ + public static function separator(): string + { + if (static::$separator !== null) { + return static::$separator; + } + + if (DIRECTORY_SEPARATOR === '/') { + return static::$separator = ':'; + } + + return static::$separator = ';'; + } + + /** + * Converts the params object to a params string + * which can then be used in the URL builder again + */ + public function toString( + bool $leadingSlash = false, + bool $trailingSlash = false + ): string { + if ($this->isEmpty() === true) { + return ''; + } + + $params = []; + $separator = static::separator(); + + foreach ($this as $key => $value) { + if ($value !== null && $value !== '') { + $params[] = rawurlencode($key) . $separator . rawurlencode($value); + } + } + + if (empty($params) === true) { + return ''; + } + + $params = implode('/', $params); + + $leadingSlash = $leadingSlash === true ? '/' : null; + $trailingSlash = $trailingSlash === true ? '/' : null; + + return $leadingSlash . $params . $trailingSlash; + } + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/kirby/src/Http/Path.php b/kirby/src/Http/Path.php new file mode 100644 index 0000000..02d2b05 --- /dev/null +++ b/kirby/src/Http/Path.php @@ -0,0 +1,49 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Path extends Collection +{ + public function __construct(string|array|null $items) + { + if (is_string($items) === true) { + $items = Str::split($items, '/'); + } + + parent::__construct($items ?? []); + } + + public function __toString(): string + { + return $this->toString(); + } + + public function toString( + bool $leadingSlash = false, + bool $trailingSlash = false + ): string { + if (empty($this->data) === true) { + return ''; + } + + $path = implode('/', $this->data); + + $leadingSlash = $leadingSlash === true ? '/' : null; + $trailingSlash = $trailingSlash === true ? '/' : null; + + return $leadingSlash . $path . $trailingSlash; + } +} diff --git a/kirby/src/Http/Query.php b/kirby/src/Http/Query.php new file mode 100644 index 0000000..410e2f0 --- /dev/null +++ b/kirby/src/Http/Query.php @@ -0,0 +1,59 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Query extends Obj +{ + public function __construct(string|array|null $query) + { + if (is_string($query) === true) { + parse_str(ltrim($query, '?'), $query); + } + + parent::__construct($query ?? []); + } + + public function isEmpty(): bool + { + return empty((array)$this) === true; + } + + public function isNotEmpty(): bool + { + return $this->isEmpty() === false; + } + + public function toString(bool $questionMark = false): string + { + $query = http_build_query($this, '', '&', PHP_QUERY_RFC3986); + + if (empty($query) === true) { + return ''; + } + + if ($questionMark === true) { + $query = '?' . $query; + } + + return $query; + } + + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/kirby/src/Http/Remote.php b/kirby/src/Http/Remote.php new file mode 100644 index 0000000..ce93bb3 --- /dev/null +++ b/kirby/src/Http/Remote.php @@ -0,0 +1,361 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Remote +{ + public const CA_INTERNAL = 1; + public const CA_SYSTEM = 2; + + public static array $defaults = [ + 'agent' => null, + 'basicAuth' => null, + 'body' => true, + 'ca' => self::CA_INTERNAL, + 'data' => [], + 'encoding' => 'utf-8', + 'file' => null, + 'headers' => [], + 'method' => 'GET', + 'progress' => null, + 'test' => false, + 'timeout' => 10, + ]; + + public string|null $content = null; + public CurlHandle|false $curl; + public array $curlopt = []; + public int $errorCode; + public string $errorMessage; + public array $headers = []; + public array $info = []; + public array $options = []; + + /** + * @throws \Exception when the curl request failed + */ + public function __construct(string $url, array $options = []) + { + $defaults = static::$defaults; + + // use the system CA store by default if + // one has been configured in php.ini + $cainfo = ini_get('curl.cainfo'); + if (empty($cainfo) === false && is_file($cainfo) === true) { + $defaults['ca'] = self::CA_SYSTEM; + } + + // update the defaults with App config if set; + // request the App instance lazily + if ($app = App::instance(null, true)) { + $defaults = array_merge($defaults, $app->option('remote', [])); + } + + // set all options + $this->options = array_merge($defaults, $options); + + // add the url + $this->options['url'] = $url; + + // send the request + $this->fetch(); + } + + /** + * Magic getter for request info data + */ + public function __call(string $method, array $arguments = []) + { + $method = str_replace('-', '_', Str::kebab($method)); + return $this->info[$method] ?? null; + } + + public static function __callStatic( + string $method, + array $arguments = [] + ): static { + return new static( + url: $arguments[0], + options: array_merge( + ['method' => strtoupper($method)], + $arguments[1] ?? [] + ) + ); + } + + /** + * Returns the http status code + */ + public function code(): int|null + { + return $this->info['http_code'] ?? null; + } + + /** + * Returns the response content + */ + public function content(): string|null + { + return $this->content; + } + + /** + * Sets up all curl options and sends the request + * + * @return $this + * @throws \Exception when the curl request failed + */ + public function fetch(): static + { + // curl options + $this->curlopt = [ + CURLOPT_URL => $this->options['url'], + CURLOPT_ENCODING => $this->options['encoding'], + CURLOPT_CONNECTTIMEOUT => $this->options['timeout'], + CURLOPT_TIMEOUT => $this->options['timeout'], + CURLOPT_AUTOREFERER => true, + CURLOPT_RETURNTRANSFER => $this->options['body'], + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 10, + CURLOPT_HEADER => false, + CURLOPT_HEADERFUNCTION => function ($curl, $header): int { + $parts = Str::split($header, ':'); + + if (empty($parts[0]) === false && empty($parts[1]) === false) { + $key = array_shift($parts); + $this->headers[$key] = implode(':', $parts); + } + + return strlen($header); + } + ]; + + // determine the TLS CA to use + if ($this->options['ca'] === self::CA_INTERNAL) { + $this->curlopt[CURLOPT_SSL_VERIFYPEER] = true; + $this->curlopt[CURLOPT_CAINFO] = dirname(__DIR__, 2) . '/cacert.pem'; + } elseif ($this->options['ca'] === self::CA_SYSTEM) { + $this->curlopt[CURLOPT_SSL_VERIFYPEER] = true; + } elseif ($this->options['ca'] === false) { + $this->curlopt[CURLOPT_SSL_VERIFYPEER] = false; + } elseif ( + is_string($this->options['ca']) === true && + is_file($this->options['ca']) === true + ) { + $this->curlopt[CURLOPT_SSL_VERIFYPEER] = true; + $this->curlopt[CURLOPT_CAINFO] = $this->options['ca']; + } elseif ( + is_string($this->options['ca']) === true && + is_dir($this->options['ca']) === true + ) { + $this->curlopt[CURLOPT_SSL_VERIFYPEER] = true; + $this->curlopt[CURLOPT_CAPATH] = $this->options['ca']; + } else { + throw new InvalidArgumentException('Invalid "ca" option for the Remote class'); + } + + // add the progress + if (is_callable($this->options['progress']) === true) { + $this->curlopt[CURLOPT_NOPROGRESS] = false; + $this->curlopt[CURLOPT_PROGRESSFUNCTION] = $this->options['progress']; + } + + // add all headers + if (empty($this->options['headers']) === false) { + // convert associative arrays to strings + $headers = []; + foreach ($this->options['headers'] as $key => $value) { + if (is_string($key) === true) { + $value = $key . ': ' . $value; + } + + $headers[] = $value; + } + + $this->curlopt[CURLOPT_HTTPHEADER] = $headers; + } + + // add HTTP Basic authentication + if (empty($this->options['basicAuth']) === false) { + $this->curlopt[CURLOPT_USERPWD] = $this->options['basicAuth']; + } + + // add the user agent + if (empty($this->options['agent']) === false) { + $this->curlopt[CURLOPT_USERAGENT] = $this->options['agent']; + } + + // do some request specific stuff + switch (strtoupper($this->options['method'])) { + case 'POST': + $this->curlopt[CURLOPT_POST] = true; + $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'POST'; + $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']); + break; + case 'PUT': + $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'PUT'; + $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']); + + // put a file + if ($this->options['file']) { + $this->curlopt[CURLOPT_INFILE] = fopen($this->options['file'], 'r'); + $this->curlopt[CURLOPT_INFILESIZE] = F::size($this->options['file']); + } + break; + case 'PATCH': + $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'PATCH'; + $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']); + break; + case 'DELETE': + $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'DELETE'; + $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']); + break; + case 'HEAD': + $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'HEAD'; + $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']); + $this->curlopt[CURLOPT_NOBODY] = true; + break; + } + + if ($this->options['test'] === true) { + return $this; + } + + // start a curl request + $this->curl = curl_init(); + + curl_setopt_array($this->curl, $this->curlopt); + + $this->content = curl_exec($this->curl); + $this->info = curl_getinfo($this->curl); + $this->errorCode = curl_errno($this->curl); + $this->errorMessage = curl_error($this->curl); + + if ($this->errorCode) { + throw new Exception($this->errorMessage, $this->errorCode); + } + + curl_close($this->curl); + + return $this; + } + + /** + * Static method to send a GET request + * + * @throws \Exception when the curl request failed + */ + public static function get(string $url, array $params = []): static + { + $defaults = [ + 'method' => 'GET', + 'data' => [], + ]; + + $options = array_merge($defaults, $params); + $query = http_build_query($options['data']); + + if (empty($query) === false) { + $url = match (Url::hasQuery($url)) { + true => $url . '&' . $query, + default => $url . '?' . $query + }; + } + + // remove the data array from the options + unset($options['data']); + + return new static($url, $options); + } + + /** + * Returns all received headers + */ + public function headers(): array + { + return $this->headers; + } + + /** + * Returns the request info + */ + public function info(): array + { + return $this->info; + } + + /** + * Decode the response content + * + * @param bool $array decode as array or object + */ + public function json(bool $array = true): array|stdClass|null + { + return json_decode($this->content(), $array); + } + + /** + * Returns the request method + */ + public function method(): string + { + return $this->options['method']; + } + + /** + * Returns all options which have been + * set for the current request + */ + public function options(): array + { + return $this->options; + } + + /** + * Internal method to handle post field data + */ + protected function postfields($data) + { + if (is_object($data) || is_array($data)) { + return http_build_query($data); + } + + return $data; + } + + /** + * Static method to init this class and send a request + * + * @throws \Exception when the curl request failed + */ + public static function request(string $url, array $params = []): static + { + return new static($url, $params); + } + + /** + * Returns the request Url + */ + public function url(): string + { + return $this->options['url']; + } +} diff --git a/kirby/src/Http/Request.php b/kirby/src/Http/Request.php new file mode 100644 index 0000000..85178a9 --- /dev/null +++ b/kirby/src/Http/Request.php @@ -0,0 +1,426 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Request +{ + public static array $authTypes = [ + 'basic' => BasicAuth::class, + 'bearer' => BearerAuth::class, + 'session' => SessionAuth::class, + ]; + + /** + * The auth object if available + */ + protected Auth|false|null $auth = null; + + /** + * The Body object is a wrapper around + * the request body, which parses the contents + * of the body and provides an API to fetch + * particular parts of the body + * + * Examples: + * + * `$request->body()->get('foo')` + */ + protected Body|null $body = null; + + /** + * The Files object is a wrapper around + * the $_FILES global. It sanitizes the + * $_FILES array and provides an API to fetch + * individual files by key + * + * Examples: + * + * `$request->files()->get('upload')['size']` + * `$request->file('upload')['size']` + */ + protected Files|null $files = null; + + /** + * The Method type + */ + protected string $method; + + /** + * All options that have been passed to + * the request in the constructor + */ + protected array $options; + + /** + * The Query object is a wrapper around + * the URL query string, which parses the + * string and provides a clean API to fetch + * particular parts of the query + * + * Examples: + * + * `$request->query()->get('foo')` + */ + protected Query $query; + + /** + * Request URL object + */ + protected Uri $url; + + /** + * Creates a new Request object + * You can either pass your own request + * data via the $options array or use + * the data from the incoming request. + */ + public function __construct(array $options = []) + { + $this->options = $options; + $this->method = $this->detectRequestMethod($options['method'] ?? null); + + if (isset($options['body']) === true) { + $this->body = + $options['body'] instanceof Body + ? $options['body'] + : new Body($options['body']); + } + + if (isset($options['files']) === true) { + $this->files = + $options['files'] instanceof Files + ? $options['files'] + : new Files($options['files']); + } + + if (isset($options['query']) === true) { + $this->query = + $options['query'] instanceof Query + ? $options['query'] + : new Query($options['query']); + } + + if (isset($options['url']) === true) { + $this->url = + $options['url'] instanceof Uri + ? $options['url'] + : new Uri($options['url']); + } + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return [ + 'body' => $this->body(), + 'files' => $this->files(), + 'method' => $this->method(), + 'query' => $this->query(), + 'url' => $this->url()->toString() + ]; + } + + /** + * Returns the Auth object if authentication is set + */ + public function auth(): Auth|false|null + { + if ($this->auth !== null) { + return $this->auth; + } + + // lazily request the instance for non-CMS use cases + $kirby = App::instance(null, true); + + // tell the CMS responder that the response relies on + // the `Authorization` header and its value (even if + // the header isn't set in the current request); + // this ensures that the response is only cached for + // unauthenticated visitors; + // https://github.com/getkirby/kirby/issues/4423#issuecomment-1166300526 + $kirby?->response()->usesAuth(true); + + if ($auth = $this->authString()) { + $type = Str::lower(Str::before($auth, ' ')); + $data = Str::after($auth, ' '); + + $class = static::$authTypes[$type] ?? null; + if (!$class || class_exists($class) === false) { + return $this->auth = false; + } + + $object = new $class($data); + + return $this->auth = $object; + } + + return $this->auth = false; + } + + /** + * Returns the Body object + */ + public function body(): Body + { + return $this->body ??= new Body(); + } + + /** + * Checks if the request has been made from the command line + */ + public function cli(): bool + { + return $this->options['cli'] ?? (new Environment())->cli(); + } + + /** + * Returns a CSRF token if stored in a header or the query + */ + public function csrf(): string|null + { + return $this->header('x-csrf') ?? $this->query()->get('csrf'); + } + + /** + * Returns the request input as array + */ + public function data(): array + { + return array_replace( + $this->body()->toArray(), + $this->query()->toArray() + ); + } + + /** + * Detect the request method from various + * options: given method, query string, server vars + */ + public function detectRequestMethod(string|null $method = null): string + { + // all possible methods + $methods = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE', 'PATCH']; + + // the request method can be overwritten with a header + $methodOverride = strtoupper(Environment::getGlobally('HTTP_X_HTTP_METHOD_OVERRIDE', '')); + + if (in_array($methodOverride, $methods) === true) { + $method ??= $methodOverride; + } + + // final chain of options to detect the method + $method ??= Environment::getGlobally('REQUEST_METHOD', 'GET'); + + // uppercase the shit out of it + $method = strtoupper($method); + + // sanitize the method + if (in_array($method, $methods) === false) { + $method = 'GET'; + } + + return $method; + } + + /** + * Returns the domain + */ + public function domain(): string + { + return $this->url()->domain(); + } + + /** + * Fetches a single file array + * from the Files object by key + */ + public function file(string $key): array|null + { + return $this->files()->get($key); + } + + /** + * Returns the Files object + */ + public function files(): Files + { + return $this->files ??= new Files(); + } + + /** + * Returns any data field from the request + * if it exists + */ + public function get(string|array|null $key = null, $fallback = null) + { + return A::get($this->data(), $key, $fallback); + } + + /** + * Returns whether the request contains + * the `Authorization` header + * @since 3.7.0 + */ + public function hasAuth(): bool + { + return $this->authString() !== null; + } + + /** + * Returns a header by key if it exists + */ + public function header(string $key, $fallback = null) + { + $headers = array_change_key_case($this->headers()); + return $headers[strtolower($key)] ?? $fallback; + } + + /** + * Return all headers with polyfill for + * missing getallheaders function + */ + public function headers(): array + { + $headers = []; + + foreach (Environment::getGlobally() as $key => $value) { + if ( + substr($key, 0, 5) !== 'HTTP_' && + substr($key, 0, 14) !== 'REDIRECT_HTTP_' + ) { + continue; + } + + // remove HTTP_ + $key = str_replace(['REDIRECT_HTTP_', 'HTTP_'], '', $key); + + // convert to lowercase + $key = strtolower($key); + + // replace _ with spaces + $key = str_replace('_', ' ', $key); + + // uppercase first char in each word + $key = ucwords($key); + + // convert spaces to dashes + $key = str_replace(' ', '-', $key); + + $headers[$key] = $value; + } + + return $headers; + } + + /** + * Checks if the given method name + * matches the name of the request method. + */ + public function is(string $method): bool + { + return strtoupper($this->method) === strtoupper($method); + } + + /** + * Returns the request method + */ + public function method(): string + { + return $this->method; + } + + /** + * Shortcut to the Params object + */ + public function params(): Params + { + return $this->url()->params(); + } + + /** + * Shortcut to the Path object + */ + public function path(): Path + { + return $this->url()->path(); + } + + /** + * Returns the Query object + */ + public function query(): Query + { + return $this->query ??= new Query(); + } + + /** + * Checks for a valid SSL connection + */ + public function ssl(): bool + { + return $this->url()->scheme() === 'https'; + } + + /** + * Returns the current Uri object. + * If you pass props you can safely modify + * the Url with new parameters without destroying + * the original object. + */ + public function url(array|null $props = null): Uri + { + if ($props !== null) { + return $this->url()->clone($props); + } + + return $this->url ??= Uri::current(); + } + + /** + * Returns the raw auth string from the `auth` option + * or `Authorization` header unless both are empty + */ + protected function authString(): string|null + { + // both variants need to be checked separately + // because empty strings are treated as invalid + // but the `??` operator wouldn't do the fallback + + $option = $this->options['auth'] ?? null; + if (empty($option) === false) { + return $option; + } + + $header = $this->header('authorization'); + if (empty($header) === false) { + return $header; + } + + return null; + } +} diff --git a/kirby/src/Http/Request/Auth.php b/kirby/src/Http/Request/Auth.php new file mode 100644 index 0000000..e73f4da --- /dev/null +++ b/kirby/src/Http/Request/Auth.php @@ -0,0 +1,48 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class Auth +{ + /** + * @param string $data Raw authentication data after the first space in the `Authorization` header + */ + public function __construct( + #[SensitiveParameter] + protected string $data + ) { + } + + /** + * Converts the object to a string + */ + public function __toString(): string + { + return ucfirst($this->type()) . ' ' . $this->data(); + } + + /** + * Returns the raw authentication data after the + * first space in the `Authorization` header + */ + public function data(): string + { + return $this->data; + } + + /** + * Returns the name of the auth type (lowercase) + */ + abstract public function type(): string; +} diff --git a/kirby/src/Http/Request/Auth/BasicAuth.php b/kirby/src/Http/Request/Auth/BasicAuth.php new file mode 100644 index 0000000..f0e80ce --- /dev/null +++ b/kirby/src/Http/Request/Auth/BasicAuth.php @@ -0,0 +1,66 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class BasicAuth extends Auth +{ + protected string $credentials; + protected string|null $password; + protected string|null $username; + + public function __construct( + #[SensitiveParameter] + string $data + ) { + parent::__construct($data); + + $this->credentials = base64_decode($data); + $this->username = Str::before($this->credentials, ':'); + $this->password = Str::after($this->credentials, ':'); + } + + /** + * Returns the entire unencoded credentials string + */ + public function credentials(): string + { + return $this->credentials; + } + + /** + * Returns the password + */ + public function password(): string|null + { + return $this->password; + } + + /** + * Returns the authentication type + */ + public function type(): string + { + return 'basic'; + } + + /** + * Returns the username + */ + public function username(): string|null + { + return $this->username; + } +} diff --git a/kirby/src/Http/Request/Auth/BearerAuth.php b/kirby/src/Http/Request/Auth/BearerAuth.php new file mode 100644 index 0000000..81dc9a9 --- /dev/null +++ b/kirby/src/Http/Request/Auth/BearerAuth.php @@ -0,0 +1,33 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class BearerAuth extends Auth +{ + /** + * Returns the authentication token + */ + public function token(): string + { + return $this->data; + } + + /** + * Returns the auth type + */ + public function type(): string + { + return 'bearer'; + } +} diff --git a/kirby/src/Http/Request/Auth/SessionAuth.php b/kirby/src/Http/Request/Auth/SessionAuth.php new file mode 100644 index 0000000..ca10830 --- /dev/null +++ b/kirby/src/Http/Request/Auth/SessionAuth.php @@ -0,0 +1,43 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class SessionAuth extends Auth +{ + /** + * Tries to return the session object + */ + public function session(): Session + { + return App::instance()->sessionHandler()->getManually($this->data); + } + + /** + * Returns the session token + */ + public function token(): string + { + return $this->data; + } + + /** + * Returns the authentication type + */ + public function type(): string + { + return 'session'; + } +} diff --git a/kirby/src/Http/Request/Body.php b/kirby/src/Http/Request/Body.php new file mode 100644 index 0000000..53bcdd0 --- /dev/null +++ b/kirby/src/Http/Request/Body.php @@ -0,0 +1,115 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Body +{ + use Data; + + /** + * The raw body content + */ + protected string|array|null $contents; + + /** + * The parsed content as array + */ + protected array|null $data = null; + + /** + * Creates a new request body object. + * You can pass your own array or string. + * If null is being passed, the class will + * fetch the body either from the $_POST global + * or from php://input. + */ + public function __construct(array|string|null $contents = null) + { + $this->contents = $contents; + } + + /** + * Fetches the raw contents for the body + * or uses the passed contents. + */ + public function contents(): string|array + { + if ($this->contents !== null) { + return $this->contents; + } + + if (empty($_POST) === false) { + return $this->contents = $_POST; + } + + return $this->contents = file_get_contents('php://input'); + } + + /** + * Parses the raw contents once and caches + * the result. The parser will try to convert + * the body with the json decoder first and + * then run parse_str to get some results + * if the json decoder failed. + */ + public function data(): array + { + if (is_array($this->data) === true) { + return $this->data; + } + + $contents = $this->contents(); + + // return content which is already in array form + if (is_array($contents) === true) { + return $this->data = $contents; + } + + // try to convert the body from json + $json = json_decode($contents, true); + + if (is_array($json) === true) { + return $this->data = $json; + } + + if (strstr($contents, '=') !== false) { + // try to parse the body as query string + parse_str($contents, $parsed); + + if (is_array($parsed)) { + return $this->data = $parsed; + } + } + + return $this->data = []; + } + + /** + * Converts the data array back + * to a http query string + */ + public function toString(): string + { + return http_build_query($this->data()); + } + + /** + * Magic string converter + */ + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/kirby/src/Http/Request/Data.php b/kirby/src/Http/Request/Data.php new file mode 100644 index 0000000..d2f3d95 --- /dev/null +++ b/kirby/src/Http/Request/Data.php @@ -0,0 +1,73 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +trait Data +{ + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * The data provider method has to be + * implemented by each class using this Trait + * and has to return an associative array + * for the get method + */ + abstract public function data(): array; + + /** + * The get method is the heart and soul of this + * Trait. You can use it to fetch a single value + * of the data array by key or multiple values by + * passing an array of keys. + */ + public function get(string|array $key, $default = null) + { + if (is_array($key) === true) { + $result = []; + foreach ($key as $k) { + $result[$k] = $this->get($k); + } + return $result; + } + + return $this->data()[$key] ?? $default; + } + + /** + * Returns the data array. + * This is basically an alias for Data::data() + */ + public function toArray(): array + { + return $this->data(); + } + + /** + * Converts the data array to json + */ + public function toJson(): string + { + return json_encode($this->data()); + } +} diff --git a/kirby/src/Http/Request/Files.php b/kirby/src/Http/Request/Files.php new file mode 100644 index 0000000..244028a --- /dev/null +++ b/kirby/src/Http/Request/Files.php @@ -0,0 +1,63 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Files +{ + use Data; + + /** + * Sanitized array of all received files + */ + protected array $files = []; + + /** + * Creates a new Files object + * Pass your own array to mock + * uploads. + */ + public function __construct(array|null $files = null) + { + $files ??= $_FILES; + + foreach ($files as $key => $file) { + if (is_array($file['name'])) { + foreach ($file['name'] as $i => $name) { + $this->files[$key][] = [ + 'name' => $file['name'][$i] ?? null, + 'type' => $file['type'][$i] ?? null, + 'tmp_name' => $file['tmp_name'][$i] ?? null, + 'error' => $file['error'][$i] ?? null, + 'size' => $file['size'][$i] ?? null, + ]; + } + } else { + $this->files[$key] = $file; + } + } + } + + /** + * The data method returns the files + * array. This is only needed to make + * the Data trait work for the Files::get($key) + * method. + */ + public function data(): array + { + return $this->files; + } +} diff --git a/kirby/src/Http/Request/Query.php b/kirby/src/Http/Request/Query.php new file mode 100644 index 0000000..b6c4ad1 --- /dev/null +++ b/kirby/src/Http/Request/Query.php @@ -0,0 +1,84 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Query +{ + use Data; + + /** + * The Query data array + */ + protected array|null $data; + + /** + * Creates a new Query object. + * The passed data can be an array + * or a parsable query string. If + * null is passed, the current Query + * will be taken from $_GET + */ + public function __construct(array|string|null $data = null) + { + if ($data === null) { + $this->data = $_GET; + } elseif (is_array($data) === true) { + $this->data = $data; + } else { + parse_str($data, $parsed); + $this->data = $parsed; + } + } + + /** + * Returns the Query data as array + */ + public function data(): array + { + return $this->data; + } + + /** + * Returns `true` if the request doesn't contain query variables + */ + public function isEmpty(): bool + { + return empty($this->data) === true; + } + + /** + * Returns `true` if the request contains query variables + */ + public function isNotEmpty(): bool + { + return empty($this->data) === false; + } + + /** + * Converts the query data array + * back to a query string + */ + public function toString(): string + { + return http_build_query($this->data()); + } + + /** + * Magic string converter + */ + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/kirby/src/Http/Response.php b/kirby/src/Http/Response.php new file mode 100644 index 0000000..53892d6 --- /dev/null +++ b/kirby/src/Http/Response.php @@ -0,0 +1,319 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Response +{ + /** + * Store for all registered headers, + * which will be sent with the response + */ + protected array $headers = []; + + /** + * The response body + */ + protected string $body; + + /** + * The HTTP response code + */ + protected int $code; + + /** + * The content type for the response + */ + protected string $type; + + /** + * The content type charset + */ + protected string $charset = 'UTF-8'; + + /** + * Creates a new response object + */ + public function __construct( + string|array $body = '', + string|null $type = null, + int|null $code = null, + array|null $headers = null, + string|null $charset = null + ) { + // array construction + if (is_array($body) === true) { + $params = $body; + $body = $params['body'] ?? ''; + $type = $params['type'] ?? $type; + $code = $params['code'] ?? $code; + $headers = $params['headers'] ?? $headers; + $charset = $params['charset'] ?? $charset; + } + + // regular construction + $this->body = $body; + $this->type = $type ?? 'text/html'; + $this->code = $code ?? 200; + $this->headers = $headers ?? []; + $this->charset = $charset ?? 'UTF-8'; + + // automatic mime type detection + if (strpos($this->type, '/') === false) { + $this->type = F::extensionToMime($this->type) ?? 'text/html'; + } + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Makes it possible to convert the + * entire response object to a string + * to send the headers and print the body + */ + public function __toString(): string + { + return $this->send(); + } + + /** + * Getter for the body + */ + public function body(): string + { + return $this->body; + } + + /** + * Getter for the content type charset + */ + public function charset(): string + { + return $this->charset; + } + + /** + * Getter for the HTTP status code + */ + public function code(): int + { + return $this->code; + } + + /** + * Creates a response that triggers + * a file download for the given file + * + * @param array $props Custom overrides for response props (e.g. headers) + */ + public static function download( + string $file, + string|null $filename = null, + array $props = [] + ): static { + if (file_exists($file) === false) { + throw new Exception('The file could not be found'); + } + + $filename ??= basename($file); + $modified = filemtime($file); + $body = file_get_contents($file); + $size = strlen($body); + + $props = array_replace_recursive([ + 'body' => $body, + 'type' => F::mime($file), + 'headers' => [ + 'Pragma' => 'public', + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + 'Last-Modified' => gmdate('D, d M Y H:i:s', $modified) . ' GMT', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + 'Content-Transfer-Encoding' => 'binary', + 'Content-Length' => $size, + 'Connection' => 'close' + ] + ], $props); + + return new static($props); + } + + /** + * Creates a response for a file and + * sends the file content to the browser + * + * @param array $props Custom overrides for response props (e.g. headers) + */ + public static function file(string $file, array $props = []): static + { + $props = array_merge([ + 'body' => F::read($file), + 'type' => F::extensionToMime(F::extension($file)) + ], $props); + + // if we couldn't serve a correct MIME type, force + // the browser to display the file as plain text to + // harden against attacks from malicious file uploads + if ($props['type'] === null) { + if (isset($props['headers']) !== true) { + $props['headers'] = []; + } + + $props['type'] = 'text/plain'; + $props['headers']['X-Content-Type-Options'] = 'nosniff'; + } + + return new static($props); + } + + + /** + * Redirects to the given Urls + * Urls can be relative or absolute. + * @since 3.7.0 + * + * @codeCoverageIgnore + */ + public static function go(string $url = '/', int $code = 302): never + { + die(static::redirect($url, $code)); + } + + /** + * Ensures that the callback does not produce the first body output + * (used to show when loading a file creates side effects) + */ + public static function guardAgainstOutput(Closure $callback, ...$args): mixed + { + $before = headers_sent(); + $result = $callback(...$args); + $after = headers_sent($file, $line); + + if ($before === false && $after === true) { + throw new LogicException("Disallowed output from file $file:$line, possible accidental whitespace?"); + } + + return $result; + } + + /** + * Getter for single headers + * + * @param string $key Name of the header + */ + public function header(string $key): string|null + { + return $this->headers[$key] ?? null; + } + + /** + * Getter for all headers + */ + public function headers(): array + { + return $this->headers; + } + + /** + * Creates a json response with appropriate + * header and automatic conversion of arrays. + */ + public static function json( + string|array $body = '', + int|null $code = null, + bool|null $pretty = null, + array $headers = [] + ): static { + if (is_array($body) === true) { + $body = json_encode($body, $pretty === true ? JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES : 0); + } + + return new static([ + 'body' => $body, + 'code' => $code, + 'type' => 'application/json', + 'headers' => $headers + ]); + } + + /** + * Creates a redirect response, + * which will send the visitor to the + * given location. + */ + public static function redirect(string $location = '/', int $code = 302): static + { + return new static([ + 'code' => $code, + 'headers' => [ + 'Location' => Url::unIdn($location) + ] + ]); + } + + /** + * Sends all registered headers and + * returns the response body + */ + public function send(): string + { + // send the status response code + http_response_code($this->code()); + + // send all custom headers + foreach ($this->headers() as $key => $value) { + header($key . ': ' . $value); + } + + // send the content type header + header('Content-Type:' . $this->type() . '; charset=' . $this->charset()); + + // print the response body + return $this->body(); + } + + /** + * Converts all relevant response attributes + * to an associative array for debugging, + * testing or whatever. + */ + public function toArray(): array + { + return [ + 'type' => $this->type(), + 'charset' => $this->charset(), + 'code' => $this->code(), + 'headers' => $this->headers(), + 'body' => $this->body() + ]; + } + + /** + * Getter for the content type + */ + public function type(): string + { + return $this->type; + } +} diff --git a/kirby/src/Http/Route.php b/kirby/src/Http/Route.php new file mode 100644 index 0000000..7d6dd7d --- /dev/null +++ b/kirby/src/Http/Route.php @@ -0,0 +1,192 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Route +{ + /** + * The callback action function + */ + protected Closure $action; + + /** + * Listed of parsed arguments + */ + protected array $arguments = []; + + /** + * An array of all passed attributes + */ + protected array $attributes = []; + + /** + * The registered request method + */ + protected string $method; + + /** + * The registered pattern + */ + protected string $pattern; + + /** + * Wildcards, which can be used in + * Route patterns to make regular expressions + * a little more human + */ + protected array $wildcards = [ + 'required' => [ + '(:num)' => '(-?[0-9]+)', + '(:alpha)' => '([a-zA-Z]+)', + '(:alphanum)' => '([a-zA-Z0-9]+)', + '(:any)' => '([a-zA-Z0-9\.\-_%= \+\@\(\)]+)', + '(:all)' => '(.*)', + ], + 'optional' => [ + '/(:num?)' => '(?:/(-?[0-9]+)', + '/(:alpha?)' => '(?:/([a-zA-Z]+)', + '/(:alphanum?)' => '(?:/([a-zA-Z0-9]+)', + '/(:any?)' => '(?:/([a-zA-Z0-9\.\-_%= \+\@\(\)]+)', + '/(:all?)' => '(?:/(.*)', + ], + ]; + + /** + * Magic getter for route attributes + */ + public function __call(string $key, array $args = null): mixed + { + return $this->attributes[$key] ?? null; + } + + /** + * Creates a new Route object for the given + * pattern(s), method(s) and the callback action + */ + public function __construct( + string $pattern, + string $method, + Closure $action, + array $attributes = [] + ) { + $this->action = $action; + $this->attributes = $attributes; + $this->method = $method; + $this->pattern = $this->regex(ltrim($pattern, '/')); + } + + /** + * Getter for the action callback + */ + public function action(): Closure + { + return $this->action; + } + + /** + * Returns all parsed arguments + */ + public function arguments(): array + { + return $this->arguments; + } + + /** + * Getter for additional attributes + */ + public function attributes(): array + { + return $this->attributes; + } + + /** + * Getter for the method + */ + public function method(): string + { + return $this->method; + } + + /** + * Returns the route name if set + */ + public function name(): string|null + { + return $this->attributes['name'] ?? null; + } + + /** + * Throws a specific exception to tell + * the router to jump to the next route + * @since 3.0.3 + */ + public static function next(): void + { + throw new Exceptions\NextRouteException('next'); + } + + /** + * Getter for the pattern + */ + public function pattern(): string + { + return $this->pattern; + } + + /** + * Converts the pattern into a full regular + * expression by replacing all the wildcards + */ + public function regex(string $pattern): string + { + $search = array_keys($this->wildcards['optional']); + $replace = array_values($this->wildcards['optional']); + + // For optional parameters, first translate the wildcards to their + // regex equivalent, sans the ")?" ending. We'll add the endings + // back on when we know the replacement count. + $pattern = str_replace($search, $replace, $pattern, $count); + + if ($count > 0) { + $pattern .= str_repeat(')?', $count); + } + + return strtr($pattern, $this->wildcards['required']); + } + + /** + * Tries to match the path with the regular expression and + * extracts all arguments for the Route action + */ + public function parse(string $pattern, string $path): array|false + { + // check for direct matches + if ($pattern === $path) { + return $this->arguments = []; + } + + // We only need to check routes with regular expression since all others + // would have been able to be matched by the search for literal matches + // we just did before we started searching. + if (strpos($pattern, '(') === false) { + return false; + } + + // If we have a match we'll return all results + // from the preg without the full first match. + if (preg_match('#^' . $this->regex($pattern) . '$#u', $path, $parameters)) { + return $this->arguments = array_slice($parameters, 1); + } + + return false; + } +} diff --git a/kirby/src/Http/Router.php b/kirby/src/Http/Router.php new file mode 100644 index 0000000..f561bd3 --- /dev/null +++ b/kirby/src/Http/Router.php @@ -0,0 +1,198 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Router +{ + /** + * Hook that is called after each route + */ + protected Closure|null $afterEach; + + /** + * Hook that is called before each route + */ + protected Closure|null $beforeEach; + + /** + * Store for the current route, + * if one can be found + */ + protected Route|null $route = null; + + /** + * All registered routes, sorted by + * their request method. This makes + * it faster to find the right route + * later. + */ + protected array $routes = [ + 'GET' => [], + 'HEAD' => [], + 'POST' => [], + 'PUT' => [], + 'DELETE' => [], + 'CONNECT' => [], + 'OPTIONS' => [], + 'TRACE' => [], + 'PATCH' => [], + ]; + + /** + * Creates a new router object and + * registers all the given routes + * + * @param array $hooks Optional `beforeEach` and `afterEach` hooks + */ + public function __construct(array $routes = [], array $hooks = []) + { + $this->beforeEach = $hooks['beforeEach'] ?? null; + $this->afterEach = $hooks['afterEach'] ?? null; + + foreach ($routes as $props) { + if (isset($props['pattern'], $props['action']) === false) { + throw new InvalidArgumentException('Invalid route parameters'); + } + + $patterns = A::wrap($props['pattern']); + $methods = A::map( + explode('|', strtoupper($props['method'] ?? 'GET')), + 'trim' + ); + + if ($methods === ['ALL']) { + $methods = array_keys($this->routes); + } + + foreach ($methods as $method) { + foreach ($patterns as $pattern) { + $this->routes[$method][] = new Route( + $pattern, + $method, + $props['action'], + $props + ); + } + } + } + } + + /** + * Calls the Router by path and method. + * This will try to find a Route object + * and then call the Route action with + * the appropriate arguments and a Result + * object. + */ + public function call( + string|null $path = null, + string $method = 'GET', + Closure|null $callback = null + ) { + $path ??= ''; + $ignore = []; + $result = null; + $loop = true; + + while ($loop === true) { + $route = $this->find($path, $method, $ignore); + + if ($this->beforeEach instanceof Closure) { + ($this->beforeEach)($route, $path, $method); + } + + try { + if ($callback) { + $result = $callback($route); + } else { + $result = $route->action()->call( + $route, + ...$route->arguments() + ); + } + + $loop = false; + } catch (Exceptions\NextRouteException) { + $ignore[] = $route; + } + + if ($this->afterEach instanceof Closure) { + $final = $loop === false; + $result = ($this->afterEach)($route, $path, $method, $result, $final); + } + } + + return $result; + } + + /** + * Creates a micro-router and executes + * the routing action immediately + * @since 3.7.0 + */ + public static function execute( + string|null $path = null, + string $method = 'GET', + array $routes = [], + Closure|null $callback = null + ) { + return (new static($routes))->call($path, $method, $callback); + } + + /** + * Finds a Route object by path and method + * The Route's arguments method is used to + * find matches and return all the found + * arguments in the path. + */ + public function find( + string $path, + string $method, + array|null $ignore = null + ): Route { + if (isset($this->routes[$method]) === false) { + throw new InvalidArgumentException('Invalid routing method: ' . $method, 400); + } + + // remove leading and trailing slashes + $path = trim($path, '/'); + + foreach ($this->routes[$method] as $route) { + $arguments = $route->parse($route->pattern(), $path); + + if ($arguments !== false) { + if ( + empty($ignore) === true || + in_array($route, $ignore) === false + ) { + return $this->route = $route; + } + } + } + + throw new Exception('No route found for path: "' . $path . '" and request method: "' . $method . '"', 404); + } + + /** + * Returns the current route. + * This will only return something, + * once Router::find() has been called + * and only if a route was found. + */ + public function route(): Route|null + { + return $this->route; + } +} diff --git a/kirby/src/Http/Uri.php b/kirby/src/Http/Uri.php new file mode 100644 index 0000000..08c4618 --- /dev/null +++ b/kirby/src/Http/Uri.php @@ -0,0 +1,514 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Uri +{ + /** + * Cache for the current Uri object + */ + public static Uri|null $current = null; + + /** + * The fragment after the hash + */ + protected string|false|null $fragment; + + /** + * The host address + */ + protected string|null $host; + + /** + * The optional password for basic authentication + */ + protected string|false|null $password; + + /** + * The optional list of params + */ + protected Params $params; + + /** + * The optional path + */ + protected Path $path; + + /** + * The optional port number + */ + protected int|false|null $port; + + /** + * All original properties + */ + protected array $props; + + /** + * The optional query string without leading ? + */ + protected Query $query; + + /** + * https or http + */ + protected string|null $scheme; + + /** + * Supported schemes + */ + protected static array $schemes = ['http', 'https', 'ftp']; + + protected bool $slash; + + /** + * The optional username for basic authentication + */ + protected string|false|null $username = null; + + /** + * Creates a new URI object + * + * @param array $inject Additional props to inject if a URL string is passed + */ + public function __construct(array|string $props = [], array $inject = []) + { + if (is_string($props) === true) { + $props = parse_url($props); + $props['username'] = $props['user'] ?? null; + $props['password'] = $props['pass'] ?? null; + + $props = array_merge($props, $inject); + } + + // parse the path and extract params + if (empty($props['path']) === false) { + $props = static::parsePath($props); + } + + $this->props = $props; + $this->setFragment($props['fragment'] ?? null); + $this->setHost($props['host'] ?? null); + $this->setParams($props['params'] ?? null); + $this->setPassword($props['password'] ?? null); + $this->setPath($props['path'] ?? null); + $this->setPort($props['port'] ?? null); + $this->setQuery($props['query'] ?? null); + $this->setScheme($props['scheme'] ?? 'http'); + $this->setSlash($props['slash'] ?? false); + $this->setUsername($props['username'] ?? null); + } + + /** + * Magic caller to access all properties + */ + public function __call(string $property, array $arguments = []) + { + return $this->$property ?? null; + } + + /** + * Make sure that cloning also clones + * the path and query objects + */ + public function __clone() + { + $this->path = clone $this->path; + $this->query = clone $this->query; + $this->params = clone $this->params; + } + + /** + * Magic getter + */ + public function __get(string $property) + { + return $this->$property ?? null; + } + + /** + * Magic setter + */ + public function __set(string $property, $value): void + { + if (method_exists($this, 'set' . $property) === true) { + $this->{'set' . $property}($value); + } + } + + /** + * Converts the URL object to string + */ + public function __toString(): string + { + try { + return $this->toString(); + } catch (Throwable) { + return ''; + } + } + + /** + * Returns the auth details (username:password) + */ + public function auth(): string|null + { + $auth = trim($this->username . ':' . $this->password); + return $auth !== ':' ? $auth : null; + } + + /** + * Returns the base url (scheme + host) + * without trailing slash + */ + public function base(): string|null + { + if ($domain = $this->domain()) { + return $this->scheme ? $this->scheme . '://' . $domain : $domain; + } + + return null; + } + + /** + * Clones the Uri object and applies optional + * new props. + */ + public function clone(array $props = []): static + { + $clone = clone $this; + + foreach ($props as $key => $value) { + $clone->__set($key, $value); + } + + return $clone; + } + + public static function current(array $props = []): static + { + if (static::$current !== null) { + return static::$current; + } + + if ($app = App::instance(null, true)) { + $environment = $app->environment(); + } else { + $environment = new Environment(); + } + + return new static($environment->requestUrl(), $props); + } + + /** + * Returns the domain without scheme, path or query. + * Includes auth part when not empty. + * Includes port number when different from 80 or 443. + */ + public function domain(): string|null + { + if (empty($this->host) === true || $this->host === '/') { + return null; + } + + $auth = $this->auth(); + $domain = ''; + + if ($auth !== null) { + $domain .= $auth . '@'; + } + + $domain .= $this->host; + + if ( + $this->port !== null && + in_array($this->port, [80, 443]) === false + ) { + $domain .= ':' . $this->port; + } + + return $domain; + } + + public function hasFragment(): bool + { + return empty($this->fragment) === false; + } + + public function hasPath(): bool + { + return $this->path()->isNotEmpty(); + } + + public function hasQuery(): bool + { + return $this->query()->isNotEmpty(); + } + + public function https(): bool + { + return $this->scheme() === 'https'; + } + + /** + * Tries to convert the internationalized host + * name to the human-readable UTF8 representation + * + * @return $this + */ + public function idn(): static + { + if (empty($this->host) === false) { + $this->setHost(Idn::decode($this->host)); + } + return $this; + } + + /** + * Creates an Uri object for the URL to the index.php + * or any other executed script. + */ + public static function index(array $props = []): static + { + if ($app = App::instance(null, true)) { + $url = $app->url('index'); + } else { + $url = (new Environment())->baseUrl(); + } + + return new static($url, $props); + } + + /** + * Checks if the host exists + */ + public function isAbsolute(): bool + { + return empty($this->host) === false; + } + + /** + * @return $this + */ + public function setFragment(string|null $fragment = null): static + { + $this->fragment = $fragment ? ltrim($fragment, '#') : null; + return $this; + } + + /** + * @return $this + */ + public function setHost(string|null $host = null): static + { + $this->host = $host; + return $this; + } + + /** + * @return $this + */ + public function setParams(Params|string|array|false|null $params = null): static + { + // ensure that the special constructor value of `false` + // is never passed through as it's not supported by `Params` + if ($params === false) { + $params = []; + } + + $this->params = $params instanceof Params ? $params : new Params($params); + return $this; + } + + /** + * @return $this + */ + public function setPassword( + #[SensitiveParameter] + string|null $password = null + ): static { + $this->password = $password; + return $this; + } + + /** + * @return $this + */ + public function setPath(Path|string|array|null $path = null): static + { + $this->path = $path instanceof Path ? $path : new Path($path); + return $this; + } + + /** + * @return $this + */ + public function setPort(int|null $port = null): static + { + if ($port === 0) { + $port = null; + } + + if ($port !== null) { + if ($port < 1 || $port > 65535) { + throw new InvalidArgumentException('Invalid port format: ' . $port); + } + } + + $this->port = $port; + return $this; + } + + /** + * @return $this + */ + public function setQuery(Query|string|array|null $query = null): static + { + $this->query = $query instanceof Query ? $query : new Query($query); + return $this; + } + + /** + * @return $this + */ + public function setScheme(string|null $scheme = null): static + { + if ($scheme !== null && in_array($scheme, static::$schemes) === false) { + throw new InvalidArgumentException('Invalid URL scheme: ' . $scheme); + } + + $this->scheme = $scheme; + return $this; + } + + /** + * Set if a trailing slash should be added to + * the path when the URI is being built + * + * @return $this + */ + public function setSlash(bool $slash = false): static + { + $this->slash = $slash; + return $this; + } + + /** + * @return $this + */ + public function setUsername(string|null $username = null): static + { + $this->username = $username; + return $this; + } + + /** + * Converts the Url object to an array + */ + public function toArray(): array + { + $array = []; + + foreach ($this->props as $key => $value) { + $value = $this->$key; + + if (is_object($value) === true) { + $value = $value->toArray(); + } + + $array[$key] = $value; + } + + return $array; + } + + public function toJson(...$arguments): string + { + return json_encode($this->toArray(), ...$arguments); + } + + /** + * Returns the full URL as string + */ + public function toString(): string + { + $url = $this->base(); + $slash = true; + + if (empty($url) === true) { + $url = '/'; + $slash = false; + } + + $path = $this->path->toString($slash) . $this->params->toString(true); + + if ($this->slash && $slash === true) { + $path .= '/'; + } + + $url .= $path; + $url .= $this->query->toString(true); + + if (empty($this->fragment) === false) { + $url .= '#' . $this->fragment; + } + + return $url; + } + + /** + * Tries to convert a URL with an internationalized host + * name to the machine-readable Punycode representation + * + * @return $this + */ + public function unIdn(): static + { + if (empty($this->host) === false) { + $this->setHost(Idn::encode($this->host)); + } + return $this; + } + + /** + * Parses the path inside the props and extracts + * the params unless disabled + * + * @return array Modified props array + */ + protected static function parsePath(array $props): array + { + // extract params, the rest is the path; + // only do this if not explicitly disabled (set to `false`) + if (isset($props['params']) === false || $props['params'] !== false) { + $extract = Params::extract($props['path']); + $props['params'] ??= $extract['params']; + $props['path'] = $extract['path']; + $props['slash'] ??= $extract['slash']; + + return $props; + } + + // use the full path; + // automatically detect the trailing slash from it if possible + if (is_string($props['path']) === true) { + $props['slash'] = substr($props['path'], -1, 1) === '/'; + } + + return $props; + } +} diff --git a/kirby/src/Http/Url.php b/kirby/src/Http/Url.php new file mode 100644 index 0000000..622f350 --- /dev/null +++ b/kirby/src/Http/Url.php @@ -0,0 +1,249 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Url +{ + /** + * The base Url to build absolute Urls from + */ + public static string|null $home = '/'; + + /** + * The current Uri object as string + */ + public static string|null $current = null; + + /** + * Facade for all Uri object methods + */ + public static function __callStatic(string $method, array $arguments) + { + $uri = new Uri($arguments[0] ?? static::current()); + return $uri->$method(...array_slice($arguments, 1)); + } + + /** + * Url Builder + * Actually just a factory for `new Uri($parts)` + */ + public static function build( + array $parts = [], + string|null $url = null + ): string { + $url ??= static::current(); + $uri = new Uri($url); + return $uri->clone($parts)->toString(); + } + + /** + * Returns the current url with all bells and whistles + */ + public static function current(): string + { + return static::$current ??= static::toObject()->toString(); + } + + /** + * Returns the url for the current directory + */ + public static function currentDir(): string + { + return dirname(static::current()); + } + + /** + * Tries to fix a broken url without protocol + * @psalm-return ($url is null ? string|null : string) + */ + public static function fix(string|null $url = null): string|null + { + // make sure to not touch absolute urls + if (!preg_match('!^(https|http|ftp)\:\/\/!i', $url ?? '')) { + return 'http://' . $url; + } + + return $url; + } + + /** + * Returns the home url if defined + */ + public static function home(): string + { + return static::$home; + } + + /** + * Returns the url to the executed script + */ + public static function index(array $props = []): string + { + return Uri::index($props)->toString(); + } + + /** + * Checks if an URL is absolute + */ + public static function isAbsolute(string|null $url = null): bool + { + // matches the following groups of URLs: + // //example.com/uri + // http://example.com/uri, https://example.com/uri, ftp://example.com/uri + // mailto:example@example.com, geo:49.0158,8.3239?z=11 + return + $url !== null && + preg_match('!^(//|[a-z0-9+-.]+://|mailto:|tel:|geo:)!i', $url) === 1; + } + + /** + * Convert a relative path into an absolute URL + */ + public static function makeAbsolute(string|null $path = null, string|null $home = null): string + { + if ($path === '' || $path === '/' || $path === null) { + return $home ?? static::home(); + } + + if (substr($path, 0, 1) === '#') { + return $path; + } + + if (static::isAbsolute($path)) { + return $path; + } + + // build the full url + $path = ltrim($path, '/'); + $home ??= static::home(); + + if (empty($path) === true) { + return $home; + } + + return $home === '/' ? '/' . $path : $home . '/' . $path; + } + + /** + * Returns the path for the given url + */ + public static function path( + string|array|null $url = null, + bool $leadingSlash = false, + bool $trailingSlash = false + ): string { + return Url::toObject($url) + ->path() + ->toString($leadingSlash, $trailingSlash); + } + + /** + * Returns the query for the given url + */ + public static function query(string|array|null $url = null): string + { + return Url::toObject($url)->query()->toString(); + } + + /** + * Return the last url the user has been on if detectable + */ + public static function last(): string + { + return Environment::getGlobally('HTTP_REFERER', ''); + } + + /** + * Shortens the Url by removing all unnecessary parts + */ + public static function short( + string|null $url = null, + int $length = 0, + bool $base = false, + string $rep = '…' + ): string { + $uri = static::toObject($url); + + $uri->fragment = null; + $uri->query = null; + $uri->password = null; + $uri->port = null; + $uri->scheme = null; + $uri->username = null; + + // remove the trailing slash from the path + $uri->slash = false; + + $url = $base ? $uri->base() : $uri->toString(); + $url = str_replace('www.', '', $url); + + return Str::short($url, $length, $rep); + } + + /** + * Removes the path from the Url + */ + public static function stripPath(string|null $url = null): string + { + return static::toObject($url)->setPath(null)->toString(); + } + + /** + * Removes the query string from the Url + */ + public static function stripQuery(string|null $url = null): string + { + return static::toObject($url)->setQuery(null)->toString(); + } + + /** + * Removes the fragment (hash) from the Url + */ + public static function stripFragment(string|null $url = null): string + { + return static::toObject($url)->setFragment(null)->toString(); + } + + /** + * Smart resolver for internal and external urls + */ + public static function to( + string|null $path = null, + array $options = null + ): string { + // make sure $path is string + $path ??= ''; + + // keep relative urls + if (substr($path, 0, 2) === './' || substr($path, 0, 3) === '../') { + return $path; + } + + $url = static::makeAbsolute($path); + + if ($options === null) { + return $url; + } + + return (new Uri($url, $options))->toString(); + } + + /** + * Converts the Url to a Uri object + */ + public static function toObject(string|null $url = null): Uri + { + return $url === null ? Uri::current() : new Uri($url); + } +} diff --git a/kirby/src/Http/Visitor.php b/kirby/src/Http/Visitor.php new file mode 100644 index 0000000..ea2ac2e --- /dev/null +++ b/kirby/src/Http/Visitor.php @@ -0,0 +1,229 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Visitor +{ + protected string|null $ip = null; + protected string|null $userAgent = null; + protected string|null $acceptedLanguage = null; + protected string|null $acceptedMimeType = null; + + /** + * Creates a new visitor object. + * Optional arguments can be passed to + * modify the information about the visitor. + * + * By default everything is pulled from $_SERVER + */ + public function __construct(array $arguments = []) + { + $ip = $arguments['ip'] ?? null; + $ip ??= Environment::getGlobally('REMOTE_ADDR', ''); + $agent = $arguments['userAgent'] ?? null; + $agent ??= Environment::getGlobally('HTTP_USER_AGENT', ''); + $language = $arguments['acceptedLanguage'] ?? null; + $language ??= Environment::getGlobally('HTTP_ACCEPT_LANGUAGE', ''); + $mime = $arguments['acceptedMimeType'] ?? null; + $mime ??= Environment::getGlobally('HTTP_ACCEPT', ''); + + $this->ip($ip); + $this->userAgent($agent); + $this->acceptedLanguage($language); + $this->acceptedMimeType($mime); + } + + /** + * Sets the accepted language if + * provided or returns the user's + * accepted language otherwise + * + * @return $this|\Kirby\Toolkit\Obj|null + */ + public function acceptedLanguage( + string|null $acceptedLanguage = null + ): static|Obj|null { + if ($acceptedLanguage === null) { + return $this->acceptedLanguages()->first(); + } + + $this->acceptedLanguage = $acceptedLanguage; + return $this; + } + + /** + * Returns an array of all accepted languages + * including their quality and locale + */ + public function acceptedLanguages(): Collection + { + $accepted = Str::accepted($this->acceptedLanguage); + $languages = []; + + foreach ($accepted as $language) { + $value = $language['value']; + $parts = Str::split($value, '-'); + $code = isset($parts[0]) ? Str::lower($parts[0]) : null; + $region = isset($parts[1]) ? Str::upper($parts[1]) : null; + $locale = $region ? $code . '_' . $region : $code; + + $languages[$locale] = new Obj([ + 'code' => $code, + 'locale' => $locale, + 'original' => $value, + 'quality' => $language['quality'], + 'region' => $region, + ]); + } + + return new Collection($languages); + } + + /** + * Checks if the user accepts the given language + */ + public function acceptsLanguage(string $code): bool + { + $mode = Str::contains($code, '_') === true ? 'locale' : 'code'; + + foreach ($this->acceptedLanguages() as $language) { + if ($language->$mode() === $code) { + return true; + } + } + + return false; + } + + /** + * Sets the accepted mime type if + * provided or returns the user's + * accepted mime type otherwise + * + * @return $this|\Kirby\Toolkit\Obj|null + */ + public function acceptedMimeType( + string|null $acceptedMimeType = null + ): static|Obj|null { + if ($acceptedMimeType === null) { + return $this->acceptedMimeTypes()->first(); + } + + $this->acceptedMimeType = $acceptedMimeType; + return $this; + } + + /** + * Returns a collection of all accepted mime types + */ + public function acceptedMimeTypes(): Collection + { + $accepted = Str::accepted($this->acceptedMimeType); + $mimes = []; + + foreach ($accepted as $mime) { + $mimes[$mime['value']] = new Obj([ + 'type' => $mime['value'], + 'quality' => $mime['quality'], + ]); + } + + return new Collection($mimes); + } + + /** + * Checks if the user accepts the given mime type + */ + public function acceptsMimeType(string $mimeType): bool + { + return Mime::isAccepted($mimeType, $this->acceptedMimeType); + } + + /** + * Returns the MIME type from the provided list that + * is most accepted (= preferred) by the visitor + * @since 3.3.0 + * + * @param string ...$mimeTypes MIME types to query for + * @return string|null Preferred MIME type + */ + public function preferredMimeType(string ...$mimeTypes): string|null + { + foreach ($this->acceptedMimeTypes() as $acceptedMime) { + // look for direct matches + if (in_array($acceptedMime->type(), $mimeTypes)) { + return $acceptedMime->type(); + } + + // test each option against wildcard `Accept` values + foreach ($mimeTypes as $expectedMime) { + if (Mime::matches($expectedMime, $acceptedMime->type()) === true) { + return $expectedMime; + } + } + } + + return null; + } + + /** + * Returns true if the visitor prefers a JSON response over + * an HTML response based on the `Accept` request header + * @since 3.3.0 + */ + public function prefersJson(): bool + { + $preferred = $this->preferredMimeType('application/json', 'text/html'); + return $preferred === 'application/json'; + } + + /** + * Sets the ip address if provided + * or returns the ip of the current + * visitor otherwise + * + * @return $this|string|null + */ + public function ip(string|null $ip = null): static|string|null + { + if ($ip === null) { + return $this->ip; + } + + $this->ip = $ip; + return $this; + } + + /** + * Sets the user agent if provided + * or returns the user agent string of + * the current visitor otherwise + * + * @return $this|string|null + */ + public function userAgent(string|null $userAgent = null): static|string|null + { + if ($userAgent === null) { + return $this->userAgent; + } + + $this->userAgent = $userAgent; + return $this; + } +} diff --git a/kirby/src/Image/Camera.php b/kirby/src/Image/Camera.php new file mode 100644 index 0000000..c149c2e --- /dev/null +++ b/kirby/src/Image/Camera.php @@ -0,0 +1,68 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Camera +{ + protected string|null $make; + protected string|null $model; + + public function __construct(array $exif) + { + $this->make = $exif['Make'] ?? null; + $this->model = $exif['Model'] ?? null; + } + + /** + * Returns the make of the camera + */ + public function make(): string|null + { + return $this->make; + } + + /** + * Returns the camera model + */ + public function model(): string|null + { + return $this->model; + } + + /** + * Converts the object into a nicely readable array + */ + public function toArray(): array + { + return [ + 'make' => $this->make, + 'model' => $this->model + ]; + } + + /** + * Returns the full make + model name + */ + public function __toString(): string + { + return trim($this->make . ' ' . $this->model); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } +} diff --git a/kirby/src/Image/Darkroom.php b/kirby/src/Image/Darkroom.php new file mode 100644 index 0000000..a09747c --- /dev/null +++ b/kirby/src/Image/Darkroom.php @@ -0,0 +1,141 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Darkroom +{ + public static array $types = [ + 'gd' => GdLib::class, + 'im' => ImageMagick::class + ]; + + public function __construct( + protected array $settings = [] + ) { + $this->settings = array_merge($this->defaults(), $settings); + } + + /** + * Creates a new Darkroom instance for the given + * type/driver + * + * @throws \Exception + */ + public static function factory(string $type, array $settings = []): object + { + if (isset(static::$types[$type]) === false) { + throw new Exception('Invalid Darkroom type'); + } + + $class = static::$types[$type]; + return new $class($settings); + } + + /** + * Returns the default thumb settings + */ + protected function defaults(): array + { + return [ + 'autoOrient' => true, + 'blur' => false, + 'crop' => false, + 'format' => null, + 'grayscale' => false, + 'height' => null, + 'quality' => 90, + 'scaleHeight' => null, + 'scaleWidth' => null, + 'width' => null, + ]; + } + + /** + * Normalizes all thumb options + */ + protected function options(array $options = []): array + { + $options = array_merge($this->settings, $options); + + // normalize the crop option + if ($options['crop'] === true) { + $options['crop'] = 'center'; + } + + // normalize the blur option + if ($options['blur'] === true) { + $options['blur'] = 10; + } + + // normalize the greyscale option + if (isset($options['greyscale']) === true) { + $options['grayscale'] = $options['greyscale']; + unset($options['greyscale']); + } + + // normalize the bw option + if (isset($options['bw']) === true) { + $options['grayscale'] = $options['bw']; + unset($options['bw']); + } + + $options['quality'] ??= $this->settings['quality']; + + return $options; + } + + /** + * Calculates the dimensions of the final thumb based + * on the given options and returns a full array with + * all the final options to be used for the image generator + */ + public function preprocess(string $file, array $options = []): array + { + $options = $this->options($options); + $image = new Image($file); + + $options['sourceWidth'] = $image->width(); + $options['sourceHeight'] = $image->height(); + + $dimensions = $image->dimensions(); + $thumbDimensions = $dimensions->thumb($options); + + $options['width'] = $thumbDimensions->width(); + $options['height'] = $thumbDimensions->height(); + + // scale ratio compared to the source dimensions + $options['scaleWidth'] = Focus::ratio( + $options['width'], + $options['sourceWidth'] + ); + $options['scaleHeight'] = Focus::ratio( + $options['height'], + $options['sourceHeight'] + ); + + return $options; + } + + /** + * This method must be replaced by the driver to run the + * actual image processing job. + */ + public function process(string $file, array $options = []): array + { + return $this->preprocess($file, $options); + } +} diff --git a/kirby/src/Image/Darkroom/GdLib.php b/kirby/src/Image/Darkroom/GdLib.php new file mode 100644 index 0000000..8e05f72 --- /dev/null +++ b/kirby/src/Image/Darkroom/GdLib.php @@ -0,0 +1,130 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class GdLib extends Darkroom +{ + /** + * Processes the image with the SimpleImage library + */ + public function process(string $file, array $options = []): array + { + $options = $this->preprocess($file, $options); + $mime = $this->mime($options); + + $image = new SimpleImage(); + $image->fromFile($file); + + $image = $this->resize($image, $options); + $image = $this->autoOrient($image, $options); + $image = $this->blur($image, $options); + $image = $this->grayscale($image, $options); + + $image->toFile($file, $mime, $options); + + return $options; + } + + /** + * Activates the autoOrient option in SimpleImage + * unless this is deactivated + */ + protected function autoOrient(SimpleImage $image, array $options): SimpleImage + { + if ($options['autoOrient'] === false) { + return $image; + } + + return $image->autoOrient(); + } + + /** + * Wrapper around SimpleImage's resize and crop methods + */ + protected function resize(SimpleImage $image, array $options): SimpleImage + { + // just resize, no crop + if ($options['crop'] === false) { + return $image->resize($options['width'], $options['height']); + } + + // crop based on focus point + if (Focus::isFocalPoint($options['crop']) === true) { + // get crop coords for focal point: + // if image needs to be cropped, crop before resizing + if ($focus = Focus::coords( + $options['crop'], + $options['sourceWidth'], + $options['sourceHeight'], + $options['width'], + $options['height'] + )) { + $image->crop( + $focus['x1'], + $focus['y1'], + $focus['x2'], + $focus['y2'] + ); + } + + return $image->thumbnail($options['width'], $options['height']); + } + + // normal crop with crop anchor + return $image->thumbnail( + $options['width'], + $options['height'] ?? $options['width'], + $options['crop'] + ); + } + + /** + * Applies the correct blur settings for SimpleImage + */ + protected function blur(SimpleImage $image, array $options): SimpleImage + { + if ($options['blur'] === false) { + return $image; + } + + return $image->blur('gaussian', (int)$options['blur']); + } + + /** + * Applies grayscale conversion if activated in the options. + */ + protected function grayscale(SimpleImage $image, array $options): SimpleImage + { + if ($options['grayscale'] === false) { + return $image; + } + + return $image->desaturate(); + } + + /** + * Returns mime type based on `format` option + */ + protected function mime(array $options): string|null + { + if ($options['format'] === null) { + return null; + } + + return Mime::fromExtension($options['format']); + } +} diff --git a/kirby/src/Image/Darkroom/ImageMagick.php b/kirby/src/Image/Darkroom/ImageMagick.php new file mode 100644 index 0000000..ea7e8a1 --- /dev/null +++ b/kirby/src/Image/Darkroom/ImageMagick.php @@ -0,0 +1,239 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class ImageMagick extends Darkroom +{ + /** + * Activates imagemagick's auto-orient feature unless + * it is deactivated via the options + */ + protected function autoOrient(string $file, array $options): string|null + { + if ($options['autoOrient'] === true) { + return '-auto-orient'; + } + + return null; + } + + /** + * Applies the blur settings + */ + protected function blur(string $file, array $options): string|null + { + if ($options['blur'] !== false) { + return '-blur ' . escapeshellarg('0x' . $options['blur']); + } + + return null; + } + + /** + * Keep animated gifs + */ + protected function coalesce(string $file, array $options): string|null + { + if (F::extension($file) === 'gif') { + return '-coalesce'; + } + + return null; + } + + /** + * Creates the convert command with the right path to the binary file + */ + protected function convert(string $file, array $options): string + { + $command = escapeshellarg($options['bin']); + + // limit to single-threading to keep CPU usage sane + $command .= ' -limit thread 1'; + + // add JPEG size hint to optimize CPU and memory usage + if (F::mime($file) === 'image/jpeg') { + // add hint only when downscaling + if ($options['scaleWidth'] < 1 && $options['scaleHeight'] < 1) { + $command .= ' -define ' . escapeshellarg(sprintf('jpeg:size=%dx%d', $options['width'], $options['height'])); + } + } + + // append input file + return $command . ' ' . escapeshellarg($file); + } + + /** + * Returns additional default parameters for imagemagick + */ + protected function defaults(): array + { + return parent::defaults() + [ + 'bin' => 'convert', + 'interlace' => false, + ]; + } + + /** + * Applies the correct settings for grayscale images + */ + protected function grayscale(string $file, array $options): string|null + { + if ($options['grayscale'] === true) { + return '-colorspace gray'; + } + + return null; + } + + /** + * Applies the correct settings for interlaced JPEGs if + * activated via options + */ + protected function interlace(string $file, array $options): string|null + { + if ($options['interlace'] === true) { + return '-interlace line'; + } + + return null; + } + + /** + * Creates and runs the full imagemagick command + * to process the image + * + * @throws \Exception + */ + public function process(string $file, array $options = []): array + { + $options = $this->preprocess($file, $options); + $command = []; + + $command[] = $this->convert($file, $options); + $command[] = $this->strip($file, $options); + $command[] = $this->interlace($file, $options); + $command[] = $this->coalesce($file, $options); + $command[] = $this->grayscale($file, $options); + $command[] = $this->autoOrient($file, $options); + $command[] = $this->resize($file, $options); + $command[] = $this->quality($file, $options); + $command[] = $this->blur($file, $options); + $command[] = $this->save($file, $options); + + // remove all null values and join the parts + $command = implode(' ', array_filter($command)); + + // try to execute the command + exec($command, $output, $return); + + // log broken commands + if ($return !== 0) { + throw new Exception('The imagemagick convert command could not be executed: ' . $command); + } + + return $options; + } + + /** + * Applies the correct JPEG compression quality settings + */ + protected function quality(string $file, array $options): string + { + return '-quality ' . escapeshellarg($options['quality']); + } + + /** + * Creates the correct options to crop or resize the image + * and translates the crop positions for imagemagick + */ + protected function resize(string $file, array $options): string + { + // simple resize + if ($options['crop'] === false) { + return '-thumbnail ' . escapeshellarg(sprintf('%sx%s!', $options['width'], $options['height'])); + } + + // crop based on focus point + if (Focus::isFocalPoint($options['crop']) === true) { + if ($focus = Focus::coords( + $options['crop'], + $options['sourceWidth'], + $options['sourceHeight'], + $options['width'], + $options['height'] + )) { + return sprintf( + '-crop %sx%s+%s+%s -resize %sx%s^', + $focus['width'], + $focus['height'], + $focus['x1'], + $focus['y1'], + $options['width'], + $options['height'] + ); + } + } + + // translate the gravity option into something imagemagick understands + $gravity = match ($options['crop'] ?? null) { + 'top left' => 'NorthWest', + 'top' => 'North', + 'top right' => 'NorthEast', + 'left' => 'West', + 'right' => 'East', + 'bottom left' => 'SouthWest', + 'bottom' => 'South', + 'bottom right' => 'SouthEast', + default => 'Center' + }; + + $command = '-thumbnail ' . escapeshellarg(sprintf('%sx%s^', $options['width'], $options['height'])); + $command .= ' -gravity ' . escapeshellarg($gravity); + $command .= ' -crop ' . escapeshellarg(sprintf('%sx%s+0+0', $options['width'], $options['height'])); + + return $command; + } + + /** + * Creates the option for the output file + */ + protected function save(string $file, array $options): string + { + if ($options['format'] !== null) { + $file = pathinfo($file, PATHINFO_DIRNAME) . '/' . pathinfo($file, PATHINFO_FILENAME) . '.' . $options['format']; + } + + return escapeshellarg($file); + } + + /** + * Removes all metadata from the image + */ + protected function strip(string $file, array $options): string + { + if (F::extension($file) === 'png') { + // ImageMagick does not support keeping ICC profiles while + // stripping other privacy- and security-related information, + // such as GPS data; so discard all color profiles for PNG files + // (tested with ImageMagick 7.0.11-14 Q16 x86_64 2021-05-31) + return '-strip'; + } + + return ''; + } +} diff --git a/kirby/src/Image/Dimensions.php b/kirby/src/Image/Dimensions.php new file mode 100644 index 0000000..cf6d334 --- /dev/null +++ b/kirby/src/Image/Dimensions.php @@ -0,0 +1,409 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Dimensions +{ + public function __construct( + public int $width, + public int $height + ) { + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } + + /** + * Echos the dimensions as width × height + */ + public function __toString(): string + { + return $this->width . ' × ' . $this->height; + } + + /** + * Crops the dimensions by width and height + * + * @return $this + */ + public function crop(int $width, int|null $height = null): static + { + $this->width = $width; + $this->height = $width; + + if ($height !== 0 && $height !== null) { + $this->height = $height; + } + + return $this; + } + + /** + * Returns the height + */ + public function height(): int + { + return $this->height; + } + + /** + * Recalculates the width and height to fit into the given box. + * + * + * + * $dimensions = new Dimensions(1200, 768); + * $dimensions->fit(500); + * + * echo $dimensions->width(); + * // output: 500 + * + * echo $dimensions->height(); + * // output: 320 + * + * + * + * @param int $box the max width and/or height + * @param bool $force If true, the dimensions will be + * upscaled to fit the box if smaller + * @return $this object with recalculated dimensions + */ + public function fit(int $box, bool $force = false): static + { + if ($this->width === 0 || $this->height === 0) { + $this->width = $box; + $this->height = $box; + return $this; + } + + $ratio = $this->ratio(); + + if ($this->width > $this->height) { + // wider than tall + if ($this->width > $box || $force === true) { + $this->width = $box; + } + $this->height = (int)round($this->width / $ratio); + } elseif ($this->height > $this->width) { + // taller than wide + if ($this->height > $box || $force === true) { + $this->height = $box; + } + $this->width = (int)round($this->height * $ratio); + } elseif ($this->width > $box) { + // width = height but bigger than box + $this->width = $box; + $this->height = $box; + } + + return $this; + } + + /** + * Recalculates the width and height to fit the given height + * + * + * + * $dimensions = new Dimensions(1200, 768); + * $dimensions->fitHeight(500); + * + * echo $dimensions->width(); + * // output: 781 + * + * echo $dimensions->height(); + * // output: 500 + * + * + * + * @param int|null $fit the max height + * @param bool $force If true, the dimensions will be + * upscaled to fit the box if smaller + * @return $this object with recalculated dimensions + */ + public function fitHeight( + int|null $fit = null, + bool $force = false + ): static { + return $this->fitSize('height', $fit, $force); + } + + /** + * Helper for fitWidth and fitHeight methods + * + * @param string $ref reference (width or height) + * @param int|null $fit the max width + * @param bool $force If true, the dimensions will be + * upscaled to fit the box if smaller + * @return $this object with recalculated dimensions + */ + protected function fitSize( + string $ref, + int|null $fit = null, + bool $force = false + ): static { + if ($fit === 0 || $fit === null) { + return $this; + } + + if ($this->$ref <= $fit && !$force) { + return $this; + } + + $ratio = $this->ratio(); + $mode = $ref === 'width'; + $this->width = $mode ? $fit : (int)round($fit * $ratio); + $this->height = !$mode ? $fit : (int)round($fit / $ratio); + + return $this; + } + + /** + * Recalculates the width and height to fit the given width + * + * + * + * $dimensions = new Dimensions(1200, 768); + * $dimensions->fitWidth(500); + * + * echo $dimensions->width(); + * // output: 500 + * + * echo $dimensions->height(); + * // output: 320 + * + * + * + * @param int|null $fit the max width + * @param bool $force If true, the dimensions will be + * upscaled to fit the box if smaller + * @return $this object with recalculated dimensions + */ + public function fitWidth( + int|null $fit = null, + bool $force = false + ): static { + return $this->fitSize('width', $fit, $force); + } + + /** + * Recalculates the dimensions by the width and height + * + * @param int|null $width the max height + * @param int|null $height the max width + * @return $this + */ + public function fitWidthAndHeight( + int|null $width = null, + int|null $height = null, + bool $force = false + ): static { + if ($this->width > $this->height) { + $this->fitWidth($width, $force); + + // do another check for the max height + if ($this->height > $height) { + $this->fitHeight($height); + } + } else { + $this->fitHeight($height, $force); + + // do another check for the max width + if ($this->width > $width) { + $this->fitWidth($width); + } + } + + return $this; + } + + /** + * Detect the dimensions for an image file + */ + public static function forImage(string $root): static + { + if (file_exists($root) === false) { + return new static(0, 0); + } + + $size = getimagesize($root); + return new static($size[0] ?? 0, $size[1] ?? 1); + } + + /** + * Detect the dimensions for a svg file + */ + public static function forSvg(string $root): static + { + // avoid xml errors + libxml_use_internal_errors(true); + + $content = file_get_contents($root); + $height = 0; + $width = 0; + $xml = simplexml_load_string($content); + + if ($xml !== false) { + $attr = $xml->attributes(); + $rawWidth = $attr->width; + $width = (int)$rawWidth; + $rawHeight = $attr->height; + $height = (int)$rawHeight; + + // use viewbox values if direct attributes are 0 + // or based on percentages + if (empty($attr->viewBox) === false) { + $box = explode(' ', $attr->viewBox); + + // when using viewbox values, make sure to subtract + // first two box values from last two box values + // to retrieve the absolute dimensions + + if (Str::endsWith($rawWidth, '%') === true || $width === 0) { + $width = (int)($box[2] ?? 0) - (int)($box[0] ?? 0); + } + + if (Str::endsWith($rawHeight, '%') === true || $height === 0) { + $height = (int)($box[3] ?? 0) - (int)($box[1] ?? 0); + } + } + } + + return new static($width, $height); + } + + /** + * Checks if the dimensions are landscape + */ + public function landscape(): bool + { + return $this->width > $this->height; + } + + /** + * Returns a string representation of the orientation + */ + public function orientation(): string|false + { + if (!$this->ratio()) { + return false; + } + + if ($this->portrait() === true) { + return 'portrait'; + } + + if ($this->landscape() === true) { + return 'landscape'; + } + + return 'square'; + } + + /** + * Checks if the dimensions are portrait + */ + public function portrait(): bool + { + return $this->height > $this->width; + } + + /** + * Calculates and returns the ratio + * + * + * + * $dimensions = new Dimensions(1200, 768); + * echo $dimensions->ratio(); + * // output: 1.5625 + * + * + */ + public function ratio(): float + { + if ($this->width !== 0 && $this->height !== 0) { + return $this->width / $this->height; + } + + return 0.0; + } + + /** + * Resizes image + * @return $this + */ + public function resize( + int|null $width = null, + int|null $height = null, + bool $force = false + ): static { + return $this->fitWidthAndHeight($width, $height, $force); + } + + /** + * Checks if the dimensions are square + */ + public function square(): bool + { + return $this->width === $this->height; + } + + /** + * Resize and crop + * + * @return $this + */ + public function thumb(array $options = []): static + { + $width = $options['width'] ?? null; + $height = $options['height'] ?? null; + $crop = $options['crop'] ?? false; + $method = $crop !== false ? 'crop' : 'resize'; + + if ($width === null && $height === null) { + return $this; + } + + return $this->$method($width, $height); + } + + /** + * Converts the dimensions object + * to a plain PHP array + */ + public function toArray(): array + { + return [ + 'width' => $this->width(), + 'height' => $this->height(), + 'ratio' => $this->ratio(), + 'orientation' => $this->orientation(), + ]; + } + + /** + * Returns the width + */ + public function width(): int + { + return $this->width; + } +} diff --git a/kirby/src/Image/Exif.php b/kirby/src/Image/Exif.php new file mode 100644 index 0000000..f32afd0 --- /dev/null +++ b/kirby/src/Image/Exif.php @@ -0,0 +1,200 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Exif +{ + /** + * The raw exif array + */ + protected array $data = []; + + protected Camera|null $camera = null; + protected Location|null $location = null; + protected string|null $timestamp = null; + protected string|null $exposure = null; + protected string|null $aperture = null; + protected string|null $iso = null; + protected string|null $focalLength = null; + protected bool|null $isColor = null; + + public function __construct( + protected Image $image + ) { + $this->data = $this->read(); + $this->timestamp = $this->parseTimestamp(); + $this->exposure = $this->data['ExposureTime'] ?? null; + $this->iso = $this->data['ISOSpeedRatings'] ?? null; + $this->focalLength = $this->parseFocalLength(); + $this->aperture = $this->computed()['ApertureFNumber'] ?? null; + $this->isColor = V::accepted($this->computed()['IsColor'] ?? null); + } + + /** + * Returns the raw data array from the parser + */ + public function data(): array + { + return $this->data; + } + + /** + * Returns the Camera object + */ + public function camera(): Camera + { + return $this->camera ??= new Camera($this->data); + } + + /** + * Returns the location object + */ + public function location(): Location + { + return $this->location ??= new Location($this->data); + } + + /** + * Returns the timestamp + */ + public function timestamp(): string|null + { + return $this->timestamp; + } + + /** + * Returns the exposure + */ + public function exposure(): string|null + { + return $this->exposure; + } + + /** + * Returns the aperture + */ + public function aperture(): string|null + { + return $this->aperture; + } + + /** + * Returns the iso value + */ + public function iso(): string|null + { + return $this->iso; + } + + /** + * Checks if this is a color picture + */ + public function isColor(): bool|null + { + return $this->isColor; + } + + /** + * Checks if this is a bw picture + */ + public function isBW(): bool|null + { + return ($this->isColor !== null) ? $this->isColor === false : null; + } + + /** + * Returns the focal length + */ + public function focalLength(): string|null + { + return $this->focalLength; + } + + /** + * Read the exif data of the image object if possible + */ + protected function read(): array + { + // @codeCoverageIgnoreStart + if (function_exists('exif_read_data') === false) { + return []; + } + // @codeCoverageIgnoreEnd + + $data = @exif_read_data($this->image->root()); + return is_array($data) ? $data : []; + } + + /** + * Get all computed data + */ + protected function computed(): array + { + return $this->data['COMPUTED'] ?? []; + } + + /** + * Return the timestamp when the picture has been taken + */ + protected function parseTimestamp(): string + { + if (isset($this->data['DateTimeOriginal']) === true) { + if ($time = strtotime($this->data['DateTimeOriginal'])) { + return (string)$time; + } + } + + return $this->data['FileDateTime'] ?? $this->image->modified(); + } + + /** + * Return the focal length + */ + protected function parseFocalLength(): string|null + { + return + $this->data['FocalLength'] ?? + $this->data['FocalLengthIn35mmFilm'] ?? + null; + } + + /** + * Converts the object into a nicely readable array + */ + public function toArray(): array + { + return [ + 'camera' => $this->camera()->toArray(), + 'location' => $this->location()->toArray(), + 'timestamp' => $this->timestamp(), + 'exposure' => $this->exposure(), + 'aperture' => $this->aperture(), + 'iso' => $this->iso(), + 'focalLength' => $this->focalLength(), + 'isColor' => $this->isColor() + ]; + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return array_merge($this->toArray(), [ + 'camera' => $this->camera(), + 'location' => $this->location() + ]); + } +} diff --git a/kirby/src/Image/Focus.php b/kirby/src/Image/Focus.php new file mode 100644 index 0000000..da1dc73 --- /dev/null +++ b/kirby/src/Image/Focus.php @@ -0,0 +1,110 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Focus +{ + /** + * Generates crop coordinates based on focal point + */ + public static function coords( + string $crop, + int $sourceWidth, + int $sourceHeight, + int $width, + int $height + ): array|null { + [$x, $y] = static::parse($crop); + + // determine aspect ratios + $ratioSource = static::ratio($sourceWidth, $sourceHeight); + $ratioThumb = static::ratio($width, $height); + + // no cropping necessary + if ($ratioSource == $ratioThumb) { + return null; + } + + // defaults + $width = $sourceWidth; + $height = $sourceHeight; + + if ($ratioThumb > $ratioSource) { + $height = $sourceWidth / $ratioThumb; + } else { + $width = $sourceHeight * $ratioThumb; + } + + // calculate focus for original image + $x = $sourceWidth * $x; + $y = $sourceHeight * $y; + + $x1 = max(0, $x - $width / 2); + $y1 = max(0, $y - $height / 2); + + // off canvas? + if ($x1 + $width > $sourceWidth) { + $x1 = $sourceWidth - $width; + } + + if ($y1 + $height > $sourceHeight) { + $y1 = $sourceHeight - $height; + } + + return [ + 'x1' => (int)floor($x1), + 'y1' => (int)floor($y1), + 'x2' => (int)floor($x1 + $width), + 'y2' => (int)floor($y1 + $height), + 'width' => (int)floor($width), + 'height' => (int)floor($height), + ]; + } + + public static function isFocalPoint(string $value): bool + { + return Str::contains($value, '%') === true; + } + + /** + * Transforms the focal point's string value (from content field) + * to a [x, y] array (values 0.0-1.0) + */ + public static function parse(string $value): array + { + // support for former Focus plugin + if (Str::startsWith($value, '{') === true) { + $focus = json_decode($value); + return [$focus->x, $focus->y]; + } + + preg_match_all("/(\d{1,3}\.?\d*)[%|,|\s]*/", $value, $points); + + return A::map( + $points[1], + function ($point) { + $point = (float)$point; + $point = $point > 1 ? $point / 100 : $point; + return round($point, 3); + } + ); + } + + /** + * Calculates the image ratio + */ + public static function ratio(int $width, int $height): float + { + return $height !== 0 ? $width / $height : 0; + } +} diff --git a/kirby/src/Image/Image.php b/kirby/src/Image/Image.php new file mode 100644 index 0000000..5bc1b7f --- /dev/null +++ b/kirby/src/Image/Image.php @@ -0,0 +1,222 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Image extends File +{ + protected Exif|null $exif = null; + protected Dimensions|null $dimensions = null; + + public static array $resizableTypes = [ + 'jpg', + 'jpeg', + 'gif', + 'png', + 'webp' + ]; + + public static array $viewableTypes = [ + 'avif', + 'jpg', + 'jpeg', + 'gif', + 'png', + 'svg', + 'webp' + ]; + + /** + * Validation rules to be used for `::match()` + */ + public static array $validations = [ + 'maxsize' => ['size', 'max'], + 'minsize' => ['size', 'min'], + 'maxwidth' => ['width', 'max'], + 'minwidth' => ['width', 'min'], + 'maxheight' => ['height', 'max'], + 'minheight' => ['height', 'min'], + 'orientation' => ['orientation', 'same'] + ]; + + /** + * Returns the `` tag for the image object + */ + public function __toString(): string + { + return $this->html(); + } + + /** + * Returns the dimensions of the file if possible + */ + public function dimensions(): Dimensions + { + if ($this->dimensions !== null) { + return $this->dimensions; + } + + if (in_array($this->mime(), [ + 'image/jpeg', + 'image/jp2', + 'image/png', + 'image/gif', + 'image/webp' + ])) { + return $this->dimensions = Dimensions::forImage($this->root); + } + + if ($this->extension() === 'svg') { + return $this->dimensions = Dimensions::forSvg($this->root); + } + + return $this->dimensions = new Dimensions(0, 0); + } + + /** + * Returns the exif object for this file (if image) + */ + public function exif(): Exif + { + return $this->exif ??= new Exif($this); + } + + /** + * Returns the height of the asset + */ + public function height(): int + { + return $this->dimensions()->height(); + } + + /** + * Converts the file to html + */ + public function html(array $attr = []): string + { + // if no alt text explicitly provided, + // try to infer from model content file + if ( + $this->model !== null && + method_exists($this->model, 'content') === true && + $this->model->content() instanceof Content && + $this->model->content()->get('alt')->isNotEmpty() === true + ) { + $attr['alt'] ??= $this->model->content()->get('alt')->value(); + } + + if ($url = $this->url()) { + return Html::img($url, $attr); + } + + throw new LogicException('Calling Image::html() requires that the URL property is not null'); + } + + /** + * Returns the PHP imagesize array + */ + public function imagesize(): array + { + return getimagesize($this->root); + } + + /** + * Checks if the dimensions of the asset are portrait + */ + public function isPortrait(): bool + { + return $this->dimensions()->portrait(); + } + + /** + * Checks if the dimensions of the asset are landscape + */ + public function isLandscape(): bool + { + return $this->dimensions()->landscape(); + } + + /** + * Checks if the dimensions of the asset are square + */ + public function isSquare(): bool + { + return $this->dimensions()->square(); + } + + /** + * Checks if the file is a resizable image + */ + public function isResizable(): bool + { + return in_array($this->extension(), static::$resizableTypes) === true; + } + + /** + * Checks if a preview can be displayed for the file + * in the Panel or in the frontend + */ + public function isViewable(): bool + { + return in_array($this->extension(), static::$viewableTypes) === true; + } + + /** + * Returns the ratio of the asset + */ + public function ratio(): float + { + return $this->dimensions()->ratio(); + } + + /** + * Returns the orientation as string + * `landscape` | `portrait` | `square` + */ + public function orientation(): string|false + { + return $this->dimensions()->orientation(); + } + + /** + * Converts the object to an array + */ + public function toArray(): array + { + $array = array_merge(parent::toArray(), [ + 'dimensions' => $this->dimensions()->toArray(), + 'exif' => $this->exif()->toArray(), + ]); + + ksort($array); + + return $array; + } + + /** + * Returns the width of the asset + */ + public function width(): int + { + return $this->dimensions()->width(); + } +} diff --git a/kirby/src/Image/Location.php b/kirby/src/Image/Location.php new file mode 100644 index 0000000..0eddb68 --- /dev/null +++ b/kirby/src/Image/Location.php @@ -0,0 +1,116 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Location +{ + protected float|null $lat = null; + protected float|null $lng = null; + + /** + * Constructor + * + * @param array $exif The entire exif array + */ + public function __construct(array $exif) + { + if ( + isset($exif['GPSLatitude']) === true && + isset($exif['GPSLatitudeRef']) === true && + isset($exif['GPSLongitude']) === true && + isset($exif['GPSLongitudeRef']) === true + ) { + $this->lat = $this->gps( + $exif['GPSLatitude'], + $exif['GPSLatitudeRef'] + ); + $this->lng = $this->gps( + $exif['GPSLongitude'], + $exif['GPSLongitudeRef'] + ); + } + } + + /** + * Returns the latitude + */ + public function lat(): float|null + { + return $this->lat; + } + + /** + * Returns the longitude + */ + public function lng(): float|null + { + return $this->lng; + } + + /** + * Converts the gps coordinates + */ + protected function gps(array $coord, string $hemi): float + { + $degrees = count($coord) > 0 ? $this->num($coord[0]) : 0; + $minutes = count($coord) > 1 ? $this->num($coord[1]) : 0; + $seconds = count($coord) > 2 ? $this->num($coord[2]) : 0; + + $hemi = strtoupper($hemi); + $flip = ($hemi === 'W' || $hemi === 'S') ? -1 : 1; + + return $flip * ($degrees + $minutes / 60 + $seconds / 3600); + } + + /** + * Converts coordinates to floats + */ + protected function num(string $part): float + { + $parts = explode('/', $part); + + if (count($parts) === 1) { + return (float)$parts[0]; + } + + return (float)($parts[0]) / (float)($parts[1]); + } + + /** + * Converts the object into a nicely readable array + */ + public function toArray(): array + { + return [ + 'lat' => $this->lat(), + 'lng' => $this->lng() + ]; + } + + /** + * Echos the entire location as lat, lng + */ + public function __toString(): string + { + return trim($this->lat() . ', ' . $this->lng(), ','); + } + + /** + * Improved `var_dump` output + * @codeCoverageIgnore + */ + public function __debugInfo(): array + { + return $this->toArray(); + } +} diff --git a/kirby/src/Image/QrCode.php b/kirby/src/Image/QrCode.php new file mode 100644 index 0000000..b6599fb --- /dev/null +++ b/kirby/src/Image/QrCode.php @@ -0,0 +1,1603 @@ +, + * Lukas Bestle + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * QR Code® is a registered trademark of DENSO WAVE INCORPORATED. + * + * The code of this class is based on: + * https://github.com/psyon/php-qrcode + * + * qrcode.php - Generate QR Codes. MIT license. + * + * Copyright for portions of this project are held by Kreative Software, 2016-2018. + * All other copyright for the project are held by Donald Becker, 2019 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + */ +class QrCode +{ + public function __construct(public string $data) + { + } + + /** + * Returns the QR code as a PNG data URI + * + * @param int|null $size Image width/height in pixels, defaults to a size per module of 4x4 + * @param string $color Foreground color in hex format + * @param string $back Background color in hex format + */ + public function toDataUri( + int|null $size = null, + string $color = '#000000', + string $back = '#ffffff' + ): string { + $image = $this->toImage($size, $color, $back); + + ob_start(); + imagepng($image); + imagedestroy($image); + $data = ob_get_contents(); + ob_end_clean(); + + return 'data:image/png;base64,' . base64_encode($data); + } + + /** + * Returns the QR code as a GdImage object + * + * @param int|null $size Image width/height in pixels, defaults to a size per module of 4x4 + * @param string $color Foreground color in hex format + * @param string $back Background color in hex format + */ + public function toImage( + int|null $size = null, + string $color = '#000000', + string $back = '#ffffff' + ): GdImage { + // get code and size measurements + $code = $this->encode(); + [$width, $height] = $this->measure($code); + $size ??= ceil($width * 4); + $ws = $size / $width; + $hs = $size / $height; + + // create image baseplate + $image = imagecreatetruecolor($size, $size); + + $allocateColor = function (string $hex) use ($image) { + $hex = preg_replace('/[^0-9A-Fa-f]/', '', $hex); + $r = hexdec(substr($hex, 0, 2)); + $g = hexdec(substr($hex, 2, 2)); + $b = hexdec(substr($hex, 4, 2)); + return imagecolorallocate($image, $r, $g, $b); + }; + + $back = $allocateColor($back); + $color = $allocateColor($color); + imagefill($image, 0, 0, $back); + + // paint square for each module + $this->eachModuleGroup( + $code, + fn ($x, $y, $width, $height) => imagefilledrectangle( + $image, + floor($x * $ws), + floor($y * $hs), + floor($x * $ws + $ws * $width) - 1, + floor($y * $hs + $hs * $height) - 1, + $color + ) + ); + + return $image; + } + + /** + * Returns the QR code as `` element + * + * @param int|string|null $size Optional CSS width of the `` element + * @param string $color Foreground color in hex format + * @param string $back Background color in hex format + */ + public function toSvg( + int|string|null $size = null, + string $color = '#000000', + string $back = '#ffffff' + ): string { + $code = $this->encode(); + [$vbw, $vbh] = $this->measure($code); + + $modules = $this->eachModuleGroup( + $code, + fn ($x, $y, $width, $height) => 'M' . $x . ',' . $y . 'h' . $width . 'v' . $height . 'h-' . $width . 'z' + ); + + $size = $size ? ' style="width: ' . $size . '"' : ''; + + return '' . + '' . + '' . + ''; + } + + public function __toString(): string + { + return $this->toSvg(); + } + + /** + * Saves the QR code to a file. + * Supported formats: gif, jpg, jpeg, png, svg, webp + * + * @param string $file Path to the output file with one of the supported file extensions + * @param int|string|null $size Optional image width/height in pixels (defaults to a size per module of 4x4) or CSS width of the `` element + * @param string $color Foreground color in hex format + * @param string $back Background color in hex format + */ + public function write( + string $file, + int|string|null $size = null, + string $color = '#000000', + string $back = '#ffffff' + ): void { + $format = F::extension($file); + $args = [$size, $color, $back]; + + match ($format) { + 'gif' => imagegif($this->toImage(...$args), $file), + 'jpg', + 'jpeg' => imagejpeg($this->toImage(...$args), $file), + 'png' => imagepng($this->toImage(...$args), $file), + 'svg' => F::write($file, $this->toSvg(...$args)), + 'webp' => imagewebp($this->toImage(...$args), $file), + default => throw new InvalidArgumentException('Cannot write QR code as ' . $format) + }; + } + + protected function applyMask(array $matrix, int $size, int $mask): array + { + for ($i = 0; $i < $size; $i++) { + for ($j = 0; $j < $size; $j++) { + if ($matrix[$i][$j] >= 4 && $this->mask($mask, $i, $j)) { + $matrix[$i][$j] ^= 1; + } + } + } + + return $matrix; + } + + protected function applyBestMask(array $matrix, int $size): array + { + $mask = 0; + $mmatrix = $this->applyMask($matrix, $size, $mask); + $penalty = $this->penalty($mmatrix, $size); + + for ($tmask = 1; $tmask < 8; $tmask++) { + $tmatrix = $this->applyMask($matrix, $size, $tmask); + $tpenalty = $this->penalty($tmatrix, $size); + + if ($tpenalty < $penalty) { + $mask = $tmask; + $mmatrix = $tmatrix; + $penalty = $tpenalty; + } + } + + return [$mask, $mmatrix]; + } + + protected function createMatrix(int $version, array $data): array + { + $size = $version * 4 + 17; + $matrix = []; + $row = array_fill(0, $size, 0); + + for ($i = 0; $i < $size; $i++) { + $matrix[] = $row; + } + + // finder patterns + for ($i = 0; $i < 8; $i++) { + for ($j = 0; $j < 8; $j++) { + $m = (($i == 7 || $j == 7) ? 2 : + (($i == 0 || $j == 0 || $i == 6 || $j == 6) ? 3 : + (($i == 1 || $j == 1 || $i == 5 || $j == 5) ? 2 : 3))); + $matrix[$i][$j] = $m; + $matrix[$size - $i - 1][$j] = $m; + $matrix[$i][$size - $j - 1] = $m; + } + } + + // alignment patterns + if ($version >= 2) { + $alignment = static::ALIGNMENT_PATTERNS[$version - 2]; + + foreach ($alignment as $i) { + foreach ($alignment as $j) { + if (!$matrix[$i][$j]) { + for ($ii = -2; $ii <= 2; $ii++) { + for ($jj = -2; $jj <= 2; $jj++) { + $m = (max(abs($ii), abs($jj)) & 1) ^ 3; + $matrix[$i + $ii][$j + $jj] = $m; + } + } + } + } + } + } + + // timing patterns + for ($i = $size - 9; $i >= 8; $i--) { + $matrix[$i][6] = ($i & 1) ^ 3; + $matrix[6][$i] = ($i & 1) ^ 3; + } + + // dark module – such an ominous name for such an innocuous thing + $matrix[$size - 8][8] = 3; + + // format information area + for ($i = 0; $i <= 8; $i++) { + if (!$matrix[$i][8]) { + $matrix[$i][8] = 1; + } + if (!$matrix[8][$i]) { + $matrix[8][$i] = 1; + } + if ($i && !$matrix[$size - $i][8]) { + $matrix[$size - $i][8] = 1; + } + if ($i && !$matrix[8][$size - $i]) { + $matrix[8][$size - $i] = 1; + } + } + + // version information area + if ($version >= 7) { + for ($i = 9; $i < 12; $i++) { + for ($j = 0; $j < 6; $j++) { + $matrix[$size - $i][$j] = 1; + $matrix[$j][$size - $i] = 1; + } + } + } + + // data + $col = $size - 1; + $row = $size - 1; + $dir = -1; + $offset = 0; + $length = count($data); + + while ($col > 0 && $offset < $length) { + if (!$matrix[$row][$col]) { + $matrix[$row][$col] = $data[$offset] ? 5 : 4; + $offset++; + } + if (!$matrix[$row][$col - 1]) { + $matrix[$row][$col - 1] = $data[$offset] ? 5 : 4; + $offset++; + } + $row += $dir; + if ($row < 0 || $row >= $size) { + $dir = -$dir; + $row += $dir; + $col -= 2; + + if ($col == 6) { + $col--; + } + } + } + + return [$size, $matrix]; + } + + /** + * Loops over every row and column, finds all modules that can + * be grouped as rectangle (starting at the top left corner) + * and applies the given action to each active module group + */ + protected function eachModuleGroup(array $code, Closure $action): array + { + $result = []; + $xStart = $code['q'][3]; + $yStart = $code['q'][0]; + + // generate empty matrix to track what modules have been covered + $covered = array_fill(0, count($code['bits']), array_fill(0, count($code['bits'][0]), 0)); + + foreach ($code['bits'] as $by => $row) { + foreach ($row as $bx => $module) { + // skip if module is inactive or already covered + if ($module === 0 || $covered[$by][$bx] === 1) { + continue; + } + + $width = 0; + $height = 0; + + $rowLength = count($row); + $colLength = count($code['bits']); + + // extend to the right as long as the modules are active + // and use this to determine the width of the group + for ($x = $bx; $x < $rowLength; $x++) { + if ($row[$x] === 0) { + break; + } + $width++; + $covered[$by][$x] = 1; + } + + // extend downwards as long as all the modules + // at the same width range are active; + // use this to determine the height of the group + for ($y = $by; $y < $colLength; $y++) { + $below = array_slice($code['bits'][$y], $bx, $width); + + // if the sum is less than the width, + // there is at least one inactive module + if (array_sum($below) < $width) { + break; + } + + $height++; + + for ($x = $bx; $x < $bx + $width; $x++) { + $covered[$y][$x] = 1; + } + } + + $result[] = $action( + $xStart + $bx, + $yStart + $by, + $width, + $height + ); + } + } + + return $result; + } + + protected function encode(): array + { + [$data, $version, $ecl, $ec] = $this->encodeData(); + $data = $this->encodeErrorCorrection($data, $ec, $version); + [$size, $mtx] = $this->createMatrix($version, $data); + [$mask, $mtx] = $this->applyBestMask($mtx, $size); + $mtx = $this->finalizeMatrix($mtx, $size, $ecl, $mask, $version); + + return [ + 'q' => [4, 4, 4, 4], + 'size' => [$size, $size], + 'bits' => $mtx + ]; + } + + protected function encodeData(): array + { + $mode = $this->mode(); + [$version, $ecl] = $this->version($mode); + + $group = match (true) { + $version >= 27 => 2, + $version >= 10 => 1, + default => 0 + }; + + $ec = static::EC_PARAMS[($version - 1) * 4 + $ecl]; + + // don't cut off mid-character if exceeding capacity + $max_chars = static::CAPACITY[$version - 1][$ecl][$mode]; + + if ($mode == 3) { + $max_chars <<= 1; + } + + $data = substr($this->data, 0, $max_chars); + + // convert from character level to bit level + $code = match ($mode) { + 0 => $this->encodeNumeric($data, $group), + 1 => $this->encodeAlphanum($data, $group), + 2 => $this->encodeBinary($data, $group), + default => throw new LogicException('Invalid QR mode') // @codeCoverageIgnore + }; + + $code = array_merge($code, array_fill(0, 4, 0)); + + if ($remainder = count($code) % 8) { + $code = array_merge($code, array_fill(0, 8 - $remainder, 0)); + } + + // convert from bit level to byte level + $data = []; + + for ($i = 0, $n = count($code); $i < $n; $i += 8) { + $byte = 0; + + if ($code[$i + 0]) { + $byte |= 0x80; + } + if ($code[$i + 1]) { + $byte |= 0x40; + } + if ($code[$i + 2]) { + $byte |= 0x20; + } + if ($code[$i + 3]) { + $byte |= 0x10; + } + if ($code[$i + 4]) { + $byte |= 0x08; + } + if ($code[$i + 5]) { + $byte |= 0x04; + } + if ($code[$i + 6]) { + $byte |= 0x02; + } + if ($code[$i + 7]) { + $byte |= 0x01; + } + + $data[] = $byte; + } + + for ( + $i = count($data), + $a = 1, + $n = $ec[0]; + $i < $n; + $i++, + $a ^= 1 + ) { + $data[] = $a ? 236 : 17; + } + + return [ + $data, + $version, + $ecl, + $ec + ]; + } + + protected function encodeNumeric($data, $version_group): array + { + $code = [0, 0, 0, 1]; + $length = strlen($data); + + switch ($version_group) { + case 2: // 27 - 40 + $code[] = $length & 0x2000; + $code[] = $length & 0x1000; + // no break + case 1: // 10 - 26 + $code[] = $length & 0x0800; + $code[] = $length & 0x0400; + // no break + case 0: // 1 - 9 + $code[] = $length & 0x0200; + $code[] = $length & 0x0100; + $code[] = $length & 0x0080; + $code[] = $length & 0x0040; + $code[] = $length & 0x0020; + $code[] = $length & 0x0010; + $code[] = $length & 0x0008; + $code[] = $length & 0x0004; + $code[] = $length & 0x0002; + $code[] = $length & 0x0001; + } + for ($i = 0; $i < $length; $i += 3) { + $group = substr($data, $i, 3); + switch (strlen($group)) { + case 3: + $code[] = $group & 0x200; + $code[] = $group & 0x100; + $code[] = $group & 0x080; + // no break + case 2: + $code[] = $group & 0x040; + $code[] = $group & 0x020; + $code[] = $group & 0x010; + // no break + case 1: + $code[] = $group & 0x008; + $code[] = $group & 0x004; + $code[] = $group & 0x002; + $code[] = $group & 0x001; + } + } + return $code; + } + + protected function encodeAlphanum($data, $version_group): array + { + $alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'; + $code = [0, 0, 1, 0]; + $length = strlen($data); + switch ($version_group) { + case 2: // 27 - 40 + $code[] = $length & 0x1000; + $code[] = $length & 0x0800; + // no break + case 1: // 10 - 26 + $code[] = $length & 0x0400; + $code[] = $length & 0x0200; + // no break + case 0: // 1 - 9 + $code[] = $length & 0x0100; + $code[] = $length & 0x0080; + $code[] = $length & 0x0040; + $code[] = $length & 0x0020; + $code[] = $length & 0x0010; + $code[] = $length & 0x0008; + $code[] = $length & 0x0004; + $code[] = $length & 0x0002; + $code[] = $length & 0x0001; + } + for ($i = 0; $i < $length; $i += 2) { + $group = substr($data, $i, 2); + if (strlen($group) > 1) { + $c1 = strpos($alphabet, substr($group, 0, 1)); + $c2 = strpos($alphabet, substr($group, 1, 1)); + $ch = $c1 * 45 + $c2; + $code[] = $ch & 0x400; + $code[] = $ch & 0x200; + $code[] = $ch & 0x100; + $code[] = $ch & 0x080; + $code[] = $ch & 0x040; + $code[] = $ch & 0x020; + $code[] = $ch & 0x010; + $code[] = $ch & 0x008; + $code[] = $ch & 0x004; + $code[] = $ch & 0x002; + $code[] = $ch & 0x001; + } else { + $ch = strpos($alphabet, $group); + $code[] = $ch & 0x020; + $code[] = $ch & 0x010; + $code[] = $ch & 0x008; + $code[] = $ch & 0x004; + $code[] = $ch & 0x002; + $code[] = $ch & 0x001; + } + } + return $code; + } + + protected function encodeBinary(string $data, int $version_group): array + { + $code = [0, 1, 0, 0]; + $length = strlen($data); + + switch ($version_group) { + case 2: // 27 - 40 + case 1: // 10 - 26 + $code[] = $length & 0x8000; + $code[] = $length & 0x4000; + $code[] = $length & 0x2000; + $code[] = $length & 0x1000; + $code[] = $length & 0x0800; + $code[] = $length & 0x0400; + $code[] = $length & 0x0200; + $code[] = $length & 0x0100; + // no break + case 0: // 1 - 9 + $code[] = $length & 0x0080; + $code[] = $length & 0x0040; + $code[] = $length & 0x0020; + $code[] = $length & 0x0010; + $code[] = $length & 0x0008; + $code[] = $length & 0x0004; + $code[] = $length & 0x0002; + $code[] = $length & 0x0001; + } + + for ($i = 0; $i < $length; $i++) { + $ch = ord(substr($data, $i, 1)); + $code[] = $ch & 0x80; + $code[] = $ch & 0x40; + $code[] = $ch & 0x20; + $code[] = $ch & 0x10; + $code[] = $ch & 0x08; + $code[] = $ch & 0x04; + $code[] = $ch & 0x02; + $code[] = $ch & 0x01; + } + + return $code; + } + + protected function encodeErrorCorrection( + array $data, + array $ec_params, + int $version + ): array { + $blocks = $this->errorCorrectionSplit($data, $ec_params); + $ec_blocks = []; + + for ($i = 0, $n = count($blocks); $i < $n; $i++) { + $ec_blocks[] = $this->errorCorrectionDivide($blocks[$i], $ec_params); + } + + $data = $this->errorCorrectionInterleave($blocks); + $ec_data = $this->errorCorrectionInterleave($ec_blocks); + $code = []; + + foreach ($data as $ch) { + $code[] = $ch & 0x80; + $code[] = $ch & 0x40; + $code[] = $ch & 0x20; + $code[] = $ch & 0x10; + $code[] = $ch & 0x08; + $code[] = $ch & 0x04; + $code[] = $ch & 0x02; + $code[] = $ch & 0x01; + } + foreach ($ec_data as $ch) { + $code[] = $ch & 0x80; + $code[] = $ch & 0x40; + $code[] = $ch & 0x20; + $code[] = $ch & 0x10; + $code[] = $ch & 0x08; + $code[] = $ch & 0x04; + $code[] = $ch & 0x02; + $code[] = $ch & 0x01; + } + for ($n = static::REMAINER_BITS[$version - 1]; $n > 0; $n--) { + $code[] = 0; + } + + return $code; + } + + protected function errorCorrectionSplit(array $data, array $ec): array + { + $blocks = []; + $offset = 0; + + for ($i = $ec[2], $length = $ec[3]; $i > 0; $i--) { + $blocks[] = array_slice($data, $offset, $length); + $offset += $length; + } + for ($i = $ec[4], $length = $ec[5]; $i > 0; $i--) { + $blocks[] = array_slice($data, $offset, $length); + $offset += $length; + } + + return $blocks; + } + + protected function errorCorrectionDivide(array $data, array $ec): array + { + $num_data = count($data); + $num_error = $ec[1]; + $generator = static::EC_POLYNOMIALS[$num_error]; + $message = $data; + + for ($i = 0; $i < $num_error; $i++) { + $message[] = 0; + } + + for ($i = 0; $i < $num_data; $i++) { + if ($message[$i]) { + $leadterm = static::LOG[$message[$i]]; + + for ($j = 0; $j <= $num_error; $j++) { + $term = ($generator[$j] + $leadterm) % 255; + $message[$i + $j] ^= static::EXP[$term]; + } + } + } + + return array_slice($message, $num_data, $num_error); + } + + protected function errorCorrectionInterleave(array $blocks): array + { + $data = []; + $num_blocks = count($blocks); + + for ($offset = 0; true; $offset++) { + $break = true; + + for ($i = 0; $i < $num_blocks; $i++) { + if (isset($blocks[$i][$offset]) === true) { + $data[] = $blocks[$i][$offset]; + $break = false; + } + } + + if ($break) { + break; + } + } + + return $data; + } + + protected function finalizeMatrix( + array $matrix, + int $size, + int $ecl, + int $mask, + int $version + ): array { + // Format info + $format = static::FORMAT_INFO[$ecl * 8 + $mask]; + $matrix[8][0] = $format[0]; + $matrix[8][1] = $format[1]; + $matrix[8][2] = $format[2]; + $matrix[8][3] = $format[3]; + $matrix[8][4] = $format[4]; + $matrix[8][5] = $format[5]; + $matrix[8][7] = $format[6]; + $matrix[8][8] = $format[7]; + $matrix[7][8] = $format[8]; + $matrix[5][8] = $format[9]; + $matrix[4][8] = $format[10]; + $matrix[3][8] = $format[11]; + $matrix[2][8] = $format[12]; + $matrix[1][8] = $format[13]; + $matrix[0][8] = $format[14]; + $matrix[$size - 1][8] = $format[0]; + $matrix[$size - 2][8] = $format[1]; + $matrix[$size - 3][8] = $format[2]; + $matrix[$size - 4][8] = $format[3]; + $matrix[$size - 5][8] = $format[4]; + $matrix[$size - 6][8] = $format[5]; + $matrix[$size - 7][8] = $format[6]; + $matrix[8][$size - 8] = $format[7]; + $matrix[8][$size - 7] = $format[8]; + $matrix[8][$size - 6] = $format[9]; + $matrix[8][$size - 5] = $format[10]; + $matrix[8][$size - 4] = $format[11]; + $matrix[8][$size - 3] = $format[12]; + $matrix[8][$size - 2] = $format[13]; + $matrix[8][$size - 1] = $format[14]; + + // version info + if ($version >= 7) { + $version = static::VERSION_INFO[$version - 7]; + + for ($i = 0; $i < 18; $i++) { + $r = $size - 9 - ($i % 3); + $c = 5 - floor($i / 3); + $matrix[$r][$c] = $version[$i]; + $matrix[$c][$r] = $version[$i]; + } + } + + // patterns and data + for ($i = 0; $i < $size; $i++) { + for ($j = 0; $j < $size; $j++) { + $matrix[$i][$j] &= 1; + } + } + + return $matrix; + } + + protected function mask(int $mask, int $row, int $column): int + { + return match ($mask) { + 0 => !(($row + $column) % 2), + 1 => !($row % 2), + 2 => !($column % 3), + 3 => !(($row + $column) % 3), + 4 => !((floor($row / 2) + floor($column / 3)) % 2), + 5 => !(((($row * $column) % 2) + (($row * $column) % 3))), + 6 => !(((($row * $column) % 2) + (($row * $column) % 3)) % 2), + 7 => !(((($row + $column) % 2) + (($row * $column) % 3)) % 2), + default => throw new LogicException('Invalid QR mask') // @codeCoverageIgnore + }; + } + + /** + * Returns width and height based on the + * generated modules and quiet zone + */ + protected function measure($code): array + { + return [ + $code['q'][3] + $code['size'][0] + $code['q'][1], + $code['q'][0] + $code['size'][1] + $code['q'][2] + ]; + } + + /** + * Detect what encoding mode (numeric, alphanumeric, binary) + * can be used + */ + protected function mode(): int + { + // numeric + if (preg_match('/^[0-9]*$/', $this->data)) { + return 0; + } + + // alphanumeric + if (preg_match('/^[0-9A-Z .\/:$%*+-]*$/', $this->data)) { + return 1; + } + + return 2; + } + + protected function penalty(array &$matrix, int $size): int + { + $score = $this->penalty1($matrix, $size); + $score += $this->penalty2($matrix, $size); + $score += $this->penalty3($matrix, $size); + $score += $this->penalty4($matrix, $size); + return $score; + } + + protected function penalty1(array &$matrix, int $size): int + { + $score = 0; + + for ($i = 0; $i < $size; $i++) { + $rowvalue = 0; + $rowcount = 0; + $colvalue = 0; + $colcount = 0; + + for ($j = 0; $j < $size; $j++) { + $rv = ($matrix[$i][$j] == 5 || $matrix[$i][$j] == 3) ? 1 : 0; + $cv = ($matrix[$j][$i] == 5 || $matrix[$j][$i] == 3) ? 1 : 0; + + if ($rv == $rowvalue) { + $rowcount++; + } else { + if ($rowcount >= 5) { + $score += $rowcount - 2; + } + $rowvalue = $rv; + $rowcount = 1; + } + + if ($cv == $colvalue) { + $colcount++; + } else { + if ($colcount >= 5) { + $score += $colcount - 2; + } + $colvalue = $cv; + $colcount = 1; + } + } + + if ($rowcount >= 5) { + $score += $rowcount - 2; + } + if ($colcount >= 5) { + $score += $colcount - 2; + } + } + + return $score; + } + + protected function penalty2(array &$matrix, int $size): int + { + $score = 0; + + for ($i = 1; $i < $size; $i++) { + for ($j = 1; $j < $size; $j++) { + $v1 = $matrix[$i - 1][$j - 1]; + $v2 = $matrix[$i - 1][$j ]; + $v3 = $matrix[$i ][$j - 1]; + $v4 = $matrix[$i ][$j ]; + $v1 = ($v1 == 5 || $v1 == 3) ? 1 : 0; + $v2 = ($v2 == 5 || $v2 == 3) ? 1 : 0; + $v3 = ($v3 == 5 || $v3 == 3) ? 1 : 0; + $v4 = ($v4 == 5 || $v4 == 3) ? 1 : 0; + + if ($v1 == $v2 && $v2 == $v3 && $v3 == $v4) { + $score += 3; + } + } + } + + return $score; + } + + protected function penalty3(array &$matrix, int $size): int + { + $score = 0; + + for ($i = 0; $i < $size; $i++) { + $rowvalue = 0; + $colvalue = 0; + + for ($j = 0; $j < 11; $j++) { + $rv = ($matrix[$i][$j] == 5 || $matrix[$i][$j] == 3) ? 1 : 0; + $cv = ($matrix[$j][$i] == 5 || $matrix[$j][$i] == 3) ? 1 : 0; + $rowvalue = (($rowvalue << 1) & 0x7FF) | $rv; + $colvalue = (($colvalue << 1) & 0x7FF) | $cv; + } + + if ($rowvalue == 0x5D0 || $rowvalue == 0x5D) { + $score += 40; + } + if ($colvalue == 0x5D0 || $colvalue == 0x5D) { + $score += 40; + } + + for ($j = 11; $j < $size; $j++) { + $rv = ($matrix[$i][$j] == 5 || $matrix[$i][$j] == 3) ? 1 : 0; + $cv = ($matrix[$j][$i] == 5 || $matrix[$j][$i] == 3) ? 1 : 0; + $rowvalue = (($rowvalue << 1) & 0x7FF) | $rv; + $colvalue = (($colvalue << 1) & 0x7FF) | $cv; + + if ($rowvalue == 0x5D0 || $rowvalue == 0x5D) { + $score += 40; + } + + if ($colvalue == 0x5D0 || $colvalue == 0x5D) { + $score += 40; + } + } + } + + return $score; + } + + protected function penalty4(array &$matrix, int $size): int + { + $dark = 0; + + for ($i = 0; $i < $size; $i++) { + for ($j = 0; $j < $size; $j++) { + if ($matrix[$i][$j] == 5 || $matrix[$i][$j] == 3) { + $dark++; + } + } + } + + $dark *= 20; + $dark /= $size * $size; + $a = abs(floor($dark) - 10); + $b = abs(ceil($dark) - 10); + return min($a, $b) * 10; + } + + /** + * Detect what version needs to be used by + * trying to maximize the error correction level + */ + protected function version(int $mode): array + { + $length = strlen($this->data); + + if ($mode == 3) { + $length >>= 1; + } + + $ecl = 0; + + // first try to find the minimum version + // that can contain the data + for ($version = 1; $version <= 40; $version++) { + if ($length <= static::CAPACITY[$version - 1][$ecl][$mode]) { + break; + } + } + + // with the version in place, try to raise + // the error correction level as long as + // the data still fits + for ($newEcl = 1; $newEcl <= 3; $newEcl++) { + if ($length <= static::CAPACITY[$version - 1][$newEcl][$mode]) { + $ecl = $newEcl; + } + } + + return [$version, $ecl]; + } + + /** + * maximum encodable characters = $qr_capacity [ (version - 1) ] + * [ (0 for L, 1 for M, 2 for Q, 3 for H) ] + * [ (0 for numeric, 1 for alpha, 2 for binary) ] + */ + protected const CAPACITY = [ + [ + [ 41, 25, 17], + [ 34, 20, 14], + [ 27, 16, 11], + [ 17, 10, 7] + ], + [ + [ 77, 47, 32], + [ 63, 38, 26], + [ 48, 29, 20], + [ 34, 20, 14] + ], + [ + [ 127, 77, 53], + [ 101, 61, 42], + [ 77, 47, 32], + [ 58, 35, 24] + ], + [ + [ 187, 114, 78], + [ 149, 90, 62], + [ 111, 67, 46], + [ 82, 50, 34] + ], + [ + [ 255, 154, 106], + [ 202, 122, 84], + [ 144, 87, 60], + [ 106, 64, 44] + ], + [ + [ 322, 195, 134], + [ 255, 154, 106], + [ 178, 108, 74], + [ 139, 84, 58] + ], + [ + [ 370, 224, 154], + [ 293, 178, 122], + [ 207, 125, 86], + [ 154, 93, 64] + ], + [ + [ 461, 279, 192], + [ 365, 221, 152], + [ 259, 157, 108], + [ 202, 122, 84] + ], + [ + [ 552, 335, 230], + [ 432, 262, 180], + [ 312, 189, 130], + [ 235, 143, 98]], + [ + [ 652, 395, 271], + [ 513, 311, 213], + [ 364, 221, 151], + [ 288, 174, 119] + ], + [ + [ 772, 468, 321], + [ 604, 366, 251], + [ 427, 259, 177], + [ 331, 200, 137] + ], + [ + [ 883, 535, 367], + [ 691, 419, 287], + [ 489, 296, 203], + [ 374, 227, 155] + ], + [ + [1022, 619, 425], + [ 796, 483, 331], + [ 580, 352, 241], + [ 427, 259, 177] + ], + [ + [1101, 667, 458], + [ 871, 528, 362], + [ 621, 376, 258], + [ 468, 283, 194] + ], + [ + [1250, 758, 520], + [ 991, 600, 412], + [ 703, 426, 292], + [ 530, 321, 220] + ], + [ + [1408, 854, 586], + [1082, 656, 450], + [ 775, 470, 322], + [ 602, 365, 250] + ], + [ + [1548, 938, 644], + [1212, 734, 504], + [ 876, 531, 364], + [ 674, 408, 280] + ], + [ + [1725, 1046, 718], + [1346, 816, 560], + [ 948, 574, 394], + [ 746, 452, 310] + ], + [ + [1903, 1153, 792], + [1500, 909, 624], + [1063, 644, 442], + [ 813, 493, 338] + ], + [ + [2061, 1249, 858], + [1600, 970, 666], + [1159, 702, 482], + [ 919, 557, 382] + ], + [ + [2232, 1352, 929], + [1708, 1035, 711], + [1224, 742, 509], + [ 969, 587, 403] + ], + [ + [2409, 1460, 1003], + [1872, 1134, 779], + [1358, 823, 565], + [1056, 640, 439] + ], + [ + [2620, 1588, 1091], + [2059, 1248, 857], + [1468, 890, 611], + [1108, 672, 461] + ], + [ + [2812, 1704, 1171], + [2188, 1326, 911], + [1588, 963, 661], + [1228, 744, 511] + ], + [ + [3057, 1853, 1273], + [2395, 1451, 997], + [1718, 1041, 715], + [1286, 779, 535] + ], + [ + [3283, 1990, 1367], + [2544, 1542, 1059], + [1804, 1094, 751], + [1425, 864, 593] + ], + [ + [3517, 2132, 1465], + [2701, 1637, 1125], + [1933, 1172, 805], + [1501, 910, 625] + ], + [ + [3669, 2223, 1528], + [2857, 1732, 1190], + [2085, 1263, 868], + [1581, 958, 658] + ], + [ + [3909, 2369, 1628], + [3035, 1839, 1264], + [2181, 1322, 908], + [1677, 1016, 698] + ], + [ + [4158, 2520, 1732], + [3289, 1994, 1370], + [2358, 1429, 982], + [1782, 1080, 742] + ], + [ + [4417, 2677, 1840], + [3486, 2113, 1452], + [2473, 1499, 1030], + [1897, 1150, 790] + ], + [ + [4686, 2840, 1952], + [3693, 2238, 1538], + [2670, 1618, 1112], + [2022, 1226, 842] + ], + [ + [4965, 3009, 2068], + [3909, 2369, 1628], + [2805, 1700, 1168], + [2157, 1307, 898] + ], + [ + [5253, 3183, 2188], + [4134, 2506, 1722], + [2949, 1787, 1228], + [2301, 1394, 958] + ], + [ + [5529, 3351, 2303], + [4343, 2632, 1809], + [3081, 1867, 1283], + [2361, 1431, 983] + ], + [ + [5836, 3537, 2431], + [4588, 2780, 1911], + [3244, 1966, 1351], + [2524, 1530, 1051] + ], + [ + [6153, 3729, 2563], + [4775, 2894, 1989], + [3417, 2071, 1423], + [2625, 1591, 1093] + ], + [ + [6479, 3927, 2699], + [5039, 3054, 2099], + [3599, 2181, 1499], + [2735, 1658, 1139] + ], + [ + [6743, 4087, 2809], + [5313, 3220, 2213], + [3791, 2298, 1579], + [2927, 1774, 1219] + ], + [ + [7089, 4296, 2953], + [5596, 3391, 2331], + [3993, 2420, 1663], + [3057, 1852, 1273] + ], + ]; + + /** + * $qr_ec_params[ + * 4 * (version - 1) + (0 for L, 1 for M, 2 for Q, 3 for H) + * ] = [ + * total number of data codewords, + * number of error correction codewords per block, + * number of blocks in first group, + * number of data codewords per block in first group, + * number of blocks in second group, + * number of data codewords per block in second group + * ); + */ + protected const EC_PARAMS = [ + [ 19, 7, 1, 19, 0, 0], + [ 16, 10, 1, 16, 0, 0], + [ 13, 13, 1, 13, 0, 0], + [ 9, 17, 1, 9, 0, 0], + [ 34, 10, 1, 34, 0, 0], + [ 28, 16, 1, 28, 0, 0], + [ 22, 22, 1, 22, 0, 0], + [ 16, 28, 1, 16, 0, 0], + [ 55, 15, 1, 55, 0, 0], + [ 44, 26, 1, 44, 0, 0], + [ 34, 18, 2, 17, 0, 0], + [ 26, 22, 2, 13, 0, 0], + [ 80, 20, 1, 80, 0, 0], + [ 64, 18, 2, 32, 0, 0], + [ 48, 26, 2, 24, 0, 0], + [ 36, 16, 4, 9, 0, 0], + [ 108, 26, 1, 108, 0, 0], + [ 86, 24, 2, 43, 0, 0], + [ 62, 18, 2, 15, 2, 16], + [ 46, 22, 2, 11, 2, 12], + [ 136, 18, 2, 68, 0, 0], + [ 108, 16, 4, 27, 0, 0], + [ 76, 24, 4, 19, 0, 0], + [ 60, 28, 4, 15, 0, 0], + [ 156, 20, 2, 78, 0, 0], + [ 124, 18, 4, 31, 0, 0], + [ 88, 18, 2, 14, 4, 15], + [ 66, 26, 4, 13, 1, 14], + [ 194, 24, 2, 97, 0, 0], + [ 154, 22, 2, 38, 2, 39], + [ 110, 22, 4, 18, 2, 19], + [ 86, 26, 4, 14, 2, 15], + [ 232, 30, 2, 116, 0, 0], + [ 182, 22, 3, 36, 2, 37], + [ 132, 20, 4, 16, 4, 17], + [ 100, 24, 4, 12, 4, 13], + [ 274, 18, 2, 68, 2, 69], + [ 216, 26, 4, 43, 1, 44], + [ 154, 24, 6, 19, 2, 20], + [ 122, 28, 6, 15, 2, 16], + [ 324, 20, 4, 81, 0, 0], + [ 254, 30, 1, 50, 4, 51], + [ 180, 28, 4, 22, 4, 23], + [ 140, 24, 3, 12, 8, 13], + [ 370, 24, 2, 92, 2, 93], + [ 290, 22, 6, 36, 2, 37], + [ 206, 26, 4, 20, 6, 21], + [ 158, 28, 7, 14, 4, 15], + [ 428, 26, 4, 107, 0, 0], + [ 334, 22, 8, 37, 1, 38], + [ 244, 24, 8, 20, 4, 21], + [ 180, 22, 12, 11, 4, 12], + [ 461, 30, 3, 115, 1, 116], + [ 365, 24, 4, 40, 5, 41], + [ 261, 20, 11, 16, 5, 17], + [ 197, 24, 11, 12, 5, 13], + [ 523, 22, 5, 87, 1, 88], + [ 415, 24, 5, 41, 5, 42], + [ 295, 30, 5, 24, 7, 25], + [ 223, 24, 11, 12, 7, 13], + [ 589, 24, 5, 98, 1, 99], + [ 453, 28, 7, 45, 3, 46], + [ 325, 24, 15, 19, 2, 20], + [ 253, 30, 3, 15, 13, 16], + [ 647, 28, 1, 107, 5, 108], + [ 507, 28, 10, 46, 1, 47], + [ 367, 28, 1, 22, 15, 23], + [ 283, 28, 2, 14, 17, 15], + [ 721, 30, 5, 120, 1, 121], + [ 563, 26, 9, 43, 4, 44], + [ 397, 28, 17, 22, 1, 23], + [ 313, 28, 2, 14, 19, 15], + [ 795, 28, 3, 113, 4, 114], + [ 627, 26, 3, 44, 11, 45], + [ 445, 26, 17, 21, 4, 22], + [ 341, 26, 9, 13, 16, 14], + [ 861, 28, 3, 107, 5, 108], + [ 669, 26, 3, 41, 13, 42], + [ 485, 30, 15, 24, 5, 25], + [ 385, 28, 15, 15, 10, 16], + [ 932, 28, 4, 116, 4, 117], + [ 714, 26, 17, 42, 0, 0], + [ 512, 28, 17, 22, 6, 23], + [ 406, 30, 19, 16, 6, 17], + [1006, 28, 2, 111, 7, 112], + [ 782, 28, 17, 46, 0, 0], + [ 568, 30, 7, 24, 16, 25], + [ 442, 24, 34, 13, 0, 0], + [1094, 30, 4, 121, 5, 122], + [ 860, 28, 4, 47, 14, 48], + [ 614, 30, 11, 24, 14, 25], + [ 464, 30, 16, 15, 14, 16], + [1174, 30, 6, 117, 4, 118], + [ 914, 28, 6, 45, 14, 46], + [ 664, 30, 11, 24, 16, 25], + [ 514, 30, 30, 16, 2, 17], + [1276, 26, 8, 106, 4, 107], + [1000, 28, 8, 47, 13, 48], + [ 718, 30, 7, 24, 22, 25], + [ 538, 30, 22, 15, 13, 16], + [1370, 28, 10, 114, 2, 115], + [1062, 28, 19, 46, 4, 47], + [ 754, 28, 28, 22, 6, 23], + [ 596, 30, 33, 16, 4, 17], + [1468, 30, 8, 122, 4, 123], + [1128, 28, 22, 45, 3, 46], + [ 808, 30, 8, 23, 26, 24], + [ 628, 30, 12, 15, 28, 16], + [1531, 30, 3, 117, 10, 118], + [1193, 28, 3, 45, 23, 46], + [ 871, 30, 4, 24, 31, 25], + [ 661, 30, 11, 15, 31, 16], + [1631, 30, 7, 116, 7, 117], + [1267, 28, 21, 45, 7, 46], + [ 911, 30, 1, 23, 37, 24], + [ 701, 30, 19, 15, 26, 16], + [1735, 30, 5, 115, 10, 116], + [1373, 28, 19, 47, 10, 48], + [ 985, 30, 15, 24, 25, 25], + [ 745, 30, 23, 15, 25, 16], + [1843, 30, 13, 115, 3, 116], + [1455, 28, 2, 46, 29, 47], + [1033, 30, 42, 24, 1, 25], + [ 793, 30, 23, 15, 28, 16], + [1955, 30, 17, 115, 0, 0], + [1541, 28, 10, 46, 23, 47], + [1115, 30, 10, 24, 35, 25], + [ 845, 30, 19, 15, 35, 16], + [2071, 30, 17, 115, 1, 116], + [1631, 28, 14, 46, 21, 47], + [1171, 30, 29, 24, 19, 25], + [ 901, 30, 11, 15, 46, 16], + [2191, 30, 13, 115, 6, 116], + [1725, 28, 14, 46, 23, 47], + [1231, 30, 44, 24, 7, 25], + [ 961, 30, 59, 16, 1, 17], + [2306, 30, 12, 121, 7, 122], + [1812, 28, 12, 47, 26, 48], + [1286, 30, 39, 24, 14, 25], + [ 986, 30, 22, 15, 41, 16], + [2434, 30, 6, 121, 14, 122], + [1914, 28, 6, 47, 34, 48], + [1354, 30, 46, 24, 10, 25], + [1054, 30, 2, 15, 64, 16], + [2566, 30, 17, 122, 4, 123], + [1992, 28, 29, 46, 14, 47], + [1426, 30, 49, 24, 10, 25], + [1096, 30, 24, 15, 46, 16], + [2702, 30, 4, 122, 18, 123], + [2102, 28, 13, 46, 32, 47], + [1502, 30, 48, 24, 14, 25], + [1142, 30, 42, 15, 32, 16], + [2812, 30, 20, 117, 4, 118], + [2216, 28, 40, 47, 7, 48], + [1582, 30, 43, 24, 22, 25], + [1222, 30, 10, 15, 67, 16], + [2956, 30, 19, 118, 6, 119], + [2334, 28, 18, 47, 31, 48], + [1666, 30, 34, 24, 34, 25], + [1276, 30, 20, 15, 61, 16], + ]; + + protected const EC_POLYNOMIALS = [ + 7 => [0, 87, 229, 146, 149, 238, 102, 21], + 10 => [0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45], + 13 => [0, 74, 152, 176, 100, 86, 100, 106, 104, 130, 218, 206, 140, 78], + 15 => [0, 8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105], + 16 => [0, 120, 104, 107, 109, 102, 161, 76, 3, 91, 191, 147, 169, 182, 194, 225, 120], + 17 => [0, 43, 139, 206, 78, 43, 239, 123, 206, 214, 147, 24, 99, 150, 39, 243, 163, 136], + 18 => [0, 215, 234, 158, 94, 184, 97, 118, 170, 79, 187, 152, 148, 252, 179, 5, 98, 96, 153], + 20 => [0, 17, 60, 79, 50, 61, 163, 26, 187, 202, 180, 221, 225, 83, 239, 156, 164, 212, 212, 188, 190], + 22 => [0, 210, 171, 247, 242, 93, 230, 14, 109, 221, 53, 200, 74, 8, 172, 98, 80, 219, 134, 160, 105, 165, 231], + 24 => [0, 229, 121, 135, 48, 211, 117, 251, 126, 159, 180, 169, 152, 192, 226, 228, 218, 111, 0, 117, 232, 87, 96, 227, 21], + 26 => [0, 173, 125, 158, 2, 103, 182, 118, 17, 145, 201, 111, 28, 165, 53, 161, 21, 245, 142, 13, 102, 48, 227, 153, 145, 218, 70], + 28 => [0, 168, 223, 200, 104, 224, 234, 108, 180, 110, 190, 195, 147, 205, 27, 232, 201, 21, 43, 245, 87, 42, 195, 212, 119, 242, 37, 9, 123], + 30 => [0, 41, 173, 145, 152, 216, 31, 179, 182, 50, 48, 110, 86, 239, 96, 222, 125, 42, 173, 226, 193, 224, 130, 156, 37, 251, 216, 238, 40, 192, 180], + ]; + + protected const LOG = [0, 0, 1, 25, 2, 50, 26, 198, 3, 223, 51, 238, 27, 104, 199, 75, 4, 100, 224, 14, 52, 141, 239, 129, 28, 193, 105, 248, 200, 8, 76, 113, 5, 138, 101, 47, 225, 36, 15, 33, 53, 147, 142, 218, 240, 18, 130, 69, 29, 181, 194, 125, 106, 39, 249, 185, 201, 154, 9, 120, 77, 228, 114, 166, 6, 191, 139, 98, 102, 221, 48, 253, 226, 152, 37, 179, 16, 145, 34, 136, 54, 208, 148, 206, 143, 150, 219, 189, 241, 210, 19, 92, 131, 56, 70, 64, 30, 66, 182, 163, 195, 72, 126, 110, 107, 58, 40, 84, 250, 133, 186, 61, 202, 94, 155, 159, 10, 21, 121, 43, 78, 212, 229, 172, 115, 243, 167, 87, 7, 112, 192, 247, 140, 128, 99, 13, 103, 74, 222, 237, 49, 197, 254, 24, 227, 165, 153, 119, 38, 184, 180, 124, 17, 68, 146, 217, 35, 32, 137, 46, 55, 63, 209, 91, 149, 188, 207, 205, 144, 135, 151, 178, 220, 252, 190, 97, 242, 86, 211, 171, 20, 42, 93, 158, 132, 60, 57, 83, 71, 109, 65, 162, 31, 45, 67, 216, 183, 123, 164, 118, 196, 23, 73, 236, 127, 12, 111, 246, 108, 161, 59, 82, 41, 157, 85, 170, 251, 96, 134, 177, 187, 204, 62, 90, 203, 89, 95, 176, 156, 169, 160, 81, 11, 245, 22, 235, 122, 117, 44, 215, 79, 174, 213, 233, 230, 231, 173, 232, 116, 214, 244, 234, 168, 80, 88, 175]; + + protected const EXP = [1, 2, 4, 8, 16, 32, 64, 128, 29, 58, 116, 232, 205, 135, 19, 38, 76, 152, 45, 90, 180, 117, 234, 201, 143, 3, 6, 12, 24, 48, 96, 192, 157, 39, 78, 156, 37, 74, 148, 53, 106, 212, 181, 119, 238, 193, 159, 35, 70, 140, 5, 10, 20, 40, 80, 160, 93, 186, 105, 210, 185, 111, 222, 161, 95, 190, 97, 194, 153, 47, 94, 188, 101, 202, 137, 15, 30, 60, 120, 240, 253, 231, 211, 187, 107, 214, 177, 127, 254, 225, 223, 163, 91, 182, 113, 226, 217, 175, 67, 134, 17, 34, 68, 136, 13, 26, 52, 104, 208, 189, 103, 206, 129, 31, 62, 124, 248, 237, 199, 147, 59, 118, 236, 197, 151, 51, 102, 204, 133, 23, 46, 92, 184, 109, 218, 169, 79, 158, 33, 66, 132, 21, 42, 84, 168, 77, 154, 41, 82, 164, 85, 170, 73, 146, 57, 114, 228, 213, 183, 115, 230, 209, 191, 99, 198, 145, 63, 126, 252, 229, 215, 179, 123, 246, 241, 255, 227, 219, 171, 75, 150, 49, 98, 196, 149, 55, 110, 220, 165, 87, 174, 65, 130, 25, 50, 100, 200, 141, 7, 14, 28, 56, 112, 224, 221, 167, 83, 166, 81, 162, 89, 178, 121, 242, 249, 239, 195, 155, 43, 86, 172, 69, 138, 9, 18, 36, 72, 144, 61, 122, 244, 245, 247, 243, 251, 235, 203, 139, 11, 22, 44, 88, 176, 125, 250, 233, 207, 131, 27, 54, 108, 216, 173, 71, 142, 1]; + + protected const REMAINER_BITS = [0, 7, 7, 7, 7, 7, 0, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0]; + + protected const ALIGNMENT_PATTERNS = [ + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170], + ]; + + /** + * format info string = $qr_format_info[ + * (0 for L, 8 for M, 16 for Q, 24 for H) + mask + *]; + */ + protected const FORMAT_INFO = [ + [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0], + [1, 1, 1, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1], + [1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0], + [1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1], + [1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1], + [1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0], + [1, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1], + [1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 0], + [1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0], + [1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1], + [1, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0], + [1, 0, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1], + [1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1], + [1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0], + [1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 1], + [1, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0], + [0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1], + [0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0], + [0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1], + [0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0], + [0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0], + [0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1], + [0, 1, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0], + [0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1], + [0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1], + [0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0], + [0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1], + [0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 1], + [0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0], + [0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1] + ]; + + /** + * version info string = $qr_version_info[ (version - 7) ] + */ + protected const VERSION_INFO = [ + [0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0], + [0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0], + [0, 0, 1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1], + [0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1], + [0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0], + [0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 1, 0], + [0, 0, 1, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1], + [0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1], + [0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0], + [0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1], + [0, 1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1], + [0, 1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0], + [0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 0], + [0, 1, 0, 1, 0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1], + [0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 1], + [0, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0], + [0, 1, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 1, 0, 0], + [0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1], + [0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, 1, 1], + [0, 1, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0], + [0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0], + [0, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1], + [0, 1, 1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1], + [0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1], + [1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0], + [1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1, 0], + [1, 0, 0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1], + [1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1], + [1, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0], + [1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0], + [1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1], + [1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1] + ]; +} diff --git a/kirby/src/Option/Option.php b/kirby/src/Option/Option.php new file mode 100644 index 0000000..3e58741 --- /dev/null +++ b/kirby/src/Option/Option.php @@ -0,0 +1,64 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Option +{ + public function __construct( + public string|int|float|null $value, + public bool $disabled = false, + public NodeIcon|null $icon = null, + public NodeText|null $info = null, + public NodeText|null $text = null + ) { + $this->text ??= new NodeText(['en' => $this->value]); + } + + public static function factory(string|int|float|null|array $props): static + { + if (is_array($props) === false) { + $props = ['value' => $props]; + } + + $props = Factory::apply($props, [ + 'icon' => NodeIcon::class, + 'info' => NodeText::class, + 'text' => NodeText::class + ]); + + return new static(...$props); + } + + public function id(): string|int|float + { + return $this->value ?? ''; + } + + /** + * Renders all data for the option + */ + public function render(ModelWithContent $model): array + { + return [ + 'disabled' => $this->disabled, + 'icon' => $this->icon?->render($model), + 'info' => $this->info?->render($model), + 'text' => $this->text?->render($model), + 'value' => $this->value + ]; + } +} diff --git a/kirby/src/Option/Options.php b/kirby/src/Option/Options.php new file mode 100644 index 0000000..de16a14 --- /dev/null +++ b/kirby/src/Option/Options.php @@ -0,0 +1,57 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Options extends Collection +{ + public const TYPE = Option::class; + + public function __construct(array $objects = []) + { + foreach ($objects as $object) { + $this->__set($object->value, $object); + } + } + + public static function factory(array $items = []): static + { + $collection = new static(); + + foreach ($items as $key => $option) { + // convert an associative value => text array into props; + // skip if option is already an array of option props + if ( + is_array($option) === false || + array_key_exists('value', $option) === false + ) { + $option = match (true) { + is_string($key) => ['value' => $key, 'text' => $option], + default => ['value' => $option] + }; + } + + $option = Option::factory($option); + $collection->__set($option->id(), $option); + } + + return $collection; + } + + public function render(ModelWithContent $model): array + { + return array_values(parent::render($model)); + } +} diff --git a/kirby/src/Option/OptionsApi.php b/kirby/src/Option/OptionsApi.php new file mode 100644 index 0000000..a3b7ae8 --- /dev/null +++ b/kirby/src/Option/OptionsApi.php @@ -0,0 +1,148 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class OptionsApi extends OptionsProvider +{ + public function __construct( + public string $url, + public string|null $query = null, + public string|null $text = null, + public string|null $value = null + ) { + } + + public function defaults(): static + { + $this->text ??= '{{ item.value }}'; + $this->value ??= '{{ item.key }}'; + return $this; + } + + public static function factory(string|array $props): static + { + if (is_string($props) === true) { + return new static(url: $props); + } + + return new static( + url: $props['url'], + query: $props['query'] ?? $props['fetch'] ?? null, + text: $props['text'] ?? null, + value: $props['value'] ?? null + ); + } + + /** + * Loads the API content from a remote URL + * or local file (or from cache) + */ + public function load(ModelWithContent $model): array|null + { + // resolve query templates in $this->url string + $url = $model->toSafeString($this->url); + + // URL, request via cURL + if (Url::isAbsolute($url) === true) { + return Remote::get($url)->json(); + } + + // local file + return Json::read($url); + } + + public static function polyfill(array|string $props = []): array + { + if (is_string($props) === true) { + return ['url' => $props]; + } + + if ($query = $props['fetch'] ?? null) { + $props['query'] ??= $query; + unset($props['fetch']); + } + + return $props; + } + + /** + * Creates the actual options by loading + * data from the API and resolving it to + * the correct text-value entries + * + * @param bool $safeMode Whether to escape special HTML characters in + * the option text for safe output in the Panel; + * only set to `false` if the text is later escaped! + */ + public function resolve(ModelWithContent $model, bool $safeMode = true): Options + { + // use cached options if present + // @codeCoverageIgnoreStart + if ($this->options !== null) { + return $this->options; + } + // @codeCoverageIgnoreEnd + + // apply property defaults + $this->defaults(); + + // load data from URL and convert from JSON to array + $data = $this->load($model); + + // @codeCoverageIgnoreStart + if ($data === null) { + throw new NotFoundException('Options could not be loaded from API: ' . $model->toSafeString($this->url)); + } + // @codeCoverageIgnoreEnd + + // turn data into Nest so that it can be queried + // or field methods applied to the data + $data = Nest::create($data); + + // optionally query a substructure inside the data array + $data = Query::factory($this->query)->resolve($data); + $options = []; + + // create options by resolving text and value query strings + // for each item from the data + foreach ($data as $key => $item) { + // convert simple `key: value` API data + if (is_string($item) === true) { + $item = new Field(null, $key, $item); + } + + $safeMethod = $safeMode === true ? 'toSafeString' : 'toString'; + + $options[] = [ + // value is always a raw string + 'value' => $model->toString($this->value, ['item' => $item]), + // text is only a raw string when using {< >} + // or when the safe mode is explicitly disabled (select field) + 'text' => $model->$safeMethod($this->text, ['item' => $item]) + ]; + } + + // create Options object and render this subsequently + return $this->options = Options::factory($options); + } +} diff --git a/kirby/src/Option/OptionsProvider.php b/kirby/src/Option/OptionsProvider.php new file mode 100644 index 0000000..433b2ab --- /dev/null +++ b/kirby/src/Option/OptionsProvider.php @@ -0,0 +1,38 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class OptionsProvider +{ + public Options|null $options = null; + + /** + * Returns options as array + */ + public function render(ModelWithContent $model) + { + return $this->resolve($model)->render($model); + } + + /** + * Dynamically determines the actual options and resolves + * them to the correct text-value entries + * + * @param bool $safeMode Whether to escape special HTML characters in + * the option text for safe output in the Panel; + * only set to `false` if the text is later escaped! + */ + abstract public function resolve(ModelWithContent $model, bool $safeMode = true): Options; +} diff --git a/kirby/src/Option/OptionsQuery.php b/kirby/src/Option/OptionsQuery.php new file mode 100644 index 0000000..ce4f4e4 --- /dev/null +++ b/kirby/src/Option/OptionsQuery.php @@ -0,0 +1,184 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class OptionsQuery extends OptionsProvider +{ + public function __construct( + public string $query, + public string|null $text = null, + public string|null $value = null + ) { + } + + protected function collection(array $array): Collection + { + foreach ($array as $key => $value) { + if (is_scalar($value) === true) { + $array[$key] = new Obj([ + 'key' => new Field(null, 'key', $key), + 'value' => new Field(null, 'value', $value), + ]); + } + } + + return new Collection($array); + } + + public static function factory(string|array $props): static + { + if (is_string($props) === true) { + return new static(query: $props); + } + + return new static( + query: $props['query'] ?? $props['fetch'], + text: $props['text'] ?? null, + value: $props['value'] ?? null + ); + } + + /** + * Returns defaults for the following based on item type: + * [query entry alias, default text query, default value query] + */ + protected function itemToDefaults(array|object $item): array + { + return match (true) { + is_array($item), + $item instanceof Obj => [ + 'arrayItem', + '{{ item.value }}', + '{{ item.value }}' + ], + + $item instanceof StructureObject => [ + 'structureItem', + '{{ item.title }}', + '{{ item.id }}' + ], + + $item instanceof Block => [ + 'block', + '{{ block.type }}: {{ block.id }}', + '{{ block.id }}' + ], + + $item instanceof Page => [ + 'page', + '{{ page.title }}', + '{{ page.id }}' + ], + + $item instanceof File => [ + 'file', + '{{ file.filename }}', + '{{ file.id }}' + ], + + $item instanceof User => [ + 'user', + '{{ user.username }}', + '{{ user.email }}' + ], + + default => [ + 'item', + '{{ item.value }}', + '{{ item.value }}' + ] + }; + } + + public static function polyfill(array|string $props = []): array + { + if (is_string($props) === true) { + return ['query' => $props]; + } + + if ($query = $props['fetch'] ?? null) { + $props['query'] ??= $query; + unset($props['fetch']); + } + + return $props; + } + + /** + * Creates the actual options by running + * the query on the model and resolving it to + * the correct text-value entries + * + * @param bool $safeMode Whether to escape special HTML characters in + * the option text for safe output in the Panel; + * only set to `false` if the text is later escaped! + */ + public function resolve(ModelWithContent $model, bool $safeMode = true): Options + { + // use cached options if present + // @codeCoverageIgnoreStart + if ($this->options !== null) { + return $this->options; + } + // @codeCoverageIgnoreEnd + + // run query + $result = $model->query($this->query); + + // the query already returned an options collection + if ($result instanceof Options) { + return $result; + } + + // convert result to a collection + if (is_array($result) === true) { + $result = $this->collection($result); + } + + if ($result instanceof Collection === false) { + throw new InvalidArgumentException('Invalid query result data: ' . get_class($result)); + } + + // create options array + $options = $result->toArray(function ($item) use ($model, $safeMode) { + // get defaults based on item type + [$alias, $text, $value] = $this->itemToDefaults($item); + $data = ['item' => $item, $alias => $item]; + + // value is always a raw string + $value = $model->toString($this->value ?? $value, $data); + + // text is only a raw string when using {< >} + // or when the safe mode is explicitly disabled (select field) + $safeMethod = $safeMode === true ? 'toSafeString' : 'toString'; + $text = $model->$safeMethod($this->text ?? $text, $data); + + return compact('text', 'value'); + }); + + return $this->options = Options::factory($options); + } +} diff --git a/kirby/src/Panel/Assets.php b/kirby/src/Panel/Assets.php new file mode 100644 index 0000000..5e02a59 --- /dev/null +++ b/kirby/src/Panel/Assets.php @@ -0,0 +1,298 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + * @since 4.0.0 + */ +class Assets +{ + protected bool $dev; + protected App $kirby; + protected string $nonce; + protected Plugins $plugins; + protected string $url; + protected bool $vite; + + public function __construct() + { + $this->kirby = App::instance(); + $this->nonce = $this->kirby->nonce(); + $this->plugins = new Plugins(); + + $vite = $this->kirby->roots()->panel() . '/.vite-running'; + $this->vite = is_file($vite) === true; + + // get the assets from the Vite dev server in dev mode; + // dev mode = explicitly enabled in the config AND Vite is running + $dev = $this->kirby->option('panel.dev', false); + $this->dev = $dev !== false && $this->vite === true; + + // get the base URL + $this->url = $this->url(); + } + + /** + * Get all CSS files + */ + public function css(): array + { + $css = A::merge( + [ + 'index' => $this->url . '/css/style.min.css', + 'plugins' => $this->plugins->url('css') + ], + $this->custom('panel.css') + ); + + // during dev mode we do not need to load + // the general stylesheet (as styling will be inlined) + if ($this->dev === true) { + $css['index'] = null; + } + + return array_filter($css); + } + + /** + * Check for a custom asset file from the + * config (e.g. panel.css or panel.js) + */ + public function custom(string $option): array + { + $customs = []; + + if ($assets = $this->kirby->option($option)) { + $assets = A::wrap($assets); + + foreach ($assets as $index => $path) { + if (Url::isAbsolute($path) === true) { + $customs['custom-' . $index] = $path; + continue; + } + + $asset = new Asset($path); + + if ($asset->exists() === true) { + $customs['custom-' . $index] = $asset->url() . '?' . $asset->modified(); + } + } + } + + return $customs; + } + + /** + * Generates an array with all assets + * that need to be loaded for the panel (js, css, icons) + */ + public function external(): array + { + return [ + 'css' => $this->css(), + 'icons' => $this->favicons(), + // loader for plugins' index.dev.mjs files – inlined, + // so we provide the code instead of the asset URL + 'plugin-imports' => $this->plugins->read('mjs'), + 'js' => $this->js() + ]; + } + + /** + * Returns array of favicon icons + * based on config option + * + * @throws \Kirby\Exception\InvalidArgumentException + */ + public function favicons(): array + { + $icons = $this->kirby->option('panel.favicon', [ + 'apple-touch-icon' => [ + 'type' => 'image/png', + 'url' => $this->url . '/apple-touch-icon.png', + ], + 'alternate icon' => [ + 'type' => 'image/png', + 'url' => $this->url . '/favicon.png', + ], + 'shortcut icon' => [ + 'type' => 'image/svg+xml', + 'url' => $this->url . '/favicon.svg', + ] + ]); + + if (is_array($icons) === true) { + return $icons; + } + + // make sure to convert favicon string to array + if (is_string($icons) === true) { + return [ + 'shortcut icon' => [ + 'type' => F::mime($icons), + 'url' => $icons, + ] + ]; + } + + throw new InvalidArgumentException('Invalid panel.favicon option'); + } + + /** + * Load the SVG icon sprite + * This will be injected in the + * initial HTML document for the Panel + */ + public function icons(): string + { + $dir = $this->kirby->root('panel') . '/'; + $dir .= $this->dev ? 'public' : 'dist'; + $icons = F::read($dir . '/img/icons.svg'); + $icons = preg_replace('//', '', $icons); + return $icons; + } + + /** + * Get all js files + */ + public function js(): array + { + $js = A::merge( + [ + 'vue' => [ + 'nonce' => $this->nonce, + 'src' => $this->url . '/js/vue.min.js' + ], + 'vendor' => [ + 'nonce' => $this->nonce, + 'src' => $this->url . '/js/vendor.min.js', + 'type' => 'module' + ], + 'pluginloader' => [ + 'nonce' => $this->nonce, + 'src' => $this->url . '/js/plugins.js', + 'type' => 'module' + ], + 'plugins' => [ + 'nonce' => $this->nonce, + 'src' => $this->plugins->url('js'), + 'defer' => true + ] + ], + A::map($this->custom('panel.js'), fn ($src) => [ + 'nonce' => $this->nonce, + 'src' => $src, + 'type' => 'module' + ]), + [ + 'index' => [ + 'nonce' => $this->nonce, + 'src' => $this->url . '/js/index.min.js', + 'type' => 'module' + ], + ] + ); + + + // during dev mode, add vite client and adapt + // path to `index.js` - vendor does not need + // to be loaded in dev mode + if ($this->dev === true) { + $js['vite'] = [ + 'nonce' => $this->nonce, + 'src' => $this->url . '/@vite/client', + 'type' => 'module' + ]; + + $js['index'] = [ + 'nonce' => $this->nonce, + 'src' => $this->url . '/src/index.js', + 'type' => 'module' + ]; + + // load the development version of Vue + $js['vue']['src'] = $this->url . '/node_modules/vue/dist/vue.js'; + + // remove the vendor script + $js['vendor']['src'] = null; + } + + return array_filter($js, fn ($js) => empty($js['src']) === false); + } + + /** + * Links all dist files in the media folder + * and returns the link to the requested asset + * + * @throws \Kirby\Exception\Exception If Panel assets could not be moved to the public directory + */ + public function link(): bool + { + $mediaRoot = $this->kirby->root('media') . '/panel'; + $panelRoot = $this->kirby->root('panel') . '/dist'; + $versionHash = $this->kirby->versionHash(); + $versionRoot = $mediaRoot . '/' . $versionHash; + + // check if the version already exists + if (is_dir($versionRoot) === true) { + return false; + } + + // delete the panel folder and all previous versions + Dir::remove($mediaRoot); + + // recreate the panel folder + Dir::make($mediaRoot, true); + + // copy assets to the dist folder + if (Dir::copy($panelRoot, $versionRoot) !== true) { + throw new Exception('Panel assets could not be linked'); + } + + return true; + } + + /** + * Get the base URL for all assets depending on dev mode + */ + public function url(): string + { + // vite is not running, use production assets + if ($this->dev === false) { + return $this->kirby->url('media') . '/panel/' . $this->kirby->versionHash(); + } + + // explicitly configured base URL + $dev = $this->kirby->option('panel.dev'); + if (is_string($dev) === true) { + return $dev; + } + + // port 3000 of the current Kirby request + return rtrim($this->kirby->request()->url([ + 'port' => 3000, + 'path' => null, + 'params' => null, + 'query' => null + ])->toString(), '/'); + } +} diff --git a/kirby/src/Panel/ChangesDialog.php b/kirby/src/Panel/ChangesDialog.php new file mode 100644 index 0000000..7053626 --- /dev/null +++ b/kirby/src/Panel/ChangesDialog.php @@ -0,0 +1,71 @@ +multilang(); + $changes = []; + + foreach ($ids as $id) { + try { + // parse the given ID to extract + // the path and an optional query + $uri = new Uri($id); + $path = $uri->path()->toString(); + $query = $uri->query(); + $model = Find::parent($path); + $item = $model->panel()->dropdownOption(); + + // add the language to each option, if it is included in the query + // of the given ID and the language actually exists + if ( + $multilang && + $query->language && + $language = $kirby->language($query->language) + ) { + $item['text'] .= ' (' . $language->code() . ')'; + $item['link'] .= '?language=' . $language->code(); + } + + $item['text'] = Escape::html($item['text']); + + $changes[] = $item; + } catch (Throwable) { + continue; + } + } + + return $changes; + } + + public function load(): array + { + return $this->state(); + } + + public function state(bool $loading = true, array $changes = []) + { + return [ + 'component' => 'k-changes-dialog', + 'props' => [ + 'changes' => $changes, + 'loading' => $loading + ] + ]; + } + + public function submit(array $ids): array + { + return $this->state(false, $this->changes($ids)); + } +} diff --git a/kirby/src/Panel/Dialog.php b/kirby/src/Panel/Dialog.php new file mode 100644 index 0000000..df43c4a --- /dev/null +++ b/kirby/src/Panel/Dialog.php @@ -0,0 +1,72 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Dialog extends Json +{ + protected static string $key = '$dialog'; + + /** + * Renders dialogs + */ + public static function response($data, array $options = []): Response + { + // interpret true as success + if ($data === true) { + $data = [ + 'code' => 200 + ]; + } + + return parent::response($data, $options); + } + + /** + * Builds the routes for a dialog + */ + public static function routes( + string $id, + string $areaId, + string $prefix = '', + array $options = [] + ) { + $routes = []; + + // create the full pattern with dialogs prefix + $pattern = trim($prefix . '/' . ($options['pattern'] ?? $id), '/'); + $type = str_replace('$', '', static::$key); + + // load event + $routes[] = [ + 'pattern' => $pattern, + 'type' => $type, + 'area' => $areaId, + 'action' => $options['load'] ?? fn () => 'The load handler is missing' + ]; + + // submit event + $routes[] = [ + 'pattern' => $pattern, + 'type' => $type, + 'area' => $areaId, + 'method' => 'POST', + 'action' => $options['submit'] ?? fn () => 'The submit handler is missing' + ]; + + return $routes; + } +} diff --git a/kirby/src/Panel/Document.php b/kirby/src/Panel/Document.php new file mode 100644 index 0000000..1c145f0 --- /dev/null +++ b/kirby/src/Panel/Document.php @@ -0,0 +1,72 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Document +{ + /** + * Renders the panel document + */ + public static function response(array $fiber): Response + { + $kirby = App::instance(); + $assets = new Assets(); + + // Full HTML response + // @codeCoverageIgnoreStart + try { + if ($assets->link() === true) { + usleep(1); + Response::go($kirby->url('base') . '/' . $kirby->path()); + } + } catch (Throwable $e) { + die('The Panel assets cannot be installed properly. ' . $e->getMessage()); + } + // @codeCoverageIgnoreEnd + + // get the uri object for the panel url + $uri = new Uri($kirby->url('panel')); + + // proper response code + $code = $fiber['$view']['code'] ?? 200; + + // load the main Panel view template + $body = Tpl::load($kirby->root('kirby') . '/views/panel.php', [ + 'assets' => $assets->external(), + 'icons' => $assets->icons(), + 'nonce' => $kirby->nonce(), + 'fiber' => $fiber, + 'panelUrl' => $uri->path()->toString(true) . '/', + ]); + + $frameAncestors = $kirby->option('panel.frameAncestors'); + $frameAncestors = match (true) { + $frameAncestors === true => "'self'", + is_array($frameAncestors) => "'self' " . implode(' ', $frameAncestors), + is_string($frameAncestors) => $frameAncestors, + default => "'none'" + }; + + return new Response($body, 'text/html', $code, [ + 'Content-Security-Policy' => 'frame-ancestors ' . $frameAncestors + ]); + } +} diff --git a/kirby/src/Panel/Drawer.php b/kirby/src/Panel/Drawer.php new file mode 100644 index 0000000..0952088 --- /dev/null +++ b/kirby/src/Panel/Drawer.php @@ -0,0 +1,21 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Drawer extends Dialog +{ + protected static string $key = '$drawer'; +} diff --git a/kirby/src/Panel/Dropdown.php b/kirby/src/Panel/Dropdown.php new file mode 100644 index 0000000..de01bf6 --- /dev/null +++ b/kirby/src/Panel/Dropdown.php @@ -0,0 +1,71 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Dropdown extends Json +{ + protected static string $key = '$dropdown'; + + /** + * Renders dropdowns + */ + public static function response($data, array $options = []): Response + { + if (is_array($data) === true) { + $data = [ + 'options' => array_values($data) + ]; + } + + return parent::response($data, $options); + } + + /** + * Routes for the dropdown + */ + public static function routes( + string $id, + string $areaId, + string $prefix = '', + Closure|array $options = [] + ): array { + // Handle shortcuts for dropdowns. The name is the pattern + // and options are defined in a Closure + if ($options instanceof Closure) { + $options = [ + 'pattern' => $id, + 'action' => $options + ]; + } + + // create the full pattern with dialogs prefix + $pattern = trim($prefix . '/' . ($options['pattern'] ?? $id), '/'); + $type = str_replace('$', '', static::$key); + + return [ + // load event + [ + 'pattern' => $pattern, + 'type' => $type, + 'area' => $areaId, + 'method' => 'GET|POST', + 'action' => $options['options'] ?? $options['action'] + ] + ]; + } +} diff --git a/kirby/src/Panel/Field.php b/kirby/src/Panel/Field.php new file mode 100644 index 0000000..e895712 --- /dev/null +++ b/kirby/src/Panel/Field.php @@ -0,0 +1,292 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Field +{ + /** + * Creates the routes for a field dialog + * This is most definitely not a good place for this + * method, but as long as the other classes are + * not fully refactored, it still feels appropriate + */ + public static function dialog( + ModelWithContent $model, + string $fieldName, + string|null $path = null, + string $method = 'GET', + ) { + $field = Form::for($model)->field($fieldName); + $routes = []; + + foreach ($field->dialogs() as $dialogId => $dialog) { + $routes = array_merge($routes, Dialog::routes( + id: $dialogId, + areaId: 'site', + options: $dialog + )); + } + + return Router::execute($path, $method, $routes); + } + + /** + * Creates the routes for a field drawer + * This is most definitely not a good place for this + * method, but as long as the other classes are + * not fully refactored, it still feels appropriate + */ + public static function drawer( + ModelWithContent $model, + string $fieldName, + string|null $path = null, + string $method = 'GET', + ) { + $field = Form::for($model)->field($fieldName); + $routes = []; + + foreach ($field->drawers() as $drawerId => $drawer) { + $routes = array_merge($routes, Drawer::routes( + id: $drawerId, + areaId: 'site', + options: $drawer + )); + } + + return Router::execute($path, $method, $routes); + } + + /** + * A standard email field + */ + public static function email(array $props = []): array + { + return array_merge([ + 'label' => I18n::translate('email'), + 'type' => 'email', + 'counter' => false, + ], $props); + } + + /** + * File position + */ + public static function filePosition(File $file, array $props = []): array + { + $index = 0; + $options = []; + + foreach ($file->siblings(false)->sorted() as $sibling) { + $index++; + + $options[] = [ + 'value' => $index, + 'text' => $index + ]; + + $options[] = [ + 'value' => $sibling->id(), + 'text' => $sibling->filename(), + 'disabled' => true + ]; + } + + $index++; + + $options[] = [ + 'value' => $index, + 'text' => $index + ]; + + return array_merge([ + 'label' => I18n::translate('file.sort'), + 'type' => 'select', + 'empty' => false, + 'options' => $options + ], $props); + } + + + public static function hidden(): array + { + return ['hidden' => true]; + } + + /** + * Page position + */ + public static function pagePosition(Page $page, array $props = []): array + { + $index = 0; + $options = []; + $siblings = $page->parentModel()->children()->listed()->not($page); + + foreach ($siblings as $sibling) { + $index++; + + $options[] = [ + 'value' => $index, + 'text' => $index + ]; + + $options[] = [ + 'value' => $sibling->id(), + 'text' => $sibling->title()->value(), + 'disabled' => true + ]; + } + + $index++; + + $options[] = [ + 'value' => $index, + 'text' => $index + ]; + + // if only one available option, + // hide field when not in debug mode + if (count($options) < 2) { + return static::hidden(); + } + + return array_merge([ + 'label' => I18n::translate('page.changeStatus.position'), + 'type' => 'select', + 'empty' => false, + 'options' => $options, + ], $props); + } + + /** + * A regular password field + */ + public static function password(array $props = []): array + { + return array_merge([ + 'label' => I18n::translate('password'), + 'type' => 'password' + ], $props); + } + + /** + * User role radio buttons + */ + public static function role(array $props = []): array + { + $kirby = App::instance(); + $isAdmin = $kirby->user()?->isAdmin() ?? false; + $roles = []; + + foreach ($kirby->roles() as $role) { + // exclude the admin role, if the user + // is not allowed to change role to admin + if ($role->name() === 'admin' && $isAdmin === false) { + continue; + } + + $roles[] = [ + 'text' => $role->title(), + 'info' => $role->description() ?? I18n::translate('role.description.placeholder'), + 'value' => $role->name() + ]; + } + + return array_merge([ + 'label' => I18n::translate('role'), + 'type' => count($roles) <= 1 ? 'hidden' : 'radio', + 'options' => $roles + ], $props); + } + + public static function slug(array $props = []): array + { + return array_merge([ + 'label' => I18n::translate('slug'), + 'type' => 'slug', + 'allow' => Str::$defaults['slug']['allowed'] + ], $props); + } + + public static function template( + array|null $blueprints = [], + array|null $props = [] + ): array { + $options = []; + + foreach ($blueprints as $blueprint) { + $options[] = [ + 'text' => $blueprint['title'] ?? $blueprint['text'] ?? null, + 'value' => $blueprint['name'] ?? $blueprint['value'] ?? null, + ]; + } + + return array_merge([ + 'label' => I18n::translate('template'), + 'type' => 'select', + 'empty' => false, + 'options' => $options, + 'icon' => 'template', + 'disabled' => count($options) <= 1 + ], $props); + } + + public static function title(array $props = []): array + { + return array_merge([ + 'label' => I18n::translate('title'), + 'type' => 'text', + 'icon' => 'title', + ], $props); + } + + /** + * Panel translation select box + */ + public static function translation(array $props = []): array + { + $translations = []; + foreach (App::instance()->translations() as $translation) { + $translations[] = [ + 'text' => $translation->name(), + 'value' => $translation->code() + ]; + } + + return array_merge([ + 'label' => I18n::translate('language'), + 'type' => 'select', + 'icon' => 'translate', + 'options' => $translations, + 'empty' => false + ], $props); + } + + public static function username(array $props = []): array + { + return array_merge([ + 'icon' => 'user', + 'label' => I18n::translate('name'), + 'type' => 'text', + ], $props); + } +} diff --git a/kirby/src/Panel/File.php b/kirby/src/Panel/File.php new file mode 100644 index 0000000..d58e7a1 --- /dev/null +++ b/kirby/src/Panel/File.php @@ -0,0 +1,493 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class File extends Model +{ + /** + * @var \Kirby\Cms\File + */ + protected ModelWithContent $model; + + /** + * Breadcrumb array + */ + public function breadcrumb(): array + { + $breadcrumb = []; + $parent = $this->model->parent(); + + switch ($parent::CLASS_ALIAS) { + case 'user': + /** @var \Kirby\Cms\User $parent */ + // The breadcrumb is not necessary + // on the account view + if ($parent->isLoggedIn() === false) { + $breadcrumb[] = [ + 'label' => $parent->username(), + 'link' => $parent->panel()->url(true) + ]; + } + break; + case 'page': + /** @var \Kirby\Cms\Page $parent */ + $breadcrumb = $this->model->parents()->flip()->values( + fn ($parent) => [ + 'label' => $parent->title()->toString(), + 'link' => $parent->panel()->url(true), + ] + ); + } + + // add the file + $breadcrumb[] = [ + 'label' => $this->model->filename(), + 'link' => $this->url(true), + ]; + + return $breadcrumb; + } + + /** + * Provides a kirbytag or markdown + * tag for the file, which will be + * used in the panel, when the file + * gets dragged onto a textarea + * + * @internal + * @param string|null $type (`auto`|`kirbytext`|`markdown`) + */ + public function dragText( + string|null $type = null, + bool $absolute = false + ): string { + $type = $this->dragTextType($type); + $url = $this->model->filename(); + $file = $this->model->type(); + + // By default only the filename is added as relative URL. + // If an absolute URL is required, either use the permalink + // for markdown notation or the UUID for Kirbytext (since + // Kirbytags support can resolve UUIDs directly) + if ($absolute === true) { + $url = match ($type) { + 'markdown' => $this->model->permalink(), + default => $this->model->uuid() + }; + + // if UUIDs are disabled, fall back to URL + $url ??= $this->model->url(); + } + + if ($callback = $this->dragTextFromCallback($type, $url)) { + return $callback; + } + + if ($type === 'markdown') { + return match ($file) { + 'image' => '![' . $this->model->alt() . '](' . $url . ')', + default => '[' . $this->model->filename() . '](' . $url . ')' + }; + } + + return match ($file) { + 'image', 'video' => '(' . $file . ': ' . $url . ')', + default => '(file: ' . $url . ')' + }; + } + + /** + * Provides options for the file dropdown + */ + public function dropdown(array $options = []): array + { + $file = $this->model; + $request = $file->kirby()->request(); + $defaults = $request->get(['view', 'update', 'delete']); + $options = array_merge($defaults, $options); + + $permissions = $this->options(['preview']); + $view = $options['view'] ?? 'view'; + $url = $this->url(true); + $result = []; + + if ($view === 'list') { + $result[] = [ + 'link' => $file->previewUrl(), + 'target' => '_blank', + 'icon' => 'open', + 'text' => I18n::translate('open') + ]; + $result[] = '-'; + } + + $result[] = [ + 'dialog' => $url . '/changeName', + 'icon' => 'title', + 'text' => I18n::translate('rename'), + 'disabled' => $this->isDisabledDropdownOption('changeName', $options, $permissions) + ]; + + if ($view === 'list') { + $result[] = [ + 'dialog' => $url . '/changeSort', + 'icon' => 'sort', + 'text' => I18n::translate('file.sort'), + 'disabled' => $this->isDisabledDropdownOption('update', $options, $permissions) + ]; + } + + $result[] = [ + 'dialog' => $url . '/changeTemplate', + 'icon' => 'template', + 'text' => I18n::translate('file.changeTemplate'), + 'disabled' => $this->isDisabledDropdownOption('changeTemplate', $options, $permissions) + ]; + + $result[] = '-'; + + $result[] = [ + 'click' => 'replace', + 'icon' => 'upload', + 'text' => I18n::translate('replace'), + 'disabled' => $this->isDisabledDropdownOption('replace', $options, $permissions) + ]; + + $result[] = '-'; + $result[] = [ + 'dialog' => $url . '/delete', + 'icon' => 'trash', + 'text' => I18n::translate('delete'), + 'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions) + ]; + + return $result; + } + + /** + * Returns the setup for a dropdown option + * which is used in the changes dropdown + * for example + */ + public function dropdownOption(): array + { + return [ + 'icon' => 'image', + 'text' => $this->model->filename(), + ] + parent::dropdownOption(); + } + + /** + * Returns the Panel icon color + */ + protected function imageColor(): string + { + $types = [ + 'archive' => 'gray-500', + 'audio' => 'aqua-500', + 'code' => 'pink-500', + 'document' => 'red-500', + 'image' => 'orange-500', + 'video' => 'yellow-500', + ]; + + $extensions = [ + 'csv' => 'green-500', + 'doc' => 'blue-500', + 'docx' => 'blue-500', + 'indd' => 'purple-500', + 'rtf' => 'blue-500', + 'xls' => 'green-500', + 'xlsx' => 'green-500', + ]; + + return + $extensions[$this->model->extension()] ?? + $types[$this->model->type()] ?? + parent::imageDefaults()['color']; + } + + /** + * Default settings for the file's Panel image + */ + protected function imageDefaults(): array + { + return array_merge(parent::imageDefaults(), [ + 'color' => $this->imageColor(), + 'icon' => $this->imageIcon(), + ]); + } + + /** + * Returns the Panel icon type + */ + protected function imageIcon(): string + { + $types = [ + 'archive' => 'archive', + 'audio' => 'audio', + 'code' => 'code', + 'document' => 'document', + 'image' => 'image', + 'video' => 'video', + ]; + + $extensions = [ + 'csv' => 'table', + 'doc' => 'pen', + 'docx' => 'pen', + 'md' => 'markdown', + 'mdown' => 'markdown', + 'rtf' => 'pen', + 'xls' => 'table', + 'xlsx' => 'table', + ]; + + return + $extensions[$this->model->extension()] ?? + $types[$this->model->type()] ?? + 'file'; + } + + /** + * Returns the image file object based on provided query + * @internal + */ + protected function imageSource( + string|null $query = null + ): CmsFile|Asset|null { + if ($query === null && $this->model->isViewable()) { + return $this->model; + } + + return parent::imageSource($query); + } + + /** + * Whether focus can be added in Panel view + */ + public function isFocusable(): bool + { + // blueprint option + $option = $this->model->blueprint()->focus(); + // fallback to whether the file is viewable + // (images should be focusable by default, others not) + $option ??= $this->model->isViewable(); + + if ($option === false) { + return false; + } + + // ensure that user can update content file + if ($this->options()['update'] === false) { + return false; + } + + $kirby = $this->model->kirby(); + + // ensure focus is only added when editing primary/only language + if ( + $kirby->multilang() === false || + $kirby->languages()->count() === 0 || + $kirby->language()->isDefault() === true + ) { + return true; + } + + return false; + } + + /** + * Returns an array of all actions + * that can be performed in the Panel + * + * @param array $unlock An array of options that will be force-unlocked + */ + public function options(array $unlock = []): array + { + $options = parent::options($unlock); + + try { + // check if the file type is allowed at all, + // otherwise it cannot be replaced + $this->model->match($this->model->blueprint()->accept()); + } catch (Throwable) { + $options['replace'] = false; + } + + return $options; + } + + /** + * Returns the full path without leading slash + */ + public function path(): string + { + return 'files/' . $this->model->filename(); + } + + /** + * Prepares the response data for file pickers + * and file fields + */ + public function pickerData(array $params = []): array + { + $name = $this->model->filename(); + $id = $this->model->id(); + + if (empty($params['model']) === false) { + $parent = $this->model->parent(); + + // if the file belongs to the current parent model, + // store only name as ID to keep its path relative to the model + $id = $parent === $params['model'] ? $name : $id; + $absolute = $parent !== $params['model']; + } + + $params['text'] ??= '{{ file.filename }}'; + + return array_merge(parent::pickerData($params), [ + 'dragText' => $this->dragText('auto', $absolute ?? false), + 'filename' => $name, + 'id' => $id, + 'type' => $this->model->type(), + 'url' => $this->model->url() + ]); + } + + /** + * Returns the data array for the + * view's component props + * @internal + */ + public function props(): array + { + $file = $this->model; + $dimensions = $file->dimensions(); + + return array_merge( + parent::props(), + $this->prevNext(), + [ + 'blueprint' => $this->model->template() ?? 'default', + 'model' => [ + 'content' => $this->content(), + 'dimensions' => $dimensions->toArray(), + 'extension' => $file->extension(), + 'filename' => $file->filename(), + 'link' => $this->url(true), + 'mime' => $file->mime(), + 'niceSize' => $file->niceSize(), + 'id' => $id = $file->id(), + 'parent' => $file->parent()->panel()->path(), + 'template' => $file->template(), + 'type' => $file->type(), + 'url' => $file->url(), + ], + 'preview' => [ + 'focusable' => $this->isFocusable(), + 'image' => $this->image([ + 'back' => 'transparent', + 'ratio' => '1/1' + ], 'cards'), + 'url' => $url = $file->previewUrl(), + 'details' => [ + [ + 'title' => I18n::translate('template'), + 'text' => $file->template() ?? '—' + ], + [ + 'title' => I18n::translate('mime'), + 'text' => $file->mime() + ], + [ + 'title' => I18n::translate('url'), + 'text' => $id, + 'link' => $url + ], + [ + 'title' => I18n::translate('size'), + 'text' => $file->niceSize() + ], + [ + 'title' => I18n::translate('dimensions'), + 'text' => $file->type() === 'image' ? $file->dimensions() . ' ' . I18n::translate('pixel') : '—' + ], + [ + 'title' => I18n::translate('orientation'), + 'text' => $file->type() === 'image' ? I18n::translate('orientation.' . $dimensions->orientation()) : '—' + ], + ] + ] + ] + ); + } + + /** + * Returns navigation array with + * previous and next file + * @internal + */ + public function prevNext(): array + { + $file = $this->model; + $siblings = $file->templateSiblings()->sortBy( + 'sort', + 'asc', + 'filename', + 'asc' + ); + + return [ + 'next' => function () use ($file, $siblings): array|null { + $next = $siblings->nth($siblings->indexOf($file) + 1); + return $this->toPrevNextLink($next, 'filename'); + }, + 'prev' => function () use ($file, $siblings): array|null { + $prev = $siblings->nth($siblings->indexOf($file) - 1); + return $this->toPrevNextLink($prev, 'filename'); + } + ]; + } + /** + * Returns the url to the editing view + * in the panel + */ + public function url(bool $relative = false): string + { + $parent = $this->model->parent()->panel()->url($relative); + return $parent . '/' . $this->path(); + } + + /** + * Returns the data array for + * this model's Panel view + * @internal + */ + public function view(): array + { + return [ + 'breadcrumb' => fn (): array => $this->model->panel()->breadcrumb(), + 'component' => 'k-file-view', + 'props' => $this->props(), + 'search' => 'files', + 'title' => $this->model->filename(), + ]; + } +} diff --git a/kirby/src/Panel/Home.php b/kirby/src/Panel/Home.php new file mode 100644 index 0000000..3abb1c4 --- /dev/null +++ b/kirby/src/Panel/Home.php @@ -0,0 +1,255 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Home +{ + /** + * Returns an alternative URL if access + * to the first choice is blocked. + * + * It will go through the entire menu and + * take the first area which is not disabled + * or locked in other ways + */ + public static function alternative(User $user): string + { + $permissions = $user->role()->permissions(); + + // no access to the panel? The only good alternative is the main url + if ($permissions->for('access', 'panel') === false) { + return App::instance()->site()->url(); + } + + // needed to create a proper menu + $areas = Panel::areas(); + $menu = new Menu($areas, $permissions->toArray()); + $menu = $menu->entries(); + + // go through the menu and search for the first + // available view we can go to + foreach ($menu as $menuItem) { + // skip separators + if ($menuItem === '-') { + continue; + } + + // skip disabled items + if (($menuItem['disabled'] ?? false) === true) { + continue; + } + + // skip buttons that don't open a link + // (but e.g. a dialog) + if (isset($menuItem['link']) === false) { + continue; + } + + // skip the logout button + if ($menuItem['link'] === 'logout') { + continue; + } + + return Panel::url($menuItem['link']); + } + + throw new NotFoundException('There’s no available Panel page to redirect to'); + } + + /** + * Checks if the user has access to the given + * panel path. This is quite tricky, because we + * need to call a trimmed down router to check + * for available routes and their firewall status. + */ + public static function hasAccess(User $user, string $path): bool + { + $areas = Panel::areas(); + $routes = Panel::routes($areas); + + // Remove fallback routes. Otherwise a route + // would be found even if the view does + // not exist at all. + foreach ($routes as $index => $route) { + if ($route['pattern'] === '(:all)') { + unset($routes[$index]); + } + } + + // create a dummy router to check if we can access this route at all + try { + return Router::execute($path, 'GET', $routes, function ($route) use ($user) { + $attrs = $route->attributes(); + $auth = $attrs['auth'] ?? true; + $areaId = $attrs['area'] ?? null; + $type = $attrs['type'] ?? 'view'; + + // only allow redirects to views + if ($type !== 'view') { + return false; + } + + // if auth is not required the redirect is allowed + if ($auth === false) { + return true; + } + + // check the firewall + return Panel::hasAccess($user, $areaId); + }); + } catch (Throwable) { + return false; + } + } + + /** + * Checks if the given Uri has the same domain + * as the index URL of the Kirby installation. + * This is used to block external URLs to third-party + * domains as redirect options. + */ + public static function hasValidDomain(Uri $uri): bool + { + $rootUrl = App::instance()->site()->url(); + $rootUri = new Uri($rootUrl); + return $uri->domain() === $rootUri->domain(); + } + + /** + * Checks if the given URL is a Panel Url + */ + public static function isPanelUrl(string $url): bool + { + $panel = App::instance()->url('panel'); + return Str::startsWith($url, $panel); + } + + /** + * Returns the path after /panel/ which can then + * be used in the router or to find a matching view + */ + public static function panelPath(string $url): string|null + { + $after = Str::after($url, App::instance()->url('panel')); + return trim($after, '/'); + } + + /** + * Returns the Url that has been stored in the session + * before the last logout. We take this Url if possible + * to redirect the user back to the last point where they + * left before they got logged out. + */ + public static function remembered(): string|null + { + // check for a stored path after login + if ($remembered = App::instance()->session()->pull('panel.path')) { + // convert the result to an absolute URL if available + return Panel::url($remembered); + } + + return null; + } + + /** + * Tries to find the best possible Url to redirect + * the user to after the login. + * + * When the user got logged out, we try to send them back + * to the point where they left. + * + * If they have a custom redirect Url defined in their blueprint + * via the `home` option, we send them there if no Url is stored + * in the session. + * + * If none of the options above find any result, we try to send + * them to the site view. + * + * Before the redirect happens, the final Url is sanitized, the query + * and params are removed to avoid any attacks and the domain is compared + * to avoid redirects to external Urls. + * + * Afterwards, we also check for permissions before the redirect happens + * to avoid redirects to inaccessible Panel views. In such a case + * the next best accessible view is picked from the menu. + */ + public static function url(): string + { + $user = App::instance()->user(); + + // if there's no authenticated user, all internal + // redirects will be blocked and the user is redirected + // to the login instead + if (!$user) { + return Panel::url('login'); + } + + // get the last visited url from the session or the custom home + $url = static::remembered() ?? $user->panel()->home(); + + // inspect the given URL + $uri = new Uri($url); + + // compare domains to avoid external redirects + if (static::hasValidDomain($uri) !== true) { + throw new InvalidArgumentException('External URLs are not allowed for Panel redirects'); + } + + // remove all params to avoid + // possible attack vectors + $uri->params = ''; + $uri->query = ''; + + // get a clean version of the URL + $url = $uri->toString(); + + // Don't further inspect URLs outside of the Panel + if (static::isPanelUrl($url) === false) { + return $url; + } + + // get the plain panel path + $path = static::panelPath($url); + + // a redirect to login, logout or installation + // views would lead to an infinite redirect loop + if (in_array($path, ['', 'login', 'logout', 'installation'], true) === true) { + $path = 'site'; + } + + // Check if the user can access the URL + if (static::hasAccess($user, $path) === true) { + return Panel::url($path); + } + + // Try to find an alternative + return static::alternative($user); + } +} diff --git a/kirby/src/Panel/Json.php b/kirby/src/Panel/Json.php new file mode 100644 index 0000000..2cfa895 --- /dev/null +++ b/kirby/src/Panel/Json.php @@ -0,0 +1,84 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class Json +{ + protected static string $key = '$response'; + + /** + * Renders the error response with the provided message + */ + public static function error(string $message, int $code = 404): array + { + return [ + 'code' => $code, + 'error' => $message + ]; + } + + /** + * Prepares the JSON response for the Panel + */ + public static function response($data, array $options = []): Response + { + $data = static::responseData($data); + + // always inject the response code + $data['code'] ??= 200; + $data['path'] = $options['path'] ?? null; + $data['query'] = App::instance()->request()->query()->toArray(); + $data['referrer'] = Panel::referrer(); + + return Panel::json([static::$key => $data], $data['code']); + } + + public static function responseData(mixed $data): array + { + // handle redirects + if ($data instanceof Redirect) { + return [ + 'redirect' => $data->location(), + ]; + } + + // handle Kirby exceptions + if ($data instanceof Exception) { + return static::error($data->getMessage(), $data->getHttpCode()); + } + + // handle exceptions + if ($data instanceof Throwable) { + return static::error($data->getMessage(), 500); + } + + // only expect arrays from here on + if (is_array($data) === false) { + return static::error('Invalid response', 500); + } + + if (empty($data) === true) { + return static::error('The response is empty', 404); + } + + return $data; + } +} diff --git a/kirby/src/Panel/Lab/Category.php b/kirby/src/Panel/Lab/Category.php new file mode 100644 index 0000000..4926737 --- /dev/null +++ b/kirby/src/Panel/Lab/Category.php @@ -0,0 +1,134 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Category +{ + protected string $root; + + public function __construct( + protected string $id, + string|null $root = null, + protected array $props = [] + ) { + $this->root = $root ?? static::base() . '/' . $this->id; + + if (file_exists($this->root . '/index.php') === true) { + $this->props = array_merge( + require $this->root . '/index.php', + $this->props + ); + } + } + + public static function all(): array + { + // all core lab examples from `kirby/panel/lab` + $examples = A::map( + Dir::inventory(static::base())['children'], + fn ($props) => (new static($props['dirname']))->toArray() + ); + + // all custom lab examples from `site/lab` + $custom = static::factory('site')->toArray(); + + array_push($examples, $custom); + + return $examples; + } + + public static function base(): string + { + return App::instance()->root('panel') . '/lab'; + } + + public function example(string $id, string|null $tab = null): Example + { + return new Example(parent: $this, id: $id, tab: $tab); + } + + public function examples(): array + { + return A::map( + Dir::inventory($this->root)['children'], + fn ($props) => $this->example($props['dirname'])->toArray() + ); + } + + public static function factory(string $id) + { + return match ($id) { + 'site' => static::site(), + default => new static($id) + }; + } + + public function icon(): string + { + return $this->props['icon'] ?? 'palette'; + } + + public function id(): string + { + return $this->id; + } + + public static function installed(): bool + { + return Dir::exists(static::base()) === true; + } + + public function name(): string + { + return $this->props['name'] ?? ucfirst($this->id); + } + + public function root(): string + { + return $this->root; + } + + public static function site(): static + { + return new static( + 'site', + App::instance()->root('site') . '/lab', + [ + 'name' => 'Your examples', + 'icon' => 'live' + ] + ); + } + + public function toArray(): array + { + return [ + 'name' => $this->name(), + 'examples' => $this->examples(), + 'icon' => $this->icon(), + 'path' => Str::after( + $this->root(), + App::instance()->root('index') + ), + ]; + } +} diff --git a/kirby/src/Panel/Lab/Docs.php b/kirby/src/Panel/Lab/Docs.php new file mode 100644 index 0000000..44a6af0 --- /dev/null +++ b/kirby/src/Panel/Lab/Docs.php @@ -0,0 +1,340 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Docs +{ + protected array $json; + protected App $kirby; + + public function __construct( + protected string $name + ) { + $this->kirby = App::instance(); + $this->json = $this->read(); + } + + public static function all(): array + { + $dist = static::root(); + $tmp = static::root(true); + $files = Dir::inventory($dist)['files']; + + if (Dir::exists($tmp) === true) { + $files = [...Dir::inventory($tmp)['files'], ...$files]; + } + + $docs = A::map( + $files, + function ($file) { + $component = 'k-' . Str::camelToKebab(F::name($file['filename'])); + + return [ + 'image' => [ + 'icon' => 'book', + 'back' => 'white', + ], + 'text' => $component, + 'link' => '/lab/docs/' . $component, + ]; + } + ); + + usort($docs, fn ($a, $b) => $a['text'] <=> $b['text']); + + return array_values($docs); + } + + public function deprecated(): string|null + { + return $this->kt($this->json['tags']['deprecated'][0]['description'] ?? ''); + } + + public function description(): string + { + return $this->kt($this->json['description'] ?? ''); + } + + public function docBlock(): string + { + return $this->kt($this->json['docsBlocks'][0] ?? ''); + } + + public function events(): array + { + $events = A::map( + $this->json['events'] ?? [], + fn ($event) => [ + 'name' => $event['name'], + 'description' => $this->kt($event['description'] ?? ''), + 'deprecated' => $this->kt($event['tags']['deprecated'][0]['description'] ?? ''), + 'since' => $event['tags']['since'][0]['description'] ?? null, + 'properties' => A::map( + $event['properties'] ?? [], + fn ($property) => [ + 'name' => $property['name'], + 'type' => $property['type']['names'][0] ?? '', + 'description' => $this->kt($property['description'] ?? '', true), + ] + ), + ] + ); + + usort($events, fn ($a, $b) => $a['name'] <=> $b['name']); + + return $events; + } + + public function examples(): array + { + if (empty($this->json['tags']['examples']) === false) { + return $this->json['tags']['examples']; + } + + return []; + } + + public function file(string $context): string + { + $root = match ($context) { + 'dev' => $this->kirby->root('panel') . '/tmp', + 'dist' => $this->kirby->root('panel') . '/dist/ui', + }; + + $name = Str::after($this->name, 'k-'); + $name = Str::kebabToCamel($name); + return $root . '/' . $name . '.json'; + } + + public function github(): string + { + return 'https://github.com/getkirby/kirby/tree/main/panel/' . $this->json['sourceFile']; + } + + public static function installed(): bool + { + return Dir::exists(static::root()) === true; + } + + protected function kt(string $text, bool $inline = false): string + { + return $this->kirby->kirbytext($text, [ + 'markdown' => [ + 'breaks' => false, + 'inline' => $inline, + ] + ]); + } + + public function lab(): string|null + { + $root = $this->kirby->root('panel') . '/lab'; + + foreach (glob($root . '/{,*/,*/*/,*/*/*/}index.php', GLOB_BRACE) as $example) { + $props = require $example; + + if (($props['docs'] ?? null) === $this->name) { + return Str::before(Str::after($example, $root), 'index.php'); + } + } + + return null; + } + + public function methods(): array + { + $methods = A::map( + $this->json['methods'] ?? [], + fn ($method) => [ + 'name' => $method['name'], + 'description' => $this->kt($method['description'] ?? ''), + 'deprecated' => $this->kt($method['tags']['deprecated'][0]['description'] ?? ''), + 'since' => $method['tags']['since'][0]['description'] ?? null, + 'params' => A::map( + $method['params'] ?? [], + fn ($param) => [ + 'name' => $param['name'], + 'type' => $param['type']['name'] ?? '', + 'description' => $this->kt($param['description'] ?? '', true), + ] + ), + 'returns' => $method['returns']['type']['name'] ?? null, + ] + ); + + usort($methods, fn ($a, $b) => $a['name'] <=> $b['name']); + + return $methods; + } + + public function name(): string + { + return $this->name; + } + + public function prop(string|int $key): array|null + { + $prop = $this->json['props'][$key]; + + // filter private props + if (($prop['tags']['access'][0]['description'] ?? null) === 'private') { + return null; + } + + // filter unset props + if (($type = $prop['type']['name'] ?? null) === 'null') { + return null; + } + + $default = $prop['defaultValue']['value'] ?? null; + $deprecated = $this->kt($prop['tags']['deprecated'][0]['description'] ?? ''); + + return [ + 'name' => Str::camelToKebab($prop['name']), + 'type' => $type, + 'description' => $this->kt($prop['description'] ?? ''), + 'default' => $this->propDefault($default, $type), + 'deprecated' => $deprecated, + 'example' => $prop['tags']['example'][0]['description'] ?? null, + 'required' => $prop['required'] ?? false, + 'since' => $prop['tags']['since'][0]['description'] ?? null, + 'value' => $prop['tags']['value'][0]['description'] ?? null, + 'values' => $prop['values'] ?? null, + ]; + } + + protected function propDefault( + string|null $default, + string|null $type + ): string|null { + if ($default !== null) { + // normalize longform function + if (preg_match('/function\(\) {.*return (.*);.*}/si', $default, $matches) === 1) { + return $matches[1]; + } + + // normalize object shorthand function + if (preg_match('/\(\) => \((.*)\)/si', $default, $matches) === 1) { + return $matches[1]; + } + + // normalize all other defaults from shorthand function + if (preg_match('/\(\) => (.*)/si', $default, $matches) === 1) { + return $matches[1]; + } + + return $default; + } + + // if type is boolean primarily and no default + // value has been set, add `false` as default + // for clarity + if (Str::startsWith($type, 'boolean')) { + return 'false'; + } + + return null; + } + + public function props(): array + { + $props = A::map( + array_keys($this->json['props'] ?? []), + fn ($key) => $this->prop($key) + ); + + // remove empty props + $props = array_filter($props); + + usort($props, fn ($a, $b) => $a['name'] <=> $b['name']); + + // always return an array + return array_values($props); + } + + protected function read(): array + { + $file = $this->file('dev'); + + if (file_exists($file) === false) { + $file = $this->file('dist'); + } + + return Data::read($file); + } + + public static function root(bool $tmp = false): string + { + return App::instance()->root('panel') . '/' . match ($tmp) { + true => 'tmp', + default => 'dist/ui', + }; + } + + public function since(): string|null + { + return $this->json['tags']['since'][0]['description'] ?? null; + } + + public function slots(): array + { + $slots = A::map( + $this->json['slots'] ?? [], + fn ($slot) => [ + 'name' => $slot['name'], + 'description' => $this->kt($slot['description'] ?? ''), + 'deprecated' => $this->kt($slot['tags']['deprecated'][0]['description'] ?? ''), + 'since' => $slot['tags']['since'][0]['description'] ?? null, + 'bindings' => A::map( + $slot['bindings'] ?? [], + fn ($binding) => [ + 'name' => $binding['name'], + 'type' => $binding['type']['name'] ?? '', + 'description' => $this->kt($binding['description'] ?? '', true), + ] + ), + ] + ); + + usort($slots, fn ($a, $b) => $a['name'] <=> $b['name']); + + return $slots; + } + + public function toArray(): array + { + return [ + 'component' => $this->name(), + 'deprecated' => $this->deprecated(), + 'description' => $this->description(), + 'docBlock' => $this->docBlock(), + 'events' => $this->events(), + 'examples' => $this->examples(), + 'github' => $this->github(), + 'methods' => $this->methods(), + 'props' => $this->props(), + 'since' => $this->since(), + 'slots' => $this->slots(), + ]; + } +} diff --git a/kirby/src/Panel/Lab/Example.php b/kirby/src/Panel/Lab/Example.php new file mode 100644 index 0000000..6df026a --- /dev/null +++ b/kirby/src/Panel/Lab/Example.php @@ -0,0 +1,271 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Example +{ + protected string $root; + protected string|null $tab = null; + protected array $tabs; + + public function __construct( + protected Category $parent, + protected string $id, + string|null $tab = null, + ) { + $this->root = $this->parent->root() . '/' . $this->id; + + if ($this->exists() === false) { + throw new NotFoundException('The example could not be found'); + } + + $this->tabs = $this->collectTabs(); + $this->tab = $this->collectTab($tab); + } + + public function collectTab(string|null $tab): string|null + { + if (empty($this->tabs) === true) { + return null; + } + + if (array_key_exists($tab, $this->tabs) === true) { + return $tab; + } + + return array_key_first($this->tabs); + } + + public function collectTabs(): array + { + $tabs = []; + + foreach (Dir::inventory($this->root)['children'] as $child) { + $tabs[$child['dirname']] = [ + 'name' => $child['dirname'], + 'label' => $child['slug'], + 'link' => '/lab/' . $this->parent->id() . '/' . $this->id . '/' . $child['dirname'] + ]; + } + + return $tabs; + } + + public function exists(): bool + { + return is_dir($this->root) === true; + } + + public function file(string $filename): string + { + return $this->parent->root() . '/' . $this->path() . '/' . $filename; + } + + public function github(): string + { + $path = Str::after($this->root(), App::instance()->root('kirby')); + + if ($tab = $this->tab()) { + $path .= '/' . $tab; + } + + return 'https://github.com/getkirby/kirby/tree/main' . $path; + } + + public function id(): string + { + return $this->id; + } + + public function load(string $filename): array|null + { + if ($file = $this->file($filename)) { + return F::load($file); + } + + return null; + } + + public function module(): string + { + return $this->url() . '/index.vue'; + } + + public function path(): string + { + return match ($this->tab) { + null => $this->id, + default => $this->id . '/' . $this->tab + }; + } + + public function props(): array + { + if ($this->tab !== null) { + $props = $this->load('../index.php'); + } + + return array_replace_recursive( + $props ?? [], + $this->load('index.php') ?? [] + ); + } + + public function read(string $filename): string|null + { + $file = $this->file($filename); + + if (is_file($file) === false) { + return null; + } + + return F::read($file); + } + + public function root(): string + { + return $this->root; + } + + public function serve(): Response + { + return new Response($this->vue()['script'], 'application/javascript'); + } + + public function tab(): string|null + { + return $this->tab; + } + + public function tabs(): array + { + return $this->tabs; + } + + public function template(string $filename): string|null + { + $file = $this->file($filename); + + if (is_file($file) === false) { + return null; + } + + $data = $this->props(); + return (new Template($file))->render($data); + } + + public function title(): string + { + return basename($this->id); + } + + public function toArray(): array + { + return [ + 'image' => [ + 'icon' => $this->parent->icon(), + 'back' => 'white', + ], + 'text' => $this->title(), + 'link' => $this->url() + ]; + } + + public function url(): string + { + return '/lab/' . $this->parent->id() . '/' . $this->path(); + } + + public function vue(): array + { + // read the index.vue file (or programmabel Vue PHP file) + $file = $this->read('index.vue'); + $file ??= $this->template('index.vue.php'); + $file ??= ''; + + // extract parts + $parts['template'] = $this->vueTemplate($file); + $parts['examples'] = $this->vueExamples($parts['template']); + $parts['script'] = $this->vueScript($file); + $parts['style'] = $this->vueStyle($file); + + return $parts; + } + + public function vueExamples(string|null $template): array + { + $template ??= ''; + $examples = []; + + if (preg_match_all('!(.*?)<\/k-lab-example>!s', $template, $matches)) { + foreach ($matches[1] as $key => $name) { + $code = $matches[2][$key]; + + // only use the code between the @code and @code-end comments + if (preg_match('$(.*?)$s', $code, $match)) { + $code = $match[1]; + } + + if (preg_match_all('/^(\t*)\S/m', $code, $indents)) { + // get minimum indent + $indents = array_map(fn ($i) => strlen($i), $indents[1]); + $indents = min($indents); + + // strip minimum indent from each line + $code = preg_replace('/^\t{' . $indents . '}/m', '', $code); + } + + $examples[$name] = trim($code); + } + } + + return $examples; + } + + public function vueScript(string $file): string + { + if (preg_match('!!s', $file, $match)) { + return trim($match[1]); + } + + return 'export default {}'; + } + + public function vueStyle(string $file): string|null + { + if (preg_match('!!s', $file, $match)) { + return trim($match[1]); + } + + return null; + } + + public function vueTemplate(string $file): string|null + { + if (preg_match('!!s', $file, $match)) { + return preg_replace('!^\n!', '', $match[1]); + } + + return null; + } +} diff --git a/kirby/src/Panel/Lab/Snippet.php b/kirby/src/Panel/Lab/Snippet.php new file mode 100644 index 0000000..0739eb7 --- /dev/null +++ b/kirby/src/Panel/Lab/Snippet.php @@ -0,0 +1,26 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Snippet extends BaseSnippet +{ + public static function root(): string + { + return __DIR__ . '/snippets'; + } +} diff --git a/kirby/src/Panel/Lab/Template.php b/kirby/src/Panel/Lab/Template.php new file mode 100644 index 0000000..71cf484 --- /dev/null +++ b/kirby/src/Panel/Lab/Template.php @@ -0,0 +1,34 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Template extends BaseTemplate +{ + public function __construct( + public string $file + ) { + parent::__construct( + name: basename($this->file) + ); + } + + public function file(): string|null + { + return $this->file; + } +} diff --git a/kirby/src/Panel/Menu.php b/kirby/src/Panel/Menu.php new file mode 100644 index 0000000..c362665 --- /dev/null +++ b/kirby/src/Panel/Menu.php @@ -0,0 +1,221 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Menu +{ + public function __construct( + protected array $areas = [], + protected array $permissions = [], + protected string|null $current = null + ) { + } + + /** + * Returns all areas that are configured for the menu + * @internal + */ + public function areas(): array + { + // get from config option which areas should be listed in the menu + $kirby = App::instance(); + $areas = $kirby->option('panel.menu'); + + if ($areas instanceof Closure) { + $areas = $areas($kirby); + } + + // if no config is defined… + if ($areas === null) { + // ensure that some defaults are on top in the right order + $defaults = ['site', 'languages', 'users', 'system']; + // add all other areas after that + $additionals = array_diff(array_keys($this->areas), $defaults); + $areas = array_merge($defaults, $additionals); + } + + $result = []; + + foreach ($areas as $id => $area) { + // separator, keep as is in array + if ($area === '-') { + $result[] = '-'; + continue; + } + + // for a simple id, get global area definition + if (is_numeric($id) === true) { + $id = $area; + $area = $this->areas[$id] ?? null; + } + + // did not receive custom entry definition in config, + // but also is not a global area + if ($area === null) { + continue; + } + + // merge area definition (e.g. from config) + // with global area definition + if (is_array($area) === true) { + $area = array_merge( + $this->areas[$id] ?? [], + ['menu' => true], + $area + ); + $area = Panel::area($id, $area); + } + + $result[] = $area; + } + + return $result; + } + + /** + * Transforms an area definition into a menu entry + * @internal + */ + public function entry(array $area): array|false + { + // areas without access permissions get skipped entirely + if ($this->hasPermission($area['id']) === false) { + return false; + } + + // check menu setting from the area definition + $menu = $area['menu'] ?? false; + + // menu setting can be a callback + // that returns true, false or 'disabled' + if ($menu instanceof Closure) { + $menu = $menu($this->areas, $this->permissions, $this->current); + } + + // false will remove the area/entry entirely + //just like with disabled permissions + if ($menu === false) { + return false; + } + + $menu = match ($menu) { + 'disabled' => ['disabled' => true], + true => [], + default => $menu + }; + + $entry = array_merge([ + 'current' => $this->isCurrent( + $area['id'], + $area['current'] ?? null + ), + 'icon' => $area['icon'] ?? null, + 'link' => $area['link'] ?? null, + 'dialog' => $area['dialog'] ?? null, + 'drawer' => $area['drawer'] ?? null, + 'text' => $area['label'], + ], $menu); + + // unset the link (which is always added by default to an area) + // if a dialog or drawer should be opened instead + if (isset($entry['dialog']) || isset($entry['drawer'])) { + unset($entry['link']); + } + + return array_filter($entry); + } + + /** + * Returns all menu entries + */ + public function entries(): array + { + $entries = []; + $areas = $this->areas(); + + foreach ($areas as $area) { + if ($area === '-') { + $entries[] = '-'; + } elseif ($entry = $this->entry($area)) { + $entries[] = $entry; + } + } + + $entries[] = '-'; + + return array_merge($entries, $this->options()); + } + + /** + * Checks if the access permission to a specific area is granted. + * Defaults to allow access. + * @internal + */ + public function hasPermission(string $id): bool + { + return $this->permissions['access'][$id] ?? true; + } + + /** + * Whether the menu entry should receive aria-current + * @internal + */ + public function isCurrent( + string $id, + bool|Closure|null $callback = null + ): bool { + if ($callback !== null) { + if ($callback instanceof Closure) { + $callback = $callback($this->current); + } + + return $callback; + } + + return $this->current === $id; + } + + /** + * Default options entries for bottom of menu + * @internal + */ + public function options(): array + { + $options = [ + [ + 'icon' => 'edit-line', + 'dialog' => 'changes', + 'text' => I18n::translate('changes'), + ], + [ + 'current' => $this->isCurrent('account'), + 'icon' => 'account', + 'link' => 'account', + 'disabled' => $this->hasPermission('account') === false, + 'text' => I18n::translate('view.account'), + ], + [ + 'icon' => 'logout', + 'link' => 'logout', + 'text' => I18n::translate('logout') + ] + ]; + + return $options; + } +} diff --git a/kirby/src/Panel/Model.php b/kirby/src/Panel/Model.php new file mode 100644 index 0000000..7338405 --- /dev/null +++ b/kirby/src/Panel/Model.php @@ -0,0 +1,421 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class Model +{ + public function __construct( + protected ModelWithContent $model + ) { + } + + /** + * Get the content values for the model + */ + public function content(): array + { + return Form::for($this->model)->values(); + } + + /** + * Returns the drag text from a custom callback + * if the callback is defined in the config + * @internal + * + * @param string $type markdown or kirbytext + */ + public function dragTextFromCallback(string $type, ...$args): string|null + { + $option = 'panel.' . $type . '.' . $this->model::CLASS_ALIAS . 'DragText'; + $callback = $this->model->kirby()->option($option); + + if ($callback instanceof Closure) { + return $callback($this->model, ...$args); + } + + return null; + } + + /** + * Returns the correct drag text type + * depending on the given type or the + * configuration + * + * @internal + * + * @param string|null $type (`auto`|`kirbytext`|`markdown`) + */ + public function dragTextType(string|null $type = null): string + { + $type ??= 'auto'; + + if ($type === 'auto') { + $kirby = $this->model->kirby(); + $type = $kirby->option('panel.kirbytext', true) ? 'kirbytext' : 'markdown'; + } + + return $type === 'markdown' ? 'markdown' : 'kirbytext'; + } + + /** + * Returns the setup for a dropdown option + * which is used in the changes dropdown + * for example. + */ + public function dropdownOption(): array + { + return [ + 'icon' => 'page', + 'image' => $this->image(['back' => 'black']), + 'link' => $this->url(true), + 'text' => $this->model->id(), + ]; + } + + /** + * Returns the Panel image definition + * @internal + */ + public function image( + string|array|false|null $settings = [], + string $layout = 'list' + ): array|null { + // completely switched off + if ($settings === false) { + return null; + } + + // skip image thumbnail if option + // is explicitly set to show the icon + if ($settings === 'icon') { + $settings = ['query' => false]; + } elseif (is_string($settings) === true) { + // convert string settings to proper array + $settings = ['query' => $settings]; + } + + // merge with defaults and blueprint option + $settings = array_merge( + $this->imageDefaults(), + $settings ?? [], + $this->model->blueprint()->image() ?? [], + ); + + if ($image = $this->imageSource($settings['query'] ?? null)) { + // main url + $settings['url'] = $image->url(); + + if ($image->isResizable() === true) { + // only create srcsets for resizable files + $settings['src'] = static::imagePlaceholder(); + $settings['srcset'] = $this->imageSrcset($image, $layout, $settings); + } elseif ($image->isViewable() === true) { + $settings['src'] = $image->url(); + } + } + + unset($settings['query']); + + // resolve remaining options defined as query + return A::map($settings, function ($option) { + if (is_string($option) === false) { + return $option; + } + + return $this->model->toString($option); + }); + } + + /** + * Default settings for Panel image + */ + protected function imageDefaults(): array + { + return [ + 'back' => 'pattern', + 'color' => 'gray-500', + 'cover' => false, + 'icon' => 'page' + ]; + } + + /** + * Data URI placeholder string for Panel image + * @internal + */ + public static function imagePlaceholder(): string + { + return 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw'; + } + + /** + * Returns the image file object based on provided query + * @internal + */ + protected function imageSource( + string|null $query = null + ): CmsFile|Asset|null { + $image = $this->model->query($query ?? null); + + // validate the query result + if ( + $image instanceof CmsFile || + $image instanceof Asset + ) { + return $image; + } + + return null; + } + + /** + * Provides the correct srcset string based on + * the layout and settings + * @internal + */ + protected function imageSrcset( + CmsFile|Asset $image, + string $layout, + array $settings + ): string|null { + // depending on layout type, set different sizes + // to have multiple options for the srcset attribute + $sizes = match ($layout) { + 'cards' => [352, 864, 1408], + 'cardlets' => [96, 192], + default => [38, 76] + }; + + // no additional modfications needed if `cover: false` + if (($settings['cover'] ?? false) === false) { + return $image->srcset($sizes); + } + + // for card layouts with `cover: true` provide + // crops based on the card ratio + if ($layout === 'cards') { + $ratio = explode('/', $settings['ratio'] ?? '1/1'); + $ratio = $ratio[0] / $ratio[1]; + + return $image->srcset([ + $sizes[0] . 'w' => [ + 'width' => $sizes[0], + 'height' => round($sizes[0] / $ratio), + 'crop' => true + ], + $sizes[1] . 'w' => [ + 'width' => $sizes[1], + 'height' => round($sizes[1] / $ratio), + 'crop' => true + ], + $sizes[2] . 'w' => [ + 'width' => $sizes[2], + 'height' => round($sizes[2] / $ratio), + 'crop' => true + ] + ]); + } + + // for list and cardlets with `cover: true` + // provide square crops in two resolutions + return $image->srcset([ + '1x' => [ + 'width' => $sizes[0], + 'height' => $sizes[0], + 'crop' => true + ], + '2x' => [ + 'width' => $sizes[1], + 'height' => $sizes[1], + 'crop' => true + ] + ]); + } + + /** + * Checks for disabled dropdown options according + * to the given permissions + */ + public function isDisabledDropdownOption( + string $action, + array $options, + array $permissions + ): bool { + $option = $options[$action] ?? true; + + return + $permissions[$action] === false || + $option === false || + $option === 'false'; + } + + /** + * Returns lock info for the Panel + * + * @return array|false array with lock info, + * false if locking is not supported + */ + public function lock(): array|false + { + return $this->model->lock()?->toArray() ?? false; + } + + /** + * Returns an array of all actions + * that can be performed in the Panel + * This also checks for the lock status + * + * @param array $unlock An array of options that will be force-unlocked + */ + public function options(array $unlock = []): array + { + $options = $this->model->permissions()->toArray(); + + if ($this->model->isLocked()) { + foreach ($options as $key => $value) { + if (in_array($key, $unlock)) { + continue; + } + + $options[$key] = false; + } + } + + return $options; + } + + /** + * Returns the full path without leading slash + */ + abstract public function path(): string; + + /** + * Prepares the response data for page pickers + * and page fields + */ + public function pickerData(array $params = []): array + { + return [ + 'id' => $this->model->id(), + 'image' => $this->image( + $params['image'] ?? [], + $params['layout'] ?? 'list' + ), + 'info' => $this->model->toSafeString($params['info'] ?? false), + 'link' => $this->url(true), + 'sortable' => true, + 'text' => $this->model->toSafeString($params['text'] ?? false), + 'uuid' => $this->model->uuid()?->toString() ?? $this->model->id(), + ]; + } + + /** + * Returns the data array for the + * view's component props + * @internal + */ + public function props(): array + { + $blueprint = $this->model->blueprint(); + $request = $this->model->kirby()->request(); + $tabs = $blueprint->tabs(); + $tab = $blueprint->tab($request->get('tab')) ?? $tabs[0] ?? null; + + $props = [ + 'lock' => $this->lock(), + 'permissions' => $this->model->permissions()->toArray(), + 'tabs' => $tabs, + ]; + + // only send the tab if it exists + // this will let the vue component define + // a proper default value + if ($tab) { + $props['tab'] = $tab; + } + + return $props; + } + + /** + * Returns link url and title + * for model (e.g. used for prev/next navigation) + * @internal + */ + public function toLink(string $title = 'title'): array + { + return [ + 'link' => $this->url(true), + 'title' => $title = (string)$this->model->{$title}() + ]; + } + + /** + * Returns link url and title + * for optional sibling model and + * preserves tab selection + * + * @internal + */ + protected function toPrevNextLink( + ModelWithContent|null $model = null, + string $title = 'title' + ): array|null { + if ($model === null) { + return null; + } + + $data = $model->panel()->toLink($title); + + if ($tab = $model->kirby()->request()->get('tab')) { + $uri = new Uri($data['link'], [ + 'query' => ['tab' => $tab] + ]); + + $data['link'] = $uri->toString(); + } + + return $data; + } + + /** + * Returns the url to the editing view + * in the Panel + * + * @internal + */ + public function url(bool $relative = false): string + { + if ($relative === true) { + return '/' . $this->path(); + } + + return $this->model->kirby()->url('panel') . '/' . $this->path(); + } + + /** + * Returns the data array for + * this model's Panel view + * + * @internal + */ + abstract public function view(): array; +} diff --git a/kirby/src/Panel/Page.php b/kirby/src/Panel/Page.php new file mode 100644 index 0000000..72c564c --- /dev/null +++ b/kirby/src/Panel/Page.php @@ -0,0 +1,369 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Page extends Model +{ + /** + * @var \Kirby\Cms\Page + */ + protected ModelWithContent $model; + + /** + * Breadcrumb array + */ + public function breadcrumb(): array + { + $parents = $this->model->parents()->flip()->merge($this->model); + + return $parents->values( + fn ($parent) => [ + 'label' => $parent->title()->toString(), + 'link' => $parent->panel()->url(true), + ] + ); + } + + /** + * Provides a kirbytag or markdown + * tag for the page, which will be + * used in the panel, when the page + * gets dragged onto a textarea + * + * @internal + * @param string|null $type (`auto`|`kirbytext`|`markdown`) + */ + public function dragText(string|null $type = null): string + { + $type = $this->dragTextType($type); + + if ($callback = $this->dragTextFromCallback($type)) { + return $callback; + } + + $title = $this->model->title(); + + // type: markdown + if ($type === 'markdown') { + $url = $this->model->permalink() ?? $this->model->url(); + return '[' . $title . '](' . $url . ')'; + } + + // type: kirbytext + $link = $this->model->uuid() ?? $this->model->uri(); + return '(link: ' . $link . ' text: ' . $title . ')'; + } + + /** + * Provides options for the page dropdown + */ + public function dropdown(array $options = []): array + { + $page = $this->model; + $request = $page->kirby()->request(); + $defaults = $request->get(['view', 'sort', 'delete']); + $options = array_merge($defaults, $options); + + $permissions = $this->options(['preview']); + $view = $options['view'] ?? 'view'; + $url = $this->url(true); + $result = []; + + if ($view === 'list') { + $result['preview'] = [ + 'link' => $page->previewUrl(), + 'target' => '_blank', + 'icon' => 'open', + 'text' => I18n::translate('open'), + 'disabled' => $this->isDisabledDropdownOption('preview', $options, $permissions) + ]; + $result[] = '-'; + } + + $result['changeTitle'] = [ + 'dialog' => [ + 'url' => $url . '/changeTitle', + 'query' => [ + 'select' => 'title' + ] + ], + 'icon' => 'title', + 'text' => I18n::translate('rename'), + 'disabled' => $this->isDisabledDropdownOption('changeTitle', $options, $permissions) + ]; + + $result['changeSlug'] = [ + 'dialog' => [ + 'url' => $url . '/changeTitle', + 'query' => [ + 'select' => 'slug' + ] + ], + 'icon' => 'url', + 'text' => I18n::translate('page.changeSlug'), + 'disabled' => $this->isDisabledDropdownOption('changeSlug', $options, $permissions) + ]; + + $result['changeStatus'] = [ + 'dialog' => $url . '/changeStatus', + 'icon' => 'preview', + 'text' => I18n::translate('page.changeStatus'), + 'disabled' => $this->isDisabledDropdownOption('changeStatus', $options, $permissions) + ]; + + $siblings = $page->parentModel()->children()->listed()->not($page); + + $result['changeSort'] = [ + 'dialog' => $url . '/changeSort', + 'icon' => 'sort', + 'text' => I18n::translate('page.sort'), + 'disabled' => $siblings->count() === 0 || $this->isDisabledDropdownOption('sort', $options, $permissions) + ]; + + $result['changeTemplate'] = [ + 'dialog' => $url . '/changeTemplate', + 'icon' => 'template', + 'text' => I18n::translate('page.changeTemplate'), + 'disabled' => $this->isDisabledDropdownOption('changeTemplate', $options, $permissions) + ]; + + $result[] = '-'; + + $result['move'] = [ + 'dialog' => $url . '/move', + 'icon' => 'parent', + 'text' => I18n::translate('page.move'), + 'disabled' => $this->isDisabledDropdownOption('move', $options, $permissions) + ]; + + $result['duplicate'] = [ + 'dialog' => $url . '/duplicate', + 'icon' => 'copy', + 'text' => I18n::translate('duplicate'), + 'disabled' => $this->isDisabledDropdownOption('duplicate', $options, $permissions) + ]; + + $result[] = '-'; + + $result['delete'] = [ + 'dialog' => $url . '/delete', + 'icon' => 'trash', + 'text' => I18n::translate('delete'), + 'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions) + ]; + + return $result; + } + + /** + * Returns the setup for a dropdown option + * which is used in the changes dropdown + * for example. + */ + public function dropdownOption(): array + { + return [ + 'text' => $this->model->title()->value(), + ] + parent::dropdownOption(); + } + + /** + * Returns the escaped Id, which is + * used in the panel to make routing work properly + */ + public function id(): string + { + return str_replace('/', '+', $this->model->id()); + } + + /** + * Default settings for the page's Panel image + */ + protected function imageDefaults(): array + { + $defaults = []; + + if ($icon = $this->model->blueprint()->icon()) { + $defaults['icon'] = $icon; + } + + return array_merge(parent::imageDefaults(), $defaults); + } + + /** + * Returns the image file object based on provided query + * + * @internal + */ + protected function imageSource( + string|null $query = null + ): CmsFile|Asset|null { + $query ??= 'page.image'; + return parent::imageSource($query); + } + + /** + * Returns the full path without leading slash + * + * @internal + */ + public function path(): string + { + return 'pages/' . $this->id(); + } + + /** + * Prepares the response data for page pickers + * and page fields + */ + public function pickerData(array $params = []): array + { + $params['text'] ??= '{{ page.title }}'; + + return array_merge(parent::pickerData($params), [ + 'dragText' => $this->dragText(), + 'hasChildren' => $this->model->hasChildren(), + 'url' => $this->model->url() + ]); + } + + /** + * The best applicable position for + * the position/status dialog + */ + public function position(): int + { + return + $this->model->num() ?? + $this->model->parentModel()->children()->listed()->not($this->model)->count() + 1; + } + + /** + * Returns navigation array with + * previous and next page + * based on blueprint definition + * + * @internal + */ + public function prevNext(): array + { + $page = $this->model; + + // create siblings collection based on + // blueprint navigation + $siblings = function (string $direction) use ($page) { + $navigation = $page->blueprint()->navigation(); + $sortBy = $navigation['sortBy'] ?? null; + $status = $navigation['status'] ?? null; + $template = $navigation['template'] ?? null; + $direction = $direction === 'prev' ? 'prev' : 'next'; + + // if status is defined in navigation, + // all items in the collection are used + // (drafts, listed and unlisted) otherwise + // it depends on the status of the page + $siblings = $status !== null ? $page->parentModel()->childrenAndDrafts() : $page->siblings(); + + // sort the collection if custom sortBy + // defined in navigation otherwise + // default sorting will apply + if ($sortBy !== null) { + $siblings = $siblings->sort(...$siblings::sortArgs($sortBy)); + } + + $siblings = $page->{$direction . 'All'}($siblings); + + if (empty($navigation) === false) { + $statuses = (array)($status ?? $page->status()); + $templates = (array)($template ?? $page->intendedTemplate()); + + // do not filter if template navigation is all + if (in_array('all', $templates) === false) { + $siblings = $siblings->filter('intendedTemplate', 'in', $templates); + } + + // do not filter if status navigation is all + if (in_array('all', $statuses) === false) { + $siblings = $siblings->filter('status', 'in', $statuses); + } + } else { + $siblings = $siblings + ->filter('intendedTemplate', $page->intendedTemplate()) + ->filter('status', $page->status()); + } + + return $siblings->filter('isListable', true); + }; + + return [ + 'next' => fn () => $this->toPrevNextLink($siblings('next')->first()), + 'prev' => fn () => $this->toPrevNextLink($siblings('prev')->last()) + ]; + } + + /** + * Returns the data array for the + * view's component props + * + * @internal + */ + public function props(): array + { + $page = $this->model; + + return array_merge( + parent::props(), + $this->prevNext(), + [ + 'blueprint' => $page->intendedTemplate()->name(), + 'model' => [ + 'content' => $this->content(), + 'id' => $page->id(), + 'link' => $this->url(true), + 'parent' => $page->parentModel()->panel()->url(true), + 'previewUrl' => $page->previewUrl(), + 'status' => $page->status(), + 'title' => $page->title()->toString(), + ], + 'status' => function () use ($page) { + if ($status = $page->status()) { + return $page->blueprint()->status()[$status] ?? null; + } + }, + ] + ); + } + + /** + * Returns the data array for + * this model's Panel view + * + * @internal + */ + public function view(): array + { + $page = $this->model; + + return [ + 'breadcrumb' => $page->panel()->breadcrumb(), + 'component' => 'k-page-view', + 'props' => $this->props(), + 'title' => $page->title()->toString(), + ]; + } +} diff --git a/kirby/src/Panel/PageCreateDialog.php b/kirby/src/Panel/PageCreateDialog.php new file mode 100644 index 0000000..68a7bf1 --- /dev/null +++ b/kirby/src/Panel/PageCreateDialog.php @@ -0,0 +1,313 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class PageCreateDialog +{ + protected PageBlueprint $blueprint; + protected Page $model; + protected Page|Site $parent; + protected string $parentId; + protected string|null $sectionId; + protected string|null $slug; + protected string|null $template; + protected string|null $title; + protected Page|Site $view; + protected string|null $viewId; + + public static array $fieldTypes = [ + 'checkboxes', + 'date', + 'email', + 'info', + 'line', + 'link', + 'list', + 'number', + 'multiselect', + 'radio', + 'range', + 'select', + 'slug', + 'tags', + 'tel', + 'text', + 'toggles', + 'time', + 'url' + ]; + + public function __construct( + string|null $parentId, + string|null $sectionId, + string|null $template, + string|null $viewId, + + // optional + string|null $slug = null, + string|null $title = null, + ) { + $this->parentId = $parentId ?? 'site'; + $this->parent = Find::parent($this->parentId); + $this->sectionId = $sectionId; + $this->slug = $slug; + $this->template = $template; + $this->title = $title; + $this->viewId = $viewId; + $this->view = Find::parent($this->viewId ?? $this->parentId); + } + + /** + * Get the blueprint settings for the new page + */ + public function blueprint(): PageBlueprint + { + // create a temporary page object + return $this->blueprint ??= $this->model()->blueprint(); + } + + /** + * Get an array of all blueprints for the parent view + */ + public function blueprints(): array + { + return A::map( + $this->view->blueprints($this->sectionId), + function ($blueprint) { + $blueprint['name'] ??= $blueprint['value'] ?? null; + return $blueprint; + } + ); + } + + /** + * All the default fields for the dialog + */ + public function coreFields(): array + { + $title = $this->blueprint()->create()['title']['label'] ?? 'title'; + + return [ + 'title' => Field::title([ + 'label' => I18n::translate($title, $title), + 'required' => true, + 'preselect' => true + ]), + 'slug' => Field::slug([ + 'required' => true, + 'sync' => 'title', + 'path' => $this->parent instanceof Page ? '/' . $this->parent->id() . '/' : '/' + ]), + 'parent' => Field::hidden(), + 'section' => Field::hidden(), + 'template' => Field::hidden(), + 'view' => Field::hidden(), + ]; + } + + /** + * Loads custom fields for the page type + */ + public function customFields(): array + { + $custom = []; + $ignore = ['title', 'slug', 'parent', 'template']; + $blueprint = $this->blueprint(); + $fields = $blueprint->fields(); + + foreach ($blueprint->create()['fields'] ?? [] as $name) { + if (!$field = ($fields[$name] ?? null)) { + throw new InvalidArgumentException('Unknown field "' . $name . '" in create dialog'); + } + + if (in_array($field['type'], static::$fieldTypes) === false) { + throw new InvalidArgumentException('Field type "' . $field['type'] . '" not supported in create dialog'); + } + + if (in_array($name, $ignore) === true) { + throw new InvalidArgumentException('Field name "' . $name . '" not allowed as custom field in create dialog'); + } + + // switch all fields to 1/1 + $field['width'] = '1/1'; + + // add the field to the form + $custom[$name] = $field; + } + + // create form so that field props, options etc. + // can be properly resolved + $form = new Form([ + 'fields' => $custom, + 'model' => $this->model(), + 'strict' => true + ]); + + return $form->fields()->toArray(); + } + + /** + * Loads all the fields for the dialog + */ + public function fields(): array + { + return array_merge( + $this->coreFields(), + $this->customFields() + ); + } + + /** + * Provides all the props for the + * dialog, including the fields and + * initial values + */ + public function load(): array + { + $blueprints = $this->blueprints(); + + $this->template ??= $blueprints[0]['name']; + + $status = $this->blueprint()->create()['status'] ?? 'draft'; + $status = $this->blueprint()->status()[$status]['label'] ?? I18n::translate('page.status.' . $status); + + return [ + 'component' => 'k-page-create-dialog', + 'props' => [ + 'blueprints' => $blueprints, + 'fields' => $this->fields(), + 'submitButton' => I18n::template('page.create', [ + 'status' => $status + ]), + 'template' => $this->template, + 'value' => $this->value() + ] + ]; + } + + /** + * Temporary model for the page to + * be created, used to properly render + * the blueprint for fields + */ + public function model(): Page + { + return $this->model ??= Page::factory([ + 'slug' => 'new', + 'template' => $this->template, + 'model' => $this->template, + 'parent' => $this->parent instanceof Page ? $this->parent : null + ]); + } + + /** + * Prepares and cleans up the input data + */ + public function sanitize(array $input): array + { + $input['slug'] ??= $this->slug ?? ''; + $input['title'] ??= $this->title ?? ''; + + $content = [ + 'title' => trim($input['title']), + ]; + + foreach ($this->customFields() as $name => $field) { + $content[$name] = $input[$name] ?? null; + } + + return [ + 'content' => $content, + 'slug' => $input['slug'], + 'template' => $this->template, + ]; + } + + /** + * Submits the dialog form and creates the new page + */ + public function submit(array $input): array + { + $input = $this->sanitize($input); + $status = $this->blueprint()->create()['status'] ?? 'draft'; + + // validate the input before creating the page + $this->validate($input, $status); + + $page = $this->parent->createChild($input); + + if ($status !== 'draft') { + // grant all permissions as the status is set in the blueprint and + // should not be treated as if the user would try to change it + $page->kirby()->impersonate( + 'kirby', + fn () => $page->changeStatus($status) + ); + } + + $payload = [ + 'event' => 'page.create' + ]; + + // add redirect, if not explicitly disabled + if (($this->blueprint()->create()['redirect'] ?? null) !== false) { + $payload['redirect'] = $page->panel()->url(true); + } + + return $payload; + } + + public function validate(array $input, string $status = 'draft'): bool + { + // basic validation + PageRules::validateTitleLength($input['content']['title']); + PageRules::validateSlugLength($input['slug']); + + // if the page is supposed to be published directly, + // ensure that all field validations are met + if ($status !== 'draft') { + // create temporary form to validate the input + $form = Form::for($this->model(), ['values' => $input['content']]); + + if ($form->isInvalid() === true) { + throw new InvalidArgumentException([ + 'key' => 'page.changeStatus.incomplete' + ]); + } + } + + return true; + } + + public function value(): array + { + return [ + 'parent' => $this->parentId, + 'section' => $this->sectionId, + 'slug' => $this->slug ?? '', + 'template' => $this->template, + 'title' => $this->title ?? '', + 'view' => $this->viewId, + ]; + } +} diff --git a/kirby/src/Panel/Panel.php b/kirby/src/Panel/Panel.php new file mode 100644 index 0000000..cb3698b --- /dev/null +++ b/kirby/src/Panel/Panel.php @@ -0,0 +1,593 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Panel +{ + /** + * Normalize a panel area + */ + public static function area(string $id, array|string $area): array + { + $area['id'] = $id; + $area['label'] ??= $id; + $area['breadcrumb'] ??= []; + $area['breadcrumbLabel'] ??= $area['label']; + $area['title'] = $area['label']; + $area['menu'] ??= false; + $area['link'] ??= $id; + $area['search'] ??= null; + + return $area; + } + + /** + * Collect all registered areas + */ + public static function areas(): array + { + $kirby = App::instance(); + $system = $kirby->system(); + $user = $kirby->user(); + $areas = $kirby->load()->areas(); + + // the system is not ready + if ( + $system->isOk() === false || + $system->isInstalled() === false + ) { + return [ + 'installation' => static::area( + 'installation', + $areas['installation'] + ), + ]; + } + + // not yet authenticated + if (!$user) { + return [ + 'logout' => static::area('logout', $areas['logout']), + // login area last because it defines a fallback route + 'login' => static::area('login', $areas['login']), + ]; + } + + unset($areas['installation'], $areas['login']); + + // Disable the language area for single-language installations + // This does not check for installed languages. Otherwise you'd + // not be able to add the first language through the view + if (!$kirby->option('languages')) { + unset($areas['languages']); + } + + $result = []; + + foreach ($areas as $id => $area) { + $result[$id] = static::area($id, $area); + } + + return $result; + } + + /** + * Check for access permissions + */ + public static function firewall( + User|null $user = null, + string|null $areaId = null + ): bool { + // a user has to be logged in + if ($user === null) { + throw new PermissionException(['key' => 'access.panel']); + } + + // get all access permissions for the user role + $permissions = $user->role()->permissions()->toArray()['access']; + + // check for general panel access + if (($permissions['panel'] ?? true) !== true) { + throw new PermissionException(['key' => 'access.panel']); + } + + // don't check if the area is not defined + if (empty($areaId) === true) { + return true; + } + + // undefined area permissions means access + if (isset($permissions[$areaId]) === false) { + return true; + } + + // no access + if ($permissions[$areaId] !== true) { + throw new PermissionException(['key' => 'access.view']); + } + + return true; + } + + + /** + * Redirect to a Panel url + * + * @throws \Kirby\Panel\Redirect + * @codeCoverageIgnore + */ + public static function go(string|null $url = null, int $code = 302): void + { + throw new Redirect(static::url($url), $code); + } + + /** + * Check if the given user has access to the panel + * or to a given area + */ + public static function hasAccess( + User|null $user = null, + string|null $area = null + ): bool { + try { + static::firewall($user, $area); + return true; + } catch (Throwable) { + return false; + } + } + + /** + * Checks for a Fiber request + * via get parameters or headers + */ + public static function isFiberRequest(): bool + { + $request = App::instance()->request(); + + if ($request->method() === 'GET') { + return + (bool)($request->get('_json') ?? + $request->header('X-Fiber')); + } + + return false; + } + + /** + * Returns a JSON response + * for Fiber calls + */ + public static function json(array $data, int $code = 200): Response + { + $request = App::instance()->request(); + + return Response::json($data, $code, $request->get('_pretty'), [ + 'X-Fiber' => 'true', + 'Cache-Control' => 'no-store, private' + ]); + } + + /** + * Checks for a multilanguage installation + */ + public static function multilang(): bool + { + // multilang setup check + $kirby = App::instance(); + return $kirby->option('languages') || $kirby->multilang(); + } + + /** + * Returns the referrer path if present + */ + public static function referrer(): string + { + $request = App::instance()->request(); + + $referrer = $request->header('X-Fiber-Referrer') + ?? $request->get('_referrer') + ?? ''; + + return '/' . trim($referrer, '/'); + } + + /** + * Creates a Response object from the result of + * a Panel route call + */ + public static function response($result, array $options = []): Response + { + // pass responses directly down to the Kirby router + if ($result instanceof Response) { + return $result; + } + + // interpret missing/empty results as not found + if ($result === null || $result === false) { + $result = new NotFoundException('The data could not be found'); + + // interpret strings as errors + } elseif (is_string($result) === true) { + $result = new Exception($result); + } + + // handle different response types (view, dialog, ...) + return match ($options['type'] ?? null) { + 'dialog' => Dialog::response($result, $options), + 'drawer' => Drawer::response($result, $options), + 'dropdown' => Dropdown::response($result, $options), + 'request' => Request::response($result, $options), + 'search' => Search::response($result, $options), + default => View::response($result, $options) + }; + } + + /** + * Router for the Panel views + */ + public static function router(string|null $path = null): Response|null + { + $kirby = App::instance(); + + if ($kirby->option('panel') === false) { + return null; + } + + // set the translation for Panel UI before + // gathering areas and routes, so that the + // `t()` helper can already be used + static::setTranslation(); + + // set the language in multi-lang installations + static::setLanguage(); + + $areas = static::areas(); + $routes = static::routes($areas); + + // create a micro-router for the Panel + return Router::execute($path, $method = $kirby->request()->method(), $routes, function ($route) use ($areas, $kirby, $method, $path) { + // route needs authentication? + $auth = $route->attributes()['auth'] ?? true; + $areaId = $route->attributes()['area'] ?? null; + $type = $route->attributes()['type'] ?? 'view'; + $area = $areas[$areaId] ?? null; + + // call the route action to check the result + try { + // trigger hook + $route = $kirby->apply( + 'panel.route:before', + compact('route', 'path', 'method'), + 'route' + ); + + // check for access before executing area routes + if ($auth !== false) { + static::firewall($kirby->user(), $areaId); + } + + $result = $route->action()->call($route, ...$route->arguments()); + } catch (Throwable $e) { + $result = $e; + } + + $response = static::response($result, [ + 'area' => $area, + 'areas' => $areas, + 'path' => $path, + 'type' => $type + ]); + + return $kirby->apply( + 'panel.route:after', + compact('route', 'path', 'method', 'response'), + 'response' + ); + }); + } + + /** + * Extract the routes from the given array + * of active areas. + */ + public static function routes(array $areas): array + { + $kirby = App::instance(); + + // the browser incompatibility + // warning is always needed + $routes = [ + [ + 'pattern' => 'browser', + 'auth' => false, + 'action' => fn () => new Response( + Tpl::load($kirby->root('kirby') . '/views/browser.php') + ), + ] + ]; + + // register all routes from areas + foreach ($areas as $areaId => $area) { + $routes = array_merge( + $routes, + static::routesForViews($areaId, $area), + static::routesForSearches($areaId, $area), + static::routesForDialogs($areaId, $area), + static::routesForDrawers($areaId, $area), + static::routesForDropdowns($areaId, $area), + static::routesForRequests($areaId, $area), + ); + } + + // if the Panel is already installed and/or the + // user is authenticated, those areas won't be + // included, which is why we add redirect routes + // to main Panel view as fallbacks + $routes[] = [ + 'pattern' => [ + '/', + 'installation', + 'login', + ], + 'action' => fn () => Panel::go(Home::url()), + 'auth' => false + ]; + + // catch all route + $routes[] = [ + 'pattern' => '(:all)', + 'action' => fn (string $pattern) => 'Could not find Panel view for route: ' . $pattern + ]; + + return $routes; + } + + /** + * Extract all routes from an area + */ + public static function routesForDialogs(string $areaId, array $area): array + { + $dialogs = $area['dialogs'] ?? []; + $routes = []; + + foreach ($dialogs as $dialogId => $dialog) { + $routes = array_merge($routes, Dialog::routes( + id: $dialogId, + areaId: $areaId, + prefix: 'dialogs', + options: $dialog + )); + } + + return $routes; + } + + /** + * Extract all routes from an area + */ + public static function routesForDrawers(string $areaId, array $area): array + { + $drawers = $area['drawers'] ?? []; + $routes = []; + + foreach ($drawers as $drawerId => $drawer) { + $routes = array_merge($routes, Drawer::routes( + id: $drawerId, + areaId: $areaId, + prefix: 'drawers', + options: $drawer + )); + } + + return $routes; + } + + /** + * Extract all routes for dropdowns + */ + public static function routesForDropdowns(string $areaId, array $area): array + { + $dropdowns = $area['dropdowns'] ?? []; + $routes = []; + + foreach ($dropdowns as $dropdownId => $dropdown) { + $routes = array_merge($routes, Dropdown::routes( + id: $dropdownId, + areaId: $areaId, + prefix: 'dropdowns', + options: $dropdown + )); + } + + return $routes; + } + + /** + * Extract all routes from an area + */ + public static function routesForRequests(string $areaId, array $area): array + { + $routes = $area['requests'] ?? []; + + foreach ($routes as $key => $route) { + $routes[$key]['area'] = $areaId; + $routes[$key]['type'] = 'request'; + } + + return $routes; + } + + /** + * Extract all routes for searches + */ + public static function routesForSearches(string $areaId, array $area): array + { + $searches = $area['searches'] ?? []; + $routes = []; + + foreach ($searches as $name => $params) { + // create the full routing pattern + $pattern = 'search/' . $name; + + // load event + $routes[] = [ + 'pattern' => $pattern, + 'type' => 'search', + 'area' => $areaId, + 'action' => function () use ($params) { + $kirby = App::instance(); + $request = $kirby->request(); + $query = $request->get('query'); + $limit = (int)$request->get('limit', $kirby->option('panel.search.limit', 10)); + $page = (int)$request->get('page', 1); + + return $params['query']($query, $limit, $page); + } + ]; + } + + return $routes; + } + + /** + * Extract all views from an area + */ + public static function routesForViews(string $areaId, array $area): array + { + $views = $area['views'] ?? []; + $routes = []; + + foreach ($views as $view) { + $view['area'] = $areaId; + $view['type'] = 'view'; + + $when = $view['when'] ?? null; + unset($view['when']); + + // enable the route by default, but if there is a + // when condition closure, it must return `true` + if ( + $when instanceof Closure === false || + $when($view, $area) === true + ) { + $routes[] = $view; + } + } + + return $routes; + } + + /** + * Set the current language in multi-lang + * installations based on the session or the + * query language query parameter + */ + public static function setLanguage(): string|null + { + $kirby = App::instance(); + + // language switcher + if (static::multilang()) { + $fallback = 'en'; + + if ($defaultLanguage = $kirby->defaultLanguage()) { + $fallback = $defaultLanguage->code(); + } + + $session = $kirby->session(); + $sessionLanguage = $session->get('panel.language', $fallback); + $language = $kirby->request()->get('language') ?? $sessionLanguage; + + // keep the language for the next visit + if ($language !== $sessionLanguage) { + $session->set('panel.language', $language); + } + + // activate the current language in Kirby + $kirby->setCurrentLanguage($language); + + return $language; + } + + return null; + } + + /** + * Set the currently active Panel translation + * based on the current user or config + */ + public static function setTranslation(): string + { + $kirby = App::instance(); + + // use the user language for the default translation or + // fall back to the language from the config + $translation = $kirby->user()?->language() ?? + $kirby->panelLanguage(); + + $kirby->setCurrentTranslation($translation); + + return $translation; + } + + /** + * Creates an absolute Panel URL + * independent of the Panel slug config + */ + public static function url(string|null $url = null, array $options = []): string + { + // only touch relative paths + if (Url::isAbsolute($url) === false) { + $kirby = App::instance(); + $slug = $kirby->option('panel.slug', 'panel'); + $path = trim($url, '/'); + + $baseUri = new Uri($kirby->url()); + $basePath = trim($baseUri->path()->toString(), '/'); + + // removes base path if relative path contains it + if (empty($basePath) === false && Str::startsWith($path, $basePath) === true) { + $path = Str::after($path, $basePath); + } + // add the panel slug prefix if it it's not + // included in the path yet + elseif (Str::startsWith($path, $slug . '/') === false) { + $path = $slug . '/' . $path; + } + + // create an absolute URL + $url = CmsUrl::to($path, $options); + } + + return $url; + } +} diff --git a/kirby/src/Panel/Plugins.php b/kirby/src/Panel/Plugins.php new file mode 100644 index 0000000..f264f73 --- /dev/null +++ b/kirby/src/Panel/Plugins.php @@ -0,0 +1,139 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Plugins +{ + /** + * Cache of all collected plugin files + */ + public array|null $files = null; + + /** + * Collects and returns the plugin files for all plugins + */ + public function files(): array + { + if ($this->files !== null) { + return $this->files; + } + + $this->files = []; + + foreach (App::instance()->plugins() as $plugin) { + $this->files[] = $plugin->root() . '/index.css'; + $this->files[] = $plugin->root() . '/index.js'; + // During plugin development, kirbyup adds an index.dev.mjs as entry point, which + // Kirby will load instead of the regular index.js. Since kirbyup is based on Vite, + // it can't use the standard index.js as entry for its development server: + // Vite requires an entry of type module so it can use JavaScript imports, + // but Kirbyup needs index.js to load as a regular script, synchronously. + $this->files[] = $plugin->root() . '/index.dev.mjs'; + } + + return $this->files; + } + + /** + * Returns the last modification + * of the collected plugin files + */ + public function modified(): int + { + $files = $this->files(); + $modified = [0]; + + foreach ($files as $file) { + $modified[] = F::modified($file); + } + + return max($modified); + } + + /** + * Read the files from all plugins and concatenate them + */ + public function read(string $type): string + { + $dist = []; + + foreach ($this->files() as $file) { + // filter out files with a different type + if (F::extension($file) !== $type) { + continue; + } + + // filter out empty files and files that don't exist + $content = F::read($file); + if (!$content) { + continue; + } + + if ($type === 'mjs') { + // index.dev.mjs files are turned into data URIs so they + // can be imported without having to copy them to /media + // (avoids having to clean the files from /media again) + $content = F::uri($file); + } + + if ($type === 'js') { + // filter out all index.js files that shouldn't be loaded + // because an index.dev.mjs exists + if (F::exists(preg_replace('/\.js$/', '.dev.mjs', $file)) === true) { + continue; + } + + $content = trim($content); + + // make sure that each plugin is ended correctly + if (Str::endsWith($content, ';') === false) { + $content .= ';'; + } + } + + $dist[] = $content; + } + + if ($type === 'mjs') { + // if no index.dev.mjs modules exist, we MUST return an empty string instead + // of loading an empty array; this is because the module loader code uses + // top level await, which is not compatible with Kirby's minimum browser + // version requirements and therefore must not appear in a default setup + if (empty($dist)) { + return ''; + } + + $modules = Json::encode($dist); + $modulePromise = "Promise.all($modules.map(url => import(url)))"; + return "try { await $modulePromise } catch (e) { console.error(e) }" . PHP_EOL; + } + + return implode(PHP_EOL . PHP_EOL, $dist); + } + + /** + * Absolute url to the cache file + * This is used by the panel to link the plugins + */ + public function url(string $type): string + { + return App::instance()->url('media') . '/plugins/index.' . $type . '?' . $this->modified(); + } +} diff --git a/kirby/src/Panel/Redirect.php b/kirby/src/Panel/Redirect.php new file mode 100644 index 0000000..d00cd6f --- /dev/null +++ b/kirby/src/Panel/Redirect.php @@ -0,0 +1,42 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Redirect extends Exception +{ + /** + * Returns the HTTP code for the redirect + */ + public function code(): int + { + $codes = [301, 302, 303, 307, 308]; + + if (in_array($this->getCode(), $codes) === true) { + return $this->getCode(); + } + + return 302; + } + + /** + * Returns the URL for the redirect + */ + public function location(): string + { + return $this->getMessage(); + } +} diff --git a/kirby/src/Panel/Request.php b/kirby/src/Panel/Request.php new file mode 100644 index 0000000..9656d7d --- /dev/null +++ b/kirby/src/Panel/Request.php @@ -0,0 +1,24 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Request +{ + /** + * Renders request responses + */ + public static function response($data, array $options = []): Response + { + $data = Json::responseData($data); + return Panel::json($data, $data['code'] ?? 200); + } +} diff --git a/kirby/src/Panel/Search.php b/kirby/src/Panel/Search.php new file mode 100644 index 0000000..f9b4295 --- /dev/null +++ b/kirby/src/Panel/Search.php @@ -0,0 +1,41 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Search extends Json +{ + protected static string $key = '$search'; + + public static function response($data, array $options = []): Response + { + if ( + is_array($data) === true && + array_key_exists('results', $data) === false + ) { + $data = [ + 'results' => $data, + 'pagination' => [ + 'page' => 1, + 'limit' => $total = count($data), + 'total' => $total + ] + ]; + } + + return parent::response($data, $options); + } +} diff --git a/kirby/src/Panel/Site.php b/kirby/src/Panel/Site.php new file mode 100644 index 0000000..68f7892 --- /dev/null +++ b/kirby/src/Panel/Site.php @@ -0,0 +1,91 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Site extends Model +{ + /** + * @var \Kirby\Cms\Site + */ + protected ModelWithContent $model; + + /** + * Returns the setup for a dropdown option + * which is used in the changes dropdown + * for example. + */ + public function dropdownOption(): array + { + return [ + 'icon' => 'home', + 'text' => $this->model->title()->value(), + ] + parent::dropdownOption(); + } + + /** + * Returns the image file object based on provided query + * + * @internal + */ + protected function imageSource( + string|null $query = null + ): CmsFile|Asset|null { + $query ??= 'site.image'; + return parent::imageSource($query); + } + + /** + * Returns the full path without leading slash + */ + public function path(): string + { + return 'site'; + } + + /** + * Returns the data array for the + * view's component props + * + * @internal + */ + public function props(): array + { + return array_merge(parent::props(), [ + 'blueprint' => 'site', + 'model' => [ + 'content' => $this->content(), + 'link' => $this->url(true), + 'previewUrl' => $this->model->previewUrl(), + 'title' => $this->model->title()->toString(), + ] + ]); + } + + /** + * Returns the data array for + * this model's Panel view + * + * @internal + */ + public function view(): array + { + return [ + 'component' => 'k-site-view', + 'props' => $this->props() + ]; + } +} diff --git a/kirby/src/Panel/User.php b/kirby/src/Panel/User.php new file mode 100644 index 0000000..e0a5149 --- /dev/null +++ b/kirby/src/Panel/User.php @@ -0,0 +1,271 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class User extends Model +{ + /** + * @var \Kirby\Cms\User + */ + protected ModelWithContent $model; + + /** + * Breadcrumb array + */ + public function breadcrumb(): array + { + return [ + [ + 'label' => $this->model->username(), + 'link' => $this->url(true), + ] + ]; + } + + /** + * Provides options for the user dropdown + */ + public function dropdown(array $options = []): array + { + $account = $this->model->isLoggedIn(); + $i18nPrefix = $account ? 'account' : 'user'; + $permissions = $this->options(['preview']); + $url = $this->url(true); + $result = []; + + $result[] = [ + 'dialog' => $url . '/changeName', + 'icon' => 'title', + 'text' => I18n::translate($i18nPrefix . '.changeName'), + 'disabled' => $this->isDisabledDropdownOption('changeName', $options, $permissions) + ]; + + $result[] = '-'; + + $result[] = [ + 'dialog' => $url . '/changeEmail', + 'icon' => 'email', + 'text' => I18n::translate('user.changeEmail'), + 'disabled' => $this->isDisabledDropdownOption('changeEmail', $options, $permissions) + ]; + + $result[] = [ + 'dialog' => $url . '/changeRole', + 'icon' => 'bolt', + 'text' => I18n::translate('user.changeRole'), + 'disabled' => $this->isDisabledDropdownOption('changeRole', $options, $permissions) + ]; + + $result[] = [ + 'dialog' => $url . '/changeLanguage', + 'icon' => 'translate', + 'text' => I18n::translate('user.changeLanguage'), + 'disabled' => $this->isDisabledDropdownOption('changeLanguage', $options, $permissions) + ]; + + $result[] = '-'; + + $result[] = [ + 'dialog' => $url . '/changePassword', + 'icon' => 'key', + 'text' => I18n::translate('user.changePassword'), + 'disabled' => $this->isDisabledDropdownOption('changePassword', $options, $permissions) + ]; + + if ($this->model->kirby()->system()->is2FAWithTOTP() === true) { + if ($account || $this->model->kirby()->user()->isAdmin()) { + if ($this->model->secret('totp') !== null) { + $result[] = [ + 'dialog' => $url . '/totp/disable', + 'icon' => 'qr-code', + 'text' => I18n::translate('login.totp.disable.option'), + ]; + } elseif ($account) { + $result[] = [ + 'dialog' => $url . '/totp/enable', + 'icon' => 'qr-code', + 'text' => I18n::translate('login.totp.enable.option') + ]; + } + } + } + + $result[] = '-'; + + $result[] = [ + 'dialog' => $url . '/delete', + 'icon' => 'trash', + 'text' => I18n::translate($i18nPrefix . '.delete'), + 'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions) + ]; + + return $result; + } + + /** + * Returns the setup for a dropdown option + * which is used in the changes dropdown + * for example. + */ + public function dropdownOption(): array + { + return [ + 'icon' => 'user', + 'text' => $this->model->username(), + ] + parent::dropdownOption(); + } + + public function home(): string|null + { + if ($home = ($this->model->blueprint()->home() ?? null)) { + $url = $this->model->toString($home); + return Url::to($url); + } + + return Panel::url('site'); + } + + /** + * Default settings for the user's Panel image + */ + protected function imageDefaults(): array + { + return array_merge(parent::imageDefaults(), [ + 'back' => 'black', + 'icon' => 'user', + 'ratio' => '1/1', + ]); + } + + /** + * Returns the image file object based on provided query + * @internal + */ + protected function imageSource( + string|null $query = null + ): CmsFile|Asset|null { + if ($query === null) { + return $this->model->avatar(); + } + + return parent::imageSource($query); + } + + /** + * Returns the full path without leading slash + */ + public function path(): string + { + // path to your own account + if ($this->model->isLoggedIn() === true) { + return 'account'; + } + + return 'users/' . $this->model->id(); + } + + /** + * Returns prepared data for the panel user picker + */ + public function pickerData(array $params = []): array + { + $params['text'] ??= '{{ user.username }}'; + + return array_merge(parent::pickerData($params), [ + 'email' => $this->model->email(), + 'username' => $this->model->username(), + ]); + } + + /** + * Returns navigation array with + * previous and next user + * + * @internal + */ + public function prevNext(): array + { + $user = $this->model; + + return [ + 'next' => fn () => $this->toPrevNextLink($user->next(), 'username'), + 'prev' => fn () => $this->toPrevNextLink($user->prev(), 'username') + ]; + } + + /** + * Returns the data array for the + * view's component props + * + * @internal + */ + public function props(): array + { + $user = $this->model; + $account = $user->isLoggedIn(); + + return array_merge( + parent::props(), + $account ? [] : $this->prevNext(), + [ + 'blueprint' => $this->model->role()->name(), + 'model' => [ + 'account' => $account, + 'avatar' => $user->avatar()?->url(), + 'content' => $this->content(), + 'email' => $user->email(), + 'id' => $user->id(), + 'language' => $this->translation()->name(), + 'link' => $this->url(true), + 'name' => $user->name()->toString(), + 'role' => $user->role()->title(), + 'username' => $user->username(), + ] + ] + ); + } + + /** + * Returns the Translation object + * for the selected Panel language + */ + public function translation(): Translation + { + $kirby = $this->model->kirby(); + $lang = $this->model->language(); + return $kirby->translation($lang); + } + + /** + * Returns the data array for + * this model's Panel view + * + * @internal + */ + public function view(): array + { + return [ + 'breadcrumb' => $this->breadcrumb(), + 'component' => 'k-user-view', + 'props' => $this->props(), + 'title' => $this->model->username(), + ]; + } +} diff --git a/kirby/src/Panel/UserTotpDisableDialog.php b/kirby/src/Panel/UserTotpDisableDialog.php new file mode 100644 index 0000000..7050cc7 --- /dev/null +++ b/kirby/src/Panel/UserTotpDisableDialog.php @@ -0,0 +1,114 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserTotpDisableDialog +{ + public App $kirby; + public User $user; + + public function __construct( + string|null $id = null + ) { + $this->kirby = App::instance(); + $this->user = $id ? Find::user($id) : $this->kirby->user(); + } + + /** + * Returns the Panel dialog state when opening the dialog + */ + public function load(): array + { + $currentUser = $this->kirby->user(); + $submitBtn = [ + 'text' => I18n::translate('disable'), + 'icon' => 'protected', + 'theme' => 'negative' + ]; + + // admins can disable TOTP for other users without + // entering their password (but not for themselves) + if ( + $currentUser->isAdmin() === true && + $currentUser->is($this->user) === false + ) { + $name = $this->user->name()->or($this->user->email()); + + return [ + 'component' => 'k-remove-dialog', + 'props' => [ + 'text' => I18n::template('login.totp.disable.admin', ['user' => Escape::html($name)]), + 'submitButton' => $submitBtn, + ] + ]; + } + + // everybody else + return [ + 'component' => 'k-form-dialog', + 'props' => [ + 'fields' => [ + 'password' => [ + 'type' => 'password', + 'required' => true, + 'counter' => false, + 'label' => I18n::translate('login.totp.disable.label'), + 'help' => I18n::translate('login.totp.disable.help'), + ] + ], + 'submitButton' => $submitBtn, + ] + ]; + } + + /** + * Removes the user's TOTP secret when the dialog is submitted + */ + public function submit(): array + { + $password = $this->kirby->request()->get('password'); + + try { + if ($this->kirby->user()->is($this->user) === true) { + $this->user->validatePassword($password); + } elseif ($this->kirby->user()->isAdmin() === false) { + throw new PermissionException('You are not allowed to disable TOTP for other users'); + } + + // Remove the TOTP secret from the account + $this->user->changeTotp(null); + + return [ + 'message' => I18n::translate('login.totp.disable.success') + ]; + } catch (InvalidArgumentException $e) { + // Catch and re-throw exception so that any + // Unauthenticated exception for incorrect passwords + // does not trigger a logout + throw new InvalidArgumentException([ + 'key' => $e->getKey(), + 'data' => $e->getData(), + 'fallback' => $e->getMessage(), + 'previous' => $e + ]); + } + } +} diff --git a/kirby/src/Panel/UserTotpEnableDialog.php b/kirby/src/Panel/UserTotpEnableDialog.php new file mode 100644 index 0000000..e2917db --- /dev/null +++ b/kirby/src/Panel/UserTotpEnableDialog.php @@ -0,0 +1,95 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class UserTotpEnableDialog +{ + public App $kirby; + public Totp $totp; + public User $user; + + public function __construct() + { + $this->kirby = App::instance(); + $this->user = $this->kirby->user(); + } + + /** + * Returns the Panel dialog state when opening the dialog + */ + public function load(): array + { + return [ + 'component' => 'k-totp-dialog', + 'props' => [ + 'qr' => $this->qr()->toSvg(size: '100%'), + 'value' => ['secret' => $this->secret()] + ] + ]; + } + + /** + * Creates a QR code with a new TOTP secret for the user + */ + public function qr(): QrCode + { + $issuer = $this->kirby->site()->title(); + $label = $this->user->email(); + $uri = $this->totp()->uri($issuer, $label); + return new QrCode($uri); + } + + public function secret(): string + { + return $this->totp()->secret(); + } + + /** + * Changes the user's TOTP secret when the dialog is submitted + */ + public function submit(): array + { + $secret = $this->kirby->request()->get('secret'); + $confirm = $this->kirby->request()->get('confirm'); + + if ($confirm === null) { + throw new InvalidArgumentException( + ['key' => 'login.totp.confirm.missing'] + ); + } + + if ($this->totp($secret)->verify($confirm) === false) { + throw new InvalidArgumentException( + ['key' => 'login.totp.confirm.invalid'] + ); + } + + $this->user->changeTotp($secret); + + return [ + 'message' => I18n::translate('login.totp.enable.success') + ]; + } + + public function totp(string|null $secret = null): Totp + { + return $this->totp ??= new Totp($secret); + } +} diff --git a/kirby/src/Panel/View.php b/kirby/src/Panel/View.php new file mode 100644 index 0000000..a2fcebc --- /dev/null +++ b/kirby/src/Panel/View.php @@ -0,0 +1,384 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class View +{ + /** + * Filters the data array based on headers or + * query parameters. Requests can return only + * certain data fields that way or globals can + * be injected on demand. + */ + public static function apply(array $data): array + { + $request = App::instance()->request(); + $only = $request->header('X-Fiber-Only') ?? $request->get('_only'); + + if (empty($only) === false) { + return static::applyOnly($data, $only); + } + + $globals = + $request->header('X-Fiber-Globals') ?? + $request->get('_globals'); + + if (empty($globals) === false) { + return static::applyGlobals($data, $globals); + } + + return A::apply($data); + } + + /** + * Checks if globals should be included in a JSON Fiber request. They are normally + * only loaded with the full document request, but sometimes need to be updated. + * + * A global request can be activated with the `X-Fiber-Globals` header or the + * `_globals` query parameter. + */ + public static function applyGlobals( + array $data, + string|null $globals = null + ): array { + // split globals string into an array of fields + $globalKeys = Str::split($globals, ','); + + // add requested globals + if (empty($globalKeys) === true) { + return $data; + } + + $globals = static::globals(); + + foreach ($globalKeys as $globalKey) { + if (isset($globals[$globalKey]) === true) { + $data[$globalKey] = $globals[$globalKey]; + } + } + + // merge with shared data + return A::apply($data); + } + + /** + * Checks if the request should only return a limited + * set of data. This can be activated with the `X-Fiber-Only` + * header or the `_only` query parameter in a request. + * + * Such requests can fetch shared data or globals. + * Globals will be loaded on demand. + */ + public static function applyOnly( + array $data, + string|null $only = null + ): array { + // split include string into an array of fields + $onlyKeys = Str::split($only, ','); + + // if a full request is made, return all data + if (empty($onlyKeys) === true) { + return $data; + } + + // otherwise filter data based on + // dot notation, e.g. `$props.tab.columns` + $result = []; + + // check if globals are requested and need to be merged + if (Str::contains($only, '$')) { + $data = array_merge_recursive(static::globals(), $data); + } + + // make sure the data is already resolved to make + // nested data fetching work + $data = A::apply($data); + + // build a new array with all requested data + foreach ($onlyKeys as $onlyKey) { + $result[$onlyKey] = A::get($data, $onlyKey); + } + + // Nest dotted keys in array but ignore $translation + return A::nest($result, ['$translation']); + } + + /** + * Creates the shared data array for the individual views + * The full shared data is always sent on every JSON and + * full document request unless the `X-Fiber-Only` header or + * the `_only` query parameter is set. + */ + public static function data(array $view = [], array $options = []): array + { + $kirby = App::instance(); + + // multilang setup check + $multilang = Panel::multilang(); + + // get the authenticated user + $user = $kirby->user(); + + // user permissions + $permissions = $user?->role()->permissions()->toArray() ?? []; + + // current content language + $language = $kirby->language(); + + // shared data for all requests + return [ + '$direction' => function () use ($kirby, $multilang, $language, $user) { + if ($multilang === true && $language && $user) { + $default = $kirby->defaultLanguage(); + + if ( + $language->direction() !== $default->direction() && + $language->code() !== $user->language() + ) { + return $language->direction(); + } + } + }, + '$dialog' => null, + '$drawer' => null, + '$language' => function () use ($kirby, $multilang, $language) { + if ($multilang === true && $language) { + return [ + 'code' => $language->code(), + 'default' => $language->isDefault(), + 'direction' => $language->direction(), + 'name' => $language->name(), + 'rules' => $language->rules(), + ]; + } + }, + '$languages' => function () use ($kirby, $multilang): array { + if ($multilang === true) { + return $kirby->languages()->values(fn ($language) => [ + 'code' => $language->code(), + 'default' => $language->isDefault(), + 'direction' => $language->direction(), + 'name' => $language->name(), + 'rules' => $language->rules(), + ]); + } + + return []; + }, + '$menu' => function () use ($options, $permissions) { + $menu = new Menu( + $options['areas'] ?? [], + $permissions, + $options['area']['id'] ?? null + ); + return $menu->entries(); + }, + '$permissions' => $permissions, + '$license' => $kirby->system()->license()->status()->value(), + '$multilang' => $multilang, + '$searches' => static::searches($options['areas'] ?? [], $permissions), + '$url' => $kirby->request()->url()->toString(), + '$user' => function () use ($user) { + if ($user) { + return [ + 'email' => $user->email(), + 'id' => $user->id(), + 'language' => $user->language(), + 'role' => $user->role()->id(), + 'username' => $user->username(), + ]; + } + + return null; + }, + '$view' => function () use ($kirby, $options, $view) { + $defaults = [ + 'breadcrumb' => [], + 'code' => 200, + 'path' => Str::after($kirby->path(), '/'), + 'props' => [], + 'query' => App::instance()->request()->query()->toArray(), + 'referrer' => Panel::referrer(), + 'search' => $kirby->option('panel.search.type', 'pages'), + 'timestamp' => (int)(microtime(true) * 1000), + ]; + + $view = array_replace_recursive( + $defaults, + $options['area'] ?? [], + $view + ); + + // make sure that views and dialogs are gone + unset( + $view['dialogs'], + $view['drawers'], + $view['dropdowns'], + $view['requests'], + $view['searches'], + $view['views'] + ); + + // resolve all callbacks in the view array + return A::apply($view); + } + ]; + } + + /** + * Renders the error view with provided message + */ + public static function error(string $message, int $code = 404) + { + return [ + 'code' => $code, + 'component' => 'k-error-view', + 'error' => $message, + 'props' => [ + 'error' => $message, + 'layout' => Panel::hasAccess(App::instance()->user()) ? 'inside' : 'outside' + ], + 'title' => 'Error' + ]; + } + + /** + * Creates global data for the Panel. + * This will be injected in the full Panel + * view via the script tag. Global data + * is only requested once on the first page load. + * It can be loaded partially later if needed, + * but is otherwise not included in Fiber calls. + */ + public static function globals(): array + { + $kirby = App::instance(); + + return [ + '$config' => fn () => [ + 'debug' => $kirby->option('debug', false), + 'kirbytext' => $kirby->option('panel.kirbytext', true), + 'translation' => $kirby->option('panel.language', 'en'), + ], + '$system' => function () use ($kirby) { + $locales = []; + + foreach ($kirby->translations() as $translation) { + $locales[$translation->code()] = $translation->locale(); + } + + return [ + 'ascii' => Str::$ascii, + 'csrf' => $kirby->auth()->csrfFromSession(), + 'isLocal' => $kirby->system()->isLocal(), + 'locales' => $locales, + 'slugs' => Str::$language, + 'title' => $kirby->site()->title()->or('Kirby Panel')->toString() + ]; + }, + '$translation' => function () use ($kirby) { + if ($user = $kirby->user()) { + $translation = $kirby->translation($user->language()); + } else { + $translation = $kirby->translation($kirby->panelLanguage()); + } + + return [ + 'code' => $translation->code(), + 'data' => $translation->dataWithFallback(), + 'direction' => $translation->direction(), + 'name' => $translation->name(), + ]; + }, + '$urls' => fn () => [ + 'api' => $kirby->url('api'), + 'site' => $kirby->url('index') + ] + ]; + } + + /** + * Renders the main panel view either as + * JSON response or full HTML document based + * on the request header or query params + */ + public static function response($data, array $options = []): Response + { + // handle redirects + if ($data instanceof Redirect) { + return Response::redirect($data->location(), $data->code()); + + // handle Kirby exceptions + } elseif ($data instanceof Exception) { + $data = static::error($data->getMessage(), $data->getHttpCode()); + + // handle regular exceptions + } elseif ($data instanceof Throwable) { + $data = static::error($data->getMessage(), 500); + + // only expect arrays from here on + } elseif (is_array($data) === false) { + $data = static::error('Invalid Panel response', 500); + } + + // get all data for the request + $fiber = static::data($data, $options); + + // if requested, send $fiber data as JSON + if (Panel::isFiberRequest() === true) { + // filter data, if only or globals headers or + // query parameters are set + $fiber = static::apply($fiber); + + return Panel::json($fiber, $fiber['$view']['code'] ?? 200); + } + + // load globals for the full document response + $globals = static::globals(); + + // resolve and merge globals and shared data + $fiber = array_merge_recursive(A::apply($globals), A::apply($fiber)); + + // render the full HTML document + return Document::response($fiber); + } + + public static function searches(array $areas, array $permissions): array + { + $searches = []; + + foreach ($areas as $areaId => $area) { + // by default, all areas are accessible unless + // the permissions are explicitly set to false + if (($permissions['access'][$areaId] ?? true) !== false) { + foreach ($area['searches'] ?? [] as $id => $params) { + $searches[$id] = [ + 'icon' => $params['icon'] ?? 'search', + 'label' => $params['label'] ?? Str::ucfirst($id), + 'id' => $id + ]; + } + } + } + return $searches; + } +} diff --git a/kirby/src/Parsley/Element.php b/kirby/src/Parsley/Element.php new file mode 100644 index 0000000..649c5a3 --- /dev/null +++ b/kirby/src/Parsley/Element.php @@ -0,0 +1,159 @@ +, + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Element +{ + public function __construct( + protected DOMElement $node, + protected array $marks = [] + ) { + } + + /** + * The returns the attribute value or + * the given fallback if the attribute does not exist + */ + public function attr( + string $attr, + string|null $fallback = null + ): string|null { + if ($this->node->hasAttribute($attr) === true) { + return $this->node->getAttribute($attr) ?? $fallback; + } + + return $fallback; + } + + /** + * Returns a list of all child elements + */ + public function children(): DOMNodeList + { + return $this->node->childNodes; + } + + /** + * Returns an array with all class names + */ + public function classList(): array + { + return Str::split($this->className(), ' '); + } + + /** + * Returns the value of the class attribute + */ + public function className(): string|null + { + return $this->attr('class'); + } + + /** + * Returns the original dom element + */ + public function element(): DOMElement + { + return $this->node; + } + + /** + * Returns an array with all nested elements + * that could be found for the given query + */ + public function filter(string $query): array + { + $result = []; + + if ($queryResult = $this->query($query)) { + foreach ($queryResult as $node) { + $result[] = new static($node); + } + } + + return $result; + } + + /** + * Tries to find a single nested element by + * query and otherwise returns null + */ + public function find(string $query): static|null + { + if ($result = $this->query($query)[0]) { + return new static($result); + } + + return null; + } + + /** + * Returns the inner HTML of the element + * + * @param array|null $marks List of allowed marks + */ + public function innerHtml(array|null $marks = null): string + { + $marks ??= $this->marks; + $inline = new Inline($this->node, $marks); + return $inline->innerHtml(); + } + + /** + * Returns the contents as plain text + */ + public function innerText(): string + { + return trim($this->node->textContent); + } + + /** + * Returns the full HTML for the element + */ + public function outerHtml(array|null $marks = null): string + { + return $this->node->ownerDocument->saveHtml($this->node); + } + + /** + * Searches nested elements + */ + public function query(string $query): DOMNodeList|null + { + $path = new DOMXPath($this->node->ownerDocument); + return $path->query($query, $this->node); + } + + /** + * Removes the element from the DOM + */ + public function remove(): void + { + $this->node->parentNode->removeChild($this->node); + } + + /** + * Returns the name of the element + */ + public function tagName(): string + { + return $this->node->tagName; + } +} diff --git a/kirby/src/Parsley/Inline.php b/kirby/src/Parsley/Inline.php new file mode 100644 index 0000000..32e6b08 --- /dev/null +++ b/kirby/src/Parsley/Inline.php @@ -0,0 +1,161 @@ +, + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Inline +{ + protected string $html = ''; + protected array $marks = []; + + public function __construct(DOMNode $node, array $marks = []) + { + $this->createMarkRules($marks); + + $html = static::parseNode($node, $this->marks) ?? ''; + + // only trim HTML if it doesn't consist of only spaces + if (trim($html) !== '') { + $html = trim($html); + } + + $this->html = $html; + } + + /** + * Loads all mark rules + */ + protected function createMarkRules(array $marks): array + { + foreach ($marks as $mark) { + $this->marks[$mark['tag']] = $mark; + } + + return $this->marks; + } + + /** + * Get all allowed attributes for a DOMElement + * as clean array + */ + public static function parseAttrs( + DOMElement $node, + array $marks = [] + ): array { + $attrs = []; + $mark = $marks[$node->tagName]; + $defaults = $mark['defaults'] ?? []; + + foreach ($mark['attrs'] ?? [] as $attr) { + $attrs[$attr] = match ($node->hasAttribute($attr)) { + true => $node->getAttribute($attr), + default => $defaults[$attr] ?? null + }; + } + + return $attrs; + } + + /** + * Parses all children and creates clean HTML + * for each of them. + */ + public static function parseChildren( + DOMNodeList $children, + array $marks + ): string { + $html = ''; + foreach ($children as $child) { + $html .= static::parseNode($child, $marks); + } + return $html; + } + + /** + * Go through all child elements and create + * clean inner HTML for them + */ + public static function parseInnerHtml( + DOMElement $node, + array $marks = [] + ): string|null { + $html = static::parseChildren($node->childNodes, $marks); + + // trim the inner HTML for paragraphs + if ($node->tagName === 'p') { + $html = trim($html); + } + + // return null for empty inner HTML + if ($html === '') { + return null; + } + + return $html; + } + + /** + * Converts the given node to clean HTML + */ + public static function parseNode(DOMNode $node, array $marks = []): string|null + { + if ($node instanceof DOMText) { + return Html::encode($node->textContent); + } + + if ($node instanceof DOMElement) { + // unknown marks + if (array_key_exists($node->tagName, $marks) === false) { + return static::parseChildren($node->childNodes, $marks); + } + + // collect all allowed attributes + $attrs = static::parseAttrs($node, $marks); + + // close self-closing elements + if (Html::isVoid($node->tagName) === true) { + return '<' . $node->tagName . Html::attr($attrs, null, ' ') . ' />'; + } + + $innerHtml = static::parseInnerHtml($node, $marks); + + // skip empty paragraphs + if ($innerHtml === null && $node->tagName === 'p') { + return null; + } + + // create the outer html for the element + $html = '<' . $node->tagName . Html::attr($attrs, null, ' ') . '>'; + $html .= $innerHtml; + $html .= 'tagName . '>'; + return $html; + } + + return null; + } + + /** + * Returns the HTML contents of the element + */ + public function innerHtml(): string + { + return $this->html; + } +} diff --git a/kirby/src/Parsley/Parsley.php b/kirby/src/Parsley/Parsley.php new file mode 100644 index 0000000..3f2242e --- /dev/null +++ b/kirby/src/Parsley/Parsley.php @@ -0,0 +1,300 @@ +, + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Parsley +{ + protected array $blocks = []; + protected DOMDocument $doc; + protected Dom $dom; + protected array $inline = []; + protected array $marks = []; + protected array $nodes = []; + protected Schema $schema; + protected array $skip = []; + + public static bool $useXmlExtension = true; + + public function __construct(string $html, Schema|null $schema = null) + { + // fail gracefully if the XML extension is not installed + // or should be skipped + if ($this->useXmlExtension() === false) { + $this->blocks[] = [ + 'type' => 'markdown', + 'content' => ['text' => $html] + ]; + return; + } + + if (!preg_match('//', $html)) { + $html = '
' . $html . '
'; + } + + $this->dom = new Dom($html); + $this->doc = $this->dom->document(); + $this->schema = $schema ?? new Plain(); + $this->skip = $this->schema->skip(); + $this->marks = $this->schema->marks(); + $this->inline = []; + + // load all allowed nodes from the schema + $this->createNodeRules($this->schema->nodes()); + + // start parsing at the top level and go through + // all children of the document + foreach ($this->doc->childNodes as $childNode) { + $this->parseNode($childNode); + } + + // needs to be called at last to fetch remaining + // inline elements after parsing has ended + $this->endInlineBlock(); + } + + /** + * Returns all detected blocks + */ + public function blocks(): array + { + return $this->blocks; + } + + /** + * Load all node rules from the schema + */ + public function createNodeRules(array $nodes): array + { + foreach ($nodes as $node) { + $this->nodes[$node['tag']] = $node; + } + + return $this->nodes; + } + + /** + * Checks if the given element contains + * any other block level elements + */ + public function containsBlock(DOMNode $element): bool + { + if ($element->hasChildNodes() === false) { + return false; + } + + foreach ($element->childNodes as $childNode) { + if ( + $this->isBlock($childNode) === true || + $this->containsBlock($childNode) + ) { + return true; + } + } + + return false; + } + + /** + * Takes all inline elements in the inline cache + * and combines them in a final block. The block + * will either be merged with the previous block + * if the type matches, or will be appended. + * + * The inline cache will be reset afterwards + */ + public function endInlineBlock(): void + { + if (empty($this->inline) === true) { + return; + } + + $html = []; + + foreach ($this->inline as $inline) { + $node = new Inline($inline, $this->marks); + $html[] = $node->innerHTML(); + } + + $innerHTML = implode(' ', $html); + + if ($fallback = $this->fallback($innerHTML)) { + $this->mergeOrAppend($fallback); + } + + $this->inline = []; + } + + /** + * Creates a fallback block type for the given + * element. The element can either be a element object + * or a simple HTML/plain text string + */ + public function fallback(Element|string $element): array|null + { + if ($fallback = $this->schema->fallback($element)) { + return $fallback; + } + + return null; + } + + /** + * Checks if the given DOMNode is a block element + */ + public function isBlock(DOMNode $element): bool + { + if ($element instanceof DOMElement) { + return array_key_exists($element->tagName, $this->nodes) === true; + } + + return false; + } + + /** + * Checks if the given DOMNode is an inline element + */ + public function isInline(DOMNode $element): bool + { + if ($element instanceof DOMText) { + return true; + } + + if ($element instanceof DOMElement) { + // all spans will be treated as inline elements + if ($element->tagName === 'span') { + return true; + } + + if ($this->containsBlock($element) === true) { + return false; + } + + if ($element->tagName === 'p') { + return false; + } + + $marks = array_column($this->marks, 'tag'); + return in_array($element->tagName, $marks); + } + + return false; + } + + public function mergeOrAppend(array $block): void + { + $lastIndex = count($this->blocks) - 1; + $lastItem = $this->blocks[$lastIndex] ?? null; + + // merge with previous block + if ( + $block['type'] === 'text' && + $lastItem && + $lastItem['type'] === 'text' + ) { + $this->blocks[$lastIndex]['content']['text'] .= ' ' . $block['content']['text']; + + // append + } else { + $this->blocks[] = $block; + } + } + + /** + * Parses the given DOM node and tries to + * convert it to a block or a list of blocks + */ + public function parseNode(DOMNode $element): bool + { + $skip = ['DOMComment', 'DOMDocumentType']; + + // unwanted element types + if (in_array(get_class($element), $skip) === true) { + return false; + } + + // inline context + if ($this->isInline($element) === true) { + $this->inline[] = $element; + return true; + } + + $this->endInlineBlock(); + + // known block nodes + if ($this->isBlock($element) === true) { + /** + * @var DOMElement $element + */ + if ($parser = ($this->nodes[$element->tagName]['parse'] ?? null)) { + if ($result = $parser(new Element($element, $this->marks))) { + $this->blocks[] = $result; + } + } + return true; + } + + // has only unknown children (div, etc.) + if ($this->containsBlock($element) === false) { + /** + * @var DOMElement $element + */ + if (in_array($element->tagName, $this->skip) === true) { + return false; + } + + $wrappers = [ + 'body', + 'head', + 'html', + ]; + + // wrapper elements should never be converted + // to a simple fallback block. Their children + // have to be parsed individually. + if (in_array($element->tagName, $wrappers) === false) { + $node = new Element($element, $this->marks); + + if ($block = $this->fallback($node)) { + $this->mergeOrAppend($block); + } + + return true; + } + } + + // parse all children + foreach ($element->childNodes as $childNode) { + $this->parseNode($childNode); + } + + return true; + } + + public function useXmlExtension(): bool + { + if (static::$useXmlExtension !== true) { + return false; + } + + return Dom::isSupported(); + } +} diff --git a/kirby/src/Parsley/Schema.php b/kirby/src/Parsley/Schema.php new file mode 100644 index 0000000..3cf74f1 --- /dev/null +++ b/kirby/src/Parsley/Schema.php @@ -0,0 +1,53 @@ +, + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Schema +{ + /** + * Returns the fallback block when no + * other block type can be detected + */ + public function fallback(Element|string $element): array|null + { + return null; + } + + /** + * Returns a list of allowed inline marks + * and their parsing rules + */ + public function marks(): array + { + return []; + } + + /** + * Returns a list of allowed nodes and + * their parsing rules + */ + public function nodes(): array + { + return []; + } + + /** + * Returns a list of all elements that should be + * skipped and not be parsed at all + */ + public function skip(): array + { + return []; + } +} diff --git a/kirby/src/Parsley/Schema/Blocks.php b/kirby/src/Parsley/Schema/Blocks.php new file mode 100644 index 0000000..a72106c --- /dev/null +++ b/kirby/src/Parsley/Schema/Blocks.php @@ -0,0 +1,371 @@ +, + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Blocks extends Plain +{ + public function blockquote(Element $node): array + { + $text = []; + + // get all the text for the quote + foreach ($node->children() as $child) { + if ($child instanceof DOMText) { + $text[] = trim($child->textContent); + } + + if ( + $child instanceof DOMElement && + $child->tagName !== 'footer' + ) { + $element = new Element($child); + $text[] = $element->innerHTML($this->marks()); + } + } + + // filter empty blocks and separate text blocks with breaks + $text = implode('', array_filter($text)); + + // get the citation from the footer + $citation = $node->find('footer')?->innerHTML($this->marks()); + + return [ + 'content' => [ + 'citation' => $citation, + 'text' => $text + ], + 'type' => 'quote', + ]; + } + + /** + * Creates the fallback block type + * if no other block can be found + */ + public function fallback(Element|string $element): array|null + { + if ($element instanceof Element) { + $html = $element->innerHtml(); + + // wrap the inner HTML in a p tag if it doesn't + // contain one yet. + if (Str::contains($html, '

') === false) { + $html = '

' . $html . '

'; + } + } elseif (is_string($element) === true) { + $html = trim($element); + + if (Str::length($html) === 0) { + return null; + } + + $html = '

' . $html . '

'; + } else { + return null; + } + + return [ + 'content' => [ + 'text' => $html, + ], + 'type' => 'text', + ]; + } + + /** + * Converts a heading element to a heading block + */ + public function heading(Element $node): array + { + $content = [ + 'level' => strtolower($node->tagName()), + 'text' => $node->innerHTML() + ]; + + if ($id = $node->attr('id')) { + $content['id'] = $id; + } + + ksort($content); + + return [ + 'content' => $content, + 'type' => 'heading', + ]; + } + + public function iframe(Element $node): array + { + $src = $node->attr('src'); + $figcaption = $node->find('ancestor::figure[1]//figcaption'); + $caption = $figcaption?->innerHTML($this->marks()); + + // avoid parsing the caption twice + $figcaption?->remove(); + + // reverse engineer video URLs + if (preg_match('!player.vimeo.com\/video\/([0-9]+)!i', $src, $array) === 1) { + $src = 'https://vimeo.com/' . $array[1]; + } elseif (preg_match('!youtube.com\/embed\/([a-zA-Z0-9_-]+)!', $src, $array) === 1) { + $src = 'https://youtube.com/watch?v=' . $array[1]; + } elseif (preg_match('!youtube-nocookie.com\/embed\/([a-zA-Z0-9_-]+)!', $src, $array) === 1) { + $src = 'https://youtube.com/watch?v=' . $array[1]; + } else { + $src = false; + } + + // correct video URL + if ($src) { + return [ + 'content' => [ + 'caption' => $caption, + 'url' => $src + ], + 'type' => 'video', + ]; + } + + return [ + 'content' => [ + 'text' => $node->outerHTML() + ], + 'type' => 'markdown', + ]; + } + + public function img(Element $node): array + { + $link = $node->find('ancestor::a')?->attr('href'); + $figcaption = $node->find('ancestor::figure[1]//figcaption'); + $caption = $figcaption?->innerHTML($this->marks()); + + // avoid parsing the caption twice + $figcaption?->remove(); + + return [ + 'content' => [ + 'alt' => $node->attr('alt'), + 'caption' => $caption, + 'link' => $link, + 'location' => 'web', + 'src' => $node->attr('src'), + ], + 'type' => 'image', + ]; + } + + /** + * Converts a list element to HTML + */ + public function list(Element $node): string + { + $html = []; + + foreach ($node->filter('li') as $li) { + $innerHtml = ''; + + foreach ($li->children() as $child) { + if ($child instanceof DOMText) { + $innerHtml .= $child->textContent; + } elseif ($child instanceof DOMElement) { + $child = new Element($child); + $list = ['ul', 'ol']; + $innerHtml .= match (in_array($child->tagName(), $list)) { + true => $this->list($child), + default => $child->innerHTML($this->marks()) + }; + } + } + + $html[] = '
  • ' . trim($innerHtml) . '
  • '; + } + + $outerHtml = '<' . $node->tagName() . '>'; + $outerHtml .= implode($html); + $outerHtml .= 'tagName() . '>'; + return $outerHtml; + } + + /** + * Returns a list of allowed inline marks + * and their parsing rules + */ + public function marks(): array + { + return [ + [ + 'tag' => 'a', + 'attrs' => ['href', 'rel', 'target', 'title'], + 'defaults' => [ + 'rel' => 'noreferrer' + ] + ], + [ + 'tag' => 'abbr', + ], + [ + 'tag' => 'b' + ], + [ + 'tag' => 'br', + ], + [ + 'tag' => 'code' + ], + [ + 'tag' => 'del', + ], + [ + 'tag' => 'em', + ], + [ + 'tag' => 'i', + ], + [ + 'tag' => 'p', + ], + [ + 'tag' => 'strike', + ], + [ + 'tag' => 'sub', + ], + [ + 'tag' => 'sup', + ], + [ + 'tag' => 'strong', + ], + [ + 'tag' => 'u', + ], + ]; + } + + /** + * Returns a list of allowed nodes and + * their parsing rules + * + * @codeCoverageIgnore + */ + public function nodes(): array + { + return [ + [ + 'tag' => 'blockquote', + 'parse' => fn (Element $node) => $this->blockquote($node) + ], + [ + 'tag' => 'h1', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'h2', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'h3', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'h4', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'h5', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'h6', + 'parse' => fn (Element $node) => $this->heading($node) + ], + [ + 'tag' => 'hr', + 'parse' => fn (Element $node) => ['type' => 'line'] + ], + [ + 'tag' => 'iframe', + 'parse' => fn (Element $node) => $this->iframe($node) + ], + [ + 'tag' => 'img', + 'parse' => fn (Element $node) => $this->img($node) + ], + [ + 'tag' => 'ol', + 'parse' => fn (Element $node) => [ + 'content' => [ + 'text' => $this->list($node) + ], + 'type' => 'list', + ] + ], + [ + 'tag' => 'pre', + 'parse' => fn (Element $node) => $this->pre($node) + ], + [ + 'tag' => 'table', + 'parse' => fn (Element $node) => $this->table($node) + ], + [ + 'tag' => 'ul', + 'parse' => fn (Element $node) => [ + 'content' => [ + 'text' => $this->list($node) + ], + 'type' => 'list', + ] + ], + ]; + } + + public function pre(Element $node): array + { + $language = 'text'; + + if ($code = $node->find('//code')) { + foreach ($code->classList() as $className) { + if (preg_match('!language-(.*?)!', $className)) { + $language = str_replace('language-', '', $className); + break; + } + } + } + + return [ + 'content' => [ + 'code' => $node->innerText(), + 'language' => $language + ], + 'type' => 'code', + ]; + } + + public function table(Element $node): array + { + return [ + 'content' => [ + 'text' => $node->outerHTML(), + ], + 'type' => 'markdown', + ]; + } +} diff --git a/kirby/src/Parsley/Schema/Plain.php b/kirby/src/Parsley/Schema/Plain.php new file mode 100644 index 0000000..183556f --- /dev/null +++ b/kirby/src/Parsley/Schema/Plain.php @@ -0,0 +1,64 @@ +, + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Plain extends Schema +{ + /** + * Creates the fallback block type + * if no other block can be found + */ + public function fallback(Element|string $element): array|null + { + if ($element instanceof Element) { + $text = $element->innerText(); + } elseif (is_string($element) === true) { + $text = trim($element); + + if (Str::length($text) === 0) { + return null; + } + } else { + return null; + } + + return [ + 'content' => [ + 'text' => $text + ], + 'type' => 'text', + ]; + } + + /** + * Returns a list of all elements that + * should be skipped during parsing + */ + public function skip(): array + { + return [ + 'base', + 'link', + 'meta', + 'script', + 'style', + 'title' + ]; + } +} diff --git a/kirby/src/Query/Argument.php b/kirby/src/Query/Argument.php new file mode 100644 index 0000000..d9ca493 --- /dev/null +++ b/kirby/src/Query/Argument.php @@ -0,0 +1,117 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Argument +{ + public function __construct( + public mixed $value + ) { + } + + /** + * Sanitizes argument string into actual + * PHP type/object as new Argument instance + */ + public static function factory(string $argument): static + { + $argument = trim($argument); + + // remove grouping parantheses + if ( + Str::startsWith($argument, '(') && + Str::endsWith($argument, ')') + ) { + $argument = trim(substr($argument, 1, -1)); + } + + // string with single quotes + if ( + Str::startsWith($argument, "'") && + Str::endsWith($argument, "'") + ) { + $string = substr($argument, 1, -1); + $string = str_replace("\'", "'", $string); + return new static($string); + } + + // string with double quotes + if ( + Str::startsWith($argument, '"') && + Str::endsWith($argument, '"') + ) { + $string = substr($argument, 1, -1); + $string = str_replace('\"', '"', $string); + return new static($string); + } + + // array: split and recursive sanitizing + if ( + Str::startsWith($argument, '[') && + Str::endsWith($argument, ']') + ) { + $array = substr($argument, 1, -1); + $array = Arguments::factory($array); + return new static($array); + } + + // numeric + if (is_numeric($argument) === true) { + if (strpos($argument, '.') === false) { + return new static((int)$argument); + } + + return new static((float)$argument); + } + + // Closure + if (Str::startsWith($argument, '() =>')) { + $query = Str::after($argument, '() =>'); + $query = trim($query); + return new static(fn () => $query); + } + + return new static(match ($argument) { + 'null' => null, + 'true' => true, + 'false' => false, + + // resolve parameter for objects and methods itself + default => new Query($argument) + }); + } + + /** + * Return the argument value and + * resolves nested objects to scaler types + */ + public function resolve(array|object $data = []): mixed + { + // don't resolve the Closure immediately, instead + // resolve it to the sub-query and create a new Closure + // that resolves the sub-query with the same data set once called + if ($this->value instanceof Closure) { + $query = ($this->value)(); + return fn () => static::factory($query)->resolve($data); + } + + if (is_object($this->value) === true) { + return $this->value->resolve($data); + } + + return $this->value; + } +} diff --git a/kirby/src/Query/Arguments.php b/kirby/src/Query/Arguments.php new file mode 100644 index 0000000..c78dd22 --- /dev/null +++ b/kirby/src/Query/Arguments.php @@ -0,0 +1,59 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Arguments extends Collection +{ + // skip all matches inside of parantheses + public const NO_PNTH = '\([^)]+\)(*SKIP)(*FAIL)'; + // skip all matches inside of square brackets + public const NO_SQBR = '\[[^]]+\](*SKIP)(*FAIL)'; + // skip all matches inside of double quotes + public const NO_DLQU = '\"(?:[^"\\\\]|\\\\.)*\"(*SKIP)(*FAIL)'; + // skip all matches inside of single quotes + public const NO_SLQU = '\'(?:[^\'\\\\]|\\\\.)*\'(*SKIP)(*FAIL)'; + // skip all matches inside of any of the above skip groups + public const OUTSIDE = + self::NO_PNTH . '|' . self::NO_SQBR . '|' . + self::NO_DLQU . '|' . self::NO_SLQU; + + /** + * Splits list of arguments into individual + * Argument instances while respecting skip groups + */ + public static function factory(string $arguments): static + { + $arguments = A::map( + // split by comma, but not inside skip groups + preg_split('!,|' . self::OUTSIDE . '!', $arguments), + fn ($argument) => Argument::factory($argument) + ); + + return new static($arguments); + } + + /** + * Resolve each argument, so that they can + * passed together to the actual method call + */ + public function resolve(array|object $data = []): array + { + return A::map( + $this->data, + fn ($argument) => $argument->resolve($data) + ); + } +} diff --git a/kirby/src/Query/Expression.php b/kirby/src/Query/Expression.php new file mode 100644 index 0000000..f205c30 --- /dev/null +++ b/kirby/src/Query/Expression.php @@ -0,0 +1,119 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Expression +{ + public function __construct( + public array $parts + ) { + } + + /** + * Parses an expression string into its parts + */ + public static function factory(string $expression, Query $parent = null): static|Segments + { + // split into different expression parts and operators + $parts = static::parse($expression); + + // shortcut: if expression has only one part, directly + // continue with the segments chain + if (count($parts) === 1) { + return Segments::factory(query: $parts[0], parent: $parent); + } + + // turn all non-operator parts into an Argument + // which takes care of converting string, arrays booleans etc. + // into actual types and treats all other parts as their own queries + $parts = A::map( + $parts, + fn ($part) => + in_array($part, ['?', ':', '?:', '??']) + ? $part + : Argument::factory($part) + ); + + return new static(parts: $parts); + } + + /** + * Splits a comparison string into an array + * of expressions and operators + * @internal + */ + public static function parse(string $string): array + { + // split by multiples of `?` and `:`, but not inside skip groups + // (parantheses, quotes etc.) + return preg_split( + '/\s+([\?\:]+)\s+|' . Arguments::OUTSIDE . '/', + trim($string), + flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY + ); + } + + /** + * Resolves the expression by evaluating + * the supported comparisons and consecutively + * resolving the resulting query/argument + */ + public function resolve(array|object $data = []): mixed + { + $base = null; + + foreach ($this->parts as $index => $part) { + // `a ?? b` + // if the base/previous (e.g. `a`) isn't null, + // stop the expression chain and return `a` + if ($part === '??') { + if ($base !== null) { + return $base; + } + + continue; + } + + // `a ?: b` + // if `a` isn't false, return `a`, otherwise `b` + if ($part === '?:') { + if ($base != false) { + return $base; + } + + return $this->parts[$index + 1]->resolve($data); + } + + // `a ? b : c` + // if `a` isn't false, return `b`, otherwise `c` + if ($part === '?') { + if (($this->parts[$index + 2] ?? null) !== ':') { + throw new LogicException('Query: Incomplete ternary operator (missing matching `? :`)'); + } + + if ($base != false) { + return $this->parts[$index + 1]->resolve($data); + } + + return $this->parts[$index + 3]->resolve($data); + } + + $base = $part->resolve($data); + } + + return $base; + } +} diff --git a/kirby/src/Query/Query.php b/kirby/src/Query/Query.php new file mode 100644 index 0000000..ab6381c --- /dev/null +++ b/kirby/src/Query/Query.php @@ -0,0 +1,142 @@ +, + * Nico Hoffmann + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Query +{ + /** + * Default data entries + */ + public static array $entries = []; + + /** + * Creates a new Query object + */ + public function __construct( + public string|null $query = null + ) { + if ($query !== null) { + $this->query = trim($query); + } + } + + /** + * Creates a new Query object + */ + public static function factory(string|null $query): static + { + return new static(query: $query); + } + + /** + * Method to help classes that extend Query + * to intercept a segment's result. + */ + public function intercept(mixed $result): mixed + { + return $result; + } + + /** + * Returns the query result if anything + * can be found, otherwise returns null + * + * @throws \Kirby\Exception\BadMethodCallException If an invalid method is accessed by the query + */ + public function resolve(array|object $data = []): mixed + { + if (empty($this->query) === true) { + return $data; + } + + // merge data with default entries + if (is_array($data) === true) { + $data = array_merge(static::$entries, $data); + } + + // direct data array access via key + if ( + is_array($data) === true && + array_key_exists($this->query, $data) === true + ) { + $value = $data[$this->query]; + + if ($value instanceof Closure) { + $value = $value(); + } + + return $value; + } + + // loop through all segments to resolve query + return Expression::factory($this->query, $this)->resolve($data); + } +} + +/** + * Default entries/functions + */ +Query::$entries['kirby'] = function (): App { + return App::instance(); +}; + +Query::$entries['collection'] = function (string $name): Collection|null { + return App::instance()->collection($name); +}; + +Query::$entries['file'] = function (string $id): File|null { + return App::instance()->file($id); +}; + +Query::$entries['page'] = function (string $id): Page|null { + return App::instance()->page($id); +}; + +Query::$entries['qr'] = function (string $data): QrCode { + return new QrCode($data); +}; + +Query::$entries['site'] = function (): Site { + return App::instance()->site(); +}; + +Query::$entries['t'] = function ( + string $key, + string|array $fallback = null, + string $locale = null +): string|null { + return I18n::translate($key, $fallback, $locale); +}; + +Query::$entries['user'] = function (string $id = null): User|null { + return App::instance()->user($id); +}; diff --git a/kirby/src/Query/Segment.php b/kirby/src/Query/Segment.php new file mode 100644 index 0000000..89e7574 --- /dev/null +++ b/kirby/src/Query/Segment.php @@ -0,0 +1,182 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Segment +{ + public function __construct( + public string $method, + public int $position, + public Arguments|null $arguments = null, + ) { + } + + /** + * Throws an exception for an access to an invalid method + * @internal + * + * @param mixed $data Variable on which the access was tried + * @param string $name Name of the method/property that was accessed + * @param string $label Type of the name (`method`, `property` or `method/property`) + * + * @throws \Kirby\Exception\BadMethodCallException + */ + public static function error(mixed $data, string $name, string $label): void + { + $type = strtolower(gettype($data)); + + if ($type === 'double') { + $type = 'float'; + } + + $nonExisting = in_array($type, ['array', 'object']) ? 'non-existing ' : ''; + + $error = 'Access to ' . $nonExisting . $label . ' "' . $name . '" on ' . $type; + + throw new BadMethodCallException($error); + } + + /** + * Parses a segment into the property/method name and its arguments + * + * @param int $position String position of the segment inside the full query + */ + public static function factory( + string $segment, + int $position = 0 + ): static { + if (Str::endsWith($segment, ')') === false) { + return new static(method: $segment, position: $position); + } + + // the args are everything inside the *outer* parentheses + $args = Str::substr($segment, Str::position($segment, '(') + 1, -1); + + return new static( + method: Str::before($segment, '('), + position: $position, + arguments: Arguments::factory($args) + ); + } + + /** + * Automatically resolves the segment depending on the + * segment position and the type of the base + * + * @param mixed $base Current value of the query chain + */ + public function resolve(mixed $base = null, array|object $data = []): mixed + { + // resolve arguments to array + $args = $this->arguments?->resolve($data) ?? []; + + // 1st segment, use $data as base + if ($this->position === 0) { + $base = $data; + } + + if (is_array($base) === true) { + return $this->resolveArray($base, $args); + } + + if (is_object($base) === true) { + return $this->resolveObject($base, $args); + } + + // trying to access further segments on a scalar/null value + static::error($base, $this->method, 'method/property'); + } + + /** + * Resolves segment by calling the corresponding array key + */ + protected function resolveArray(array $array, array $args): mixed + { + // the directly provided array takes precedence + // to look up a matching entry + if (array_key_exists($this->method, $array) === true) { + $value = $array[$this->method]; + + // if this is a Closure we can directly use it, as + // Closures from the $array should always have priority + // over the Query::$entries Closures + if ($value instanceof Closure) { + return $value(...$args); + } + + // if we have no arguments to pass, we also can directly + // use the value from the $array as it must not be different + // to the one from Query::$entries with the same name + if ($args === []) { + return $value; + } + } + + // fallback time: only if we are handling the first segment, + // we can also try to resolve the segment with an entry from the + // default Query::$entries + if ($this->position === 0) { + if (array_key_exists($this->method, Query::$entries) === true) { + return Query::$entries[$this->method](...$args); + } + } + + // if we have not been able to return anything so far, + // we just need to differntiate between two different error messages + + // this one is in case the original array contained the key, + // but was not a Closure while the segment had arguments + if ( + array_key_exists($this->method, $array) && + $args !== [] + ) { + throw new InvalidArgumentException('Cannot access array element "' . $this->method . '" with arguments'); + } + + // last, the standard error for trying to access something + // that does not exist + static::error($array, $this->method, 'property'); + } + + /** + * Resolves segment by calling the method/ + * accessing the property on the base object + */ + protected function resolveObject(object $object, array $args): mixed + { + if ( + method_exists($object, $this->method) === true || + method_exists($object, '__call') === true + ) { + return $object->{$this->method}(...$args); + } + + if ( + $args === [] && + ( + property_exists($object, $this->method) === true || + method_exists($object, '__get') === true + ) + ) { + return $object->{$this->method}; + } + + $label = ($args === []) ? 'method/property' : 'method'; + static::error($object, $this->method, $label); + } +} diff --git a/kirby/src/Query/Segments.php b/kirby/src/Query/Segments.php new file mode 100644 index 0000000..d2af470 --- /dev/null +++ b/kirby/src/Query/Segments.php @@ -0,0 +1,100 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Segments extends Collection +{ + public function __construct( + array $data = [], + protected Query|null $parent = null, + ) { + parent::__construct($data); + } + + /** + * Split query string into segments by dot + * but not inside (nested) parens + */ + public static function factory(string $query, Query $parent = null): static + { + $segments = static::parse($query); + $position = 0; + + $segments = A::map( + $segments, + function ($segment) use (&$position) { + // leave connectors as they are + if (in_array($segment, ['.', '?.']) === true) { + return $segment; + } + + // turn all other parts into Segment objects + // and pass their position in the chain (ignoring connectors) + $position++; + return Segment::factory($segment, $position - 1); + } + ); + + return new static($segments, $parent); + } + + /** + * Splits the string of a segment chaing into an + * array of segments as well as conenctors (`.` or `?.`) + * @internal + */ + public static function parse(string $string): array + { + return preg_split( + '/(\??\.)|(\(([^()]+|(?2))*+\))(*SKIP)(*FAIL)/', + trim($string), + flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY + ); + } + + /** + * Resolves the segments chain by looping through + * each segment call to be applied to the value of + * all previous segment calls, returning gracefully at + * `?.` when current value is `null` + */ + public function resolve(array|object $data = []) + { + $value = null; + + foreach ($this->data as $segment) { + // optional chaining: stop if current value is null + if ($segment === '?.' && $value === null) { + return null; + } + + // for regular connectors and optional chaining on non-null, + // just skip this connecting segment + if ($segment === '.' || $segment === '?.') { + continue; + } + + // offer possibility to intercept on objects + if ($value !== null) { + $value = $this->parent?->intercept($value) ?? $value; + } + + $value = $segment->resolve($value, $data); + } + + return $value; + } +} diff --git a/kirby/src/Sane/DomHandler.php b/kirby/src/Sane/DomHandler.php new file mode 100644 index 0000000..2fe2e09 --- /dev/null +++ b/kirby/src/Sane/DomHandler.php @@ -0,0 +1,171 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + * + * @SuppressWarnings(PHPMD.LongVariable) + */ +class DomHandler extends Handler +{ + /** + * List of all MIME types that may + * be used in data URIs + */ + public static array $allowedDataUris = [ + 'data:image/png', + 'data:image/gif', + 'data:image/jpg', + 'data:image/jpe', + 'data:image/pjp', + 'data:img/png', + 'data:img/gif', + 'data:img/jpg', + 'data:img/jpe', + 'data:img/pjp', + ]; + + /** + * Allowed hostnames for HTTP(S) URLs + * + * @var array|true + */ + public static array|bool $allowedDomains = true; + + /** + * Whether URLs that begin with `/` should be allowed even if the + * site index URL is in a subfolder (useful when using the HTML + * `` element where the sanitized code will be rendered) + */ + public static bool $allowHostRelativeUrls = true; + + /** + * Names of allowed XML processing instructions + */ + public static array $allowedPIs = []; + + /** + * The document type (`'HTML'` or `'XML'`) + * (to be set in child classes) + */ + protected static string $type = 'XML'; + + /** + * Sanitizes the given string + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + * + * @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed + */ + public static function sanitize(string $string, bool $isExternal = false): string + { + $dom = static::parse($string); + $dom->sanitize(static::options($isExternal)); + return $dom->toString(); + } + + /** + * Validates file contents + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + * + * @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + */ + public static function validate(string $string, bool $isExternal = false): void + { + $dom = static::parse($string); + $errors = $dom->sanitize(static::options($isExternal)); + + // there may be multiple errors, we can only throw one of them at a time + if (count($errors) > 0) { + throw $errors[0]; + } + } + + /** + * Custom callback for additional attribute sanitization + * @internal + * + * @return array Array with exception objects for each modification + */ + public static function sanitizeAttr(DOMAttr $attr, array $options): array + { + // to be extended in child classes + return []; + } + + /** + * Custom callback for additional element sanitization + * @internal + * + * @return array Array with exception objects for each modification + */ + public static function sanitizeElement(DOMElement $element, array $options): array + { + // to be extended in child classes + return []; + } + + /** + * Custom callback for additional doctype validation + * @internal + */ + public static function validateDoctype(DOMDocumentType $doctype, array $options): void + { + // to be extended in child classes + } + + /** + * Returns the sanitization options for the handler + * (to be extended in child classes) + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + */ + protected static function options(bool $isExternal): array + { + $options = [ + 'allowedDataUris' => static::$allowedDataUris, + 'allowedDomains' => static::$allowedDomains, + 'allowHostRelativeUrls' => static::$allowHostRelativeUrls, + 'allowedPIs' => static::$allowedPIs, + 'attrCallback' => [static::class, 'sanitizeAttr'], + 'doctypeCallback' => [static::class, 'validateDoctype'], + 'elementCallback' => [static::class, 'sanitizeElement'], + ]; + + // never allow host-relative URLs in external files as we + // cannot set a `` element for them when accessed directly + if ($isExternal === true) { + $options['allowHostRelativeUrls'] = false; + } + + return $options; + } + + /** + * Parses the given string into a `Toolkit\Dom` object + * + * @throws \Kirby\Exception\InvalidArgumentException If the file couldn't be parsed + */ + protected static function parse(string $string): Dom + { + return new Dom($string, static::$type); + } +} diff --git a/kirby/src/Sane/Handler.php b/kirby/src/Sane/Handler.php new file mode 100644 index 0000000..7dfcd98 --- /dev/null +++ b/kirby/src/Sane/Handler.php @@ -0,0 +1,84 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class Handler +{ + /** + * Sanitizes the given string + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + */ + abstract public static function sanitize(string $string, bool $isExternal = false): string; + + /** + * Sanitizes the contents of a file by overwriting + * the file with the sanitized version + * + * @throws \Kirby\Exception\Exception If the file does not exist + * @throws \Kirby\Exception\Exception On other errors + */ + public static function sanitizeFile(string $file): void + { + $content = static::readFile($file); + $sanitized = static::sanitize($content, isExternal: true); + F::write($file, $sanitized); + } + + /** + * Validates file contents + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\Exception On other errors + */ + abstract public static function validate(string $string, bool $isExternal = false): void; + + /** + * Validates the contents of a file + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\Exception If the file does not exist + * @throws \Kirby\Exception\Exception On other errors + */ + public static function validateFile(string $file): void + { + $content = static::readFile($file); + static::validate($content, isExternal: true); + } + + /** + * Reads the contents of a file + * for sanitization or validation + * + * @throws \Kirby\Exception\Exception If the file does not exist + */ + protected static function readFile(string $file): string + { + $contents = F::read($file); + + if ($contents === false) { + throw new Exception('The file "' . $file . '" does not exist'); + } + + return $contents; + } +} diff --git a/kirby/src/Sane/Html.php b/kirby/src/Sane/Html.php new file mode 100644 index 0000000..0766e36 --- /dev/null +++ b/kirby/src/Sane/Html.php @@ -0,0 +1,126 @@ +, + * Lukas Bestle + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Html extends DomHandler +{ + /** + * Global list of allowed attribute prefixes + */ + public static array $allowedAttrPrefixes = [ + 'aria-', + 'data-', + ]; + + /** + * Global list of allowed attributes + */ + public static array $allowedAttrs = [ + 'class', + 'id', + ]; + + /** + * Associative array of all allowed tag names with the value + * of either an array with the list of all allowed attributes + * for this tag, `true` to allow any attribute from the + * `allowedAttrs` list or `false` to allow the tag without + * any attributes + */ + public static array $allowedTags = [ + 'a' => ['href', 'rel', 'title', 'target'], + 'abbr' => ['title'], + 'b' => true, + 'body' => true, + 'blockquote' => true, + 'br' => true, + 'code' => true, + 'dl' => true, + 'dd' => true, + 'del' => true, + 'div' => true, + 'dt' => true, + 'em' => true, + 'footer' => true, + 'h1' => true, + 'h2' => true, + 'h3' => true, + 'h4' => true, + 'h5' => true, + 'h6' => true, + 'hr' => true, + 'html' => true, + 'i' => true, + 'ins' => true, + 'li' => true, + 'small' => true, + 'span' => true, + 'strong' => true, + 'sub' => true, + 'sup' => true, + 'ol' => true, + 'p' => true, + 'pre' => true, + 's' => true, + 'u' => true, + 'ul' => true, + ]; + + /** + * Array of explicitly disallowed tags + * + * IMPORTANT: Use lower-case names here because + * of the case-insensitive matching + */ + public static array $disallowedTags = [ + 'iframe', + 'meta', + 'object', + 'script', + 'style', + ]; + + /** + * List of attributes that may contain URLs + */ + public static array $urlAttrs = [ + 'href', + 'src', + 'xlink:href', + ]; + + /** + * The document type (`'HTML'` or `'XML'`) + */ + protected static string $type = 'HTML'; + + /** + * Returns the sanitization options for the handler + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + */ + protected static function options(bool $isExternal): array + { + return array_merge(parent::options($isExternal), [ + 'allowedAttrPrefixes' => static::$allowedAttrPrefixes, + 'allowedAttrs' => static::$allowedAttrs, + 'allowedNamespaces' => [], + 'allowedPIs' => [], + 'allowedTags' => static::$allowedTags, + 'disallowedTags' => static::$disallowedTags, + 'urlAttrs' => static::$urlAttrs, + ]); + } +} diff --git a/kirby/src/Sane/Sane.php b/kirby/src/Sane/Sane.php new file mode 100644 index 0000000..c079e2a --- /dev/null +++ b/kirby/src/Sane/Sane.php @@ -0,0 +1,211 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Sane +{ + /** + * Handler Type Aliases + */ + public static array $aliases = [ + 'application/xml' => 'xml', + 'image/svg' => 'svg', + 'image/svg+xml' => 'svg', + 'text/html' => 'html', + 'text/xml' => 'xml', + ]; + + /** + * All registered handlers + */ + public static array $handlers = [ + 'html' => Html::class, + 'svg' => Svg::class, + 'svgz' => Svgz::class, + 'xml' => Xml::class, + ]; + + /** + * Handler getter + * + * @param bool $lazy If set to `true`, `null` is returned for undefined handlers + * + * @throws \Kirby\Exception\NotFoundException If no handler was found and `$lazy` was set to `false` + */ + public static function handler( + string $type, + bool $lazy = false + ): Handler|null { + // normalize the type + $type = mb_strtolower($type); + + // find a handler or alias + $handler = static::$handlers[$type] ?? null; + + if ($alias = static::$aliases[$type] ?? null) { + $handler ??= static::$handlers[$alias] ?? null; + } + + if (empty($handler) === false && class_exists($handler) === true) { + return new $handler(); + } + + if ($lazy === true) { + return null; + } + + throw new NotFoundException('Missing handler for type: "' . $type . '"'); + } + + /** + * Sanitizes the given string with the specified handler + * @since 3.6.0 + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + */ + public static function sanitize(string $string, string $type, bool $isExternal = false): string + { + return static::handler($type)->sanitize($string, $isExternal); + } + + /** + * Sanitizes the contents of a file by overwriting + * the file with the sanitized version; + * the sane handlers are automatically chosen by + * the extension and MIME type if not specified + * @since 3.6.0 + * + * @param string|bool $typeLazy Explicit handler type string, + * `true` for lazy autodetection or + * `false` for normal autodetection + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\LogicException If more than one handler applies + * @throws \Kirby\Exception\NotFoundException If the handler was not found + * @throws \Kirby\Exception\Exception On other errors + */ + public static function sanitizeFile( + string $file, + string|bool $typeLazy = false + ): void { + if (is_string($typeLazy) === true) { + static::handler($typeLazy)->sanitizeFile($file); + return; + } + + // try to find exactly one matching handler + $handlers = static::handlersForFile($file, $typeLazy === true); + switch (count($handlers)) { + case 0: + // lazy autodetection didn't find a handler + break; + case 1: + $handlers[0]->sanitizeFile($file); + break; + default: + // more than one matching handler; + // sanitizing with all handlers will not leave much in the output + $handlerNames = array_map('get_class', $handlers); + throw new LogicException( + 'Cannot sanitize file as more than one handler applies: ' . + implode(', ', $handlerNames) + ); + } + } + + /** + * Validates file contents with the specified handler + * + * @param bool $isExternal Whether the string is from an external file + * that may be accessed directly + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\NotFoundException If the handler was not found + * @throws \Kirby\Exception\Exception On other errors + */ + public static function validate(string $string, string $type, bool $isExternal = false): void + { + static::handler($type)->validate($string, $isExternal); + } + + /** + * Validates the contents of a file; + * the sane handlers are automatically chosen by + * the extension and MIME type if not specified + * + * @param string|bool $typeLazy Explicit handler type string, + * `true` for lazy autodetection or + * `false` for normal autodetection + * + * @throws \Kirby\Exception\InvalidArgumentException If the file didn't pass validation + * @throws \Kirby\Exception\NotFoundException If the handler was not found + * @throws \Kirby\Exception\Exception On other errors + */ + public static function validateFile( + string $file, + string|bool $typeLazy = false + ): void { + if (is_string($typeLazy) === true) { + static::handler($typeLazy)->validateFile($file); + return; + } + + $handlers = static::handlersForFile($file, $typeLazy === true); + + foreach ($handlers as $handler) { + $handler->validateFile($file); + } + } + + /** + * Returns all handler objects that apply to the given file based on + * file extension and MIME type + * + * @param bool $lazy If set to `true`, undefined handlers are skipped + * @return array<\Kirby\Sane\Handler> + */ + protected static function handlersForFile( + string $file, + bool $lazy = false + ): array { + $handlers = $handlerClasses = []; + + // all values that can be used for the handler search; + // filter out all empty options + $options = array_filter([F::extension($file), F::mime($file)]); + + foreach ($options as $option) { + $handler = static::handler($option, $lazy); + $handlerClass = $handler ? get_class($handler) : null; + + // ensure that each handler class is only returned once + if ( + $handler && + in_array($handlerClass, $handlerClasses) === false + ) { + $handlers[] = $handler; + $handlerClasses[] = $handlerClass; + } + } + + return $handlers; + } +} diff --git a/kirby/src/Sane/Svg.php b/kirby/src/Sane/Svg.php new file mode 100644 index 0000000..1a947ec --- /dev/null +++ b/kirby/src/Sane/Svg.php @@ -0,0 +1,501 @@ +, + * Lukas Bestle + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Svg extends Xml +{ + /** + * Allow and block lists are inspired by DOMPurify + * + * @link https://github.com/cure53/DOMPurify + * @copyright 2015 Mario Heiderich + * @license https://www.apache.org/licenses/LICENSE-2.0 + */ + + /** + * Global list of allowed attribute prefixes + */ + public static array $allowedAttrPrefixes = [ + 'aria-', + 'data-', + ]; + + /** + * Global list of allowed attributes + */ + public static array $allowedAttrs = [ + // core attributes + 'id', + 'lang', + 'tabindex', + 'xml:id', + 'xml:lang', + 'xml:space', + + // styling attributes + 'class', + 'style', + + // conditional processing attributes + 'systemLanguage', + + // presentation attributes + 'alignment-baseline', + 'baseline-shift', + 'clip', + 'clip-path', + 'clip-rule', + 'color', + 'color-interpolation', + 'color-interpolation-filters', + 'color-profile', + 'color-rendering', + 'd', + 'direction', + 'display', + 'dominant-baseline', + 'enable-background', + 'fill', + 'fill-opacity', + 'fill-rule', + 'filter', + 'flood-color', + 'flood-opacity', + 'font-family', + 'font-size', + 'font-size-adjust', + 'font-stretch', + 'font-style', + 'font-variant', + 'font-weight', + 'image-rendering', + 'kerning', + 'letter-spacing', + 'lighting-color', + 'marker-end', + 'marker-mid', + 'marker-start', + 'mask', + 'opacity', + 'overflow', + 'paint-order', + 'shape-rendering', + 'stop-color', + 'stop-opacity', + 'stroke', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-opacity', + 'stroke-width', + 'text-anchor', + 'text-decoration', + 'text-rendering', + 'transform', + 'visibility', + 'word-spacing', + 'writing-mode', + + // animation attribute target attributes + 'attributeName', + 'attributeType', + + // animation timing attributes + 'begin', + 'dur', + 'end', + 'max', + 'min', + 'repeatCount', + 'repeatDur', + 'restart', + + // animation value attributes + 'by', + 'from', + 'keySplines', + 'keyTimes', + 'to', + 'values', + + // animation addition attributes + 'accumulate', + 'additive', + + // filter primitive attributes + 'height', + 'result', + 'width', + 'x', + 'y', + + // transfer function attributes + 'amplitude', + 'exponent', + 'intercept', + 'offset', + 'slope', + 'tableValues', + 'type', + + // other attributes specific to one or multiple elements + 'azimuth', + 'baseFrequency', + 'bias', + 'clipPathUnits', + 'cx', + 'cy', + 'diffuseConstant', + 'divisor', + 'dx', + 'dy', + 'edgeMode', + 'elevation', + 'filterUnits', + 'fr', + 'fx', + 'fy', + 'g1', + 'g2', + 'glyph-name', + 'glyphRef', + 'gradientTransform', + 'gradientUnits', + 'href', + 'hreflang', + 'in', + 'in2', + 'k', + 'k1', + 'k2', + 'k3', + 'k4', + 'kernelMatrix', + 'kernelUnitLength', + 'keyPoints', + 'lengthAdjust', + 'limitingConeAngle', + 'markerHeight', + 'markerUnits', + 'markerWidth', + 'maskContentUnits', + 'maskUnits', + 'media', + 'method', + 'mode', + 'numOctaves', + 'operator', + 'order', + 'orient', + 'orientation', + 'path', + 'pathLength', + 'patternContentUnits', + 'patternTransform', + 'patternUnits', + 'points', + 'pointsAtX', + 'pointsAtY', + 'pointsAtZ', + 'preserveAlpha', + 'preserveAspectRatio', + 'primitiveUnits', + 'r', + 'radius', + 'refX', + 'refY', + 'rotate', + 'rx', + 'ry', + 'scale', + 'seed', + 'side', + 'spacing', + 'specularConstant', + 'specularExponent', + 'spreadMethod', + 'startOffset', + 'stdDeviation', + 'stitchTiles', + 'surfaceScale', + 'targetX', + 'targetY', + 'textLength', + 'u1', + 'u2', + 'unicode', + 'version', + 'vert-adv-y', + 'vert-origin-x', + 'vert-origin-y', + 'viewBox', + 'x1', + 'x2', + 'xChannelSelector', + 'xlink:href', + 'xlink:title', + 'y1', + 'y2', + 'yChannelSelector', + 'z', + 'zoomAndPan', + ]; + + /** + * Allowed hostnames for HTTP(S) URLs + * + * @var array|true + */ + public static array|bool $allowedDomains = []; + + /** + * Associative array of all allowed namespace URIs + */ + public static array $allowedNamespaces = [ + '' => 'http://www.w3.org/2000/svg', + 'xlink' => 'http://www.w3.org/1999/xlink' + ]; + + /** + * Associative array of all allowed tag names with the value + * of either an array with the list of all allowed attributes + * for this tag, `true` to allow any attribute from the + * `allowedAttrs` list or `false` to allow the tag without + * any attributes + */ + public static array $allowedTags = [ + 'a' => true, + 'altGlyph' => true, + 'altGlyphDef' => true, + 'altGlyphItem' => true, + 'animateColor' => true, + 'animateMotion' => true, + 'animateTransform' => true, + 'circle' => true, + 'clipPath' => true, + 'defs' => true, + 'desc' => true, + 'ellipse' => true, + 'feBlend' => true, + 'feColorMatrix' => true, + 'feComponentTransfer' => true, + 'feComposite' => true, + 'feConvolveMatrix' => true, + 'feDiffuseLighting' => true, + 'feDisplacementMap' => true, + 'feDistantLight' => true, + 'feFlood' => true, + 'feFuncA' => true, + 'feFuncB' => true, + 'feFuncG' => true, + 'feFuncR' => true, + 'feGaussianBlur' => true, + 'feMerge' => true, + 'feMergeNode' => true, + 'feMorphology' => true, + 'feOffset' => true, + 'fePointLight' => true, + 'feSpecularLighting' => true, + 'feSpotLight' => true, + 'feTile' => true, + 'feTurbulence' => true, + 'filter' => true, + 'font' => true, + 'g' => true, + 'glyph' => true, + 'glyphRef' => true, + 'hkern' => true, + 'image' => true, + 'line' => true, + 'linearGradient' => true, + 'marker' => true, + 'mask' => true, + 'metadata' => true, + 'mpath' => true, + 'path' => true, + 'pattern' => true, + 'polygon' => true, + 'polyline' => true, + 'radialGradient' => true, + 'rect' => true, + 'stop' => true, + 'style' => true, + 'svg' => true, + 'switch' => true, + 'symbol' => true, + 'text' => true, + 'textPath' => true, + 'title' => true, + 'tref' => true, + 'tspan' => true, + 'use' => true, + 'view' => true, + 'vkern' => true, + ]; + + /** + * Array of explicitly disallowed tags + * + * IMPORTANT: Use lower-case names here because + * of the case-insensitive matching + */ + public static array $disallowedTags = [ + 'animate', + 'color-profile', + 'cursor', + 'discard', + 'fedropshadow', + 'feimage', + 'font-face', + 'font-face-format', + 'font-face-name', + 'font-face-src', + 'font-face-uri', + 'foreignobject', + 'hatch', + 'hatchpath', + 'mesh', + 'meshgradient', + 'meshpatch', + 'meshrow', + 'missing-glyph', + 'script', + 'set', + 'solidcolor', + 'unknown', + ]; + + /** + * Custom callback for additional attribute sanitization + * @internal + * + * @return array Array with exception objects for each modification + */ + public static function sanitizeAttr(DOMAttr $attr, array $options): array + { + $element = $attr->ownerElement; + $name = $attr->name; + $value = $attr->value; + $errors = []; + + // block nested elements ("Billion Laughs" DoS attack) + if ( + $element->localName === 'use' && + Str::contains($name, 'href') !== false && + Str::startsWith($value, '#') === true + ) { + // find the target (used element) + $id = str_replace('"', '', mb_substr($value, 1)); + $path = new DOMXPath($attr->ownerDocument); + $target = $path->query('//*[@id="' . $id . '"]')->item(0); + + // the target must not contain any other elements + if ( + $target instanceof DOMElement && + $target->getElementsByTagName('use')->count() > 0 + ) { + $errors[] = new InvalidArgumentException( + 'Nested "use" elements are not allowed' . + ' (used in line ' . $element->getLineNo() . ')' + ); + $element->removeAttributeNode($attr); + } + } + + return $errors; + } + + /** + * Custom callback for additional element sanitization + * @internal + * + * @return array Array with exception objects for each modification + */ + public static function sanitizeElement(DOMElement $element, array $options): array + { + $errors = []; + + // check for URLs inside + * + * text + */ + public static function css(string $string): string + { + return static::escaper()->escapeCss($string); + } + + /** + * Get the escaper instance (and create if needed) + */ + protected static function escaper(): Escaper + { + return static::$escaper ??= new Escaper('utf-8'); + } + + /** + * Escape HTML element content + * + * This can be used to put untrusted data directly into the HTML body somewhere. + * This includes inside normal tags like div, p, b, td, etc. + * + * Escapes &, <, >, ", and ' with HTML entity encoding to prevent switching + * into any execution context, such as script, style, or event handlers. + * + * ...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE... + *
    ...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...
    + */ + public static function html(string $string): string + { + return static::escaper()->escapeHtml($string); + } + + /** + * Escape JavaScript data values + * + * This can be used to put dynamically generated JavaScript code + * into both script blocks and event-handler attributes. + * + * + * + *
    + */ + public static function js(string $string): string + { + return static::escaper()->escapeJs($string); + } + + /** + * Escape URL parameter values + * + * This can be used to put untrusted data into HTTP GET parameter values. + * This should not be used to escape an entire URI. + * + * link + */ + public static function url(string $string): string + { + return rawurlencode($string); + } + + /** + * Escape XML element content + * + * Removes offending characters that could be wrongfully interpreted as XML markup. + * + * The following characters are reserved in XML and will be replaced with their + * corresponding XML entities: + * + * ' is replaced with ' + * " is replaced with " + * & is replaced with & + * < is replaced with < + * > is replaced with > + */ + public static function xml(string $string): string + { + return htmlspecialchars($string, ENT_QUOTES | ENT_XML1, 'UTF-8'); + } +} diff --git a/kirby/src/Toolkit/Facade.php b/kirby/src/Toolkit/Facade.php new file mode 100644 index 0000000..b1547ec --- /dev/null +++ b/kirby/src/Toolkit/Facade.php @@ -0,0 +1,30 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +abstract class Facade +{ + /** + * Returns the instance that should be + * available statically + */ + abstract public static function instance(); + + /** + * Proxy for all public instance calls + */ + public static function __callStatic(string $method, array $args = null) + { + return static::instance()->$method(...$args); + } +} diff --git a/kirby/src/Toolkit/Html.php b/kirby/src/Toolkit/Html.php new file mode 100644 index 0000000..cacd86c --- /dev/null +++ b/kirby/src/Toolkit/Html.php @@ -0,0 +1,658 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://opensource.org/licenses/MIT + */ +class Html extends Xml +{ + /** + * An internal store for an HTML entities translation table + */ + public static array|null $entities; + + /** + * List of HTML tags that can be used inline + */ + public static array $inlineList = [ + 'b', + 'i', + 'small', + 'abbr', + 'cite', + 'code', + 'dfn', + 'em', + 'kbd', + 'strong', + 'samp', + 'var', + 'a', + 'bdo', + 'br', + 'img', + 'q', + 'span', + 'sub', + 'sup' + ]; + + /** + * Closing string for void tags; + * can be used to switch to trailing slashes if required + * + * ```php + * Html::$void = ' />' + * ``` + * + * @var string + */ + public static $void = '>'; + + /** + * List of HTML tags that are considered to be self-closing + * + * @var array + */ + public static $voidList = [ + 'area', + 'base', + 'br', + 'col', + 'command', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr' + ]; + + /** + * Generic HTML tag generator + * Can be called like `Html::p('A paragraph', ['class' => 'text'])` + * + * @param string $tag Tag name + * @param array $arguments Further arguments for the Html::tag() method + */ + public static function __callStatic( + string $tag, + array $arguments = [] + ): string { + if (static::isVoid($tag) === true) { + return static::tag($tag, null, ...$arguments); + } + + return static::tag($tag, ...$arguments); + } + + /** + * Generates an `` tag; automatically supports mailto: and tel: links + * + * @param string $href The URL for the `` tag + * @param string|array|null $text The optional text; if `null`, the URL will be used as text + * @param array $attr Additional attributes for the tag + * @return string The generated HTML + */ + public static function a(string $href, $text = null, array $attr = []): string + { + if (Str::startsWith($href, 'mailto:')) { + return static::email(substr($href, 7), $text, $attr); + } + + if (Str::startsWith($href, 'tel:')) { + return static::tel(substr($href, 4), $text, $attr); + } + + return static::link($href, $text, $attr); + } + + /** + * Generates a single attribute or a list of attributes + * + * @param string|array $name String: A single attribute with that name will be generated. + * Key-value array: A list of attributes will be generated. Don't pass a second argument in that case. + * @param mixed $value If used with a `$name` string, pass the value of the attribute here. + * If used with a `$name` array, this can be set to `false` to disable attribute sorting. + * @param string|null $before An optional string that will be prepended if the result is not empty + * @param string|null $after An optional string that will be appended if the result is not empty + * @return string|null The generated HTML attributes string + */ + public static function attr( + string|array $name, + $value = null, + string|null $before = null, + string|null $after = null + ): string|null { + // HTML supports boolean attributes without values + if (is_array($name) === false && is_bool($value) === true) { + return $value === true ? strtolower($name) : null; + } + + // HTML attribute names are case-insensitive + if (is_string($name) === true) { + $name = strtolower($name); + } + + // all other cases can share the XML variant + $attr = parent::attr($name, $value); + + if ($attr === null) { + return null; + } + + // HTML supports named entities + $entities = parent::entities(); + $html = array_keys($entities); + $xml = array_values($entities); + $attr = str_replace($xml, $html, $attr); + + if ($attr) { + return $before . $attr . $after; + } + + return null; + } + + /** + * Converts lines in a string into HTML breaks + */ + public static function breaks(string $string): string + { + return nl2br($string); + } + + /** + * Generates an `` tag with `mailto:` + * + * @param string $email The email address + * @param string|array|null $text The optional text; if `null`, the email address will be used as text + * @param array $attr Additional attributes for the tag + * @return string The generated HTML + */ + public static function email( + string $email, + string|array|null $text = null, + array $attr = [] + ): string { + if (empty($email) === true) { + return ''; + } + + if (empty($text) === true) { + // show only the email address without additional parameters + $address = Str::contains($email, '?') ? Str::before($email, '?') : $email; + + $text = [Str::encode($address)]; + } + + $email = Str::encode($email); + $attr = array_merge([ + 'href' => [ + 'value' => 'mailto:' . $email, + 'escape' => false + ] + ], $attr); + + // add rel=noopener to target blank links to improve security + $attr['rel'] = static::rel($attr['rel'] ?? null, $attr['target'] ?? null); + + return static::tag('a', $text, $attr); + } + + /** + * Converts a string to an HTML-safe string + * + * @param bool $keepTags If true, existing tags won't be escaped + * @return string The HTML string + * + * @psalm-suppress ParamNameMismatch + */ + public static function encode( + string|null $string, + bool $keepTags = false + ): string { + if ($string === null) { + return ''; + } + + if ($keepTags === true) { + $list = static::entities(); + unset($list['"'], $list['<'], $list['>'], $list['&']); + + $search = array_keys($list); + $values = array_values($list); + + return str_replace($search, $values, $string); + } + + return htmlentities($string, ENT_QUOTES, 'utf-8'); + } + + /** + * Returns the entity translation table + */ + public static function entities(): array + { + return self::$entities ??= get_html_translation_table(HTML_ENTITIES); + } + + /** + * Creates a `
    ` tag with optional caption + * + * @param string|array $content Contents of the `
    ` tag + * @param string|array $caption Optional `
    ` text to use + * @param array $attr Additional attributes for the `
    ` tag + * @return string The generated HTML + */ + public static function figure( + string|array $content, + string|array|null $caption = '', + array $attr = [] + ): string { + if ($caption) { + $figcaption = static::tag('figcaption', $caption); + + if (is_string($content) === true) { + $content = [static::encode($content, false)]; + } + + $content[] = $figcaption; + } + + return static::tag('figure', $content, $attr); + } + + /** + * Embeds a GitHub Gist + * + * @param string $url Gist URL + * @param string|null $file Optional specific file to embed + * @param array $attr Additional attributes for the ` + + + + + diff --git a/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_details.html.php b/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_details.html.php new file mode 100644 index 0000000..a85e451 --- /dev/null +++ b/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_details.html.php @@ -0,0 +1,2 @@ +render($frame_code) ?> +render($env_details) ?> \ No newline at end of file diff --git a/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_details_outer.html.php b/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_details_outer.html.php new file mode 100644 index 0000000..8162d8c --- /dev/null +++ b/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_details_outer.html.php @@ -0,0 +1,3 @@ +
    + render($panel_details) ?> +
    \ No newline at end of file diff --git a/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_left.html.php b/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_left.html.php new file mode 100644 index 0000000..7e652e4 --- /dev/null +++ b/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_left.html.php @@ -0,0 +1,4 @@ +render($header_outer); +$tpl->render($frames_description); +$tpl->render($frames_container); diff --git a/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_left_outer.html.php b/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_left_outer.html.php new file mode 100644 index 0000000..77b575c --- /dev/null +++ b/kirby/vendor/filp/whoops/src/Whoops/Resources/views/panel_left_outer.html.php @@ -0,0 +1,3 @@ +
    + render($panel_left) ?> +
    \ No newline at end of file diff --git a/kirby/vendor/filp/whoops/src/Whoops/Run.php b/kirby/vendor/filp/whoops/src/Whoops/Run.php new file mode 100644 index 0000000..0862768 --- /dev/null +++ b/kirby/vendor/filp/whoops/src/Whoops/Run.php @@ -0,0 +1,597 @@ + + */ + +namespace Whoops; + +use InvalidArgumentException; +use Throwable; +use Whoops\Exception\ErrorException; +use Whoops\Handler\CallbackHandler; +use Whoops\Handler\Handler; +use Whoops\Handler\HandlerInterface; +use Whoops\Inspector\CallableInspectorFactory; +use Whoops\Inspector\InspectorFactory; +use Whoops\Inspector\InspectorFactoryInterface; +use Whoops\Inspector\InspectorInterface; +use Whoops\Util\Misc; +use Whoops\Util\SystemFacade; + +final class Run implements RunInterface +{ + /** + * @var bool + */ + private $isRegistered; + + /** + * @var bool + */ + private $allowQuit = true; + + /** + * @var bool + */ + private $sendOutput = true; + + /** + * @var integer|false + */ + private $sendHttpCode = 500; + + /** + * @var integer|false + */ + private $sendExitCode = 1; + + /** + * @var HandlerInterface[] + */ + private $handlerStack = []; + + /** + * @var array + * @psalm-var list + */ + private $silencedPatterns = []; + + /** + * @var SystemFacade + */ + private $system; + + /** + * In certain scenarios, like in shutdown handler, we can not throw exceptions. + * + * @var bool + */ + private $canThrowExceptions = true; + + /** + * The inspector factory to create inspectors. + * + * @var InspectorFactoryInterface + */ + private $inspectorFactory; + + /** + * @var array + */ + private $frameFilters = []; + + public function __construct(SystemFacade $system = null) + { + $this->system = $system ?: new SystemFacade; + $this->inspectorFactory = new InspectorFactory(); + } + + /** + * Explicitly request your handler runs as the last of all currently registered handlers. + * + * @param callable|HandlerInterface $handler + * + * @return Run + */ + public function appendHandler($handler) + { + array_unshift($this->handlerStack, $this->resolveHandler($handler)); + return $this; + } + + /** + * Explicitly request your handler runs as the first of all currently registered handlers. + * + * @param callable|HandlerInterface $handler + * + * @return Run + */ + public function prependHandler($handler) + { + return $this->pushHandler($handler); + } + + /** + * Register your handler as the last of all currently registered handlers (to be executed first). + * Prefer using appendHandler and prependHandler for clarity. + * + * @param callable|HandlerInterface $handler + * + * @return Run + * + * @throws InvalidArgumentException If argument is not callable or instance of HandlerInterface. + */ + public function pushHandler($handler) + { + $this->handlerStack[] = $this->resolveHandler($handler); + return $this; + } + + /** + * Removes and returns the last handler pushed to the handler stack. + * + * @see Run::removeFirstHandler(), Run::removeLastHandler() + * + * @return HandlerInterface|null + */ + public function popHandler() + { + return array_pop($this->handlerStack); + } + + /** + * Removes the first handler. + * + * @return void + */ + public function removeFirstHandler() + { + array_pop($this->handlerStack); + } + + /** + * Removes the last handler. + * + * @return void + */ + public function removeLastHandler() + { + array_shift($this->handlerStack); + } + + /** + * Returns an array with all handlers, in the order they were added to the stack. + * + * @return array + */ + public function getHandlers() + { + return $this->handlerStack; + } + + /** + * Clears all handlers in the handlerStack, including the default PrettyPage handler. + * + * @return Run + */ + public function clearHandlers() + { + $this->handlerStack = []; + return $this; + } + + public function getFrameFilters() + { + return $this->frameFilters; + } + + public function clearFrameFilters() + { + $this->frameFilters = []; + return $this; + } + + /** + * Registers this instance as an error handler. + * + * @return Run + */ + public function register() + { + if (!$this->isRegistered) { + // Workaround PHP bug 42098 + // https://bugs.php.net/bug.php?id=42098 + class_exists("\\Whoops\\Exception\\ErrorException"); + class_exists("\\Whoops\\Exception\\FrameCollection"); + class_exists("\\Whoops\\Exception\\Frame"); + class_exists("\\Whoops\\Exception\\Inspector"); + class_exists("\\Whoops\\Inspector\\InspectorFactory"); + + $this->system->setErrorHandler([$this, self::ERROR_HANDLER]); + $this->system->setExceptionHandler([$this, self::EXCEPTION_HANDLER]); + $this->system->registerShutdownFunction([$this, self::SHUTDOWN_HANDLER]); + + $this->isRegistered = true; + } + + return $this; + } + + /** + * Unregisters all handlers registered by this Whoops\Run instance. + * + * @return Run + */ + public function unregister() + { + if ($this->isRegistered) { + $this->system->restoreExceptionHandler(); + $this->system->restoreErrorHandler(); + + $this->isRegistered = false; + } + + return $this; + } + + /** + * Should Whoops allow Handlers to force the script to quit? + * + * @param bool|int $exit + * + * @return bool + */ + public function allowQuit($exit = null) + { + if (func_num_args() == 0) { + return $this->allowQuit; + } + + return $this->allowQuit = (bool) $exit; + } + + /** + * Silence particular errors in particular files. + * + * @param array|string $patterns List or a single regex pattern to match. + * @param int $levels Defaults to E_STRICT | E_DEPRECATED. + * + * @return Run + */ + public function silenceErrorsInPaths($patterns, $levels = 10240) + { + $this->silencedPatterns = array_merge( + $this->silencedPatterns, + array_map( + function ($pattern) use ($levels) { + return [ + "pattern" => $pattern, + "levels" => $levels, + ]; + }, + (array) $patterns + ) + ); + + return $this; + } + + /** + * Returns an array with silent errors in path configuration. + * + * @return array + */ + public function getSilenceErrorsInPaths() + { + return $this->silencedPatterns; + } + + /** + * Should Whoops send HTTP error code to the browser if possible? + * Whoops will by default send HTTP code 500, but you may wish to + * use 502, 503, or another 5xx family code. + * + * @param bool|int $code + * + * @return int|false + * + * @throws InvalidArgumentException + */ + public function sendHttpCode($code = null) + { + if (func_num_args() == 0) { + return $this->sendHttpCode; + } + + if (!$code) { + return $this->sendHttpCode = false; + } + + if ($code === true) { + $code = 500; + } + + if ($code < 400 || 600 <= $code) { + throw new InvalidArgumentException( + "Invalid status code '$code', must be 4xx or 5xx" + ); + } + + return $this->sendHttpCode = $code; + } + + /** + * Should Whoops exit with a specific code on the CLI if possible? + * Whoops will exit with 1 by default, but you can specify something else. + * + * @param int $code + * + * @return int + * + * @throws InvalidArgumentException + */ + public function sendExitCode($code = null) + { + if (func_num_args() == 0) { + return $this->sendExitCode; + } + + if ($code < 0 || 255 <= $code) { + throw new InvalidArgumentException( + "Invalid status code '$code', must be between 0 and 254" + ); + } + + return $this->sendExitCode = (int) $code; + } + + /** + * Should Whoops push output directly to the client? + * If this is false, output will be returned by handleException. + * + * @param bool|int $send + * + * @return bool + */ + public function writeToOutput($send = null) + { + if (func_num_args() == 0) { + return $this->sendOutput; + } + + return $this->sendOutput = (bool) $send; + } + + /** + * Handles an exception, ultimately generating a Whoops error page. + * + * @param Throwable $exception + * + * @return string Output generated by handlers. + */ + public function handleException($exception) + { + // Walk the registered handlers in the reverse order + // they were registered, and pass off the exception + $inspector = $this->getInspector($exception); + + // Capture output produced while handling the exception, + // we might want to send it straight away to the client, + // or return it silently. + $this->system->startOutputBuffering(); + + // Just in case there are no handlers: + $handlerResponse = null; + $handlerContentType = null; + + try { + foreach (array_reverse($this->handlerStack) as $handler) { + $handler->setRun($this); + $handler->setInspector($inspector); + $handler->setException($exception); + + // The HandlerInterface does not require an Exception passed to handle() + // and neither of our bundled handlers use it. + // However, 3rd party handlers may have already relied on this parameter, + // and removing it would be possibly breaking for users. + $handlerResponse = $handler->handle($exception); + + // Collect the content type for possible sending in the headers. + $handlerContentType = method_exists($handler, 'contentType') ? $handler->contentType() : null; + + if (in_array($handlerResponse, [Handler::LAST_HANDLER, Handler::QUIT])) { + // The Handler has handled the exception in some way, and + // wishes to quit execution (Handler::QUIT), or skip any + // other handlers (Handler::LAST_HANDLER). If $this->allowQuit + // is false, Handler::QUIT behaves like Handler::LAST_HANDLER + break; + } + } + + $willQuit = $handlerResponse == Handler::QUIT && $this->allowQuit(); + } finally { + $output = $this->system->cleanOutputBuffer(); + } + + // If we're allowed to, send output generated by handlers directly + // to the output, otherwise, and if the script doesn't quit, return + // it so that it may be used by the caller + if ($this->writeToOutput()) { + // @todo Might be able to clean this up a bit better + if ($willQuit) { + // Cleanup all other output buffers before sending our output: + while ($this->system->getOutputBufferLevel() > 0) { + $this->system->endOutputBuffering(); + } + + // Send any headers if needed: + if (Misc::canSendHeaders() && $handlerContentType) { + header("Content-Type: {$handlerContentType}"); + } + } + + $this->writeToOutputNow($output); + } + + if ($willQuit) { + // HHVM fix for https://github.com/facebook/hhvm/issues/4055 + $this->system->flushOutputBuffer(); + + $this->system->stopExecution( + $this->sendExitCode() + ); + } + + return $output; + } + + /** + * Converts generic PHP errors to \ErrorException instances, before passing them off to be handled. + * + * This method MUST be compatible with set_error_handler. + * + * @param int $level + * @param string $message + * @param string|null $file + * @param int|null $line + * + * @return bool + * + * @throws ErrorException + */ + public function handleError($level, $message, $file = null, $line = null) + { + if ($level & $this->system->getErrorReportingLevel()) { + foreach ($this->silencedPatterns as $entry) { + $pathMatches = (bool) preg_match($entry["pattern"], $file); + $levelMatches = $level & $entry["levels"]; + if ($pathMatches && $levelMatches) { + // Ignore the error, abort handling + // See https://github.com/filp/whoops/issues/418 + return true; + } + } + + // XXX we pass $level for the "code" param only for BC reasons. + // see https://github.com/filp/whoops/issues/267 + $exception = new ErrorException($message, /*code*/ $level, /*severity*/ $level, $file, $line); + if ($this->canThrowExceptions) { + throw $exception; + } else { + $this->handleException($exception); + } + // Do not propagate errors which were already handled by Whoops. + return true; + } + + // Propagate error to the next handler, allows error_get_last() to + // work on silenced errors. + return false; + } + + /** + * Special case to deal with Fatal errors and the like. + * + * @return void + */ + public function handleShutdown() + { + // If we reached this step, we are in shutdown handler. + // An exception thrown in a shutdown handler will not be propagated + // to the exception handler. Pass that information along. + $this->canThrowExceptions = false; + + $error = $this->system->getLastError(); + if ($error && Misc::isLevelFatal($error['type'])) { + // If there was a fatal error, + // it was not handled in handleError yet. + $this->allowQuit = false; + $this->handleError( + $error['type'], + $error['message'], + $error['file'], + $error['line'] + ); + } + } + + + /** + * @param InspectorFactoryInterface $factory + * + * @return void + */ + public function setInspectorFactory(InspectorFactoryInterface $factory) + { + $this->inspectorFactory = $factory; + } + + public function addFrameFilter($filterCallback) + { + if (!is_callable($filterCallback)) { + throw new \InvalidArgumentException(sprintf( + "A frame filter must be of type callable, %s type given.", + gettype($filterCallback) + )); + } + + $this->frameFilters[] = $filterCallback; + return $this; + } + + /** + * @param Throwable $exception + * + * @return InspectorInterface + */ + private function getInspector($exception) + { + return $this->inspectorFactory->create($exception); + } + + /** + * Resolves the giving handler. + * + * @param callable|HandlerInterface $handler + * + * @return HandlerInterface + * + * @throws InvalidArgumentException + */ + private function resolveHandler($handler) + { + if (is_callable($handler)) { + $handler = new CallbackHandler($handler); + } + + if (!$handler instanceof HandlerInterface) { + throw new InvalidArgumentException( + "Handler must be a callable, or instance of " + . "Whoops\\Handler\\HandlerInterface" + ); + } + + return $handler; + } + + /** + * Echo something to the browser. + * + * @param string $output + * + * @return Run + */ + private function writeToOutputNow($output) + { + if ($this->sendHttpCode() && Misc::canSendHeaders()) { + $this->system->setHttpResponseCode( + $this->sendHttpCode() + ); + } + + echo $output; + + return $this; + } +} diff --git a/kirby/vendor/filp/whoops/src/Whoops/RunInterface.php b/kirby/vendor/filp/whoops/src/Whoops/RunInterface.php new file mode 100644 index 0000000..0ef3e3f --- /dev/null +++ b/kirby/vendor/filp/whoops/src/Whoops/RunInterface.php @@ -0,0 +1,158 @@ + + */ + +namespace Whoops; + +use InvalidArgumentException; +use Whoops\Exception\ErrorException; +use Whoops\Handler\HandlerInterface; + +interface RunInterface +{ + const EXCEPTION_HANDLER = "handleException"; + const ERROR_HANDLER = "handleError"; + const SHUTDOWN_HANDLER = "handleShutdown"; + + /** + * Pushes a handler to the end of the stack + * + * @throws InvalidArgumentException If argument is not callable or instance of HandlerInterface + * @param Callable|HandlerInterface $handler + * @return Run + */ + public function pushHandler($handler); + + /** + * Removes the last handler in the stack and returns it. + * Returns null if there"s nothing else to pop. + * + * @return null|HandlerInterface + */ + public function popHandler(); + + /** + * Returns an array with all handlers, in the + * order they were added to the stack. + * + * @return array + */ + public function getHandlers(); + + /** + * Clears all handlers in the handlerStack, including + * the default PrettyPage handler. + * + * @return Run + */ + public function clearHandlers(); + + /** + * @return array + */ + public function getFrameFilters(); + + /** + * @return Run + */ + public function clearFrameFilters(); + + /** + * Registers this instance as an error handler. + * + * @return Run + */ + public function register(); + + /** + * Unregisters all handlers registered by this Whoops\Run instance + * + * @return Run + */ + public function unregister(); + + /** + * Should Whoops allow Handlers to force the script to quit? + * + * @param bool|int $exit + * @return bool + */ + public function allowQuit($exit = null); + + /** + * Silence particular errors in particular files + * + * @param array|string $patterns List or a single regex pattern to match + * @param int $levels Defaults to E_STRICT | E_DEPRECATED + * @return \Whoops\Run + */ + public function silenceErrorsInPaths($patterns, $levels = 10240); + + /** + * Should Whoops send HTTP error code to the browser if possible? + * Whoops will by default send HTTP code 500, but you may wish to + * use 502, 503, or another 5xx family code. + * + * @param bool|int $code + * @return int|false + */ + public function sendHttpCode($code = null); + + /** + * Should Whoops exit with a specific code on the CLI if possible? + * Whoops will exit with 1 by default, but you can specify something else. + * + * @param int $code + * @return int + */ + public function sendExitCode($code = null); + + /** + * Should Whoops push output directly to the client? + * If this is false, output will be returned by handleException + * + * @param bool|int $send + * @return bool + */ + public function writeToOutput($send = null); + + /** + * Handles an exception, ultimately generating a Whoops error + * page. + * + * @param \Throwable $exception + * @return string Output generated by handlers + */ + public function handleException($exception); + + /** + * Converts generic PHP errors to \ErrorException + * instances, before passing them off to be handled. + * + * This method MUST be compatible with set_error_handler. + * + * @param int $level + * @param string $message + * @param string $file + * @param int $line + * + * @return bool + * @throws ErrorException + */ + public function handleError($level, $message, $file = null, $line = null); + + /** + * Special case to deal with Fatal errors and the like. + */ + public function handleShutdown(); + + /** + * Registers a filter callback in the frame filters stack. + * + * @param callable $filterCallback + * @return \Whoops\Run + */ + public function addFrameFilter($filterCallback); +} diff --git a/kirby/vendor/filp/whoops/src/Whoops/Util/HtmlDumperOutput.php b/kirby/vendor/filp/whoops/src/Whoops/Util/HtmlDumperOutput.php new file mode 100644 index 0000000..8c828fd --- /dev/null +++ b/kirby/vendor/filp/whoops/src/Whoops/Util/HtmlDumperOutput.php @@ -0,0 +1,36 @@ + + */ + +namespace Whoops\Util; + +/** + * Used as output callable for Symfony\Component\VarDumper\Dumper\HtmlDumper::dump() + * + * @see TemplateHelper::dump() + */ +class HtmlDumperOutput +{ + private $output; + + public function __invoke($line, $depth) + { + // A negative depth means "end of dump" + if ($depth >= 0) { + // Adds a two spaces indentation to the line + $this->output .= str_repeat(' ', $depth) . $line . "\n"; + } + } + + public function getOutput() + { + return $this->output; + } + + public function clear() + { + $this->output = null; + } +} diff --git a/kirby/vendor/filp/whoops/src/Whoops/Util/Misc.php b/kirby/vendor/filp/whoops/src/Whoops/Util/Misc.php new file mode 100644 index 0000000..001a687 --- /dev/null +++ b/kirby/vendor/filp/whoops/src/Whoops/Util/Misc.php @@ -0,0 +1,77 @@ + + */ + +namespace Whoops\Util; + +class Misc +{ + /** + * Can we at this point in time send HTTP headers? + * + * Currently this checks if we are even serving an HTTP request, + * as opposed to running from a command line. + * + * If we are serving an HTTP request, we check if it's not too late. + * + * @return bool + */ + public static function canSendHeaders() + { + return isset($_SERVER["REQUEST_URI"]) && !headers_sent(); + } + + public static function isAjaxRequest() + { + return ( + !empty($_SERVER['HTTP_X_REQUESTED_WITH']) + && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest'); + } + + /** + * Check, if possible, that this execution was triggered by a command line. + * @return bool + */ + public static function isCommandLine() + { + return PHP_SAPI == 'cli'; + } + + /** + * Translate ErrorException code into the represented constant. + * + * @param int $error_code + * @return string + */ + public static function translateErrorCode($error_code) + { + $constants = get_defined_constants(true); + if (array_key_exists('Core', $constants)) { + foreach ($constants['Core'] as $constant => $value) { + if (substr($constant, 0, 2) == 'E_' && $value == $error_code) { + return $constant; + } + } + } + return "E_UNKNOWN"; + } + + /** + * Determine if an error level is fatal (halts execution) + * + * @param int $level + * @return bool + */ + public static function isLevelFatal($level) + { + $errors = E_ERROR; + $errors |= E_PARSE; + $errors |= E_CORE_ERROR; + $errors |= E_CORE_WARNING; + $errors |= E_COMPILE_ERROR; + $errors |= E_COMPILE_WARNING; + return ($level & $errors) > 0; + } +} diff --git a/kirby/vendor/filp/whoops/src/Whoops/Util/SystemFacade.php b/kirby/vendor/filp/whoops/src/Whoops/Util/SystemFacade.php new file mode 100644 index 0000000..9eb0acf --- /dev/null +++ b/kirby/vendor/filp/whoops/src/Whoops/Util/SystemFacade.php @@ -0,0 +1,144 @@ + + */ + +namespace Whoops\Util; + +class SystemFacade +{ + /** + * Turns on output buffering. + * + * @return bool + */ + public function startOutputBuffering() + { + return ob_start(); + } + + /** + * @param callable $handler + * @param int $types + * + * @return callable|null + */ + public function setErrorHandler(callable $handler, $types = 'use-php-defaults') + { + // Since PHP 5.4 the constant E_ALL contains all errors (even E_STRICT) + if ($types === 'use-php-defaults') { + $types = E_ALL; + } + return set_error_handler($handler, $types); + } + + /** + * @param callable $handler + * + * @return callable|null + */ + public function setExceptionHandler(callable $handler) + { + return set_exception_handler($handler); + } + + /** + * @return void + */ + public function restoreExceptionHandler() + { + restore_exception_handler(); + } + + /** + * @return void + */ + public function restoreErrorHandler() + { + restore_error_handler(); + } + + /** + * @param callable $function + * + * @return void + */ + public function registerShutdownFunction(callable $function) + { + register_shutdown_function($function); + } + + /** + * @return string|false + */ + public function cleanOutputBuffer() + { + return ob_get_clean(); + } + + /** + * @return int + */ + public function getOutputBufferLevel() + { + return ob_get_level(); + } + + /** + * @return bool + */ + public function endOutputBuffering() + { + return ob_end_clean(); + } + + /** + * @return void + */ + public function flushOutputBuffer() + { + flush(); + } + + /** + * @return int + */ + public function getErrorReportingLevel() + { + return error_reporting(); + } + + /** + * @return array|null + */ + public function getLastError() + { + return error_get_last(); + } + + /** + * @param int $httpCode + * + * @return int + */ + public function setHttpResponseCode($httpCode) + { + if (!headers_sent()) { + // Ensure that no 'location' header is present as otherwise this + // will override the HTTP code being set here, and mask the + // expected error page. + header_remove('location'); + } + + return http_response_code($httpCode); + } + + /** + * @param int $exitStatus + */ + public function stopExecution($exitStatus) + { + exit($exitStatus); + } +} diff --git a/kirby/vendor/filp/whoops/src/Whoops/Util/TemplateHelper.php b/kirby/vendor/filp/whoops/src/Whoops/Util/TemplateHelper.php new file mode 100644 index 0000000..8e4df32 --- /dev/null +++ b/kirby/vendor/filp/whoops/src/Whoops/Util/TemplateHelper.php @@ -0,0 +1,349 @@ + + */ + +namespace Whoops\Util; + +use Symfony\Component\VarDumper\Caster\Caster; +use Symfony\Component\VarDumper\Cloner\AbstractCloner; +use Symfony\Component\VarDumper\Cloner\VarCloner; +use Symfony\Component\VarDumper\Dumper\HtmlDumper; +use Whoops\Exception\Frame; + +/** + * Exposes useful tools for working with/in templates + */ +class TemplateHelper +{ + /** + * An array of variables to be passed to all templates + * @var array + */ + private $variables = []; + + /** + * @var HtmlDumper + */ + private $htmlDumper; + + /** + * @var HtmlDumperOutput + */ + private $htmlDumperOutput; + + /** + * @var AbstractCloner + */ + private $cloner; + + /** + * @var string + */ + private $applicationRootPath; + + public function __construct() + { + // root path for ordinary composer projects + $this->applicationRootPath = dirname(dirname(dirname(dirname(dirname(dirname(__DIR__)))))); + } + + /** + * Escapes a string for output in an HTML document + * + * @param string $raw + * @return string + */ + public function escape($raw) + { + $flags = ENT_QUOTES; + + // HHVM has all constants defined, but only ENT_IGNORE + // works at the moment + if (defined("ENT_SUBSTITUTE") && !defined("HHVM_VERSION")) { + $flags |= ENT_SUBSTITUTE; + } else { + // This is for 5.3. + // The documentation warns of a potential security issue, + // but it seems it does not apply in our case, because + // we do not blacklist anything anywhere. + $flags |= ENT_IGNORE; + } + + $raw = str_replace(chr(9), ' ', $raw); + + return htmlspecialchars($raw, $flags, "UTF-8"); + } + + /** + * Escapes a string for output in an HTML document, but preserves + * URIs within it, and converts them to clickable anchor elements. + * + * @param string $raw + * @return string + */ + public function escapeButPreserveUris($raw) + { + $escaped = $this->escape($raw); + return preg_replace( + "@([A-z]+?://([-\w\.]+[-\w])+(:\d+)?(/([\w/_\.#-]*(\?\S+)?[^\.\s])?)?)@", + "
    $1", + $escaped + ); + } + + /** + * Makes sure that the given string breaks on the delimiter. + * + * @param string $delimiter + * @param string $s + * @return string + */ + public function breakOnDelimiter($delimiter, $s) + { + $parts = explode($delimiter, $s); + foreach ($parts as &$part) { + $part = '' . $part . ''; + } + + return implode($delimiter, $parts); + } + + /** + * Replace the part of the path that all files have in common. + * + * @param string $path + * @return string + */ + public function shorten($path) + { + if ($this->applicationRootPath != "/") { + $path = str_replace($this->applicationRootPath, '…', $path); + } + + return $path; + } + + private function getDumper() + { + if (!$this->htmlDumper && class_exists('Symfony\Component\VarDumper\Cloner\VarCloner')) { + $this->htmlDumperOutput = new HtmlDumperOutput(); + // re-use the same var-dumper instance, so it won't re-render the global styles/scripts on each dump. + $this->htmlDumper = new HtmlDumper($this->htmlDumperOutput); + + $styles = [ + 'default' => 'color:#FFFFFF; line-height:normal; font:12px "Inconsolata", "Fira Mono", "Source Code Pro", Monaco, Consolas, "Lucida Console", monospace !important; word-wrap: break-word; white-space: pre-wrap; position:relative; z-index:99999; word-break: normal', + 'num' => 'color:#BCD42A', + 'const' => 'color: #4bb1b1;', + 'str' => 'color:#BCD42A', + 'note' => 'color:#ef7c61', + 'ref' => 'color:#A0A0A0', + 'public' => 'color:#FFFFFF', + 'protected' => 'color:#FFFFFF', + 'private' => 'color:#FFFFFF', + 'meta' => 'color:#FFFFFF', + 'key' => 'color:#BCD42A', + 'index' => 'color:#ef7c61', + ]; + $this->htmlDumper->setStyles($styles); + } + + return $this->htmlDumper; + } + + /** + * Format the given value into a human readable string. + * + * @param mixed $value + * @return string + */ + public function dump($value) + { + $dumper = $this->getDumper(); + + if ($dumper) { + // re-use the same DumpOutput instance, so it won't re-render the global styles/scripts on each dump. + // exclude verbose information (e.g. exception stack traces) + if (class_exists('Symfony\Component\VarDumper\Caster\Caster')) { + $cloneVar = $this->getCloner()->cloneVar($value, Caster::EXCLUDE_VERBOSE); + // Symfony VarDumper 2.6 Caster class dont exist. + } else { + $cloneVar = $this->getCloner()->cloneVar($value); + } + + $dumper->dump( + $cloneVar, + $this->htmlDumperOutput + ); + + $output = $this->htmlDumperOutput->getOutput(); + $this->htmlDumperOutput->clear(); + + return $output; + } + + return htmlspecialchars(print_r($value, true)); + } + + /** + * Format the args of the given Frame as a human readable html string + * + * @param Frame $frame + * @return string the rendered html + */ + public function dumpArgs(Frame $frame) + { + // we support frame args only when the optional dumper is available + if (!$this->getDumper()) { + return ''; + } + + $html = ''; + $numFrames = count($frame->getArgs()); + + if ($numFrames > 0) { + $html = '
      '; + foreach ($frame->getArgs() as $j => $frameArg) { + $html .= '
    1. '. $this->dump($frameArg) .'
    2. '; + } + $html .= '
    '; + } + + return $html; + } + + /** + * Convert a string to a slug version of itself + * + * @param string $original + * @return string + */ + public function slug($original) + { + $slug = str_replace(" ", "-", $original); + $slug = preg_replace('/[^\w\d\-\_]/i', '', $slug); + return strtolower($slug); + } + + /** + * Given a template path, render it within its own scope. This + * method also accepts an array of additional variables to be + * passed to the template. + * + * @param string $template + */ + public function render($template, array $additionalVariables = null) + { + $variables = $this->getVariables(); + + // Pass the helper to the template: + $variables["tpl"] = $this; + + if ($additionalVariables !== null) { + $variables = array_replace($variables, $additionalVariables); + } + + call_user_func(function () { + extract(func_get_arg(1)); + require func_get_arg(0); + }, $template, $variables); + } + + /** + * Sets the variables to be passed to all templates rendered + * by this template helper. + */ + public function setVariables(array $variables) + { + $this->variables = $variables; + } + + /** + * Sets a single template variable, by its name: + * + * @param string $variableName + * @param mixed $variableValue + */ + public function setVariable($variableName, $variableValue) + { + $this->variables[$variableName] = $variableValue; + } + + /** + * Gets a single template variable, by its name, or + * $defaultValue if the variable does not exist + * + * @param string $variableName + * @param mixed $defaultValue + * @return mixed + */ + public function getVariable($variableName, $defaultValue = null) + { + return isset($this->variables[$variableName]) ? + $this->variables[$variableName] : $defaultValue; + } + + /** + * Unsets a single template variable, by its name + * + * @param string $variableName + */ + public function delVariable($variableName) + { + unset($this->variables[$variableName]); + } + + /** + * Returns all variables for this helper + * + * @return array + */ + public function getVariables() + { + return $this->variables; + } + + /** + * Set the cloner used for dumping variables. + * + * @param AbstractCloner $cloner + */ + public function setCloner($cloner) + { + $this->cloner = $cloner; + } + + /** + * Get the cloner used for dumping variables. + * + * @return AbstractCloner + */ + public function getCloner() + { + if (!$this->cloner) { + $this->cloner = new VarCloner(); + } + return $this->cloner; + } + + /** + * Set the application root path. + * + * @param string $applicationRootPath + */ + public function setApplicationRootPath($applicationRootPath) + { + $this->applicationRootPath = $applicationRootPath; + } + + /** + * Return the application root path. + * + * @return string + */ + public function getApplicationRootPath() + { + return $this->applicationRootPath; + } +} diff --git a/kirby/vendor/laminas/laminas-escaper/COPYRIGHT.md b/kirby/vendor/laminas/laminas-escaper/COPYRIGHT.md new file mode 100644 index 0000000..0a8cccc --- /dev/null +++ b/kirby/vendor/laminas/laminas-escaper/COPYRIGHT.md @@ -0,0 +1 @@ +Copyright (c) 2020 Laminas Project a Series of LF Projects, LLC. (https://getlaminas.org/) diff --git a/kirby/vendor/laminas/laminas-escaper/LICENSE.md b/kirby/vendor/laminas/laminas-escaper/LICENSE.md new file mode 100644 index 0000000..10b40f1 --- /dev/null +++ b/kirby/vendor/laminas/laminas-escaper/LICENSE.md @@ -0,0 +1,26 @@ +Copyright (c) 2020 Laminas Project a Series of LF Projects, LLC. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of Laminas Foundation nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/kirby/vendor/laminas/laminas-escaper/composer.json b/kirby/vendor/laminas/laminas-escaper/composer.json new file mode 100644 index 0000000..16cf063 --- /dev/null +++ b/kirby/vendor/laminas/laminas-escaper/composer.json @@ -0,0 +1,68 @@ +{ + "name": "laminas/laminas-escaper", + "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", + "license": "BSD-3-Clause", + "keywords": [ + "laminas", + "escaper" + ], + "homepage": "https://laminas.dev", + "support": { + "docs": "https://docs.laminas.dev/laminas-escaper/", + "issues": "https://github.com/laminas/laminas-escaper/issues", + "source": "https://github.com/laminas/laminas-escaper", + "rss": "https://github.com/laminas/laminas-escaper/releases.atom", + "chat": "https://laminas.dev/chat", + "forum": "https://discourse.laminas.dev" + }, + "config": { + "sort-packages": true, + "platform": { + "php": "8.1.99" + }, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "composer/package-versions-deprecated": true, + "infection/extension-installer": true + } + }, + "extra": { + }, + "require": { + "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "ext-ctype": "*", + "ext-mbstring": "*" + }, + "require-dev": { + "infection/infection": "^0.27.0", + "laminas/laminas-coding-standard": "~2.5.0", + "maglnet/composer-require-checker": "^3.8.0", + "phpunit/phpunit": "^9.6.7", + "psalm/plugin-phpunit": "^0.18.4", + "vimeo/psalm": "^5.9" + }, + "autoload": { + "psr-4": { + "Laminas\\Escaper\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "LaminasTest\\Escaper\\": "test/" + } + }, + "scripts": { + "check": [ + "@cs-check", + "@test" + ], + "cs-check": "phpcs", + "cs-fix": "phpcbf", + "static-analysis": "psalm --shepherd --stats", + "test": "phpunit --colors=always", + "test-coverage": "phpunit --colors=always --coverage-clover clover.xml" + }, + "conflict": { + "zendframework/zend-escaper": "*" + } +} diff --git a/kirby/vendor/laminas/laminas-escaper/src/Escaper.php b/kirby/vendor/laminas/laminas-escaper/src/Escaper.php new file mode 100644 index 0000000..c4964cb --- /dev/null +++ b/kirby/vendor/laminas/laminas-escaper/src/Escaper.php @@ -0,0 +1,424 @@ + + */ + protected static $htmlNamedEntityMap = [ + 34 => 'quot', // quotation mark + 38 => 'amp', // ampersand + 60 => 'lt', // less-than sign + 62 => 'gt', // greater-than sign + ]; + + /** + * Current encoding for escaping. If not UTF-8, we convert strings from this encoding + * pre-escaping and back to this encoding post-escaping. + * + * @var string + */ + protected $encoding = 'utf-8'; + + /** + * Holds the value of the special flags passed as second parameter to + * htmlspecialchars(). + * + * @var int + */ + protected $htmlSpecialCharsFlags; + + /** + * Static Matcher which escapes characters for HTML Attribute contexts + * + * @var callable + * @psalm-var callable(array):string + */ + protected $htmlAttrMatcher; + + /** + * Static Matcher which escapes characters for Javascript contexts + * + * @var callable + * @psalm-var callable(array):string + */ + protected $jsMatcher; + + /** + * Static Matcher which escapes characters for CSS Attribute contexts + * + * @var callable + * @psalm-var callable(array):string + */ + protected $cssMatcher; + + /** + * List of all encoding supported by this class + * + * @var array + */ + protected $supportedEncodings = [ + 'iso-8859-1', + 'iso8859-1', + 'iso-8859-5', + 'iso8859-5', + 'iso-8859-15', + 'iso8859-15', + 'utf-8', + 'cp866', + 'ibm866', + '866', + 'cp1251', + 'windows-1251', + 'win-1251', + '1251', + 'cp1252', + 'windows-1252', + '1252', + 'koi8-r', + 'koi8-ru', + 'koi8r', + 'big5', + '950', + 'gb2312', + '936', + 'big5-hkscs', + 'shift_jis', + 'sjis', + 'sjis-win', + 'cp932', + '932', + 'euc-jp', + 'eucjp', + 'eucjp-win', + 'macroman', + ]; + + /** + * Constructor: Single parameter allows setting of global encoding for use by + * the current object. + * + * @throws Exception\InvalidArgumentException + */ + public function __construct(?string $encoding = null) + { + if ($encoding !== null) { + if ($encoding === '') { + throw new Exception\InvalidArgumentException( + static::class . ' constructor parameter does not allow a blank value' + ); + } + + $encoding = strtolower($encoding); + if (! in_array($encoding, $this->supportedEncodings)) { + throw new Exception\InvalidArgumentException( + 'Value of \'' . $encoding . '\' passed to ' . static::class + . ' constructor parameter is invalid. Provide an encoding supported by htmlspecialchars()' + ); + } + + $this->encoding = $encoding; + } + + // We take advantage of ENT_SUBSTITUTE flag to correctly deal with invalid UTF-8 sequences. + $this->htmlSpecialCharsFlags = ENT_QUOTES | ENT_SUBSTITUTE; + + // set matcher callbacks + $this->htmlAttrMatcher = + /** @param array $matches */ + function (array $matches): string { + return $this->htmlAttrMatcher($matches); + }; + $this->jsMatcher = + /** @param array $matches */ + function (array $matches): string { + return $this->jsMatcher($matches); + }; + $this->cssMatcher = + /** @param array $matches */ + function (array $matches): string { + return $this->cssMatcher($matches); + }; + } + + /** + * Return the encoding that all output/input is expected to be encoded in. + * + * @return string + */ + public function getEncoding() + { + return $this->encoding; + } + + /** + * Escape a string for the HTML Body context where there are very few characters + * of special meaning. Internally this will use htmlspecialchars(). + * + * @return string + */ + public function escapeHtml(string $string) + { + return htmlspecialchars($string, $this->htmlSpecialCharsFlags, $this->encoding); + } + + /** + * Escape a string for the HTML Attribute context. We use an extended set of characters + * to escape that are not covered by htmlspecialchars() to cover cases where an attribute + * might be unquoted or quoted illegally (e.g. backticks are valid quotes for IE). + * + * @return string + */ + public function escapeHtmlAttr(string $string) + { + $string = $this->toUtf8($string); + if ($string === '' || ctype_digit($string)) { + return $string; + } + + $result = preg_replace_callback('/[^a-z0-9,\.\-_]/iSu', $this->htmlAttrMatcher, $string); + return $this->fromUtf8($result); + } + + /** + * Escape a string for the Javascript context. This does not use json_encode(). An extended + * set of characters are escaped beyond ECMAScript's rules for Javascript literal string + * escaping in order to prevent misinterpretation of Javascript as HTML leading to the + * injection of special characters and entities. The escaping used should be tolerant + * of cases where HTML escaping was not applied on top of Javascript escaping correctly. + * Backslash escaping is not used as it still leaves the escaped character as-is and so + * is not useful in a HTML context. + * + * @return string + */ + public function escapeJs(string $string) + { + $string = $this->toUtf8($string); + if ($string === '' || ctype_digit($string)) { + return $string; + } + + $result = preg_replace_callback('/[^a-z0-9,\._]/iSu', $this->jsMatcher, $string); + return $this->fromUtf8($result); + } + + /** + * Escape a string for the URI or Parameter contexts. This should not be used to escape + * an entire URI - only a subcomponent being inserted. The function is a simple proxy + * to rawurlencode() which now implements RFC 3986 since PHP 5.3 completely. + * + * @return string + */ + public function escapeUrl(string $string) + { + return rawurlencode($string); + } + + /** + * Escape a string for the CSS context. CSS escaping can be applied to any string being + * inserted into CSS and escapes everything except alphanumerics. + * + * @return string + */ + public function escapeCss(string $string) + { + $string = $this->toUtf8($string); + if ($string === '' || ctype_digit($string)) { + return $string; + } + + $result = preg_replace_callback('/[^a-z0-9]/iSu', $this->cssMatcher, $string); + return $this->fromUtf8($result); + } + + /** + * Callback function for preg_replace_callback that applies HTML Attribute + * escaping to all matches. + * + * @param array $matches + * @return string + */ + protected function htmlAttrMatcher($matches) + { + $chr = $matches[0]; + $ord = ord($chr); + + /** + * The following replaces characters undefined in HTML with the + * hex entity for the Unicode replacement character. + */ + if ( + ($ord <= 0x1f && $chr !== "\t" && $chr !== "\n" && $chr !== "\r") + || ($ord >= 0x7f && $ord <= 0x9f) + ) { + return '�'; + } + + /** + * Check if the current character to escape has a name entity we should + * replace it with while grabbing the integer value of the character. + */ + if (strlen($chr) > 1) { + $chr = $this->convertEncoding($chr, 'UTF-32BE', 'UTF-8'); + } + + $hex = bin2hex($chr); + $ord = hexdec($hex); + if (isset(static::$htmlNamedEntityMap[$ord])) { + return '&' . static::$htmlNamedEntityMap[$ord] . ';'; + } + + /** + * Per OWASP recommendations, we'll use upper hex entities + * for any other characters where a named entity does not exist. + */ + if ($ord > 255) { + return sprintf('&#x%04X;', $ord); + } + return sprintf('&#x%02X;', $ord); + } + + /** + * Callback function for preg_replace_callback that applies Javascript + * escaping to all matches. + * + * @param array $matches + * @return string + */ + protected function jsMatcher($matches) + { + $chr = $matches[0]; + if (strlen($chr) === 1) { + return sprintf('\\x%02X', ord($chr)); + } + $chr = $this->convertEncoding($chr, 'UTF-16BE', 'UTF-8'); + $hex = strtoupper(bin2hex($chr)); + if (strlen($hex) <= 4) { + return sprintf('\\u%04s', $hex); + } + $highSurrogate = substr($hex, 0, 4); + $lowSurrogate = substr($hex, 4, 4); + return sprintf('\\u%04s\\u%04s', $highSurrogate, $lowSurrogate); + } + + /** + * Callback function for preg_replace_callback that applies CSS + * escaping to all matches. + * + * @param array $matches + * @return string + */ + protected function cssMatcher($matches) + { + $chr = $matches[0]; + if (strlen($chr) === 1) { + $ord = ord($chr); + } else { + $chr = $this->convertEncoding($chr, 'UTF-32BE', 'UTF-8'); + $ord = hexdec(bin2hex($chr)); + } + return sprintf('\\%X ', $ord); + } + + /** + * Converts a string to UTF-8 from the base encoding. The base encoding is set via this + * + * @param string $string + * @throws Exception\RuntimeException + * @return string + */ + protected function toUtf8($string) + { + if ($this->getEncoding() === 'utf-8') { + $result = $string; + } else { + $result = $this->convertEncoding($string, 'UTF-8', $this->getEncoding()); + } + + if (! $this->isUtf8($result)) { + throw new Exception\RuntimeException( + sprintf('String to be escaped was not valid UTF-8 or could not be converted: %s', $result) + ); + } + + return $result; + } + + /** + * Converts a string from UTF-8 to the base encoding. The base encoding is set via this + * + * @param string $string + * @return string + */ + protected function fromUtf8($string) + { + if ($this->getEncoding() === 'utf-8') { + return $string; + } + + return $this->convertEncoding($string, $this->getEncoding(), 'UTF-8'); + } + + /** + * Checks if a given string appears to be valid UTF-8 or not. + * + * @param string $string + * @return bool + */ + protected function isUtf8($string) + { + return $string === '' || preg_match('/^./su', $string); + } + + /** + * Encoding conversion helper which wraps mb_convert_encoding + * + * @param string $string + * @param string $to + * @param array|string $from + * @return string + */ + protected function convertEncoding($string, $to, $from) + { + $result = mb_convert_encoding($string, $to, $from); + + if ($result === false) { + return ''; // return non-fatal blank string on encoding errors from users + } + + return $result; + } +} diff --git a/kirby/vendor/laminas/laminas-escaper/src/Exception/ExceptionInterface.php b/kirby/vendor/laminas/laminas-escaper/src/Exception/ExceptionInterface.php new file mode 100644 index 0000000..8f5fd89 --- /dev/null +++ b/kirby/vendor/laminas/laminas-escaper/src/Exception/ExceptionInterface.php @@ -0,0 +1,11 @@ + $color >> 16 & 0xFF, + 'g' => $color >> 8 & 0xFF, + 'b' => $color & 0xFF, + ]; + } + + /** + * @param array $components + * + * @return int + */ + public static function fromRgbToInt(array $components) + { + return ($components['r'] * 65536) + ($components['g'] * 256) + ($components['b']); + } +} diff --git a/kirby/vendor/league/color-extractor/src/ColorExtractor.php b/kirby/vendor/league/color-extractor/src/ColorExtractor.php new file mode 100644 index 0000000..364a3bd --- /dev/null +++ b/kirby/vendor/league/color-extractor/src/ColorExtractor.php @@ -0,0 +1,282 @@ +palette = $palette; + } + + /** + * @param int $colorCount + * + * @return array + */ + public function extract($colorCount = 1) + { + if ($colorCount === 0) { + return []; + } + + if (!$this->isInitialized()) { + $this->initialize(); + } + + return self::mergeColors($this->sortedColors, $colorCount, 100 / $colorCount); + } + + /** + * @return bool + */ + protected function isInitialized() + { + return $this->sortedColors !== null; + } + + protected function initialize() + { + $queue = new \SplPriorityQueue(); + $this->sortedColors = new \SplFixedArray(count($this->palette)); + + $i = 0; + foreach ($this->palette as $color => $count) { + $labColor = self::intColorToLab($color); + $queue->insert( + $color, + (sqrt($labColor['a'] * $labColor['a'] + $labColor['b'] * $labColor['b']) ?: 1) * + (1 - $labColor['L'] / 200) * + sqrt($count) + ); + ++$i; + } + + $i = 0; + while ($queue->valid()) { + $this->sortedColors[$i] = $queue->current(); + $queue->next(); + ++$i; + } + } + + /** + * @param \SplFixedArray $colors + * @param int $limit + * @param int $maxDelta + * + * @return array + */ + protected static function mergeColors(\SplFixedArray $colors, $limit, $maxDelta) + { + $limit = min(count($colors), $limit); + if ($limit === 0) { + return []; + } + if ($limit === 1) { + return [$colors[0]]; + } + $labCache = new \SplFixedArray($limit - 1); + $mergedColors = []; + + foreach ($colors as $color) { + $hasColorBeenMerged = false; + + $colorLab = self::intColorToLab($color); + + foreach ($mergedColors as $i => $mergedColor) { + if (self::ciede2000DeltaE($colorLab, $labCache[$i]) < $maxDelta) { + $hasColorBeenMerged = true; + break; + } + } + + if ($hasColorBeenMerged) { + continue; + } + + $mergedColorCount = count($mergedColors); + $mergedColors[] = $color; + + if ($mergedColorCount + 1 == $limit) { + break; + } + + $labCache[$mergedColorCount] = $colorLab; + } + + return $mergedColors; + } + + /** + * @param array $firstLabColor + * @param array $secondLabColor + * + * @return float + */ + protected static function ciede2000DeltaE($firstLabColor, $secondLabColor) + { + $C1 = sqrt(pow($firstLabColor['a'], 2) + pow($firstLabColor['b'], 2)); + $C2 = sqrt(pow($secondLabColor['a'], 2) + pow($secondLabColor['b'], 2)); + $Cb = ($C1 + $C2) / 2; + + $G = .5 * (1 - sqrt(pow($Cb, 7) / (pow($Cb, 7) + pow(25, 7)))); + + $a1p = (1 + $G) * $firstLabColor['a']; + $a2p = (1 + $G) * $secondLabColor['a']; + + $C1p = sqrt(pow($a1p, 2) + pow($firstLabColor['b'], 2)); + $C2p = sqrt(pow($a2p, 2) + pow($secondLabColor['b'], 2)); + + $h1p = $a1p == 0 && $firstLabColor['b'] == 0 ? 0 : atan2($firstLabColor['b'], $a1p); + $h2p = $a2p == 0 && $secondLabColor['b'] == 0 ? 0 : atan2($secondLabColor['b'], $a2p); + + $LpDelta = $secondLabColor['L'] - $firstLabColor['L']; + $CpDelta = $C2p - $C1p; + + if ($C1p * $C2p == 0) { + $hpDelta = 0; + } elseif (abs($h2p - $h1p) <= 180) { + $hpDelta = $h2p - $h1p; + } elseif ($h2p - $h1p > 180) { + $hpDelta = $h2p - $h1p - 360; + } else { + $hpDelta = $h2p - $h1p + 360; + } + + $HpDelta = 2 * sqrt($C1p * $C2p) * sin($hpDelta / 2); + + $Lbp = ($firstLabColor['L'] + $secondLabColor['L']) / 2; + $Cbp = ($C1p + $C2p) / 2; + + if ($C1p * $C2p == 0) { + $hbp = $h1p + $h2p; + } elseif (abs($h1p - $h2p) <= 180) { + $hbp = ($h1p + $h2p) / 2; + } elseif ($h1p + $h2p < 360) { + $hbp = ($h1p + $h2p + 360) / 2; + } else { + $hbp = ($h1p + $h2p - 360) / 2; + } + + $T = 1 - .17 * cos($hbp - 30) + .24 * cos(2 * $hbp) + .32 * cos(3 * $hbp + 6) - .2 * cos(4 * $hbp - 63); + + $sigmaDelta = 30 * exp(-pow(($hbp - 275) / 25, 2)); + + $Rc = 2 * sqrt(pow($Cbp, 7) / (pow($Cbp, 7) + pow(25, 7))); + + $Sl = 1 + ((.015 * pow($Lbp - 50, 2)) / sqrt(20 + pow($Lbp - 50, 2))); + $Sc = 1 + .045 * $Cbp; + $Sh = 1 + .015 * $Cbp * $T; + + $Rt = -sin(2 * $sigmaDelta) * $Rc; + + return sqrt( + pow($LpDelta / $Sl, 2) + + pow($CpDelta / $Sc, 2) + + pow($HpDelta / $Sh, 2) + + $Rt * ($CpDelta / $Sc) * ($HpDelta / $Sh) + ); + } + + /** + * @param int $color + * + * @return array + */ + protected static function intColorToLab($color) + { + return self::xyzToLab( + self::srgbToXyz( + self::rgbToSrgb( + [ + 'R' => ($color >> 16) & 0xFF, + 'G' => ($color >> 8) & 0xFF, + 'B' => $color & 0xFF, + ] + ) + ) + ); + } + + /** + * @param int $value + * + * @return float + */ + protected static function rgbToSrgbStep($value) + { + $value /= 255; + + return $value <= .03928 ? + $value / 12.92 : + pow(($value + .055) / 1.055, 2.4); + } + + /** + * @param array $rgb + * + * @return array + */ + protected static function rgbToSrgb($rgb) + { + return [ + 'R' => self::rgbToSrgbStep($rgb['R']), + 'G' => self::rgbToSrgbStep($rgb['G']), + 'B' => self::rgbToSrgbStep($rgb['B']), + ]; + } + + /** + * @param array $rgb + * + * @return array + */ + protected static function srgbToXyz($rgb) + { + return [ + 'X' => (.4124564 * $rgb['R']) + (.3575761 * $rgb['G']) + (.1804375 * $rgb['B']), + 'Y' => (.2126729 * $rgb['R']) + (.7151522 * $rgb['G']) + (.0721750 * $rgb['B']), + 'Z' => (.0193339 * $rgb['R']) + (.1191920 * $rgb['G']) + (.9503041 * $rgb['B']), + ]; + } + + /** + * @param float $value + * + * @return float + */ + protected static function xyzToLabStep($value) + { + return $value > 216 / 24389 ? pow($value, 1 / 3) : 841 * $value / 108 + 4 / 29; + } + + /** + * @param array $xyz + * + * @return array + */ + protected static function xyzToLab($xyz) + { + //http://en.wikipedia.org/wiki/Illuminant_D65#Definition + $Xn = .95047; + $Yn = 1; + $Zn = 1.08883; + + // http://en.wikipedia.org/wiki/Lab_color_space#CIELAB-CIEXYZ_conversions + return [ + 'L' => 116 * self::xyzToLabStep($xyz['Y'] / $Yn) - 16, + 'a' => 500 * (self::xyzToLabStep($xyz['X'] / $Xn) - self::xyzToLabStep($xyz['Y'] / $Yn)), + 'b' => 200 * (self::xyzToLabStep($xyz['Y'] / $Yn) - self::xyzToLabStep($xyz['Z'] / $Zn)), + ]; + } +} diff --git a/kirby/vendor/league/color-extractor/src/Palette.php b/kirby/vendor/league/color-extractor/src/Palette.php new file mode 100644 index 0000000..5c5266f --- /dev/null +++ b/kirby/vendor/league/color-extractor/src/Palette.php @@ -0,0 +1,180 @@ +colors); + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->colors); + } + + /** + * @return int + */ + public function getColorCount($color) + { + if (!array_key_exists($color, $this->colors)) { + return 0; + } + + return $this->colors[$color]; + } + + /** + * @param int $limit = null + * + * @return array + */ + public function getMostUsedColors($limit = null) + { + return array_slice($this->colors, 0, $limit, true); + } + + /** + * @param string $filename + * @param int|null $backgroundColor + * + * @return Palette + * + * @throws \InvalidArgumentException + */ + public static function fromFilename($filename, $backgroundColor = null) + { + if (!is_readable($filename)) { + throw new \InvalidArgumentException('Filename must be a valid path and should be readable'); + } + + return self::fromContents(file_get_contents($filename), $backgroundColor); + } + + /** + * @param string $url + * @param int|null $backgroundColor + * + * @return Palette + * + * @throws \RuntimeException + */ + public static function fromUrl($url, $backgroundColor = null) + { + if (!function_exists('curl_init')){ + return self::fromContents(file_get_contents($url)); + } + + $ch = curl_init(); + try { + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + $contents = curl_exec($ch); + if ($contents === false) { + throw new \RuntimeException('Failed to fetch image from URL'); + } + } finally { + curl_close($ch); + } + + return self::fromContents($contents, $backgroundColor); + } + + /** + * Create instance with file contents + * + * @param string $contents + * @param int|null $backgroundColor + * + * @return Palette + */ + public static function fromContents($contents, $backgroundColor = null) { + $image = imagecreatefromstring($contents); + $palette = self::fromGD($image, $backgroundColor); + imagedestroy($image); + + return $palette; + } + + /** + * @param \GDImage|resource $image + * @param int|null $backgroundColor + * + * @return Palette + * + * @throws \InvalidArgumentException + */ + public static function fromGD($image, ?int $backgroundColor = null) + { + if (!$image instanceof \GDImage && (!is_resource($image) || get_resource_type($image) !== 'gd')) { + throw new \InvalidArgumentException('Image must be a gd resource'); + } + if ($backgroundColor !== null && (!is_numeric($backgroundColor) || $backgroundColor < 0 || $backgroundColor > 16777215)) { + throw new \InvalidArgumentException(sprintf('"%s" does not represent a valid color', $backgroundColor)); + } + + $palette = new self(); + + $areColorsIndexed = !imageistruecolor($image); + $imageWidth = imagesx($image); + $imageHeight = imagesy($image); + $palette->colors = []; + + $backgroundColorRed = ($backgroundColor >> 16) & 0xFF; + $backgroundColorGreen = ($backgroundColor >> 8) & 0xFF; + $backgroundColorBlue = $backgroundColor & 0xFF; + + for ($x = 0; $x < $imageWidth; ++$x) { + for ($y = 0; $y < $imageHeight; ++$y) { + $color = imagecolorat($image, $x, $y); + if ($areColorsIndexed) { + $colorComponents = imagecolorsforindex($image, $color); + $color = ($colorComponents['alpha'] * 16777216) + + ($colorComponents['red'] * 65536) + + ($colorComponents['green'] * 256) + + ($colorComponents['blue']); + } + + if ($alpha = $color >> 24) { + if ($backgroundColor === null) { + continue; + } + + $alpha /= 127; + $color = (int) (($color >> 16 & 0xFF) * (1 - $alpha) + $backgroundColorRed * $alpha) * 65536 + + (int) (($color >> 8 & 0xFF) * (1 - $alpha) + $backgroundColorGreen * $alpha) * 256 + + (int) (($color & 0xFF) * (1 - $alpha) + $backgroundColorBlue * $alpha); + } + + isset($palette->colors[$color]) ? + $palette->colors[$color] += 1 : + $palette->colors[$color] = 1; + } + } + + arsort($palette->colors); + + return $palette; + } + + protected function __construct() + { + $this->colors = []; + } +} diff --git a/kirby/vendor/michelf/php-smartypants/License.md b/kirby/vendor/michelf/php-smartypants/License.md new file mode 100644 index 0000000..20aad72 --- /dev/null +++ b/kirby/vendor/michelf/php-smartypants/License.md @@ -0,0 +1,36 @@ +PHP SmartyPants Lib +Copyright (c) 2005-2016 Michel Fortin + +All rights reserved. + +Original SmartyPants +Copyright (c) 2003-2004 John Gruber + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* Neither the name "SmartyPants" nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +This software is provided by the copyright holders and contributors "as +is" and any express or implied warranties, including, but not limited +to, the implied warranties of merchantability and fitness for a +particular purpose are disclaimed. In no event shall the copyright owner +or contributors be liable for any direct, indirect, incidental, special, +exemplary, or consequential damages (including, but not limited to, +procurement of substitute goods or services; loss of use, data, or +profits; or business interruption) however caused and on any theory of +liability, whether in contract, strict liability, or tort (including +negligence or otherwise) arising in any way out of the use of this +software, even if advised of the possibility of such damage. diff --git a/kirby/vendor/michelf/php-smartypants/Michelf/SmartyPants.inc.php b/kirby/vendor/michelf/php-smartypants/Michelf/SmartyPants.inc.php new file mode 100644 index 0000000..b4ee661 --- /dev/null +++ b/kirby/vendor/michelf/php-smartypants/Michelf/SmartyPants.inc.php @@ -0,0 +1,9 @@ + +# +# Original SmartyPants +# Copyright (c) 2003-2004 John Gruber +# +# +namespace Michelf; + + +# +# SmartyPants Parser Class +# + +class SmartyPants { + + ### Version ### + + const SMARTYPANTSLIB_VERSION = "1.8.1"; + + + ### Presets + + # SmartyPants does nothing at all + const ATTR_DO_NOTHING = 0; + # "--" for em-dashes; no en-dash support + const ATTR_EM_DASH = 1; + # "---" for em-dashes; "--" for en-dashes + const ATTR_LONG_EM_DASH_SHORT_EN = 2; + # "--" for em-dashes; "---" for en-dashes + const ATTR_SHORT_EM_DASH_LONG_EN = 3; + # "--" for em-dashes; "---" for en-dashes + const ATTR_STUPEFY = -1; + + # The default preset: ATTR_EM_DASH + const ATTR_DEFAULT = SmartyPants::ATTR_EM_DASH; + + + ### Standard Function Interface ### + + public static function defaultTransform($text, $attr = SmartyPants::ATTR_DEFAULT) { + # + # Initialize the parser and return the result of its transform method. + # This will work fine for derived classes too. + # + # Take parser class on which this function was called. + $parser_class = \get_called_class(); + + # try to take parser from the static parser list + static $parser_list; + $parser =& $parser_list[$parser_class][$attr]; + + # create the parser if not already set + if (!$parser) + $parser = new $parser_class($attr); + + # Transform text using parser. + return $parser->transform($text); + } + + + ### Configuration Variables ### + + # Partial regex for matching tags to skip + public $tags_to_skip = 'pre|code|kbd|script|style|math'; + + # Options to specify which transformations to make: + public $do_nothing = 0; # disable all transforms + public $do_quotes = 0; + public $do_backticks = 0; # 1 => double only, 2 => double & single + public $do_dashes = 0; # 1, 2, or 3 for the three modes described above + public $do_ellipses = 0; + public $do_stupefy = 0; + public $convert_quot = 0; # should we translate " entities into normal quotes? + + # Smart quote characters: + # Opening and closing smart double-quotes. + public $smart_doublequote_open = '“'; + public $smart_doublequote_close = '”'; + public $smart_singlequote_open = '‘'; + public $smart_singlequote_close = '’'; # Also apostrophe. + + # ``Backtick quotes'' + public $backtick_doublequote_open = '“'; // replacement for `` + public $backtick_doublequote_close = '”'; // replacement for '' + public $backtick_singlequote_open = '‘'; // replacement for ` + public $backtick_singlequote_close = '’'; // replacement for ' (also apostrophe) + + # Other punctuation + public $em_dash = '—'; + public $en_dash = '–'; + public $ellipsis = '…'; + + ### Parser Implementation ### + + public function __construct($attr = SmartyPants::ATTR_DEFAULT) { + # + # Initialize a parser with certain attributes. + # + # Parser attributes: + # 0 : do nothing + # 1 : set all + # 2 : set all, using old school en- and em- dash shortcuts + # 3 : set all, using inverted old school en and em- dash shortcuts + # + # q : quotes + # b : backtick quotes (``double'' only) + # B : backtick quotes (``double'' and `single') + # d : dashes + # D : old school dashes + # i : inverted old school dashes + # e : ellipses + # w : convert " entities to " for Dreamweaver users + # + if ($attr == "0") { + $this->do_nothing = 1; + } + else if ($attr == "1") { + # Do everything, turn all options on. + $this->do_quotes = 1; + $this->do_backticks = 1; + $this->do_dashes = 1; + $this->do_ellipses = 1; + } + else if ($attr == "2") { + # Do everything, turn all options on, use old school dash shorthand. + $this->do_quotes = 1; + $this->do_backticks = 1; + $this->do_dashes = 2; + $this->do_ellipses = 1; + } + else if ($attr == "3") { + # Do everything, turn all options on, use inverted old school dash shorthand. + $this->do_quotes = 1; + $this->do_backticks = 1; + $this->do_dashes = 3; + $this->do_ellipses = 1; + } + else if ($attr == "-1") { + # Special "stupefy" mode. + $this->do_stupefy = 1; + } + else { + $chars = preg_split('//', $attr); + foreach ($chars as $c){ + if ($c == "q") { $this->do_quotes = 1; } + else if ($c == "b") { $this->do_backticks = 1; } + else if ($c == "B") { $this->do_backticks = 2; } + else if ($c == "d") { $this->do_dashes = 1; } + else if ($c == "D") { $this->do_dashes = 2; } + else if ($c == "i") { $this->do_dashes = 3; } + else if ($c == "e") { $this->do_ellipses = 1; } + else if ($c == "w") { $this->convert_quot = 1; } + else { + # Unknown attribute option, ignore. + } + } + } + } + + public function transform($text) { + + if ($this->do_nothing) { + return $text; + } + + $tokens = $this->tokenizeHTML($text); + $result = ''; + $in_pre = 0; # Keep track of when we're inside
     or  tags.
    +
    +		$prev_token_last_char = ""; # This is a cheat, used to get some context
    +									# for one-character tokens that consist of 
    +									# just a quote char. What we do is remember
    +									# the last character of the previous text
    +									# token, to use as context to curl single-
    +									# character quote tokens correctly.
    +
    +		foreach ($tokens as $cur_token) {
    +			if ($cur_token[0] == "tag") {
    +				# Don't mess with quotes inside tags.
    +				$result .= $cur_token[1];
    +				if (preg_match('@<(/?)(?:'.$this->tags_to_skip.')[\s>]@', $cur_token[1], $matches)) {
    +					$in_pre = isset($matches[1]) && $matches[1] == '/' ? 0 : 1;
    +				}
    +			} else {
    +				$t = $cur_token[1];
    +				$last_char = substr($t, -1); # Remember last char of this token before processing.
    +				if (! $in_pre) {
    +					$t = $this->educate($t, $prev_token_last_char);
    +				}
    +				$prev_token_last_char = $last_char;
    +				$result .= $t;
    +			}
    +		}
    +
    +		return $result;
    +	}
    +
    +
    +	function decodeEntitiesInConfiguration() {
    +	#
    +	#   Utility function that converts entities in configuration variables to
    +	#   UTF-8 characters.
    +	#
    +		$output_config_vars = array(
    +			'smart_doublequote_open',
    +			'smart_doublequote_close',
    +			'smart_singlequote_open',
    +			'smart_singlequote_close',
    +			'backtick_doublequote_open',
    +			'backtick_doublequote_close',
    +			'backtick_singlequote_open',
    +			'backtick_singlequote_close',
    +			'em_dash',
    +			'en_dash',
    +			'ellipsis',
    +		);
    +		foreach ($output_config_vars as $var) {
    +			$this->$var = html_entity_decode($this->$var);
    +		}
    +	}
    +
    +
    +	protected function educate($t, $prev_token_last_char) {
    +		$t = $this->processEscapes($t);
    +
    +		if ($this->convert_quot) {
    +			$t = preg_replace('/"/', '"', $t);
    +		}
    +
    +		if ($this->do_dashes) {
    +			if ($this->do_dashes == 1) $t = $this->educateDashes($t);
    +			if ($this->do_dashes == 2) $t = $this->educateDashesOldSchool($t);
    +			if ($this->do_dashes == 3) $t = $this->educateDashesOldSchoolInverted($t);
    +		}
    +
    +		if ($this->do_ellipses) $t = $this->educateEllipses($t);
    +
    +		# Note: backticks need to be processed before quotes.
    +		if ($this->do_backticks) {
    +			$t = $this->educateBackticks($t);
    +			if ($this->do_backticks == 2) $t = $this->educateSingleBackticks($t);
    +		}
    +
    +		if ($this->do_quotes) {
    +			if ($t == "'") {
    +				# Special case: single-character ' token
    +				if (preg_match('/\S/', $prev_token_last_char)) {
    +					$t = $this->smart_singlequote_close;
    +				}
    +				else {
    +					$t = $this->smart_singlequote_open;
    +				}
    +			}
    +			else if ($t == '"') {
    +				# Special case: single-character " token
    +				if (preg_match('/\S/', $prev_token_last_char)) {
    +					$t = $this->smart_doublequote_close;
    +				}
    +				else {
    +					$t = $this->smart_doublequote_open;
    +				}
    +			}
    +			else {
    +				# Normal case:
    +				$t = $this->educateQuotes($t);
    +			}
    +		}
    +
    +		if ($this->do_stupefy) $t = $this->stupefyEntities($t);
    +		
    +		return $t;
    +	}
    +
    +
    +	protected function educateQuotes($_) {
    +	#
    +	#   Parameter:  String.
    +	#
    +	#   Returns:    The string, with "educated" curly quote HTML entities.
    +	#
    +	#   Example input:  "Isn't this fun?"
    +	#   Example output: “Isn’t this fun?”
    +	#
    +		$dq_open  = $this->smart_doublequote_open;
    +		$dq_close = $this->smart_doublequote_close;
    +		$sq_open  = $this->smart_singlequote_open;
    +		$sq_close = $this->smart_singlequote_close;
    +	
    +		# Make our own "punctuation" character class, because the POSIX-style
    +		# [:PUNCT:] is only available in Perl 5.6 or later:
    +		$punct_class = "[!\"#\\$\\%'()*+,-.\\/:;<=>?\\@\\[\\\\\]\\^_`{|}~]";
    +
    +		# Special case if the very first character is a quote
    +		# followed by punctuation at a non-word-break. Close the quotes by brute force:
    +		$_ = preg_replace(
    +			array("/^'(?=$punct_class\\B)/", "/^\"(?=$punct_class\\B)/"),
    +			array($sq_close,                 $dq_close), $_);
    +
    +		# Special case for double sets of quotes, e.g.:
    +		#   

    He said, "'Quoted' words in a larger quote."

    + $_ = preg_replace( + array("/\"'(?=\w)/", "/'\"(?=\w)/"), + array($dq_open.$sq_open, $sq_open.$dq_open), $_); + + # Special case for decade abbreviations (the '80s): + $_ = preg_replace("/'(?=\\d{2}s)/", $sq_close, $_); + + $close_class = '[^\ \t\r\n\[\{\(\-]'; + $dec_dashes = '&\#8211;|&\#8212;'; + + # Get most opening single quotes: + $_ = preg_replace("{ + ( + \\s | # a whitespace char, or +   | # a non-breaking space entity, or + -- | # dashes, or + &[mn]dash; | # named dash entities + $dec_dashes | # or decimal entities + &\\#x201[34]; # or hex + ) + ' # the quote + (?=\\w) # followed by a word character + }x", '\1'.$sq_open, $_); + # Single closing quotes: + $_ = preg_replace("{ + ($close_class)? + ' + (?(1)| # If $1 captured, then do nothing; + (?=\\s | s\\b) # otherwise, positive lookahead for a whitespace + ) # char or an 's' at a word ending position. This + # is a special case to handle something like: + # \"Custer's Last Stand.\" + }xi", '\1'.$sq_close, $_); + + # Any remaining single quotes should be opening ones: + $_ = str_replace("'", $sq_open, $_); + + + # Get most opening double quotes: + $_ = preg_replace("{ + ( + \\s | # a whitespace char, or +   | # a non-breaking space entity, or + -- | # dashes, or + &[mn]dash; | # named dash entities + $dec_dashes | # or decimal entities + &\\#x201[34]; # or hex + ) + \" # the quote + (?=\\w) # followed by a word character + }x", '\1'.$dq_open, $_); + + # Double closing quotes: + $_ = preg_replace("{ + ($close_class)? + \" + (?(1)|(?=\\s)) # If $1 captured, then do nothing; + # if not, then make sure the next char is whitespace. + }x", '\1'.$dq_close, $_); + + # Any remaining quotes should be opening ones. + $_ = str_replace('"', $dq_open, $_); + + return $_; + } + + + protected function educateBackticks($_) { + # + # Parameter: String. + # Returns: The string, with ``backticks'' -style double quotes + # translated into HTML curly quote entities. + # + # Example input: ``Isn't this fun?'' + # Example output: “Isn't this fun?” + # + + $_ = str_replace(array("``", "''",), + array($this->backtick_doublequote_open, + $this->backtick_doublequote_close), $_); + return $_; + } + + + protected function educateSingleBackticks($_) { + # + # Parameter: String. + # Returns: The string, with `backticks' -style single quotes + # translated into HTML curly quote entities. + # + # Example input: `Isn't this fun?' + # Example output: ‘Isn’t this fun?’ + # + + $_ = str_replace(array("`", "'",), + array($this->backtick_singlequote_open, + $this->backtick_singlequote_close), $_); + return $_; + } + + + protected function educateDashes($_) { + # + # Parameter: String. + # + # Returns: The string, with each instance of "--" translated to + # an em-dash HTML entity. + # + + $_ = str_replace('--', $this->em_dash, $_); + return $_; + } + + + protected function educateDashesOldSchool($_) { + # + # Parameter: String. + # + # Returns: The string, with each instance of "--" translated to + # an en-dash HTML entity, and each "---" translated to + # an em-dash HTML entity. + # + + # em en + $_ = str_replace(array("---", "--",), + array($this->em_dash, $this->en_dash), $_); + return $_; + } + + + protected function educateDashesOldSchoolInverted($_) { + # + # Parameter: String. + # + # Returns: The string, with each instance of "--" translated to + # an em-dash HTML entity, and each "---" translated to + # an en-dash HTML entity. Two reasons why: First, unlike the + # en- and em-dash syntax supported by + # EducateDashesOldSchool(), it's compatible with existing + # entries written before SmartyPants 1.1, back when "--" was + # only used for em-dashes. Second, em-dashes are more + # common than en-dashes, and so it sort of makes sense that + # the shortcut should be shorter to type. (Thanks to Aaron + # Swartz for the idea.) + # + + # en em + $_ = str_replace(array("---", "--",), + array($this->en_dash, $this->em_dash), $_); + return $_; + } + + + protected function educateEllipses($_) { + # + # Parameter: String. + # Returns: The string, with each instance of "..." translated to + # an ellipsis HTML entity. Also converts the case where + # there are spaces between the dots. + # + # Example input: Huh...? + # Example output: Huh…? + # + + $_ = str_replace(array("...", ". . .",), $this->ellipsis, $_); + return $_; + } + + + protected function stupefyEntities($_) { + # + # Parameter: String. + # Returns: The string, with each SmartyPants HTML entity translated to + # its ASCII counterpart. + # + # Example input: “Hello — world.” + # Example output: "Hello -- world." + # + + # en-dash em-dash + $_ = str_replace(array('–', '—'), + array('-', '--'), $_); + + # single quote open close + $_ = str_replace(array('‘', '’'), "'", $_); + + # double quote open close + $_ = str_replace(array('“', '”'), '"', $_); + + $_ = str_replace('…', '...', $_); # ellipsis + + return $_; + } + + + protected function processEscapes($_) { + # + # Parameter: String. + # Returns: The string, with after processing the following backslash + # escape sequences. This is useful if you want to force a "dumb" + # quote or other character to appear. + # + # Escape Value + # ------ ----- + # \\ \ + # \" " + # \' ' + # \. . + # \- - + # \` ` + # + $_ = str_replace( + array('\\\\', '\"', "\'", '\.', '\-', '\`'), + array('\', '"', ''', '.', '-', '`'), $_); + + return $_; + } + + + protected function tokenizeHTML($str) { + # + # Parameter: String containing HTML markup. + # Returns: An array of the tokens comprising the input + # string. Each token is either a tag (possibly with nested, + # tags contained therein, such as , or a + # run of text between tags. Each element of the array is a + # two-element array; the first is either 'tag' or 'text'; + # the second is the actual value. + # + # + # Regular expression derived from the _tokenize() subroutine in + # Brad Choate's MTRegex plugin. + # + # + $index = 0; + $tokens = array(); + + $match = '(?s:)|'. # comment + '(?s:<\?.*?\?>)|'. # processing instruction + # regular tags + '(?:<[/!$]?[-a-zA-Z0-9:]+\b(?>[^"\'>]+|"[^"]*"|\'[^\']*\')*>)'; + + $parts = preg_split("{($match)}", $str, -1, PREG_SPLIT_DELIM_CAPTURE); + + foreach ($parts as $part) { + if (++$index % 2 && $part != '') + $tokens[] = array('text', $part); + else + $tokens[] = array('tag', $part); + } + return $tokens; + } + +} diff --git a/kirby/vendor/michelf/php-smartypants/Michelf/SmartyPantsTypographer.inc.php b/kirby/vendor/michelf/php-smartypants/Michelf/SmartyPantsTypographer.inc.php new file mode 100644 index 0000000..9b3d274 --- /dev/null +++ b/kirby/vendor/michelf/php-smartypants/Michelf/SmartyPantsTypographer.inc.php @@ -0,0 +1,10 @@ + +# +# Original SmartyPants +# Copyright (c) 2003-2004 John Gruber +# +# +namespace Michelf; + + +# +# SmartyPants Typographer Parser Class +# +class SmartyPantsTypographer extends \Michelf\SmartyPants { + + ### Configuration Variables ### + + # Options to specify which transformations to make: + public $do_comma_quotes = 0; + public $do_guillemets = 0; + public $do_geresh_gershayim = 0; + public $do_space_emdash = 0; + public $do_space_endash = 0; + public $do_space_colon = 0; + public $do_space_semicolon = 0; + public $do_space_marks = 0; + public $do_space_frenchquote = 0; + public $do_space_thousand = 0; + public $do_space_unit = 0; + + # Quote characters for replacing ASCII approximations + public $doublequote_low = "„"; // replacement for ,, + public $guillemet_leftpointing = "«"; // replacement for << + public $guillemet_rightpointing = "»"; // replacement for >> + public $geresh = "׳"; + public $gershayim = "״"; + + # Space characters for different places: + # Space around em-dashes. "He_—_or she_—_should change that." + public $space_emdash = " "; + # Space around en-dashes. "He_–_or she_–_should change that." + public $space_endash = " "; + # Space before a colon. "He said_: here it is." + public $space_colon = " "; + # Space before a semicolon. "That's what I said_; that's what he said." + public $space_semicolon = " "; + # Space before a question mark and an exclamation mark: "¡_Holà_! What_?" + public $space_marks = " "; + # Space inside french quotes. "Voici la «_chose_» qui m'a attaqué." + public $space_frenchquote = " "; + # Space as thousand separator. "On compte 10_000 maisons sur cette liste." + public $space_thousand = " "; + # Space before a unit abreviation. "This 12_kg of matter costs 10_$." + public $space_unit = " "; + + + # Expression of a space (breakable or not): + public $space = '(?: | | |�*160;|�*[aA]0;)'; + + + ### Parser Implementation ### + + public function __construct($attr = SmartyPants::ATTR_DEFAULT) { + # + # Initialize a SmartyPantsTypographer_Parser with certain attributes. + # + # Parser attributes: + # 0 : do nothing + # 1 : set all, except dash spacing + # 2 : set all, except dash spacing, using old school en- and em- dash shortcuts + # 3 : set all, except dash spacing, using inverted old school en and em- dash shortcuts + # + # Punctuation: + # q -> quotes + # b -> backtick quotes (``double'' only) + # B -> backtick quotes (``double'' and `single') + # c -> comma quotes (,,double`` only) + # g -> guillemets (<> only) + # d -> dashes + # D -> old school dashes + # i -> inverted old school dashes + # e -> ellipses + # w -> convert " entities to " for Dreamweaver users + # + # Spacing: + # : -> colon spacing +- + # ; -> semicolon spacing +- + # m -> question and exclamation marks spacing +- + # h -> em-dash spacing +- + # H -> en-dash spacing +- + # f -> french quote spacing +- + # t -> thousand separator spacing - + # u -> unit spacing +- + # (you can add a plus sign after some of these options denoted by + to + # add the space when it is not already present, or you can add a minus + # sign to completly remove any space present) + # + # Initialize inherited SmartyPants parser. + parent::__construct($attr); + + if ($attr == "1" || $attr == "2" || $attr == "3") { + # Do everything, turn all options on. + $this->do_comma_quotes = 1; + $this->do_guillemets = 1; + $this->do_geresh_gershayim = 1; + $this->do_space_emdash = 1; + $this->do_space_endash = 1; + $this->do_space_colon = 1; + $this->do_space_semicolon = 1; + $this->do_space_marks = 1; + $this->do_space_frenchquote = 1; + $this->do_space_thousand = 1; + $this->do_space_unit = 1; + } + else if ($attr == "-1") { + # Special "stupefy" mode. + $this->do_stupefy = 1; + } + else { + $chars = preg_split('//', $attr); + foreach ($chars as $c){ + if ($c == "c") { $current =& $this->do_comma_quotes; } + else if ($c == "g") { $current =& $this->do_guillemets; } + else if ($c == "G") { $current =& $this->do_geresh_gershayim; } + else if ($c == ":") { $current =& $this->do_space_colon; } + else if ($c == ";") { $current =& $this->do_space_semicolon; } + else if ($c == "m") { $current =& $this->do_space_marks; } + else if ($c == "h") { $current =& $this->do_space_emdash; } + else if ($c == "H") { $current =& $this->do_space_endash; } + else if ($c == "f") { $current =& $this->do_space_frenchquote; } + else if ($c == "t") { $current =& $this->do_space_thousand; } + else if ($c == "u") { $current =& $this->do_space_unit; } + else if ($c == "+") { + $current = 2; + unset($current); + } + else if ($c == "-") { + $current = -1; + unset($current); + } + else { + # Unknown attribute option, ignore. + } + $current = 1; + } + } + } + + + function decodeEntitiesInConfiguration() { + parent::decodeEntitiesInConfiguration(); + $output_config_vars = array( + 'doublequote_low', + 'guillemet_leftpointing', + 'guillemet_rightpointing', + 'space_emdash', + 'space_endash', + 'space_colon', + 'space_semicolon', + 'space_marks', + 'space_frenchquote', + 'space_thousand', + 'space_unit', + ); + foreach ($output_config_vars as $var) { + $this->$var = html_entity_decode($this->$var); + } + } + + + function educate($t, $prev_token_last_char) { + # must happen before regular smart quotes + if ($this->do_geresh_gershayim) $t = $this->educateGereshGershayim($t); + + $t = parent::educate($t, $prev_token_last_char); + + if ($this->do_comma_quotes) $t = $this->educateCommaQuotes($t); + if ($this->do_guillemets) $t = $this->educateGuillemets($t); + + if ($this->do_space_emdash) $t = $this->spaceEmDash($t); + if ($this->do_space_endash) $t = $this->spaceEnDash($t); + if ($this->do_space_colon) $t = $this->spaceColon($t); + if ($this->do_space_semicolon) $t = $this->spaceSemicolon($t); + if ($this->do_space_marks) $t = $this->spaceMarks($t); + if ($this->do_space_frenchquote) $t = $this->spaceFrenchQuotes($t); + if ($this->do_space_thousand) $t = $this->spaceThousandSeparator($t); + if ($this->do_space_unit) $t = $this->spaceUnit($t); + + return $t; + } + + + protected function educateCommaQuotes($_) { + # + # Parameter: String. + # Returns: The string, with ,,comma,, -style double quotes + # translated into HTML curly quote entities. + # + # Example input: ,,Isn't this fun?,, + # Example output: „Isn't this fun?„ + # + # Note: this is meant to be used alongside with backtick quotes; there is + # no language that use only lower quotations alone mark like in the example. + # + $_ = str_replace(",,", $this->doublequote_low, $_); + return $_; + } + + + protected function educateGuillemets($_) { + # + # Parameter: String. + # Returns: The string, with << guillemets >> -style quotes + # translated into HTML guillemets entities. + # + # Example input: << Isn't this fun? >> + # Example output: „ Isn't this fun? „ + # + $_ = preg_replace("/(?:<|<){2}/", $this->guillemet_leftpointing, $_); + $_ = preg_replace("/(?:>|>){2}/", $this->guillemet_rightpointing, $_); + return $_; + } + + + protected function educateGereshGershayim($_) { + # + # Parameter: String, UTF-8 encoded. + # Returns: The string, where simple a or double quote surrounded by + # two hebrew characters is replaced into a typographic + # geresh or gershayim punctuation mark. + # + # Example input: צה"ל / צ'ארלס + # Example output: צה״ל / צ׳ארלס + # + // surrounding code points can be U+0590 to U+05BF and U+05D0 to U+05F2 + // encoded in UTF-8: D6.90 to D6.BF and D7.90 to D7.B2 + $_ = preg_replace('/(?<=\xD6[\x90-\xBF]|\xD7[\x90-\xB2])\'(?=\xD6[\x90-\xBF]|\xD7[\x90-\xB2])/', $this->geresh, $_); + $_ = preg_replace('/(?<=\xD6[\x90-\xBF]|\xD7[\x90-\xB2])"(?=\xD6[\x90-\xBF]|\xD7[\x90-\xB2])/', $this->gershayim, $_); + return $_; + } + + + protected function spaceFrenchQuotes($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # inside french-style quotes, only french quotes. + # + # Example input: Quotes in « French », »German« and »Finnish» style. + # Example output: Quotes in «_French_», »German« and »Finnish» style. + # + $opt = ( $this->do_space_frenchquote == 2 ? '?' : '' ); + $chr = ( $this->do_space_frenchquote != -1 ? $this->space_frenchquote : '' ); + + # Characters allowed immediatly outside quotes. + $outside_char = $this->space . '|\s|[.,:;!?\[\](){}|@*~=+-]|¡|¿'; + + $_ = preg_replace( + "/(^|$outside_char)(«|«|›|‹)$this->space$opt/", + "\\1\\2$chr", $_); + $_ = preg_replace( + "/$this->space$opt(»|»|‹|›)($outside_char|$)/", + "$chr\\1\\2", $_); + return $_; + } + + + protected function spaceColon($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # before colons. + # + # Example input: Ingredients : fun. + # Example output: Ingredients_: fun. + # + $opt = ( $this->do_space_colon == 2 ? '?' : '' ); + $chr = ( $this->do_space_colon != -1 ? $this->space_colon : '' ); + + $_ = preg_replace("/$this->space$opt(:)(\\s|$)/m", + "$chr\\1\\2", $_); + return $_; + } + + + protected function spaceSemicolon($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # before semicolons. + # + # Example input: There he goes ; there she goes. + # Example output: There he goes_; there she goes. + # + $opt = ( $this->do_space_semicolon == 2 ? '?' : '' ); + $chr = ( $this->do_space_semicolon != -1 ? $this->space_semicolon : '' ); + + $_ = preg_replace("/$this->space(;)(?=\\s|$)/m", + " \\1", $_); + $_ = preg_replace("/((?:^|\\s)(?>[^&;\\s]+|&#?[a-zA-Z0-9]+;)*)". + " $opt(;)(?=\\s|$)/m", + "\\1$chr\\2", $_); + return $_; + } + + + protected function spaceMarks($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # around question and exclamation marks. + # + # Example input: ¡ Holà ! What ? + # Example output: ¡_Holà_! What_? + # + $opt = ( $this->do_space_marks == 2 ? '?' : '' ); + $chr = ( $this->do_space_marks != -1 ? $this->space_marks : '' ); + + // Regular marks. + $_ = preg_replace("/$this->space$opt([?!]+)/", "$chr\\1", $_); + + // Inverted marks. + $imarks = "(?:¡|¡|¡|&#x[Aa]1;|¿|¿|¿|&#x[Bb][Ff];)"; + $_ = preg_replace("/($imarks+)$this->space$opt/", "\\1$chr", $_); + + return $_; + } + + + protected function spaceEmDash($_) { + # + # Parameters: String, two replacement characters separated by a hyphen (`-`), + # and forcing flag. + # + # Returns: The string, with appropriates spaces replaced + # around dashes. + # + # Example input: Then — without any plan — the fun happend. + # Example output: Then_—_without any plan_—_the fun happend. + # + $opt = ( $this->do_space_emdash == 2 ? '?' : '' ); + $chr = ( $this->do_space_emdash != -1 ? $this->space_emdash : '' ); + $_ = preg_replace("/$this->space$opt(—|—)$this->space$opt/", + "$chr\\1$chr", $_); + return $_; + } + + + protected function spaceEnDash($_) { + # + # Parameters: String, two replacement characters separated by a hyphen (`-`), + # and forcing flag. + # + # Returns: The string, with appropriates spaces replaced + # around dashes. + # + # Example input: Then — without any plan — the fun happend. + # Example output: Then_—_without any plan_—_the fun happend. + # + $opt = ( $this->do_space_endash == 2 ? '?' : '' ); + $chr = ( $this->do_space_endash != -1 ? $this->space_endash : '' ); + $_ = preg_replace("/$this->space$opt(–|–)$this->space$opt/", + "$chr\\1$chr", $_); + return $_; + } + + + protected function spaceThousandSeparator($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # inside numbers (thousand separator in french). + # + # Example input: Il y a 10 000 insectes amusants dans ton jardin. + # Example output: Il y a 10_000 insectes amusants dans ton jardin. + # + $chr = ( $this->do_space_thousand != -1 ? $this->space_thousand : '' ); + $_ = preg_replace('/([0-9]) ([0-9])/', "\\1$chr\\2", $_); + return $_; + } + + + protected $units = ' + ### Metric units (with prefixes) + (?: + p | + µ | µ | &\#0*181; | &\#[xX]0*[Bb]5; | + [mcdhkMGT] + )? + (?: + [mgstAKNJWCVFSTHBL]|mol|cd|rad|Hz|Pa|Wb|lm|lx|Bq|Gy|Sv|kat| + Ω | Ohm | Ω | &\#0*937; | &\#[xX]0*3[Aa]9; + )| + ### Computers units (KB, Kb, TB, Kbps) + [kKMGT]?(?:[oBb]|[oBb]ps|flops)| + ### Money + ¢ | ¢ | &\#0*162; | &\#[xX]0*[Aa]2; | + M?(?: + £ | £ | &\#0*163; | &\#[xX]0*[Aa]3; | + ¥ | ¥ | &\#0*165; | &\#[xX]0*[Aa]5; | + € | € | &\#0*8364; | &\#[xX]0*20[Aa][Cc]; | + $ + )| + ### Other units + (?: ° | ° | &\#0*176; | &\#[xX]0*[Bb]0; ) [CF]? | + %|pt|pi|M?px|em|en|gal|lb|[NSEOW]|[NS][EOW]|ha|mbar + '; //x + + protected function spaceUnit($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # before unit symbols. + # + # Example input: Get 3 mol of fun for 3 $. + # Example output: Get 3_mol of fun for 3_$. + # + $opt = ( $this->do_space_unit == 2 ? '?' : '' ); + $chr = ( $this->do_space_unit != -1 ? $this->space_unit : '' ); + + $_ = preg_replace('/ + (?:([0-9])[ ]'.$opt.') # Number followed by space. + ('.$this->units.') # Unit. + (?![a-zA-Z0-9]) # Negative lookahead for other unit characters. + /x', + "\\1$chr\\2", $_); + + return $_; + } + + + protected function spaceAbbr($_) { + # + # Parameters: String, replacement character, and forcing flag. + # Returns: The string, with appropriates spaces replaced + # around abbreviations. + # + # Example input: Fun i.e. something pleasant. + # Example output: Fun i.e._something pleasant. + # + $opt = ( $this->do_space_abbr == 2 ? '?' : '' ); + + $_ = preg_replace("/(^|\s)($this->abbr_after) $opt/m", + "\\1\\2$this->space_abbr", $_); + $_ = preg_replace("/( )$opt($this->abbr_sp_before)(?![a-zA-Z'])/m", + "\\1$this->space_abbr\\2", $_); + return $_; + } + + + protected function stupefyEntities($_) { + # + # Adding angle quotes and lower quotes to SmartyPants's stupefy mode. + # + $_ = parent::stupefyEntities($_); + + $_ = str_replace(array('„', '«', '»'), '"', $_); + + return $_; + } + + + protected function processEscapes($_) { + # + # Adding a few more escapes to SmartyPants's escapes: + # + # Escape Value + # ------ ----- + # \, , + # \< < + # \> > + # + $_ = parent::processEscapes($_); + + $_ = str_replace( + array('\,', '\<', '\>', '\<', '\>'), + array(',', '<', '>', '<', '>'), $_); + + return $_; + } +} diff --git a/kirby/vendor/michelf/php-smartypants/composer.json b/kirby/vendor/michelf/php-smartypants/composer.json new file mode 100644 index 0000000..2c2e6c1 --- /dev/null +++ b/kirby/vendor/michelf/php-smartypants/composer.json @@ -0,0 +1,26 @@ +{ + "name": "michelf/php-smartypants", + "type": "library", + "description": "PHP SmartyPants", + "homepage": "https://michelf.ca/projects/php-smartypants/", + "keywords": ["quotes", "dashes", "spaces", "typography", "typographer"], + "license": "BSD-3-Clause", + "authors": [ + { + "name": "Michel Fortin", + "email": "michel.fortin@michelf.ca", + "homepage": "https://michelf.ca/", + "role": "Developer" + }, + { + "name": "John Gruber", + "homepage": "https://daringfireball.net/" + } + ], + "require": { + "php": ">=5.3.0" + }, + "autoload": { + "psr-0": { "Michelf": "" } + } +} diff --git a/kirby/vendor/phpmailer/phpmailer/LICENSE b/kirby/vendor/phpmailer/phpmailer/LICENSE new file mode 100644 index 0000000..f166cc5 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/LICENSE @@ -0,0 +1,502 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! \ No newline at end of file diff --git a/kirby/vendor/phpmailer/phpmailer/composer.json b/kirby/vendor/phpmailer/phpmailer/composer.json new file mode 100644 index 0000000..fa170a0 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/composer.json @@ -0,0 +1,79 @@ +{ + "name": "phpmailer/phpmailer", + "type": "library", + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "authors": [ + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "funding": [ + { + "url": "https://github.com/Synchro", + "type": "github" + } + ], + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "require": { + "php": ">=5.5.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "doctrine/annotations": "^1.2.6 || ^1.13.3", + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpcompatibility/php-compatibility": "^9.3.5", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.7.2", + "yoast/phpunit-polyfills": "^1.0.4" + }, + "suggest": { + "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", + "ext-openssl": "Needed for secure SMTP sending and DKIM signing", + "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)" + }, + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PHPMailer\\Test\\": "test/" + } + }, + "license": "LGPL-2.1-only", + "scripts": { + "check": "./vendor/bin/phpcs", + "test": "./vendor/bin/phpunit --no-coverage", + "coverage": "./vendor/bin/phpunit", + "lint": [ + "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . --show-deprecated -e php,phps --exclude vendor --exclude .git --exclude build" + ] + } +} diff --git a/kirby/vendor/phpmailer/phpmailer/get_oauth_token.php b/kirby/vendor/phpmailer/phpmailer/get_oauth_token.php new file mode 100644 index 0000000..cda0445 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/get_oauth_token.php @@ -0,0 +1,182 @@ + + * @author Jim Jagielski (jimjag) + * @author Andy Prevost (codeworxtech) + * @author Brent R. Matzelle (original founder) + * @copyright 2012 - 2020 Marcus Bointon + * @copyright 2010 - 2012 Jim Jagielski + * @copyright 2004 - 2009 Andy Prevost + * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License + * @note This program is distributed in the hope that it will be useful - WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. + */ + +/** + * Get an OAuth2 token from an OAuth2 provider. + * * Install this script on your server so that it's accessible + * as [https/http]:////get_oauth_token.php + * e.g.: http://localhost/phpmailer/get_oauth_token.php + * * Ensure dependencies are installed with 'composer install' + * * Set up an app in your Google/Yahoo/Microsoft account + * * Set the script address as the app's redirect URL + * If no refresh token is obtained when running this file, + * revoke access to your app and run the script again. + */ + +namespace PHPMailer\PHPMailer; + +/** + * Aliases for League Provider Classes + * Make sure you have added these to your composer.json and run `composer install` + * Plenty to choose from here: + * @see http://oauth2-client.thephpleague.com/providers/thirdparty/ + */ +//@see https://github.com/thephpleague/oauth2-google +use League\OAuth2\Client\Provider\Google; +//@see https://packagist.org/packages/hayageek/oauth2-yahoo +use Hayageek\OAuth2\Client\Provider\Yahoo; +//@see https://github.com/stevenmaguire/oauth2-microsoft +use Stevenmaguire\OAuth2\Client\Provider\Microsoft; +//@see https://github.com/greew/oauth2-azure-provider +use Greew\OAuth2\Client\Provider\Azure; + +if (!isset($_GET['code']) && !isset($_POST['provider'])) { + ?> + + +
    +

    Select Provider

    + +
    + +
    + +
    + +
    +

    Enter id and secret

    +

    These details are obtained by setting up an app in your provider's developer console. +

    +

    ClientId:

    +

    ClientSecret:

    +

    TenantID (only relevant for Azure):

    + +
    + + + $clientId, + 'clientSecret' => $clientSecret, + 'redirectUri' => $redirectUri, + 'accessType' => 'offline' +]; + +$options = []; +$provider = null; + +switch ($providerName) { + case 'Google': + $provider = new Google($params); + $options = [ + 'scope' => [ + 'https://mail.google.com/' + ] + ]; + break; + case 'Yahoo': + $provider = new Yahoo($params); + break; + case 'Microsoft': + $provider = new Microsoft($params); + $options = [ + 'scope' => [ + 'wl.imap', + 'wl.offline_access' + ] + ]; + break; + case 'Azure': + $params['tenantId'] = $tenantId; + + $provider = new Azure($params); + $options = [ + 'scope' => [ + 'https://outlook.office.com/SMTP.Send', + 'offline_access' + ] + ]; + break; +} + +if (null === $provider) { + exit('Provider missing'); +} + +if (!isset($_GET['code'])) { + //If we don't have an authorization code then get one + $authUrl = $provider->getAuthorizationUrl($options); + $_SESSION['oauth2state'] = $provider->getState(); + header('Location: ' . $authUrl); + exit; + //Check given state against previously stored one to mitigate CSRF attack +} elseif (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) { + unset($_SESSION['oauth2state']); + unset($_SESSION['provider']); + exit('Invalid state'); +} else { + unset($_SESSION['provider']); + //Try to get an access token (using the authorization code grant) + $token = $provider->getAccessToken( + 'authorization_code', + [ + 'code' => $_GET['code'] + ] + ); + //Use this to interact with an API on the users behalf + //Use this to get a new access token if the old one expires + echo 'Refresh Token: ', $token->getRefreshToken(); +} diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-af.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-af.php new file mode 100644 index 0000000..0b2a72d --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-af.php @@ -0,0 +1,26 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'خطأ SMTP : لا يمكن تأكيد الهوية.'; +$PHPMAILER_LANG['connect_host'] = 'خطأ SMTP: لا يمكن الاتصال بالخادم SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'خطأ SMTP: لم يتم قبول المعلومات .'; +$PHPMAILER_LANG['empty_message'] = 'نص الرسالة فارغ'; +$PHPMAILER_LANG['encoding'] = 'ترميز غير معروف: '; +$PHPMAILER_LANG['execute'] = 'لا يمكن تنفيذ : '; +$PHPMAILER_LANG['file_access'] = 'لا يمكن الوصول للملف: '; +$PHPMAILER_LANG['file_open'] = 'خطأ في الملف: لا يمكن فتحه: '; +$PHPMAILER_LANG['from_failed'] = 'خطأ على مستوى عنوان المرسل : '; +$PHPMAILER_LANG['instantiate'] = 'لا يمكن توفير خدمة البريد.'; +$PHPMAILER_LANG['invalid_address'] = 'الإرسال غير ممكن لأن عنوان البريد الإلكتروني غير صالح: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' برنامج الإرسال غير مدعوم.'; +$PHPMAILER_LANG['provide_address'] = 'يجب توفير عنوان البريد الإلكتروني لمستلم واحد على الأقل.'; +$PHPMAILER_LANG['recipients_failed'] = 'خطأ SMTP: الأخطاء التالية فشل في الارسال لكل من : '; +$PHPMAILER_LANG['signing'] = 'خطأ في التوقيع: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() غير ممكن.'; +$PHPMAILER_LANG['smtp_error'] = 'خطأ على مستوى الخادم SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'لا يمكن تعيين أو إعادة تعيين متغير: '; +$PHPMAILER_LANG['extension_missing'] = 'الإضافة غير موجودة: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-as.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-as.php new file mode 100644 index 0000000..327dfba --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-as.php @@ -0,0 +1,35 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP ত্ৰুটি: প্ৰমাণীকৰণ কৰিব নোৱাৰি'; +$PHPMAILER_LANG['buggy_php'] = 'আপোনাৰ PHP সংস্কৰণ এটা বাগৰ দ্বাৰা প্ৰভাৱিত হয় যাৰ ফলত নষ্ট বাৰ্তা হব পাৰে । ইয়াক সমাধান কৰিবলে, প্ৰেৰণ কৰিবলে SMTP ব্যৱহাৰ কৰক, আপোনাৰ php.ini ত mail.add_x_header বিকল্প নিষ্ক্ৰিয় কৰক, MacOS বা Linux লৈ সলনি কৰক, বা আপোনাৰ PHP সংস্কৰণ 7.0.17+ বা 7.1.3+ লৈ সলনি কৰক ।'; +$PHPMAILER_LANG['connect_host'] = 'SMTP ত্ৰুটি: SMTP চাৰ্ভাৰৰ সৈতে সংযোগ কৰিবলে অক্ষম'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP ত্ৰুটি: তথ্য গ্ৰহণ কৰা হোৱা নাই'; +$PHPMAILER_LANG['empty_message'] = 'বাৰ্তাৰ মূখ্য অংশ খালী।'; +$PHPMAILER_LANG['encoding'] = 'অজ্ঞাত এনকোডিং: '; +$PHPMAILER_LANG['execute'] = 'এক্সিকিউট কৰিব নোৱাৰি: '; +$PHPMAILER_LANG['extension_missing'] = 'সম্প্ৰসাৰণ নোহোৱা হৈছে: '; +$PHPMAILER_LANG['file_access'] = 'ফাইল অভিগম কৰিবলে অক্ষম: '; +$PHPMAILER_LANG['file_open'] = 'ফাইল ত্ৰুটি: ফাইল খোলিবলৈ অক্ষম: '; +$PHPMAILER_LANG['from_failed'] = 'নিম্নলিখিত প্ৰেৰকৰ ঠিকনা(সমূহ) ব্যৰ্থ: '; +$PHPMAILER_LANG['instantiate'] = 'মেইল ফাংচনৰ এটা উদাহৰণ সৃষ্টি কৰিবলে অক্ষম'; +$PHPMAILER_LANG['invalid_address'] = 'প্ৰেৰণ কৰিব নোৱাৰি: অবৈধ ইমেইল ঠিকনা: '; +$PHPMAILER_LANG['invalid_header'] = 'অবৈধ হেডাৰৰ নাম বা মান'; +$PHPMAILER_LANG['invalid_hostentry'] = 'অবৈধ হোষ্টেন্ট্ৰি: '; +$PHPMAILER_LANG['invalid_host'] = 'অবৈধ হস্ট:'; +$PHPMAILER_LANG['mailer_not_supported'] = 'মেইলাৰ সমৰ্থিত নহয়।'; +$PHPMAILER_LANG['provide_address'] = 'আপুনি অন্ততঃ এটা গন্তব্য ইমেইল ঠিকনা দিব লাগিব'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP ত্ৰুটি: নিম্নলিখিত গন্তব্যস্থানসমূহ ব্যৰ্থ: '; +$PHPMAILER_LANG['signing'] = 'স্বাক্ষৰ কৰাত ব্যৰ্থ: '; +$PHPMAILER_LANG['smtp_code'] = 'SMTP কড: '; +$PHPMAILER_LANG['smtp_code_ex'] = 'অতিৰিক্ত SMTP তথ্য: '; +$PHPMAILER_LANG['smtp_detail'] = 'বিৱৰণ:'; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP সংযোগ() ব্যৰ্থ'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP চাৰ্ভাৰৰ ত্ৰুটি: '; +$PHPMAILER_LANG['variable_set'] = 'চলক নিৰ্ধাৰণ কৰিব পৰা নগল: '; +$PHPMAILER_LANG['extension_missing'] = 'অনুপস্থিত সম্প্ৰসাৰণ: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-az.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-az.php new file mode 100644 index 0000000..552167e --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-az.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP Greška: Neuspjela prijava.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP Greška: Nije moguće spojiti se sa SMTP serverom.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP Greška: Podatci nisu prihvaćeni.'; +$PHPMAILER_LANG['empty_message'] = 'Sadržaj poruke je prazan.'; +$PHPMAILER_LANG['encoding'] = 'Nepoznata kriptografija: '; +$PHPMAILER_LANG['execute'] = 'Nije moguće izvršiti naredbu: '; +$PHPMAILER_LANG['file_access'] = 'Nije moguće pristupiti datoteci: '; +$PHPMAILER_LANG['file_open'] = 'Nije moguće otvoriti datoteku: '; +$PHPMAILER_LANG['from_failed'] = 'SMTP Greška: Slanje sa navedenih e-mail adresa nije uspjelo: '; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP Greška: Slanje na navedene e-mail adrese nije uspjelo: '; +$PHPMAILER_LANG['instantiate'] = 'Ne mogu pokrenuti mail funkcionalnost.'; +$PHPMAILER_LANG['invalid_address'] = 'E-mail nije poslan. Neispravna e-mail adresa: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer nije podržan.'; +$PHPMAILER_LANG['provide_address'] = 'Definišite barem jednu adresu primaoca.'; +$PHPMAILER_LANG['signing'] = 'Greška prilikom prijave: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Spajanje na SMTP server nije uspjelo.'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP greška: '; +$PHPMAILER_LANG['variable_set'] = 'Nije moguće postaviti varijablu ili je vratiti nazad: '; +$PHPMAILER_LANG['extension_missing'] = 'Nedostaje ekstenzija: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-be.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-be.php new file mode 100644 index 0000000..9e92dda --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-be.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'Памылка SMTP: памылка ідэнтыфікацыі.'; +$PHPMAILER_LANG['connect_host'] = 'Памылка SMTP: нельга ўстанавіць сувязь з SMTP-серверам.'; +$PHPMAILER_LANG['data_not_accepted'] = 'Памылка SMTP: звесткі непрынятыя.'; +$PHPMAILER_LANG['empty_message'] = 'Пустое паведамленне.'; +$PHPMAILER_LANG['encoding'] = 'Невядомая кадыроўка тэксту: '; +$PHPMAILER_LANG['execute'] = 'Нельга выканаць каманду: '; +$PHPMAILER_LANG['file_access'] = 'Няма доступу да файла: '; +$PHPMAILER_LANG['file_open'] = 'Нельга адкрыць файл: '; +$PHPMAILER_LANG['from_failed'] = 'Няправільны адрас адпраўніка: '; +$PHPMAILER_LANG['instantiate'] = 'Нельга прымяніць функцыю mail().'; +$PHPMAILER_LANG['invalid_address'] = 'Нельга даслаць паведамленне, няправільны email атрымальніка: '; +$PHPMAILER_LANG['provide_address'] = 'Запоўніце, калі ласка, правільны email атрымальніка.'; +$PHPMAILER_LANG['mailer_not_supported'] = ' - паштовы сервер не падтрымліваецца.'; +$PHPMAILER_LANG['recipients_failed'] = 'Памылка SMTP: няправільныя атрымальнікі: '; +$PHPMAILER_LANG['signing'] = 'Памылка подпісу паведамлення: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Памылка сувязі з SMTP-серверам.'; +$PHPMAILER_LANG['smtp_error'] = 'Памылка SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'Нельга ўстанавіць або перамяніць значэнне пераменнай: '; +//$PHPMAILER_LANG['extension_missing'] = 'Extension missing: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-bg.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-bg.php new file mode 100644 index 0000000..c41f675 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-bg.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP грешка: Не може да се удостовери пред сървъра.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP грешка: Не може да се свърже с SMTP хоста.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP грешка: данните не са приети.'; +$PHPMAILER_LANG['empty_message'] = 'Съдържанието на съобщението е празно'; +$PHPMAILER_LANG['encoding'] = 'Неизвестно кодиране: '; +$PHPMAILER_LANG['execute'] = 'Не може да се изпълни: '; +$PHPMAILER_LANG['file_access'] = 'Няма достъп до файл: '; +$PHPMAILER_LANG['file_open'] = 'Файлова грешка: Не може да се отвори файл: '; +$PHPMAILER_LANG['from_failed'] = 'Следните адреси за подател са невалидни: '; +$PHPMAILER_LANG['instantiate'] = 'Не може да се инстанцира функцията mail.'; +$PHPMAILER_LANG['invalid_address'] = 'Невалиден адрес: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' - пощенски сървър не се поддържа.'; +$PHPMAILER_LANG['provide_address'] = 'Трябва да предоставите поне един email адрес за получател.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP грешка: Следните адреси за Получател са невалидни: '; +$PHPMAILER_LANG['signing'] = 'Грешка при подписване: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP провален connect().'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP сървърна грешка: '; +$PHPMAILER_LANG['variable_set'] = 'Не може да се установи или възстанови променлива: '; +$PHPMAILER_LANG['extension_missing'] = 'Липсва разширение: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-bn.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-bn.php new file mode 100644 index 0000000..4736510 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-bn.php @@ -0,0 +1,35 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP ত্রুটি: প্রমাণীকরণ করতে অক্ষম৷'; +$PHPMAILER_LANG['buggy_php'] = 'আপনার PHP সংস্করণ একটি বাগ দ্বারা প্রভাবিত হয় যার ফলে দূষিত বার্তা হতে পারে। এটি ঠিক করতে, পাঠাতে SMTP ব্যবহার করুন, আপনার php.ini এ mail.add_x_header বিকল্পটি নিষ্ক্রিয় করুন, MacOS বা Linux-এ স্যুইচ করুন, অথবা আপনার PHP সংস্করণকে 7.0.17+ বা 7.1.3+ এ পরিবর্তন করুন।'; +$PHPMAILER_LANG['connect_host'] = 'SMTP ত্রুটি: SMTP সার্ভারের সাথে সংযোগ করতে অক্ষম৷'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP ত্রুটি: ডেটা গ্রহণ করা হয়নি৷'; +$PHPMAILER_LANG['empty_message'] = 'বার্তার অংশটি খালি।'; +$PHPMAILER_LANG['encoding'] = 'অজানা এনকোডিং: '; +$PHPMAILER_LANG['execute'] = 'নির্বাহ করতে অক্ষম: '; +$PHPMAILER_LANG['extension_missing'] = 'এক্সটেনশন অনুপস্থিত:'; +$PHPMAILER_LANG['file_access'] = 'ফাইল অ্যাক্সেস করতে অক্ষম: '; +$PHPMAILER_LANG['file_open'] = 'ফাইল ত্রুটি: ফাইল খুলতে অক্ষম: '; +$PHPMAILER_LANG['from_failed'] = 'নিম্নলিখিত প্রেরকের ঠিকানা(গুলি) ব্যর্থ হয়েছে: '; +$PHPMAILER_LANG['instantiate'] = 'মেল ফাংশনের একটি উদাহরণ তৈরি করতে অক্ষম৷'; +$PHPMAILER_LANG['invalid_address'] = 'পাঠাতে অক্ষম: অবৈধ ইমেল ঠিকানা: '; +$PHPMAILER_LANG['invalid_header'] = 'অবৈধ হেডার নাম বা মান'; +$PHPMAILER_LANG['invalid_hostentry'] = 'অবৈধ হোস্টেন্ট্রি: '; +$PHPMAILER_LANG['invalid_host'] = 'অবৈধ হোস্ট:'; +$PHPMAILER_LANG['mailer_not_supported'] = 'মেইলার সমর্থিত নয়।'; +$PHPMAILER_LANG['provide_address'] = 'আপনাকে অবশ্যই অন্তত একটি গন্তব্য ইমেল ঠিকানা প্রদান করতে হবে৷'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP ত্রুটি: নিম্নলিখিত গন্তব্যগুলি ব্যর্থ হয়েছে: '; +$PHPMAILER_LANG['signing'] = 'স্বাক্ষর করতে ব্যর্থ হয়েছে: '; +$PHPMAILER_LANG['smtp_code'] = 'SMTP কোড: '; +$PHPMAILER_LANG['smtp_code_ex'] = 'অতিরিক্ত SMTP তথ্য:'; +$PHPMAILER_LANG['smtp_detail'] = 'বর্ণনা: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP সংযোগ() ব্যর্থ হয়েছে৷'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP সার্ভার ত্রুটি: '; +$PHPMAILER_LANG['variable_set'] = 'পরিবর্তনশীল সেট করা যায়নি: '; +$PHPMAILER_LANG['extension_missing'] = 'অনুপস্থিত এক্সটেনশন: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ca.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ca.php new file mode 100644 index 0000000..3468485 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ca.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'Error SMTP: No s’ha pogut autenticar.'; +$PHPMAILER_LANG['connect_host'] = 'Error SMTP: No es pot connectar al servidor SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'Error SMTP: Dades no acceptades.'; +$PHPMAILER_LANG['empty_message'] = 'El cos del missatge està buit.'; +$PHPMAILER_LANG['encoding'] = 'Codificació desconeguda: '; +$PHPMAILER_LANG['execute'] = 'No es pot executar: '; +$PHPMAILER_LANG['file_access'] = 'No es pot accedir a l’arxiu: '; +$PHPMAILER_LANG['file_open'] = 'Error d’Arxiu: No es pot obrir l’arxiu: '; +$PHPMAILER_LANG['from_failed'] = 'La(s) següent(s) adreces de remitent han fallat: '; +$PHPMAILER_LANG['instantiate'] = 'No s’ha pogut crear una instància de la funció Mail.'; +$PHPMAILER_LANG['invalid_address'] = 'Adreça d’email invalida: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer no està suportat'; +$PHPMAILER_LANG['provide_address'] = 'S’ha de proveir almenys una adreça d’email com a destinatari.'; +$PHPMAILER_LANG['recipients_failed'] = 'Error SMTP: Els següents destinataris han fallat: '; +$PHPMAILER_LANG['signing'] = 'Error al signar: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Ha fallat el SMTP Connect().'; +$PHPMAILER_LANG['smtp_error'] = 'Error del servidor SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'No s’ha pogut establir o restablir la variable: '; +//$PHPMAILER_LANG['extension_missing'] = 'Extension missing: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-cs.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-cs.php new file mode 100644 index 0000000..e770a1a --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-cs.php @@ -0,0 +1,28 @@ + + * Rewrite and extension of the work by Mikael Stokkebro + * + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP fejl: Login mislykkedes.'; +$PHPMAILER_LANG['buggy_php'] = 'Din version af PHP er berørt af en fejl, som gør at dine beskeder muligvis vises forkert. For at rette dette kan du skifte til SMTP, slå mail.add_x_header headeren i din php.ini fil fra, skifte til MacOS eller Linux eller opgradere din version af PHP til 7.0.17+ eller 7.1.3+.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP fejl: Forbindelse til SMTP serveren kunne ikke oprettes.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP fejl: Data blev ikke accepteret.'; +$PHPMAILER_LANG['empty_message'] = 'Meddelelsen er uden indhold'; +$PHPMAILER_LANG['encoding'] = 'Ukendt encode-format: '; +$PHPMAILER_LANG['execute'] = 'Kunne ikke afvikle: '; +$PHPMAILER_LANG['extension_missing'] = 'Udvidelse mangler: '; +$PHPMAILER_LANG['file_access'] = 'Kunne ikke tilgå filen: '; +$PHPMAILER_LANG['file_open'] = 'Fil fejl: Kunne ikke åbne filen: '; +$PHPMAILER_LANG['from_failed'] = 'Følgende afsenderadresse er forkert: '; +$PHPMAILER_LANG['instantiate'] = 'Email funktionen kunne ikke initialiseres.'; +$PHPMAILER_LANG['invalid_address'] = 'Udgyldig adresse: '; +$PHPMAILER_LANG['invalid_header'] = 'Ugyldig header navn eller værdi'; +$PHPMAILER_LANG['invalid_hostentry'] = 'Ugyldig hostentry: '; +$PHPMAILER_LANG['invalid_host'] = 'Ugyldig vært: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer understøttes ikke.'; +$PHPMAILER_LANG['provide_address'] = 'Indtast mindst en modtagers email adresse.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP fejl: Følgende modtagere fejlede: '; +$PHPMAILER_LANG['signing'] = 'Signeringsfejl: '; +$PHPMAILER_LANG['smtp_code'] = 'SMTP kode: '; +$PHPMAILER_LANG['smtp_code_ex'] = 'Yderligere SMTP info: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() fejlede.'; +$PHPMAILER_LANG['smtp_detail'] = 'Detalje: '; +$PHPMAILER_LANG['smtp_error'] = 'SMTP server fejl: '; +$PHPMAILER_LANG['variable_set'] = 'Kunne ikke definere eller nulstille variablen: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-de.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-de.php new file mode 100644 index 0000000..e7e59d2 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-de.php @@ -0,0 +1,28 @@ + + * @author Crystopher Glodzienski Cardoso + */ + +$PHPMAILER_LANG['authenticate'] = 'Error SMTP: Imposible autentificar.'; +$PHPMAILER_LANG['connect_host'] = 'Error SMTP: Imposible conectar al servidor SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'Error SMTP: Datos no aceptados.'; +$PHPMAILER_LANG['empty_message'] = 'El cuerpo del mensaje está vacío.'; +$PHPMAILER_LANG['encoding'] = 'Codificación desconocida: '; +$PHPMAILER_LANG['execute'] = 'Imposible ejecutar: '; +$PHPMAILER_LANG['file_access'] = 'Imposible acceder al archivo: '; +$PHPMAILER_LANG['file_open'] = 'Error de Archivo: Imposible abrir el archivo: '; +$PHPMAILER_LANG['from_failed'] = 'La(s) siguiente(s) direcciones de remitente fallaron: '; +$PHPMAILER_LANG['instantiate'] = 'Imposible crear una instancia de la función Mail.'; +$PHPMAILER_LANG['invalid_address'] = 'Imposible enviar: dirección de email inválido: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer no está soportado.'; +$PHPMAILER_LANG['provide_address'] = 'Debe proporcionar al menos una dirección de email de destino.'; +$PHPMAILER_LANG['recipients_failed'] = 'Error SMTP: Los siguientes destinos fallaron: '; +$PHPMAILER_LANG['signing'] = 'Error al firmar: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() falló.'; +$PHPMAILER_LANG['smtp_error'] = 'Error del servidor SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'No se pudo configurar la variable: '; +$PHPMAILER_LANG['extension_missing'] = 'Extensión faltante: '; +$PHPMAILER_LANG['smtp_code'] = 'Código del servidor SMTP: '; +$PHPMAILER_LANG['smtp_code_ex'] = 'Información adicional del servidor SMTP: '; +$PHPMAILER_LANG['invalid_header'] = 'Nombre o valor de encabezado no válido'; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-et.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-et.php new file mode 100644 index 0000000..93addc9 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-et.php @@ -0,0 +1,28 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP Viga: Autoriseerimise viga.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP Viga: Ei õnnestunud luua ühendust SMTP serveriga.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP Viga: Vigased andmed.'; +$PHPMAILER_LANG['empty_message'] = 'Tühi kirja sisu'; +$PHPMAILER_LANG["encoding"] = 'Tundmatu kodeering: '; +$PHPMAILER_LANG['execute'] = 'Tegevus ebaõnnestus: '; +$PHPMAILER_LANG['file_access'] = 'Pole piisavalt õiguseid järgneva faili avamiseks: '; +$PHPMAILER_LANG['file_open'] = 'Faili Viga: Faili avamine ebaõnnestus: '; +$PHPMAILER_LANG['from_failed'] = 'Järgnev saatja e-posti aadress on vigane: '; +$PHPMAILER_LANG['instantiate'] = 'mail funktiooni käivitamine ebaõnnestus.'; +$PHPMAILER_LANG['invalid_address'] = 'Saatmine peatatud, e-posti address vigane: '; +$PHPMAILER_LANG['provide_address'] = 'Te peate määrama vähemalt ühe saaja e-posti aadressi.'; +$PHPMAILER_LANG['mailer_not_supported'] = ' maileri tugi puudub.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP Viga: Järgnevate saajate e-posti aadressid on vigased: '; +$PHPMAILER_LANG["signing"] = 'Viga allkirjastamisel: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() ebaõnnestus.'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP serveri viga: '; +$PHPMAILER_LANG['variable_set'] = 'Ei õnnestunud määrata või lähtestada muutujat: '; +$PHPMAILER_LANG['extension_missing'] = 'Nõutud laiendus on puudu: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-fa.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-fa.php new file mode 100644 index 0000000..295a47f --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-fa.php @@ -0,0 +1,28 @@ + + * @author Mohammad Hossein Mojtahedi + */ + +$PHPMAILER_LANG['authenticate'] = 'خطای SMTP: احراز هویت با شکست مواجه شد.'; +$PHPMAILER_LANG['connect_host'] = 'خطای SMTP: اتصال به سرور SMTP برقرار نشد.'; +$PHPMAILER_LANG['data_not_accepted'] = 'خطای SMTP: داده‌ها نا‌درست هستند.'; +$PHPMAILER_LANG['empty_message'] = 'بخش متن پیام خالی است.'; +$PHPMAILER_LANG['encoding'] = 'کد‌گذاری نا‌شناخته: '; +$PHPMAILER_LANG['execute'] = 'امکان اجرا وجود ندارد: '; +$PHPMAILER_LANG['file_access'] = 'امکان دسترسی به فایل وجود ندارد: '; +$PHPMAILER_LANG['file_open'] = 'خطای File: امکان بازکردن فایل وجود ندارد: '; +$PHPMAILER_LANG['from_failed'] = 'آدرس فرستنده اشتباه است: '; +$PHPMAILER_LANG['instantiate'] = 'امکان معرفی تابع ایمیل وجود ندارد.'; +$PHPMAILER_LANG['invalid_address'] = 'آدرس ایمیل معتبر نیست: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer پشتیبانی نمی‌شود.'; +$PHPMAILER_LANG['provide_address'] = 'باید حداقل یک آدرس گیرنده وارد کنید.'; +$PHPMAILER_LANG['recipients_failed'] = 'خطای SMTP: ارسال به آدرس گیرنده با خطا مواجه شد: '; +$PHPMAILER_LANG['signing'] = 'خطا در امضا: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'خطا در اتصال به SMTP.'; +$PHPMAILER_LANG['smtp_error'] = 'خطا در SMTP Server: '; +$PHPMAILER_LANG['variable_set'] = 'امکان ارسال یا ارسال مجدد متغیر‌ها وجود ندارد: '; +$PHPMAILER_LANG['extension_missing'] = 'افزونه موجود نیست: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-fi.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-fi.php new file mode 100644 index 0000000..6d1e637 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-fi.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP feilur: Kundi ikki góðkenna.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP feilur: Kundi ikki knýta samband við SMTP vert.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP feilur: Data ikki góðkent.'; +//$PHPMAILER_LANG['empty_message'] = 'Message body empty'; +$PHPMAILER_LANG['encoding'] = 'Ókend encoding: '; +$PHPMAILER_LANG['execute'] = 'Kundi ikki útføra: '; +$PHPMAILER_LANG['file_access'] = 'Kundi ikki tilganga fílu: '; +$PHPMAILER_LANG['file_open'] = 'Fílu feilur: Kundi ikki opna fílu: '; +$PHPMAILER_LANG['from_failed'] = 'fylgjandi Frá/From adressa miseydnaðist: '; +$PHPMAILER_LANG['instantiate'] = 'Kuni ikki instantiera mail funktión.'; +//$PHPMAILER_LANG['invalid_address'] = 'Invalid address: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' er ikki supporterað.'; +$PHPMAILER_LANG['provide_address'] = 'Tú skal uppgeva minst móttakara-emailadressu(r).'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP Feilur: Fylgjandi móttakarar miseydnaðust: '; +//$PHPMAILER_LANG['signing'] = 'Signing Error: '; +//$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() failed.'; +//$PHPMAILER_LANG['smtp_error'] = 'SMTP server error: '; +//$PHPMAILER_LANG['variable_set'] = 'Cannot set or reset variable: '; +//$PHPMAILER_LANG['extension_missing'] = 'Extension missing: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-fr.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-fr.php new file mode 100644 index 0000000..0d367fc --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-fr.php @@ -0,0 +1,37 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'Erro SMTP: Non puido ser autentificado.'; +$PHPMAILER_LANG['connect_host'] = 'Erro SMTP: Non puido conectar co servidor SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'Erro SMTP: Datos non aceptados.'; +$PHPMAILER_LANG['empty_message'] = 'Corpo da mensaxe vacía'; +$PHPMAILER_LANG['encoding'] = 'Codificación descoñecida: '; +$PHPMAILER_LANG['execute'] = 'Non puido ser executado: '; +$PHPMAILER_LANG['file_access'] = 'Nob puido acceder ó arquivo: '; +$PHPMAILER_LANG['file_open'] = 'Erro de Arquivo: No puido abrir o arquivo: '; +$PHPMAILER_LANG['from_failed'] = 'A(s) seguinte(s) dirección(s) de remitente(s) deron erro: '; +$PHPMAILER_LANG['instantiate'] = 'Non puido crear unha instancia da función Mail.'; +$PHPMAILER_LANG['invalid_address'] = 'Non puido envia-lo correo: dirección de email inválida: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer non está soportado.'; +$PHPMAILER_LANG['provide_address'] = 'Debe engadir polo menos unha dirección de email coma destino.'; +$PHPMAILER_LANG['recipients_failed'] = 'Erro SMTP: Os seguintes destinos fallaron: '; +$PHPMAILER_LANG['signing'] = 'Erro ó firmar: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() fallou.'; +$PHPMAILER_LANG['smtp_error'] = 'Erro do servidor SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'Non puidemos axustar ou reaxustar a variábel: '; +//$PHPMAILER_LANG['extension_missing'] = 'Extension missing: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-he.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-he.php new file mode 100644 index 0000000..b123aa5 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-he.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'שגיאת SMTP: פעולת האימות נכשלה.'; +$PHPMAILER_LANG['connect_host'] = 'שגיאת SMTP: לא הצלחתי להתחבר לשרת SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'שגיאת SMTP: מידע לא התקבל.'; +$PHPMAILER_LANG['empty_message'] = 'גוף ההודעה ריק'; +$PHPMAILER_LANG['invalid_address'] = 'כתובת שגויה: '; +$PHPMAILER_LANG['encoding'] = 'קידוד לא מוכר: '; +$PHPMAILER_LANG['execute'] = 'לא הצלחתי להפעיל את: '; +$PHPMAILER_LANG['file_access'] = 'לא ניתן לגשת לקובץ: '; +$PHPMAILER_LANG['file_open'] = 'שגיאת קובץ: לא ניתן לגשת לקובץ: '; +$PHPMAILER_LANG['from_failed'] = 'כתובות הנמענים הבאות נכשלו: '; +$PHPMAILER_LANG['instantiate'] = 'לא הצלחתי להפעיל את פונקציית המייל.'; +$PHPMAILER_LANG['mailer_not_supported'] = ' אינה נתמכת.'; +$PHPMAILER_LANG['provide_address'] = 'חובה לספק לפחות כתובת אחת של מקבל המייל.'; +$PHPMAILER_LANG['recipients_failed'] = 'שגיאת SMTP: הנמענים הבאים נכשלו: '; +$PHPMAILER_LANG['signing'] = 'שגיאת חתימה: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() failed.'; +$PHPMAILER_LANG['smtp_error'] = 'שגיאת שרת SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'לא ניתן לקבוע או לשנות את המשתנה: '; +//$PHPMAILER_LANG['extension_missing'] = 'Extension missing: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-hi.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-hi.php new file mode 100644 index 0000000..d2856e0 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-hi.php @@ -0,0 +1,35 @@ + + * Rewrite and extension of the work by Jayanti Suthar + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP त्रुटि: प्रामाणिकता की जांच नहीं हो सका। '; +$PHPMAILER_LANG['buggy_php'] = 'PHP का आपका संस्करण एक बग से प्रभावित है जिसके परिणामस्वरूप संदेश दूषित हो सकते हैं. इसे ठीक करने हेतु, भेजने के लिए SMTP का उपयोग करे, अपने php.ini में mail.add_x_header विकल्प को अक्षम करें, MacOS या Linux पर जाए, या अपने PHP संस्करण को 7.0.17+ या 7.1.3+ बदले.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP त्रुटि: SMTP सर्वर से कनेक्ट नहीं हो सका। '; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP त्रुटि: डेटा स्वीकार नहीं किया जाता है। '; +$PHPMAILER_LANG['empty_message'] = 'संदेश खाली है। '; +$PHPMAILER_LANG['encoding'] = 'अज्ञात एन्कोडिंग प्रकार। '; +$PHPMAILER_LANG['execute'] = 'आदेश को निष्पादित करने में विफल। '; +$PHPMAILER_LANG['extension_missing'] = 'एक्सटेन्षन गायब है: '; +$PHPMAILER_LANG['file_access'] = 'फ़ाइल उपलब्ध नहीं है। '; +$PHPMAILER_LANG['file_open'] = 'फ़ाइल त्रुटि: फाइल को खोला नहीं जा सका। '; +$PHPMAILER_LANG['from_failed'] = 'प्रेषक का पता गलत है। '; +$PHPMAILER_LANG['instantiate'] = 'मेल फ़ंक्शन कॉल नहीं कर सकता है।'; +$PHPMAILER_LANG['invalid_address'] = 'पता गलत है। '; +$PHPMAILER_LANG['invalid_header'] = 'अमान्य हेडर नाम या मान'; +$PHPMAILER_LANG['invalid_hostentry'] = 'अमान्य hostentry: '; +$PHPMAILER_LANG['invalid_host'] = 'अमान्य होस्ट: '; +$PHPMAILER_LANG['mailer_not_supported'] = 'मेल सर्वर के साथ काम नहीं करता है। '; +$PHPMAILER_LANG['provide_address'] = 'आपको कम से कम एक प्राप्तकर्ता का ई-मेल पता प्रदान करना होगा।'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP त्रुटि: निम्न प्राप्तकर्ताओं को पते भेजने में विफल। '; +$PHPMAILER_LANG['signing'] = 'साइनअप त्रुटि: '; +$PHPMAILER_LANG['smtp_code'] = 'SMTP कोड: '; +$PHPMAILER_LANG['smtp_code_ex'] = 'अतिरिक्त SMTP जानकारी: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP का connect () फ़ंक्शन विफल हुआ। '; +$PHPMAILER_LANG['smtp_detail'] = 'विवरण: '; +$PHPMAILER_LANG['smtp_error'] = 'SMTP सर्वर त्रुटि। '; +$PHPMAILER_LANG['variable_set'] = 'चर को बना या संशोधित नहीं किया जा सकता। '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-hr.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-hr.php new file mode 100644 index 0000000..cacb6c3 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-hr.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP Greška: Neuspjela autentikacija.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP Greška: Ne mogu se spojiti na SMTP poslužitelj.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP Greška: Podatci nisu prihvaćeni.'; +$PHPMAILER_LANG['empty_message'] = 'Sadržaj poruke je prazan.'; +$PHPMAILER_LANG['encoding'] = 'Nepoznati encoding: '; +$PHPMAILER_LANG['execute'] = 'Nije moguće izvršiti naredbu: '; +$PHPMAILER_LANG['file_access'] = 'Nije moguće pristupiti datoteci: '; +$PHPMAILER_LANG['file_open'] = 'Nije moguće otvoriti datoteku: '; +$PHPMAILER_LANG['from_failed'] = 'SMTP Greška: Slanje s navedenih e-mail adresa nije uspjelo: '; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP Greška: Slanje na navedenih e-mail adresa nije uspjelo: '; +$PHPMAILER_LANG['instantiate'] = 'Ne mogu pokrenuti mail funkcionalnost.'; +$PHPMAILER_LANG['invalid_address'] = 'E-mail nije poslan. Neispravna e-mail adresa: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer nije podržan.'; +$PHPMAILER_LANG['provide_address'] = 'Definirajte barem jednu adresu primatelja.'; +$PHPMAILER_LANG['signing'] = 'Greška prilikom prijave: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Spajanje na SMTP poslužitelj nije uspjelo.'; +$PHPMAILER_LANG['smtp_error'] = 'Greška SMTP poslužitelja: '; +$PHPMAILER_LANG['variable_set'] = 'Ne mogu postaviti varijablu niti ju vratiti nazad: '; +$PHPMAILER_LANG['extension_missing'] = 'Nedostaje proširenje: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-hu.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-hu.php new file mode 100644 index 0000000..e6b58b0 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-hu.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP -ի սխալ: չհաջողվեց ստուգել իսկությունը.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP -ի սխալ: չհաջողվեց կապ հաստատել SMTP սերվերի հետ.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP -ի սխալ: տվյալները ընդունված չեն.'; +$PHPMAILER_LANG['empty_message'] = 'Հաղորդագրությունը դատարկ է'; +$PHPMAILER_LANG['encoding'] = 'Կոդավորման անհայտ տեսակ: '; +$PHPMAILER_LANG['execute'] = 'Չհաջողվեց իրականացնել հրամանը: '; +$PHPMAILER_LANG['file_access'] = 'Ֆայլը հասանելի չէ: '; +$PHPMAILER_LANG['file_open'] = 'Ֆայլի սխալ: ֆայլը չհաջողվեց բացել: '; +$PHPMAILER_LANG['from_failed'] = 'Ուղարկողի հետևյալ հասցեն սխալ է: '; +$PHPMAILER_LANG['instantiate'] = 'Հնարավոր չէ կանչել mail ֆունկցիան.'; +$PHPMAILER_LANG['invalid_address'] = 'Հասցեն սխալ է: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' փոստային սերվերի հետ չի աշխատում.'; +$PHPMAILER_LANG['provide_address'] = 'Անհրաժեշտ է տրամադրել գոնե մեկ ստացողի e-mail հասցե.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP -ի սխալ: չի հաջողվել ուղարկել հետևյալ ստացողների հասցեներին: '; +$PHPMAILER_LANG['signing'] = 'Ստորագրման սխալ: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP -ի connect() ֆունկցիան չի հաջողվել'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP սերվերի սխալ: '; +$PHPMAILER_LANG['variable_set'] = 'Չի հաջողվում ստեղծել կամ վերափոխել փոփոխականը: '; +$PHPMAILER_LANG['extension_missing'] = 'Հավելվածը բացակայում է: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-id.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-id.php new file mode 100644 index 0000000..212a11f --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-id.php @@ -0,0 +1,31 @@ + + * @author @januridp + * @author Ian Mustafa + */ + +$PHPMAILER_LANG['authenticate'] = 'Kesalahan SMTP: Tidak dapat mengotentikasi.'; +$PHPMAILER_LANG['connect_host'] = 'Kesalahan SMTP: Tidak dapat terhubung ke host SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'Kesalahan SMTP: Data tidak diterima.'; +$PHPMAILER_LANG['empty_message'] = 'Isi pesan kosong'; +$PHPMAILER_LANG['encoding'] = 'Pengkodean karakter tidak dikenali: '; +$PHPMAILER_LANG['execute'] = 'Tidak dapat menjalankan proses: '; +$PHPMAILER_LANG['file_access'] = 'Tidak dapat mengakses berkas: '; +$PHPMAILER_LANG['file_open'] = 'Kesalahan Berkas: Berkas tidak dapat dibuka: '; +$PHPMAILER_LANG['from_failed'] = 'Alamat pengirim berikut mengakibatkan kesalahan: '; +$PHPMAILER_LANG['instantiate'] = 'Tidak dapat menginisialisasi fungsi surel.'; +$PHPMAILER_LANG['invalid_address'] = 'Gagal terkirim, alamat surel tidak sesuai: '; +$PHPMAILER_LANG['invalid_hostentry'] = 'Gagal terkirim, entri host tidak sesuai: '; +$PHPMAILER_LANG['invalid_host'] = 'Gagal terkirim, host tidak sesuai: '; +$PHPMAILER_LANG['provide_address'] = 'Harus tersedia minimal satu alamat tujuan'; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer tidak didukung'; +$PHPMAILER_LANG['recipients_failed'] = 'Kesalahan SMTP: Alamat tujuan berikut menyebabkan kesalahan: '; +$PHPMAILER_LANG['signing'] = 'Kesalahan dalam penandatangan SSL: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() gagal.'; +$PHPMAILER_LANG['smtp_error'] = 'Kesalahan pada pelayan SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'Tidak dapat mengatur atau mengatur ulang variabel: '; +$PHPMAILER_LANG['extension_missing'] = 'Ekstensi PHP tidak tersedia: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-it.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-it.php new file mode 100644 index 0000000..08a6b73 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-it.php @@ -0,0 +1,28 @@ + + * @author Stefano Sabatini + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP Error: Impossibile autenticarsi.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP Error: Impossibile connettersi all\'host SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP Error: Dati non accettati dal server.'; +$PHPMAILER_LANG['empty_message'] = 'Il corpo del messaggio è vuoto'; +$PHPMAILER_LANG['encoding'] = 'Codifica dei caratteri sconosciuta: '; +$PHPMAILER_LANG['execute'] = 'Impossibile eseguire l\'operazione: '; +$PHPMAILER_LANG['file_access'] = 'Impossibile accedere al file: '; +$PHPMAILER_LANG['file_open'] = 'File Error: Impossibile aprire il file: '; +$PHPMAILER_LANG['from_failed'] = 'I seguenti indirizzi mittenti hanno generato errore: '; +$PHPMAILER_LANG['instantiate'] = 'Impossibile istanziare la funzione mail'; +$PHPMAILER_LANG['invalid_address'] = 'Impossibile inviare, l\'indirizzo email non è valido: '; +$PHPMAILER_LANG['provide_address'] = 'Deve essere fornito almeno un indirizzo ricevente'; +$PHPMAILER_LANG['mailer_not_supported'] = 'Mailer non supportato'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP Error: I seguenti indirizzi destinatari hanno generato un errore: '; +$PHPMAILER_LANG['signing'] = 'Errore nella firma: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() fallita.'; +$PHPMAILER_LANG['smtp_error'] = 'Errore del server SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'Impossibile impostare o resettare la variabile: '; +$PHPMAILER_LANG['extension_missing'] = 'Estensione mancante: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ja.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ja.php new file mode 100644 index 0000000..c76f526 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ja.php @@ -0,0 +1,29 @@ + + * @author Yoshi Sakai + * @author Arisophy + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTPエラー: 認証できませんでした。'; +$PHPMAILER_LANG['connect_host'] = 'SMTPエラー: SMTPホストに接続できませんでした。'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTPエラー: データが受け付けられませんでした。'; +$PHPMAILER_LANG['empty_message'] = 'メール本文が空です。'; +$PHPMAILER_LANG['encoding'] = '不明なエンコーディング: '; +$PHPMAILER_LANG['execute'] = '実行できませんでした: '; +$PHPMAILER_LANG['file_access'] = 'ファイルにアクセスできません: '; +$PHPMAILER_LANG['file_open'] = 'ファイルエラー: ファイルを開けません: '; +$PHPMAILER_LANG['from_failed'] = 'Fromアドレスを登録する際にエラーが発生しました: '; +$PHPMAILER_LANG['instantiate'] = 'メール関数が正常に動作しませんでした。'; +$PHPMAILER_LANG['invalid_address'] = '不正なメールアドレス: '; +$PHPMAILER_LANG['provide_address'] = '少なくとも1つメールアドレスを 指定する必要があります。'; +$PHPMAILER_LANG['mailer_not_supported'] = ' メーラーがサポートされていません。'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTPエラー: 次の受信者アドレスに 間違いがあります: '; +$PHPMAILER_LANG['signing'] = '署名エラー: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP接続に失敗しました。'; +$PHPMAILER_LANG['smtp_error'] = 'SMTPサーバーエラー: '; +$PHPMAILER_LANG['variable_set'] = '変数が存在しません: '; +$PHPMAILER_LANG['extension_missing'] = '拡張機能が見つかりません: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ka.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ka.php new file mode 100644 index 0000000..51fe403 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ka.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP შეცდომა: ავტორიზაცია შეუძლებელია.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP შეცდომა: SMTP სერვერთან დაკავშირება შეუძლებელია.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP შეცდომა: მონაცემები არ იქნა მიღებული.'; +$PHPMAILER_LANG['encoding'] = 'კოდირების უცნობი ტიპი: '; +$PHPMAILER_LANG['execute'] = 'შეუძლებელია შემდეგი ბრძანების შესრულება: '; +$PHPMAILER_LANG['file_access'] = 'შეუძლებელია წვდომა ფაილთან: '; +$PHPMAILER_LANG['file_open'] = 'ფაილური სისტემის შეცდომა: არ იხსნება ფაილი: '; +$PHPMAILER_LANG['from_failed'] = 'გამგზავნის არასწორი მისამართი: '; +$PHPMAILER_LANG['instantiate'] = 'mail ფუნქციის გაშვება ვერ ხერხდება.'; +$PHPMAILER_LANG['provide_address'] = 'გთხოვთ მიუთითოთ ერთი ადრესატის e-mail მისამართი მაინც.'; +$PHPMAILER_LANG['mailer_not_supported'] = ' - საფოსტო სერვერის მხარდაჭერა არ არის.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP შეცდომა: შემდეგ მისამართებზე გაგზავნა ვერ მოხერხდა: '; +$PHPMAILER_LANG['empty_message'] = 'შეტყობინება ცარიელია'; +$PHPMAILER_LANG['invalid_address'] = 'არ გაიგზავნა, e-mail მისამართის არასწორი ფორმატი: '; +$PHPMAILER_LANG['signing'] = 'ხელმოწერის შეცდომა: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'შეცდომა SMTP სერვერთან დაკავშირებისას'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP სერვერის შეცდომა: '; +$PHPMAILER_LANG['variable_set'] = 'შეუძლებელია შემდეგი ცვლადის შექმნა ან შეცვლა: '; +$PHPMAILER_LANG['extension_missing'] = 'ბიბლიოთეკა არ არსებობს: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ko.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ko.php new file mode 100644 index 0000000..8c97dd9 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ko.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP 오류: 인증할 수 없습니다.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP 오류: SMTP 호스트에 접속할 수 없습니다.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP 오류: 데이터가 받아들여지지 않았습니다.'; +$PHPMAILER_LANG['empty_message'] = '메세지 내용이 없습니다'; +$PHPMAILER_LANG['encoding'] = '알 수 없는 인코딩: '; +$PHPMAILER_LANG['execute'] = '실행 불가: '; +$PHPMAILER_LANG['file_access'] = '파일 접근 불가: '; +$PHPMAILER_LANG['file_open'] = '파일 오류: 파일을 열 수 없습니다: '; +$PHPMAILER_LANG['from_failed'] = '다음 From 주소에서 오류가 발생했습니다: '; +$PHPMAILER_LANG['instantiate'] = 'mail 함수를 인스턴스화할 수 없습니다'; +$PHPMAILER_LANG['invalid_address'] = '잘못된 주소: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' 메일러는 지원되지 않습니다.'; +$PHPMAILER_LANG['provide_address'] = '적어도 한 개 이상의 수신자 메일 주소를 제공해야 합니다.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP 오류: 다음 수신자에서 오류가 발생했습니다: '; +$PHPMAILER_LANG['signing'] = '서명 오류: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP 연결을 실패하였습니다.'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP 서버 오류: '; +$PHPMAILER_LANG['variable_set'] = '변수 설정 및 초기화 불가: '; +$PHPMAILER_LANG['extension_missing'] = '확장자 없음: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-lt.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-lt.php new file mode 100644 index 0000000..4f115b1 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-lt.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP klaida: autentifikacija nepavyko.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP klaida: nepavyksta prisijungti prie SMTP stoties.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP klaida: duomenys nepriimti.'; +$PHPMAILER_LANG['empty_message'] = 'Laiško turinys tuščias'; +$PHPMAILER_LANG['encoding'] = 'Neatpažinta koduotė: '; +$PHPMAILER_LANG['execute'] = 'Nepavyko įvykdyti komandos: '; +$PHPMAILER_LANG['file_access'] = 'Byla nepasiekiama: '; +$PHPMAILER_LANG['file_open'] = 'Bylos klaida: Nepavyksta atidaryti: '; +$PHPMAILER_LANG['from_failed'] = 'Neteisingas siuntėjo adresas: '; +$PHPMAILER_LANG['instantiate'] = 'Nepavyko paleisti mail funkcijos.'; +$PHPMAILER_LANG['invalid_address'] = 'Neteisingas adresas: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' pašto stotis nepalaikoma.'; +$PHPMAILER_LANG['provide_address'] = 'Nurodykite bent vieną gavėjo adresą.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP klaida: nepavyko išsiųsti šiems gavėjams: '; +$PHPMAILER_LANG['signing'] = 'Prisijungimo klaida: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP susijungimo klaida'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP stoties klaida: '; +$PHPMAILER_LANG['variable_set'] = 'Nepavyko priskirti reikšmės kintamajam: '; +//$PHPMAILER_LANG['extension_missing'] = 'Extension missing: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-lv.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-lv.php new file mode 100644 index 0000000..679b18c --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-lv.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP kļūda: Autorizācija neizdevās.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP Kļūda: Nevar izveidot savienojumu ar SMTP serveri.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP Kļūda: Nepieņem informāciju.'; +$PHPMAILER_LANG['empty_message'] = 'Ziņojuma teksts ir tukšs'; +$PHPMAILER_LANG['encoding'] = 'Neatpazīts kodējums: '; +$PHPMAILER_LANG['execute'] = 'Neizdevās izpildīt komandu: '; +$PHPMAILER_LANG['file_access'] = 'Fails nav pieejams: '; +$PHPMAILER_LANG['file_open'] = 'Faila kļūda: Nevar atvērt failu: '; +$PHPMAILER_LANG['from_failed'] = 'Nepareiza sūtītāja adrese: '; +$PHPMAILER_LANG['instantiate'] = 'Nevar palaist sūtīšanas funkciju.'; +$PHPMAILER_LANG['invalid_address'] = 'Nepareiza adrese: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' sūtītājs netiek atbalstīts.'; +$PHPMAILER_LANG['provide_address'] = 'Lūdzu, norādiet vismaz vienu adresātu.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP kļūda: neizdevās nosūtīt šādiem saņēmējiem: '; +$PHPMAILER_LANG['signing'] = 'Autorizācijas kļūda: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP savienojuma kļūda'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP servera kļūda: '; +$PHPMAILER_LANG['variable_set'] = 'Nevar piešķirt mainīgā vērtību: '; +//$PHPMAILER_LANG['extension_missing'] = 'Extension missing: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-mg.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-mg.php new file mode 100644 index 0000000..8a94f6a --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-mg.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'Hadisoana SMTP: Tsy nahomby ny fanamarinana.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP Error: Tsy afaka mampifandray amin\'ny mpampiantrano SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP diso: tsy voarakitra ny angona.'; +$PHPMAILER_LANG['empty_message'] = 'Tsy misy ny votoaty mailaka.'; +$PHPMAILER_LANG['encoding'] = 'Tsy fantatra encoding: '; +$PHPMAILER_LANG['execute'] = 'Tsy afaka manatanteraka ity baiko manaraka ity: '; +$PHPMAILER_LANG['file_access'] = 'Tsy nahomby ny fidirana amin\'ity rakitra ity: '; +$PHPMAILER_LANG['file_open'] = 'Hadisoana diso: Tsy afaka nanokatra ity file manaraka ity: '; +$PHPMAILER_LANG['from_failed'] = 'Ny adiresy iraka manaraka dia diso: '; +$PHPMAILER_LANG['instantiate'] = 'Tsy afaka nanomboka ny hetsika mail.'; +$PHPMAILER_LANG['invalid_address'] = 'Tsy mety ny adiresy: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer tsy manohana.'; +$PHPMAILER_LANG['provide_address'] = 'Alefaso azafady iray adiresy iray farafahakeliny.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP Error: Tsy mety ireo mpanaraka ireto: '; +$PHPMAILER_LANG['signing'] = 'Error nandritra ny sonia:'; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Tsy nahomby ny fifandraisana tamin\'ny server SMTP.'; +$PHPMAILER_LANG['smtp_error'] = 'Fahadisoana tamin\'ny server SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'Tsy azo atao ny mametraka na mamerina ny variable: '; +$PHPMAILER_LANG['extension_missing'] = 'Tsy hita ny ampahany: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-mn.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-mn.php new file mode 100644 index 0000000..04d262c --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-mn.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'Ralat SMTP: Tidak dapat pengesahan.'; +$PHPMAILER_LANG['connect_host'] = 'Ralat SMTP: Tidak dapat menghubungi hos pelayan SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'Ralat SMTP: Data tidak diterima oleh pelayan.'; +$PHPMAILER_LANG['empty_message'] = 'Tiada isi untuk mesej'; +$PHPMAILER_LANG['encoding'] = 'Pengekodan tidak diketahui: '; +$PHPMAILER_LANG['execute'] = 'Tidak dapat melaksanakan: '; +$PHPMAILER_LANG['file_access'] = 'Tidak dapat mengakses fail: '; +$PHPMAILER_LANG['file_open'] = 'Ralat Fail: Tidak dapat membuka fail: '; +$PHPMAILER_LANG['from_failed'] = 'Berikut merupakan ralat dari alamat e-mel: '; +$PHPMAILER_LANG['instantiate'] = 'Tidak dapat memberi contoh fungsi e-mel.'; +$PHPMAILER_LANG['invalid_address'] = 'Alamat emel tidak sah: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' jenis penghantar emel tidak disokong.'; +$PHPMAILER_LANG['provide_address'] = 'Anda perlu menyediakan sekurang-kurangnya satu alamat e-mel penerima.'; +$PHPMAILER_LANG['recipients_failed'] = 'Ralat SMTP: Penerima e-mel berikut telah gagal: '; +$PHPMAILER_LANG['signing'] = 'Ralat pada tanda tangan: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() telah gagal.'; +$PHPMAILER_LANG['smtp_error'] = 'Ralat pada pelayan SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'Tidak boleh menetapkan atau menetapkan semula pembolehubah: '; +$PHPMAILER_LANG['extension_missing'] = 'Sambungan hilang: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-nb.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-nb.php new file mode 100644 index 0000000..c9621a1 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-nb.php @@ -0,0 +1,33 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP-fout: authenticatie mislukt.'; +$PHPMAILER_LANG['buggy_php'] = 'PHP versie gededecteerd die onderhavig is aan een bug die kan resulteren in gecorrumpeerde berichten. Om dit te voorkomen, gebruik SMTP voor het verzenden van berichten, zet de mail.add_x_header optie in uw php.ini file uit, gebruik MacOS of Linux, of pas de gebruikte PHP versie aan naar versie 7.0.17+ or 7.1.3+.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP-fout: kon niet verbinden met SMTP-host.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP-fout: data niet geaccepteerd.'; +$PHPMAILER_LANG['empty_message'] = 'Berichttekst is leeg'; +$PHPMAILER_LANG['encoding'] = 'Onbekende codering: '; +$PHPMAILER_LANG['execute'] = 'Kon niet uitvoeren: '; +$PHPMAILER_LANG['extension_missing'] = 'Extensie afwezig: '; +$PHPMAILER_LANG['file_access'] = 'Kreeg geen toegang tot bestand: '; +$PHPMAILER_LANG['file_open'] = 'Bestandsfout: kon bestand niet openen: '; +$PHPMAILER_LANG['from_failed'] = 'Het volgende afzendersadres is mislukt: '; +$PHPMAILER_LANG['instantiate'] = 'Kon mailfunctie niet initialiseren.'; +$PHPMAILER_LANG['invalid_address'] = 'Ongeldig adres: '; +$PHPMAILER_LANG['invalid_header'] = 'Ongeldige header naam of waarde'; +$PHPMAILER_LANG['invalid_hostentry'] = 'Ongeldige hostentry: '; +$PHPMAILER_LANG['invalid_host'] = 'Ongeldige host: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer wordt niet ondersteund.'; +$PHPMAILER_LANG['provide_address'] = 'Er moet minstens één ontvanger worden opgegeven.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP-fout: de volgende ontvangers zijn mislukt: '; +$PHPMAILER_LANG['signing'] = 'Signeerfout: '; +$PHPMAILER_LANG['smtp_code'] = 'SMTP code: '; +$PHPMAILER_LANG['smtp_code_ex'] = 'Aanvullende SMTP informatie: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Verbinding mislukt.'; +$PHPMAILER_LANG['smtp_detail'] = 'Detail: '; +$PHPMAILER_LANG['smtp_error'] = 'SMTP-serverfout: '; +$PHPMAILER_LANG['variable_set'] = 'Kan de volgende variabele niet instellen of resetten: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-pl.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-pl.php new file mode 100644 index 0000000..cb7b2c2 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-pl.php @@ -0,0 +1,33 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'Erro do SMTP: Não foi possível realizar a autenticação.'; +$PHPMAILER_LANG['connect_host'] = 'Erro do SMTP: Não foi possível realizar ligação com o servidor SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'Erro do SMTP: Os dados foram rejeitados.'; +$PHPMAILER_LANG['empty_message'] = 'A mensagem no e-mail está vazia.'; +$PHPMAILER_LANG['encoding'] = 'Codificação desconhecida: '; +$PHPMAILER_LANG['execute'] = 'Não foi possível executar: '; +$PHPMAILER_LANG['file_access'] = 'Não foi possível aceder o ficheiro: '; +$PHPMAILER_LANG['file_open'] = 'Abertura do ficheiro: Não foi possível abrir o ficheiro: '; +$PHPMAILER_LANG['from_failed'] = 'Ocorreram falhas nos endereços dos seguintes remententes: '; +$PHPMAILER_LANG['instantiate'] = 'Não foi possível iniciar uma instância da função mail.'; +$PHPMAILER_LANG['invalid_address'] = 'Não foi enviado nenhum e-mail para o endereço de e-mail inválido: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer não é suportado.'; +$PHPMAILER_LANG['provide_address'] = 'Tem de fornecer pelo menos um endereço como destinatário do e-mail.'; +$PHPMAILER_LANG['recipients_failed'] = 'Erro do SMTP: O endereço do seguinte destinatário falhou: '; +$PHPMAILER_LANG['signing'] = 'Erro ao assinar: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() falhou.'; +$PHPMAILER_LANG['smtp_error'] = 'Erro de servidor SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'Não foi possível definir ou redefinir a variável: '; +$PHPMAILER_LANG['extension_missing'] = 'Extensão em falta: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-pt_br.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-pt_br.php new file mode 100644 index 0000000..5239865 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-pt_br.php @@ -0,0 +1,38 @@ + + * @author Lucas Guimarães + * @author Phelipe Alves + * @author Fabio Beneditto + * @author Geidson Benício Coelho + */ + +$PHPMAILER_LANG['authenticate'] = 'Erro de SMTP: Não foi possível autenticar.'; +$PHPMAILER_LANG['buggy_php'] = 'Sua versão do PHP é afetada por um bug que por resultar em messagens corrompidas. Para corrigir, mude para enviar usando SMTP, desative a opção mail.add_x_header em seu php.ini, mude para MacOS ou Linux, ou atualize seu PHP para versão 7.0.17+ ou 7.1.3+ '; +$PHPMAILER_LANG['connect_host'] = 'Erro de SMTP: Não foi possível conectar ao servidor SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'Erro de SMTP: Dados rejeitados.'; +$PHPMAILER_LANG['empty_message'] = 'Mensagem vazia'; +$PHPMAILER_LANG['encoding'] = 'Codificação desconhecida: '; +$PHPMAILER_LANG['execute'] = 'Não foi possível executar: '; +$PHPMAILER_LANG['extension_missing'] = 'Extensão não existe: '; +$PHPMAILER_LANG['file_access'] = 'Não foi possível acessar o arquivo: '; +$PHPMAILER_LANG['file_open'] = 'Erro de Arquivo: Não foi possível abrir o arquivo: '; +$PHPMAILER_LANG['from_failed'] = 'Os seguintes remetentes falharam: '; +$PHPMAILER_LANG['instantiate'] = 'Não foi possível instanciar a função mail.'; +$PHPMAILER_LANG['invalid_address'] = 'Endereço de e-mail inválido: '; +$PHPMAILER_LANG['invalid_header'] = 'Nome ou valor de cabeçalho inválido'; +$PHPMAILER_LANG['invalid_hostentry'] = 'hostentry inválido: '; +$PHPMAILER_LANG['invalid_host'] = 'host inválido: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer não é suportado.'; +$PHPMAILER_LANG['provide_address'] = 'Você deve informar pelo menos um destinatário.'; +$PHPMAILER_LANG['recipients_failed'] = 'Erro de SMTP: Os seguintes destinatários falharam: '; +$PHPMAILER_LANG['signing'] = 'Erro de Assinatura: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() falhou.'; +$PHPMAILER_LANG['smtp_code'] = 'Código do servidor SMTP: '; +$PHPMAILER_LANG['smtp_error'] = 'Erro de servidor SMTP: '; +$PHPMAILER_LANG['smtp_code_ex'] = 'Informações adicionais do servidor SMTP: '; +$PHPMAILER_LANG['smtp_detail'] = 'Detalhes do servidor SMTP: '; +$PHPMAILER_LANG['variable_set'] = 'Não foi possível definir ou redefinir a variável: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ro.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ro.php new file mode 100644 index 0000000..45bef91 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-ro.php @@ -0,0 +1,33 @@ + + * @author Foster Snowhill + */ + +$PHPMAILER_LANG['authenticate'] = 'Ошибка SMTP: ошибка авторизации.'; +$PHPMAILER_LANG['connect_host'] = 'Ошибка SMTP: не удается подключиться к SMTP-серверу.'; +$PHPMAILER_LANG['data_not_accepted'] = 'Ошибка SMTP: данные не приняты.'; +$PHPMAILER_LANG['encoding'] = 'Неизвестная кодировка: '; +$PHPMAILER_LANG['execute'] = 'Невозможно выполнить команду: '; +$PHPMAILER_LANG['file_access'] = 'Нет доступа к файлу: '; +$PHPMAILER_LANG['file_open'] = 'Файловая ошибка: не удаётся открыть файл: '; +$PHPMAILER_LANG['from_failed'] = 'Неверный адрес отправителя: '; +$PHPMAILER_LANG['instantiate'] = 'Невозможно запустить функцию mail().'; +$PHPMAILER_LANG['provide_address'] = 'Пожалуйста, введите хотя бы один email-адрес получателя.'; +$PHPMAILER_LANG['mailer_not_supported'] = ' — почтовый сервер не поддерживается.'; +$PHPMAILER_LANG['recipients_failed'] = 'Ошибка SMTP: не удалась отправка таким адресатам: '; +$PHPMAILER_LANG['empty_message'] = 'Пустое сообщение'; +$PHPMAILER_LANG['invalid_address'] = 'Не отправлено из-за неправильного формата email-адреса: '; +$PHPMAILER_LANG['signing'] = 'Ошибка подписи: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Ошибка соединения с SMTP-сервером'; +$PHPMAILER_LANG['smtp_error'] = 'Ошибка SMTP-сервера: '; +$PHPMAILER_LANG['variable_set'] = 'Невозможно установить или сбросить переменную: '; +$PHPMAILER_LANG['extension_missing'] = 'Расширение отсутствует: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-si.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-si.php new file mode 100644 index 0000000..dce502a --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-si.php @@ -0,0 +1,34 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP දෝෂය: සත්‍යාපනය අසාර්ථක විය.'; +$PHPMAILER_LANG['buggy_php'] = 'ඔබගේ PHP version එකෙහි පවතින දෝෂයක් නිසා email පණිවිඩ දෝෂ සහගත වීමේ හැකියාවක් ඇත. මෙය විසදීම සදහා SMTP භාවිතා කිරීම, mail.add_x_header INI setting එක අක්‍රීය කිරීම, MacOS හෝ Linux වලට මාරු වීම, හෝ ඔබගේ PHP version එක 7.0.17+ හෝ 7.1.3+ වලට අලුත් කිරීම කරගන්න.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP දෝෂය: සම්බන්ධ වීමට නොහැකි විය.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP දෝෂය: දත්ත පිළිගනු නොලැබේ.'; +$PHPMAILER_LANG['empty_message'] = 'පණිවිඩ අන්තර්ගතය හිස්'; +$PHPMAILER_LANG['encoding'] = 'නොදන්නා කේතනය: '; +$PHPMAILER_LANG['execute'] = 'ක්‍රියාත්මක කළ නොහැකි විය: '; +$PHPMAILER_LANG['extension_missing'] = 'Extension එක නොමැත: '; +$PHPMAILER_LANG['file_access'] = 'File එකට ප්‍රවේශ විය නොහැකි විය: '; +$PHPMAILER_LANG['file_open'] = 'File දෝෂය: File එක විවෘත කළ නොහැක: '; +$PHPMAILER_LANG['from_failed'] = 'පහත From ලිපිනයන් අසාර්ථක විය: '; +$PHPMAILER_LANG['instantiate'] = 'mail function එක ක්‍රියාත්මක කළ නොහැක.'; +$PHPMAILER_LANG['invalid_address'] = 'වලංගු නොවන ලිපිනය: '; +$PHPMAILER_LANG['invalid_header'] = 'වලංගු නොවන header නාමයක් හෝ අගයක්'; +$PHPMAILER_LANG['invalid_hostentry'] = 'වලංගු නොවන hostentry එකක්: '; +$PHPMAILER_LANG['invalid_host'] = 'වලංගු නොවන host එකක්: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer සහාය නොදක්වයි.'; +$PHPMAILER_LANG['provide_address'] = 'ඔබ අවම වශයෙන් එක් ලබන්නෙකුගේ ඊමේල් ලිපිනයක් සැපයිය යුතුය.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP දෝෂය: පහත ලබන්නන් අසමත් විය: '; +$PHPMAILER_LANG['signing'] = 'Sign කිරීමේ දෝෂය: '; +$PHPMAILER_LANG['smtp_code'] = 'SMTP කේතය: '; +$PHPMAILER_LANG['smtp_code_ex'] = 'අමතර SMTP තොරතුරු: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP සම්බන්ධය අසාර්ථක විය.'; +$PHPMAILER_LANG['smtp_detail'] = 'තොරතුරු: '; +$PHPMAILER_LANG['smtp_error'] = 'SMTP දෝෂය: '; +$PHPMAILER_LANG['variable_set'] = 'Variable එක සැකසීමට හෝ නැවත සැකසීමට නොහැක: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sk.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sk.php new file mode 100644 index 0000000..028f5bc --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sk.php @@ -0,0 +1,30 @@ + + * @author Peter Orlický + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP Error: Chyba autentifikácie.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP Error: Nebolo možné nadviazať spojenie so SMTP serverom.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP Error: Dáta neboli prijaté'; +$PHPMAILER_LANG['empty_message'] = 'Prázdne telo správy.'; +$PHPMAILER_LANG['encoding'] = 'Neznáme kódovanie: '; +$PHPMAILER_LANG['execute'] = 'Nedá sa vykonať: '; +$PHPMAILER_LANG['file_access'] = 'Súbor nebol nájdený: '; +$PHPMAILER_LANG['file_open'] = 'File Error: Súbor sa otvoriť pre čítanie: '; +$PHPMAILER_LANG['from_failed'] = 'Následujúca adresa From je nesprávna: '; +$PHPMAILER_LANG['instantiate'] = 'Nedá sa vytvoriť inštancia emailovej funkcie.'; +$PHPMAILER_LANG['invalid_address'] = 'Neodoslané, emailová adresa je nesprávna: '; +$PHPMAILER_LANG['invalid_hostentry'] = 'Záznam hostiteľa je nesprávny: '; +$PHPMAILER_LANG['invalid_host'] = 'Hostiteľ je nesprávny: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' emailový klient nieje podporovaný.'; +$PHPMAILER_LANG['provide_address'] = 'Musíte zadať aspoň jednu emailovú adresu príjemcu.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP Error: Adresy príjemcov niesu správne '; +$PHPMAILER_LANG['signing'] = 'Chyba prihlasovania: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() zlyhalo.'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP chyba serveru: '; +$PHPMAILER_LANG['variable_set'] = 'Nemožno nastaviť alebo resetovať premennú: '; +$PHPMAILER_LANG['extension_missing'] = 'Chýba rozšírenie: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sl.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sl.php new file mode 100644 index 0000000..3e00c25 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sl.php @@ -0,0 +1,36 @@ + + * @author Filip Š + * @author Blaž Oražem + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP napaka: Avtentikacija ni uspela.'; +$PHPMAILER_LANG['buggy_php'] = 'Na vašo PHP različico vpliva napaka, ki lahko povzroči poškodovana sporočila. Če želite težavo odpraviti, preklopite na pošiljanje prek SMTP, onemogočite možnost mail.add_x_header v vaši php.ini datoteki, preklopite na MacOS ali Linux, ali nadgradite vašo PHP zaličico na 7.0.17+ ali 7.1.3+.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP napaka: Vzpostavljanje povezave s SMTP gostiteljem ni uspelo.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP napaka: Strežnik zavrača podatke.'; +$PHPMAILER_LANG['empty_message'] = 'E-poštno sporočilo nima vsebine.'; +$PHPMAILER_LANG['encoding'] = 'Nepoznan tip kodiranja: '; +$PHPMAILER_LANG['execute'] = 'Operacija ni uspela: '; +$PHPMAILER_LANG['extension_missing'] = 'Manjkajoča razširitev: '; +$PHPMAILER_LANG['file_access'] = 'Nimam dostopa do datoteke: '; +$PHPMAILER_LANG['file_open'] = 'Ne morem odpreti datoteke: '; +$PHPMAILER_LANG['from_failed'] = 'Neveljaven e-naslov pošiljatelja: '; +$PHPMAILER_LANG['instantiate'] = 'Ne morem inicializirati mail funkcije.'; +$PHPMAILER_LANG['invalid_address'] = 'E-poštno sporočilo ni bilo poslano. E-naslov je neveljaven: '; +$PHPMAILER_LANG['invalid_header'] = 'Neveljavno ime ali vrednost glave'; +$PHPMAILER_LANG['invalid_hostentry'] = 'Neveljaven vnos gostitelja: '; +$PHPMAILER_LANG['invalid_host'] = 'Neveljaven gostitelj: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer ni podprt.'; +$PHPMAILER_LANG['provide_address'] = 'Prosimo, vnesite vsaj enega naslovnika.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP napaka: Sledeči naslovniki so neveljavni: '; +$PHPMAILER_LANG['signing'] = 'Napaka pri podpisovanju: '; +$PHPMAILER_LANG['smtp_code'] = 'SMTP koda: '; +$PHPMAILER_LANG['smtp_code_ex'] = 'Dodatne informacije o SMTP: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Ne morem vzpostaviti povezave s SMTP strežnikom.'; +$PHPMAILER_LANG['smtp_detail'] = 'Podrobnosti: '; +$PHPMAILER_LANG['smtp_error'] = 'Napaka SMTP strežnika: '; +$PHPMAILER_LANG['variable_set'] = 'Ne morem nastaviti oz. ponastaviti spremenljivke: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sr.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sr.php new file mode 100644 index 0000000..0b5280f --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sr.php @@ -0,0 +1,28 @@ + + * @author Miloš Milanović + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP грешка: аутентификација није успела.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP грешка: повезивање са SMTP сервером није успело.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP грешка: подаци нису прихваћени.'; +$PHPMAILER_LANG['empty_message'] = 'Садржај поруке је празан.'; +$PHPMAILER_LANG['encoding'] = 'Непознато кодирање: '; +$PHPMAILER_LANG['execute'] = 'Није могуће извршити наредбу: '; +$PHPMAILER_LANG['file_access'] = 'Није могуће приступити датотеци: '; +$PHPMAILER_LANG['file_open'] = 'Није могуће отворити датотеку: '; +$PHPMAILER_LANG['from_failed'] = 'SMTP грешка: слање са следећих адреса није успело: '; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP грешка: слање на следеће адресе није успело: '; +$PHPMAILER_LANG['instantiate'] = 'Није могуће покренути mail функцију.'; +$PHPMAILER_LANG['invalid_address'] = 'Порука није послата. Неисправна адреса: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' мејлер није подржан.'; +$PHPMAILER_LANG['provide_address'] = 'Дефинишите бар једну адресу примаоца.'; +$PHPMAILER_LANG['signing'] = 'Грешка приликом пријаве: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Повезивање са SMTP сервером није успело.'; +$PHPMAILER_LANG['smtp_error'] = 'Грешка SMTP сервера: '; +$PHPMAILER_LANG['variable_set'] = 'Није могуће задати нити ресетовати променљиву: '; +$PHPMAILER_LANG['extension_missing'] = 'Недостаје проширење: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sr_latn.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sr_latn.php new file mode 100644 index 0000000..6213832 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sr_latn.php @@ -0,0 +1,28 @@ + + * @author Miloš Milanović + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP greška: autentifikacija nije uspela.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP greška: povezivanje sa SMTP serverom nije uspelo.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP greška: podaci nisu prihvaćeni.'; +$PHPMAILER_LANG['empty_message'] = 'Sadržaj poruke je prazan.'; +$PHPMAILER_LANG['encoding'] = 'Nepoznato kodiranje: '; +$PHPMAILER_LANG['execute'] = 'Nije moguće izvršiti naredbu: '; +$PHPMAILER_LANG['file_access'] = 'Nije moguće pristupiti datoteci: '; +$PHPMAILER_LANG['file_open'] = 'Nije moguće otvoriti datoteku: '; +$PHPMAILER_LANG['from_failed'] = 'SMTP greška: slanje sa sledećih adresa nije uspelo: '; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP greška: slanje na sledeće adrese nije uspelo: '; +$PHPMAILER_LANG['instantiate'] = 'Nije moguće pokrenuti mail funkciju.'; +$PHPMAILER_LANG['invalid_address'] = 'Poruka nije poslata. Neispravna adresa: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' majler nije podržan.'; +$PHPMAILER_LANG['provide_address'] = 'Definišite bar jednu adresu primaoca.'; +$PHPMAILER_LANG['signing'] = 'Greška prilikom prijave: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Povezivanje sa SMTP serverom nije uspelo.'; +$PHPMAILER_LANG['smtp_error'] = 'Greška SMTP servera: '; +$PHPMAILER_LANG['variable_set'] = 'Nije moguće zadati niti resetovati promenljivu: '; +$PHPMAILER_LANG['extension_missing'] = 'Nedostaje proširenje: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sv.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sv.php new file mode 100644 index 0000000..9872c19 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-sv.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP fel: Kunde inte autentisera.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP fel: Kunde inte ansluta till SMTP-server.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP fel: Data accepterades inte.'; +//$PHPMAILER_LANG['empty_message'] = 'Message body empty'; +$PHPMAILER_LANG['encoding'] = 'Okänt encode-format: '; +$PHPMAILER_LANG['execute'] = 'Kunde inte köra: '; +$PHPMAILER_LANG['file_access'] = 'Ingen åtkomst till fil: '; +$PHPMAILER_LANG['file_open'] = 'Fil fel: Kunde inte öppna fil: '; +$PHPMAILER_LANG['from_failed'] = 'Följande avsändaradress är felaktig: '; +$PHPMAILER_LANG['instantiate'] = 'Kunde inte initiera e-postfunktion.'; +$PHPMAILER_LANG['invalid_address'] = 'Felaktig adress: '; +$PHPMAILER_LANG['provide_address'] = 'Du måste ange minst en mottagares e-postadress.'; +$PHPMAILER_LANG['mailer_not_supported'] = ' mailer stöds inte.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP fel: Följande mottagare är felaktig: '; +$PHPMAILER_LANG['signing'] = 'Signeringsfel: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP Connect() misslyckades.'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP serverfel: '; +$PHPMAILER_LANG['variable_set'] = 'Kunde inte definiera eller återställa variabel: '; +$PHPMAILER_LANG['extension_missing'] = 'Tillägg ej tillgängligt: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-tl.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-tl.php new file mode 100644 index 0000000..d15bed1 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-tl.php @@ -0,0 +1,28 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP Error: Hindi mapatotohanan.'; +$PHPMAILER_LANG['connect_host'] = 'SMTP Error: Hindi makakonekta sa SMTP host.'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP Error: Ang datos ay hindi naitanggap.'; +$PHPMAILER_LANG['empty_message'] = 'Walang laman ang mensahe'; +$PHPMAILER_LANG['encoding'] = 'Hindi alam ang encoding: '; +$PHPMAILER_LANG['execute'] = 'Hindi maisasagawa: '; +$PHPMAILER_LANG['file_access'] = 'Hindi ma-access ang file: '; +$PHPMAILER_LANG['file_open'] = 'File Error: Hindi mabuksan ang file: '; +$PHPMAILER_LANG['from_failed'] = 'Ang sumusunod na address ay nabigo: '; +$PHPMAILER_LANG['instantiate'] = 'Hindi maisimulan ang instance ng mail function.'; +$PHPMAILER_LANG['invalid_address'] = 'Hindi wasto ang address na naibigay: '; +$PHPMAILER_LANG['mailer_not_supported'] = 'Ang mailer ay hindi suportado.'; +$PHPMAILER_LANG['provide_address'] = 'Kailangan mong magbigay ng kahit isang email address na tatanggap.'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP Error: Ang mga sumusunod na tatanggap ay nabigo: '; +$PHPMAILER_LANG['signing'] = 'Hindi ma-sign: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Ang SMTP connect() ay nabigo.'; +$PHPMAILER_LANG['smtp_error'] = 'Ang server ng SMTP ay nabigo: '; +$PHPMAILER_LANG['variable_set'] = 'Hindi matatakda o ma-reset ang mga variables: '; +$PHPMAILER_LANG['extension_missing'] = 'Nawawala ang extension: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-tr.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-tr.php new file mode 100644 index 0000000..f938f80 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-tr.php @@ -0,0 +1,31 @@ + + * @fixed by Boris Yurchenko + */ + +$PHPMAILER_LANG['authenticate'] = 'Помилка SMTP: помилка авторизації.'; +$PHPMAILER_LANG['connect_host'] = 'Помилка SMTP: не вдається під\'єднатися до SMTP-серверу.'; +$PHPMAILER_LANG['data_not_accepted'] = 'Помилка SMTP: дані не прийнято.'; +$PHPMAILER_LANG['encoding'] = 'Невідоме кодування: '; +$PHPMAILER_LANG['execute'] = 'Неможливо виконати команду: '; +$PHPMAILER_LANG['file_access'] = 'Немає доступу до файлу: '; +$PHPMAILER_LANG['file_open'] = 'Помилка файлової системи: не вдається відкрити файл: '; +$PHPMAILER_LANG['from_failed'] = 'Невірна адреса відправника: '; +$PHPMAILER_LANG['instantiate'] = 'Неможливо запустити функцію mail().'; +$PHPMAILER_LANG['provide_address'] = 'Будь ласка, введіть хоча б одну email-адресу отримувача.'; +$PHPMAILER_LANG['mailer_not_supported'] = ' - поштовий сервер не підтримується.'; +$PHPMAILER_LANG['recipients_failed'] = 'Помилка SMTP: не вдалося відправлення для таких отримувачів: '; +$PHPMAILER_LANG['empty_message'] = 'Пусте повідомлення'; +$PHPMAILER_LANG['invalid_address'] = 'Не відправлено через неправильний формат email-адреси: '; +$PHPMAILER_LANG['signing'] = 'Помилка підпису: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Помилка з\'єднання з SMTP-сервером'; +$PHPMAILER_LANG['smtp_error'] = 'Помилка SMTP-сервера: '; +$PHPMAILER_LANG['variable_set'] = 'Неможливо встановити або скинути змінну: '; +$PHPMAILER_LANG['extension_missing'] = 'Розширення відсутнє: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-vi.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-vi.php new file mode 100644 index 0000000..d65576e --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-vi.php @@ -0,0 +1,27 @@ + + */ + +$PHPMAILER_LANG['authenticate'] = 'Lỗi SMTP: Không thể xác thực.'; +$PHPMAILER_LANG['connect_host'] = 'Lỗi SMTP: Không thể kết nối máy chủ SMTP.'; +$PHPMAILER_LANG['data_not_accepted'] = 'Lỗi SMTP: Dữ liệu không được chấp nhận.'; +$PHPMAILER_LANG['empty_message'] = 'Không có nội dung'; +$PHPMAILER_LANG['encoding'] = 'Mã hóa không xác định: '; +$PHPMAILER_LANG['execute'] = 'Không thực hiện được: '; +$PHPMAILER_LANG['file_access'] = 'Không thể truy cập tệp tin '; +$PHPMAILER_LANG['file_open'] = 'Lỗi Tập tin: Không thể mở tệp tin: '; +$PHPMAILER_LANG['from_failed'] = 'Lỗi địa chỉ gửi đi: '; +$PHPMAILER_LANG['instantiate'] = 'Không dùng được các hàm gửi thư.'; +$PHPMAILER_LANG['invalid_address'] = 'Đại chỉ emai không đúng: '; +$PHPMAILER_LANG['mailer_not_supported'] = ' trình gửi thư không được hỗ trợ.'; +$PHPMAILER_LANG['provide_address'] = 'Bạn phải cung cấp ít nhất một địa chỉ người nhận.'; +$PHPMAILER_LANG['recipients_failed'] = 'Lỗi SMTP: lỗi địa chỉ người nhận: '; +$PHPMAILER_LANG['signing'] = 'Lỗi đăng nhập: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'Lỗi kết nối với SMTP'; +$PHPMAILER_LANG['smtp_error'] = 'Lỗi máy chủ smtp '; +$PHPMAILER_LANG['variable_set'] = 'Không thể thiết lập hoặc thiết lập lại biến: '; +//$PHPMAILER_LANG['extension_missing'] = 'Extension missing: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-zh.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-zh.php new file mode 100644 index 0000000..35e4e70 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-zh.php @@ -0,0 +1,29 @@ + + * @author Peter Dave Hello <@PeterDaveHello/> + * @author Jason Chiang + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP 錯誤:登入失敗。'; +$PHPMAILER_LANG['connect_host'] = 'SMTP 錯誤:無法連線到 SMTP 主機。'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP 錯誤:無法接受的資料。'; +$PHPMAILER_LANG['empty_message'] = '郵件內容為空'; +$PHPMAILER_LANG['encoding'] = '未知編碼: '; +$PHPMAILER_LANG['execute'] = '無法執行:'; +$PHPMAILER_LANG['file_access'] = '無法存取檔案:'; +$PHPMAILER_LANG['file_open'] = '檔案錯誤:無法開啟檔案:'; +$PHPMAILER_LANG['from_failed'] = '發送地址錯誤:'; +$PHPMAILER_LANG['instantiate'] = '未知函數呼叫。'; +$PHPMAILER_LANG['invalid_address'] = '因為電子郵件地址無效,無法傳送: '; +$PHPMAILER_LANG['mailer_not_supported'] = '不支援的發信客戶端。'; +$PHPMAILER_LANG['provide_address'] = '必須提供至少一個收件人地址。'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP 錯誤:以下收件人地址錯誤:'; +$PHPMAILER_LANG['signing'] = '電子簽章錯誤: '; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP 連線失敗'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP 伺服器錯誤: '; +$PHPMAILER_LANG['variable_set'] = '無法設定或重設變數: '; +$PHPMAILER_LANG['extension_missing'] = '遺失模組 Extension: '; diff --git a/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-zh_cn.php b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-zh_cn.php new file mode 100644 index 0000000..03d4911 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/language/phpmailer.lang-zh_cn.php @@ -0,0 +1,36 @@ + + * @author young + * @author Teddysun + */ + +$PHPMAILER_LANG['authenticate'] = 'SMTP 错误:登录失败。'; +$PHPMAILER_LANG['buggy_php'] = '您的 PHP 版本存在漏洞,可能会导致消息损坏。为修复此问题,请切换到使用 SMTP 发送,在您的 php.ini 中禁用 mail.add_x_header 选项。切换到 MacOS 或 Linux,或将您的 PHP 升级到 7.0.17+ 或 7.1.3+ 版本。'; +$PHPMAILER_LANG['connect_host'] = 'SMTP 错误:无法连接到 SMTP 主机。'; +$PHPMAILER_LANG['data_not_accepted'] = 'SMTP 错误:数据不被接受。'; +$PHPMAILER_LANG['empty_message'] = '邮件正文为空。'; +$PHPMAILER_LANG['encoding'] = '未知编码:'; +$PHPMAILER_LANG['execute'] = '无法执行:'; +$PHPMAILER_LANG['extension_missing'] = '缺少扩展名:'; +$PHPMAILER_LANG['file_access'] = '无法访问文件:'; +$PHPMAILER_LANG['file_open'] = '文件错误:无法打开文件:'; +$PHPMAILER_LANG['from_failed'] = '发送地址错误:'; +$PHPMAILER_LANG['instantiate'] = '未知函数调用。'; +$PHPMAILER_LANG['invalid_address'] = '发送失败,电子邮箱地址是无效的:'; +$PHPMAILER_LANG['mailer_not_supported'] = '发信客户端不被支持。'; +$PHPMAILER_LANG['provide_address'] = '必须提供至少一个收件人地址。'; +$PHPMAILER_LANG['recipients_failed'] = 'SMTP 错误:收件人地址错误:'; +$PHPMAILER_LANG['smtp_connect_failed'] = 'SMTP服务器连接失败。'; +$PHPMAILER_LANG['smtp_error'] = 'SMTP服务器出错:'; +$PHPMAILER_LANG['variable_set'] = '无法设置或重置变量:'; +$PHPMAILER_LANG['invalid_header'] = '无效的标题名称或值'; +$PHPMAILER_LANG['invalid_hostentry'] = '无效的hostentry: '; +$PHPMAILER_LANG['invalid_host'] = '无效的主机:'; +$PHPMAILER_LANG['signing'] = '签名错误:'; +$PHPMAILER_LANG['smtp_code'] = 'SMTP代码: '; +$PHPMAILER_LANG['smtp_code_ex'] = '附加SMTP信息: '; +$PHPMAILER_LANG['smtp_detail'] = '详情:'; diff --git a/kirby/vendor/phpmailer/phpmailer/src/DSNConfigurator.php b/kirby/vendor/phpmailer/phpmailer/src/DSNConfigurator.php new file mode 100644 index 0000000..566c961 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/src/DSNConfigurator.php @@ -0,0 +1,245 @@ + + * @author Jim Jagielski (jimjag) + * @author Andy Prevost (codeworxtech) + * @author Brent R. Matzelle (original founder) + * @copyright 2012 - 2023 Marcus Bointon + * @copyright 2010 - 2012 Jim Jagielski + * @copyright 2004 - 2009 Andy Prevost + * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License + * @note This program is distributed in the hope that it will be useful - WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. + */ + +namespace PHPMailer\PHPMailer; + +/** + * Configure PHPMailer with DSN string. + * + * @see https://en.wikipedia.org/wiki/Data_source_name + * + * @author Oleg Voronkovich + */ +class DSNConfigurator +{ + /** + * Create new PHPMailer instance configured by DSN. + * + * @param string $dsn DSN + * @param bool $exceptions Should we throw external exceptions? + * + * @return PHPMailer + */ + public static function mailer($dsn, $exceptions = null) + { + static $configurator = null; + + if (null === $configurator) { + $configurator = new DSNConfigurator(); + } + + return $configurator->configure(new PHPMailer($exceptions), $dsn); + } + + /** + * Configure PHPMailer instance with DSN string. + * + * @param PHPMailer $mailer PHPMailer instance + * @param string $dsn DSN + * + * @return PHPMailer + */ + public function configure(PHPMailer $mailer, $dsn) + { + $config = $this->parseDSN($dsn); + + $this->applyConfig($mailer, $config); + + return $mailer; + } + + /** + * Parse DSN string. + * + * @param string $dsn DSN + * + * @throws Exception If DSN is malformed + * + * @return array Configuration + */ + private function parseDSN($dsn) + { + $config = $this->parseUrl($dsn); + + if (false === $config || !isset($config['scheme']) || !isset($config['host'])) { + throw new Exception('Malformed DSN'); + } + + if (isset($config['query'])) { + parse_str($config['query'], $config['query']); + } + + return $config; + } + + /** + * Apply configuration to mailer. + * + * @param PHPMailer $mailer PHPMailer instance + * @param array $config Configuration + * + * @throws Exception If scheme is invalid + */ + private function applyConfig(PHPMailer $mailer, $config) + { + switch ($config['scheme']) { + case 'mail': + $mailer->isMail(); + break; + case 'sendmail': + $mailer->isSendmail(); + break; + case 'qmail': + $mailer->isQmail(); + break; + case 'smtp': + case 'smtps': + $mailer->isSMTP(); + $this->configureSMTP($mailer, $config); + break; + default: + throw new Exception( + sprintf( + 'Invalid scheme: "%s". Allowed values: "mail", "sendmail", "qmail", "smtp", "smtps".', + $config['scheme'] + ) + ); + } + + if (isset($config['query'])) { + $this->configureOptions($mailer, $config['query']); + } + } + + /** + * Configure SMTP. + * + * @param PHPMailer $mailer PHPMailer instance + * @param array $config Configuration + */ + private function configureSMTP($mailer, $config) + { + $isSMTPS = 'smtps' === $config['scheme']; + + if ($isSMTPS) { + $mailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } + + $mailer->Host = $config['host']; + + if (isset($config['port'])) { + $mailer->Port = $config['port']; + } elseif ($isSMTPS) { + $mailer->Port = SMTP::DEFAULT_SECURE_PORT; + } + + $mailer->SMTPAuth = isset($config['user']) || isset($config['pass']); + + if (isset($config['user'])) { + $mailer->Username = $config['user']; + } + + if (isset($config['pass'])) { + $mailer->Password = $config['pass']; + } + } + + /** + * Configure options. + * + * @param PHPMailer $mailer PHPMailer instance + * @param array $options Options + * + * @throws Exception If option is unknown + */ + private function configureOptions(PHPMailer $mailer, $options) + { + $allowedOptions = get_object_vars($mailer); + + unset($allowedOptions['Mailer']); + unset($allowedOptions['SMTPAuth']); + unset($allowedOptions['Username']); + unset($allowedOptions['Password']); + unset($allowedOptions['Hostname']); + unset($allowedOptions['Port']); + unset($allowedOptions['ErrorInfo']); + + $allowedOptions = \array_keys($allowedOptions); + + foreach ($options as $key => $value) { + if (!in_array($key, $allowedOptions)) { + throw new Exception( + sprintf( + 'Unknown option: "%s". Allowed values: "%s"', + $key, + implode('", "', $allowedOptions) + ) + ); + } + + switch ($key) { + case 'AllowEmpty': + case 'SMTPAutoTLS': + case 'SMTPKeepAlive': + case 'SingleTo': + case 'UseSendmailOptions': + case 'do_verp': + case 'DKIM_copyHeaderFields': + $mailer->$key = (bool) $value; + break; + case 'Priority': + case 'SMTPDebug': + case 'WordWrap': + $mailer->$key = (int) $value; + break; + default: + $mailer->$key = $value; + break; + } + } + } + + /** + * Parse a URL. + * Wrapper for the built-in parse_url function to work around a bug in PHP 5.5. + * + * @param string $url URL + * + * @return array|false + */ + protected function parseUrl($url) + { + if (\PHP_VERSION_ID >= 50600 || false === strpos($url, '?')) { + return parse_url($url); + } + + $chunks = explode('?', $url); + if (is_array($chunks)) { + $result = parse_url($chunks[0]); + if (is_array($result)) { + $result['query'] = $chunks[1]; + } + return $result; + } + + return false; + } +} diff --git a/kirby/vendor/phpmailer/phpmailer/src/Exception.php b/kirby/vendor/phpmailer/phpmailer/src/Exception.php new file mode 100644 index 0000000..52eaf95 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/src/Exception.php @@ -0,0 +1,40 @@ + + * @author Jim Jagielski (jimjag) + * @author Andy Prevost (codeworxtech) + * @author Brent R. Matzelle (original founder) + * @copyright 2012 - 2020 Marcus Bointon + * @copyright 2010 - 2012 Jim Jagielski + * @copyright 2004 - 2009 Andy Prevost + * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License + * @note This program is distributed in the hope that it will be useful - WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. + */ + +namespace PHPMailer\PHPMailer; + +/** + * PHPMailer exception handler. + * + * @author Marcus Bointon + */ +class Exception extends \Exception +{ + /** + * Prettify error message output. + * + * @return string + */ + public function errorMessage() + { + return '' . htmlspecialchars($this->getMessage(), ENT_COMPAT | ENT_HTML401) . "
    \n"; + } +} diff --git a/kirby/vendor/phpmailer/phpmailer/src/OAuth.php b/kirby/vendor/phpmailer/phpmailer/src/OAuth.php new file mode 100644 index 0000000..c1d5b77 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/src/OAuth.php @@ -0,0 +1,139 @@ + + * @author Jim Jagielski (jimjag) + * @author Andy Prevost (codeworxtech) + * @author Brent R. Matzelle (original founder) + * @copyright 2012 - 2020 Marcus Bointon + * @copyright 2010 - 2012 Jim Jagielski + * @copyright 2004 - 2009 Andy Prevost + * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License + * @note This program is distributed in the hope that it will be useful - WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. + */ + +namespace PHPMailer\PHPMailer; + +use League\OAuth2\Client\Grant\RefreshToken; +use League\OAuth2\Client\Provider\AbstractProvider; +use League\OAuth2\Client\Token\AccessToken; + +/** + * OAuth - OAuth2 authentication wrapper class. + * Uses the oauth2-client package from the League of Extraordinary Packages. + * + * @see http://oauth2-client.thephpleague.com + * + * @author Marcus Bointon (Synchro/coolbru) + */ +class OAuth implements OAuthTokenProvider +{ + /** + * An instance of the League OAuth Client Provider. + * + * @var AbstractProvider + */ + protected $provider; + + /** + * The current OAuth access token. + * + * @var AccessToken + */ + protected $oauthToken; + + /** + * The user's email address, usually used as the login ID + * and also the from address when sending email. + * + * @var string + */ + protected $oauthUserEmail = ''; + + /** + * The client secret, generated in the app definition of the service you're connecting to. + * + * @var string + */ + protected $oauthClientSecret = ''; + + /** + * The client ID, generated in the app definition of the service you're connecting to. + * + * @var string + */ + protected $oauthClientId = ''; + + /** + * The refresh token, used to obtain new AccessTokens. + * + * @var string + */ + protected $oauthRefreshToken = ''; + + /** + * OAuth constructor. + * + * @param array $options Associative array containing + * `provider`, `userName`, `clientSecret`, `clientId` and `refreshToken` elements + */ + public function __construct($options) + { + $this->provider = $options['provider']; + $this->oauthUserEmail = $options['userName']; + $this->oauthClientSecret = $options['clientSecret']; + $this->oauthClientId = $options['clientId']; + $this->oauthRefreshToken = $options['refreshToken']; + } + + /** + * Get a new RefreshToken. + * + * @return RefreshToken + */ + protected function getGrant() + { + return new RefreshToken(); + } + + /** + * Get a new AccessToken. + * + * @return AccessToken + */ + protected function getToken() + { + return $this->provider->getAccessToken( + $this->getGrant(), + ['refresh_token' => $this->oauthRefreshToken] + ); + } + + /** + * Generate a base64-encoded OAuth token. + * + * @return string + */ + public function getOauth64() + { + //Get a new token if it's not available or has expired + if (null === $this->oauthToken || $this->oauthToken->hasExpired()) { + $this->oauthToken = $this->getToken(); + } + + return base64_encode( + 'user=' . + $this->oauthUserEmail . + "\001auth=Bearer " . + $this->oauthToken . + "\001\001" + ); + } +} diff --git a/kirby/vendor/phpmailer/phpmailer/src/OAuthTokenProvider.php b/kirby/vendor/phpmailer/phpmailer/src/OAuthTokenProvider.php new file mode 100644 index 0000000..1155507 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/src/OAuthTokenProvider.php @@ -0,0 +1,44 @@ + + * @author Jim Jagielski (jimjag) + * @author Andy Prevost (codeworxtech) + * @author Brent R. Matzelle (original founder) + * @copyright 2012 - 2020 Marcus Bointon + * @copyright 2010 - 2012 Jim Jagielski + * @copyright 2004 - 2009 Andy Prevost + * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License + * @note This program is distributed in the hope that it will be useful - WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. + */ + +namespace PHPMailer\PHPMailer; + +/** + * OAuthTokenProvider - OAuth2 token provider interface. + * Provides base64 encoded OAuth2 auth strings for SMTP authentication. + * + * @see OAuth + * @see SMTP::authenticate() + * + * @author Peter Scopes (pdscopes) + * @author Marcus Bointon (Synchro/coolbru) + */ +interface OAuthTokenProvider +{ + /** + * Generate a base64-encoded OAuth token ensuring that the access token has not expired. + * The string to be base 64 encoded should be in the form: + * "user=\001auth=Bearer \001\001" + * + * @return string + */ + public function getOauth64(); +} diff --git a/kirby/vendor/phpmailer/phpmailer/src/PHPMailer.php b/kirby/vendor/phpmailer/phpmailer/src/PHPMailer.php new file mode 100644 index 0000000..ba4bcd4 --- /dev/null +++ b/kirby/vendor/phpmailer/phpmailer/src/PHPMailer.php @@ -0,0 +1,5252 @@ + + * @author Jim Jagielski (jimjag) + * @author Andy Prevost (codeworxtech) + * @author Brent R. Matzelle (original founder) + * @copyright 2012 - 2020 Marcus Bointon + * @copyright 2010 - 2012 Jim Jagielski + * @copyright 2004 - 2009 Andy Prevost + * @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License + * @note This program is distributed in the hope that it will be useful - WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. + */ + +namespace PHPMailer\PHPMailer; + +/** + * PHPMailer - PHP email creation and transport class. + * + * @author Marcus Bointon (Synchro/coolbru) + * @author Jim Jagielski (jimjag) + * @author Andy Prevost (codeworxtech) + * @author Brent R. Matzelle (original founder) + */ +class PHPMailer +{ + const CHARSET_ASCII = 'us-ascii'; + const CHARSET_ISO88591 = 'iso-8859-1'; + const CHARSET_UTF8 = 'utf-8'; + + const CONTENT_TYPE_PLAINTEXT = 'text/plain'; + const CONTENT_TYPE_TEXT_CALENDAR = 'text/calendar'; + const CONTENT_TYPE_TEXT_HTML = 'text/html'; + const CONTENT_TYPE_MULTIPART_ALTERNATIVE = 'multipart/alternative'; + const CONTENT_TYPE_MULTIPART_MIXED = 'multipart/mixed'; + const CONTENT_TYPE_MULTIPART_RELATED = 'multipart/related'; + + const ENCODING_7BIT = '7bit'; + const ENCODING_8BIT = '8bit'; + const ENCODING_BASE64 = 'base64'; + const ENCODING_BINARY = 'binary'; + const ENCODING_QUOTED_PRINTABLE = 'quoted-printable'; + + const ENCRYPTION_STARTTLS = 'tls'; + const ENCRYPTION_SMTPS = 'ssl'; + + const ICAL_METHOD_REQUEST = 'REQUEST'; + const ICAL_METHOD_PUBLISH = 'PUBLISH'; + const ICAL_METHOD_REPLY = 'REPLY'; + const ICAL_METHOD_ADD = 'ADD'; + const ICAL_METHOD_CANCEL = 'CANCEL'; + const ICAL_METHOD_REFRESH = 'REFRESH'; + const ICAL_METHOD_COUNTER = 'COUNTER'; + const ICAL_METHOD_DECLINECOUNTER = 'DECLINECOUNTER'; + + /** + * Email priority. + * Options: null (default), 1 = High, 3 = Normal, 5 = low. + * When null, the header is not set at all. + * + * @var int|null + */ + public $Priority; + + /** + * The character set of the message. + * + * @var string + */ + public $CharSet = self::CHARSET_ISO88591; + + /** + * The MIME Content-type of the message. + * + * @var string + */ + public $ContentType = self::CONTENT_TYPE_PLAINTEXT; + + /** + * The message encoding. + * Options: "8bit", "7bit", "binary", "base64", and "quoted-printable". + * + * @var string + */ + public $Encoding = self::ENCODING_8BIT; + + /** + * Holds the most recent mailer error message. + * + * @var string + */ + public $ErrorInfo = ''; + + /** + * The From email address for the message. + * + * @var string + */ + public $From = ''; + + /** + * The From name of the message. + * + * @var string + */ + public $FromName = ''; + + /** + * The envelope sender of the message. + * This will usually be turned into a Return-Path header by the receiver, + * and is the address that bounces will be sent to. + * If not empty, will be passed via `-f` to sendmail or as the 'MAIL FROM' value over SMTP. + * + * @var string + */ + public $Sender = ''; + + /** + * The Subject of the message. + * + * @var string + */ + public $Subject = ''; + + /** + * An HTML or plain text message body. + * If HTML then call isHTML(true). + * + * @var string + */ + public $Body = ''; + + /** + * The plain-text message body. + * This body can be read by mail clients that do not have HTML email + * capability such as mutt & Eudora. + * Clients that can read HTML will view the normal Body. + * + * @var string + */ + public $AltBody = ''; + + /** + * An iCal message part body. + * Only supported in simple alt or alt_inline message types + * To generate iCal event structures, use classes like EasyPeasyICS or iCalcreator. + * + * @see http://sprain.ch/blog/downloads/php-class-easypeasyics-create-ical-files-with-php/ + * @see http://kigkonsult.se/iCalcreator/ + * + * @var string + */ + public $Ical = ''; + + /** + * Value-array of "method" in Contenttype header "text/calendar" + * + * @var string[] + */ + protected static $IcalMethods = [ + self::ICAL_METHOD_REQUEST, + self::ICAL_METHOD_PUBLISH, + self::ICAL_METHOD_REPLY, + self::ICAL_METHOD_ADD, + self::ICAL_METHOD_CANCEL, + self::ICAL_METHOD_REFRESH, + self::ICAL_METHOD_COUNTER, + self::ICAL_METHOD_DECLINECOUNTER, + ]; + + /** + * The complete compiled MIME message body. + * + * @var string + */ + protected $MIMEBody = ''; + + /** + * The complete compiled MIME message headers. + * + * @var string + */ + protected $MIMEHeader = ''; + + /** + * Extra headers that createHeader() doesn't fold in. + * + * @var string + */ + protected $mailHeader = ''; + + /** + * Word-wrap the message body to this number of chars. + * Set to 0 to not wrap. A useful value here is 78, for RFC2822 section 2.1.1 compliance. + * + * @see static::STD_LINE_LENGTH + * + * @var int + */ + public $WordWrap = 0; + + /** + * Which method to use to send mail. + * Options: "mail", "sendmail", or "smtp". + * + * @var string + */ + public $Mailer = 'mail'; + + /** + * The path to the sendmail program. + * + * @var string + */ + public $Sendmail = '/usr/sbin/sendmail'; + + /** + * Whether mail() uses a fully sendmail-compatible MTA. + * One which supports sendmail's "-oi -f" options. + * + * @var bool + */ + public $UseSendmailOptions = true; + + /** + * The email address that a reading confirmation should be sent to, also known as read receipt. + * + * @var string + */ + public $ConfirmReadingTo = ''; + + /** + * The hostname to use in the Message-ID header and as default HELO string. + * If empty, PHPMailer attempts to find one with, in order, + * $_SERVER['SERVER_NAME'], gethostname(), php_uname('n'), or the value + * 'localhost.localdomain'. + * + * @see PHPMailer::$Helo + * + * @var string + */ + public $Hostname = ''; + + /** + * An ID to be used in the Message-ID header. + * If empty, a unique id will be generated. + * You can set your own, but it must be in the format "", + * as defined in RFC5322 section 3.6.4 or it will be ignored. + * + * @see https://tools.ietf.org/html/rfc5322#section-3.6.4 + * + * @var string + */ + public $MessageID = ''; + + /** + * The message Date to be used in the Date header. + * If empty, the current date will be added. + * + * @var string + */ + public $MessageDate = ''; + + /** + * SMTP hosts. + * Either a single hostname or multiple semicolon-delimited hostnames. + * You can also specify a different port + * for each host by using this format: [hostname:port] + * (e.g. "smtp1.example.com:25;smtp2.example.com"). + * You can also specify encryption type, for example: + * (e.g. "tls://smtp1.example.com:587;ssl://smtp2.example.com:465"). + * Hosts will be tried in order. + * + * @var string + */ + public $Host = 'localhost'; + + /** + * The default SMTP server port. + * + * @var int + */ + public $Port = 25; + + /** + * The SMTP HELO/EHLO name used for the SMTP connection. + * Default is $Hostname. If $Hostname is empty, PHPMailer attempts to find + * one with the same method described above for $Hostname. + * + * @see PHPMailer::$Hostname + * + * @var string + */ + public $Helo = ''; + + /** + * What kind of encryption to use on the SMTP connection. + * Options: '', static::ENCRYPTION_STARTTLS, or static::ENCRYPTION_SMTPS. + * + * @var string + */ + public $SMTPSecure = ''; + + /** + * Whether to enable TLS encryption automatically if a server supports it, + * even if `SMTPSecure` is not set to 'tls'. + * Be aware that in PHP >= 5.6 this requires that the server's certificates are valid. + * + * @var bool + */ + public $SMTPAutoTLS = true; + + /** + * Whether to use SMTP authentication. + * Uses the Username and Password properties. + * + * @see PHPMailer::$Username + * @see PHPMailer::$Password + * + * @var bool + */ + public $SMTPAuth = false; + + /** + * Options array passed to stream_context_create when connecting via SMTP. + * + * @var array + */ + public $SMTPOptions = []; + + /** + * SMTP username. + * + * @var string + */ + public $Username = ''; + + /** + * SMTP password. + * + * @var string + */ + public $Password = ''; + + /** + * SMTP authentication type. Options are CRAM-MD5, LOGIN, PLAIN, XOAUTH2. + * If not specified, the first one from that list that the server supports will be selected. + * + * @var string + */ + public $AuthType = ''; + + /** + * SMTP SMTPXClient command attibutes + * + * @var array + */ + protected $SMTPXClient = []; + + /** + * An implementation of the PHPMailer OAuthTokenProvider interface. + * + * @var OAuthTokenProvider + */ + protected $oauth; + + /** + * The SMTP server timeout in seconds. + * Default of 5 minutes (300sec) is from RFC2821 section 4.5.3.2. + * + * @var int + */ + public $Timeout = 300; + + /** + * Comma separated list of DSN notifications + * 'NEVER' under no circumstances a DSN must be returned to the sender. + * If you use NEVER all other notifications will be ignored. + * 'SUCCESS' will notify you when your mail has arrived at its destination. + * 'FAILURE' will arrive if an error occurred during delivery. + * 'DELAY' will notify you if there is an unusual delay in delivery, but the actual + * delivery's outcome (success or failure) is not yet decided. + * + * @see https://tools.ietf.org/html/rfc3461 See section 4.1 for more information about NOTIFY + */ + public $dsn = ''; + + /** + * SMTP class debug output mode. + * Debug output level. + * Options: + * @see SMTP::DEBUG_OFF: No output + * @see SMTP::DEBUG_CLIENT: Client messages + * @see SMTP::DEBUG_SERVER: Client and server messages + * @see SMTP::DEBUG_CONNECTION: As SERVER plus connection status + * @see SMTP::DEBUG_LOWLEVEL: Noisy, low-level data output, rarely needed + * + * @see SMTP::$do_debug + * + * @var int + */ + public $SMTPDebug = 0; + + /** + * How to handle debug output. + * Options: + * * `echo` Output plain-text as-is, appropriate for CLI + * * `html` Output escaped, line breaks converted to `
    `, appropriate for browser output + * * `error_log` Output to error log as configured in php.ini + * By default PHPMailer will use `echo` if run from a `cli` or `cli-server` SAPI, `html` otherwise. + * Alternatively, you can provide a callable expecting two params: a message string and the debug level: + * + * ```php + * $mail->Debugoutput = function($str, $level) {echo "debug level $level; message: $str";}; + * ``` + * + * Alternatively, you can pass in an instance of a PSR-3 compatible logger, though only `debug` + * level output is used: + * + * ```php + * $mail->Debugoutput = new myPsr3Logger; + * ``` + * + * @see SMTP::$Debugoutput + * + * @var string|callable|\Psr\Log\LoggerInterface + */ + public $Debugoutput = 'echo'; + + /** + * Whether to keep the SMTP connection open after each message. + * If this is set to true then the connection will remain open after a send, + * and closing the connection will require an explicit call to smtpClose(). + * It's a good idea to use this if you are sending multiple messages as it reduces overhead. + * See the mailing list example for how to use it. + * + * @var bool + */ + public $SMTPKeepAlive = false; + + /** + * Whether to split multiple to addresses into multiple messages + * or send them all in one message. + * Only supported in `mail` and `sendmail` transports, not in SMTP. + * + * @var bool + * + * @deprecated 6.0.0 PHPMailer isn't a mailing list manager! + */ + public $SingleTo = false; + + /** + * Storage for addresses when SingleTo is enabled. + * + * @var array + */ + protected $SingleToArray = []; + + /** + * Whether to generate VERP addresses on send. + * Only applicable when sending via SMTP. + * + * @see https://en.wikipedia.org/wiki/Variable_envelope_return_path + * @see http://www.postfix.org/VERP_README.html Postfix VERP info + * + * @var bool + */ + public $do_verp = false; + + /** + * Whether to allow sending messages with an empty body. + * + * @var bool + */ + public $AllowEmpty = false; + + /** + * DKIM selector. + * + * @var string + */ + public $DKIM_selector = ''; + + /** + * DKIM Identity. + * Usually the email address used as the source of the email. + * + * @var string + */ + public $DKIM_identity = ''; + + /** + * DKIM passphrase. + * Used if your key is encrypted. + * + * @var string + */ + public $DKIM_passphrase = ''; + + /** + * DKIM signing domain name. + * + * @example 'example.com' + * + * @var string + */ + public $DKIM_domain = ''; + + /** + * DKIM Copy header field values for diagnostic use. + * + * @var bool + */ + public $DKIM_copyHeaderFields = true; + + /** + * DKIM Extra signing headers. + * + * @example ['List-Unsubscribe', 'List-Help'] + * + * @var array + */ + public $DKIM_extraHeaders = []; + + /** + * DKIM private key file path. + * + * @var string + */ + public $DKIM_private = ''; + + /** + * DKIM private key string. + * + * If set, takes precedence over `$DKIM_private`. + * + * @var string + */ + public $DKIM_private_string = ''; + + /** + * Callback Action function name. + * + * The function that handles the result of the send email action. + * It is called out by send() for each email sent. + * + * Value can be any php callable: http://www.php.net/is_callable + * + * Parameters: + * bool $result result of the send action + * array $to email addresses of the recipients + * array $cc cc email addresses + * array $bcc bcc email addresses + * string $subject the subject + * string $body the email body + * string $from email address of sender + * string $extra extra information of possible use + * "smtp_transaction_id' => last smtp transaction id + * + * @var string + */ + public $action_function = ''; + + /** + * What to put in the X-Mailer header. + * Options: An empty string for PHPMailer default, whitespace/null for none, or a string to use. + * + * @var string|null + */ + public $XMailer = ''; + + /** + * Which validator to use by default when validating email addresses. + * May be a callable to inject your own validator, but there are several built-in validators. + * The default validator uses PHP's FILTER_VALIDATE_EMAIL filter_var option. + * + * @see PHPMailer::validateAddress() + * + * @var string|callable + */ + public static $validator = 'php'; + + /** + * An instance of the SMTP sender class. + * + * @var SMTP + */ + protected $smtp; + + /** + * The array of 'to' names and addresses. + * + * @var array + */ + protected $to = []; + + /** + * The array of 'cc' names and addresses. + * + * @var array + */ + protected $cc = []; + + /** + * The array of 'bcc' names and addresses. + * + * @var array + */ + protected $bcc = []; + + /** + * The array of reply-to names and addresses. + * + * @var array + */ + protected $ReplyTo = []; + + /** + * An array of all kinds of addresses. + * Includes all of $to, $cc, $bcc. + * + * @see PHPMailer::$to + * @see PHPMailer::$cc + * @see PHPMailer::$bcc + * + * @var array + */ + protected $all_recipients = []; + + /** + * An array of names and addresses queued for validation. + * In send(), valid and non duplicate entries are moved to $all_recipients + * and one of $to, $cc, or $bcc. + * This array is used only for addresses with IDN. + * + * @see PHPMailer::$to + * @see PHPMailer::$cc + * @see PHPMailer::$bcc + * @see PHPMailer::$all_recipients + * + * @var array + */ + protected $RecipientsQueue = []; + + /** + * An array of reply-to names and addresses queued for validation. + * In send(), valid and non duplicate entries are moved to $ReplyTo. + * This array is used only for addresses with IDN. + * + * @see PHPMailer::$ReplyTo + * + * @var array + */ + protected $ReplyToQueue = []; + + /** + * The array of attachments. + * + * @var array + */ + protected $attachment = []; + + /** + * The array of custom headers. + * + * @var array + */ + protected $CustomHeader = []; + + /** + * The most recent Message-ID (including angular brackets). + * + * @var string + */ + protected $lastMessageID = ''; + + /** + * The message's MIME type. + * + * @var string + */ + protected $message_type = ''; + + /** + * The array of MIME boundary strings. + * + * @var array + */ + protected $boundary = []; + + /** + * The array of available text strings for the current language. + * + * @var array + */ + protected $language = []; + + /** + * The number of errors encountered. + * + * @var int + */ + protected $error_count = 0; + + /** + * The S/MIME certificate file path. + * + * @var string + */ + protected $sign_cert_file = ''; + + /** + * The S/MIME key file path. + * + * @var string + */ + protected $sign_key_file = ''; + + /** + * The optional S/MIME extra certificates ("CA Chain") file path. + * + * @var string + */ + protected $sign_extracerts_file = ''; + + /** + * The S/MIME password for the key. + * Used only if the key is encrypted. + * + * @var string + */ + protected $sign_key_pass = ''; + + /** + * Whether to throw exceptions for errors. + * + * @var bool + */ + protected $exceptions = false; + + /** + * Unique ID used for message ID and boundaries. + * + * @var string + */ + protected $uniqueid = ''; + + /** + * The PHPMailer Version number. + * + * @var string + */ + const VERSION = '6.9.1'; + + /** + * Error severity: message only, continue processing. + * + * @var int + */ + const STOP_MESSAGE = 0; + + /** + * Error severity: message, likely ok to continue processing. + * + * @var int + */ + const STOP_CONTINUE = 1; + + /** + * Error severity: message, plus full stop, critical error reached. + * + * @var int + */ + const STOP_CRITICAL = 2; + + /** + * The SMTP standard CRLF line break. + * If you want to change line break format, change static::$LE, not this. + */ + const CRLF = "\r\n"; + + /** + * "Folding White Space" a white space string used for line folding. + */ + const FWS = ' '; + + /** + * SMTP RFC standard line ending; Carriage Return, Line Feed. + * + * @var string + */ + protected static $LE = self::CRLF; + + /** + * The maximum line length supported by mail(). + * + * Background: mail() will sometimes corrupt messages + * with headers longer than 65 chars, see #818. + * + * @var int + */ + const MAIL_MAX_LINE_LENGTH = 63; + + /** + * The maximum line length allowed by RFC 2822 section 2.1.1. + * + * @var int + */ + const MAX_LINE_LENGTH = 998; + + /** + * The lower maximum line length allowed by RFC 2822 section 2.1.1. + * This length does NOT include the line break + * 76 means that lines will be 77 or 78 chars depending on whether + * the line break format is LF or CRLF; both are valid. + * + * @var int + */ + const STD_LINE_LENGTH = 76; + + /** + * Constructor. + * + * @param bool $exceptions Should we throw external exceptions? + */ + public function __construct($exceptions = null) + { + if (null !== $exceptions) { + $this->exceptions = (bool) $exceptions; + } + //Pick an appropriate debug output format automatically + $this->Debugoutput = (strpos(PHP_SAPI, 'cli') !== false ? 'echo' : 'html'); + } + + /** + * Destructor. + */ + public function __destruct() + { + //Close any open SMTP connection nicely + $this->smtpClose(); + } + + /** + * Call mail() in a safe_mode-aware fashion. + * Also, unless sendmail_path points to sendmail (or something that + * claims to be sendmail), don't pass params (not a perfect fix, + * but it will do). + * + * @param string $to To + * @param string $subject Subject + * @param string $body Message Body + * @param string $header Additional Header(s) + * @param string|null $params Params + * + * @return bool + */ + private function mailPassthru($to, $subject, $body, $header, $params) + { + //Check overloading of mail function to avoid double-encoding + if ((int)ini_get('mbstring.func_overload') & 1) { + $subject = $this->secureHeader($subject); + } else { + $subject = $this->encodeHeader($this->secureHeader($subject)); + } + //Calling mail() with null params breaks + $this->edebug('Sending with mail()'); + $this->edebug('Sendmail path: ' . ini_get('sendmail_path')); + $this->edebug("Envelope sender: {$this->Sender}"); + $this->edebug("To: {$to}"); + $this->edebug("Subject: {$subject}"); + $this->edebug("Headers: {$header}"); + if (!$this->UseSendmailOptions || null === $params) { + $result = @mail($to, $subject, $body, $header); + } else { + $this->edebug("Additional params: {$params}"); + $result = @mail($to, $subject, $body, $header, $params); + } + $this->edebug('Result: ' . ($result ? 'true' : 'false')); + return $result; + } + + /** + * Output debugging info via a user-defined method. + * Only generates output if debug output is enabled. + * + * @see PHPMailer::$Debugoutput + * @see PHPMailer::$SMTPDebug + * + * @param string $str + */ + protected function edebug($str) + { + if ($this->SMTPDebug <= 0) { + return; + } + //Is this a PSR-3 logger? + if ($this->Debugoutput instanceof \Psr\Log\LoggerInterface) { + $this->Debugoutput->debug($str); + + return; + } + //Avoid clash with built-in function names + if (is_callable($this->Debugoutput) && !in_array($this->Debugoutput, ['error_log', 'html', 'echo'])) { + call_user_func($this->Debugoutput, $str, $this->SMTPDebug); + + return; + } + switch ($this->Debugoutput) { + case 'error_log': + //Don't output, just log + /** @noinspection ForgottenDebugOutputInspection */ + error_log($str); + break; + case 'html': + //Cleans up output a bit for a better looking, HTML-safe output + echo htmlentities( + preg_replace('/[\r\n]+/', '', $str), + ENT_QUOTES, + 'UTF-8' + ), "
    \n"; + break; + case 'echo': + default: + //Normalize line breaks + $str = preg_replace('/\r\n|\r/m', "\n", $str); + echo gmdate('Y-m-d H:i:s'), + "\t", + //Trim trailing space + trim( + //Indent for readability, except for trailing break + str_replace( + "\n", + "\n \t ", + trim($str) + ) + ), + "\n"; + } + } + + /** + * Sets message type to HTML or plain. + * + * @param bool $isHtml True for HTML mode + */ + public function isHTML($isHtml = true) + { + if ($isHtml) { + $this->ContentType = static::CONTENT_TYPE_TEXT_HTML; + } else { + $this->ContentType = static::CONTENT_TYPE_PLAINTEXT; + } + } + + /** + * Send messages using SMTP. + */ + public function isSMTP() + { + $this->Mailer = 'smtp'; + } + + /** + * Send messages using PHP's mail() function. + */ + public function isMail() + { + $this->Mailer = 'mail'; + } + + /** + * Send messages using $Sendmail. + */ + public function isSendmail() + { + $ini_sendmail_path = ini_get('sendmail_path'); + + if (false === stripos($ini_sendmail_path, 'sendmail')) { + $this->Sendmail = '/usr/sbin/sendmail'; + } else { + $this->Sendmail = $ini_sendmail_path; + } + $this->Mailer = 'sendmail'; + } + + /** + * Send messages using qmail. + */ + public function isQmail() + { + $ini_sendmail_path = ini_get('sendmail_path'); + + if (false === stripos($ini_sendmail_path, 'qmail')) { + $this->Sendmail = '/var/qmail/bin/qmail-inject'; + } else { + $this->Sendmail = $ini_sendmail_path; + } + $this->Mailer = 'qmail'; + } + + /** + * Add a "To" address. + * + * @param string $address The email address to send to + * @param string $name + * + * @throws Exception + * + * @return bool true on success, false if address already used or invalid in some way + */ + public function addAddress($address, $name = '') + { + return $this->addOrEnqueueAnAddress('to', $address, $name); + } + + /** + * Add a "CC" address. + * + * @param string $address The email address to send to + * @param string $name + * + * @throws Exception + * + * @return bool true on success, false if address already used or invalid in some way + */ + public function addCC($address, $name = '') + { + return $this->addOrEnqueueAnAddress('cc', $address, $name); + } + + /** + * Add a "BCC" address. + * + * @param string $address The email address to send to + * @param string $name + * + * @throws Exception + * + * @return bool true on success, false if address already used or invalid in some way + */ + public function addBCC($address, $name = '') + { + return $this->addOrEnqueueAnAddress('bcc', $address, $name); + } + + /** + * Add a "Reply-To" address. + * + * @param string $address The email address to reply to + * @param string $name + * + * @throws Exception + * + * @return bool true on success, false if address already used or invalid in some way + */ + public function addReplyTo($address, $name = '') + { + return $this->addOrEnqueueAnAddress('Reply-To', $address, $name); + } + + /** + * Add an address to one of the recipient arrays or to the ReplyTo array. Because PHPMailer + * can't validate addresses with an IDN without knowing the PHPMailer::$CharSet (that can still + * be modified after calling this function), addition of such addresses is delayed until send(). + * Addresses that have been added already return false, but do not throw exceptions. + * + * @param string $kind One of 'to', 'cc', 'bcc', or 'ReplyTo' + * @param string $address The email address + * @param string $name An optional username associated with the address + * + * @throws Exception + * + * @return bool true on success, false if address already used or invalid in some way + */ + protected function addOrEnqueueAnAddress($kind, $address, $name) + { + $pos = false; + if ($address !== null) { + $address = trim($address); + $pos = strrpos($address, '@'); + } + if (false === $pos) { + //At-sign is missing. + $error_message = sprintf( + '%s (%s): %s', + $this->lang('invalid_address'), + $kind, + $address + ); + $this->setError($error_message); + $this->edebug($error_message); + if ($this->exceptions) { + throw new Exception($error_message); + } + + return false; + } + if ($name !== null && is_string($name)) { + $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim + } else { + $name = ''; + } + $params = [$kind, $address, $name]; + //Enqueue addresses with IDN until we know the PHPMailer::$CharSet. + //Domain is assumed to be whatever is after the last @ symbol in the address + if (static::idnSupported() && $this->has8bitChars(substr($address, ++$pos))) { + if ('Reply-To' !== $kind) { + if (!array_key_exists($address, $this->RecipientsQueue)) { + $this->RecipientsQueue[$address] = $params; + + return true; + } + } elseif (!array_key_exists($address, $this->ReplyToQueue)) { + $this->ReplyToQueue[$address] = $params; + + return true; + } + + return false; + } + + //Immediately add standard addresses without IDN. + return call_user_func_array([$this, 'addAnAddress'], $params); + } + + /** + * Set the boundaries to use for delimiting MIME parts. + * If you override this, ensure you set all 3 boundaries to unique values. + * The default boundaries include a "=_" sequence which cannot occur in quoted-printable bodies, + * as suggested by https://www.rfc-editor.org/rfc/rfc2045#section-6.7 + * + * @return void + */ + public function setBoundaries() + { + $this->uniqueid = $this->generateId(); + $this->boundary[1] = 'b1=_' . $this->uniqueid; + $this->boundary[2] = 'b2=_' . $this->uniqueid; + $this->boundary[3] = 'b3=_' . $this->uniqueid; + } + + /** + * Add an address to one of the recipient arrays or to the ReplyTo array. + * Addresses that have been added already return false, but do not throw exceptions. + * + * @param string $kind One of 'to', 'cc', 'bcc', or 'ReplyTo' + * @param string $address The email address to send, resp. to reply to + * @param string $name + * + * @throws Exception + * + * @return bool true on success, false if address already used or invalid in some way + */ + protected function addAnAddress($kind, $address, $name = '') + { + if (!in_array($kind, ['to', 'cc', 'bcc', 'Reply-To'])) { + $error_message = sprintf( + '%s: %s', + $this->lang('Invalid recipient kind'), + $kind + ); + $this->setError($error_message); + $this->edebug($error_message); + if ($this->exceptions) { + throw new Exception($error_message); + } + + return false; + } + if (!static::validateAddress($address)) { + $error_message = sprintf( + '%s (%s): %s', + $this->lang('invalid_address'), + $kind, + $address + ); + $this->setError($error_message); + $this->edebug($error_message); + if ($this->exceptions) { + throw new Exception($error_message); + } + + return false; + } + if ('Reply-To' !== $kind) { + if (!array_key_exists(strtolower($address), $this->all_recipients)) { + $this->{$kind}[] = [$address, $name]; + $this->all_recipients[strtolower($address)] = true; + + return true; + } + } elseif (!array_key_exists(strtolower($address), $this->ReplyTo)) { + $this->ReplyTo[strtolower($address)] = [$address, $name]; + + return true; + } + + return false; + } + + /** + * Parse and validate a string containing one or more RFC822-style comma-separated email addresses + * of the form "display name
    " into an array of name/address pairs. + * Uses the imap_rfc822_parse_adrlist function if the IMAP extension is available. + * Note that quotes in the name part are removed. + * + * @see http://www.andrew.cmu.edu/user/agreen1/testing/mrbs/web/Mail/RFC822.php A more careful implementation + * + * @param string $addrstr The address list string + * @param bool $useimap Whether to use the IMAP extension to parse the list + * @param string $charset The charset to use when decoding the address list string. + * + * @return array + */ + public static function parseAddresses($addrstr, $useimap = true, $charset = self::CHARSET_ISO88591) + { + $addresses = []; + if ($useimap && function_exists('imap_rfc822_parse_adrlist')) { + //Use this built-in parser if it's available + $list = imap_rfc822_parse_adrlist($addrstr, ''); + // Clear any potential IMAP errors to get rid of notices being thrown at end of script. + imap_errors(); + foreach ($list as $address) { + if ( + '.SYNTAX-ERROR.' !== $address->host && + static::validateAddress($address->mailbox . '@' . $address->host) + ) { + //Decode the name part if it's present and encoded + if ( + property_exists($address, 'personal') && + //Check for a Mbstring constant rather than using extension_loaded, which is sometimes disabled + defined('MB_CASE_UPPER') && + preg_match('/^=\?.*\?=$/s', $address->personal) + ) { + $origCharset = mb_internal_encoding(); + mb_internal_encoding($charset); + //Undo any RFC2047-encoded spaces-as-underscores + $address->personal = str_replace('_', '=20', $address->personal); + //Decode the name + $address->personal = mb_decode_mimeheader($address->personal); + mb_internal_encoding($origCharset); + } + + $addresses[] = [ + 'name' => (property_exists($address, 'personal') ? $address->personal : ''), + 'address' => $address->mailbox . '@' . $address->host, + ]; + } + } + } else { + //Use this simpler parser + $list = explode(',', $addrstr); + foreach ($list as $address) { + $address = trim($address); + //Is there a separate name part? + if (strpos($address, '<') === false) { + //No separate name, just use the whole thing + if (static::validateAddress($address)) { + $addresses[] = [ + 'name' => '', + 'address' => $address, + ]; + } + } else { + list($name, $email) = explode('<', $address); + $email = trim(str_replace('>', '', $email)); + $name = trim($name); + if (static::validateAddress($email)) { + //Check for a Mbstring constant rather than using extension_loaded, which is sometimes disabled + //If this name is encoded, decode it + if (defined('MB_CASE_UPPER') && preg_match('/^=\?.*\?=$/s', $name)) { + $origCharset = mb_internal_encoding(); + mb_internal_encoding($charset); + //Undo any RFC2047-encoded spaces-as-underscores + $name = str_replace('_', '=20', $name); + //Decode the name + $name = mb_decode_mimeheader($name); + mb_internal_encoding($origCharset); + } + $addresses[] = [ + //Remove any surrounding quotes and spaces from the name + 'name' => trim($name, '\'" '), + 'address' => $email, + ]; + } + } + } + } + + return $addresses; + } + + /** + * Set the From and FromName properties. + * + * @param string $address + * @param string $name + * @param bool $auto Whether to also set the Sender address, defaults to true + * + * @throws Exception + * + * @return bool + */ + public function setFrom($address, $name = '', $auto = true) + { + $address = trim((string)$address); + $name = trim(preg_replace('/[\r\n]+/', '', $name)); //Strip breaks and trim + //Don't validate now addresses with IDN. Will be done in send(). + $pos = strrpos($address, '@'); + if ( + (false === $pos) + || ((!$this->has8bitChars(substr($address, ++$pos)) || !static::idnSupported()) + && !static::validateAddress($address)) + ) { + $error_message = sprintf( + '%s (From): %s', + $this->lang('invalid_address'), + $address + ); + $this->setError($error_message); + $this->edebug($error_message); + if ($this->exceptions) { + throw new Exception($error_message); + } + + return false; + } + $this->From = $address; + $this->FromName = $name; + if ($auto && empty($this->Sender)) { + $this->Sender = $address; + } + + return true; + } + + /** + * Return the Message-ID header of the last email. + * Technically this is the value from the last time the headers were created, + * but it's also the message ID of the last sent message except in + * pathological cases. + * + * @return string + */ + public function getLastMessageID() + { + return $this->lastMessageID; + } + + /** + * Check that a string looks like an email address. + * Validation patterns supported: + * * `auto` Pick best pattern automatically; + * * `pcre8` Use the squiloople.com pattern, requires PCRE > 8.0; + * * `pcre` Use old PCRE implementation; + * * `php` Use PHP built-in FILTER_VALIDATE_EMAIL; + * * `html5` Use the pattern given by the HTML5 spec for 'email' type form input elements. + * * `noregex` Don't use a regex: super fast, really dumb. + * Alternatively you may pass in a callable to inject your own validator, for example: + * + * ```php + * PHPMailer::validateAddress('user@example.com', function($address) { + * return (strpos($address, '@') !== false); + * }); + * ``` + * + * You can also set the PHPMailer::$validator static to a callable, allowing built-in methods to use your validator. + * + * @param string $address The email address to check + * @param string|callable $patternselect Which pattern to use + * + * @return bool + */ + public static function validateAddress($address, $patternselect = null) + { + if (null === $patternselect) { + $patternselect = static::$validator; + } + //Don't allow strings as callables, see SECURITY.md and CVE-2021-3603 + if (is_callable($patternselect) && !is_string($patternselect)) { + return call_user_func($patternselect, $address); + } + //Reject line breaks in addresses; it's valid RFC5322, but not RFC5321 + if (strpos($address, "\n") !== false || strpos($address, "\r") !== false) { + return false; + } + switch ($patternselect) { + case 'pcre': //Kept for BC + case 'pcre8': + /* + * A more complex and more permissive version of the RFC5322 regex on which FILTER_VALIDATE_EMAIL + * is based. + * In addition to the addresses allowed by filter_var, also permits: + * * dotless domains: `a@b` + * * comments: `1234 @ local(blah) .machine .example` + * * quoted elements: `'"test blah"@example.org'` + * * numeric TLDs: `a@b.123` + * * unbracketed IPv4 literals: `a@192.168.0.1` + * * IPv6 literals: 'first.last@[IPv6:a1::]' + * Not all of these will necessarily work for sending! + * + * @see http://squiloople.com/2009/12/20/email-address-validation/ + * @copyright 2009-2010 Michael Rushton + * Feel free to use and redistribute this code. But please keep this copyright notice. + */ + return (bool) preg_match( + '/^(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){255,})(?!(?>(?1)"?(?>\\\[ -~]|[^"])"?(?1)){65,}@)' . + '((?>(?>(?>((?>(?>(?>\x0D\x0A)?[\t ])+|(?>[\t ]*\x0D\x0A)?[\t ]+)?)(\((?>(?2)' . + '(?>[\x01-\x08\x0B\x0C\x0E-\'*-\[\]-\x7F]|\\\[\x00-\x7F]|(?3)))*(?2)\)))+(?2))|(?2))?)' . + '([!#-\'*+\/-9=?^-~-]+|"(?>(?2)(?>[\x01-\x08\x0B\x0C\x0E-!#-\[\]-\x7F]|\\\[\x00-\x7F]))*' . + '(?2)")(?>(?1)\.(?1)(?4))*(?1)@(?!(?1)[a-z0-9-]{64,})(?1)(?>([a-z0-9](?>[a-z0-9-]*[a-z0-9])?)' . + '(?>(?1)\.(?!(?1)[a-z0-9-]{64,})(?1)(?5)){0,126}|\[(?:(?>IPv6:(?>([a-f0-9]{1,4})(?>:(?6)){7}' . + '|(?!(?:.*[a-f0-9][:\]]){8,})((?6)(?>:(?6)){0,6})?::(?7)?))|(?>(?>IPv6:(?>(?6)(?>:(?6)){5}:' . + '|(?!(?:.*[a-f0-9]:){6,})(?8)?::(?>((?6)(?>:(?6)){0,4}):)?))?(25[0-5]|2[0-4][0-9]|1[0-9]{2}' . + '|[1-9]?[0-9])(?>\.(?9)){3}))\])(?1)$/isD', + $address + ); + case 'html5': + /* + * This is the pattern used in the HTML5 spec for validation of 'email' type form input elements. + * + * @see https://html.spec.whatwg.org/#e-mail-state-(type=email) + */ + return (bool) preg_match( + '/^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}' . + '[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/sD', + $address + ); + case 'php': + default: + return filter_var($address, FILTER_VALIDATE_EMAIL) !== false; + } + } + + /** + * Tells whether IDNs (Internationalized Domain Names) are supported or not. This requires the + * `intl` and `mbstring` PHP extensions. + * + * @return bool `true` if required functions for IDN support are present + */ + public static function idnSupported() + { + return function_exists('idn_to_ascii') && function_exists('mb_convert_encoding'); + } + + /** + * Converts IDN in given email address to its ASCII form, also known as punycode, if possible. + * Important: Address must be passed in same encoding as currently set in PHPMailer::$CharSet. + * This function silently returns unmodified address if: + * - No conversion is necessary (i.e. domain name is not an IDN, or is already in ASCII form) + * - Conversion to punycode is impossible (e.g. required PHP functions are not available) + * or fails for any reason (e.g. domain contains characters not allowed in an IDN). + * + * @see PHPMailer::$CharSet + * + * @param string $address The email address to convert + * + * @return string The encoded address in ASCII form + */ + public function punyencodeAddress($address) + { + //Verify we have required functions, CharSet, and at-sign. + $pos = strrpos($address, '@'); + if ( + !empty($this->CharSet) && + false !== $pos && + static::idnSupported() + ) { + $domain = substr($address, ++$pos); + //Verify CharSet string is a valid one, and domain properly encoded in this CharSet. + if ($this->has8bitChars($domain) && @mb_check_encoding($domain, $this->CharSet)) { + //Convert the domain from whatever charset it's in to UTF-8 + $domain = mb_convert_encoding($domain, self::CHARSET_UTF8, $this->CharSet); + //Ignore IDE complaints about this line - method signature changed in PHP 5.4 + $errorcode = 0; + if (defined('INTL_IDNA_VARIANT_UTS46')) { + //Use the current punycode standard (appeared in PHP 7.2) + $punycode = idn_to_ascii( + $domain, + \IDNA_DEFAULT | \IDNA_USE_STD3_RULES | \IDNA_CHECK_BIDI | + \IDNA_CHECK_CONTEXTJ | \IDNA_NONTRANSITIONAL_TO_ASCII, + \INTL_IDNA_VARIANT_UTS46 + ); + } elseif (defined('INTL_IDNA_VARIANT_2003')) { + //Fall back to this old, deprecated/removed encoding + $punycode = idn_to_ascii($domain, $errorcode, \INTL_IDNA_VARIANT_2003); + } else { + //Fall back to a default we don't know about + $punycode = idn_to_ascii($domain, $errorcode); + } + if (false !== $punycode) { + return substr($address, 0, $pos) . $punycode; + } + } + } + + return $address; + } + + /** + * Create a message and send it. + * Uses the sending method specified by $Mailer. + * + * @throws Exception + * + * @return bool false on error - See the ErrorInfo property for details of the error + */ + public function send() + { + try { + if (!$this->preSend()) { + return false; + } + + return $this->postSend(); + } catch (Exception $exc) { + $this->mailHeader = ''; + $this->setError($exc->getMessage()); + if ($this->exceptions) { + throw $exc; + } + + return false; + } + } + + /** + * Prepare a message for sending. + * + * @throws Exception + * + * @return bool + */ + public function preSend() + { + if ( + 'smtp' === $this->Mailer + || ('mail' === $this->Mailer && (\PHP_VERSION_ID >= 80000 || stripos(PHP_OS, 'WIN') === 0)) + ) { + //SMTP mandates RFC-compliant line endings + //and it's also used with mail() on Windows + static::setLE(self::CRLF); + } else { + //Maintain backward compatibility with legacy Linux command line mailers + static::setLE(PHP_EOL); + } + //Check for buggy PHP versions that add a header with an incorrect line break + if ( + 'mail' === $this->Mailer + && ((\PHP_VERSION_ID >= 70000 && \PHP_VERSION_ID < 70017) + || (\PHP_VERSION_ID >= 70100 && \PHP_VERSION_ID < 70103)) + && ini_get('mail.add_x_header') === '1' + && stripos(PHP_OS, 'WIN') === 0 + ) { + trigger_error($this->lang('buggy_php'), E_USER_WARNING); + } + + try { + $this->error_count = 0; //Reset errors + $this->mailHeader = ''; + + //Dequeue recipient and Reply-To addresses with IDN + foreach (array_merge($this->RecipientsQueue, $this->ReplyToQueue) as $params) { + $params[1] = $this->punyencodeAddress($params[1]); + call_user_func_array([$this, 'addAnAddress'], $params); + } + if (count($this->to) + count($this->cc) + count($this->bcc) < 1) { + throw new Exception($this->lang('provide_address'), self::STOP_CRITICAL); + } + + //Validate From, Sender, and ConfirmReadingTo addresses + foreach (['From', 'Sender', 'ConfirmReadingTo'] as $address_kind) { + if ($this->{$address_kind} === null) { + $this->{$address_kind} = ''; + continue; + } + $this->{$address_kind} = trim($this->{$address_kind}); + if (empty($this->{$address_kind})) { + continue; + } + $this->{$address_kind} = $this->punyencodeAddress($this->{$address_kind}); + if (!static::validateAddress($this->{$address_kind})) { + $error_message = sprintf( + '%s (%s): %s', + $this->lang('invalid_address'), + $address_kind, + $this->{$address_kind} + ); + $this->setError($error_message); + $this->edebug($error_message); + if ($this->exceptions) { + throw new Exception($error_message); + } + + return false; + } + } + + //Set whether the message is multipart/alternative + if ($this->alternativeExists()) { + $this->ContentType = static::CONTENT_TYPE_MULTIPART_ALTERNATIVE; + } + + $this->setMessageType(); + //Refuse to send an empty message unless we are specifically allowing it + if (!$this->AllowEmpty && empty($this->Body)) { + throw new Exception($this->lang('empty_message'), self::STOP_CRITICAL); + } + + //Trim subject consistently + $this->Subject = trim($this->Subject); + //Create body before headers in case body makes changes to headers (e.g. altering transfer encoding) + $this->MIMEHeader = ''; + $this->MIMEBody = $this->createBody(); + //createBody may have added some headers, so retain them + $tempheaders = $this->MIMEHeader; + $this->MIMEHeader = $this->createHeader(); + $this->MIMEHeader .= $tempheaders; + + //To capture the complete message when using mail(), create + //an extra header list which createHeader() doesn't fold in + if ('mail' === $this->Mailer) { + if (count($this->to) > 0) { + $this->mailHeader .= $this->addrAppend('To', $this->to); + } else { + $this->mailHeader .= $this->headerLine('To', 'undisclosed-recipients:;'); + } + $this->mailHeader .= $this->headerLine( + 'Subject', + $this->encodeHeader($this->secureHeader($this->Subject)) + ); + } + + //Sign with DKIM if enabled + if ( + !empty($this->DKIM_domain) + && !empty($this->DKIM_selector) + && (!empty($this->DKIM_private_string) + || (!empty($this->DKIM_private) + && static::isPermittedPath($this->DKIM_private) + && file_exists($this->DKIM_private) + ) + ) + ) { + $header_dkim = $this->DKIM_Add( + $this->MIMEHeader . $this->mailHeader, + $this->encodeHeader($this->secureHeader($this->Subject)), + $this->MIMEBody + ); + $this->MIMEHeader = static::stripTrailingWSP($this->MIMEHeader) . static::$LE . + static::normalizeBreaks($header_dkim) . static::$LE; + } + + return true; + } catch (Exception $exc) { + $this->setError($exc->getMessage()); + if ($this->exceptions) { + throw $exc; + } + + return false; + } + } + + /** + * Actually send a message via the selected mechanism. + * + * @throws Exception + * + * @return bool + */ + public function postSend() + { + try { + //Choose the mailer and send through it + switch ($this->Mailer) { + case 'sendmail': + case 'qmail': + return $this->sendmailSend($this->MIMEHeader, $this->MIMEBody); + case 'smtp': + return $this->smtpSend($this->MIMEHeader, $this->MIMEBody); + case 'mail': + return $this->mailSend($this->MIMEHeader, $this->MIMEBody); + default: + $sendMethod = $this->Mailer . 'Send'; + if (method_exists($this, $sendMethod)) { + return $this->{$sendMethod}($this->MIMEHeader, $this->MIMEBody); + } + + return $this->mailSend($this->MIMEHeader, $this->MIMEBody); + } + } catch (Exception $exc) { + $this->setError($exc->getMessage()); + $this->edebug($exc->getMessage()); + if ($this->Mailer === 'smtp' && $this->SMTPKeepAlive == true && $this->smtp->connected()) { + $this->smtp->reset(); + } + if ($this->exceptions) { + throw $exc; + } + } + + return false; + } + + /** + * Send mail using the $Sendmail program. + * + * @see PHPMailer::$Sendmail + * + * @param string $header The message headers + * @param string $body The message body + * + * @throws Exception + * + * @return bool + */ + protected function sendmailSend($header, $body) + { + if ($this->Mailer === 'qmail') { + $this->edebug('Sending with qmail'); + } else { + $this->edebug('Sending with sendmail'); + } + $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; + //This sets the SMTP envelope sender which gets turned into a return-path header by the receiver + //A space after `-f` is optional, but there is a long history of its presence + //causing problems, so we don't use one + //Exim docs: http://www.exim.org/exim-html-current/doc/html/spec_html/ch-the_exim_command_line.html + //Sendmail docs: http://www.sendmail.org/~ca/email/man/sendmail.html + //Qmail docs: http://www.qmail.org/man/man8/qmail-inject.html + //Example problem: https://www.drupal.org/node/1057954 + + //PHP 5.6 workaround + $sendmail_from_value = ini_get('sendmail_from'); + if (empty($this->Sender) && !empty($sendmail_from_value)) { + //PHP config has a sender address we can use + $this->Sender = ini_get('sendmail_from'); + } + //CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. + if (!empty($this->Sender) && static::validateAddress($this->Sender) && self::isShellSafe($this->Sender)) { + if ($this->Mailer === 'qmail') { + $sendmailFmt = '%s -f%s'; + } else { + $sendmailFmt = '%s -oi -f%s -t'; + } + } else { + //allow sendmail to choose a default envelope sender. It may + //seem preferable to force it to use the From header as with + //SMTP, but that introduces new problems (see + //), and + //it has historically worked this way. + $sendmailFmt = '%s -oi -t'; + } + + $sendmail = sprintf($sendmailFmt, escapeshellcmd($this->Sendmail), $this->Sender); + $this->edebug('Sendmail path: ' . $this->Sendmail); + $this->edebug('Sendmail command: ' . $sendmail); + $this->edebug('Envelope sender: ' . $this->Sender); + $this->edebug("Headers: {$header}"); + + if ($this->SingleTo) { + foreach ($this->SingleToArray as $toAddr) { + $mail = @popen($sendmail, 'w'); + if (!$mail) { + throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); + } + $this->edebug("To: {$toAddr}"); + fwrite($mail, 'To: ' . $toAddr . "\n"); + fwrite($mail, $header); + fwrite($mail, $body); + $result = pclose($mail); + $addrinfo = static::parseAddresses($toAddr, true, $this->CharSet); + $this->doCallback( + ($result === 0), + [[$addrinfo['address'], $addrinfo['name']]], + $this->cc, + $this->bcc, + $this->Subject, + $body, + $this->From, + [] + ); + $this->edebug("Result: " . ($result === 0 ? 'true' : 'false')); + if (0 !== $result) { + throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); + } + } + } else { + $mail = @popen($sendmail, 'w'); + if (!$mail) { + throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); + } + fwrite($mail, $header); + fwrite($mail, $body); + $result = pclose($mail); + $this->doCallback( + ($result === 0), + $this->to, + $this->cc, + $this->bcc, + $this->Subject, + $body, + $this->From, + [] + ); + $this->edebug("Result: " . ($result === 0 ? 'true' : 'false')); + if (0 !== $result) { + throw new Exception($this->lang('execute') . $this->Sendmail, self::STOP_CRITICAL); + } + } + + return true; + } + + /** + * Fix CVE-2016-10033 and CVE-2016-10045 by disallowing potentially unsafe shell characters. + * Note that escapeshellarg and escapeshellcmd are inadequate for our purposes, especially on Windows. + * + * @see https://github.com/PHPMailer/PHPMailer/issues/924 CVE-2016-10045 bug report + * + * @param string $string The string to be validated + * + * @return bool + */ + protected static function isShellSafe($string) + { + //It's not possible to use shell commands safely (which includes the mail() function) without escapeshellarg, + //but some hosting providers disable it, creating a security problem that we don't want to have to deal with, + //so we don't. + if (!function_exists('escapeshellarg') || !function_exists('escapeshellcmd')) { + return false; + } + + if ( + escapeshellcmd($string) !== $string + || !in_array(escapeshellarg($string), ["'$string'", "\"$string\""]) + ) { + return false; + } + + $length = strlen($string); + + for ($i = 0; $i < $length; ++$i) { + $c = $string[$i]; + + //All other characters have a special meaning in at least one common shell, including = and +. + //Full stop (.) has a special meaning in cmd.exe, but its impact should be negligible here. + //Note that this does permit non-Latin alphanumeric characters based on the current locale. + if (!ctype_alnum($c) && strpos('@_-.', $c) === false) { + return false; + } + } + + return true; + } + + /** + * Check whether a file path is of a permitted type. + * Used to reject URLs and phar files from functions that access local file paths, + * such as addAttachment. + * + * @param string $path A relative or absolute path to a file + * + * @return bool + */ + protected static function isPermittedPath($path) + { + //Matches scheme definition from https://tools.ietf.org/html/rfc3986#section-3.1 + return !preg_match('#^[a-z][a-z\d+.-]*://#i', $path); + } + + /** + * Check whether a file path is safe, accessible, and readable. + * + * @param string $path A relative or absolute path to a file + * + * @return bool + */ + protected static function fileIsAccessible($path) + { + if (!static::isPermittedPath($path)) { + return false; + } + $readable = is_file($path); + //If not a UNC path (expected to start with \\), check read permission, see #2069 + if (strpos($path, '\\\\') !== 0) { + $readable = $readable && is_readable($path); + } + return $readable; + } + + /** + * Send mail using the PHP mail() function. + * + * @see http://www.php.net/manual/en/book.mail.php + * + * @param string $header The message headers + * @param string $body The message body + * + * @throws Exception + * + * @return bool + */ + protected function mailSend($header, $body) + { + $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; + + $toArr = []; + foreach ($this->to as $toaddr) { + $toArr[] = $this->addrFormat($toaddr); + } + $to = trim(implode(', ', $toArr)); + + //If there are no To-addresses (e.g. when sending only to BCC-addresses) + //the following should be added to get a correct DKIM-signature. + //Compare with $this->preSend() + if ($to === '') { + $to = 'undisclosed-recipients:;'; + } + + $params = null; + //This sets the SMTP envelope sender which gets turned into a return-path header by the receiver + //A space after `-f` is optional, but there is a long history of its presence + //causing problems, so we don't use one + //Exim docs: http://www.exim.org/exim-html-current/doc/html/spec_html/ch-the_exim_command_line.html + //Sendmail docs: http://www.sendmail.org/~ca/email/man/sendmail.html + //Qmail docs: http://www.qmail.org/man/man8/qmail-inject.html + //Example problem: https://www.drupal.org/node/1057954 + //CVE-2016-10033, CVE-2016-10045: Don't pass -f if characters will be escaped. + + //PHP 5.6 workaround + $sendmail_from_value = ini_get('sendmail_from'); + if (empty($this->Sender) && !empty($sendmail_from_value)) { + //PHP config has a sender address we can use + $this->Sender = ini_get('sendmail_from'); + } + if (!empty($this->Sender) && static::validateAddress($this->Sender)) { + if (self::isShellSafe($this->Sender)) { + $params = sprintf('-f%s', $this->Sender); + } + $old_from = ini_get('sendmail_from'); + ini_set('sendmail_from', $this->Sender); + } + $result = false; + if ($this->SingleTo && count($toArr) > 1) { + foreach ($toArr as $toAddr) { + $result = $this->mailPassthru($toAddr, $this->Subject, $body, $header, $params); + $addrinfo = static::parseAddresses($toAddr, true, $this->CharSet); + $this->doCallback( + $result, + [[$addrinfo['address'], $addrinfo['name']]], + $this->cc, + $this->bcc, + $this->Subject, + $body, + $this->From, + [] + ); + } + } else { + $result = $this->mailPassthru($to, $this->Subject, $body, $header, $params); + $this->doCallback($result, $this->to, $this->cc, $this->bcc, $this->Subject, $body, $this->From, []); + } + if (isset($old_from)) { + ini_set('sendmail_from', $old_from); + } + if (!$result) { + throw new Exception($this->lang('instantiate'), self::STOP_CRITICAL); + } + + return true; + } + + /** + * Get an instance to use for SMTP operations. + * Override this function to load your own SMTP implementation, + * or set one with setSMTPInstance. + * + * @return SMTP + */ + public function getSMTPInstance() + { + if (!is_object($this->smtp)) { + $this->smtp = new SMTP(); + } + + return $this->smtp; + } + + /** + * Provide an instance to use for SMTP operations. + * + * @return SMTP + */ + public function setSMTPInstance(SMTP $smtp) + { + $this->smtp = $smtp; + + return $this->smtp; + } + + /** + * Provide SMTP XCLIENT attributes + * + * @param string $name Attribute name + * @param ?string $value Attribute value + * + * @return bool + */ + public function setSMTPXclientAttribute($name, $value) + { + if (!in_array($name, SMTP::$xclient_allowed_attributes)) { + return false; + } + if (isset($this->SMTPXClient[$name]) && $value === null) { + unset($this->SMTPXClient[$name]); + } elseif ($value !== null) { + $this->SMTPXClient[$name] = $value; + } + + return true; + } + + /** + * Get SMTP XCLIENT attributes + * + * @return array + */ + public function getSMTPXclientAttributes() + { + return $this->SMTPXClient; + } + + /** + * Send mail via SMTP. + * Returns false if there is a bad MAIL FROM, RCPT, or DATA input. + * + * @see PHPMailer::setSMTPInstance() to use a different class. + * + * @uses \PHPMailer\PHPMailer\SMTP + * + * @param string $header The message headers + * @param string $body The message body + * + * @throws Exception + * + * @return bool + */ + protected function smtpSend($header, $body) + { + $header = static::stripTrailingWSP($header) . static::$LE . static::$LE; + $bad_rcpt = []; + if (!$this->smtpConnect($this->SMTPOptions)) { + throw new Exception($this->lang('smtp_connect_failed'), self::STOP_CRITICAL); + } + //Sender already validated in preSend() + if ('' === $this->Sender) { + $smtp_from = $this->From; + } else { + $smtp_from = $this->Sender; + } + if (count($this->SMTPXClient)) { + $this->smtp->xclient($this->SMTPXClient); + } + if (!$this->smtp->mail($smtp_from)) { + $this->setError($this->lang('from_failed') . $smtp_from . ' : ' . implode(',', $this->smtp->getError())); + throw new Exception($this->ErrorInfo, self::STOP_CRITICAL); + } + + $callbacks = []; + //Attempt to send to all recipients + foreach ([$this->to, $this->cc, $this->bcc] as $togroup) { + foreach ($togroup as $to) { + if (!$this->smtp->recipient($to[0], $this->dsn)) { + $error = $this->smtp->getError(); + $bad_rcpt[] = ['to' => $to[0], 'error' => $error['detail']]; + $isSent = false; + } else { + $isSent = true; + } + + $callbacks[] = ['issent' => $isSent, 'to' => $to[0], 'name' => $to[1]]; + } + } + + //Only send the DATA command if we have viable recipients + if ((count($this->all_recipients) > count($bad_rcpt)) && !$this->smtp->data($header . $body)) { + throw new Exception($this->lang('data_not_accepted'), self::STOP_CRITICAL); + } + + $smtp_transaction_id = $this->smtp->getLastTransactionID(); + + if ($this->SMTPKeepAlive) { + $this->smtp->reset(); + } else { + $this->smtp->quit(); + $this->smtp->close(); + } + + foreach ($callbacks as $cb) { + $this->doCallback( + $cb['issent'], + [[$cb['to'], $cb['name']]], + [], + [], + $this->Subject, + $body, + $this->From, + ['smtp_transaction_id' => $smtp_transaction_id] + ); + } + + //Create error message for any bad addresses + if (count($bad_rcpt) > 0) { + $errstr = ''; + foreach ($bad_rcpt as $bad) { + $errstr .= $bad['to'] . ': ' . $bad['error']; + } + throw new Exception($this->lang('recipients_failed') . $errstr, self::STOP_CONTINUE); + } + + return true; + } + + /** + * Initiate a connection to an SMTP server. + * Returns false if the operation failed. + * + * @param array $options An array of options compatible with stream_context_create() + * + * @throws Exception + * + * @uses \PHPMailer\PHPMailer\SMTP + * + * @return bool + */ + public function smtpConnect($options = null) + { + if (null === $this->smtp) { + $this->smtp = $this->getSMTPInstance(); + } + + //If no options are provided, use whatever is set in the instance + if (null === $options) { + $options = $this->SMTPOptions; + } + + //Already connected? + if ($this->smtp->connected()) { + return true; + } + + $this->smtp->setTimeout($this->Timeout); + $this->smtp->setDebugLevel($this->SMTPDebug); + $this->smtp->setDebugOutput($this->Debugoutput); + $this->smtp->setVerp($this->do_verp); + if ($this->Host === null) { + $this->Host = 'localhost'; + } + $hosts = explode(';', $this->Host); + $lastexception = null; + + foreach ($hosts as $hostentry) { + $hostinfo = []; + if ( + !preg_match( + '/^(?:(ssl|tls):\/\/)?(.+?)(?::(\d+))?$/', + trim($hostentry), + $hostinfo + ) + ) { + $this->edebug($this->lang('invalid_hostentry') . ' ' . trim($hostentry)); + //Not a valid host entry + continue; + } + //$hostinfo[1]: optional ssl or tls prefix + //$hostinfo[2]: the hostname + //$hostinfo[3]: optional port number + //The host string prefix can temporarily override the current setting for SMTPSecure + //If it's not specified, the default value is used + + //Check the host name is a valid name or IP address before trying to use it + if (!static::isValidHost($hostinfo[2])) { + $this->edebug($this->lang('invalid_host') . ' ' . $hostinfo[2]); + continue; + } + $prefix = ''; + $secure = $this->SMTPSecure; + $tls = (static::ENCRYPTION_STARTTLS === $this->SMTPSecure); + if ('ssl' === $hostinfo[1] || ('' === $hostinfo[1] && static::ENCRYPTION_SMTPS === $this->SMTPSecure)) { + $prefix = 'ssl://'; + $tls = false; //Can't have SSL and TLS at the same time + $secure = static::ENCRYPTION_SMTPS; + } elseif ('tls' === $hostinfo[1]) { + $tls = true; + //TLS doesn't use a prefix + $secure = static::ENCRYPTION_STARTTLS; + } + //Do we need the OpenSSL extension? + $sslext = defined('OPENSSL_ALGO_SHA256'); + if (static::ENCRYPTION_STARTTLS === $secure || static::ENCRYPTION_SMTPS === $secure) { + //Check for an OpenSSL constant rather than using extension_loaded, which is sometimes disabled + if (!$sslext) { + throw new Exception($this->lang('extension_missing') . 'openssl', self::STOP_CRITICAL); + } + } + $host = $hostinfo[2]; + $port = $this->Port; + if ( + array_key_exists(3, $hostinfo) && + is_numeric($hostinfo[3]) && + $hostinfo[3] > 0 && + $hostinfo[3] < 65536 + ) { + $port = (int) $hostinfo[3]; + } + if ($this->smtp->connect($prefix . $host, $port, $this->Timeout, $options)) { + try { + if ($this->Helo) { + $hello = $this->Helo; + } else { + $hello = $this->serverHostname(); + } + $this->smtp->hello($hello); + //Automatically enable TLS encryption if: + //* it's not disabled + //* we are not connecting to localhost + //* we have openssl extension + //* we are not already using SSL + //* the server offers STARTTLS + if ( + $this->SMTPAutoTLS && + $this->Host !== 'localhost' && + $sslext && + $secure !== 'ssl' && + $this->smtp->getServerExt('STARTTLS') + ) { + $tls = true; + } + if ($tls) { + if (!$this->smtp->startTLS()) { + $message = $this->getSmtpErrorMessage('connect_host'); + throw new Exception($message); + } + //We must resend EHLO after TLS negotiation + $this->smtp->hello($hello); + } + if ( + $this->SMTPAuth && !$this->smtp->authenticate( + $this->Username, + $this->Password, + $this->AuthType, + $this->oauth + ) + ) { + throw new Exception($this->lang('authenticate')); + } + + return true; + } catch (Exception $exc) { + $lastexception = $exc; + $this->edebug($exc->getMessage()); + //We must have connected, but then failed TLS or Auth, so close connection nicely + $this->smtp->quit(); + } + } + } + //If we get here, all connection attempts have failed, so close connection hard + $this->smtp->close(); + //As we've caught all exceptions, just report whatever the last one was + if ($this->exceptions && null !== $lastexception) { + throw $lastexception; + } + if ($this->exceptions) { + // no exception was thrown, likely $this->smtp->connect() failed + $message = $this->getSmtpErrorMessage('connect_host'); + throw new Exception($message); + } + + return false; + } + + /** + * Close the active SMTP session if one exists. + */ + public function smtpClose() + { + if ((null !== $this->smtp) && $this->smtp->connected()) { + $this->smtp->quit(); + $this->smtp->close(); + } + } + + /** + * Set the language for error messages. + * The default language is English. + * + * @param string $langcode ISO 639-1 2-character language code (e.g. French is "fr") + * Optionally, the language code can be enhanced with a 4-character + * script annotation and/or a 2-character country annotation. + * @param string $lang_path Path to the language file directory, with trailing separator (slash) + * Do not set this from user input! + * + * @return bool Returns true if the requested language was loaded, false otherwise. + */ + public function setLanguage($langcode = 'en', $lang_path = '') + { + //Backwards compatibility for renamed language codes + $renamed_langcodes = [ + 'br' => 'pt_br', + 'cz' => 'cs', + 'dk' => 'da', + 'no' => 'nb', + 'se' => 'sv', + 'rs' => 'sr', + 'tg' => 'tl', + 'am' => 'hy', + ]; + + if (array_key_exists($langcode, $renamed_langcodes)) { + $langcode = $renamed_langcodes[$langcode]; + } + + //Define full set of translatable strings in English + $PHPMAILER_LANG = [ + 'authenticate' => 'SMTP Error: Could not authenticate.', + 'buggy_php' => 'Your version of PHP is affected by a bug that may result in corrupted messages.' . + ' To fix it, switch to sending using SMTP, disable the mail.add_x_header option in' . + ' your php.ini, switch to MacOS or Linux, or upgrade your PHP to version 7.0.17+ or 7.1.3+.', + 'connect_host' => 'SMTP Error: Could not connect to SMTP host.', + 'data_not_accepted' => 'SMTP Error: data not accepted.', + 'empty_message' => 'Message body empty', + 'encoding' => 'Unknown encoding: ', + 'execute' => 'Could not execute: ', + 'extension_missing' => 'Extension missing: ', + 'file_access' => 'Could not access file: ', + 'file_open' => 'File Error: Could not open file: ', + 'from_failed' => 'The following From address failed: ', + 'instantiate' => 'Could not instantiate mail function.', + 'invalid_address' => 'Invalid address: ', + 'invalid_header' => 'Invalid header name or value', + 'invalid_hostentry' => 'Invalid hostentry: ', + 'invalid_host' => 'Invalid host: ', + 'mailer_not_supported' => ' mailer is not supported.', + 'provide_address' => 'You must provide at least one recipient email address.', + 'recipients_failed' => 'SMTP Error: The following recipients failed: ', + 'signing' => 'Signing Error: ', + 'smtp_code' => 'SMTP code: ', + 'smtp_code_ex' => 'Additional SMTP info: ', + 'smtp_connect_failed' => 'SMTP connect() failed.', + 'smtp_detail' => 'Detail: ', + 'smtp_error' => 'SMTP server error: ', + 'variable_set' => 'Cannot set or reset variable: ', + ]; + if (empty($lang_path)) { + //Calculate an absolute path so it can work if CWD is not here + $lang_path = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'language' . DIRECTORY_SEPARATOR; + } + + //Validate $langcode + $foundlang = true; + $langcode = strtolower($langcode); + if ( + !preg_match('/^(?P[a-z]{2})(?P + + + + + + $icon): ?> + + + + + + + + + + + + +
    + + + + + + + + $js): ?> + + + + + + + + + diff --git a/kirby/views/php.php b/kirby/views/php.php new file mode 100644 index 0000000..3eefa03 --- /dev/null +++ b/kirby/views/php.php @@ -0,0 +1,11 @@ + + +

    + This page is currently offline. We are very sorry for the inconvenience and will fix it as soon as possible. +

    +

    + Advice for developers and administrators:
    + Change the PHP version to one supported by your version of Kirby +

    + + diff --git a/kirby/views/snippets/footer.php b/kirby/views/snippets/footer.php new file mode 100644 index 0000000..308b1d0 --- /dev/null +++ b/kirby/views/snippets/footer.php @@ -0,0 +1,2 @@ + + diff --git a/kirby/views/snippets/header.php b/kirby/views/snippets/header.php new file mode 100644 index 0000000..5592609 --- /dev/null +++ b/kirby/views/snippets/header.php @@ -0,0 +1,42 @@ + + + + + + + Error + + + + + diff --git a/media/index.html b/media/index.html new file mode 100644 index 0000000..e69de29 diff --git a/site/accounts/index.html b/site/accounts/index.html new file mode 100644 index 0000000..e69de29 diff --git a/site/blueprints/files/default.yml b/site/blueprints/files/default.yml new file mode 100644 index 0000000..1b79e98 --- /dev/null +++ b/site/blueprints/files/default.yml @@ -0,0 +1,12 @@ +fields: + crop: + width: 1/3 + label: Image Crop + type: imagecrop + minSize: + width: 350 + height: 450 + targetSize: + width: 350 + height: 450 + preserveAspectRatio: true diff --git a/site/blueprints/pages/board.yml b/site/blueprints/pages/board.yml new file mode 100644 index 0000000..bcd3110 --- /dev/null +++ b/site/blueprints/pages/board.yml @@ -0,0 +1,15 @@ +title: Board + +create: + status: listed + +sections: + fields: + type: fields + fields: + game: + label: Current Game + type: pages + query: site.find('seasons').getAllGames + text: "{{ page.parent.title }}: {{ page.type }} {{ page.player.toPages.first.surname }} vs. {{ page.player.toPages.last.surname }}" + multiple: false diff --git a/site/blueprints/pages/boards.yml b/site/blueprints/pages/boards.yml new file mode 100644 index 0000000..af8f4c2 --- /dev/null +++ b/site/blueprints/pages/boards.yml @@ -0,0 +1,10 @@ +title: Boards + +create: + status: listed + +sections: + pages: + type: pages + label: Boards + template: board diff --git a/site/blueprints/pages/default.yml b/site/blueprints/pages/default.yml new file mode 100644 index 0000000..0cb0129 --- /dev/null +++ b/site/blueprints/pages/default.yml @@ -0,0 +1,21 @@ +title: Default Page + +columns: + main: + width: 2/3 + sections: + fields: + type: fields + fields: + text: + type: textarea + size: huge + sidebar: + width: 1/3 + sections: + pages: + type: pages + template: default + files: + type: files + diff --git a/site/blueprints/pages/member.yml b/site/blueprints/pages/member.yml new file mode 100644 index 0000000..a98f20a --- /dev/null +++ b/site/blueprints/pages/member.yml @@ -0,0 +1,48 @@ +title: Member + +create: + title: + label: Member ID + fields: + - forename + - surname + - nickname + - number + redirect: false + status: listed + +# columns: +# main: +# width: 2/3 +sections: + fields: + type: fields + width: 2/3 + fields: + forename: + label: First name + type: text + width: 1/6 + surname: + label: Last Name + type: text + width: 1/6 + nickname: + label: Nickname + type: text + width: 2/6 + number: + label: Number + type: number + width: 1/6 + pic: + label: Picture + type: files + width: 1/6 + multiple: false + query: page.images + files: + width: 1/3 + sections: + files: + type: files diff --git a/site/blueprints/pages/members.yml b/site/blueprints/pages/members.yml new file mode 100644 index 0000000..06d175b --- /dev/null +++ b/site/blueprints/pages/members.yml @@ -0,0 +1,12 @@ +title: Members + +create: + status: listed + +sections: + pages: + type: pages + label: Members + template: member + text: "[{{ page.title }}] {{ page.forename }} {{ page.surname }} ({{ page.nickname }})" + sortBy: number asc diff --git a/site/blueprints/pages/season.yml b/site/blueprints/pages/season.yml new file mode 100644 index 0000000..a5fdf2a --- /dev/null +++ b/site/blueprints/pages/season.yml @@ -0,0 +1,12 @@ +title: Season + +create: + status: listed + +sections: + pages: + type: pages + label: Tournaments + template: tournament + # text: "{{ page.type }} {{ page.surname }} ({{ page.nickname }})" + # sortBy: page.surname diff --git a/site/blueprints/pages/seasons.yml b/site/blueprints/pages/seasons.yml new file mode 100644 index 0000000..971d18c --- /dev/null +++ b/site/blueprints/pages/seasons.yml @@ -0,0 +1,12 @@ +title: Seasons + +create: + status: listed + +sections: + pages: + type: pages + label: Seasons + template: season + # text: "{{ page.type }} {{ page.surname }} ({{ page.nickname }})" + # sortBy: page.surname diff --git a/site/blueprints/pages/tournament.yml b/site/blueprints/pages/tournament.yml new file mode 100644 index 0000000..7d7abe6 --- /dev/null +++ b/site/blueprints/pages/tournament.yml @@ -0,0 +1,33 @@ +title: Tournament + +create: + status: listed + +sections: + fields: + type: fields + fields: + participants: + width: 1/2 + label: Participants + type: pages + query: site.find('members') + text: "{{ page.forename }} {{ page.surname }} ({{ page.nickname }})" + date: + width: 1/2 + label: Date + type: date + # layout: + # type: layout + # layouts: + # - "1/6, 1/6, 1/6, 1/6, 1/6, 1/6" + # fieldsets: + # - heading + # - simple_game + + pages: + type: pages + label: Games + template: xoi + text: "[{{ page.startdate.toDate('d.m.') }}] {{ page.players.toPages.first.forename }} {{ page.players.toPages.first.surname }} vs. {{ page.players.toPages.nth(1).forename }} {{ page.players.toPages.nth(1).surname }}" + sortBy: startdate desc diff --git a/site/blueprints/pages/xoi.yml b/site/blueprints/pages/xoi.yml new file mode 100644 index 0000000..5714aaa --- /dev/null +++ b/site/blueprints/pages/xoi.yml @@ -0,0 +1,89 @@ +title: Game + +create: + status: listed + +sections: + fields: + type: fields + fields: + max: + width: 2/12 + label: Game Max + type: select + default: "501" + options: + - "301" + - "501" + - "701" + sets: + width: 1/12 + label: Sets + type: number + default: 1 + legs: + width: 1/12 + label: Legs + type: number + default: 7 + in: + width: 1/12 + label: In + type: select + default: "Straight" + options: + - "Straight" + - "Double" + - "Master" + out: + width: 1/12 + label: Out + type: select + default: "Double" + options: + - "Straight" + - "Double" + - "Master" + players: + width: 6/12 + label: Players + type: pages + multiple: true + query: site.find('members') + text: "{{ page.forename }} {{ page.surname }} ({{ page.nickname }})" + startdate: + width: 6/12 + label: Start + type: date + time: + step: + unit: minute + size: 1 + enddate: + width: 6/12 + label: End + type: date + time: + step: + unit: minute + size: 1 + # bulled: + # width: 2/12 + # label: Bulled + # type: toggle + # text: + # - Nope + # - "Yes" + rounds: + label: Rounds + type: json + width: 3/6 + stats: + label: Stats + type: json + width: 3/6 + comment: + label: Comment + type: textarea + size: small + width: 6/6 diff --git a/site/blueprints/site.yml b/site/blueprints/site.yml new file mode 100644 index 0000000..b7da661 --- /dev/null +++ b/site/blueprints/site.yml @@ -0,0 +1,5 @@ +title: Site + +sections: + pages: + type: pages diff --git a/site/blueprints/users/api.yml b/site/blueprints/users/api.yml new file mode 100644 index 0000000..e69de29 diff --git a/site/cache/index.html b/site/cache/index.html new file mode 100644 index 0000000..e69de29 diff --git a/site/config/config.php b/site/config/config.php new file mode 100644 index 0000000..c5c8fa0 --- /dev/null +++ b/site/config/config.php @@ -0,0 +1,16 @@ + true, + 'panel' =>[ + 'install' => true + ], + 'api' => [ + 'allowInsecure' => true, + 'basicAuth' => true, + ], + 'auth' => false, + 'kql' => [ + 'auth' => false + ] +]; diff --git a/site/controllers/home.php b/site/controllers/home.php new file mode 100644 index 0000000..362da11 --- /dev/null +++ b/site/controllers/home.php @@ -0,0 +1,40 @@ +request()->is('POST')) { + $action = $kirby->request()->get("action"); + if ($action == "createGame") { + // $name = $kirby->request()->get("name"); + $name = Uuid::generate($length = 8); + $tournament = $kirby->request()->get("tournament"); + $content = [ + 'title' => $name + ]; + try { + + $kirby->impersonate('kirby'); + $id = $site->find($tournament)->createChild([ + 'content' => $content, + 'slug' => Str::slug($name), + 'template' => 'xoi', + 'isDraft' => false + ]); + $json["status"] = "ok"; + $json["url"] = $id->url(); + } catch (\Exception $e) { + $error = $e->getMessage(); + } + } + $data = $kirby->request()->get("name"); + } + return [ + 'type' => $type, + 'json' => $json, + 'error' => $error + ]; +}; diff --git a/site/controllers/xoi.php b/site/controllers/xoi.php new file mode 100644 index 0000000..b7b3c05 --- /dev/null +++ b/site/controllers/xoi.php @@ -0,0 +1,175 @@ +enddate()->isEmpty()) { + $error = "Game ended"; + $json["status"] = "error"; + $json["error"] = $error; + return $json; + } + $throws = $kirby->request()->get("throws"); + if (is_null($throws) || !is_array($throws)) { + $error = "You have to pass throws as array"; + if ($error != "") { + $json["status"] = "error"; + $json["error"] = $error; + } + return $json; + } + $checkoutTries = $kirby->request()->get("checkoutTries") ? intval($kirby->request()->get("checkoutTries")) : 0; + $done = $kirby->request()->get("done"); + $numDarts = 3; + if ($kirby->request()->get("numDarts")) { + $numDarts = intval($kirby->request()->get("numDarts")); + } elseif (!$done) { + $numDarts = count($throws); + } + try { + $page->addThrows($throws, $numDarts, $checkoutTries, $done); + } catch (\Exception $e) { + $error = $e->getMessage(); + } + if ($error != "") { + $json["status"] = "error"; + $json["error"] = $error; + } + return $json; +} + +function deleteLastThrow($page, $site, $kirby){ + $json["status"] = "ok"; + $error = ""; + if (!$page->enddate()->isEmpty()) { + $error = "Game ended"; + $json["status"] = "error"; + $json["error"] = $error; + return $json; + } + $game = $page->rounds()->parseJSON(); + $set = &$game["sets"][count($game["sets"])-1]; + $leg = &$set["legs"][count($set["legs"])-1]; + $visit = &$leg["visits"][count($leg["visits"])-1]; + + $completeVisit = $kirby->request()->get("visit"); + if (count($visit['throws']) > 0 && !$completeVisit) { + array_pop($visit['throws']); + $kirby->impersonate('kirby'); + $page = $page->update([ + 'rounds' => json_encode($game), + ]); + } else { + // delete last visit. This means, delete 2 lasts and insert one. + array_pop($leg["visits"]); + if (count($leg["visits"]) == 0){ + array_pop($set["legs"]); + if (count($set["legs"]) == 0){ + array_pop($game["sets"]); + if (count($game["sets"]) == 0){ + $page = $page->initGame(false); + $page->recalcStats(); + return $json; + } + } + } + $kirby->impersonate('kirby'); + $page = $page->update([ + 'rounds' => json_encode($game), + ]); + $page = $page->clearLastVisit(); + } + $page = $page->recalcStats(); + if ($error != "") { + $json["status"] = "error"; + $json["error"] = $error; + } + return $json; +} + +function init($page, $site, $kirby){ + $json["status"] = "ok"; + $error = ""; + if ($page->rounds()->toString() != "") { + $error = "Game already started, can not update"; + $json["status"] = "error"; + $json["error"] = $error; + return $json; + } + $players = $kirby->request()->get("players"); + if (is_null($players) || !is_array($players) || count($players) == 0) { + $error = "Players not given."; + $json["status"] = "error"; + $json["error"] = $error; + return $json; + } + foreach ($players as $i => $player) { + try { + if (page($player)->intendedTemplate() != "member") { + $error = "Player is not a member ".page($player)->template(); + $json["status"] = "error"; + $json["error"] = $error; + return $json; + } + } catch (\Exception $e) { + $error = "Player not found".$e; + $json["status"] = "error"; + $json["error"] = $error; + return $json; + } + } + $page = $page->reorderPlayer($players); + $page->initGame(); + + if ($error != "") { + $json["status"] = "error"; + $json["error"] = $error; + } + return $json; +} + +function update($page, $site, $kirby){ + $json["status"] = "ok"; + $error = ""; + + // TODO check data for valid fields + $kirby->impersonate('kirby'); + $data = $kirby->request()->get("data"); + $page = $page->update($data); + + if ($error != "") { + $json["status"] = "error"; + $json["error"] = $error; + } + return $json; +} + +return function ($page, $site, $kirby) { + $error = ""; + $json = []; + $type = "html"; + if ($kirby->request()->is('POST')) { + $type = "json"; + $action = $kirby->request()->get("action"); + if ($action == "update") { + $json = update($page, $site, $kirby); + } elseif ($action == "init") { + $json = init($page, $site, $kirby); + } elseif ($action == "addThrows") { + $json = addThrows($page, $site, $kirby); + } elseif ($action == "deleteLastThrow") { + $json = deleteLastThrow($page, $site, $kirby); + } else { + $json["error"] = "No known action defined"; + $json["status"] = "error"; + } + } + + return [ + 'type' => $type, + 'json' => $json, + 'error' => $error + ]; +}; diff --git a/site/models/season.php b/site/models/season.php new file mode 100644 index 0000000..27967f2 --- /dev/null +++ b/site/models/season.php @@ -0,0 +1,16 @@ +children() as $key => $value) { + $children->add($value->children()); + } + return $children; + } + public function getRunningTournaments() { + return $this->children()->filter( + fn ($child) => $child->date()->isEmpty() or $child->date()->toDate()+(60*60*24) > time() + ); + } +} diff --git a/site/models/seasons.php b/site/models/seasons.php new file mode 100644 index 0000000..40ee0f6 --- /dev/null +++ b/site/models/seasons.php @@ -0,0 +1,24 @@ +children() as $key => $value) { + $children->add($value->getAllGames()); + } + return $children; + } + /** + * @kql-allowed + */ + public function getRunningTournaments() { + $children = new Pages(); + foreach ($this->children() as $key => $value) { + $children->add($value->getRunningTournaments()); + } + return $children; + } +} diff --git a/site/models/tournament.php b/site/models/tournament.php new file mode 100644 index 0000000..654890b --- /dev/null +++ b/site/models/tournament.php @@ -0,0 +1,39 @@ +children()->filter( + fn ($child) => $child->enddate()->isEmpty() + ); + } + + /** + * @kql-allowed + */ + public function getStats($uuid) { + $lol = $this->children()->filter( + fn ($child) => count($child->players()->toPages()->filter( fn ($player) => $player->uuid()->toString() == $uuid ) ) + ); + $statsSum = []; + foreach ($lol as $key => $value) { + if ($value->stats()->isEmpty()) { + continue; + } + $stats = $value->stats()->parseJSON()["stats"]; + if ($stats[0]["player"] == $uuid) { + $i = 0; + } else if ($stats[1]["player"] == $uuid) { + $i = 1; + } + if (count($statsSum) == 0){ + $statsSum = $stats[$i]; + continue; + } + $statsSum = $value->addStats($statsSum, $stats[$i]); + } + return $statsSum;//$lol;//$stats[1]["player"]$player->uuid()->toString(); + } +} diff --git a/site/models/xoi.php b/site/models/xoi.php new file mode 100644 index 0000000..177205b --- /dev/null +++ b/site/models/xoi.php @@ -0,0 +1,413 @@ +playerUUIDs()) == 2){ + return [$this->parent()->getStats($this->playerUUIDs()[0]),$this->parent()->getStats($this->playerUUIDs()[1])]; + } + return []; + } + + public function reorderPlayer($order){ + kirby()->impersonate('kirby'); + return $this->update([ + "players" => $order + ]); + } + public function playerUUIDs(){ + $pp = $this->players()->toPages(); + $players = []; + foreach ($pp as $i => $p) { + $players[] = $p->uuid()->toString(); + } + return $players; + } + + public function getPlayerPos($playerUUIDs, $uuid) + { + $k = 0; + foreach ($playerUUIDs as $i => $playeruuid) { + if ($playeruuid == $uuid) { + break; + } + $k++; + } + return $k; + } + // entity can be visit, leg or set + public function nextPlayer($playerUUIDs, $uuid) + { + $k = $this->getPlayerPos($playerUUIDs, $uuid); + return $playerUUIDs[($k+1)%count($playerUUIDs)]; + } + + public function updateStats(&$stats, $visit){ + $playerUUIDs = $this->playerUUIDs(); + $k = $this->getPlayerPos($playerUUIDs, $visit["player"]); + + $todos = [&$stats["stats"][$k]]; + $todos[] = &last($stats["sets"])["stats"][$k]; + $todos[] = &last(last($stats["sets"])["legs"])["stats"][$k]; + + foreach ($todos as $i => $value) { + $todos[$i]["average"][0] += $visit["sum"]; + $todos[$i]["average"][1] += $visit["numDarts"]; + if ($visit["visit"] < 4) { + $todos[$i]["first9"][0] += $visit["sum"]; + $todos[$i]["first9"][1] += $visit["numDarts"]; + } + if ($visit["toGo"][$k] == 0) { + $todos[$i]["checkouts"][0] += 1; + $todos[$i]["checkoutPoints"][] += $visit["sum"]; + } + $todos[$i]["checkouts"][1] += $visit["checkoutTries"]; + if ($visit["sum"] == 180) { + $todos[$i]["180"] += 1; + } elseif ($visit["sum"] >= 140) { + $todos[$i]["140+"] += 1; + } elseif ($visit["sum"] >= 100) { + $todos[$i]["100+"] += 1; + } elseif ($visit["sum"] >= 60) { + $todos[$i]["60+"] += 1; + } + } + return $stats; + } + + private function newVisit($player, $round) + { + return array( + 'player' => $player, + 'throws' => [], + 'visit' => $round, + 'checkoutTries' => 0, + 'numDarts' => 0, + ); + } + + public function calcPoints($throw) + { + $throw = trim($throw); + if ($throw == "") { + return 0; + } + if ($throw == "SB"){ + return 25; + } + if ($throw == "DB"){ + return 50; + } + if ($throw[0] == "S" || $throw[0] == "O" || $throw[0] == "I"){ + return intval(substr($throw, 1)); + } + if ($throw[0] == "D"){ + return 2*intval(substr($throw, 1)); + } + if ($throw[0] == "T"){ + return 3*intval(substr($throw, 1)); + } + if ($throw[0] == "M"){ + return 0; + } else { + // TODO: Check for Na + return intval($throw); + } + } + + public function sumPoints($throws) + { + $sum = 0; + foreach ($throws as $i => $throw) { + $points = $this->calcPoints($throw); + $sum += $points; + } + return $sum; + } + + public function getWinner($points, $mode) + { + $sum = 0; + $k = 0; + $max = -1; + foreach ($points as $i => $point) { + $sum += $point; + if ($max < $point) { + $max = $point; + $maxidx = $k; + } + $k++; + } + if ($max > $mode/$k) { + return $maxidx; + } + if ($sum == $mode) { + return -2; + } + return -1; + } + + public function clearLastVisit() + { + $game = $this->rounds()->parseJSON(); + $set = &$game["sets"][count($game["sets"])-1]; + $leg = &$set["legs"][count($set["legs"])-1]; + $visit = &$leg["visits"][count($leg["visits"])-1]; + $visit["throws"] = []; + $visit["checkoutTries"] = 0; + $visit["numDarts"] = 0; + unset($visit["sum"]); + unset($visit["toGo"]); + return $this->storeGame($game); + } + + public function addThrows($throws, $numDarts, $checkoutTries, $done=true) + { + $game = $this->rounds()->parseJSON(); + $set = &$game["sets"][count($game["sets"])-1]; + $leg = &$set["legs"][count($set["legs"])-1]; + $visit = &$leg["visits"][count($leg["visits"])-1]; + $current_throws = $visit['throws']; + if (count($current_throws)+count($throws) > 3) { + $error = "Two many throws given: ".count($current_throws)." + ".count($throws)." > 3"; + throw new \Exception($error, 1); + } + $new_throws = array_merge($current_throws, $throws); + + $playerUUIDs = $this->playerUUIDs(); + $k = $this->getPlayerPos($playerUUIDs, $visit["player"]); + if (count($leg["visits"])-2 < 0) { + $toGo = array_fill(0, count($playerUUIDs), $this->max()->toInt()); + } else { + $toGo = $leg["visits"][count($leg["visits"])-2]["toGo"]; + } + + $visit["numDarts"] += $numDarts; + $visit["throws"] = $new_throws; + $visit["checkoutTries"] += $checkoutTries; + $visit["sum"] = $this->sumPoints($visit["throws"]); + + $rest = $toGo[$k] - $visit["sum"]; + if ($rest < 0 or ($this->out()->toString() == "Double" && $rest == 1)) { + $visit["sum"] = 0; + } else { + $toGo[$k] = $rest; + } + $visit["toGo"] = $toGo; + if (!$done) { + $this->storeGame($game); + return; + } + + $stats = $this->stats()->parseJSON(); + $page = $this->updateStats($stats, $visit); + $update = []; + if ($rest != 0) { + // Normal case...next players turn + $nextPlayer = $this->nextPlayer($playerUUIDs, $visit["player"]); + $isNextRound = 0; + if (last(last($game["sets"])["legs"])['visits'][0]["player"] == $nextPlayer) { + $isNextRound = 1; + } + $newVisit = $this->newVisit($nextPlayer, $visit["visit"]+$isNextRound); + last(last($game["sets"])["legs"])['visits'][] = $newVisit; + } else { + // rest == 0 leg finished + // updates Stats...this is maybe the wrong place to store points + $newlegp = $leg["points"]; + $newlegp[$k] += 1; + $winner = $this->getWinner($newlegp, $this->legs()->toInt()); + if ($winner != -1) { + $newsetp = $set["points"]; + $newsetp[$k] += 1; + $winner = $this->getWinner($newsetp, $this->sets()->toInt()); + if ($winner != -1) { + if ($winner == -2) { + $stats["winner"] = "DRAW"; + } else { + $stats["winner"] = $visit["player"]; + } + // Game Over + $update = [ + "enddate" => date("Y-m-d H:i:s") + ]; + } else { + // new Set + $this->addNewSet($game, $newsetp); + $stats = $this->addNewSetStats($stats); + $stats = $this->addNewLegStats($stats); + } + } else { + // new Leg + $this->addNewLeg($game, $newlegp); + $stats = $this->addNewLegStats($stats); + } + } + $page = $this->storeGame($game); + $page = $page->storeStats($stats); + $page->update($update); + } + + private function newSingleStats($player) + { + return array( + "player" => $player, + "average" => [0, 0], + "first9" => [0, 0], + "checkouts" => [0, 0], + "checkoutPoints" => [], + "60+" => 0, + "100+" => 0, + "140+" => 0, + "180" => 0 + ); + } + private function newStats(){ + $playerUUIDs = $this->playerUUIDs(); + $stats = array( + // "winner" => "", + "stats" => [] + ); + foreach ($playerUUIDs as $i => $player) { + $n = $this->newSingleStats($player); + $stats["stats"][] = $n; + } + return $stats; + } + + private function addNewSet(&$game, $points){ + $playerUUIDs = $this->playerUUIDs(); + $set = &$game["sets"][count($game["sets"])-1]; + $leg = &$set["legs"][count($set["legs"])-1]; + $p = $this->nextPlayer($playerUUIDs, $set["legs"][0]["visits"][0]["player"]); + $game["sets"][] = array( + 'points' => $points, + 'legs' => [ + array( + 'points' => array_fill(0, count($playerUUIDs), 0), + 'visits' => [$this->newVisit($p, 1)] + ) + ] + ); + return $game; + } + private function addNewLeg(&$game, $points){ + $playerUUIDs = $this->playerUUIDs(); + $set = &$game["sets"][count($game["sets"])-1]; + $leg = &$set["legs"][count($set["legs"])-1]; + $p = $this->nextPlayer($playerUUIDs, $leg["visits"][0]["player"]); + $set["legs"][] = array( + 'points' => $points, + 'visits' => [$this->newVisit($p, 1)] + ); + return $game; + } + + private function addNewSetStats(&$stats){ + $stats["sets"][] = $this->newStats(); + $stats["sets"][0]["legs"] = []; + return $stats; + } + private function addNewLegStats(&$stats){ + last($stats["sets"])["legs"][] = $this->newStats(); + return $stats; + } + + public function recalcStats(){ + $game = $this->rounds()->parseJSON(); + $page = $this->initStats(); + $stats = $page->stats()->parseJSON(); + $lastset = count($game["sets"])-1; + foreach ($game["sets"] as $i => $set) { + $lastleg = count($set["legs"])-1; + foreach ($set["legs"] as $j => $leg) { + $lastvisit = count($leg["visits"])-1; + foreach ($leg["visits"] as $k => $visit) { + if (!($k == $lastvisit && $j == $lastleg && $i == $lastset)) { + $this->updateStats($stats, $visit); + } + if ($k == $lastvisit && $j != $lastleg) { + $this->addNewLegStats($stats); + } + } + if ($j == $lastleg && $i != $lastset) { + $this->addNewSetStats($stats); + } + } + } + return $this->storeStats($stats); + } + + private function initStats() + { + $stats = $this->newStats(); + $stats["sets"] = []; + $this->addNewSetStats($stats); + $this->addNewLegStats($stats); + return $this->storeStats($stats); + } + + public function initGame($startTime = true) { + $playerUUIDs = $this->playerUUIDs(); + $game = array('sets' => [array( + 'points' => array_fill(0, count($playerUUIDs), 0), + 'legs' => [ + array( + 'points' => array_fill(0, count($playerUUIDs), 0), + 'visits' => [$this->newVisit($playerUUIDs[0], 1)] + ) + ] + ) + ]); + + $page = $this->initStats(); + $update = [ + 'rounds' => json_encode($game) + ]; + if ($startTime) { + $update['startdate'] = date("Y-m-d H:i:s"); + } + kirby()->impersonate('kirby'); + return $page->update($update); + } + + public function storeStats($stats) + { + kirby()->impersonate('kirby'); + return $this->update([ + "stats" => json_encode($stats) + ]); + + } + public function storeGame($game) + { + kirby()->impersonate('kirby'); + return $this->update([ + "rounds" => json_encode($game) + ]); + } +} diff --git a/site/plugins/jsonField/.editorconfig b/site/plugins/jsonField/.editorconfig new file mode 100644 index 0000000..3b762c9 --- /dev/null +++ b/site/plugins/jsonField/.editorconfig @@ -0,0 +1,20 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.php] +indent_size = 4 + +[*.md,*.txt] +trim_trailing_whitespace = false +insert_final_newline = false + +[composer.json] +indent_size = 4 diff --git a/site/plugins/jsonField/.gitattributes b/site/plugins/jsonField/.gitattributes new file mode 100644 index 0000000..033ba13 --- /dev/null +++ b/site/plugins/jsonField/.gitattributes @@ -0,0 +1,11 @@ +# Note: You need to uncomment the lines you want to use; the other lines can be deleted + +# Git +# .gitattributes export-ignore +# .gitignore export-ignore + +# Tests +# /.coveralls.yml export-ignore +# /.travis.yml export-ignore +# /phpunit.xml.dist export-ignore +# /tests/ export-ignore diff --git a/site/plugins/jsonField/.gitignore b/site/plugins/jsonField/.gitignore new file mode 100644 index 0000000..4d81cf5 --- /dev/null +++ b/site/plugins/jsonField/.gitignore @@ -0,0 +1,14 @@ +# OS files +.DS_Store + +# npm modules +/node_modules + +# Parcel cache folder +.cache + +# Composer files +/vendor + +# kirbyup temp development entry +/index.dev.mjs diff --git a/site/plugins/jsonField/LICENSE.md b/site/plugins/jsonField/LICENSE.md new file mode 100755 index 0000000..8e663d7 --- /dev/null +++ b/site/plugins/jsonField/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/site/plugins/jsonField/README.md b/site/plugins/jsonField/README.md new file mode 100755 index 0000000..ad2b202 --- /dev/null +++ b/site/plugins/jsonField/README.md @@ -0,0 +1,117 @@ +# Kirby Pluginkit: Example plugin for Kirby + +> Variant "Panel plugin setup" + +This is a boilerplate for a Kirby Panel plugin that can be installed via all three [supported installation methods](https://getkirby.com/docs/guide/plugins/plugin-setup-basic#the-three-plugin-installation-methods). + +You can find a list of Pluginkit variants on the [`master` branch](https://github.com/getkirby/pluginkit/tree/master). + +**** + +## How to use the Pluginkit + +1. Fork this repository +2. Change the plugin name and description in the `composer.json` +3. Change the plugin name in the `index.php` and `src/index.js` +4. Change the license if you don't want to publish under MIT +5. Add your plugin code to the `index.php` and `src/index.js` +6. Update this `README` with instructions for your plugin + +### Install the development and build setup + +We use [kirbyup](https://github.com/johannschopplich/kirbyup) for the development and build setup. + +You can start developing directly. kirbyup will be fetched remotely with your first `npm run` command, which may take a short amount of time. + +### Development + +You can start the dev process with: + +```bash +npm run dev +``` + +This will automatically update the `index.js` and `index.css` of your plugin as soon as you make changes. +Reload the Panel to see your code changes reflected. + +With kirbyup 2.0.0+ and Kirby 3.7.4+ you can alternatively use hot module reloading (HMR): + +```bash +npm run serve +``` + +This will start a development server that updates the page as soon as you make changes. Some updates are instant, like CSS or Vue template changes, others require a reload of the page, which happens automatically. + +> [!NOTE] +> The live reload functionality requires top level await, [which is only supported in modern browsers](https://caniuse.com/mdn-javascript_operators_await_top_level). If you're developing in older browsers, use `npm run dev` and reload the page manually to see changes. + +### Production + +As soon as you are happy with your plugin, you should build the final version with: + +```bash +npm run build +``` + +This will automatically create a minified and optimized version of your `index.js` and `index.css` +which you can ship with your plugin. + +We have a tutorial on how to build your own plugin based on the Pluginkit [in the Kirby documentation](https://getkirby.com/docs/guide/plugins/plugin-setup-basic). + +### Build reproducibility + +While kirbyup will stay backwards compatible, exact build reproducibility may be of importance to you. If so, we recommend to target a specific package version, rather than using npx: + +```json +{ + "scripts": { + "dev": "kirbyup src/index.js --watch", + "build": "kirbyup src/index.js" + }, + "devDependencies": { + "kirbyup": "^3.1.0" + } +} +``` + +What follows is an example README for your plugin. + +**** + +## Installation + +### Download + +Download and copy this repository to `/site/plugins/{{ plugin-name }}`. + +### Git submodule + +```bash +git submodule add https://github.com/{{ your-name }}/{{ plugin-name }}.git site/plugins/{{ plugin-name }} +``` + +### Composer + +```bash +composer require {{ your-name }}/{{ plugin-name }} +``` + +## Setup + +*Additional instructions on how to configure the plugin (e.g. blueprint setup, config options, etc.)* + +## Options + +*Document the options and APIs that this plugin offers* + +## Development + +*Add instructions on how to help working on the plugin (e.g. npm setup, Composer dev dependencies, etc.)* + +## License + +MIT + +## Credits + +- [Your Name](https://github.com/ghost) diff --git a/site/plugins/jsonField/SECURITY.md b/site/plugins/jsonField/SECURITY.md new file mode 100644 index 0000000..3726336 --- /dev/null +++ b/site/plugins/jsonField/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +*Use this section to tell people about which versions of your project are currently being supported with security updates.* + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +*Use this section to tell people how to report a vulnerability.* + +*Tell them where to go, how often they can expect to get an update on a reported vulnerability, what to expect if the vulnerability is accepted or declined, etc.* diff --git a/site/plugins/jsonField/composer.json b/site/plugins/jsonField/composer.json new file mode 100755 index 0000000..bfc40ab --- /dev/null +++ b/site/plugins/jsonField/composer.json @@ -0,0 +1,16 @@ +{ + "name": "getkirby/pluginkit", + "description": "Kirby Example Plugin", + "license": "MIT", + "type": "kirby-plugin", + "version": "1.0.0", + "authors": [ + { + "name": "Your Name", + "email": "you@example.com" + } + ], + "require": { + "getkirby/composer-installer": "^1.1" + } +} diff --git a/site/plugins/jsonField/index.css b/site/plugins/jsonField/index.css new file mode 100644 index 0000000..aebd96f --- /dev/null +++ b/site/plugins/jsonField/index.css @@ -0,0 +1,2 @@ + +/* optional scoped styles for the component */ diff --git a/site/plugins/jsonField/index.js b/site/plugins/jsonField/index.js new file mode 100644 index 0000000..d1fd9f1 --- /dev/null +++ b/site/plugins/jsonField/index.js @@ -0,0 +1,100 @@ +(function() { + "use strict"; + function normalizeComponent(scriptExports, render, staticRenderFns, functionalTemplate, injectStyles, scopeId, moduleIdentifier, shadowMode) { + var options = typeof scriptExports === "function" ? scriptExports.options : scriptExports; + if (render) { + options.render = render; + options.staticRenderFns = staticRenderFns; + options._compiled = true; + } + if (functionalTemplate) { + options.functional = true; + } + if (scopeId) { + options._scopeId = "data-v-" + scopeId; + } + var hook; + if (moduleIdentifier) { + hook = function(context) { + context = context || // cached call + this.$vnode && this.$vnode.ssrContext || // stateful + this.parent && this.parent.$vnode && this.parent.$vnode.ssrContext; + if (!context && typeof __VUE_SSR_CONTEXT__ !== "undefined") { + context = __VUE_SSR_CONTEXT__; + } + if (injectStyles) { + injectStyles.call(this, context); + } + if (context && context._registeredComponents) { + context._registeredComponents.add(moduleIdentifier); + } + }; + options._ssrRegister = hook; + } else if (injectStyles) { + hook = shadowMode ? function() { + injectStyles.call( + this, + (options.functional ? this.parent : this).$root.$options.shadowRoot + ); + } : injectStyles; + } + if (hook) { + if (options.functional) { + options._injectStyles = hook; + var originalRender = options.render; + options.render = function renderWithStyleInjection(h, context) { + hook.call(context); + return originalRender(h, context); + }; + } else { + var existing = options.beforeCreate; + options.beforeCreate = existing ? [].concat(existing, hook) : [hook]; + } + } + return { + exports: scriptExports, + options + }; + } + const _sfc_main = { + props: { + after: String, + before: String, + disabled: Boolean, + help: String, + icon: String, + label: String, + required: Boolean, + when: String, + value: String + }, + methods: { + onInput(value) { + this.$emit("input", value); + } + } + }; + var _sfc_render = function render() { + var _vm = this, _c = _vm._self._c; + return _c("k-field", { staticClass: "k-doi-field", attrs: { "disabled": _vm.disabled, "help": _vm.help, "label": _vm.label, "required": _vm.required, "when": _vm.when } }, [_c("k-input", { attrs: { "after": _vm.after, "before": _vm.before, "icon": _vm.icon, "theme": "field", "type": "text", "name": "textfield", "value": _vm.value }, on: { "input": _vm.onInput } })], 1); + }; + var _sfc_staticRenderFns = []; + _sfc_render._withStripped = true; + var __component__ = /* @__PURE__ */ normalizeComponent( + _sfc_main, + _sfc_render, + _sfc_staticRenderFns, + false, + null, + null, + null, + null + ); + __component__.options.__file = "/home/ugo/Desktop/dart/redo/site/plugins/jsonField/src/components/jsonField.vue"; + const JSONField = __component__.exports; + panel.plugin("ucomeugo/jsonField", { + fields: { + json: JSONField + } + }); +})(); diff --git a/site/plugins/jsonField/index.php b/site/plugins/jsonField/index.php new file mode 100755 index 0000000..7df46b1 --- /dev/null +++ b/site/plugins/jsonField/index.php @@ -0,0 +1,15 @@ + [ + 'json' => [ + // here we could define the backend logic for our field if needed + ] + ], + 'fieldMethods' => [ + 'parseJSON' => function ($field) { + // $field->value = json_decode($field->value); + return json_decode($field->value, true); + } + ] +]); diff --git a/site/plugins/jsonField/package-lock.json b/site/plugins/jsonField/package-lock.json new file mode 100644 index 0000000..238ba3d --- /dev/null +++ b/site/plugins/jsonField/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "jsonField", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/site/plugins/jsonField/package.json b/site/plugins/jsonField/package.json new file mode 100644 index 0000000..bdbe47f --- /dev/null +++ b/site/plugins/jsonField/package.json @@ -0,0 +1,7 @@ +{ + "scripts": { + "dev": "npx -y kirbyup src/index.js --watch", + "serve": "npx -y kirbyup serve src/index.js", + "build": "npx -y kirbyup src/index.js" + } +} diff --git a/site/plugins/jsonField/src/components/jsonField.vue b/site/plugins/jsonField/src/components/jsonField.vue new file mode 100755 index 0000000..ce22f54 --- /dev/null +++ b/site/plugins/jsonField/src/components/jsonField.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/site/plugins/jsonField/src/index.js b/site/plugins/jsonField/src/index.js new file mode 100755 index 0000000..3768da5 --- /dev/null +++ b/site/plugins/jsonField/src/index.js @@ -0,0 +1,7 @@ +import JSONField from "./components/jsonField.vue"; + +panel.plugin('ucomeugo/jsonField', { + fields: { + json: JSONField + } +}); diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/.gitignore b/site/plugins/kirby-plugin-image-crop-field-2.0.5/.gitignore new file mode 100644 index 0000000..fa7e9b2 --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/.gitignore @@ -0,0 +1,6 @@ +vendor +vendor/* +composer.lock +node_modules +.cache +.DS_Store \ No newline at end of file diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/LICENSE.md b/site/plugins/kirby-plugin-image-crop-field-2.0.5/LICENSE.md new file mode 100644 index 0000000..15bc72f --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/README.md b/site/plugins/kirby-plugin-image-crop-field-2.0.5/README.md new file mode 100644 index 0000000..ae90ed1 --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/README.md @@ -0,0 +1,135 @@ +# Kirby Image Crop Field + +This plugin provides a field for cropping images visually and very flexibly. + +![kirby-plugin-image-crop-field](https://user-images.githubusercontent.com/10421363/59161680-0b683280-8ae6-11e9-8bc4-5b9145f34387.gif) + +The field is based on [vue-cropperjs](https://github.com/Agontuk/vue-cropperjs +) and [gumlet/php-image-resize](https://github.com/gumlet/php-image-resize). + +## Installation + +Use one of the alternatives below. + +### Download + +Download and copy this repository to `/site/plugins/kirby-plugin-image-crop-field. + +### Git submodule + +``` +git submodule add https://github.com/steirico/kirby-plugin-image-crop-field.git site/plugins/kirby-plugin-image-crop-field +``` + +### Composer + +``` +composer require steirico/kirby-plugin-image-crop-field +``` + +## Usage + +### File Blueprint Usage + +The plugin defines the new field type `imagecrop` which can be used in [file blueprints](https://getkirby.com/docs/reference/panel/blueprints/file). +Define an appropriate file blueprint for images and add the field as follow: + +> `/site/blueprints/files/image.yml`: +> ```yaml +> fields: +> crop: +> label: Image Crop +> type: imagecrop +> minSize: +> width: 700 +> height: 250 +> targetSize: +> width: 1400 +> height: 500 +> preserveAspectRatio: true +> ``` + +### Blueprint Options + +#### `minSize` + +Defines the minimum allowed size of the area that can be cropped from the original image. + +* `width`: Minimum allowed width, bigger or equal to `1` +* `height`: Minimum allowed height, bigger or equal to `1` + +Defaults: + +* `width`: `1` +* `height`: `1` + + +#### `targetSize` + +Target size of the image after it has been cropped. The resulting image will be scaled to `width` as defined by `targetSize.width` and `height` as defined by `targetSize.height`. + +* `width`: Width of the target image, bigger or equal to `1` +* `height`: Minimum allowed height, bigger or equal to `1` + +For both, `width` and `height`, negative values are interpreted as absolute values. + +Defaults: The resulting image represents the cropped area and is not scaled. + +#### `preserveAspectRatio` + +Whether to preserve the aspect ratio of the crop area as defined by `minSize.width / minSize.height` or to allow free cropping: + +* `true`: Preserve aspect ratio +* `false`: Free cropping + +Default: `false` + +### Cropped Image in the Panel + +The plugin provides the [file method](https://getkirby.com/docs/reference/plugins/extensions/file-methods) called `croppedImage`. Applied as any other file method, `croppedImage` provides a `file` object of the cropped version of origin image. + +The following configuration previews the cropped image in a `files sections`: + +> `/site/blueprints/pages/album.yml`: +> ```yaml +> title: Album +> +> sections: +> images: +> type: files +> layout: cards +> template: image +> info: "{{ file.dimensions }}" +> image: +> ratio: 16/9 +> cover: false +> query: file.croppedImage +> min: 1 +> size: small +>``` + +### Use Cropped Image in Templates and Snippets + +Use the the [file method](https://getkirby.com/docs/reference/plugins/extensions/file-methods) called `croppedImage` in order to work with the cropped image in templates and snippets: + +> ```html +>
    +> image()->croppedImage() ?> +>
    + + +## Issues + +Feel free to file an [issue](https://github.com/steirico/kirby-plugin-image-crop-field/issues) if you encounter any problems or unexpected behavior. + +Currently there is a know issue that crooped images apear twice when geting images by `$page->images()`. + +## License + +MIT + +## Credits + +* [Rico Steiner](https://github.com/steirico) +* [vue-cropperjs](https://github.com/Agontuk/vue-cropperjs) +* [gumlet/php-image-resize](https://github.com/gumlet/php-image-resize) diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/composer.json b/site/plugins/kirby-plugin-image-crop-field-2.0.5/composer.json new file mode 100644 index 0000000..720d70b --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/composer.json @@ -0,0 +1,32 @@ +{ + "name": "steirico/kirby-plugin-image-crop-field", + "description": "A image cropping field for kirby.", + "keywords": [ + "kirby3", + "plugin", + "field", + "image", "crop" + ], + "authors": [ + { + "name": "Rico Steiner", + "email": "rico@vweb.ch" + } + ], + "version": "2.0.5", + "type": "kirby-plugin", + "license": "MIT", + "autoload": { + "classmap": [ + "fields/", + "lib/" + ], + "files": [ + "config.php" + ] + }, + "require": { + "gumlet/php-image-resize": "2.0.*", + "getkirby/composer-installer": "^1.1" + } +} diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/config.php b/site/plugins/kirby-plugin-image-crop-field-2.0.5/config.php new file mode 100644 index 0000000..7eabce3 --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/config.php @@ -0,0 +1,56 @@ + [ + 'croppedImage' => function() { + return CroppedImage::croppedImage($this); + }, + ], + 'fields' => [ + 'imagecrop' => [ + 'props' => [ + 'image' => function() { + return $this->model()->url(); + }, + + 'minSize' => function(array $minSize = []) { + $width = max(A::get($minSize, 'width', 1), 1); + $height = max(A::get($minSize, 'height', 1), 1); + return array( + 'width' => $width, + 'height' => $height + ); + }, + + 'targetSize' => function(array $targetSize = []) { + return $targetSize; + }, + + 'preserveAspectRatio' => function(bool $preserveAspectRatio = false){ + return $preserveAspectRatio; + }, + + 'value' => function($value = []){ + $method = kirby()->request()->method(); + if(($method == "PATCH") || ($method == "POST")) { + new CroppedImage($this->model()); + } + + if(is_array($value)){ + return $value; + } else { + return Data::decode($value, 'yaml'); + } + } + ] + ], + ], + 'hooks' => [ + 'file.delete:before' => function ($file) { + $croppedImage = $file->croppedImage(); + if ($croppedImage->exists()) { + $croppedImage->delete(); + } + } + ] +]); \ No newline at end of file diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/fields/CroppedImage.php b/site/plugins/kirby-plugin-image-crop-field-2.0.5/fields/CroppedImage.php new file mode 100644 index 0000000..357f439 --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/fields/CroppedImage.php @@ -0,0 +1,168 @@ +__debuginfo(); + $this->original = $original; + $cropData = $this->getCropData(); + + if(is_array($cropData) && count($cropData) != 0){ + $w = A::get($cropData, "width", $original->width()); + $h = A::get($cropData, "height", $original->height()); + $x = A::get($cropData, "x", 0); + $y = A::get($cropData, "y", 0); + + $originalParts = pathinfo($original->root()); + $croppedFileName = sprintf("%s-cropped-w%sh%s-x%sy%s.%s", + $originalParts['filename'], + $w, $h, $x, $y, + $original->extension() + ); + $croppedPath = dirname($original->root()); + $croppedRoot = $croppedPath . '/' . $croppedFileName; + + $oldRoot = $croppedPath . '/' . $original->filename(); + $oldCropped = F::similar($oldRoot, "-cropped-*"); + + $props = array( + 'root' => $croppedRoot, + 'filename' => $croppedFileName, + 'parent' => $original->parent() + ); + + parent::__construct($props); + + if(!$this->exists()){ + if(!file_exists($croppedPath)){ + mkdir($croppedPath, 0770, true); + } + + foreach($oldCropped as $old){ + F::remove($old); + } + + $cropped = new ImageResize($original->root()); + $cropped->freecrop($w, $h, $x, $y)->setMemory()->save($croppedRoot); + unset($cropped); + + $cropConfig = $this->getCropField(); + $targetSize = $cropConfig["targetSize"]; + + if(is_array($targetSize)){ + $targetW = abs(A::get($targetSize, "width", 0)); + $targetH = abs(A::get($targetSize, "height", 0)); + if(0 < $targetW){ + $image = new ImageResize($croppedRoot); + $image->resizeToWidth($targetW, $allow_enlarge = true)->setMemory()->save($croppedRoot); + unset($image); + } + + if(0 < $targetH){ + $image = new ImageResize($croppedRoot); + $image->resizeToHeight($targetH, $allow_enlarge = true)->setMemory()->save($croppedRoot); + unset($image); + } + } + } + } else { + $original->propertyData["filename"] = F::safeName($original->filename()); + parent::__construct($this->original->propertiesToArray()); + } + } + + public function getCropData() { + if($this->cropData){ + return $this->cropData; + } + + if($this->original){ + $field = $this->getCropField(); + $fieldName = $field["name"]; + if($fieldName) { + $this->cropData = $this->original->content()->{$fieldName}()->yaml(); + return $this->cropData; + } else { + return array(); + } + } + + return null; + } + + public function getCropField() { + if($this->cropField){ + return $this->cropField; + } + + if($this->original){ + $fields = $this->original->blueprint()->fields(); + + foreach($fields as $field){ + if($field["type"] == CroppedImage::FIELD_TYPE){ + $this->cropField = $field; + return $this->cropField; + } + } + } + + return null; + } + + public function delete(bool $force = false): bool { + //taken from src/kirby/src/Cms/FileActions.php + // remove all versions in the media folder + $this->unpublish(); + + // remove the lock of the old file + if ($lock = $this->lock()) { + $lock->remove(); + } + + if ($this->kirby()->multilang() === true) { + foreach ($this->translations() as $translation) { + F::remove($this->contentFile($translation->code())); + } + } else { + F::remove($this->contentFile()); + } + + F::remove($this->root()); + + // remove the file from the sibling collection + $this->parent()->files()->remove($this); + + return true; + } + + public static function croppedImage($requestedFile) { + $media = new CroppedImage($requestedFile); + if($media && $media->exists()){ + return $media; + } else { + return $requestedFile; + } + } + + public function __debuginfo(): array { + try { + $parent = $this->toArray(); + } catch(Throwable $e) { + $parent = []; + } + + return array_merge($parent, [ + 'original' => $this->original, + 'cropField' => $this->cropField, + 'cropData' => $this->cropData + ]); + } +} \ No newline at end of file diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/index.css b/site/plugins/kirby-plugin-image-crop-field-2.0.5/index.css new file mode 100644 index 0000000..777ccf0 --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/index.css @@ -0,0 +1,9 @@ +/*! + * Cropper.js v1.5.11 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2021-02-17T11:53:21.992Z + */.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed} \ No newline at end of file diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/index.js b/site/plugins/kirby-plugin-image-crop-field-2.0.5/index.js new file mode 100644 index 0000000..5205076 --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/index.js @@ -0,0 +1,14 @@ +parcelRequire=function(e,r,t,n){var i,o="function"==typeof parcelRequire&&parcelRequire,u="function"==typeof require&&require;function f(t,n){if(!r[t]){if(!e[t]){var i="function"==typeof parcelRequire&&parcelRequire;if(!n&&i)return i(t,!0);if(o)return o(t,!0);if(u&&"string"==typeof t)return u(t);var c=new Error("Cannot find module '"+t+"'");throw c.code="MODULE_NOT_FOUND",c}p.resolve=function(r){return e[t][1][r]||r},p.cache={};var l=r[t]=new f.Module(t);e[t][0].call(l.exports,p,l,l.exports,this)}return r[t].exports;function p(e){return f(p.resolve(e))}}f.isParcelRequire=!0,f.Module=function(e){this.id=e,this.bundle=f,this.exports={}},f.modules=e,f.cache=r,f.parent=o,f.register=function(r,t){e[r]=[function(e,r){r.exports=t},{}]};for(var c=0;ct.length)&&(e=t.length);for(var i=0,a=new Array(e);i0&&t<1/0};function H(t){return void 0===t}function N(e){return"object"===t(e)&&null!==e}var z=Object.prototype.hasOwnProperty;function L(t){if(!N(t))return!1;try{var e=t.constructor,i=e.prototype;return e&&i&&z.call(i,"isPrototypeOf")}catch(a){return!1}}function Y(t){return"function"==typeof t}var X=Array.prototype.slice;function R(t){return Array.from?Array.from(t):X.call(t)}function S(t,e){return t&&Y(e)&&(Array.isArray(t)||E(t.length)?R(t).forEach(function(i,a){e.call(t,i,a,t)}):N(t)&&Object.keys(t).forEach(function(i){e.call(t,t[i],i,t)})),t}var j=Object.assign||function(t){for(var e=arguments.length,i=new Array(e>1?e-1:0),a=1;a0&&i.forEach(function(e){N(e)&&Object.keys(e).forEach(function(i){t[i]=e[i]})}),t},A=/\.\d*(?:0|9){12}\d*$/;function I(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1e11;return A.test(t)?Math.round(t*e)/e:t}var P=/^width|height|left|top|marginLeft|marginTop$/;function U(t,e){var i=t.style;S(e,function(t,e){P.test(e)&&E(t)&&(t="".concat(t,"px")),i[e]=t})}function q(t,e){if(e)if(E(t.length))S(t,function(t){q(t,e)});else if(t.classList)t.classList.add(e);else{var i=t.className.trim();i?i.indexOf(e)<0&&(t.className="".concat(i," ").concat(e)):t.className=e}}function $(t,e){e&&(E(t.length)?S(t,function(t){$(t,e)}):t.classList?t.classList.remove(e):t.className.indexOf(e)>=0&&(t.className=t.className.replace(e,"")))}function Q(t,e,i){e&&(E(t.length)?S(t,function(t){Q(t,e,i)}):i?q(t,e):$(t,e))}var K=/([a-z\d])([A-Z])/g;function Z(t){return t.replace(K,"$1-$2").toLowerCase()}function G(t,e){return N(t[e])?t[e]:t.dataset?t.dataset[e]:t.getAttribute("data-".concat(Z(e)))}function V(t,e,i){N(i)?t[e]=i:t.dataset?t.dataset[e]=i:t.setAttribute("data-".concat(Z(e)),i)}var F=/\s\s*/,J=function(){var t=!1;if(h){var e=!1,i=function(){},a=Object.defineProperty({},"once",{get:function(){return t=!0,e},set:function(t){e=t}});s.addEventListener("test",i,a),s.removeEventListener("test",i,a)}return t}();function _(t,e,i){var a=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},n=i;e.trim().split(F).forEach(function(e){if(!J){var o=t.listeners;o&&o[e]&&o[e][i]&&(n=o[e][i],delete o[e][i],0===Object.keys(o[e]).length&&delete o[e],0===Object.keys(o).length&&delete t.listeners)}t.removeEventListener(e,n,a)})}function tt(t,e,i){var a=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},n=i;e.trim().split(F).forEach(function(e){if(a.once&&!J){var o=t.listeners,r=void 0===o?{}:o;n=function(){delete r[e][i],t.removeEventListener(e,n,a);for(var o=arguments.length,h=new Array(o),s=0;s1&&void 0!==arguments[1]?arguments[1]:"contain",o=W(a),r=W(i);if(o&&r){var h=i*e;"contain"===n&&h>a||"cover"===n&&h=8&&(o=s+l)}}}if(o){var p,d,m=i.getUint16(o,a);for(d=0;d=0?n:200),height:Math.max(i.offsetHeight,o>=0?o:100)};this.containerData=r,U(a,{width:r.width,height:r.height}),q(t,m),$(a,m)},initCanvas:function(){var t=this.containerData,e=this.imageData,i=this.options.viewMode,a=Math.abs(e.rotate)%180==90,n=a?e.naturalHeight:e.naturalWidth,o=a?e.naturalWidth:e.naturalHeight,r=n/o,h=t.width,s=t.height;t.height*r>t.width?3===i?h=t.height*r:s=t.width/r:3===i?s=t.width/r:h=t.height*r;var c={aspectRatio:r,naturalWidth:n,naturalHeight:o,width:h,height:s};this.canvasData=c,this.limited=1===i||2===i,this.limitCanvas(!0,!0),c.width=Math.min(Math.max(c.width,c.minWidth),c.maxWidth),c.height=Math.min(Math.max(c.height,c.minHeight),c.maxHeight),c.left=(t.width-c.width)/2,c.top=(t.height-c.height)/2,c.oldLeft=c.left,c.oldTop=c.top,this.initialCanvasData=j({},c)},limitCanvas:function(t,e){var i=this.options,a=this.containerData,n=this.canvasData,o=this.cropBoxData,r=i.viewMode,h=n.aspectRatio,s=this.cropped&&o;if(t){var c=Number(i.minCanvasWidth)||0,l=Number(i.minCanvasHeight)||0;r>1?(c=Math.max(c,a.width),l=Math.max(l,a.height),3===r&&(l*h>c?c=l*h:l=c/h)):r>0&&(c?c=Math.max(c,s?o.width:0):l?l=Math.max(l,s?o.height:0):s&&(c=o.width,(l=o.height)*h>c?c=l*h:l=c/h));var p=ct({aspectRatio:h,width:c,height:l});c=p.width,l=p.height,n.minWidth=c,n.minHeight=l,n.maxWidth=1/0,n.maxHeight=1/0}if(e)if(r>(s?0:1)){var d=a.width-n.width,m=a.height-n.height;n.minLeft=Math.min(0,d),n.minTop=Math.min(0,m),n.maxLeft=Math.max(0,d),n.maxTop=Math.max(0,m),s&&this.limited&&(n.minLeft=Math.min(o.left,o.left+(o.width-n.width)),n.minTop=Math.min(o.top,o.top+(o.height-n.height)),n.maxLeft=o.left,n.maxTop=o.top,2===r&&(n.width>=a.width&&(n.minLeft=Math.min(0,d),n.maxLeft=Math.max(0,d)),n.height>=a.height&&(n.minTop=Math.min(0,m),n.maxTop=Math.max(0,m))))}else n.minLeft=-n.width,n.minTop=-n.height,n.maxLeft=a.width,n.maxTop=a.height},renderCanvas:function(t,e){var i=this.canvasData,a=this.imageData;if(e){var n=function(t){var e=t.width,i=t.height,a=t.degree;if(90==(a=Math.abs(a)%180))return{width:i,height:e};var n=a%90*Math.PI/180,o=Math.sin(n),r=Math.cos(n),h=e*r+i*o,s=e*o+i*r;return a>90?{width:s,height:h}:{width:h,height:s}}({width:a.naturalWidth*Math.abs(a.scaleX||1),height:a.naturalHeight*Math.abs(a.scaleY||1),degree:a.rotate||0}),o=n.width,r=n.height,h=i.width*(o/i.naturalWidth),s=i.height*(r/i.naturalHeight);i.left-=(h-i.width)/2,i.top-=(s-i.height)/2,i.width=h,i.height=s,i.aspectRatio=o/r,i.naturalWidth=o,i.naturalHeight=r,this.limitCanvas(!0,!1)}(i.width>i.maxWidth||i.widthi.maxHeight||i.heighte.width?n.height=n.width/i:n.width=n.height*i),this.cropBoxData=n,this.limitCropBox(!0,!0),n.width=Math.min(Math.max(n.width,n.minWidth),n.maxWidth),n.height=Math.min(Math.max(n.height,n.minHeight),n.maxHeight),n.width=Math.max(n.minWidth,n.width*a),n.height=Math.max(n.minHeight,n.height*a),n.left=e.left+(e.width-n.width)/2,n.top=e.top+(e.height-n.height)/2,n.oldLeft=n.left,n.oldTop=n.top,this.initialCropBoxData=j({},n)},limitCropBox:function(t,e){var i=this.options,a=this.containerData,n=this.canvasData,o=this.cropBoxData,r=this.limited,h=i.aspectRatio;if(t){var s=Number(i.minCropBoxWidth)||0,c=Number(i.minCropBoxHeight)||0,l=r?Math.min(a.width,n.width,n.width+n.left,a.width-n.left):a.width,p=r?Math.min(a.height,n.height,n.height+n.top,a.height-n.top):a.height;s=Math.min(s,a.width),c=Math.min(c,a.height),h&&(s&&c?c*h>s?c=s/h:s=c*h:s?c=s/h:c&&(s=c*h),p*h>l?p=l/h:l=p*h),o.minWidth=Math.min(s,l),o.minHeight=Math.min(c,p),o.maxWidth=l,o.maxHeight=p}e&&(r?(o.minLeft=Math.max(0,n.left),o.minTop=Math.max(0,n.top),o.maxLeft=Math.min(a.width,n.left+n.width)-o.width,o.maxTop=Math.min(a.height,n.top+n.height)-o.height):(o.minLeft=0,o.minTop=0,o.maxLeft=a.width-o.width,o.maxTop=a.height-o.height))},renderCropBox:function(){var t=this.options,e=this.containerData,i=this.cropBoxData;(i.width>i.maxWidth||i.widthi.maxHeight||i.height=e.width&&i.height>=e.height?"move":"all"),U(this.cropBox,j({width:i.width,height:i.height},ht({translateX:i.left,translateY:i.top}))),this.cropped&&this.limited&&this.limitCanvas(!0,!0),this.disabled||this.output()},output:function(){this.preview(),et(this.element,"crop",this.getData())}},ut={initPreview:function(){var t=this.element,e=this.crossOrigin,i=this.options.preview,a=e?this.crossOriginUrl:this.url,n=t.alt||"The image to preview",o=document.createElement("img");if(e&&(o.crossOrigin=e),o.src=a,o.alt=n,this.viewBox.appendChild(o),this.viewBoxImage=o,i){var r=i;"string"==typeof i?r=t.ownerDocument.querySelectorAll(i):i.querySelector&&(r=[i]),this.previews=r,S(r,function(t){var i=document.createElement("img");V(t,b,{width:t.offsetWidth,height:t.offsetHeight,html:t.innerHTML}),e&&(i.crossOrigin=e),i.src=a,i.alt=n,i.style.cssText='display:block;width:100%;height:auto;min-width:0!important;min-height:0!important;max-width:none!important;max-height:none!important;image-orientation:0deg!important;"',t.innerHTML="",t.appendChild(i)})}},resetPreview:function(){S(this.previews,function(t){var e=G(t,b);U(t,{width:e.width,height:e.height}),t.innerHTML=e.html,function(t,e){if(N(t[e]))try{delete t[e]}catch(i){t[e]=void 0}else if(t.dataset)try{delete t.dataset[e]}catch(i){t.dataset[e]=void 0}else t.removeAttribute("data-".concat(Z(e)))}(t,b)})},preview:function(){var t=this.imageData,e=this.canvasData,i=this.cropBoxData,a=i.width,n=i.height,o=t.width,r=t.height,h=i.left-e.left-t.left,s=i.top-e.top-t.top;this.cropped&&!this.disabled&&(U(this.viewBoxImage,j({width:o,height:r},ht(j({translateX:-h,translateY:-s},t)))),S(this.previews,function(e){var i=G(e,b),c=i.width,l=i.height,p=c,d=l,m=1;a&&(d=n*(m=c/a)),n&&d>l&&(p=a*(m=l/n),d=l),U(e,{width:p,height:d}),U(e.getElementsByTagName("img")[0],j({width:o*m,height:r*m},ht(j({translateX:-h*m,translateY:-s*m},t))))}))}},gt={bind:function(){var t=this.element,e=this.options,i=this.cropper;Y(e.cropstart)&&tt(t,"cropstart",e.cropstart),Y(e.cropmove)&&tt(t,"cropmove",e.cropmove),Y(e.cropend)&&tt(t,"cropend",e.cropend),Y(e.crop)&&tt(t,"crop",e.crop),Y(e.zoom)&&tt(t,"zoom",e.zoom),tt(i,y,this.onCropStart=this.cropStart.bind(this)),e.zoomable&&e.zoomOnWheel&&tt(i,"wheel",this.onWheel=this.wheel.bind(this),{passive:!1,capture:!0}),e.toggleDragModeOnDblclick&&tt(i,"dblclick",this.onDblclick=this.dblclick.bind(this)),tt(t.ownerDocument,x,this.onCropMove=this.cropMove.bind(this)),tt(t.ownerDocument,M,this.onCropEnd=this.cropEnd.bind(this)),e.responsive&&tt(window,"resize",this.onResize=this.resize.bind(this))},unbind:function(){var t=this.element,e=this.options,i=this.cropper;Y(e.cropstart)&&_(t,"cropstart",e.cropstart),Y(e.cropmove)&&_(t,"cropmove",e.cropmove),Y(e.cropend)&&_(t,"cropend",e.cropend),Y(e.crop)&&_(t,"crop",e.crop),Y(e.zoom)&&_(t,"zoom",e.zoom),_(i,y,this.onCropStart),e.zoomable&&e.zoomOnWheel&&_(i,"wheel",this.onWheel,{passive:!1,capture:!0}),e.toggleDragModeOnDblclick&&_(i,"dblclick",this.onDblclick),_(t.ownerDocument,x,this.onCropMove),_(t.ownerDocument,M,this.onCropEnd),e.responsive&&_(window,"resize",this.onResize)}},ft={resize:function(){if(!this.disabled){var t,e,i=this.options,a=this.container,n=this.containerData,o=a.offsetWidth/n.width;if(1!==o||a.offsetHeight!==n.height)i.restore&&(t=this.getCanvasData(),e=this.getCropBoxData()),this.render(),i.restore&&(this.setCanvasData(S(t,function(e,i){t[i]=e*o})),this.setCropBoxData(S(e,function(t,i){e[i]=t*o})))}},dblclick:function(){var t,e;this.disabled||"none"===this.options.dragMode||this.setDragMode((t=this.dragBox,e=p,(t.classList?t.classList.contains(e):t.className.indexOf(e)>-1)?"move":"crop"))},wheel:function(t){var e=this,i=Number(this.options.wheelZoomRatio)||.1,a=1;this.disabled||(t.preventDefault(),this.wheeling||(this.wheeling=!0,setTimeout(function(){e.wheeling=!1},50),t.deltaY?a=t.deltaY>0?1:-1:t.wheelDelta?a=-t.wheelDelta/120:t.detail&&(a=t.detail>0?1:-1),this.zoom(-a*i,t)))},cropStart:function(t){var e=t.buttons,i=t.button;if(!(this.disabled||("mousedown"===t.type||"pointerdown"===t.type&&"mouse"===t.pointerType)&&(E(e)&&1!==e||E(i)&&0!==i||t.ctrlKey))){var a,n=this.options,o=this.pointers;t.changedTouches?S(t.changedTouches,function(t){o[t.identifier]=st(t)}):o[t.pointerId||0]=st(t),a=Object.keys(o).length>1&&n.zoomable&&n.zoomOnTouch?"zoom":G(t.target,w),C.test(a)&&!1!==et(this.element,"cropstart",{originalEvent:t,action:a})&&(t.preventDefault(),this.action=a,this.cropping=!1,"crop"===a&&(this.cropping=!0,q(this.dragBox,f)))}},cropMove:function(t){var e=this.action;if(!this.disabled&&e){var i=this.pointers;t.preventDefault(),!1!==et(this.element,"cropmove",{originalEvent:t,action:e})&&(t.changedTouches?S(t.changedTouches,function(t){j(i[t.identifier]||{},st(t,!0))}):j(i[t.pointerId||0]||{},st(t,!0)),this.change(t))}},cropEnd:function(t){if(!this.disabled){var e=this.action,i=this.pointers;t.changedTouches?S(t.changedTouches,function(t){delete i[t.identifier]}):delete i[t.pointerId||0],e&&(t.preventDefault(),Object.keys(i).length||(this.action=""),this.cropping&&(this.cropping=!1,Q(this.dragBox,f,this.cropped&&this.options.modal)),et(this.element,"cropend",{originalEvent:t,action:e}))}}},vt={change:function(t){var e,i=this.options,a=this.canvasData,o=this.containerData,r=this.cropBoxData,h=this.pointers,s=this.action,c=i.aspectRatio,l=r.left,p=r.top,d=r.width,u=r.height,g=l+d,f=p+u,v=0,w=0,b=o.width,y=o.height,x=!0;!c&&t.shiftKey&&(c=d&&u?d/u:1),this.limited&&(v=r.minLeft,w=r.minTop,b=v+Math.min(o.width,a.width,a.left+a.width),y=w+Math.min(o.height,a.height,a.top+a.height));var M=h[Object.keys(h)[0]],C={x:M.endX-M.startX,y:M.endY-M.startY},D=function(t){switch(t){case"e":g+C.x>b&&(C.x=b-g);break;case"w":l+C.xy&&(C.y=y-f)}};switch(s){case"all":l+=C.x,p+=C.y;break;case"e":if(C.x>=0&&(g>=b||c&&(p<=w||f>=y))){x=!1;break}D("e"),(d+=C.x)<0&&(s="w",l-=d=-d),c&&(u=d/c,p+=(r.height-u)/2);break;case"n":if(C.y<=0&&(p<=w||c&&(l<=v||g>=b))){x=!1;break}D("n"),u-=C.y,p+=C.y,u<0&&(s="s",p-=u=-u),c&&(d=u*c,l+=(r.width-d)/2);break;case"w":if(C.x<=0&&(l<=v||c&&(p<=w||f>=y))){x=!1;break}D("w"),d-=C.x,l+=C.x,d<0&&(s="e",l-=d=-d),c&&(u=d/c,p+=(r.height-u)/2);break;case"s":if(C.y>=0&&(f>=y||c&&(l<=v||g>=b))){x=!1;break}D("s"),(u+=C.y)<0&&(s="n",p-=u=-u),c&&(d=u*c,l+=(r.width-d)/2);break;case"ne":if(c){if(C.y<=0&&(p<=w||g>=b)){x=!1;break}D("n"),u-=C.y,p+=C.y,d=u*c}else D("n"),D("e"),C.x>=0?gw&&(u-=C.y,p+=C.y):(u-=C.y,p+=C.y);d<0&&u<0?(s="sw",p-=u=-u,l-=d=-d):d<0?(s="nw",l-=d=-d):u<0&&(s="se",p-=u=-u);break;case"nw":if(c){if(C.y<=0&&(p<=w||l<=v)){x=!1;break}D("n"),u-=C.y,p+=C.y,d=u*c,l+=r.width-d}else D("n"),D("w"),C.x<=0?l>v?(d-=C.x,l+=C.x):C.y<=0&&p<=w&&(x=!1):(d-=C.x,l+=C.x),C.y<=0?p>w&&(u-=C.y,p+=C.y):(u-=C.y,p+=C.y);d<0&&u<0?(s="se",p-=u=-u,l-=d=-d):d<0?(s="ne",l-=d=-d):u<0&&(s="sw",p-=u=-u);break;case"sw":if(c){if(C.x<=0&&(l<=v||f>=y)){x=!1;break}D("w"),d-=C.x,l+=C.x,u=d/c}else D("s"),D("w"),C.x<=0?l>v?(d-=C.x,l+=C.x):C.y>=0&&f>=y&&(x=!1):(d-=C.x,l+=C.x),C.y>=0?f=0&&(g>=b||f>=y)){x=!1;break}D("e"),u=(d+=C.x)/c}else D("s"),D("e"),C.x>=0?g=0&&f>=y&&(x=!1):d+=C.x,C.y>=0?fMath.abs(i)&&(i=s)})}),i}(h),t),x=!1;break;case"crop":if(!C.x||!C.y){x=!1;break}e=it(this.cropper),l=M.startX-e.left,p=M.startY-e.top,d=r.minWidth,u=r.minHeight,C.x>0?s=C.y>0?"se":"ne":C.x<0&&(l-=d,s=C.y>0?"sw":"nw"),C.y<0&&(p-=u),this.cropped||($(this.cropBox,m),this.cropped=!0,this.limited&&this.limitCropBox(!0,!0))}x&&(r.width=d,r.height=u,r.left=l,r.top=p,this.action=s,this.renderCropBox()),S(h,function(t){t.startX=t.endX,t.startY=t.endY})}},wt={crop:function(){return!this.ready||this.cropped||this.disabled||(this.cropped=!0,this.limitCropBox(!0,!0),this.options.modal&&q(this.dragBox,f),$(this.cropBox,m),this.setCropBoxData(this.initialCropBoxData)),this},reset:function(){return this.ready&&!this.disabled&&(this.imageData=j({},this.initialImageData),this.canvasData=j({},this.initialCanvasData),this.cropBoxData=j({},this.initialCropBoxData),this.renderCanvas(),this.cropped&&this.renderCropBox()),this},clear:function(){return this.cropped&&!this.disabled&&(j(this.cropBoxData,{left:0,top:0,width:0,height:0}),this.cropped=!1,this.renderCropBox(),this.limitCanvas(!0,!0),this.renderCanvas(),$(this.dragBox,f),q(this.cropBox,m)),this},replace:function(t){var e=arguments.length>1&&void 0!==arguments[1]&&arguments[1];return!this.disabled&&t&&(this.isImg&&(this.element.src=t),e?(this.url=t,this.image.src=t,this.ready&&(this.viewBoxImage.src=t,S(this.previews,function(e){e.getElementsByTagName("img")[0].src=t}))):(this.isImg&&(this.replaced=!0),this.options.data=null,this.uncreate(),this.load(t))),this},enable:function(){return this.ready&&this.disabled&&(this.disabled=!1,$(this.cropper,d)),this},disable:function(){return this.ready&&!this.disabled&&(this.disabled=!0,q(this.cropper,d)),this},destroy:function(){var t=this.element;return t.cropper?(t.cropper=void 0,this.isImg&&this.replaced&&(t.src=this.originalUrl),this.uncreate(),this):this},move:function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:t,i=this.canvasData,a=i.left,n=i.top;return this.moveTo(H(t)?t:a+Number(t),H(e)?e:n+Number(e))},moveTo:function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:t,i=this.canvasData,a=!1;return t=Number(t),e=Number(e),this.ready&&!this.disabled&&this.options.movable&&(E(t)&&(i.left=t,a=!0),E(e)&&(i.top=e,a=!0),a&&this.renderCanvas(!0)),this},zoom:function(t,e){var i=this.canvasData;return t=(t=Number(t))<0?1/(1-t):1+t,this.zoomTo(i.width*t/i.naturalWidth,null,e)},zoomTo:function(t,e,i){var a=this.options,n=this.canvasData,o=n.width,r=n.height,h=n.naturalWidth,s=n.naturalHeight;if((t=Number(t))>=0&&this.ready&&!this.disabled&&a.zoomable){var c=h*t,l=s*t;if(!1===et(this.element,"zoom",{ratio:t,oldRatio:o/h,originalEvent:i}))return this;if(i){var p=this.pointers,d=it(this.cropper),m=p&&Object.keys(p).length?function(t){var e=0,i=0,a=0;return S(t,function(t){var n=t.startX,o=t.startY;e+=n,i+=o,a+=1}),{pageX:e/=a,pageY:i/=a}}(p):{pageX:i.pageX,pageY:i.pageY};n.left-=(c-o)*((m.pageX-d.left-n.left)/o),n.top-=(l-r)*((m.pageY-d.top-n.top)/r)}else L(e)&&E(e.x)&&E(e.y)?(n.left-=(c-o)*((e.x-n.left)/o),n.top-=(l-r)*((e.y-n.top)/r)):(n.left-=(c-o)/2,n.top-=(l-r)/2);n.width=c,n.height=l,this.renderCanvas(!0)}return this},rotate:function(t){return this.rotateTo((this.imageData.rotate||0)+Number(t))},rotateTo:function(t){return E(t=Number(t))&&this.ready&&!this.disabled&&this.options.rotatable&&(this.imageData.rotate=t%360,this.renderCanvas(!0,!0)),this},scaleX:function(t){var e=this.imageData.scaleY;return this.scale(t,E(e)?e:1)},scaleY:function(t){var e=this.imageData.scaleX;return this.scale(E(e)?e:1,t)},scale:function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:t,i=this.imageData,a=!1;return t=Number(t),e=Number(e),this.ready&&!this.disabled&&this.options.scalable&&(E(t)&&(i.scaleX=t,a=!0),E(e)&&(i.scaleY=e,a=!0),a&&this.renderCanvas(!0,!0)),this},getData:function(){var t,e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],i=this.options,a=this.imageData,n=this.canvasData,o=this.cropBoxData;if(this.ready&&this.cropped){t={x:o.left-n.left,y:o.top-n.top,width:o.width,height:o.height};var r=a.width/a.naturalWidth;if(S(t,function(e,i){t[i]=e/r}),e){var h=Math.round(t.y+t.height),s=Math.round(t.x+t.width);t.x=Math.round(t.x),t.y=Math.round(t.y),t.width=s-t.x,t.height=h-t.y}}else t={x:0,y:0,width:0,height:0};return i.rotatable&&(t.rotate=a.rotate||0),i.scalable&&(t.scaleX=a.scaleX||1,t.scaleY=a.scaleY||1),t},setData:function(t){var e=this.options,i=this.imageData,a=this.canvasData,n={};if(this.ready&&!this.disabled&&L(t)){var o=!1;e.rotatable&&E(t.rotate)&&t.rotate!==i.rotate&&(i.rotate=t.rotate,o=!0),e.scalable&&(E(t.scaleX)&&t.scaleX!==i.scaleX&&(i.scaleX=t.scaleX,o=!0),E(t.scaleY)&&t.scaleY!==i.scaleY&&(i.scaleY=t.scaleY,o=!0)),o&&this.renderCanvas(!0,!0);var r=i.width/i.naturalWidth;E(t.x)&&(n.left=t.x*r+a.left),E(t.y)&&(n.top=t.y*r+a.top),E(t.width)&&(n.width=t.width*r),E(t.height)&&(n.height=t.height*r),this.setCropBoxData(n)}return this},getContainerData:function(){return this.ready?j({},this.containerData):{}},getImageData:function(){return this.sized?j({},this.imageData):{}},getCanvasData:function(){var t=this.canvasData,e={};return this.ready&&S(["left","top","width","height","naturalWidth","naturalHeight"],function(i){e[i]=t[i]}),e},setCanvasData:function(t){var e=this.canvasData,i=e.aspectRatio;return this.ready&&!this.disabled&&L(t)&&(E(t.left)&&(e.left=t.left),E(t.top)&&(e.top=t.top),E(t.width)?(e.width=t.width,e.height=t.width/i):E(t.height)&&(e.height=t.height,e.width=t.height*i),this.renderCanvas(!0)),this},getCropBoxData:function(){var t,e=this.cropBoxData;return this.ready&&this.cropped&&(t={left:e.left,top:e.top,width:e.width,height:e.height}),t||{}},setCropBoxData:function(t){var e,i,a=this.cropBoxData,n=this.options.aspectRatio;return this.ready&&this.cropped&&!this.disabled&&L(t)&&(E(t.left)&&(a.left=t.left),E(t.top)&&(a.top=t.top),E(t.width)&&t.width!==a.width&&(e=!0,a.width=t.width),E(t.height)&&t.height!==a.height&&(i=!0,a.height=t.height),n&&(e?a.height=a.width/n:i&&(a.width=a.height*n)),this.renderCropBox()),this},getCroppedCanvas:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!this.ready||!window.HTMLCanvasElement)return null;var e=this.canvasData,i=function(t,e,i,a){var n=e.aspectRatio,r=e.naturalWidth,h=e.naturalHeight,s=e.rotate,c=void 0===s?0:s,l=e.scaleX,p=void 0===l?1:l,d=e.scaleY,m=void 0===d?1:d,u=i.aspectRatio,g=i.naturalWidth,f=i.naturalHeight,v=a.fillColor,w=void 0===v?"transparent":v,b=a.imageSmoothingEnabled,y=void 0===b||b,x=a.imageSmoothingQuality,M=void 0===x?"low":x,C=a.maxWidth,D=void 0===C?1/0:C,B=a.maxHeight,k=void 0===B?1/0:B,O=a.minWidth,T=void 0===O?0:O,E=a.minHeight,W=void 0===E?0:E,H=document.createElement("canvas"),N=H.getContext("2d"),z=ct({aspectRatio:u,width:D,height:k}),L=ct({aspectRatio:u,width:T,height:W},"cover"),Y=Math.min(z.width,Math.max(L.width,g)),X=Math.min(z.height,Math.max(L.height,f)),R=ct({aspectRatio:n,width:D,height:k}),S=ct({aspectRatio:n,width:T,height:W},"cover"),j=Math.min(R.width,Math.max(S.width,r)),A=Math.min(R.height,Math.max(S.height,h)),P=[-j/2,-A/2,j,A];return H.width=I(Y),H.height=I(X),N.fillStyle=w,N.fillRect(0,0,Y,X),N.save(),N.translate(Y/2,X/2),N.rotate(c*Math.PI/180),N.scale(p,m),N.imageSmoothingEnabled=y,N.imageSmoothingQuality=M,N.drawImage.apply(N,[t].concat(o(P.map(function(t){return Math.floor(I(t))})))),N.restore(),H}(this.image,this.imageData,e,t);if(!this.cropped)return i;var a=this.getData(),n=a.x,r=a.y,h=a.width,s=a.height,c=i.width/Math.floor(e.naturalWidth);1!==c&&(n*=c,r*=c,h*=c,s*=c);var l=h/s,p=ct({aspectRatio:l,width:t.maxWidth||1/0,height:t.maxHeight||1/0}),d=ct({aspectRatio:l,width:t.minWidth||0,height:t.minHeight||0},"cover"),m=ct({aspectRatio:l,width:t.width||(1!==c?i.width:h),height:t.height||(1!==c?i.height:s)}),u=m.width,g=m.height;u=Math.min(p.width,Math.max(d.width,u)),g=Math.min(p.height,Math.max(d.height,g));var f=document.createElement("canvas"),v=f.getContext("2d");f.width=I(u),f.height=I(g),v.fillStyle=t.fillColor||"transparent",v.fillRect(0,0,u,g);var w=t.imageSmoothingEnabled,b=void 0===w||w,y=t.imageSmoothingQuality;v.imageSmoothingEnabled=b,y&&(v.imageSmoothingQuality=y);var x,M,C,D,B,k,O=i.width,T=i.height,E=n,W=r;E<=-h||E>O?(E=0,x=0,C=0,B=0):E<=0?(C=-E,E=0,B=x=Math.min(O,h+E)):E<=O&&(C=0,B=x=Math.min(h,O-E)),x<=0||W<=-s||W>T?(W=0,M=0,D=0,k=0):W<=0?(D=-W,W=0,k=M=Math.min(T,s+W)):W<=T&&(D=0,k=M=Math.min(s,T-W));var H=[E,W,x,M];if(B>0&&k>0){var N=u/h;H.push(C*N,D*N,B*N,k*N)}return v.drawImage.apply(v,[i].concat(o(H.map(function(t){return Math.floor(I(t))})))),f},setAspectRatio:function(t){var e=this.options;return this.disabled||H(t)||(e.aspectRatio=Math.max(0,t)||NaN,this.ready&&(this.initCropBox(),this.cropped&&this.renderCropBox())),this},setDragMode:function(t){var e=this.options,i=this.dragBox,a=this.face;if(this.ready&&!this.disabled){var n="crop"===t,o=e.movable&&"move"===t;t=n||o?t:"none",e.dragMode=t,V(i,w,t),Q(i,p,n),Q(i,v,o),e.cropBoxMovable||(V(a,w,t),Q(a,p,n),Q(a,v,o))}return this}},bt=s.Cropper,yt=function(){function t(e){var i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}(this,t),!e||!k.test(e.tagName))throw new Error("The first argument is required and must be an or element.");this.element=e,this.options=j({},O,L(i)&&i),this.cropped=!1,this.disabled=!1,this.pointers={},this.ready=!1,this.reloading=!1,this.replaced=!1,this.sized=!1,this.sizing=!1,this.init()}var i,a,n;return i=t,n=[{key:"noConflict",value:function(){return window.Cropper=bt,t}},{key:"setDefaults",value:function(t){j(O,L(t)&&t)}}],(a=[{key:"init",value:function(){var t,e=this.element,i=e.tagName.toLowerCase();if(!e.cropper){if(e.cropper=this,"img"===i){if(this.isImg=!0,t=e.getAttribute("src")||"",this.originalUrl=t,!t)return;t=e.src}else"canvas"===i&&window.HTMLCanvasElement&&(t=e.toDataURL());this.load(t)}}},{key:"load",value:function(t){var e=this;if(t){this.url=t,this.imageData={};var i=this.element,a=this.options;if(a.rotatable||a.scalable||(a.checkOrientation=!1),a.checkOrientation&&window.ArrayBuffer)if(D.test(t))B.test(t)?this.read((n=t.replace(pt,""),o=atob(n),r=new ArrayBuffer(o.length),S(h=new Uint8Array(r),function(t,e){h[e]=o.charCodeAt(e)}),r)):this.clone();else{var n,o,r,h,s=new XMLHttpRequest,c=this.clone.bind(this);this.reloading=!0,this.xhr=s,s.onabort=c,s.onerror=c,s.ontimeout=c,s.onprogress=function(){"image/jpeg"!==s.getResponseHeader("content-type")&&s.abort()},s.onload=function(){e.read(s.response)},s.onloadend=function(){e.reloading=!1,e.xhr=null},a.checkCrossOrigin&&ot(t)&&i.crossOrigin&&(t=rt(t)),s.open("GET",t,!0),s.responseType="arraybuffer",s.withCredentials="use-credentials"===i.crossOrigin,s.send()}else this.clone()}}},{key:"read",value:function(t){var e=this.options,i=this.imageData,a=dt(t),n=0,o=1,r=1;if(a>1){this.url=function(t,e){for(var i=[],a=new Uint8Array(t);a.length>0;)i.push(lt.apply(null,R(a.subarray(0,8192)))),a=a.subarray(8192);return"data:".concat(e,";base64,").concat(btoa(i.join("")))}(t,"image/jpeg");var h=function(t){var e=0,i=1,a=1;switch(t){case 2:i=-1;break;case 3:e=-180;break;case 4:a=-1;break;case 5:e=90,a=-1;break;case 6:e=90;break;case 7:e=90,i=-1;break;case 8:e=-90}return{rotate:e,scaleX:i,scaleY:a}}(a);n=h.rotate,o=h.scaleX,r=h.scaleY}e.rotatable&&(i.rotate=n),e.scalable&&(i.scaleX=o,i.scaleY=r),this.clone()}},{key:"clone",value:function(){var t=this.element,e=this.url,i=t.crossOrigin,a=e;this.options.checkCrossOrigin&&ot(e)&&(i||(i="anonymous"),a=rt(e)),this.crossOrigin=i,this.crossOriginUrl=a;var n=document.createElement("img");i&&(n.crossOrigin=i),n.src=a||e,n.alt=t.alt||"The image to crop",this.image=n,n.onload=this.start.bind(this),n.onerror=this.stop.bind(this),q(n,u),t.parentNode.insertBefore(n,t.nextSibling)}},{key:"start",value:function(){var t=this,e=this.image;e.onload=null,e.onerror=null,this.sizing=!0;var i=s.navigator&&/(?:iPad|iPhone|iPod).*?AppleWebKit/i.test(s.navigator.userAgent),a=function(e,i){j(t.imageData,{naturalWidth:e,naturalHeight:i,aspectRatio:e/i}),t.initialImageData=j({},t.imageData),t.sizing=!1,t.sized=!0,t.build()};if(!e.naturalWidth||i){var n=document.createElement("img"),o=document.body||document.documentElement;this.sizingImage=n,n.onload=function(){a(n.width,n.height),i||o.removeChild(n)},n.src=e.src,i||(n.style.cssText="left:0;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;opacity:0;position:absolute;top:0;z-index:-1;",o.appendChild(n))}else a(e.naturalWidth,e.naturalHeight)}},{key:"stop",value:function(){var t=this.image;t.onload=null,t.onerror=null,t.parentNode.removeChild(t),this.image=null}},{key:"build",value:function(){if(this.sized&&!this.ready){var t=this.element,e=this.options,i=this.image,a=t.parentNode,n=document.createElement("div");n.innerHTML='
    ';var o=n.querySelector(".".concat("cropper","-container")),r=o.querySelector(".".concat("cropper","-canvas")),h=o.querySelector(".".concat("cropper","-drag-box")),s=o.querySelector(".".concat("cropper","-crop-box")),c=s.querySelector(".".concat("cropper","-face"));this.container=a,this.cropper=o,this.canvas=r,this.dragBox=h,this.cropBox=s,this.viewBox=o.querySelector(".".concat("cropper","-view-box")),this.face=c,r.appendChild(i),q(t,m),a.insertBefore(o,t.nextSibling),this.isImg||$(i,u),this.initPreview(),this.bind(),e.initialAspectRatio=Math.max(0,e.initialAspectRatio)||NaN,e.aspectRatio=Math.max(0,e.aspectRatio)||NaN,e.viewMode=Math.max(0,Math.min(3,Math.round(e.viewMode)))||0,q(s,m),e.guides||q(s.getElementsByClassName("".concat("cropper","-dashed")),m),e.center||q(s.getElementsByClassName("".concat("cropper","-center")),m),e.background&&q(o,"".concat("cropper","-bg")),e.highlight||q(c,g),e.cropBoxMovable&&(q(c,v),V(c,w,"all")),e.cropBoxResizable||(q(s.getElementsByClassName("".concat("cropper","-line")),m),q(s.getElementsByClassName("".concat("cropper","-point")),m)),this.render(),this.ready=!0,this.setDragMode(e.dragMode),e.autoCrop&&this.crop(),this.setData(e.data),Y(e.ready)&&tt(t,"ready",e.ready,{once:!0}),et(t,"ready")}}},{key:"unbuild",value:function(){this.ready&&(this.ready=!1,this.unbind(),this.resetPreview(),this.cropper.parentNode.removeChild(this.cropper),$(this.element,m))}},{key:"uncreate",value:function(){this.ready?(this.unbuild(),this.ready=!1,this.cropped=!1):this.sizing?(this.sizingImage.onload=null,this.sizing=!1,this.sized=!1):this.reloading?(this.xhr.onabort=null,this.xhr.abort()):this.image&&this.stop()}}])&&e(i.prototype,a),n&&e(i,n),t}();return j(yt.prototype,mt,ut,gt,ft,vt,wt),yt}); +},{}],"gzqx":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=require("cropperjs"),t=r(e);function r(e){return e&&e.__esModule?e:{default:e}}function o(e,t){var r={};for(var o in e)t.indexOf(o)>=0||Object.prototype.hasOwnProperty.call(e,o)&&(r[o]=e[o]);return r}var n="undefined"==typeof window?[String,Array]:[String,Array,Element,NodeList];exports.default={render:function(e){var t=this.crossorigin||void 0;return e("div",{style:this.containerStyle},[e("img",{ref:"img",attrs:{src:this.src,alt:this.alt||"image",style:"max-width: 100%",crossorigin:t},on:this.$listeners,style:this.imgStyle})])},props:{containerStyle:Object,src:{type:String,default:""},alt:String,imgStyle:Object,viewMode:Number,dragMode:String,initialAspectRatio:Number,aspectRatio:Number,data:Object,preview:n,responsive:{type:Boolean,default:!0},restore:{type:Boolean,default:!0},checkCrossOrigin:{type:Boolean,default:!0},checkOrientation:{type:Boolean,default:!0},crossorigin:{type:String},modal:{type:Boolean,default:!0},guides:{type:Boolean,default:!0},center:{type:Boolean,default:!0},highlight:{type:Boolean,default:!0},background:{type:Boolean,default:!0},autoCrop:{type:Boolean,default:!0},autoCropArea:Number,movable:{type:Boolean,default:!0},rotatable:{type:Boolean,default:!0},scalable:{type:Boolean,default:!0},zoomable:{type:Boolean,default:!0},zoomOnTouch:{type:Boolean,default:!0},zoomOnWheel:{type:Boolean,default:!0},wheelZoomRatio:Number,cropBoxMovable:{type:Boolean,default:!0},cropBoxResizable:{type:Boolean,default:!0},toggleDragModeOnDblclick:{type:Boolean,default:!0},minCanvasWidth:Number,minCanvasHeight:Number,minCropBoxWidth:Number,minCropBoxHeight:Number,minContainerWidth:Number,minContainerHeight:Number,ready:Function,cropstart:Function,cropmove:Function,cropend:Function,crop:Function,zoom:Function},mounted:function(){var e=this.$options.props,r=(e.containerStyle,e.src,e.alt,e.imgStyle,o(e,["containerStyle","src","alt","imgStyle"])),n={};for(var a in r)void 0!==this[a]&&(n[a]=this[a]);this.cropper=new t.default(this.$refs.img,n)},methods:{reset:function(){return this.cropper.reset()},clear:function(){return this.cropper.clear()},initCrop:function(){return this.cropper.crop()},replace:function(e){var t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];return this.cropper.replace(e,t)},enable:function(){return this.cropper.enable()},disable:function(){return this.cropper.disable()},destroy:function(){return this.cropper.destroy()},move:function(e,t){return this.cropper.move(e,t)},moveTo:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:e;return this.cropper.moveTo(e,t)},relativeZoom:function(e,t){return this.cropper.zoom(e,t)},zoomTo:function(e,t){return this.cropper.zoomTo(e,t)},rotate:function(e){return this.cropper.rotate(e)},rotateTo:function(e){return this.cropper.rotateTo(e)},scaleX:function(e){return this.cropper.scaleX(e)},scaleY:function(e){return this.cropper.scaleY(e)},scale:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:e;return this.cropper.scale(e,t)},getData:function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0];return this.cropper.getData(e)},setData:function(e){return this.cropper.setData(e)},getContainerData:function(){return this.cropper.getContainerData()},getImageData:function(){return this.cropper.getImageData()},getCanvasData:function(){return this.cropper.getCanvasData()},setCanvasData:function(e){return this.cropper.setCanvasData(e)},getCropBoxData:function(){return this.cropper.getCropBoxData()},setCropBoxData:function(e){return this.cropper.setCropBoxData(e)},getCroppedCanvas:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return this.cropper.getCroppedCanvas(e)},setAspectRatio:function(e){return this.cropper.setAspectRatio(e)},setDragMode:function(e){return this.cropper.setDragMode(e)}}}; +},{"cropperjs":"iUPs"}],"c7uD":[function(require,module,exports) { + +},{}],"Amhf":[function(require,module,exports) { +"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=void 0;var e=t(require("vue-cropperjs"));function t(e){return e&&e.__esModule?e:{default:e}}require("cropperjs/dist/cropper.css");var i={components:{VueCropper:e.default},props:{label:String,image:String,value:Object,minSize:Object,preserveAspectRatio:Boolean,isCropping:Boolean},computed:{data:function(){return this.value},aspectRatio:function(){return this.preserveAspectRatio?this.minSize.width/this.minSize.height:NaN}},watch:{value:function(){this.isCropping||this.$refs.cropper.setData(this.value)}},methods:{cropmove:function(e){var t=!1,i=this.$refs.cropper.getData(!0);this.isCropping=!0,i.widthimageInfo = getimagesize($filename); + $this->memoryLimit = self::getMemory(); + $this->setMemory(); + self::$runCount++; + self::setExecutionTime(); + parent::__construct($filename); + } + + /** + * Inspired by corey34 seen at https://github.com/gumlet/php-image-resize/issues/55#issuecomment-437035599 + */ + public function setMemory() { + if($this->memoryLimit == -1){ + return $this; + } else { + $w = max($this->imageInfo[0], $this->dest_w, $this->source_w, $this->original_w, 1); + $h = max($this->imageInfo[1], $this->dest_h, $this->source_h, $this->original_h, 1); + + if (array_key_exists("channels", $this->imageInfo)) { + $channels = $this->imageInfo['channels']; + } else { + $channels = 3; + } + + $memoryNeeded = round(($w * $h * $this->imageInfo['bits'] * $channels / 8 + self::K64) * self::TWEAKFACTOR); + + $memoryUsage = memory_get_usage(true); + $newLimit = $memoryUsage + $memoryNeeded; + + if ($newLimit > $this->memoryLimit) { + $newLimit = ceil($newLimit / self::MB); + ini_set( 'memory_limit', $newLimit . "M" ); + $this->memoryLimit = $newLimit; + return $this; + } else { + return $this; + } + } + } + + private static function setExecutionTime(){ + $val = (int)ini_get('max_execution_time'); + set_time_limit($val + self::$runCount * self::INCREASE_SEC); + } + + private static function getMemory() { + $val = ini_get('memory_limit'); + $val = trim($val); + $last = strtolower($val[strlen($val)-1]); + $val = (int)$val; + + switch($last) { + // The 'G' modifier is available since PHP 5.1.0 + case 'g': + $val *= 1024; + case 'm': + $val *= 1024; + case 'k': + $val *= 1024; + } + + return $val; + } +} \ No newline at end of file diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/package-lock.json b/site/plugins/kirby-plugin-image-crop-field-2.0.5/package-lock.json new file mode 100644 index 0000000..4a6a2a3 --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/package-lock.json @@ -0,0 +1,9361 @@ +{ + "name": "kirby-plugin-image-crop-field", + "version": "2.0.4", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.13.tgz", + "integrity": "sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.12.13" + } + }, + "@babel/compat-data": { + "version": "7.13.15", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.13.15.tgz", + "integrity": "sha512-ltnibHKR1VnrU4ymHyQ/CXtNXI6yZC0oJThyW78Hft8XndANwi+9H+UIklBDraIjFEJzw8wmcM427oDd9KS5wA==", + "dev": true + }, + "@babel/core": { + "version": "7.13.15", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.13.15.tgz", + "integrity": "sha512-6GXmNYeNjS2Uz+uls5jalOemgIhnTMeaXo+yBUA72kC2uX/8VW6XyhVIo2L8/q0goKQA3EVKx0KOQpVKSeWadQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@babel/generator": "^7.13.9", + "@babel/helper-compilation-targets": "^7.13.13", + "@babel/helper-module-transforms": "^7.13.14", + "@babel/helpers": "^7.13.10", + "@babel/parser": "^7.13.15", + "@babel/template": "^7.12.13", + "@babel/traverse": "^7.13.15", + "@babel/types": "^7.13.14", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.1.2", + "semver": "^6.3.0", + "source-map": "^0.5.0" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "json5": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz", + "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.13.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.13.9.tgz", + "integrity": "sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw==", + "dev": true, + "requires": { + "@babel/types": "^7.13.0", + "jsesc": "^2.5.1", + "source-map": "^0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.13.tgz", + "integrity": "sha512-7YXfX5wQ5aYM/BOlbSccHDbuXXFPxeoUmfWtz8le2yTkTZc+BxsiEnENFoi2SlmA8ewDkG2LgIMIVzzn2h8kfw==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.12.13.tgz", + "integrity": "sha512-CZOv9tGphhDRlVjVkAgm8Nhklm9RzSmWpX2my+t7Ua/KT616pEzXsQCjinzvkRvHWJ9itO4f296efroX23XCMA==", + "dev": true, + "requires": { + "@babel/helper-explode-assignable-expression": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.13.13", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.13.tgz", + "integrity": "sha512-q1kcdHNZehBwD9jYPh3WyXcsFERi39X4I59I3NadciWtNDyZ6x+GboOxncFK0kXlKIv6BJm5acncehXWUjWQMQ==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.13.12", + "@babel/helper-validator-option": "^7.12.17", + "browserslist": "^4.14.5", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.13.11", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.13.11.tgz", + "integrity": "sha512-ays0I7XYq9xbjCSvT+EvysLgfc3tOkwCULHjrnscGT3A9qD4sk3wXnJ3of0MAWsWGjdinFvajHU2smYuqXKMrw==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.12.13", + "@babel/helper-member-expression-to-functions": "^7.13.0", + "@babel/helper-optimise-call-expression": "^7.12.13", + "@babel/helper-replace-supers": "^7.13.0", + "@babel/helper-split-export-declaration": "^7.12.13" + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.12.17", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.17.tgz", + "integrity": "sha512-p2VGmBu9oefLZ2nQpgnEnG0ZlRPvL8gAGvPUMQwUdaE8k49rOMuZpOwdQoy5qJf6K8jL3bcAMhVUlHAjIgJHUg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.12.13", + "regexpu-core": "^4.7.1" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + }, + "regexpu-core": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-4.7.1.tgz", + "integrity": "sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==", + "dev": true, + "requires": { + "regenerate": "^1.4.0", + "regenerate-unicode-properties": "^8.2.0", + "regjsgen": "^0.5.1", + "regjsparser": "^0.6.4", + "unicode-match-property-ecmascript": "^1.0.4", + "unicode-match-property-value-ecmascript": "^1.2.0" + } + }, + "regjsgen": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.5.2.tgz", + "integrity": "sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==", + "dev": true + }, + "regjsparser": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.6.9.tgz", + "integrity": "sha512-ZqbNRz1SNjLAiYuwY0zoXW8Ne675IX5q+YHioAGbCw4X96Mjl2+dcX9B2ciaeyYjViDAfvIjFpQjJgLttTEERQ==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + } + } + } + }, + "@babel/helper-define-polyfill-provider": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.2.0.tgz", + "integrity": "sha512-JT8tHuFjKBo8NnaUbblz7mIu1nnvUDiHVjXXkulZULyidvo/7P6TY7+YqpV37IfF+KUFxmlK04elKtGKXaiVgw==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.13.0", + "@babel/helper-module-imports": "^7.12.13", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/traverse": "^7.13.0", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/helper-explode-assignable-expression": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.13.0.tgz", + "integrity": "sha512-qS0peLTDP8kOisG1blKbaoBg/o9OSa1qoumMjTK5pM+KDTtpxpsiubnCGP34vK8BXGcb2M9eigwgvoJryrzwWA==", + "dev": true, + "requires": { + "@babel/types": "^7.13.0" + } + }, + "@babel/helper-function-name": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz", + "integrity": "sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.12.13", + "@babel/template": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz", + "integrity": "sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.13.0.tgz", + "integrity": "sha512-0kBzvXiIKfsCA0y6cFEIJf4OdzfpRuNk4+YTeHZpGGc666SATFKTz6sRncwFnQk7/ugJ4dSrCj6iJuvW4Qwr2g==", + "dev": true, + "requires": { + "@babel/traverse": "^7.13.0", + "@babel/types": "^7.13.0" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.13.12", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.13.12.tgz", + "integrity": "sha512-48ql1CLL59aKbU94Y88Xgb2VFy7a95ykGRbJJaaVv+LX5U8wFpLfiGXJJGUozsmA1oEh/o5Bp60Voq7ACyA/Sw==", + "dev": true, + "requires": { + "@babel/types": "^7.13.12" + } + }, + "@babel/helper-module-imports": { + "version": "7.13.12", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz", + "integrity": "sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==", + "dev": true, + "requires": { + "@babel/types": "^7.13.12" + } + }, + "@babel/helper-module-transforms": { + "version": "7.13.14", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.13.14.tgz", + "integrity": "sha512-QuU/OJ0iAOSIatyVZmfqB0lbkVP0kDRiKj34xy+QNsnVZi/PA6BoSoreeqnxxa9EHFAIL0R9XOaAR/G9WlIy5g==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.13.12", + "@babel/helper-replace-supers": "^7.13.12", + "@babel/helper-simple-access": "^7.13.12", + "@babel/helper-split-export-declaration": "^7.12.13", + "@babel/helper-validator-identifier": "^7.12.11", + "@babel/template": "^7.12.13", + "@babel/traverse": "^7.13.13", + "@babel/types": "^7.13.14" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.13.tgz", + "integrity": "sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz", + "integrity": "sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ==", + "dev": true + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.13.0.tgz", + "integrity": "sha512-pUQpFBE9JvC9lrQbpX0TmeNIy5s7GnZjna2lhhcHC7DzgBs6fWn722Y5cfwgrtrqc7NAJwMvOa0mKhq6XaE4jg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.12.13", + "@babel/helper-wrap-function": "^7.13.0", + "@babel/types": "^7.13.0" + } + }, + "@babel/helper-replace-supers": { + "version": "7.13.12", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.13.12.tgz", + "integrity": "sha512-Gz1eiX+4yDO8mT+heB94aLVNCL+rbuT2xy4YfyNqu8F+OI6vMvJK891qGBTqL9Uc8wxEvRW92Id6G7sDen3fFw==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.13.12", + "@babel/helper-optimise-call-expression": "^7.12.13", + "@babel/traverse": "^7.13.0", + "@babel/types": "^7.13.12" + } + }, + "@babel/helper-simple-access": { + "version": "7.13.12", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.13.12.tgz", + "integrity": "sha512-7FEjbrx5SL9cWvXioDbnlYTppcZGuCY6ow3/D5vMggb2Ywgu4dMrpTJX0JdQAIcRRUElOIxF3yEooa9gUb9ZbA==", + "dev": true, + "requires": { + "@babel/types": "^7.13.12" + } + }, + "@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz", + "integrity": "sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA==", + "dev": true, + "requires": { + "@babel/types": "^7.12.1" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz", + "integrity": "sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg==", + "dev": true, + "requires": { + "@babel/types": "^7.12.13" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz", + "integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.12.17", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.17.tgz", + "integrity": "sha512-TopkMDmLzq8ngChwRlyjR6raKD6gMSae4JdYDB8bByKreQgG0RBTuKe9LRxW3wFtUnjxOPRKBDwEH6Mg5KeDfw==", + "dev": true + }, + "@babel/helper-wrap-function": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.13.0.tgz", + "integrity": "sha512-1UX9F7K3BS42fI6qd2A4BjKzgGjToscyZTdp1DjknHLCIvpgne6918io+aL5LXFcER/8QWiwpoY902pVEqgTXA==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.12.13", + "@babel/template": "^7.12.13", + "@babel/traverse": "^7.13.0", + "@babel/types": "^7.13.0" + } + }, + "@babel/helpers": { + "version": "7.13.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.13.10.tgz", + "integrity": "sha512-4VO883+MWPDUVRF3PhiLBUFHoX/bsLTGFpFK/HqvvfBZz2D57u9XzPVNFVBTc0PW/CWR9BXTOKt8NF4DInUHcQ==", + "dev": true, + "requires": { + "@babel/template": "^7.12.13", + "@babel/traverse": "^7.13.0", + "@babel/types": "^7.13.0" + } + }, + "@babel/highlight": { + "version": "7.13.10", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.13.10.tgz", + "integrity": "sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.12.11", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + } + } + }, + "@babel/parser": { + "version": "7.13.15", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.15.tgz", + "integrity": "sha512-b9COtcAlVEQljy/9fbcMHpG+UIW9ReF+gpaxDHTlZd0c6/UU9ng8zdySAW9sRTzpvcdCHn6bUcbuYUgGzLAWVQ==", + "dev": true + }, + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.13.12", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.13.12.tgz", + "integrity": "sha512-d0u3zWKcoZf379fOeJdr1a5WPDny4aOFZ6hlfKivgK0LY7ZxNfoaHL2fWwdGtHyVvra38FC+HVYkO+byfSA8AQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.12.1", + "@babel/plugin-proposal-optional-chaining": "^7.13.12" + } + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.13.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.13.15.tgz", + "integrity": "sha512-VapibkWzFeoa6ubXy/NgV5U2U4MVnUlvnx6wo1XhlsaTrLYWE0UFpDQsVrmn22q5CzeloqJ8gEMHSKxuee6ZdA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-remap-async-to-generator": "^7.13.0", + "@babel/plugin-syntax-async-generators": "^7.8.4" + } + }, + "@babel/plugin-proposal-class-properties": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.13.0.tgz", + "integrity": "sha512-KnTDjFNC1g+45ka0myZNvSBFLhNCLN+GeGYLDEA8Oq7MZ6yMgfLoIRh86GRT0FjtJhZw8JyUskP9uvj5pHM9Zg==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.13.0", + "@babel/helper-plugin-utils": "^7.13.0" + } + }, + "@babel/plugin-proposal-dynamic-import": { + "version": "7.13.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.13.8.tgz", + "integrity": "sha512-ONWKj0H6+wIRCkZi9zSbZtE/r73uOhMVHh256ys0UzfM7I3d4n+spZNWjOnJv2gzopumP2Wxi186vI8N0Y2JyQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + } + }, + "@babel/plugin-proposal-export-namespace-from": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.13.tgz", + "integrity": "sha512-INAgtFo4OnLN3Y/j0VwAgw3HDXcDtX+C/erMvWzuV9v71r7urb6iyMXu7eM9IgLr1ElLlOkaHjJ0SbCmdOQ3Iw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + } + }, + "@babel/plugin-proposal-json-strings": { + "version": "7.13.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.13.8.tgz", + "integrity": "sha512-w4zOPKUFPX1mgvTmL/fcEqy34hrQ1CRcGxdphBc6snDnnqJ47EZDIyop6IwXzAC8G916hsIuXB2ZMBCExC5k7Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/plugin-syntax-json-strings": "^7.8.3" + } + }, + "@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.13.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.13.8.tgz", + "integrity": "sha512-aul6znYB4N4HGweImqKn59Su9RS8lbUIqxtXTOcAGtNIDczoEFv+l1EhmX8rUBp3G1jMjKJm8m0jXVp63ZpS4A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + } + }, + "@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.13.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.13.8.tgz", + "integrity": "sha512-iePlDPBn//UhxExyS9KyeYU7RM9WScAG+D3Hhno0PLJebAEpDZMocbDe64eqynhNAnwz/vZoL/q/QB2T1OH39A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + } + }, + "@babel/plugin-proposal-numeric-separator": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.13.tgz", + "integrity": "sha512-O1jFia9R8BUCl3ZGB7eitaAPu62TXJRHn7rh+ojNERCFyqRwJMTmhz+tJ+k0CwI6CLjX/ee4qW74FSqlq9I35w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.13.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.13.8.tgz", + "integrity": "sha512-DhB2EuB1Ih7S3/IRX5AFVgZ16k3EzfRbq97CxAVI1KSYcW+lexV8VZb7G7L8zuPVSdQMRn0kiBpf/Yzu9ZKH0g==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.13.8", + "@babel/helper-compilation-targets": "^7.13.8", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.13.0" + } + }, + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.13.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.13.8.tgz", + "integrity": "sha512-0wS/4DUF1CuTmGo+NiaHfHcVSeSLj5S3e6RivPTg/2k3wOv3jO35tZ6/ZWsQhQMvdgI7CwphjQa/ccarLymHVA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + } + }, + "@babel/plugin-proposal-optional-chaining": { + "version": "7.13.12", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.13.12.tgz", + "integrity": "sha512-fcEdKOkIB7Tf4IxrgEVeFC4zeJSTr78no9wTdBuZZbqF64kzllU0ybo2zrzm7gUQfxGhBgq4E39oRs8Zx/RMYQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.12.1", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + } + }, + "@babel/plugin-proposal-private-methods": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.13.0.tgz", + "integrity": "sha512-MXyyKQd9inhx1kDYPkFRVOBXQ20ES8Pto3T7UZ92xj2mY0EVD8oAVzeyYuVfy/mxAdTSIayOvg+aVzcHV2bn6Q==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.13.0", + "@babel/helper-plugin-utils": "^7.13.0" + } + }, + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.13.tgz", + "integrity": "sha512-XyJmZidNfofEkqFV5VC/bLabGmO5QzenPO/YOfGuEbgU+2sSwMmio3YLb4WtBgcmmdwZHyVyv8on77IUjQ5Gvg==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-syntax-flow": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.12.13.tgz", + "integrity": "sha512-J/RYxnlSLXZLVR7wTRsozxKT8qbsx1mNKJzXEEjQ0Kjx1ZACcyHgbanNWNCFtc36IzuWhYWPpvJFFoexoOWFmA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.13.tgz", + "integrity": "sha512-d4HM23Q1K7oq/SLNmG6mRt85l2csmQ0cHRaxRXjKW0YFdEXqlZ5kzFQKH5Uc3rDJECgu+yCRgPkG04Mm98R/1g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.13.tgz", + "integrity": "sha512-A81F9pDwyS7yM//KwbCSDqy3Uj4NMIurtplxphWxoYtNPov7cJsDkAFNNyVlIZ3jwGycVsurZ+LtOA8gZ376iQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.13.0.tgz", + "integrity": "sha512-96lgJagobeVmazXFaDrbmCLQxBysKu7U6Do3mLsx27gf5Dk85ezysrs2BZUpXD703U/Su1xTBDxxar2oa4jAGg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.13.0.tgz", + "integrity": "sha512-3j6E004Dx0K3eGmhxVJxwwI89CTJrce7lg3UrtFuDAVQ/2+SJ/h/aSFOeE6/n0WB1GsOffsJp6MnPQNQ8nmwhg==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.12.13", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-remap-async-to-generator": "^7.13.0" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.13.tgz", + "integrity": "sha512-zNyFqbc3kI/fVpqwfqkg6RvBgFpC4J18aKKMmv7KdQ/1GgREapSJAykLMVNwfRGO3BtHj3YQZl8kxCXPcVMVeg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.13.tgz", + "integrity": "sha512-Pxwe0iqWJX4fOOM2kEZeUuAxHMWb9nK+9oh5d11bsLoB0xMg+mkDpt0eYuDZB7ETrY9bbcVlKUGTOGWy7BHsMQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.13.0.tgz", + "integrity": "sha512-9BtHCPUARyVH1oXGcSJD3YpsqRLROJx5ZNP6tN5vnk17N0SVf9WCtf8Nuh1CFmgByKKAIMstitKduoCmsaDK5g==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.12.13", + "@babel/helper-function-name": "^7.12.13", + "@babel/helper-optimise-call-expression": "^7.12.13", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-replace-supers": "^7.13.0", + "@babel/helper-split-export-declaration": "^7.12.13", + "globals": "^11.1.0" + }, + "dependencies": { + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + } + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.13.0.tgz", + "integrity": "sha512-RRqTYTeZkZAz8WbieLTvKUEUxZlUTdmL5KGMyZj7FnMfLNKV4+r5549aORG/mgojRmFlQMJDUupwAMiF2Q7OUg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.13.0.tgz", + "integrity": "sha512-zym5em7tePoNT9s964c0/KU3JPPnuq7VhIxPRefJ4/s82cD+q1mgKfuGRDMCPL0HTyKz4dISuQlCusfgCJ86HA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.13.tgz", + "integrity": "sha512-foDrozE65ZFdUC2OfgeOCrEPTxdB3yjqxpXh8CH+ipd9CHd4s/iq81kcUpyH8ACGNEPdFqbtzfgzbT/ZGlbDeQ==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.13.tgz", + "integrity": "sha512-NfADJiiHdhLBW3pulJlJI2NB0t4cci4WTZ8FtdIuNc2+8pslXdPtRRAEWqUY+m9kNOk2eRYbTAOipAxlrOcwwQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.13.tgz", + "integrity": "sha512-fbUelkM1apvqez/yYx1/oICVnGo2KM5s63mhGylrmXUxK/IAXSIf87QIxVfZldWf4QsOafY6vV3bX8aMHSvNrA==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-flow-strip-types": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.13.0.tgz", + "integrity": "sha512-EXAGFMJgSX8gxWD7PZtW/P6M+z74jpx3wm/+9pn+c2dOawPpBkUX7BrfyPvo6ZpXbgRIEuwgwDb/MGlKvu2pOg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/plugin-syntax-flow": "^7.12.13" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.13.0.tgz", + "integrity": "sha512-IHKT00mwUVYE0zzbkDgNRP6SRzvfGCYsOxIRz8KsiaaHCcT9BWIkO+H9QRJseHBLOGBZkHUdHiqj6r0POsdytg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.13.tgz", + "integrity": "sha512-6K7gZycG0cmIwwF7uMK/ZqeCikCGVBdyP2J5SKNCXO5EOHcqi+z7Jwf8AmyDNcBgxET8DrEtCt/mPKPyAzXyqQ==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.13.tgz", + "integrity": "sha512-FW+WPjSR7hiUxMcKqyNjP05tQ2kmBCdpEpZHY1ARm96tGQCCBvXKnpjILtDplUnJ/eHZ0lALLM+d2lMFSpYJrQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.13.tgz", + "integrity": "sha512-kxLkOsg8yir4YeEPHLuO2tXP9R/gTjpuTOjshqSpELUN3ZAg2jfDnKUvzzJxObun38sw3wm4Uu69sX/zA7iRvg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.13.0.tgz", + "integrity": "sha512-EKy/E2NHhY/6Vw5d1k3rgoobftcNUmp9fGjb9XZwQLtTctsRBOTRO7RHHxfIky1ogMN5BxN7p9uMA3SzPfotMQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.13.0", + "@babel/helper-plugin-utils": "^7.13.0", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.13.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.13.8.tgz", + "integrity": "sha512-9QiOx4MEGglfYZ4XOnU79OHr6vIWUakIj9b4mioN8eQIoEh+pf5p/zEB36JpDFWA12nNMiRf7bfoRvl9Rn79Bw==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.13.0", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-simple-access": "^7.12.13", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.13.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.13.8.tgz", + "integrity": "sha512-hwqctPYjhM6cWvVIlOIe27jCIBgHCsdH2xCJVAYQm7V5yTMoilbVMi9f6wKg0rpQAOn6ZG4AOyvCqFF/hUh6+A==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.13.0", + "@babel/helper-module-transforms": "^7.13.0", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-validator-identifier": "^7.12.11", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.13.0.tgz", + "integrity": "sha512-D/ILzAh6uyvkWjKKyFE/W0FzWwasv6vPTSqPcjxFqn6QpX3u8DjRVliq4F2BamO2Wee/om06Vyy+vPkNrd4wxw==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.13.0", + "@babel/helper-plugin-utils": "^7.13.0" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.13.tgz", + "integrity": "sha512-Xsm8P2hr5hAxyYblrfACXpQKdQbx4m2df9/ZZSQ8MAhsadw06+jW7s9zsSw6he+mJZXRlVMyEnVktJo4zjk1WA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.12.13" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.13.tgz", + "integrity": "sha512-/KY2hbLxrG5GTQ9zzZSc3xWiOy379pIETEhbtzwZcw9rvuaVV4Fqy7BYGYOWZnaoXIQYbbJ0ziXLa/sKcGCYEQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.13.tgz", + "integrity": "sha512-JzYIcj3XtYspZDV8j9ulnoMPZZnF/Cj0LUxPOjR89BdBVx+zYJI9MdMIlUZjbXDX+6YVeS6I3e8op+qQ3BYBoQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13", + "@babel/helper-replace-supers": "^7.12.13" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.13.0.tgz", + "integrity": "sha512-Jt8k/h/mIwE2JFEOb3lURoY5C85ETcYPnbuAJ96zRBzh1XHtQZfs62ChZ6EP22QlC8c7Xqr9q+e1SU5qttwwjw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.13.tgz", + "integrity": "sha512-nqVigwVan+lR+g8Fj8Exl0UQX2kymtjcWfMOYM1vTYEKujeyv2SkMgazf2qNcK7l4SDiKyTA/nHCPqL4e2zo1A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.13.12", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.13.12.tgz", + "integrity": "sha512-jcEI2UqIcpCqB5U5DRxIl0tQEProI2gcu+g8VTIqxLO5Iidojb4d77q+fwGseCvd8af/lJ9masp4QWzBXFE2xA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.12.13", + "@babel/helper-module-imports": "^7.13.12", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/plugin-syntax-jsx": "^7.12.13", + "@babel/types": "^7.13.12" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.13.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.13.15.tgz", + "integrity": "sha512-Bk9cOLSz8DiurcMETZ8E2YtIVJbFCPGW28DJWUakmyVWtQSm6Wsf0p4B4BfEr/eL2Nkhe/CICiUiMOCi1TPhuQ==", + "dev": true, + "requires": { + "regenerator-transform": "^0.14.2" + }, + "dependencies": { + "regenerator-transform": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.14.5.tgz", + "integrity": "sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.4" + } + } + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.13.tgz", + "integrity": "sha512-xhUPzDXxZN1QfiOy/I5tyye+TRz6lA7z6xaT4CLOjPRMVg1ldRf0LHw0TDBpYL4vG78556WuHdyO9oi5UmzZBg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.13.tgz", + "integrity": "sha512-xpL49pqPnLtf0tVluuqvzWIgLEhuPpZzvs2yabUHSKRNlN7ScYU7aMlmavOeyXJZKgZKQRBlh8rHbKiJDraTSw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.13.0.tgz", + "integrity": "sha512-V6vkiXijjzYeFmQTr3dBxPtZYLPcUfY34DebOU27jIl2M/Y8Egm52Hw82CSjjPqd54GTlJs5x+CR7HeNr24ckg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.12.1" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.13.tgz", + "integrity": "sha512-Jc3JSaaWT8+fr7GRvQP02fKDsYk4K/lYwWq38r/UGfaxo89ajud321NH28KRQ7xy1Ybc0VUE5Pz8psjNNDUglg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.13.0.tgz", + "integrity": "sha512-d67umW6nlfmr1iehCcBv69eSUSySk1EsIS8aTDX4Xo9qajAh6mYtcl4kJrBkGXuxZPEgVr7RVfAvNW6YQkd4Mw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.13.tgz", + "integrity": "sha512-eKv/LmUJpMnu4npgfvs3LiHhJua5fo/CysENxa45YCQXZwKnGCQKAg87bvoqSW1fFT+HA32l03Qxsm8ouTY3ZQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-unicode-escapes": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.13.tgz", + "integrity": "sha512-0bHEkdwJ/sN/ikBHfSmOXPypN/beiGqjo+o4/5K+vxEFNPRPdImhviPakMKG4x96l85emoa0Z6cDflsdBusZbw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.13.tgz", + "integrity": "sha512-mDRzSNY7/zopwisPZ5kM9XKCfhchqIYwAKRERtEnhYscZB79VRekuRSoYbN0+KVe3y8+q1h6A4svXtP7N+UoCA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.12.13", + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/preset-env": { + "version": "7.13.15", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.13.15.tgz", + "integrity": "sha512-D4JAPMXcxk69PKe81jRJ21/fP/uYdcTZ3hJDF5QX2HSI9bBxxYw/dumdR6dGumhjxlprHPE4XWoPaqzZUVy2MA==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.13.15", + "@babel/helper-compilation-targets": "^7.13.13", + "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-validator-option": "^7.12.17", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.13.12", + "@babel/plugin-proposal-async-generator-functions": "^7.13.15", + "@babel/plugin-proposal-class-properties": "^7.13.0", + "@babel/plugin-proposal-dynamic-import": "^7.13.8", + "@babel/plugin-proposal-export-namespace-from": "^7.12.13", + "@babel/plugin-proposal-json-strings": "^7.13.8", + "@babel/plugin-proposal-logical-assignment-operators": "^7.13.8", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8", + "@babel/plugin-proposal-numeric-separator": "^7.12.13", + "@babel/plugin-proposal-object-rest-spread": "^7.13.8", + "@babel/plugin-proposal-optional-catch-binding": "^7.13.8", + "@babel/plugin-proposal-optional-chaining": "^7.13.12", + "@babel/plugin-proposal-private-methods": "^7.13.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.12.13", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.12.13", + "@babel/plugin-transform-arrow-functions": "^7.13.0", + "@babel/plugin-transform-async-to-generator": "^7.13.0", + "@babel/plugin-transform-block-scoped-functions": "^7.12.13", + "@babel/plugin-transform-block-scoping": "^7.12.13", + "@babel/plugin-transform-classes": "^7.13.0", + "@babel/plugin-transform-computed-properties": "^7.13.0", + "@babel/plugin-transform-destructuring": "^7.13.0", + "@babel/plugin-transform-dotall-regex": "^7.12.13", + "@babel/plugin-transform-duplicate-keys": "^7.12.13", + "@babel/plugin-transform-exponentiation-operator": "^7.12.13", + "@babel/plugin-transform-for-of": "^7.13.0", + "@babel/plugin-transform-function-name": "^7.12.13", + "@babel/plugin-transform-literals": "^7.12.13", + "@babel/plugin-transform-member-expression-literals": "^7.12.13", + "@babel/plugin-transform-modules-amd": "^7.13.0", + "@babel/plugin-transform-modules-commonjs": "^7.13.8", + "@babel/plugin-transform-modules-systemjs": "^7.13.8", + "@babel/plugin-transform-modules-umd": "^7.13.0", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.12.13", + "@babel/plugin-transform-new-target": "^7.12.13", + "@babel/plugin-transform-object-super": "^7.12.13", + "@babel/plugin-transform-parameters": "^7.13.0", + "@babel/plugin-transform-property-literals": "^7.12.13", + "@babel/plugin-transform-regenerator": "^7.13.15", + "@babel/plugin-transform-reserved-words": "^7.12.13", + "@babel/plugin-transform-shorthand-properties": "^7.12.13", + "@babel/plugin-transform-spread": "^7.13.0", + "@babel/plugin-transform-sticky-regex": "^7.12.13", + "@babel/plugin-transform-template-literals": "^7.13.0", + "@babel/plugin-transform-typeof-symbol": "^7.12.13", + "@babel/plugin-transform-unicode-escapes": "^7.12.13", + "@babel/plugin-transform-unicode-regex": "^7.12.13", + "@babel/preset-modules": "^0.1.4", + "@babel/types": "^7.13.14", + "babel-plugin-polyfill-corejs2": "^0.2.0", + "babel-plugin-polyfill-corejs3": "^0.2.0", + "babel-plugin-polyfill-regenerator": "^0.2.0", + "core-js-compat": "^3.9.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/preset-modules": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.4.tgz", + "integrity": "sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + } + }, + "@babel/runtime": { + "version": "7.13.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.10.tgz", + "integrity": "sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + } + } + }, + "@babel/template": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.12.13.tgz", + "integrity": "sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@babel/parser": "^7.12.13", + "@babel/types": "^7.12.13" + } + }, + "@babel/traverse": { + "version": "7.13.15", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.13.15.tgz", + "integrity": "sha512-/mpZMNvj6bce59Qzl09fHEs8Bt8NnpEDQYleHUPZQ3wXUMvXi+HJPLars68oAbmp839fGoOkv2pSL2z9ajCIaQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@babel/generator": "^7.13.9", + "@babel/helper-function-name": "^7.12.13", + "@babel/helper-split-export-declaration": "^7.12.13", + "@babel/parser": "^7.13.15", + "@babel/types": "^7.13.14", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "dependencies": { + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.13.14", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.14.tgz", + "integrity": "sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.12.11", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + }, + "dependencies": { + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + } + } + }, + "@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "dev": true + }, + "@mrmlnc/readdir-enhanced": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", + "integrity": "sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==", + "dev": true, + "requires": { + "call-me-maybe": "^1.0.1", + "glob-to-regexp": "^0.3.0" + } + }, + "@nodelib/fs.stat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz", + "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", + "dev": true + }, + "@parcel/fs": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@parcel/fs/-/fs-1.11.0.tgz", + "integrity": "sha512-86RyEqULbbVoeo8OLcv+LQ1Vq2PKBAvWTU9fCgALxuCTbbs5Ppcvll4Vr+Ko1AnmMzja/k++SzNAwJfeQXVlpA==", + "dev": true, + "requires": { + "@parcel/utils": "^1.11.0", + "mkdirp": "^0.5.1", + "rimraf": "^2.6.2" + } + }, + "@parcel/logger": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@parcel/logger/-/logger-1.11.1.tgz", + "integrity": "sha512-9NF3M6UVeP2udOBDILuoEHd8VrF4vQqoWHEafymO1pfSoOMfxrSJZw1MfyAAIUN/IFp9qjcpDCUbDZB+ioVevA==", + "dev": true, + "requires": { + "@parcel/workers": "^1.11.0", + "chalk": "^2.1.0", + "grapheme-breaker": "^0.3.2", + "ora": "^2.1.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "@parcel/utils": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@parcel/utils/-/utils-1.11.0.tgz", + "integrity": "sha512-cA3p4jTlaMeOtAKR/6AadanOPvKeg8VwgnHhOyfi0yClD0TZS/hi9xu12w4EzA/8NtHu0g6o4RDfcNjqN8l1AQ==", + "dev": true + }, + "@parcel/watcher": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-1.12.1.tgz", + "integrity": "sha512-od+uCtCxC/KoNQAIE1vWx1YTyKYY+7CTrxBJPRh3cDWw/C0tCtlBMVlrbplscGoEpt6B27KhJDCv82PBxOERNA==", + "dev": true, + "requires": { + "@parcel/utils": "^1.11.0", + "chokidar": "^2.1.5" + }, + "dependencies": { + "chokidar": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", + "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.1", + "braces": "^2.3.2", + "fsevents": "^1.2.7", + "glob-parent": "^3.1.0", + "inherits": "^2.0.3", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "normalize-path": "^3.0.0", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.2.1", + "upath": "^1.1.1" + } + } + } + }, + "@parcel/workers": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@parcel/workers/-/workers-1.11.0.tgz", + "integrity": "sha512-USSjRAAQYsZFlv43FUPdD+jEGML5/8oLF0rUzPQTtK4q9kvaXr49F5ZplyLz5lox78cLZ0TxN2bIDQ1xhOkulQ==", + "dev": true, + "requires": { + "@parcel/utils": "^1.11.0", + "physical-cpu-count": "^2.0.0" + } + }, + "@types/q": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz", + "integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==", + "dev": true + }, + "@vue/component-compiler-utils": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@vue/component-compiler-utils/-/component-compiler-utils-2.6.0.tgz", + "integrity": "sha512-IHjxt7LsOFYc0DkTncB7OXJL7UzwOLPPQCfEUNyxL2qt+tF12THV+EO33O1G2Uk4feMSWua3iD39Itszx0f0bw==", + "dev": true, + "requires": { + "consolidate": "^0.15.1", + "hash-sum": "^1.0.2", + "lru-cache": "^4.1.2", + "merge-source-map": "^1.1.0", + "postcss": "^7.0.14", + "postcss-selector-parser": "^5.0.0", + "prettier": "1.16.3", + "source-map": "~0.6.1", + "vue-template-es2015-compiler": "^1.9.0" + } + }, + "abab": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz", + "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==", + "dev": true + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + }, + "acorn-globals": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.4.tgz", + "integrity": "sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==", + "dev": true, + "requires": { + "acorn": "^6.0.1", + "acorn-walk": "^6.0.1" + }, + "dependencies": { + "acorn": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz", + "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", + "dev": true + } + } + }, + "acorn-walk": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz", + "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==", + "dev": true + }, + "ajv": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.11.0.tgz", + "integrity": "sha512-nCprB/0syFYy9fVYU1ox1l2KN8S9I+tziH8D4zdZuLT3N6RMlGSGt5FSTpAiHB/Whv8Qs1cWHma1aMKZyaHRKA==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "alphanum-sort": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz", + "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=", + "dev": true + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "ansi-to-html": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.6.14.tgz", + "integrity": "sha512-7ZslfB1+EnFSDO5Ju+ue5Y6It19DRnZXWv8jrGHgIlPna5Mh4jz7BV5jCbQneXNFurQcKoolaaAjHtgSBfOIuA==", + "dev": true, + "requires": { + "entities": "^1.1.2" + } + }, + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + }, + "dependencies": { + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + } + } + }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", + "dev": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", + "integrity": "sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=", + "dev": true + }, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "dev": true, + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.0.tgz", + "integrity": "sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "util": "0.10.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", + "dev": true + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "dev": true, + "requires": { + "inherits": "2.0.1" + } + } + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "async-each": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", + "integrity": "sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==", + "dev": true + }, + "async-foreach": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", + "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=", + "dev": true + }, + "async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "dev": true + }, + "aws4": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz", + "integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==", + "dev": true + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "esutils": "^2.0.2", + "js-tokens": "^3.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "babel-core": { + "version": "6.26.3", + "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.3.tgz", + "integrity": "sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==", + "dev": true, + "requires": { + "babel-code-frame": "^6.26.0", + "babel-generator": "^6.26.0", + "babel-helpers": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-register": "^6.26.0", + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "convert-source-map": "^1.5.1", + "debug": "^2.6.9", + "json5": "^0.5.1", + "lodash": "^4.17.4", + "minimatch": "^3.0.4", + "path-is-absolute": "^1.0.1", + "private": "^0.1.8", + "slash": "^1.0.0", + "source-map": "^0.5.7" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "babel-generator": { + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.1.tgz", + "integrity": "sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==", + "dev": true, + "requires": { + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "detect-indent": "^4.0.0", + "jsesc": "^1.3.0", + "lodash": "^4.17.4", + "source-map": "^0.5.7", + "trim-right": "^1.0.1" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "babel-helper-builder-binary-assignment-operator-visitor": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz", + "integrity": "sha1-zORReto1b0IgvK6KAsKzRvmlZmQ=", + "dev": true, + "requires": { + "babel-helper-explode-assignable-expression": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-call-delegate": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz", + "integrity": "sha1-7Oaqzdx25Bw0YfiL/Fdb0Nqi340=", + "dev": true, + "requires": { + "babel-helper-hoist-variables": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-define-map": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz", + "integrity": "sha1-pfVtq0GiX5fstJjH66ypgZ+Vvl8=", + "dev": true, + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + } + }, + "babel-helper-explode-assignable-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz", + "integrity": "sha1-8luCz33BBDPFX3BZLVdGQArCLKo=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz", + "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=", + "dev": true, + "requires": { + "babel-helper-get-function-arity": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-get-function-arity": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz", + "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-hoist-variables": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz", + "integrity": "sha1-HssnaJydJVE+rbyZFKc/VAi+enY=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-optimise-call-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz", + "integrity": "sha1-96E0J7qfc/j0+pk8VKl4gtEkQlc=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-regex": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz", + "integrity": "sha1-MlxZ+QL4LyS3T6zu0DY5VPZJXnI=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + } + }, + "babel-helper-remap-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz", + "integrity": "sha1-XsWBgnrXI/7N04HxySg5BnbkVRs=", + "dev": true, + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-replace-supers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz", + "integrity": "sha1-v22/5Dk40XNpohPKiov3S2qQqxo=", + "dev": true, + "requires": { + "babel-helper-optimise-call-expression": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helpers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz", + "integrity": "sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-messages": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", + "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-check-es2015-constants": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz", + "integrity": "sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "dev": true, + "requires": { + "object.assign": "^4.1.0" + } + }, + "babel-plugin-polyfill-corejs2": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.2.0.tgz", + "integrity": "sha512-9bNwiR0dS881c5SHnzCmmGlMkJLl0OUZvxrxHo9w/iNoRuqaPjqlvBf4HrovXtQs/au5yKkpcdgfT1cC5PAZwg==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.13.11", + "@babel/helper-define-polyfill-provider": "^0.2.0", + "semver": "^6.1.1" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "babel-plugin-polyfill-corejs3": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.0.tgz", + "integrity": "sha512-zZyi7p3BCUyzNxLx8KV61zTINkkV65zVkDAFNZmrTCRVhjo1jAS+YLvDJ9Jgd/w2tsAviCwFHReYfxO3Iql8Yg==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.2.0", + "core-js-compat": "^3.9.1" + } + }, + "babel-plugin-polyfill-regenerator": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.2.0.tgz", + "integrity": "sha512-J7vKbCuD2Xi/eEHxquHN14bXAW9CXtecwuLrOIDJtcZzTaPzV1VdEfoUf9AzcRBMolKUQKM9/GVojeh0hFiqMg==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.2.0" + } + }, + "babel-plugin-syntax-async-functions": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz", + "integrity": "sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU=", + "dev": true + }, + "babel-plugin-syntax-exponentiation-operator": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz", + "integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=", + "dev": true + }, + "babel-plugin-syntax-trailing-function-commas": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz", + "integrity": "sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM=", + "dev": true + }, + "babel-plugin-transform-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz", + "integrity": "sha1-ZTbjeK/2yx1VF6wOQOs+n8jQh2E=", + "dev": true, + "requires": { + "babel-helper-remap-async-to-generator": "^6.24.1", + "babel-plugin-syntax-async-functions": "^6.8.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-arrow-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz", + "integrity": "sha1-RSaSy3EdX3ncf4XkQM5BufJE0iE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-block-scoped-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz", + "integrity": "sha1-u8UbSflk1wy42OC5ToICRs46YUE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-block-scoping": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz", + "integrity": "sha1-1w9SmcEwjQXBL0Y4E7CgnnOxiV8=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + } + }, + "babel-plugin-transform-es2015-classes": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz", + "integrity": "sha1-WkxYpQyclGHlZLSyo7+ryXolhNs=", + "dev": true, + "requires": { + "babel-helper-define-map": "^6.24.1", + "babel-helper-function-name": "^6.24.1", + "babel-helper-optimise-call-expression": "^6.24.1", + "babel-helper-replace-supers": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-computed-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz", + "integrity": "sha1-b+Ko0WiV1WNPTNmZttNICjCBWbM=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-destructuring": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz", + "integrity": "sha1-mXux8auWf2gtKwh2/jWNYOdlxW0=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-duplicate-keys": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz", + "integrity": "sha1-c+s9MQypaePvnskcU3QabxV2Qj4=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-for-of": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz", + "integrity": "sha1-9HyVsrYT3x0+zC/bdXNiPHUkhpE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz", + "integrity": "sha1-g0yJhTvDaxrw86TF26qU/Y6sqos=", + "dev": true, + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz", + "integrity": "sha1-T1SgLWzWbPkVKAAZox0xklN3yi4=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-modules-amd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz", + "integrity": "sha1-Oz5UAXI5hC1tGcMBHEvS8AoA0VQ=", + "dev": true, + "requires": { + "babel-plugin-transform-es2015-modules-commonjs": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-modules-commonjs": { + "version": "6.26.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz", + "integrity": "sha512-CV9ROOHEdrjcwhIaJNBGMBCodN+1cfkwtM1SbUHmvyy35KGT7fohbpOxkE2uLz1o6odKK2Ck/tz47z+VqQfi9Q==", + "dev": true, + "requires": { + "babel-plugin-transform-strict-mode": "^6.24.1", + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-types": "^6.26.0" + } + }, + "babel-plugin-transform-es2015-modules-systemjs": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz", + "integrity": "sha1-/4mhQrkRmpBhlfXxBuzzBdlAfSM=", + "dev": true, + "requires": { + "babel-helper-hoist-variables": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-modules-umd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz", + "integrity": "sha1-rJl+YoXNGO1hdq22B9YCNErThGg=", + "dev": true, + "requires": { + "babel-plugin-transform-es2015-modules-amd": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-object-super": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz", + "integrity": "sha1-JM72muIcuDp/hgPa0CH1cusnj40=", + "dev": true, + "requires": { + "babel-helper-replace-supers": "^6.24.1", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-parameters": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz", + "integrity": "sha1-V6w1GrScrxSpfNE7CfZv3wpiXys=", + "dev": true, + "requires": { + "babel-helper-call-delegate": "^6.24.1", + "babel-helper-get-function-arity": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-shorthand-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz", + "integrity": "sha1-JPh11nIch2YbvZmkYi5R8U3jiqA=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-spread": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz", + "integrity": "sha1-1taKmfia7cRTbIGlQujdnxdG+NE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-sticky-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz", + "integrity": "sha1-AMHNsaynERLN8M9hJsLta0V8zbw=", + "dev": true, + "requires": { + "babel-helper-regex": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-template-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz", + "integrity": "sha1-qEs0UPfp+PH2g51taH2oS7EjbY0=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-typeof-symbol": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz", + "integrity": "sha1-3sCfHN3/lLUqxz1QXITfWdzOs3I=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-unicode-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz", + "integrity": "sha1-04sS9C6nMj9yk4fxinxa4frrNek=", + "dev": true, + "requires": { + "babel-helper-regex": "^6.24.1", + "babel-runtime": "^6.22.0", + "regexpu-core": "^2.0.0" + } + }, + "babel-plugin-transform-exponentiation-operator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz", + "integrity": "sha1-KrDJx/MJj6SJB3cruBP+QejeOg4=", + "dev": true, + "requires": { + "babel-helper-builder-binary-assignment-operator-visitor": "^6.24.1", + "babel-plugin-syntax-exponentiation-operator": "^6.8.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-regenerator": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz", + "integrity": "sha1-4HA2lvveJ/Cj78rPi03KL3s6jy8=", + "dev": true, + "requires": { + "regenerator-transform": "^0.10.0" + } + }, + "babel-plugin-transform-strict-mode": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz", + "integrity": "sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-preset-env": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/babel-preset-env/-/babel-preset-env-1.7.0.tgz", + "integrity": "sha512-9OR2afuKDneX2/q2EurSftUYM0xGu4O2D9adAhVfADDhrYDaxXV0rBbevVYoY9n6nyX1PmQW/0jtpJvUNr9CHg==", + "dev": true, + "requires": { + "babel-plugin-check-es2015-constants": "^6.22.0", + "babel-plugin-syntax-trailing-function-commas": "^6.22.0", + "babel-plugin-transform-async-to-generator": "^6.22.0", + "babel-plugin-transform-es2015-arrow-functions": "^6.22.0", + "babel-plugin-transform-es2015-block-scoped-functions": "^6.22.0", + "babel-plugin-transform-es2015-block-scoping": "^6.23.0", + "babel-plugin-transform-es2015-classes": "^6.23.0", + "babel-plugin-transform-es2015-computed-properties": "^6.22.0", + "babel-plugin-transform-es2015-destructuring": "^6.23.0", + "babel-plugin-transform-es2015-duplicate-keys": "^6.22.0", + "babel-plugin-transform-es2015-for-of": "^6.23.0", + "babel-plugin-transform-es2015-function-name": "^6.22.0", + "babel-plugin-transform-es2015-literals": "^6.22.0", + "babel-plugin-transform-es2015-modules-amd": "^6.22.0", + "babel-plugin-transform-es2015-modules-commonjs": "^6.23.0", + "babel-plugin-transform-es2015-modules-systemjs": "^6.23.0", + "babel-plugin-transform-es2015-modules-umd": "^6.23.0", + "babel-plugin-transform-es2015-object-super": "^6.22.0", + "babel-plugin-transform-es2015-parameters": "^6.23.0", + "babel-plugin-transform-es2015-shorthand-properties": "^6.22.0", + "babel-plugin-transform-es2015-spread": "^6.22.0", + "babel-plugin-transform-es2015-sticky-regex": "^6.22.0", + "babel-plugin-transform-es2015-template-literals": "^6.22.0", + "babel-plugin-transform-es2015-typeof-symbol": "^6.23.0", + "babel-plugin-transform-es2015-unicode-regex": "^6.22.0", + "babel-plugin-transform-exponentiation-operator": "^6.22.0", + "babel-plugin-transform-regenerator": "^6.22.0", + "browserslist": "^3.2.6", + "invariant": "^2.2.2", + "semver": "^5.3.0" + }, + "dependencies": { + "browserslist": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-3.2.8.tgz", + "integrity": "sha512-WHVocJYavUwVgVViC0ORikPHQquXwVh939TaelZ4WDqpWgTX/FsGhl/+P4qBUAGcRvtOgDgC+xftNWWp2RUTAQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000844", + "electron-to-chromium": "^1.3.47" + } + } + } + }, + "babel-register": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz", + "integrity": "sha1-btAhFz4vy0htestFxgCahW9kcHE=", + "dev": true, + "requires": { + "babel-core": "^6.26.0", + "babel-runtime": "^6.26.0", + "core-js": "^2.5.0", + "home-or-tmp": "^2.0.0", + "lodash": "^4.17.4", + "mkdirp": "^0.5.1", + "source-map-support": "^0.4.15" + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "dev": true, + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "babel-template": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz", + "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "lodash": "^4.17.4" + } + }, + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "dev": true, + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + }, + "babylon": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", + "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", + "dev": true + }, + "babylon-walk": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babylon-walk/-/babylon-walk-1.0.2.tgz", + "integrity": "sha1-OxWl3btIKni0zpwByLoYFwLZ1s4=", + "dev": true, + "requires": { + "babel-runtime": "^6.11.6", + "babel-types": "^6.15.0", + "lodash.clone": "^4.5.0" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "dev": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, + "binary-extensions": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", + "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==", + "dev": true + }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, + "block-stream": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", + "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", + "dev": true, + "requires": { + "inherits": "~2.0.0" + } + }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==", + "dev": true + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "brfs": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/brfs/-/brfs-1.6.1.tgz", + "integrity": "sha512-OfZpABRQQf+Xsmju8XE9bDjs+uU4vLREGolP7bDgcpsI17QREyZ4Bl+2KLxxx1kCgA0fAIhKQBaBYh+PEcCqYQ==", + "dev": true, + "requires": { + "quote-stream": "^1.0.1", + "resolve": "^1.1.5", + "static-module": "^2.2.0", + "through2": "^2.0.0" + } + }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", + "dev": true + }, + "browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true + }, + "browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "requires": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "requires": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "browserify-rsa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.0.tgz", + "integrity": "sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==", + "dev": true, + "requires": { + "bn.js": "^5.0.0", + "randombytes": "^2.0.1" + } + }, + "browserify-sign": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz", + "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==", + "dev": true, + "requires": { + "bn.js": "^5.1.1", + "browserify-rsa": "^4.0.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.5.3", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.5", + "readable-stream": "^3.6.0", + "safe-buffer": "^5.2.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } + } + }, + "browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "requires": { + "pako": "~1.0.5" + }, + "dependencies": { + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true + } + } + }, + "browserslist": { + "version": "4.17.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.17.5.tgz", + "integrity": "sha512-I3ekeB92mmpctWBoLXe0d5wPS2cBuRvvW0JyyJHMrk9/HmP2ZjrTboNAZ8iuGqaEIlKguljbQY32OkOJIRrgoA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001271", + "electron-to-chromium": "^1.3.878", + "escalade": "^3.1.1", + "node-releases": "^2.0.1", + "picocolors": "^1.0.0" + }, + "dependencies": { + "electron-to-chromium": { + "version": "1.3.885", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.885.tgz", + "integrity": "sha512-JXKFJcVWrdHa09n4CNZYfYaK6EW5aAew7/wr3L1OnsD1L+JHL+RCtd7QgIsxUbFPeTwPlvnpqNNTOLkoefmtXg==", + "dev": true + } + } + }, + "buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "buffer-equal": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz", + "integrity": "sha1-kbx0sR6kBbyRa8aqkI+q+ltKrEs=", + "dev": true + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", + "dev": true + }, + "builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", + "dev": true + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + } + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "call-me-maybe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", + "integrity": "sha1-JtII6onje1y95gJQoV8DHBak1ms=", + "dev": true + }, + "caller-callsite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz", + "integrity": "sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=", + "dev": true, + "requires": { + "callsites": "^2.0.0" + } + }, + "caller-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz", + "integrity": "sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=", + "dev": true, + "requires": { + "caller-callsite": "^2.0.0" + } + }, + "callsites": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", + "integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=", + "dev": true + }, + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", + "dev": true + }, + "camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "dev": true, + "requires": { + "camelcase": "^2.0.0", + "map-obj": "^1.0.0" + } + }, + "caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "caniuse-lite": { + "version": "1.0.30001274", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001274.tgz", + "integrity": "sha512-+Nkvv0fHyhISkiMIjnyjmf5YJcQ1IQHZN6U9TLUMroWR38FNwpsC51Gb68yueafX1V6ifOisInSgP9WJFS13ew==", + "dev": true + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "chokidar": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", + "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "dependencies": { + "anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + } + } + }, + "cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "cli-spinners": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-1.3.1.tgz", + "integrity": "sha512-1QL4544moEsDVH9T/l6Cemov/37iv1RtoKf7NJ04A60+4MREXNfx/QvavbH6QoGdsD4N4Mwy49cmaINR/o2mdg==", + "dev": true + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=", + "dev": true + }, + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, + "coa": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", + "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", + "dev": true, + "requires": { + "@types/q": "^1.5.1", + "chalk": "^2.4.1", + "q": "^1.1.2" + } + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color/-/color-3.1.3.tgz", + "integrity": "sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ==", + "dev": true, + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.4" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "color-string": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.5.tgz", + "integrity": "sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==", + "dev": true, + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "command-exists": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", + "dev": true + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "console-browserify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", + "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", + "dev": true + }, + "consolidate": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.15.1.tgz", + "integrity": "sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw==", + "dev": true, + "requires": { + "bluebird": "^3.1.1" + } + }, + "constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=", + "dev": true + }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "core-js": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==", + "dev": true + }, + "core-js-compat": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.10.1.tgz", + "integrity": "sha512-ZHQTdTPkqvw2CeHiZC970NNJcnwzT6YIueDMASKt+p3WbZsLXOcoD392SkcWhkC0wBBHhlfhqGKKsNCQUozYtg==", + "dev": true, + "requires": { + "browserslist": "^4.16.3", + "semver": "7.0.0" + }, + "dependencies": { + "semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true + } + } + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "cosmiconfig": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz", + "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==", + "dev": true, + "requires": { + "import-fresh": "^2.0.0", + "is-directory": "^0.3.1", + "js-yaml": "^3.13.1", + "parse-json": "^4.0.0" + }, + "dependencies": { + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + } + } + }, + "create-ecdh": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "cropperjs": { + "version": "1.5.11", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.11.tgz", + "integrity": "sha512-SJUeBBhtNBnnn+UrLKluhFRIXLJn7XFPv8QN1j49X5t+BIMwkgvDev541f96bmu8Xe0TgCx3gON22KmY/VddaA==" + }, + "cross-spawn": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz", + "integrity": "sha1-ElYDfsufDF9549bvE14wdwGEuYI=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, + "crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "dev": true, + "requires": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + } + }, + "css": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz", + "integrity": "sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "source-map": "^0.6.1", + "source-map-resolve": "^0.5.2", + "urix": "^0.1.0" + } + }, + "css-color-names": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz", + "integrity": "sha1-gIrcLnnPhHOAabZGyyDsJ762KeA=", + "dev": true + }, + "css-declaration-sorter": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-4.0.1.tgz", + "integrity": "sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA==", + "dev": true, + "requires": { + "postcss": "^7.0.1", + "timsort": "^0.3.0" + } + }, + "css-modules-loader-core": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/css-modules-loader-core/-/css-modules-loader-core-1.1.0.tgz", + "integrity": "sha1-WQhmgpShvs0mGuCkziGwtVHyHRY=", + "dev": true, + "requires": { + "icss-replace-symbols": "1.1.0", + "postcss": "6.0.1", + "postcss-modules-extract-imports": "1.1.0", + "postcss-modules-local-by-default": "1.2.0", + "postcss-modules-scope": "1.1.0", + "postcss-modules-values": "1.3.0" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "dependencies": { + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "postcss": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.1.tgz", + "integrity": "sha1-AA29H47vIXqjaLmiEsX8QLKo8/I=", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "source-map": "^0.5.6", + "supports-color": "^3.2.3" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "css-parse": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/css-parse/-/css-parse-2.0.0.tgz", + "integrity": "sha1-pGjuZnwW2BzPBcWMONKpfHgNv9Q=", + "dev": true, + "requires": { + "css": "^2.0.0" + } + }, + "css-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", + "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^3.2.1", + "domutils": "^1.7.0", + "nth-check": "^1.0.2" + } + }, + "css-select-base-adapter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", + "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==", + "dev": true + }, + "css-selector-tokenizer": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.7.3.tgz", + "integrity": "sha512-jWQv3oCEL5kMErj4wRnK/OPoBi0D+P1FR2cDCKYPaMeD2eW3/mttav8HT4hT1CKopiJI/psEULjkClhvJo4Lvg==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "fastparse": "^1.1.2" + }, + "dependencies": { + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + } + } + }, + "css-tree": { + "version": "1.0.0-alpha.37", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", + "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", + "dev": true, + "requires": { + "mdn-data": "2.0.4", + "source-map": "^0.6.1" + } + }, + "css-what": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz", + "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==", + "dev": true + }, + "cssesc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-2.0.0.tgz", + "integrity": "sha512-MsCAG1z9lPdoO/IUMLSBWBSVxVtJ1395VGIQ+Fc2gNdkQ1hNDnQdw3YhA71WJCBW1vdwA0cAnk/DnW6bqoEUYg==", + "dev": true + }, + "cssnano": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-4.1.11.tgz", + "integrity": "sha512-6gZm2htn7xIPJOHY824ERgj8cNPgPxyCSnkXc4v7YvNW+TdVfzgngHcEhy/8D11kUWRUMbke+tC+AUcUsnMz2g==", + "dev": true, + "requires": { + "cosmiconfig": "^5.0.0", + "cssnano-preset-default": "^4.0.8", + "is-resolvable": "^1.0.0", + "postcss": "^7.0.0" + } + }, + "cssnano-preset-default": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-4.0.8.tgz", + "integrity": "sha512-LdAyHuq+VRyeVREFmuxUZR1TXjQm8QQU/ktoo/x7bz+SdOge1YKc5eMN6pRW7YWBmyq59CqYba1dJ5cUukEjLQ==", + "dev": true, + "requires": { + "css-declaration-sorter": "^4.0.1", + "cssnano-util-raw-cache": "^4.0.1", + "postcss": "^7.0.0", + "postcss-calc": "^7.0.1", + "postcss-colormin": "^4.0.3", + "postcss-convert-values": "^4.0.1", + "postcss-discard-comments": "^4.0.2", + "postcss-discard-duplicates": "^4.0.2", + "postcss-discard-empty": "^4.0.1", + "postcss-discard-overridden": "^4.0.1", + "postcss-merge-longhand": "^4.0.11", + "postcss-merge-rules": "^4.0.3", + "postcss-minify-font-values": "^4.0.2", + "postcss-minify-gradients": "^4.0.2", + "postcss-minify-params": "^4.0.2", + "postcss-minify-selectors": "^4.0.2", + "postcss-normalize-charset": "^4.0.1", + "postcss-normalize-display-values": "^4.0.2", + "postcss-normalize-positions": "^4.0.2", + "postcss-normalize-repeat-style": "^4.0.2", + "postcss-normalize-string": "^4.0.2", + "postcss-normalize-timing-functions": "^4.0.2", + "postcss-normalize-unicode": "^4.0.1", + "postcss-normalize-url": "^4.0.1", + "postcss-normalize-whitespace": "^4.0.2", + "postcss-ordered-values": "^4.1.2", + "postcss-reduce-initial": "^4.0.3", + "postcss-reduce-transforms": "^4.0.2", + "postcss-svgo": "^4.0.3", + "postcss-unique-selectors": "^4.0.1" + } + }, + "cssnano-util-get-arguments": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cssnano-util-get-arguments/-/cssnano-util-get-arguments-4.0.0.tgz", + "integrity": "sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8=", + "dev": true + }, + "cssnano-util-get-match": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cssnano-util-get-match/-/cssnano-util-get-match-4.0.0.tgz", + "integrity": "sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0=", + "dev": true + }, + "cssnano-util-raw-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssnano-util-raw-cache/-/cssnano-util-raw-cache-4.0.1.tgz", + "integrity": "sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "cssnano-util-same-parent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz", + "integrity": "sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q==", + "dev": true + }, + "csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, + "requires": { + "css-tree": "^1.1.2" + }, + "dependencies": { + "css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "requires": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + } + }, + "mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true + } + } + }, + "cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + }, + "cssstyle": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-1.4.0.tgz", + "integrity": "sha512-GBrLZYZ4X4x6/QEoBnIrqb8B/f5l4+8me2dkom/j1Gtbxy0kBv6OGzKuAsGM75bkGwGAFkt56Iwg28S3XTZgSA==", + "dev": true, + "requires": { + "cssom": "0.3.x" + } + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "dev": true, + "requires": { + "array-find-index": "^1.0.1" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "data-urls": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-1.1.0.tgz", + "integrity": "sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==", + "dev": true, + "requires": { + "abab": "^2.0.0", + "whatwg-mimetype": "^2.2.0", + "whatwg-url": "^7.0.0" + } + }, + "de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0=", + "dev": true + }, + "deasync": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/deasync/-/deasync-0.1.21.tgz", + "integrity": "sha512-kUmM8Y+PZpMpQ+B4AuOW9k2Pfx/mSupJtxOsLzmnHY2WqZUYRFccFn2RhzPAqt3Xb+sorK/badW2D4zNzqZz5w==", + "dev": true, + "requires": { + "bindings": "^1.5.0", + "node-addon-api": "^1.7.1" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "dev": true, + "requires": { + "clone": "^1.0.2" + }, + "dependencies": { + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true + } + } + }, + "define-properties": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", + "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "dev": true, + "requires": { + "object-keys": "^1.0.12" + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "dev": true + }, + "denque": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz", + "integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==" + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", + "dev": true + }, + "des.js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.1.tgz", + "integrity": "sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", + "dev": true + }, + "detect-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", + "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + }, + "dependencies": { + "domelementtype": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", + "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", + "dev": true + }, + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true + } + } + }, + "domain-browser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", + "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", + "dev": true + }, + "domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "dev": true + }, + "domexception": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", + "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", + "dev": true, + "requires": { + "webidl-conversions": "^4.0.2" + } + }, + "domhandler": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz", + "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1" + }, + "dependencies": { + "domelementtype": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", + "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", + "dev": true + } + } + }, + "domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "dev": true, + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "requires": { + "is-obj": "^2.0.0" + } + }, + "dotenv": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-5.0.1.tgz", + "integrity": "sha512-4As8uPrjfwb7VXC+WnLCbXK7y+Ueb2B3zgNCePYfhxS1PYeaO1YTeplffTEcbfLhvFNGLAz90VvJs9yomG7bow==", + "dev": true + }, + "dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true + }, + "duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", + "dev": true, + "requires": { + "readable-stream": "^2.0.2" + } + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "dev": true, + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "dev": true + }, + "electron-to-chromium": { + "version": "1.3.345", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.345.tgz", + "integrity": "sha512-f8nx53+Z9Y+SPWGg3YdHrbYYfIJAtbUjpFfW4X1RwTZ94iUG7geg9tV8HqzAXX7XTNgyWgAFvce4yce8ZKxKmg==", + "dev": true + }, + "elliptic": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", + "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "dev": true, + "requires": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "emojis-list": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz", + "integrity": "sha1-TapNnbAPmBmIDHn6RXrlsJof04k=", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "dev": true + }, + "entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "dev": true + }, + "envinfo": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz", + "integrity": "sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==", + "dev": true + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-abstract": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0.tgz", + "integrity": "sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "get-intrinsic": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.2", + "is-callable": "^1.2.3", + "is-negative-zero": "^2.0.1", + "is-regex": "^1.1.2", + "is-string": "^1.0.5", + "object-inspect": "^1.9.0", + "object-keys": "^1.1.1", + "object.assign": "^4.1.2", + "string.prototype.trimend": "^1.0.4", + "string.prototype.trimstart": "^1.0.4", + "unbox-primitive": "^1.0.0" + }, + "dependencies": { + "object-inspect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz", + "integrity": "sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==", + "dev": true + } + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "escodegen": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.9.1.tgz", + "integrity": "sha512-6hTjO1NAWkHnDk3OqQ4YrCuwwmGHL9S3nPlzBOUG/R44rda3wLNrfvQ5fkSGjyhHFKM7ALPKcKGrwvCLe0lC7Q==", + "dev": true, + "requires": { + "esprima": "^3.1.3", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + } + }, + "esprima": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", + "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=", + "dev": true + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "dev": true + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true + }, + "evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "requires": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true + }, + "falafel": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.4.tgz", + "integrity": "sha512-0HXjo8XASWRmsS0X1EkhwEMZaD3Qvp7FfURwjLKjG1ghfRm/MGZl2r4cWUTv41KdNghTw4OUMmVtdGQp3+H+uQ==", + "dev": true, + "requires": { + "acorn": "^7.1.1", + "foreach": "^2.0.5", + "isarray": "^2.0.1", + "object-keys": "^1.0.6" + }, + "dependencies": { + "isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + } + } + }, + "fast-deep-equal": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", + "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==", + "dev": true + }, + "fast-glob": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.7.tgz", + "integrity": "sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==", + "dev": true, + "requires": { + "@mrmlnc/readdir-enhanced": "^2.2.1", + "@nodelib/fs.stat": "^1.1.2", + "glob-parent": "^3.1.0", + "is-glob": "^4.0.0", + "merge2": "^1.2.3", + "micromatch": "^3.1.10" + } + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fastparse": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", + "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==", + "dev": true + }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true + }, + "filesize": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-3.6.1.tgz", + "integrity": "sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg==", + "dev": true + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "dev": true, + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "foreach": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", + "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=", + "dev": true + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", + "dev": true, + "optional": true, + "requires": { + "bindings": "^1.5.0", + "nan": "^2.12.1" + } + }, + "fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + } + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "dev": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "gaze": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", + "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", + "dev": true, + "requires": { + "globule": "^1.0.0" + } + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "get-port": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", + "integrity": "sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=", + "dev": true + }, + "get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=", + "dev": true + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + }, + "dependencies": { + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + } + } + }, + "glob-to-regexp": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz", + "integrity": "sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=", + "dev": true + }, + "globals": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", + "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==", + "dev": true + }, + "globule": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.2.tgz", + "integrity": "sha512-7IDTQTIu2xzXkT+6mlluidnWo+BypnbSoEVVQCGfzqnl5Ik8d3e1d4wycb8Rj9tWW+Z39uPWsdlquqiqPCd/pA==", + "dev": true, + "requires": { + "glob": "~7.1.1", + "lodash": "~4.17.10", + "minimatch": "~3.0.2" + } + }, + "graceful-fs": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", + "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", + "dev": true + }, + "grapheme-breaker": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/grapheme-breaker/-/grapheme-breaker-0.3.2.tgz", + "integrity": "sha1-W55reMODJFLSuiuxy4MPlidkEKw=", + "dev": true, + "requires": { + "brfs": "^1.2.0", + "unicode-trie": "^0.3.1" + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "dev": true + }, + "har-validator": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", + "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "dev": true, + "requires": { + "ajv": "^6.5.5", + "har-schema": "^2.0.0" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-bigints": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", + "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "dev": true + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", + "dev": true + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "hash-base": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", + "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", + "dev": true, + "requires": { + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "safe-buffer": "^5.2.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } + } + }, + "hash-sum": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-1.0.2.tgz", + "integrity": "sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ=", + "dev": true + }, + "hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "hex-color-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hex-color-regex/-/hex-color-regex-1.1.0.tgz", + "integrity": "sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==", + "dev": true + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "dev": true, + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "home-or-tmp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz", + "integrity": "sha1-42w/LSyufXRqhX440Y1fMqeILbg=", + "dev": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.1" + } + }, + "hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "hsl-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsl-regex/-/hsl-regex-1.0.0.tgz", + "integrity": "sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4=", + "dev": true + }, + "hsla-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsla-regex/-/hsla-regex-1.0.0.tgz", + "integrity": "sha1-wc56MWjIxmFAM6S194d/OyJfnDg=", + "dev": true + }, + "html-encoding-sniffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz", + "integrity": "sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==", + "dev": true, + "requires": { + "whatwg-encoding": "^1.0.1" + } + }, + "html-tags": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-1.2.0.tgz", + "integrity": "sha1-x43mW1Zjqll5id0rerSSANfk25g=", + "dev": true + }, + "htmlnano": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-0.2.9.tgz", + "integrity": "sha512-jWTtP3dCd7R8x/tt9DK3pvpcQd7HDMcRPUqPxr/i9989q2k5RHIhmlRDFeyQ/LSd8IKrteG8Ce5g0Ig4eGIipg==", + "dev": true, + "requires": { + "cssnano": "^4.1.11", + "posthtml": "^0.15.1", + "purgecss": "^2.3.0", + "relateurl": "^0.2.7", + "srcset": "^3.0.0", + "svgo": "^1.3.2", + "terser": "^5.6.1", + "timsort": "^0.3.0", + "uncss": "^0.17.3" + }, + "dependencies": { + "posthtml": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.15.1.tgz", + "integrity": "sha512-QSnUnvnnRv+wt7T9igqNG7GPcc+ZsbX93X+9aPldzgiuQfqIXTbnD47FY8pAtq4gjB9QZrDadDuG8jusmOPpYA==", + "dev": true, + "requires": { + "posthtml-parser": "^0.6.0", + "posthtml-render": "^1.2.3" + } + }, + "posthtml-parser": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.6.0.tgz", + "integrity": "sha512-5ffwKQNgtVHdhZniWxu+1ryvaZv5l25HPLUV6W5xy5nYVWMXtvjtwRnbSpfbKFvbyl7XI+d4AqkjmonkREqnXA==", + "dev": true, + "requires": { + "htmlparser2": "^5.0.1" + } + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + }, + "source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "terser": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.6.1.tgz", + "integrity": "sha512-yv9YLFQQ+3ZqgWCUk+pvNJwgUTdlIxUk1WTN+RnaFJe2L7ipG2csPT0ra2XRm7Cs8cxN7QXmK1rFzEwYEQkzXw==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "source-map": "~0.7.2", + "source-map-support": "~0.5.19" + } + } + } + }, + "htmlparser2": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz", + "integrity": "sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^3.3.0", + "domutils": "^2.4.2", + "entities": "^2.0.0" + }, + "dependencies": { + "dom-serializer": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.1.tgz", + "integrity": "sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "entities": "^2.0.0" + }, + "dependencies": { + "domhandler": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.1.0.tgz", + "integrity": "sha512-/6/kmsGlMY4Tup/nGVutdrK9yQi4YjWVcVeoQmixpzjOUK1U7pQkvAPHBJeUxOgxF0J8f8lwCJSlCfD0V4CMGQ==", + "dev": true, + "requires": { + "domelementtype": "^2.2.0" + } + } + } + }, + "domelementtype": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", + "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", + "dev": true + }, + "domutils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.5.2.tgz", + "integrity": "sha512-MHTthCb1zj8f1GVfRpeZUbohQf/HdBos0oX5gZcQFepOZPLLRyj6Wn7XS7EMnY7CVpwv8863u2vyE83Hfu28HQ==", + "dev": true, + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.1.0" + }, + "dependencies": { + "domhandler": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.1.0.tgz", + "integrity": "sha512-/6/kmsGlMY4Tup/nGVutdrK9yQi4YjWVcVeoQmixpzjOUK1U7pQkvAPHBJeUxOgxF0J8f8lwCJSlCfD0V4CMGQ==", + "dev": true, + "requires": { + "domelementtype": "^2.2.0" + } + } + } + }, + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true + } + } + }, + "http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", + "dev": true + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "icss-replace-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", + "integrity": "sha1-Bupvg2ead0njhs/h/oEq5dsiPe0=", + "dev": true + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true + }, + "import-fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", + "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=", + "dev": true, + "requires": { + "caller-path": "^2.0.0", + "resolve-from": "^3.0.0" + } + }, + "in-publish": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.1.tgz", + "integrity": "sha512-oDM0kUSNFC31ShNxHKUyfZKy8ZeXZBWMjMdZHKLOk13uvT27VTL/QzRGfRUcevJhpkZAvlhPYuXkF7eNWrtyxQ==", + "dev": true + }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "indexes-of": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", + "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "requires": { + "loose-envify": "^1.0.0" + } + }, + "is-absolute-url": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.1.0.tgz", + "integrity": "sha1-UFMN+4T8yap9vnhS6Do3uTufKqY=", + "dev": true + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-bigint": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.1.tgz", + "integrity": "sha512-J0ELF4yHFxHy0cmSxZuheDOz2luOdVvqjwmEcj8H/L1JHeuEDSDbeRP+Dk9kFVk5RTFzbucJ2Kb9F7ixY2QaCg==", + "dev": true + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-boolean-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.0.tgz", + "integrity": "sha512-a7Uprx8UtD+HWdyYwnD1+ExtTgqQtD2k/1yJgtXP6wnMm8byhkoTZRl+95LLThpzNZJ5aEvi46cdH+ayMFRwmA==", + "dev": true, + "requires": { + "call-bind": "^1.0.0" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-callable": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", + "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==", + "dev": true + }, + "is-color-stop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-color-stop/-/is-color-stop-1.1.0.tgz", + "integrity": "sha1-z/9HGu5N1cnhWFmPvhKWe1za00U=", + "dev": true, + "requires": { + "css-color-names": "^0.0.4", + "hex-color-regex": "^1.1.0", + "hsl-regex": "^1.0.0", + "hsla-regex": "^1.0.0", + "rgb-regex": "^1.0.1", + "rgba-regex": "^1.0.0" + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", + "dev": true + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "is-directory": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", + "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=", + "dev": true + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-glob": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz", + "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-html": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-html/-/is-html-1.1.0.tgz", + "integrity": "sha1-4E8cGNOUhRETlvmgJz6rUa8hhGQ=", + "dev": true, + "requires": { + "html-tags": "^1.0.0" + } + }, + "is-negative-zero": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz", + "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==", + "dev": true + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-number-object": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.4.tgz", + "integrity": "sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw==", + "dev": true + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz", + "integrity": "sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "has-symbols": "^1.0.1" + } + }, + "is-resolvable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz", + "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", + "dev": true + }, + "is-string": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz", + "integrity": "sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==", + "dev": true + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "dev": true + }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "js-base64": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.3.tgz", + "integrity": "sha512-fiUvdfCaAXoQTHdKMgTvg6IkecXDcVz6V5rlftUTclF9IKBjMizvSdQaCl/z/6TApDeby5NL+axYou3i0mu1Pg==", + "dev": true + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "dev": true + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "dependencies": { + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + } + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true + }, + "jsdom": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-14.1.0.tgz", + "integrity": "sha512-O901mfJSuTdwU2w3Sn+74T+RnDVP+FuV5fH8tcPWyqrseRAb0s5xOtPgCFiPOtLcyK7CLIJwPyD83ZqQWvA5ng==", + "dev": true, + "requires": { + "abab": "^2.0.0", + "acorn": "^6.0.4", + "acorn-globals": "^4.3.0", + "array-equal": "^1.0.0", + "cssom": "^0.3.4", + "cssstyle": "^1.1.1", + "data-urls": "^1.1.0", + "domexception": "^1.0.1", + "escodegen": "^1.11.0", + "html-encoding-sniffer": "^1.0.2", + "nwsapi": "^2.1.3", + "parse5": "5.1.0", + "pn": "^1.1.0", + "request": "^2.88.0", + "request-promise-native": "^1.0.5", + "saxes": "^3.1.9", + "symbol-tree": "^3.2.2", + "tough-cookie": "^2.5.0", + "w3c-hr-time": "^1.0.1", + "w3c-xmlserializer": "^1.1.2", + "webidl-conversions": "^4.0.2", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^7.0.0", + "ws": "^6.1.2", + "xml-name-validator": "^3.0.0" + }, + "dependencies": { + "acorn": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz", + "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", + "dev": true + }, + "escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "dev": true, + "requires": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, + "ws": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.2.tgz", + "integrity": "sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==", + "dev": true, + "requires": { + "async-limiter": "~1.0.0" + } + } + } + }, + "jsesc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "json5": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", + "dev": true + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "dev": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "load-json-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" + } + }, + "loader-utils": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.2.3.tgz", + "integrity": "sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^2.0.0", + "json5": "^1.0.1" + }, + "dependencies": { + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + } + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "dependencies": { + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + } + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "lodash.clone": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz", + "integrity": "sha1-GVhwRQ9aExkkeN9Lw9I9LeoZB7Y=", + "dev": true + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", + "dev": true + }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", + "dev": true + }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", + "dev": true + }, + "lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=", + "dev": true + }, + "log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "dev": true, + "requires": { + "chalk": "^2.0.1" + } + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "dev": true, + "requires": { + "currently-unhandled": "^0.4.1", + "signal-exit": "^3.0.0" + } + }, + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "magic-string": { + "version": "0.22.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.22.5.tgz", + "integrity": "sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==", + "dev": true, + "requires": { + "vlq": "^0.2.2" + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "mdn-data": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", + "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==", + "dev": true + }, + "meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "dev": true, + "requires": { + "camelcase-keys": "^2.0.0", + "decamelize": "^1.1.2", + "loud-rejection": "^1.0.0", + "map-obj": "^1.0.1", + "minimist": "^1.1.3", + "normalize-package-data": "^2.3.4", + "object-assign": "^4.0.1", + "read-pkg-up": "^1.0.1", + "redent": "^1.0.0", + "trim-newlines": "^1.0.0" + } + }, + "merge-source-map": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz", + "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + } + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "mime-db": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", + "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==", + "dev": true + }, + "mime-types": { + "version": "2.1.26", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz", + "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==", + "dev": true, + "requires": { + "mime-db": "1.43.0" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "dev": true + }, + "mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "nan": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", + "dev": true + }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "neo-async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz", + "integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true + }, + "node-forge": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz", + "integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==", + "dev": true + }, + "node-gyp": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz", + "integrity": "sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA==", + "dev": true, + "requires": { + "fstream": "^1.0.0", + "glob": "^7.0.3", + "graceful-fs": "^4.1.2", + "mkdirp": "^0.5.0", + "nopt": "2 || 3", + "npmlog": "0 || 1 || 2 || 3 || 4", + "osenv": "0", + "request": "^2.87.0", + "rimraf": "2", + "semver": "~5.3.0", + "tar": "^2.0.0", + "which": "1" + }, + "dependencies": { + "semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", + "dev": true + } + } + }, + "node-libs-browser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", + "integrity": "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==", + "dev": true, + "requires": { + "assert": "^1.1.1", + "browserify-zlib": "^0.2.0", + "buffer": "^4.3.0", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "crypto-browserify": "^3.11.0", + "domain-browser": "^1.1.1", + "events": "^3.0.0", + "https-browserify": "^1.0.0", + "os-browserify": "^0.3.0", + "path-browserify": "0.0.1", + "process": "^0.11.10", + "punycode": "^1.2.4", + "querystring-es3": "^0.2.0", + "readable-stream": "^2.3.3", + "stream-browserify": "^2.0.1", + "stream-http": "^2.7.2", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.0", + "url": "^0.11.0", + "util": "^0.11.0", + "vm-browserify": "^1.0.1" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + } + } + }, + "node-releases": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz", + "integrity": "sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA==", + "dev": true + }, + "node-sass": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.14.1.tgz", + "integrity": "sha512-sjCuOlvGyCJS40R8BscF5vhVlQjNN069NtQ1gSxyK1u9iqvn6tf7O1R4GNowVZfiZUCRt5MmMs1xd+4V/7Yr0g==", + "dev": true, + "requires": { + "async-foreach": "^0.1.3", + "chalk": "^1.1.1", + "cross-spawn": "^3.0.0", + "gaze": "^1.0.0", + "get-stdin": "^4.0.1", + "glob": "^7.0.3", + "in-publish": "^2.0.0", + "lodash": "^4.17.15", + "meow": "^3.7.0", + "mkdirp": "^0.5.1", + "nan": "^2.13.2", + "node-gyp": "^3.8.0", + "npmlog": "^4.0.0", + "request": "^2.88.0", + "sass-graph": "2.2.5", + "stdout-stream": "^1.4.0", + "true-case-path": "^1.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "normalize-url": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz", + "integrity": "sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==", + "dev": true + }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "dev": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "dev": true, + "requires": { + "boolbase": "~1.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "nwsapi": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", + "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", + "dev": true + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-inspect": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.4.1.tgz", + "integrity": "sha512-wqdhLpfCUbEsoEwl3FXwGyv8ief1k/1aUdIPCqVnupM6e8l63BEJdiF/0swtn04/8p05tG/T0FrpTlfwvljOdw==", + "dev": true + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.assign": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", + "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3", + "has-symbols": "^1.0.1", + "object-keys": "^1.1.1" + } + }, + "object.getownpropertydescriptors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.2.tgz", + "integrity": "sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.2" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "object.values": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.3.tgz", + "integrity": "sha512-nkF6PfDB9alkOUxpf1HNm/QlkeW3SReqL5WXeBLpEJJnlPSvRaDQpW3gQTksTN3fgJX4hL42RzKyOin6ff3tyw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.2", + "has": "^1.0.3" + } + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "opn": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz", + "integrity": "sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA==", + "dev": true, + "requires": { + "is-wsl": "^1.1.0" + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + } + }, + "ora": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-2.1.0.tgz", + "integrity": "sha512-hNNlAd3gfv/iPmsNxYoAPLvxg7HuPozww7fFonMZvL84tP6Ox5igfk5j/+a9rtJJwqMgKK+JgWsAQik5o0HTLA==", + "dev": true, + "requires": { + "chalk": "^2.3.1", + "cli-cursor": "^2.1.0", + "cli-spinners": "^1.1.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^4.0.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=", + "dev": true + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "dev": true + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "dev": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "p-limit": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.2.tgz", + "integrity": "sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU=", + "dev": true + }, + "parcel-bundler": { + "version": "1.12.5", + "resolved": "https://registry.npmjs.org/parcel-bundler/-/parcel-bundler-1.12.5.tgz", + "integrity": "sha512-hpku8mW67U6PXQIenW6NBbphBOMb8XzW6B9r093DUhYj5GN2FUB/CXCiz5hKoPYUsusZ35BpProH8AUF9bh5IQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/core": "^7.4.4", + "@babel/generator": "^7.4.4", + "@babel/parser": "^7.4.4", + "@babel/plugin-transform-flow-strip-types": "^7.4.4", + "@babel/plugin-transform-modules-commonjs": "^7.4.4", + "@babel/plugin-transform-react-jsx": "^7.0.0", + "@babel/preset-env": "^7.4.4", + "@babel/runtime": "^7.4.4", + "@babel/template": "^7.4.4", + "@babel/traverse": "^7.4.4", + "@babel/types": "^7.4.4", + "@iarna/toml": "^2.2.0", + "@parcel/fs": "^1.11.0", + "@parcel/logger": "^1.11.1", + "@parcel/utils": "^1.11.0", + "@parcel/watcher": "^1.12.1", + "@parcel/workers": "^1.11.0", + "ansi-to-html": "^0.6.4", + "babylon-walk": "^1.0.2", + "browserslist": "^4.1.0", + "chalk": "^2.1.0", + "clone": "^2.1.1", + "command-exists": "^1.2.6", + "commander": "^2.11.0", + "core-js": "^2.6.5", + "cross-spawn": "^6.0.4", + "css-modules-loader-core": "^1.1.0", + "cssnano": "^4.0.0", + "deasync": "^0.1.14", + "dotenv": "^5.0.0", + "dotenv-expand": "^5.1.0", + "envinfo": "^7.3.1", + "fast-glob": "^2.2.2", + "filesize": "^3.6.0", + "get-port": "^3.2.0", + "htmlnano": "^0.2.2", + "is-glob": "^4.0.0", + "is-url": "^1.2.2", + "js-yaml": "^3.10.0", + "json5": "^1.0.1", + "micromatch": "^3.0.4", + "mkdirp": "^0.5.1", + "node-forge": "^0.10.0", + "node-libs-browser": "^2.0.0", + "opn": "^5.1.0", + "postcss": "^7.0.11", + "postcss-value-parser": "^3.3.1", + "posthtml": "^0.11.2", + "posthtml-parser": "^0.4.0", + "posthtml-render": "^1.1.3", + "resolve": "^1.4.0", + "semver": "^5.4.1", + "serialize-to-js": "^3.0.0", + "serve-static": "^1.12.4", + "source-map": "0.6.1", + "terser": "^3.7.3", + "v8-compile-cache": "^2.0.0", + "ws": "^5.1.1" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "json5": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + } + } + }, + "parse-asn1": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz", + "integrity": "sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==", + "dev": true, + "requires": { + "asn1.js": "^5.2.0", + "browserify-aes": "^1.0.0", + "evp_bytestokey": "^1.0.0", + "pbkdf2": "^3.0.3", + "safe-buffer": "^5.1.1" + } + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "parse5": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz", + "integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==", + "dev": true + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", + "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==", + "dev": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "dev": true, + "requires": { + "pinkie-promise": "^2.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "pbkdf2": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz", + "integrity": "sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==", + "dev": true, + "requires": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "dev": true + }, + "physical-cpu-count": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/physical-cpu-count/-/physical-cpu-count-2.0.0.tgz", + "integrity": "sha1-GN4vl+S/epVRrXURlCtUlverpmA=", + "dev": true + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "dev": true + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", + "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==", + "dev": true + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "postcss": { + "version": "7.0.36", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.36.tgz", + "integrity": "sha512-BebJSIUMwJHRH0HAQoxN4u1CN86glsrwsW0q7T+/m44eXOUAxSNdHRkNZPYz5vVUbg17hFgOQDE7fZk7li3pZw==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + } + }, + "postcss-calc": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-7.0.5.tgz", + "integrity": "sha512-1tKHutbGtLtEZF6PT4JSihCHfIVldU72mZ8SdZHIYriIZ9fh9k9aWSppaT8rHsyI3dX+KSR+W+Ix9BMY3AODrg==", + "dev": true, + "requires": { + "postcss": "^7.0.27", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.0.2" + }, + "dependencies": { + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "postcss": { + "version": "7.0.36", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.36.tgz", + "integrity": "sha512-BebJSIUMwJHRH0HAQoxN4u1CN86glsrwsW0q7T+/m44eXOUAxSNdHRkNZPYz5vVUbg17hFgOQDE7fZk7li3pZw==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + } + }, + "postcss-selector-parser": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz", + "integrity": "sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1", + "util-deprecate": "^1.0.2" + } + }, + "postcss-value-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz", + "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==", + "dev": true + } + } + }, + "postcss-colormin": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-4.0.3.tgz", + "integrity": "sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "color": "^3.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-convert-values": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-4.0.1.tgz", + "integrity": "sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ==", + "dev": true, + "requires": { + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-discard-comments": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-4.0.2.tgz", + "integrity": "sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-discard-duplicates": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-4.0.2.tgz", + "integrity": "sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-discard-empty": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-4.0.1.tgz", + "integrity": "sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-discard-overridden": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-4.0.1.tgz", + "integrity": "sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-merge-longhand": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-4.0.11.tgz", + "integrity": "sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw==", + "dev": true, + "requires": { + "css-color-names": "0.0.4", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0", + "stylehacks": "^4.0.0" + } + }, + "postcss-merge-rules": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-4.0.3.tgz", + "integrity": "sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-api": "^3.0.0", + "cssnano-util-same-parent": "^4.0.0", + "postcss": "^7.0.0", + "postcss-selector-parser": "^3.0.0", + "vendors": "^1.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz", + "integrity": "sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA==", + "dev": true, + "requires": { + "dot-prop": "^5.2.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "postcss-minify-font-values": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-4.0.2.tgz", + "integrity": "sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg==", + "dev": true, + "requires": { + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-minify-gradients": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-4.0.2.tgz", + "integrity": "sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "is-color-stop": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-minify-params": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-4.0.2.tgz", + "integrity": "sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "browserslist": "^4.0.0", + "cssnano-util-get-arguments": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0", + "uniqs": "^2.0.0" + } + }, + "postcss-minify-selectors": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-4.0.2.tgz", + "integrity": "sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-selector-parser": "^3.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz", + "integrity": "sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA==", + "dev": true, + "requires": { + "dot-prop": "^5.2.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "postcss-modules-extract-imports": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.1.0.tgz", + "integrity": "sha1-thTJcgvmgW6u41+zpfqh26agXds=", + "dev": true, + "requires": { + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "postcss-modules-local-by-default": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz", + "integrity": "sha1-99gMOYxaOT+nlkRmvRlQCn1hwGk=", + "dev": true, + "requires": { + "css-selector-tokenizer": "^0.7.0", + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "postcss-modules-scope": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz", + "integrity": "sha1-1upkmUx5+XtipytCb75gVqGUu5A=", + "dev": true, + "requires": { + "css-selector-tokenizer": "^0.7.0", + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "postcss-modules-values": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz", + "integrity": "sha1-7P+p1+GSUYOJ9CrQ6D9yrsRW6iA=", + "dev": true, + "requires": { + "icss-replace-symbols": "^1.1.0", + "postcss": "^6.0.1" + }, + "dependencies": { + "postcss": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", + "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "postcss-normalize-charset": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-4.0.1.tgz", + "integrity": "sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g==", + "dev": true, + "requires": { + "postcss": "^7.0.0" + } + }, + "postcss-normalize-display-values": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.2.tgz", + "integrity": "sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ==", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-positions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-4.0.2.tgz", + "integrity": "sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-repeat-style": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-4.0.2.tgz", + "integrity": "sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "cssnano-util-get-match": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-string": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-4.0.2.tgz", + "integrity": "sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA==", + "dev": true, + "requires": { + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-timing-functions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-4.0.2.tgz", + "integrity": "sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A==", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-unicode": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-4.0.1.tgz", + "integrity": "sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-url": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-4.0.1.tgz", + "integrity": "sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA==", + "dev": true, + "requires": { + "is-absolute-url": "^2.0.0", + "normalize-url": "^3.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-normalize-whitespace": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-4.0.2.tgz", + "integrity": "sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA==", + "dev": true, + "requires": { + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-ordered-values": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-4.1.2.tgz", + "integrity": "sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw==", + "dev": true, + "requires": { + "cssnano-util-get-arguments": "^4.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-reduce-initial": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz", + "integrity": "sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-api": "^3.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0" + } + }, + "postcss-reduce-transforms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.2.tgz", + "integrity": "sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg==", + "dev": true, + "requires": { + "cssnano-util-get-match": "^4.0.0", + "has": "^1.0.0", + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0" + } + }, + "postcss-selector-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-5.0.0.tgz", + "integrity": "sha512-w+zLE5Jhg6Liz8+rQOWEAwtwkyqpfnmsinXjXg6cY7YIONZZtgvE0v2O0uhQBs0peNomOJwWRKt6JBfTdTd3OQ==", + "dev": true, + "requires": { + "cssesc": "^2.0.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + }, + "postcss-svgo": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-4.0.3.tgz", + "integrity": "sha512-NoRbrcMWTtUghzuKSoIm6XV+sJdvZ7GZSc3wdBN0W19FTtp2ko8NqLsgoh/m9CzNhU3KLPvQmjIwtaNFkaFTvw==", + "dev": true, + "requires": { + "postcss": "^7.0.0", + "postcss-value-parser": "^3.0.0", + "svgo": "^1.0.0" + } + }, + "postcss-unique-selectors": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-4.0.1.tgz", + "integrity": "sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg==", + "dev": true, + "requires": { + "alphanum-sort": "^1.0.0", + "postcss": "^7.0.0", + "uniqs": "^2.0.0" + } + }, + "postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true + }, + "posthtml": { + "version": "0.11.6", + "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.11.6.tgz", + "integrity": "sha512-C2hrAPzmRdpuL3iH0TDdQ6XCc9M7Dcc3zEW5BLerY65G4tWWszwv6nG/ksi6ul5i2mx22ubdljgktXCtNkydkw==", + "dev": true, + "requires": { + "posthtml-parser": "^0.4.1", + "posthtml-render": "^1.1.5" + } + }, + "posthtml-parser": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.4.2.tgz", + "integrity": "sha512-BUIorsYJTvS9UhXxPTzupIztOMVNPa/HtAm9KHni9z6qEfiJ1bpOBL5DfUOL9XAc3XkLIEzBzpph+Zbm4AdRAg==", + "dev": true, + "requires": { + "htmlparser2": "^3.9.2" + }, + "dependencies": { + "domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "dev": true, + "requires": { + "domelementtype": "1" + } + }, + "htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "dev": true, + "requires": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "posthtml-render": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/posthtml-render/-/posthtml-render-1.3.1.tgz", + "integrity": "sha512-eSToKjNLu0FiF76SSGMHjOFXYzAc/CJqi677Sq6hYvcvFCBtD6de/W5l+0IYPf7ypscqAfjCttxvTdMJt5Gj8Q==", + "dev": true + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "prettier": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.16.3.tgz", + "integrity": "sha512-kn/GU6SMRYPxUakNXhpP0EedT/KmaPzr0H5lIsDogrykbaxOpOfAFfk5XA7DZrJyMAv1wlMV3CPcZruGXVVUZw==", + "dev": true + }, + "private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", + "dev": true + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "dev": true + }, + "psl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.7.0.tgz", + "integrity": "sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ==", + "dev": true + }, + "public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + }, + "dependencies": { + "bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", + "dev": true + } + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "purgecss": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-2.3.0.tgz", + "integrity": "sha512-BE5CROfVGsx2XIhxGuZAT7rTH9lLeQx/6M0P7DTXQH4IUc3BBzs9JUzt4yzGf3JrH9enkeq6YJBe9CTtkm1WmQ==", + "dev": true, + "requires": { + "commander": "^5.0.0", + "glob": "^7.0.0", + "postcss": "7.0.32", + "postcss-selector-parser": "^6.0.2" + }, + "dependencies": { + "commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "postcss": { + "version": "7.0.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz", + "integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + } + }, + "postcss-selector-parser": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz", + "integrity": "sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1", + "util-deprecate": "^1.0.2" + } + } + } + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", + "dev": true + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", + "dev": true + }, + "querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", + "dev": true + }, + "quote-stream": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/quote-stream/-/quote-stream-1.0.2.tgz", + "integrity": "sha1-hJY/jJwmuULhU/7rU6rnRlK34LI=", + "dev": true, + "requires": { + "buffer-equal": "0.0.1", + "minimist": "^1.1.3", + "through2": "^2.0.0" + } + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "requires": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "dev": true, + "requires": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "dev": true, + "requires": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + } + }, + "redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=", + "dev": true, + "requires": { + "indent-string": "^2.1.0", + "strip-indent": "^1.0.1" + } + }, + "redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "requires": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + }, + "dependencies": { + "redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "^1.0.0" + } + } + } + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + }, + "redis-url": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/redis-url/-/redis-url-0.2.0.tgz", + "integrity": "sha1-G3otrMw+qCZLH7ZWwNkB2cqcVHA=", + "requires": { + "redis": ">= 0.0.1" + } + }, + "regenerate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", + "integrity": "sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz", + "integrity": "sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==", + "dev": true, + "requires": { + "regenerate": "^1.4.0" + } + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true + }, + "regenerator-transform": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.10.1.tgz", + "integrity": "sha512-PJepbvDbuK1xgIgnau7Y90cwaAmO/LCLMI2mPvaXq2heGMR3aWW5/BQvYrhJ8jgmQjXewXvBjzfqKcVOmhjZ6Q==", + "dev": true, + "requires": { + "babel-runtime": "^6.18.0", + "babel-types": "^6.19.0", + "private": "^0.1.6" + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexpu-core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz", + "integrity": "sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA=", + "dev": true, + "requires": { + "regenerate": "^1.2.1", + "regjsgen": "^0.2.0", + "regjsparser": "^0.1.4" + } + }, + "regjsgen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz", + "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=", + "dev": true + }, + "regjsparser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz", + "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, + "relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=", + "dev": true + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true + }, + "repeat-element": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", + "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "dev": true, + "requires": { + "is-finite": "^1.0.0" + } + }, + "request": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", + "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.0", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.4.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "dev": true + } + } + }, + "request-promise-core": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", + "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", + "dev": true, + "requires": { + "lodash": "^4.17.19" + } + }, + "request-promise-native": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.9.tgz", + "integrity": "sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==", + "dev": true, + "requires": { + "request-promise-core": "1.1.4", + "stealthy-require": "^1.1.1", + "tough-cookie": "^2.3.3" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "resolve": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz", + "integrity": "sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "resolve-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", + "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=", + "dev": true + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "rgb-regex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgb-regex/-/rgb-regex-1.0.1.tgz", + "integrity": "sha1-wODWiC3w4jviVKR16O3UGRX+rrE=", + "dev": true + }, + "rgba-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rgba-regex/-/rgba-regex-1.0.0.tgz", + "integrity": "sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=", + "dev": true + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "sass": { + "version": "1.43.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.43.4.tgz", + "integrity": "sha512-/ptG7KE9lxpGSYiXn7Ar+lKOv37xfWsZRtFYal2QHNigyVQDx685VFT/h7ejVr+R8w7H4tmUgtulsKl5YpveOg==", + "dev": true, + "requires": { + "chokidar": ">=3.0.0 <4.0.0" + } + }, + "sass-graph": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.5.tgz", + "integrity": "sha512-VFWDAHOe6mRuT4mZRd4eKE+d8Uedrk6Xnh7Sh9b4NGufQLQjOrvf/MQoOdx+0s92L89FeyUUNfU597j/3uNpag==", + "dev": true, + "requires": { + "glob": "^7.0.0", + "lodash": "^4.0.0", + "scss-tokenizer": "^0.2.3", + "yargs": "^13.3.2" + } + }, + "sass-loader": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-7.3.1.tgz", + "integrity": "sha512-tuU7+zm0pTCynKYHpdqaPpe+MMTQ76I9TPZ7i4/5dZsigE350shQWe5EZNl5dBidM49TPET75tNqRbcsUZWeNA==", + "dev": true, + "requires": { + "clone-deep": "^4.0.1", + "loader-utils": "^1.0.1", + "neo-async": "^2.5.0", + "pify": "^4.0.1", + "semver": "^6.3.0" + }, + "dependencies": { + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + }, + "saxes": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-3.1.11.tgz", + "integrity": "sha512-Ydydq3zC+WYDJK1+gRxRapLIED9PWeSuuS41wqyoRmzvhhh9nc+QQrVMKJYzJFULazeGhzSV0QleN2wD3boh2g==", + "dev": true, + "requires": { + "xmlchars": "^2.1.1" + } + }, + "scss-tokenizer": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", + "integrity": "sha1-jrBtualyMzOCTT9VMGQRSYR85dE=", + "dev": true, + "requires": { + "js-base64": "^2.1.8", + "source-map": "^0.4.2" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "dev": true, + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "serialize-to-js": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/serialize-to-js/-/serialize-to-js-3.1.1.tgz", + "integrity": "sha512-F+NGU0UHMBO4Q965tjw7rvieNVjlH6Lqi2emq/Lc9LUURYJbiCzmpi4Cy1OOjjVPtxu0c+NE85LU6968Wko5ZA==", + "dev": true + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "dev": true, + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", + "dev": true + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "dev": true + }, + "sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "shallow-copy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", + "integrity": "sha1-QV9CcC1z2BAzApLMXuhurhoRoXA=", + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "dev": true, + "requires": { + "is-arrayish": "^0.3.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "dev": true + } + } + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=", + "dev": true + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "dev": true, + "requires": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, + "requires": { + "source-map": "^0.5.6" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "source-map-url": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz", + "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==", + "dev": true + }, + "spdx-correct": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", + "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", + "dev": true + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "src": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/src/-/src-1.1.2.tgz", + "integrity": "sha1-eKvdHAjKyibMbPRb1YC1bZOs+38=", + "requires": { + "redis-url": "~0.2.0", + "underscore": "~1.6.0", + "uuid": "~1.4.1" + } + }, + "srcset": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/srcset/-/srcset-3.0.0.tgz", + "integrity": "sha512-D59vF08Qzu/C4GAOXVgMTLfgryt5fyWo93FZyhEWANo0PokFz/iWdDe13mX3O5TRf6l8vMTqckAfR4zPiaH0yQ==", + "dev": true + }, + "sshpk": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "dev": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "dev": true + }, + "static-eval": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.0.tgz", + "integrity": "sha512-agtxZ/kWSsCkI5E4QifRwsaPs0P0JmZV6dkLz6ILYfFYQGn+5plctanRN+IC8dJRiFkyXHrwEE3W9Wmx67uDbw==", + "dev": true, + "requires": { + "escodegen": "^1.11.1" + }, + "dependencies": { + "escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "dev": true, + "requires": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + } + } + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "static-module": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/static-module/-/static-module-2.2.5.tgz", + "integrity": "sha512-D8vv82E/Kpmz3TXHKG8PPsCPg+RAX6cbCOyvjM6x04qZtQ47EtJFVwRsdov3n5d6/6ynrOY9XB4JkaZwB2xoRQ==", + "dev": true, + "requires": { + "concat-stream": "~1.6.0", + "convert-source-map": "^1.5.1", + "duplexer2": "~0.1.4", + "escodegen": "~1.9.0", + "falafel": "^2.1.0", + "has": "^1.0.1", + "magic-string": "^0.22.4", + "merge-source-map": "1.0.4", + "object-inspect": "~1.4.0", + "quote-stream": "~1.0.2", + "readable-stream": "~2.3.3", + "shallow-copy": "~0.0.1", + "static-eval": "^2.0.0", + "through2": "~2.0.3" + }, + "dependencies": { + "merge-source-map": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.0.4.tgz", + "integrity": "sha1-pd5GU42uhNQRTMXqArR3KmNGcB8=", + "dev": true, + "requires": { + "source-map": "^0.5.6" + } + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", + "dev": true + }, + "stdout-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/stdout-stream/-/stdout-stream-1.4.1.tgz", + "integrity": "sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA==", + "dev": true, + "requires": { + "readable-stream": "^2.0.1" + } + }, + "stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", + "dev": true + }, + "stream-browserify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", + "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==", + "dev": true, + "requires": { + "inherits": "~2.0.1", + "readable-stream": "^2.0.2" + } + }, + "stream-http": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", + "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "dev": true, + "requires": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string.prototype.trimend": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", + "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string.prototype.trimstart": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", + "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "^0.2.0" + } + }, + "strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=", + "dev": true, + "requires": { + "get-stdin": "^4.0.1" + } + }, + "stylehacks": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-4.0.3.tgz", + "integrity": "sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "postcss": "^7.0.0", + "postcss-selector-parser": "^3.0.0" + }, + "dependencies": { + "postcss-selector-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz", + "integrity": "sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA==", + "dev": true, + "requires": { + "dot-prop": "^5.2.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "stylus": { + "version": "0.54.8", + "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.54.8.tgz", + "integrity": "sha512-vr54Or4BZ7pJafo2mpf0ZcwA74rpuYCZbxrHBsH8kbcXOwSfvBFwsRfpGO5OD5fhG5HDCFW737PKaawI7OqEAg==", + "dev": true, + "requires": { + "css-parse": "~2.0.0", + "debug": "~3.1.0", + "glob": "^7.1.6", + "mkdirp": "~1.0.4", + "safer-buffer": "^2.1.2", + "sax": "~1.2.4", + "semver": "^6.3.0", + "source-map": "^0.7.3" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "source-map": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", + "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", + "dev": true + } + } + }, + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "svgo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", + "integrity": "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "coa": "^2.0.2", + "css-select": "^2.0.0", + "css-select-base-adapter": "^0.1.1", + "css-tree": "1.0.0-alpha.37", + "csso": "^4.0.2", + "js-yaml": "^3.13.1", + "mkdirp": "~0.5.1", + "object.values": "^1.1.0", + "sax": "~1.2.4", + "stable": "^0.1.8", + "unquote": "~1.1.1", + "util.promisify": "~1.0.0" + } + }, + "symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "tar": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz", + "integrity": "sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA==", + "dev": true, + "requires": { + "block-stream": "*", + "fstream": "^1.0.12", + "inherits": "2" + } + }, + "terser": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-3.17.0.tgz", + "integrity": "sha512-/FQzzPJmCpjAH9Xvk2paiWrFq+5M6aVOf+2KRbwhByISDX/EujxsK+BAvrhb6H+2rtrLCHK9N01wO014vrIwVQ==", + "dev": true, + "requires": { + "commander": "^2.19.0", + "source-map": "~0.6.1", + "source-map-support": "~0.5.10" + }, + "dependencies": { + "source-map-support": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + } + } + }, + "through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "requires": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "timers-browserify": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", + "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==", + "dev": true, + "requires": { + "setimmediate": "^1.0.4" + } + }, + "timsort": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", + "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=", + "dev": true + }, + "tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "dev": true + }, + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", + "dev": true + }, + "to-fast-properties": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", + "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=", + "dev": true + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "dev": true + }, + "tough-cookie": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", + "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", + "dev": true, + "requires": { + "psl": "^1.1.24", + "punycode": "^1.4.1" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + } + } + }, + "tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=", + "dev": true + }, + "trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", + "dev": true + }, + "true-case-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-1.0.3.tgz", + "integrity": "sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew==", + "dev": true, + "requires": { + "glob": "^7.1.2" + } + }, + "tty-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", + "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=", + "dev": true + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "unbox-primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", + "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has-bigints": "^1.0.1", + "has-symbols": "^1.0.2", + "which-boxed-primitive": "^1.0.2" + } + }, + "uncss": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/uncss/-/uncss-0.17.3.tgz", + "integrity": "sha512-ksdDWl81YWvF/X14fOSw4iu8tESDHFIeyKIeDrK6GEVTQvqJc1WlOEXqostNwOCi3qAj++4EaLsdAgPmUbEyog==", + "dev": true, + "requires": { + "commander": "^2.20.0", + "glob": "^7.1.4", + "is-absolute-url": "^3.0.1", + "is-html": "^1.1.0", + "jsdom": "^14.1.0", + "lodash": "^4.17.15", + "postcss": "^7.0.17", + "postcss-selector-parser": "6.0.2", + "request": "^2.88.0" + }, + "dependencies": { + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "is-absolute-url": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-3.0.3.tgz", + "integrity": "sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==", + "dev": true + }, + "postcss-selector-parser": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz", + "integrity": "sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + } + } + }, + "underscore": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.6.0.tgz", + "integrity": "sha1-izixDKze9jM3uLJOT/htRa6lKag=" + }, + "unicode-canonical-property-names-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", + "integrity": "sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz", + "integrity": "sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^1.0.4", + "unicode-property-aliases-ecmascript": "^1.0.4" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz", + "integrity": "sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz", + "integrity": "sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==", + "dev": true + }, + "unicode-trie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-0.3.1.tgz", + "integrity": "sha1-1nHd3YkQGgi6w3tqUWEBBgIFIIU=", + "dev": true, + "requires": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + } + }, + "uniq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", + "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", + "dev": true + }, + "uniqs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz", + "integrity": "sha1-/+3ks2slKQaW5uFl1KWe25mOawI=", + "dev": true + }, + "unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ=", + "dev": true + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + } + } + }, + "upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, + "url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "dev": true, + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=", + "dev": true + } + } + }, + "use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true + }, + "util": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", + "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==", + "dev": true, + "requires": { + "inherits": "2.0.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + } + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "util.promisify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", + "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.2", + "has-symbols": "^1.0.1", + "object.getownpropertydescriptors": "^2.1.0" + } + }, + "uuid": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-1.4.2.tgz", + "integrity": "sha1-RTAZ9oaWam34PNxSROfJkOzDMvw=" + }, + "v8-compile-cache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", + "dev": true + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "vendors": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/vendors/-/vendors-1.0.4.tgz", + "integrity": "sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w==", + "dev": true + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "vlq": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-0.2.3.tgz", + "integrity": "sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==", + "dev": true + }, + "vm-browserify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", + "dev": true + }, + "vue": { + "version": "2.6.14", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.14.tgz", + "integrity": "sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==" + }, + "vue-cropperjs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/vue-cropperjs/-/vue-cropperjs-4.2.0.tgz", + "integrity": "sha512-dvwCBtjGMiznkNIK2GFd1SQm1x+wmtWg4g4t+NrJSPj/fpHnubXxAUOIvY7lMFeR2lawRLsigCaGZrcXCzuTKA==", + "requires": { + "cropperjs": "^1.5.6" + } + }, + "vue-hot-reload-api": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.4.tgz", + "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==", + "dev": true + }, + "vue-template-compiler": { + "version": "2.6.14", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.6.14.tgz", + "integrity": "sha512-ODQS1SyMbjKoO1JBJZojSw6FE4qnh9rIpUZn2EUT86FKizx9uH5z6uXiIrm4/Nb/gwxTi/o17ZDEGWAXHvtC7g==", + "dev": true, + "requires": { + "de-indent": "^1.0.2", + "he": "^1.1.0" + } + }, + "vue-template-es2015-compiler": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz", + "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==", + "dev": true + }, + "w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "dev": true, + "requires": { + "browser-process-hrtime": "^1.0.0" + } + }, + "w3c-xmlserializer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-1.1.2.tgz", + "integrity": "sha512-p10l/ayESzrBMYWRID6xbuCKh2Fp77+sA0doRuGn4tTIMrrZVeqfpKjXHY+oDh3K4nLdPgNwMTVP6Vp4pvqbNg==", + "dev": true, + "requires": { + "domexception": "^1.0.1", + "webidl-conversions": "^4.0.2", + "xml-name-validator": "^3.0.0" + } + }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "dev": true, + "requires": { + "defaults": "^1.0.3" + } + }, + "webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, + "whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "dev": true, + "requires": { + "iconv-lite": "0.4.24" + } + }, + "whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true + }, + "whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "ws": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.2.tgz", + "integrity": "sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA==", + "dev": true, + "requires": { + "async-limiter": "~1.0.0" + } + }, + "xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true + }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true + }, + "y18n": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.1.tgz", + "integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==", + "dev": true + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + } + } + } + } +} diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/package.json b/site/plugins/kirby-plugin-image-crop-field-2.0.5/package.json new file mode 100644 index 0000000..b74b316 --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/package.json @@ -0,0 +1,42 @@ +{ + "name": "kirby-plugin-image-crop-field", + "description": "A image cropping field for kirby.", + "author": "Rico Steiner ", + "version": "2.0.5", + "type": "kirby-field", + "license": "MIT", + "scripts": { + "watch": "parcel watch src/main.js --out-dir ./ --out-file index.js --no-source-maps", + "build": "parcel build src/main.js --out-dir ./ --out-file index.js --no-source-maps" + }, + "postcss": { + "plugins": { + "autoprefixer": {} + } + }, + "browserslist": [ + "last 2 versions" + ], + "posthtml": { + "recognizeSelfClosing": true + }, + "devDependencies": { + "@vue/component-compiler-utils": "^2.3.0", + "babel-core": "^6.26.3", + "babel-preset-env": "^1.7.0", + "browserslist": "^4.17.5", + "caniuse-lite": "^1.0.30001274", + "node-sass": "^4.14.1", + "parcel-bundler": "^1.12.5", + "sass": "^1.43.4", + "sass-loader": "^7.3.1", + "stylus": "^0.54.8", + "vue-hot-reload-api": "^2.3.4", + "vue-template-compiler": "^2.6.14" + }, + "dependencies": { + "src": "^1.1.2", + "vue": "^2.6.14", + "vue-cropperjs": "^4.2.0" + } +} diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/src/fields/ImageCrop.vue b/site/plugins/kirby-plugin-image-crop-field-2.0.5/src/fields/ImageCrop.vue new file mode 100644 index 0000000..1a0040e --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/src/fields/ImageCrop.vue @@ -0,0 +1,110 @@ + + + \ No newline at end of file diff --git a/site/plugins/kirby-plugin-image-crop-field-2.0.5/src/main.js b/site/plugins/kirby-plugin-image-crop-field-2.0.5/src/main.js new file mode 100644 index 0000000..3be1ca0 --- /dev/null +++ b/site/plugins/kirby-plugin-image-crop-field-2.0.5/src/main.js @@ -0,0 +1,8 @@ +import ImageCrop from './fields/ImageCrop.vue' + + +panel.plugin("steirico/kirby-plugin-image-crop-field", { + fields: { + imagecrop: ImageCrop + } +}); diff --git a/site/plugins/kql-2.1.0/LICENSE.md b/site/plugins/kql-2.1.0/LICENSE.md new file mode 100755 index 0000000..4ebd321 --- /dev/null +++ b/site/plugins/kql-2.1.0/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Bastian Allgeier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/site/plugins/kql-2.1.0/README.md b/site/plugins/kql-2.1.0/README.md new file mode 100755 index 0000000..1e10ec2 --- /dev/null +++ b/site/plugins/kql-2.1.0/README.md @@ -0,0 +1,987 @@ +# Kirby QL + +Kirby's Query Language API combines the flexibility of Kirby's data structures, the power of GraphQL and the simplicity of REST. + +The Kirby QL API takes POST requests with standard JSON objects and returns highly customized results that fit your application. + +## Playground + +You can play in our [KQL sandbox](https://kql.getkirby.com). The sandbox is based on the Kirby starterkit. + +> ℹ️ Source code of the playground is [available on GitHub](https://github.com/getkirby/kql.getkirby.com). + +## Example + +Given a POST request to: `/api/query` + +```json +{ + "query": "page('photography').children", + "select": { + "url": true, + "title": true, + "text": "page.text.markdown", + "images": { + "query": "page.images", + "select": { + "url": true + } + } + }, + "pagination": { + "limit": 10 + } +} +``` + +
    +🆗 Response + +```json +{ + "code": 200, + "result": { + "data": [ + { + "url": "https://example.com/photography/trees", + "title": "Trees", + "text": "Lorem ipsum …", + "images": [ + { + "url": "https://example.com/media/pages/photography/trees/1353177920-1579007734/cheesy-autumn.jpg" + }, + { + "url": "https://example.com/media/pages/photography/trees/1940579124-1579007734/last-tree-standing.jpg" + }, + { + "url": "https://example.com/media/pages/photography/trees/3506294441-1579007734/monster-trees-in-the-fog.jpg" + } + ] + }, + { + "url": "https://example.com/photography/sky", + "title": "Sky", + "text": "

    Dolor sit amet

    …", + "images": [ + { + "url": "https://example.com/media/pages/photography/sky/183363500-1579007734/blood-moon.jpg" + }, + { + "url": "https://example.com/media/pages/photography/sky/3904851178-1579007734/coconut-milkyway.jpg" + } + ] + } + ], + "pagination": { + "page": 1, + "pages": 1, + "offset": 0, + "limit": 10, + "total": 2 + } + }, + "status": "ok" +} +``` + +
    + +## Installation + +### Manual + +[Download](https://github.com/getkirby/kql/releases) and copy this repository to `/site/plugins/kql` of your Kirby installation. + +### Composer + +```bash +composer require getkirby/kql +``` + +## Documentation + +### API Endpoint + +KQL adds a new `query` API endpoint to your Kirby API (i.e. `yoursite.com/api/query`). This endpoint [requires authentication](https://getkirby.com/docs/guide/api/authentication). + +You can switch off authentication in your config at your own risk: + +```php +return [ + 'kql' => [ + 'auth' => false + ] +]; +``` + +### Sending POST Requests + +You can use any HTTP request library in your language of choice to make regular POST requests to your `/api/query` endpoint. In this example, we are using [the `fetch` API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and JavaScript to retrieve data from our Kirby installation. + +```js +const api = "https://yoursite.com/api/query"; +const username = "apiuser"; +const password = "strong-secret-api-password"; + +const headers = { + Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), + "Content-Type": "application/json", + Accept: "application/json", +}; + +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "page('notes').children", + select: { + title: true, + text: "page.text.kirbytext", + slug: true, + date: "page.date.toDate('d.m.Y')", + }, + }), + headers, +}); + +console.log(await response.json()); +``` + +### `query` + +With the query, you can fetch data from anywhere in your Kirby site. You can query fields, pages, files, users, languages, roles and more. + +#### Queries Without Selects + +When you don't pass the select option, Kirby will try to come up with the most useful result set for you. This is great for simple queries. + +##### Fetching the Site Title + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "site.title", + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: "Kirby Starterkit", + status: "ok" +} +``` + +
    + +##### Fetching a List of Page IDs + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "site.children", + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: [ + "photography", + "notes", + "about", + "error", + "home" + ], + status: "ok" +} +``` + +
    + +#### Running Field Methods + +Queries can even execute field methods. + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "site.title.upper", + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: "KIRBY STARTERKIT", + status: "ok" +} +``` + +
    + +### `select` + +KQL becomes really powerful by its flexible way to control the result set with the select option. + +#### Select Single Properties and Fields + +To include a property or field in your results, list them as an array. Check out our [reference for available properties](https://getkirby.com/docs/reference) for pages, users, files, etc. + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "site.children", + select: ["title", "url"], + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: { + data: [ + { + title: "Photography", + url: "/photography" + }, + { + title: "Notes", + url: "/notes" + }, + { + title: "About us", + url: "/about" + }, + { + title: "Error", + url: "/error" + }, + { + title: "Home", + url: "/" + } + ], + pagination: { + page: 1, + pages: 1, + offset: 0, + limit: 100, + total: 5 + } + }, + status: "ok" +} +``` + +
    + +You can also use the object notation and pass true for each key/property you want to include. + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "site.children", + select: { + title: true, + url: true, + }, + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: { + data: [ + { + title: "Photography", + url: "/photography" + }, + { + title: "Notes", + url: "/notes" + }, + { + title: "About us", + url: "/about" + }, + { + title: "Error", + url: "/error" + }, + { + title: "Home", + url: "/" + } + ], + pagination: { ... } + }, + status: "ok" +} +``` + +
    + +#### Using Queries for Properties and Fields + +Instead of passing true, you can also pass a string query to specify what you want to return for each key in your select object. + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "site.children", + select: { + title: "page.title", + }, + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: { + data: [ + { + title: "Photography", + }, + { + title: "Notes", + }, + ... + ], + pagination: { ... } + }, + status: "ok" +} +``` + +
    + +#### Executing Field Methods + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "site.children", + select: { + title: "page.title.upper", + }, + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: { + data: [ + { + title: "PHOTOGRAPHY", + }, + { + title: "NOTES", + }, + ... + ], + pagination: { ... } + }, + status: "ok" +} +``` + +
    + +#### Creating Aliases + +String queries are a perfect way to create aliases or return variations of the same field or property multiple times. + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "page('notes').children", + select: { + title: "page.title", + upperCaseTitle: "page.title.upper", + lowerCaseTitle: "page.title.lower", + guid: "page.id", + date: "page.date.toDate('d.m.Y')", + timestamp: "page.date.toTimestamp", + }, + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: { + data: [ + { + title: "Explore the universe", + upperCaseTitle: "EXPLORE THE UNIVERSE", + lowerCaseTitle: "explore the universe", + guid: "notes/explore-the-universe", + date: "21.04.2018", + timestamp: 1524316200 + }, + { ... }, + { ... }, + ... + ], + pagination: { ... } + }, + status: "ok" +} +``` + +
    + +#### Subqueries + +With such string queries you can of course also include nested data + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "page('photography').children", + select: { + title: "page.title", + images: "page.images", + }, + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: { + data: [ + { + title: "Trees", + images: [ + "photography/trees/cheesy-autumn.jpg", + "photography/trees/last-tree-standing.jpg", + "photography/trees/monster-trees-in-the-fog.jpg", + "photography/trees/sharewood-forest.jpg", + "photography/trees/stay-in-the-car.jpg" + ] + }, + { ... }, + { ... }, + ... + ], + pagination: { ... } + }, + status: "ok" +} +``` + +
    + +#### Subqueries With Selects + +You can also pass an object with a `query` and a `select` option + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "page('photography').children", + select: { + title: "page.title", + images: { + query: "page.images", + select: { + filename: true, + }, + }, + }, + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: { + data: [ + { + title: "Trees", + images: { + { + filename: "cheesy-autumn.jpg" + }, + { + filename: "last-tree-standing.jpg" + }, + { + filename: "monster-trees-in-the-fog.jpg" + }, + { + filename: "sharewood-forest.jpg" + }, + { + filename: "stay-in-the-car.jpg" + } + } + }, + { ... }, + { ... }, + ... + ], + pagination: { ... } + }, + status: "ok" +} +``` + +
    + +### Pagination + +Whenever you query a collection (pages, files, users, roles, languages) you can limit the resultset and also paginate through entries. You've probably already seen the pagination object in the results above. It is included in all results for collections, even if you didn't specify any pagination settings. + +#### `limit` + +You can specify a custom limit with the limit option. The default limit for collections is 100 entries. + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "page('notes').children", + pagination: { + limit: 5, + }, + select: { + title: "page.title", + }, + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: { + data: [ + { + title: "Across the ocean" + }, + { + title: "A night in the forest" + }, + { + title: "In the jungle of Sumatra" + }, + { + title: "Through the desert" + }, + { + title: "Himalaya and back" + } + ], + pagination: { + page: 1, + pages: 2, + offset: 0, + limit: 5, + total: 7 + } + }, + status: "ok" +} +``` + +
    + +#### `page` + +You can jump to any page in the resultset with the `page` option. + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "page('notes').children", + pagination: { + page: 2, + limit: 5, + }, + select: { + title: "page.title", + }, + }), + headers, +}); + +console.log(await response.json()); +``` + +
    +🆗 Response + +```js +{ + code: 200, + result: { + data: [ + { + title: "Chasing waterfalls" + }, + { + title: "Exploring the universe" + } + ], + pagination: { + page: 2, + pages: 2, + offset: 5, + limit: 5, + total: 7 + } + }, + status: "ok" +} +``` + +
    + +### Pagination in Subqueries + +Pagination settings also work for subqueries. + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "page('photography').children", + select: { + title: "page.title", + images: { + query: "page.images", + pagination: { + page: 2, + limit: 5, + }, + select: { + filename: true, + }, + }, + }, + }), + headers, +}); + +console.log(await response.json()); +``` + +### Multiple Queries in a Single Call + +With the power of selects and subqueries you can basically query the entire site in a single request + +```js +const response = await fetch(api, { + method: "post", + body: JSON.stringify({ + query: "site", + select: { + title: "site.title", + url: "site.url", + notes: { + query: "page('notes').children.listed", + select: { + title: true, + url: true, + date: "page.date.toDate('d.m.Y')", + text: "page.text.kirbytext", + }, + }, + photography: { + query: "page('photography').children.listed", + select: { + title: true, + images: { + query: "page.images", + select: { + url: true, + alt: true, + caption: "file.caption.kirbytext", + }, + }, + }, + }, + about: { + text: "page.text.kirbytext", + }, + }, + }), + headers, +}); + +console.log(await response.json()); +``` + +### Allowing Methods + +KQL is very strict with allowed methods by default. Custom page methods, file methods or model methods are not allowed to make sure you don't miss an important security issue by accident. You can allow additional methods though. + +#### Allow List + +The most straight forward way is to define allowed methods in your config. + +```php +return [ + 'kql' => [ + 'methods' => [ + 'allowed' => [ + 'MyCustomPage::cover' + ] + ] + ] +]; +``` + +#### DocBlock Comment + +You can also add a comment to your methods' doc blocks to allow them: + +```php +class MyCustomPage extends Page +{ + /** + * @kql-allowed + */ + public function cover() + { + return $this->images()->findBy('name', 'cover') ?? $this->image(); + } +} +``` + +This works for model methods as well as for custom page methods, file methods or other methods defined in plugins. + +```php +Kirby::plugin('your-name/your-plugin', [ + 'pageMethods' => [ + /** + * @kql-allowed + */ + 'cover' => function () { + return $this->images()->findBy('name', 'cover') ?? $this->image(); + } + ] +]); +``` + +### Blocking Methods + +You can block individual class methods that would normally be accessible by listing them in your config: + +```php +return [ + 'kql' => [ + 'methods' => [ + 'blocked' => [ + 'Kirby\Cms\Page::url' + ] + ] + ] +]; +``` + +### Blocking Classes + +Sometimes you might want to reduce access to various parts of the system. This can be done by blocking individual methods (see above) or by blocking entire classes. + +```php +return [ + 'kql' => [ + 'classes' => [ + 'blocked' => [ + 'Kirby\Cms\User' + ] + ] + ] +]; +``` + +Now, access to any user is blocked. + +### Custom Classes and Interceptors + +If you want to add support for a custom class or a class in Kirby's source that is not supported yet, you can list your own interceptors in your config + +```php +return [ + 'kql' => [ + 'interceptors' => [ + 'Kirby\Cms\System' => 'SystemInterceptor' + ] + ] +]; +``` + +You can put the class for such a custom interceptor in a plugin for example. + +```php +class SystemInterceptor extends Kirby\Kql\Interceptors\Interceptor +{ + public const CLASS_ALIAS = 'system'; + + protected $toArray = [ + 'isInstallable', + ]; + + public function allowedMethods(): array + { + return [ + 'isInstallable', + ]; + } +} +``` + +Interceptor classes are pretty straight forward. With the `CLASS_ALIAS` you can give objects with that class a short name for KQL queries. The `$toArray` property lists all methods that should be rendered if you don't run a subquery. I.e. in this case `kirby.system` would render an array with the `isInstallable` value. + +The `allowedMethods` method must return an array of all methods that can be access for this object. In addition to that you can also create your own custom methods in an interceptor that will then become available in KQL. + +```php +class SystemInterceptor extends Kirby\Kql\Interceptors\Interceptor +{ + ... + + public function isReady() + { + return 'yes it is!'; + } +} +``` + +This custom method can now be used with `kirby.system.isReady` in KQL and will return `yes it is!` + +### Unintercepted Classes + +If you want to fully allow access to an entire class without putting an interceptor in between, you can add the class to the allow list in your config: + +```php +return [ + 'kql' => [ + 'classes' => [ + 'allowed' => [ + 'Kirby\Cms\System' + ] + ] + ] +]; +``` + +This will introduce full access to all public class methods. This can be very risky though and you should avoid this if possible. + +### No Mutations + +KQL only offers access to data in your site. It does not support any mutations. All destructive methods are blocked and cannot be accessed in queries. + +## Plugins + +- [KQL + 11ty](https://github.com/getkirby/eleventykit) +- [KQL + Nuxt](https://nuxt-kql.jhnn.dev) + +## What's Kirby? +- **[getkirby.com](https://getkirby.com)** – Get to know the CMS. +- **[Try it](https://getkirby.com/try)** – Take a test ride with our online demo. Or download one of our kits to get started. +- **[Documentation](https://getkirby.com/docs/guide)** – Read the official guide, reference and cookbook recipes. +- **[Issues](https://github.com/getkirby/kirby/issues)** – Report bugs and other problems. +- **[Feedback](https://feedback.getkirby.com)** – You have an idea for Kirby? Share it. +- **[Forum](https://forum.getkirby.com)** – Whenever you get stuck, don't hesitate to reach out for questions and support. +- **[Discord](https://chat.getkirby.com)** – Hang out and meet the community. +- **[Mastodon](https://mastodon.social/@getkirby)** – Spread the word. +- **[Instagram](https://www.instagram.com/getkirby/)** – Share your creations: #madewithkirby. + +--- + +## License + +[MIT](./LICENSE) License © 2020-2023 [Bastian Allgeier](https://getkirby.com) diff --git a/site/plugins/kql-2.1.0/composer.json b/site/plugins/kql-2.1.0/composer.json new file mode 100755 index 0000000..6939d91 --- /dev/null +++ b/site/plugins/kql-2.1.0/composer.json @@ -0,0 +1,73 @@ +{ + "name": "getkirby/kql", + "description": "Kirby Query Language", + "license": "MIT", + "type": "kirby-plugin", + "version": "2.1.0", + "keywords": [ + "kirby", + "cms", + "api", + "json", + "query", + "headless" + ], + "authors": [ + { + "name": "Bastian Allgeier", + "email": "bastian@getkirby.com" + }, + { + "name": "Nico Hoffmann", + "email": "nico@getkirby.com" + } + ], + "homepage": "https://getkirby.com", + "support": { + "email": "support@getkirby.com", + "issues": "https://github.com/getkirby/kql/issues", + "forum": "https://forum.getkirby.com", + "source": "https://github.com/getkirby/kql" + }, + "require": { + "php": ">=8.0.0 <8.3.0", + "getkirby/cms": ">=3.8.2", + "getkirby/composer-installer": "^1.2.1" + }, + "autoload": { + "psr-4": { + "Kirby\\": [ + "tests/" + ] + } + }, + "config": { + "allow-plugins": { + "getkirby/composer-installer": true + }, + "optimize-autoloader": true + }, + "extra": { + "installer-name": "kql", + "kirby-cms-path": false + }, + "scripts": { + "analyze": [ + "@analyze:composer", + "@analyze:psalm", + "@analyze:phpcpd", + "@analyze:phpmd" + ], + "analyze:composer": "composer validate --strict --no-check-version --no-check-all", + "analyze:phpcpd": "phpcpd --fuzzy --exclude tests --exclude vendor .", + "analyze:phpmd": "phpmd . ansi phpmd.xml.dist --exclude 'dependencies/*,tests/*,vendor/*'", + "analyze:psalm": "psalm", + "ci": [ + "@fix", + "@analyze", + "@test" + ], + "fix": "php-cs-fixer fix", + "test": "phpunit --stderr --coverage-html=tests/coverage" + } +} diff --git a/site/plugins/kql-2.1.0/extensions/aliases.php b/site/plugins/kql-2.1.0/extensions/aliases.php new file mode 100644 index 0000000..e3f1330 --- /dev/null +++ b/site/plugins/kql-2.1.0/extensions/aliases.php @@ -0,0 +1,8 @@ + function ($kirby) { + return [ + [ + 'pattern' => 'query', + 'method' => 'POST|GET', + 'auth' => $kirby->option('kql.auth') === false ? false : true, + 'action' => function () use ($kirby) { + $input = $kirby->request()->get(); + $result = Kql::run($input); + + return [ + 'code' => 200, + 'result' => $result, + 'status' => 'ok', + ]; + } + ] + ]; + } +]; diff --git a/site/plugins/kql-2.1.0/extensions/autoload.php b/site/plugins/kql-2.1.0/extensions/autoload.php new file mode 100644 index 0000000..274e1f0 --- /dev/null +++ b/site/plugins/kql-2.1.0/extensions/autoload.php @@ -0,0 +1,21 @@ + require_once 'extensions/api.php' +]); diff --git a/site/plugins/kql-2.1.0/src/Kql/Help.php b/site/plugins/kql-2.1.0/src/Kql/Help.php new file mode 100644 index 0000000..faa6f21 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Help.php @@ -0,0 +1,152 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Help +{ + /** + * Provides information about passed value + * depending on its type + */ + public static function for($value): array + { + if (is_array($value) === true) { + return static::forArray($value); + } + + if (is_object($value) === true) { + return static::forObject($value); + } + + return [ + 'type' => gettype($value), + 'value' => $value + ]; + } + + /** + * @internal + */ + public static function forArray(array $array): array + { + return [ + 'type' => 'array', + 'keys' => array_keys($array), + ]; + } + + /** + * Gathers information for method about + * name, parameters, return type etc. + * @internal + */ + public static function forMethod(object $object, string $method): array + { + $reflection = new ReflectionMethod($object, $method); + $returns = $reflection->getReturnType()?->getName(); + $params = []; + + foreach ($reflection->getParameters() as $param) { + $name = $param->getName(); + $required = $param->isOptional() === false; + $type = $param->hasType() ? $param->getType()->getName() : null; + $default = null; + + if ($param->isDefaultValueAvailable()) { + $default = $param->getDefaultValue(); + } + + $call = ''; + + if ($type !== null) { + $call = $type . ' '; + } + + $call .= '$' . $name; + + if ($required === false && $default !== null) { + $call .= ' = ' . var_export($default, true); + } + + $p['call'] = $call; + + $params[$name] = compact('name', 'type', 'required', 'default', 'call'); + } + + $call = '.' . $method; + + if (empty($params) === false) { + $call .= '(' . implode(', ', array_column($params, 'call')) . ')'; + } + + return [ + 'call' => $call, + 'name' => $method, + 'params' => $params, + 'returns' => $returns + ]; + } + + /** + * Gathers informations for each unique method + * @internal + */ + public static function forMethods(object $object, array $methods): array + { + $methods = array_unique($methods); + $reflection = []; + + sort($methods); + + foreach ($methods as $methodName) { + if (method_exists($object, $methodName) === false) { + continue; + } + + $reflection[$methodName] = static::forMethod($object, $methodName); + } + + return $reflection; + } + + /** + * Retrieves info for objects either from Interceptor (to + * only list allowed methods) or via reflection + * @internal + */ + public static function forObject(object $object): array + { + // get interceptor object to only return info on allowed methods + $interceptor = Interceptor::replace($object); + + if ($interceptor instanceof Interceptor) { + return $interceptor->__debugInfo(); + } + + // for original classes, use reflection + $class = new ReflectionClass($object); + $methods = A::map( + $class->getMethods(), + fn ($method) => static::forMethod($object, $method->getName()) + ); + + return [ + 'type' => $class->getName(), + 'methods' => $methods + ]; + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptor.php b/site/plugins/kql-2.1.0/src/Kql/Interceptor.php new file mode 100644 index 0000000..7f6e774 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptor.php @@ -0,0 +1,295 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +abstract class Interceptor +{ + public const CLASS_ALIAS = null; + + protected $toArray = []; + + public function __construct(protected $object) + { + } + + /** + * Magic caller that prevents access + * to restricted methods + */ + public function __call(string $method, array $args = []) + { + if ($this->isAllowedMethod($method) === true) { + return $this->object->$method(...$args); + } + + $this->forbiddenMethod($method); + } + + /** + * Return information about corresponding object + * incl. information about allowed methods + */ + public function __debugInfo(): array + { + $help = Help::forMethods($this->object, $this->allowedMethods()); + + return [ + 'type' => $this::CLASS_ALIAS, + 'methods' => $help, + 'value' => $this->toArray() + ]; + } + + /** + * Returns list of allowed classes. Specific list + * to be implemented in specific interceptor child classes. + * @codeCoverageIgnore + */ + public function allowedMethods(): array + { + return []; + } + + /** + * Returns class name for Interceptor that responds + * to passed name string of a Kirby core class + * @internal + */ + public static function class(string $class): string + { + return str_replace('Kirby\\', 'Kirby\\Kql\\Interceptors\\', $class); + } + + /** + * Throws exception for accessing a restricted method + * @throws \Kirby\Exception\PermissionException + */ + protected function forbiddenMethod(string $method) + { + $name = get_class($this->object) . '::' . $method . '()'; + throw new PermissionException('The method "' . $name . '" is not allowed in the API context'); + } + + /** + * Checks if method is allowed to call + */ + public function isAllowedMethod($method) + { + $kirby = App::instance(); + $name = strtolower(get_class($this->object) . '::' . $method); + + // get list of blocked methods from config + $blocked = $kirby->option('kql.methods.blocked', []); + $blocked = array_map('strtolower', $blocked); + + // check in the block list from the config + if (in_array($name, $blocked) === true) { + return false; + } + + // check in class allow list + if (in_array($method, $this->allowedMethods()) === true) { + return true; + } + + // get list of explicitly allowed methods from config + $allowed = $kirby->option('kql.methods.allowed', []); + $allowed = array_map('strtolower', $allowed); + + // check in the allow list from the config + if (in_array($name, $allowed) === true) { + return true; + } + + // support for model methods with docblock comment + if ($this->isAllowedCallable($method) === true) { + return true; + } + + // support for custom methods with docblock comment + if ($this->isAllowedCustomMethod($method) === true) { + return true; + } + + return false; + } + + /** + * Checks if closure or object method is allowed + */ + protected function isAllowedCallable($method): bool + { + try { + $ref = match (true) { + $method instanceof Closure + => new ReflectionFunction($method), + is_string($method) === true + => new ReflectionMethod($this->object, $method), + default + => throw new InvalidArgumentException('Invalid method') + }; + + if ($comment = $ref->getDocComment()) { + if (Str::contains($comment, '@kql-allowed') === true) { + return true; + } + } + } catch (Throwable) { + return false; + } + + return false; + } + + protected function isAllowedCustomMethod(string $method): bool + { + // has no custom methods + if (property_exists($this->object, 'methods') === false) { + return false; + } + + // does not have that method + if (!$call = $this->method($method)) { + return false; + } + + // check for a docblock comment + if ($this->isAllowedCallable($call) === true) { + return true; + } + + return false; + } + + /** + * Returns a registered method by name, either from + * the current class or from a parent class ordered by + * inheritance order (top to bottom) + */ + protected function method(string $method) + { + if (isset($this->object::$methods[$method]) === true) { + return $this->object::$methods[$method]; + } + + foreach (class_parents($this->object) as $parent) { + if (isset($parent::$methods[$method]) === true) { + return $parent::$methods[$method]; + } + } + + return null; + } + + /** + * Tries to replace a Kirby core object with the + * corresponding interceptor. + * @throws \Kirby\Exception\InvalidArgumentException for non-objects + * @throws \Kirby\Exception\PermissionException when accessing blocked class + */ + public static function replace($object) + { + if (is_object($object) === false) { + throw new InvalidArgumentException('Unsupported value: ' . gettype($object)); + } + + $kirby = App::instance(); + $class = get_class($object); + $name = strtolower($class); + + // 1. Is $object class explicitly blocked? + // get list of blocked classes from config + $blocked = $kirby->option('kql.classes.blocked', []); + $blocked = array_map('strtolower', $blocked); + + // check in the block list from the config + if (in_array($name, $blocked) === true) { + throw new PermissionException('Access to the class "' . $class . '" is blocked'); + } + + // 2. Is $object already an interceptor? + // directly return interceptor objects + if ($object instanceof Interceptor) { + return $object; + } + + // 3. Does an interceptor class for $object exist? + // check for an interceptor class + $interceptors = $kirby->option('kql.interceptors', []); + $interceptors = array_change_key_case($interceptors, CASE_LOWER); + // load an interceptor from config if it exists and otherwise fall back to a built-in interceptor + $interceptor = $interceptors[$name] ?? static::class($class); + + // check for a valid interceptor class + if ($class !== $interceptor && class_exists($interceptor) === true) { + return new $interceptor($object); + } + + // 4. Also check for parent classes of $object + // go through parents of the current object to use their interceptors as fallback + foreach (class_parents($object) as $parent) { + $interceptor = static::class($parent); + + if (class_exists($interceptor) === true) { + return new $interceptor($object); + } + } + + // 5. $object has no interceptor but is explicitly allowed? + // check for a class in the allow list + $allowed = $kirby->option('kql.classes.allowed', []); + $allowed = array_map('strtolower', $allowed); + + // return the plain object if it is allowed + if (in_array($name, $allowed) === true) { + return $object; + } + + // 6. None of the above? Block class. + throw new PermissionException('Access to the class "' . $class . '" is not supported'); + } + + public function toArray(): array|null + { + $toArray = []; + + // filter methods which cannot be called + foreach ($this->toArray as $method) { + if ($this->isAllowedMethod($method) === true) { + $toArray[] = $method; + } + } + + return Kql::select($this, $toArray); + } + + /** + * Mirrors by default ::toArray but can be + * implemented differently by specifc interceptor. + * KQL will prefer ::toResponse over ::toArray + */ + public function toResponse() + { + return $this->toArray(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/App.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/App.php new file mode 100644 index 0000000..e984dbb --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/App.php @@ -0,0 +1,39 @@ +allowedMethodsForSiblings(), + [ + 'content', + 'id', + 'isEmpty', + 'isHidden', + 'isNotEmpty', + 'toField', + 'toHtml', + 'parent', + 'type' + ] + ); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Blocks.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Blocks.php new file mode 100755 index 0000000..9112d6c --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Blocks.php @@ -0,0 +1,24 @@ +object->toArray(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Blueprint.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Blueprint.php new file mode 100644 index 0000000..d05aea0 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Blueprint.php @@ -0,0 +1,72 @@ +object->fields(); + } + + public function sections(): array + { + return array_keys($this->object->sections()); + } + + public function tab(string $name): ?array + { + if ($tab = $this->object->tab($name)) { + foreach ($tab['columns'] as $columnIndex => $column) { + $tab['columns'][$columnIndex]['sections'] = array_keys($column['sections']); + } + + return $tab; + } + + return null; + } + + public function tabs(): array + { + $tabs = []; + + foreach ($this->object->tabs() as $tab) { + $tabs[$tab['name']] = $this->tab($tab['name']); + } + + return $tabs; + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Collection.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Collection.php new file mode 100644 index 0000000..0608365 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Collection.php @@ -0,0 +1,49 @@ +object->keys(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/File.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/File.php new file mode 100644 index 0000000..fadd56e --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/File.php @@ -0,0 +1,71 @@ +allowedMethodsForModels(), + $this->allowedMethodsForParents(), + $this->allowedMethodsForSiblings(), + [ + 'blur', + 'bw', + 'crop', + 'dataUri', + 'dimensions', + 'exif', + 'extension', + 'filename', + 'files', + 'grayscale', + 'greyscale', + 'height', + 'html', + 'isPortrait', + 'isLandscape', + 'isSquare', + 'mime', + 'name', + 'niceSize', + 'orientation', + 'ratio', + 'resize', + 'size', + 'srcset', + 'template', + 'templateSiblings', + 'thumb', + 'type', + 'width' + ] + ); + } + + public function dimensions(): array + { + return $this->object->dimensions()->toArray(); + } + + public function exif(): array + { + return $this->object->exif()->toArray(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/FileVersion.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/FileVersion.php new file mode 100644 index 0000000..f37eb79 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/FileVersion.php @@ -0,0 +1,8 @@ +allowedMethodsForSiblings(), + [ + 'attrs', + 'columns', + 'id', + 'isEmpty', + 'isNotEmpty', + 'parent' + ] + ); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/LayoutColumn.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/LayoutColumn.php new file mode 100755 index 0000000..33b1b23 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/LayoutColumn.php @@ -0,0 +1,30 @@ +allowedMethodsForSiblings(), + [ + 'blocks', + 'id', + 'isEmpty', + 'isNotEmpty', + 'span', + 'width' + ] + ); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/LayoutColumns.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/LayoutColumns.php new file mode 100755 index 0000000..4d74820 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/LayoutColumns.php @@ -0,0 +1,13 @@ +object->toArray(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Layouts.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Layouts.php new file mode 100755 index 0000000..167412b --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Layouts.php @@ -0,0 +1,13 @@ +object->toArray(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Model.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Model.php new file mode 100644 index 0000000..79e51ab --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Model.php @@ -0,0 +1,113 @@ +isAllowedMethod($method) === true) { + return $this->object->$method(...$args); + } + + if (method_exists($this->object, $method) === false) { + return $this->object->content()->get($method); + } + + $this->forbiddenMethod($method); + } + + protected function allowedMethodsForChildren() + { + return [ + 'children', + 'childrenAndDrafts', + 'draft', + 'drafts', + 'find', + 'findPageOrDraft', + 'grandChildren', + 'hasChildren', + 'hasDrafts', + 'hasListedChildren', + 'hasUnlistedChildren', + 'index', + 'search', + ]; + } + + protected function allowedMethodsForFiles() + { + return [ + 'audio', + 'code', + 'documents', + 'file', + 'files', + 'hasAudio', + 'hasCode', + 'hasDocuments', + 'hasFiles', + 'hasImages', + 'hasVideos', + 'image', + 'images', + 'videos' + ]; + } + + protected function allowedMethodsForModels() + { + return [ + 'apiUrl', + 'blueprint', + 'content', + 'dragText', + 'exists', + 'id', + 'mediaUrl', + 'modified', + 'permissions', + 'panel', + 'permalink', + 'previewUrl', + 'url', + ]; + } + + protected function allowedMethodsForSiblings() + { + return [ + 'indexOf', + 'next', + 'nextAll', + 'prev', + 'prevAll', + 'siblings', + 'hasNext', + 'hasPrev', + 'isFirst', + 'isLast', + 'isNth' + ]; + } + + protected function allowedMethodsForParents() + { + return [ + 'parent', + 'parentId', + 'parentModel', + 'site', + ]; + } + + public function uuid(): string + { + return $this->object->uuid()->toString(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Page.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Page.php new file mode 100644 index 0000000..429bcfb --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Page.php @@ -0,0 +1,68 @@ +allowedMethodsForChildren(), + $this->allowedMethodsForFiles(), + $this->allowedMethodsForModels(), + $this->allowedMethodsForParents(), + $this->allowedMethodsForSiblings(), + [ + 'blueprints', + 'depth', + 'hasTemplate', + 'intendedTemplate', + 'isDraft', + 'isErrorPage', + 'isHomePage', + 'isHomeOrErrorPage', + 'isListed', + 'isReadable', + 'isSortable', + 'isUnlisted', + 'num', + 'slug', + 'status', + 'template', + 'title', + 'uid', + 'uri', + ] + ); + } + + public function intendedTemplate(): string + { + return $this->object->intendedTemplate()->name(); + } + + public function template(): string + { + return $this->object->template()->name(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Pages.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Pages.php new file mode 100644 index 0000000..2936226 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Pages.php @@ -0,0 +1,34 @@ +object->permissions()->toArray(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Site.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Site.php new file mode 100644 index 0000000..0ae094c --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Site.php @@ -0,0 +1,36 @@ +allowedMethodsForChildren(), + $this->allowedMethodsForFiles(), + $this->allowedMethodsForModels(), + [ + 'blueprints', + 'breadcrumb', + 'errorPage', + 'errorPageId', + 'homePage', + 'homePageId', + 'page', + 'pages', + 'title', + ] + ); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Structure.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Structure.php new file mode 100644 index 0000000..31e5971 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Structure.php @@ -0,0 +1,13 @@ +object->toArray(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/StructureObject.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/StructureObject.php new file mode 100644 index 0000000..517d8cc --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/StructureObject.php @@ -0,0 +1,20 @@ +allowedMethodsForSiblings(), + [ + 'content', + 'id', + 'parent', + ] + ); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Translation.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Translation.php new file mode 100755 index 0000000..5c0d75a --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Translation.php @@ -0,0 +1,35 @@ +allowedMethodsForFiles(), + $this->allowedMethodsForModels(), + $this->allowedMethodsForSiblings(), + [ + 'avatar', + 'email', + 'id', + 'isAdmin', + 'language', + 'modified', + 'name', + 'role', + 'username', + ] + ); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Users.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Users.php new file mode 100644 index 0000000..7178268 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Cms/Users.php @@ -0,0 +1,18 @@ +isAllowedMethod($method) === true) { + return $this->object->$method(...$args); + } + + if (method_exists($this->object, $method) === false) { + return $this->object->get($method); + } + + $this->forbiddenMethod($method); + } + + public function allowedMethods(): array + { + return [ + 'data', + 'fields', + 'has', + 'get', + 'keys', + 'not', + ]; + } + + public function toArray(): array + { + return $this->object->toArray(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Content/Field.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Content/Field.php new file mode 100644 index 0000000..05c589e --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Content/Field.php @@ -0,0 +1,52 @@ +isAllowedMethod($method) === true) { + return $this->object->$method(...$args); + } + + // field methods + $methods = array_keys($this->object::$methods); + $method = strtolower($method); + + if (in_array($method, $methods) === true) { + return $this->object->$method(...$args); + } + + // aliases + $aliases = array_keys($this->object::$aliases); + $alias = strtolower($method); + + if (in_array($alias, $aliases) === true) { + return $this->object->$method(...$args); + } + + $this->forbiddenMethod($method); + } + + public function allowedMethods(): array + { + return [ + 'exists', + 'isEmpty', + 'isNotEmpty', + 'key', + 'or', + 'value' + ]; + } + + public function toResponse() + { + return $this->object->toString(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Panel/Model.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Panel/Model.php new file mode 100755 index 0000000..006d47f --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Panel/Model.php @@ -0,0 +1,30 @@ + $this->dragText(), + 'image' => $this->image(), + 'path' => $this->path(), + 'url' => $this->url(), + ]; + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Interceptors/Toolkit/Obj.php b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Toolkit/Obj.php new file mode 100755 index 0000000..0ac2861 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Interceptors/Toolkit/Obj.php @@ -0,0 +1,24 @@ +object->toArray(); + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Kql.php b/site/plugins/kql-2.1.0/src/Kql/Kql.php new file mode 100644 index 0000000..55d1016 --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Kql.php @@ -0,0 +1,226 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Kql +{ + public static function fetch($model, $key, $selection) + { + // simple key/value + if ($selection === true) { + return static::render($model->$key()); + } + + // selection without additional query + if ( + is_array($selection) === true && + empty($selection['query']) === true + ) { + return static::select( + $model->$key(), + $selection['select'] ?? null, + $selection['options'] ?? [] + ); + } + + // nested queries + return static::run($selection, $model); + } + + /** + * Returns helpful information about the object + * type as well as, if available, values and methods + */ + public static function help($object): array + { + return Help::for($object); + } + + public static function query(string $query, $model = null) + { + $model ??= App::instance()->site(); + $data = [$model::CLASS_ALIAS => $model]; + + return Query::factory($query)->resolve($data); + } + + public static function render($value) + { + if (is_object($value) === true) { + // replace actual object with intercepting proxy class + $object = Interceptor::replace($value); + + if (method_exists($object, 'toResponse') === true) { + return $object->toResponse(); + } + + if (method_exists($object, 'toArray') === true) { + return $object->toArray(); + } + + throw new Exception('The object "' . get_class($object) . '" cannot be rendered. Try querying one of its methods instead.'); + } + + return $value; + } + + public static function run($input, $model = null) + { + // string queries + if (is_string($input) === true) { + $result = static::query($input, $model); + return static::render($result); + } + + // multiple queries + if (isset($input['queries']) === true) { + $result = []; + + foreach ($input['queries'] as $name => $query) { + $result[$name] = static::run($query); + } + + return $result; + } + + $query = $input['query'] ?? 'site'; + $select = $input['select'] ?? null; + $options = ['pagination' => $input['pagination'] ?? null]; + + // check for invalid queries + if (is_string($query) === false) { + throw new Exception('The query must be a string'); + } + + $result = static::query($query, $model); + return static::select($result, $select, $options); + } + + public static function select( + $data, + array|string|null $select = null, + array $options = [] + ) { + if ($select === null) { + return static::render($data); + } + + if ($select === '?') { + return static::help($data); + } + + if ($data instanceof Collection) { + return static::selectFromCollection($data, $select, $options); + } + + if (is_object($data) === true) { + return static::selectFromObject($data, $select); + } + + if (is_array($data) === true) { + return static::selectFromArray($data, $select); + } + } + + /** + * @internal + */ + public static function selectFromArray(array $array, array $select): array + { + $result = []; + + foreach ($select as $key => $selection) { + if ($selection === false) { + continue; + } + + if (is_int($key) === true) { + $key = $selection; + $selection = true; + } + + $result[$key] = $array[$key] ?? null; + } + + return $result; + } + + /** + * @internal + */ + public static function selectFromCollection( + Collection $collection, + array|string $select, + array $options = [] + ): array { + if ($options['pagination'] ?? false) { + $collection = $collection->paginate($options['pagination']); + } + + $data = []; + + foreach ($collection as $model) { + $data[] = static::selectFromObject($model, $select); + } + + if ($pagination = $collection->pagination()) { + return [ + 'data' => $data, + 'pagination' => [ + 'page' => $pagination->page(), + 'pages' => $pagination->pages(), + 'offset' => $pagination->offset(), + 'limit' => $pagination->limit(), + 'total' => $pagination->total(), + ], + ]; + } + + return $data; + } + + /** + * @internal + */ + public static function selectFromObject( + object $object, + array|string $select + ): array { + // replace actual object with intercepting proxy class + $object = Interceptor::replace($object); + $result = []; + + if (is_string($select) === true) { + $select = Str::split($select); + } + + foreach ($select as $key => $selection) { + if ($selection === false) { + continue; + } + + if (is_int($key) === true) { + $key = $selection; + $selection = true; + } + + $result[$key] = static::fetch($object, $key, $selection); + } + + return $result; + } +} diff --git a/site/plugins/kql-2.1.0/src/Kql/Query.php b/site/plugins/kql-2.1.0/src/Kql/Query.php new file mode 100644 index 0000000..e4eebcb --- /dev/null +++ b/site/plugins/kql-2.1.0/src/Kql/Query.php @@ -0,0 +1,29 @@ + + * @link https://getkirby.com + * @copyright Bastian Allgeier + * @license https://getkirby.com/license + */ +class Query extends BaseQuery +{ + /** + * Intercepts the chain of segments called + * on each other by replacing objects with + * their corresponding Interceptor which + * handles blocking calls to restricted methods + */ + public function intercept(mixed $result): mixed + { + return is_object($result) ? Interceptor::replace($result): $result; + } +} diff --git a/site/plugins/thumb/index.php b/site/plugins/thumb/index.php new file mode 100644 index 0000000..05d23ec --- /dev/null +++ b/site/plugins/thumb/index.php @@ -0,0 +1,15 @@ + [ + /** + * @kql-allowed + */ + 'thumbnail' => function ($width) { + return $this->thumb([ + 'width' => $width + // other stuff you need + ]); + } + ] +]); diff --git a/site/sessions/index.html b/site/sessions/index.html new file mode 100644 index 0000000..e69de29 diff --git a/site/snippets/footer.php b/site/snippets/footer.php new file mode 100644 index 0000000..b605728 --- /dev/null +++ b/site/snippets/footer.php @@ -0,0 +1,2 @@ + + diff --git a/site/snippets/header.php b/site/snippets/header.php new file mode 100644 index 0000000..9b16e41 --- /dev/null +++ b/site/snippets/header.php @@ -0,0 +1,42 @@ +response()->type('application/json'); + header("Content-Type: application/json"); + echo json_encode($json); + exit(); +}?> + + + + + YGDC - <?= $title ?> + "> + + + url() == $page->url()): ?> + + + diff --git a/site/templates/board.php b/site/templates/board.php new file mode 100644 index 0000000..3b7526d --- /dev/null +++ b/site/templates/board.php @@ -0,0 +1,12 @@ + $page->title()]) ?> + +
    +

    Under Construction

    +
      + find("seasons")->getAllGames() as $key => $value): ?> +
    • title() ?>
    • + +
    +
    + + diff --git a/site/templates/default.php b/site/templates/default.php new file mode 100644 index 0000000..4cb627d --- /dev/null +++ b/site/templates/default.php @@ -0,0 +1,24 @@ + "???"]) ?> + +

    title() ?>

    + + +getStats($page->children()->first()->players()->toPages()->first()->uuid()->toString())); + +?> +children() as $page) { +// echo json_decode($page->stats()->toString())->stats[0]->average[0]; +// echo "/"; +// echo json_decode($page->stats()->toString())->stats[0]->average[1]; +// echo "
    "; +// echo json_decode($page->stats()->toString())->stats[1]->average[0]; +// echo "/"; +// echo json_decode($page->stats()->toString())->stats[1]->average[1]; +// echo "
    "; +// }; + +?> diff --git a/site/templates/home.php b/site/templates/home.php new file mode 100644 index 0000000..c98c80d --- /dev/null +++ b/site/templates/home.php @@ -0,0 +1,35 @@ + "Dart Scorer"]) ?> + + +
    +
    + url() ?>"> +
    + +
    + + diff --git a/site/templates/xoi.php b/site/templates/xoi.php new file mode 100644 index 0000000..14c9fba --- /dev/null +++ b/site/templates/xoi.php @@ -0,0 +1,36 @@ + "Game"]) ?> + +
    + + +
    + + + diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..8c3ca41 --- /dev/null +++ b/sw.js @@ -0,0 +1,72 @@ +/* + Copyright 2016 Google Inc. All Rights Reserved. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +// Names of the two caches used in this version of the service worker. +// Change to v2, etc. when you update any of the local resources, which will +// in turn trigger the install event again. +const PRECACHE = 'precache-v2'; +const RUNTIME = 'runtime-v2'; +const cacheOn = true; + +// A list of local resources we always want to be cached. +const PRECACHE_URLS = [ +]; + +// The install handler takes care of precaching the resources we always need. +self.addEventListener('install', event => { + event.waitUntil( + caches.open(PRECACHE) + .then(cache => cache.addAll(PRECACHE_URLS)) + .then(self.skipWaiting()) + ); +}); + +// The activate handler takes care of cleaning up old caches. +self.addEventListener('activate', event => { + // const currentCaches = [PRECACHE, RUNTIME]; + const currentCaches = []; + event.waitUntil( + caches.keys().then(cacheNames => { + return cacheNames.filter(cacheName => !currentCaches.includes(cacheName)); + }).then(cachesToDelete => { + return Promise.all(cachesToDelete.map(cacheToDelete => { + return caches.delete(cacheToDelete); + })); + }).then(() => self.clients.claim()) + ); +}); + +// The fetch handler serves responses for same-origin resources from a cache. +// If no response is found, it populates the runtime cache with the response +// from the network before returning it to the page. +self.addEventListener('fetch', event => { + // Skip cross-origin requests, like those for Google Analytics. + if (event.request.url.startsWith(self.location.origin) && !event.request.url.includes("panel") && event.request.method === "GET" && cacheOn) { + event.respondWith( + caches.match(event.request).then(cachedResponse => { + if (cachedResponse) { + return cachedResponse; + } + + return caches.open(RUNTIME).then(cache => { + return fetch(event.request).then(response => { + // Put a copy of the response in the runtime cache. + return cache.put(event.request, response.clone()).then(() => { + return response; + }); + }); + }); + }) + ); + } +}); diff --git a/wsify_linux_amd64 b/wsify_linux_amd64 new file mode 100755 index 0000000..724cf0c Binary files /dev/null and b/wsify_linux_amd64 differ