senäh

17senäh und so…

Wordpress Logo

PHP, Server & Config, WordPress
24. Jul 2012
Kommentare: 0

Bilder hochladen und problematischen Code ersetzen

Kategorien: PHP, Server & Config, WordPress | 24. Jul 2012 | Kommentare: 0

Serie: Effizienteres Bloggen mit WordPress dank XML-RPC

Heute soll es uns darum gehen einerseits die Bilder im Blogpost automatisch hochzuladen und andererseits bestimmte Code-Segmente zu ersetzen. Beginnen wir mit dem einfachen Teil: dem Ersetzen von Code-Schnipsel-Markup in unserem HTML-Code.

Warum das Markup für Code-Schnipsel ersetzen?

WordPress mag keine Code-Beispiele. Gewagte These? Hm. Warum gibt es dann im Rich-Text-Editor kein Button für das Einfügen von Code-Schnipseln? Es gibt so viele Dev-Blogs da draußen, die diese Funktion jeden Tag benötigen. Naja, glücklicherweise sind wir darauf nicht angewiesen, wir nutzen ja Markdown. In Markdown werden Code-Beispiele durch ein einfaches Einrücken der betroffenen Zeilen erzeugt. Ihr wollt ein Beispiel? Sollt ihr haben.

Folgende Markdown-Notation…

1
2
3
4
5
6
7
8
9
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam.

    <h1>Eine <em>coole</em> Überschrift</h1>
    <div>
        <p>ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam.</p>
        <a href="#">Klick mich</a>
    </div>
   
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam.

… Erzeugt diesen HTML-Output

1
2
3
4
5
6
7
8
9
10
<p>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam.</p>

<pre><code>&lt;h1&gt;Eine &lt;em&gt;coole&lt;/em&gt; Überschrift&lt;/h1&gt;
&lt;div&gt;
    &lt;p&gt;ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam.&lt;/p&gt;
    &lt;a href=&quot;#&quot;&gt;Klick mich&lt;/a&gt;
&lt;/div&gt;
</code></pre>

<p>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam.</p>

Wie ihr hoffentlich seht, wird der Code-Block von <pre><code> ummantelt. Zum Verständnis: <code> ist semantisch korrekt, da es einen Code-Schnipsel markiert. <pre> ist gestalterisch notwendig, damit Einrückungen und Umbrüche in unserem Code-Block erhalten bleiben (pre steht für preserve). Daher die Ummantelung mit beiden Tags.

Sinnvoll ist das leider nicht. Die “bewahrende” Wirkung des <pre> wird durch das innere <code> aufgehoben. Unser Beispiel-Code würde also im Frontend so hier aussehen:

1
2
3
4
5
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam.

<h1>Eine <em>coole</em> Überschrift</h1> <div> <p>ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam.</p> <a href="#">Klick mich</a> </div>

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam.

Da uns unser Syntax-Highlighting also durch das <code> versaut wird, können wir es gleich weglassen. Und – manch einer wird es erraten haben – genau das werden wir tun.

replaceCode

Sinnvollerweise erstellen wir in unserer Klasse eine Methode namens replaceCode. Wenn ihr den vorherigen Teil der Serie aufmerksam verfolgt habt, wisst ihr noch, dass $this->_content unseren HTML-String enthält. Darin ersetzen wir einfach alle öffnenden und schließenden <pre><code>-Kombinationen durch ein <pre>:

1
2
3
4
5
public function replaceCode()
{
    $this->_content = str_replace('<pre><code>', '<pre>', $this->_content);
    $this->_content = str_replace('</code></pre>', '</pre>', $this->_content);
}

Diese Funktion rufen wir nun in unserer index.php auf, die mittlerweile wie folgt aussieht:

1
2
3
4
5
6
<?php
require_once 'inc/EnnoAutoPost.php';
$htmlString = $_SERVER['KMVAR_temp'];
$obj = new EnnoAutoPost($htmlString);
$obj->setMetadata();
$obj->replaceCode();

Das wäre das. Falls nach eurem Ermessen weitere Markup-Replacements notwendig sind, macht das Einfügen der Funktionalität an dieser Stelle Sinn.

Bild-Markup ersetzen

Zweierlei Sachen möchte ich noch ansprechen:

  1. Das Ersetzen des Markups für eingebaute Bilder sowie
  2. das Hochladen der Bilder selbst.

In Byword lässt sich ein Bild durch einfaches Drag & Drop einbinden. Die entsprechende Markdown-Syntax wird automatisch angewandt. Der HTML-Output ist ein einfaches <img>-Tag. Beispiel? Beispiel!

Eingebautes Bild in Markdown-Syntax:

1
![Titel des Bildes](file://localhost/Users/Enno/Desktop/bild-name.jpg)

Gewandelt in HTML:

1
<img src="file://localhost/Users/Enno/Desktop/bild-name.jpg" alt="Titel des Bildes" />

Lade ich das Bild in WordPress hoch und schaue mit den HTML-Output an, kommt aber so etwas bei raus:

1
2
3
4
5
6
[caption id="attachment_666" align="aligncenter" width="520"]
<a href="http://www.senaeh.de/?attachment_id=666" rel="attachment wp-att-666">
<img class="size-medium wp-image-666" title="Titel des Bildes" src="http://www.senaeh.de/wp-content/uploads/2012/07/bild-name.png" alt="Titel des Bildes" width="520" height="497" />
</a>
Titel des Bildes
[/caption]

Hier müsst ihr schauen, was davon ihr braucht. Mir reicht das Markup auch ohne rel-Attribut des Links und size-medium-Klasse des Bildes. Also:

1
2
3
4
5
[caption id="attachment_666" align="aligncenter" width="520"]
<a href="http://www.senaeh.de/?attachment_id=666"><img class="wp-image-666" title="Titel des Bildes" src="http://www.senaeh.de/wp-content/uploads/2012/07/bild-name.png" alt="Titel des Bildes" width="520" height="497" />
</a>
Titel des Bildes
[/caption]

Außerdem kann auch die generierte Breite bei euch abweichen. Schaut mal im WordPress-Backend unter EinstellungenMediathek nach. Dort könnt ihr die Größen selbst festlegen. Ich weiß auch nicht, wie ihr das handhabt mit dem ausnahmslosen Zentrieren. Hier müsst ihr wie gesagt ein bisschen selbst schauen und ggf. den Code anpassen.

Wie ihr vielleicht schon bemerkt habt, benötigen wir die URL des Bildes, d.h. wir müssen es vorher hochladen. Und hier wird es interessant.

replaceImageMarkup

Ich zeige euch einfach mal die Funktion und erkläre danach:

1
2
3
4
5
6
7
8
9
10
public function replaceImageMarkup()
{
    $pattern = '<img src="([^"]+)" alt="([^"]+)" />';
    $pattern = self::DELIMITER . $pattern . self::DELIMITER;
    $this->_content = preg_replace_callback(
        $pattern,
        "EnnoAutoPost::_replaceImages",
        $this->_content
    );
}

Wir suchen also nach allen <img>-Tags im HTML-Output und extrahieren per Regex sowohl den Dateipfad, als auch den Titel. Dabei rufen wir für jede Übereinstimmung eine private Methode namens _replaceImages() auf. Diese Methode generiert uns unser Ziel-Markup – so wie WordPress es auch erzeugen würde – und lädt die Bilder hoch.

_replaceImages

Erstellen wir also die Funktion.

1
2
3
4
5
private function _replaceImages($matches)
{
    $path = $matches[1];
    $title = $matches[2];
}

$matches stellt hierbei den von preg_replace_callback übergebenen Parameter dar. Dabei enthält $matches[0] den kompletten Übereinstimmungs-String, $matches[1] den Inhalt in der ersten Klammer in unserem Pattern (=der Bildpfad) und $matches[2] den in der zweiten (=unser Bildtitel).

Was macht diese Funktion im Einzelnen?

Prüfen, ob eine Datei existiert

1
2
3
4
if (!file_exists($path))
    $this->_displayError('checking file', "File $path does not exist!");
if (!$filestream = file_get_contents($path))
    $this->_displayError('checking file', "Could not get contents");

Ist klar, oder?

Die _displayError-Methode ist übrigens nicht weiter spektakulär:

1
2
3
4
5
6
7
8
9
10
11
private function _displayError($position, $msg = '')
{
    if (empty($msg)) {
        $code = $this->_client->getErrorCode();
        $msg = $this->_client->getErrorMessage();
    } else
        $code = '666';

    echo "Position: $position<br />";
    exit("An error occurred - $code: $msg");
}

Bild-Metadaten extrahieren

… und zwar Breite, Höhe, MIME-Type sowie Dateiname und -erweiterung.

1
2
3
4
5
6
$fileMeta = $this->_extractFilenameData($path);
$filename = $fileMeta['basename'] . $fileMeta['extension'];
$sizeData = getimagesize($path);
$type = $sizeData['mime'];
$width = $sizeData[0];
$height = $sizeData[1];

Die Funktion _extractFilenameData macht nichts weiter als aus einem String den Dateipfad, -namen und die -erweiterung zurückzugeben.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private function _extractFilenameData($path)
{
    if ($indexOfLastSlash = strrpos($path, '/')) {
        $filename = substr($path, $indexOfLastSlash + 1);
        $basepath = substr($path, 0, $indexOfLastSlash + 1);
    } else {
        $filename = $path;
        $basepath = '';
    }

    $indexOfLastDot = strrpos($filename, '.');
    $basename = substr($filename, 0, $indexOfLastDot);
    $extension = substr($filename, $indexOfLastDot);

    return array(
        'basepath' => $basepath,
        'basename' => $basename,
        'extension' => $extension
    );
}

Bild-Dimensionen berechnen

… falls die Original-Dimesion dies erfordern, sprich das Bild zu groß für die 1:1-Darstellung im Blog ist. Dazu legen wir initial erst mal 2 Variablen an, die uns genau diese Werte geben. Ich wähle hier einen Wert von 520px.

1
2
private $_imgMaxWidth = 520;
private $_imgMaxHeight = 520;

Die eigentliche Berechnung der Dimensionen sieht so aus:

1
2
3
4
5
6
7
8
9
10
11
12
$sizeAppend = "";
if ($width > $this->_imgMaxWidth && $width >= $height) {
    $ratio = $this->_imgMaxWidth / $width;
    $width = $this->_imgMaxWidth;
    $height = floor($height * $ratio);
    $sizeAppend = "-{$width}x{$height}";
} elseif ($height > $this->_imgMaxHeight && $width < $height) {
    $ratio = $this->_imgMaxHeight / $height;
    $height = $this->_imgMaxHeight;
    $width = floor($width * $ratio);
    $sizeAppend = "-{$width}x{$height}";
}

$sizeAppend ist hierbei wichtig für die Bild-URL am Ende.

Bild hochladen

Endlich mal etwas XML-RPC. Benutzernamen uns Passwort eures Blogs müsst ihr ebenso wie die Grenzwerte für Maximalhöhe und -breite in Variablen festhalten:

1
2
private $_user = 'Benutzername';
private $_pass = 'ç00l€$ P@$$wø®†';

Das eigentliche Hochladen erfolgt über die in der Variable $this->_client gespeicherte Instanz unseres XML-RPC-Clients.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$user = $this->_user;
$pass = $this->_pass;
$data = array(
    'name' => $filename,
    'type' => $type,
    'bits' => new IXR_Base64($filestream)
);

if (!$this->_client->query('wp.uploadFile', 1, $user, $pass, $data))
    $this->_displayError("uploading photo $path");

$response = $this->_client->getResponse();

$imageID = $response['id'];
$imageUrl = $response['url'];
$fileMeta = $this->_extractFilenameData($imageUrl); // necessary for building image-URL

Wichtig ist, dass die Bilddaten als base64-codierter String übergeben werden, allerdings als Instanz der Klasse IXR_Base64. Warum? Darum.

Zu Parametern und Rückgabewerten der eigentlich Abfrage ($this->_client->query('wp.uploadFile')) bitte hier schauen.

Bildtitel anpassen

Eine hochgeladene Datei wird in der WordPress-Datenbank als eigener Blogpost betrachtet. Entsprechend hat ein Bild auch einen Auszug, Inhalt und Titel. Der folgende Schritt ist nicht notwendig. Ich führe ihn trotzdem der Sauberkeit wegen durch.

1
2
3
4
5
6
7
8
$data = array(
    'post_title' => $title,
    'post_excerpt' => $title,
    'post_content' => $title
);

if (!$this->_client->query('wp.editPost', 1, $user, $pass, $imageID, $data))
    $this->_displayError("editing photo $path");

Die Doku zur wp.editPost gibt es hier.

Markup ersetzen

Schlussendlich müssen wir noch den finalen Output zurückgeben.

1
2
3
4
5
6
7
8
$titleEscaped = htmlspecialchars($title);
$srcFinal = $fileMeta['basepath'] . $fileMeta['basename'] . $sizeAppend . $fileMeta['extension'];
$output = "[caption id='attachment_$imageID' align='aligncenter' width='$width' caption='$titleEscaped']";
$output.= "<a href='$imageUrl'>";
$output.= "<img class='wp-image-$imageID' title='$titleEscaped' src='$srcFinal' alt='$titleEscaped' width='$width' height='$height' />";
$output.= "</a> {$title}[/caption]";

return $output;

Das war’s zur _replaceImages-Methode. Uff.

Das (bisherige) Ergebnis in der Übersicht

Die index.php:

1
2
3
4
5
6
7
<?php
require_once 'inc/EnnoAutoPost.php';
$htmlString = $_SERVER['KMVAR_temp'];
$obj = new EnnoAutoPost($htmlString);
$obj->setMetadata();
$obj->replaceCode();
$obj->replaceImageMarkup();

Und die EnnoAutoPost-Klasse:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
<?php
require_once 'IXR_Library.php';

class EnnoAutoPost
{
    /**
     * config
     */

    private $_user = 'Benutzername';
    private $_pass = 'ç00l€$ P@$$wø®†';
    private $_url = 'http://www.senaeh.de/xmlrpc.php';

    private $_imgMaxWidth = 520;
    private $_imgMaxHeight = 520;

    /**
     * constants
     */

    const DELIMITER = '|';

    /**
     * internals
     */

    private $_client;
    private $_title;
    private $_content;
    private $_slug;
    private $_tags;
    private $_categories;
    private $_excerpt;
    private $_postData = array();


    /**
     * Creates a client instance for XML-RPC requests and sets the post's
     * initial content.
     *
     * @param string $htmlString    Sets the post's initial content.
     */

    public function __construct($htmlString)
    {
        $this->_client = new IXR_Client($this->_url);
        $this->_content = $htmlString;
    }

    /**
     * Looks for headline and meta-Tags and sets them.
     *
     * The $data-Array includes the properties to set as keys and the patterns
     * to apply to a regular expression as values.
     */

    public function setMetadata()
    {
        $wildcard = "([^<\n]+)";
        $data = array(
            '_title' => '<h1>(.*)</h1>',
            '_slug' => "
            '_excerpt' => "

            '_tags' => "
            '_categories' => "

        );
        $this->_extractPostMetadata($data);

        // title
        if (is_null($this->_title) && $this->_title != 'TODO')
            exit("title missing");
        else
            $this->_postData['post_title'] = $this->_title;

        // slug
        if (is_null($this->_slug) && $this->_slug != 'TODO')
            exit("slug missing");
        else
            $this->_postData['post_name'] = $this->_slug;

        // excerpt
        if (is_null($this->_excerpt) && $this->_excerpt != 'TODO')
            exit("excerpt missing");
        else
            $this->_postData['post_excerpt'] = $this->_excerpt;

        // tags
        if (is_null($this->_tags) && $this->_tags != 'TODO')
            exit("tags missing");
        else {
            $taxonomyName = 'post_tag';
            $cats = explode(', ', $this->_tags);
            $this->_addTaxonomyItems($taxonomyName, $cats);
        }

        // categories
        if (is_null($this->_categories) && $this->_categories != 'TODO')
            exit("categories missing");
        else {
            $taxonomyName = 'category';
            $cats = explode(', ', $this->_categories);
            $this->_addTaxonomyItems($taxonomyName, $cats);
        }
    }

    /**
     * Finds images and adjusts the markup so the code fits to what adding
     * images via the WordPress frontend would result in.
     */

    public function replaceImageMarkup()
    {
        $pattern = '<img src="([^"]+)" alt="([^"]+)" />';
        $pattern = self::DELIMITER . $pattern . self::DELIMITER;
        $this->_content = preg_replace_callback(
            $pattern,
            "EnnoAutoPost::_replaceImages",
            $this->_content
        );
    }

    /**
     * Replaces the generated <pre><code>-combination with a single
     * <pre>-wrapper.
     */

    public function replaceCode()
    {
        $this->_content = str_replace('<pre><code>', '<pre>', $this->_content);
        $this->_content = str_replace('</code></pre>', '</pre>', $this->_content);
    }

    /**
     * @param array $matches    Contains complete match (index 0) first submatch
     *                             (image source, index 1) and second submatch
     *                             (image title, index 2)
     * @return string $output    The updated output.
     */

    private function _replaceImages($matches)
    {
        $path = $matches[1];
        $title = $matches[2];

        // read picture data
        if (!file_exists($path))
            $this->_displayError('checking file', "File $path does not exist!");
        if (!$filestream = file_get_contents($path))
            $this->_displayError('checking file', "Could not get contents");

        // set variables
        $fileMeta = $this->_extractFilenameData($path);
        $filename = $fileMeta['basename'] . $fileMeta['extension'];
        $sizeData = getimagesize($path);
        $type = $sizeData['mime'];
        $width = $sizeData[0];
        $height = $sizeData[1];

        // adjust dimensions if necessary
        $sizeAppend = "";
        if ($width > $this->_imgMaxWidth && $width >= $height) {
            $ratio = $this->_imgMaxWidth / $width;
            $width = $this->_imgMaxWidth;
            $height = floor($height * $ratio);
            $sizeAppend = "-{$width}x{$height}";
        } elseif ($height > $this->_imgMaxHeight && $width < $height) {
            $ratio = $this->_imgMaxHeight / $height;
            $height = $this->_imgMaxHeight;
            $width = floor($width * $ratio);
            $sizeAppend = "-{$width}x{$height}";
        }

        // upload picture
        $user = $this->_user;
        $pass = $this->_pass;
        $data = array(
            'name' => $filename,
            'type' => $type,
            'bits' => new IXR_Base64($filestream)
        );

        if (!$this->_client->query('wp.uploadFile', 1, $user, $pass, $data))
            $this->_displayError("uploading photo $path");

        $response = $this->_client->getResponse();

        $imageID = $response['id'];
        $imageUrl = $response['url'];
        $fileMeta = $this->_extractFilenameData($imageUrl); // necessary for building image-URL

        // add title and other data
        $data = array(
            'post_title' => $title,
            'post_excerpt' => $title,
            'post_content' => $title
        );

        if (!$this->_client->query('wp.editPost', 1, $user, $pass, $imageID, $data))
            $this->_displayError("editing photo $path");

        // prepare output
        $titleEscaped = htmlspecialchars($title);
        $srcFinal = $fileMeta['basepath'] . $fileMeta['basename'] . $sizeAppend . $fileMeta['extension'];
        $output = "[caption id='attachment_$imageID' align='aligncenter' width='$width' caption='$titleEscaped']";
        $output.= "<a href='$imageUrl'>";
        $output.= "<img class='wp-image-$imageID' title='$titleEscaped' src='$srcFinal' alt='$titleEscaped' width='$width' height='$height' />";
        $output.= "</a> {$title}[/caption]";

        return $output;
    }

    /**
     * Returns an image's file' basepath, basename and extension.
     *
     * @param string $path    The image's path.
     * @return array        Contains basepath, basename and extension.
     */

    private function _extractFilenameData($path)
    {
        if ($indexOfLastSlash = strrpos($path, '/')) {
            $filename = substr($path, $indexOfLastSlash + 1);
            $basepath = substr($path, 0, $indexOfLastSlash + 1);
        } else {
            $filename = $path;
            $basepath = '';
        }

        $indexOfLastDot = strrpos($filename, '.');
        $basename = substr($filename, 0, $indexOfLastDot);
        $extension = substr($filename, $indexOfLastDot);

        return array(
            'basepath' => $basepath,
            'basename' => $basename,
            'extension' => $extension
        );
    }

    /**
     * Saves additional info based upon a regular expression and deletes the
     * affected text lines afterwards.
     *
     * @param array $data    Contains pairs of properties to set (keys) and
     *                         patterns to apply (values).
     */

    private function _extractPostMetadata($data)
    {
        foreach ($data as $prop => $pattern) {
            $pattern = self::DELIMITER . $pattern . self::DELIMITER;
            preg_match($pattern, $this->_content, $matches);
            if (isset($matches[1])) {
                $this->$prop = $matches[1];
                $this->_content = trim(preg_replace($pattern, '', $this->_content));
            }
        }

        // clean whitespace and empty paragraphs
        $this->_content = preg_replace(
            self::DELIMITER . "<p>\n+</p>\n*" . self::DELIMITER,
            '',
            $this->_content
        );
    }

    /**
     * Adds taxonomy items as metadata.
     *
     * @param string $key
     * @param array $data
     */

    private function _addTaxonomyItems($key, $data)
    {
        if (isset($this->_postData['terms_names']))
            $this->_postData['terms_names'][$key] = $data;
        else
            $this->_postData['terms_names'] = array($key => $data);
    }

    /**
     * Displays error message and quits execution.
     *
     * @param string $position    Position where error occured.
     * @param string $msg        The message to display.
     */

    private function _displayError($position, $msg = '')
    {
        if (empty($msg)) {
            $code = $this->_client->getErrorCode();
            $msg = $this->_client->getErrorMessage();
        } else
            $code = '666';

        echo "Position: $position<br />";
        exit("An error occurred - $code: $msg");
    }
}

Zum Schluss

Falls ihr Fragen habt, immer her damit. Sicherlich ist auch hier nicht alles optimal umgesetzt. Ich bin sowas von offen für Verbesserungen ;)

Alles, was jetzt noch fehlt, ist das Absenden des eigentlich Posts. Im Gegensatz zu diesem Marathon-Artikel wird es aber ziemlich easy, soviel sei versprochen. Bis zum nächsten Teil :)

Autor: Enno

Ich bin Enno. PHP ist mein Ding, aber auch alles Neue rund um die Themen HTML5, CSS3 & Co finde ich interessant. Ich mag es Leuten zu helfen und mein Wissen weiterzugeben. Sollte dir mein Beitrag gefallen haben, lass doch nen Kommentar da oder benutze einen der Social Buttons, um deinen Dank auszudrücken ;)

Kommentare (0)

Für diesen Beitrag wurden Kommentare deaktiviert.