Mostrando la imagen y el título correctos para aplicaciones de página única (SPAs)
Cuando se comparte un enlace a una página web en plataformas de redes sociales como Twitter o Facebook, se muestra el título e imagen en miniatura de la página. Desafortunadamente, si la página es parte de una aplicación de página única (SPA), el servidor proporciona un index.html fijo en lugar de la página real, lo que resulta en que se muestre un título e imagen fijos. Algunos motores de búsqueda tampoco pueden indexar el título y contenido correctos. Este artículo explica cómo superar estos problemas y mostrar el título e imagen correctos para la página.
El problema
Mientras desarrollábamos Requestland, nos encontramos con un problema. Cuando un usuario compartía un enlace a una página de Requestland en plataformas de redes sociales como Twitter o Facebook, se mostraba el mismo título e imagen en miniatura predeterminados en lugar del título y la imagen en miniatura específicos de la página.
Si los lectores de Twitter no podían ver la imagen en miniatura de la página, era menos probable que hicieran clic en el enlace.
La causa raíz de este problema fue que Requestland era una aplicación de página única (SPA). Aunque el problema es común y molesto, no pude encontrar una explicación en la red ni en ChatGPT.
En una SPA, cuando se accede o se vuelve a cargar cualquier enlace, el servidor web sirve el archivo index.html predeterminado que contiene etiquetas <script> que apuntan a archivos de paquete con código JavaScript y CSS. Estos archivos son necesarios para ejecutar la SPA. Dado que el archivo index.html tiene un título e imagen en miniatura, el usuario siempre veía los mismos.
Por ejemplo, cargar una página en una aplicación de notas en https://my-service.com/app/note/995 devuelve el siguiente index.html. <title> es el título de la aplicación, no el título de la página.
<html>
<head>
<base href="/app">
<title>My SPA notes app</title>
<description>This is a notes app.</description>
<meta property="og:image" content="https://file-server.image1.png">
<!-- injected script tags for bundle files -->
<script src="https://file-server.bundle.a94.js"></script>
<link href="https://file-server.bundle.vc0.css" rel="stylesheet"></link>
</head>
<body>
...
</body>
</html>
<html>
<head>
<base href="/app">
<title>Note 995 title</title>
<description>Note 995 description</description>
<meta property="og:image" content="https://file-server.note995-image.png">
<!-- injected script tags for bundle files -->
<script src="https://file-server.bundle.a94.js"></script>
<link href="https://file-server.bundle.vc0.css" rel="stylesheet"></link>
</head>
<body>
(content here)
</body>
</html>
Entorno de desarrollo local
devServer: {
historyApiFallback: true,
proxy: [
{ context: [ '/api' ],
target: 'http://localhost:8080,
secure: false,
changeOrigin: true,
}]Nuestra solución para el entorno de desarrollo local
proxy: [
{ context: [ '/api', '/app' ],
target: 'http://localhost:8080,
changeOrigin: true,
}]
% yarn add WebpackManifestPlugin
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
...
plugins: [
new WebpackManifestPlugin({
fileName: 'app-bundle-manifest.json',
writeToFileEmit: true,
filter: (file) => {
return file.isChunk;
},
sort: (fileA, fileB) => {
if (fileA.path.endsWith('.js') && fileB.path.endsWith('.css')) return -1;
if (fileA.path.endsWith('.css') && fileB.path.endsWith('.js')) return 1;
if (fileA.path > fileB.path) return -1;
return 1;
},
//seed: {
// version: 'local-dev-server-version'
//}
}), {
"app-a1d170d4.css": "/app-a1d170d4.d1dd426331d40f5930fe.bundle.css",
"app-a1d170d4.js": "/app-a1d170d4.d1dd426331d40f5930fe.bundle.js",
"runtime~app.js": "/runtime~app.d1dd426331d40f5930fe.bundle.js",
"vendors-91c40cd8.css": "/vendors-91c40cd8.d1dd426331d40f5930fe.bundle.css",
"vendors-91c40cd8.js": "/vendors-91c40cd8.d1dd426331d40f5930fe.bundle.js",
...Servidor API
<html>
<head>
<base href="/app">
<title>{{title}}</title>
<description>{{desc}}</description>
<meta property="og:image" content="{{image_url}}">
{{bundles}}
</head>
<body>
...
</body>
</html>
import falcon
import requests
from jinja2
from markupsafe import Markup
is_local = True
file_server = 'https://localhost' if is_local else 'https://file-server.com'
jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader('.'))
class APIServer():
''' Generates the HTTP response for reloading. '''
def on_get_api(self, req, resp):
resp.body = 'API result'
resp.content_type = 'text/html; charset=UTF-8'
def on_get_note(self, req, resp, note_id):
note = get_note_from_db(note_id)
resp.body = jinja_env.get_template("index.html").render({
'title': note.title,
'desc': note.content,
'image_url': note.image_url,
'bundles': Markup(self.get_script_tags())
})
resp.content_type = 'text/html; charset=UTF-8'
def get_script_tags(self) -> str: # need caching
response = requests.get(file_server + '/app-bundle-manifest.json', timeout=5, verify=is_local)
js = css = ''
for filename in json.loads(response.text).values():
if filename.endswith('.js'):
js += ' <script defer src="' + filename + '"></script>\n'
if filename.endswith('.css'):
css += ' <link href="' + filename + '" rel="stylesheet"></link>\n'
return js + css
api_server = APIServer()
app = falcon.App()
app.add_route('/api, api_server, suffix='api')
app.add_route('/app/note/{note_id}', api_server, suffix='note')
def sink(req, resp):
resp.body = jinja_env.get_template("index.html").render({
'title': 'My SPA notes app',
'desc': 'This is a notes app.,
'image_url': 'https://file-server.image1.png',
'bundles': Markup(api_server.get_script_tags())
})
resp.content_type = 'text/html; charset=UTF-8'
app.add_sink(sink, prefix='/app')
if __name__ == '__main__':
from wsgiref.simple_server import make_server
with make_server('', 8080, app) as httpd:
httpd.serve_forever()
El entorno de producción
pathMatchers:
- name: Note API server
routeRules:
- description: Note API server
matchRules:
- pathTemplateMatch: '/app/note/{rest=**}'
service: note-server
priority: 1
routeAction:
urlRewrite:
pathTemplateRewrite: '/app/note/{rest}'
Mejora adicional
<body>
<div>
{{content}}
</div>
</body>
def on_get_note(self, req, resp, note_id):
note = get_note_from_db(note_id)
resp.body = jinja_env.get_template("index.html").render({
'title': note.title,
'desc': note.content,
'image_url': note.image_url,
'bundles': Markup(self.get_script_tags()),
'content': note.content
})
resp.content_type = 'text/html; charset=UTF-8'