{ "version": "https://jsonfeed.org/version/1.1", "title": "fboës - Der Blog | Artikel mit dem Tag \"Programmierung\"", "home_page_url": "https://journal.3960.org/", "feed_url": "https://journal.3960.org/tagged/programmierung/feed.json", "description": "Programmierung, Luft- & Raumfahrt, Kurioses: Der Blog von und mit Frank Boës.", "icon": "https://cdn.3960.org/favicon-192x192.png", "favicon": "https://cdn.3960.org/images/tile-128x128.png", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org" } ], "language": "de-DE", "_rss": { "about": "http://cyber.harvard.edu/rss/rss.html", "copyright": "© 2008-2023 Creative Commons BY" }, "items": [ { "id": "user/posts/2024-02-03-kleinste-javascript-unit-tester-welt/index.md", "url": "https://journal.3960.org/posts/2024-02-03-kleinste-javascript-unit-tester-welt/", "title": "Der kleinste JavaScript-Unit-Tester der Welt", "content_html": "
Schon das kleinste JavaScript-Projekt kann von Unit Testing profitieren. Aber gerade in kleinen Projekten können Lösungen wie Mocha oder Jest sich als viel zu schwergewichtig anfühlen.
\nAber warum eigentlich ein Framework verwenden? Wozu das Projekt mit Dependencies vollstopfen, wenn ein paar elegante Zeilen Code für unsere Zwecke reichen?
\n\nIn seiner einfachsten Form ist Unit Testing nichts anderes als das Vergleichen von Annahmen mit der Ausgabe von tatsächlichen Code. In JavaScript gibt es dafür eine eingebaute Lösung: console.assert()
.
const starDestroyer = new StarDestroyer();\nconsole.assert(starDestroyer !== null, 'Star destroyer exists');\nconsole.assert(starDestroyer.speed >= 0, 'Star destroyer has speed property with positive Number');\nconsole.assert(starDestroyer.faction === 'imperial', 'Star destroyer is always imperial');\n
\nIm Browser wie auch in Node.js erzeugt diese Methode eine Konsolen-Ausgabe, wenn die Annahme nicht zutrifft.
\nWenn in ein und derselben Datei mehrere Testfälle geprüft werden sollen, ist eine klares Scoping von Variablen notwendig. So wird sichergestellt, das Ergebnisse aus einem Test nicht irrtümlicherweise im nächsten Test verwendet werden. In JavaScript ist die Erzeugung eines neuen Scopes jederzeit mittels {…}
möglich:
{\n const starDestroyer = new StarDestroyer();\n console.assert(starDestroyer !== null, 'Star destroyer exists');\n console.assert(starDestroyer.speed >= 0, 'Star destroyer has speed property with positive Number');\n console.assert(starDestroyer.faction === 'imperial', 'Star destroyer is always imperial');\n}\n\n// No starDestroyer here\n
\nMehrere Tests können so in einer Datei abgehandelt werden.
\nFür automatisiertes Testing ist die Verwendung von console.assert
natürlich zu kurz gesprungen, denn hier kann nur durch Sichtkontrolle überprüft werden, ob alle Tests sauber durchgelaufen sind. Wichtig wäre es also, auf der Kommandozeile einen Exit-Code zu bekommen, der auf einen Fehler hinweist.
Assert
in Node.jsIn Node.js existiert dafür Assert
:
const assert = require('node:assert');\n\n{\n const starDestroyer = new StarDestroyer();\n assert.ok(starDestroyer !== null, 'Star destroyer exists');\n assert.ok(starDestroyer.speed >= 0, 'Star destroyer has speed property with positive Number');\n assert.strictEqual(starDestroyer.faction, 'imperial', 'Star destroyer is always imperial');\n}\n
\nIm Fehlerfall wird assert.AssertionError
geworfen, der auf der CLI einen auch für Automatisierungen wahrnehmbaren Exit-Code erzeugt.
Der Nachteil dieser Lösung ist gut erkennbar: Diese Art von Testing funktioniert nur in Node.js, aber nicht im Browser.
\nGott sei Dank ist die Idee mit dem Werfen von Errors aber gar nicht so komplex. Und damit können wir uns eine kleine Funktion konstruieren, mit der wir ganz grundsätzliches Testing durchführen können:
\nconst assertOk = (assertion, message = 'Assertion') => {\n console.log((assertion ? "✅" : "💥") + " " + message);\n if (!assertion) {\n throw new Error(`"${message}" failed`);\n }\n};\n
\nDamit schlagen wir mehrere Fliegen mit einer Klappe:
\nEtwas aussagekräftiger könnte die Funktion sogar so aussehen:
\nconst assertStrictEqual = (a, b = true, message = '') => {\n message === '' || (message += " | ");\n message += message = `'${a}' equals '${b}'`;\n\n console.log((a === b ? "✅" : "💥") + " " + message);\n if (a !== b) {\n throw new Error(`"${message}" failed`);\n }\n};\n
\nDamit können nicht nur direkt Vergleiche durchgeführt werden, sondern auch gleich eine aussagekräftige Nachricht über den tatsächlichen Inhalt des Tests mit ausgegeben werden.
\nWenn ihr ein Drop-In-Replacement für assert
von Node.js haben wollt, könnt ihr die beiden Funktionen in statische Methoden verwandeln:
const assert = {\n /**\n * Check if `assertion` is `true`. Throw Error on error.\n * @param {Boolean} assertion\n * @param {String} message\n */\n ok(assertion, message = 'Assertion') {\n console.log((assertion ? "✅" : "💥") + " " + message);\n if (!assertion) {\n throw new Error(`"${message}" failed`);\n }\n },\n\n /**\n * Check if `actual` strictly equals `b`. Throw Error on error.\n * @param {any} actual\n * @param {any} expected\n * @param {String} message\n */\n strictEqual(actual, expected, message = '') {\n message === '' || (message += " | ");\n message += message = `'${actual}' equals '${expected}'`;\n\n assert.ok(actual == expected, message)\n }\n};\n
\nOh, und wenn ihr eure Tests noch gruppieren wollt:
\nconsole.group('Testing StarDestroyer');\n{\n const starDestroyer = new StarDestroyer();\n assert.ok(starDestroyer !== null, 'Star destroyer exists');\n assert.ok(starDestroyer.speed >= 0, 'Star destroyer has speed property with positive Number');\n assert.strictEqual(starDestroyer.faction, 'imperial', 'Star destroyer is always imperial');\n}\nconsole.groupEnd();\n
\n…erzeugt eine schön gruppierte Ausgabe eurer Tests.
\nVoilà! Eine Beispiel-Ausgabe:
\nGeoJson.Feature\n ✅ Type matches | 'Feature' equals 'Feature'\n ✅ Feature.properties.title | 'Test' equals 'Test'\n ✅ Feature.geometry.type | 'Point' equals 'Point'\n ✅ No id\nGeoJson.FeatureCollection\n ✅ 'FeatureCollection' equals 'FeatureCollection'\n ✅ Bounding Box West | '6.5664576' equals '6.5664576'\n ✅ Bounding Box South | '51.04571' equals '51.04571'\n ✅ Bounding Box East | '1.53946' equals '1.53946'\n ✅ Bounding Box North | '58.109285' equals '58.109285'\n ✅ FeatureCollection | 'FeatureCollection' equals 'FeatureCollection'\n ✅ Two Features exist | '2' equals '2'\n ✅ Feature.type | 'Feature' equals 'Feature'\n ✅ Feature.properties.title | 'Sailing boat' equals 'Sailing boat'\n
\nNatürlich ersetzt diese sehr simple Herangehensweise nicht wirklich echte Unit-Test-Frameworks. So ist zum Beispiel der Vergleich von Objekt-Strukturen nicht möglich, das Testen von Exceptions oder auch die Verwendung von Mocking und ähnlichen Features. Aber zumindest senkt es die Hürde, überhaupt mit Testing zu beginnen. 😉
", "summary": "Schon das kleinste JavaScript-Projekt kann von Unit Testing profitieren. Aber gerade in kleinen Projekten können Lösungen wie Mocha oder Jest sich als viel zu…", "date_published": "2024-02-03T18:14:32+01:00", "date_modified": "2024-02-04T05:06:25+01:00", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" } ], "banner_image": "https://cdn.3960.org/favicon-192x192.png", "language": "de-DE", "image": "https://cdn.3960.org/favicon-192x192.png", "tags": [ "Javascript", "Programmierung", "Webdevelop" ] }, { "id": "user/posts/2023-12-29-wie-funktioniert-tolino-webbrowser/index.md", "url": "https://journal.3960.org/posts/2023-12-29-wie-funktioniert-tolino-webbrowser/", "title": "Wie funktioniert der Tolino-Webbrowser?", "content_html": "In den eBook-Readern von Tolino steckt auch ein Webbrowser. Welche Fähigkeiten hat der eigentlich? Und lohnt es sich, eigens für eBook-Reader optimierte Webseiten zu bauen?
\n\nJeder Browser strahlt seine eigene Browser-Kennung ab: Den User Agent String. Er verrät nicht nur, welche Browser-Software in welcher Version im Einsatz ist, sondern auch meist das Betriebssystem.
\nFür einen Tolino Shine sieht das wie folgt aus:
\nMozilla/5.0 (Linux; Android 8.1.0; de-; tolino shine 4/16.1.0) AppleWebKit/537.36 (KHTML, like Gecko) Verions/4.0 Chrome/61.0.0.0 Mobile Mobile Safari/537.36\n
\nDamit lüften sich mehrere Geheimnisse: Nicht nur ist auf dem Tolino ein Android 8 als Betriebssystem im Einsatz, sondern der Browser ist auch ein veralteter Google Chrome 61. (Zum Zeitpunkt der Untersuchung war gerade der Google Chrome 121 aktuell.) Damit beherrscht er leider nicht alle modernen Tricks von CSS und ECMAScript, ist aber für Brot-und-Butter-Websites immer noch eine sehr gute Wahl.
\nMein Tolino Webbrowser hat eine interessante Eigenheit: Er strahlt an jeden besuchten Web-Server einen eigenen Header ab:
\nX-REQUESTED-WITH: de.telekom.epub\n
\nTatsächlich gibt es möglicherweise historische Gründe für diesen Header, uns hilft dies aber sonst nicht weiter. Viel spannender ist für uns…
\nDie physische Bildschirmgröße des Tolino Shine 4 ist 1072×1448 Pixel. Im Web wird dabei ein Device Pixel Ratio von 1.875 angenommen.
\nNun haben wir auf einem eBook-Reader zwei Herausforderungen, die bei der Gestaltung von Websites berücksichtigt werden müssen:
\nSinnvoll wäre also eine Reaktion des Webbrowsers auf folgende Media Queries:
\n\nUnd hier kommt der etwas traurige Part: In Bezug auf Media Queries gibt es leider keine Anpassungen des Google Chrome auf dem Tolino gegenüber einem normalen Google Chrome auf einem Android-Smartphone oder -Tablet:
\nMedia Query | \nValue | \n
---|---|
Media type | \nscreen | \n
Monochrome | \nfalse | \n
Color | \ntrue | \n
Width | \n572px | \n
Height | \n773px | \n
Resolution (dpi) | \n180 | \n
Colors | \n8 | \n
Orientation | \nportrait | \n
Script | \nfalse | \n
Hover | \nfalse | \n
Pointer | \ncoarse | \n
Prefers reduced motion | \nfalse | \n
Kurz gesprochen gibt es also keine Media Query, mit der das CSS einer Website für den Webbrowser des Tolino zugeschnitten werden kann.
\nMeine Lösung für dieses Dilemma ist wenig schön, aber besser als nichts:
\n(function () {\n if (window.navigator.userAgent.match(/(Tolino|Kindle)/i)) {\n document.body.classList.add("is-ebook-reader");\n }\n})();\n
\nAuf jeder neuen Seite wird mit einer kleinen Zeile JavaScript kurz überprüft, ob es im User Agent String einen Hinweis darauf gibt, dass es sich bei dem besuchenden Browser um einen eBook-Reader handelt. Wenn ja, wird der Seite eine CSS-Klasse is-ebook-reader
hinzugefügt.
Zum Spaß habe ich das hier in diesen Blog eingebaut, und einfach mittels CSS Custom Properties eine kleine Fallunterscheidung in mein CSS gebaut. Eine ganz einfache Lösung könnte aber wie folgt aussehen:
\n:root {\n --color-background: #eee;\n --color-text: #112;\n --color-link: orange;\n --text-decoration-link: none\n}\n\n.is-ebook-reader {\n --color-background: white;\n --color-text: black;\n --color-link: black;\n --text-decoration-link: underline dotted;\n}\n\na {\n color: var(--color-link);\n text-decoration: var(--text-decoration-link);\n}\n
",
"summary": "In den eBook-Readern von Tolino steckt auch ein Webbrowser. Welche Fähigkeiten hat der eigentlich? Und lohnt es sich, eigens für eBook-Reader optimierte…",
"date_published": "2023-12-29T18:14:24+01:00",
"date_modified": "2023-12-30T19:47:58+01:00",
"author": {
"name": "Frank Boës",
"url": "mailto:info@3960.org",
"avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca"
},
"authors": [
{
"name": "Frank Boës",
"url": "mailto:info@3960.org",
"avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca"
}
],
"banner_image": "https://cdn.3960.org/favicon-192x192.png",
"language": "de-DE",
"image": "https://cdn.3960.org/favicon-192x192.png",
"tags": [
"CSS",
"Programmierung",
"Webdevelop"
]
},
{
"id": "user/posts/2023-05-26-sommermodus-mit-fritz-dect-home-assistant/index.md",
"url": "https://journal.3960.org/posts/2023-05-26-sommermodus-mit-fritz-dect-home-assistant/",
"title": "Sommermodus mit FRITZ!DECT und Home Assistant",
"content_html": "Der Sommermodus für die FRITZ!DECT-Thermostate wird über die FRITZ!Box gesteuert – mit einem festen Zeitplan. Mehr Flexibilität gibt es mit dem Home Assistant, um zum Beispiel auf Temperaturdaten oder Wetterprognosen reagieren zu können.
\n\nDummerweise lässt sich der Sommermodus an den Thermostaten vom Home Assistant aus nicht einfach auslösen. Und selbst die im Home Assistant vorhandene Möglichkeit, den Thermostat einfach auszuschalten, funktioniert nicht wirklich gut – der nächste Schaltbefehl an den Thermostat hebt die Abschaltung wieder auf. Außerdem kann der Sommermodus deutlich mehr als nur die Heizung abzuschalten – unter anderem kümmert er sich um das gelegentliche Bewegen der Ventile, um sie vor dem Steckenbleiben zu bewahren.
\n…ist zweiteilig: In der FRITZ!Box legen wir mittels Vorlagen das ein- und ausschalten des Sommermodus' an – und im Home Assistant lösen wir dann die Vorlagen aus.
\n\n
Den Druck auf einen dieser Knöpfe könnt ihr im Hone Assistant abfangen und zusätzliche Aktionen auslösen.
\nDazu müsst ihr eine Automatisierung bauen, die beim Druck auf die Knöpfe neben den Vorlagen in der FRITZ!Box noch zusätzliche Aktionen auslösen:
\nalias: "Heizungen: Sommermodus an"\ndescription: ""\ntrigger:\n - platform: device\n device_id: 5f6c4925f76d4ef27407160a968bb7fd\n domain: button\n entity_id: button.heizungen_sommermodus_an\n type: pressed\ncondition: []\naction:\n - service: notify.notify\n data:\n title: "Sommermodus an"\n message: >-\n Alle Heizungen sind nun abgeschaltet, willkommen im Sommermodus.\n enabled: true\nmode: single\n
\nWir haben aber ja die ganze Arbeit nicht auf uns genommen, um im Home Assistant selber auf Knöpfe drücken zu müssen. Tatsächlich können wir Automatisierungen des Home Assistants für uns auf den Knopf drücken lassen.
\nDabei kann in jeder Automatisierung als Aktion der Druck auf die Knöpfe ausgelöst werden:
\naction:\n - service: button.press\n data: {}\n target:\n entity_id: button.sommermodus_an\n enabled: true\n
\nSomit könnt ihr euch nun beliebige Auslöser im Home Assistant konfigurieren, die eure Heizung in den Sommermodus schicken.
\nDie selbe Methode lässt sich neben dem Sommermodus auch für den Urlaubsmodus und eigentlich jede andere Vorlage in der FRITZ!Box anwenden. Dafür müsst ihr nur jeweils in der FRITZ!Box das gewünschte Verhalten als Vorlage definieren, um danach den zugehörigen Button im Home Assistant drücken zu können – beziehungsweise durch eine Automatisierungen auslösen zu können.
\nTheoretisch könnt ihr darüber auch abweichende Zeitpläne oder geänderte Spar-/Komforttemperaturen oder zeitlich begrenzte EInstellungen wie Boost oder Fenster-Modus aktivieren.
", "summary": "Der Sommermodus für die FRITZ!DECT-Thermostate wird über die FRITZ!Box gesteuert – mit einem festen Zeitplan. Mehr Flexibilität gibt es mit dem Home Assistant…", "date_published": "2023-05-26T18:45:26+02:00", "date_modified": "2023-05-29T09:48:19+02:00", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" } ], "banner_image": "https://journal.3960.org/posts/2023-05-26-sommermodus-mit-fritz-dect-home-assistant/ha-301.png", "language": "de-DE", "image": "https://journal.3960.org/posts/2023-05-26-sommermodus-mit-fritz-dect-home-assistant/ha-301.png", "tags": [ "Home Assistant", "Programmierung", "Technologie", "The Cool", "AVM", "Homeoffice" ] }, { "id": "user/posts/2023-04-10-verabschiedung/index.md", "url": "https://journal.3960.org/posts/2023-04-10-verabschiedung/", "title": "Verabschiedung", "content_html": "", "summary": "Alle Branches sind gepusht, ich meld' mich ab.\n\nEin Programmierer verabschiedet sich von seinem bisherigen Arbeitgeber", "date_published": "2023-04-10T18:54:53+02:00", "date_modified": "2023-04-10T18:54:53+02:00", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" } ], "banner_image": "https://cdn.3960.org/favicon-192x192.png", "language": "de-DE", "image": "https://cdn.3960.org/favicon-192x192.png", "tags": [ "Programmierung", "Philosophie", "Webdevelop" ] }, { "id": "user/posts/2023-03-05-24h-uhr-als-web-component/index.md", "url": "https://journal.3960.org/posts/2023-03-05-24h-uhr-als-web-component/", "title": "Die 24h-Uhr – als Web Component", "content_html": "Web Components zu bauen ist gar nicht so kompliziert, selbst ohne Framework. Nach meinen vorherigen Überlegungen zur Verwendung von SVGs in Web Components war es also höchste Zeit, eine 24-Stunden-Uhr zu bauen.
\n\n24h-Uhren sind analoge Uhren, die einen feinen Unterschied zu herkömmlichen Uhren aufweisen. An Stelle von 12 Stunden pro Umdrehung des Stundenzeigers zeigen sie 24 Stunden an. Damit braucht also der Stundenzeiger einen kompletten Tag für einen Vollkreis.
\nDamit ist es möglich, die vierundzwanzig Stunden auf dem Ziffernblatt grafisch in Tag, Dämmerung und Nacht einzuteilen. Da diese sich in Bezug auf das Datum und die geografische Position verändern, brauchte die Uhr also auch diese Informationen. In meinem Fall habe ich eine Skala auf dem Stundenring angebracht, die sich für die Nacht dunkelblau, für die Dämmung mittelblau und für den Tag hellblau verfärbt.
\n\n
Der Bau als Web Component erlaubt es nun, die Uhr mit relativ wenig HTML in eine beliebige Seite einzubauen. Unter der Github-Seite zur 24h-Uhr findet sich eine knappe Anleitung, der Einbau einer Web Component muss aber nicht komplizierter sein als:
\n<twentyfour-hours-clock></twentyfour-hours-clock>\n
\nWeitere Attribute erlauben es, die Component vorher mit Werten zu bestücken – die sich auch im Nachhinein mit JavaScript ändern lassen:
\n<twentyfour-hours-clock width="128" height="128" datetime="2011-10-10T14:48:00" longitude="auto" latitude="auto"></twentyfour-hours-clock>\n
\nAuch das Einfärben, Vergrößern und Verkleinern von Einzelteilen der Web Component sind mit CSS Custom Properties möglich:
\n<style>\ntwentyfour-hours-clock {\n --color-watchhand: pink;\n --color-night: #111111;\n}\n</style>\n<twentyfour-hours-clock></twentyfour-hours-clock>\n
\nDie Web Component selber wurde mit TypeScript gebaut. Tatsächlich wäre auch der Bau direkt in Vanilla JavaScript möglich gewesen. Meine letzten Abenteuer in TypeScript sind aber so angenehme Erfahrungen gewesen, dass ich die Unterstützung bei der Typisierung in JavaScript nicht mehr missen möchte, und die Web Component in TypeScript gebaut habe. Inzwischen halte ich bei JavaScript die Verwendung von TypeScript für so hilfreich, dass ich auf ESLint zur Überprüfung meiner Programmierung bei Privatprojekten gerne verzichte.
\nEin funktionierendes Beispiel für die fertige Uhr findet sich auf der Demonstrationsseite für die 24h-Uhr.
", "summary": "Web Components zu bauen ist gar nicht so kompliziert, selbst ohne Framework. Nach meinen vorherigen Überlegungen zur Verwendung von SVGs in Web Components war…", "date_published": "2023-03-05T18:56:12+01:00", "date_modified": "2023-03-05T18:56:12+01:00", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" } ], "banner_image": "https://journal.3960.org/posts/2023-03-05-24h-uhr-als-web-component/24h-clock.png", "language": "de-DE", "image": "https://journal.3960.org/posts/2023-03-05-24h-uhr-als-web-component/24h-clock.png", "tags": [ "Webdevelop", "Web-Components", "SVG", "Javascript", "CSS", "Geografie", "Outdoor", "Programmierung", "Technologie" ] }, { "id": "user/posts/2023-02-14-pragmatische-heizungssteuerung-home-office/index.md", "url": "https://journal.3960.org/posts/2023-02-14-pragmatische-heizungssteuerung-home-office/", "title": "Pragmatische Heizungssteuerung im Home-Office", "content_html": "Mein neuestes Abenteuer mit dem Home Assistant in Verbindung mit FRITZ!DECT 301 beschäftigt sich mit einer smarten Heizungssteuerung für das Home-Office.
\n\nAch, was habe ich alles gebaut und gebastelt. Los ging es mit einem einfachen Zeitplan in der FRITZ!Box, der jeden Montag bis Freitag in der Arbeitszeit die Heizung ein- und danach wieder ausschaltete. Ich war's zufrieden.
\nAber an Feiertagen, die zwischen Montag und Freitag lagen, lief die Heizung immer noch. Höchste Zeit, den Werktags-Sensor über den Home Assistant zu verbauen und in die Heizungssteuerung mit einzubeziehen. Dafür musste ich dann den Zeitplan aus der FRITZ!Box entfernen und ebenfalls in den Home Assistant übertragen.
\nUnd im Urlaub? Urlaube sind ja keine Feiertage. Also noch einen Kalender in den Home Assistant integriert, der an Urlaubstagen die Heizung ebenfalls ausgeschaltet hält.
\nOha, Gleitzeit und Überstunden: Mal starte ich früher, mal höre ich später auf. Also diesen Teil von Hand steuern? Und wie ist es am Wochenende, wenn man im Arbeitszimmer am privaten PC etwas bastelt?
\nWie man es auch dreht und wendet – der Plan hatte inzwischen viel zu viele Variablen bekommen. Eigentlich wollte ich doch nur, dass die Heizung läuft, wenn ich im Raum bin! Aber wie kann ich das dem Home Assistant beibringen?
\nGanz einfach! Ob im Büro jemand arbeitet kann man daran erkennen, dass einer der PCs im Büro eingeschaltet ist. Ein super-simples Rezept kann also prüfen, ob mindestens einer der Rechner dort an und im Netzwerk sichtbar ist, und schaltet entsprechend die Heizung an. Wenn keiner der Rechner im Netzwerk ist, wird die Heizung entsprechend ausgeschaltet.
\nDazu bauen wir uns einen Zustand, der eingeschaltet ist, wenn irgendeiner der PCs angeschaltet und im Netzwerk ist – und erst ausgeschaltet ist, wenn alle PCs ausgeschaltet sind. Das funktioniert wie folgt:
\narbeitspferd
und daddelkiste
.device_tracker
beginnt. In meinem Beispiel gehen wir von zwei PC-Trackern aus, device_tracker.arbeitspferd
und device_tracker.daddelkiste
.configuration.yaml
nun so erweitert werden, dass die beiden Tracker (oder beliebig viele Tracker) zu einem Sammel-Tracker zusammengefasst werden.- name: "Home-Office PCs"\n unique_id: device_tracker_homeoffice\n state: "{{ is_state('device_tracker.arbeitspferd', 'home') or is_state('device_tracker.daddelkiste', 'home') }}"\n icon: "mdi:lan-connect"\n device_class: presence\n
\nNach einmaligem Neustart sollte unter „Entwicklerwerkzeuge > Zustände“ der neue Tracker sichtbar sein. Er heißt dann wahrscheinlich binary_sensor.device_tracker_homeoffice
.
Unser neuer kleiner Device-Tracker „Home-Office PCs“ kann nun zum Beispiel auf dem Dashboard verbaut werden. Sobald eines der Geräte eingeschaltet wird, wird auch der Tracker eingeschaltet – beim Ausschalten gibt es knapp zwei Minuten Verzögerung, bis der Home Assistant das Gerät wirklich als offline annimmt.
\nDer Home Assistant kann nun die Programmierung der FRITZ!Box verbessern. Dafür wird in der FRITZ!Box die Heizung zu 100% in den Spar-Modus geschickt, weil ab sofort der Home Assistant die Heizung zwischen Spar- und Komfort-Modus hin- und herschaltet. Die Verbindung ist dabei einfach:
\nUnter „Einstellungen > Automatisierungen & Szenen“ fügen wir dazu ein einfaches Rezept hinzu:
\n# Replace all occurences of `office` with your thermostat's identifier\n# `device_id` has to be assigned via GUI\nalias: "Office: Someone is there"\ndescription: "Be nice, and turn on the heating if someone is using a PC in the office"\ntrigger:\n - platform: state\n entity_id:\n - binary_sensor.device_tracker_homeoffice\n from: "on"\n - platform: state\n entity_id:\n - binary_sensor.device_tracker_homeoffice\n to: "on"\naction:\n - if:\n - condition: state\n entity_id: binary_sensor.device_tracker_homeoffice\n state: "on"\n then:\n - device_id: b46c4851235fb8c90f4a659b6e9a953d\n domain: climate\n entity_id: climate.office\n type: set_preset_mode\n preset_mode: comfort\n else:\n - device_id: b46c4851235fb8c90f4a659b6e9a953d\n domain: climate\n entity_id: climate.office\n type: set_preset_mode\n preset_mode: eco\nmode: single\n
\nErst mit der Zusammenfassung der beiden Zustände zu einem Tracker ist es sauber möglich, zwischen Spar- und Komfort-Modus hin- und herzuschalten. Wenn wir die Tracker trennen würden könnte es ansonsten passieren, dass das Ausschalten eines Rechners die Heizung deaktiviert, obwohl der andere Rechner eigentlich noch läuft.
\nTatsächlich kann man diese Idee auch auf andere Geräte ausdehnen, wie zum Beispiel Fernseher und Spielekonsolen, die ebenfalls im Internet hängen. Eine kleine Automatisierung könnte also abends die Heizung noch etwas länger an lassen, solange der Fernseher läuft.
\nOder wie wäre es mit einem Sammel-Tracker, der die Smartphones aller Haushaltsteilnehmer als Schalter verwendet?
", "summary": "Mein neuestes Abenteuer mit dem Home Assistant in Verbindung mit FRITZ!DECT 301 beschäftigt sich mit einer smarten Heizungssteuerung für das Home-Office.", "date_published": "2023-02-14T19:04:06+01:00", "date_modified": "2023-02-21T10:37:45+01:00", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" } ], "banner_image": "https://journal.3960.org/posts/2023-02-14-pragmatische-heizungssteuerung-home-office/ha-301.png", "language": "de-DE", "image": "https://journal.3960.org/posts/2023-02-14-pragmatische-heizungssteuerung-home-office/ha-301.png", "tags": [ "Home Assistant", "Programmierung", "Technologie", "The Cool", "AVM", "Homeoffice" ] }, { "id": "user/posts/2023-01-27-javascript-pattern-zum-event-handling/index.md", "url": "https://journal.3960.org/posts/2023-01-27-javascript-pattern-zum-event-handling/", "title": "JavaScript: Pattern zum Event-Handling", "content_html": "Manche JavaScript-Applikationen haben eine erhebliche Zahl an Event-Listenern. Diese ordentlich und übersichtlich zu erfassen und abzuarbeiten kann eine Herausforderung sein – bis wir uns die Anleitung zu addEventListener
genau durchlesen.
Wie schon in dem Artikel „Event-Handling mit JavaScript – und ohne jQuery“ angemerkt, ist ein gutes Verständnis von addEventListener
sehr hilfreich, um sich in der eigenen JavaScript-Applikation keine Performance-Ärger einzubrocken – und gleichzeitig die Übersicht zu behalten, wenn die Anzahl der Listener schnell ansteigt.
Gehen wir davon aus, dass ihr klassen- bzw. objektorientiert arbeitet. Schnell werdet ihr auf die Herausforderung treffen, dass this
innerhalb des Listeners eine andere Bedeutung hat als außerhalb.
Natürlich könntet ihr mit .bind()
den Kontext neu setzen. Oder ihr könntet mit anonymen Arrow-Funktionen arbeiten, die aber den üblichen Nachteil von anonymen Funktionen bei Listenern mitbringen.
MDN's Anleitung für addEventListener
verrät uns, dass als Listener drei verschiedene Dinge eingetragen werden können:
null
handleEvent
-Methode verfügt.Die letzte Option erlaubt es uns, this
mit wenig Aufwand vorhersehbar einzusetzen.
Tatsächlich ist die Übergabe des gesamten Objekts als Listener und die Definition einer handleEvent
-Methode überraschend simpel:
// 1st example: Multiple event listeners attached,\n// one handler\nclass App {\n constructor() {\n document.querySelectorAll('a, button').forEach(function(element) {\n element.addEventListener('click', this);\n });\n }\n\n handleEvent(event) {\n // do something\n }\n}\n
\nInnerhalb von handleEvent
kann nun this
mit dem zu erwartenden Bezug auf das aktuelle Klassen-Objekt verwendet werden, während die für das Event notwendigen Eigenschaften (ebenfalls regulär) in dem Parameter event
stehen.
Ärgerlicherweise werden nun aber alle so registrierten Events mit ein und dem selben Listener bedient. Zum Glück verrät uns aber jedes Event, für welches Element es ausgelöst wurde – die sogenannte Event-Delegation. Eine Fallunterscheidung z.B. nach ID kann uns also helfen, die einzelnen Aktionen auseinander zu halten:
\n// 2nd example: Multiple event listeners attached,\n// one handler with delegation per id\nclass App {\n constructor() {\n document.querySelectorAll('a, button').forEach(function(element) {\n element.addEventListener('click', this);\n });\n }\n\n handleEvent(event) {\n switch (event.target.id) {\n case 'example-1':\n // do something\n break;\n case 'example-2':\n // do something other\n break;\n }\n }\n}\n
\n…und aus dem Nachteil ist ein Vorteil geworden, da jetzt die einzelnen Event-Listenern nichts anderes als Fälle in einer switch
-Anweisung geworden sind.
Jetzt können wir die Vielzahl der im DOM verteilten Event-Listener eigentlich auch gleich auf einen einzigen reduzieren, da der Listener sowieso nochmals überprüft, welches Element genau getroffen wurde. Dabei machen wir uns Event-Bubbling zu Nutze, und montieren an zentraler Stelle einen Event-Listener, der alle Interaktionen abfängt, und dann mittels Event-Delegation den korrekten Ansprechpartner auswählt.
\nDie zentralste Stelle ist natürlich… der <body>
. Damit erschlagen wir übrigens gleich den Fall, dass nachträglich dynamisch DOM-Elemente auf der Seite hinzugefügt werden. Denn der auf dem <body>
montierte Listener fängt natürlich auch erst nachträglich im DOM montierte Elemente ab.
// 3rd example: Single event listeners attached,\n// one handler with delegation per id\nclass App {\n constructor() {\n document.body.addEventListener("click", this);\n }\n\n handleEvent(event) {\n switch (event.target.id) {\n case 'example-1':\n // do something\n break;\n case 'example-2':\n // do something\n break;\n }\n }\n}\n
\nNoch weiter abstrahieren lässt sich das Delegations-Beispiel, wenn wir vorher die unterschiedlichen Event-Typen auseinandersortieren:
\n// 4th example: Single event listeners attached,\n// one handler triggers subhandlers per event type\nclass App {\n constructor() {\n document.body.addEventListener("input", this, true);\n document.body.addEventListener("click", this, true);\n document.body.addEventListener("change", this, true);\n }\n\n handleEvent(event) {\n // @see https://developer.mozilla.org/en-US/docs/Web/API/Event\n switch (event.type) {\n case 'click' : this.handleEventClick(event); break;\n case 'input' : this.handleEventInput(event); break;\n case 'change': this.handleEventChange(event); break;\n }\n }\n\n handleEventClick(event) {\n switch (event.target.id) {\n case 'example-1':\n // do something\n break;\n case 'example-2':\n // do something\n break;\n }\n }\n\n handleEventInput(event) {\n switch (event.target.id) {\n case 'example-3':\n // do something\n break;\n case 'example-4':\n // do something\n break;\n }\n }\n\n handleEventChange(event) {\n switch (event.target.id) {\n case 'example-5':\n // do something\n break;\n case 'example-6':\n // do something\n break;\n }\n }\n}\n
\nDieses Pattern lässt sich natürlich auch ohne Objekte einsetzen, und mit regulären Funktionsaufrufen durchführen. Dabei verliert es aber natürlich die Möglichkeit, this
ohne Gehirn- oder Code-Akrobatik zu verwenden.
Unser schönes Beispiel geht davon aus, dass wir die Auswahl der eigentlich durchzuführenden Aktion an der id
des Elements festmachen, dass das Event ausgelöst hat. Tatsächlich können wir das aber an jedes Attribut koppeln. Damit sind auch class
-Attribute mögliche Ziele.
<dialog>\n <button class="modal-close">Close<button>\n \n <label for="date">Input</label>\n <input id="date" type="date" class="change-date" />\n\n <button class="set-to-current">Set to current time</button>\n</dialog>\n
\nDas erlaubt uns zum Beispiel, die erste Klasse eines Elements darüber entscheiden zu lassen, was getan werden soll:
\n// 5th example: Single event listeners attached,\n// one handler triggers subhandlers per `class` attribute\nclass App {\n constructor() {\n document.body.addEventListener("input", this, true);\n document.body.addEventListener("click", this, true);\n document.body.addEventListener("change", this, true);\n }\n\n handleEvent(event) {\n // Get first `class` of element\n const handler = e.target.classList.item(0);\n switch (handler) {\n case 'modal-close' : this.handleModalClose(event); break;\n case 'change-data' : this.handleChangeDate(event); break;\n case 'set-to-current': this.handleSetToCurrent(event); break;\n }\n }\n\n handleModalClose(event) {\n // do something\n }\n\n handleChangeDate(event) {\n // do something\n }\n\n handleSetToCurrent(event) {\n // do something\n }\n}\n
\nAber wie wäre es mit einem eigenen data
-Attribut, zum Beispiel in Form eines data-handler
?
<dialog>\n <button data-handler="modal-close">Close<button>\n \n <label for="date">Input</label>\n <input id="date" type=="date" data-handler="change-date" />\n\n <button data-handler="set-to-current">Set to current time</button>\n</dialog>\n
\nIn diesem Fall hängen wir an alle HTML-Elemente ein data-handle
, mit dem wir zum Beispiel auch direkt benennen können, mit welchem Handler gearbeitet werden soll:
// 5th example: Single event listeners attached,\n// one handler triggers subhandlers per `data-handler` attribute\nclass App {\n constructor() {\n document.body.addEventListener("input", this, true);\n document.body.addEventListener("click", this, true);\n document.body.addEventListener("change", this, true);\n }\n\n handleEvent(event) {\n // Bubble up to closest DOM element with `data-handler`\n const handler = e.target.closest('[data-handler]')?.dataset.handler;\n switch (handler) {\n case 'modal-close' : this.handleModalClose(event); break;\n case 'change-data' : this.handleChangeDate(event); break;\n case 'set-to-current': this.handleSetToCurrent(event); break;\n }\n }\n\n handleModalClose(event) {\n // do something\n }\n\n handleChangeDate(event) {\n // do something\n }\n\n handleSetToCurrent(event) {\n // do something\n }\n}\n
\nAls angenehmer Nebeneffekt können wir uns auf die Elemente beschränken, die auch wirklich ein data-handler
-Attribut haben – oder im DOM nach oben wandern, bis wir ein Element gefunden haben, dass über ein solches Attribut verfügt. \n.closest()
ist hier eine wirkliche Hilfe.
Der Home Assistant hat gegenüber vielen anderen Smart-Home-Lösungen einen Vorteil: Er benötigt keinen Internet-Dienst, um zu funktionieren. Damit ist er von vorne herein sehr sicher, da initial keine Daten aus eurem Smart-Home ins Internet entweichen.
\nEinen kleinen Nachteil hat diese Konstruktion aber: Euer Home Assistant lässt sich nicht außerhalb eures Heimnetzwerks steuern. Oder?
\n\nNatürlich stimmt das nicht: Direkt im Home Assistant wird Werbung für die Home Assistant Cloud gemacht. Mit diesem kleinen Dienst kann eure vormals nicht im Internet erreichbare Lösung auch außerhalb eures Heimnnetzwerks angesprochen werden. Neben der Tatsache, dass ihr damit ein Sicherheitsfeature eures Home Assistants über Bord geworfen habt, kostet dieser Dienst aber auch Geld.
\nTatsächlich ist der entscheidende Satz, dass euer Home Assistant nur aus eurem Heimnetzwerk zugreifbar ist… beziehungsweise, wenn ihr Zugriff auf euer Heimnetzwerk habt. Wenn ihr also mittels eine Virtual Private Networks auch außerhalb eurer WLAN-Reichweite euch in euer Heimnnetz einwählen könnt, könnt ihr natürlich auch auf euren Home Assistant zugreifen.
\nDas schöne an dieser Lösung: Der Zugang zu eurem System ist somit nur durch eine VPN-Verbindung möglich; die Daten eures Systems wie auch der Zugang zum Home Assistant sind nicht direkt im Internet verfügbar.
\nDie AVM FRITZ!Box bietet netterweise einen eingebauten VPN-Dienst, der euch mit beliebigen Betriebssystemen für PCs, Laptops und Smartphones eine VPN-Verbindung erlaubt. Die Einrichtung ist nicht schwierig, aber voller Fallstricke.
\nRichtet also auf eurer AVM FRITZ!Box einen VPN-Zugang ein, aber mit einer wichtigen Änderung: Wenn euer Home Assistant bereits mit der FRITZ!Box eingerichtet wurde, solltet ihr keinesfalls (wie von AVM vorgeschlagen) eure FRITZ!Box (und damit alle anderen Netzwerkteilnehmer) mit neuen IPs versehen. Denn der Home Assistant merkt sich für so ziemlich jedes Gerät explizit die IP, und eine Änderung ist ein größerer Schmerz. Tatsächlich könnt ihr die IP eurer FRITZ!Box so belassen – und verliert nur die Möglichkeit, über einen Router mit der selben IP das VPN zu benutzen. Tatsächlich erhaltet ihr aber bei einer Mobilverbindung immer eine IP außerhalb des Heimbereichs, könnt also problemlos das VPN nutzen.
\nAn eurem Home Assistant muss keine weitere Änderung vorgenommen werden. Sobald ihr die VPN-Verbindung aufgebaut habt, könnt ihr sowohl im Browser als auch über die App den Home Assistant benutzen, als ob ihr im Heimnetzwerk wärt.
\nInteressanterweise kann eure Home Assistant App (zumindest unter Android) auch außerhalb des Heimnetzwerks Benachrichtigungen eures Home Assistants empfangen. Das liegt daran, dass der Home Assistant mit dem Übertragen der Benachrichtigungen einen Android-Dienst außerhalb eures Netzwerks beauftragt, der euch die Nachrichten über das Internet zustellt.
\nHeizungssteuerung ist in aller Munde. Als Besitzer einer FRITZ!Box habe ich mir also ein paar FRITZ!DECT 301 Thermostate der Firma AVM zugelegt, um meine Heizung sparsamer zu machen.
\nUnd dann bin ich dummerweise auf Home Assistant aufmerksam gemacht worden, mit dem man die etwas spartanischen Möglichkeiten der Thermostate gewaltig aufbohren kann.
\nBegleitet mich auf meiner Reise in den Kaninchenbau.
\n\nBei der Steuerung der Thermostate ist wichtig zu wissen, dass der Home Assistant (wie auch die FRITZ!Box selber) nur in festen Intervallen Aufträge an die Thermostate übertragen kann. Im schlimmsten Fall dauert es bis zu 15 Minuten, bis euer Auftrag dort angekommen ist. Wenn ihr also Automatisierungen oder eine Steuerung bastelt, müsst ihr etwas Vorlauf in Kauf nehmen.
\nMeine Philosophie bei der Integration des Home Assistant war wie folgt:
\nNach der Installation wird man von den etwas überzüchteten Heizkörperreglern in der Oberfläche des Home Assistant begrüßt. Meine Regler sollen nur aus drei Teilen bestehen:
\nDas ist tatsächlich schön konfigurativ zu lösen…
\n\n…oder mit dem folgendem YAML als schnelles Rezept für das Dashboard:
\n# Replace all occurences of `wohnzimmer` with your thermostat's identifier\ntype: thermostat\nfeatures:\n - style: icons\n preset_modes:\n - eco\n - comfort\n type: climate-preset-modes\nentity: climate.wohnzimmer\n
\n(Update 2024–02: Die neuen Konfigurationsmöglichkeiten des Home Assistant haben den Einbau weiterer Knöpfe deutlich vereinfacht.)
\nDurch den Home Assistant werden die FRITZ!DECT 301 Thermostate folgerichtig als Thermostate erkannt. Tatsächlich beinhalten sie aber auch einen Temperatur-Sensor, den man mit etwas Bastelei auch im Dashboard anzeigen kann.
\nMit dem Home Assistant Community Add-on: Visual Studio Code kann die configuration.yaml
um die folgenden Zeilen erweitern werden:
# Converting thermostats into thermometers\n# Replace all occurences of `wohnzimmer` with your thermostat's identifier\ntemplate:\n - sensor:\n - name: "Wohnzimmer Heizung Temperatur"\n unique_id: wohnzimmer_heizung_temperatur\n state: "{{ state_attr('climate.wohnzimmer', 'current_temperature') }}"\n unit_of_measurement: "°C"\n device_class: temperature\n
\nDanach können diese Sensoren im Dashboard angezeigt werden:
\n# Replace all occurences of `wohnzimmer` with your thermostat's identifier\ntype: gauge\nentity: sensor.wohnzimmer_heizung_temperatur\nmin: 10\nmax: 32\nseverity:\n green: 21\n yellow: 23\n red: 25\n
\nDie FRITZ!DECT 301 Thermostate können in einen Urlaubsmodus geschickt werden – ein Zustand, der im Home Assistant ebenfalls ausgelesen werden kann. Besonders luxuriös geschieht das mit einem eigenen Binär-Sensor.
\nMit dem Home Assistant Community Add-on: Visual Studio Code kann die configuration.yaml
um die folgenden Zeilen erweitern werden:
# Replace all occurences of `wohnzimmer` with your thermostat's identifier\ntemplate:\n - binary_sensor:\n - name: "Wohnzimmer Heizung Urlaubsmodus"\n unique_id: wohnzimmer_heizung_urlaubsmodus\n state: "{{ state_attr('climate.wohnzimmer', 'holiday_mode') }}"\n icon: "mdi:bag-checked"\n
\nAnalog lässt sich auch der Sommermodus der Heizung in der configuration.yaml
als eigener Sensor einrichten:
# Replace all occurences of `wohnzimmer` with your thermostat's identifier\ntemplate:\n - binary_sensor:\n - name: "Wohnzimmer Heizung Sommermodus"\n unique_id: wohnzimmer_heizung_sommermodus\n state: "{{ state_attr('climate.wohnzimmer', 'summer_mode') }}"\n icon: "mdi:hvac-off"\n
\nIn der FRITZ!Box hatte ich die Steuerung meiner Büro-Heizung bisher stumpf an die Wochentage Montags bis Freitag gekoppelt. So lief die Heizung auch, wenn ein Feiertag das eigentlich unnötig machte. Höchste Zeit also für den Arbeitstag-Sensor.
\nMit dem Home Assistant Community Add-on: Visual Studio Code kann die configuration.yaml
um die folgenden Zeilen erweitern werden:
# Workday sensor\nbinary_sensor:\n - platform: workday\n country: DE\n province: NI # Niedersachsen, Lower Saxony\n
\nDer Home Assistant kann nun die Programmierung der FRITZ!Box verbessern. Dafür wird in der FRITZ!Box die Heizung zu 100% in den Spar-Modus geschickt, weil ab sofort der Home Assistant die Heizung auf den Komfort-Modus schaltet, wenn ein Arbeitstag vorliegt.
\nVorher legen wir uns einen Zeitplan an, den wir in folgende Automatisierung einbinden:
\n# Replace all occurences of `office` with your thermostat's identifier\n# `device_id` has to be assigned via GUI\nalias: "Home Office"\ndescription: Only trigger comfort mode on work days\ntrigger:\n - platform: state\n entity_id:\n - binary_sensor.office_heizung_home_office\ncondition:\n - condition: and\n conditions:\n - condition: state\n entity_id: binary_sensor.workday_sensor\n state: "on"\n - condition: state\n entity_id: binary_sensor.office_heizung_urlaubsmodus\n state: "off"\naction:\n - if:\n - condition: state\n state: "on"\n entity_id: binary_sensor.office_heizung_home_office\n then:\n - device_id: b46c4851235fb8c90f4a659b6e9a953a\n domain: climate\n entity_id: climate.office\n type: set_preset_mode\n preset_mode: comfort\n else:\n - device_id: b46c4851235fb8c90f4a659b6e9a953a\n domain: climate\n entity_id: climate.office\n type: set_preset_mode\n preset_mode: eco\n enabled: true\nmode: single\n
\nHier können aber auch die Vorlagen in der FRITZ!Box hilfreich sein, um aus dem Home Assistant in der FRITZ!Box komplexere Szenarien auslösen zu können.
", "summary": "Heizungssteuerung ist in aller Munde. Als Besitzer einer FRITZ!Box habe ich mir also ein paar FRITZ!DECT 301 Thermostate der Firma AVM zugelegt, um meine…", "date_published": "2022-11-08T18:17:23+01:00", "date_modified": "2024-01-05T12:46:57+01:00", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" } ], "banner_image": "https://journal.3960.org/posts/2022-11-08-fritz-dect-301-home-assistant/ha-301.png", "language": "de-DE", "image": "https://journal.3960.org/posts/2022-11-08-fritz-dect-301-home-assistant/ha-301.png", "tags": [ "Home Assistant", "Programmierung", "Technologie", "The Cool", "AVM", "Homeoffice" ] }, { "id": "user/posts/2022-06-27-javascript-gamepad-api-test/index.md", "url": "https://journal.3960.org/posts/2022-06-27-javascript-gamepad-api-test/", "title": "Controller testen mit dem JavaScript Gamepad API Test", "content_html": " Mit dem Gampead API Test könnt ihr die tatsächlichen Rohwerte der Gamepad
API auslesen – und nebenbei eure Gamepads, Joysticks, Schubkontrollen, Fußpedale und sonstige Controller testen.
Moderne Browser haben inzwischen eine Vielzahl interessanter JavaScript-Schnittstellen. Höchste Zeit, die Gamepad
-API genau zu begutachten.
Die Gamepad
-API ist eine der Zutaten, die moderne Browser für die Spiele-Entwicklung bereit stellen. Neben Grafik (inklusive Virtual Reality), Sound und Socket-Verbindungen gibt es damit alles, was das Spiele-Entwickler-Herz begehrt.
Anleitungen für die Gamepad
-API gibt es viele: Allen voran die Dokumentation zur Gamepad API auf MDN lässt wenig Fragen offen. Ich habe mich aber in einen etwas speziellen Bereich vorgewagt. Inspiriert von einem Retro Flug-Simulator im Browser \nhabe ich mir die Frage gestellt: Wie sieht es aus, wenn ich einen Flugsimulator für den Browser baue, und dort Unterstützung für Joysticks, Schubkontrollen oder Fußpedale integrieren möchte?
Kurz gefasst: Gar nicht so übel.
\nDie Dokumentation zur Gamepad API auf MDN bespricht in den meisten Beispielen nur Gamepads. Der schöne Artikel „Using The Gamepad API In Web Games“ aus dem Smashing Magazine erklärt deutlich mehr die Verwendung von Joysticks & Co – aber auch hier bleiben ein paar Fragen offen.
\nTatsächlich funktioniert die Gamepad API mit so ziemlich jedem USB/Bluetooth-Eingabegerät, das wie ein Gamepad angesprochen werden kann. Darunter fallen Joysticks, Schubkontrollen, Ruder-Pedale und so ziemlich jeder andere HOTAS-Controller, den Flugsimulator-Fans kennen.
\nDer Vorgang ist in JavaScript verblüffend einfach: Mit der JavaScript-Methode Navigator.getGamepads()
erhaltet ihr ein Array aller an eurem PC/Smartphone/sonstigen Endgerät angeschlossenen Controller.
Die Controller verraten folgendes über sich:
\ngamepad.id
: Das ist die ID des Eingabegeräts, die die meisten Browser in einen verständlichen Namen übersetzen – wenn auch jeder Browser ein etwas anderes Ergebnis ausgibt. 😖gamepad.buttons
: Dieses Array verrät euch, wie viele Buttons das Eingabegerät hat.gamepad.axes
: Dieses Array verrät euch, wie viele Achsen das Eingabegerät hat.gamepad.mapping
: Hier verrät der Browser mit dem Schlüsselwort „standard“
, ob der Controller auf ein Standard-Gamepad umbelegt werden konnte.gamepad.timestamp
: Der Zeitpunkt, an dem das letzte Mal eine Messung auf dem Gamepad durchgeführt wurde. Das wird später wichtig, um die Zeit zwischen den Messungen zu verfolgen, und damit z.B. die Dauer eines Tastendrucks zu erkennen.\n
Das Standard-Mapping ist eine große Hilfe für die Implementation von Gamepads in den Browser: Dabei wird die Achsen- und Button-Belegung des Gamepads auf ein erwartbares Layout eines Standard-Gamepads umgebogen. Die Belegung beinhaltet:
\nDer Haken ist, das ein und dasselbe Gamepad nicht von jedem Browser diesem Layout zugeordnet wird. So wird ein Xbox-Controller unter Firefox/Windows, Chrome/Windows und Chrome/Ubuntu als Standard-Controller erkannt, unter Firefox/Ubuntu dagegen nicht. 😖
\nDas kann fatale Auswirkungen haben: Ein und dasselbe Gamepad kann zum Beispiel im Standard-Mapping die analogen Trigger als Buttons mit Analogwerten melden, während der nächste Browser ohne Standard-Mapping die Buttons streicht und stattdessen zwei zusätzliche Achsen sendet (die dann auch noch die Reihenfolge durcheinander bringen). Oder das D-Pad wird nicht als Sammlung von vier Knöpfen sondern als zwei Achsen gemeldet.
\nHöchste Zeit also, einen Blick auf den lokalen Fuhrpark an HOTAS-Controller zu werfen. Mit dem selbstgebauten Gamepad API Test kann jedes beliebige Eingabegerät so dargestellt werden, wie der Browser dieses Gerät auswertet.
\n\n
Ziemlich schnell zeichnet sich dabei ein Muster ab. Jeder Joystick bietet verlässlich folgendes an:
\nBereits dahinter scheiden sich die Geister. In den meisten Fällen könnt ihr euch noch knapp auf die folgende Konvention verlassen:
\nInteressanterweise lassen es sich Joystick-Hersteller nicht nehmen, einige Achsen zu überspringen. Ein vermeintlicher 5-Achsen-Joysticks kann beim Auslesen der API trotzdem neun Achsen senden, wenn der Hersteller des Joysticks das für eine gute Idee hält.
\n\n
Weitere Eingabegeräte wie Schubkontrollen versuchen sich, auch an diese Konvention zu halten. Hier ist aber deutlich mehr Glück gefragt.
\n\n
Ganz verrückt sieht es dann mit Fußpedalen aus, die über gar keine Buttons, aber dafür eine Achse für die Verschiebung der Füße und zwei Achsen für die jeweiligen Pedale haben.
\nDen Zustand des Gamepads und all seiner Achsen und Buttons zu kennen ist für euer Spiel oder eure Simulation nicht immer hilfreich, denn ihr bekommt die absoluten Roh-Daten des Controllers. Diese solltet ihr in der Regel in ein besser handhabbares Format umwandeln.
\n\nDie Programmier-Beispiele in diesem Artikel sind natürlich sehr simpel gehalten; in einer echten Umgebung kann deutlich mehr Abstraktion hilfreich sein.
\n
Analoge Eingabeinstrumente haben in der Regel kleine Ungenauigkeiten. Manchmal zentrieren sie nicht richtig, manchmal flattern die Ausgabewerte etwas. Um diese kleinen Fehler zu korrigieren werden sogenannte dead zones eingerichtet.
\nDead zones oder dead bands ignorieren bestimmte Werte einer Achse, und schneiden diese ab. Somit kann zum Beispiel eine leicht verkalibrierte Mitte eures Sticks trotzdem als 0
ausgelesen werden, oder ein Stick saubere -1
und +1
übermitteln, obwohl er aus Altersschwäche nur +0.9995
erreicht.
Ein fantastischer Artikel zu diesem Thema ist „Joystick input and using deadbands“ von Mimir Games. Einfach gesprochen brauchen wir zwei Arten von Todzonen:
\ninnerThreshold
: Bei selbst zentrierenden Achsen sollte im einer kleinen Zone in der Mitte keine Eingabe genommen werden.outerThreshold
: Generell sollte das obere und untere Ende des Wertebereichs vorzeitig einen Vollausschlag erzeugen.Eine sehr einfache Funktion für diesen Zweck sieht wie folgt aus:
\nconst axisDeadzone = function(axis, outerThreshold, innerThreshold = 0) {\n if (innerThreshold > 0) {\n const multiplier = (axis > 0 ? 1 : -1);\n axis = Math.max(0, (Math.abs(axis) - innerThreshold) / (1 - innerThreshold)) * multiplier;\n }\n\n if (outerThreshold > 0) {\n axis = Math.max(-1, Math.min(1, axis / (1 - outerThreshold)));\n }\n\n return axis;\n}\n
\nDie Gamepad
-API liefert euch immer eine Momentaufnahme des Gamepads, die ihr so oft wie möglich abfragt. Der zeitliche Abstand zwischen diesen Abfragen ist nicht direkt vorhersehbar, sondern wird ebenfalls gemessen. Dafür übermittelt das Gamepad den gamepad.timestamp
.
Den Effekt einer Achs-Eingabe können wir einfach mit der Zeitspanne multiplizieren, der zwischen den letzten beiden Messungen vergangen ist; selbst wenn unser Gamepad etwas länger für die Messung braucht, ist der Effekt im Spiel trotzdem der selbe.
\nZuerst sollten wir uns eine Variable anlegen, in der wir uns den letzten Timestamp merken können. In der Abfrageschleife (dem Game-Loop) können wir nun die Zeit zwischen den letzten beiden Updates ermitteln:
\nlet lastTimestamp = 0;\nlet posX = 0;\nlet posY = 0;\n\nfunction gameLoop () {\n const gamepad = navigator.getGamepads()[0]; // this is just a stub ;)\n\n // Find time elapsed since last gamepad update\n const elapsedTime = lastTimestamp === 0\n ? 0 \n : timestamp - lastTimestamp;\n // Store current timestamp for next time\n lastTimestamp = timestamp;\n\n // Move Pac Man / Thunderblade / Turrican / whatever relative to elapsed time\n posX += gamepad.axes[0] * elapsedTime;\n posY += gamepad.axes[1] * elapsedTime;\n\n window.requestAnimationFrame(gameLoop); // Restart loop\n}\n
\nButtons klingen trivial, sind es aber nicht. Die Gamepad
-API übermittelt, ob ein Button gerade jetzt gedrückt wird. Ob er vorher schon gedrückt wurde und für wie lange, oder ob er in der aktuellen Messung das erste Mal gedrückt wurde – diese Information müssen wir uns selber erarbeiten.
Folgende Zustände sind für uns hilfreich:
\npressed
: Der Button wird gerade gedrückt gehaltentriggered
: Der Button wird gerade zum ersten Mal gedrücktreleased
: Der Button wird gerade wieder losgelassenIn einem Skript könnte das wie folgt aussehen:
\nlet lastButtonStates = [];\n\nfunction gameLoop () {\n const gamepad = navigator.getGamepads()[0]; // this is just a stub ;)\n\n // Get button states\n const buttonStates = gamepad.buttons.map((button, index) => {\n return {\n pressed: button.pressed,\n triggered: lastButtonStates[index]\n ? (button.pressed && lastButtonStates[index].pressed !== button.pressed)\n : button.pressed,\n released: lastButtonStates[index]\n ? (!button.pressed && lastButtonStates[index].pressed !== button.pressed)\n : false\n }\n });\n\n // Have Pac Man / Thunderblade / Turrican / whatever do stuff\n buttonStates[0].triggered && turrican.jump();\n buttonStates[1].pressed && turrican.fire();\n buttonStates[2].pressed && turrican.chargeSuperpower();\n buttonStates[2].released && turrican.releaseSuperpower();\n\n window.requestAnimationFrame(gameLoop); // Restart loop\n}\n
\nMit den Informationen aus triggered
und released
könnt ihr noch die Zeitdauer des Gedrückthaltens ableiten, und zum Beispiel auch in einem bestimmten Intervall bei gedrückter Taste Aktionen ausführen.
Einige unscheinbar Kontrollen an eurem Joystick sind in der Auswertung initial etwas kompliziert zu verstehen. Mit einer kleinen Trickkiste könnt ihr euch aber schnell Übersicht verschaffen.
\nSchubkontrollen sind eine ganz normale Achse wie zum Beispiel ein Joystick, mit dem Unterschied, dass sie sich nicht selber zentrieren. Sie haben den selben Wertebereich wie jede andere Achse auch. Der value
kann zwischen -1
..+1
liegen.
Dieser Wertebereich ist für eine Schubkontrolle in der Regel nicht so hilfreich, aber mittels einer einfachen Funktion auf den Bereich 0
..1
transformierbar:
const axisToThrottle = function(axis) {\n return (axis + 1) / 2;\n}\n
\nEinige Buttons (auch gerne Trigger genannt) haben nicht nur einen festen Auslösepunkt, sondern auch einen analoge Achse verbaut. So sind bei den meisten Gamepads die unteren Schultertasten als analoge Trigger ausgeführt. Dies erlaubt euch als Spieleentwickler genau zu messen, wie weit der Button gedrückt wurde.
\nAuf einem Standard-Gamepad wird dies am GampepadButton
als value
gemeldet:
gamepad.button[4] = {\n pressed: true,\n value: 0.4321\n};\n
\nDer value
liegt dann zwischen 0
..1
.
Verflixt wird es, wenn das Eingabegerät nicht als Standard-Gamepad erkannt wird; denn tatsächlich ist der Trigger gar kein Button, sondern eine Achse:
\ngamepad.axes[3] = -0.1358;\n
\n…wobei der Wert zwischen -1
..1
liegt, womit er einen anderen Wertebereich umfasst als in seiner GampepadButton
-Form.
Eine einfache Transformation macht uns die Achse aber wieder zum Button:
\nconst axisToButton = function(axis) {\n return {\n pressed: (axis > -1),\n value: (axis + 1) / 2,\n touched: (axis > -1)\n };\n}\n
\n\n
Jedes Vier-Wege-Kreuz auf eurem Gamepad, Joystick oder Schubkontrolle kann eine kleine Überraschung für euch bereit halten. Tatsächlich gibt es mehrere Wege, wie sie in der Gamepad
-API auftreten können:
Wenn ein Vier- bzw. Acht-Wege-Kreuz als eine einzige Achse auftritt, hat der Hersteller sich etwas besonderes überlegt: Jeder Wert zwischen -1..+1
steht für Ausrichtung des Coolie hats zwischen 0°..315°. In Ruheposition sendet er dagegen einen unmöglich hohen Wert, den ihr verwerfen solltet.
Der value
auf der Achse beginnen mit -1
auf der 0°-Position. Pro 90°-Drehung erhöht sich der value
um 0.5715
, bis er auf 315° bei +1
endet.
Um aus diesem analogen Wert einen von acht Zuständen zu machen hilft eine kleine Funktion:
\n// 7 0 1\n// 6 ∗ 2\n// 5 4 3\nconst axisToEightWay = function(axis) {\n return (axis > 1) \n ? undefined // centered state\n : Math.round((axis + 1) * 7/2); // 0 means "top", 4 means "bottom"\n}\n
\nDiese acht Zustände können auch in zwei Achsen umgerechnet werden:
\nconst eightWaytoAxes = function(value) {\n if (value === undefined) {\n return [0,0];\n }\n\n let x = value % 4 ? 1 : 0;\n x *= value > 4 ? -1 : 1;\n \n let y = ((value +2) % 4) ? 1 : 0;\n y *= value < 2 || value > 6 ? -1 : 1;\n \n return [x,y];\n}\n
\nWenn euch vier Zustände reichen, braucht es eine etwas andere Formel:
\n// 0\n// 3 + 1\n// 2 \nconst axisToFourWay = function(axis) {\n return (axis > 1) \n ? undefined // centered state\n : Math.round((axis + 1) * 7/4) % 4; // 0 means "top", 2 means "bottom"\n}\n
\nDas Auslesen eines Joysticks hat jede Menge Fallstricke. Aus gutem Grund spekulieren Spielekonsolen und viele Spiele auf das Standard-Gamepad, um sich Ärger beim Mapping zu ersparen.
\nAndersherum stellt sich auch im Browser die Herausforderung, dass wir nicht bei jedem angeschlossenen Controller das genaue Mapping vorher kennen können. So oder so werdet ihr nicht darum herum kommen, dem Spieler einen Konfigurationsdialog anzubieten, mit dem die Zuordnung von Spielfunktion zu Controller-Button/Achse festgelegt werden kann. Bei der Speicherung des Mappings kann die Web Storage API weiterhelfen, damit der Aufwand nur einmalig anfällt.
\nEin Mapping könnte jedenfalls wie folgt aussehen:
\nconst mapping = {\n buttons: {\n radio: 2,\n flapsUp: 4,\n flapsDown: 6,\n trimLeft: 7,\n trimRight: 5\n },\n axes: {\n roll: 0,\n pitch: 1,\n throttle: 3,\n yaw: 4,\n freeLook: 9\n }\n}\n
\nEin Zugriff auf die konfigurierten Achsen ist damit auch deutlich übersichtlicher:
\nlet roll = 0;\nlet pitch = 0;\n\nfunction gameLoop () {\n const gamepad = navigator.getGamepads()[0]; // this is just a stub ;)\n\n // ...\n\n roll += gamepad.axes[mapping.axes.roll] * elapsedTime;\n pitch += gamepad.axes[mapping.axes.pitch] * elapsedTime;\n\n window.requestAnimationFrame(gameLoop); // Restart loop\n}\n
\nWichtig für die Konfiguration von Achsen ist es, die genaue Art der Achse zu kennen:
\nIn den meisten Fällen werdet ihr nur wenige absolute Achsen in einem Spiel benötigen. Beim Mapping ist es aber sinnvoll, für einen über eine absolute Achse einstellbaren Wert auch ein Mapping für relative Achsen anzubieten. Als Beispiel kann die Schubkontrolle dienen (die in einer Simulation ihren Wert 1:1 auf die simulierte Schubkontrolle überträgt), die auch durch einen kleinen Stick gesteuert werden könnte (was in der Simulation die simulierte Schubkontrolle nach oben oder unten schiebt).
\nNicht zuletzt müsst ihr beim Mapping berücksichtigen, dass die Anzahl der vorhandenen Achsen und Knöpfe mit dem Eingabegerät des Nutzers nicht abdeckbar ist. Hier bieten sich sogenannte Modifier an: Ähnlich wie die Shift-, Alt- und Strg-Taste die Bedeutung einer Taste auf der Tastatur verändern kann, kann ein gedrückt gehaltener Button die Bedeutung der Achsen und Buttons eines Controllers verändern.
\nDas Mapping-Objekt für Modifier sowie der Konfigurationsdialog sind natürlich deutlich komplexer, können aber selbst Retro-Controller mit wenigen Achsen mit einer erstaunlichen Vielzahl von Funktionen belegen.
\nGame Controller im Browser mittels der Gamepad
-API anzusprechen ist nicht kompliziert – die Interpretation der Werte und Berücksichtigung der verschiedenen Controller hingegen schon. Ein robustes Handling im JavaScript erspart euch und euren Nutzern unschöne Erlebnisse.
Die einzelnen Skripte aus diesem Artikel gibt es übrigens als kompletten GamepadHelper
zum Download. Diese Bibliothek kann eine gute Grundlage für ein eigenes Mapping sein.
SVGs können nicht nur mit Grafikprogrammen wie Adobe Illustrator oder Inkscape erstellt werden, sondern auch mit einem einfachen Texteditor – oder programmatisch mit jeder Skriptsprache, die man so zur Hand hat. Damit die SVG-Grafik aber in jedem Umfeld funktioniert, sind ein paar Besonderheiten zu beachten:
\n\n<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<svg version="1.1" viewBox="0 0 100 100" width="210mm" height="210mm" xmlns="http://www.w3.org/2000/svg">\n <title><!-- Document Title goes here --></title>\n <style>\n <!-- CSS goes here -->\n </style>\n <defs>\n <!-- Optional: Pastable objects -->\n </defs>\n <!-- Here comes the actual SVG -->\n</svg>\n
\nDie Grundstruktur eines SVG-Dokuments kann man sich ähnlich wie die eines HTML-Dokuments vorstellen:
\nZuerst kommen Meta-Angaben. Dazu gehört der Dokumenten-Titel, CSS und gegebenenfalls noch <defs>
, falls ihr Grafikkomponenten mehrfach verwenden wollt.
Erst danach sollte die eigentliche Ausgabe von Grafikelementen beginnen.
\nIn SVG hat das <title>
-Tag mehrere Bedeutungen:
<title>
-Tag HTML verwendet, um den Namen eines Dokuments festzulegen.title
-Attributs. Beim Überfahren mit der Maus wird zum Beispiel eine kleiner Tooltip angezeigt.…funktioniert wie in HTML. Dementsprechend sollte man versuchen, nicht jedem Element einzeln mittels stroke
- und fill
-Attributen Styles zuzuordnen, sondern mit CSS das Layout globaler zu definieren.
Tatsächlich gibt es aber ein paar Besonderheiten bei der Verwendung von CSS in SVGs:
\nRGBA-Farben haben die Fähigkeit, darunterliegende Teile einer Grafik durchscheinen zu lassen. Der Alpha-Kanal bestimmt dabei die Durchlässigkeit bzw. Opazität. Leider werden RGBA-Farben nicht in jedem Fall korrekt ausgegeben. Die meisten Browser können es, dafür kaum ein Grafikprogramm oder Bildbetrachter.
\nStattdessen gibt es verschiedene Formen von Opazität bzw. Transparenz.
\nopacity
macht ein gesamtes Element,fill-opacity
nur die Füllung,stroke-opacity
nur die Umrandung opak bzw. transparent.In CSS sieht das wie folgt aus:
\n.black-a-fifty {\n /* fill: rgba(0,0,0,0.5); --- will not always work */\n fill: #000000;\n fill-opacity: 0.5;\n}\n
\nFans von CSS Custom Properties haben bei SVG ebenfalls kein Glück: Die meisten Grafikprogramme können diese Variablen nicht verarbeiten. Stattdessen gibt es aber einen kleinen Ausweg mit dem CSS-Wert currentColor
:
Der Wert für currentColor
wird mit der in SVG sonst irrelevanten CSS-Eigenschaft color
gesetzt (die im Gegensatz zu der gleichnamigen Eigenschaft in HTML nicht aktiv ELemente einfärbt – dafür ist in SVG ja fill
und stroke
da). Der Aufruf von currentColor
wird dann mit dem zuletzt via color
gesetztem Wert gefüllt – ganz wie bei einer CSS Custom Property.
.black {\n color: black; /* sets currentColor to `black` */\n}\n\n.red {\n color: red; /* sets currentColor `red` */\n}\n\n/* These elements will be colored black or red, depending on class */\n\nrect {\n stroke: currentColor;\n fill: currentColor;\n}\n\ntext {\n fill: currentColor;\n}\n
\nIn SVG kann in Theorie nur einzeiliger Text gesetzt werden, beziehungsweise muss jede neue Zeile neu positioniert werden. Mit einem kleinen Trick kann man aber mehrzeiligen Text erstellen, indem jede Zeile in ein <tspan>
-Element gehüllt und diese relativ unter der vorhergehenden Zeile positioniert wird:
<text>\n <tspan x="0">Line 1</tspan>\n <tspan x="0" dy="1.1em">Line 2</tspan>\n <tspan x="0" dy="1.1em">Line 3</tspan>\n <tspan x="0" dy="1.1em">Line 4</tspan>\n</text>\n
\nWichtig ist hier die Definition des Zeilenabstands – diese muss in jedem dy
-Attribut wiederholt werden.
Wenn ihr Links in eurem SVG verwenden wollt, gibt es zwei Wege, wobei je nach Ausgabemedium nur einer von beiden funktioniert… oder auch gar keiner.
\nDie veraltete, aber noch weit verbreitete Methode sind xlink:href
-Attribute, die die Ziel-URL an einem Element festlegen. Damit diese funktionieren, muss zuerst an dem <svg>
-Knoten ein neuer Namespace deklariert werden:
<svg … xmlns:xlink="http://www.w3.org/1999/xlink">\n
\nSobald dieser Namespace bekannt ist, kann das Attribut verwendet werden.
\nIn SVG2 wird stattdessen das aus HTML bekannte <a>
-Tag mit href
-Attribut verwendet.
Für eine maximale Kompatibilität kann man beide Techniken miteinander kombinieren:
\n<a xlink:href="https://www.example.com" href="https://www.example.com">…</a>\n
\nÜbrigens: Die Links für <defs>
und <use>
werden mit dem selben xlink:href
- beziehungsweise href
-Attribut gebaut.
Im Standard sind SVG-Linien eine scharfkantige Sache. Zum Glück gibt es CSS-Eigenschaften, die sowohl die Enden mit stroke-linecap
als auch die Ecken mit stroke-linejoin
abrunden können.
.rounded-line {\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n
\nWenn euer SVG-Dokument auch in Inkscape bearbeitbar sein soll, könnt ihr mit einem zusätzlichen XML-Namensraum kleine Helferlein im Dokument hinterlassen:
\n<svg … xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">\n
\nJetzt steht euch die Möglichkeit zur Verfügung, die aus Inkscape bekannten Layer in eurem SVG zu verwenden. Dazu müsst ihr eine beliebige Gruppe nur mit zusätzlichen Attributen ausstatten:
\n<g inkscape:groupmode="layer" inkscape:label="Name your layer">…</g>\n
\nWenn eure SVGs nicht nur in modernsten Browsern, sondern auch in älteren Browsern und Grafikprogrammen funktionieren soll, müssen ein paar Spielregeln eingehalten werden. Im Endeffekt ist nach kurzer Umgewöhnung das Erzeugen wunderschöner SVG-Dokumente nicht viel komplizierter als das Erstellen von HTML-Dokumenten.
", "summary": "SVGs können nicht nur mit Grafikprogrammen wie Adobe Illustrator oder Inkscape erstellt werden, sondern auch mit einem einfachen Texteditor – oder…", "date_published": "2022-05-10T19:06:51+02:00", "date_modified": "2022-05-11T08:52:28+02:00", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" } ], "banner_image": "https://cdn.3960.org/favicon-192x192.png", "language": "de-DE", "image": "https://cdn.3960.org/favicon-192x192.png", "tags": [ "SVG", "CSS", "Programmierung", "Webdevelop" ] }, { "id": "user/posts/2021-12-17-world-smallest-php-templating-engine/index.md", "url": "https://journal.3960.org/posts/2021-12-17-world-smallest-php-templating-engine/", "title": "World's smallest PHP template engine", "content_html": "A fundamental principle of building (web) applications is to have a strict separation between logic and output – at least if your goal as a programmer is to stay sane. For outputting stuff most often there is a simple (template) language to prevent mixing too much logic into your templates.
\nFor PHP template engines like Twig or Smarty solve the outputting part. But do we really need a template engine? Is it possible that PHP (without any additional libraries) is quite sufficient to do templating?
\n\nThe main idea of template engines is:
\nif
, while
or for
are more simple to use in a template engine.A perfect example for this philosophy is the template engine Mustache: It only contains a minimum of required methods in order to reduce complexity.
\nBut do we really need the overhead of a proper template engine? Or is it possible to use plain PHP for our templating needs, without adding dependencies like template engines to our project?
\nActually PHP has good capabilities to do templating – its founding idea revolves around templating, as a matter of fact.
\nIt is common practice to put all your templates into a separate folder, and append a common file extension to all template files. For example Symfony with Twig puts all templates into a directory called /templates
and use the file extension .twig
.
Actually there is nothing stopping us from using the same /templates
directory for our templates. As file extension we are using (like Twig and to keep our mental health):
Template output | \nFile extension | \n(MIME-type) | \n
---|---|---|
HTML file | \n*.html.php | \ntext/html | \n
XML file | \n*.xml.php | \ntext/xml or application/xml | \n
JSON file | \n*.json.php | \napplication/json | \n
Text file | \n*.txt.php | \ntext/plain | \n
CSV file | \n*.csv.php | \ntext/csv | \n
This list is easily extended be even more MIME-types and corresponding file extensions. Important for us is that all files end with .php
, so our code editor knows how to handle these files. This also secures the files if by mistake these files get called directly by a web server; files ending with .php
will be executed as PHP, and their PHP secrets will be compiled and not shown to a world-wide audience.
It is good custom to name your template after the controller calling it. Accordingly a HTML template for an index-controller will be index.html.php
, for an user-controller user.html.php
. The XML template for the index-controller becomes index.xml.php
.
Templates not attached to a controller (called snippets or partials) will be prefixed with a _
, e.g. _meta.html.php
. The purpose of these files will be explained later on.
We will take advantage of the fact that PHP returns every line of code not enclosed in PHP tags (<?php ?>
) unaltered back to the browser. That allows us to concentrate on building our beautiful template output:
<!DOCTYPE html>\n<html xmlns="http://www.w3.org/1999/xhtml" lang="en">\n<head>\n</head>\n<body>\n <!-- I am valid PHP -->\n</body>\n
\nThe most important task of templates is to output variables prepared by the controller. Like in every template language we open up a instruction block and execute a function for outputting a variable.
\n<!-- Bad idea -->\n<div><?php echo($title) ?></div>\n
\nPHP kindly allows for shortening the output instruction:
\n<!-- Still a bad idea -->\n<div><?= $title ?></div>\n
\nBut actually the above examples are very bad ideas, because both ignore quoting / escaping – a core function of any good template engine, because only this secures variable output against malicious intentions. Look at the above examples and imagine some HTML tags (or characters like a single <
) inside $title
– as you can see these will be embedded into the HTML output. In most cases this is undesirable, possibly breaking your site or (even worse) allowing attackers to make your site send harmful HTML to your visitor's browsers. Want to see an example?
<!-- See, a bad idea -->\n<div><?= $_GET['search'] ?></div>\n
\nBut PHP has you covered: htmlspecialchars
converts any string to safe HTML (or XML, for that matter). Characters like <
, >
or "
will be safely encoded:
<!-- Better idea -->\n<div><?= htmlspecialchars($title) ?></div>\n
\nBecause this main method of outputting variables will become tedious to write, we are introducing an alias for htmlspecialchars
– the beginning of the world's smallest PHP template engine:
// Template.php\n\n/**\n * Alias for `htmlspecialchars`\n */\nfunction html($s): string\n{\n return htmlspecialchars($s);\n}\n
\nThis function shortens our work to convert variables and will be our new main function to send variable content back to the browser:
\n<!-- Best idea -->\n<div><?= html($title) ?></div>\n
\nPHP offers even more function for quoting / escaping. There is a function for encoding query parameters used in URLs, quoting characters like #
or &
: urlencode
. These can be easily combined with our html
function:
<a href="https://www.example.com?id=<?= html(urlencode($id)) ?>">Test</a>\n
\nSometimes we also have to convert our PHP variables to JavaScript variables. Not explicitly build for that purpose but highly recommendable is a function called json_encode
. It actually converts the given variable to JSON – which is also valid JavaScript. Even better the function also converts PHP arrays and objects to JavaScript arrays and objects:
<script>\nvar data = <?= json_encode($data) ?>;\n</script>\n
\nThe most important control structure in templates are conditions, with your best friend if
to be mentioned first. But „pure“ PHP with its curly brackets used in conjunction with if
tends to make matters confusing in your template, making it hard to see where your conditional block ends:
<?php if (!empty($title)) { ?>\n <div><?= html($title) ?></div>\n<?php } ?>\n
\nDo not despair, PHP has an alternative syntax for controls structures tailored for templating tasks. Instead of starting a block via {
a simple :
is used. And instead of ending a block via }
something like end...;
will be used:
<?php if (!empty($title)): ?>\n <div><?= html($title) ?></div>\n<?php endif ?>\n
\nYour if
-conditions can use any operators and functions known to PHP. One of your most important conditions in templating will be a check if a given variable has content to output. This test is simply executed by using !empty
or isset
.
Of course there is also else
and elseif
available:
<?php if (!empty($results)): ?>\n <h4>We have found <?= html(count($results)) ?> results.</h4>\n<?php else: ?>\n <h4>Sorry, we have found no results.</h4>\n<?php endif ?>\n
\nThen there are lists and tables in your templates. With PHP loops your are able to iterate over an PHP array to output lists and tables. Lucky for you there is also an alternate syntax for looping control structures:
\n<?php if (!empty($list)): ?>\n <ul>\n <?php foreach($list as $index => $item): ?>\n <li><?= html($item) ?></li>\n <?php endforeach ?>\n </ul>\n<?php endif ?>\n
\nThis syntax does a proper job for for
, foreach
or while
:
<h4>Lottery numbers</h4>\n<ol>\n <?php for ($i = 1; $i <= 49; $i++): ?>\n <li>\n <input type="checkbox" name="lottery_<?= html($i) ?>" id="lottery_<?= html($i) ?>" value="1" />\n <label for="lottery_<?= html($i) ?>"><?= html($i) ?></label>\n </li>\n <?php endfor ?>\n</ol>\n
\nPartial templates present in multiple controllers (like a header, footer or navigation) are called… partials (doh!) or snippets. These partials are prefixed with a single _
, for example _header.xml.php
.
These partials or snippets can be embedded into other templates by PHP's very own include
instruction:
<!DOCTYPE html>\n<html xmlns="http://www.w3.org/1999/xhtml" lang="en">\n<head>\n <?php include('_meta.html.php') ?>\n</head>\n<body>\n <?php include('_header.html.php') ?>\n ...\n <?php include('_footer_.html.php') ?>\n</body>\n
\nYou never would have guessed, but PHP also handles translations! The Gettext library is included in PHP and allows to translate strings, which Gettext will look up in a dictionary you are able to build. If a translation is found, Gettext will return it to PHP.
\nMost important for translations is to tell PHP the language / locale you are using. This is done via setlocale
:
setlocale(LC_MESSAGES, 'de_DE'); // German in Germany\nsetlocale(LC_MESSAGES, 'en_GB'); // English in Great Britain\n
\nThis also switches to the correct format for numbers and dates in the given language, as well as using the correct date terms for months and weekdays.
\nThe strings you want translated are passed to the gettext
/ _
function:
<!-- translate to 'Guten Morgen!' -->\n<h4><?= html(_('Good morning!')) ?></h4>\n
\nThe required dictionaries to translate your strings can be created by yourself. The required PO- and MO-files can be created using programmes like Poedit. Poedit also offers to search your whole project for translatable strings to automatically add them to your dictionaries.
\nIf your happen to add variables to your translatable strings things can get a little hairy. This somewhat complex construction allows for a translatable string to also work with variables:
\n<div><?= html(vsprintf(_('There are %d results'), [$count])) ?></div>\n
\nSo here is our next alias to be added to our simple template engine: A shortcut for outputting translatable strings mixed with variables.
\n// Template.php\n\n/**\n * Alias for `vsprintf`, but with HTML escaping and translation\n */\nfunction _html(string $format, array $args = []): string\n{\n return htmlspecialchars($args\n ? vsprintf(_($format), $args)\n : _($format)\n );\n}\n
\n…which makes writing translatable strings mixed with variables somewhat less tedious:
\n<h4><?= _html('Good morning!') ?></h4>\n<div><?= _html('There are %d results', [$count]) ?></div>\n
\nIt is very helpful for developers as well as template builders alike to have a look into complex variable constructs for debugging purposes – think of big objects or arrays to traverse. PHP offers print_r
and var_dump
/ var_export
for this purpose. These functions are helpful, but not in HTML as they do not produce any line breaks, dumping all your structured data into a single line. Again our little template engine comes to the rescue with a small shortcut:
// Template.php\n\n/**\n * HTML dumper für PHP variables\n */\nfunction debug($mixed, bool $extended = false): void\n{\n echo('<pre class="debug" style="margin: 1em 0; border: 1px solid red; background: #fee; color: #000; padding: 1em;">');\n echo(htmlspecialchars($extended\n ? var_export($mixed, true)\n : print_r($mixed, true)\n ));\n echo('</pre>');\n}\n
\nThis allows for complex variables to be dumped in a readable manner:
\n<?php debug($data) ?>\n
\nWhile your at it, you might consider using console.log
for debugging output in the console of your browser.
PHP was build to output HTML. This works by PHP telling the browser that the MIME-type of the document it is sending is text/html
. But actually PHP is perfectly capable to send any other MIME-type header. The function header
allows PHP to send custom headers to your visitor's browsers, replacing default headers while your at it.
The header used to set the MIME-type is Content-Type
. So it is perfectly possible for PHP to send any conceivable MIME-type.
For XML we choose the file extension .xml.php
, for example in sitemap.xml.php
. This template does not differ very much from a standard HTML template, and uses the same quoting / encoding routines. We just have to send a different Content-Type
header and we are serving proper XML:
<?php header('Content-Type: text/xml'); ?>\n<urlset>\n <?php foreach($urls as $url): ?>\n <url>\n <loc><?= html($url) ?></loc>\n </url>\n <?php endforeach ?>\n</urlset>\n
\nOutputting JSON is also quite simple with PHP. For this MIME-type we are using the file extension .json.php
(like in feed.json.php
) and need to set the correct Content-Type
. To properly use quoting / escaping the inbuilt PHP function json_encode
is very helpful, as it converts even complex PHP variables into structured JSON output:
<?php\n header('Content-Type: application/json');\n echo(json_encode($data));\n
\nIf you like your JSON's source to look nice, there is also a parameter called JSON_PRETTY_PRINT
for json_encode
:
<?php\n header('Content-Type: application/json');\n echo(json_encode($data, JSON_PRETTY_PRINT));\n
\nActually PHP's json_encode
precisely converts PHP variable types to the matching JSON types. A PHP integer will be a JSON integer, a PHP string will be a JSON string. So note the difference between 1
and „1“
.
The examples above for fetching templates and setting the correct MIME-type can be solved with a general switch
block:
// e.g. $template = 'index';\n// e.g. $contentType = 'html';\n\nswitch ($contentType) {\n case 'xml':\n header('Content-Type: text/xml');\n break;\n case 'json':\n header('Content-Type: application/json');\n break;\n case 'txt':\n header('Content-Type: text/plain');\n break;\n case 'csv':\n header('Content-Type: text/csv');\n break;\n default:\n $contentType = 'html';\n break;\n}\n\n$templateFilename = __DIR__ . '/templates/' . $template . '.' . $contentType . '.php';\nrequire($templateFilename);\n
\nEVery PHP project needs to have a clean separation between logic and output – but not always you will need a template engine like Twig or Smarty for this separation. Common off-the-shelf PHP (with few small helpers) can be a simple, fuss-free alternative for templating.
\nSo here it is, our small manual of the most important PHP functions for templating:
\nFunction | \nAlias | \nDescription | \n
---|---|---|
htmlspecialchars | \nhtml | \nHTML escaping | \n
urlencode | \n- | \nURL escaping | \n
json_encode | \n- | \nJSON/JavaScript escaping | \n
setlocale | \n- | \nSet language / locale for translation | \n
gettext / _ | \n_html | \nOutputting translation | \n
strftime | \n- | \nOutput localized date format | \n
localeconv | \n- | \nGet number format for current locale | \n
printf | \n_html | \nOutput formatted variables in string | \n
nl2br | \n- | \nConvert line breaks to <br /> | \n
implode | \n- | \nJoins array members to a single string | \n
var_dump | \ndebug | \nDump PHP variables | \n
header | \n- | \nChange Content-Type in browser | \n
The only thing missing from PHP is template inheritance, like in Twig's template inheritance or Smarty's template inheritance. Also have a look at CSS-Trick's comparison of template engines.
\nEs gibt auch eine deutsche Version von diesem Artikel, „Die kleinste PHP-Templating-Engine der Welt“.
", "summary": "A fundamental principle of building (web) applications is to have a strict separation between logic and output – at least if your goal as a programmer is to…", "date_published": "2021-12-17T18:48:53+01:00", "date_modified": "2024-02-02T17:26:09+01:00", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" } ], "banner_image": "https://cdn.3960.org/favicon-192x192.png", "language": "en-US", "image": "https://cdn.3960.org/favicon-192x192.png", "tags": [ "PHP", "Programmierung", "Webdevelop" ] }, { "id": "user/posts/2021-12-04-print-stylesheets-fuer-svg-grafiken/index.md", "url": "https://journal.3960.org/posts/2021-12-04-print-stylesheets-fuer-svg-grafiken/", "title": "Print-Stylesheets… für SVG-Grafiken", "content_html": "Vektor-Grafiken haben für Logos, Diagramme und Symbole die fantastische Eigenschaft, bei beliebig hoher Auflösung relativ kleine Dateigrößen zu verursachen. Und hier folgen ein paar Kniffe, um SVG-Grafiken mit der Hilfe von CSS sowohl am Bildschirm als auch im Druck gut aussehen zu lassen.
\n\nSeitdem die Unterstützung von SVG-Vektor-Grafiken in jedem modernen Browser kein Problem mehr darstellt, ist der Siegeszug von SVG-Grafiken unaufhaltsam. Ob via <img>
, als CSS-Hintergrundbild, oder direkt in ein HTML-Dokument eingebettet: kleine Symbole wie komplexe Grafiken können so responsiv auf den Bildschirm gezaubert werden. Eine Einleitung in das Thema „SVG“ bei CSS-Tricks erklärt die grundsätzlichen Handgriffe.
Fast immer wird eine SVG-Grafik mit einem Vektor-Grafikprogramm (wie zum Beispiel dem kostenlosen Inkscape) erzeugt. Dabei wird oft vergessen, dass SVG-Dateien eigentlich nur aus XML bestehen, das mit Hilfe eines Text-Editors bearbeitet werden kann.
\nInteressanterweise kann innerhalb eines SVG-Dokuments auch CSS verwendet werden. Und mit CSS können wir auch Media-Queries einsetzen.
\nHöchste Zeit, den Text-Editor auszupacken und unserem SVG zusätzliches CSS überzuhelfen!
\nEin einfaches Stylesheet, um beim Druck von HTML- und SVG-Dokumenten im Browser störendes Beiwerk wie die automatisch vom Browser erzeugte Kopf- und Fußzeile loszuwerden, sieht wie folgt aus:
\n@page {\n margin: 0;\n padding: 0;\n}\n@page :footer {\n display: none\n}\n@page :header {\n display: none\n}\n
\nGenau dieses CSS lässt sich mit einem <style>
-Tag direkt in die SVG-Grafik einbauen. Dazu kann man an jeder beliebigen Stelle im Dokument ein solches Tag einfügen, und dann CSS einfügen:
<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<svg version="1.1" xmlns="http://www.w3.org/2000/svg">\n <title>…</title>\n <style>\n @page {\n margin: 0;\n padding: 0;\n }\n @page :footer {\n display: none\n }\n @page :header {\n display: none\n }\n </style>\n <!-- your svg here -->\n</svg>\n
\nNatürlich könnt ihr auch jede andere Media-Query in das Dokument einfügen. Zu beachten: Beim Re-Import in ein Grafik-Programm kann dies möglicherweise nicht mehr korrekt interpretiert werden, und eure Grafik wirkt etwas verspielt. Uns interessiert aber sowieso nur das Ergebnis im Browser.
\nUm die Idee weiterzutreiben, kann man für eine SVG-Grafik im DIN A4 eine praktische Druckvorschau mitliefern. Dazu bauen wir uns ein Dokument mit den entsprechenden Abmessungen von 210×297 Millimetern und erzeugen ein Rechteck, dass exakt dem Seitenrand folgt:
\n<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<svg width="210mm" height="297mm" viewBox="0 0 210 297" version="1.1" xmlns="http://www.w3.org/2000/svg">\n <rect x="0" y="0" width="210" height="297" />\n <!-- your svg here -->\n</svg>\n
\nDieses Rechteck ist nun sowohl auf dem Bildschirm als auch im Druck zu sehen. Interessanterweise können wir so ziemlich jedem SVG-Element mittels class
und id
Attribute mitgeben, die wir (wie aus HTML gewohnt) CSS zuweisen können.
In diesem Fall wollen wir die eigentliche Seite weiß und den nicht druckbaren Bereich grau darstellen. Nicht zuletzt zeigt eine gepunktete Linie die Blattgrenze an:
\n<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<svg width="210mm" height="297mm" viewBox="0 0 210 297" version="1.1" xmlns="http://www.w3.org/2000/svg">\n <title>…</title>\n <style>\n svg {\n margin: 1em auto;\n background-color: #ddd;\n }\n .page {\n stroke: #999;\n stroke-dasharray: 0.5, 0.5;\n fill: #fff;\n display: inline;\n }\n </style>\n <rect class="page" x="0" y="0" width="210" height="297" />\n <!-- your svg here -->\n</svg>\n
\nDamit wird unsere Kosmetik nun nicht im Druck zu Gesicht bekommen, unterscheiden wir noch zwischen Screen- und Print-Styles:
\n<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<svg width="210mm" height="297mm" viewBox="0 0 210 297" version="1.1" xmlns="http://www.w3.org/2000/svg">\n <title>…</title>\n <style>\n @page {\n margin: 0;\n padding: 0;\n }\n @page :footer {\n display: none\n }\n @page :header {\n display: none\n }\n .page {\n display: none;\n }\n @media screen {\n svg {\n margin: 1em auto;\n background-color: #ddd;\n }\n .page {\n stroke: #999;\n stroke-dasharray: 0.5, 0.5;\n fill: #fff;\n display: inline;\n }\n }\n </style>\n <rect class="page" x="0" y="0" width="210" height="297" />\n <!-- your svg here -->\n</svg>\n
\nFertig ist die SVG-Grafik mit Media Queries (in diesem Falle ein Spielbrett für Raumschiffe) – sowohl für die Ausgabe am Bildschirm als auch den Druck vorbereitet.
\nDie Kombination aus SVG und CSS kann man natürlich noch weiter treiben. Da so ziemlich jede Media-Query im Browser für SVGs funktioniert, können auch Anpassungen an den Dark Mode oder für verschiedene Monitor-Größen direkt im SVG eingebettet werden.
", "summary": "Vektor-Grafiken haben für Logos, Diagramme und Symbole die fantastische Eigenschaft, bei beliebig hoher Auflösung relativ kleine Dateigrößen zu verursachen.…", "date_published": "2021-12-04T18:23:43+01:00", "date_modified": "2021-12-04T18:23:43+01:00", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" } ], "banner_image": "https://cdn.3960.org/favicon-192x192.png", "language": "de-DE", "image": "https://cdn.3960.org/favicon-192x192.png", "tags": [ "SVG", "CSS", "Idee", "Programmierung", "Webdevelop" ] }, { "id": "user/posts/2021-08-27-merge-einen-git-branch-abgekuerzt/index.md", "url": "https://journal.3960.org/posts/2021-08-27-merge-einen-git-branch-abgekuerzt/", "title": "Merge in einen Git-Branch – abgekürzt", "content_html": "Ob man nun Gitflow, GitHub Flow oder ein selbstausgedachte Abwandlung dieser Modelle verwendet: Einige dieser Modelle benötigen einen Weg, um einen Branch in einen anderen Branch zu mergen. Dieser Handgriff ist in Git mehrteilig – und teilweise etwas nervig.
\n\nGit sieht dafür den Weg vor, in den Ziel-Branch zu wechseln (z.B. main
), und vom Quell-Branch (z.B. feature/XYZ
) mittels git merge
zu ziehen. Das ist in der Regel eine sehr gute Methode, um Fehlbedienungen zu vermeiden.
Bei vielen Git-Workflows wird nun aber davon ausgegangen, dass im Ziel-Branch nicht direkt gearbeitet werden soll. Es ist also sehr wichtig, nach Abschluss des Merges wieder in den Quell-Branch zu wechseln, um dort versehentliche Commits zu vermeiden. Die verschiedenen Branch-Wechsel bedürfen also einiger Konzentration.
\nZudem ist die Schreibarbeit für einen solchen Merge auf der Kommandozeile etwas umfangreicher, will man nichts übersehen und vor allen Dingen alle Zwischenschritte auch auf dem Repo-Server bzw. origin
bekannt machen:
$ git push\n$ git checkout main\n$ git pull\n$ git merge feature/XYZ\n$ git push\n$ git checkout feature/XYZ\n
\nTatsächlich gibt es für Gitflow die Gitflow-CLI-Tools, die genau dieses Problem lösen:
\n$ git flow feature finish feature/XYZ\n
\nDummerweise haben diese Tools den Nachteil, dass das Ziel der Operation fest verdrahtet ist – und außerdem die Installation der Tools voraussetzt. In der Regel scheitert es bei mir daran, dass in dem Projekt gar nicht Gitflow verwendet wird. 😉
\nDer Wunsch ist also: Ein leichtgewichtiges, flexibles Tool, dass sicher einen Quell-Branch in einen Ziel-Branch merged.
\nNicht zuletzt durch die beeindruckende Sammlung von Bash Aliases meines Kollegen Marty kam ich auf die Idee, für meine Wünsche ebenfalls einen Bash-Alias zu schreiben. Analog zu Git Aliases (zu denen ich meine persönliche Sammlung von Git Aliases angelegt habe) kann man mittels Bash-Aliases neue Befehle für die Bash erzeugen.
\nIm einfachsten Fall sind Bash-Aliases Aufrufe, die komplizierte Einzeiler ersetzen. Meine Idee war etwas komplexer, so dass ich stattdessen eine Bash Function verwendet habe, um meine Idee umzusetzen. Ich hatte die folgende Vorstellung:
\nSollten Merge Conflicts auftreten, würde das Programm abbrechen und Gelegenheit zur Korrektur des Konflikts geben.
\nDas finale Script (die aktuellste Version findet sich auch in meinen öffentlichen .bash_aliases
) sieht nun wie folgt aus:
# Put me in `.bash_aliases`, works like an alias.\ngit-merge-to() {\n TARGET_BRANCH=develop\n if [[ "${1}" ]]; then\n TARGET_BRANCH=${1}\n fi\n\n FEATURE_BRANCH=$(git branch | sed -n -e 's/^\\* \\(.*\\)/\\1/p')\n\n if [[ ! "${FEATURE_BRANCH}" ]]; then\n echo -e "\\e[91mERROR\\e[0m: No git branch found"\n return 4\n fi\n if [[ "${FEATURE_BRANCH}" =~ ^(main|master|develop|preview)$ ]]; then\n echo -e "\\e[91mERROR\\e[0m: Branch ${FEATURE_BRANCH} is not a mergable branch"\n git branch\n return 2\n fi\n if [[ "${FEATURE_BRANCH}" == "${TARGET_BRANCH}" ]]; then\n echo -e "\\e[91mERROR\\e[0m: Branches are identical"\n return 3\n fi\n\n echo -en "Merge branch \\e[94m${FEATURE_BRANCH}\\e[0m into \\e[94m${TARGET_BRANCH}\\e[0m? [yn] "\n read CONFIRM\n if [[ ! "${CONFIRM}" =~ ^(y|Y|yes|Yes)$ ]]; then\n echo "Cancelled"\n return 1\n fi\n\n git push\n git checkout ${TARGET_BRANCH}\n git pull\n git merge ${FEATURE_BRANCH} --no-edit\n git push\n git checkout ${FEATURE_BRANCH}\n}\n
\nSobald dieses Script in der .bash_aliases
eingetragen ist, kann man es auf dem lokalen Rechner von jedem Ort aus starten:
$ git-merge-to develop\nMerge branch feature/web-components into develop? [yn] y\n Everything up-to-date\n Switched to branch 'develop'\n Your branch is up-to-date with 'origin/develop'.\n Already up-to-date.\n Merge made by the 'recursive' strategy.\n Writing objects: 100% (6/6), 578 bytes | 578.00 KiB/s, done.\n Switched to branch 'feature/web-components'\n Your branch is up-to-date with 'origin/feature/web-components'.\n$\n
\nNetterweise funktioniert auch die Auto-Vervollständigung des Befehles mittels der Tabulatoren-Taste, so dass ein git-
und TAB den Befehl anzeigt.
Mit Accelerated Mobile Pages hat eine inzwischen nur noch lose an Google angekoppelte Stiftung einen Standard verabschiedet, der das Internet deutlich schneller machen soll – vor allen Dingen im mobilen Bereich. Die Einschränkungen im Bereich Layout (u.a. in Bezug auf CSS) sind für Entwickler aber eine gewisse Herausforderung.
\nZum Glück gibt es SASS – damit können wir ein bestehendes Layouts für eine „konventionelle“ Site pragmatisch für AMP übernehmen und anpassen, ohne ein komplett eigenständiges AMP-Layout bauen zu müssen.
\n\nDazu muss man wissen, wie AMP dafür sorgt, dass die in AMP geschriebenen Seiten schnell werden:
\n<script>
, <iframe>
).Für die Verwendung von AMP gibt es zwei Strategien:
\nWenn wir uns für die Strategie „AMP-Duplikat“ entscheiden, haben wir auf einmal zwei Template-Sets (reguläres HTML und AMP), und deswegen auch zwei verschiedene Layouts. Natürlich können wir versuchen, auf beiden Seiten auch das selbe Layout zu verwenden – aber wie schaffen wir es, die Größenbeschränkung von AMP einzuhalten, und für die eigenen AMP-Tags eigene Styles zu schreiben, ohne zwei verschiedene CSS-Dateien schreiben zu müssen?
\nHier kommt SASS ins Spiel: Mit diesem CSS-Preprocessor können wir nicht nur eine CSS-Datei erzeugen – wir können auch zwei CSS-Dateien erzeugen. Dabei machen wir uns zu nutze, dass SASS jede .scss
-Datei ohne _
am Anfang des Dateinamens in eine CSS-Datei kompiliert.
Unser Setup ist also:
\nstyles.scss
enthält das Layout für die regulären HTML-Seiten, erzeugt styles.css
amp.scss
enthält das Layout für die AMP-Seiten, erzeugt amp.css
SASS bietet darüber hinaus an, mittels @import
den Inhalt anderer SASS-Dateien zu inkludieren. Das machen wir uns im Falle der amp.scss
zu nutze:
// amp.scss\n\n@import "styles";\n
\nJetzt wird SASS die amp.scss
zu einer amp.css
kompilieren, die das selbe Ergebnis hat wie styles.scss
bzw. styles.css
.
Ein Konzept, mit dem jeder CSS-Entwickler vertraut ist, sind Media Queries: Als CSS-Entwickler sperren wir CSS-Regeln in diese Queries ein, um je nach Ausgabegerät bzw. -kanal ein unterschiedliches Layout zu erzwingen.
\nGenau so denken wir nun AMP-CSS: Wir klammern in unserer SASS-Datei styles.scss
nun Blöcke ein, die nicht (oder nur) in der amp.css
auftauchen sollen. Dazu schreiben wir uns zwei kleine Mixins:
// styles.scss\n\n$amp: false !default;\n\n// Only output this block in AMP pages.\n@mixin amp() {\n @if($amp) {\n @content;\n }\n}\n\n// Only output this block in non-AMP pages.\n@mixin no-amp() {\n @if(not($amp)) {\n @content;\n }\n}\n
\nDiese beiden Mixins reagieren auf eine Variable namens $amp
. Im oberen Mixin wird der an das Mixin übergebene Inhalt nur ausgegeben, wenn $amp: true
ist – im unteren Beispiel wird er nur ausgeben, wenn $amp: false
ist.
Davor definieren wir die Variable $amp
. Wichtig ist hier die Verwendung von !default
. Damit verraten wir SASS, dass der Wert dieser Variable nur false
sein soll, wenn $amp
nicht bereits vorher gesetzt wurde.
Übrigens können wir die Mixins auch in eine eigene Datei auslagern (zum Beispiel _mixins.scss
), das Prinzip bleibt aber das gleiche.
Unsere styles.scss
soll nun alle AMP-Regeln nicht ausgeben, und alle Nicht-AMP-Regeln ausgeben. Dementsprechend setzen wir $amp
zu false
.
Derweil in der amp.scss
:
// amp.scss\n\n$amp: true;\n@import "styles";\n
\nHier setzen wir also $amp
auf true
und holen uns danach die Inhalte von styles.scss
– hier wird das dort definierte $amp
überschrieben, und danach die styles.scss
von SASS ganz normal weiter kompiliert. Damit haben wir die selben Inhalte, aber die Mixins werden sich nun genau umgekehrt verhalten.
Damit können wir nun alle CSS-Regeln für unsere regulären HTML-Seiten und AMP-Seiten in der style.scss
schreiben. An den Stellen, wo eine Unterscheidung zwischen regulären und AMP-CSS notwendig wird, verwenden wir die Mixins:
// styles.scss\n\n// AMP & regular CSS\nnav {\n a {\n color: red;\n }\n}\n\n// Hide rule for AMP CSS\n@include no-amp() {\n header {\n background-color: black;\n color: white;\n }\n}\n\nfooter {\n background-color: black;\n color: white;\n // Hide partial rule for AMP CSS\n @include no-amp() {\n font-size: 0.6em;\n }\n}\n\n// Hide rule for non-AMP CSS\n@include amp() {\n amp-img {\n border: 1px solid red;\n }\n}\n
\nDamit müssen viele CSS-Regeln nur einmal definiert werden, und werden in beiden Layouts identisch ausgegeben. Dieser Artikel zum Beispiel verwendet im regulären HTML und AMP-HTML weitestgehend identisches CSS, und kann trotzdem auf die Besonderheiten und Kompressionswünsche von AMP eingehen.
\namp.css
montierenDas so produzierte amp.css
dürft ihr gemäß den Vorschriften von AMP für die Integration von CSS nicht als separates Stylesheet laden. Stattdessen erreicht AMP seine hohe Geschwindigkeit durch die Tatsache, dass das gesamte AMP-CSS in die HTML-Seite eingebunden werden muss. Hier müssen wir also nur eine kleine Template-Anpassung in den AMP-Templates durchführen, die den Inhalt der amp.css
im AMP-Tag <style amp-custom>
am Beginn einer jeden AMP-Seite montiert.
<!doctype html>\n<head>\n …\n <style amp-custom>\n /* Content of amp.css goes here. */\n </style>\n …\n</head>\n
\nÜbrigens empfiehlt es sich spätestens hier, alle überflüssigen Leerzeichen aus der amp.css
zu entfernen, um weiter Platz zu sparen. Eine Ersetzung mit einem regulären Ausdruck (wie z.B. /\\n\\s+/
) lässt hier weiter die Luft raus, oder ihr setzt für den SASS-Compiler den Schalter compressed
.
Die Trennung von Logik und Ausgabe in einer (Web-)Anwendung ist eines der fundamentalen Prinzipien, um als Programmierer seine geistige Gesundheit zu behalten. Für die Ausgabe wird dabei eine zumeist einfache (Template-)Sprache verwendet, die verhindern soll, dass zu viel Logik in die Ausgabe wandert.
\nIn PHP wird dies in der Regel von Template-Engines wie Twig oder Smarty gelöst. Aber warum benötigen wir eigentlich eine Template-Engine? Könnte es sein, dass PHP ohne weitere Zusätze auch eine ganz passable Lösung für Templating parat hält?
\n\nDie Hauptidee hinter den meisten Template-Engines ist:
\nif
, while
und for
.Ein ausgezeichnetes Beispiel für die Philosophie von Template-Engines ist Mustache, das ein absolutes Minimum von Methoden anbietet, um Komplexität zu verhindern.
\nAber brauchen wir in PHP den Overhead einer Template-Engine wirklich? Oder können wir nicht mit ein bisschen Disziplin direkt mit PHP alle unsere Template-Bedürfnisse stillen – ohne uns in Abhängigkeit zu einer weiteren Software-Bibliothek zu begeben?
\nTatsächlich bietet PHP (auch aufgrund seiner Genese) sehr gute Möglichkeiten, Templating damit zu betreiben.
\nTemplate-Engines legen ihre Templates in der Regel in einen eigenen Ordner mit einer eigenen Dateiendung ab. In der Kombination Symfony mit Twig liegen Templates im Projekt in einem Ordner /templates
und haben die Dateiendung .twig
.
Tatsächlich hindert uns nichts daran, unsere PHP-Templates ebenfalls in einem /templates
-Verzeichnis abzulegen. Als Datei-Endung verwenden wir dabei (analog zu Twig und zur Erhaltung der geistigen Gesundheit):
Template-Ausgabe | \nDatei-Endung | \n(MIME-Type) | \n
---|---|---|
HTML-Dateien | \n*.html.php | \ntext/html | \n
XML-Dateien | \n*.xml.php | \ntext/xml oder application/xml | \n
JSON-Dateien | \n*.json.php | \napplication/json | \n
Text-Dateien | \n*.txt.php | \ntext/plain | \n
CSV-Dateien | \n*.csv.php | \ntext/csv | \n
Diese Liste lässt sich natürlich beliebig um neue Dateitypen erweitern. Wichtig ist nur, dass die Dateien auf PHP enden, damit sie einerseits von einem Code-Editor als PHP-Dateien erkannt werden, andererseits beim irrtümlichen Aufruf über einen Webserver auch als PHP ausgeführt werden, und nicht irgendwelche PHP-Geheimnisse verraten.
\nNach alter Sitte benennen wir das Template nach dem Controller. So wird das HTML-Template für den Index-Controller index.html.php
, für den User-Controller user.html.php
benannt. Das XML-Template für den Index-Controller ist dann entsprechend index.xml.php
.
Templates, die keinen Controller zugeordnet werden können (sogenannte Snippets oder Partials) beginnen mit einem _
, also zum Beispiel _meta.html.php
. Was es mit diesen Dateien auf sich hat klären wir weiter unten.
Wir nutzen die Fähigkeit von PHP, dass jede Anweisung, die nicht in PHP-Tags (<?php ?>
) steht, nicht als PHP verstanden wird, sondern direkt an den Browser zurückgegeben wird. Damit können wir innerhalb unserer Template-Dateien uns tatsächlich auf die Ausgabe konzentrieren:
<!DOCTYPE html>\n<html xmlns="http://www.w3.org/1999/xhtml" lang="en">\n<head>\n</head>\n<body>\n <!-- I am valid PHP -->\n</body>\n
\nDie wichtigste Funktion eines Templates ist die Ausgabe von Variablen, die im Controller vorbereitet wurden. Dazu öffnen wir (wie in jeder Template-Sprache) einen Kommandoblock, und führen darin das Kommando zum Ausgeben von Variablen aus.
\n<!-- Bad idea -->\n<div><?php echo($title) ?></div>\n
\nNetterweise gibt es eine PHP-Tag, das diese Ausgabe nochmals verkürzt:
\n<!-- Still a bad idea -->\n<div><?= $title ?></div>\n
\nTatsächlich sind aber beide Wege eine ganz schlechte Idee, weil wir hier sträflich das Quoting / Escaping vernachlässigt haben – eine Kernfunktionalität einer jeden Template-Engine und ein Garant für die Sicherheit unserer Web-Applikation. Im obigen Beispiel wäre es möglich, dass in $title
HTML steckt, was direkt wieder auf der Seite ausgegeben werden kann. In den wenigsten Fällen ist dies erwünscht und setzt voraus, dass man dem Variablen-Inhalt jederzeit vertrauen kann. Beispiel gefällig?
<!-- See, a bad idea -->\n<div><?= $_GET['search'] ?></div>\n
\nPraktischerweise liefert PHP die Funktion htmlspecialchars
mit, die einen String für die Ausgabe in sicherem HTML umwandelt. So werden Zeichen wie <
, >
und "
sicher umgewandelt:
<!-- Better idea -->\n<div><?= htmlspecialchars($title) ?></div>\n
\nDa diese Art der Ausgabe unsere Hauptmethode sein soll, die Schreibweise aber etwas sperrig ist, bauen wir uns einen Alias für htmlspecialchars
. Der Beginn der kleinsten PHP-Template-Engine der Welt:
// Template.php\n\n/**\n * Alias for `htmlspecialchars`\n */\nfunction html($s): string\n{\n return htmlspecialchars($s);\n}\n
\nDiese Funktion kürzt uns die Schreibarbeit ab, um eine Variable sicher auszugeben, und ist ab sofort unsere Hauptfunktion zur Ausgabe von Variablen:
\n<!-- Best idea -->\n<div><?= html($title) ?></div>\n
\nPHP bietet aber noch mehr Funktion für Quoting / Escaping. Unter anderem müssen wir bei der Ausgabe von Query-Parametern in URLs Zeichen wie #
und &
umwandeln. Dafür hat PHP die Funktion urlencode
parat – in Kombination mit unserer html
-Funktion unschlagbar:
<a href="https://www.example.com?id=<?= html(urlencode($id)) ?>">Test</a>\n
\nDann und wann müssen wir auch PHP-Variablen in JavaScript-Variablen umwandeln. Hier bietet sich die (etwas artfremde) Funktion json_encode
an, die PHP-Variablen als JSON ausgibt. Damit wird die Variable nicht nur in korrekte Anführungszeichen gesetzt (die wir wiederum nicht HTML-enkodieren dürfen), sondern erlaubt sogar die Ausgabe von komplexen Variablen wie Arrays:
<script>\nvar data = <?= json_encode($data) ?>;\n</script>\n
\nDie wichtigsten Kontrollstrukturen für das Templating sind Bedingungen. Hier ist das if
der wichtigste Ansprechpartner. „Echtes“ PHP mit seinen geschweiften Klammern ist dabei aber eher unübersichtlich, da zum Beispiel bei Schachtelungen unklar sein kann, welche schließende Klammer für welchen Block zuständig ist:
<?php if (!empty($title)) { ?>\n <div><?= html($title) ?></div>\n<?php } ?>\n
\nPraktischerweise kennt PHP auch eine alternative Syntax für Kontrollstrukturen, bei dem jeder Block hinter einem Kommando nicht mit einem {
, sondern mit einem :
beginnt – und statt mit einem }
mit einem end...;
beendet wird.
<?php if (!empty($title)): ?>\n <div><?= html($title) ?></div>\n<?php endif ?>\n
\nIn den if
-Bedingungen kann man dabei alle Operatoren und Funktionen verwenden, die PHP so kennt. Wichtig ist (wie immer), dass vor dem Verwenden einer Variable getestet wird, ob die Variable überhaupt definiert wird, was mit !empty
oder isset
geschehen kann.
Darüber hinaus stehen natürlich auch else
und elseif
zur Verfügung:
<?php if (!empty($results)): ?>\n <h4>Wir haben <?= html(count($results)) ?> Ergebnisse gefunden.</h4>\n<?php else: ?>\n <h4>Wir haben leider keine Ergebnisse gefunden.</h4>\n<?php endif ?>\n
\nDie Ausgabe von Listen und Tabellen ist ebenfalls eine wichtige Funktion von Templates. Mittels Schleifen kann man zum Beispiel über vorher definierte Arrays iterieren – ebenfalls mit der alternative Syntax für Kontrollstrukturen:
\n<?php if (!empty($list)): ?>\n <ul>\n <?php foreach($list as $index => $item): ?>\n <li><?= html($item) ?></li>\n <?php endforeach ?>\n </ul>\n<?php endif ?>\n
\nDabei funktionieren for
, foreach
und while
ganz wunderbar:
<h4>Lottozahlen</h4>\n<ol>\n <?php for ($i = 1; $i <= 49; $i++): ?>\n <li>\n <input type="checkbox" name="lotto_<?= html($i) ?>" id="lotto_<?= html($i) ?>" value="1" />\n <label for="lotto_<?= html($i) ?>"><?= html($i) ?></label>\n </li>\n <?php endfor ?>\n</ol>\n
\nUm Template-Teile wie zum Beispiel einen Header, Footer oder Navigation in ein Template einzufügen, fügen wir dem Dateinamen des Template-Teils einfach einen führenden _
hinzu, wie zum Beispiel _header.xml.php
.
Ein Template-Teil (auch als Snippet oder Partial bekannt) kann mit dem PHP eigenen include
ins ein anderes Template eingebunden werden:
<!DOCTYPE html>\n<html xmlns="http://www.w3.org/1999/xhtml" lang="en">\n<head>\n <?php include('_meta.html.php') ?>\n</head>\n<body>\n <?php include('_header.html.php') ?>\n ...\n <?php include('_footer_.html.php') ?>\n</body>\n
\nSogar Übersetzungen von Templates beherrscht PHP! Die in PHP vorhandene Gettext-Bibliothek erlaubt es, einen String zu übergeben, für den Gettext in einem beigefügten Wörterbuch nach der korrekten Übersetzung sucht, und diesen zurückgibt.
\nWichtig dafür ist das Setzen der Locale in PHP mittels setlocale
:
setlocale(LC_MESSAGES, 'de_DE');\n
\nDie korrekte Bedienung dieser Funktion sorgt übrigens auch für die Übersetzung von Zahlen- und Datumsformaten, wie auch Datumsbezeichnern.
\nDie eigentlich zu übersetzenden Bezeichner können an die Funktion gettext
bzw. _
übergeben werden.
<!-- translate to 'Guten Morgen!' -->\n<h4><?= html(_('Good morning!')) ?></h4>\n
\nDie dafür notwendigen Wörterbücher kann man spielend leicht selber erstellen. Die dafür notwendigen PO- und MO-Dateien kann man zum Beispiel mit Poedit erstellen. Poedit ist sogar in der Lage, die gesamte PHP-Programmierung nach noch nicht katalogisierten, übersetzbaren Strings zu durchsuchen.
\nWenn wir mit Variablen-Ersetzung arbeiten, wird die Konstruktion etwas schwieriger:
\n<div><?= html(vsprintf(_('There are %d results'), [$count])) ?></div>\n
\nDa wir so ziemlich jeden hardcodierten Text übersetzbar machen wollen, können wir unserer simplen Template-Engine eine weitere Funktion als Abkürzung hinzufügen:
\n// Template.php\n\n/**\n * Alias for `vsprintf`, but with HTML escaping and translation\n */\nfunction _html(string $format, array $args = []): string\n{\n return htmlspecialchars($args\n ? vsprintf(_($format), $args)\n : _($format)\n );\n}\n
\n…was den Aufruf deutlich einfacher macht:
\n<h4><?= _html('Good morning!') ?></h4>\n<div><?= _html('There are %d results', [$count]) ?></div>\n
\nFür Entwickler wie auch Template-Designer ist es hilfreich, sich komplexe Variablen im Frontend zu Debugging-Zwecken ausgeben zu lassen. Dafür bietet PHP die Funktionen print_r
und var_dump
/ var_export
. Dummerweise ist in HTML ein solcher Dump schwer lesbar, da die Zeilenumbrüche in HTML ignoriert werden. Eine kleine Funktion für unsere Template-Engine kann auch diesen Missstand beheben:
// Template.php\n\n/**\n * HTML dumper für PHP variables\n */\nfunction debug($mixed, bool $extended = false): void\n{\n echo('<pre class="debug" style="margin: 1em 0; border: 1px solid red; background: #fee; color: #000; padding: 1em;">');\n echo(htmlspecialchars($extended\n ? var_export($mixed, true)\n : print_r($mixed, true)\n ));\n echo('</pre>');\n}\n
\nDamit können auch komplexe Variablen bis hin zu Objekten lesbar ausgegeben werden:
\n<?php debug($data) ?>\n
\nStatt einer weithin sichtbaren Debug-Ausgabe könnte man natürlich gleich auf die Ausgabe von Debugging-Daten in der Browser-Console ausweichen.
\nPHP erzeugt von Haus aus eine HTML-Ausgabe. Im Browser funktioniert dies so, dass jedes PHP-Skript den Webserver anweist, als MIME-Type text/html
zurückzugeben. Tatsächlich kann PHP aber auch ganz andere MIME-Types ausgeben. Dazu gibt es die Funktion header
, mit der PHP den Webserver anweisen kann, bestehende Header zu ändern bzw. neue zu setzen. Der für uns interessante Header ist Content-Type
.
So können wir PHP anweisen, jeden erdenklichen MIME-Type zurückzugeben.
\nFür XML verwenden wir die Template-Endung .xml.php
, also zum Beispiel sitemap.xml.php
. Diese Templates unterscheiden sich tatsächlich nicht großartig von HTML-Templates, und verwenden sogar die selben Quoting/Escaping-Funktionen. Nur der zusätzliche Content-Type
-Header muss mitgeschickt werden:
<?php header('Content-Type: text/xml'); ?>\n<urlset>\n <?php foreach($urls as $url): ?>\n <url>\n <loc><?= html($url) ?></loc>\n </url>\n <?php endforeach ?>\n</urlset>\n
\nAuch JSON kann PHP für uns ausgeben. Hierfür verwenden wir die Template-Endung .json.php
, wie zum Beispiel feed.json.php
, und senden wieder den korrekten Content-Type
. Um das Quoting/Escaping in den Griff zu bekommen, bauen wir unser JSON nicht von Hand, sondern verwenden die Funktion json_encode
, die auch strukturierte PHP-Variablen strukturiert und proper formatiert ausspuckt:
<?php\n header('Content-Type: application/json');\n echo(json_encode($data));\n
\nFür besonders schönes JSON kann man übrigens den Parameter JSON_PRETTY_PRINT
mitgeben:
<?php\n header('Content-Type: application/json');\n echo(json_encode($data, JSON_PRETTY_PRINT));\n
\nÜbrigens respektiert die Funktion json_encode
tatsächlich PHP-Typen. Damit werden PHP-Integer tatsächlich als JSON-Integer ausgegeben, und PHP-Strings als JSON-Strings. Hier muss also in PHP den Unterschied zwischen 1
und „1“
berücksichtigt werden.
Die obigen Beispiele zum Setzen des korrekten MIME-Types und Holen der Templates kann man natürlich auch allgemein lösen:
\n// e.g. $template = 'index';\n// e.g. $contentType = 'html';\n\nswitch ($contentType) {\n case 'xml':\n header('Content-Type: text/xml');\n break;\n case 'json':\n header('Content-Type: application/json');\n break;\n case 'txt':\n header('Content-Type: text/plain');\n break;\n case 'csv':\n header('Content-Type: text/csv');\n break;\n default:\n $contentType = 'html';\n break;\n}\n\n$templateFilename = __DIR__ . '/templates/' . $template . '.' . $contentType . '.php';\nrequire($templateFilename);\n
\nJedes PHP-Projekt braucht eine saubere Trennung zwischen Logik und Ausgabe – aber für diese Trennung braucht es nicht immer eine Template-Engine wie Twig oder Smarty. Auch handelsübliches PHP kann (mit wenigen, kleinen Helferlein) eine performante, wartungsarme Alternative fürs Templating sein.
\nHier nochmals eine Übersicht über die wichtigsten PHP-Funktionen für Templating:
\nFunktion | \nAlias | \nBeschreibung | \n
---|---|---|
htmlspecialchars | \nhtml | \nHTML-Escaping | \n
urlencode | \n- | \nURL-Escaping | \n
json_encode | \n- | \nJSON/JavaScript-Escaping | \n
setlocale | \n- | \nSetzen der Sprache für Übersetzungen | \n
gettext / _ | \n_html | \nAusgabe von Übersetzungen | \n
strftime | \n- | \nGibt ein lokales Datumsformat aus | \n
localeconv | \n- | \nErmittelt die lokale Ausgabeeinstellungen für Zahlen | \n
printf | \n_html | \nAusgabe von Variablen in Strings | \n
nl2br | \n- | \nKonvertiert Zeilenumbrüche in HTML-<br /> | \n
implode | \n- | \nVerbindet ein Array zu einem String | \n
var_dump | \ndebug | \nDump von PHP-Variablen | \n
header | \n- | \nAusgabe von anderen Content-Type | \n
Einzig beim Thema Vererbung von Templates sind echte Template-Engines deutlich komfortabler, wie Twigs Template-Vererbung und Smartys Template-Vererbung zeigen. Siehe dazu auch den Vergleich der Templating-Engines für PHP auf CSS-Tricks.
\nThere also is an English version of this article, „World's smallest template engine“.
", "summary": "Die Trennung von Logik und Ausgabe in einer (Web-)Anwendung ist eines der fundamentalen Prinzipien, um als Programmierer seine geistige Gesundheit zu behalten…", "date_published": "2020-08-20T18:02:28+02:00", "date_modified": "2022-02-21T18:22:25+01:00", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" } ], "banner_image": "https://cdn.3960.org/favicon-192x192.png", "language": "de-DE", "image": "https://cdn.3960.org/favicon-192x192.png", "tags": [ "PHP", "Programmierung", "Webdevelop" ] }, { "id": "user/posts/2020-07-30-sichere-passwoerter-fuer-leute-sichere-passwoerter-hassen/index.md", "url": "https://journal.3960.org/posts/2020-07-30-sichere-passwoerter-fuer-leute-sichere-passwoerter-hassen/", "title": "Sichere Passwörter für Leute, die sichere Passwörter hassen", "content_html": "Wirklich sichere Passwörter haben in der Regel den Nachteil, dass sie schwer zu merken oder schwer zu schreiben sind. Das muss aber nicht sein: mit diesem einfachen Rezept kann man die Sicherheit seiner Passwörter erhöhen, und muss sich nur ein Passwort merken.
\n\nUnd damit sind nicht Passwort-Manager gemeint. Stattdessen versteht sich dieses einfache Rezept als Anleitung für diejenigen, die auch im Jahr 2020 nur ein Passwort für alle ihre Accounts verwenden. Denkt zum Beispiel an die Passwörter für eure Oma. 😉
\nWohlgemerkt: Das folgende Rezept sollte nur verwendet werden, wenn man keine Lust hat, sich ein wirklich sicheres Passwort zuzulegen. Deswegen nenne ich das folgende, mit meiner Frau zusammen entwickelte Rezept…
\nWir beginnen einfach mal mit der Annahme, dass das Standardpasswort bisher folgendes war:
\npasswort\n
\nDas ist nachgewiesenermaßen ein schlechtes Passwort: Es ist ganze 8 Zeichen lang, steht direkt so in einem Wörterbuch, und ist (laut Kaspersky-Tool zum Testen der Passwortsicherheit) in 20 Sekunden1 geknackt. Aber mit wenigen Tricks und gleichem Merkaufwand kann dieses schlechte Passwort in ein deutlich sichereres Passwort umgewandelt werden.
\nDie Minimalanforderungen an Passwörter sind in der Regel:
\nDie Groß- und Kleinschreibung erledigen wird über einen großen Anfangsbuchstaben, was die Zeit zum Knacken schon auf 39 Sekunden1 anhebt:
\nPasswort\n
\nDa die deutsche Sprache gerne Komposita verwendet, kann man zusätzlich jedes im Passwort vorkommende Nomen einzeln mit einem Großbuchstaben versehen:
\nPassWort\n
\nDas hebt die Zeit zum Knacken schon auf 12 Minuten1 an – diese Regel ignorieren wir aber der Einfachheit halber, und machen mit anderen Rezeptbestandteilen weiter.
\nEin paar Ziffern hat jeder von uns im Kopf. In der Regel Jahreszahlen mit vier Stellen – aber warum nicht eine deutsche Postleitzahl mit fünf Stellen, oder auch die auswendig gelernte Telefonnummer eures ersten Schwarms mit acht Stellen?
\nPasswort09648\n
\nJetzt benötigen wir zum Knacken bereits 16 Tage1.
\nUnd als Sonderzeichen fügen wir zwischen dem Buchstaben- und Ziffernteil sowie am Ende ein einfaches Sonderzeichen ein.
\nPasswort-09648!\n
\nDamit ist unser neues Standardpasswort zwar nicht kompliziert… aber mit 15 Zeichen ist es nun deutlich länger und widersteht Knackversuchen für circa 4 Jahrhunderte1!
\nDas eigentlich wichtige an Passwörtern ist aber, dass für jeden Account ein eigenes Passwort verwendet wird. Andernfalls riskieren wir, dass beim Hack einer Passwort-Datenbank der Hacker danach Zugriff auf alle unsere Accounts hat.
\nDarüber hinaus besteht die Gefahr, dass der Hacker das Passwort einfach mal bei eurem E-Mail-Account ausprobiert – und dann für alle eure Accounts das Passwort über die „Passwort vergessen“-Funktion zurückgesetzt werden kann. Also benötigen wir unterschiedliche Passwörter für alle Accounts.
\nAuch hier greift Omas Passwort-Rezept – als Extra-Zutat erweitert man das Passwort um den Namen des Dienstes:
\nPasswort-09648!google\nPasswort-09648!facebook\nPasswort-09648!web.de\n
\nDamit wird aus unserem 15-Zeichen-Passwort ein Passwort mit über 20 Zeichen Länge, dass mehr als 10.000 Jahrhunderte1 standhält, und durch seine Länge verblüffenderweise sicherer ist als z.B. „&uG4ftL!
“ mit 12 Tagen Knackdauer1. Selbst ein so einfacher Vertreter wie „Klaus-123!google
“ hält immerhin 24 Jahrhunderte1.
Um die Tipparbeit zu reduzieren kann die Extra-Zutat auch nur durch drei Buchstaben ersetzt werden (mit Dank an Mathias für die Inspiration). Das können zum Beispiel die ersten drei Buchstaben des Dienstes sein, oder jeder zweite Buchstabe – was verblüffenderweise bei der bereits erreichten Länge die Sicherheit gar nicht so großartig reduziert1:
\nPasswort-09648!goo\nPasswort-09648!fac\nPasswort-09648!web\n
\nDie Extra-Zutat kann für Schreibfaule noch weiter verkürzt werden, indem man nur den Anfangsbuchstaben des Dienstes verwendet, was zumindest 8 Jahrhunderte hält1:
\nPasswort-09648!g\nPasswort-09648!f\nPasswort-09648!w\n
\n„Moment“, denkt ihr jetzt, „wenn mein Passwort in einem Account geklaut wird, kann man ja einfach erraten, wie es in einem anderen Account aussehen würde.“ Gut mitgedacht, aber gar nicht so wahrscheinlich:
\nTatsächlich speichert kein ernsthafter Dienst euer Passwort, sondern eine Art „Quersumme“ eures Passworts, ein sogenanntes Hash. Euer Passwort passt genau zu diesem Hash, aber von dem Hash kann man nicht unbedingt auf das Passwort schließen. Alleine aus diesem Grund kann euch niemand euer Passwort nachträglich zuschicken. Je länger das Passwort, desto zufälliger wird das Hash, und desto schwerer ist es, auf das ursprüngliche Passwort zurückzuschließen2.
\nPasswort | \nMD5 Hash ohne Salt | \n
---|---|
Passwort-09648! | \nb443a258958c87a19b7fe521de7a6958 | \n
Passwort-09648!g | \nda01b07dd811a00e6576fa4d83b4eee6 | \n
Passwort-09648!goo | \nfd39389348f433257dbde5361d33d757 | \n
Passwort-09648!google | \n0da72493f73589b4e1c96dbff8f7d2e1 | \n
Passwort-09648!facebook | \nc20ed3dc0acd60e05a6d668ad25758fb | \n
Passwort-09648!web.de | \n7a3d0f5304dd9bdad469c025269ca274 | \n
Ergo: Falls jemand das Hash eures Passworts klaut, wird er viel Zeit brauchen, dieses Hash in euer Passwort zurückzuverwandeln, um sich danach in eurem Namen in dem Dienst anzumelden. In der Regel macht sich aber niemand die Mühe, dieses Passwort anzuschauen um dann auszuknobeln, wie das Passwort bei anderen Diensten aussehen könnte.
\nOmas Passwort-Rezept besteht also aus den folgenden Schritten:
\n-
“.!
“.Die ganze schöne Anleitung ändert aber nichts an der Tatsache, dass man die eigenen Passwörter geheim halten muss – und jedes Gerät sicher sein muss, auf dem man ein Passwort eintippt.
\nCSS-Variablen erlauben komfortabel, Farben und andere CSS-Eigenschaften in CSS-Stylesheets zu definieren – wenn da nur nicht ältere Browser wären, die CSS-Variablen nicht unterstützen, und damit den schönen CSS-Plan zerstören.
\nAber keine Bange: SASS und CSS-Variablen sind die perfekte Kombination, um eine bombensichere Unterstützung für CSS-Variablen zu erreichen.
\n\nCSS-Variablen bzw. CSS Custom Properties erlauben eine übersichtliche, wiederverwendbare Definition von CSS-Eigenschaften. Wie schon von SASS, LESS und anderen CSS-Präprozessoren bekannt, kann eine solche Variable mehrfach verwendet werden – eine Änderung an der zentralen Definition ändert zeitgleich und zuverlässig die CSS-Ausgabe:
\n:root {\n --color-background: #ffffee;\n --color-text: #111100;\n}\n\nbody {\n color: var(--color-text);\n background-color: var(--color-background);\n}\n\narticle {\n border: 1px dotted var(--color-text);\n padding: 1em;\n}\n
\nDas ist besonders praktisch, wenn man einem Styleguide folgt, später übersichtlich Werte im CSS austauschen möchte, oder einen zentralen Ansatzpunkt für CSS-Änderung zum Beispiel durch Redakteure haben möchte.
\nGleichzeitig kann die Definition von CSS-Variablen auch in Abhängigkeit von Media Queries (@media
) definiert werden. So kann sich der Inhalt einer Variable für die Druckausgabe oder z.B. für die Ausgabe im Dark Mode ändern.
@media screen {\n :root {\n --color-background: #ffffee;\n --color-text: #111100;\n --color-link: #000899;\n --color-link-hover: #1520c7;\n }\n}\n@media (prefers-color-scheme: dark) {\n :root {\n --color-background: #333;\n --color-text: #fff;\n --color-link: #0eb9e7;\n --color-link-hover: #39cbf6;\n }\n}\n@media print {\n :root {\n --color-link: blue;\n --color-link-hover: blue;\n }\n}\n
\nDamit entfällt die Notwendigkeit, Variablen durch SASS oder LESS definieren zu müssen – und damit in bestimmten Projekten die Notwendigkeit, überhaupt SASS oder LESS zu verwenden…
\n…wenn da nicht (gar nicht so) alte Browser wären, die CSS-Variablen nicht interpretieren können. So sind alle Versionen des Microsoft Internet Explorers, sowie ältere Versionen von Microsoft Edge und Apple Safari nicht in der Lage, CSS-Variablen zu verarbeiten.
\nNetterweise gibt es aber einen Weg, auch diesen Browsern grundsätzliche Wünsche mitzuteilen:
\nbody {\n color: #111100;\n color: var(--color-text);\n background-color: #ffffee;\n background-color: var(--color-background);\n}\n
\nCSS ist so aufgebaut, dass bei einer mehrfachen Zuweisung von CSS-Eigenschaften die letzte Eigenschaft verwendet wird, die erfolgreich vom Browser interpretiert werden konnte.
\nIn unserem obigen Beispiel werden ältere Browser nicht in der Lage sein, eine korrekte Ersetzung für Print und Dark Mode durchzuführen – aber wenigstens grundsätzlich sieht die Seite gut aus. Im Sinne von progressive enhancement (oder graceful degradation) hat man mit dieser Lösung schon einen passablen Job gemacht.
\nDieses Arbeiten mit Fallbacks hat aber zwei Nachteile:
\nNetterweise bieten sich SASS-Variablen an, um CSS-Variablen zu definieren. Unser erster Schritt: Wir definieren die Werte, die wir später als CSS-Variablen verwenden wollen. Dazu benutzen wir aber nicht reguläre SASS-Variablen, sondern stattdessen SASS-Maps. Diese praktischen Konstrukte erlauben die Definition einer Liste von Schlüssel-Wert-Paaren:
\n$cssVariablesScreen: (\n 'color-background': #ffffee,\n 'color-text': #111100,\n 'color-link': #000899,\n 'color-link-hover': #1520c7\n);\n
\n…die in SASS nur wenig komplizierter als eine SASS-Variable ausgegeben werden können:
\nbody {\n color: map-get($cssVariables, color-background);\n}\n
\nWir aber interessieren uns für die Ausgabe der SASS-Variablen als CSS-Variablen. Hier hilft uns die SASS-Map, die in einer Schleife (@each
) alle Schlüssel-Wert-Kombinationen ausgeben kann:
:root {\n @each $name, $value in $cssVariablesScreen {\n --#{$name}: #{$value};\n }\n}\n
\nAuf diese Weise können wir für jede Media Query eine SASS-Map erzeugen, und den dazu passenden Block an CSS-Variablen erzeugen lassen:
\n$cssVariablesDarkMode: (\n 'color-background': #333,\n 'color-text': #fff,\n 'color-link': #0eb9e7,\n 'color-link-hover': #39cbf6\n);\n$cssVariablesPrint: (\n 'color-link': blue,\n 'color-link-hover': blue\n);\n\n:root {\n @media screen {\n @each $name, $value in $cssVariablesScreen {\n --#{$name}: #{$value};\n }\n }\n @media (prefers-color-scheme: dark) {\n @each $name, $value in $cssVariablesDarkMode {\n --#{$name}: #{$value};\n }\n }\n @media print {\n @each $name, $value in $cssVariablesPrint {\n --#{$name}: #{$value};\n }\n }\n}\n
\nUm uns unnötige Wiederholungen von SASS-Code zu vermeiden, verwenden wir SASS-Mixins (@mixin
). So kann das Durchlaufen von SASS-Maps zum Erzeugen von CSS-Variablen wunderbar in ein solches Mixin ausgelagert werden:
@mixin make-css-variables($cssVariables) {\n @each $name, $value in $cssVariables {\n --#{$name}: #{$value};\n }\n}\n\n:root {\n @media screen {\n @include make-css-variables($cssVariablesScreen)\n }\n @media (prefers-color-scheme: dark) {\n @include make-css-variables($cssVariablesDarkMode)\n }\n @media print {\n @include make-css-variables($cssVariablesPrint)\n }\n}\n
\nDamit haben wir zumindest schonmal die Ausgabe der CSS-Variablen – haben aber leider noch nichts gewonnen, was die Fallbacks für ältere Browser angeht.
\nTatsächlich haben wir aber schon alle Teile herumliegen, die folgende Fallbacks möglich machen:
\nbody {\n color: #111100;\n color: var(--color-text);\n background-color: #ffffee;\n background-color: var(--color-background);\n}\n
\nTheoretisch könnten wir uns der Aufgabe in SASS wie folgt nähern:
\nbody {\n color: map-get($cssVariables, color-text);\n color: var(--color-text);\n background-color: map-get($cssVariables, color-background);\n background-color: var(--color-background);\n}\n
\nBei einem genauen Blick erkennt man hier aber, das die beiden Zeilen direkt zusammenhängen, und ganz wunderbar durch das folgende Mixin abgebildet werden können:
\n@mixin variable-fallback($property, $name) {\n #{$property}: map-get($cssVariablesScreen, $name);\n #{$property}: var(--#{$name});\n}\n\nbody {\n @include variable-fallback(color, color-text);\n @include variable-fallback(background-color, color-background);\n}\n
\nMit ein bisschen zusätzlicher Fehlerbehandlung und mehr Flexibilität, welche SASS-Map für den Fallback verwendet werden soll:
\n@mixin variable-fallback($property, $name, $cssVariables: $cssVariablesScreen) {\n @if map-has-key($cssVariables, $name) {\n #{$property}: map-get($cssVariables, $name);\n } @else {\n @warn "Missing CSS variable: #{$name}"\n }\n #{$property}: var(--#{$name});\n}\n
\nCSS-Variablen mit Kompatibilität für Browser, die CSS-Variablen nicht unterstützen, kann man mit wenig Schreibarbeit via SASS lösen. Die Schritt sind:
\n// Defining SASS variables to build CSS variables\n// --------------------------------------------------------------\n\n$cssVariablesScreen: (\n 'color-background': #ffffee,\n 'color-text': #111100,\n 'color-link': #000899,\n 'color-link-hover': #1520c7\n);\n$cssVariablesDarkMode: (\n 'color-background': #333,\n 'color-text': #fff,\n 'color-link': #0eb9e7,\n 'color-link-hover': #39cbf6\n);\n$cssVariablesPrint: (\n 'color-link': blue,\n 'color-link-hover': blue\n);\n\n// Defining mixins for converting SASS variables to CSS variables\n// --------------------------------------------------------------\n\n@mixin make-css-variables($cssVariables) {\n @each $name, $value in $cssVariables {\n --#{$name}: #{$value};\n }\n}\n\n@mixin variable-fallback($property, $name, $cssVariables: $cssVariablesScreen) {\n @if map-has-key($cssVariables, $name) {\n #{$property}: map-get($cssVariables, $name);\n } @else {\n @warn "Missing CSS variable: #{$name}"\n }\n #{$property}: var(--#{$name});\n}\n\n// Convert SASS variables to CSS variables\n// --------------------------------------------------------------\n\n:root {\n @media screen {\n @include make-css-variables($cssVariablesScreen)\n }\n @media (prefers-color-scheme: dark) {\n @include make-css-variables($cssVariablesDarkMode)\n }\n @media print {\n @include make-css-variables($cssVariablesPrint)\n }\n}\n\n// Use CSS variables\n// --------------------------------------------------------------\n\nbody {\n @include variable-fallback(color, color-text);\n @include variable-fallback(background-color, color-background);\n}\n
",
"summary": "CSS-Variablen erlauben komfortabel, Farben und andere CSS-Eigenschaften in CSS-Stylesheets zu definieren – wenn da nur nicht ältere Browser wären, die CSS…",
"date_published": "2020-06-17T18:41:26+02:00",
"date_modified": "2020-06-18T11:41:37+02:00",
"author": {
"name": "Frank Boës",
"url": "mailto:info@3960.org",
"avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca"
},
"authors": [
{
"name": "Frank Boës",
"url": "mailto:info@3960.org",
"avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca"
}
],
"banner_image": "https://cdn.3960.org/favicon-192x192.png",
"language": "de-DE",
"image": "https://cdn.3960.org/favicon-192x192.png",
"tags": [
"Webdevelop",
"CSS",
"Programmierung"
]
},
{
"id": "user/posts/2020-06-15-css-variables-dark-mode/index.md",
"url": "https://journal.3960.org/posts/2020-06-15-css-variables-dark-mode/",
"title": "CSS-Variablen und Dark Mode",
"content_html": "Mit dem Dark Mode kann man in so ziemlich jedem Betriebssystem den Wunsch äußern, dass alle Darstellungen möglichst dunkel gehalten werden sollen – in der Regel mit heller Text auf dunklem Hintergrund. Interessanterweise betrifft dieser Wunsch auch Webseiten.
\n\nDer Dark Mode hat inzwischen in so ziemlich jedem Betriebssystem Einzug gehalten. So sind z.B. in Windows 10, Mac OSX, Linux, Android und auch iOS Einstellungen vorhanden, mit denen viele Applikationen sich diesem Farbwunsch anpassen.
\nInteressanterweise wird dieser Wunsch vom Betriebssystem auch auch an Browser weitergereicht. Der Browser entscheidet aber nicht selber, wie eine Seite im Dark Mode umgefärbt werden muss, sondern überlässt die Definition des Verhaltens dem jeweiligen Betreiber der Website, die gerade besucht wird.
\n@media (prefers-color-scheme: dark)
Technisch wird das über eine @media
-Query in CSS gelöst:
@media (prefers-color-scheme: dark) {\n body {\n color: white;\n background: black;\n }\n\n a {\n color: orange;\n }\n}\n
\nDabei müsst ihr als Entwickler nicht zwangsläufig euer Betriebssystem auf Dark Mode umschalten, um eure Styles zu testen. In Chrome kann man den Dark Mode kurzzeitig aktivieren, indem man im Web Inspektor (z.B. via F12 öffnen) mittels Strg+Shift+P die Kommandoleiste aktiviert, und dort „color-scheme“ (oder „dark“) eintippt. Eine Auswahl namens „Emulate CSS prefers-color-scheme: dark“ für die Aktivierung des Dark Mode im Browser erscheint.
\nMit CSS-Variablen bzw. CSS Custom Properties kann man unter anderem auch wunderschöne Farbdefinitionen hinterlegen, die je nach @media
-Query abgeändert werden können. Neben der Verwendung für Print-Styles ist die Verwendung für den Dark Mode eine gute Idee.
Anstatt in eurem CSS an jedem Element Farbcodes zu hinterlegen, die dann für jede Kombination aus Media-Query und Element neu definiert werden müssen, hinterlegt ihr an den einzufärbenden Elementen nur CSS-Variablen. Diese definiert ihr zentral, und könnt diese auch zentral umschalten:
\n@media screen {\n :root {\n --color-background: #ffffee;\n --color-text: #111100;\n --color-link: #000899;\n --color-link-hover: #1520c7;\n }\n}\n@media (prefers-color-scheme: dark) {\n :root {\n --color-background: #333;\n --color-text: #fff;\n --color-link: #0eb9e7;\n --color-link-hover: #39cbf6;\n }\n}\n\nbody {\n color: var(--color-text);\n background-color: var(--color-background);\n}\n\na {\n color: var(--color-link);\n}\n\na:hover {\n color: var(--color-link-hover);\n}\n
\nÜbrigens kann man auch SVG-Grafiken in der Seite mit diesem Trick umfärben lassen, da SVG-Grafiken ebenfalls über CSS ihre Farben verändern können:
\nsvg {\n fill: var(--color-text);\n stroke: var(--color-text);\n}\n
\nMit der obigen Methode können natürlich auch Hintergrundgrafiken ausgetauscht werden. In vielen Fällen ist das aber nicht nötig: Üblicherweise werden beim Bau von Grafiken von Websites viele Grafiken auf weiße Hintergrundfarben freigestellt. Hier sollte man prüfen, ob diese nicht besser auf einen transparenten Hintergrund (als PNG-Grafik) freigestellt werden.
", "summary": "Mit dem Dark Mode kann man in so ziemlich jedem Betriebssystem den Wunsch äußern, dass alle Darstellungen möglichst dunkel gehalten werden sollen – in der…", "date_published": "2020-06-15T18:41:26+02:00", "date_modified": "2020-06-16T09:20:21+02:00", "author": { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" }, "authors": [ { "name": "Frank Boës", "url": "mailto:info@3960.org", "avatar": "https://www.gravatar.com/avatar/71fcf51cf2ae9acdd54182d3e367ceca" } ], "banner_image": "https://journal.3960.org/posts/2020-06-15-css-variables-dark-mode/blackwhite.jpg", "language": "de-DE", "image": "https://journal.3960.org/posts/2020-06-15-css-variables-dark-mode/blackwhite.jpg", "tags": [ "Webdevelop", "CSS", "Programmierung" ] } ] }