跳转到内容
Jsonq's Blog
返回

使用 node 脚本与 Sharepoint 集成实现项目发版更新

结合 Sharepoint 本地代理 实现,这个功能很早就写好了并始终在使用,还算稳定,不过 Sharepoint 也因为国产化要丢掉了,也算是做一个记录留存吧。

Table of contents

Open Table of contents

准备工作

pnpjs 分别提供了 node 版本和 web 版本,在 node 端使用自然要再安装 node 版本依赖

pnpm i @pnp/nodejs

pnp.js 的文件上传使用

其实 Sharepoint 就是操作性更强的服务器,你只要把资源( css + js + html)上传上去,并进行母版页(html)发版,所有人就都可以访问了,而上传文件就需要用到 pnpjs 的 api。

由于 pnpjs 目前仅支持 esm 模块,所以只能使用 import 来导入相关文件模块

// pnpjs 上传文件需要的最基础依赖
import { spfi } from "@pnp/sp";
import { SPDefault } from "@pnp/nodejs";
import "@pnp/sp/webs";
import "@pnp/sp/files";
import "@pnp/sp/folders";

const sp = spfi().using(
  SPDefault({
    // 利用 sp-rest-proxy 实现的 Sharepoint 代理地址
    baseUrl: "https://localhost:9999",
  })
);

// 文件夹创建 api
sp.web.folders.addUsingPath("/myFolder");
// 文件上传 api 将 index.js 文件上传到 myFolder 文件夹下,并写入内容 Hello world
await sp.web
  .getFolderByServerRelativePath("/myFolder")
  .files.addUsingPath("index.js", textDecoder.decode(`Hello world`), {
    Overwrite: true,
  });

实现文件上传

OK,大致了解之后,我们就有思路该如何实现了

这里使用 tsx 来运行脚本

import path from "node:path";
import fs from "node:fs";
import inquirer from "inquirer";

// pnp 仅支持 esm
import { spfi } from "@pnp/sp";
import { SPDefault } from "@pnp/nodejs";
import "@pnp/sp/webs";
import "@pnp/sp/files";
import "@pnp/sp/folders";

import pLimit from "p-limit";

const limit = pLimit(6);

const textDecoder = new TextDecoder("utf-8");

// 由于代理为 https ,需要忽略证书验证
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

const sp = spfi().using(
  SPDefault({
    baseUrl: "https://localhost:9999", // proxy 代理地址
  })
);

const BaseConfig = {
  sitePageDir: "/SitePages",
  pageName: "mypage.aspx", // Sharepoint 母版页名称
  siteAssetDir: "/SiteAssets/mypage", // Sharepoint 资源目录文件名
  distDir: path.join(process.cwd(), "dist"), // 打包后的目录
};

async function uploadFileToSharePoint(localPath: string, remotePath: string) {
  try {
    const fileContent = fs.readFileSync(localPath);
    const fileName = path.basename(localPath);
    console.log(`正在上传文件: ${fileName} to ${remotePath}`);

    await sp.web
      .getFolderByServerRelativePath(remotePath)
      // 这里 decode 是为了确保文件内容能够被 pnp 正确解析,比如 buffer 类型在上传时就会出现内容错乱
      .files.addUsingPath(fileName, textDecoder.decode(fileContent), {
        Overwrite: true,
      });

    console.log(`文件上传成功: ${fileName}`);
  } catch (error) {
    console.error(`文件上传失败: ${path.basename(localPath)}`, error);
    throw error;
  }
}

async function handleAssets(localPath: string, remotePath: string) {
  const items = fs.readdirSync(localPath, { withFileTypes: true });

  // 递归查找文件夹结构,先把文件夹创建好
  for (const item of items.filter((d) => d.isDirectory())) {
    const localFullPath = path.join(localPath, item.name);
    const remoteFullPath = path.posix.join(remotePath, item.name);

    await sp.web.folders.addUsingPath(remoteFullPath);
    await handleAssets(localFullPath, remoteFullPath);
  }

  // 再处理所有文件
  await Promise.all(
    items
      .filter((item) => item.isFile())
      .map((item) =>
        limit(() =>
          uploadFileToSharePoint(path.join(localPath, item.name), remotePath)
        )
      )
  );
}

async function deployAssets() {
  const localBasePath = BaseConfig.distDir;
  const remoteBasePath = BaseConfig.siteAssetDir;
  // 再上传前先把旧资源删除
  await sp.web.getFolderByServerRelativePath(remoteBasePath).delete();
  // 创建根文件夹
  await sp.web.folders.addUsingPath(remoteBasePath);
  try {
    console.log("🚀 Asset 资源部署中...");
    await handleAssets(localBasePath, remoteBasePath);
  } catch (error) {
    console.error("❌ Asset 资源部署失败:", error);
    throw error;
  }
}

deployAssets();

此时这个脚本已经可以把静态资源部署到 Sharepoint 上了

母版页的创建及相关打包资源的动态引入

首先,你打包时的访问地址的 baseUrl 要和线上对应的上,比如线上访问地址为 https://mysharepoint.com/sites/mySite,那么打包时 baseUrl 要设置为 /sites/mySite

其次,其实打包好的 html 结构和母版页是非常接近的,以下是对比图

html-to-aspx

那么我们实际上就可以把母版页不变的内容作为纯静态内容,然后把动态内容script link 等提取出来插入进入即可,这里借助 cheerio 就可以实现,它专门提取 xml 和 html 的结构内容,代码见最后部分

母版页的发布

这个就再次回到部署资源的文件

async function deployAspxPage() {
  // 在部署前先 node 运行生成母版页的 js 文件
  const workPagePath = path.join(__dirname, "../dist/mypage.aspx");
  const fileContent = fs.readFileSync(workPagePath);
  const targetPage = `${BaseConfig.sitePageDir}/${BaseConfig.pageName}`;

  console.log("🚀 Page 页面部署中...");
  await sp.web.getFolderByServerRelativePath(targetPage).delete();
  await sp.web
    .getFolderByServerRelativePath(BaseConfig.sitePageDir)
    .files.addUsingPath(BaseConfig.pageName, textDecoder.decode(fileContent), {
      Overwrite: true,
    });

  try {
    const file = sp.web.getFileByServerRelativePath(targetPage);
    // 签出
    // await file.checkin('Updated by script', CheckinType.Major);
    // 发布
    await file.publish("Published by mobile script");
    console.log(`📄 Page 替换并发布成功`);
  } catch (error) {
    console.error(`❌ Page 页面更新页面更新失败: `, error);
    throw error;
  }
}

使用 inquirer 来控制部署可选项

这一步不是非必须的,简单使用的话就跟你使用各种 CLI 工具来创建项目时效果差不多,选择某项执行对应的逻辑

简单使用

import inquirer from "inquirer";

// prompt 返回的是一个 Promise
const selectedValue = await inquirer.prompt([
  {
    type: "list",
    name: "selectType",
    message: "请选择类型:",
    choices: [
      { name: "A", value: "a" },
      { name: "B", value: "b" },
      { name: "C", value: "c" },
    ],
  },
]);

if (selectedValue.selectType === "a") {
  // ...
} else if (selectedValue.selectType === "b") {
  // ...
} else if (selectedValue.selectType === "c") {
  // ...
}

如果在部署前需要告知使用者一些东西,进行一些提示,比如使用者必须输入 y 回车才会触发部署逻辑,则可以使用 node 内置的 process.stdoutprocess.stdin

async function confirmDeployment(): Promise<boolean> {
  return new Promise((resolve) => {
    process.stdout.write("是否确认部署?(Y/N) ");
    process.stdin.setEncoding("utf8");
    process.stdin.once("data", (data) => {
      const input = data.toString().trim().toLowerCase();
      resolve(input === "y");
    });
  });
}

完整脚本

npm run build && node generateAspx.js && tsx deploy.ts

generateAspx.js

const path = require("path");
const fs = require("fs");
const cheerio = require("cheerio");

const readPth = path.join(__dirname, "../dist/index.html");
const outputPath = path.join(__dirname, "../dist/mypage.aspx");

const html = fs.readFileSync(readPth, "utf8");
const $ = cheerio.load(html);

const siteTitle = $("title").text();
const coreDistFilePath = [];
const extraScript = [];

// 构建资源 提取 head 中的 script 和 link
$("head script, head link").each((i, el) => {
  coreDistFilePath.push($(el).toString());
});
// 提取额外注入的 script
$("body script").each((i, el) => {
  extraScript.push($(el).toString());
});

const mountDiv = $("#app").toString();

const template = `
<%@ Page Language="C#" masterpagefile="../_catalogs/masterpage/cwePureMobile.master" title="chartShow"
  inherits="Microsoft.SharePoint.WebPartPages.WebPartPage, Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=xxxxx"
  meta:progid="SharePoint.WebPartPage.Document" %>
  <%@ Register tagprefix="SharePoint" namespace="Microsoft.SharePoint.WebControls"
    assembly="Microsoft.SharePoint, Version=15.0.0.0, Culture=neutral, PublicKeyToken=xxxxx" %>
    <asp:Content ContentPlaceHolderID="PlaceHolderAdditionalPageHead" runat="server">
      <SharePoint:ScriptLink Name="sp.js" runat="server" LoadAfterUI="true" Localizable="false" />
      <SharePoint:ScriptLink Name="sp.runtime.js" runat="server" LoadAfterUI="true" Localizable="false" />
      <SharePoint:ScriptLink Name="sp.core.js" runat="server" LoadAfterUI="true" Localizable="false" />

      ${coreDistFilePath.join("\n      ")}

    </asp:Content>
    <asp:Content ContentPlaceHolderID="PlaceHolderMain" runat="server">
      ${mountDiv}
      ${extraScript.join("\n      ")}
    </asp:Content>

    <asp:Content ID="PageTitle" ContentPlaceHolderID="PlaceHolderPageTitle" runat="server">
      ${siteTitle}
    </asp:Content>
`;

if (fs.existsSync(outputPath)) {
  fs.rmSync(outputPath);
}

// 写入文件,创建母版页
fs.writeFileSync(outputPath, template);

deploy.ts

import path from "node:path";
import fs from "node:fs";
import inquirer from "inquirer";

// pnp 仅支持 esm
import { spfi } from "@pnp/sp";
import { SPDefault } from "@pnp/nodejs";
import "@pnp/sp/webs";
import "@pnp/sp/files";
import "@pnp/sp/folders";

import pLimit from "p-limit";

const limit = pLimit(6);

const textDecoder = new TextDecoder("utf-8");

// 忽略证书验证
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";

const sp = spfi().using(
  SPDefault({
    baseUrl: "https://localhost:9999", // proxy 代理地址
  })
);

const BaseConfig = {
  sitePageDir: "/SitePages",
  pageName: "work.aspx",
  siteAssetDir: "/SiteAssets/work",
  distDir: path.join(process.cwd(), "dist"),
};

async function uploadFileToSharePoint(localPath: string, remotePath: string) {
  try {
    const fileContent = fs.readFileSync(localPath);
    const fileName = path.basename(localPath);
    console.log(`📤 正在上传文件: ${fileName} to ${remotePath}`);

    await sp.web
      .getFolderByServerRelativePath(remotePath)
      .files.addUsingPath(fileName, textDecoder.decode(fileContent), {
        Overwrite: true,
      });

    console.log(`📦 文件上传成功: ${fileName}`);
  } catch (error) {
    console.error(`❌ 文件上传失败: ${path.basename(localPath)}`, error);
    throw error;
  }
}

async function processDirectory(localPath: string, remotePath: string) {
  const items = fs.readdirSync(localPath, { withFileTypes: true });

  // 先把文件夹创建好
  for (const item of items.filter((d) => d.isDirectory())) {
    const localFullPath = path.join(localPath, item.name);
    const remoteFullPath = path.posix.join(remotePath, item.name);

    await sp.web.folders.addUsingPath(remoteFullPath);
    await processDirectory(localFullPath, remoteFullPath);
  }

  // 再处理所有文件
  await Promise.all(
    items
      .filter((item) => item.isFile() && item.name !== "work.aspx")
      .map((item) =>
        limit(() =>
          uploadFileToSharePoint(path.join(localPath, item.name), remotePath)
        )
      )
  );
}

async function deployAssets() {
  const localBasePath = BaseConfig.distDir;
  const remoteBasePath = BaseConfig.siteAssetDir;
  await sp.web.getFolderByServerRelativePath(remoteBasePath).delete();
  await sp.web.folders.addUsingPath(remoteBasePath); // 创建文件夹
  try {
    console.log("🚀 Asset 资源部署中...");
    await processDirectory(localBasePath, remoteBasePath);
  } catch (error) {
    console.error("❌ Asset 资源部署失败:", error);
    throw error;
  }
}

async function deployAspxPage() {
  const workPagePath = path.join(__dirname, "../dist/work.aspx");
  const fileContent = fs.readFileSync(workPagePath);
  const targetPage = `${BaseConfig.sitePageDir}/${BaseConfig.pageName}`;

  console.log("🚀 Page 页面部署中...");
  await sp.web.getFolderByServerRelativePath(targetPage).delete();
  await sp.web
    .getFolderByServerRelativePath(BaseConfig.sitePageDir)
    .files.addUsingPath(BaseConfig.pageName, textDecoder.decode(fileContent), {
      Overwrite: true,
    });

  try {
    const file = sp.web.getFileByServerRelativePath(targetPage);
    // 签出
    // await file.checkin('Updated by script', CheckinType.Major);
    // 发布
    await file.publish("Published by mobile script");
    console.log(`📄 Page 替换并发布成功`);
  } catch (error) {
    console.error(`❌ Page 页面更新页面更新失败: `, error);
    throw error;
  }
}

async function main() {
  try {
    const confirmed = await confirmDeployment();
    if (!confirmed) {
      console.log("🚫 已取消部署");
      process.exit(0);
    }

    const { deployType } = await selectDeployType();

    switch (deployType) {
      case "all":
        await deployAssets();
        await deployAspxPage();
        break;
      case "assets":
        await deployAssets();
        break;
      case "page":
        await deployAspxPage();
        break;
    }

    console.log("✅ 部署成功!");
    process.exit(0);
  } catch (error) {
    console.error("❌ 部署失败:", error);
    process.exit(1);
  }
}

main();

async function confirmDeployment(): Promise<boolean> {
  return new Promise((resolve) => {
    process.stdout.write("请确保已执行 pnpm build, 是否继续部署?(Y/N) ");
    process.stdin.setEncoding("utf8");
    process.stdin.once("data", (data) => {
      const input = data.toString().trim().toLowerCase();
      resolve(input === "y");
    });
  });
}

async function selectDeployType(): Promise<{
  deployType: "page" | "assets" | "all";
}> {
  return inquirer.prompt([
    {
      type: "list",
      name: "deployType",
      message: "请选择部署类型:",
      choices: [
        { name: "全量发布", value: "all" },
        { name: "发布资源(dist)", value: "assets" },
        { name: "发布页面(work.aspx)", value: "page" },
      ],
    },
  ]);
}

至此,结束!



上一篇
如何发布一个 npm 包
下一篇
2025 年终总结