Compare commits

...
Sign in to create a new pull request.

121 commits

Author SHA1 Message Date
isUnknown
8f9e75126e SEO : add tombi mori plugin 2025-05-13 09:03:14 +02:00
isUnknown
df2843123f fix strong tag font weight 2025-04-26 10:24:06 +02:00
isUnknown
78a63a9d60 footnote force style regular 2025-04-24 09:45:55 +02:00
isUnknown
e8e23379b0 fix strong fw bold 2025-04-24 09:44:51 +02:00
isUnknown
bf20f1d394 add space 2025-03-14 18:38:44 +01:00
isUnknown
772d310fa9 remove 'publication prévue le' 2025-02-27 08:18:48 +01:00
isUnknown
cba733c14c home page remove pointer events on header 2025-02-25 11:38:49 +01:00
isUnknown
6a841d3b57 normalize sup tag 2025-02-21 16:16:43 +01:00
isUnknown
5d63958374 article titles : adjust line height 2025-02-21 16:13:23 +01:00
isUnknown
b019b99284 fix nav spacing 2025-02-21 16:05:25 +01:00
isUnknown
9f65afaf70 unlock articles 2025-02-21 15:02:17 +01:00
isUnknown
bb924bed7b finish year page 2025-02-21 14:44:43 +01:00
isUnknown
9d3800690d adjust title wrapper h1 mgbt 2025-02-21 13:29:23 +01:00
isUnknown
7f661597ba fix category page 2025-02-21 13:24:06 +01:00
isUnknown
a1d945a8ab fix category page 2025-02-21 13:08:53 +01:00
isUnknown
18db74998d fix grid column spacing mobile 2025-02-21 12:43:48 +01:00
isUnknown
28fc768228 fix open nav button home 2025-02-21 12:30:54 +01:00
isUnknown
5496ae3964 h3 spacing 2025-02-21 09:41:30 +01:00
isUnknown
b6a573e7ad link break word 2025-02-21 09:38:53 +01:00
isUnknown
6c245966e1 mobile : center text button. homogenize close / open nav btns 2025-02-21 09:37:28 +01:00
isUnknown
455801a0d2 fix page cover top 2025-02-21 08:33:37 +01:00
isUnknown
217dde5842 desktop : page-cover min height 2025-02-21 08:26:11 +01:00
isUnknown
949d7437a4 fix title word wrapping 2025-02-21 08:20:01 +01:00
isUnknown
328fd95842 fix links overflow 2025-02-21 08:15:06 +01:00
isUnknown
915f3e91b5 body overflow-x hidden 2025-02-21 08:00:42 +01:00
isUnknown
dc3750ffb7 fix home page-cover 2025-02-21 07:58:58 +01:00
isUnknown
205dac3fde adjust page-cover 2025-02-21 07:58:25 +01:00
isUnknown
f951d3e5d6 remove footnote before space. Add underline on target 2025-02-21 07:30:28 +01:00
isUnknown
a5d8f4dc80 change footnote number color 2025-02-21 07:24:10 +01:00
isUnknown
0766e64b96 fix #31 2025-02-18 17:11:09 +01:00
isUnknown
b4217f80d4 add additionnalCss 2025-02-18 10:16:52 +01:00
isUnknown
08c2f57d6e improve main width 2025-02-18 09:58:31 +01:00
isUnknown
8629d58240 chapo : remove italic 2025-02-17 19:23:38 +01:00
isUnknown
57edf14f53 footer mobile : links only on home page and align left 2025-02-17 19:21:58 +01:00
isUnknown
60709ab632 fix body front 2025-02-17 19:13:21 +01:00
isUnknown
7f4eb816c1 consistancy isHtmlMode 2025-02-17 19:02:18 +01:00
isUnknown
23153263a0 fix linear body 2025-02-17 19:01:26 +01:00
isUnknown
be447667cc add html mode 2025-02-17 17:57:53 +01:00
isUnknown
72a84c9283 nav : increase spacing at the end of the collection list 2025-02-17 17:17:34 +01:00
isUnknown
4758048e61 ajustement graisse actuel 2025-02-13 10:08:34 +01:00
isUnknown
d76a53a3f6 remove useless variable 2025-02-10 10:05:04 +01:00
isUnknown
21e058b336 newsletter - little fixes 2025-02-09 17:36:12 +01:00
isUnknown
025b3a1d08 css build 2025-02-07 15:40:51 +01:00
isUnknown
ff2b7663a2 nav : adjust texts list spacing 2025-02-07 15:39:24 +01:00
isUnknown
1dad30e71a nav : remove sbtt mgt 2025-02-07 11:28:47 +01:00
isUnknown
014858fe82 css build 2025-02-07 10:49:53 +01:00
isUnknown
d5d0b29fb0 nav : add subtitle spacing 2025-02-07 10:46:52 +01:00
isUnknown
989f042158 email : create split panel 2025-02-07 10:44:01 +01:00
isUnknown
b525ea4f5c newsletter : liste d'adresse de test, écriture des logs 2025-02-06 18:34:46 +01:00
isUnknown
c053ce2d27 script d'envoi de newsletter en masse terminé 2025-02-06 17:28:09 +01:00
isUnknown
2e718f61ae nav : fix subtitle size 2025-02-06 15:37:36 +01:00
isUnknown
bd2503fb52 fix cedric problem wekbit line clamp 2025-02-06 15:36:45 +01:00
isUnknown
62c13dccee formulation 2025-02-06 13:28:38 +01:00
isUnknown
dd4be3e2fb fix date mistake 2025-02-06 13:16:29 +01:00
isUnknown
1cd5360a1a nav : mettre publication à venir plutôt que la date de publication 2025-02-06 13:14:57 +01:00
isUnknown
f903d12369 nav subtitles : adjust spacing 2025-02-06 13:06:01 +01:00
isUnknown
d54ed28aa0 nav - show subtitles 2025-02-06 13:03:33 +01:00
isUnknown
3a6ff118bc comment text footer button 2025-02-06 12:57:13 +01:00
isUnknown
3dd3286bb8 display footer on home page only 2025-02-06 11:38:29 +01:00
isUnknown
4f6e32bfaa send newsletter working through API for one adress 2025-02-05 16:01:16 +01:00
isUnknown
08323f4a02 remove composer install ci 2025-02-05 15:49:45 +01:00
isUnknown
b6df7adc6a add composer install to ci 2025-02-05 15:48:29 +01:00
isUnknown
cb5e08d3fc try send emails through mailerSend API 2025-02-05 15:44:09 +01:00
isUnknown
04b04ccf6a give access to texts 2025-02-05 15:02:44 +01:00
isUnknown
9b9e5a2ef7 batch email send 2025-02-05 13:51:13 +01:00
isUnknown
dde483ee20 fix mobile footer 2025-02-04 19:23:27 +01:00
isUnknown
669a35daaf fix unsubscribe route 2025-02-04 19:11:10 +01:00
isUnknown
0e7fa10ab3 fix unsuscribe route 2025-02-04 19:07:25 +01:00
isUnknown
5f3a577c1c update plugin 2025-02-04 19:00:47 +01:00
isUnknown
f76e3d9fb3 newsletter : handle errors 2025-02-04 19:00:27 +01:00
isUnknown
72fff85a8c change SMTP config 2025-02-04 18:17:31 +01:00
isUnknown
2ad2c593ce enable security 2025-02-04 17:41:02 +01:00
isUnknown
f095954a33 add unsubscribe and view mail on the web buttons 2025-02-04 17:35:09 +01:00
isUnknown
8bdd63afe2 fix html 2025-02-04 10:12:01 +01:00
isUnknown
416d8e94b3 add favicon 2025-02-04 09:54:20 +01:00
isUnknown
b2853043c8 disable ragadjust for h3 nodes 2025-02-04 09:33:15 +01:00
isUnknown
7d577654a4 fix nav panel texts list spacing 2025-02-03 16:43:24 +01:00
isUnknown
be631dbe67 save 2025-02-03 16:39:43 +01:00
isUnknown
84a8437ad2 fix year and panel texts list spacing 2025-02-03 16:36:42 +01:00
isUnknown
fc267d80d1 fix #15 2025-02-03 16:32:36 +01:00
isUnknown
1e91353c86 #16 2025-02-03 15:29:26 +01:00
isUnknown
3fd93ac292 #14 2025-02-03 15:18:39 +01:00
isUnknown
172923fc6d #11 2025-02-03 15:15:13 +01:00
isUnknown
1c2c45e5ab #26 2025-02-03 14:58:37 +01:00
isUnknown
4cdb92017b #9 2025-02-03 14:36:43 +01:00
isUnknown
4172bba4cc #10 2025-02-03 14:06:04 +01:00
isUnknown
979106e8eb #11 2025-02-03 13:48:12 +01:00
isUnknown
69f4cddd00 fix #11 2025-02-03 13:32:17 +01:00
isUnknown
5d927217f9 fix #13 2025-02-03 12:16:37 +01:00
isUnknown
5d09b519c3 fix #17 2025-02-03 12:13:52 +01:00
isUnknown
32b81867e5 fix #12 2025-02-03 12:05:07 +01:00
isUnknown
c4048f8a60 fix #18 2025-02-03 12:03:04 +01:00
isUnknown
9d3d534de3 fix #18 2025-02-03 12:01:13 +01:00
isUnknown
40685d633b refactoring 2025-02-03 10:01:50 +01:00
isUnknown
2af1980274 hide text button 2025-02-03 09:38:55 +01:00
isUnknown
cc3feb0366 fix #23 2025-02-03 09:35:57 +01:00
isUnknown
f1e85efcf9 fix email validation problem 2025-01-28 15:40:24 +01:00
isUnknown
aa237d8628 envoi des mails un par un pour ne pas partager toutes les adresses 2025-01-28 15:31:46 +01:00
isUnknown
b1923e8d4c send email - do not change status if test 2025-01-28 15:06:22 +01:00
isUnknown
afd3df123f send email v1 2025-01-28 14:55:10 +01:00
isUnknown
fe4df9cbd3 fix request -> data 2025-01-28 13:12:23 +01:00
isUnknown
49d48015a8 newsletter config try 2025-01-28 13:10:11 +01:00
isUnknown
7e5872aad1 fix #20 2025-01-27 07:57:48 +01:00
isUnknown
5c467757fe fix #21 2025-01-26 14:44:21 +01:00
isUnknown
b1418ef194 fix #24 2025-01-26 13:11:06 +01:00
isUnknown
aea050f6cc fix #14 2025-01-26 13:00:50 +01:00
isUnknown
6801286c5f fix #16 2025-01-26 12:22:57 +01:00
isUnknown
e3829df88c add + to footnotes links 2024-12-02 17:48:21 +01:00
isUnknown
fdd1650343 cover > title : add title attribute 2024-12-02 17:43:23 +01:00
isUnknown
affda01429 fix pointer events 2024-12-02 17:40:02 +01:00
isUnknown
9aee1ad5b5 fix main > article pointer event 2024-12-02 17:31:52 +01:00
isUnknown
7cda66f837 mobile grid : disable grid 2024-12-02 17:00:28 +01:00
isUnknown
cab0bb1ca4 tune grid layout 2024-12-02 16:52:35 +01:00
isUnknown
5252d51633 improve mobile footer 2024-12-02 16:39:38 +01:00
isUnknown
af1ffe78c6 add bottom margin 2024-12-02 15:30:24 +01:00
isUnknown
9b1aeb5cd0 create body reusable field 2024-12-01 13:57:24 +01:00
isUnknown
1b62fa7589 prepare subscription page 2024-12-01 12:55:24 +01:00
isUnknown
4c5b7677d7 improve home 2024-12-01 12:37:11 +01:00
isUnknown
8e09bceb02 home : center texts button 2024-12-01 12:16:27 +01:00
isUnknown
e3207ee58e aad fields 2024-11-30 18:49:45 +01:00
isUnknown
6f3774a5d7 new design 2024-11-30 18:18:43 +01:00
141 changed files with 14643 additions and 1357 deletions

BIN
apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View file

@ -8,3 +8,18 @@
.k-panel-menu-button[aria-current] * {
color: #000;
}
.k-panel[data-template="year"] .k-list-items .k-item:first-child {
margin-bottom: 2rem;
}
.k-panel[data-template="year"] .k-list-items::before {
content: "Texte princeps";
display: block;
padding: 0.5rem;
padding-bottom: 0.7rem;
border-radius: var(--rounded-md) var(--rounded-md) 0 0;
transform: translateY(0.3rem);
background-color: #000;
color: #fff;
}

View file

@ -1,8 +1,39 @@
article #main-content {
max-width: calc(18 * var(--unit--horizontal));
scroll-margin-block-start: calc(var(--unit--vertical) * 6);
margin-top: calc(var(--unit--vertical) * 2);
padding-bottom: calc(var(--unit--vertical) * 2);
margin-top: calc(2 * var(--unit--vertical));
}
#main-content .texts {
margin-top: calc(2 * var(--unit--vertical));
}
#main-content .see-more {
margin-top: calc(var(--unit--vertical) * 0.5);
}
[data-template="year"] article > h1 {
margin-bottom: 0;
}
article #main-content #chapo::after {
content: "";
display: block;
margin-top: calc(2 * var(--unit--vertical));
margin-bottom: calc(4 * var(--unit--vertical));
width: 100%;
height: 1px;
background-color: #fff;
}
article #main-content li:not(.text) {
list-style-type: inherit;
}
article h3 {
margin-top: calc(3 * var(--unit--vertical));
margin-bottom: calc(1 * var(--unit--vertical));
}
@media screen and (min-width: 640px) {

View file

@ -1,36 +1,72 @@
#main-footer {
position: fixed;
bottom: 0;
width: 100%;
box-sizing: border-box;
padding: var(--unit--horizontal);
border-bottom: 0;
}
[data-template="home"] #main-footer {
position: fixed;
left: 0;
bottom: 0;
}
#main-footer ul {
display: flex;
justify-content: center;
gap: calc(2 * var(--unit--horizontal));
#main-footer li:not(.open-nav-wrapper) {
display: none;
}
#main-footer a {
text-shadow: 0 0 2px #000;
-moz-text-shadow: 0 0 2px #000;
-webkit-text-shadow: 0 0 2px #000;
#main-footer button.open-nav {
transform: translateY(-1px);
}
[data-template="home"] .title-wrapper button.open-nav {
display: inline-block !important;
}
@media screen and (max-width: 640px) {
#main-footer .open-nav {
box-sizing: border-box;
bottom: 0;
display: flex;
justify-content: center;
width: 100%;
outline: none;
border-top: 1px solid #fff;
font-size: var(--font-size-m);
background-color: #000;
padding: calc(var(--unit--vertical) / 2) var(--unit--horizontal);
margin-bottom: env(safe-area-inset-bottom);
}
}
@media screen and (min-width: 640px) {
#main-footer li {
display: block !important;
}
#main-footer {
position: fixed;
left: 0;
bottom: 0;
width: var(--body-padding);
background-color: transparent;
padding: var(--unit--vertical) var(--unit--horizontal);
}
#main-footer ul {
display: block;
}
#main-footer button.open-nav {
margin-bottom: var(--unit--vertical);
}
[data-template="home"] #main-footer .open-nav-wrapper {
display: none !important;
}
.open-nav-wrapper {
padding: 0;
border: none;
background-color: transparent;
}
}

View file

@ -74,6 +74,18 @@ body {
opacity: var(--opacity-light);
}
.footnote::before {
content: "[";
}
.footnote::after {
content: "]";
}
/* ================= COLORS ================= */
.texts .text {
margin-bottom: var(--unit--vertical);
}
/* ================= COLORS ================= */
.color {
color: var(--color);
@ -84,7 +96,8 @@ body {
/* ================= BUTTONS ================= */
.toggle-btn--left::after,
button.plus::after {
button.plus::after,
button.less::after {
margin-left: var(--unit--horizontal);
}
@ -92,7 +105,8 @@ button.plus::after {
button.plus::after {
content: "+";
}
.toggle-btn--left.open::after {
.toggle-btn--left.open::after,
button.less::after {
content: "-";
}
.toggle-btn--right::before {
@ -108,3 +122,19 @@ button.plus::after {
.transition {
transition: all 0.5s var(--curve-sine);
}
.short {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow: hidden;
/* Do not remove : required not to crop letters descenders */
padding-bottom: 0.16rem;
height: 8.5rem;
}
.see-more {
width: 100%;
margin-top: calc(var(--unit--vertical) / 2);
}

View file

@ -13,13 +13,6 @@ body.full-width #main-content {
box-sizing: border-box;
}
.grid {
display: grid;
grid-template-columns: repeat(39, 1fr);
column-gap: var(--unit--horizontal);
margin-bottom: calc(var(--unit--vertical) * 2);
}
.grid .column {
grid-column: span var(--span);
}
@ -27,3 +20,26 @@ body.full-width #main-content {
.grid[data-columns="1"] .column {
grid-column: 11 / span 20;
}
[data-template="grid"] main #main-content {
margin-left: inherit;
}
@media screen and (max-width: 640px) {
.grid .column:not(:last-child) {
margin-bottom: calc(2 * var(--unit--vertical));
}
}
@media screen and (min-width: 640px) {
[data-template="grid"] main #main-content {
margin-left: calc(0px - calc(4 * var(--unit--horizontal)));
}
[data-template="grid"] .grid {
display: grid;
grid-template-columns: repeat(39, 1fr);
column-gap: var(--unit--horizontal);
margin-bottom: calc(var(--unit--vertical) * 2);
}
}

View file

@ -1,12 +1,29 @@
main {
pointer-events: none;
}
main article > div {
pointer-events: all;
}
.page-cover > * {
pointer-events: all;
}
#main-header {
position: fixed;
z-index: 2;
box-sizing: border-box;
width: 100vw;
padding-top: calc(var(--unit--vertical) / 2);
}
[data-template="home"] #main-header {
pointer-events: none;
}
#logo * {
font-size: 26.65vw;
font-size: 25.3vw;
font-weight: var(--font-weight-extra-bold);
}
@ -14,7 +31,7 @@
height: 20vw;
width: 100%;
box-sizing: border-box;
padding-right: 1vw;
padding-right: 3vw;
display: flex;
justify-content: flex-end;
align-items: center;
@ -24,6 +41,10 @@
text-align: right;
}
#logo #actuel {
font-weight: 550;
}
#logo #actuel,
#logo #inactuel {
mix-blend-mode: difference;
@ -35,82 +56,91 @@
#main-header.minimized #inactuel {
margin-top: -20vw;
transform: translateX(-2px) translateY(-2px);
transform: translateX(-1px) translateY(-1px);
}
#main-header.minimized #inactuel:not([data-template="home"] *) {
transform: translateX(-1px) translateY(-1px) !important;
}
.page-cover,
article > h1 {
padding-top: calc(50vw);
}
.page-cover {
position: relative;
height: 100svh;
box-sizing: border-box;
padding-top: calc(var(--unit--vertical-relative) * 9);
display: flex;
flex-direction: column;
}
[data-template="home"] .page-cover {
padding-top: calc(42.5vw);
}
.page-cover .title-wrapper h1 {
margin-bottom: calc(0.255 * var(--unit--vertical));
}
.page-cover .text-wrapper {
height: 100%;
overflow: auto;
}
/* ================= ENTRY BTNS ================= */
#entry-btns {
position: sticky;
position: -webkit-sticky;
top: calc(var(--unit--vertical) * 4);
height: var(--entry-btns-height);
display: flex;
justify-content: space-between;
}
[data-template="info"] #entry-btns {
top: calc(var(--unit--vertical) * 4);
}
[data-template="home"] .entry-btn {
align-items: start;
}
button.toggle.left::after,
button.toggle.right::before {
transition: all 0.5s var(--curve-sine);
content: "+";
}
#entry-btns.minimized {
color: #000;
}
#entry-btns.minimized .entry-btn--left::before,
#entry-btns.minimized .entry-btn--right::after {
background-color: var(--color-secondary);
.page-cover .links {
position: absolute;
bottom: 8px;
width: 100%;
box-sizing: border-box;
}
#entry-btns.minimized .entry-btn::before,
#entry-btns.minimized .entry-btn::after {
font-weight: bold;
.page-cover .links li {
display: inline-block;
margin-right: var(--unit--horizontal);
}
#entry-btns.minimized .entry-btn--left {
padding-right: 4px;
margin-left: calc(-4px - var(--width));
[data-template="author"] .page-cover,
[data-template="category"] .page-cover,
[data-template="year"] .page-cover,
[data-template="email"] .page-cover,
[data-template="error"] .page-cover,
[data-template="info"] .page-cover {
height: initial !important;
}
#entry-btns.minimized .entry-btn--right {
padding-left: 4px;
margin-right: calc(-4px - var(--width));
[data-template="author"] .page-cover .links,
[data-template="category"] .page-cover .links,
[data-template="year"] .page-cover .links,
[data-template="email"] .page-cover .links,
[data-template="error"] .page-cover .links,
[data-template="info"] .page-cover .links {
display: none;
}
@media screen and (min-width: 640px) {
body:not([data-template="home"]) #main-header {
width: var(--body-padding);
}
#logo * {
font-size: 26.65vw;
}
#logo span {
padding-right: 1vw;
}
body:not([data-template="home"]) #logo * {
font-size: 6vw;
font-size: 5.6vw;
}
body:not([data-template="home"]) #logo span {
height: 5vw;
padding-right: calc(var(--unit--horizontal) - 0.3vw);
}
body:not([data-template="home"]) #main-header.minimized #inactuel {
margin-top: -4.9vw;
@ -123,20 +153,21 @@ button.toggle.right::before {
[data-template="category"] .page-cover
) {
height: 100vh;
padding: calc(10 * var(--unit--vertical)) 0;
padding-top: calc(var(--unit--vertical) * 8);
}
.page-cover,
article > h1 {
padding-top: 15vw;
}
.page-cover {
min-height: calc(22 * var(--unit--vertical));
}
[data-template="home"] .page-cover {
padding-top: calc(42.5vw) !important;
}
[data-template="author"] .page-cover,
[data-template="category"] .page-cover,
[data-template="year"] .page-cover {
height: initial;
}
#entry-btns {
.page-cover .links {
display: none;
}
}

View file

@ -1 +1,9 @@
.main-edito-btn {
display: inline-block;
margin-right: calc(2 * var(--unit--horizontal));
}
#main-edito {
margin-top: calc(var(--unit--vertical) * 4);
scroll-margin-block-start: 25vw;
}

View file

@ -8,10 +8,11 @@ body {
main {
padding: 0 var(--unit--horizontal);
padding-bottom: calc(2 * var(--unit--vertical)) !important;
}
[data-template="info"] main {
/* [data-template="info"] main {
margin-top: calc(var(--unit--vertical) * 2);
}
} */
hr {
height: 1px;
@ -38,7 +39,7 @@ html {
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
scrollbar-color: rgba(255, 255, 255) transparent;
}
/* Works on Chrome, Edge, and Safari */
@ -53,7 +54,7 @@ html {
}
*::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.25);
background-color: rgba(255, 255, 255);
border-radius: 0px;
border: none;
}
@ -69,6 +70,7 @@ html {
}
main {
padding: 0 var(--body-padding);
width: min(60vw, 45rem);
padding-left: var(--body-padding);
}
}

View file

@ -1,4 +1,3 @@
/* ================= PANELS ================= */
#nav-overlay {
position: fixed;
inset: 0;
@ -105,6 +104,9 @@ button.search__icon {
padding: var(--unit--vertical) var(--unit--horizontal);
}
footer {
width: 100%;
}
.panel-close {
position: fixed;
box-sizing: border-box;
@ -134,15 +136,15 @@ button.search__icon {
}
/* ================= YEARS ================= */
.panel__collection .panel__item:last-child {
margin-bottom: 6rem;
}
.panel-item-content__edito {
margin-bottom: calc(var(--unit--vertical) / 2);
}
.panel-item-content__edito.short {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow: hidden;
.panel-item-content__edito p:not(:last-child) {
margin-bottom: var(--unit--vertical);
}
button.see-more {
@ -154,43 +156,37 @@ button.see-more {
}
/* ================= TEXT ITEM ================= */
.text {
margin-bottom: var(--unit--vertical);
.panel .text:first-child,
.panel .text:last-child {
margin-bottom: calc(var(--unit--vertical) * 2);
}
.panel__collection--years .text:first-child .text__title {
display: inline-block;
padding-left: var(--unit--horizontal);
}
.panel .text__subtitle {
margin-bottom: calc(var(--unit--vertical) / 4);
}
.panel__collection--years .text:first-child .text__infos {
padding-left: var(--unit--horizontal);
}
.panel__collection--years .text:first-child .text__infos::before {
content: "";
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 1px;
background-color: #fff;
}
@media screen and (min-width: 640px) {
nav.panel {
width: 40rem;
}
#subscribe-btn-wrapper {
height: 2.5rem;
margin-bottom: 0.2rem;
}
#subscribe-form {
position: relative;
width: fit-content;
}
#subscribe-form input {
background-color: transparent;
border: none;
border-bottom: 1px solid #fff;
box-sizing: border-box;
padding-bottom: 0.3rem;
padding-right: 2rem;
color: #fff;
width: 15rem;
}
#subscribe-form input:focus-visible {
outline: none;
}
#subscribe-form button[type="submit"] {
color: #fff;
position: absolute;
top: 0;
right: 0;
}
.panel {
z-index: 4;

View file

@ -0,0 +1,29 @@
#subscribe-btn-wrapper {
height: 2.5rem;
margin-bottom: 0.2rem;
}
#subscribe-form {
position: relative;
width: fit-content;
}
#subscribe-form input {
background-color: transparent;
border: none;
border-bottom: 1px solid #fff;
box-sizing: border-box;
padding-bottom: 0.3rem;
padding-right: 2rem;
color: #fff;
width: 15rem;
}
#subscribe-form input:focus-visible {
outline: none;
}
#subscribe-form button[type="submit"] {
color: #fff;
position: absolute;
top: 0;
right: 0;
}

View file

@ -76,18 +76,6 @@ button.toggle.right.open::before {
border: none;
}
.texts__year.short .year__edito {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
overflow: hidden;
}
.texts__year .see-more {
width: 100%;
margin-top: calc(var(--unit--vertical) / 2);
}
@media screen and (min-width: 640px) {
[data-template="home"] #tabs {
margin-top: calc(0px - (10 * var(--unit--vertical)));

View file

@ -28,12 +28,12 @@ h4 *,
h5,
h5 *,
p,
p * {
p *:not(strong) {
font-weight: var(--font-weight-light);
line-height: 1;
}
h2 {
h1 {
font-size: var(--font-size-xl);
}
@ -87,11 +87,11 @@ button,
}
.fs-l {
font-size: var(--font-size-l) !important;
line-height: var(--unit--vertical);
line-height: calc(var(--unit--vertical) * 1.3);
}
.fs-xl {
font-size: var(--font-size-xl) !important;
line-height: calc(var(--unit--vertical) * 1.5) !important;
line-height: calc(var(--unit--vertical) * 2) !important;
}
.fs-xxl {
font-size: var(--font-size-xxl) !important;
@ -137,6 +137,14 @@ button {
align-items: center;
}
a {
word-break: break-word;
}
a > * {
word-break: normal;
}
a * {
transition: font 0.2s ease-in-out;
}
@ -166,14 +174,29 @@ article p:not(:last-child) {
.footnote,
.footnote * {
font-style: normal !important;
scroll-margin-block-start: calc(var(--unit--vertical) * 6);
color: var(--color-secondary);
color: var(--color-primary);
text-decoration: none !important;
font-weight: var(--font-weight-bold) !important;
}
.footnote sup {
all: unset;
}
.footnote:focus-visible,
.footnote:target {
text-decoration: underline !important;
text-underline-offset: 0.5rem;
}
@media screen and (min-width: 640px) {
.title-center {
margin-left: calc(2 * var(--unit--horizontal));
}
.fs-l {
line-height: calc(var(--unit--vertical) * 1.5);
}
}

View file

@ -8,6 +8,7 @@
@import url("src/virtual.css");
@import url("src/home.css");
@import url("src/grid.css");
@import url("src/newsletter.css");
@import url("src/footer.css");
@import url("src/print.css");
:root {

202
assets/dist/script.js vendored
View file

@ -1,63 +1,181 @@
"use strict";
var remFactor = 16;
var verticalUnit = 1.3 * remFactor;
function toggleTab(data, tab) {
if (data.activeTab === tab) {
window.scrollTo({
top: 0,
behavior: "smooth"
});
setTimeout(function () {
data.isOpen = false;
data.activeTab = "";
}, 500);
} else {
data.activeTab = tab;
data.isOpen = true;
scrollToElem(".active-tab");
}
var verticalUnit = getUnit("--unit--vertical");
function getUnit(id) {
var remFactor = 16;
var rawUnit = getComputedStyle(document.documentElement).getPropertyValue(id) || "1.7rem";
var remUnit = parseFloat(rawUnit);
var pxUnit = remUnit * remFactor;
return pxUnit;
}
function scrollToElem(selector) {
document.querySelector(".active-tab").scrollTop = 0;
setTimeout(function () {
var yOffset = -7 * verticalUnit;
var elem = document.querySelector(selector);
var top = elem.getBoundingClientRect().top;
window.scrollTo({
top: top + window.scrollY + yOffset,
behavior: "smooth"
});
}, 100);
// Throttle found here : https://gist.github.com/ionurboz/51b505ee3281cd713747b4a84d69f434
function throttle(func, wait, options) {
var context, args, result;
var timeout = null;
var previous = 0;
if (!options) options = {};
var later = function later() {
previous = options.leading === false ? 0 : Date.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function () {
var now = Date.now();
if (!previous && options.leading === false) previous = now;
var remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
}
function setWindowHeightFactor() {
var windowHeight = window.innerHeight;
var min = 650;
var delta = windowHeight - min;
var factor = roundToNearestHalf(delta / 300) + 1;
var head = document.querySelector("head");
var style = document.createElement("style");
style.innerText = ":root { --window-height-factor:".concat(factor, " }");
head.appendChild(style);
document.querySelector(":root").style.setProperty("--window-height-factor", factor);
}
function roundToNearestHalf(num) {
var round = Math.round(num * 2) / 2;
return Math.max(round, 0);
}
setWindowHeightFactor();
document.addEventListener("DOMContentLoaded", function () {
function toggleLogoState() {
var scrollY = window.scrollY || window.pageYOffset;
if (scrollY > 10) {
document.querySelector("#main-header").classList.add("minimized");
} else {
document.querySelector("#main-header").classList.remove("minimized");
}
function toggleLogoState() {
var scrollY = window.scrollY || window.pageYOffset;
if (scrollY > 10) {
document.querySelector("#main-header").classList.add("minimized");
} else {
document.querySelector("#main-header").classList.remove("minimized");
}
}
function toggleFooterState() {
if (scrollY > 90) {
document.querySelector("#main-footer").classList.add("main-footer--background");
} else {
document.querySelector("#main-footer").classList.remove("main-footer--background");
}
}
function fixFootNotes() {
var footnotes = document.querySelectorAll('a[href^="#sdfootnote"]');
footnotes.forEach(function (footnote) {
var href = footnote.href;
footnote.classList.add("footnote");
if (href.includes("sym")) {
footnote.id = footnote.hash.replace("sym", "anc").replace("#", "");
} else if (href.includes("anc")) {
footnote.id = footnote.hash.replace("anc", "sym").replace("#", "");
}
});
}
function removeAccents(str) {
var from = "áäâàãåčçćďéěëèêẽĕȇíìîïňñóöòôõøðřŕšťúůüùûýÿžþÞĐđ߯a·/_,:;";
var to = "aaaaaacccdeeeeeeeeiiiinnooooooorrstuuuuuyyzbBDdBAa------";
for (var i = 0, l = from.length; i < l; i++) {
str = str.replace(new RegExp(from.charAt(i), "g"), to.charAt(i));
}
return str;
}
function slugify(str) {
return removeAccents(str.toLowerCase());
}
function subscribe(event) {
event.preventDefault();
var emailInput = document.querySelector("#subscribe-form input");
if (emailInput.value.toLowerCase().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
var header = {
method: "POST",
body: JSON.stringify(emailInput.value)
};
fetch("/subscribe.json", header).then(function (res) {
return res.json();
}).then(function (data) {
var formNode = emailInput.parentNode.parentNode;
formNode.outerHTML = "<p>" + data.message + "</p>";
});
} else {
emailInput.value = "E-mail invalide. Recommencez.";
}
}
var panelNav = document.querySelector(".panel");
var navOverlay = document.querySelector("#nav-overlay");
var openNavBtn = document.querySelector("button.open-nav");
var closeNavBtn = document.querySelector(".panel-close");
function closeNav() {
panelNav.classList.remove("panel--visible");
navOverlay.classList.remove("nav-overlay--visible");
document.body.classList.remove("no-scroll");
}
document.addEventListener("DOMContentLoaded", function () {
ragadjust("h1, h2, h4, h5", ["all"]);
window.window.scrollTo({
top: 0
});
window.addEventListener("scroll", function () {
var handleScroll = throttle(function () {
toggleLogoState();
if (window.innerWidth <= 680) {
toggleFooterState();
}
}, 100);
window.addEventListener("scroll", handleScroll);
setWindowHeightFactor();
window.addEventListener("resize", function () {
setWindowHeightFactor();
});
fixFootNotes();
window.addEventListener("keyup", function (event) {
if (event.key === "Escape") {
closeNav();
}
});
document.querySelectorAll(".panel").forEach(function (panel) {
panel.addEventListener("click", function (event) {
event.stopPropagation();
});
});
var navSortBtns = document.querySelectorAll("nav .sort-btn");
var navSections = document.querySelectorAll(".panel__all-texts, .panel__collection");
navSortBtns.forEach(function (sortBtn) {
sortBtn.addEventListener("click", function () {
navSortBtns.forEach(function (btn) {
return btn.classList.remove("active");
});
sortBtn.classList.add("active");
var sections = {
"sort-btn--all": ".panel__all-texts",
"sort-btn--years": ".panel__collection--years",
"sort-btn--categories": ".panel__collection--categories"
};
navSections.forEach(function (navSection) {
return navSection.classList.add("hidden");
});
Object.keys(sections).forEach(function (key) {
if (sortBtn.classList.contains(key)) {
document.querySelector(sections[key]).classList.remove("hidden");
}
});
});
});
openNavBtn.addEventListener("click", function () {
panelNav.classList.add("panel--visible");
navOverlay.classList.add("nav-overlay--visible");
document.body.classList.add("no-scroll");
});
closeNavBtn.addEventListener("click", function () {
closeNav();
});
navOverlay.addEventListener("click", function () {
closeNav();
});
});

475
assets/dist/style.css vendored

File diff suppressed because one or more lines are too long

View file

@ -9,16 +9,36 @@ function getUnit(id) {
return pxUnit;
}
function throttle(callback, limit) {
let waiting = false;
// Throttle found here : https://gist.github.com/ionurboz/51b505ee3281cd713747b4a84d69f434
function throttle(func, wait, options) {
var context, args, result;
var timeout = null;
var previous = 0;
if (!options) options = {};
var later = function () {
previous = options.leading === false ? 0 : Date.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function () {
if (!waiting) {
callback.apply(this, arguments);
waiting = true;
setTimeout(function () {
waiting = false;
}, limit);
var now = Date.now();
if (!previous && options.leading === false) previous = now;
var remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
}
@ -47,17 +67,25 @@ function toggleLogoState() {
document.querySelector("#main-header").classList.remove("minimized");
}
}
function toggleFooterState() {
if (scrollY > 90) {
document.querySelector(".open-nav-wrapper").classList.remove("hidden");
} else {
document.querySelector(".open-nav-wrapper").classList.add("hidden");
}
}
function fixFootNotes() {
const footnotes = document.querySelectorAll('a[href^="#sdfootnote"]');
footnotes.forEach((footnote) => {
const href = footnote.href;
footnote.classList.add("footnote");
if (href.includes("sym")) {
footnote.id = footnote.hash.replace("sym", "anc").replace("#", "");
}
if (href.includes("anc")) {
} else if (href.includes("anc")) {
footnote.id = footnote.hash.replace("anc", "sym").replace("#", "");
}
});
@ -76,38 +104,30 @@ function slugify(str) {
return removeAccents(str.toLowerCase());
}
const subscribeBtn = document.querySelector("#subscribe-btn");
function showSubscribeField(event) {
event.preventDefault();
const button = event.target;
const li = button.parentNode;
const form = li.querySelector("#subscribe-form");
const input = form.querySelector("input");
form.classList.remove("hidden");
button.classList.add("hidden");
input.focus();
}
function subscribe(event) {
event.preventDefault();
const email = document.querySelector("#subscribe-form input");
const emailInput = document.querySelector("#subscribe-form input");
if (email.value.toLowerCase().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
if (emailInput.value.toLowerCase().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
const header = {
method: "POST",
body: email.value,
body: JSON.stringify(emailInput.value),
};
fetch("/subscribe.json");
fetch("/subscribe.json", header)
.then((res) => res.json())
.then((data) => {
const formNode = emailInput.parentNode.parentNode;
formNode.outerHTML = "<p>" + data.message + "</p>";
});
} else {
email.value = "E-mail invalide. Recommencez.";
emailInput.value = "E-mail invalide. Recommencez.";
}
}
const panelNav = document.querySelector(".panel");
const navOverlay = document.querySelector("#nav-overlay");
const openNavBtn = document.querySelector("button.open-nav");
const openNavBtns = document.querySelectorAll("button.open-nav");
const closeNavBtn = document.querySelector(".panel-close");
function closeNav() {
panelNav.classList.remove("panel--visible");
@ -116,14 +136,18 @@ function closeNav() {
}
document.addEventListener("DOMContentLoaded", () => {
ragadjust("h1, h2, h3, h4, h5", ["all"]);
ragadjust("h1, h2, h4, h5", ["all"]);
window.window.scrollTo({
top: 0,
});
window.addEventListener("scroll", () => {
const handleScroll = throttle(() => {
toggleLogoState();
});
if (window.innerWidth <= 680) {
toggleFooterState();
}
}, 100);
window.addEventListener("scroll", handleScroll);
setWindowHeightFactor();
window.addEventListener("resize", () => {
@ -169,10 +193,12 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
openNavBtn.addEventListener("click", () => {
panelNav.classList.add("panel--visible");
navOverlay.classList.add("nav-overlay--visible");
document.body.classList.add("no-scroll");
openNavBtns.forEach((openNavBtn) => {
openNavBtn.addEventListener("click", () => {
panelNav.classList.add("panel--visible");
navOverlay.classList.add("nav-overlay--visible");
document.body.classList.add("no-scroll");
});
});
closeNavBtn.addEventListener("click", () => {
@ -181,6 +207,4 @@ document.addEventListener("DOMContentLoaded", () => {
navOverlay.addEventListener("click", () => {
closeNav();
});
subscribeBtn.addEventListener("click", showSubscribeField);
});

View file

@ -23,11 +23,21 @@
},
"require": {
"php": "~8.1.0 || ~8.2.0 || ~8.3.0",
"getkirby/cms": "^4.0"
"getkirby/cms": "^4.5",
"symfony/http-client": "^7.2",
"nyholm/psr7": "^1.8",
"php-http/guzzle7-adapter": "^1.1",
"mailersend/mailersend": "^0.28.0",
"sylvainjule/code-editor": "^1.0",
"tobimori/kirby-seo": "^1.1"
},
"config": {
"platform": {
"php": "8.3.0"
},
"allow-plugins": {
"getkirby/composer-installer": true
"getkirby/composer-installer": true,
"php-http/discovery": true
},
"optimize-autoloader": true
},

2333
composer.lock generated

File diff suppressed because it is too large Load diff

BIN
favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

17
favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

21
site.webmanifest Normal file
View file

@ -0,0 +1,21 @@
{
"name": "actuel-inactuel",
"short_name": "a-i",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone"
}

View file

@ -0,0 +1,8 @@
label: Corps
type: writer
headings:
- 3
marks:
- bold
- italic
- link

View file

@ -9,11 +9,16 @@ tabs:
type: fields
fields:
presentation:
extends: fields/body
label: Présentation
type: writer
help: Optionnelle
help: |
Optionnelle, sans mention du poste. Peut inclure un lien.
Exemple : "Co-fondateur des éditions [Athom](http://www.athom.xyz/)."
texts:
label: Textes
type: pages
create: false
query: page.getTexts()
seo:
extends: seo/page
label: Indexation

View file

@ -7,3 +7,6 @@ tabs:
label: Liste
type: pages
template: author
seo:
extends: seo/page
label: Indexation

View file

@ -6,21 +6,41 @@ image:
back: black
color: white
icon: email
options:
changeStatus: false
tabs:
contentTab:
fields:
month:
label: Mois
type: date
display: "mmmm YYYY"
default: today
width: 1/4
sendBtn:
type: send-button
width: 1/4
content:
label: Contenu
type: writer
label: Contenu
icon: text
columns:
- width: 1/1
fields:
body:
label: Contenu
type: writer
sendTab:
label: Envoi
icon: plane
columns:
- width: 1/4
fields:
published:
label: Date d'envoi
type: date
help: Automatiquement rempli au moment de l'envoi.
disabled: true
width: 1/2
- width: 1/2
fields:
testAdressList:
label: Liste d'adresses de test
type: structure
sortBy: email asc
help: Le bouton "Tester" ci-dessous tentera d'envoyer l'email aux adresses de cette liste
fields:
email:
type: email
sendBtn:
type: send-button
seo:
extends: seo/page
label: Indexation

View file

@ -18,6 +18,17 @@ tabs:
fullWidth:
label: Pleine largeur
type: toggle
subtitle:
label: Sous-titre
type: writer
help: optionnel
marks:
- italic
node: false
chapo:
label: Chapo
extends: fields/body
help: optionnel
body:
label: Corps
type: layout
@ -31,4 +42,9 @@ tabs:
- image
- line
- quote
metaTab: tabs/meta
seo:
extends: seo/page
label: Indexation

View file

@ -15,6 +15,7 @@ tabs:
label: Corps
type: fields
fields:
body:
label: Corps
type: writer
body: fields/body
seo:
extends: seo/page
label: Indexation

View file

@ -15,7 +15,36 @@ tabs:
label: Corps
type: fields
fields:
body:
label: Corps
subtitle:
label: Sous-titre
type: writer
help: optionnel
marks:
- italic
node: false
chapo:
label: Chapo
extends: fields/body
help: optionnel
body:
extends: fields/body
when:
isHtmlMode: false
htmlBody:
label: Corps
type: textarea
buttons:
- headlines
- bold
- italic
- link
when:
isHtmlMode: true
paramsTab: tabs/params
metaTab: tabs/meta
seo:
extends: seo/page
label: Indexation

View file

@ -3,19 +3,26 @@ title: Liste de diffusion
tabs:
content:
columns:
- width: 1/1
fields:
body:
extends: fields/body
- width: 1/2
fields:
subscribers:
label: Abonnés
type: structure
sortBy: email asc
fields:
email:
type: email
- width: 1/2
sections:
newsletters:
label: Emails
label: Lettres
type: pages
template: email
info: "{{ page.month.toDate('M Y') }}"
sortBy: month desc
info: "{{ page.status == 'listed' ? 'envoyée' : 'brouillon' }}"
seo:
extends: seo/page
label: Indexation

View file

@ -1,24 +1,30 @@
title: texts
columns:
- width: 1/2
fields:
categories:
type: tags
- width: 1/2
sections:
yearsSection:
label: Années
type: pages
template: year
sortBy: title desc
- width: 1/1
sections:
allTextsSection:
label: Tous les textes
type: pages
create: false
search: true
query: page.children.children
info: "{{ page.author.toPage.title }} [{{ page.category }}]"
sortBy: modified desc
tabs:
contentTab:
label: Contenu
columns:
- width: 1/2
fields:
categories:
type: tags
- width: 1/2
sections:
yearsSection:
label: Années
type: pages
template: year
sortBy: title desc
- width: 1/1
sections:
allTextsSection:
label: Tous les textes
type: pages
create: false
search: true
query: page.children.children
info: "{{ page.author.toPage.title }} [{{ page.category }}]"
sortBy: modified desc
seo:
extends: seo/page
label: Indexation

View file

@ -4,16 +4,30 @@ image:
back: black
color: white
columns:
- width: 1/3
sections:
texts:
label: Textes
type: pages
templates:
- linear
- grid
- width: 2/3
fields:
edito:
type: writer
tabs:
contentTab:
label: Contenu
columns:
- width: 1/3
sections:
fieldsSection:
type: fields
fields:
openDate:
label: Date d'ouverture
type: date
display: DD/MM/YYYY
texts:
label: Textes
type: pages
help: **Pour réorganiser les textes**, utiliser la poignée ⁝⁝ qui apparait au survol.
templates:
- linear
- grid
- width: 2/3
fields:
edito:
type: writer
seo:
extends: seo/page
label: Indexation

View file

@ -9,6 +9,7 @@ tabs:
type: writer
nodes: false
marks: false
edito:
label: Éditorial
type: writer
edito: fields/body
seo:
extends: seo/site
label: Indexation

View file

@ -3,6 +3,9 @@ sections:
metadata:
type: fields
fields:
keywords:
label: Mots-clés
type: tags
published:
label: Date de publication
type: date

View file

@ -0,0 +1,13 @@
label: Paramètres
fields:
isHtmlMode:
label: Mode HTML
type: toggle
width: 1/3
help: |
Actif : le code HTML du corps sera exécuté.
additionnalCss:
label: CSS additionnel
type: code-editor
help: Le point de rupture mobile / ordinateur se situe à 640px.
width: 2/3

View file

@ -1,25 +0,0 @@
title: Auteur
description: Droits de lecture et écriture de certaines pages.
image:
icon: pen
color: "#FFF"
permissions:
access:
*: false
files:
*: false
languages:
*: false
pages:
*: false
site:
*: false
user:
*: false
users:
*: false
fields:
presentation:
type: writer

View file

@ -1,16 +1,17 @@
<?php
function createEmptyCategories() {
function createEmptyCategories()
{
$categories = new Pages();
foreach (page('textes')->categories()->split() as $categoryName) {
$category = new Page([
'slug' => Str::slug($categoryName),
'slug' => Str::slug($categoryName),
'template' => 'category',
'status' => 'listed',
'content' => [
'status' => 'listed',
'content' => [
'title' => $categoryName,
],
'children' => []
'children' => [],
]);
$categories->add($category);
@ -20,7 +21,8 @@ function createEmptyCategories() {
}
function createCategories() {
function createCategories()
{
$emptyCategories = createEmptyCategories();
foreach (page('textes')->grandChildren() as $text) {
try {

View file

@ -1,25 +1,29 @@
<?php
return array(
return [
'debug' => true,
'panel' => array(
'panel' => [
'menu' => require __DIR__ . '/menu.php',
'css' => 'assets/css/panel.css'
),
'email' => [
'transport' => [
'type' => 'smtp',
'host' => 'smtp.outlook.com',
'port' => 587,
'security' => 'tls',
'auth' => true,
'username' => 'adrien.payet@outlook.com',
'password' => 't8nVpxCpEZcqH8y'
]
'css' => 'assets/css/panel.css',
],
'routes' => array(
require __DIR__ . '/routes/virtual-author.php',
'mailerSendApiKey' => 'mlsn.0a9f20751951e3c2d130b1d6c3749b0a0f5b14f1c52da65a3369d658c736513c',
'email' => [
'transport' => [
'type' => 'smtp',
'host' => 'smtp.mailersend.net',
'port' => 587,
'security' => true,
'auth' => true,
'username' => 'MS_ncQ2K5@actuel-inactuel.fr',
'password' => 'mssp.ou3hOyX.z86org8y2kklew13.raOTfvP',
],
],
'routes' => [
require __DIR__ . '/routes/virtual-category.php',
require __DIR__ . '/routes/send-newsletter.php',
),
);
require __DIR__ . '/routes/subscribe.php',
require __DIR__ . '/routes/virtual-pending.php',
],
'hooks' => [
'page.create:after' => require __DIR__ . '/hooks/prefill-test-adress-list.php',
],
];

View file

@ -0,0 +1,17 @@
<?php
return function ($page) {
if ($page->template() == 'email') {
$list = [
['email' => 'fournelcecile@yahoo.fr'],
['email' => 'payet.adrien@protonmail.com'],
['email' => 'mazet.zaccardelli@free.fr'],
['email' => 'wafaabida@hotmail.com'],
['email' => 'elisegarraud@yahoo.fr'],
['email' => 'pierre-damien.huyghe@univ-paris1.fr'],
];
$page->update([
'testAdressList' => Yaml::encode($list),
]);
}
};

View file

@ -2,62 +2,62 @@
return [
'site' => [
'label' => 'Accueil',
'label' => 'Accueil',
'current' => function ($current) {
$path = Kirby::instance()->request()->path()->toString();
return Str::contains($path, 'site');
}
},
],
'texts' => [
'icon' => 'pen',
'label' => 'Textes',
'link' => 'pages/textes',
'icon' => 'pen',
'label' => 'Textes',
'link' => 'pages/textes',
'current' => function ($current) {
$path = Kirby::instance()->request()->path()->toString();
return Str::contains($path, 'pages/textes');
}
},
],
'authors' => [
'icon' => 'users',
'label' => 'Auteurs',
'link' => 'pages/auteurs',
'icon' => 'users',
'label' => 'Auteurs',
'link' => 'pages/auteurs',
'current' => function ($current) {
$path = Kirby::instance()->request()->path()->toString();
return Str::contains($path, 'pages/auteurs');
}
$path = Kirby::instance()->request()->path()->toString();
return Str::contains($path, 'pages/auteurs');
},
],
'-',
'-',
'infos' => [
'icon' => 'question',
'label' => 'À propos',
'link' => 'pages/a-propos',
'icon' => 'question',
'label' => 'À propos',
'link' => 'pages/a-propos',
'current' => function ($current) {
$path = Kirby::instance()->request()->path()->toString();
return Str::contains($path, 'pages/a-propos');
}
},
],
'newsletter' => [
'icon' => 'email',
'label' => 'Liste de diffusion',
'link' => 'pages/liste-de-diffusion',
'subscription' => [
'icon' => 'email',
'label' => 'Liste de diffusion',
'link' => 'pages/lettre',
'current' => function ($current) {
$path = Kirby::instance()->request()->path()->toString();
return Str::contains($path, 'pages/liste-de-diffusion');
}
},
],
'-',
'-',
'users',
'comments',
'admin' => [
'icon' => 'folder',
'label' => 'Administration',
'link' => 'pages/admin',
'icon' => 'folder',
'label' => 'Administration',
'link' => 'pages/admin',
'current' => function ($current) {
$path = Kirby::instance()->request()->path()->toString();
return Str::contains($path, 'pages/admin');
}
},
],
'-',
'-',

View file

@ -1,24 +0,0 @@
<?php
return [
'pattern' => '/send-newsletter.json',
'method' => 'POST',
'action' => function () {
$jsonRequest = file_get_contents("php://input");
$request = json_decode($jsonRequest);
$kirby = kirby();
try {
$kirby->email([
'from' => "adrien.payet@outlook.com",
'to' => 'payet.adrien@protonmail.com',
'subject' => 'actualités',
'body' => 'Ceci est un test simple.',
]);
return json_encode(['status' => 'success', 'message' => 'Email envoyé avec succès.']);
} catch (Exception $error) {
return json_encode(['status' => 'error', 'message' => $error->getMessage()]);
}
}
];

View file

@ -0,0 +1,43 @@
<?php
return [
'pattern' => '/subscribe.json',
'method' => 'POST',
'action' => function () {
$jsonRequest = file_get_contents('php://input');
$email = Str::lower(json_decode($jsonRequest));
if (V::email($email)) {
kirby()->impersonate('kirby');
$page = page('lettre');
$subscribers = $page->subscribers()->yaml();
$emailExists = in_array(['email' => $email], $subscribers);
if ($emailExists) {
return [
'status' => 'error',
'message' => 'Cet email est déjà inscris.',
];
}
$newSubscriber = ['email' => $email];
$subscribers[] = $newSubscriber;
$page->update([
'subscribers' => $subscribers,
]);
return [
'status' => 'success',
'message' => 'lettre réussie.',
];
} else {
return [
'status' => 'error',
'message' => 'Email invalide.',
];
}
},
];

View file

@ -1,25 +0,0 @@
<?php
use Kirby\Uuid\Uuid;
return [
'pattern' => 'auteurs/(:any)',
'action' => function ($slug) {
$kirby = kirby();
$author = getAuthorBySlug($slug);
return Page::factory(
[
'slug' => '',
'template' => 'author',
'model' => 'authors',
'content' => [
'title' => $author->name(),
'presentation' => $author->presentation(),
'author' => $author->name(),
'uuid' => Uuid::generate(),
]
]
);
}
];

View file

@ -5,18 +5,33 @@ use Kirby\Uuid\Uuid;
return [
'pattern' => 'categories/(:any)',
'action' => function ($category) {
$kirby = kirby();
return Page::factory(
[
'slug' => '',
'template' => 'category',
'model' => 'categories',
'content' => [
'title' => $category,
'uuid' => Uuid::generate(),
]
]
$allTexts = page('textes')->grandChildren();
$textsInCategory = $allTexts->filter(
fn($text) => Str::slug($text->category()) === $category
);
}
$texts = [];
foreach ($textsInCategory as $text) {
$texts[] = (string) $text->uri();
}
try {
$title = $textsInCategory->first()->category();
} catch (\Throwable $th) {
go('/erreur');
}
return Page::factory([
'slug' => Str::slug($category) . '-' . Uuid::generate(),
'template' => 'category',
'model' => 'category',
'content' => [
'title' => $title,
'texts' => Yaml::encode($texts),
'uuid' => Uuid::generate(),
],
]);
},
];

View file

@ -0,0 +1,18 @@
<?php
use Kirby\Uuid\Uuid;
return [
'pattern' => 'a-venir',
'action' => function () {
return Page::factory([
'slug' => 'a-venir',
'template' => 'error',
'content' => [
'title' => 'Textes disponibles à partir du 21 janvier',
'body' => '<p>Rendez-vous vendredi 21 février à partir de 18h, au Centre Césure (ancien campus Censier), 13 rue Santeuil, 75005 Paris - salle Las Vergnas (métro Censier-Daubenton ou Saint-Marcel).</p>',
'uuid' => Uuid::generate(),
],
]);
},
];

View file

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
[*.php]
indent_size = 4

View file

@ -0,0 +1,15 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:vue/recommended",
"prettier"
],
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
}
}

2
site/plugins/code-editor/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.DS_Store
node_modules

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Sylvain Julé
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.

View file

@ -0,0 +1,111 @@
# Kirby Code editor
Code editor field for Kirby 3 and 4.
![screenshot-code-editor](https://user-images.githubusercontent.com/14079751/109679014-7b043800-7b7b-11eb-8c4e-2ae25da8288d.png)
<br/>
## Overview
> This plugin is completely free and published under the MIT license. However, if you are using it in a commercial project and want to help me keep up with maintenance, please consider [making a donation of your choice](https://paypal.me/sylvainjl) or purchasing your license(s) through [my affiliate link](https://a.paddle.com/v2/click/1129/36369?link=1170).
- [1. Installation](#1-installation)
- [2. Setup](#2-setup)
- [3. Options](#3-options)
- [4. Available languages](#4-available-languages)
- [5. License](#5-license)
- [6. Credits](#6-credits)
<br/>
## 1. Installation
Download and copy this repository to ```/site/plugins/code-editor```
Alternatively, you can install it with composer: ```composer require sylvainjule/code-editor```
<br/>
## 2. Setup
This field adds a code editor in the panel:
```yaml
editor:
label: My code editor
type: code-editor
```
<br/>
## 3. Options
| Name | Type | Default | Options | Description |
| -------------------- | ------------------ | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| language | `String` | `'css'` | - | Syntax mode of the editor. See below for available languages |
| size | `String` | `'small'` | - | Min height of the editor. `small / medium / large / huge` |
| lineNumbers | `Boolean` | `true` | - | Whether to show line numbers. |
| tabSize | `number` | `4` | - | The number of characters to insert when pressing tab key. |
| insertSpaces | `boolean` | `true` | - | Whether to use spaces for indentation. If you set it to `false`, you might also want to set `tabSize` to `1` |
| ignoreTabKey | `boolean` | `false` | - | Whether the editor should ignore tab key presses so that keyboard users can tab past the editor. Users can toggle this behaviour using `Ctrl+Shift+M` (Mac) / `Ctrl+M` manually when this is `false`. |
Note that you can make the default height any height you want with some [custom panel CSS](https://getkirby.com/docs/reference/system/options/panel#custom-panel-css). First, set the `size` option to any string you'd like:
```yaml
size: custom-size
```
Then in your `panel.css`:
```css
.k-code-editor-input[data-size="custom-size"] {
min-height: 15rem;
}
```
### 3.1. Default options
You can globally override the default options, instead of setting them on a per-field basis. In your `site/config/config.php`:
```php
return [
'sylvainjule.code-editor.language' => 'css',
'sylvainjule.code-editor.size' => 'small',
'sylvainjule.code-editor.lineNumbers' => true,
'sylvainjule.code-editor.tabSize' => 4,
'sylvainjule.code-editor.insertSpaces' => true,
'sylvainjule.code-editor.ignoreTabKey' => false,
];
```
<br/>
## 4. Available languages
Currently supported languages are:
* `css`
* `javascript`
* `json`
* `less`
* `php`
* `python`
* `ruby`
* `scss`
* `yaml`
<br/>
## 5. License
MIT
<br/>
## 6. Credits
**Code editor:**
- [Vue Prism Editor](https://github.com/koca/vue-prism-editor)

View file

@ -0,0 +1,20 @@
{
"name": "sylvainjule/code-editor",
"description": "Code editor field for Kirby 3 and 4",
"type": "kirby-plugin",
"license": "MIT",
"version": "1.0.3",
"authors": [
{
"name": "Sylvain Julé",
"email": "contact@sylvain-jule.fr"
}
],
"require": {
"getkirby/composer-installer": "^1.1"
},
"extra": {
"installer-name": "code-editor"
},
"minimum-stability": "beta"
}

View file

@ -0,0 +1 @@
.prism-editor-wrapper{width:100%;height:100%;display:flex;align-items:flex-start;overflow:auto;-o-tab-size:1.5em;tab-size:1.5em;-moz-tab-size:1.5em}@media (-ms-high-contrast:active),(-ms-high-contrast:none){.prism-editor-wrapper .prism-editor__textarea{color:transparent!important}.prism-editor-wrapper .prism-editor__textarea::-moz-selection{background-color:#accef7!important;color:transparent!important}.prism-editor-wrapper .prism-editor__textarea::selection{background-color:#accef7!important;color:transparent!important}}.prism-editor-wrapper .prism-editor__container{position:relative;text-align:left;box-sizing:border-box;padding:0;overflow:hidden;width:100%}.prism-editor-wrapper .prism-editor__line-numbers{height:100%;overflow:hidden;flex-shrink:0;padding-top:4px;margin-top:0;margin-right:10px}.prism-editor-wrapper .prism-editor__line-number{text-align:right;white-space:nowrap}.prism-editor-wrapper .prism-editor__textarea{position:absolute;top:0;left:0;height:100%;width:100%;resize:none;color:inherit;overflow:hidden;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;-webkit-text-fill-color:transparent}.prism-editor-wrapper .prism-editor__editor,.prism-editor-wrapper .prism-editor__textarea{margin:0;border:0;background:none;box-sizing:inherit;display:inherit;font-family:inherit;font-size:inherit;font-style:inherit;font-variant-ligatures:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;-moz-tab-size:inherit;-o-tab-size:inherit;tab-size:inherit;text-indent:inherit;text-rendering:inherit;text-transform:inherit;white-space:pre-wrap;word-wrap:keep-all;overflow-wrap:break-word;padding:0}.prism-editor-wrapper .prism-editor__textarea--empty{-webkit-text-fill-color:inherit!important}.prism-editor-wrapper .prism-editor__editor{position:relative;pointer-events:none}code[class*=language-],pre[class*=language-]{color:#ccc;background:none;font-family:Consolas,Monaco,Andale Mono,Ubuntu Mono,monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#2d2d2d}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.comment,.token.block-comment,.token.prolog,.token.doctype,.token.cdata{color:#999}.token.punctuation{color:#ccc}.token.tag,.token.attr-name,.token.namespace,.token.deleted{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.number,.token.function{color:#f08d49}.token.property,.token.class-name,.token.constant,.token.symbol{color:#f8c555}.token.selector,.token.important,.token.atrule,.token.keyword,.token.builtin{color:#cc99cd}.token.string,.token.char,.token.attr-value,.token.regex,.token.variable{color:#7ec699}.token.operator,.token.entity,.token.url{color:#67cdcc}.token.important,.token.bold{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}.k-code-editor-input{background:#2d2d2d;color:#ccc;font-family:Fira code,Fira Mono,Consolas,Menlo,Courier,monospace;font-size:.9rem;line-height:1.5;padding:10px}.k-code-editor-input[data-size=small]{min-height:7.5rem}.k-code-editor-input[data-size=medium]{min-height:15rem}.k-code-editor-input[data-size=large],.k-code-editor-input[data-size=huge]{min-height:30rem}.prism-editor__textarea:focus{outline:none}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,15 @@
<?php
Kirby::plugin('sylvainjule/code-editor', [
'options' => [
'language' => 'css',
'size' => 'small',
'lineNumbers' => true,
'tabSize' => 4,
'insertSpaces' => true,
'ignoreTabKey' => false,
],
'fields' => [
'code-editor' => require_once __DIR__ . '/lib/fields/code-editor.php',
],
]);

View file

@ -0,0 +1,32 @@
<?php
$options = require kirby()->root('kirby') . '/config/fields/textarea.php';
/* Merge new properties
--------------------------------*/
$options = A::merge($options, [
'props' => [
'size' => function($size = null) {
return $size ?? option('sylvainjule.code-editor.size');
},
'language' => function($language = null) {
return $language ?? option('sylvainjule.code-editor.language');
},
'lineNumbers' => function($lineNumbers = null) {
return $lineNumbers ?? option('sylvainjule.code-editor.lineNumbers');
},
'tabSize' => function($tabSize = null) {
return $tabSize ?? option('sylvainjule.code-editor.tabSize');
},
'insertSpaces' => function($insertSpaces = null) {
return $tabSize ?? option('sylvainjule.code-editor.insertSpaces');
},
'ignoreTabKey' => function($ignoreTabKey = null) {
return $tabSize ?? option('sylvainjule.code-editor.ignoreTabKey');
},
]
]);
// return the updated options
return $options;

6615
site/plugins/code-editor/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
{
"name": "kirby-code-editor",
"version": "1.0.3",
"description": "Code editor field for Kirby 3 and 4",
"main": "index.js",
"author": "Kirby Community",
"license": "MIT",
"repository": {
"type": "git",
"url": "git@github.com:sylvainjule/kirby-code-editor.git"
},
"scripts": {
"dev": "kirbyup src/index.js --watch",
"build": "kirbyup src/index.js",
"lint": "eslint \"src/**/*.{js,vue}\"",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --write \"src/**/*.{css,js,vue}\"",
"prepare": "node src/node/patchVuePrismEditor.mjs"
},
"devDependencies": {
"consola": "^2.15.3",
"eslint": "^8.3.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^8.1.1",
"kirbyup": "^0.21.1",
"prettier": "^2.5.0",
"prismjs": "^1.25.0",
"vue-prism-editor": "^1.3.0"
}
}

View file

@ -0,0 +1,62 @@
/* Colors
---------------------------------*/
$color-black: #000;
$color-white: #fff;
$color-dark: #16171a;
$color-dark-grey: #777;
$color-light: #efefef;
$color-light-grey: #999;
$color-background: $color-light;
$color-positive: #5d800d;
$color-positive-border: $color-positive;
$color-positive-outline: rgba($color-positive, 0.25);
$color-positive-on-dark: #a7bd68;
$color-focus: #4271ae;
$color-focus-border: $color-focus;
$color-focus-outline: rgba($color-focus, 0.25);
$color-focus-on-dark: #81a2be;
$color-notice: #f5871f;
$color-notice-on-dark: #de935f;
$color-negative: #c82829;
$color-negative-border: $color-negative;
$color-negative-outline: rgba($color-negative, 0.25);
$color-negative-on-dark: #d16464;
$color-border: #ccc;
$color-backdrop: rgba($color-dark, 0.6);
$color-inset: #ebebeb;
/* Breakpoint
---------------------------------*/
$breakpoint-small: 30em;
$breakpoint-menu: 45em;
$breakpoint-medium: 65em;
$breakpoint-large: 90em;
$breakpoint-huge: 120em;
/* Fields
---------------------------------*/
$field-input-padding: .5rem;
$field-input-height: 2.25rem;
$field-input-line-height: 1.25rem;
/* Typography
---------------------------------*/
$font-size-tiny: 0.75rem;
$font-size-small: 0.875rem;
$font-size-medium: 1rem;
$font-size-large: 1.25rem;
$font-size-huge: 1.5rem;
$font-size-monster: 1.75rem;
$font-weight-normal: 400;
$font-weight-bold: 600;
$font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$font-family-mono: SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace;

View file

@ -0,0 +1,29 @@
@import
'abstracts/variables.scss';
.k-code-editor-input {
background: #2d2d2d;
color: #ccc;
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
font-size: .9rem;
line-height: 1.5;
padding: 10px;
&[data-size="small"] {
min-height: 7.5rem;
}
&[data-size="medium"] {
min-height: 15rem;
}
&[data-size="large"] {
min-height: 30rem;
}
&[data-size="huge"] {
min-height: 30rem;
}
}
.prism-editor__textarea:focus {
outline: none;
}

View file

@ -0,0 +1,72 @@
<template>
<k-field :input="uid" v-bind="$props" class="k-code-editor-field">
<prism-editor
v-model="code"
class="k-code-editor-input"
:highlight="highlighter"
:line-numbers="lineNumbers"
:tab-size="tabSize"
:insert-spaces="insertSpaces"
:ignore-tab-key="ignoreTabKey"
:data-size="size"
@input="onCodeInput"
/>
</k-field>
</template>
<script>
import { PrismEditor } from "vue-prism-editor";
import "vue-prism-editor/dist/prismeditor.min.css";
import { highlight, languages } from "prismjs/components/prism-core";
import "prismjs/components/prism-markup-templating";
import "prismjs/components/prism-clike";
import "prismjs/components/prism-css";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-json";
import "prismjs/components/prism-less";
import "prismjs/components/prism-php";
import "prismjs/components/prism-python";
import "prismjs/components/prism-ruby";
import "prismjs/components/prism-scss";
import "prismjs/components/prism-yaml";
import "prismjs/themes/prism-tomorrow.css";
export default {
components: { PrismEditor },
extends: "k-textarea-field",
props: {
size: String,
language: String,
lineNumbers: Boolean,
tabSize: Number,
insertSpaces: Boolean,
ignoreTabKey: Boolean,
},
data() {
return {
code: "",
};
},
mounted() {
this.code = this.value;
},
methods: {
highlighter() {
return highlight(this.code, languages[this.language]);
},
onCodeInput() {
this.$emit("input", this.code);
},
},
};
</script>
<style lang="scss">
@import "../../assets/css/styles.scss";
</style>

View file

@ -0,0 +1,7 @@
import CodeEditor from "./components/field/CodeEditor.vue";
window.panel.plugin("sylvainjule/code-editor", {
fields: {
"code-editor": CodeEditor,
},
});

View file

@ -0,0 +1,38 @@
import { existsSync, readFileSync, writeFileSync } from "fs";
import chalk from "chalk";
import consola from "consola";
const srcPath = "node_modules/vue-prism-editor/dist/prismeditor.esm.js";
async function main() {
consola.start("Vue Prism Editor patcher");
if (!existsSync(srcPath)) {
consola.error(
`couldn't find ${chalk.cyan(srcPath)}, did you run ${chalk.green(
"npm install"
)}?`
);
return;
}
const source = readFileSync(srcPath, "utf8");
if (!source.includes("Vue.extend")) {
consola.success("already patched");
return;
}
consola.info("patching the source component...");
let output = source
.replace(/^import Vue from 'vue';/, "")
.replace("/*#__PURE__*/Vue.extend(", "")
.replace(/\}\)(;\s+export)/, "}$1");
writeFileSync(srcPath, output, "utf8");
consola.success("successfully patched");
}
main().catch((err) => consola.error(err));

View file

@ -23,15 +23,10 @@ function setTitleFontSizeClass($title, $level = 'h1')
function getAuthorBySlug($slug)
{
$kirby = kirby();
$author = '';
foreach ($kirby->users() as $user) {
if (Str::slug($user->name()) === $slug) {
$author = $user;
}
}
$site = site();
$authors = page("auteurs")->children();
$author = $authors->find($slug);
return $author;
}

View file

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm run build && git add index.css index.js

View file

@ -0,0 +1 @@
20

View file

@ -0,0 +1,59 @@
<?php
$finder = PhpCsFixer\Finder::create()
->exclude('vendor')
->in(__DIR__);
$config = new PhpCsFixer\Config();
return $config
->setRules([
'@PSR12' => true,
'align_multiline_comment' => ['comment_type' => 'phpdocs_like'],
'array_indentation' => true,
'array_syntax' => ['syntax' => 'short'],
'cast_spaces' => ['space' => 'none'],
// 'class_keyword_remove' => true, // replaces static::class with 'static' (won't work)
'combine_consecutive_issets' => true,
'combine_consecutive_unsets' => true,
'combine_nested_dirname' => true,
'concat_space' => ['spacing' => 'one'],
'declare_equal_normalize' => ['space' => 'single'],
'dir_constant' => true,
'function_typehint_space' => true,
'include' => true,
'logical_operators' => true,
'lowercase_cast' => true,
'lowercase_static_reference' => true,
'magic_constant_casing' => true,
'magic_method_casing' => true,
'method_chaining_indentation' => true,
'modernize_types_casting' => true,
'multiline_comment_opening_closing' => true,
'native_function_casing' => true,
'native_function_type_declaration_casing' => true,
'new_with_braces' => true,
'no_blank_lines_after_class_opening' => true,
'no_blank_lines_after_phpdoc' => true,
'no_empty_comment' => true,
'no_empty_phpdoc' => true,
'no_empty_statement' => true,
'no_leading_namespace_whitespace' => true,
'no_mixed_echo_print' => ['use' => 'echo'],
'no_unneeded_control_parentheses' => true,
'no_unused_imports' => true,
'no_useless_return' => true,
'ordered_imports' => ['sort_algorithm' => 'alpha'],
// 'phpdoc_add_missing_param_annotation' => ['only_untyped' => false], // adds params in the wrong order
'phpdoc_align' => ['align' => 'left'],
'phpdoc_indent' => true,
'phpdoc_scalar' => true,
'phpdoc_trim' => true,
'short_scalar_cast' => true,
'single_line_comment_style' => true,
'single_quote' => true,
'ternary_to_null_coalescing' => true,
'whitespace_after_comma_in_array' => true
])
->setRiskyAllowed(true)
->setIndent("\t")
->setFinder($finder);

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023-2024 Tobias Möritz
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.

View file

@ -0,0 +1,30 @@
![Kirby SEO Banner](/.github/new-banner.png)
<h1 align="center">Kirby SEO</h1>
<p align="center">All-in-one toolkit that makes implementing SEO & Meta best practices a breeze</p>
---
## Features
- 🔎 All-in-one SEO and meta solution
- 🪜 The Meta Cascade: Intelligently merge metadata from multiple sources
- 🎛 Completely configurable: Disable features you don't need
- 💻 Simple Panel UI with previews for Google, Twitter, Facebook & Co.
- 📮 [Schema.org (JSON-LD)](https://schema.org/) support with fluent classes
- 🤖 Automatic Robots rule generation, based on page status
- 📝 Sitemap generation with multi-lang support
## Get started
[Read the documentation](https://plugins.andkindness.com/seo/docs/get-started/feature-overview) to get started with Kirby SEO.
## Support the project
> [!NOTE]
> This plugin is provided free of charge & published under the permissive MIT License. If you use it in a commercial project, please consider sponsoring me on GitHub to support further development and continued maintenance of Kirby SEO.
## License
[MIT License](./../LICENSE)
Copyright © 2023-2024 Tobias Möritz

View file

@ -0,0 +1,33 @@
type: group
fields:
metaHeadline:
label: meta-headline
type: headline
numbered: false
metaTitle:
label: title-overwrite
type: text
placeholder: '{{ page.title }}'
metaTemplate:
label: meta-title-template
type: text
help: meta-title-template-help
width: 2/3
placeholder: '{{ page.metadata.metaTemplate }}'
useTitleTemplate:
label: use-title-template
type: toggle
help: use-title-template-help
width: 1/3
default: true
text:
- "{{ t('use-title-template-no') }}"
- "{{ t('use-title-template-yes') }}"
metaDescription:
label: meta-description
type: textarea
help: meta-description-help
placeholder: '{{ page.metadata.metaDescription }}'
buttons: false
seoLine1:
type: line

View file

@ -0,0 +1,48 @@
type: group
fields:
ogHeadline:
label: og-headline
type: headline
numbered: false
help: global-og-headline-help
ogTemplate:
label: og-title-template
type: text
width: 2/3
help: meta-title-template-help
placeholder: '{{ page.metadata.ogTemplate }}'
useOgTemplate:
label: use-title-template
type: toggle
help: use-title-template-help
width: 1/3
default: true
text:
- "{{ t('use-title-template-no') }}"
- "{{ t('use-title-template-yes') }}"
ogDescription:
label: og-description
type: textarea
buttons: false
placeholder: '{{ page.metadata.ogDescription }}'
ogImage:
label: og-image
extends: seo/fields/og-image
empty: og-image-empty
twitterCardType:
label: twitter-card-type
width: 1/2
placeholder: "{{ t('default-select') }} {{ t(site.twitterCardType) }}"
type: select
options:
summary: "{{ t('summary') }}"
summary_large_image: "{{ t('summary_large_image') }}"
help: twitter-card-type-help
twitterAuthor:
label: twitter-author
width: 1/2
type: text
before: '@'
placeholder: '{{ page.metadata.twitterCreator }}'
seoLine2:
type: line

View file

@ -0,0 +1,31 @@
<?php
use Kirby\Cms\App;
use Kirby\Toolkit\Str;
return function (App $kirby) {
$blueprint = [
'type' => 'files',
'multiple' => false,
'uploads' => [],
'query' => Str::contains($kirby->path(), '/site') && !Str::contains($kirby->path(), 'pages') ? 'site.images' : 'page.images' // small hack to get context for field using api path
];
if ($parent = option('tobimori.seo.files.parent')) {
$blueprint['uploads'] = [
'parent' => $parent
];
$blueprint['query'] = "{$parent}.images";
}
if ($template = option('tobimori.seo.files.template')) {
$blueprint['uploads'] = [
...$blueprint['uploads'],
'template' => $template
];
$blueprint['query'] = "{$blueprint['query']}.filterBy('template', '{$template}')";
}
return $blueprint;
};

View file

@ -0,0 +1,55 @@
<?php
use Kirby\Cms\App;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
use tobimori\Seo\Meta;
return function (App $kirby) {
if (!$kirby->option('tobimori.seo.robots.active') || !$kirby->option('tobimori.seo.robots.pageSettings')) {
return [
'type' => 'hidden'
];
}
$fields = [
'robotsHeadline' => [
'label' => 'robots',
'type' => 'headline',
'numbered' => false,
]
];
$page = Meta::currentPage();
foreach ($kirby->option('tobimori.seo.robots.types') as $robots) {
$upper = Str::ucfirst($robots);
$fields["robots{$upper}"] = [
'label' => "robots-{$robots}",
'type' => 'toggles',
'help' => "robots-{$robots}-help",
'width' => '1/2',
'default' => 'default',
'reset' => false,
'options' => [
'default' => $page ?
A::join([
t('default-select'),
$page->metadata()->get("robots{$upper}", ['fields'])->toBool() ? t('yes') : t('no')
], ' ')
: t('default-select'),
'true' => t('yes'),
'false' => t('no'),
]
];
}
$fields['seoLine3'] = [
'type' => 'line'
];
return [
'type' => 'group',
'fields' => $fields,
];
};

View file

@ -0,0 +1,49 @@
<?php
use Kirby\Cms\App;
return function (App $kirby) {
if (!$kirby->option('tobimori.seo.robots.active') || !$kirby->option('tobimori.seo.robots.pageSettings')) {
return [
'type' => 'hidden'
];
}
$fields = [
'robotsHeadline' => [
'label' => 'robots',
'type' => 'headline',
'numbered' => false,
]
];
foreach ($kirby->option('tobimori.seo.robots.types') as $robots) {
$index = $kirby->option('tobimori.seo.robots.index');
if (is_callable($index)) {
$index = $index();
}
$fields["robots{$robots}"] = [
'label' => "robots-{$robots}",
'type' => 'toggles',
'help' => "robots-{$robots}-help",
'width' => '1/2',
'default' => 'default',
'reset' => false,
'options' => [
'default' => t('default-select') . ' ' . ($index ? t('yes') : t('no')),
'true' => t('yes'),
'false' => t('no'),
]
];
}
$fields['seoLine3'] = [
'type' => 'line'
];
return [
'type' => 'group',
'fields' => $fields,
];
};

View file

@ -0,0 +1,30 @@
<?php
/**
* Social Media Accounts field
* Allows social media account list to be filled by config options
*/
use Kirby\Cms\App;
return function (App $kirby) {
$fields = [];
foreach ($kirby->option('tobimori.seo.socialMedia') as $key => $value) {
if ($value) {
$fields[$key] = [
'label' => ucfirst($key),
'type' => 'url',
'icon' => strtolower($key),
'placeholder' => $value
];
}
}
return [
'label' => 'social-media-accounts',
'type' => 'object',
'help' => 'social-media-accounts-help',
'fields' => $fields
];
};

View file

@ -0,0 +1,29 @@
label: metadata-site
icon: search
columns:
main:
width: 7/12
fields:
metaGroup: seo/fields/meta-group
ogGroup: seo/fields/og-group
robots: seo/fields/robots
metaInherit:
label: inherit-settings
type: multiselect
help: inherit-settings-help
options:
metaTemplate: "{{ t('meta-title-template') }}"
metaDescription: "{{ t('meta-description') }}"
ogTemplate: "{{ t('og-title-template') }}"
ogDescription: "{{ t('og-description') }}"
ogImage: "{{ t('og-image') }}"
twitterCardType: "{{ t('twitter-card-type') }}"
twitterAuthor: "{{ t('twitter-author') }}"
robots: '{{ t("robots") }}'
sidebar:
width: 5/12
sticky: true
sections:
seoPreview:
type: seo-preview

View file

@ -0,0 +1,70 @@
label: metadata-site
icon: search
columns:
main:
width: 7/12
fields:
metaHeadline:
label: global-meta-headline
type: headline
numbered: false
help: global-meta-headline-help
metaTemplate:
label: meta-title-template
type: text
help: meta-title-template-help
metaDescription:
label: meta-description
type: textarea
help: meta-description-help
buttons: false
seoLine1:
type: line
ogHeadline:
label: global-og-headline
type: headline
numbered: false
help: global-og-headline-help
ogTemplate:
label: og-title-template
type: text
default: '{{ title }}'
help: meta-title-template-help
placeholder: '{{ site.metaTemplate }}'
ogDescription:
label: og-description
type: textarea
buttons: false
placeholder: '{{ site.metaDescription }}'
ogSiteName:
label: og-site-name
type: text
default: '{{ site.title }}'
placeholder: '{{ site.title }}'
width: 1/2
ogImage:
label: og-image
extends: seo/fields/og-image
empty: og-image-empty
width: 1/2
twitterCardType:
label: twitter-card-type
width: 1/2
type: select
default: summary
required: true
options:
summary: "{{ t('summary') }}"
summary_large_image: "{{ t('summary_large_image') }}"
help: twitter-card-type-help
seoLine2:
type: line
robots: seo/fields/site-robots
socialMediaAccounts: seo/fields/social-media
sidebar:
width: 5/12
sticky: true
sections:
seoPreview:
type: seo-preview

View file

@ -0,0 +1,670 @@
<?php
namespace tobimori\Seo;
use Kirby\Cms\App;
use Kirby\Cms\Page;
use Kirby\Content\Field;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* The Meta class is responsible for handling the meta data & cascading
*/
class Meta
{
/**
* These values will be handled as 'field is empty'
*/
public const DEFAULT_VALUES = ['[]', 'default'];
protected Page $page;
protected ?string $lang;
protected array $consumed = [];
protected array $metaDefaults = [];
protected array $metaArray = [];
/**
* Creates a new Meta instance
*/
public function __construct(Page $page, ?string $lang = null)
{
$this->page = $page;
$this->lang = $lang;
if (method_exists($this->page, 'metaDefaults')) {
$this->metaDefaults = $this->page->metaDefaults($this->lang);
}
}
/**
* Returns the meta array which maps meta tags to their fieldnames
*/
protected function metaArray(): array
{
if ($this->metaArray) {
return $this->metaArray;
}
/**
* We have to specify field names and resolve them later, so we can use this
* function to resolve meta tags from field names in the programmatic function
*/
$meta =
[
'title' => 'metaTitle',
'description' => 'metaDescription',
'date' => fn () => $this->page->modified($this->dateFormat()),
'og:title' => 'ogTitle',
'og:description' => 'ogDescription',
'og:site_name' => 'ogSiteName',
'og:image' => 'ogImage',
'og:image:width' => fn () => $this->ogImage() ? $this->get('ogImage')->toFile()?->width() : null,
'og:image:height' => fn () => $this->ogImage() ? $this->get('ogImage')->toFile()?->height() : null,
'og:image:alt' => fn () => $this->get('ogImage')->toFile()?->alt(),
'og:type' => 'ogType',
];
// Robots
if ($robotsActive = option('tobimori.seo.robots.active')) {
$meta['robots'] = fn () => $this->robots();
}
// only add canonical and alternate tags if the page is indexable
// we have to resolve this lazily (using a callable) to avoid an infinite loop
$allowsIndexFn = fn () => !$robotsActive || !Str::contains($this->robots(), 'noindex');
// canonical
$canonicalFn = fn () => $allowsIndexFn() ? $this->canonicalUrl() : null;
$meta['canonical'] = $canonicalFn;
$meta['og:url'] = $canonicalFn;
// Multi-lang alternate tags
if (kirby()->languages()->count() > 1 && kirby()->language() !== null) {
foreach (kirby()->languages() as $lang) {
// only add alternate tags if the page is indexable
$meta['alternate'][] = fn () => $allowsIndexFn() ? [
'hreflang' => $lang->code(),
'href' => $this->page->url($lang->code()),
] : null;
if ($lang !== kirby()->language()) {
$meta['og:locale:alternate'][] = fn () => $lang->code();
}
}
// only add alternate tags if the page is indexable
$meta['alternate'][] = fn () => $allowsIndexFn() ? [
'hreflang' => 'x-default',
'href' => $this->page->url(kirby()->language()->code()),
] : null;
$meta['og:locale'] = fn () => kirby()->language()->locale(LC_ALL);
} else {
$meta['og:locale'] = fn () => $this->locale(LC_ALL);
}
// Twitter tags "opt-in" - TODO: wip
if (option('tobimori.seo.twitter', true)) {
$meta = array_merge($meta, [
'twitter:card' => 'twitterCardType',
'twitter:title' => 'ogTitle',
'twitter:description' => 'ogDescription',
'twitter:image' => 'ogImage',
'twitter:site' => 'twitterSite',
'twitter:creator' => 'twitterCreator',
]);
}
// This array will be normalized for use in the snippet in $this->snippetData()
return $this->metaArray = $meta;
}
/**
* This array defines what HTML tag the corresponding meta tags are using including the attributes,
* so everything is a bit more elegant when defining programmatic content (supports regex)
*/
public const TAG_TYPE_MAP = [
[
'tag' => 'title',
'tags' => [
'title'
]
],
[
'tag' => 'link',
'attributes' => [
'name' => 'rel',
'content' => 'href',
],
'tags' => [
'canonical',
'alternate',
]
],
[
'tag' => 'meta',
'attributes' => [
'name' => 'property',
'content' => 'content',
],
'tags' => [
'/og:.+/'
]
]
];
/**
* Normalize the meta array and remaining meta defaults to be used in the snippet,
* also resolve the content, if necessary
*/
public function snippetData(array $raw = null): array
{
$mergeWithDefaults = !isset($raw);
$raw ??= $this->metaArray();
$tags = [];
foreach ($raw as $name => $value) {
// if the key is numeric, it is already normalized to the correct array syntax
if (is_numeric($name)) {
// but we still check if the array is valid
if (!is_array($value) || count(array_intersect(['tag', 'content', 'attributes'], array_keys($value))) !== count($value)) {
throw new InvalidArgumentException("[kirby-seo] Invalid array structure found in programmatic content for page {$this->slug()}. Please check your metaDefaults method for template {$this->template()->name()}.");
}
$tags[] = $value;
continue;
}
// allow overrides from metaDefaults for keys that are a callable or array by default
// (all fields from meta array that are not part of the regular cascade)
if ((is_callable($value) || is_array($value)) && $mergeWithDefaults && array_key_exists($name, $this->metaDefaults)) {
$this->consumed[] = $name;
$value = $this->metaDefaults[$name];
}
// if the value is a string, we know it's a field name
if (is_string($value)) {
$value = $this->$value($name);
}
// if the value is a callable, we resolve it
if (is_callable($value)) {
$value = $value($this->page);
}
// if the value is empty, we don't want to output it
if ((is_a($value, 'Kirby\Content\Field') && $value->isEmpty()) || !$value) {
continue;
}
// resolve the tag type from the meta array
// so we can use the correct attributes to normalize it
$tag = $this->resolveTag($name);
// if the value is an associative array now, all of them are attributes
// and we don't look for what the TAG_TYPE_MAP says
// or there should be multiple tags with the same name (non-associative array)
if (is_array($value)) {
if (!A::isAssociative($value)) {
foreach ($value as $val) {
$tags = array_merge($tags, $this->snippetData([$name => $val]));
}
continue;
}
// array is associative, so it's an array of attributes
// we resolve the values, if they are callable
array_walk($value, function (&$item) {
if (is_callable($item)) {
$item = $item($this->page);
}
});
// add the tag to the array
$tags[] = [
'tag' => $tag['tag'],
'attributes' => $value,
'content' => null,
];
continue;
}
// if the value is a string, we use the TAG_TYPE_MAP
// to correctly map the attributes
$tags[] = [
'tag' => $tag['tag'],
'attributes' => isset($tag['attributes']) ? [
$tag['attributes']['name'] => $name,
$tag['attributes']['content'] => $value,
] : null,
'content' => !isset($tag['attributes']) ? $value : null,
];
}
if ($mergeWithDefaults) {
// merge the remaining meta defaults
$tags = array_merge($tags, $this->snippetData(array_diff_key($this->metaDefaults, array_flip($this->consumed))));
}
return $tags;
}
/**
* Resolves the tag type from the meta array
*/
protected function resolveTag(string $tag): array
{
foreach (self::TAG_TYPE_MAP as $type) {
foreach ($type['tags'] as $regexOrString) {
// Check if the supplied tag is a regex or a normal tag name
if (Str::startsWith($regexOrString, '/') && Str::endsWith($regexOrString, '/') ?
Str::match($tag, $regexOrString) : $tag === $regexOrString
) {
return $type;
}
}
}
return [
'tag' => 'meta',
'attributes' => [
'name' => 'name',
'content' => 'content',
]
];
}
/**
* Magic method to get a meta value by calling the method name
*/
public function __call($name, $args = null): mixed
{
if (method_exists($this, $name)) {
return $this->$name($args);
}
return $this->get($name);
}
/**
* Get the meta value for a given key
*/
public function get(string $key, array $exclude = []): Field
{
$cascade = option('tobimori.seo.cascade');
if (count(array_intersect(get_class_methods($this), $cascade)) !== count($cascade)) {
throw new InvalidArgumentException('[kirby-seo] Invalid cascade method in config. Please check your options for `tobimori.seo.cascade`.');
}
// Track consumed keys, so we don't output legacy field values
$toBeConsumed = $key;
if (
(array_key_exists($toBeConsumed, $this->metaDefaults)
|| array_key_exists($toBeConsumed = $this->findTagForField($toBeConsumed), $this->metaDefaults))
&& !in_array($toBeConsumed, $this->consumed)
) {
$this->consumed[] = $toBeConsumed;
}
foreach (array_diff($cascade, $exclude) as $method) {
if ($field = $this->$method($key)) {
return $field;
}
}
return new Field($this->page, $key, '');
}
/**
* Get the meta value for a given key from the page's fields
*/
protected function fields(string $key): Field|null
{
if (($field = $this->page->content($this->lang)->get($key))) {
if (Str::contains($key, 'robots') && !option('tobimori.seo.robots.pageSettings')) {
return null;
}
if ($field->isNotEmpty() && !A::has(self::DEFAULT_VALUES, $field->value())) {
return $field;
}
}
return null;
}
/**
* Maps Open Graph fields to Meta fields for fallbackFields
* cascade method
*/
public const FALLBACK_MAP = [
'ogTitle' => 'metaTitle',
'ogDescription' => 'metaDescription',
'ogTemplate' => 'metaTemplate',
];
/**
* We only allow the following cascade methods for fallbacks,
* because we don't want to fallback to the config defaults for
* Meta fields, because we most likely already have those set
* for the Open Graph fields
*/
public const FALLBACK_CASCADE = [
'fields',
'programmatic',
'parent',
'site'
];
/**
* Get the meta value for a given key using the fallback fields
* defined above (usually Open Graph > Meta Fields)
*/
protected function fallbackFields(string $key): Field|null
{
if (array_key_exists($key, self::FALLBACK_MAP)) {
$fallback = self::FALLBACK_MAP[$key];
$cascade = option('tobimori.seo.cascade');
foreach (array_intersect($cascade, self::FALLBACK_CASCADE) as $method) {
if ($field = $this->$method($fallback)) {
return $field;
}
}
}
return null;
}
protected function findTagForField(string $fieldName): string|null
{
return array_search($fieldName, $this->metaArray());
}
/**
* Get the meta value for a given key from the page's meta
* array, which can be set in the page's model metaDefaults method
*/
protected function programmatic(string $key): Field|null
{
if (!$this->metaDefaults) {
return null;
}
// Check if the key (field name) is in the array syntax
if (array_key_exists($key, $this->metaDefaults)) {
$val = $this->metaDefaults[$key];
}
/* If there is no programmatic value for the key,
* try looking it up in the meta array
* maybe it is a meta tag and not a field name?
*/
if (!isset($val) && ($key = $this->findTagForField($key)) && array_key_exists($key, $this->metaDefaults)) {
$val = $this->metaDefaults[$key];
}
if (isset($val)) {
if (is_callable($val)) {
$val = $val($this->page);
}
if (is_array($val)) {
$val = $val['content'] ?? $val['href'] ?? null;
// Last sanity check, if the array syntax doesn't have a supported key
if ($val === null) {
// Remove the key from the consumed array, so it doesn't get filtered out
// (we can assume the entry is a custom meta tag that uses different attributes)
$this->consumed = array_filter($this->consumed, fn ($item) => $item !== $key);
return null;
}
}
if (is_a($val, 'Kirby\Content\Field')) {
return new Field($this->page, $key, $val->value());
}
return new Field($this->page, $key, $val);
}
return null;
}
/**
* Get the meta value for a given key from the page's parent,
* if the page is allowed to inherit the value
*/
protected function parent(string $key): Field|null
{
if ($this->canInherit($key)) {
$parent = $this->page->parent();
$parentMeta = new Meta($parent, $this->lang);
if ($value = $parentMeta->get($key)) {
return $value;
}
}
return null;
}
/**
* Get the meta value for a given key from the
* site's meta blueprint & content
*/
protected function site(string $key): Field|null
{
if (($site = $this->page->site()->content($this->lang)->get($key)) && ($site->isNotEmpty() && !A::has(self::DEFAULT_VALUES, $site->value))) {
return $site;
}
return null;
}
/**
* Get the meta value for a given key from the
* config.php options
*/
protected function options(string $key): Field|null
{
if ($option = option("tobimori.seo.default.{$key}")) {
if (is_callable($option)) {
$option = $option($this->page);
}
if (is_a($option, 'Kirby\Content\Field')) {
return $option;
}
return new Field($this->page, $key, $option);
}
return null;
}
/**
* Checks if the page can inherit a meta value from its parent
*/
private function canInherit(string $key): bool
{
$parent = $this->page->parent();
if (!$parent) {
return false;
}
$inherit = $parent->metaInherit()->split();
if (Str::contains($key, 'robots') && A::has($inherit, 'robots')) {
return true;
}
return A::has($inherit, $key);
}
/**
* Applies the title template, and returns the correct title
*/
public function metaTitle()
{
$title = $this->get('metaTitle');
$template = $this->get('metaTemplate');
$useTemplate = $this->page->useTitleTemplate();
$useTemplate = $useTemplate->isEmpty() ? true : $useTemplate->toBool();
$string = $title->value();
if ($useTemplate) {
$string = $this->page->toString(
$template,
['title' => $title]
);
}
return new Field(
$this->page,
'metaTitle',
$string
);
}
/**
* Applies the OG title template, and returns the OG Title
*/
public function ogTitle()
{
$title = $this->get('metaTitle');
$template = $this->get('ogTemplate');
$useTemplate = $this->page->useOgTemplate();
$useTemplate = $useTemplate->isEmpty() ? true : $useTemplate->toBool();
$string = $title->value();
if ($useTemplate) {
$string = $this->page->toString(
$template,
['title' => $title]
);
}
return new Field(
$this->page,
'ogTitle',
$string
);
}
/**
* Gets the canonical url for the page
*/
public function canonicalUrl()
{
return $this->page->site()->canonicalFor($this->page->url());
}
/**
* Get the Twitter username from an account url set in the site options
*/
public function twitterSite()
{
$accs = $this->page->site()->socialMediaAccounts()->toObject();
$username = '';
if ($accs->twitter()->isNotEmpty()) {
// tries to match all twitter urls, and extract the username
$matches = [];
preg_match('/^(https?:\/\/)?(www\.)?twitter\.com\/(#!\/)?@?(?<name>[^\/\?]*)$/', $accs->twitter()->value(), $matches);
if (isset($matches['name'])) {
$username = $matches['name'];
}
}
return new Field($this->page, 'twitter', $username);
}
/**
* Gets the date format for modified meta tags, based on the registered date handler
*/
public function dateFormat(): string
{
if ($custom = option('tobimori.seo.dateFormat')) {
if (is_callable($custom)) {
return $custom($this->page);
}
return $custom;
}
switch (option('date.handler')) {
case 'strftime':
return '%Y-%m-%d';
case 'intl':
return 'yyyy-MM-dd';
case 'date':
default:
return 'Y-m-d';
}
}
/**
* Get the pages' robots rules as string
*/
public function robots()
{
$robots = [];
foreach (option('tobimori.seo.robots.types') as $type) {
if (!$this->get('robots' . Str::ucfirst($type))->toBool()) {
$robots[] = 'no' . Str::lower($type);
}
}
if (A::count($robots) === 0) {
$robots = ['all'];
}
return A::join($robots, ',');
}
/**
* Get the og:image url
*/
public function ogImage(): string|null
{
$field = $this->get('ogImage');
if ($ogImage = $field->toFile()?->thumb([
'width' => 1200,
'height' => 630,
'crop' => true,
])) {
return $ogImage->url();
}
if ($field->isNotEmpty()) {
return $field->value();
}
return null;
}
/**
* Helper method the get the current page from the URL path,
* for use in programmatic blueprints
*/
public static function currentPage(): Page|null
{
$path = App::instance()->request()->url()->toString();
$matches = Str::match($path, "/pages\/([a-zA-Z0-9-_+]+)\/?/m");
$segments = Str::split($matches[1], '+');
$page = App::instance()->site();
foreach ($segments as $segment) {
if ($page = $page->findPageOrDraft($segment)) {
continue;
}
return null;
}
return $page;
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace tobimori\Seo;
use Kirby\Cms\Page;
use Spatie\SchemaOrg\Schema;
class SchemaSingleton
{
private static $instances = [];
private function __construct()
{
}
public static function getInstance(string $type, Page|null $page = null): mixed
{
if (!isset(self::$instances[$page?->id() ?? 'default'][$type])) {
self::$instances[$page?->id() ?? 'default'][$type] = Schema::{$type}();
}
return self::$instances[$page?->id() ?? 'default'][$type];
}
public static function getInstances(Page|null $page = null): array
{
return self::$instances[$page?->id() ?? 'default'] ?? [];
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace tobimori\Seo\Sitemap;
use DOMDocument;
use Kirby\Cms\App;
use Kirby\Toolkit\Collection;
class Sitemap extends Collection
{
public function __construct(protected string $key, array $data = [], bool $caseSensitive = false)
{
parent::__construct($data, $caseSensitive);
}
public function key(): string
{
return $this->key;
}
public function loc(): string
{
return kirby()->site()->canonicalFor('sitemap-' . $this->key . '.xml');
}
public function lastmod(): string
{
$lastmod = 0;
foreach ($this as $url) {
$lastmod = max($lastmod, strtotime($url->lastmod()));
}
if ($lastmod > 0) {
return date('c', $lastmod);
}
return date('c');
}
public function createUrl(string $loc): SitemapUrl
{
$url = $this->makeUrl($loc);
$this->append($url);
return $url;
}
public static function makeUrl(string $url): SitemapUrl
{
return new SitemapUrl($url);
}
public function toDOMNode(DOMDocument $doc = new DOMDocument('1.0', 'UTF-8'))
{
$doc->formatOutput = true;
$root = $doc->createElement('sitemap');
foreach (['loc', 'lastmod'] as $key) {
$root->appendChild($doc->createElement($key, $this->$key()));
}
return $root;
}
public function toString(): string
{
$doc = new DOMDocument('1.0', 'UTF-8');
$doc->formatOutput = true;
$doc->appendChild($doc->createProcessingInstruction('xml-stylesheet', 'type="text/xsl" href="/sitemap.xsl"'));
$root = $doc->createElementNS('http://www.sitemaps.org/schemas/sitemap/0.9', 'urlset');
$root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xhtml', 'http://www.w3.org/1999/xhtml');
$root->setAttribute('seo-version', App::plugin('tobimori/seo')->version());
foreach ($this as $url) {
$root->appendChild($url->toDOMNode($doc));
}
$doc->appendChild($root);
return $doc->saveXML();
}
}

View file

@ -0,0 +1,100 @@
<?php
namespace tobimori\Seo\Sitemap;
use DOMDocument;
use Kirby\Cms\App;
use Kirby\Cms\Page;
use Kirby\Toolkit\Collection;
class SitemapIndex extends Collection
{
protected static $instance = null;
public static function instance(...$args): static
{
if (static::$instance === null) {
static::$instance = new static(...$args);
}
return static::$instance;
}
public function create(string $key = 'pages'): Sitemap
{
$sitemap = $this->make($key);
$this->append($sitemap);
return $sitemap;
}
public static function make(string $key = 'pages'): Sitemap
{
return new Sitemap($key);
}
public static function makeUrl(string $url): SitemapUrl
{
return new SitemapUrl($url);
}
public function toString(): string
{
$doc = new DOMDocument('1.0', 'UTF-8');
$doc->formatOutput = true;
$doc->appendChild($doc->createProcessingInstruction('xml-stylesheet', 'type="text/xsl" href="sitemap.xsl"'));
$root = $doc->createElementNS('http://www.sitemaps.org/schemas/sitemap/0.9', 'sitemapindex');
$root->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xhtml', 'http://www.w3.org/1999/xhtml');
$root->setAttribute('seo-version', App::plugin('tobimori/seo')->version());
$doc->appendChild($root);
foreach ($this as $sitemap) {
$root->appendChild($sitemap->toDOMNode($doc));
}
return $doc->saveXML();
}
public function isValidIndex(string $key = null): bool
{
if ($key === null) {
return $this->count() > 1;
}
return !!$this->findBy('key', $key) && $this->count() > 1;
}
public function generate(): void
{
$generator = option('tobimori.seo.sitemap.generator');
if (is_callable($generator)) {
$generator($this);
}
}
public function render(Page $page): string|null
{
// There always has to be at least one index,
// otherwise the sitemap will fail to render
if ($this->count() === 0) {
$this->generate();
}
if ($this->count() === 0) {
$this->create();
}
if (($index = $page->content()->get('index'))->isEmpty()) {
// If there is only one index, we do not need to render the index page
return $this->count() > 1 ? $this->toString() : $this->first()->toString();
}
$sitemap = $this->findBy('key', $index->value());
if ($sitemap) {
return $sitemap->toString();
}
return null;
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace tobimori\Seo\Sitemap;
use DOMDocument;
use DOMNode;
use Kirby\Exception\Exception;
class SitemapUrl
{
protected string $lastmod;
protected string $changefreq;
protected string $priority;
protected array $alternates = [];
public function __construct(protected string $loc)
{
}
public function loc(string $url = null): SitemapUrl|string
{
if ($url === null) {
return $this->loc;
}
$this->loc = $url;
return $this;
}
public function lastmod(string $lastmod = null): SitemapUrl|string
{
if ($lastmod === null) {
return $this->lastmod;
}
$this->lastmod = date('c', $lastmod);
return $this;
}
public function changefreq(string $changefreq = null): SitemapUrl|string
{
if ($changefreq === null) {
return $this->changefreq;
}
$this->changefreq = $changefreq;
return $this;
}
public function priority(string $priority = null): SitemapUrl|string
{
if ($priority === null) {
return $this->priority;
}
$this->priority = $priority;
return $this;
}
public function alternates(array $alternates = []): SitemapUrl|array
{
if (empty($alternates)) {
return $this->alternates;
}
foreach ($alternates as $alternate) {
foreach (['href', 'hreflang'] as $key) {
if (!array_key_exists($key, $alternate)) {
new Exception("[kirby-seo] The alternate link to '{$this->loc()} is missing the '{$key}' attribute");
}
}
}
$this->alternates = $alternates;
return $this;
}
public function toDOMNode(DOMDocument $doc = new DOMDocument('1.0', 'UTF-8')): DOMNode
{
$doc->formatOutput = true;
$node = $doc->createElement('url');
foreach (array_diff_key(get_object_vars($this), array_flip(['alternates'])) as $key => $value) {
$node->appendChild($doc->createElement($key, $value));
}
if (!empty($this->alternates())) {
foreach ($this->alternates() as $alternate) {
$alternateNode = $doc->createElement('xhtml:link');
foreach ($alternate as $key => $value) {
$alternateNode->setAttribute($key, $value);
}
$node->appendChild($alternateNode);
}
}
return $node;
}
public function toString(): string
{
$doc = new DOMDocument('1.0', 'UTF-8');
$doc->formatOutput = true;
$node = $this->toDOMNode();
$doc->appendChild($node);
return $doc->saveXML($node);
}
}

View file

@ -0,0 +1,41 @@
{
"name": "tobimori/kirby-seo",
"description": "The ultimate Kirby SEO toolkit",
"type": "kirby-plugin",
"version": "1.1.2",
"license": "MIT",
"homepage": "https://github.com/tobimori/kirby-seo#readme",
"authors": [
{
"name": "Tobias Möritz",
"email": "tobias@moeritz.io"
}
],
"autoload": {
"psr-4": {
"tobimori\\Seo\\": "classes"
}
},
"require": {
"php": ">=8.1.0",
"getkirby/composer-installer": "^1.2.1",
"spatie/schema-org": "^3.14"
},
"require-dev": {
"getkirby/cms": "^4.0",
"getkirby/cli": "^1.2",
"friendsofphp/php-cs-fixer": "^3.48"
},
"scripts": {
"dist": "composer install --no-dev --optimize-autoloader"
},
"config": {
"optimize-autoloader": true,
"allow-plugins": {
"getkirby/composer-installer": true
}
},
"extra": {
"kirby-cms-path": false
}
}

View file

@ -0,0 +1,102 @@
<?php
use Kirby\Cms\Page;
use Kirby\Cms\Site;
use Kirby\Form\Form;
use Kirby\Toolkit\Str;
return [
'data' => [
'dirtyPageOrSite' => function (string $slug) {
$kirby = kirby();
$page = $slug == 'site' ? $kirby->site() : $kirby->page(Str::replace($slug, '+', '/'));
if ($this->requestBody()) {
$form = Form::for($page, [ // Form class handles transformation of changed items
'ignoreDisabled' => true,
'input' => array_merge(['title' => $page->title()], $page->content()->data(), $this->requestBody()),
'language' => $kirby->language()?->code()
]);
$page = $page->clone(['content' => $form->data()]);
}
return $page;
}
],
'routes' => [
[
'pattern' => '/k-seo/(:any)/heading-structure',
'method' => 'POST',
'action' => function (string $slug) {
$model = $this->dirtyPageOrSite($slug);
if ($model instanceof Page) {
$page = $model->render();
$dom = new DOMDocument();
$dom->loadHTML(htmlspecialchars_decode(mb_convert_encoding(htmlentities($page, ENT_COMPAT, 'UTF-8'), 'ISO-8859-1', 'UTF-8'), ENT_QUOTES), libxml_use_internal_errors(true));
$xpath = new DOMXPath($dom);
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
$data = [];
foreach ($headings as $heading) {
$data[] = [
'level' => (int)str_replace('h', '', $heading->nodeName),
'text' => $heading->textContent,
];
}
return $data;
}
return null;
}
],
[
'pattern' => '/k-seo/(:any)/seo-preview',
'method' => 'POST',
'action' => function (string $slug) {
$model = $this->dirtyPageOrSite($slug);
if ($model instanceof Site) {
$model = $model->homePage();
}
if ($model instanceof Page) {
$meta = $model->metadata();
return [
'page' => $model->slug(),
'url' => $model->url(),
'title' => $meta->metaTitle()->value(),
'description' => $meta->metaDescription()->value(),
'ogSiteName' => $meta->ogSiteName()->value(),
'ogTitle' => $meta->ogTitle()->value(),
'ogDescription' => $meta->ogDescription()->value(),
'ogImage' => $meta->ogImage(),
'twitterCardType' => $meta->twitterCardType()->value(),
];
}
return null;
}
],
[
'pattern' => '/k-seo/(:any)/robots',
'method' => 'POST',
'action' => function (string $slug) {
$model = $this->dirtyPageOrSite($slug);
if (!($model instanceof Page)) {
return null;
}
$robots = $model->robots();
return [
'active' => option('tobimori.seo.robots.indicator', option('tobimori.seo.robots.active', true)),
'state' => $robots,
];
}
]
]
];

View file

@ -0,0 +1,11 @@
<?php
use Kirby\CLI\CLI;
return [
'description' => 'Hello world',
'args' => [],
'command' => static function (CLI $cli): void {
$cli->success('Hello world! This command is a preparation for a future release.');
}
];

View file

@ -0,0 +1,27 @@
<?php
use Kirby\Cms\Page;
use Kirby\Toolkit\Str;
return [
'page.update:after' => function (Page $newPage, Page $oldPage) {
foreach ($newPage->kirby()->option('tobimori.seo.robots.types') as $robots) {
$upper = Str::ucfirst($robots);
if ($newPage->content()->get("robots{$upper}")->value() === "") {
$newPage = $newPage->update([
"robots{$upper}" => 'default'
]);
}
}
},
'page.render:before' => function (string $contentType, array $data, Page $page) {
if (option('tobimori.seo.generateSchema')) {
$page->schema('WebSite')
->url($page->metadata()->canonicalUrl())
->copyrightYear(date('Y'))
->description($page->metadata()->metaDescription())
->name($page->metadata()->metaTitle())
->headline($page->metadata()->title());
}
},
];

View file

@ -0,0 +1,82 @@
<?php
use Kirby\Cms\Page;
return [
'cascade' => [
'fields',
'programmatic',
// 'fallbackFields', // fallback to meta fields for open graph fields
'parent',
'site',
'options'
],
'default' => [ // default field values for metadata, format is [field => value]
'metaTitle' => fn (Page $page) => $page->title(),
'metaTemplate' => '{{ title }} - {{ site.title }}',
'ogTemplate' => '{{ title }}',
'ogSiteName' => fn (Page $page) => $page->site()->title(),
'ogType' => 'website',
'twitterCardType' => 'summary',
'ogDescription' => fn (Page $page) => $page->metadata()->metaDescription(),
'twitterCreator' => fn (Page $page) => $page->metadata()->twitterSite(),
'lang' => fn (Page $page) => $page->kirby()->language()?->locale(LC_ALL) ?? $page->kirby()->option('tobimori.seo.lang', 'en_US'),
// default for robots: noIndex if global index configuration is set, otherwise fall back to page status
'robotsIndex' => function (Page $page) {
$index = $page->kirby()->option('tobimori.seo.robots.index');
if (is_callable($index)) {
$index = $index();
}
if (!$index) {
return false;
}
return $page->kirby()->option('tobimori.seo.robots.followPageStatus', true) ? $page->isListed() : true;
},
'robotsFollow' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
'robotsArchive' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
'robotsImageindex' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
'robotsSnippet' => fn (Page $page) => $page->kirby()->option('tobimori.seo.default.robotsIndex')($page),
],
'socialMedia' => [ // default fields for social media links, format is [field => placeholder]
'twitter' => 'https://twitter.com/my-company',
'facebook' => 'https://facebook.com/my-company',
'instagram' => 'https://instagram.com/my-company',
'youtube' => 'https://youtube.com/channel/my-company',
'linkedin' => 'https://linkedin.com/company/my-company',
],
'previews' => [
'google',
'facebook',
'slack'
],
'robots' => [
'active' => true, // whether robots handling should be done by the plugin
'followPageStatus' => true, // should unlisted pages be noindex by default?
'pageSettings' => true, // whether to have robots settings on each page
'indicator' => true, // whether the indicator should be shown in the panel
'index' => fn () => !option('debug'), // default site-wide robots setting
'sitemap' => null, // sets sitemap url, will be replaced by plugin sitemap in the future
'content' => [], // custom robots content
'types' => ['index', 'follow', 'archive', 'imageindex', 'snippet'] // available robots types
],
'sitemap' => [
'active' => true,
'redirect' => true, // redirect /sitemap to /sitemap.xml
'lang' => 'en',
'generator' => require __DIR__ . '/options/sitemap.php',
'changefreq' => 'weekly',
'groupByTemplate' => false,
'excludeTemplates' => ['error'],
'priority' => fn (Page $p) => number_format(($p->isHomePage()) ? 1 : max(1 - 0.2 * $p->depth(), 0.2), 1),
],
'files' => [
'parent' => null,
'template' => null,
],
'canonicalBase' => null, // base url for canonical links
'generateSchema' => true, // whether to generate default schema.org data
'lang' => 'en_US', // default language, used for single-language sites
'dateFormat' => null, // custom date format
];

View file

@ -0,0 +1,37 @@
<?php
use Kirby\Toolkit\Obj;
use tobimori\Seo\Sitemap\SitemapIndex;
return function (SitemapIndex $sitemap) {
$exclude = option('tobimori.seo.sitemap.excludeTemplates', []);
$pages = site()->index()->filter(fn ($page) => $page->metadata()->robotsIndex()->toBool() && !in_array($page->intendedTemplate()->name(), $exclude));
if ($group = option('tobimori.seo.sitemap.groupByTemplate')) {
$pages = $pages->group('intendedTemplate');
}
if (is_a($pages->first(), 'Kirby\Cms\Page')) {
$pages = $pages->group(fn () => 'pages');
}
foreach ($pages as $group) {
$index = $sitemap->create($group ? $group->first()->intendedTemplate()->name() : 'pages');
foreach ($group as $page) {
$url = $index->createUrl($page->metadata()->canonicalUrl())
->lastmod($page->modified() ?? (int)(date('c')))
->changefreq(is_callable($changefreq = option('tobimori.seo.sitemap.changefreq')) ? $changefreq($page) : $changefreq)
->priority(is_callable($priority = option('tobimori.seo.sitemap.priority')) ? $priority($page) : $priority);
if (kirby()->languages()->count() > 1 && kirby()->language() !== null) {
$url->alternates(
kirby()->languages()->map(fn ($language) => new Obj([
'hreflang' => $language->code() === kirby()->language()->code() ? 'x-default' : $language->code(),
'href' => $page->url($language->code()),
]))->toArray()
);
}
}
}
};

View file

@ -0,0 +1,11 @@
<?php
use tobimori\Seo\Meta;
use tobimori\Seo\SchemaSingleton;
return [
'schema' => fn ($type) => SchemaSingleton::getInstance($type, $this),
'schemas' => fn () => SchemaSingleton::getInstances($this),
'metadata' => fn (?string $lang = null) => new Meta($this, $lang),
'robots' => fn (?string $lang = null) => $this->metadata($lang)->robots(),
];

View file

@ -0,0 +1,98 @@
<?php
use Kirby\Cms\Page;
use Kirby\Http\Response;
use tobimori\Seo\Sitemap\SitemapIndex;
return [
[
'pattern' => 'robots.txt',
'action' => function () {
if (option('tobimori.seo.robots.active', true)) {
$content = snippet('seo/robots.txt', [], true);
return new Response($content, 'text/plain', 200);
}
$this->next();
}
],
[
'pattern' => 'sitemap',
'action' => function () {
if (!option('tobimori.seo.sitemap.redirect', true) || !option('tobimori.seo.sitemap.active', true)) {
$this->next();
}
go('/sitemap.xml');
}
],
[
'pattern' => 'sitemap.xsl',
'action' => function () {
if (!option('tobimori.seo.sitemap.active', true)) {
$this->next();
}
kirby()->response()->type('text/xsl');
$lang = option('tobimori.seo.sitemap.lang', 'en');
if (is_callable($lang)) {
$lang = $lang();
}
kirby()->setCurrentTranslation($lang);
return Page::factory([
'slug' => 'sitemap',
'template' => 'sitemap',
'model' => 'sitemap',
'content' => [
'title' => t('sitemap'),
],
])->render(contentType: 'xsl');
}
],
[
'pattern' => 'sitemap.xml',
'action' => function () {
if (!option('tobimori.seo.sitemap.active', true)) {
$this->next();
}
SitemapIndex::instance()->generate();
kirby()->response()->type('text/xml');
return Page::factory([
'slug' => 'sitemap',
'template' => 'sitemap',
'model' => 'sitemap',
'content' => [
'title' => t('sitemap'),
'index' => null,
],
])->render(contentType: 'xml');
}
],
[
'pattern' => 'sitemap-(:any).xml',
'action' => function (string $index) {
if (!option('tobimori.seo.sitemap.active', true)) {
$this->next();
}
SitemapIndex::instance()->generate();
if (!SitemapIndex::instance()->isValidIndex($index)) {
$this->next();
}
kirby()->response()->type('text/xml');
return Page::factory([
'slug' => 'sitemap-' . $index,
'template' => 'sitemap',
'model' => 'sitemap',
'content' => [
'title' => t('sitemap'),
'index' => $index,
],
])->render(contentType: 'xml');
}
]
];

View file

@ -0,0 +1,20 @@
<?php
use Kirby\Toolkit\A;
return [
'seo-preview' => [
'mixins' => ['headline'],
'computed' => [
'options' => function () {
return A::map(option('tobimori.seo.previews'), fn ($item) => [
'value' => $item,
'text' => t($item)
]);
}
]
],
'heading-structure' => [
'mixins' => ['headline']
]
];

View file

@ -0,0 +1,28 @@
<?php
use Kirby\Http\Url;
use Kirby\Toolkit\Str;
use tobimori\Seo\SchemaSingleton;
return [
'schema' => fn ($type) => SchemaSingleton::getInstance($type),
'schemas' => fn () => SchemaSingleton::getInstances(),
'lang' => fn () => Str::replace(option('tobimori.seo.default.lang')($this->homePage()), '_', '-'),
'canonicalFor' => function (string $url) {
$base = option('tobimori.seo.canonicalBase');
if (is_callable($base)) {
$base = $base($url);
}
if ($base === null) {
$base = $this->url(); // graceful fallback to site url
}
if (Str::startsWith($url, $base)) {
return $url;
}
$path = Url::path($url);
return url($base . '/' . $path);
}
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,69 @@
<?php
@include_once __DIR__ . '/vendor/autoload.php';
use Kirby\Cms\App;
use Kirby\Data\Yaml;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\A;
use Spatie\SchemaOrg\Schema;
// shamelessly borrowed from distantnative/retour-for-kirby
if (
version_compare(App::version() ?? '0.0.0', '4.0.2', '<') === true ||
version_compare(App::version() ?? '0.0.0', '5.0.0', '>') === true
) {
throw new Exception('Kirby SEO requires Kirby 4.0.2 or higher.');
}
App::plugin('tobimori/seo', [
'options' => require __DIR__ . '/config/options.php',
'sections' => require __DIR__ . '/config/sections.php',
'api' => require __DIR__ . '/config/api.php',
'siteMethods' => require __DIR__ . '/config/siteMethods.php',
'pageMethods' => require __DIR__ . '/config/pageMethods.php',
'hooks' => require __DIR__ . '/config/hooks.php',
'routes' => require __DIR__ . '/config/routes.php',
// load all commands automatically
'commands' => A::keyBy(A::map(
Dir::read(__DIR__ . '/config/commands'),
fn ($file) => A::merge([
'id' => 'seo:' . F::name($file),
], require __DIR__ . '/config/commands/' . $file)
), 'id'),
// get all files from /translations and register them as language files
'translations' => A::keyBy(A::map(
Dir::read(__DIR__ . '/translations'),
fn ($file) => A::merge([
'lang' => F::name($file),
], Yaml::decode(F::read(__DIR__ . '/translations/' . $file)))
), 'lang'),
'snippets' => [
'seo/schemas' => __DIR__ . '/snippets/schemas.php',
'seo/head' => __DIR__ . '/snippets/head.php',
'seo/robots.txt' => __DIR__ . '/snippets/robots.txt.php',
],
'templates' => [
'sitemap' => __DIR__ . '/templates/sitemap.php',
'sitemap.xml' => __DIR__ . '/templates/sitemap.xml.php',
'sitemap.xsl' => __DIR__ . '/templates/sitemap.xsl.php',
],
'blueprints' => [
'seo/site' => __DIR__ . '/blueprints/site.yml',
'seo/page' => __DIR__ . '/blueprints/page.yml',
'seo/fields/og-image' => require __DIR__ . '/blueprints/fields/og-image.php',
'seo/fields/og-group' => __DIR__ . '/blueprints/fields/og-group.yml',
'seo/fields/meta-group' => __DIR__ . '/blueprints/fields/meta-group.yml',
'seo/fields/robots' => require __DIR__ . '/blueprints/fields/robots.php',
'seo/fields/site-robots' => require __DIR__ . '/blueprints/fields/site-robots.php',
'seo/fields/social-media' => require __DIR__ . '/blueprints/fields/social-media.php',
],
]);
if (!function_exists('schema')) {
function schema($type)
{
return Schema::{$type}();
}
}

View file

@ -0,0 +1,17 @@
{
"private": true,
"license": "MIT",
"author": "Tobias Möritz",
"type": "module",
"scripts": {
"dev": "kirbyup serve src/index.js",
"build": "kirbyup src/index.js",
"prepare": "husky install"
},
"devDependencies": {
"husky": "^9.0.11",
"kirbyup": "^3.1.4",
"postcss": "^8.4.35",
"sass": "^1.71.1"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
'postcss-logical': {},
autoprefixer: {}
}
}

View file

@ -0,0 +1,13 @@
<?php
/**
* @var \Kirby\Cms\Page $page
*/
use Kirby\Cms\Html;
$tags = $page->metadata()->snippetData();
foreach ($tags as $tag) {
echo Html::tag($tag['tag'], $tag['content'] ?? null, $tag['attributes'] ?? []) . PHP_EOL;
}

View file

@ -0,0 +1,58 @@
<?php
use Kirby\Toolkit\A;
if ($content = option('tobimori.seo.robots.content')) {
if (is_callable($content)) {
$content = $content();
}
if (is_array($content)) {
$str = [];
foreach ($content as $ua => $data) {
$str[] = 'User-agent: ' . $ua;
foreach ($data as $type => $values) {
foreach ($values as $value) {
$str[] = $type . ': ' . $value;
}
}
}
$content = A::join($str, PHP_EOL);
}
echo $content;
} else {
// output default
echo "User-agent: *\n";
$index = option('tobimori.seo.robots.index');
if (is_callable($index)) {
$index = $index();
}
if ($index) {
echo 'Allow: /';
echo "\nDisallow: /panel";
} else {
echo 'Disallow: /';
}
}
if (($sitemap = option('tobimori.seo.robots.sitemap')) || ($sitemapModule = option('tobimori.seo.sitemap.active'))) {
// Allow closure to be used
if (is_callable($sitemap)) {
$sitemap = $sitemap();
}
// Use default sitemap if none is set
if (!$sitemap && $sitemapModule) {
$sitemap = site()->canonicalFor('/sitemap.xml');
}
// Check again, so falsy values can't be used
if ($sitemap) {
echo "\n\nSitemap: {$sitemap}";
}
}

View file

@ -0,0 +1,8 @@
<?php
$siteSchema ??= true;
$pageSchema ??= true;
foreach (array_merge($siteSchema ? $site->schemas() : [], $pageSchema ? $page->schemas() : []) as $schema) {
echo $schema;
}

Some files were not shown because too many files have changed in this diff Show more