feat: structured data, graph preview and primary category in the SEO tab
- E8 golem15-seo-schema: the type per content kind, the Service grid with price and currency checks, custom JSON-LD with a 300 ms parse status - E9 golem15-seo-graph: nodes and findings from the seo_graph action, the JSON, the Rich Results Test link, 800 ms re-check on fill changes, the ML locale switch, one queued request, the failure line - golem15-seo-parent: the primary category select over the record's parents; the seoparent Decode accepts the loaded value sent back - the rebuilt element bundle and vitest smoke tests
This commit is contained in:
2
assets/dist/seo.css
vendored
2
assets/dist/seo.css
vendored
File diff suppressed because one or more lines are too long
3
assets/dist/seo.js
vendored
3
assets/dist/seo.js
vendored
File diff suppressed because one or more lines are too long
240
assets/src/elements/graph.ts
Normal file
240
assets/src/elements/graph.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { ActionQueue } from './actions'
|
||||
import { el, fmt, icon, jsonAttr, labelsFor, uiLocaleOf, type Labels } from './labels'
|
||||
|
||||
export const GRAPH_TAG = 'golem15-seo-graph'
|
||||
|
||||
/** One missing property of a node. */
|
||||
export interface GraphFinding {
|
||||
level: 'required' | 'recommended'
|
||||
property: string
|
||||
}
|
||||
|
||||
/** A node of the graph with its findings. */
|
||||
export interface GraphNode {
|
||||
id: string
|
||||
type: string
|
||||
findings: GraphFinding[]
|
||||
rules: boolean
|
||||
}
|
||||
|
||||
/** The seo_graph action's answer (UI-SPEC C18). */
|
||||
export interface GraphData {
|
||||
locale: string
|
||||
url: string | null
|
||||
graph: Record<string, unknown> | null
|
||||
nodes: GraphNode[]
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
op: 'graph'
|
||||
locale: string
|
||||
}
|
||||
|
||||
/** The Rich Results Test address of a page. */
|
||||
export function richResultsURL(url: string): string {
|
||||
return 'https://search.google.com/test/rich-results?url=' + encodeURIComponent(url)
|
||||
}
|
||||
|
||||
/** The fragment of an @id (#article), the whole id when it has none. */
|
||||
function fragment(id: string): string {
|
||||
const i = id.indexOf('#')
|
||||
return i >= 0 ? id.slice(i) : id
|
||||
}
|
||||
|
||||
/**
|
||||
* E9 golem15-seo-graph (UI-SPEC C18, D-46): the effective @graph of the
|
||||
* saved page with the unsaved seo_schema value, every node with Google's
|
||||
* required and recommended findings, the JSON and the Rich Results Test
|
||||
* link. It asks through summer-action only ({op: graph, locale}), on
|
||||
* connect, 800 ms after the last fill change and on the ML locale switch.
|
||||
*/
|
||||
export class SeoGraphElement extends HTMLElement {
|
||||
static observedAttributes = ['fill-values', 'busy', 'state', 'ui-locale', 'disabled', 'label']
|
||||
|
||||
private data: GraphData | null = null
|
||||
private failed = false
|
||||
private pending = false
|
||||
private showJSON = false
|
||||
private shownLocale = ''
|
||||
private lastFill = ''
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
private connected = false
|
||||
private readonly actions = new ActionQueue<Payload>(this, () => this.fail())
|
||||
|
||||
private readonly onResult = (ev: Event): void => {
|
||||
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
|
||||
this.actions.settle()
|
||||
const data = detail?.data as GraphData | undefined
|
||||
if (data && Array.isArray(data.nodes)) {
|
||||
this.data = data
|
||||
this.failed = false
|
||||
}
|
||||
this.pending = this.actions.current !== null
|
||||
this.render()
|
||||
}
|
||||
|
||||
private readonly onLocale = (ev: Event): void => {
|
||||
const locale = (ev as CustomEvent).detail
|
||||
if (typeof locale === 'string' && locale !== '' && locale !== this.shownLocale) {
|
||||
this.shownLocale = locale
|
||||
this.check()
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.connected = true
|
||||
this.lastFill = this.getAttribute('fill-values') ?? ''
|
||||
this.addEventListener('summer-result', this.onResult)
|
||||
window.addEventListener('summer:ml-locale', this.onLocale)
|
||||
this.render()
|
||||
queueMicrotask(() => {
|
||||
if (this.connected) {
|
||||
this.check()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.connected = false
|
||||
this.removeEventListener('summer-result', this.onResult)
|
||||
window.removeEventListener('summer:ml-locale', this.onLocale)
|
||||
clearTimeout(this.timer)
|
||||
this.actions.reset()
|
||||
this.pending = false
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
||||
if (!this.connected) {
|
||||
return
|
||||
}
|
||||
if (name === 'busy' && value === null) {
|
||||
this.actions.flush()
|
||||
return
|
||||
}
|
||||
if (name === 'state' && value === 'error') {
|
||||
if (this.actions.settle()) {
|
||||
this.fail()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (name === 'fill-values') {
|
||||
const fill = value ?? ''
|
||||
if (fill !== this.lastFill) {
|
||||
this.lastFill = fill
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => this.check(), 800)
|
||||
}
|
||||
return
|
||||
}
|
||||
this.render()
|
||||
}
|
||||
|
||||
private labels(): Labels {
|
||||
return labelsFor(uiLocaleOf(this))
|
||||
}
|
||||
|
||||
/** Asks for the graph of the shown locale (one queued request at most). */
|
||||
check(): void {
|
||||
clearTimeout(this.timer)
|
||||
this.pending = true
|
||||
this.actions.request({ op: 'graph', locale: this.shownLocale })
|
||||
this.render()
|
||||
}
|
||||
|
||||
private fail(): void {
|
||||
this.failed = true
|
||||
this.pending = this.actions.current !== null
|
||||
this.render()
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (!this.connected) {
|
||||
return
|
||||
}
|
||||
const labels = this.labels()
|
||||
const sections: HTMLElement[] = []
|
||||
this.setAttribute('aria-busy', String(this.pending))
|
||||
const data = this.data
|
||||
|
||||
const header = el('p', 'seo-graph__meta')
|
||||
if (data) {
|
||||
header.textContent = data.url ? fmt(labels.forLocale, { locale: (data.locale || '').toUpperCase(), url: data.url }) : labels.graphNoUrl
|
||||
}
|
||||
if (this.pending) {
|
||||
const line = el('p', 'seo-graph__meta seo-graph__checking', labels.graphChecking)
|
||||
line.setAttribute('role', 'status')
|
||||
sections.push(line)
|
||||
}
|
||||
if (data) {
|
||||
sections.push(header)
|
||||
const list = el('div', 'seo-graph__nodes')
|
||||
let problems = 0
|
||||
for (const node of data.nodes) {
|
||||
const row = el('div', 'seo-graph__node')
|
||||
const head = el('div', 'seo-graph__node-head')
|
||||
head.append(el('span', 'seo-graph__pill', node.type || '—'), el('code', 'seo-graph__id', fragment(node.id || '')))
|
||||
row.append(head)
|
||||
if (!node.rules) {
|
||||
row.append(el('p', 'seo-graph__meta', fmt(labels.noRules, { type: node.type })))
|
||||
}
|
||||
for (const f of node.findings ?? []) {
|
||||
problems++
|
||||
const required = f.level === 'required'
|
||||
const line = el('p', 'seo-graph__finding seo-graph__finding--' + (required ? 'required' : 'recommended'))
|
||||
line.append(icon(required ? 'circle-alert' : 'triangle-alert'), el('span', '', fmt(required ? labels.required : labels.recommended, { property: f.property, type: node.type })))
|
||||
row.append(line)
|
||||
}
|
||||
list.append(row)
|
||||
}
|
||||
sections.push(list)
|
||||
if (problems === 0 && data.nodes.length > 0) {
|
||||
const ok = el('p', 'seo-graph__ok')
|
||||
ok.append(icon('circle-check'), el('span', '', labels.allGood))
|
||||
sections.push(ok)
|
||||
}
|
||||
if (data.graph) {
|
||||
const toggle = el('button', 'seo-graph__disclosure')
|
||||
toggle.type = 'button'
|
||||
toggle.setAttribute('aria-expanded', String(this.showJSON))
|
||||
toggle.append(el('span', '', this.showJSON ? labels.hideJson : labels.showJson), icon('chevron-down'))
|
||||
toggle.addEventListener('click', () => {
|
||||
this.showJSON = !this.showJSON
|
||||
this.render()
|
||||
})
|
||||
sections.push(toggle)
|
||||
if (this.showJSON) {
|
||||
sections.push(el('pre', 'seo-graph__json', JSON.stringify(data.graph, null, 2)))
|
||||
}
|
||||
}
|
||||
const notes = el('div', 'seo-graph__links')
|
||||
if (data.url) {
|
||||
const link = el('a', 'seo-graph__link')
|
||||
link.href = richResultsURL(data.url)
|
||||
link.target = '_blank'
|
||||
link.rel = 'noopener'
|
||||
link.append(el('span', '', labels.richResults), icon('external-link'))
|
||||
notes.append(link)
|
||||
}
|
||||
notes.append(el('p', 'seo-graph__meta', labels.graphUnsaved), el('p', 'seo-graph__meta', labels.layerNodes))
|
||||
sections.push(notes)
|
||||
}
|
||||
if (this.failed) {
|
||||
const line = el('p', 'seo-graph__error')
|
||||
line.setAttribute('role', 'status')
|
||||
line.append(icon('circle-alert'), el('span', '', labels.graphFailed))
|
||||
sections.push(line)
|
||||
}
|
||||
if (!this.hasAttribute('disabled')) {
|
||||
const button = el('button', 'seo-graph__button', this.getAttribute('label') || labels.graphAction)
|
||||
button.type = 'button'
|
||||
button.addEventListener('click', () => this.check())
|
||||
sections.push(button)
|
||||
}
|
||||
this.replaceChildren(...sections)
|
||||
}
|
||||
|
||||
/** The fill snapshot (seo_schema), for tests. */
|
||||
fill(): Record<string, unknown> {
|
||||
return jsonAttr<Record<string, unknown>>(this, 'fill-values', {})
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import './styles.css'
|
||||
import { GRAPH_TAG, SeoGraphElement } from './graph'
|
||||
import { IMAGE_TAG, SeoImageElement } from './image'
|
||||
import { PARENT_TAG, SeoParentElement } from './parent'
|
||||
import { ROBOTS_TAG, SeoRobotsElement } from './robots'
|
||||
import { ROBOTSTESTER_TAG, SeoRobotsTesterElement } from './robotstester'
|
||||
import { ROBOTSTXT_TAG, SeoRobotsTxtElement } from './robotstxt'
|
||||
import { SCHEMA_TAG, SeoSchemaElement } from './schema'
|
||||
import { SERP_TAG, SeoSerpElement } from './serp'
|
||||
import { SeoTextElement, TEXT_TAG } from './text'
|
||||
import { SeoTransferElement, TRANSFER_TAG } from './transfer'
|
||||
@@ -18,6 +21,9 @@ const elements: Array<[string, CustomElementConstructor]> = [
|
||||
[ROBOTSTXT_TAG, SeoRobotsTxtElement],
|
||||
[ROBOTSTESTER_TAG, SeoRobotsTesterElement],
|
||||
[TRANSFER_TAG, SeoTransferElement],
|
||||
['golem15-seo-schema', SeoSchemaElement],
|
||||
['golem15-seo-graph', SeoGraphElement],
|
||||
['golem15-seo-parent', SeoParentElement],
|
||||
]
|
||||
for (const [tag, ctor] of elements) {
|
||||
if (!customElements.get(tag)) {
|
||||
@@ -26,7 +32,13 @@ for (const [tag, ctor] of elements) {
|
||||
}
|
||||
|
||||
export {
|
||||
GRAPH_TAG,
|
||||
IMAGE_TAG,
|
||||
PARENT_TAG,
|
||||
SCHEMA_TAG,
|
||||
SeoGraphElement,
|
||||
SeoParentElement,
|
||||
SeoSchemaElement,
|
||||
ROBOTS_TAG,
|
||||
ROBOTSTESTER_TAG,
|
||||
ROBOTSTXT_TAG,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Element copy of the SEO field elements (UI-SPEC copywriting E1 to E6,
|
||||
// plus E7 for the social image), chosen by the ui-locale attribute. Plural
|
||||
// E7 for the social image, E8 and E9 for structured data), chosen by the ui-locale attribute. Plural
|
||||
// texts are CLDR maps; placeholders use :name.
|
||||
|
||||
/** A CLDR plural map: one/other in English, one/few/many/other in Polish. */
|
||||
@@ -134,6 +134,58 @@ export interface Labels {
|
||||
readFailed: string
|
||||
openHistory: string
|
||||
applyCount: Plural
|
||||
// E8 golem15-seo-schema
|
||||
schemaType: string
|
||||
typeDefault: string
|
||||
tBlogPosting: string
|
||||
tArticle: string
|
||||
tNewsArticle: string
|
||||
tWebPage: string
|
||||
tAboutPage: string
|
||||
tContactPage: string
|
||||
tService: string
|
||||
tCollectionPage: string
|
||||
tProfilePage: string
|
||||
noteArticle: string
|
||||
notePage: string
|
||||
noteContact: string
|
||||
noteService: string
|
||||
noteCollection: string
|
||||
noteProfile: string
|
||||
serviceType: string
|
||||
serviceTypePh: string
|
||||
areaServed: string
|
||||
areaServedHint: string
|
||||
price: string
|
||||
priceHint: string
|
||||
currency: string
|
||||
currencyHint: string
|
||||
priceInvalid: string
|
||||
currencyInvalid: string
|
||||
custom: string
|
||||
customSet: string
|
||||
customHint: string
|
||||
jsonValid: Plural
|
||||
jsonError: string
|
||||
jsonNotObject: string
|
||||
jsonTooLarge: string
|
||||
// E9 golem15-seo-graph
|
||||
forLocale: string
|
||||
graphChecking: string
|
||||
allGood: string
|
||||
required: string
|
||||
recommended: string
|
||||
noRules: string
|
||||
showJson: string
|
||||
hideJson: string
|
||||
richResults: string
|
||||
graphNoUrl: string
|
||||
graphUnsaved: string
|
||||
layerNodes: string
|
||||
graphAction: string
|
||||
graphFailed: string
|
||||
// golem15-seo-parent
|
||||
primaryDefault: string
|
||||
}
|
||||
|
||||
const en: Labels = {
|
||||
@@ -253,6 +305,55 @@ const en: Labels = {
|
||||
readFailed: 'The file could not be read.',
|
||||
openHistory: 'Open History',
|
||||
applyCount: { one: 'Apply :count change', other: 'Apply :count changes' },
|
||||
schemaType: 'Schema type',
|
||||
typeDefault: 'Default (:type)',
|
||||
tBlogPosting: 'BlogPosting (blog post)',
|
||||
tArticle: 'Article (article)',
|
||||
tNewsArticle: 'NewsArticle (news)',
|
||||
tWebPage: 'WebPage (page)',
|
||||
tAboutPage: 'AboutPage (about us)',
|
||||
tContactPage: 'ContactPage (contact)',
|
||||
tService: 'Service (service or offer)',
|
||||
tCollectionPage: 'CollectionPage (listing)',
|
||||
tProfilePage: 'ProfilePage (profile)',
|
||||
noteArticle: 'Author, dates, image, word count and section come from the content.',
|
||||
notePage: 'Name, description and breadcrumbs come from the SEO tab.',
|
||||
noteContact: 'Address and opening hours come from the SEO settings (Publisher, Local business).',
|
||||
noteService: 'The provider is the publisher from the SEO settings.',
|
||||
noteCollection: 'Lists the items shown on the page.',
|
||||
noteProfile: 'Name, image and profiles come from the author.',
|
||||
serviceType: 'Kind of service',
|
||||
serviceTypePh: 'e.g. Mushroom workshops',
|
||||
areaServed: 'Area served',
|
||||
areaServedHint: 'Cities or regions, separated by commas.',
|
||||
price: 'Price',
|
||||
priceHint: 'Leave empty when the price is set individually.',
|
||||
currency: 'Currency',
|
||||
currencyHint: 'ISO code, e.g. PLN.',
|
||||
priceInvalid: 'Enter a price such as 150 or 149.99.',
|
||||
currencyInvalid: 'Use a three-letter currency code, e.g. PLN.',
|
||||
custom: 'Custom JSON-LD (advanced)',
|
||||
customSet: 'Set',
|
||||
customHint: 'An object or a list of objects. A node with the @id of a generated node is merged into it, and your values win; other nodes are added to the graph. Paste only the JSON, without a script tag.',
|
||||
jsonValid: { one: 'Valid JSON: :count node', other: 'Valid JSON: :count nodes' },
|
||||
jsonError: 'Line :line, column :column: :message',
|
||||
jsonNotObject: 'Use an object or a list of objects.',
|
||||
jsonTooLarge: 'Over 16 KB: shorten the custom JSON-LD.',
|
||||
forLocale: 'JSON-LD for :locale — :url',
|
||||
graphChecking: 'Building the graph…',
|
||||
allGood: 'No problems: every node has its required and recommended properties.',
|
||||
required: 'Missing required property “:property” (:type)',
|
||||
recommended: 'Missing recommended property “:property” (:type)',
|
||||
noRules: ':type has no rich-result rules at Google; it is checked as valid schema.org only.',
|
||||
showJson: 'Show JSON-LD',
|
||||
hideJson: 'Hide JSON-LD',
|
||||
richResults: "Check in Google's Rich Results Test",
|
||||
graphNoUrl: 'The Rich Results Test link appears after the first save, when the page has an address.',
|
||||
graphUnsaved: 'Shows the saved page with your unsaved structured data changes.',
|
||||
layerNodes: 'The site may add nodes of its own, such as FAQPage. The site audit checks the rendered result.',
|
||||
graphAction: 'Check structured data',
|
||||
graphFailed: 'The structured data could not be built. Try again.',
|
||||
primaryDefault: 'Default (first category)',
|
||||
}
|
||||
|
||||
const pl: Labels = {
|
||||
@@ -382,6 +483,55 @@ const pl: Labels = {
|
||||
readFailed: 'Nie udało się odczytać pliku.',
|
||||
openHistory: 'Otwórz Historię zmian',
|
||||
applyCount: { one: 'Zastosuj :count zmianę', few: 'Zastosuj :count zmiany', many: 'Zastosuj :count zmian', other: 'Zastosuj :count zmiany' },
|
||||
schemaType: 'Typ schema',
|
||||
typeDefault: 'Domyślny (:type)',
|
||||
tBlogPosting: 'BlogPosting (wpis na blogu)',
|
||||
tArticle: 'Article (artykuł)',
|
||||
tNewsArticle: 'NewsArticle (wiadomość)',
|
||||
tWebPage: 'WebPage (strona)',
|
||||
tAboutPage: 'AboutPage (o nas)',
|
||||
tContactPage: 'ContactPage (kontakt)',
|
||||
tService: 'Service (usługa lub oferta)',
|
||||
tCollectionPage: 'CollectionPage (lista)',
|
||||
tProfilePage: 'ProfilePage (profil)',
|
||||
noteArticle: 'Autor, daty, obraz, liczba słów i sekcja pochodzą z treści.',
|
||||
notePage: 'Nazwa, opis i ścieżka nawigacji pochodzą z zakładki SEO.',
|
||||
noteContact: 'Adres i godziny otwarcia pochodzą z ustawień SEO (Wydawca, Firma lokalna).',
|
||||
noteService: 'Usługodawcą jest wydawca z ustawień SEO.',
|
||||
noteCollection: 'Wymienia elementy pokazane na stronie.',
|
||||
noteProfile: 'Imię i nazwisko, zdjęcie i profile pochodzą od autora.',
|
||||
serviceType: 'Rodzaj usługi',
|
||||
serviceTypePh: 'np. warsztaty grzybowe',
|
||||
areaServed: 'Obszar działania',
|
||||
areaServedHint: 'Miasta lub regiony oddzielone przecinkami.',
|
||||
price: 'Cena',
|
||||
priceHint: 'Zostaw puste, gdy cena jest ustalana indywidualnie.',
|
||||
currency: 'Waluta',
|
||||
currencyHint: 'Kod ISO, np. PLN.',
|
||||
priceInvalid: 'Wpisz cenę, np. 150 lub 149,99.',
|
||||
currencyInvalid: 'Użyj trzyliterowego kodu waluty, np. PLN.',
|
||||
custom: 'Własny JSON-LD (zaawansowane)',
|
||||
customSet: 'Ustawiony',
|
||||
customHint: 'Obiekt lub lista obiektów. Węzeł z @id wygenerowanego węzła zostaje z nim scalony, a Twoje wartości mają pierwszeństwo; pozostałe węzły są dodawane do grafu. Wklej sam JSON, bez znacznika script.',
|
||||
jsonValid: { one: 'Poprawny JSON: :count węzeł', few: 'Poprawny JSON: :count węzły', many: 'Poprawny JSON: :count węzłów', other: 'Poprawny JSON: :count węzła' },
|
||||
jsonError: 'Wiersz :line, kolumna :column: :message',
|
||||
jsonNotObject: 'Użyj obiektu lub listy obiektów.',
|
||||
jsonTooLarge: 'Ponad 16 KB: skróć własny JSON-LD.',
|
||||
forLocale: 'JSON-LD dla :locale — :url',
|
||||
graphChecking: 'Budowanie grafu…',
|
||||
allGood: 'Brak problemów: każdy węzeł ma wymagane i zalecane właściwości.',
|
||||
required: 'Brak wymaganej właściwości „:property” (:type)',
|
||||
recommended: 'Brak zalecanej właściwości „:property” (:type)',
|
||||
noRules: ':type nie ma reguł wyników z elementami rozszerzonymi w Google; sprawdzana jest tylko poprawność schema.org.',
|
||||
showJson: 'Pokaż JSON-LD',
|
||||
hideJson: 'Ukryj JSON-LD',
|
||||
richResults: 'Sprawdź w Teście wyników z elementami rozszerzonymi Google',
|
||||
graphNoUrl: 'Link do testu pojawi się po pierwszym zapisie, gdy strona będzie miała adres.',
|
||||
graphUnsaved: 'Pokazuje zapisaną stronę z niezapisanymi zmianami danych strukturalnych.',
|
||||
layerNodes: 'Witryna może dodać własne węzły, np. FAQPage. Audyt witryny sprawdza wynik na stronie.',
|
||||
graphAction: 'Sprawdź dane strukturalne',
|
||||
graphFailed: 'Nie udało się zbudować danych strukturalnych. Spróbuj ponownie.',
|
||||
primaryDefault: 'Domyślna (pierwsza kategoria)',
|
||||
}
|
||||
|
||||
const dictionaries: Record<string, Labels> = { en, pl }
|
||||
@@ -441,6 +591,7 @@ const ICONS: Record<string, string[]> = {
|
||||
'circle-check': ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'm9 12 2 2 4-4'],
|
||||
'circle-alert': ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z', 'M12 8v4', 'M12 16h.01'],
|
||||
'chevron-down': ['m6 9 6 6 6-6'],
|
||||
'external-link': ['M15 3h6v6', 'M10 14 21 3', 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'],
|
||||
}
|
||||
|
||||
/** An aria-hidden inline SVG icon of size px. */
|
||||
|
||||
97
assets/src/elements/parent.ts
Normal file
97
assets/src/elements/parent.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { el, emit, labelsFor, uid, uiLocaleOf } from './labels'
|
||||
|
||||
export const PARENT_TAG = 'golem15-seo-parent'
|
||||
|
||||
/** One parent the record can take as its primary category. */
|
||||
export interface ParentOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** The loaded value: the stored choice and the record's parents. */
|
||||
export interface ParentValue {
|
||||
selected: string
|
||||
options: ParentOption[]
|
||||
}
|
||||
|
||||
/** Reads the loaded value (an object) or a plain stored choice. */
|
||||
export function parseParent(v: unknown): ParentValue {
|
||||
if (typeof v === 'string') {
|
||||
return { selected: v, options: [] }
|
||||
}
|
||||
const out: ParentValue = { selected: '', options: [] }
|
||||
if (v && typeof v === 'object') {
|
||||
const raw = v as { selected?: unknown; options?: unknown }
|
||||
out.selected = typeof raw.selected === 'string' ? raw.selected : ''
|
||||
if (Array.isArray(raw.options)) {
|
||||
for (const o of raw.options) {
|
||||
if (o && typeof o === 'object' && typeof (o as ParentOption).value === 'string') {
|
||||
const opt = o as ParentOption
|
||||
out.options.push({ value: opt.value, label: typeof opt.label === 'string' && opt.label ? opt.label : opt.value })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* golem15-seo-parent (P33-23, D-86): the primary category of a record, a
|
||||
* 44px select over the record's parents with the "first category" default.
|
||||
* The value is the chosen <type>:<id>, "" for the default. A stored choice
|
||||
* that is no longer one of the parents falls back to the default (P1 says
|
||||
* so).
|
||||
*/
|
||||
export class SeoParentElement extends HTMLElement {
|
||||
static observedAttributes = ['disabled', 'invalid', 'ui-locale']
|
||||
|
||||
private current: ParentValue = { selected: '', options: [] }
|
||||
|
||||
get value(): string {
|
||||
return this.current.options.some((o) => o.value === this.current.selected) ? this.current.selected : ''
|
||||
}
|
||||
|
||||
set value(v: unknown) {
|
||||
const next = parseParent(v)
|
||||
if (typeof v === 'string' && this.current.options.length > 0) {
|
||||
next.options = this.current.options
|
||||
}
|
||||
this.current = next
|
||||
this.render()
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.render()
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
if (this.isConnected) {
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (!this.isConnected) {
|
||||
return
|
||||
}
|
||||
const labels = labelsFor(uiLocaleOf(this))
|
||||
const select = el('select', 'seo-parent__select')
|
||||
select.id = uid('seo-parent')
|
||||
select.disabled = this.hasAttribute('disabled')
|
||||
const def = el('option', '', labels.primaryDefault)
|
||||
def.value = ''
|
||||
select.append(def)
|
||||
for (const o of this.current.options) {
|
||||
const opt = el('option', '', o.label)
|
||||
opt.value = o.value
|
||||
select.append(opt)
|
||||
}
|
||||
select.value = this.value
|
||||
select.addEventListener('change', () => {
|
||||
this.current.selected = select.value
|
||||
emit(this, 'summer-change', { value: select.value })
|
||||
})
|
||||
this.classList.toggle('seo-parent--invalid', this.hasAttribute('invalid'))
|
||||
this.replaceChildren(select)
|
||||
}
|
||||
}
|
||||
384
assets/src/elements/schema.ts
Normal file
384
assets/src/elements/schema.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import { el, emit, fmt, icon, jsonAttr, labelsFor, plural, uid, uiLocaleOf, type Labels } from './labels'
|
||||
|
||||
export const SCHEMA_TAG = 'golem15-seo-schema'
|
||||
|
||||
/** The C17 cap of custom JSON-LD, in bytes. */
|
||||
export const MAX_CUSTOM_BYTES = 16 * 1024
|
||||
|
||||
/** The editor's Service fields. */
|
||||
export interface ServiceFields {
|
||||
serviceType?: string
|
||||
areaServed?: string
|
||||
price?: string
|
||||
currency?: string
|
||||
}
|
||||
|
||||
/** The seoschema value, stored as a JSON string. */
|
||||
export interface SchemaValue {
|
||||
type: string
|
||||
fields: ServiceFields
|
||||
custom: string
|
||||
}
|
||||
|
||||
/** The types an editor may choose per kind (UI-SPEC C17), the default first. */
|
||||
export function typesFor(kind: string): string[] {
|
||||
switch (kind) {
|
||||
case 'article':
|
||||
return ['BlogPosting', 'Article', 'NewsArticle']
|
||||
case 'collection':
|
||||
return ['CollectionPage']
|
||||
case 'profile':
|
||||
return ['ProfilePage']
|
||||
}
|
||||
return ['WebPage', 'AboutPage', 'ContactPage', 'Service']
|
||||
}
|
||||
|
||||
/** Reads a stored value; anything else is the empty value. */
|
||||
export function parseValue(raw: unknown): SchemaValue {
|
||||
const out: SchemaValue = { type: '', fields: {}, custom: '' }
|
||||
if (typeof raw !== 'string' || raw.trim() === '') {
|
||||
return out
|
||||
}
|
||||
try {
|
||||
const v = JSON.parse(raw) as Partial<SchemaValue>
|
||||
out.type = typeof v.type === 'string' ? v.type : ''
|
||||
out.fields = v.fields && typeof v.fields === 'object' ? { ...v.fields } : {}
|
||||
out.custom = typeof v.custom === 'string' ? v.custom : ''
|
||||
} catch {
|
||||
// An unreadable value starts empty.
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Writes a value as its JSON string; the empty value is "". */
|
||||
export function serializeValue(v: SchemaValue): string {
|
||||
const fields: ServiceFields = {}
|
||||
for (const key of ['serviceType', 'areaServed', 'price', 'currency'] as const) {
|
||||
const text = (v.fields[key] ?? '').trim()
|
||||
if (text !== '') {
|
||||
fields[key] = text
|
||||
}
|
||||
}
|
||||
const custom = v.custom.trim()
|
||||
if (v.type === '' && custom === '' && Object.keys(fields).length === 0) {
|
||||
return ''
|
||||
}
|
||||
return JSON.stringify({ type: v.type, fields, custom })
|
||||
}
|
||||
|
||||
/** A price such as 150, 149.99 or 149,99. */
|
||||
export function validPrice(s: string): boolean {
|
||||
return s.trim() === '' || /^\d+([.,]\d{1,2})?$/.test(s.trim())
|
||||
}
|
||||
|
||||
/** A three-letter currency code. */
|
||||
export function validCurrency(s: string): boolean {
|
||||
return s.trim() === '' || /^[A-Za-z]{3}$/.test(s.trim())
|
||||
}
|
||||
|
||||
/** The status of custom JSON-LD text. */
|
||||
export type JsonStatus =
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'valid'; nodes: number }
|
||||
| { kind: 'error'; line: number; column: number; message: string }
|
||||
| { kind: 'notObject' }
|
||||
| { kind: 'tooLarge' }
|
||||
|
||||
/** Line and column of a character offset. */
|
||||
function position(text: string, offset: number): { line: number; column: number } {
|
||||
const before = text.slice(0, Math.max(0, Math.min(offset, text.length)))
|
||||
const lines = before.split('\n')
|
||||
return { line: lines.length, column: lines[lines.length - 1].length + 1 }
|
||||
}
|
||||
|
||||
/** Checks custom JSON-LD the way the server will (UI-SPEC C17). */
|
||||
export function checkCustom(text: string): JsonStatus {
|
||||
if (text.trim() === '') {
|
||||
return { kind: 'empty' }
|
||||
}
|
||||
if (new TextEncoder().encode(text.trim()).length > MAX_CUSTOM_BYTES) {
|
||||
return { kind: 'tooLarge' }
|
||||
}
|
||||
let v: unknown
|
||||
try {
|
||||
v = JSON.parse(text)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const lc = /line (\d+) column (\d+)/i.exec(message)
|
||||
if (lc) {
|
||||
return { kind: 'error', line: Number(lc[1]), column: Number(lc[2]), message }
|
||||
}
|
||||
const pos = /position (\d+)/i.exec(message)
|
||||
const at = pos ? position(text, Number(pos[1])) : position(text, text.length)
|
||||
return { kind: 'error', line: at.line, column: at.column, message }
|
||||
}
|
||||
const isObject = (x: unknown): boolean => x !== null && typeof x === 'object' && !Array.isArray(x)
|
||||
if (isObject(v)) {
|
||||
return { kind: 'valid', nodes: 1 }
|
||||
}
|
||||
if (Array.isArray(v) && v.length > 0 && v.every(isObject)) {
|
||||
return { kind: 'valid', nodes: v.length }
|
||||
}
|
||||
return { kind: 'notObject' }
|
||||
}
|
||||
|
||||
const typeLabel = (labels: Labels, type: string): string => (labels as unknown as Record<string, string>)['t' + type] ?? type
|
||||
|
||||
const noteFor = (labels: Labels, type: string): string => {
|
||||
switch (type) {
|
||||
case 'BlogPosting':
|
||||
case 'Article':
|
||||
case 'NewsArticle':
|
||||
return labels.noteArticle
|
||||
case 'ContactPage':
|
||||
return labels.noteContact
|
||||
case 'Service':
|
||||
return labels.noteService
|
||||
case 'CollectionPage':
|
||||
return labels.noteCollection
|
||||
case 'ProfilePage':
|
||||
return labels.noteProfile
|
||||
}
|
||||
return labels.notePage
|
||||
}
|
||||
|
||||
/**
|
||||
* E8 golem15-seo-schema (UI-SPEC C17, D-44): the page's schema type per
|
||||
* content kind (option kind), the Service fields and custom JSON-LD with a
|
||||
* live parse status. Its value is a JSON string, so E9 reads it through
|
||||
* fill; the server checks it again on save.
|
||||
*/
|
||||
export class SeoSchemaElement extends HTMLElement {
|
||||
static observedAttributes = ['options', 'disabled', 'invalid', 'ui-locale']
|
||||
|
||||
private current: SchemaValue = { type: '', fields: {}, custom: '' }
|
||||
private open = false
|
||||
private status: JsonStatus = { kind: 'empty' }
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
private fieldErrors: Record<string, string> = {}
|
||||
private connected = false
|
||||
|
||||
get value(): string {
|
||||
return serializeValue(this.current)
|
||||
}
|
||||
|
||||
set value(v: unknown) {
|
||||
this.current = parseValue(v)
|
||||
this.status = checkCustom(this.current.custom)
|
||||
this.open = this.current.custom.trim() !== ''
|
||||
this.render()
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.connected = true
|
||||
this.render()
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.connected = false
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
|
||||
attributeChangedCallback(): void {
|
||||
if (this.connected) {
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
private kind(): string {
|
||||
const options = jsonAttr<Record<string, unknown>>(this, 'options', {})
|
||||
return typeof options.kind === 'string' ? options.kind : ''
|
||||
}
|
||||
|
||||
private labels(): Labels {
|
||||
return labelsFor(uiLocaleOf(this))
|
||||
}
|
||||
|
||||
private changed(): void {
|
||||
emit(this, 'summer-change', { value: this.value })
|
||||
}
|
||||
|
||||
private effectiveType(): string {
|
||||
const types = typesFor(this.kind())
|
||||
return types.includes(this.current.type) ? this.current.type : types[0]
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (!this.connected) {
|
||||
return
|
||||
}
|
||||
const labels = this.labels()
|
||||
const disabled = this.hasAttribute('disabled')
|
||||
const types = typesFor(this.kind())
|
||||
const sections: HTMLElement[] = []
|
||||
|
||||
// 1. The type row.
|
||||
const typeRow = el('div', 'seo-schema__section')
|
||||
const selectId = uid('seo-schema-type')
|
||||
const label = el('label', 'seo-schema__label', labels.schemaType)
|
||||
label.htmlFor = selectId
|
||||
if (types.length > 1) {
|
||||
const select = el('select', 'seo-schema__select')
|
||||
select.id = selectId
|
||||
select.disabled = disabled
|
||||
const def = el('option', '', fmt(labels.typeDefault, { type: types[0] }))
|
||||
def.value = ''
|
||||
select.append(def)
|
||||
for (const t of types) {
|
||||
const opt = el('option', '', typeLabel(labels, t))
|
||||
opt.value = t
|
||||
select.append(opt)
|
||||
}
|
||||
select.value = types.includes(this.current.type) ? this.current.type : ''
|
||||
select.addEventListener('change', () => {
|
||||
this.current.type = select.value
|
||||
this.changed()
|
||||
this.render()
|
||||
})
|
||||
typeRow.append(label, select)
|
||||
} else {
|
||||
const fixed = el('div', 'seo-schema__fixed', typeLabel(labels, types[0]))
|
||||
fixed.id = selectId
|
||||
typeRow.append(label, fixed)
|
||||
}
|
||||
typeRow.append(el('p', 'seo-schema__note', noteFor(labels, this.effectiveType())))
|
||||
sections.push(typeRow)
|
||||
|
||||
// 2. The Service fields.
|
||||
if (this.effectiveType() === 'Service') {
|
||||
const grid = el('div', 'seo-schema__grid')
|
||||
const field = (key: keyof ServiceFields, text: string, hint: string, placeholder = '', mode = ''): void => {
|
||||
const wrap = el('div', 'seo-schema__cell')
|
||||
const id = uid('seo-schema-' + key)
|
||||
const l = el('label', 'seo-schema__label', text)
|
||||
l.htmlFor = id
|
||||
const input = el('input', 'seo-schema__input')
|
||||
input.id = id
|
||||
input.type = 'text'
|
||||
input.disabled = disabled
|
||||
input.value = this.current.fields[key] ?? ''
|
||||
if (placeholder) {
|
||||
input.placeholder = placeholder
|
||||
}
|
||||
if (mode) {
|
||||
input.inputMode = mode
|
||||
}
|
||||
if (key === 'currency') {
|
||||
input.maxLength = 3
|
||||
}
|
||||
const error = el('p', 'seo-schema__error', this.fieldErrors[key] ?? '')
|
||||
error.setAttribute('role', 'status')
|
||||
input.addEventListener('input', () => {
|
||||
const v = input.value
|
||||
const ok = key === 'price' ? validPrice(v) : key === 'currency' ? validCurrency(v) : true
|
||||
if (ok) {
|
||||
this.current.fields[key] = v
|
||||
delete this.fieldErrors[key]
|
||||
error.textContent = ''
|
||||
this.changed()
|
||||
} else {
|
||||
this.fieldErrors[key] = key === 'price' ? labels.priceInvalid : labels.currencyInvalid
|
||||
error.textContent = this.fieldErrors[key]
|
||||
}
|
||||
})
|
||||
if (key === 'currency') {
|
||||
input.addEventListener('blur', () => {
|
||||
input.value = input.value.toUpperCase()
|
||||
if (validCurrency(input.value)) {
|
||||
this.current.fields.currency = input.value
|
||||
this.changed()
|
||||
}
|
||||
})
|
||||
}
|
||||
wrap.append(l, input)
|
||||
if (hint) {
|
||||
wrap.append(el('p', 'seo-schema__note', hint))
|
||||
}
|
||||
wrap.append(error)
|
||||
grid.append(wrap)
|
||||
}
|
||||
field('serviceType', labels.serviceType, '', labels.serviceTypePh)
|
||||
field('areaServed', labels.areaServed, labels.areaServedHint)
|
||||
field('price', labels.price, labels.priceHint, '', 'decimal')
|
||||
field('currency', labels.currency, labels.currencyHint)
|
||||
sections.push(grid)
|
||||
}
|
||||
|
||||
// 3. Custom JSON-LD.
|
||||
const customSection = el('div', 'seo-schema__section')
|
||||
const toggle = el('button', 'seo-schema__disclosure')
|
||||
toggle.type = 'button'
|
||||
toggle.setAttribute('aria-expanded', String(this.open))
|
||||
const panelId = uid('seo-schema-custom')
|
||||
toggle.setAttribute('aria-controls', panelId)
|
||||
toggle.append(el('span', '', labels.custom), icon('chevron-down'))
|
||||
toggle.addEventListener('click', () => {
|
||||
this.open = !this.open
|
||||
this.render()
|
||||
})
|
||||
const head = el('div', 'seo-schema__disclosure-row')
|
||||
head.append(toggle)
|
||||
if (this.current.custom.trim() !== '') {
|
||||
head.append(el('span', 'seo-schema__note', labels.customSet))
|
||||
}
|
||||
customSection.append(head)
|
||||
if (this.open) {
|
||||
const panel = el('div', 'seo-schema__panel')
|
||||
panel.id = panelId
|
||||
const areaId = uid('seo-schema-json')
|
||||
const hint = el('p', 'seo-schema__note', labels.customHint)
|
||||
hint.id = areaId + '-hint'
|
||||
const area = el('textarea', 'seo-schema__json')
|
||||
area.id = areaId
|
||||
area.spellcheck = false
|
||||
area.disabled = disabled
|
||||
area.value = this.current.custom
|
||||
area.setAttribute('aria-label', labels.custom)
|
||||
area.setAttribute('aria-describedby', hint.id)
|
||||
const line = el('p', 'seo-schema__status')
|
||||
line.setAttribute('role', 'status')
|
||||
line.setAttribute('aria-live', 'polite')
|
||||
this.statusLine(line, labels)
|
||||
area.addEventListener('input', () => {
|
||||
this.current.custom = area.value
|
||||
this.changed()
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
this.status = checkCustom(area.value)
|
||||
this.statusLine(line, labels)
|
||||
}, 300)
|
||||
})
|
||||
panel.append(hint, area, line)
|
||||
customSection.append(panel)
|
||||
}
|
||||
sections.push(customSection)
|
||||
|
||||
this.classList.toggle('seo-schema--invalid', this.hasAttribute('invalid'))
|
||||
this.replaceChildren(...sections)
|
||||
}
|
||||
|
||||
private statusLine(line: HTMLElement, labels: Labels): void {
|
||||
const s = this.status
|
||||
line.className = 'seo-schema__status'
|
||||
switch (s.kind) {
|
||||
case 'empty':
|
||||
line.replaceChildren()
|
||||
return
|
||||
case 'valid':
|
||||
line.classList.add('seo-schema__status--ok')
|
||||
line.replaceChildren(icon('circle-check'), el('span', '', plural(labels.jsonValid, s.nodes, uiLocaleOf(this))))
|
||||
return
|
||||
case 'error':
|
||||
line.classList.add('seo-schema__status--error')
|
||||
line.replaceChildren(icon('circle-alert'), el('span', '', fmt(labels.jsonError, { line: s.line, column: s.column, message: s.message })))
|
||||
return
|
||||
case 'notObject':
|
||||
line.classList.add('seo-schema__status--error')
|
||||
line.replaceChildren(icon('circle-alert'), el('span', '', labels.jsonNotObject))
|
||||
return
|
||||
case 'tooLarge':
|
||||
line.classList.add('seo-schema__status--error')
|
||||
line.replaceChildren(icon('circle-alert'), el('span', '', labels.jsonTooLarge))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,3 +850,279 @@ golem15-seo-transfer .seo-transfer__actions {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* E8 golem15-seo-schema (UI-SPEC C17) */
|
||||
golem15-seo-schema {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__select,
|
||||
golem15-seo-schema .seo-schema__input,
|
||||
golem15-seo-parent .seo-parent__select {
|
||||
box-sizing: border-box;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__select,
|
||||
golem15-seo-parent .seo-parent__select {
|
||||
min-width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__fixed {
|
||||
font-size: 14px;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__note,
|
||||
golem15-seo-graph .seo-graph__meta {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__section > .seo-schema__note {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
golem15-seo-schema .seo-schema__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__error {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__error:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__disclosure-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__disclosure,
|
||||
golem15-seo-graph .seo-graph__disclosure {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__disclosure[aria-expanded='true'] .seo-icon,
|
||||
golem15-seo-graph .seo-graph__disclosure[aria-expanded='true'] .seo-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__json {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
resize: vertical;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__status,
|
||||
golem15-seo-graph .seo-graph__finding,
|
||||
golem15-seo-graph .seo-graph__ok,
|
||||
golem15-seo-graph .seo-graph__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__status--ok,
|
||||
golem15-seo-graph .seo-graph__ok {
|
||||
color: var(--c-ok-text);
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__status--error,
|
||||
golem15-seo-graph .seo-graph__error,
|
||||
golem15-seo-graph .seo-graph__finding--required .seo-icon {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
golem15-seo-graph .seo-graph__finding {
|
||||
font-size: 14px;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
golem15-seo-schema .seo-schema__select:focus-visible,
|
||||
golem15-seo-schema .seo-schema__input:focus-visible,
|
||||
golem15-seo-schema .seo-schema__json:focus-visible,
|
||||
golem15-seo-schema .seo-schema__disclosure:focus-visible,
|
||||
golem15-seo-graph .seo-graph__disclosure:focus-visible,
|
||||
golem15-seo-graph .seo-graph__button:focus-visible,
|
||||
golem15-seo-graph .seo-graph__link:focus-visible,
|
||||
golem15-seo-parent .seo-parent__select:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--c-ring);
|
||||
}
|
||||
|
||||
golem15-seo-schema.seo-schema--invalid .seo-schema__json,
|
||||
golem15-seo-parent.seo-parent--invalid .seo-parent__select {
|
||||
border-color: var(--c-danger);
|
||||
}
|
||||
|
||||
/* E9 golem15-seo-graph (UI-SPEC C18) */
|
||||
golem15-seo-graph {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
golem15-seo-graph .seo-graph__nodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
golem15-seo-graph .seo-graph__node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-graph .seo-graph__node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-graph .seo-graph__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--c-subtle);
|
||||
color: var(--c-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
golem15-seo-graph .seo-graph__id {
|
||||
font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
golem15-seo-graph .seo-graph__json {
|
||||
box-sizing: border-box;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 10px;
|
||||
background: var(--c-subtle);
|
||||
color: var(--c-text);
|
||||
font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
golem15-seo-graph .seo-graph__links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-graph .seo-graph__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--c-primary);
|
||||
font-size: 14px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
golem15-seo-graph .seo-graph__button {
|
||||
align-self: flex-start;
|
||||
height: 42px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms;
|
||||
}
|
||||
|
||||
golem15-seo-graph .seo-graph__button:hover {
|
||||
background: var(--c-subtle);
|
||||
}
|
||||
|
||||
@@ -177,7 +177,12 @@ func decodeSchema(app func() *backpack.App) func(context.Context, pact.FormField
|
||||
// parents as <type>:<id>.
|
||||
func decodeParent(app func() *backpack.App) func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
return func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
raw, ok := stringValue(in.Value)
|
||||
value := in.Value
|
||||
if m, isMap := value.(map[string]any); isMap {
|
||||
// The loaded {selected, options} sent back unchanged.
|
||||
value = m["selected"]
|
||||
}
|
||||
raw, ok := stringValue(value)
|
||||
raw = strings.TrimSpace(raw)
|
||||
if !ok {
|
||||
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"golem15.seo::lang.api.value_invalid"}}
|
||||
|
||||
180
tests/elements/schema.test.ts
Normal file
180
tests/elements/schema.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { checkCustom, parseValue, serializeValue, validCurrency, validPrice } from '../../assets/src/elements/schema'
|
||||
import { labelsFor } from '../../assets/src/elements/labels'
|
||||
import { HostFailure, mountWidget, settle } from './host'
|
||||
|
||||
// E8 golem15-seo-schema, E9 golem15-seo-graph and golem15-seo-parent
|
||||
// (UI-SPEC C17, C18, P33-23).
|
||||
|
||||
beforeAll(async () => {
|
||||
await import('../../assets/src/elements/index')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
function mountSchema(kind: string, value: string): HTMLElement & { value: string } {
|
||||
const node = document.createElement('golem15-seo-schema') as HTMLElement & { value: string }
|
||||
node.setAttribute('ui-locale', 'en')
|
||||
node.setAttribute('options', JSON.stringify({ kind }))
|
||||
node.value = value
|
||||
document.body.append(node)
|
||||
return node
|
||||
}
|
||||
|
||||
describe('E8 value helpers', () => {
|
||||
it('round-trips the Service fields as the same JSON string', () => {
|
||||
const raw = JSON.stringify({ type: 'Service', fields: { serviceType: 'Workshops', areaServed: 'Warszawa', price: '150', currency: 'PLN' }, custom: '' })
|
||||
expect(serializeValue(parseValue(raw))).toBe(raw)
|
||||
expect(serializeValue(parseValue(''))).toBe('')
|
||||
})
|
||||
|
||||
it('checks prices, currencies and custom JSON-LD', () => {
|
||||
expect(validPrice('149,99')).toBe(true)
|
||||
expect(validPrice('abc')).toBe(false)
|
||||
expect(validCurrency('PLN')).toBe(true)
|
||||
expect(validCurrency('ZLOTY')).toBe(false)
|
||||
expect(checkCustom('{"@type": "Thing"}')).toEqual({ kind: 'valid', nodes: 1 })
|
||||
expect(checkCustom('[{"a": 1}, {"b": 2}]')).toEqual({ kind: 'valid', nodes: 2 })
|
||||
expect(checkCustom('42').kind).toBe('notObject')
|
||||
expect(checkCustom('{"a": "' + 'x'.repeat(17000) + '"}').kind).toBe('tooLarge')
|
||||
const bad = checkCustom('{\n "a": 1,\n oops\n}')
|
||||
expect(bad.kind).toBe('error')
|
||||
if (bad.kind === 'error') {
|
||||
expect(bad.line).toBe(3)
|
||||
expect(bad.column).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('E8 golem15-seo-schema', () => {
|
||||
it('renders the article type options with the default and its note, custom collapsed', () => {
|
||||
const node = mountSchema('article', '')
|
||||
const options = [...node.querySelectorAll('option')].map((o) => o.textContent)
|
||||
expect(options).toEqual(['Default (BlogPosting)', 'BlogPosting (blog post)', 'Article (article)', 'NewsArticle (news)'])
|
||||
expect(node.textContent).toContain(labelsFor('en').noteArticle)
|
||||
expect(node.querySelector('textarea')).toBeNull()
|
||||
expect(node.querySelector('.seo-schema__disclosure')!.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('shows the Service grid and keeps the last valid price', () => {
|
||||
const node = mountSchema('page', JSON.stringify({ type: 'Service', fields: { price: '100' }, custom: '' }))
|
||||
expect(node.querySelectorAll('.seo-schema__grid input')).toHaveLength(4)
|
||||
const changes: string[] = []
|
||||
node.addEventListener('summer-change', (e) => changes.push((e as CustomEvent).detail.value))
|
||||
const price = node.querySelectorAll<HTMLInputElement>('.seo-schema__grid input')[2]
|
||||
price.value = 'abc'
|
||||
price.dispatchEvent(new Event('input'))
|
||||
expect(node.textContent).toContain(labelsFor('en').priceInvalid)
|
||||
expect(JSON.parse(node.value).fields.price).toBe('100')
|
||||
price.value = '149,99'
|
||||
price.dispatchEvent(new Event('input'))
|
||||
expect(JSON.parse(changes[changes.length - 1]).fields.price).toBe('149,99')
|
||||
})
|
||||
|
||||
it('opens set custom JSON-LD and reports parse errors with line and column', () => {
|
||||
vi.useFakeTimers()
|
||||
const node = mountSchema('page', JSON.stringify({ type: '', fields: {}, custom: '{"a": 1}' }))
|
||||
const area = node.querySelector('textarea')!
|
||||
expect(area).not.toBeNull()
|
||||
area.value = '{"a": '
|
||||
area.dispatchEvent(new Event('input'))
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(node.querySelector('.seo-schema__status')!.textContent).toMatch(/^Line 1, column \d+:/)
|
||||
area.value = '{"x": "' + 'y'.repeat(17000) + '"}'
|
||||
area.dispatchEvent(new Event('input'))
|
||||
vi.advanceTimersByTime(300)
|
||||
expect(node.querySelector('.seo-schema__status')!.textContent).toBe(labelsFor('en').jsonTooLarge)
|
||||
})
|
||||
|
||||
it('shows a single fixed type for collections', () => {
|
||||
const node = mountSchema('collection', '')
|
||||
expect(node.querySelector('select')).toBeNull()
|
||||
expect(node.querySelector('.seo-schema__fixed')!.textContent).toBe('CollectionPage (listing)')
|
||||
})
|
||||
})
|
||||
|
||||
const answer = {
|
||||
locale: 'en',
|
||||
url: 'https://example.test/blog/post',
|
||||
graph: { '@context': 'https://schema.org', '@graph': [{ '@type': 'BlogPosting' }] },
|
||||
nodes: [
|
||||
{ id: 'https://example.test/blog/post#article', type: 'BlogPosting', rules: true, findings: [{ level: 'recommended', property: 'image' }] },
|
||||
{ id: 'https://example.test/blog/post#video-1', type: 'VideoObject', rules: true, findings: [{ level: 'required', property: 'uploadDate' }] },
|
||||
{ id: 'https://example.test/blog/post#webpage', type: 'WebPage', rules: false, findings: [] },
|
||||
],
|
||||
}
|
||||
|
||||
describe('E9 golem15-seo-graph', () => {
|
||||
it('asks on connect and renders the nodes, findings, JSON and the Rich Results link', async () => {
|
||||
const host = mountWidget('golem15-seo-graph', { attrs: { 'ui-locale': 'en' }, answer: () => answer })
|
||||
await settle()
|
||||
expect(host.posted).toEqual([{ op: 'graph', locale: '' }])
|
||||
const text = host.node.textContent ?? ''
|
||||
expect(text).toContain('JSON-LD for EN — https://example.test/blog/post')
|
||||
expect(text).toContain('Missing recommended property “image” (BlogPosting)')
|
||||
expect(text).toContain('Missing required property “uploadDate” (VideoObject)')
|
||||
expect(text).toContain('WebPage has no rich-result rules at Google')
|
||||
const link = host.node.querySelector<HTMLAnchorElement>('.seo-graph__link')!
|
||||
expect(link.href).toBe('https://search.google.com/test/rich-results?url=https%3A%2F%2Fexample.test%2Fblog%2Fpost')
|
||||
expect(link.rel).toBe('noopener')
|
||||
host.node.querySelector<HTMLButtonElement>('.seo-graph__disclosure')!.click()
|
||||
expect(host.node.querySelector('pre')!.textContent).toContain('"@context": "https://schema.org"')
|
||||
expect(host.node.getAttribute('aria-busy')).toBe('false')
|
||||
})
|
||||
|
||||
it('shows allGood without findings, re-checks 800 ms after a fill change and follows the locale', async () => {
|
||||
const clean = { ...answer, nodes: [{ id: '#webpage', type: 'WebPage', rules: false, findings: [] }] }
|
||||
const host = mountWidget('golem15-seo-graph', { attrs: { 'ui-locale': 'en' }, answer: () => clean })
|
||||
await settle()
|
||||
expect(host.node.textContent).toContain(labelsFor('en').allGood)
|
||||
host.node.setAttribute('fill-values', JSON.stringify({ seo_schema: '{"type":"Article"}' }))
|
||||
await new Promise((r) => setTimeout(r, 850))
|
||||
await settle()
|
||||
expect(host.posted).toHaveLength(2)
|
||||
window.dispatchEvent(new CustomEvent('summer:ml-locale', { detail: 'pl' }))
|
||||
await settle()
|
||||
expect(host.posted[2]).toEqual({ op: 'graph', locale: 'pl' })
|
||||
})
|
||||
|
||||
it('keeps the last graph and shows the danger line when the action fails', async () => {
|
||||
let fail = false
|
||||
const host = mountWidget('golem15-seo-graph', {
|
||||
attrs: { 'ui-locale': 'en' },
|
||||
answer: () => {
|
||||
if (fail) {
|
||||
throw new HostFailure('nope')
|
||||
}
|
||||
return answer
|
||||
},
|
||||
})
|
||||
await settle()
|
||||
fail = true
|
||||
host.node.querySelector<HTMLButtonElement>('.seo-graph__button')!.click()
|
||||
await settle()
|
||||
expect(host.node.querySelector('.seo-graph__error')!.textContent).toBe(labelsFor('en').graphFailed)
|
||||
expect(host.node.textContent).toContain('Missing recommended property “image”')
|
||||
})
|
||||
})
|
||||
|
||||
describe('golem15-seo-parent', () => {
|
||||
it('lists the options of its value with the default and emits the choice', () => {
|
||||
const node = document.createElement('golem15-seo-parent') as HTMLElement & { value: unknown }
|
||||
node.setAttribute('ui-locale', 'en')
|
||||
node.value = { selected: 'acme-section:2', options: [{ value: 'acme-section:1', label: 'Recipes' }, { value: 'acme-section:2', label: 'Mushrooms' }] }
|
||||
document.body.append(node)
|
||||
const select = node.querySelector('select')!
|
||||
expect([...select.options].map((o) => o.textContent)).toEqual(['Default (first category)', 'Recipes', 'Mushrooms'])
|
||||
expect(select.value).toBe('acme-section:2')
|
||||
const changes: string[] = []
|
||||
node.addEventListener('summer-change', (e) => changes.push((e as CustomEvent).detail.value))
|
||||
select.value = ''
|
||||
select.dispatchEvent(new Event('change'))
|
||||
expect(changes).toEqual([''])
|
||||
// A stored choice that is no longer a parent falls back to the default.
|
||||
node.value = { selected: 'acme-section:9', options: [{ value: 'acme-section:1', label: 'Recipes' }] }
|
||||
expect(node.value).toBe('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user