SEO : add tombi mori plugin

This commit is contained in:
isUnknown 2025-05-13 09:03:14 +02:00
parent df2843123f
commit 8f9e75126e
64 changed files with 3719 additions and 44 deletions

View file

@ -0,0 +1,100 @@
<template>
<div>
<div class="k-facebook-preview">
<div class="k-facebook-preview__image" v-if="ogImage">
<img :src="ogImage" class="k-facebook-preview__img" />
</div>
<div class="k-facebook-preview__content">
<span class="k-facebook-preview__url">{{ host }}</span>
<span class="k-facebook-preview__title">{{ ogTitle }}</span>
<p class="k-facebook-preview__description">{{ ogDescription }}</p>
</div>
</div>
<a
class="k-seo-preview__debugger"
href="https://developers.facebook.com/tools/debug/"
aria-label="Facebook Sharing Debugger"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('open-debugger') }}
<k-icon type="open" />
</a>
</div>
</template>
<script>
export default {
props: {
ogTitle: String,
url: String,
ogDescription: String,
ogImage: String
},
computed: {
host() {
return new URL(this.url).host
}
}
}
</script>
<style lang="scss">
.k-facebook-preview {
background: #f0f2f5;
border: 1px solid #ced0d4;
overflow: hidden;
border-radius: var(--rounded);
&__image {
width: 100%;
height: 0;
padding-bottom: 52.355%;
position: relative;
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__content {
padding: 0.75rem 1rem;
}
&__title,
&__description,
&__url {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 1;
}
&__url {
color: #65676b;
font-size: 0.75rem;
text-transform: uppercase;
line-height: 1.1;
margin-bottom: 0.25rem;
}
&__title {
font-weight: 600;
line-height: 1.1765;
font-size: 1rem;
color: #050505;
margin: 0.3125rem 0;
}
&__description {
line-height: 1.3333;
color: #65676b;
font-size: 0.875rem;
}
}
</style>

View file

@ -0,0 +1,115 @@
<template>
<div>
<div class="k-google-search-preview">
<span class="k-google-search-preview__url">
<span>{{ origin }}</span>
<span
v-for="(breadcrumb, index) in breadcrumbs"
:key="index"
class="k-google-search-preview__url__breadcrumb"
>
{{ breadcrumb }}
</span>
</span>
<h2 class="k-google-search-preview__headline">{{ title }}</h2>
<p class="k-google-search-preview__paragraph">
{{ description }}
</p>
</div>
<a
class="k-seo-preview__debugger"
href="https://search.google.com/search-console"
aria-label="Google Search Console"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('open-search-console') }}
<k-icon type="open" />
</a>
</div>
</template>
<script>
export default {
props: {
title: String,
url: String,
description: String
},
computed: {
origin() {
return new URL(this.url).origin
},
breadcrumbs() {
return this.url.split('/').slice(3)
}
}
}
</script>
<style lang="scss">
.k-google-search-preview {
padding: 1em;
background: #fff;
border: 1px solid #ccc;
letter-spacing: -0.005em;
border-radius: var(--rounded);
&__headline,
&__paragraph {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__headline {
margin-top: 0;
margin-bottom: 0.25em;
font-size: 1.25em;
font-weight: normal;
color: #1a0dab;
-webkit-line-clamp: 1;
&:hover {
text-decoration: underline;
}
}
&__url {
display: inline-block;
margin-bottom: 0.5em;
font-size: 0.875em;
line-height: 1.3;
color: #202124;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 100%;
> * {
margin-right: 0.25em;
}
&__breadcrumb {
color: #5f6368;
display: inline-block;
&::before {
content: ' ';
}
}
.k-icon {
margin-left: 0.1em;
}
}
&__paragraph {
margin: 0;
font-size: 0.875em;
line-height: 1.3em;
color: #3c4043;
-webkit-line-clamp: 3;
}
}
</style>

View file

@ -0,0 +1,93 @@
<template>
<div class="k-slack-preview">
<div class="k-slack-preview__content">
<div class="k-slack-preview__site-name">{{ ogSiteName || origin }}</div>
<span class="k-slack-preview__title">{{ ogTitle }}</span>
<p class="k-slack-preview__description">{{ ogDescription }}</p>
</div>
<div class="k-slack-preview__image" v-if="ogImage">
<img :src="ogImage" />
</div>
</div>
</template>
<script>
export default {
props: {
ogTitle: String,
ogSiteName: String,
ogDescription: String,
ogImage: String
},
computed: {
origin() {
return new URL(this.url).origin
}
}
}
</script>
<style lang="scss">
.k-slack-preview {
max-width: 32.5rem;
position: relative;
padding-left: 1rem;
line-height: 1.46666667;
font-size: 0.9375rem;
&::before {
position: absolute;
content: '';
top: 0;
left: 0;
bottom: 0;
width: 0.25rem;
border-radius: 0.5rem;
background: #ddd;
}
&__site-name {
display: flex;
align-items: center;
color: #717274;
}
&__title {
font-weight: 700;
display: block;
color: #0576b9;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__description {
color: #2c2d30;
}
&__image {
border-radius: 0.5rem;
max-width: 22.5rem;
overflow: hidden;
position: relative;
margin-top: 0.5rem;
&::before {
border-radius: 0.5rem;
content: '';
inset: 0;
z-index: 2;
position: absolute;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
}
img {
width: 100%;
height: 100%;
display: block;
}
}
}
</style>

View file

@ -0,0 +1,150 @@
https://github.com/getkirby/kirby/blob/main/panel/src/components/Views/Pages/PageView.vue
<template>
<k-panel-inside
:data-has-tabs="tabs.length > 1"
:data-id="model.id"
:data-locked="isLocked"
:data-template="blueprint"
class="k-page-view"
>
<template #topbar>
<k-prev-next v-if="model.id" :prev="prev" :next="next" />
</template>
<k-header
:editable="permissions.changeTitle && !isLocked"
class="k-page-view-header"
@edit="$dialog(id + '/changeTitle')"
>
{{ model.title }}
<template #buttons>
<k-button-group>
<k-button
v-if="permissions.preview && model.previewUrl"
:link="model.previewUrl"
:title="$t('open')"
icon="open"
target="_blank"
variant="filled"
size="sm"
class="k-page-view-preview"
/>
<k-button
:disabled="isLocked === true"
:dropdown="true"
:title="$t('settings')"
icon="cog"
variant="filled"
size="sm"
class="k-page-view-options"
@click="$refs.settings.toggle()"
/>
<k-dropdown-content ref="settings" :options="$dropdown(id)" align-x="end" />
<k-languages-dropdown />
<k-button
v-if="status"
v-bind="statusBtn"
class="k-page-view-status"
variant="filled"
@click="$dialog(id + '/changeStatus')"
/>
<k-button
class="k-page-view-status k-page-view-robots"
v-if="robots && robots.active"
v-bind="robotsBtn"
@click="openSeoTab"
/>
</k-button-group>
<k-form-buttons :lock="lock" />
</template>
</k-header>
<k-model-tabs :tab="tab.name" :tabs="tabs" />
<k-sections
:blueprint="blueprint"
:empty="$t('page.blueprint', { blueprint: $esc(blueprint) })"
:lock="lock"
:parent="id"
:tab="tab"
/>
</k-panel-inside>
</template>
<script>
export default {
extends: 'k-page-view',
data() {
return {
dirty: false,
robots: {
active: false,
state: []
}
}
},
async mounted() {
await this.handleLoad()
},
methods: {
openSeoTab() {
panel.view.open(panel.view.path + '?tab=seo')
},
async handleLoad(changes) {
if (!panel.view.props.tabs.some((tab) => tab.name === 'seo')) return
const page = this.model.id.replaceAll('/', '+')
this.robots = await panel.api.post(`/k-seo/${page}/robots`, changes ?? this.changes)
}
},
computed: {
changes() {
return this.$store.getters['content/changes']() // TODO: new panel API for changes?
},
robotsBtn() {
const btn = {
responsive: true,
size: 'sm',
icon: 'robots',
theme: 'positive',
text: this.$t('indicator-index'),
variant: 'filled'
}
if (this.robots.state.includes('no')) {
btn.text = this.$t('indicator-any')
btn.theme = 'notice'
btn.icon = 'robots-off'
}
if (this.robots.state.includes('noindex')) {
btn.text = this.$t('indicator-noindex')
btn.theme = 'negative'
}
return btn
}
},
watch: {
changes(changes) {
if (Object.keys(changes).some((key) => key.includes('robots')) || this.dirty) {
this.dirty = false
this.handleLoad(changes)
if (changes) this.dirty = true
}
}
}
}
</script>
<style lang="scss">
.k-page-view-robots {
--color-green-boost: -15%;
}
</style>

View file

@ -0,0 +1,13 @@
import { kirbyup } from 'kirbyup/plugin'
import PageView from './components/Views/PageView.vue'
panel.plugin('tobimori/seo', {
icons: {
robots: `<path d="M13.5 2c0 .444-.193.843-.5 1.118V5h5a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3h5V3.118A1.5 1.5 0 1 1 13.5 2ZM6 7a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H6Zm-4 3H0v6h2v-6Zm20 0h2v6h-2v-6ZM9 14.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm6 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />`,
'robots-off': `<path fill-rule="evenodd" clip-rule="evenodd" d="M21 16.786V8a3 3 0 0 0-3-3h-5V3.118a1.5 1.5 0 1 0-2 0V5H9.214l2 2H18a1 1 0 0 1 1 1v6.786l2 2ZM2.093 3.507l2.099 2.099A2.995 2.995 0 0 0 3 8v10a3 3 0 0 0 3 3h12c.463 0 .902-.105 1.293-.292l1.9 1.9 1.414-1.415-6.88-6.88a1.5 1.5 0 1 0-2.04-2.04L3.508 2.093 2.093 3.507ZM5 8a1 1 0 0 1 .65-.937L17.585 19H6a1 1 0 0 1-1-1V8Zm-5 2h2v6H0v-6Zm24 0h-2v6h2v-6Zm-13.5 3a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z" />`
},
sections: kirbyup.import('./sections/*.vue'),
components: {
'k-page-view': PageView
}
})

View file

@ -0,0 +1,191 @@
<template>
<div class="k-section k-heading-structure" v-if="value">
<div class="k-field-header k-heading-structure__label k-label k-field-label">
<k-icon type="headline" />
<span class="k-label-text">{{ label || $t('heading-structure') }}</span>
<k-loader v-if="isLoading" />
</div>
<k-box theme="white">
<ol class="k-heading-structure__list">
<li
v-for="(item, index) in value"
:key="index"
:style="`z-index: ${value.length - index}`"
:class="`k-heading-structure__item level-${item.level} ${
itemInvalid(item, index) ? 'is-invalid' : ''
}`"
>
<span class="k-heading-structure__item__level">H{{ item.level }}</span>
<span class="k-heading-structure__item__text">{{ item.text }}</span>
</li>
</ol>
</k-box>
<k-box class="k-heading-structure__notice" theme="negative" v-if="incorrectOrder && !noH1">
<k-icon type="alert" />
<k-text>{{ $t('incorrect-heading-order') }}</k-text>
</k-box>
<k-box class="k-heading-structure__notice" theme="negative" v-if="multipleH1">
<k-icon type="alert" />
<k-text>{{ $t('multiple-h1-tags') }}</k-text>
</k-box>
<k-box class="k-heading-structure__notice" theme="negative" v-if="noH1">
<k-icon type="alert" />
<k-text>{{ $t('missing-h1-tag') }}</k-text>
</k-box>
</div>
</template>
<script>
export default {
data() {
return {
label: null,
value: null,
isLoading: true
}
},
created() {
this.isLoading = true
this.load().then((data) => {
this.label = data.label
}) // loads label and properties
this.handleLoad() // handles metadata & title change
this.debouncedLoad = this.$helper.debounce((changes) => {
this.handleLoad(changes)
}, 200) // debounce function for dirty changes watcher
},
computed: {
changes() {
return this.$store.getters['content/changes']()
},
incorrectOrder() {
return this.value?.some((item, index) => item.level > (this.value[index - 1]?.level ?? 0) + 1)
},
multipleH1() {
return this.value?.filter((item) => item.level === 1).length > 1
},
noH1() {
return this.value?.filter((item) => item.level === 1).length === 0
}
},
methods: {
async handleLoad(changes) {
this.isLoading = true
const page = panel.view.props.model.id
if (!page) {
throw new Error('[kirby-seo] The Heading structure section is only available for pages')
}
const response = await panel.api.post(
`/k-seo/${page.replaceAll('/', '+')}/heading-structure`,
changes ?? this.changes
)
this.value = response
this.isLoading = false
},
itemInvalid(item, index) {
if (item.level > (this.value[index - 1]?.level ?? 0) + 1) return true // wrong order
if (item.level === 1 && this.value[index - 1]) return true // wrong order
if (item.level === 1 && this.value.filter((item) => item.level === 1).length > 1) return true // multiple h1
return false
}
},
watch: {
changes(changes) {
this.debouncedLoad(changes)
}
}
}
</script>
<style lang="scss">
.k-heading-structure {
&__label {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--spacing-2);
> .k-icon {
color: var(--color-gray-700);
}
> .k-loader {
margin-left: auto;
color: var(--color-gray-700);
}
}
&__notice {
margin-top: var(--spacing-2);
display: flex;
align-items: flex-start;
> .k-icon {
margin-top: 0.125rem;
margin-right: var(--spacing-1);
color: var(--color-red);
}
}
&__list {
overflow: hidden;
}
&__item {
position: relative;
background: var(--theme-color-back);
padding-block: var(--spacing-px);
display: flex;
&__level {
font-family: var(--font-mono);
font-weight: 700;
margin-right: var(--spacing-2);
}
&__text {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&.is-invalid {
color: var(--color-red);
}
@for $i from 2 through 6 {
&.level-#{$i} {
margin-left: ($i - 2) * 1.6rem;
padding-left: 1.6rem;
&::before {
content: '';
position: absolute;
top: calc(50% - 0.0625rem);
left: 0.4rem;
width: 0.8rem;
height: 0.125rem;
background-color: currentColor;
}
&::after {
content: '';
position: absolute;
bottom: calc(50% - 0.0625rem);
left: 0.4rem;
height: 9999px;
width: 0.125rem;
background-color: currentColor;
}
}
}
}
}
</style>

View file

@ -0,0 +1,126 @@
<template>
<div class="k-section k-seo-preview">
<div class="k-field-header k-seo-preview__label k-label k-field-label">
<k-icon type="preview" /><span class="k-label-text">{{ label || $t('seo-preview') }}</span>
<k-loader v-if="isLoading" />
</div>
<k-select-field
type="select"
name="seo-preview-type"
:before="$t('seo-preview-for')"
v-model="type"
:options="options"
:empty="false"
/>
<div class="k-seo-preview__inner" v-if="value">
<google-preview v-if="type === 'google'" v-bind="value" />
<facebook-preview v-if="type === 'facebook'" v-bind="value" />
<slack-preview v-if="type === 'slack'" v-bind="value" />
</div>
</div>
</template>
<script>
import FacebookPreview from '../components/Previews/FacebookPreview.vue'
import GooglePreview from '../components/Previews/GooglePreview.vue'
import SlackPreview from '../components/Previews/SlackPreview.vue'
export default {
components: { GooglePreview, FacebookPreview, SlackPreview },
data() {
const type = localStorage.getItem('kSEOPreviewType') ?? 'google'
return {
label: null,
value: null,
isLoading: true,
options: [],
type
}
},
created() {
this.isLoading = true
this.load().then((data) => {
this.label = data.label
this.options = data.options
}) // loads label and properties
this.handleLoad() // handles metadata & title change
this.debouncedLoad = this.$helper.debounce((changes) => {
this.handleLoad(changes)
}, 200) // debounce function for dirty changes watcher
},
computed: {
changes() {
return this.$store.getters['content/changes']()
}
},
methods: {
async handleLoad(changes) {
this.isLoading = true
const page = panel.view.props.model?.id?.replaceAll('/', '+') ?? 'site'
const response = await panel.api.post(`/k-seo/${page}/seo-preview`, changes ?? this.changes)
this.value = response
this.isLoading = false
}
},
watch: {
changes(changes) {
this.debouncedLoad(changes)
},
type() {
localStorage.setItem('kSEOPreviewType', this.type)
}
}
}
</script>
<style lang="scss">
.k-field-name-seo-preview-type .k-field-header {
display: none;
}
.k-seo-preview {
&__inner {
margin-top: var(--spacing-2);
}
&__debugger {
margin-top: 1rem;
display: flex;
font-size: var(--text-sm);
color: var(--color-gray-700);
line-height: 1.25rem;
width: max-content;
margin-left: auto;
&:hover {
text-decoration: underline;
color: var(--text-gray-800);
}
> .k-icon {
margin-left: var(--spacing-2);
}
}
&__label {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--spacing-2);
> .k-icon {
color: var(--color-gray-700);
}
> .k-loader {
margin-left: auto;
color: var(--color-gray-700);
}
}
}
</style>