@maccesar/aiskills 1.17.1 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -1
- package/lib/commands/list.js +109 -22
- package/lib/commands/skills.js +1 -3
- package/lib/config.js +1 -0
- package/lib/prompts/checkboxCancel.js +0 -6
- package/lib/symlink.js +1 -1
- package/package.json +4 -2
- package/skills/seo-launch/SKILL.md +91 -0
- package/skills/seo-launch/assets/head.php +85 -0
- package/skills/seo-launch/assets/htaccess-static +86 -0
- package/skills/seo-launch/assets/robots.txt +17 -0
- package/skills/seo-launch/assets/social-meta.blade.php +90 -0
- package/skills/seo-launch/references/head-tags.md +109 -0
- package/skills/seo-launch/references/images.md +86 -0
- package/skills/seo-launch/references/search-engines.md +77 -0
- package/skills/seo-launch/references/server-files.md +168 -0
- package/skills/seo-launch/references/structured-data.md +139 -0
- package/skills/seo-launch/scripts/__pycache__/auditar_seo.cpython-312.pyc +0 -0
- package/skills/seo-launch/scripts/auditar_seo.py +539 -0
- package/skills/stitch-showcase/references/12-video-embedding.md +11 -28
- package/skills/stitch-showcase/references/13-language-detection.md +13 -38
- package/skills/stitch-showcase/scripts/__pycache__/build_showcase.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-313.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-314.pyc +0 -0
- package/skills/stitch-showcase/scripts/__pycache__/slug_demangle.cpython-314.pyc +0 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<div align="center">
|
|
4
4
|
|
|
5
|
-
  
|
|
5
|
+
  
|
|
6
6
|
|
|
7
7
|
</div>
|
|
8
8
|
|
|
@@ -73,6 +73,7 @@ All three platforms use the same Agent Skills format: a `SKILL.md` file with YAM
|
|
|
73
73
|
| vscode-extension-dev | VS Code | VS Code Extension API docs | 14 files |
|
|
74
74
|
| stitch-showcase | Design Tools | Google Stitch export workflow | 16 files |
|
|
75
75
|
| session-log | Project | Convention + 3 A/B rounds | 2 files |
|
|
76
|
+
| seo-launch | Web / SEO | Head tags, share cards, server files | 5 files |
|
|
76
77
|
|
|
77
78
|
Use `aiskills list` to see available skills from the command line. Pull requests are welcome.
|
|
78
79
|
|
|
@@ -452,6 +453,46 @@ Anti-patterns `refactoring-ui` guards against:
|
|
|
452
453
|
|
|
453
454
|
---
|
|
454
455
|
|
|
456
|
+
### seo-launch
|
|
457
|
+
|
|
458
|
+
Takes a site from "it's online" to "search engines can find it and the link looks right when someone shares it". It covers the part that is configuration rather than content: the `<head>` tags, the images the platforms actually fetch, the server files, the JSON-LD, and the handover to Search Console.
|
|
459
|
+
|
|
460
|
+
It runs in two stages, separated by your approval — the same shape as `audit-codebase`, and for the same reason: a tool that edits while it looks hands you a list of things it already changed instead of a diagnosis.
|
|
461
|
+
|
|
462
|
+
**Stage 1** runs `scripts/auditar_seo.py` against the live URL and reports what is missing, with a severity per finding and the consequence spelled out. The script is standard-library Python, so there is nothing to install. It checks the `<head>` tags and their lengths, the seven Open Graph tags, the Twitter card, the icons, the JSON-LD, `robots.txt`, `sitemap.xml`, the `http→https` and `www→apex` redirects, the response headers, and whether the `og:image` exists — reading its **real dimensions from the file header**, which is how it catches an image declared as 1200×630 that is not.
|
|
463
|
+
|
|
464
|
+
```bash
|
|
465
|
+
python3 <SKILL_DIR>/scripts/auditar_seo.py https://example.com
|
|
466
|
+
python3 <SKILL_DIR>/scripts/auditar_seo.py https://mysite.test --local # self-signed Herd certificate
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`<SKILL_DIR>` is wherever the skill got installed — `~/.agents/skills/seo-launch` for an npm install, a versioned path under `~/.claude/plugins/cache/` for a marketplace one. The skill reads it from the system message rather than assuming, because the working directory during an audit is your project, not the skill.
|
|
470
|
+
|
|
471
|
+
Certificate verification is on by default; `--local` is the explicit opt-out for local `.test` domains, because an unverified response is not evidence of anything.
|
|
472
|
+
|
|
473
|
+
**Stage 2**, once you approve, installs the tags from a parameterized template, generates the images with ImageMagick, writes `robots.txt` / `sitemap.xml` / `.htaccess`, and re-runs the audit against the live site to verify.
|
|
474
|
+
|
|
475
|
+
| Reference file | Covers |
|
|
476
|
+
| --- | --- |
|
|
477
|
+
| `head-tags.md` | title, description, canonical, robots, the Open Graph block, Twitter card, and why the URLs must be absolute |
|
|
478
|
+
| `images.md` | og:image 1200×630, SVG favicon, apple-touch-icon, and the ImageMagick commands with the decision behind each flag |
|
|
479
|
+
| `server-files.md` | robots.txt, sitemap.xml, .htaccess: canonical domain, compression, split caching, security headers |
|
|
480
|
+
| `structured-data.md` | JSON-LD for LocalBusiness, Organization, Article and BreadcrumbList |
|
|
481
|
+
| `search-engines.md` | Search Console (Domain vs URL prefix), Bing, submitting the sitemap, validators, busting Facebook's cache |
|
|
482
|
+
|
|
483
|
+
Templates in `assets/`: a parameterized `head.php` for static sites, a `social-meta.blade.php` component for Laravel, a commented `.htaccess`, and a `robots.txt`.
|
|
484
|
+
|
|
485
|
+
How to invoke it — in whatever words you'd use anyway:
|
|
486
|
+
|
|
487
|
+
```
|
|
488
|
+
"el enlace sale como cuadro gris cuando lo mando por WhatsApp"
|
|
489
|
+
"why doesn't Google find this site?"
|
|
490
|
+
"vamos a poner el dominio en producción, ¿qué falta?"
|
|
491
|
+
"review the meta tags on this site"
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
455
496
|
## CLI reference
|
|
456
497
|
|
|
457
498
|
### aiskills list
|
package/lib/commands/list.js
CHANGED
|
@@ -16,10 +16,104 @@ import {
|
|
|
16
16
|
const CHECK = chalk.green('✓');
|
|
17
17
|
const CROSS = chalk.red('✗');
|
|
18
18
|
|
|
19
|
+
// Two lines of description per skill: enough to tell what a skill is for,
|
|
20
|
+
// short enough that the list stays a list. Everything past that is the
|
|
21
|
+
// skill's trigger text, which is written for the agent, not for this screen.
|
|
22
|
+
const MAX_DESCRIPTION_LINES = 2;
|
|
23
|
+
|
|
24
|
+
// Descriptions still need room to say something when the window is narrow.
|
|
25
|
+
const MIN_DESCRIPTION_WIDTH = 24;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Unquote a YAML scalar. The frontmatter of every skill here is single-quoted
|
|
29
|
+
* because the descriptions contain colons and apostrophes; without this, the
|
|
30
|
+
* quote is printed as part of the text.
|
|
31
|
+
* @param {string} value - Raw scalar as it appears after `description:`
|
|
32
|
+
* @returns {string} The value with its wrapping quotes and escapes resolved
|
|
33
|
+
*/
|
|
34
|
+
function unquote(value) {
|
|
35
|
+
const text = value.trim();
|
|
36
|
+
const quote = text[0];
|
|
37
|
+
if ((quote !== "'" && quote !== '"') || !text.endsWith(quote) || text.length < 2) {
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
const inner = text.slice(1, -1);
|
|
41
|
+
// In single-quoted YAML an apostrophe is escaped by doubling it.
|
|
42
|
+
return quote === "'" ? inner.replace(/''/g, "'") : inner.replace(/\\"/g, '"');
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
46
|
+
* Wrap text into aligned lines, capped at maxLines with an ellipsis when it
|
|
47
|
+
* does not fit.
|
|
48
|
+
* @param {string} text - Text to wrap
|
|
49
|
+
* @param {number} width - Columns available for the text itself
|
|
50
|
+
* @param {number} maxLines - Hard cap on the number of lines
|
|
51
|
+
* @returns {string[]} Lines, none longer than width
|
|
52
|
+
*/
|
|
53
|
+
export function wrapDescription(text, width, maxLines = MAX_DESCRIPTION_LINES) {
|
|
54
|
+
const columns = Math.max(width, MIN_DESCRIPTION_WIDTH);
|
|
55
|
+
const lines = [];
|
|
56
|
+
let current = '';
|
|
57
|
+
|
|
58
|
+
for (let word of text.split(/\s+/).filter(Boolean)) {
|
|
59
|
+
// A single word wider than the column (a long URL) is broken by hand;
|
|
60
|
+
// otherwise the loop below could never place it and would spin.
|
|
61
|
+
while (word.length > columns) {
|
|
62
|
+
if (current) {
|
|
63
|
+
lines.push(current);
|
|
64
|
+
current = '';
|
|
65
|
+
}
|
|
66
|
+
lines.push(word.slice(0, columns));
|
|
67
|
+
word = word.slice(columns);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!current) {
|
|
71
|
+
current = word;
|
|
72
|
+
} else if (current.length + 1 + word.length <= columns) {
|
|
73
|
+
current += ` ${word}`;
|
|
74
|
+
} else {
|
|
75
|
+
lines.push(current);
|
|
76
|
+
current = word;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (current) lines.push(current);
|
|
80
|
+
|
|
81
|
+
if (lines.length <= maxLines) return lines;
|
|
82
|
+
|
|
83
|
+
const kept = lines.slice(0, maxLines);
|
|
84
|
+
let last = kept[maxLines - 1];
|
|
85
|
+
if (last.length + 1 > columns) {
|
|
86
|
+
last = last.slice(0, columns - 1).replace(/\s+\S*$/, '');
|
|
87
|
+
}
|
|
88
|
+
kept[maxLines - 1] = `${last.replace(/[,;:]$/, '')}…`;
|
|
89
|
+
return kept;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract the skill's short description from SKILL.md — the first sentence of
|
|
94
|
+
* the frontmatter description, unquoted and collapsed to a single line.
|
|
95
|
+
* @param {string} frontmatter - The YAML block between the --- fences
|
|
96
|
+
* @returns {string|null} The description, or null when there is none
|
|
97
|
+
*/
|
|
98
|
+
export function parseDescription(frontmatter) {
|
|
99
|
+
// The lookahead ends the value at the next top-level key, or at the end of
|
|
100
|
+
// the block. `(?![\s\S])` rather than `$`, which under the /m flag would
|
|
101
|
+
// match the first newline and cut a description written across two lines.
|
|
102
|
+
const descMatch = frontmatter.match(/^description:\s*([\s\S]+?)(?=\n[A-Za-z][\w-]*:|(?![\s\S]))/m);
|
|
103
|
+
if (!descMatch) return null;
|
|
104
|
+
|
|
105
|
+
const full = unquote(descMatch[1]).replace(/\s+/g, ' ').trim();
|
|
106
|
+
if (!full) return null;
|
|
107
|
+
|
|
108
|
+
const firstSentence = full.match(/^([^]*?\.)\s/);
|
|
109
|
+
if (firstSentence) return firstSentence[1].trim();
|
|
110
|
+
return full;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read a skill's description from disk.
|
|
115
|
+
* @param {string} skillDir - Directory holding the skill's SKILL.md
|
|
116
|
+
* @returns {{description: string|null, installed: boolean}}
|
|
23
117
|
*/
|
|
24
118
|
function readSkillMetadata(skillDir) {
|
|
25
119
|
const skillMd = join(skillDir, 'SKILL.md');
|
|
@@ -32,21 +126,7 @@ function readSkillMetadata(skillDir) {
|
|
|
32
126
|
const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
|
|
33
127
|
if (!frontmatter) return { description: null, installed: true };
|
|
34
128
|
|
|
35
|
-
|
|
36
|
-
if (!descMatch) return { description: null, installed: true };
|
|
37
|
-
|
|
38
|
-
const full = descMatch[1] || descMatch[2] || '';
|
|
39
|
-
let short;
|
|
40
|
-
const firstSentence = full.match(/^([^]*?\.)\s/);
|
|
41
|
-
if (firstSentence) {
|
|
42
|
-
short = firstSentence[1];
|
|
43
|
-
} else if (full.length > 80) {
|
|
44
|
-
const cut = full.slice(0, 80);
|
|
45
|
-
short = cut.slice(0, cut.lastIndexOf(' ')) + '…';
|
|
46
|
-
} else {
|
|
47
|
-
short = full;
|
|
48
|
-
}
|
|
49
|
-
return { description: short.trim(), installed: true };
|
|
129
|
+
return { description: parseDescription(frontmatter[1]), installed: true };
|
|
50
130
|
} catch {
|
|
51
131
|
return { description: null, installed: true };
|
|
52
132
|
}
|
|
@@ -81,14 +161,21 @@ export async function listCommand() {
|
|
|
81
161
|
rows.push({ name, description, installed });
|
|
82
162
|
}
|
|
83
163
|
|
|
164
|
+
// Where the description column starts: ' ' + mark + ' ' + padded name + ' '.
|
|
165
|
+
// Measured on the plain text, since chalk's colour codes take no columns.
|
|
166
|
+
const descriptionColumn = 2 + 1 + 1 + (maxNameLen + 2) + 1;
|
|
167
|
+
const available = (process.stdout.columns || 80) - descriptionColumn;
|
|
168
|
+
|
|
84
169
|
for (const row of rows) {
|
|
85
170
|
const mark = row.installed ? CHECK : CROSS;
|
|
86
171
|
const paddedName = row.name.padEnd(maxNameLen + 2);
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
: chalk.gray(row.installed ? '(no description)' : 'not installed');
|
|
172
|
+
const text = row.description || (row.installed ? '(no description)' : 'not installed');
|
|
173
|
+
const [first, ...rest] = wrapDescription(text, available);
|
|
90
174
|
|
|
91
|
-
console.log(` ${mark} ${chalk.cyan(paddedName)} ${
|
|
175
|
+
console.log(` ${mark} ${chalk.cyan(paddedName)} ${chalk.gray(first)}`);
|
|
176
|
+
for (const line of rest) {
|
|
177
|
+
console.log(`${' '.repeat(descriptionColumn)}${chalk.gray(line)}`);
|
|
178
|
+
}
|
|
92
179
|
}
|
|
93
180
|
|
|
94
181
|
console.log('');
|
package/lib/commands/skills.js
CHANGED
|
@@ -8,9 +8,7 @@ import ora from 'ora';
|
|
|
8
8
|
import {
|
|
9
9
|
SKILLS,
|
|
10
10
|
getPlatforms,
|
|
11
|
-
getAgentsSkillsDir,
|
|
12
11
|
} from '../config.js';
|
|
13
|
-
import select from '../prompts/selectCancel.js';
|
|
14
12
|
import checkbox, { Separator } from '../prompts/checkboxCancel.js';
|
|
15
13
|
import {
|
|
16
14
|
detectPlatforms,
|
|
@@ -256,7 +254,7 @@ export async function skillsCommand(options) {
|
|
|
256
254
|
if (!removeOnly) {
|
|
257
255
|
// Install skills
|
|
258
256
|
spinner.start('Installing skills...');
|
|
259
|
-
|
|
257
|
+
await installSkills(repoDir, baseDir, skillsToInstall);
|
|
260
258
|
spinner.succeed(`${skillsToInstall.length} skill${skillsToInstall.length !== 1 ? 's' : ''} installed`);
|
|
261
259
|
|
|
262
260
|
// If the user deselected some skills, sweep any previously-installed
|
package/lib/config.js
CHANGED
|
@@ -49,12 +49,6 @@ function toggle(item) {
|
|
|
49
49
|
return isSelectable(item) ? { ...item, checked: !item.checked } : item;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function check(checked) {
|
|
53
|
-
return function (item) {
|
|
54
|
-
return isSelectable(item) ? { ...item, checked } : item;
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
52
|
function normalizeChoices(choices) {
|
|
59
53
|
return choices.map((choice) => {
|
|
60
54
|
if (Separator.isSeparator(choice)) return choice;
|
package/lib/symlink.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { mkdirSync, existsSync } from 'fs';
|
|
7
|
-
import { symlink, readlink
|
|
7
|
+
import { symlink, readlink } from 'fs/promises';
|
|
8
8
|
import { join, dirname, relative } from 'path';
|
|
9
9
|
import { copy, remove } from 'fs-extra';
|
|
10
10
|
import { isWindows } from './platform.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maccesar/aiskills",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.1",
|
|
4
4
|
"description": "AI coding assistant skills for Claude Code, Gemini CLI, and Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node --test test/*.test.js",
|
|
11
|
-
"lint": "eslint lib
|
|
11
|
+
"lint": "eslint bin lib test",
|
|
12
12
|
"format": "prettier --write lib/**/*.js"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [
|
|
@@ -47,7 +47,9 @@
|
|
|
47
47
|
"tar": "^7.5.7"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
+
"@eslint/js": "^9.39.5",
|
|
50
51
|
"eslint": "^9.39.2",
|
|
52
|
+
"globals": "^16.5.0",
|
|
51
53
|
"prettier": "^3.8.1"
|
|
52
54
|
},
|
|
53
55
|
"files": [
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: seo-launch
|
|
3
|
+
description: 'Audit and then install everything a site needs to be indexed and to render a proper card when its link is shared: head tags, Open Graph and Twitter card, the 1200x630 og:image, favicon and apple-touch-icon, robots.txt, sitemap.xml, an .htaccess with one canonical domain, JSON-LD, and the Search Console handover. Works on static sites and on Laravel or plain PHP projects. Use when the user says the link shows a grey box with no preview in WhatsApp, asks why Google cannot find the site, is putting a new domain live, or asks for an SEO review, meta tags, og:image, sitemap or robots.txt — even when they never say "SEO". Not for: keyword research, writing the content itself, backlinks, paid ads, or analytics dashboards.'
|
|
4
|
+
allowed-tools: Read, Grep, Glob, Bash, Edit, Write, AskUserQuestion
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# SEO Launch
|
|
8
|
+
|
|
9
|
+
Get a site indexable and shareable: the `<head>` tags, the images the platforms actually fetch, the server files, the structured data, and the handover to Search Console.
|
|
10
|
+
|
|
11
|
+
The work has two stages separated by an explicit authorization. The separation exists because the report is the deliverable: an audit that edits while it looks produces a list of things you already changed, which is not a diagnosis and takes the decision away from the owner. Stage 1 writes nothing.
|
|
12
|
+
|
|
13
|
+
Respond in the user's language. This skill is written in English for portability; the report should match whatever language the user is writing in.
|
|
14
|
+
|
|
15
|
+
## Read the terrain first
|
|
16
|
+
|
|
17
|
+
Three questions, answered before anything else, because each one changes what you apply and where:
|
|
18
|
+
|
|
19
|
+
1. **What kind of project is this?** `artisan` in the root means Laravel — the tags belong in a Blade component or the layout's `<head>`, not in each view. A tree of `.html` files means a static site; look for a `partials/` directory or an SSI/PHP include before writing the same block into every page. Anything else (Astro, Next, Hugo, WordPress), find the one template that owns the `<head>` and edit that.
|
|
20
|
+
2. **Is the site live?** A reachable URL means the audit can be measured instead of guessed — run the script. A domain that does not resolve yet means the audit is a read of local files only, and the verification step moves to after the deploy.
|
|
21
|
+
3. **Where is the document root?** The `.htaccess`, `robots.txt` and `sitemap.xml` go where Apache serves from, which in a Laravel project is `public/` and in a static project is often `public/` too. Writing them one level up is the most common way this work silently does nothing.
|
|
22
|
+
|
|
23
|
+
Ask only what you cannot read from the repo. The domain, the business name and the phone number are the user's to give; the framework is yours to detect.
|
|
24
|
+
|
|
25
|
+
## Stage 1 — Audit, no modifications
|
|
26
|
+
|
|
27
|
+
Do not create or edit files in this stage, including images.
|
|
28
|
+
|
|
29
|
+
1. **Measure the live site**, if there is one:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
python3 <SKILL_DIR>/scripts/auditar_seo.py https://example.com
|
|
33
|
+
python3 <SKILL_DIR>/scripts/auditar_seo.py https://mysite.test --local # self-signed Herd cert
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Replace `<SKILL_DIR>` with the absolute "Base directory for this skill" from the system message that loaded this skill — the working directory here is the user's project, not this one, so a relative `scripts/…` resolves to nothing. The path also differs by install type (`~/.claude/plugins/cache/<plugin>/<version>/skills/seo-launch` for a plugin install, `~/.claude/skills/seo-launch` standalone), so read it rather than assuming it.
|
|
37
|
+
|
|
38
|
+
It reads the page once and checks the `<head>` tags, `robots.txt`, `sitemap.xml`, the `http→https` and `www→apex` redirects, the response headers, and whether the `og:image` exists — including its **real dimensions**, read from the file header, which is how you catch an image declared as 1200×630 that is not.
|
|
39
|
+
|
|
40
|
+
`--no-network` limits it to the markup. `--local` relaxes certificate verification and exists only for local `.test` domains; never point it at a site on the public internet, since an unverified response is not evidence of anything.
|
|
41
|
+
|
|
42
|
+
2. **Read the local files** that own what the script found missing: the template holding the `<head>`, the document root, `.htaccess`, `robots.txt`, `sitemap.xml`.
|
|
43
|
+
|
|
44
|
+
3. **Report findings as a table** — item, severity, what breaks because of it:
|
|
45
|
+
|
|
46
|
+
| Finding | Severity | Consequence |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| No `og:image` | High | Every shared link renders as a grey rectangle |
|
|
49
|
+
| `<title>` is 74 characters | Medium | Google truncates it at ~60 and the tail is lost |
|
|
50
|
+
|
|
51
|
+
Severity is what breaks, not how hard it is to fix. Anything that makes the site invisible (no `sitemap.xml`, `noindex` in production, two domains serving 200) or unshareable (no `og:image`, relative image URLs) is High. Nice-to-haves (`twitter:site`, `theme-color`) are Low, and say so rather than padding the list.
|
|
52
|
+
|
|
53
|
+
4. **Stop.** Present the table and what you would do about it. Do not start fixing.
|
|
54
|
+
|
|
55
|
+
If the user asked up front to "audit and fix", still show the table first — approving it takes seconds, and the content decisions inside it (the description text, which pages go in the sitemap, whether keywords are wanted) are not yours to invent.
|
|
56
|
+
|
|
57
|
+
## Stage 2 — Authorized implementation
|
|
58
|
+
|
|
59
|
+
Start only once the user approves. Work in this order, because each step depends on the previous one being real:
|
|
60
|
+
|
|
61
|
+
1. **Tags.** Install the `<head>` block from the right template in `assets/`, parameterized — one place holds the domain, one place holds the fallback image. Never paste the same literal block into five pages.
|
|
62
|
+
2. **Images.** Generate the `og:image`, the favicon and the `apple-touch-icon` per `references/images.md`. Verify each file exists and measures what the tags declare.
|
|
63
|
+
3. **Server files.** `robots.txt` with the `Sitemap:` line, `sitemap.xml` with the real pages, `.htaccess` with the canonical domain, caching and security headers.
|
|
64
|
+
4. **Structured data.** The JSON-LD block, with confirmed data only.
|
|
65
|
+
5. **Verify against the live site**, not against the repo. Re-run the script and expect the findings you fixed to be gone. If the site deploys by SFTP-on-save, the files may already be up; if it deploys by git, they are not until it ships — check, do not infer.
|
|
66
|
+
6. **Search engines.** The Search Console steps in `references/search-engines.md` need a browser and the domain's DNS. Hand the user the exact sequence; you cannot do this part for them.
|
|
67
|
+
|
|
68
|
+
## Where to look
|
|
69
|
+
|
|
70
|
+
| What the work touches | Read |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `<title>`, description, canonical, Open Graph, Twitter card | `references/head-tags.md` |
|
|
73
|
+
| `og:image`, favicon, `apple-touch-icon`, ImageMagick commands | `references/images.md` |
|
|
74
|
+
| `robots.txt`, `sitemap.xml`, `.htaccess`, caching, headers | `references/server-files.md` |
|
|
75
|
+
| JSON-LD: `LocalBusiness`, `Organization`, `Article`, breadcrumbs | `references/structured-data.md` |
|
|
76
|
+
| Search Console, Bing, submitting the sitemap, validators | `references/search-engines.md` |
|
|
77
|
+
|
|
78
|
+
Templates in `assets/`: `head.php` (static site, parameterized include), `social-meta.blade.php` (Laravel component), `htaccess-static`, `robots.txt`.
|
|
79
|
+
|
|
80
|
+
## Hard rules
|
|
81
|
+
|
|
82
|
+
These are the ones that break on their own, each with the reason it matters:
|
|
83
|
+
|
|
84
|
+
- **`og:image` and `canonical` carry absolute URLs.** Facebook, WhatsApp and X discard an image with a relative path — the tag is present, valid, and produces no thumbnail at all.
|
|
85
|
+
- **The `og:image` is a JPEG, not WebP.** Several preview clients still cannot decode WebP and fall back to no image; the 30 KB you save are not worth an invisible link.
|
|
86
|
+
- **The `apple-touch-icon` is a flat PNG with a white background and no alpha.** iOS does not honour the alpha channel here, it fills transparency with black, and a dark logo disappears into it.
|
|
87
|
+
- **The `<title>` stays under ~60 characters.** Past that Google truncates it in the results, and what gets cut is the tail — which is where the brand usually sits.
|
|
88
|
+
- **`og:image:width` and `og:image:height` must match the file.** They exist so the client can lay out the card before downloading; wrong values render a cropped or stretched card and nothing reports an error.
|
|
89
|
+
- **The canonical redirects go in the server config, not only in the tag.** `rel=canonical` is a hint Google may ignore; the 301 is the one that settles it.
|
|
90
|
+
- **JSON-LD carries confirmed data only.** Google cross-checks it against other sources — an invented address or opening hours costs you trust in the whole block, and there is no warning when that happens.
|
|
91
|
+
- **Changing the `og:image` requires busting Facebook's cache.** It caches the card for days; without the debugger refresh, the "fixed" preview keeps showing the old image and it looks like the fix failed.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
/**
|
|
3
|
+
* The full <head>, shared by every page of a static site.
|
|
4
|
+
*
|
|
5
|
+
* Include it once per page: <?php $titulo = '…'; $descripcion = '…'; $ruta = ''; include '../partials/head.php'; ?>
|
|
6
|
+
*
|
|
7
|
+
* Variables it expects before the include:
|
|
8
|
+
* $titulo (required) the <title>
|
|
9
|
+
* $descripcion (required) the meta description
|
|
10
|
+
* $ruta (required) path from the root, '' for the home page
|
|
11
|
+
* $og_titulo (optional) defaults to $titulo — the card usually drops the brand,
|
|
12
|
+
* because og:site_name already prints it right below
|
|
13
|
+
* $og_desc (optional) defaults to $descripcion
|
|
14
|
+
* $og_img (optional) absolute URL, overrides the site-wide card image
|
|
15
|
+
* $og_img_alt (optional) alt text for the card image
|
|
16
|
+
* $twitter_desc (optional) X truncates earlier than Facebook; sometimes a
|
|
17
|
+
* shorter version is worth writing
|
|
18
|
+
* $keywords (optional) Google has ignored these since 2009
|
|
19
|
+
* $jsonld (optional) an already-formatted structured data block
|
|
20
|
+
*
|
|
21
|
+
* The URLs are absolute on purpose: Facebook, WhatsApp and X discard images
|
|
22
|
+
* with a relative path. The base lives here, in one place.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const BASE = 'https://example.com/';
|
|
26
|
+
const OG_IMAGEN = BASE . 'images/og-image.jpg';
|
|
27
|
+
const OG_IMAGEN_ALT = 'Describe the card image in one sentence.';
|
|
28
|
+
const SITIO = 'Site Name';
|
|
29
|
+
|
|
30
|
+
$url = BASE . ($ruta ?? '');
|
|
31
|
+
$ogTitulo = $og_titulo ?? $titulo;
|
|
32
|
+
$ogDesc = $og_desc ?? $descripcion;
|
|
33
|
+
$ogImagen = $og_img ?? OG_IMAGEN;
|
|
34
|
+
$ogImgAlt = $og_img_alt ?? OG_IMAGEN_ALT;
|
|
35
|
+
$twitterDesc = $twitter_desc ?? $ogDesc;
|
|
36
|
+
|
|
37
|
+
function attr(string $valor): string
|
|
38
|
+
{
|
|
39
|
+
return htmlspecialchars($valor, ENT_QUOTES, 'UTF-8');
|
|
40
|
+
}
|
|
41
|
+
?>
|
|
42
|
+
<meta charset="utf-8"/>
|
|
43
|
+
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
|
44
|
+
<title><?= attr($titulo) ?></title>
|
|
45
|
+
<meta content="<?= attr($descripcion) ?>" name="description"/>
|
|
46
|
+
<link href="<?= attr($url) ?>" rel="canonical"/>
|
|
47
|
+
<meta content="index, follow" name="robots"/>
|
|
48
|
+
<meta content="#0f2d52" name="theme-color"/>
|
|
49
|
+
<?php if (! empty($keywords)): ?>
|
|
50
|
+
<meta content="<?= attr($keywords) ?>" name="keywords"/>
|
|
51
|
+
<?php endif; ?>
|
|
52
|
+
<!-- An SVG favicon has no intrinsic size, so it draws sharp in the tab, in the
|
|
53
|
+
bookmarks bar and at any density. iOS ignores it for the home screen — that
|
|
54
|
+
is what the PNG below is for. -->
|
|
55
|
+
<link href="<?= BASE ?>images/logo-symbol.svg" rel="icon" type="image/svg+xml"/>
|
|
56
|
+
<link href="<?= BASE ?>images/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180"/>
|
|
57
|
+
<!-- Without this, iOS puts the whole <title> under the icon and cuts it. -->
|
|
58
|
+
<meta content="<?= SITIO ?>" name="apple-mobile-web-app-title"/>
|
|
59
|
+
<!-- Open Graph: Facebook, WhatsApp, LinkedIn -->
|
|
60
|
+
<meta content="website" property="og:type"/>
|
|
61
|
+
<meta content="es_MX" property="og:locale"/>
|
|
62
|
+
<meta content="<?= SITIO ?>" property="og:site_name"/>
|
|
63
|
+
<meta content="<?= attr($url) ?>" property="og:url"/>
|
|
64
|
+
<meta content="<?= attr($ogTitulo) ?>" property="og:title"/>
|
|
65
|
+
<meta content="<?= attr($ogDesc) ?>" property="og:description"/>
|
|
66
|
+
<meta content="<?= attr($ogImagen) ?>" property="og:image"/>
|
|
67
|
+
<meta content="<?= attr($ogImagen) ?>" property="og:image:secure_url"/>
|
|
68
|
+
<meta content="image/jpeg" property="og:image:type"/>
|
|
69
|
+
<!-- These must match the real file. A wrong value renders a cropped card and
|
|
70
|
+
nothing reports an error. -->
|
|
71
|
+
<meta content="1200" property="og:image:width"/>
|
|
72
|
+
<meta content="630" property="og:image:height"/>
|
|
73
|
+
<meta content="<?= attr($ogImgAlt) ?>" property="og:image:alt"/>
|
|
74
|
+
<!-- X. summary_large_image is the one that shows the photo full width. -->
|
|
75
|
+
<meta content="summary_large_image" name="twitter:card"/>
|
|
76
|
+
<meta content="<?= attr($ogTitulo) ?>" name="twitter:title"/>
|
|
77
|
+
<meta content="<?= attr($twitterDesc) ?>" name="twitter:description"/>
|
|
78
|
+
<meta content="<?= attr($ogImagen) ?>" name="twitter:image"/>
|
|
79
|
+
<meta content="<?= attr($ogImgAlt) ?>" name="twitter:image:alt"/>
|
|
80
|
+
<?php if (! empty($jsonld)): ?>
|
|
81
|
+
<script type="application/ld+json">
|
|
82
|
+
<?= $jsonld ?>
|
|
83
|
+
</script>
|
|
84
|
+
<?php endif; ?>
|
|
85
|
+
<link href="<?= BASE ?>css/app.css" rel="stylesheet"/>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# .htaccess for a static site served directly by Apache.
|
|
2
|
+
#
|
|
3
|
+
# Goes in the document root. A Laravel project already ships an .htaccess with
|
|
4
|
+
# the front-controller rewrite: add these blocks to that file, do not replace it.
|
|
5
|
+
#
|
|
6
|
+
# Replace example.com with the real domain in the two RewriteRules below.
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------- hidden files
|
|
9
|
+
# Any path starting with a dot returns 404. Defence in depth for the day a
|
|
10
|
+
# deploy tool uploads .vscode/ or .git/ into the document root.
|
|
11
|
+
#
|
|
12
|
+
# The (?!well-known/) exception is NOT optional: AutoSSL renews the certificate
|
|
13
|
+
# by placing a file in /.well-known/acme-challenge/. Without it, validation
|
|
14
|
+
# fails silently and you find out when the certificate expires.
|
|
15
|
+
RedirectMatch 404 /\.(?!well-known/)
|
|
16
|
+
|
|
17
|
+
# Formats that only exist to work on the site, never to be served.
|
|
18
|
+
<FilesMatch "\.(md|json|lock|yml|yaml|sh|sql|bak|log)$">
|
|
19
|
+
Require all denied
|
|
20
|
+
</FilesMatch>
|
|
21
|
+
|
|
22
|
+
# ------------------------------------------------------------ canonical domain
|
|
23
|
+
# Without this, http://, https://www. and https:// all serve the same content
|
|
24
|
+
# with a 200 — duplicate content, with the authority split three ways.
|
|
25
|
+
<IfModule mod_rewrite.c>
|
|
26
|
+
RewriteEngine On
|
|
27
|
+
|
|
28
|
+
# The double condition is not redundant: behind a proxy or CDN, %{HTTPS} can
|
|
29
|
+
# arrive off even though the request was secure. Testing only that variable
|
|
30
|
+
# produces an infinite redirect loop.
|
|
31
|
+
RewriteCond %{HTTPS} !=on
|
|
32
|
+
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
|
33
|
+
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
|
|
34
|
+
|
|
35
|
+
# Pick apex or www and stay consistent with the canonical tag and the sitemap.
|
|
36
|
+
RewriteCond %{HTTP_HOST} ^www\.example\.com$ [NC]
|
|
37
|
+
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
|
|
38
|
+
</IfModule>
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------ compression
|
|
41
|
+
# image/svg+xml is the one usually missing from a default config. An SVG is
|
|
42
|
+
# text and compresses enormously.
|
|
43
|
+
<IfModule mod_deflate.c>
|
|
44
|
+
AddOutputFilterByType DEFLATE text/html text/css text/plain text/xml \
|
|
45
|
+
application/javascript application/json image/svg+xml
|
|
46
|
+
</IfModule>
|
|
47
|
+
|
|
48
|
+
# ----------------------------------------------------------------------- cache
|
|
49
|
+
<IfModule mod_headers.c>
|
|
50
|
+
# Images and fonts: a year, immutable — the browser will not even ask whether
|
|
51
|
+
# they changed. What makes this safe is renaming a file when its content does.
|
|
52
|
+
<FilesMatch "\.(webp|jpe?g|png|gif|svg|ico|woff2)$">
|
|
53
|
+
Header set Cache-Control "public, max-age=31536000, immutable"
|
|
54
|
+
</FilesMatch>
|
|
55
|
+
|
|
56
|
+
# HTML, CSS and JS: revalidate every time. A stylesheet always named app.css
|
|
57
|
+
# cannot be cached long — recompile it and returning visitors keep the old one
|
|
58
|
+
# until it expires, with new classes silently missing and no console error.
|
|
59
|
+
# This flips only when the build fingerprints filenames (app.4f3a1c.css).
|
|
60
|
+
<FilesMatch "\.(html|css|js)$">
|
|
61
|
+
Header set Cache-Control "public, max-age=0, must-revalidate"
|
|
62
|
+
</FilesMatch>
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------ security
|
|
65
|
+
Header always set X-Content-Type-Options "nosniff"
|
|
66
|
+
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
|
67
|
+
Header always set X-Frame-Options "SAMEORIGIN"
|
|
68
|
+
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
|
69
|
+
|
|
70
|
+
# Content-Security-Policy is deliberately absent. A miscalibrated CSP strips
|
|
71
|
+
# the site of its styles, and any page with an inline <script> or a
|
|
72
|
+
# third-party font needs it tuned by hand. Add it once those are self-hosted
|
|
73
|
+
# and you can test it.
|
|
74
|
+
</IfModule>
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------ MIME types
|
|
77
|
+
# An SVG served as text/plain renders blank — including the favicon.
|
|
78
|
+
<IfModule mod_mime.c>
|
|
79
|
+
AddType image/svg+xml .svg
|
|
80
|
+
AddType image/webp .webp
|
|
81
|
+
AddType application/xml .xml
|
|
82
|
+
</IfModule>
|
|
83
|
+
|
|
84
|
+
# --------------------------------------------------------------- default files
|
|
85
|
+
DirectoryIndex index.html index.php
|
|
86
|
+
Options -Indexes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# robots.txt — goes in the document root, reachable at https://example.com/robots.txt
|
|
2
|
+
#
|
|
3
|
+
# The point of this file here is the Sitemap: line: it is the only standard
|
|
4
|
+
# place to point at the sitemap without submitting it by hand to each engine.
|
|
5
|
+
#
|
|
6
|
+
# It is public. Do not list paths you would rather nobody found — a Disallow
|
|
7
|
+
# reads as a directory of interesting URLs. It keeps polite crawlers out; it is
|
|
8
|
+
# not access control.
|
|
9
|
+
|
|
10
|
+
User-agent: *
|
|
11
|
+
Allow: /
|
|
12
|
+
|
|
13
|
+
# Endpoints that are not pages: form handlers, internal APIs, debug routes.
|
|
14
|
+
# Disallow: /send.php
|
|
15
|
+
|
|
16
|
+
# The URL must be absolute, and must match the canonical domain.
|
|
17
|
+
Sitemap: https://example.com/sitemap.xml
|